Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 21a25c39aa test(codex): assert the steer rewind is refused 2026-10-04 02:16:02 +02:00
paseo-bot[bot] c644da16d1 test(codex): move the held-response fixture into a helper 2026-10-04 02:15:45 +02:00
paseo-bot[bot]and3ae3ae dbf41b059e fix(codex): rewind whole turns so a steer never drops earlier prompts
The turn-boundary rule now lives in the Codex rewind helper. It refuses a
steer message on both history paths and counts turns rather than user
messages for thread/rollback, so a steer in a later turn no longer rolls
back one turn too many on Codex versions that still roll back.

Co-authored-by: 3ae3ae <ogyshjsm@naver.com>
2026-10-04 02:11:43 +02:00
paseo-bot[bot] b4bf345a30 Merge main into fix/codex-steer-rewind 2026-10-04 02:09:45 +02:00
Bae Seongmin fe9b3d693e Merge branch 'main' into fix/codex-steer-rewind 2026-09-29 23:25:59 +09:00
3ae3ae 7624140141 Merge main into fix/codex-steer-rewind 2026-09-29 16:01:29 +09:00
3ae3ae 59009a2c2a fix(codex): reject rewinds inside a steered turn 2026-09-29 13:41:45 +09:00
55 changed files with 491 additions and 1892 deletions
+2 -4
View File
@@ -32,10 +32,8 @@ 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. 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
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
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).
-20
View File
@@ -65,26 +65,6 @@ 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:
+1 -1
View File
@@ -51,7 +51,7 @@ Claude hook mapping:
- `UserPromptSubmit` → `running`
- `Stop`, `StopFailure`, `SessionEnd` → `idle`
- `Notification` with `notification_type` equal to `idle_prompt` → `needs-input`
- `Notification` with `reason` or `matcher` 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,67 +96,6 @@ 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,18 +155,6 @@ 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();
@@ -177,23 +165,3 @@ 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,41 +203,6 @@ 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-");
@@ -1,4 +0,0 @@
appId: sh.paseo.debug
---
- launchApp
- runFlow: ../../../maestro/sidebar-scroll-retention.yaml
@@ -1,53 +0,0 @@
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,
token: resolution.token,
ambiguousQuery: resolution.ambiguousQuery,
})
: DISABLED_QUERY_KEY,
[resolution, serverId, workspaceRoot],
@@ -179,7 +179,7 @@ function openAssistantFileLink(input: {
const capturedQueryKey = assistantFileLinkQueryKey({
serverId: capturedConfig.serverId,
workspaceRoot: capturedConfig.workspaceRoot,
token: capturedResolution.token,
ambiguousQuery: capturedResolution.ambiguousQuery,
});
const run = async () => {
@@ -245,9 +245,14 @@ function useStableSource(source: AssistantFileLinkSource): AssistantFileLinkSour
function assistantFileLinkQueryKey(input: {
serverId?: string;
workspaceRoot?: string;
token: string;
ambiguousQuery: string;
}): AssistantFileLinkQueryKey {
return ["assistantFileLink", input.serverId ?? null, input.workspaceRoot ?? null, input.token];
return [
"assistantFileLink",
input.serverId ?? null,
input.workspaceRoot ?? null,
input.ambiguousQuery,
];
}
async function dispatchResolvedLink(input: {
+10 -14
View File
@@ -26,7 +26,6 @@ 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";
@@ -276,7 +275,7 @@ function FooterIconButton({
testID,
label,
icon: Icon,
iconSizeAdjustment = 0,
iconSize,
shortcutKeys,
theme,
}: {
@@ -285,20 +284,17 @@ function FooterIconButton({
label: string;
icon: typeof FolderPlus;
/** Only for a glyph that reads larger than the others at the same size. */
iconSizeAdjustment?: number;
iconSize?: 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(isCompact)}
style={styles.footerIconButton}
testID={testID}
nativeID={testID}
collapsable={false}
@@ -309,7 +305,7 @@ function FooterIconButton({
>
{({ hovered }) => (
<Icon
size={iconSize + iconSizeAdjustment}
size={iconSize ?? theme.iconSize.md}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
@@ -371,7 +367,7 @@ function SidebarHostPicker({
label={label}
icon={Server}
// Server's two boxes fill more of the square than the other glyphs.
iconSizeAdjustment={-1}
iconSize={theme.iconSize.md - 1}
theme={theme}
/>
</HostPicker>
@@ -927,7 +923,7 @@ const styles = StyleSheet.create((theme) => ({
borderTopWidth: 1,
borderTopColor: theme.colors.border,
},
// Buttons sit edge to edge; their own inset spaces the glyphs.
// The icons' 28pt buttons sit edge to edge; their own inset spaces the glyphs.
sidebarFooter: {
flexDirection: "row",
alignItems: "center",
@@ -944,14 +940,14 @@ const styles = StyleSheet.create((theme) => ({
paddingVertical: theme.spacing[1.5],
gap: 2,
},
footerIconButton: (isCompact: boolean) => ({
width: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
height: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
footerIconButton: {
width: 28,
height: 28,
alignItems: "center",
justifyContent: "center",
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
}),
},
tooltipRow: {
flexDirection: "row",
alignItems: "center",
@@ -14,8 +14,6 @@ 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";
@@ -79,7 +77,6 @@ function HostVersionHint({ host }: { host: HostProfile }) {
}
export function SidebarHelpMenu() {
const isCompact = useIsCompactFormFactor();
const { t } = useTranslation();
const shortcutsAvailable = useKeyboardShortcutsAvailable();
const openAppDiagnostic = useAppDiagnosticStore((state) => state.open);
@@ -106,14 +103,14 @@ export function SidebarHelpMenu() {
<TooltipTrigger asChild>
<View>
<DropdownMenuTrigger
style={styles.trigger(isCompact)}
style={styles.trigger}
testID="sidebar-help"
accessibilityRole="button"
accessibilityLabel={t("sidebar.help.trigger")}
>
{({ hovered }) => (
<ThemedCircleHelp
size={isCompact ? ICON_SIZE.xl : ICON_SIZE.md}
size={ICON_SIZE.md}
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
/>
)}
@@ -184,14 +181,14 @@ export function SidebarHelpMenu() {
}
const styles = StyleSheet.create((theme) => ({
trigger: (isCompact: boolean) => ({
width: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
height: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
trigger: {
width: 28,
height: 28,
alignItems: "center",
justifyContent: "center",
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
}),
},
tooltipText: {
fontSize: theme.fontSize.base,
color: theme.colors.popoverForeground,
@@ -1,98 +0,0 @@
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,15 +42,7 @@ export function MobilePanelOverlay({
const overlayAnimatedStyle = useAnimatedStyle(() => {
const isVisible = isLeft ? position.value < 0 : position.value > 0;
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),
};
return { display: isVisible ? ("flex" as const) : ("none" as const) };
}, [isLeft]);
const positionedPanelStyle = isLeft ? styles.leftPanel : styles.rightPanel;
@@ -87,7 +79,10 @@ export function MobilePanelOverlay({
pointerEvents={overlayPointerEvents}
style={styles.overlay}
>
<Animated.View style={[styles.overlay, overlayAnimatedStyle]}>
<Animated.View
pointerEvents={overlayPointerEvents}
style={[styles.overlay, overlayAnimatedStyle]}
>
<Pressable
accessible={false}
accessibilityElementsHidden
-1
View File
@@ -595,7 +595,6 @@ export const ICON_SIZE = {
sm: 14,
md: 16,
lg: 20,
xl: 24,
} as const;
export const ICON_STROKE_WIDTH = 1.5;
+3 -63
View File
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" };
@@ -7,45 +7,18 @@ import { runDeleteCommand } from "./delete.js";
const agent = {
id: "11111111-1111-4111-8111-111111111111",
status: "running",
archivedAt: null as string | null,
archivedAt: 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 () => historyPage(undefined)),
fetchAgentHistory,
fetchAgents: vi.fn(async () => ({ entries: [{ agent }] })),
fetchAgent: vi.fn(async () => ({ agent })),
cancelAgent,
deleteAgent,
@@ -55,16 +28,9 @@ 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({
@@ -72,30 +38,4 @@ 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],
});
});
});
+11 -23
View File
@@ -1,7 +1,6 @@
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
@@ -34,20 +33,6 @@ 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,
@@ -65,16 +50,19 @@ 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 || 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 (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 (!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("No agent found matching: missing-agent");
expect(stream.stderr).toContain("Agent not found: 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(`No agent found matching: ${agentId}`);
expect(absent.stderr).toContain("Agent not found");
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("No agent found matching");
expect(errors).not.toContain("Agent not found");
} finally {
if (follower && follower.exitCode === null) {
const exited = new Promise((resolve) => follower!.once("exit", resolve));
+3 -12
View File
@@ -17,15 +17,6 @@ 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,
@@ -132,7 +123,7 @@ describe("runHooksCommand", () => {
const send = await runHook(
claudeProvider.id,
claudeProvider.events[4].event,
inputFrom(claudeNotification("idle_prompt")),
inputFrom('{"reason":"idle_prompt"}'),
);
expectPostedState(send, "needs-input");
@@ -140,11 +131,11 @@ describe("runHooksCommand", () => {
it.each(["permission_prompt", "elicitation_prompt", "elicitation_response", "auth_success"])(
"ignores Claude %s notifications",
async (notificationType) => {
async (reason) => {
const send = await runHook(
claudeProvider.id,
claudeProvider.events[4].event,
inputFrom(claudeNotification(notificationType)),
inputFrom(JSON.stringify({ reason })),
);
expect(send.calls).toEqual([]);
-23
View File
@@ -19,7 +19,6 @@
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";
@@ -130,28 +129,6 @@ 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,7 +13,6 @@
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";
@@ -113,28 +112,6 @@ 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 });
}
+38 -90
View File
@@ -1,11 +1,4 @@
import {
defineLanguageFacet,
Language,
languageDataProp,
StreamLanguage,
sublanguageProp,
type Sublanguage,
} from "@codemirror/language";
import { defineLanguageFacet, Language, StreamLanguage } 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";
@@ -16,113 +9,68 @@ 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, type MarkdownParser } from "@lezer/markdown";
import { parser as 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, SyntaxNode } from "@lezer/common";
import type { LRParser } from "@lezer/lr";
import type { Parser } from "@lezer/common";
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";
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,
),
],
}),
);
function language(parser: Parser): Language {
return new Language(defineLanguageFacet(), parser);
}
const languagesByExtension: Record<string, Language> = {
// JavaScript/TypeScript
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),
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),
// C / C++ / Objective-C
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),
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),
// JSON
json: language(jsonParser),
// CSS
css: language(cssParser, cssComments),
scss: language(cssParser, cStyleComments),
css: language(cssParser),
scss: language(cssParser),
// HTML
html: language(htmlParser, markupComments),
htm: language(htmlParser, markupComments),
html: language(htmlParser),
htm: language(htmlParser),
// Svelte
svelte: language(
svelteBaseParser.configure({ wrap: configureNesting(defaultNesting) }),
markupComments,
),
svelte: language(svelteBaseParser.configure({ wrap: configureNesting(defaultNesting) })),
// Astro
astro: new Language(defineLanguageFacet(), astroParser),
astro: language(astroParser),
// XML
xml: language(xmlParser, markupComments),
xml: language(xmlParser),
// Java
java: language(javaParser, cStyleComments),
java: language(javaParser),
// Python
py: language(pythonParser, hashComments),
py: language(pythonParser),
// Go
go: language(goParser, cStyleComments),
go: language(goParser),
// PHP
php: language(phpParser, cStyleComments),
php: language(phpParser),
// YAML
yaml: language(yamlParser, hashComments),
yml: language(yamlParser, hashComments),
yaml: language(yamlParser),
yml: language(yamlParser),
// Rust
rs: language(rustParser, cStyleComments),
rs: language(rustParser),
// Swift
swift: StreamLanguage.define(swift),
// Dart
@@ -132,11 +80,11 @@ const languagesByExtension: Record<string, Language> = {
// Nix
nix: nixLanguage,
// Elixir
ex: language(elixirParser, hashComments),
exs: language(elixirParser, hashComments),
ex: language(elixirParser),
exs: language(elixirParser),
// Markdown
md: language(markdownParser, markupComments),
mdx: language(markdownParser, jsxChildComments),
md: language(markdownParser),
mdx: language(markdownParser),
};
export function getLanguageForFile(filename: string): Language | null {
@@ -5034,7 +5034,6 @@ 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> {
@@ -5047,7 +5046,6 @@ 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",
});
@@ -5102,7 +5100,6 @@ 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" },
@@ -5114,10 +5111,6 @@ 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 () => {
@@ -9294,7 +9287,6 @@ 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;
@@ -9304,19 +9296,14 @@ test("respondToPermission updates currentModeId after plan approval", async () =
return new PlanModeTestSession();
}
async resumeSession(
_handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
): Promise<AgentSession> {
this.resumedModeId = overrides?.modeId;
async resumeSession(): Promise<AgentSession> {
return new PlanModeTestSession();
}
}
const client = new PlanModeTestClient();
const manager = new AgentManager({
clients: {
codex: client,
codex: new PlanModeTestClient(),
},
registry: storage,
logger,
@@ -9360,13 +9347,6 @@ 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,13 +190,10 @@ 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;
@@ -3917,19 +3914,6 @@ 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 },
@@ -3942,7 +3926,7 @@ export class AgentManager {
}
try {
this.applyObservedMode(agent, await agent.session.getCurrentMode());
agent.currentModeId = await agent.session.getCurrentMode();
} catch {
agent.currentModeId = null;
}
@@ -4322,7 +4306,7 @@ export class AgentManager {
this.emitState(agent);
return undefined;
case "mode_changed":
this.applyObservedMode(agent, event.currentModeId);
agent.currentModeId = event.currentModeId;
agent.availableModes = event.availableModes;
if (agent.runtimeInfo) {
agent.runtimeInfo = { ...agent.runtimeInfo, modeId: event.currentModeId };
@@ -4338,7 +4322,7 @@ export class AgentManager {
agent.cwd,
);
}
this.applyObservedMode(agent, event.runtimeInfo.modeId ?? agent.currentModeId);
agent.currentModeId = event.runtimeInfo.modeId ?? agent.currentModeId;
flags.shouldDispatchEvent = false;
this.emitState(agent);
return undefined;
@@ -806,6 +806,7 @@ function createPaseoWorktreeForMcpTest(options: {
emit: () => {},
sessionLogger: createTestLogger(),
terminalManager: null,
archiveWorkspaceRecord: async () => {},
serviceProxy: null,
scriptRuntimeStore: null,
getDaemonTcpPort: null,
@@ -825,46 +825,6 @@ 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,29 +3178,8 @@ 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 {
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,
});
void update;
}
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", display: "summarized" },
thinking: { type: "adaptive" },
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", display: "summarized" }, "high"],
["unsupported model", "claude-fable-5", { type: "adaptive" }, "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,18 +3244,10 @@ class ClaudeAgentSession implements AgentSession {
return { thinking: { type: "disabled" }, effort: undefined, ultracode: false };
}
if (thinkingOptionId === CLAUDE_ULTRACODE_THINKING_OPTION_ID) {
return {
thinking: { type: "adaptive", display: "summarized" },
effort: "xhigh",
ultracode: true,
};
return { thinking: { type: "adaptive" }, effort: "xhigh", ultracode: true };
}
if (thinkingOptionId && isClaudeThinkingEffort(thinkingOptionId)) {
return {
thinking: { type: "adaptive", display: "summarized" },
effort: thinkingOptionId,
ultracode: false,
};
return { thinking: { type: "adaptive" }, effort: thinkingOptionId, ultracode: false };
}
return { thinking: undefined, effort: undefined, ultracode: false };
}
@@ -2,7 +2,7 @@ import { describe, expect, test, vi } from "vitest";
import { execFileSync } from "node:child_process";
import os from "node:os";
import path from "node:path";
import { createServer } from "node:http";
import { createServer, type ServerResponse } from "node:http";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { once } from "node:events";
@@ -171,6 +171,62 @@ async function startMockResponsesServer(sequence: string[]): Promise<{
};
}
async function startHeldFirstResponseServer(): Promise<{
url: string;
requestBodies: string[];
releaseFirstResponse: () => void;
close: () => Promise<void>;
}> {
const requestBodies: string[] = [];
let heldResponse: ServerResponse | null = null;
const reply = (res: ServerResponse) => {
res.statusCode = 200;
res.setHeader("content-type", "text/event-stream");
res.end(assistantMessageSse("done"));
};
const server = createServer((req, res) => {
const chunks: Buffer[] = [];
req.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
req.on("end", () => {
if (req.method !== "POST" || req.url !== "/v1/responses") {
res.statusCode = 404;
res.end("not found");
return;
}
requestBodies.push(Buffer.concat(chunks).toString("utf8"));
if (requestBodies.length === 1) {
heldResponse = res;
} else {
reply(res);
}
});
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Expected TCP address for mock responses server");
}
return {
url: `http://127.0.0.1:${address.port}`,
requestBodies,
releaseFirstResponse: () => {
if (!heldResponse) throw new Error("No held response to release");
reply(heldResponse);
heldResponse = null;
},
close: () =>
new Promise<void>((resolve, reject) => {
server.closeAllConnections();
server.close((error) => {
if (error) reject(error);
else resolve();
});
}),
};
}
function writeMockCodexConfig(codexHome: string, serverUrl: string): void {
writeFileSync(
path.join(codexHome, "config.toml"),
@@ -217,6 +273,72 @@ function waitForEvent<TEvent extends AgentStreamEvent>(params: {
}
describe("Codex app-server provider (local e2e)", () => {
test.runIf(isCodexInstalled())(
"rewinding a steer message keeps the earlier prompt of its turn in model context",
async () => {
const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-steer-rewind-cwd-"));
const codexHome = mkdtempSync(path.join(os.tmpdir(), "codex-steer-rewind-home-"));
const mockServer = await startHeldFirstResponseServer();
try {
writeMockCodexConfig(codexHome, mockServer.url);
const client = new CodexAppServerAgentClient(createTestLogger());
const session = await client.createSession(
{ provider: "codex", cwd, modeId: "auto", model: "mock-model" },
{ env: { CODEX_HOME: codexHome } },
);
try {
const userMessageIds = new Map<string, string>();
session.subscribe((event) => {
if (
event.type === "timeline" &&
event.item.type === "user_message" &&
event.item.messageId
) {
userMessageIds.set(event.item.text, event.item.messageId);
}
});
const turnFinished = waitForEvent({
session,
timeoutMs: 15_000,
label: "turn completion",
predicate: (event): event is Extract<AgentStreamEvent, { type: "turn_completed" }> =>
event.type === "turn_completed",
});
const started = await session.startTurn("ORIGINAL_PROMPT");
await vi.waitFor(() => expect(mockServer.requestBodies).toHaveLength(1), {
timeout: 15_000,
});
await expect(
session.steerActiveTurn!("STEER_PROMPT", { expectedTurnId: started.turnId }),
).resolves.toEqual({ status: "accepted" });
mockServer.releaseFirstResponse();
await turnFinished;
await vi.waitFor(() => expect(userMessageIds.get("STEER_PROMPT")).toBeDefined(), {
timeout: 15_000,
});
await expect(
session.revertConversation!({ messageId: userMessageIds.get("STEER_PROMPT")! }),
).rejects.toThrow("Select the first message in the turn instead");
await session.run("AFTER_REWIND");
expect(mockServer.requestBodies.at(-1)).toContain("AFTER_REWIND");
expect(mockServer.requestBodies.at(-1)).toContain("ORIGINAL_PROMPT");
} finally {
await session.close();
}
} finally {
await mockServer.close();
rmSync(cwd, { recursive: true, force: true });
rmSync(codexHome, { recursive: true, force: true });
}
},
45_000,
);
test.runIf(isCodexInstalled())(
"reloads a persisted idle thread repeatedly without overlapping writers",
async () => {
@@ -105,10 +105,7 @@ interface CollaborationModeRecord {
}
interface CodexSessionTestAccess {
codexUserMessageTurns(): {
resolve(messageId: string): { index: number; turnId: string | null } | null;
count(): number;
};
codexUserMessages(): { messageId: string; turnId: string | null }[];
ensureThreadLoaded(): Promise<void>;
handleToolApprovalRequest(params: unknown): Promise<unknown>;
handleNotification(method: string, params: unknown): void;
@@ -2203,6 +2200,60 @@ describe("Codex app-server provider", () => {
await session.close();
});
test.each(["legacy", "paginated"] as const)(
"rejects rewinding a steer message in a %s thread before forking",
async (historyMode) => {
const appServer = createFakeCodexAppServer({
"thread/read": () => ({ thread: { id: "thread-1", historyMode, turns: [] } }),
"turn/steer": () => ({ turnId: "turn-first" }),
});
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
try {
const started = await session.startTurn("original prompt");
appServer.startsTurn({ threadId: "thread-1", turnId: "turn-first" });
const originalMessage = waitForNextTimelineItem(session, "user_message");
emitCodexUserMessage(appServer, {
id: "codex-first",
text: "original prompt",
turnId: "turn-first",
});
await originalMessage;
await expect(
session.steerActiveTurn("steer prompt", { expectedTurnId: started.turnId }),
).resolves.toEqual({ status: "accepted" });
const steerMessage = waitForNextTimelineItem(session, "user_message");
emitCodexUserMessage(appServer, {
id: "codex-steer",
text: "steer prompt",
turnId: "turn-first",
});
await steerMessage;
appServer.completeTurn();
await expect(session.revertConversation({ messageId: "codex-steer" })).rejects.toThrow(
"Select the first message in the turn instead",
);
expect(appServer.requests().filter((request) => request.method === "thread/fork")).toEqual(
[],
);
expect(appServer.recordedRollbacks).toEqual([]);
expect(session.id).toBe("thread-1");
await session.revertConversation({ messageId: "codex-first" });
expect(session.id).toBe("forked-thread");
appServer.assertNoErrors();
} finally {
await session.close();
}
},
);
test.each(["legacy", "paginated"] as const)(
"rewinds a %s thread onto a fork that keeps the custom provider and runtime MCP servers",
async (historyMode) => {
@@ -4638,10 +4689,9 @@ describe("Codex app-server provider", () => {
await asInternals(session).loadPersistedHistory(session.client);
expect(asInternals(session).codexUserMessageTurns().resolve("message-history")).toEqual({
index: 0,
turnId: "native-turn-1",
});
expect(asInternals(session).codexUserMessages()).toEqual([
{ messageId: "message-history", turnId: "native-turn-1" },
]);
});
test("loads mixed legacy and MultiAgentV2 sub-agent history", async () => {
@@ -91,7 +91,7 @@ import {
type CodexThreadRollbackResponse,
type CodexAppServerTraceContext,
} from "./codex/app-server-transport.js";
import { type CodexUserMessageTurnIndex, revertCodexConversation } from "./codex/rewind.js";
import { type CodexUserMessage, revertCodexConversation } from "./codex/rewind.js";
import {
materializeProviderImage,
renderProviderImageOutputAsAssistantMarkdown,
@@ -4529,16 +4529,11 @@ export class CodexAppServerAgentSession implements AgentSession {
});
}
private codexUserMessageTurns(): CodexUserMessageTurnIndex {
return {
resolve: (messageId) => {
const index = this.userMessageTurnIndexes.get(messageId);
return index === undefined
? null
: { index, turnId: this.userMessageProviderTurnIds.get(messageId) ?? null };
},
count: () => this.userMessageTurnIds.length,
};
private codexUserMessages(): CodexUserMessage[] {
return this.userMessageTurnIds.map((messageId) => ({
messageId,
turnId: this.userMessageProviderTurnIds.get(messageId) ?? null,
}));
}
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
@@ -4955,7 +4950,7 @@ export class CodexAppServerAgentSession implements AgentSession {
model: this.config.model ?? null,
serviceTier: this.serviceTier,
config: this.buildCodexInnerConfig(),
userMessageTurns: this.codexUserMessageTurns(),
userMessages: this.codexUserMessages(),
threadRollbackAvailable: this.threadRollbackAvailable,
setThreadId: async (threadId) => {
this.currentThreadId = threadId;
@@ -6,11 +6,7 @@ import type {
CodexThreadRollbackParams,
CodexThreadRollbackResponse,
} from "./app-server-transport.js";
import {
type CodexUserMessageTurnIndex,
type CodexRewindClient,
revertCodexConversation,
} from "./rewind.js";
import { type CodexRewindClient, revertCodexConversation } from "./rewind.js";
class FakeCodex implements CodexRewindClient {
readonly recordedForks: CodexThreadForkParams[] = [];
@@ -59,33 +55,13 @@ class FakeCodex implements CodexRewindClient {
}
}
class CodexMessageTurns implements CodexUserMessageTurnIndex {
constructor(
private readonly indexesByMessageId: Map<string, number>,
private readonly turnIdsByMessageId: Map<string, string> = new Map(),
) {}
resolve(messageId: string): { index: number; turnId: string | null } | null {
const index = this.indexesByMessageId.get(messageId);
return index === undefined
? null
: { index, turnId: this.turnIdsByMessageId.get(messageId) ?? null };
}
count(): number {
return this.indexesByMessageId.size;
}
}
describe("Codex Rewind", () => {
test("rewinds the conversation by forking the thread and rolling back past the native user message", async () => {
const codex = new FakeCodex();
const userMessageTurns = new CodexMessageTurns(
new Map([
["codex-first", 0],
["codex-second", 1],
]),
);
const userMessages = [
{ messageId: "codex-first", turnId: null },
{ messageId: "codex-second", turnId: null },
];
let reboundThreadId: string | null = null;
await revertCodexConversation({
@@ -95,7 +71,7 @@ describe("Codex Rewind", () => {
cwd: "/workspace/project",
model: "gpt-5.4-mini",
serviceTier: null,
userMessageTurns,
userMessages,
threadRollbackAvailable: true,
setThreadId: (threadId) => {
reboundThreadId = threadId;
@@ -118,20 +94,18 @@ describe("Codex Rewind", () => {
test("rewinds the conversation using native user message ids hydrated from app-server history", async () => {
const codex = new FakeCodex();
const userMessageTurns = new CodexMessageTurns(
new Map([
["codex-first", 0],
["codex-second", 1],
["codex-third", 2],
]),
);
const userMessages = [
{ messageId: "codex-first", turnId: null },
{ messageId: "codex-second", turnId: null },
{ messageId: "codex-third", turnId: null },
];
let reboundThreadId: string | null = null;
await revertCodexConversation({
client: codex,
threadId: "source-thread",
messageId: "codex-second",
userMessageTurns,
userMessages,
threadRollbackAvailable: true,
setThreadId: (threadId) => {
reboundThreadId = threadId;
@@ -157,16 +131,10 @@ describe("Codex Rewind", () => {
}
const codex = new PaginatedCodex();
const userMessageTurns = new CodexMessageTurns(
new Map([
["codex-first", 0],
["codex-second", 1],
]),
new Map([
["codex-first", "turn-first"],
["codex-second", "turn-second"],
]),
);
const userMessages = [
{ messageId: "codex-first", turnId: "turn-first" },
{ messageId: "codex-second", turnId: "turn-second" },
];
let reboundThreadId: string | null = null;
await revertCodexConversation({
@@ -176,7 +144,7 @@ describe("Codex Rewind", () => {
cwd: "/workspace/project",
model: "gpt-5.4-mini",
serviceTier: null,
userMessageTurns,
userMessages,
threadRollbackAvailable: true,
setThreadId: (threadId) => {
reboundThreadId = threadId;
@@ -209,7 +177,7 @@ describe("Codex Rewind", () => {
}
const codex = new PaginatedCodex();
const userMessageTurns = new CodexMessageTurns(new Map([["codex-first", 0]]));
const userMessages = [{ messageId: "codex-first", turnId: null }];
let reboundThreadId: string | null = null;
await expect(
@@ -220,7 +188,7 @@ describe("Codex Rewind", () => {
cwd: "/workspace/project",
model: "gpt-5.4-mini",
serviceTier: null,
userMessageTurns,
userMessages,
threadRollbackAvailable: true,
setThreadId: (threadId) => {
reboundThreadId = threadId;
@@ -235,14 +203,14 @@ describe("Codex Rewind", () => {
test("declines to rewind when the user message is not in the Codex thread", async () => {
const codex = new FakeCodex();
const userMessageTurns = new CodexMessageTurns(new Map([["codex-first", 0]]));
const userMessages = [{ messageId: "codex-first", turnId: null }];
await expect(
revertCodexConversation({
client: codex,
threadId: "source-thread",
messageId: "missing-message",
userMessageTurns,
userMessages,
threadRollbackAvailable: true,
setThreadId: () => undefined,
}),
@@ -250,4 +218,47 @@ describe("Codex Rewind", () => {
expect(codex.recordedForks).toEqual([]);
expect(codex.recordedRollbacks).toEqual([]);
});
test("rolls back whole turns when a later turn holds a steer message", async () => {
const codex = new FakeCodex();
await revertCodexConversation({
client: codex,
threadId: "source-thread",
messageId: "codex-second",
userMessages: [
{ messageId: "codex-first", turnId: "turn-first" },
{ messageId: "codex-second", turnId: "turn-second" },
{ messageId: "codex-steer", turnId: "turn-second" },
],
threadRollbackAvailable: true,
setThreadId: () => undefined,
});
expect(codex.recordedRollbacks).toEqual([{ threadId: "forked-thread", numTurns: 1 }]);
});
test("declines to rewind a steer message without forking", async () => {
const codex = new FakeCodex();
let reboundThreadId: string | null = null;
await expect(
revertCodexConversation({
client: codex,
threadId: "source-thread",
messageId: "codex-steer",
userMessages: [
{ messageId: "codex-first", turnId: "turn-first" },
{ messageId: "codex-steer", turnId: "turn-first" },
],
threadRollbackAvailable: true,
setThreadId: (threadId) => {
reboundThreadId = threadId;
},
}),
).rejects.toThrow("Select the first message in the turn instead");
expect(codex.recordedForks).toEqual([]);
expect(codex.recordedRollbacks).toEqual([]);
expect(reboundThreadId).toBeNull();
});
});
@@ -15,9 +15,9 @@ export interface CodexRewindClient {
request(method: string, params?: unknown, timeoutMs?: number): Promise<unknown>;
}
export interface CodexUserMessageTurnIndex {
resolve(messageId: string): { index: number; turnId: string | null } | null;
count(): number;
export interface CodexUserMessage {
messageId: string;
turnId: string | null;
}
type CodexThreadHistoryMode = "legacy" | "paginated";
@@ -60,6 +60,12 @@ async function rollbackCodexThread(
return parseCodexThreadRollbackResponse(await client.request("thread/rollback", params));
}
function countTurns(userMessages: readonly CodexUserMessage[]): number {
return userMessages.filter(
(message, index) => !message.turnId || message.turnId !== userMessages[index - 1]?.turnId,
).length;
}
export async function revertCodexConversation(input: {
client: CodexRewindClient;
threadId: string | null;
@@ -68,7 +74,7 @@ export async function revertCodexConversation(input: {
model?: string | null;
serviceTier?: string | null;
config?: Record<string, unknown> | null;
userMessageTurns: CodexUserMessageTurnIndex;
userMessages: readonly CodexUserMessage[];
threadRollbackAvailable: boolean;
setThreadId: (threadId: string) => void | Promise<void>;
}): Promise<void> {
@@ -76,16 +82,21 @@ export async function revertCodexConversation(input: {
throw new Error("Codex thread is not ready for rewind");
}
const targetTurn = input.userMessageTurns.resolve(input.messageId);
if (targetTurn === null) {
const targetIndex = input.userMessages.findIndex(
(message) => message.messageId === input.messageId,
);
if (targetIndex === -1) {
throw new Error(`Codex could not find user message ${input.messageId} in the current thread`);
}
const currentUserTurnCount = input.userMessageTurns.count();
const numTurns = currentUserTurnCount - targetTurn.index;
if (numTurns < 0) {
throw new Error(`Codex user message ${input.messageId} is outside the current thread`);
const targetTurn = input.userMessages[targetIndex];
// Codex rewinds whole turns; a steer cannot be removed without the earlier
// messages of its turn.
if (targetTurn.turnId && input.userMessages[targetIndex - 1]?.turnId === targetTurn.turnId) {
throw new Error(
"Codex cannot rewind a message sent during a turn without removing earlier messages. Select the first message in the turn instead.",
);
}
const numTurns = countTurns(input.userMessages.slice(targetIndex));
// Codex does not carry the parent thread's config into a fork; without it the
// forked thread falls back to the default model provider.
@@ -1,4 +1,4 @@
import { describe, expect, onTestFinished, test } from "vitest";
import { describe, expect, 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,51 +69,6 @@ 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,6 +445,7 @@ 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 } : {}),
@@ -2372,17 +2373,16 @@ export class OmpAgentClient implements AgentClient {
resumeConfig.config,
startInput.env,
);
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);
const initialState = await this.restoreFastMode(
runtimeSession,
resumeConfig.config,
await runtimeSession.getState(),
);
return new OmpAgentSession({
runtimeSession,
hostTools,
restartRuntime: this.buildRestartRuntime(startInput, config, launchContext),
config,
restartRuntime: this.buildRestartRuntime(startInput, resumeConfig.config, launchContext),
config: resumeConfig.config,
initialState,
currentModeId: launchMode.modeId,
logger: this.logger,
@@ -2400,35 +2400,6 @@ 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,8 +54,6 @@ 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>();
@@ -77,20 +75,16 @@ 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, this.removedModels);
const session = new FakeOmpSession(launch, sessionId);
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;
}
@@ -99,15 +93,6 @@ 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;
}
@@ -189,11 +174,7 @@ export class FakeOmpSession implements OmpRuntimeSession {
private activeHeldPrompt: { promise: Promise<void>; reject: (error: Error) => void } | null =
null;
constructor(
launch: OmpRuntimeLaunch,
sessionId = "omp-session-1",
private readonly removedModels: ReadonlySet<string> = new Set(),
) {
constructor(launch: OmpRuntimeLaunch, sessionId = "omp-session-1") {
this.environment = launch.env ?? {};
this.state = {
model: null,
@@ -352,13 +333,9 @@ 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,6 +1528,8 @@ describe("PiRpcAgentSession", () => {
"pi",
"--mode",
"rpc",
"--model",
"openrouter/model-a",
"--thinking",
"high",
"--session",
@@ -1537,87 +1539,6 @@ 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) => {
@@ -1794,6 +1715,8 @@ describe("PiRpcAgentSession", () => {
"pi",
"--mode",
"rpc",
"--model",
"openrouter/model-a",
"--thinking",
"high",
"--session",
@@ -2653,11 +2576,6 @@ 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({
@@ -2676,6 +2594,8 @@ describe("PiRpcAgentClient", () => {
"pi",
"--mode",
"rpc",
"--model",
"openrouter/anthropic/claude-sonnet-4.5",
"--thinking",
"high",
"--session",
@@ -495,6 +495,7 @@ 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,
@@ -2589,14 +2590,10 @@ export class PiRpcAgentClient implements AgentClient {
throw error;
}
try {
const initialState = await this.applyResumeModel(runtimeSession, resumeConfig.model);
return new PiRpcAgentSession({
runtimeSession,
config: {
...resumeConfig.config,
model: modelToId(initialState.model) ?? resumeConfig.config.model,
},
initialState,
config: resumeConfig.config,
initialState: await runtimeSession.getState(),
capabilities: capabilitiesForSession(mcp !== null),
cleanup: combineCleanup([mcpConfigFile?.cleanup, paseoExtension?.cleanup]),
extensionTimeoutMs: providerOptions.extensionTimeoutMs,
@@ -2611,35 +2608,6 @@ 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,7 +57,6 @@ 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;
@@ -69,10 +68,7 @@ export class FakePi implements PiRuntime {
session: input,
});
this.recordedLaunches.push(launch);
if (launch.model && this.removedModels.has(launch.model)) {
throw new Error(`Model "${launch.model}" not found`);
}
const session = new FakePiSession(launch, this.removedModels);
const session = new FakePiSession(launch);
session.commands = this.queuedCommands.shift() ?? [];
this.queuedSessionSetups.shift()?.(session);
this.sessions.push(session);
@@ -87,11 +83,6 @@ 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) {
@@ -154,10 +145,7 @@ export class FakePiSession implements PiRuntimeSession {
private activeHeldPrompt: { promise: Promise<void>; reject: (error: Error) => void } | null =
null;
constructor(
launch: PiRuntimeLaunch,
private readonly removedModels: ReadonlySet<string> = new Set(),
) {
constructor(launch: PiRuntimeLaunch) {
this.environment = launch.env ?? {};
this.state = {
model: null,
@@ -279,9 +267,6 @@ 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");
}
+1
View File
@@ -1170,6 +1170,7 @@ export async function createPaseoDaemon(
emit: emitExternalSessionMessage,
sessionLogger: logger,
terminalManager,
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
serviceProxy,
scriptRuntimeStore,
getDaemonTcpPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
@@ -27,28 +27,9 @@ 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",
@@ -98,7 +79,6 @@ 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,10 +67,7 @@ export function buildConfigOverrides(record: StoredAgentRecord): Partial<AgentSe
return stripInternalPaseoMcpServer({
provider: record.provider,
cwd: record.cwd,
// 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,
modeId: record.config?.modeId ?? undefined,
model: record.config?.model ?? undefined,
thinkingOptionId: record.config?.thinkingOptionId ?? undefined,
featureValues: record.config?.featureValues ?? undefined,
+12 -13
View File
@@ -5216,10 +5216,10 @@ export class Session {
private async resolveAgentIdentifier(
identifier: string,
): Promise<{ ok: true; agentId: string } | { ok: false; notFound: boolean; error: string }> {
): Promise<{ ok: true; agentId: string } | { ok: false; error: string }> {
const trimmed = identifier.trim();
if (!trimmed) {
return { ok: false, notFound: false, error: "Agent identifier cannot be empty" };
return { ok: false, error: "Agent identifier cannot be empty" };
}
const stored = await this.agentStorage.list();
@@ -5243,7 +5243,6 @@ 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))
@@ -5258,7 +5257,6 @@ 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))
@@ -5266,7 +5264,7 @@ export class Session {
};
}
return { ok: false, notFound: true, error: `Agent not found: ${trimmed}` };
return { ok: false, error: `Agent not found: ${trimmed}` };
}
private async getAgentPayloadById(agentId: string): Promise<AgentSnapshotPayload | null> {
@@ -7255,6 +7253,7 @@ 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,
@@ -7301,6 +7300,7 @@ 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,17 +7576,11 @@ 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.notFound ? null : resolved.error,
},
payload: { requestId, agent: null, project: null, error: resolved.error },
});
return;
}
@@ -7595,7 +7589,12 @@ export class Session {
if (!agent) {
this.emit({
type: "fetch_agent_response",
payload: { requestId, agent: null, project: null, error: null },
payload: {
requestId,
agent: null,
project: null,
error: `Agent not found: ${resolved.agentId}`,
},
});
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, notFound: true, error: `Agent not found: ${identifier}` };
: { ok: false, error: `Agent not found: ${identifier}` };
session.getAgentPayloadById = async (agentId: string) => (agentId === agent.id ? agent : null);
session.buildProjectPlacementForWorkspaceId = async () => ({
projectKey: "proj-history-detail",
@@ -115,6 +115,7 @@ function createWorkflowForRequestTest(options: {
emit: () => {},
sessionLogger: createLogger(),
terminalManager: null,
archiveWorkspaceRecord: async () => {},
serviceProxy: null,
scriptRuntimeStore: null,
getDaemonTcpPort: null,
@@ -488,6 +489,7 @@ describe("create-agent worktree setup boundary", () => {
emit: (message) => emitted.push(message),
sessionLogger: createLogger(),
terminalManager: createTerminalManagerStub().manager,
archiveWorkspaceRecord: async () => {},
serviceProxy: null,
scriptRuntimeStore: null,
getDaemonTcpPort: null,
@@ -538,6 +540,7 @@ describe("create-agent worktree setup boundary", () => {
emit: (message) => workspaceSetupEvents.push(message),
sessionLogger: createLogger(),
terminalManager: null,
archiveWorkspaceRecord: async () => {},
serviceProxy: null,
scriptRuntimeStore: null,
getDaemonTcpPort: null,
@@ -711,6 +714,7 @@ describe("runWorktreeSetupInBackground", () => {
emit: () => {},
sessionLogger: createLogger(),
terminalManager: null,
archiveWorkspaceRecord: async () => {},
},
{
requestCwd: sourceWorkspaceCwd,
@@ -755,6 +759,7 @@ describe("runWorktreeSetupInBackground", () => {
const logger = createLogger();
const terminalManager = createTerminalManagerStub();
const emitWorkspaceUpdateForWorkspaceId = vi.fn(async () => {});
const archiveWorkspaceRecord = vi.fn(async () => {});
await runWorktreeSetupInBackground(
{
@@ -765,6 +770,7 @@ describe("runWorktreeSetupInBackground", () => {
emit: (message) => emitted.push(message),
sessionLogger: logger,
terminalManager: terminalManager.manager,
archiveWorkspaceRecord,
},
{
requestCwd: repoDir,
@@ -822,10 +828,11 @@ describe("runWorktreeSetupInBackground", () => {
});
expect(terminalManager.terminals).toHaveLength(0);
expect(archiveWorkspaceRecord).not.toHaveBeenCalled();
expect(emitWorkspaceUpdateForWorkspaceId).toHaveBeenCalledWith("42");
});
test("keeps the workspace available and emits a failed snapshot when setup cannot start", async () => {
test("archives the pending workspace and emits a failed snapshot when setup cannot start", async () => {
const { tempDir, repoDir } = createGitRepo();
cleanupPaths.push(tempDir);
@@ -850,6 +857,7 @@ 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(
@@ -861,6 +869,7 @@ describe("runWorktreeSetupInBackground", () => {
emit: (message) => emitted.push(message),
sessionLogger: logger,
terminalManager: null,
archiveWorkspaceRecord,
},
{
requestCwd: repoDir,
@@ -892,8 +901,7 @@ describe("runWorktreeSetupInBackground", () => {
status: "failed",
error: expect.stringMatching(/Failed to parse paseo\.json at .*paseo\.json/),
});
expect(existsSync(worktreePath)).toBe(true);
expect(existsSync(path.join(worktreePath, "paseo.json"))).toBe(true);
expect(archiveWorkspaceRecord).toHaveBeenCalledWith(workspaceId);
expect(emitWorkspaceUpdateForWorkspaceId).toHaveBeenCalledWith(workspaceId);
});
@@ -924,6 +932,7 @@ describe("runWorktreeSetupInBackground", () => {
const snapshots = new Map<string, unknown>();
const logger = createLogger();
const emitWorkspaceUpdateForWorkspaceId = vi.fn(async () => {});
const archiveWorkspaceRecord = vi.fn(async () => {});
await runWorktreeSetupInBackground(
{
@@ -934,6 +943,7 @@ describe("runWorktreeSetupInBackground", () => {
emit: (message) => emitted.push(message),
sessionLogger: logger,
terminalManager: null,
archiveWorkspaceRecord,
},
{
requestCwd: repoDir,
@@ -1046,6 +1056,7 @@ describe("runWorktreeSetupInBackground", () => {
const logger = createLogger();
const terminalManager = createTerminalManagerStub();
const emitWorkspaceUpdateForWorkspaceId = vi.fn(async () => {});
const archiveWorkspaceRecord = vi.fn(async () => {});
await runWorktreeSetupInBackground(
{
@@ -1056,6 +1067,7 @@ describe("runWorktreeSetupInBackground", () => {
emit: (message) => emitted.push(message),
sessionLogger: logger,
terminalManager: terminalManager.manager,
archiveWorkspaceRecord,
},
{
requestCwd: repoDir,
@@ -1104,6 +1116,7 @@ describe("runWorktreeSetupInBackground", () => {
status: "completed",
error: null,
});
expect(archiveWorkspaceRecord).not.toHaveBeenCalled();
expect(emitWorkspaceUpdateForWorkspaceId).toHaveBeenCalledWith("44");
});
@@ -1138,6 +1151,7 @@ describe("runWorktreeSetupInBackground", () => {
},
});
const emitWorkspaceUpdateForWorkspaceId = vi.fn(async () => {});
const archiveWorkspaceRecord = vi.fn(async () => {});
await runWorktreeSetupInBackground(
{
@@ -1148,6 +1162,7 @@ describe("runWorktreeSetupInBackground", () => {
emit: (message) => emitted.push(message),
sessionLogger: logger,
terminalManager: terminalManager.manager,
archiveWorkspaceRecord,
},
{
requestCwd: repoDir,
@@ -1187,6 +1202,7 @@ describe("runWorktreeSetupInBackground", () => {
status: "completed",
error: null,
});
expect(archiveWorkspaceRecord).not.toHaveBeenCalled();
expect(emitWorkspaceUpdateForWorkspaceId).toHaveBeenCalledWith("45");
});
@@ -1217,6 +1233,7 @@ describe("runWorktreeSetupInBackground", () => {
const logger = createLogger();
const terminalManager = createTerminalManagerStub();
const emitWorkspaceUpdateForWorkspaceId = vi.fn(async () => {});
const archiveWorkspaceRecord = vi.fn(async () => {});
await runWorktreeSetupInBackground(
{
@@ -1227,6 +1244,7 @@ describe("runWorktreeSetupInBackground", () => {
emit: (message) => emitted.push(message),
sessionLogger: logger,
terminalManager: terminalManager.manager,
archiveWorkspaceRecord,
},
{
requestCwd: repoDir,
@@ -1247,6 +1265,7 @@ describe("runWorktreeSetupInBackground", () => {
status: "completed",
error: null,
});
expect(archiveWorkspaceRecord).not.toHaveBeenCalled();
expect(emitWorkspaceUpdateForWorkspaceId).toHaveBeenCalledWith("46");
});
@@ -1408,6 +1427,7 @@ describe("runWorktreeSetupInBackground", () => {
emit: (message: SessionOutboundMessage) => emitted.push(message),
sessionLogger: createLogger(),
terminalManager: terminalManager.manager,
archiveWorkspaceRecord: vi.fn(async () => {}),
serviceProxy: null,
scriptRuntimeStore: null,
getDaemonTcpPort: null,
@@ -108,6 +108,7 @@ interface CreatePaseoWorktreeInBackgroundDependencies {
emit: EmitSessionMessage;
sessionLogger: Logger;
terminalManager: TerminalManager | null;
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
serviceProxy: ServiceProxySubsystem | null;
scriptRuntimeStore: WorkspaceScriptRuntimeStore | null;
getDaemonTcpPort: (() => number | null) | null;
@@ -824,6 +825,7 @@ export async function runWorktreeSetupInBackground(
): Promise<void> {
let worktree: WorktreeConfig = options.worktree;
let setupResults: WorktreeSetupCommandResult[] = [];
let setupStarted = false;
const progressAccumulator = createWorktreeSetupProgressAccumulator();
const workspaceId = options.workspaceId;
@@ -860,6 +862,7 @@ 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({
@@ -871,6 +874,7 @@ export async function runWorktreeSetupInBackground(
cwd: workspaceCwd,
env: runtimeEnv,
});
setupStarted = true;
setupResults = await runWorktreeSetupCommands({
worktreePath: workspaceCwd,
branchName: worktree.branchName,
@@ -902,6 +906,10 @@ 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,
@@ -909,6 +917,7 @@ 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 { notification_type?: unknown };
return payload.notification_type === "idle_prompt";
const payload = notification as { matcher?: unknown; reason?: unknown };
return payload.matcher === "idle_prompt" || payload.reason === "idle_prompt";
} catch {
return false;
}
@@ -1,4 +1,3 @@
import { createHook } from "node:async_hooks";
import { execFileSync } from "node:child_process";
import {
mkdtempSync,
@@ -1025,65 +1024,3 @@ 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,7 +1,6 @@
import type { Dirent, Stats } from "node:fs";
import * as fs from "node:fs/promises";
import { readdir, realpath, stat } 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";
@@ -103,37 +102,6 @@ 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",
@@ -687,7 +655,7 @@ async function readChildren(directory: string): Promise<ChildEntry[]> {
) {
rawEntries = cached.entries;
} else {
const dirents = await readdir(directory).catch(() => [] as Dirent[]);
const dirents = await readdir(directory, { withFileTypes: true }).catch(() => [] as Dirent[]);
rawEntries = dirents
.map(toRawChildEntry)
.filter((entry): entry is RawChildEntry => entry !== null)
+6 -29
View File
@@ -1,5 +1,4 @@
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.
@@ -28,7 +27,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");
await browseCategory(page, "Git & code review");
await expect(page).toHaveURL(/category=/);
await expect(page.getByRole("heading", { level: 2 })).toHaveCount(1);
await expect(page.getByRole("region", { name: "Themes" })).toHaveCount(0);
@@ -49,7 +48,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");
await expect(breadcrumbs).toContainText("Git & code review");
await expect(breadcrumbs).toContainText("Fresh Worktrees");
await openAuthor(page, "Omer Cohen");
@@ -87,7 +86,10 @@ async function copyInstallCommand(page: Page) {
}
async function browseCategory(page: Page, category: string) {
await page.getByRole("link", { name: category, exact: true }).click();
await page
.getByRole("region", { name: category })
.getByRole("link", { name: /^View all/ })
.click();
}
async function openPlugin(page: Page, name: RegExp) {
await page.getByRole("link", { name }).first().click();
@@ -212,28 +214,3 @@ 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,
);
}
});
});
-14
View File
@@ -1,14 +0,0 @@
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");
-631
View File
@@ -1,631 +0,0 @@
{
"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."
}
]
}
+5 -14
View File
@@ -12,18 +12,9 @@ export default defineConfig({
],
webServer: process.env.WEBSITE_TEST_URL
? undefined
: [
{
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",
},
},
],
: {
command: "npm run dev -- --host 127.0.0.1 --port 8187 --strictPort",
url: baseURL,
reuseExistingServer: !process.env.CI,
},
});
+29 -33
View File
@@ -1,51 +1,47 @@
export const CATEGORIES = [
{ slug: "themes", label: "Themes", description: "Change how Paseo looks." },
{
slug: "daemon-management",
label: "Daemon management",
description: "Manage hosts, daemon jobs, resources, approvals, and MCP servers.",
slug: "agents-and-providers",
label: "Agents & providers",
description: "Run more coding agents through Paseo.",
},
{
slug: "themes",
label: "Themes",
description: "Change the app color scheme.",
slug: "monitoring",
label: "Monitoring & orchestration",
description: "See every agent at once and steer work across workspaces.",
},
{
slug: "providers",
label: "Providers",
description:
"Connect coding agents, ACP servers, and model routers, with account and quota fallback.",
slug: "timeline-and-composer",
label: "Timeline & composer",
description: "Change what a conversation shows and how you write to agents.",
},
{
slug: "orchestration",
label: "Orchestration",
description: "Coordinate agents, delegate work, and send messages across hosts.",
slug: "workspace-panels",
label: "Workspace panels",
description: "Tools that open as tabs next to your agents.",
},
{
slug: "git",
label: "Git",
description: "Work with branches, worktrees, pull requests, stacks, diffs, and reviews.",
slug: "git-and-code-review",
label: "Git & code review",
description: "Branches, worktrees, and pull requests.",
},
{
slug: "workspaces",
label: "Workspaces",
description: "Add workspace panels and tabs for boards, browsers, documents, and terminals.",
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: "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: "integrations",
label: "Integrations",
description: "Issues, tasks, and tools from other services inside Paseo.",
},
{ 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: "utils", sort: "popular" })).toEqual([]);
expect(queryPlugins([plugin], { category: "utilities", sort: "popular" })).toEqual([]);
});
it("offers a registry command and an exact explicit command", () => {
expect(installCommand(plugin)).toBe("paseo plugin install acme/example");