mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 03:32:55 +00:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f77692fefb | ||
|
|
883c1bde67 | ||
|
|
5b4c57a301 | ||
|
|
642d69b142 | ||
|
|
5aee41952e | ||
|
|
48329facc3 | ||
|
|
97083dd736 | ||
|
|
5c2c85ebc8 | ||
|
|
30fb66c8cb | ||
|
|
05b074764d | ||
|
|
5285b7e502 | ||
|
|
409b8f9312 | ||
|
|
485221b739 | ||
|
|
ed48655a77 | ||
|
|
cc8fe41e28 | ||
|
|
8216e86ecb | ||
|
|
79a3942393 | ||
|
|
5807cf817f | ||
|
|
e092bd9147 | ||
|
|
6ae609bce3 |
@@ -32,8 +32,10 @@ The UI worklet owns transient motion:
|
||||
|
||||
React publishes the active panel only when the canonical target and the UI-thread position agree at
|
||||
the final anchor. Retained content never observes gesture previews, progress, or an unsettled target.
|
||||
The gesture hosts stay mounted; worklets reveal their retained overlays through native styles while
|
||||
React owns pointer events and accessibility. Native panel hosts and their dependent draggable lists
|
||||
The gesture hosts stay mounted. Native worklets own overlay opacity and pointer events while
|
||||
React owns settled panel activity and accessibility. Keep hidden native overlays laid out: collapsing
|
||||
their scroll ranges makes Android spring retained offsets back to zero during touch cancellation or
|
||||
release. Native panel hosts and their dependent draggable lists
|
||||
also retain identity across appearance hydration and settings changes. Do not wrap those hosts in
|
||||
appearance keys; see [Unistyles appearance boundaries](unistyles.md#runtime-theme-patching-for-user-preferences).
|
||||
|
||||
|
||||
@@ -65,6 +65,26 @@ Metro, and device through the `PASEO_COMPOSER_KEYBOARD_*` variables in
|
||||
the headless input helper cannot verify the
|
||||
[visible composer constraints](floating-panels.md#gotcha-3--keyboard-layout-and-portal-anchors).
|
||||
|
||||
The Android sidebar scroll-retention flow requires a connected host with enough
|
||||
workspaces to overflow the compact sidebar. Pass `TOP_WORKSPACE` and
|
||||
`SCROLLED_WORKSPACE` as the `serverId:workspaceId` suffixes of their sidebar row
|
||||
IDs. Choose one row near the top and one initially below the viewport. Replace
|
||||
the quoted example values below with those IDs. Start in a workspace with the
|
||||
sidebar closed and keep the list stable during the run. The same flow checks
|
||||
reopening by swipe and button:
|
||||
|
||||
```bash
|
||||
agent-device replay packages/app/e2e/mobile/agent-device/native-sidebar-scroll-retention.android.yaml \
|
||||
--maestro --platform android --serial emulator-5556 --metro-port 8099 \
|
||||
--env TOP_WORKSPACE='server-id:top-workspace-id' \
|
||||
--env SCROLLED_WORKSPACE='server-id:scrolled-workspace-id'
|
||||
```
|
||||
|
||||
Pass this checkout's Metro port so the debug app uses the intended bundle.
|
||||
For iOS, compare the scrolled row's screen bounds and screenshots across reopening.
|
||||
Agent Device's iOS visibility lookup includes offscreen scroll descendants, so
|
||||
its `assertNotVisible` cannot establish the starting scroll position for this flow.
|
||||
|
||||
On a Play-image emulator, disable Gmail and Calendar first. Their welcome
|
||||
screens launch on their own, take input focus, and the run fails with
|
||||
`IME did not become visible` while the app still looks focused:
|
||||
|
||||
@@ -51,7 +51,7 @@ Claude hook mapping:
|
||||
|
||||
- `UserPromptSubmit` → `running`
|
||||
- `Stop`, `StopFailure`, `SessionEnd` → `idle`
|
||||
- `Notification` with `reason` or `matcher` equal to `idle_prompt` → `needs-input`
|
||||
- `Notification` with `notification_type` equal to `idle_prompt` → `needs-input`
|
||||
|
||||
Claude does not run `Stop` when the user interrupts a turn. A standalone Ctrl-C or Escape input
|
||||
while terminal activity is working clears the activity without finished attention. The same
|
||||
|
||||
@@ -96,6 +96,67 @@ async function seedAgentWithFileLink(input: LinkedFile) {
|
||||
}
|
||||
|
||||
test.describe("CodeMirror workspace file editing", () => {
|
||||
test("toggles comments using each file language's tokens", async ({ page, withWorkspace }) => {
|
||||
const workspace = await withWorkspace({ prefix: "file-comment-shortcut-" });
|
||||
const sources = [
|
||||
{ filename: "comment.ts", content: "const value = 1;", commented: "// const value = 1;" },
|
||||
{ filename: "comment.py", content: "value = 1", commented: "# value = 1" },
|
||||
{ filename: "comment.css", content: "body {}", commented: "/* body {} */" },
|
||||
];
|
||||
for (const source of sources) {
|
||||
await writeFile(path.join(workspace.repoPath, source.filename), source.content, "utf8");
|
||||
}
|
||||
await workspace.navigateTo();
|
||||
for (const source of sources) {
|
||||
await openWorkspaceFile(page, source.filename);
|
||||
await editor(page).click();
|
||||
await editor(page).press("ControlOrMeta+/");
|
||||
await expect(editor(page)).toHaveText(source.commented);
|
||||
await expect
|
||||
.poll(() => readFile(path.join(workspace.repoPath, source.filename), "utf8"))
|
||||
.toBe(source.commented);
|
||||
await editor(page).press("ControlOrMeta+/");
|
||||
await expect(editor(page)).toHaveText(source.content);
|
||||
await expect
|
||||
.poll(() => readFile(path.join(workspace.repoPath, source.filename), "utf8"))
|
||||
.toBe(source.content);
|
||||
}
|
||||
await test.info().attach("comment-shortcut", {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
});
|
||||
|
||||
test("jumps to each relative reference to the same file", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "relative-reference-qa-",
|
||||
title: "Relative line references",
|
||||
initialPrompt: "Show both references",
|
||||
featureValues: {
|
||||
mockAssistantResponse: "First `src/router.go:46`. Then `src/router.go:403`.",
|
||||
},
|
||||
});
|
||||
try {
|
||||
await mkdir(path.join(session.cwd, "src"));
|
||||
await writeFile(
|
||||
path.join(session.cwd, "src/router.go"),
|
||||
Array.from({ length: 450 }, (_, n) => `// line ${n + 1}`).join("\n"),
|
||||
);
|
||||
await openAgentRoute(page, session);
|
||||
for (const line of [46, 403, 46]) {
|
||||
await page
|
||||
.getByTestId(`workspace-tab-agent_${session.agentId}`)
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
await page.getByText(`src/router.go:${line}`, { exact: true }).click();
|
||||
await expect(page.getByLabel(`Line ${line}, column 1`)).toBeVisible();
|
||||
}
|
||||
await page.screenshot({ path: test.info().outputPath("relative-line-target.png") });
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows an absolute POSIX assistant file link relative to the workspace on hover", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -155,6 +155,18 @@ test("keeps diagnostics available from Settings after globalizing the sheet", as
|
||||
test.describe("compact sidebar help", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
|
||||
|
||||
test("footer buttons provide finger-sized targets and accept taps beyond the glyph", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await openCompactSidebar(page);
|
||||
await expectFingerSizedFooterTargets(page);
|
||||
await tapHelpNearTargetEdge(page);
|
||||
await expect(page.getByTestId("sidebar-help-menu")).toBeVisible();
|
||||
await page.getByTestId("sidebar-help-diagnostics").click();
|
||||
await expectDiagnosticReport(page);
|
||||
});
|
||||
|
||||
test("offers diagnostics without advertising disabled keyboard shortcuts", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).click();
|
||||
@@ -165,3 +177,23 @@ test.describe("compact sidebar help", () => {
|
||||
await expectDiagnosticReport(page);
|
||||
});
|
||||
});
|
||||
|
||||
async function openCompactSidebar(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).click();
|
||||
await expect(page.getByTestId("sidebar-footer-bottom-line")).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectFingerSizedFooterTargets(page: Page): Promise<void> {
|
||||
const buttons = page.getByTestId("sidebar-footer-bottom-line").getByRole("button");
|
||||
await expect(buttons).toHaveCount(5);
|
||||
for (const button of await buttons.all()) {
|
||||
const bounds = await button.boundingBox();
|
||||
expect(bounds?.width).toBeGreaterThanOrEqual(44);
|
||||
expect(bounds?.height).toBeGreaterThanOrEqual(44);
|
||||
}
|
||||
}
|
||||
|
||||
async function tapHelpNearTargetEdge(page: Page): Promise<void> {
|
||||
const button = page.getByRole("button", { name: "Help and support", exact: true });
|
||||
await button.tap({ position: { x: 40, y: 40 } });
|
||||
}
|
||||
|
||||
@@ -203,6 +203,41 @@ test.describe("Workspace setup streaming", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps a workspace usable when its setup config cannot be parsed", async ({ page }) => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("setup-invalid-config-", {
|
||||
files: [{ path: "paseo.json", content: "{ invalid json\n" }],
|
||||
});
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
const failed = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) => payload.status === "failed",
|
||||
);
|
||||
const workspace = await createWorkspaceThroughDaemon(client, {
|
||||
cwd: repo.path,
|
||||
worktreeSlug: "workspace-setup-invalid-config",
|
||||
});
|
||||
const failedPayload = await failed;
|
||||
expect(failedPayload.error).toContain("Failed to parse paseo.json");
|
||||
expect(failedPayload.detail.commands).toEqual([]);
|
||||
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
||||
await expectFailedSetupTabSeededInMainPane(page, workspace.id);
|
||||
await expect(page.getByText(/Failed to parse paseo\.json at/)).toBeVisible();
|
||||
await closeSetupTab(page, workspace.id);
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
await openFileExplorer(page);
|
||||
await expectExplorerEntryVisible(page, "paseo.json");
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
daemonTest("emits a completed empty snapshot when no setup commands exist", async () => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("setup-none-");
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
appId: sh.paseo.debug
|
||||
---
|
||||
- launchApp
|
||||
- runFlow: ../../../maestro/sidebar-scroll-retention.yaml
|
||||
@@ -0,0 +1,53 @@
|
||||
appId: sh.paseo.debug
|
||||
---
|
||||
# Start in a workspace with the sidebar closed. Both workspace IDs come from
|
||||
# the connected host: TOP_WORKSPACE is near the top; SCROLLED_WORKSPACE starts
|
||||
# below the viewport. The list must remain unchanged throughout the flow.
|
||||
- tapOn:
|
||||
id: menu-button
|
||||
- scrollUntilVisible:
|
||||
element:
|
||||
id: sidebar-workspace-row-${TOP_WORKSPACE}
|
||||
direction: UP
|
||||
timeout: 60000
|
||||
- assertNotVisible:
|
||||
id: sidebar-workspace-row-${SCROLLED_WORKSPACE}
|
||||
- scrollUntilVisible:
|
||||
element:
|
||||
id: sidebar-workspace-row-${SCROLLED_WORKSPACE}
|
||||
direction: DOWN
|
||||
timeout: 60000
|
||||
- waitForAnimationToEnd
|
||||
- assertVisible:
|
||||
id: sidebar-workspace-row-${SCROLLED_WORKSPACE}
|
||||
- assertNotVisible:
|
||||
id: sidebar-workspace-row-${TOP_WORKSPACE}
|
||||
- tapOn:
|
||||
id: sidebar-workspace-row-${SCROLLED_WORKSPACE}
|
||||
- waitForAnimationToEnd
|
||||
- assertNotVisible:
|
||||
id: sidebar-project-workspace-list-scroll
|
||||
- swipe:
|
||||
start: "5%,50%"
|
||||
end: "90%,50%"
|
||||
duration: 300
|
||||
- waitForAnimationToEnd
|
||||
- assertVisible:
|
||||
id: sidebar-workspace-row-${SCROLLED_WORKSPACE}
|
||||
- assertNotVisible:
|
||||
id: sidebar-workspace-row-${TOP_WORKSPACE}
|
||||
|
||||
# Selecting the current workspace removes workspace switching as a cause.
|
||||
# Reopening by button removes horizontal movement as a cause.
|
||||
- tapOn:
|
||||
id: sidebar-workspace-row-${SCROLLED_WORKSPACE}
|
||||
- waitForAnimationToEnd
|
||||
- tapOn:
|
||||
id: menu-button
|
||||
- waitForAnimationToEnd
|
||||
- assertVisible:
|
||||
id: sidebar-workspace-row-${SCROLLED_WORKSPACE}
|
||||
- assertNotVisible:
|
||||
id: sidebar-workspace-row-${TOP_WORKSPACE}
|
||||
- tapOn:
|
||||
id: sidebar-close
|
||||
@@ -59,7 +59,7 @@ export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult
|
||||
? assistantFileLinkQueryKey({
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
ambiguousQuery: resolution.ambiguousQuery,
|
||||
token: resolution.token,
|
||||
})
|
||||
: DISABLED_QUERY_KEY,
|
||||
[resolution, serverId, workspaceRoot],
|
||||
@@ -179,7 +179,7 @@ function openAssistantFileLink(input: {
|
||||
const capturedQueryKey = assistantFileLinkQueryKey({
|
||||
serverId: capturedConfig.serverId,
|
||||
workspaceRoot: capturedConfig.workspaceRoot,
|
||||
ambiguousQuery: capturedResolution.ambiguousQuery,
|
||||
token: capturedResolution.token,
|
||||
});
|
||||
|
||||
const run = async () => {
|
||||
@@ -245,14 +245,9 @@ function useStableSource(source: AssistantFileLinkSource): AssistantFileLinkSour
|
||||
function assistantFileLinkQueryKey(input: {
|
||||
serverId?: string;
|
||||
workspaceRoot?: string;
|
||||
ambiguousQuery: string;
|
||||
token: string;
|
||||
}): AssistantFileLinkQueryKey {
|
||||
return [
|
||||
"assistantFileLink",
|
||||
input.serverId ?? null,
|
||||
input.workspaceRoot ?? null,
|
||||
input.ambiguousQuery,
|
||||
];
|
||||
return ["assistantFileLink", input.serverId ?? null, input.workspaceRoot ?? null, input.token];
|
||||
}
|
||||
|
||||
async function dispatchResolvedLink(input: {
|
||||
|
||||
@@ -26,6 +26,7 @@ import { SidebarSeparator } from "@/components/sidebar/sidebar-separator";
|
||||
import { SidebarNavRows } from "@/components/sidebar/sidebar-nav-rows";
|
||||
import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu";
|
||||
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
|
||||
import { buttonControlHeight } from "@/components/ui/control-geometry";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
|
||||
@@ -275,7 +276,7 @@ function FooterIconButton({
|
||||
testID,
|
||||
label,
|
||||
icon: Icon,
|
||||
iconSize,
|
||||
iconSizeAdjustment = 0,
|
||||
shortcutKeys,
|
||||
theme,
|
||||
}: {
|
||||
@@ -284,17 +285,20 @@ function FooterIconButton({
|
||||
label: string;
|
||||
icon: typeof FolderPlus;
|
||||
/** Only for a glyph that reads larger than the others at the same size. */
|
||||
iconSize?: number;
|
||||
iconSizeAdjustment?: number;
|
||||
shortcutKeys?: ReturnType<typeof useShortcutKeys>;
|
||||
theme: SidebarTheme;
|
||||
buttonRef?: RefObject<View | null>;
|
||||
}) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const iconSize = isCompact ? theme.iconSize.xl : theme.iconSize.md;
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
ref={buttonRef}
|
||||
style={styles.footerIconButton}
|
||||
style={styles.footerIconButton(isCompact)}
|
||||
testID={testID}
|
||||
nativeID={testID}
|
||||
collapsable={false}
|
||||
@@ -305,7 +309,7 @@ function FooterIconButton({
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Icon
|
||||
size={iconSize ?? theme.iconSize.md}
|
||||
size={iconSize + iconSizeAdjustment}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
@@ -367,7 +371,7 @@ function SidebarHostPicker({
|
||||
label={label}
|
||||
icon={Server}
|
||||
// Server's two boxes fill more of the square than the other glyphs.
|
||||
iconSize={theme.iconSize.md - 1}
|
||||
iconSizeAdjustment={-1}
|
||||
theme={theme}
|
||||
/>
|
||||
</HostPicker>
|
||||
@@ -923,7 +927,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.colors.border,
|
||||
},
|
||||
// The icons' 28pt buttons sit edge to edge; their own inset spaces the glyphs.
|
||||
// Buttons sit edge to edge; their own inset spaces the glyphs.
|
||||
sidebarFooter: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
@@ -940,14 +944,14 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingVertical: theme.spacing[1.5],
|
||||
gap: 2,
|
||||
},
|
||||
footerIconButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
footerIconButton: (isCompact: boolean) => ({
|
||||
width: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
|
||||
height: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
},
|
||||
}),
|
||||
tooltipRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { buttonControlHeight } from "@/components/ui/control-geometry";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useAppDiagnosticStore } from "@/diagnostics/store";
|
||||
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
|
||||
@@ -77,6 +79,7 @@ function HostVersionHint({ host }: { host: HostProfile }) {
|
||||
}
|
||||
|
||||
export function SidebarHelpMenu() {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const { t } = useTranslation();
|
||||
const shortcutsAvailable = useKeyboardShortcutsAvailable();
|
||||
const openAppDiagnostic = useAppDiagnosticStore((state) => state.open);
|
||||
@@ -103,14 +106,14 @@ export function SidebarHelpMenu() {
|
||||
<TooltipTrigger asChild>
|
||||
<View>
|
||||
<DropdownMenuTrigger
|
||||
style={styles.trigger}
|
||||
style={styles.trigger(isCompact)}
|
||||
testID="sidebar-help"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("sidebar.help.trigger")}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<ThemedCircleHelp
|
||||
size={ICON_SIZE.md}
|
||||
size={isCompact ? ICON_SIZE.xl : ICON_SIZE.md}
|
||||
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||
/>
|
||||
)}
|
||||
@@ -181,14 +184,14 @@ export function SidebarHelpMenu() {
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
trigger: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
trigger: (isCompact: boolean) => ({
|
||||
width: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
|
||||
height: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
},
|
||||
}),
|
||||
tooltipText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.popoverForeground,
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { EditorState, type Transaction } from "@codemirror/state";
|
||||
import { toggleComment } from "@codemirror/commands";
|
||||
import { getLanguageForFile } from "@getpaseo/highlight";
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
test.each([
|
||||
["source.ts", "const value = 1;", "// const value = 1;"],
|
||||
["source.py", "value = 1", "# value = 1"],
|
||||
["source.css", "body {}", "/* body {} */"],
|
||||
["source.html", "<p>text</p>", "<!-- <p>text</p> -->"],
|
||||
["source.md", "text", "<!-- text -->"],
|
||||
["source.mdx", "text", "{/* text */}"],
|
||||
["source.ex", "value = 1", "# value = 1"],
|
||||
["source.cs", "var value = 1;", "// var value = 1;"],
|
||||
["source.nix", "1", "# 1"],
|
||||
])("toggles the comment and restores the source in %s", (filename, doc, commented) => {
|
||||
const language = getLanguageForFile(filename);
|
||||
if (!language) throw new Error(`Missing language for ${filename}`);
|
||||
const target = editorTarget(EditorState.create({ doc, extensions: [language] }));
|
||||
expect(toggleComment(target)).toBe(true);
|
||||
expect(target.state.doc.toString()).toBe(commented);
|
||||
expect(toggleComment(target)).toBe(true);
|
||||
expect(target.state.doc.toString()).toBe(doc);
|
||||
});
|
||||
|
||||
const jsxView = [
|
||||
"const view = (",
|
||||
" <div",
|
||||
' className="x"',
|
||||
" onClick={() => go()}",
|
||||
" >",
|
||||
" text",
|
||||
" {value}",
|
||||
" <span />",
|
||||
"{flag}",
|
||||
"<br />",
|
||||
" <Foo",
|
||||
' a="1"',
|
||||
" />",
|
||||
" {items.map(() => {",
|
||||
" const item = 1;",
|
||||
" })}",
|
||||
" </div>",
|
||||
");",
|
||||
].join("\n");
|
||||
|
||||
test.each([
|
||||
["view.jsx", "text", "{/* text */}"],
|
||||
["view.tsx", "text", "{/* text */}"],
|
||||
["view.tsx", "{value}", "{/* {value} */}"],
|
||||
["view.tsx", "<span />", "{/* <span /> */}"],
|
||||
["view.tsx", "{flag}", "{/* {flag} */}"],
|
||||
["view.tsx", "<br />", "{/* <br /> */}"],
|
||||
["view.tsx", 'className="x"', '// className="x"'],
|
||||
["view.tsx", "onClick={() => go()}", "// onClick={() => go()}"],
|
||||
["view.tsx", 'a="1"', '// a="1"'],
|
||||
["view.tsx", "const item = 1;", "// const item = 1;"],
|
||||
])("toggles %s line %s as %s", (filename, line, commented) => {
|
||||
const language = getLanguageForFile(filename);
|
||||
if (!language) throw new Error(`Missing language for ${filename}`);
|
||||
const target = editorTarget(
|
||||
EditorState.create({
|
||||
doc: jsxView,
|
||||
extensions: [language],
|
||||
selection: { anchor: jsxView.indexOf(line) },
|
||||
}),
|
||||
);
|
||||
expect(toggleComment(target)).toBe(true);
|
||||
expect(target.state.doc.toString()).toBe(jsxView.replace(line, commented));
|
||||
expect(toggleComment(target)).toBe(true);
|
||||
expect(target.state.doc.toString()).toBe(jsxView);
|
||||
});
|
||||
|
||||
function editorTarget(initial: EditorState) {
|
||||
let state = initial;
|
||||
return {
|
||||
get state() {
|
||||
return state;
|
||||
},
|
||||
dispatch(transaction: Transaction) {
|
||||
state = transaction.state;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("keeps JSON comment-free", () => {
|
||||
const language = getLanguageForFile("data.json");
|
||||
if (!language) throw new Error("Missing JSON language");
|
||||
const state = EditorState.create({ doc: "{}", extensions: [language] });
|
||||
expect(
|
||||
toggleComment({
|
||||
state,
|
||||
dispatch() {
|
||||
throw new Error("JSON must not be edited");
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -42,7 +42,15 @@ export function MobilePanelOverlay({
|
||||
|
||||
const overlayAnimatedStyle = useAnimatedStyle(() => {
|
||||
const isVisible = isLeft ? position.value < 0 : position.value > 0;
|
||||
return { display: isVisible ? ("flex" as const) : ("none" as const) };
|
||||
if (isWeb) {
|
||||
return { display: isVisible ? ("flex" as const) : ("none" as const) };
|
||||
}
|
||||
// Preserve native scroll ranges while hidden. Collapsing layout makes Android
|
||||
// spring retained scroll offsets back to zero on touch cancellation or release.
|
||||
return {
|
||||
opacity: isVisible ? 1 : 0,
|
||||
pointerEvents: isVisible ? ("box-none" as const) : ("none" as const),
|
||||
};
|
||||
}, [isLeft]);
|
||||
|
||||
const positionedPanelStyle = isLeft ? styles.leftPanel : styles.rightPanel;
|
||||
@@ -79,10 +87,7 @@ export function MobilePanelOverlay({
|
||||
pointerEvents={overlayPointerEvents}
|
||||
style={styles.overlay}
|
||||
>
|
||||
<Animated.View
|
||||
pointerEvents={overlayPointerEvents}
|
||||
style={[styles.overlay, overlayAnimatedStyle]}
|
||||
>
|
||||
<Animated.View style={[styles.overlay, overlayAnimatedStyle]}>
|
||||
<Pressable
|
||||
accessible={false}
|
||||
accessibilityElementsHidden
|
||||
|
||||
@@ -595,6 +595,7 @@ export const ICON_SIZE = {
|
||||
sm: 14,
|
||||
md: 16,
|
||||
lg: 20,
|
||||
xl: 24,
|
||||
} as const;
|
||||
|
||||
export const ICON_STROKE_WIDTH = 1.5;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" };
|
||||
|
||||
@@ -7,18 +7,45 @@ import { runDeleteCommand } from "./delete.js";
|
||||
const agent = {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
status: "running",
|
||||
archivedAt: null,
|
||||
archivedAt: null as string | null,
|
||||
cwd: "/tmp/project",
|
||||
};
|
||||
const archivedAgent = {
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
status: "closed",
|
||||
archivedAt: "2026-10-01T00:00:00.000Z",
|
||||
cwd: "/tmp/project/nested",
|
||||
};
|
||||
const archivedOutsideAgent = {
|
||||
id: "33333333-3333-4333-8333-333333333333",
|
||||
status: "closed",
|
||||
archivedAt: "2026-10-01T00:00:00.000Z",
|
||||
cwd: "/tmp/elsewhere",
|
||||
};
|
||||
let daemonAgents: (typeof agent)[] = [agent];
|
||||
const toEntry = (entry: typeof agent) => ({ agent: entry });
|
||||
// The fake daemon pages history two agents at a time.
|
||||
const historyPage = (cursor: string | undefined) => {
|
||||
const start = Number(cursor ?? 0);
|
||||
const end = start + 2;
|
||||
return {
|
||||
entries: daemonAgents.slice(start, end).map(toEntry),
|
||||
pageInfo: { nextCursor: end < daemonAgents.length ? String(end) : null },
|
||||
};
|
||||
};
|
||||
const cancelAgent = vi.fn(async () => {
|
||||
throw new Error("active run cancellation was not acknowledged");
|
||||
});
|
||||
const fetchAgentHistory = vi.fn(async (options: { page: { cursor?: string } }) =>
|
||||
historyPage(options.page.cursor),
|
||||
);
|
||||
const deleteAgent = vi.fn(async () => undefined);
|
||||
const close = vi.fn(async () => undefined);
|
||||
|
||||
vi.mock("../../utils/client.js", () => ({
|
||||
connectToDaemon: vi.fn(async () => ({
|
||||
fetchAgents: vi.fn(async () => ({ entries: [{ agent }] })),
|
||||
fetchAgents: vi.fn(async () => historyPage(undefined)),
|
||||
fetchAgentHistory,
|
||||
fetchAgent: vi.fn(async () => ({ agent })),
|
||||
cancelAgent,
|
||||
deleteAgent,
|
||||
@@ -28,9 +55,16 @@ vi.mock("../../utils/client.js", () => ({
|
||||
}));
|
||||
|
||||
describe("runDeleteCommand", () => {
|
||||
beforeEach(() => {
|
||||
daemonAgents = [agent];
|
||||
deleteAgent.mockClear();
|
||||
fetchAgentHistory.mockClear();
|
||||
});
|
||||
|
||||
it("force-deletes a running agent when graceful cancellation is refused", async () => {
|
||||
const result = await runDeleteCommand(agent.id, { daemonTarget }, {} as never);
|
||||
|
||||
expect(fetchAgentHistory).not.toHaveBeenCalled();
|
||||
expect(cancelAgent).toHaveBeenCalledWith(agent.id);
|
||||
expect(deleteAgent).toHaveBeenCalledWith(agent.id);
|
||||
expect(result.data).toEqual({
|
||||
@@ -38,4 +72,30 @@ describe("runDeleteCommand", () => {
|
||||
agentIds: [agent.id],
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes archived agents on every history page with --all", async () => {
|
||||
daemonAgents = [agent, archivedAgent, archivedOutsideAgent];
|
||||
|
||||
const result = await runDeleteCommand(undefined, { daemonTarget, all: true }, {} as never);
|
||||
|
||||
expect(result.data).toEqual({
|
||||
deletedCount: 3,
|
||||
agentIds: [agent.id, archivedAgent.id, archivedOutsideAgent.id],
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes archived agents inside the directory with --cwd", async () => {
|
||||
daemonAgents = [agent, archivedAgent, archivedOutsideAgent];
|
||||
|
||||
const result = await runDeleteCommand(
|
||||
undefined,
|
||||
{ daemonTarget, cwd: "/tmp/project" },
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(result.data).toEqual({
|
||||
deletedCount: 2,
|
||||
agentIds: [agent.id, archivedAgent.id],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Command } from "commander";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import { isSameOrDescendantPath } from "../../utils/paths.js";
|
||||
import type { DaemonClient, FetchAgentHistoryEntry } from "@getpaseo/client/internal/daemon-client";
|
||||
|
||||
export function addDeleteOptions(cmd: Command): Command {
|
||||
return cmd
|
||||
@@ -33,6 +34,20 @@ export interface AgentDeleteOptions extends CommandOptions {
|
||||
|
||||
export type AgentDeleteResult = SingleResult<DeleteResult>;
|
||||
|
||||
// History includes archived agents and agents whose provider is unavailable.
|
||||
async function fetchHistoryAgents(client: DaemonClient) {
|
||||
const agents: FetchAgentHistoryEntry["agent"][] = [];
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const payload = await client.fetchAgentHistory({
|
||||
page: { limit: 200, ...(cursor ? { cursor } : {}) },
|
||||
});
|
||||
agents.push(...payload.entries.map((entry) => entry.agent));
|
||||
cursor = payload.pageInfo.nextCursor ?? undefined;
|
||||
} while (cursor);
|
||||
return agents;
|
||||
}
|
||||
|
||||
export async function runDeleteCommand(
|
||||
id: string | undefined,
|
||||
options: AgentDeleteOptions,
|
||||
@@ -50,19 +65,16 @@ export async function runDeleteCommand(
|
||||
const client = await connectToDaemon({ target: options.daemonTarget });
|
||||
|
||||
try {
|
||||
const fetchPayload = await client.fetchAgents({ filter: { includeArchived: true } });
|
||||
let agents = fetchPayload.entries.map((entry) => entry.agent);
|
||||
const deletedIds: string[] = [];
|
||||
let agents: FetchAgentHistoryEntry["agent"][];
|
||||
|
||||
if (options.all) {
|
||||
agents = agents.filter((a) => !a.archivedAt);
|
||||
} else if (options.cwd) {
|
||||
agents = agents.filter((a) => {
|
||||
if (a.archivedAt) return false;
|
||||
return isSameOrDescendantPath(options.cwd!, a.cwd);
|
||||
});
|
||||
} else if (id) {
|
||||
const fetchResult = await client.fetchAgent({ agentId: id });
|
||||
if (options.all || options.cwd) {
|
||||
agents = await fetchHistoryAgents(client);
|
||||
if (!options.all) {
|
||||
agents = agents.filter((a) => isSameOrDescendantPath(options.cwd!, a.cwd));
|
||||
}
|
||||
} else {
|
||||
const fetchResult = await client.fetchAgent({ agentId: id! });
|
||||
if (!fetchResult) {
|
||||
const error: CommandError = {
|
||||
code: "AGENT_NOT_FOUND",
|
||||
|
||||
@@ -146,7 +146,7 @@ test("managed two-home restart retains its supervisor and never routes ordinary
|
||||
expect(await f.ok(["project", "ls", "--home", a])).toEqual([]);
|
||||
expect((await f.ok(["project", "ls", "--home", b], poisoned)).length).toBe(1);
|
||||
const stream = await f.run(["logs", "missing-agent", "--follow", "--home", b], poisoned);
|
||||
expect(stream.stderr).toContain("Agent not found: missing-agent");
|
||||
expect(stream.stderr).toContain("No agent found matching: missing-agent");
|
||||
const restart = await f.ok(["restart", "--home", b, "--timeout", "30"], poisoned);
|
||||
expect(restart.supervisorPid).toBe(launchB.pid);
|
||||
expect(restart.workerPid).not.toBe(beforeB.workerPid);
|
||||
@@ -503,7 +503,7 @@ test("raw log following subscribes to a stored agent that exists only in B", asy
|
||||
const launchA = await f.ok(["start", "--home", a]);
|
||||
await f.ok(["start", "--home", b]);
|
||||
const absent = await f.run(["logs", agentId, "--home", a]);
|
||||
expect(absent.stderr).toContain("Agent not found");
|
||||
expect(absent.stderr).toContain(`No agent found matching: ${agentId}`);
|
||||
follower = spawn(
|
||||
process.execPath,
|
||||
[cli, "logs", agentId, "--follow", "--tail", "0", "--home", b],
|
||||
@@ -522,7 +522,7 @@ test("raw log following subscribes to a stored agent that exists only in B", asy
|
||||
errors += data;
|
||||
});
|
||||
await expect.poll(() => output, { timeout: 15_000 }).toContain("Following logs (no history;");
|
||||
expect(errors).not.toContain("Agent not found");
|
||||
expect(errors).not.toContain("No agent found matching");
|
||||
} finally {
|
||||
if (follower && follower.exitCode === null) {
|
||||
const exited = new Promise((resolve) => follower!.once("exit", resolve));
|
||||
|
||||
@@ -17,6 +17,15 @@ function inputFrom(value: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function claudeNotification(notificationType: string) {
|
||||
return JSON.stringify({
|
||||
session_id: "session-1",
|
||||
hook_event_name: "Notification",
|
||||
message: "Claude is waiting for your input",
|
||||
notification_type: notificationType,
|
||||
});
|
||||
}
|
||||
|
||||
function ttyInput() {
|
||||
return {
|
||||
isTTY: true,
|
||||
@@ -123,7 +132,7 @@ describe("runHooksCommand", () => {
|
||||
const send = await runHook(
|
||||
claudeProvider.id,
|
||||
claudeProvider.events[4].event,
|
||||
inputFrom('{"reason":"idle_prompt"}'),
|
||||
inputFrom(claudeNotification("idle_prompt")),
|
||||
);
|
||||
|
||||
expectPostedState(send, "needs-input");
|
||||
@@ -131,11 +140,11 @@ describe("runHooksCommand", () => {
|
||||
|
||||
it.each(["permission_prompt", "elicitation_prompt", "elicitation_response", "auth_success"])(
|
||||
"ignores Claude %s notifications",
|
||||
async (reason) => {
|
||||
async (notificationType) => {
|
||||
const send = await runHook(
|
||||
claudeProvider.id,
|
||||
claudeProvider.events[4].event,
|
||||
inputFrom(JSON.stringify({ reason })),
|
||||
inputFrom(claudeNotification(notificationType)),
|
||||
);
|
||||
|
||||
expect(send.calls).toEqual([]);
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import assert from "node:assert";
|
||||
import { getAvailablePort } from "./helpers/network.ts";
|
||||
import { runPaseoCli, startTestDaemon } from "./helpers/test-daemon.ts";
|
||||
import { $ } from "zx";
|
||||
import { mkdtemp, rm } from "fs/promises";
|
||||
import { tmpdir } from "os";
|
||||
@@ -129,6 +130,28 @@ try {
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ -q (quiet) flag is accepted with stop\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 9: stop reports AGENT_NOT_FOUND for an unknown ID");
|
||||
const daemon = await startTestDaemon();
|
||||
try {
|
||||
const result = await runPaseoCli(daemon, [
|
||||
"agent",
|
||||
"stop",
|
||||
"does-not-exist",
|
||||
"--host",
|
||||
`127.0.0.1:${daemon.port}`,
|
||||
"--json",
|
||||
]);
|
||||
assert.notStrictEqual(result.exitCode, 0, "stop should fail for an unknown ID");
|
||||
const { error } = JSON.parse(result.stderr);
|
||||
assert.strictEqual(error.code, "AGENT_NOT_FOUND", result.stderr);
|
||||
assert.match(error.details, /paseo ls/);
|
||||
} finally {
|
||||
await daemon.stop();
|
||||
}
|
||||
console.log("✓ stop reports AGENT_NOT_FOUND for an unknown ID\n");
|
||||
}
|
||||
} finally {
|
||||
// Clean up temp directory
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import assert from "node:assert";
|
||||
import { runLocalPaseo } from "./helpers/local-cli.ts";
|
||||
import { getAvailablePort } from "./helpers/network.ts";
|
||||
import { runPaseoCli, startTestDaemon } from "./helpers/test-daemon.ts";
|
||||
import { mkdtemp, rm } from "fs/promises";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
@@ -112,6 +113,28 @@ try {
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ -q (quiet) flag is accepted with delete\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 9: delete reports AGENT_NOT_FOUND for an unknown ID");
|
||||
const daemon = await startTestDaemon();
|
||||
try {
|
||||
const result = await runPaseoCli(daemon, [
|
||||
"agent",
|
||||
"delete",
|
||||
"does-not-exist",
|
||||
"--host",
|
||||
`127.0.0.1:${daemon.port}`,
|
||||
"--json",
|
||||
]);
|
||||
assert.notStrictEqual(result.exitCode, 0, "delete should fail for an unknown ID");
|
||||
const { error } = JSON.parse(result.stderr);
|
||||
assert.strictEqual(error.code, "AGENT_NOT_FOUND", result.stderr);
|
||||
assert.match(error.details, /paseo ls/);
|
||||
} finally {
|
||||
await daemon.stop();
|
||||
}
|
||||
console.log("✓ delete reports AGENT_NOT_FOUND for an unknown ID\n");
|
||||
}
|
||||
} finally {
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { defineLanguageFacet, Language, StreamLanguage } from "@codemirror/language";
|
||||
import {
|
||||
defineLanguageFacet,
|
||||
Language,
|
||||
languageDataProp,
|
||||
StreamLanguage,
|
||||
sublanguageProp,
|
||||
type Sublanguage,
|
||||
} from "@codemirror/language";
|
||||
import { dart } from "@codemirror/legacy-modes/mode/clike";
|
||||
import { swift } from "@codemirror/legacy-modes/mode/swift";
|
||||
import { parser as jsParser } from "@lezer/javascript";
|
||||
@@ -9,68 +16,113 @@ import { parser as goParser } from "@lezer/go";
|
||||
import { parser as htmlParser } from "@lezer/html";
|
||||
import { parser as javaParser } from "@lezer/java";
|
||||
import { parser as pythonParser } from "@lezer/python";
|
||||
import { parser as markdownParser } from "@lezer/markdown";
|
||||
import { parser as markdownParser, type MarkdownParser } from "@lezer/markdown";
|
||||
import { parser as phpParser } from "@lezer/php";
|
||||
import { parser as rustParser } from "@lezer/rust";
|
||||
import { parser as xmlParser } from "@lezer/xml";
|
||||
import { parser as yamlParser } from "@lezer/yaml";
|
||||
import { parser as elixirParser } from "lezer-elixir";
|
||||
import type { Parser } from "@lezer/common";
|
||||
import type { Parser, SyntaxNode } from "@lezer/common";
|
||||
import type { LRParser } from "@lezer/lr";
|
||||
import { csharpLanguage } from "./csharp/language.js";
|
||||
import { astroParser } from "./astro/parser.js";
|
||||
import { nixLanguage } from "./nix/language.js";
|
||||
import { parser as svelteBaseParser } from "./svelte/parser.js";
|
||||
import { configureNesting, defaultNesting } from "./svelte/nesting.js";
|
||||
|
||||
function language(parser: Parser): Language {
|
||||
return new Language(defineLanguageFacet(), parser);
|
||||
const cStyleComments = { commentTokens: { line: "//", block: { open: "/*", close: "*/" } } };
|
||||
const hashComments = { commentTokens: { line: "#" } };
|
||||
const cssComments = { commentTokens: { block: { open: "/*", close: "*/" } } };
|
||||
const markupComments = { commentTokens: { block: { open: "<!--", close: "-->" } } };
|
||||
|
||||
const jsxChildComments = { commentTokens: { block: { open: "{/*", close: "*/}" } } };
|
||||
|
||||
// JSX children are text, so a `//` there renders on the page instead of commenting the line out.
|
||||
// Attribute lines of a multi-line tag are not children and keep `//`.
|
||||
const jsxComments: Sublanguage = {
|
||||
test: isJsxChild,
|
||||
facet: defineLanguageFacet(jsxChildComments),
|
||||
};
|
||||
|
||||
// A line starts on a child as text, as the `{` of an escape, or as the `<` of a nested element.
|
||||
function isJsxChild(node: SyntaxNode): boolean {
|
||||
return childAt(node)?.parent?.name === "JSXElement";
|
||||
}
|
||||
|
||||
function childAt(node: SyntaxNode): SyntaxNode | null {
|
||||
if (node.name === "JSXText") return node;
|
||||
if (node.name === "{") return node.parent;
|
||||
if (node.name === "JSXStartTag") return node.parent?.parent ?? null;
|
||||
return null;
|
||||
}
|
||||
|
||||
function language(
|
||||
parser: LRParser | MarkdownParser,
|
||||
languageData: Parameters<typeof defineLanguageFacet>[0] = {},
|
||||
sublanguages: Sublanguage[] = [],
|
||||
): Language {
|
||||
const data = defineLanguageFacet(languageData);
|
||||
return new Language(
|
||||
data,
|
||||
parser.configure({
|
||||
props: [
|
||||
languageDataProp.add((node) => (node.isTop ? data : undefined)),
|
||||
sublanguageProp.add((node) =>
|
||||
node.isTop && sublanguages.length ? sublanguages : undefined,
|
||||
),
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const languagesByExtension: Record<string, Language> = {
|
||||
// JavaScript/TypeScript
|
||||
js: language(jsParser),
|
||||
jsx: language(jsParser.configure({ dialect: "jsx" })),
|
||||
ts: language(jsParser.configure({ dialect: "ts" })),
|
||||
tsx: language(jsParser.configure({ dialect: "ts jsx" })),
|
||||
mjs: language(jsParser),
|
||||
cjs: language(jsParser),
|
||||
js: language(jsParser, cStyleComments),
|
||||
jsx: language(jsParser.configure({ dialect: "jsx" }), cStyleComments, [jsxComments]),
|
||||
ts: language(jsParser.configure({ dialect: "ts" }), cStyleComments),
|
||||
tsx: language(jsParser.configure({ dialect: "ts jsx" }), cStyleComments, [jsxComments]),
|
||||
mjs: language(jsParser, cStyleComments),
|
||||
cjs: language(jsParser, cStyleComments),
|
||||
// C / C++ / Objective-C
|
||||
c: language(cppParser),
|
||||
h: language(cppParser),
|
||||
cc: language(cppParser),
|
||||
cpp: language(cppParser),
|
||||
cxx: language(cppParser),
|
||||
hpp: language(cppParser),
|
||||
hxx: language(cppParser),
|
||||
m: language(cppParser),
|
||||
mm: language(cppParser),
|
||||
c: language(cppParser, cStyleComments),
|
||||
h: language(cppParser, cStyleComments),
|
||||
cc: language(cppParser, cStyleComments),
|
||||
cpp: language(cppParser, cStyleComments),
|
||||
cxx: language(cppParser, cStyleComments),
|
||||
hpp: language(cppParser, cStyleComments),
|
||||
hxx: language(cppParser, cStyleComments),
|
||||
m: language(cppParser, cStyleComments),
|
||||
mm: language(cppParser, cStyleComments),
|
||||
// JSON
|
||||
json: language(jsonParser),
|
||||
// CSS
|
||||
css: language(cssParser),
|
||||
scss: language(cssParser),
|
||||
css: language(cssParser, cssComments),
|
||||
scss: language(cssParser, cStyleComments),
|
||||
// HTML
|
||||
html: language(htmlParser),
|
||||
htm: language(htmlParser),
|
||||
html: language(htmlParser, markupComments),
|
||||
htm: language(htmlParser, markupComments),
|
||||
// Svelte
|
||||
svelte: language(svelteBaseParser.configure({ wrap: configureNesting(defaultNesting) })),
|
||||
svelte: language(
|
||||
svelteBaseParser.configure({ wrap: configureNesting(defaultNesting) }),
|
||||
markupComments,
|
||||
),
|
||||
// Astro
|
||||
astro: language(astroParser),
|
||||
astro: new Language(defineLanguageFacet(), astroParser),
|
||||
// XML
|
||||
xml: language(xmlParser),
|
||||
xml: language(xmlParser, markupComments),
|
||||
// Java
|
||||
java: language(javaParser),
|
||||
java: language(javaParser, cStyleComments),
|
||||
// Python
|
||||
py: language(pythonParser),
|
||||
py: language(pythonParser, hashComments),
|
||||
// Go
|
||||
go: language(goParser),
|
||||
go: language(goParser, cStyleComments),
|
||||
// PHP
|
||||
php: language(phpParser),
|
||||
php: language(phpParser, cStyleComments),
|
||||
// YAML
|
||||
yaml: language(yamlParser),
|
||||
yml: language(yamlParser),
|
||||
yaml: language(yamlParser, hashComments),
|
||||
yml: language(yamlParser, hashComments),
|
||||
// Rust
|
||||
rs: language(rustParser),
|
||||
rs: language(rustParser, cStyleComments),
|
||||
// Swift
|
||||
swift: StreamLanguage.define(swift),
|
||||
// Dart
|
||||
@@ -80,11 +132,11 @@ const languagesByExtension: Record<string, Language> = {
|
||||
// Nix
|
||||
nix: nixLanguage,
|
||||
// Elixir
|
||||
ex: language(elixirParser),
|
||||
exs: language(elixirParser),
|
||||
ex: language(elixirParser, hashComments),
|
||||
exs: language(elixirParser, hashComments),
|
||||
// Markdown
|
||||
md: language(markdownParser),
|
||||
mdx: language(markdownParser),
|
||||
md: language(markdownParser, markupComments),
|
||||
mdx: language(markdownParser, jsxChildComments),
|
||||
};
|
||||
|
||||
export function getLanguageForFile(filename: string): Language | null {
|
||||
|
||||
@@ -5034,6 +5034,7 @@ test("setAgentThinkingOption surfaces a failed state read and keeps the previous
|
||||
|
||||
test("session config drift events update state through the stream channel", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-session-config-events-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
let capturedSession: TestAgentSession | null = null;
|
||||
class ConfigEventClient extends TestAgentClient {
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
@@ -5046,6 +5047,7 @@ test("session config drift events update state through the stream channel", asyn
|
||||
clients: {
|
||||
codex: new ConfigEventClient(),
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000133",
|
||||
});
|
||||
@@ -5100,6 +5102,7 @@ test("session config drift events update state through the stream channel", asyn
|
||||
|
||||
const agent = manager.getAgent(snapshot.id);
|
||||
expect(agent?.currentModeId).toBe("build");
|
||||
expect(agent?.config.modeId).toBe("build");
|
||||
expect(agent?.config.thinkingOptionId).toBe("high");
|
||||
expect(agent?.availableModes).toEqual([
|
||||
{ id: "plan", label: "Plan" },
|
||||
@@ -5111,6 +5114,10 @@ test("session config drift events update state through the stream channel", asyn
|
||||
thinkingOptionId: "high",
|
||||
});
|
||||
expect(streams.map((event) => event.type)).toEqual([]);
|
||||
|
||||
const persisted = await storage.get(snapshot.id);
|
||||
expect(persisted?.lastModeId).toBe("build");
|
||||
expect(persisted?.config?.modeId).toBe("build");
|
||||
});
|
||||
|
||||
test("setLabels merges and persists labels", async () => {
|
||||
@@ -9287,6 +9294,7 @@ test("respondToPermission updates currentModeId after plan approval", async () =
|
||||
class PlanModeTestClient implements AgentClient {
|
||||
readonly provider = "codex" as const;
|
||||
readonly capabilities = TEST_CAPABILITIES;
|
||||
resumedModeId: string | null | undefined;
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true;
|
||||
@@ -9296,14 +9304,19 @@ test("respondToPermission updates currentModeId after plan approval", async () =
|
||||
return new PlanModeTestSession();
|
||||
}
|
||||
|
||||
async resumeSession(): Promise<AgentSession> {
|
||||
async resumeSession(
|
||||
_handle: AgentPersistenceHandle,
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
): Promise<AgentSession> {
|
||||
this.resumedModeId = overrides?.modeId;
|
||||
return new PlanModeTestSession();
|
||||
}
|
||||
}
|
||||
|
||||
const client = new PlanModeTestClient();
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new PlanModeTestClient(),
|
||||
codex: client,
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
@@ -9347,6 +9360,13 @@ test("respondToPermission updates currentModeId after plan approval", async () =
|
||||
await manager.flush();
|
||||
const persisted = await storage.get(snapshot.id);
|
||||
expect(persisted?.lastModeId).toBe("acceptEdits");
|
||||
|
||||
// The provider-managed switch must also reach config.modeId — otherwise a
|
||||
// reload resumes the session with the stale creation-time mode ("plan").
|
||||
expect(persisted?.config?.modeId).toBe("acceptEdits");
|
||||
const reloaded = await manager.reloadAgentSession(snapshot.id);
|
||||
expect(reloaded.config.modeId).toBe("acceptEdits");
|
||||
expect(client.resumedModeId).toBe("acceptEdits");
|
||||
});
|
||||
|
||||
test("respondToPermission refreshes features and runtime info after provider-managed plan approval", async () => {
|
||||
|
||||
@@ -190,10 +190,13 @@ function buildStoredAgentConfig(record: StoredAgentRecord): AgentSessionConfig {
|
||||
provider: record.provider,
|
||||
cwd: record.cwd,
|
||||
};
|
||||
// lastModeId is the last live mode — it also covers provider-side switches
|
||||
// that never reach record.config.modeId.
|
||||
const modeId = record.lastModeId ?? record.config?.modeId;
|
||||
if (modeId != null) config.modeId = modeId;
|
||||
if (!record.config) {
|
||||
return config;
|
||||
}
|
||||
if (record.config.modeId != null) config.modeId = record.config.modeId;
|
||||
if (record.config.model != null) config.model = record.config.model;
|
||||
if (record.config.thinkingOptionId != null) {
|
||||
config.thinkingOptionId = record.config.thinkingOptionId;
|
||||
@@ -3914,6 +3917,19 @@ export class AgentManager {
|
||||
return this.registry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider-side mode switches (ACP current_mode_update, in-session
|
||||
* commands, permission-driven transitions) must land in config.modeId too —
|
||||
* reloadAgentSession and the persisted record derive the resumed session's
|
||||
* mode from it, so leaving it stale silently downgrades the mode on resume.
|
||||
*/
|
||||
private applyObservedMode(agent: ActiveManagedAgent, modeId: string | null): void {
|
||||
agent.currentModeId = modeId;
|
||||
if (modeId != null) {
|
||||
agent.config.modeId = modeId;
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshSessionState(
|
||||
agent: ActiveManagedAgent,
|
||||
options?: { emit?: boolean },
|
||||
@@ -3926,7 +3942,7 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
try {
|
||||
agent.currentModeId = await agent.session.getCurrentMode();
|
||||
this.applyObservedMode(agent, await agent.session.getCurrentMode());
|
||||
} catch {
|
||||
agent.currentModeId = null;
|
||||
}
|
||||
@@ -4306,7 +4322,7 @@ export class AgentManager {
|
||||
this.emitState(agent);
|
||||
return undefined;
|
||||
case "mode_changed":
|
||||
agent.currentModeId = event.currentModeId;
|
||||
this.applyObservedMode(agent, event.currentModeId);
|
||||
agent.availableModes = event.availableModes;
|
||||
if (agent.runtimeInfo) {
|
||||
agent.runtimeInfo = { ...agent.runtimeInfo, modeId: event.currentModeId };
|
||||
@@ -4322,7 +4338,7 @@ export class AgentManager {
|
||||
agent.cwd,
|
||||
);
|
||||
}
|
||||
agent.currentModeId = event.runtimeInfo.modeId ?? agent.currentModeId;
|
||||
this.applyObservedMode(agent, event.runtimeInfo.modeId ?? agent.currentModeId);
|
||||
flags.shouldDispatchEvent = false;
|
||||
this.emitState(agent);
|
||||
return undefined;
|
||||
|
||||
@@ -806,7 +806,6 @@ function createPaseoWorktreeForMcpTest(options: {
|
||||
emit: () => {},
|
||||
sessionLogger: createTestLogger(),
|
||||
terminalManager: null,
|
||||
archiveWorkspaceRecord: async () => {},
|
||||
serviceProxy: null,
|
||||
scriptRuntimeStore: null,
|
||||
getDaemonTcpPort: null,
|
||||
|
||||
@@ -825,6 +825,46 @@ describe("mapACPUsage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ACP context-window usage", () => {
|
||||
async function emitUsageUpdate(update: {
|
||||
used: number;
|
||||
size: number;
|
||||
}): Promise<{ events: unknown[] }> {
|
||||
const session = createSessionWithConfig({ provider: "dsh" });
|
||||
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
|
||||
const events: unknown[] = [];
|
||||
session.subscribe((event) => {
|
||||
if (event.type === "usage_updated") events.push(event);
|
||||
});
|
||||
await session.sessionUpdate({
|
||||
sessionId: "session-1",
|
||||
update: { sessionUpdate: "usage_update", used: update.used, size: update.size },
|
||||
});
|
||||
return { events };
|
||||
}
|
||||
|
||||
test("forwards usage_update as context-window usage state", async () => {
|
||||
const { events } = await emitUsageUpdate({ used: 13_759, size: 1_000_000 });
|
||||
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: "usage_updated",
|
||||
provider: "dsh",
|
||||
usage: { contextWindowMaxTokens: 1_000_000, contextWindowUsedTokens: 13_759 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("emits nothing when size and used cannot both drive a meter", async () => {
|
||||
await expect(
|
||||
emitUsageUpdate({ used: -1, size: 0 }).then((result) => result.events),
|
||||
).resolves.toEqual([]);
|
||||
await expect(
|
||||
emitUsageUpdate({ used: 13_759, size: 0 }).then((result) => result.events),
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveModesFromACP", () => {
|
||||
test("prefers explicit ACP mode state", () => {
|
||||
const result = deriveModesFromACP(
|
||||
|
||||
@@ -3178,8 +3178,29 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
}
|
||||
}
|
||||
|
||||
// ACP reports context occupancy out of band from the prompt response, so the
|
||||
// composer's context meter cannot read it from `turn_completed`. Forward it as
|
||||
// the same `usage_updated` snapshot the native adapters emit, preserving the
|
||||
// turn usage this session already recorded. The guards match the meter's own
|
||||
// validity rules, so a provider cannot push a window the meter would reject.
|
||||
private handleUsageUpdate(update: UsageUpdate): void {
|
||||
void update;
|
||||
const contextWindowMaxTokens =
|
||||
Number.isFinite(update.size) && update.size > 0 ? update.size : undefined;
|
||||
const contextWindowUsedTokens =
|
||||
Number.isFinite(update.used) && update.used >= 0 ? update.used : undefined;
|
||||
if (contextWindowMaxTokens === undefined || contextWindowUsedTokens === undefined) {
|
||||
return;
|
||||
}
|
||||
this.pushEvent({
|
||||
type: "usage_updated",
|
||||
provider: this.provider,
|
||||
usage: {
|
||||
...this.currentTurnUsage,
|
||||
contextWindowMaxTokens,
|
||||
contextWindowUsedTokens,
|
||||
},
|
||||
turnId: this.activeForegroundTurnId ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
private handlePromptResponse(response: PromptResponse, turnId: string): void {
|
||||
|
||||
@@ -1009,7 +1009,7 @@ describe("ClaudeAgentSession features", () => {
|
||||
|
||||
expect(queryFactory.mock.calls[0]?.[0].options).toMatchObject({
|
||||
effort: "xhigh",
|
||||
thinking: { type: "adaptive" },
|
||||
thinking: { type: "adaptive", display: "summarized" },
|
||||
settings: { ultracode: true },
|
||||
});
|
||||
|
||||
@@ -1223,7 +1223,7 @@ describe("ClaudeAgentSession features", () => {
|
||||
|
||||
test.each([
|
||||
["supported model", "claude-opus-4-8", { type: "disabled" }, undefined],
|
||||
["unsupported model", "claude-fable-5", { type: "adaptive" }, "high"],
|
||||
["unsupported model", "claude-fable-5", { type: "adaptive", display: "summarized" }, "high"],
|
||||
["custom model", "openrouter/anthropic/claude-opus-4-8", undefined, undefined],
|
||||
["provider default", null, undefined, undefined],
|
||||
])("reconciles Off when switching to a %s", async (_label, modelId, thinking, effort) => {
|
||||
|
||||
@@ -3244,10 +3244,18 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return { thinking: { type: "disabled" }, effort: undefined, ultracode: false };
|
||||
}
|
||||
if (thinkingOptionId === CLAUDE_ULTRACODE_THINKING_OPTION_ID) {
|
||||
return { thinking: { type: "adaptive" }, effort: "xhigh", ultracode: true };
|
||||
return {
|
||||
thinking: { type: "adaptive", display: "summarized" },
|
||||
effort: "xhigh",
|
||||
ultracode: true,
|
||||
};
|
||||
}
|
||||
if (thinkingOptionId && isClaudeThinkingEffort(thinkingOptionId)) {
|
||||
return { thinking: { type: "adaptive" }, effort: thinkingOptionId, ultracode: false };
|
||||
return {
|
||||
thinking: { type: "adaptive", display: "summarized" },
|
||||
effort: thinkingOptionId,
|
||||
ultracode: false,
|
||||
};
|
||||
}
|
||||
return { thinking: undefined, effort: undefined, ultracode: false };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { describe, expect, onTestFinished, test } from "vitest";
|
||||
import { setImmediate as waitForImmediate } from "node:timers/promises";
|
||||
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -69,6 +69,51 @@ test("OMP import uses the runtime's custom agent directory without a configured
|
||||
expect.objectContaining({ providerHandleId: sessionFile }),
|
||||
]);
|
||||
});
|
||||
test("OMP resumes a session whose model was removed on the model OMP falls back to", async () => {
|
||||
const runtime = new FakeOmp();
|
||||
runtime.removeModel("9router/deepseek-v4-flash");
|
||||
runtime.queueSessionSetup((session) => {
|
||||
session.state = { ...session.state, model: { provider: "openrouter", id: "fallback" } };
|
||||
});
|
||||
const client = new OmpAgentClient({ logger: createTestLogger(), runtime });
|
||||
|
||||
const session = await client.resumeSession({
|
||||
provider: "omp",
|
||||
sessionId: "omp-session-1",
|
||||
nativeHandle: "/tmp/omp-session.jsonl",
|
||||
metadata: { cwd: "/workspace/project", model: "9router/deepseek-v4-flash" },
|
||||
});
|
||||
onTestFinished(() => session.close());
|
||||
|
||||
await expect(session.getRuntimeInfo()).resolves.toMatchObject({ model: "openrouter/fallback" });
|
||||
expect(session.describePersistence()?.metadata?.model).toBe("openrouter/fallback");
|
||||
});
|
||||
|
||||
test("OMP resumes a session on the requested model when it differs from the session's", async () => {
|
||||
const runtime = new FakeOmp();
|
||||
const requestedModel = { provider: "openrouter", id: "requested" };
|
||||
runtime.queueSessionSetup((session) => {
|
||||
session.state = { ...session.state, model: { provider: "openrouter", id: "recorded" } };
|
||||
session.models = [requestedModel];
|
||||
session.setModelResult = requestedModel;
|
||||
});
|
||||
const client = new OmpAgentClient({ logger: createTestLogger(), runtime });
|
||||
|
||||
const session = await client.resumeSession({
|
||||
provider: "omp",
|
||||
sessionId: "omp-session-1",
|
||||
nativeHandle: "/tmp/omp-session.jsonl",
|
||||
metadata: { cwd: "/workspace/project", model: "openrouter/requested" },
|
||||
});
|
||||
onTestFinished(() => session.close());
|
||||
|
||||
expect(runtime.latestSession().setModelRequests).toEqual([
|
||||
{ provider: "openrouter", modelId: "requested" },
|
||||
]);
|
||||
await expect(session.getRuntimeInfo()).resolves.toMatchObject({ model: "openrouter/requested" });
|
||||
expect(session.describePersistence()?.metadata?.model).toBe("openrouter/requested");
|
||||
});
|
||||
|
||||
class ManualIdleScheduler implements OmpProviderIdleScheduler {
|
||||
private readonly retries: Array<() => void> = [];
|
||||
private readonly waiters: Array<{ count: number; resolve: () => void }> = [];
|
||||
|
||||
@@ -445,7 +445,6 @@ function buildResumeStartInput(input: {
|
||||
protocolMode: "rpc-ui",
|
||||
env: input.launchContext?.env,
|
||||
session: input.sessionFile,
|
||||
model: input.resumeConfig.model,
|
||||
thinkingOptionId: normalizeOmpThinkingOption(input.resumeConfig.thinkingOptionId) ?? undefined,
|
||||
...(input.launchMode.modeId ? { modeId: input.launchMode.modeId } : {}),
|
||||
...(input.launchMode.extraArgs ? { extraArgs: input.launchMode.extraArgs } : {}),
|
||||
@@ -2373,16 +2372,17 @@ export class OmpAgentClient implements AgentClient {
|
||||
resumeConfig.config,
|
||||
startInput.env,
|
||||
);
|
||||
const initialState = await this.restoreFastMode(
|
||||
runtimeSession,
|
||||
resumeConfig.config,
|
||||
await runtimeSession.getState(),
|
||||
);
|
||||
const resumedState = await this.applyResumeModel(runtimeSession, resumeConfig.model);
|
||||
const config = {
|
||||
...resumeConfig.config,
|
||||
model: modelToId(resumedState.model) ?? resumeConfig.config.model,
|
||||
};
|
||||
const initialState = await this.restoreFastMode(runtimeSession, config, resumedState);
|
||||
return new OmpAgentSession({
|
||||
runtimeSession,
|
||||
hostTools,
|
||||
restartRuntime: this.buildRestartRuntime(startInput, resumeConfig.config, launchContext),
|
||||
config: resumeConfig.config,
|
||||
restartRuntime: this.buildRestartRuntime(startInput, config, launchContext),
|
||||
config,
|
||||
initialState,
|
||||
currentModeId: launchMode.modeId,
|
||||
logger: this.logger,
|
||||
@@ -2400,6 +2400,35 @@ export class OmpAgentClient implements AgentClient {
|
||||
}
|
||||
}
|
||||
|
||||
// OMP resumes a session on the model it recorded, or on its default when that model
|
||||
// is gone. Switching afterwards keeps a removed model from blocking the resume.
|
||||
private async applyResumeModel(
|
||||
runtimeSession: OmpRuntimeSession,
|
||||
requestedModel: string | undefined,
|
||||
): Promise<OmpSessionState> {
|
||||
const state = await runtimeSession.getState();
|
||||
const reference = parseModelReference(requestedModel ?? null);
|
||||
if (!reference?.provider) {
|
||||
return state;
|
||||
}
|
||||
const { provider, id } = reference;
|
||||
const isRequested = (model: OmpModel | null | undefined) =>
|
||||
model?.provider === provider && model.id === id;
|
||||
if (isRequested(state.model)) {
|
||||
return state;
|
||||
}
|
||||
const availableModels = await runtimeSession.getAvailableModels();
|
||||
if (!availableModels.some(isRequested)) {
|
||||
this.logger.warn(
|
||||
{ requestedModel, sessionModel: modelToId(state.model) },
|
||||
"OMP resumed on the session's model because the requested model is unavailable",
|
||||
);
|
||||
return state;
|
||||
}
|
||||
await runtimeSession.setModel(provider, id);
|
||||
return runtimeSession.getState();
|
||||
}
|
||||
|
||||
private buildRestartRuntime(
|
||||
startInput: OmpStartSessionInput,
|
||||
config: AgentSessionConfig,
|
||||
|
||||
@@ -54,6 +54,8 @@ export class FakeOmp implements OmpRuntime {
|
||||
private readonly sessions: FakeOmpSession[] = [];
|
||||
private readonly command: [string, ...string[]];
|
||||
private readonly queuedCommands: OmpRpcSlashCommand[][] = [];
|
||||
private readonly queuedSessionSetups: Array<(session: FakeOmpSession) => void> = [];
|
||||
private readonly removedModels = new Set<string>();
|
||||
private nextStartError: Error | null = null;
|
||||
private nextSessionId = 0;
|
||||
private readonly sessionIdsByFile = new Map<string, string>();
|
||||
@@ -75,16 +77,20 @@ export class FakeOmp implements OmpRuntime {
|
||||
session: input,
|
||||
});
|
||||
this.recordedLaunches.push(launch);
|
||||
if (launch.model && this.removedModels.has(launch.model)) {
|
||||
throw new Error(`Model "${launch.model}" not found`);
|
||||
}
|
||||
const sessionId =
|
||||
(launch.session ? this.sessionIdsByFile.get(launch.session) : undefined) ??
|
||||
`omp-session-${++this.nextSessionId}`;
|
||||
const session = new FakeOmpSession(launch, sessionId);
|
||||
const session = new FakeOmpSession(launch, sessionId, this.removedModels);
|
||||
if (!launch.noSession) this.sessionIdsByFile.set(session.state.sessionFile, sessionId);
|
||||
session.commands = this.queuedCommands.shift() ?? [];
|
||||
for (const [level, error] of this.queuedSubagentSubscriptionErrors) {
|
||||
session.subagentSubscriptionErrors.set(level, error);
|
||||
}
|
||||
this.queuedSubagentSubscriptionErrors.clear();
|
||||
this.queuedSessionSetups.shift()?.(session);
|
||||
this.sessions.push(session);
|
||||
return session;
|
||||
}
|
||||
@@ -93,6 +99,15 @@ export class FakeOmp implements OmpRuntime {
|
||||
this.queuedCommands.push(commands);
|
||||
}
|
||||
|
||||
queueSessionSetup(setup: (session: FakeOmpSession) => void): void {
|
||||
this.queuedSessionSetups.push(setup);
|
||||
}
|
||||
|
||||
// OMP refuses to launch with, or switch to, a model it no longer knows.
|
||||
removeModel(modelId: string): void {
|
||||
this.removedModels.add(modelId);
|
||||
}
|
||||
|
||||
failNextStart(error: Error): void {
|
||||
this.nextStartError = error;
|
||||
}
|
||||
@@ -174,7 +189,11 @@ export class FakeOmpSession implements OmpRuntimeSession {
|
||||
private activeHeldPrompt: { promise: Promise<void>; reject: (error: Error) => void } | null =
|
||||
null;
|
||||
|
||||
constructor(launch: OmpRuntimeLaunch, sessionId = "omp-session-1") {
|
||||
constructor(
|
||||
launch: OmpRuntimeLaunch,
|
||||
sessionId = "omp-session-1",
|
||||
private readonly removedModels: ReadonlySet<string> = new Set(),
|
||||
) {
|
||||
this.environment = launch.env ?? {};
|
||||
this.state = {
|
||||
model: null,
|
||||
@@ -333,9 +352,13 @@ export class FakeOmpSession implements OmpRuntimeSession {
|
||||
|
||||
async setModel(provider: string, modelId: string): Promise<OmpModel> {
|
||||
this.setModelRequests.push({ provider, modelId });
|
||||
if (this.removedModels.has(`${provider}/${modelId}`)) {
|
||||
throw new Error(`Model not found: ${provider}/${modelId}`);
|
||||
}
|
||||
if (!this.setModelResult) {
|
||||
throw new Error("FakeOmp setModel requires setModelResult to be scripted");
|
||||
}
|
||||
this.state = { ...this.state, model: this.setModelResult };
|
||||
return this.setModelResult;
|
||||
}
|
||||
|
||||
|
||||
@@ -1528,8 +1528,6 @@ describe("PiRpcAgentSession", () => {
|
||||
"pi",
|
||||
"--mode",
|
||||
"rpc",
|
||||
"--model",
|
||||
"openrouter/model-a",
|
||||
"--thinking",
|
||||
"high",
|
||||
"--session",
|
||||
@@ -1539,6 +1537,87 @@ describe("PiRpcAgentSession", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("resumes a session whose model was removed on the model Pi falls back to", async () => {
|
||||
const pi = new FakePi();
|
||||
pi.removeModel("9router/deepseek-v4-flash");
|
||||
pi.queueSessionSetup((session) => {
|
||||
session.state = { ...session.state, model: RESTRICTED_THINKING_MODEL };
|
||||
});
|
||||
|
||||
const session = await createClient(pi).resumeSession({
|
||||
provider: "pi",
|
||||
sessionId: "pi-session-1",
|
||||
nativeHandle: "/tmp/native-pi-session",
|
||||
metadata: { cwd: "/workspace/project", model: "9router/deepseek-v4-flash" },
|
||||
});
|
||||
onTestFinished(() => session.close());
|
||||
|
||||
await expect(session.getRuntimeInfo()).resolves.toMatchObject({
|
||||
model: "kimi-coding/kimi-k3",
|
||||
});
|
||||
expect(session.describePersistence()?.metadata?.model).toBe("kimi-coding/kimi-k3");
|
||||
});
|
||||
|
||||
test("resumes a session on the requested model when it differs from the session's", async () => {
|
||||
const pi = new FakePi();
|
||||
const requestedModel = { ...RESTRICTED_THINKING_MODEL, provider: "openrouter", id: "a" };
|
||||
pi.queueSessionSetup((session) => {
|
||||
session.state = { ...session.state, model: RESTRICTED_THINKING_MODEL };
|
||||
session.models = [RESTRICTED_THINKING_MODEL, requestedModel];
|
||||
session.setModelResult = requestedModel;
|
||||
});
|
||||
|
||||
const session = await createClient(pi).resumeSession({
|
||||
provider: "pi",
|
||||
sessionId: "pi-session-1",
|
||||
nativeHandle: "/tmp/native-pi-session",
|
||||
metadata: { cwd: "/workspace/project", model: "openrouter/a" },
|
||||
});
|
||||
onTestFinished(() => session.close());
|
||||
|
||||
expect(pi.latestSession().setModelRequests).toEqual([{ provider: "openrouter", modelId: "a" }]);
|
||||
await expect(session.getRuntimeInfo()).resolves.toMatchObject({ model: "openrouter/a" });
|
||||
expect(session.describePersistence()?.metadata?.model).toBe("openrouter/a");
|
||||
});
|
||||
|
||||
test("resumes on a requested model written as provider:id", async () => {
|
||||
const pi = new FakePi();
|
||||
const requestedModel = { ...RESTRICTED_THINKING_MODEL, provider: "openrouter", id: "a" };
|
||||
pi.queueSessionSetup((session) => {
|
||||
session.state = { ...session.state, model: RESTRICTED_THINKING_MODEL };
|
||||
session.models = [RESTRICTED_THINKING_MODEL, requestedModel];
|
||||
session.setModelResult = requestedModel;
|
||||
});
|
||||
|
||||
const session = await createClient(pi).resumeSession({
|
||||
provider: "pi",
|
||||
sessionId: "pi-session-1",
|
||||
nativeHandle: "/tmp/native-pi-session",
|
||||
metadata: { cwd: "/workspace/project", model: "openrouter:a" },
|
||||
});
|
||||
onTestFinished(() => session.close());
|
||||
|
||||
await expect(session.getRuntimeInfo()).resolves.toMatchObject({ model: "openrouter/a" });
|
||||
});
|
||||
|
||||
test("fails the resume when switching to an available model fails", async () => {
|
||||
const pi = new FakePi();
|
||||
const requestedModel = { ...RESTRICTED_THINKING_MODEL, provider: "openrouter", id: "a" };
|
||||
pi.queueSessionSetup((session) => {
|
||||
session.state = { ...session.state, model: RESTRICTED_THINKING_MODEL };
|
||||
session.models = [RESTRICTED_THINKING_MODEL, requestedModel];
|
||||
});
|
||||
|
||||
await expect(
|
||||
createClient(pi).resumeSession({
|
||||
provider: "pi",
|
||||
sessionId: "pi-session-1",
|
||||
nativeHandle: "/tmp/native-pi-session",
|
||||
metadata: { cwd: "/workspace/project", model: "openrouter/a" },
|
||||
}),
|
||||
).rejects.toThrow("FakePi setModel requires setModelResult to be scripted");
|
||||
});
|
||||
|
||||
test("adopts Pi's clamped thinking level when resuming a session", async () => {
|
||||
const pi = new FakePi();
|
||||
pi.queueSessionSetup((session) => {
|
||||
@@ -1715,8 +1794,6 @@ describe("PiRpcAgentSession", () => {
|
||||
"pi",
|
||||
"--mode",
|
||||
"rpc",
|
||||
"--model",
|
||||
"openrouter/model-a",
|
||||
"--thinking",
|
||||
"high",
|
||||
"--session",
|
||||
@@ -2576,6 +2653,11 @@ describe("PiRpcAgentClient", () => {
|
||||
);
|
||||
const pi = new FakePi();
|
||||
pi.queueSessionSetup((session) => {
|
||||
session.state.model = {
|
||||
...RESTRICTED_THINKING_MODEL,
|
||||
provider: "openrouter",
|
||||
id: "anthropic/claude-sonnet-4.5",
|
||||
};
|
||||
session.state.thinkingLevel = "high";
|
||||
});
|
||||
const client = new PiRpcAgentClient({
|
||||
@@ -2594,8 +2676,6 @@ describe("PiRpcAgentClient", () => {
|
||||
"pi",
|
||||
"--mode",
|
||||
"rpc",
|
||||
"--model",
|
||||
"openrouter/anthropic/claude-sonnet-4.5",
|
||||
"--thinking",
|
||||
"high",
|
||||
"--session",
|
||||
|
||||
@@ -495,7 +495,6 @@ function buildResumeStartInput(input: {
|
||||
cwd: input.resumeConfig.cwd,
|
||||
env: input.launchContext?.env,
|
||||
session: input.sessionFile,
|
||||
model: input.resumeConfig.model,
|
||||
thinkingOptionId: normalizePiThinkingOption(input.resumeConfig.thinkingOptionId) ?? undefined,
|
||||
mcpConfigPath: input.mcpConfigFile?.path,
|
||||
extensionPaths: input.paseoExtension ? [input.paseoExtension.path] : undefined,
|
||||
@@ -2590,10 +2589,14 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const initialState = await this.applyResumeModel(runtimeSession, resumeConfig.model);
|
||||
return new PiRpcAgentSession({
|
||||
runtimeSession,
|
||||
config: resumeConfig.config,
|
||||
initialState: await runtimeSession.getState(),
|
||||
config: {
|
||||
...resumeConfig.config,
|
||||
model: modelToId(initialState.model) ?? resumeConfig.config.model,
|
||||
},
|
||||
initialState,
|
||||
capabilities: capabilitiesForSession(mcp !== null),
|
||||
cleanup: combineCleanup([mcpConfigFile?.cleanup, paseoExtension?.cleanup]),
|
||||
extensionTimeoutMs: providerOptions.extensionTimeoutMs,
|
||||
@@ -2608,6 +2611,35 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
}
|
||||
}
|
||||
|
||||
// Pi resumes a session on the model it recorded, or on its default when that model
|
||||
// is gone. Switching afterwards keeps a removed model from blocking the resume.
|
||||
private async applyResumeModel(
|
||||
runtimeSession: PiRuntimeSession,
|
||||
requestedModel: string | undefined,
|
||||
): Promise<PiSessionState> {
|
||||
const state = await runtimeSession.getState();
|
||||
const reference = parseModelReference(requestedModel ?? null);
|
||||
if (!reference?.provider) {
|
||||
return state;
|
||||
}
|
||||
const { provider, id } = reference;
|
||||
const isRequested = (model: PiModel | null | undefined) =>
|
||||
model?.provider === provider && model.id === id;
|
||||
if (isRequested(state.model)) {
|
||||
return state;
|
||||
}
|
||||
const availableModels = await runtimeSession.getAvailableModels();
|
||||
if (!availableModels.some(isRequested)) {
|
||||
this.logger.warn(
|
||||
{ requestedModel, sessionModel: modelToId(state.model) },
|
||||
"Pi resumed on the session's model because the requested model is unavailable",
|
||||
);
|
||||
return state;
|
||||
}
|
||||
await runtimeSession.setModel(provider, id);
|
||||
return runtimeSession.getState();
|
||||
}
|
||||
|
||||
async fetchCatalog(
|
||||
options: FetchCatalogOptions,
|
||||
context?: ProviderRefreshContext,
|
||||
|
||||
@@ -57,6 +57,7 @@ export class FakePi implements PiRuntime {
|
||||
private readonly command: [string, ...string[]];
|
||||
private readonly queuedCommands: PiRpcSlashCommand[][] = [];
|
||||
private readonly queuedSessionSetups: Array<(session: FakePiSession) => void> = [];
|
||||
private readonly removedModels = new Set<string>();
|
||||
|
||||
constructor(command: [string, ...string[]] = ["pi"]) {
|
||||
this.command = command;
|
||||
@@ -68,7 +69,10 @@ export class FakePi implements PiRuntime {
|
||||
session: input,
|
||||
});
|
||||
this.recordedLaunches.push(launch);
|
||||
const session = new FakePiSession(launch);
|
||||
if (launch.model && this.removedModels.has(launch.model)) {
|
||||
throw new Error(`Model "${launch.model}" not found`);
|
||||
}
|
||||
const session = new FakePiSession(launch, this.removedModels);
|
||||
session.commands = this.queuedCommands.shift() ?? [];
|
||||
this.queuedSessionSetups.shift()?.(session);
|
||||
this.sessions.push(session);
|
||||
@@ -83,6 +87,11 @@ export class FakePi implements PiRuntime {
|
||||
this.queuedSessionSetups.push(setup);
|
||||
}
|
||||
|
||||
// Pi refuses to launch with, or switch to, a model it no longer knows.
|
||||
removeModel(modelId: string): void {
|
||||
this.removedModels.add(modelId);
|
||||
}
|
||||
|
||||
latestSession(): FakePiSession {
|
||||
const session = this.sessions.at(-1);
|
||||
if (!session) {
|
||||
@@ -145,7 +154,10 @@ export class FakePiSession implements PiRuntimeSession {
|
||||
private activeHeldPrompt: { promise: Promise<void>; reject: (error: Error) => void } | null =
|
||||
null;
|
||||
|
||||
constructor(launch: PiRuntimeLaunch) {
|
||||
constructor(
|
||||
launch: PiRuntimeLaunch,
|
||||
private readonly removedModels: ReadonlySet<string> = new Set(),
|
||||
) {
|
||||
this.environment = launch.env ?? {};
|
||||
this.state = {
|
||||
model: null,
|
||||
@@ -267,6 +279,9 @@ export class FakePiSession implements PiRuntimeSession {
|
||||
|
||||
async setModel(provider: string, modelId: string): Promise<PiModel> {
|
||||
this.setModelRequests.push({ provider, modelId });
|
||||
if (this.removedModels.has(`${provider}/${modelId}`)) {
|
||||
throw new Error(`Model not found: ${provider}/${modelId}`);
|
||||
}
|
||||
if (!this.setModelResult) {
|
||||
throw new Error("FakePi setModel requires setModelResult to be scripted");
|
||||
}
|
||||
|
||||
@@ -1170,7 +1170,6 @@ export async function createPaseoDaemon(
|
||||
emit: emitExternalSessionMessage,
|
||||
sessionLogger: logger,
|
||||
terminalManager,
|
||||
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
|
||||
serviceProxy,
|
||||
scriptRuntimeStore,
|
||||
getDaemonTcpPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
|
||||
|
||||
@@ -27,9 +27,28 @@ function createRecord(overrides?: Partial<StoredAgentRecord>): StoredAgentRecord
|
||||
}
|
||||
|
||||
describe("persistence hooks", () => {
|
||||
test("buildConfigOverrides prefers the last live mode over the creation-time mode", () => {
|
||||
const record = createRecord({
|
||||
lastModeId: "bypass",
|
||||
config: { modeId: "acceptEdits" },
|
||||
});
|
||||
|
||||
expect(buildConfigOverrides(record).modeId).toBe("bypass");
|
||||
});
|
||||
|
||||
test("buildConfigOverrides falls back to the configured mode when no live mode was recorded", () => {
|
||||
const record = createRecord({
|
||||
lastModeId: null,
|
||||
config: { modeId: "acceptEdits" },
|
||||
});
|
||||
|
||||
expect(buildConfigOverrides(record).modeId).toBe("acceptEdits");
|
||||
});
|
||||
|
||||
test("buildConfigOverrides preserves the complete private launch config", () => {
|
||||
const record = createRecord({
|
||||
title: "Voice agent (current)",
|
||||
lastModeId: "default",
|
||||
config: {
|
||||
modeId: "default",
|
||||
model: "gpt-5.4-mini",
|
||||
@@ -79,6 +98,7 @@ describe("persistence hooks", () => {
|
||||
const record = createRecord({
|
||||
provider: "codex",
|
||||
title: "Renamed title",
|
||||
lastModeId: null,
|
||||
config: {
|
||||
model: "gpt-5.4-mini",
|
||||
systemPrompt: "Confirm and speak first.",
|
||||
|
||||
@@ -67,7 +67,10 @@ export function buildConfigOverrides(record: StoredAgentRecord): Partial<AgentSe
|
||||
return stripInternalPaseoMcpServer({
|
||||
provider: record.provider,
|
||||
cwd: record.cwd,
|
||||
modeId: record.config?.modeId ?? undefined,
|
||||
// lastModeId is the last live mode — it also covers provider-side switches
|
||||
// (ACP current_mode_update, in-session commands) that never reach
|
||||
// record.config.modeId.
|
||||
modeId: record.lastModeId ?? record.config?.modeId ?? undefined,
|
||||
model: record.config?.model ?? undefined,
|
||||
thinkingOptionId: record.config?.thinkingOptionId ?? undefined,
|
||||
featureValues: record.config?.featureValues ?? undefined,
|
||||
|
||||
@@ -5216,10 +5216,10 @@ export class Session {
|
||||
|
||||
private async resolveAgentIdentifier(
|
||||
identifier: string,
|
||||
): Promise<{ ok: true; agentId: string } | { ok: false; error: string }> {
|
||||
): Promise<{ ok: true; agentId: string } | { ok: false; notFound: boolean; error: string }> {
|
||||
const trimmed = identifier.trim();
|
||||
if (!trimmed) {
|
||||
return { ok: false, error: "Agent identifier cannot be empty" };
|
||||
return { ok: false, notFound: false, error: "Agent identifier cannot be empty" };
|
||||
}
|
||||
|
||||
const stored = await this.agentStorage.list();
|
||||
@@ -5243,6 +5243,7 @@ export class Session {
|
||||
if (prefixMatches.length > 1) {
|
||||
return {
|
||||
ok: false,
|
||||
notFound: false,
|
||||
error: `Agent identifier "${trimmed}" is ambiguous (${prefixMatches
|
||||
.slice(0, 5)
|
||||
.map((id) => id.slice(0, 8))
|
||||
@@ -5257,6 +5258,7 @@ export class Session {
|
||||
if (titleMatches.length > 1) {
|
||||
return {
|
||||
ok: false,
|
||||
notFound: false,
|
||||
error: `Agent title "${trimmed}" is ambiguous (${titleMatches
|
||||
.slice(0, 5)
|
||||
.map((r) => r.id.slice(0, 8))
|
||||
@@ -5264,7 +5266,7 @@ export class Session {
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: false, error: `Agent not found: ${trimmed}` };
|
||||
return { ok: false, notFound: true, error: `Agent not found: ${trimmed}` };
|
||||
}
|
||||
|
||||
private async getAgentPayloadById(agentId: string): Promise<AgentSnapshotPayload | null> {
|
||||
@@ -7253,7 +7255,6 @@ export class Session {
|
||||
emit: (message) => this.emit(message),
|
||||
sessionLogger: this.sessionLogger,
|
||||
terminalManager: this.terminalManager,
|
||||
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
|
||||
serviceProxy: this.serviceProxy,
|
||||
scriptRuntimeStore: this.scriptRuntimeStore,
|
||||
getDaemonTcpPort: this.getDaemonTcpPort,
|
||||
@@ -7300,7 +7301,6 @@ export class Session {
|
||||
emit: (message) => this.emit(message),
|
||||
sessionLogger: this.sessionLogger,
|
||||
terminalManager: this.terminalManager,
|
||||
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
|
||||
serviceProxy: this.serviceProxy,
|
||||
scriptRuntimeStore: this.scriptRuntimeStore,
|
||||
getDaemonTcpPort: this.getDaemonTcpPort,
|
||||
@@ -7576,11 +7576,17 @@ export class Session {
|
||||
}
|
||||
|
||||
private async handleFetchAgent(agentIdOrIdentifier: string, requestId: string): Promise<void> {
|
||||
// An unknown agent is a null agent, not an error. Errors are for empty or ambiguous identifiers.
|
||||
const resolved = await this.resolveAgentIdentifier(agentIdOrIdentifier);
|
||||
if (!resolved.ok) {
|
||||
this.emit({
|
||||
type: "fetch_agent_response",
|
||||
payload: { requestId, agent: null, project: null, error: resolved.error },
|
||||
payload: {
|
||||
requestId,
|
||||
agent: null,
|
||||
project: null,
|
||||
error: resolved.notFound ? null : resolved.error,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -7589,12 +7595,7 @@ export class Session {
|
||||
if (!agent) {
|
||||
this.emit({
|
||||
type: "fetch_agent_response",
|
||||
payload: {
|
||||
requestId,
|
||||
agent: null,
|
||||
project: null,
|
||||
error: `Agent not found: ${resolved.agentId}`,
|
||||
},
|
||||
payload: { requestId, agent: null, project: null, error: null },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3466,7 +3466,7 @@ test("fetch_agent_request still resolves archived historical agents", async () =
|
||||
session.resolveAgentIdentifier = async (identifier: string) =>
|
||||
identifier === "Archived History Agent"
|
||||
? { ok: true, agentId: agent.id }
|
||||
: { ok: false, error: `Agent not found: ${identifier}` };
|
||||
: { ok: false, notFound: true, error: `Agent not found: ${identifier}` };
|
||||
session.getAgentPayloadById = async (agentId: string) => (agentId === agent.id ? agent : null);
|
||||
session.buildProjectPlacementForWorkspaceId = async () => ({
|
||||
projectKey: "proj-history-detail",
|
||||
|
||||
@@ -115,7 +115,6 @@ function createWorkflowForRequestTest(options: {
|
||||
emit: () => {},
|
||||
sessionLogger: createLogger(),
|
||||
terminalManager: null,
|
||||
archiveWorkspaceRecord: async () => {},
|
||||
serviceProxy: null,
|
||||
scriptRuntimeStore: null,
|
||||
getDaemonTcpPort: null,
|
||||
@@ -489,7 +488,6 @@ describe("create-agent worktree setup boundary", () => {
|
||||
emit: (message) => emitted.push(message),
|
||||
sessionLogger: createLogger(),
|
||||
terminalManager: createTerminalManagerStub().manager,
|
||||
archiveWorkspaceRecord: async () => {},
|
||||
serviceProxy: null,
|
||||
scriptRuntimeStore: null,
|
||||
getDaemonTcpPort: null,
|
||||
@@ -540,7 +538,6 @@ describe("create-agent worktree setup boundary", () => {
|
||||
emit: (message) => workspaceSetupEvents.push(message),
|
||||
sessionLogger: createLogger(),
|
||||
terminalManager: null,
|
||||
archiveWorkspaceRecord: async () => {},
|
||||
serviceProxy: null,
|
||||
scriptRuntimeStore: null,
|
||||
getDaemonTcpPort: null,
|
||||
@@ -714,7 +711,6 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
emit: () => {},
|
||||
sessionLogger: createLogger(),
|
||||
terminalManager: null,
|
||||
archiveWorkspaceRecord: async () => {},
|
||||
},
|
||||
{
|
||||
requestCwd: sourceWorkspaceCwd,
|
||||
@@ -759,7 +755,6 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
const logger = createLogger();
|
||||
const terminalManager = createTerminalManagerStub();
|
||||
const emitWorkspaceUpdateForWorkspaceId = vi.fn(async () => {});
|
||||
const archiveWorkspaceRecord = vi.fn(async () => {});
|
||||
|
||||
await runWorktreeSetupInBackground(
|
||||
{
|
||||
@@ -770,7 +765,6 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
emit: (message) => emitted.push(message),
|
||||
sessionLogger: logger,
|
||||
terminalManager: terminalManager.manager,
|
||||
archiveWorkspaceRecord,
|
||||
},
|
||||
{
|
||||
requestCwd: repoDir,
|
||||
@@ -828,11 +822,10 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
});
|
||||
|
||||
expect(terminalManager.terminals).toHaveLength(0);
|
||||
expect(archiveWorkspaceRecord).not.toHaveBeenCalled();
|
||||
expect(emitWorkspaceUpdateForWorkspaceId).toHaveBeenCalledWith("42");
|
||||
});
|
||||
|
||||
test("archives the pending workspace and emits a failed snapshot when setup cannot start", async () => {
|
||||
test("keeps the workspace available and emits a failed snapshot when setup cannot start", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
|
||||
@@ -857,7 +850,6 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
const snapshots = new Map<string, unknown>();
|
||||
const logger = createLogger();
|
||||
const emitWorkspaceUpdateForWorkspaceId = vi.fn(async () => {});
|
||||
const archiveWorkspaceRecord = vi.fn(async () => {});
|
||||
const workspaceId = "ws-broken-feature";
|
||||
|
||||
await runWorktreeSetupInBackground(
|
||||
@@ -869,7 +861,6 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
emit: (message) => emitted.push(message),
|
||||
sessionLogger: logger,
|
||||
terminalManager: null,
|
||||
archiveWorkspaceRecord,
|
||||
},
|
||||
{
|
||||
requestCwd: repoDir,
|
||||
@@ -901,7 +892,8 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
status: "failed",
|
||||
error: expect.stringMatching(/Failed to parse paseo\.json at .*paseo\.json/),
|
||||
});
|
||||
expect(archiveWorkspaceRecord).toHaveBeenCalledWith(workspaceId);
|
||||
expect(existsSync(worktreePath)).toBe(true);
|
||||
expect(existsSync(path.join(worktreePath, "paseo.json"))).toBe(true);
|
||||
expect(emitWorkspaceUpdateForWorkspaceId).toHaveBeenCalledWith(workspaceId);
|
||||
});
|
||||
|
||||
@@ -932,7 +924,6 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
const snapshots = new Map<string, unknown>();
|
||||
const logger = createLogger();
|
||||
const emitWorkspaceUpdateForWorkspaceId = vi.fn(async () => {});
|
||||
const archiveWorkspaceRecord = vi.fn(async () => {});
|
||||
|
||||
await runWorktreeSetupInBackground(
|
||||
{
|
||||
@@ -943,7 +934,6 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
emit: (message) => emitted.push(message),
|
||||
sessionLogger: logger,
|
||||
terminalManager: null,
|
||||
archiveWorkspaceRecord,
|
||||
},
|
||||
{
|
||||
requestCwd: repoDir,
|
||||
@@ -1056,7 +1046,6 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
const logger = createLogger();
|
||||
const terminalManager = createTerminalManagerStub();
|
||||
const emitWorkspaceUpdateForWorkspaceId = vi.fn(async () => {});
|
||||
const archiveWorkspaceRecord = vi.fn(async () => {});
|
||||
|
||||
await runWorktreeSetupInBackground(
|
||||
{
|
||||
@@ -1067,7 +1056,6 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
emit: (message) => emitted.push(message),
|
||||
sessionLogger: logger,
|
||||
terminalManager: terminalManager.manager,
|
||||
archiveWorkspaceRecord,
|
||||
},
|
||||
{
|
||||
requestCwd: repoDir,
|
||||
@@ -1116,7 +1104,6 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
status: "completed",
|
||||
error: null,
|
||||
});
|
||||
expect(archiveWorkspaceRecord).not.toHaveBeenCalled();
|
||||
expect(emitWorkspaceUpdateForWorkspaceId).toHaveBeenCalledWith("44");
|
||||
});
|
||||
|
||||
@@ -1151,7 +1138,6 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
},
|
||||
});
|
||||
const emitWorkspaceUpdateForWorkspaceId = vi.fn(async () => {});
|
||||
const archiveWorkspaceRecord = vi.fn(async () => {});
|
||||
|
||||
await runWorktreeSetupInBackground(
|
||||
{
|
||||
@@ -1162,7 +1148,6 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
emit: (message) => emitted.push(message),
|
||||
sessionLogger: logger,
|
||||
terminalManager: terminalManager.manager,
|
||||
archiveWorkspaceRecord,
|
||||
},
|
||||
{
|
||||
requestCwd: repoDir,
|
||||
@@ -1202,7 +1187,6 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
status: "completed",
|
||||
error: null,
|
||||
});
|
||||
expect(archiveWorkspaceRecord).not.toHaveBeenCalled();
|
||||
expect(emitWorkspaceUpdateForWorkspaceId).toHaveBeenCalledWith("45");
|
||||
});
|
||||
|
||||
@@ -1233,7 +1217,6 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
const logger = createLogger();
|
||||
const terminalManager = createTerminalManagerStub();
|
||||
const emitWorkspaceUpdateForWorkspaceId = vi.fn(async () => {});
|
||||
const archiveWorkspaceRecord = vi.fn(async () => {});
|
||||
|
||||
await runWorktreeSetupInBackground(
|
||||
{
|
||||
@@ -1244,7 +1227,6 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
emit: (message) => emitted.push(message),
|
||||
sessionLogger: logger,
|
||||
terminalManager: terminalManager.manager,
|
||||
archiveWorkspaceRecord,
|
||||
},
|
||||
{
|
||||
requestCwd: repoDir,
|
||||
@@ -1265,7 +1247,6 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
status: "completed",
|
||||
error: null,
|
||||
});
|
||||
expect(archiveWorkspaceRecord).not.toHaveBeenCalled();
|
||||
expect(emitWorkspaceUpdateForWorkspaceId).toHaveBeenCalledWith("46");
|
||||
});
|
||||
|
||||
@@ -1427,7 +1408,6 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
emit: (message: SessionOutboundMessage) => emitted.push(message),
|
||||
sessionLogger: createLogger(),
|
||||
terminalManager: terminalManager.manager,
|
||||
archiveWorkspaceRecord: vi.fn(async () => {}),
|
||||
serviceProxy: null,
|
||||
scriptRuntimeStore: null,
|
||||
getDaemonTcpPort: null,
|
||||
|
||||
@@ -108,7 +108,6 @@ interface CreatePaseoWorktreeInBackgroundDependencies {
|
||||
emit: EmitSessionMessage;
|
||||
sessionLogger: Logger;
|
||||
terminalManager: TerminalManager | null;
|
||||
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
|
||||
serviceProxy: ServiceProxySubsystem | null;
|
||||
scriptRuntimeStore: WorkspaceScriptRuntimeStore | null;
|
||||
getDaemonTcpPort: (() => number | null) | null;
|
||||
@@ -825,7 +824,6 @@ export async function runWorktreeSetupInBackground(
|
||||
): Promise<void> {
|
||||
let worktree: WorktreeConfig = options.worktree;
|
||||
let setupResults: WorktreeSetupCommandResult[] = [];
|
||||
let setupStarted = false;
|
||||
const progressAccumulator = createWorktreeSetupProgressAccumulator();
|
||||
const workspaceId = options.workspaceId;
|
||||
|
||||
@@ -862,7 +860,6 @@ export async function runWorktreeSetupInBackground(
|
||||
const workspaceCwd = options.workspaceCwd ?? worktree.worktreePath;
|
||||
const setupCommands = getWorktreeSetupCommands(workspaceCwd);
|
||||
if (setupCommands.length === 0) {
|
||||
setupStarted = true;
|
||||
emitSetupProgress("completed", null);
|
||||
} else {
|
||||
const runtimeEnv = await resolveWorktreeRuntimeEnv({
|
||||
@@ -874,7 +871,6 @@ export async function runWorktreeSetupInBackground(
|
||||
cwd: workspaceCwd,
|
||||
env: runtimeEnv,
|
||||
});
|
||||
setupStarted = true;
|
||||
setupResults = await runWorktreeSetupCommands({
|
||||
worktreePath: workspaceCwd,
|
||||
branchName: worktree.branchName,
|
||||
@@ -906,10 +902,6 @@ export async function runWorktreeSetupInBackground(
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
emitSetupProgress("failed", message);
|
||||
|
||||
if (!setupStarted) {
|
||||
await dependencies.archiveWorkspaceRecord(options.workspaceId);
|
||||
}
|
||||
|
||||
dependencies.sessionLogger.error(
|
||||
{
|
||||
err: error,
|
||||
@@ -917,7 +909,6 @@ export async function runWorktreeSetupInBackground(
|
||||
repoRoot: options.repoRoot,
|
||||
worktreeSlug: worktree.branchName,
|
||||
worktreePath: worktree.worktreePath,
|
||||
setupStarted,
|
||||
},
|
||||
"Background worktree setup failed",
|
||||
);
|
||||
|
||||
@@ -40,8 +40,8 @@ function isIdlePrompt(raw: string | null): boolean {
|
||||
try {
|
||||
const notification = JSON.parse(raw) as unknown;
|
||||
if (!notification || typeof notification !== "object") return false;
|
||||
const payload = notification as { matcher?: unknown; reason?: unknown };
|
||||
return payload.matcher === "idle_prompt" || payload.reason === "idle_prompt";
|
||||
const payload = notification as { notification_type?: unknown };
|
||||
return payload.notification_type === "idle_prompt";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHook } from "node:async_hooks";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
mkdtempSync,
|
||||
@@ -1024,3 +1025,65 @@ describe("home-tree scan cost", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Every pending fs call holds one of libuv's shared threadpool threads (4 by default). A read that
|
||||
// never returns, such as one on a hung network mount, keeps its thread, so searches that pile up
|
||||
// while the user types must not be able to take every thread from the rest of the daemon.
|
||||
describe("filesystem threads held by concurrent searches", () => {
|
||||
const SEARCHES = 8;
|
||||
let scanRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
scanRoot = realpathSync.native(mkdtempSync(path.join(tmpdir(), "directory-scan-threads-")));
|
||||
for (let branch = 0; branch < 20; branch += 1) {
|
||||
mkdirSync(path.join(scanRoot, `b${branch}`, "inner", "leaf"), { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(scanRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function peakPendingFilesystemRequests(run: () => Promise<unknown>): Promise<number> {
|
||||
const pending = new Set<number>();
|
||||
let peak = 0;
|
||||
const hook = createHook({
|
||||
init(asyncId, type) {
|
||||
if (type !== "FSREQPROMISE" && type !== "FSREQCALLBACK") return;
|
||||
pending.add(asyncId);
|
||||
peak = Math.max(peak, pending.size);
|
||||
},
|
||||
before(asyncId) {
|
||||
pending.delete(asyncId);
|
||||
},
|
||||
});
|
||||
hook.enable();
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
hook.disable();
|
||||
}
|
||||
return peak;
|
||||
}
|
||||
|
||||
function searchWhileTyping() {
|
||||
const queries = Array.from({ length: SEARCHES }, (_, index) => `nomatch${index}`);
|
||||
return Promise.all(
|
||||
queries.map((query) => searchAbsoluteDirectoryPaths({ homeDir: scanRoot, query })),
|
||||
);
|
||||
}
|
||||
|
||||
it("leaves threadpool threads free for the rest of the daemon", async () => {
|
||||
const peak = await peakPendingFilesystemRequests(searchWhileTyping);
|
||||
|
||||
expect(peak).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
// Searches over one tree walk it in the same order, so searches stuck on one unreadable folder
|
||||
// must hold one thread between them and leave the other for unrelated searches.
|
||||
it("shares one filesystem request among searches waiting on the same path", async () => {
|
||||
const peak = await peakPendingFilesystemRequests(searchWhileTyping);
|
||||
|
||||
expect(peak).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Dirent, Stats } from "node:fs";
|
||||
import { readdir, realpath, stat } from "node:fs/promises";
|
||||
import * as fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import pLimit from "p-limit";
|
||||
import { scorePathMatch, type MatchScore } from "@getpaseo/protocol/search/text-match";
|
||||
import { isPathInsideRoot } from "./path.js";
|
||||
import { runGitCommand } from "./run-git-command.js";
|
||||
@@ -102,6 +103,37 @@ const NO_SEGMENT_INDEX = Number.MAX_SAFE_INTEGER;
|
||||
const NO_MATCH_OFFSET = Number.MAX_SAFE_INTEGER;
|
||||
const NO_FUZZY_SCORE = Number.MAX_SAFE_INTEGER;
|
||||
const NO_MATCH_TIER = 5;
|
||||
|
||||
// Each pending fs call holds one of libuv's shared threadpool threads (4 by default), and a read on
|
||||
// a hung mount or a protected folder never returns. Searches pile up while the user types, so they
|
||||
// share two threads and leave the rest of the pool to the daemon. Searches over one tree walk it
|
||||
// in the same order and stop at the same stuck folder, so they share its pending call and hold one
|
||||
// thread between them instead of both.
|
||||
const filesystemCalls = pLimit(2);
|
||||
const pendingFilesystemCalls = new Map<string, Promise<unknown>>();
|
||||
|
||||
function sharedFilesystemCall<T>(key: string, call: () => Promise<T>): Promise<T> {
|
||||
const pending = pendingFilesystemCalls.get(key);
|
||||
if (pending) return pending as Promise<T>;
|
||||
const started = filesystemCalls(call).finally(() => pendingFilesystemCalls.delete(key));
|
||||
pendingFilesystemCalls.set(key, started);
|
||||
return started;
|
||||
}
|
||||
|
||||
function readdir(directory: string): Promise<Dirent[]> {
|
||||
return sharedFilesystemCall(`readdir:${directory}`, () =>
|
||||
fs.readdir(directory, { withFileTypes: true }),
|
||||
);
|
||||
}
|
||||
|
||||
function realpath(target: string): Promise<string> {
|
||||
return sharedFilesystemCall(`realpath:${target}`, () => fs.realpath(target));
|
||||
}
|
||||
|
||||
function stat(target: string): Promise<Stats> {
|
||||
return sharedFilesystemCall(`stat:${target}`, () => fs.stat(target));
|
||||
}
|
||||
|
||||
export const WORKSPACE_SEARCH_HIDDEN_DIRECTORIES = [
|
||||
".agents",
|
||||
".claude",
|
||||
@@ -655,7 +687,7 @@ async function readChildren(directory: string): Promise<ChildEntry[]> {
|
||||
) {
|
||||
rawEntries = cached.entries;
|
||||
} else {
|
||||
const dirents = await readdir(directory, { withFileTypes: true }).catch(() => [] as Dirent[]);
|
||||
const dirents = await readdir(directory).catch(() => [] as Dirent[]);
|
||||
rawEntries = dirents
|
||||
.map(toRawChildEntry)
|
||||
.filter((entry): entry is RawChildEntry => entry !== null)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test, type Page } from "playwright/test";
|
||||
import { CATEGORIES } from "../src/plugins/categories";
|
||||
|
||||
async function openPlugins(page: Page) {
|
||||
// Wait for hydration so typing reaches React rather than the server-rendered input.
|
||||
@@ -27,7 +28,7 @@ test("browses from the directory into a category, a plugin, and its author", asy
|
||||
const themes = page.getByRole("region", { name: "Themes" });
|
||||
await expect(themes.getByRole("link", { name: /Dracula/ })).toBeVisible();
|
||||
|
||||
await browseCategory(page, "Git & code review");
|
||||
await browseCategory(page, "Git");
|
||||
await expect(page).toHaveURL(/category=/);
|
||||
await expect(page.getByRole("heading", { level: 2 })).toHaveCount(1);
|
||||
await expect(page.getByRole("region", { name: "Themes" })).toHaveCount(0);
|
||||
@@ -48,7 +49,7 @@ test("browses from the directory into a category, a plugin, and its author", asy
|
||||
|
||||
const breadcrumbs = page.getByRole("navigation", { name: "Breadcrumb" });
|
||||
await expect(breadcrumbs).toContainText("Plugins");
|
||||
await expect(breadcrumbs).toContainText("Git & code review");
|
||||
await expect(breadcrumbs).toContainText("Git");
|
||||
await expect(breadcrumbs).toContainText("Fresh Worktrees");
|
||||
|
||||
await openAuthor(page, "Omer Cohen");
|
||||
@@ -86,10 +87,7 @@ async function copyInstallCommand(page: Page) {
|
||||
}
|
||||
|
||||
async function browseCategory(page: Page, category: string) {
|
||||
await page
|
||||
.getByRole("region", { name: category })
|
||||
.getByRole("link", { name: /^View all/ })
|
||||
.click();
|
||||
await page.getByRole("link", { name: category, exact: true }).click();
|
||||
}
|
||||
async function openPlugin(page: Page, name: RegExp) {
|
||||
await page.getByRole("link", { name }).first().click();
|
||||
@@ -214,3 +212,28 @@ test("keeps the directory unlinked until the coordinated announcement", async ({
|
||||
expect(response?.status()).toBe(200);
|
||||
await expect(page.getByRole("heading", { level: 1, name: "Plugins" })).toBeVisible();
|
||||
});
|
||||
|
||||
// External deployments own their registry contents; these assertions use the local fixture.
|
||||
test.describe("registry category layout", () => {
|
||||
test.skip(Boolean(process.env.WEBSITE_TEST_URL), "Requires the local registry fixture");
|
||||
|
||||
test("orders populated categories and hides the empty Extras category", async ({ page }) => {
|
||||
await openPlugins(page);
|
||||
await expect(page.getByRole("heading", { level: 2 })).toHaveText([
|
||||
"Daemon management",
|
||||
"Themes",
|
||||
"Providers",
|
||||
"Orchestration",
|
||||
"Git",
|
||||
"Workspaces",
|
||||
"Sidebar",
|
||||
"Utils",
|
||||
]);
|
||||
await expect(page.getByRole("region", { name: "Extras", exact: true })).toHaveCount(0);
|
||||
for (const category of CATEGORIES.filter((entry) => entry.slug !== "extras")) {
|
||||
await expect(page.getByRole("region", { name: category.label, exact: true })).toContainText(
|
||||
category.description,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createServer } from "node:http";
|
||||
import registry from "./registry.fixture.json" with { type: "json" };
|
||||
|
||||
// Serve the published registry contract so SSR and browser navigation use the same data.
|
||||
createServer((request, response) => {
|
||||
response.setHeader("Content-Type", "application/json");
|
||||
if (request.url === "/index.json") {
|
||||
response.end(JSON.stringify(registry));
|
||||
return;
|
||||
}
|
||||
const plugin = registry.plugins.find((entry) => request.url === `/plugins/${entry.id}.json`);
|
||||
response.statusCode = plugin ? 200 : 404;
|
||||
response.end(JSON.stringify(plugin ?? { error: "Plugin not found" }));
|
||||
}).listen(8188, "127.0.0.1");
|
||||
@@ -0,0 +1,631 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"registry": {
|
||||
"name": "Paseo plugins",
|
||||
"url": "https://plugins.paseo.sh"
|
||||
},
|
||||
"generatedAt": "2026-10-04T15:55:47.882Z",
|
||||
"categories": [
|
||||
{
|
||||
"slug": "daemon-management",
|
||||
"label": "Daemon management",
|
||||
"description": "Manage hosts, daemon jobs, resources, approvals, and MCP servers."
|
||||
},
|
||||
{
|
||||
"slug": "themes",
|
||||
"label": "Themes",
|
||||
"description": "Change the app color scheme."
|
||||
},
|
||||
{
|
||||
"slug": "providers",
|
||||
"label": "Providers",
|
||||
"description": "Connect coding agents, ACP servers, and model routers, with account and quota fallback."
|
||||
},
|
||||
{
|
||||
"slug": "orchestration",
|
||||
"label": "Orchestration",
|
||||
"description": "Coordinate agents, delegate work, and send messages across hosts."
|
||||
},
|
||||
{
|
||||
"slug": "git",
|
||||
"label": "Git",
|
||||
"description": "Work with branches, worktrees, pull requests, stacks, diffs, and reviews."
|
||||
},
|
||||
{
|
||||
"slug": "workspaces",
|
||||
"label": "Workspaces",
|
||||
"description": "Add workspace panels and tabs for boards, browsers, documents, and terminals."
|
||||
},
|
||||
{
|
||||
"slug": "sidebar",
|
||||
"label": "Sidebar",
|
||||
"description": "Add sidebar items and panels for usage, notifications, pricing, and links."
|
||||
},
|
||||
{
|
||||
"slug": "extras",
|
||||
"label": "Extras",
|
||||
"description": "Add presence, pets, puzzles, wellbeing reminders, and demos."
|
||||
},
|
||||
{
|
||||
"slug": "utils",
|
||||
"label": "Utils",
|
||||
"description": "Extend the composer and timeline with commands, prompts, skills, and small helpers."
|
||||
}
|
||||
],
|
||||
"plugins": [
|
||||
{
|
||||
"id": "alhassanaraouf/base2tone",
|
||||
"name": "Base2Tone",
|
||||
"description": "Base2Tone theme for Paseo, ported from the Obsidian theme deathau/Base2Tone-For-Obsidian.md.",
|
||||
"artifact": {
|
||||
"kind": "npm",
|
||||
"package": "@alhassanaraouf/paseo-base2tone-theme",
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@alhassanaraouf/paseo-base2tone-theme/-/paseo-base2tone-theme-0.1.2.tgz",
|
||||
"integrity": "sha512-baP+qh3MlPgwACvt72XjrULjbDHARofzHfxyGGVVl22iw6qVnMzyfBJXi4exgtplvD/yPqf/XlsqD8wTqHB+lg=="
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"url": "https://github.com/alhassanaraouf/paseo-themes/tree/HEAD/packages/paseo-base2tone-theme"
|
||||
},
|
||||
"categories": ["themes"],
|
||||
"author": {
|
||||
"npm": "alhassanaraouf",
|
||||
"name": "alhassanaraouf",
|
||||
"github": "alhassanaraouf"
|
||||
},
|
||||
"screenshots": [
|
||||
"https://raw.githubusercontent.com/alhassanaraouf/paseo-themes/main/packages/paseo-base2tone-theme/preview.png"
|
||||
],
|
||||
"submittedAt": "2026-09-24T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-09-24T17:12:05.467Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# Base2Tone\n\n## Behavior\n\nBase2Tone theme for Paseo, ported from the Obsidian theme deathau/Base2Tone-For-Obsidian.md."
|
||||
},
|
||||
{
|
||||
"id": "dorasto/sayr",
|
||||
"name": "Sayr",
|
||||
"description": "A paseo plugin for viewing and managing your Sayr tasks",
|
||||
"artifact": {
|
||||
"kind": "npm",
|
||||
"package": "@sayrio/paseo-plugin",
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@sayrio/paseo-plugin/-/paseo-plugin-0.2.0.tgz",
|
||||
"integrity": "sha512-LPmVkEPWl8Km6US4zQAzhqSbGm7lBDwCjgNoysOhCsDiRy0jbtDKYRg1XVgwwUkoEwhF1EdZHCPwIuT8IizyWQ=="
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"url": "https://github.com/dorasto/sayr/tree/HEAD/packages/paseo-plugin",
|
||||
"commit": "e70919b89df3cf9e95c18242939193a79c7593b1"
|
||||
},
|
||||
"categories": ["workspaces"],
|
||||
"author": {
|
||||
"npm": "trent-001",
|
||||
"name": "Tom Lundy",
|
||||
"github": "dorasto"
|
||||
},
|
||||
"screenshots": [
|
||||
"https://raw.githubusercontent.com/dorasto/sayr/main/packages/paseo-plugin/assets/sayr-plguin-1.png",
|
||||
"https://raw.githubusercontent.com/dorasto/sayr/main/packages/paseo-plugin/assets/paseo-plugin-2.png"
|
||||
],
|
||||
"submittedAt": "2026-09-22T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-09-22T10:39:28.935Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# Sayr\n\n## Behavior\n\nA paseo plugin for viewing and managing your Sayr tasks"
|
||||
},
|
||||
{
|
||||
"id": "gpambrozio/github-board",
|
||||
"name": "GitHub Board",
|
||||
"description": "Paseo plugin: a GitHub sidebar board of your issues, draft and open pull requests, and discussions",
|
||||
"artifact": {
|
||||
"kind": "npm",
|
||||
"package": "@gpambrozio/paseo-github-board",
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@gpambrozio/paseo-github-board/-/paseo-github-board-0.10.0.tgz",
|
||||
"integrity": "sha512-G6LxSsbt4Kt36yWEB2rXUxOwNLFsA4DbF20ViroVqaicMPjaw5H4JUWXjzEZAxWJT+0DlH12/TtqN4I0sNoQtg=="
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"url": "https://github.com/gpambrozio/paseo-plugins/tree/HEAD/github-board",
|
||||
"commit": "530a66c592e20915169b5e67a35de8793f71ec60"
|
||||
},
|
||||
"categories": ["git"],
|
||||
"author": {
|
||||
"npm": "gpambrozio",
|
||||
"name": "gpambrozio",
|
||||
"github": "gpambrozio"
|
||||
},
|
||||
"screenshots": [
|
||||
"https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/github-board/docs/screenshot.png",
|
||||
"https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/github-board/docs/detail-panel.png"
|
||||
],
|
||||
"submittedAt": "2026-09-19T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-10-01T17:46:20.500Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# GitHub Board\n\n## Behavior\n\nPaseo plugin: a GitHub sidebar board of your issues, draft and open pull requests, and discussions"
|
||||
},
|
||||
{
|
||||
"id": "gpambrozio/herald",
|
||||
"name": "Herald",
|
||||
"description": "Paseo plugin: speaks one sentence when an agent needs you, and lists everything waiting in a sidebar panel",
|
||||
"artifact": {
|
||||
"kind": "npm",
|
||||
"package": "@gpambrozio/paseo-herald",
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@gpambrozio/paseo-herald/-/paseo-herald-0.5.1.tgz",
|
||||
"integrity": "sha512-rXdlaxgRlYP3nmZrvbG29sQeluz5JC7TPuJDgKzyh9itDH1AvZYGjLgGp9XZGKxdBBhaGxgGAFhDh+VTL8h9dw=="
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"url": "https://github.com/gpambrozio/paseo-plugins/tree/HEAD/herald",
|
||||
"commit": "b658d0ab58735959e46d12660ae179c002cdadda"
|
||||
},
|
||||
"categories": ["sidebar"],
|
||||
"author": {
|
||||
"npm": "gpambrozio",
|
||||
"name": "gpambrozio",
|
||||
"github": "gpambrozio"
|
||||
},
|
||||
"screenshots": [
|
||||
"https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/herald/docs/panel.png",
|
||||
"https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/herald/docs/timeline-card.png"
|
||||
],
|
||||
"submittedAt": "2026-09-19T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-09-27T19:03:31.943Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# Herald\n\n## Behavior\n\nPaseo plugin: speaks one sentence when an agent needs you, and lists everything waiting in a sidebar panel"
|
||||
},
|
||||
{
|
||||
"id": "gpambrozio/launchd-jobs",
|
||||
"name": "launchd Jobs",
|
||||
"description": "Paseo plugin: schedule shell commands on your Mac through launchd, from a sidebar panel",
|
||||
"artifact": {
|
||||
"kind": "npm",
|
||||
"package": "@gpambrozio/paseo-launchd-jobs",
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@gpambrozio/paseo-launchd-jobs/-/paseo-launchd-jobs-0.5.0.tgz",
|
||||
"integrity": "sha512-H6CQnalMdyQjW9ta9ZT40qEPJsREgNFSEAH8EC4sJXdz2Hz9tIsInSUmm2bDcgfY3D3HFffUrwf1mmeevi/wUA=="
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"url": "https://github.com/gpambrozio/paseo-plugins/tree/HEAD/launchd-jobs",
|
||||
"commit": "08e75003b287208ef804f3d31b2fda2597f69dce"
|
||||
},
|
||||
"categories": ["daemon-management"],
|
||||
"author": {
|
||||
"npm": "gpambrozio",
|
||||
"name": "gpambrozio",
|
||||
"github": "gpambrozio"
|
||||
},
|
||||
"screenshots": [
|
||||
"https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/launchd-jobs/docs/screenshot.png"
|
||||
],
|
||||
"submittedAt": "2026-09-19T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-09-22T17:56:41.061Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# launchd Jobs\n\n## Behavior\n\nPaseo plugin: schedule shell commands on your Mac through launchd, from a sidebar panel"
|
||||
},
|
||||
{
|
||||
"id": "gpambrozio/model-pricing",
|
||||
"name": "Model Pricing",
|
||||
"description": "Paseo plugin: a sidebar table of what every model costs, across Anthropic, OpenAI, Fireworks AI, Ollama Cloud and OpenRouter",
|
||||
"artifact": {
|
||||
"kind": "npm",
|
||||
"package": "@gpambrozio/paseo-model-pricing",
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@gpambrozio/paseo-model-pricing/-/paseo-model-pricing-0.3.0.tgz",
|
||||
"integrity": "sha512-jLfC6iCLOwNACiili8nw3/qmBymoj6YHitMeGCa2RggiMdUhT6n+INRsSjoTs2kNzeYMftMfQ3MZJXeLLF1opA=="
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"url": "https://github.com/gpambrozio/paseo-plugins/tree/HEAD/model-pricing",
|
||||
"commit": "08e75003b287208ef804f3d31b2fda2597f69dce"
|
||||
},
|
||||
"categories": ["sidebar"],
|
||||
"author": {
|
||||
"npm": "gpambrozio",
|
||||
"name": "gpambrozio",
|
||||
"github": "gpambrozio"
|
||||
},
|
||||
"screenshots": [
|
||||
"https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/model-pricing/docs/screenshot.png"
|
||||
],
|
||||
"submittedAt": "2026-09-19T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-09-22T18:03:14.979Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# Model Pricing\n\n## Behavior\n\nPaseo plugin: a sidebar table of what every model costs, across Anthropic, OpenAI, Fireworks AI, Ollama Cloud and OpenRouter"
|
||||
},
|
||||
{
|
||||
"id": "gpambrozio/skills",
|
||||
"name": "Skills",
|
||||
"description": "Paseo plugin: lists the skills an agent can use, shows where each comes from, renders its SKILL.md, and invokes it",
|
||||
"artifact": {
|
||||
"kind": "npm",
|
||||
"package": "@gpambrozio/paseo-skills",
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@gpambrozio/paseo-skills/-/paseo-skills-0.4.0.tgz",
|
||||
"integrity": "sha512-zMgP2U/vrRsLkKhoQICkN52+YoDy2WkkYymEDmruduKxf4Dq99Ct8Pk3xqJyAzijq2oprMFYLbj/wT6XepBLeA=="
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"url": "https://github.com/gpambrozio/paseo-plugins/tree/HEAD/skills",
|
||||
"commit": "d04187ee60ca3702ca5843803567600427102d99"
|
||||
},
|
||||
"categories": ["utils"],
|
||||
"author": {
|
||||
"npm": "gpambrozio",
|
||||
"name": "gpambrozio",
|
||||
"github": "gpambrozio"
|
||||
},
|
||||
"screenshots": [],
|
||||
"submittedAt": "2026-09-19T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-10-01T22:25:58.192Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# Skills\n\n## Behavior\n\nPaseo plugin: lists the skills an agent can use, shows where each comes from, renders its SKILL.md, and invokes it"
|
||||
},
|
||||
{
|
||||
"id": "hungcuong9125/prompt-kit",
|
||||
"name": "PromptKit",
|
||||
"description": "PromptKit - rewrite and transform prompts directly from the Paseo composer.",
|
||||
"artifact": {
|
||||
"kind": "npm",
|
||||
"package": "paseo-prompt-kit",
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/paseo-prompt-kit/-/paseo-prompt-kit-0.6.0.tgz",
|
||||
"integrity": "sha512-eiagOY1OokDx2iihfe3Vjh2dgKKP6IsBKbJYbfhM+TXw3aU7duV/rs/TUnBSeeWAk0cEsVg2Jd+EkeaPM8IJdA=="
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"url": "https://github.com/hungcuong9125/paseo-prompt-kit"
|
||||
},
|
||||
"categories": ["utils"],
|
||||
"author": {
|
||||
"npm": "hungcuong9125",
|
||||
"name": "hungcuong9125",
|
||||
"github": "hungcuong9125"
|
||||
},
|
||||
"screenshots": [
|
||||
"https://raw.githubusercontent.com/hungcuong9125/paseo-prompt-kit/main/docs/images/settings-provider-cli.png",
|
||||
"https://raw.githubusercontent.com/hungcuong9125/paseo-prompt-kit/main/docs/images/settings-direct-api.png"
|
||||
],
|
||||
"submittedAt": "2026-09-22T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-09-30T02:57:56.473Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# PromptKit\n\n## Behavior\n\nPromptKit - rewrite and transform prompts directly from the Paseo composer."
|
||||
},
|
||||
{
|
||||
"id": "omercnet/agent-monitor",
|
||||
"name": "Agent Monitor",
|
||||
"description": "A Paseo plugin that provides a host-wide agent triage roster.",
|
||||
"artifact": {
|
||||
"kind": "npm",
|
||||
"package": "@omercnet/paseo-agent-monitor",
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@omercnet/paseo-agent-monitor/-/paseo-agent-monitor-1.2.0.tgz",
|
||||
"integrity": "sha512-dY7/3f49sPMVwb+Wr7iUsw4jCoQWRX2UF9Li+uU8yJdN0cj4BvTsz4GDiR3OsKk8zJ1/o01DSRi1e4uDRS3TmA=="
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"url": "https://github.com/omercnet/paseo-plugins/tree/HEAD/agent-monitor",
|
||||
"commit": "d7b3e654f364b5be72edf6fd1d914a3750b53082"
|
||||
},
|
||||
"categories": ["orchestration"],
|
||||
"author": {
|
||||
"npm": "omercnet",
|
||||
"name": "Omer Cohen",
|
||||
"github": "omercnet"
|
||||
},
|
||||
"screenshots": [
|
||||
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/agent-monitor/docs/images/agent-monitor-roster.png",
|
||||
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/agent-monitor/docs/images/agent-monitor-settings.png"
|
||||
],
|
||||
"submittedAt": "2026-09-17T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-10-03T09:36:35.639Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# Agent Monitor\n\n## Behavior\n\nA Paseo plugin that provides a host-wide agent triage roster."
|
||||
},
|
||||
{
|
||||
"id": "omercnet/beads",
|
||||
"name": "Beads",
|
||||
"description": "A dependency-aware Beads work queue for each Paseo workspace.",
|
||||
"artifact": {
|
||||
"kind": "npm",
|
||||
"package": "@omercnet/paseo-beads",
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@omercnet/paseo-beads/-/paseo-beads-1.2.0.tgz",
|
||||
"integrity": "sha512-UkI1SNP73RUrW0ap4qOx2Jv1GCxkrw4HIMrWOFCS3FDoPg2jw06DqHC8Jf34psaFglxXKWLAOkfL2wEZJhlUFw=="
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"url": "https://github.com/omercnet/paseo-plugins/tree/HEAD/paseo-beads",
|
||||
"commit": "d7b3e654f364b5be72edf6fd1d914a3750b53082"
|
||||
},
|
||||
"categories": ["workspaces"],
|
||||
"author": {
|
||||
"npm": "omercnet",
|
||||
"name": "Omer Cohen",
|
||||
"github": "omercnet"
|
||||
},
|
||||
"screenshots": [
|
||||
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-beads/docs/images/paseo-beads-wide.png",
|
||||
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-beads/docs/images/paseo-beads-compact.png"
|
||||
],
|
||||
"submittedAt": "2026-09-17T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-10-03T09:37:29.149Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# Beads\n\n## Behavior\n\nA dependency-aware Beads work queue for each Paseo workspace."
|
||||
},
|
||||
{
|
||||
"id": "omercnet/dracula",
|
||||
"name": "Dracula",
|
||||
"description": "Dracula Classic and Alucard Classic themes for Paseo.",
|
||||
"artifact": {
|
||||
"kind": "npm",
|
||||
"package": "@omercnet/paseo-dracula",
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@omercnet/paseo-dracula/-/paseo-dracula-1.2.0.tgz",
|
||||
"integrity": "sha512-I3yUiFIvaIe3jdvuQ1tZyngsfgukaUJwEQza2XYkEaB8yIA0pplZehqJLvqqmA403cjNXdVtsdW7umPtmnycqw=="
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"url": "https://github.com/omercnet/paseo-plugins/tree/HEAD/paseo-dracula",
|
||||
"commit": "d7b3e654f364b5be72edf6fd1d914a3750b53082"
|
||||
},
|
||||
"categories": ["themes"],
|
||||
"author": {
|
||||
"npm": "omercnet",
|
||||
"name": "Omer Cohen",
|
||||
"github": "omercnet"
|
||||
},
|
||||
"screenshots": [
|
||||
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-dracula/screenshot.png",
|
||||
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-dracula/screenshot-alucard.png"
|
||||
],
|
||||
"submittedAt": "2026-09-17T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-10-03T09:37:18.185Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# Dracula\n\n## Behavior\n\nDracula Classic and Alucard Classic themes for Paseo."
|
||||
},
|
||||
{
|
||||
"id": "omercnet/fresh-worktrees",
|
||||
"name": "Fresh Worktrees",
|
||||
"description": "Fast-forwards clean local base branches before Paseo creates branch-off worktrees",
|
||||
"artifact": {
|
||||
"kind": "npm",
|
||||
"package": "@omercnet/paseo-fresh-worktrees",
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@omercnet/paseo-fresh-worktrees/-/paseo-fresh-worktrees-2.2.0.tgz",
|
||||
"integrity": "sha512-WRarzdfTbrZ36JfzmvX9HfnIYh6Pp+GV9TkoeVwM7I6f1MAEZ4gzPEmT51lWuUyMGvwTs2HFB7LthCa+VYs7IA=="
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"url": "https://github.com/omercnet/paseo-plugins/tree/HEAD/fresh-worktrees",
|
||||
"commit": "d7b3e654f364b5be72edf6fd1d914a3750b53082"
|
||||
},
|
||||
"categories": ["git"],
|
||||
"author": {
|
||||
"npm": "omercnet",
|
||||
"name": "Omer Cohen",
|
||||
"github": "omercnet"
|
||||
},
|
||||
"screenshots": [
|
||||
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/fresh-worktrees/docs/images/fresh-worktrees-behind.png",
|
||||
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/fresh-worktrees/docs/images/fresh-worktrees-settings.png"
|
||||
],
|
||||
"submittedAt": "2026-09-18T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-10-03T09:36:06.720Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# Fresh Worktrees\n\n## Behavior\n\nFast-forwards clean local base branches before Paseo creates branch-off worktrees"
|
||||
},
|
||||
{
|
||||
"id": "omercnet/omp",
|
||||
"name": "OMP",
|
||||
"description": "Paseo integration for OMP, including its direct provider and workspace tooling.",
|
||||
"artifact": {
|
||||
"kind": "npm",
|
||||
"package": "@omercnet/paseo-omp",
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@omercnet/paseo-omp/-/paseo-omp-1.2.0.tgz",
|
||||
"integrity": "sha512-ueZede5D34hoNrdF4y6DBaomErA+hHcv+bcz0Sd6eArQKqF8L6ecKXdBe2tEZd3Ev8uQwxMHuiDQhMBx3q6gOw=="
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"url": "https://github.com/omercnet/paseo-plugins/tree/HEAD/paseo-omp",
|
||||
"commit": "d7b3e654f364b5be72edf6fd1d914a3750b53082"
|
||||
},
|
||||
"categories": ["providers"],
|
||||
"author": {
|
||||
"npm": "omercnet",
|
||||
"name": "Omer Cohen",
|
||||
"github": "omercnet"
|
||||
},
|
||||
"screenshots": [
|
||||
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-omp/docs/images/plugin-manager.png",
|
||||
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-omp/docs/images/workspace-settings.png"
|
||||
],
|
||||
"submittedAt": "2026-09-17T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-10-03T09:36:36.267Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# OMP\n\n## Behavior\n\nPaseo integration for OMP, including its direct provider and workspace tooling."
|
||||
},
|
||||
{
|
||||
"id": "omercnet/pr-radar",
|
||||
"name": "PR Radar",
|
||||
"description": "A viewer-aware delivery queue for pull requests linked to Paseo workspaces.",
|
||||
"artifact": {
|
||||
"kind": "npm",
|
||||
"package": "@omercnet/paseo-pr-radar",
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@omercnet/paseo-pr-radar/-/paseo-pr-radar-1.2.0.tgz",
|
||||
"integrity": "sha512-sk+zuzTGZ5Vmri2toic3z3uChm4d6kEShSr/UjkqfOTeILrsZVDHUc+XKX/CtZIQazT/vWvbgZ115+KUiAonCw=="
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"url": "https://github.com/omercnet/paseo-plugins/tree/HEAD/pr-radar",
|
||||
"commit": "d7b3e654f364b5be72edf6fd1d914a3750b53082"
|
||||
},
|
||||
"categories": ["git"],
|
||||
"author": {
|
||||
"npm": "omercnet",
|
||||
"name": "Omer Cohen",
|
||||
"github": "omercnet"
|
||||
},
|
||||
"screenshots": [],
|
||||
"submittedAt": "2026-09-17T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-10-03T09:37:04.327Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# PR Radar\n\n## Behavior\n\nA viewer-aware delivery queue for pull requests linked to Paseo workspaces."
|
||||
},
|
||||
{
|
||||
"id": "omercnet/shared-browser",
|
||||
"name": "Shared Browser",
|
||||
"description": "One real Chromium session per Paseo workspace, shared live across every connected client.",
|
||||
"artifact": {
|
||||
"kind": "npm",
|
||||
"package": "@omercnet/paseo-shared-browser",
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@omercnet/paseo-shared-browser/-/paseo-shared-browser-1.2.0.tgz",
|
||||
"integrity": "sha512-ddvtkBSzHLFzyTRZFixO1y+QXOKW0of8Uo3PSkETRhW5xhd9ep0jqsJJF9JSfWLt1ouvVa5K8zFXic/EBzQ8lw=="
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"url": "https://github.com/omercnet/paseo-plugins/tree/HEAD/paseo-shared-browser",
|
||||
"commit": "d7b3e654f364b5be72edf6fd1d914a3750b53082"
|
||||
},
|
||||
"categories": ["workspaces"],
|
||||
"author": {
|
||||
"npm": "omercnet",
|
||||
"name": "Omer Cohen",
|
||||
"github": "omercnet"
|
||||
},
|
||||
"screenshots": [
|
||||
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-shared-browser/docs/images/shared-browser-wide.png",
|
||||
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-shared-browser/docs/images/shared-browser-compact.png"
|
||||
],
|
||||
"submittedAt": "2026-09-18T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-10-03T09:38:48.289Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# Shared Browser\n\n## Behavior\n\nOne real Chromium session per Paseo workspace, shared live across every connected client."
|
||||
},
|
||||
{
|
||||
"id": "omercnet/tell-agent",
|
||||
"name": "Tell Agent",
|
||||
"description": "Send messages to agents in other workspaces on the same Paseo host.",
|
||||
"artifact": {
|
||||
"kind": "npm",
|
||||
"package": "@omercnet/paseo-tell-agent",
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@omercnet/paseo-tell-agent/-/paseo-tell-agent-1.2.0.tgz",
|
||||
"integrity": "sha512-4XqL1RK1x3TQLoRRKTkZfDxytJbUCEnu2em5pbPDpsGQ5Gv6VN1h07mBci9iW8s2FbNINohNcIBdUaAMTXdOHA=="
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"url": "https://github.com/omercnet/paseo-plugins/tree/HEAD/tell-agent",
|
||||
"commit": "d7b3e654f364b5be72edf6fd1d914a3750b53082"
|
||||
},
|
||||
"categories": ["orchestration"],
|
||||
"author": {
|
||||
"npm": "omercnet",
|
||||
"name": "Omer Cohen",
|
||||
"github": "omercnet"
|
||||
},
|
||||
"screenshots": [],
|
||||
"submittedAt": "2026-09-17T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-10-03T09:36:15.719Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# Tell Agent\n\n## Behavior\n\nSend messages to agents in other workspaces on the same Paseo host."
|
||||
},
|
||||
{
|
||||
"id": "tomgrin10/defer",
|
||||
"name": "Defer",
|
||||
"description": "Defer messages to Paseo coding agents until a chosen time or usage-window reset.",
|
||||
"artifact": {
|
||||
"kind": "npm",
|
||||
"package": "paseo-defer",
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/paseo-defer/-/paseo-defer-2.1.9.tgz",
|
||||
"integrity": "sha512-xBBf4TUC8zkTh9WMzQRRDt0alRAYPRCmUdBnBI1ZEkatUFC4i9MxFHgaTY939xij99dASM/261IWqctcKsQppQ=="
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"url": "https://github.com/tomgrin10/paseo-defer"
|
||||
},
|
||||
"categories": ["utils"],
|
||||
"author": {
|
||||
"npm": "tomgrin10",
|
||||
"name": "Tom Gringauz",
|
||||
"github": "tomgrin10"
|
||||
},
|
||||
"screenshots": [
|
||||
"https://raw.githubusercontent.com/tomgrin10/paseo-defer/main/docs/defer-popover.png"
|
||||
],
|
||||
"submittedAt": "2026-09-22T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-09-27T09:59:16.553Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# Defer\n\n## Behavior\n\nDefer messages to Paseo coding agents until a chosen time or usage-window reset."
|
||||
},
|
||||
{
|
||||
"id": "tomgrin10/graphite",
|
||||
"name": "Graphite",
|
||||
"description": "Workspace-native Graphite stack status and PR action radar for Paseo.",
|
||||
"artifact": {
|
||||
"kind": "npm",
|
||||
"package": "paseo-graphite",
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmjs.org/paseo-graphite/-/paseo-graphite-0.2.4.tgz",
|
||||
"integrity": "sha512-aAFvh+118c5mmSQ3sTsDKO8Is7g1ZU8m2+0fisr8nFrgPMu0m2mokPJnM/LaIqQc3Zaaak1chB6M6k0dWOPeIA=="
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"url": "https://github.com/tomgrin10/paseo-graphite"
|
||||
},
|
||||
"categories": ["git"],
|
||||
"author": {
|
||||
"npm": "tomgrin10",
|
||||
"name": "Tom Gringauz",
|
||||
"github": "tomgrin10"
|
||||
},
|
||||
"screenshots": [],
|
||||
"submittedAt": "2026-09-22T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-09-23T10:36:50.112Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# Graphite\n\n## Behavior\n\nWorkspace-native Graphite stack status and PR action radar for Paseo."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -12,9 +12,18 @@ export default defineConfig({
|
||||
],
|
||||
webServer: process.env.WEBSITE_TEST_URL
|
||||
? undefined
|
||||
: {
|
||||
command: "npm run dev -- --host 127.0.0.1 --port 8187 --strictPort",
|
||||
url: baseURL,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
: [
|
||||
{
|
||||
command: "node e2e/registry-server.mjs",
|
||||
url: "http://127.0.0.1:8188/index.json",
|
||||
},
|
||||
{
|
||||
command: "npm run build && npm run preview -- --host 127.0.0.1 --port 8187 --strictPort",
|
||||
url: baseURL,
|
||||
env: {
|
||||
CLOUDFLARE_INCLUDE_PROCESS_ENV: "true",
|
||||
PLUGINS_REGISTRY_URL: "http://127.0.0.1:8188",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1,47 +1,51 @@
|
||||
export const CATEGORIES = [
|
||||
{ slug: "themes", label: "Themes", description: "Change how Paseo looks." },
|
||||
{
|
||||
slug: "agents-and-providers",
|
||||
label: "Agents & providers",
|
||||
description: "Run more coding agents through Paseo.",
|
||||
slug: "daemon-management",
|
||||
label: "Daemon management",
|
||||
description: "Manage hosts, daemon jobs, resources, approvals, and MCP servers.",
|
||||
},
|
||||
{
|
||||
slug: "monitoring",
|
||||
label: "Monitoring & orchestration",
|
||||
description: "See every agent at once and steer work across workspaces.",
|
||||
slug: "themes",
|
||||
label: "Themes",
|
||||
description: "Change the app color scheme.",
|
||||
},
|
||||
{
|
||||
slug: "timeline-and-composer",
|
||||
label: "Timeline & composer",
|
||||
description: "Change what a conversation shows and how you write to agents.",
|
||||
slug: "providers",
|
||||
label: "Providers",
|
||||
description:
|
||||
"Connect coding agents, ACP servers, and model routers, with account and quota fallback.",
|
||||
},
|
||||
{
|
||||
slug: "workspace-panels",
|
||||
label: "Workspace panels",
|
||||
description: "Tools that open as tabs next to your agents.",
|
||||
slug: "orchestration",
|
||||
label: "Orchestration",
|
||||
description: "Coordinate agents, delegate work, and send messages across hosts.",
|
||||
},
|
||||
{
|
||||
slug: "git-and-code-review",
|
||||
label: "Git & code review",
|
||||
description: "Branches, worktrees, and pull requests.",
|
||||
slug: "git",
|
||||
label: "Git",
|
||||
description: "Work with branches, worktrees, pull requests, stacks, diffs, and reviews.",
|
||||
},
|
||||
{
|
||||
slug: "automation",
|
||||
label: "Automation & scheduling",
|
||||
description: "Run things later, on a schedule, or when an agent goes idle.",
|
||||
},
|
||||
{ slug: "notifications", label: "Notifications", description: "Know when an agent needs you." },
|
||||
{
|
||||
slug: "usage-and-pricing",
|
||||
label: "Usage & pricing",
|
||||
description: "What models cost and how much you have used.",
|
||||
slug: "workspaces",
|
||||
label: "Workspaces",
|
||||
description: "Add workspace panels and tabs for boards, browsers, documents, and terminals.",
|
||||
},
|
||||
{
|
||||
slug: "integrations",
|
||||
label: "Integrations",
|
||||
description: "Issues, tasks, and tools from other services inside Paseo.",
|
||||
slug: "sidebar",
|
||||
label: "Sidebar",
|
||||
description: "Add sidebar items and panels for usage, notifications, pricing, and links.",
|
||||
},
|
||||
{
|
||||
slug: "extras",
|
||||
label: "Extras",
|
||||
description: "Add presence, pets, puzzles, wellbeing reminders, and demos.",
|
||||
},
|
||||
{
|
||||
slug: "utils",
|
||||
label: "Utils",
|
||||
description:
|
||||
"Extend the composer and timeline with commands, prompts, skills, and small helpers.",
|
||||
},
|
||||
{ slug: "utilities", label: "Utilities", description: "Small helpers for everyday work." },
|
||||
] as const;
|
||||
|
||||
export type CategorySlug = (typeof CATEGORIES)[number]["slug"];
|
||||
|
||||
@@ -63,7 +63,7 @@ describe("plugin registry", () => {
|
||||
"acme/example",
|
||||
]);
|
||||
expect(queryPlugins([plugin, popular], { q: "acme/example", sort: "new" })).toEqual([plugin]);
|
||||
expect(queryPlugins([plugin], { category: "utilities", sort: "popular" })).toEqual([]);
|
||||
expect(queryPlugins([plugin], { category: "utils", sort: "popular" })).toEqual([]);
|
||||
});
|
||||
it("offers a registry command and an exact explicit command", () => {
|
||||
expect(installCommand(plugin)).toBe("paseo plugin install acme/example");
|
||||
|
||||
Reference in New Issue
Block a user