Compare commits

...
Author SHA1 Message Date
paseo-bot[bot]and3ae3ae f77692fefb fix(editor): keep // on JSX attribute lines and use {/* */} in MDX
Cmd+/ on an attribute line of a multi-line JSX tag wrapped it in {/* */},
which TypeScript rejects. Only lines that start a JSX child (text, the {
of a child escape, or the < of a nested element) take {/* */} now, and
attribute lines keep //.

.mdx files got <!-- -->, which MDX 2 and later fail to compile. They use
{/* */} now.

Co-authored-by: 3ae3ae <83870598+3ae3ae@users.noreply.github.com>
2026-10-05 03:54:11 +02:00
paseo-bot[bot] 883c1bde67 fix(editor): comment JSX children with JSX comments
Cmd+/ on a JSX child line in a .jsx or .tsx file inserted `//`, which
JSX renders as text. JSX nodes now resolve to `{/* */}` block tokens,
while JavaScript inside a JSX escape keeps `//`.
2026-10-04 19:42:52 +02:00
paseo-bot[bot]andBae Seongmin 5b4c57a301 Merge main into the editor comment-token fix
Co-authored-by: Bae Seongmin <83870598+3ae3ae@users.noreply.github.com>
2026-10-04 19:25:39 +02:00
Bae Seongmin 642d69b142 fix(app): cache file links with their line locations (#5653) 2026-10-04 19:00:31 +02:00
Mohamed Boudra 5aee41952e feat(website): browse plugins by nine community categories (#6068)
Organize community plugins by what Paseo does and verify the category layout with a static registry fixture.
2026-10-04 17:12:11 +01:00
Galaxy Roaming 48329facc3 fix(acp): surface context-window usage from the ACP usage_update notification (#4848)
* fix(acp): show context-window usage for generic ACP agents

Generic ACP providers never populated Paseo's context meter: the standard ACP
`usage_update` notification was discarded by `handleUsageUpdate` (`void
update`), while the meter only reads `lastUsage.contextWindowMaxTokens` /
`contextWindowUsedTokens`, which `usage_updated` stream events write.

Forward `size` and `used` on a `usage_updated` event for the active turn,
guarded by the same validity rules the meter applies so a provider cannot push
a window the meter rejects, and preserve the turn usage already recorded.

Verified with vitest acp-agent.test.ts (106 passed), the server typecheck, and
an isolated daemon run where a dsh agent reported
`lastUsage={contextWindowMaxTokens:1000000,contextWindowUsedTokens:13623}`.

* fix(acp): emit context-window usage only with both meter fields

Require a valid size and used together so a partial usage_update cannot
wipe the composer meter. Drive the tests through sessionUpdate instead
of the private translator.
2026-10-04 18:06:41 +02:00
paseo-bot[bot] 97083dd736 fix(pi,omp): open sessions whose model was removed (#6061)
* fix(pi,omp): open sessions whose model was removed

Importing or reopening a Pi or OMP session failed with
`Model "<id>" not found` when the session's model was no longer
configured, because the resume launch always passed `--model`.

Resume now launches on the session's own model, which Pi and OMP
replace with their default when it is gone, and switches to the
requested model afterwards only when it differs and is available.

Closes #6039

* fix(pi,omp): fall back only when the requested model is unavailable

Check the provider's model list before switching on resume, so a
failed switch to an available model reaches the caller instead of
silently keeping the session's model.

* fix(pi,omp): match a requested provider:id model on resume

Compare the parsed provider and model id against the session and
available models, so a reference written as provider:id switches
instead of being treated as unavailable.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-04 17:16:14 +02:00
Petr PlenkovandDevin 5c2c85ebc8 fix(server): resume with last live mode, not creation-time mode (#5140)
* fix(server): resume with last live mode, not creation-time mode

Provider-initiated mode switches (ACP current_mode_update, in-session
commands like /bypass, permission-driven plan->acceptEdits transitions)
update currentModeId/lastModeId but never reach config.modeId. On
resume, buildConfigOverrides restored the stale creation-time mode and
applyConfiguredOverrides pushed it back via set_mode — silently
downgrading the session (e.g. bypass -> acceptEdits after restart).

- buildConfigOverrides + buildStoredAgentConfig prefer lastModeId
  (the last observed live mode) over config.modeId
- applyObservedMode syncs config.modeId on every provider-reported
  mode change (mode_changed, model_changed runtimeInfo, and the
  refreshSessionState poll) so in-memory reloadAgentSession carries
  the live mode as well

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(server): assert config.modeId sync on mode_changed/model_changed events

Extends the config-drift stream test with a registry so both the
in-memory agent.config.modeId and the persisted record.config.modeId
are verified — covering the event paths Greptile flagged as untested.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-04 16:12:33 +02:00
Mohamed Boudra 30fb66c8cb fix(app): preserve mobile sidebar scroll position when reopening (#6058)
* fix(app): preserve mobile sidebar scroll position when reopening

Keep hidden native overlays laid out so Android touch cancellation cannot spring retained scroll offsets through a zero scroll range. Hide and gate touch input on the UI thread to preserve panel motion during JS stalls.

* docs(app): quote sidebar replay workspace examples

* test(website): read plugin HTML without waiting for remote images

* test(website): exercise built worker in browser checks

The ordered plugin spec reproduces a no-response navigation timeout against a cold development server. Build and preview the published worker for E2E checks; preserve the original content and metadata assertions.
2026-10-04 14:55:24 +01:00
paseo-bot[bot] 05b074764d fix(cli): report an unknown agent ID as AGENT_NOT_FOUND (#6053)
* fix(server): report an unknown agent as not found to CLI commands

fetch_agent_request answered an unknown agent ID with an error string, so
DaemonClient.fetchAgent threw instead of returning null. Every CLI command
that looks up one agent checks for null to report AGENT_NOT_FOUND, so
delete, stop, inspect, logs and the rest reported their generic failure
code and skipped the "paseo ls" hint.

The daemon now answers an unknown agent with a null agent and no error.
An empty or ambiguous identifier is still an error.

* test(cli): expect the not-found message agent logs now prints

* test(cli): move the unknown agent ID cases into the delete and stop suites

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-04 14:55:22 +02:00
paseo-bot[bot] 5285b7e502 fix(server): keep a stuck directory search from blocking every other search (#6041)
Directory search shares two filesystem slots across all searches. Searches
over one tree walk it in the same order, so two Add Project searches that
reach the same folder whose read never returns held both slots, and every
later directory search queued behind them: command center file search,
@-file autocomplete, and file-link lookups in unrelated folders all hung
until the read returned.

Searches waiting on the same path now share its pending filesystem call, so
one stuck folder holds one slot and the other stays free.

Refs #4879

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-04 13:11:40 +02:00
409b8f9312 fix(server): keep a new worktree open when its paseo.json cannot be parsed (#6036)
* fix(server): keep workspaces available after setup config errors

* refactor(server): drop the archive dependency from worktree setup

Setup no longer archives a workspace when it fails, so the setup
workflows no longer take archiveWorkspaceRecord.

Co-authored-by: Bae Seongmin <83870598+3ae3ae@users.noreply.github.com>

* test(server): stop passing the removed archive dependency to the worktree workflow

* test(server): check paseo.json survives without comparing line endings

Git checks the fixture out with CRLF on Windows, so comparing its exact
contents failed there.

---------

Co-authored-by: 3ae3ae <ogyshjsm@naver.com>
Co-authored-by: Bae Seongmin <83870598+3ae3ae@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-04 12:20:44 +02:00
Mohamed Boudra 485221b739 fix(app): enlarge compact sidebar footer tap targets (#6044)
* fix(app): enlarge compact sidebar footer tap targets

The compact drawer reused 28px desktop buttons, making footer actions hard to tap. Use 44px targets with larger glyphs in compact layouts while preserving desktop geometry.

* test(app): wait for compact footer tap stability
2026-10-04 10:55:25 +01:00
paseo-bot[bot]andLeandro Ardissone ed48655a77 fix(server): stop directory search from taking every filesystem thread (#6029)
Each pending fs call holds one of libuv's four shared threadpool threads,
and a read on a hung mount, a protected macOS folder, or a huge network
directory does not return. Add Project starts a new search per keystroke
and nothing cancels the old ones, so four stuck searches took every
thread: logging stopped and `paseo project create` timed out until the
read returned or the daemon was killed.

Directory search now runs its filesystem calls through one shared limit
of two, leaving the rest of the pool to the daemon.

Refs #4879

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Leandro Ardissone <59826+lardissone@users.noreply.github.com>
2026-10-04 10:18:19 +02:00
Jared cc8fe41e28 Send display: summarized so Claude thinking renders (#4355)
The Agent SDK request never set thinking.display, so on Opus 4.7 and
later the API default of "omitted" applied and every thinking block
arrived with empty text. Paseo drops empty blocks, so Claude agents
never produced reasoning timeline items.

Closes #4342
2026-10-04 09:15:14 +02:00
paseo-bot[bot]and3ae3ae 8216e86ecb fix(cli): delete archived agents with agent delete --all and --cwd (#6027)
* fix(cli): delete archived agents in bulk commands

* test(cli): cover archived agents in bulk delete from the CLI suite

The daemon-backed test imported server test utilities from a CLI test,
which the CI contract in scripts/ci-workflow.test.mjs forbids. Cover
--all and --cwd against archived agents in the runDeleteCommand unit
test instead.

* fix(cli): read every history page when bulk-deleting agents

agent delete --all and --cwd read one page of fetch_agents, so agents
past the first 200 were skipped, and fetch_agents leaves out saved
agents whose provider is unavailable. List agents from the paged agent
history, which the History view uses, so bulk delete covers both.

* fix(cli): delete an agent by ID without listing history

---------

Co-authored-by: 3ae3ae <ogyshjsm@naver.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-04 07:55:21 +02:00
paseo-bot[bot] 79a3942393 fix(server): mark Claude terminals as needing input when Claude goes idle (#6022)
Claude Code reports the kind of notification in `notification_type`, but
the hook parser only read `matcher` and `reason`, which Claude never sends
in Notification input. Every idle_prompt notification resolved to nothing,
so a Claude terminal waiting for the user never showed as needing input.

Closes #5845

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