From 0bf74f78f52e84a97b0a2f513fcffe26d8e2d5ae Mon Sep 17 00:00:00 2001 From: Zhen Zhang Date: Mon, 21 Sep 2026 21:57:25 -0700 Subject: [PATCH 1/5] feat(agent-crew): auto-open Explorer tab for new workspaces Open the Agent Crew Explorer tab once per workspace without manual action. The client seeds and subscribes to the workspace directory, claims unseen workspace IDs through the agent-crew.auto-open.claim RPC, and opens the crew panel for newly claimed workspaces. Claims persist atomically in $PASEO_HOME/plugin-data/agent-crew/auto-open.json so a workspace is opened at most once across daemon restarts, reloads, and reconnects. Failed claim batches retry once after two seconds. --- agent-crew/README.md | 30 ++++++- agent-crew/client/auto-open.ts | 91 +++++++++++++++++++++ agent-crew/index.client.tsx | 3 +- agent-crew/index.server.ts | 8 ++ agent-crew/package.json | 3 + agent-crew/server/auto-open.ts | 79 ++++++++++++++++++ agent-crew/shared/auto-open.ts | 20 +++++ agent-crew/tests/auto-open.shared.test.ts | 97 +++++++++++++++++++++++ 8 files changed, 329 insertions(+), 2 deletions(-) create mode 100644 agent-crew/client/auto-open.ts create mode 100644 agent-crew/index.server.ts create mode 100644 agent-crew/server/auto-open.ts create mode 100644 agent-crew/shared/auto-open.ts create mode 100644 agent-crew/tests/auto-open.shared.test.ts diff --git a/agent-crew/README.md b/agent-crew/README.md index 5a2789ff..fd11e6cc 100644 --- a/agent-crew/README.md +++ b/agent-crew/README.md @@ -3,6 +3,9 @@ See and control every managed Paseo agent working in a workspace. Agent Crew adds a workspace-context Explorer panel plus an `Open Agent Crew` Command Center item. +This checkout is a local fork of `omercnet/paseo-plugins/agent-crew` with one addition: +automatic Explorer tab opening, described below. + It answers "which crews are active here, and which agent needs me now?" while preserving managed parent-child relationships across workspace boundaries. @@ -20,6 +23,22 @@ that no private organization names remained in the rendered page. ![Agent Crew nudge confirmation](https://raw.githubusercontent.com/omercnet/paseo-plugins/main/agent-crew/docs/images/agent-crew-action.png) +## Auto open + +The fork opens the Agent Crew Explorer tab once per workspace, without manual action: + +- The client watches the workspace directory on the selected host through + `paseo.workspaces.subscribe()` plus a full paged `paseo.workspaces.list()` seed. +- Each new workspace ID is claimed through the `agent-crew.auto-open.claim` RPC. The + daemon-side handler records claimed IDs in `$PASEO_HOME/plugin-data/agent-crew/auto-open.json` + (`~/.paseo` by default) with atomic writes, so a workspace is claimed at most once across + daemon restarts, plugin reloads, and reconnects. +- Claimed workspaces get `client.openPanel("crew", { workspaceId, location: "explorer" })`. +- Closing the tab stays closed: the claim is permanent, so the plugin never re-opens a + workspace the user closed. New workspaces, including agent-created worktrees, open on + first sight even if they were created while no client was connected. +- A failed claim batch is retried once after two seconds, then dropped with a logged error. + ## What it shows - Every non-archived managed agent in the current workspace, organized into orchestration trees. @@ -94,7 +113,16 @@ Agent Crew intentionally stays inside the public Paseo plugin SDK. Paseo plugins are trusted, unsandboxed code. Review the source before installing it. -From npm: +This fork installs from the local checkout on the Paseo daemon host: + +```bash +paseo plugin install /home/builder/workspace/paseo-plugins/agent-crew +``` + +Source changes load with `paseo plugin reload agent-crew`; `paseo plugin update` does not +apply to directory sources. + +Upstream installation, from npm: ```bash paseo plugin install npm:@omercnet/paseo-agent-crew diff --git a/agent-crew/client/auto-open.ts b/agent-crew/client/auto-open.ts new file mode 100644 index 00000000..797f92ab --- /dev/null +++ b/agent-crew/client/auto-open.ts @@ -0,0 +1,91 @@ +import type { PluginClientContext } from "@getpaseo/plugin/client"; +import { claimOpenedWorkspaces } from "../shared/auto-open"; + +const FLUSH_DELAY_MS = 400; +const RETRY_DELAY_MS = 2000; +const PAGE_LIMIT = 200; +const MAX_PAGES = 10; + +async function listAllWorkspaceIds(paseo: PluginClientContext["paseo"]): Promise { + const workspaceIds: string[] = []; + let cursor: string | undefined; + for (let page = 0; page < MAX_PAGES; page += 1) { + const result = await paseo.workspaces.list({ + page: { limit: PAGE_LIMIT, ...(cursor ? { cursor } : {}) }, + }); + for (const workspace of result.entries) workspaceIds.push(workspace.id); + cursor = result.pageInfo.hasMore ? (result.pageInfo.nextCursor ?? undefined) : undefined; + if (!cursor) break; + } + return workspaceIds; +} + +export function startAutoOpen(client: PluginClientContext): () => void { + const seen = new Set(); + const retried = new Set(); + let pending: string[] = []; + let flushTimer: ReturnType | null = null; + let stopped = false; + + function scheduleFlush(delayMs: number) { + if (stopped || flushTimer) return; + flushTimer = setTimeout(() => { + flushTimer = null; + void flush(); + }, delayMs); + } + + function enqueue(workspaceId: string) { + if (stopped || seen.has(workspaceId)) return; + seen.add(workspaceId); + pending.push(workspaceId); + scheduleFlush(FLUSH_DELAY_MS); + } + + async function flush() { + const batch = pending; + pending = []; + if (batch.length === 0) return; + try { + const { claimed } = await client.rpc(claimOpenedWorkspaces, { workspaceIds: batch }); + for (const workspaceId of claimed) { + try { + client.openPanel("crew", { workspaceId, location: "explorer" }); + } catch (error) { + console.error( + `Agent Crew auto-open could not open the panel for workspace ${workspaceId}`, + error, + ); + } + } + } catch (error) { + console.error("Agent Crew auto-open claim failed", error); + const retryable = batch.filter((workspaceId) => !retried.has(workspaceId)); + for (const workspaceId of retryable) { + retried.add(workspaceId); + seen.delete(workspaceId); + pending.push(workspaceId); + } + if (retryable.length > 0) scheduleFlush(RETRY_DELAY_MS); + } + } + + const unsubscribeWorkspaces = client.paseo.workspaces.subscribe((update) => { + if (update.kind !== "upsert") return; + enqueue(update.workspace.id); + }); + + void listAllWorkspaceIds(client.paseo) + .then((workspaceIds) => { + for (const workspaceId of workspaceIds) enqueue(workspaceId); + }) + .catch((error: unknown) => { + console.error("Agent Crew auto-open directory listing failed", error); + }); + + return () => { + stopped = true; + if (flushTimer) clearTimeout(flushTimer); + unsubscribeWorkspaces(); + }; +} diff --git a/agent-crew/index.client.tsx b/agent-crew/index.client.tsx index 7ba99868..66ee5def 100644 --- a/agent-crew/index.client.tsx +++ b/agent-crew/index.client.tsx @@ -1,4 +1,5 @@ import type { PluginClientContext } from "@getpaseo/plugin/client"; +import { startAutoOpen } from "./client/auto-open"; import { AgentCrew } from "./client/main"; export default function contribute(client: PluginClientContext) { @@ -20,5 +21,5 @@ export default function contribute(client: PluginClientContext) { openPanel("crew", { location: "explorer" }); }, }); - return () => {}; + return startAutoOpen(client); } diff --git a/agent-crew/index.server.ts b/agent-crew/index.server.ts new file mode 100644 index 00000000..eb9bc042 --- /dev/null +++ b/agent-crew/index.server.ts @@ -0,0 +1,8 @@ +import type { PluginServerContext } from "@getpaseo/plugin/server"; +import { createAutoOpenClaimHandler } from "./server/auto-open"; +import { claimOpenedWorkspaces } from "./shared/auto-open"; + +export default function contribute(server: PluginServerContext) { + server.handle(claimOpenedWorkspaces, createAutoOpenClaimHandler()); + return () => {}; +} diff --git a/agent-crew/package.json b/agent-crew/package.json index b571a88e..fa14c7fb 100644 --- a/agent-crew/package.json +++ b/agent-crew/package.json @@ -32,7 +32,10 @@ "LICENSE", "README.md", "client", + "shared", + "server", "index.client.tsx", + "index.server.ts", "paseo-plugin.json" ], "scripts": { diff --git a/agent-crew/server/auto-open.ts b/agent-crew/server/auto-open.ts new file mode 100644 index 00000000..8e1fcb41 --- /dev/null +++ b/agent-crew/server/auto-open.ts @@ -0,0 +1,79 @@ +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import type { RpcInput } from "@getpaseo/plugin"; +import { type claimOpenedWorkspaces, claimUnclaimedWorkspaces } from "../shared/auto-open"; + +export interface AutoOpenStore { + load(): Promise>; + persist(next: ReadonlySet): Promise; +} + +export function autoOpenDataFilePath(): string { + const home = process.env.PASEO_HOME ?? join(homedir(), ".paseo"); + return join(home, "plugin-data", "agent-crew", "auto-open.json"); +} + +function parseWorkspaceIds(raw: string): Set { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return new Set(); + } + if (!Array.isArray(parsed)) return new Set(); + return new Set( + parsed.filter((value): value is string => typeof value === "string" && value.length > 0), + ); +} + +export function createFileAutoOpenStore(filePath: string = autoOpenDataFilePath()): AutoOpenStore { + return { + async load() { + let raw: string; + try { + raw = await readFile(filePath, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return new Set(); + throw error; + } + return parseWorkspaceIds(raw); + }, + async persist(next) { + await mkdir(dirname(filePath), { recursive: true }); + const temporary = `${filePath}.${process.pid}.tmp`; + await writeFile(temporary, `${JSON.stringify([...next], null, 2)}\n`, "utf8"); + await rename(temporary, filePath); + }, + }; +} + +export function createMemoryAutoOpenStore(initial: Iterable = []): AutoOpenStore { + let values = new Set(initial); + return { + async load() { + return new Set(values); + }, + async persist(next) { + values = new Set(next); + }, + }; +} + +export function createAutoOpenClaimHandler(store: AutoOpenStore = createFileAutoOpenStore()) { + let chain: Promise = Promise.resolve(); + return async function handleClaim({ workspaceIds }: RpcInput) { + const run = async () => { + const opened = await store.load(); + const claimed = claimUnclaimedWorkspaces(opened, workspaceIds); + if (claimed.length === 0) return { claimed }; + const next = new Set(opened); + for (const workspaceId of claimed) next.add(workspaceId); + await store.persist(next); + return { claimed }; + }; + const result = chain.then(run, run); + chain = result.catch(() => {}); + return result; + }; +} diff --git a/agent-crew/shared/auto-open.ts b/agent-crew/shared/auto-open.ts new file mode 100644 index 00000000..90515925 --- /dev/null +++ b/agent-crew/shared/auto-open.ts @@ -0,0 +1,20 @@ +import { defineRpc } from "@getpaseo/plugin"; +import { z } from "zod"; + +export const claimOpenedWorkspaces = defineRpc({ + name: "agent-crew.auto-open.claim", + input: z.object({ workspaceIds: z.array(z.string().min(1)).min(1).max(1000) }), + output: z.object({ claimed: z.array(z.string()) }), +}); + +export function claimUnclaimedWorkspaces( + opened: ReadonlySet, + candidates: readonly string[], +): string[] { + const claimed: string[] = []; + for (const workspaceId of candidates) { + if (opened.has(workspaceId) || claimed.includes(workspaceId)) continue; + claimed.push(workspaceId); + } + return claimed; +} diff --git a/agent-crew/tests/auto-open.shared.test.ts b/agent-crew/tests/auto-open.shared.test.ts new file mode 100644 index 00000000..ecdcc662 --- /dev/null +++ b/agent-crew/tests/auto-open.shared.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, test } from "vitest"; +import { + createAutoOpenClaimHandler, + createFileAutoOpenStore, + createMemoryAutoOpenStore, +} from "../server/auto-open"; +import { claimUnclaimedWorkspaces } from "../shared/auto-open"; + +describe("claimUnclaimedWorkspaces", () => { + test("returns only candidates that were never opened", () => { + const opened = new Set(["ws-1", "ws-2"]); + expect(claimUnclaimedWorkspaces(opened, ["ws-1", "ws-3", "ws-2", "ws-4"])).toEqual([ + "ws-3", + "ws-4", + ]); + }); + + test("removes duplicate candidates within one batch", () => { + expect(claimUnclaimedWorkspaces(new Set(), ["ws-1", "ws-1", "ws-2", "ws-1"])).toEqual([ + "ws-1", + "ws-2", + ]); + }); + + test("returns an empty list when everything was opened", () => { + const opened = new Set(["ws-1"]); + expect(claimUnclaimedWorkspaces(opened, ["ws-1"])).toEqual([]); + }); +}); + +describe("createAutoOpenClaimHandler", () => { + test("claims and persists each workspace exactly once", async () => { + const handler = createAutoOpenClaimHandler(createMemoryAutoOpenStore()); + + const first = await handler({ workspaceIds: ["ws-1", "ws-2"] }); + const second = await handler({ workspaceIds: ["ws-1", "ws-2", "ws-3"] }); + + expect(first.claimed).toEqual(["ws-1", "ws-2"]); + expect(second.claimed).toEqual(["ws-3"]); + }); + + test("returns nothing new for a repeated workspace after a restart", async () => { + const store = createMemoryAutoOpenStore(); + const firstHandler = createAutoOpenClaimHandler(store); + await firstHandler({ workspaceIds: ["ws-1"] }); + + const secondHandler = createAutoOpenClaimHandler(store); + const result = await secondHandler({ workspaceIds: ["ws-1"] }); + + expect(result.claimed).toEqual([]); + }); + + test("serializes concurrent claims so no workspace opens twice", async () => { + const store = createMemoryAutoOpenStore(); + const handler = createAutoOpenClaimHandler(store); + + const [first, second] = await Promise.all([ + handler({ workspaceIds: ["ws-1"] }), + handler({ workspaceIds: ["ws-1"] }), + ]); + + const totalClaims = first.claimed.length + second.claimed.length; + expect(totalClaims).toBe(1); + }); +}); + +describe("createFileAutoOpenStore", () => { + test("loads an empty set when the file does not exist", async () => { + const store = createFileAutoOpenStore("/nonexistent/agent-crew/auto-open.json"); + expect(await store.load()).toEqual(new Set()); + }); + + test("persists and reloads claimed workspaces", async () => { + const directory = await import("node:fs/promises").then((fs) => fs.mkdtemp("/tmp/agent-crew-")); + const filePath = `${directory}/auto-open.json`; + const store = createFileAutoOpenStore(filePath); + + await store.persist(new Set(["ws-1", "ws-2"])); + expect(await store.load()).toEqual(new Set(["ws-1", "ws-2"])); + + await store.persist(new Set(["ws-1"])); + expect(await store.load()).toEqual(new Set(["ws-1"])); + + await import("node:fs/promises").then((fs) => fs.rm(directory, { recursive: true })); + }); + + test("recovers from a corrupted store file", async () => { + const directory = await import("node:fs/promises").then((fs) => fs.mkdtemp("/tmp/agent-crew-")); + const filePath = `${directory}/auto-open.json`; + await import("node:fs/promises").then((fs) => fs.writeFile(filePath, "not json", "utf8")); + + const store = createFileAutoOpenStore(filePath); + expect(await store.load()).toEqual(new Set()); + + await import("node:fs/promises").then((fs) => fs.rm(directory, { recursive: true })); + }); +}); From 21c374c95211f23c74e060e6141678f52eaae311 Mon Sep 17 00:00:00 2001 From: Omer Cohen <639682+omercnet@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:07:32 +0000 Subject: [PATCH 2/5] feat(agent-crew): make Explorer auto-open opt-in --- agent-crew/README.md | 32 +-- agent-crew/client/auto-open.ts | 157 ++++++++--- agent-crew/client/settings-screen.tsx | 78 ++++++ agent-crew/index.client.tsx | 30 ++- agent-crew/index.server.ts | 2 + agent-crew/server/auto-open.ts | 27 +- agent-crew/shared/auto-open.ts | 6 +- agent-crew/shared/settings.ts | 17 ++ agent-crew/tests/auto-open.shared.test.ts | 307 +++++++++++++++++++++- 9 files changed, 575 insertions(+), 81 deletions(-) create mode 100644 agent-crew/client/settings-screen.tsx create mode 100644 agent-crew/shared/settings.ts diff --git a/agent-crew/README.md b/agent-crew/README.md index fd11e6cc..07f1a458 100644 --- a/agent-crew/README.md +++ b/agent-crew/README.md @@ -3,9 +3,6 @@ See and control every managed Paseo agent working in a workspace. Agent Crew adds a workspace-context Explorer panel plus an `Open Agent Crew` Command Center item. -This checkout is a local fork of `omercnet/paseo-plugins/agent-crew` with one addition: -automatic Explorer tab opening, described below. - It answers "which crews are active here, and which agent needs me now?" while preserving managed parent-child relationships across workspace boundaries. @@ -25,18 +22,12 @@ that no private organization names remained in the rendered page. ## Auto open -The fork opens the Agent Crew Explorer tab once per workspace, without manual action: - -- The client watches the workspace directory on the selected host through - `paseo.workspaces.subscribe()` plus a full paged `paseo.workspaces.list()` seed. -- Each new workspace ID is claimed through the `agent-crew.auto-open.claim` RPC. The - daemon-side handler records claimed IDs in `$PASEO_HOME/plugin-data/agent-crew/auto-open.json` - (`~/.paseo` by default) with atomic writes, so a workspace is claimed at most once across - daemon restarts, plugin reloads, and reconnects. -- Claimed workspaces get `client.openPanel("crew", { workspaceId, location: "explorer" })`. -- Closing the tab stays closed: the claim is permanent, so the plugin never re-opens a - workspace the user closed. New workspaces, including agent-created worktrees, open on - first sight even if they were created while no client was connected. +When enabled in the plugin settings screen, Agent Crew opens the Explorer tab for each workspace once on that host. + +- Default: off. +- Enabling starts watching the selected host's workspace directory and opens every currently unclaimed workspace, then any new workspaces once. +- Disabling stops new opens. Re-enabling resumes for workspaces that have not already been opened on that host. +- Workspace claims are remembered on the daemon so the same workspace does not reopen after reloads or reconnects. - A failed claim batch is retried once after two seconds, then dropped with a logged error. ## What it shows @@ -113,16 +104,7 @@ Agent Crew intentionally stays inside the public Paseo plugin SDK. Paseo plugins are trusted, unsandboxed code. Review the source before installing it. -This fork installs from the local checkout on the Paseo daemon host: - -```bash -paseo plugin install /home/builder/workspace/paseo-plugins/agent-crew -``` - -Source changes load with `paseo plugin reload agent-crew`; `paseo plugin update` does not -apply to directory sources. - -Upstream installation, from npm: +From npm: ```bash paseo plugin install npm:@omercnet/paseo-agent-crew diff --git a/agent-crew/client/auto-open.ts b/agent-crew/client/auto-open.ts index 797f92ab..6a5b84f0 100644 --- a/agent-crew/client/auto-open.ts +++ b/agent-crew/client/auto-open.ts @@ -1,72 +1,105 @@ import type { PluginClientContext } from "@getpaseo/plugin/client"; -import { claimOpenedWorkspaces } from "../shared/auto-open"; +import { claimOpenedWorkspaces, MAX_AUTO_OPEN_CLAIM_BATCH } from "../shared/auto-open"; +import { + agentCrewSettingsRpc, + agentCrewSettingsSchema, + DEFAULT_AUTO_OPEN_EXPLORER, +} from "../shared/settings"; const FLUSH_DELAY_MS = 400; const RETRY_DELAY_MS = 2000; const PAGE_LIMIT = 200; -const MAX_PAGES = 10; +const SETTINGS_POLL_MS = 15_000; + +export interface AutoOpenManager { + setEnabled(enabled: boolean): void; + dispose(): void; +} async function listAllWorkspaceIds(paseo: PluginClientContext["paseo"]): Promise { const workspaceIds: string[] = []; + const seenCursors = new Set(); let cursor: string | undefined; - for (let page = 0; page < MAX_PAGES; page += 1) { + while (true) { const result = await paseo.workspaces.list({ page: { limit: PAGE_LIMIT, ...(cursor ? { cursor } : {}) }, }); for (const workspace of result.entries) workspaceIds.push(workspace.id); - cursor = result.pageInfo.hasMore ? (result.pageInfo.nextCursor ?? undefined) : undefined; - if (!cursor) break; + if (!result.pageInfo.hasMore) break; + const nextCursor = result.pageInfo.nextCursor ?? undefined; + if (!nextCursor || seenCursors.has(nextCursor)) break; + seenCursors.add(nextCursor); + cursor = nextCursor; } return workspaceIds; } export function startAutoOpen(client: PluginClientContext): () => void { const seen = new Set(); - const retried = new Set(); + const failureCounts = new Map(); let pending: string[] = []; - let flushTimer: ReturnType | null = null; - let stopped = false; + let flushTimer: NodeJS.Timeout | undefined; + let closed = false; - function scheduleFlush(delayMs: number) { - if (stopped || flushTimer) return; + function scheduleFlush(delayMs: number, force = false) { + if (closed) return; + if (flushTimer && !force) return; + clearTimeout(flushTimer); flushTimer = setTimeout(() => { - flushTimer = null; + flushTimer = undefined; void flush(); }, delayMs); } function enqueue(workspaceId: string) { - if (stopped || seen.has(workspaceId)) return; + if (closed || seen.has(workspaceId)) return; seen.add(workspaceId); pending.push(workspaceId); scheduleFlush(FLUSH_DELAY_MS); } + function queueRetry(workspaceIds: readonly string[]): string[] { + const retryable: string[] = []; + for (const workspaceId of workspaceIds) { + const failures = failureCounts.get(workspaceId) ?? 0; + if (failures >= 1) continue; + failureCounts.set(workspaceId, failures + 1); + seen.delete(workspaceId); + retryable.push(workspaceId); + } + return retryable; + } + async function flush() { const batch = pending; pending = []; - if (batch.length === 0) return; + if (batch.length === 0 || closed) return; + let chunkIndex = 0; + let currentChunk: string[] = []; try { - const { claimed } = await client.rpc(claimOpenedWorkspaces, { workspaceIds: batch }); - for (const workspaceId of claimed) { - try { - client.openPanel("crew", { workspaceId, location: "explorer" }); - } catch (error) { - console.error( - `Agent Crew auto-open could not open the panel for workspace ${workspaceId}`, - error, - ); + for (chunkIndex = 0; chunkIndex < batch.length; chunkIndex += MAX_AUTO_OPEN_CLAIM_BATCH) { + if (closed) return; + currentChunk = batch.slice(chunkIndex, chunkIndex + MAX_AUTO_OPEN_CLAIM_BATCH); + const { claimed } = await client.rpc(claimOpenedWorkspaces, { workspaceIds: currentChunk }); + for (const workspaceId of claimed) { + try { + client.openPanel("crew", { workspaceId, location: "explorer" }); + } catch (error) { + console.error( + `Agent Crew auto-open could not open the panel for workspace ${workspaceId}`, + error, + ); + } } } } catch (error) { console.error("Agent Crew auto-open claim failed", error); - const retryable = batch.filter((workspaceId) => !retried.has(workspaceId)); - for (const workspaceId of retryable) { - retried.add(workspaceId); - seen.delete(workspaceId); - pending.push(workspaceId); - } - if (retryable.length > 0) scheduleFlush(RETRY_DELAY_MS); + if (closed) return; + const failedChunk = queueRetry(currentChunk); + const remainder = batch.slice(chunkIndex + MAX_AUTO_OPEN_CLAIM_BATCH); + const spillover = pending; + pending = [...failedChunk, ...remainder, ...spillover]; + if (pending.length > 0) scheduleFlush(RETRY_DELAY_MS, true); } } @@ -77,15 +110,77 @@ export function startAutoOpen(client: PluginClientContext): () => void { void listAllWorkspaceIds(client.paseo) .then((workspaceIds) => { + if (closed) return; for (const workspaceId of workspaceIds) enqueue(workspaceId); }) .catch((error: unknown) => { + if (closed) return; console.error("Agent Crew auto-open directory listing failed", error); }); return () => { - stopped = true; - if (flushTimer) clearTimeout(flushTimer); + closed = true; + clearTimeout(flushTimer); unsubscribeWorkspaces(); }; } + +export function createAutoOpenManager(client: PluginClientContext): AutoOpenManager { + let enabled = DEFAULT_AUTO_OPEN_EXPLORER; + let activeCleanup: (() => void) | undefined; + let disposed = false; + let refreshRunning = false; + let generation = 0; + const pollTimer = setInterval(() => { + void refreshEnabled(); + }, SETTINGS_POLL_MS); + + function syncEnabled(nextEnabled: boolean) { + if (disposed || nextEnabled === enabled) return; + enabled = nextEnabled; + activeCleanup?.(); + activeCleanup = enabled ? startAutoOpen(client) : undefined; + } + + async function refreshEnabled() { + if (disposed || refreshRunning) return; + refreshRunning = true; + const requestGeneration = generation; + try { + const result = await client.rpc(agentCrewSettingsRpc.read, {}); + if (disposed || requestGeneration !== generation) return; + if (result.status !== "ready") { + syncEnabled(DEFAULT_AUTO_OPEN_EXPLORER); + return; + } + const parsed = agentCrewSettingsSchema.safeParse(result.values); + if (!parsed.success) { + syncEnabled(DEFAULT_AUTO_OPEN_EXPLORER); + return; + } + syncEnabled(parsed.data.autoOpenExplorer); + } catch (error) { + if (disposed || requestGeneration !== generation) return; + console.error("Agent Crew auto-open settings read failed", error); + syncEnabled(DEFAULT_AUTO_OPEN_EXPLORER); + } finally { + refreshRunning = false; + } + } + + void refreshEnabled(); + + return { + setEnabled(nextEnabled: boolean) { + generation += 1; + syncEnabled(nextEnabled); + }, + dispose() { + disposed = true; + generation += 1; + clearInterval(pollTimer); + activeCleanup?.(); + activeCleanup = undefined; + }, + }; +} diff --git a/agent-crew/client/settings-screen.tsx b/agent-crew/client/settings-screen.tsx new file mode 100644 index 00000000..97746942 --- /dev/null +++ b/agent-crew/client/settings-screen.tsx @@ -0,0 +1,78 @@ +import { type PluginSurfaceProps, useSettings } from "@getpaseo/plugin/client"; +import { + SettingsAction, + SettingsCard, + SettingsSection, + SettingsSwitch, +} from "@getpaseo/plugin/client/ui"; +import { useMemo } from "react"; +import { Text } from "react-native"; +import { agentCrewSettings } from "../shared/settings"; + +export interface AgentCrewSettingsScreenProps extends PluginSurfaceProps { + onAutoOpenChange(enabled: boolean): void; +} + +export function AgentCrewSettingsScreen({ onAutoOpenChange, theme }: AgentCrewSettingsScreenProps) { + const settings = useSettings(agentCrewSettings); + const styles = useMemo( + () => ({ + text: { color: theme.colors.foreground }, + error: { color: theme.colors.statusDanger }, + }), + [theme], + ); + + if (settings.status === "loading") return Loading settings…; + if (settings.status !== "ready") { + return ( + + + {settings.error} + + + + {settings.status === "invalid" ? ( + { + if (await settings.reset()) onAutoOpenChange(false); + }} + /> + ) : null} + + + ); + } + + return ( + + + { + const saved = await settings.save( + { ...settings.values, autoOpenExplorer }, + settings.revision, + ); + if (saved) onAutoOpenChange(autoOpenExplorer); + }} + /> + + {settings.saveError ? ( + + {settings.saveError} + + ) : null} + + ); +} diff --git a/agent-crew/index.client.tsx b/agent-crew/index.client.tsx index 66ee5def..f4e6deb3 100644 --- a/agent-crew/index.client.tsx +++ b/agent-crew/index.client.tsx @@ -1,9 +1,23 @@ -import type { PluginClientContext } from "@getpaseo/plugin/client"; -import { startAutoOpen } from "./client/auto-open"; +import type { PluginClientContext, PluginSurfaceProps } from "@getpaseo/plugin/client"; +import { createAutoOpenManager } from "./client/auto-open"; import { AgentCrew } from "./client/main"; +import { AgentCrewSettingsScreen } from "./client/settings-screen"; +import { agentCrewSettings } from "./shared/settings"; export default function contribute(client: PluginClientContext) { - client.addWorkspacePanel({ + const autoOpen = createAutoOpenManager(client); + + function SettingsSurface(props: PluginSurfaceProps) { + return ; + } + + const removeSettings = client.addSettingsScreen({ + id: agentCrewSettings.id, + title: "Agent Crew settings", + icon: "Settings", + Component: SettingsSurface, + }); + const removeWorkspacePanel = client.addWorkspacePanel({ id: "crew", title: "Agent Crew", icon: "Network", @@ -11,7 +25,7 @@ export default function contribute(client: PluginClientContext) { locations: ["explorer"], Component: AgentCrew, }); - client.addCommandCenterItem({ + const removeOpenCrew = client.addCommandCenterItem({ id: "open-crew", title: "Open Agent Crew", icon: "Network", @@ -21,5 +35,11 @@ export default function contribute(client: PluginClientContext) { openPanel("crew", { location: "explorer" }); }, }); - return startAutoOpen(client); + + return () => { + removeOpenCrew(); + removeWorkspacePanel(); + removeSettings(); + autoOpen.dispose(); + }; } diff --git a/agent-crew/index.server.ts b/agent-crew/index.server.ts index eb9bc042..b937e379 100644 --- a/agent-crew/index.server.ts +++ b/agent-crew/index.server.ts @@ -1,8 +1,10 @@ import type { PluginServerContext } from "@getpaseo/plugin/server"; import { createAutoOpenClaimHandler } from "./server/auto-open"; import { claimOpenedWorkspaces } from "./shared/auto-open"; +import { agentCrewSettings } from "./shared/settings"; export default function contribute(server: PluginServerContext) { + server.registerSettings(agentCrewSettings); server.handle(claimOpenedWorkspaces, createAutoOpenClaimHandler()); return () => {}; } diff --git a/agent-crew/server/auto-open.ts b/agent-crew/server/auto-open.ts index 8e1fcb41..1034d77f 100644 --- a/agent-crew/server/auto-open.ts +++ b/agent-crew/server/auto-open.ts @@ -15,16 +15,18 @@ export function autoOpenDataFilePath(): string { } function parseWorkspaceIds(raw: string): Set { - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - return new Set(); + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) { + throw new Error("auto-open store must contain an array of workspace IDs"); } - if (!Array.isArray(parsed)) return new Set(); - return new Set( - parsed.filter((value): value is string => typeof value === "string" && value.length > 0), - ); + const values = new Set(); + for (const value of parsed) { + if (typeof value !== "string" || value.length === 0) { + throw new Error("auto-open store must contain only non-empty string workspace IDs"); + } + values.add(value); + } + return values; } export function createFileAutoOpenStore(filePath: string = autoOpenDataFilePath()): AutoOpenStore { @@ -37,7 +39,12 @@ export function createFileAutoOpenStore(filePath: string = autoOpenDataFilePath( if ((error as NodeJS.ErrnoException).code === "ENOENT") return new Set(); throw error; } - return parseWorkspaceIds(raw); + try { + return parseWorkspaceIds(raw); + } catch (error) { + console.error("Agent Crew auto-open store load failed", { filePath, error }); + throw error; + } }, async persist(next) { await mkdir(dirname(filePath), { recursive: true }); diff --git a/agent-crew/shared/auto-open.ts b/agent-crew/shared/auto-open.ts index 90515925..6d96f5fa 100644 --- a/agent-crew/shared/auto-open.ts +++ b/agent-crew/shared/auto-open.ts @@ -1,9 +1,13 @@ import { defineRpc } from "@getpaseo/plugin"; import { z } from "zod"; +export const MAX_AUTO_OPEN_CLAIM_BATCH = 1000; + export const claimOpenedWorkspaces = defineRpc({ name: "agent-crew.auto-open.claim", - input: z.object({ workspaceIds: z.array(z.string().min(1)).min(1).max(1000) }), + input: z.object({ + workspaceIds: z.array(z.string().min(1)).min(1).max(MAX_AUTO_OPEN_CLAIM_BATCH), + }), output: z.object({ claimed: z.array(z.string()) }), }); diff --git a/agent-crew/shared/settings.ts b/agent-crew/shared/settings.ts new file mode 100644 index 00000000..95586874 --- /dev/null +++ b/agent-crew/shared/settings.ts @@ -0,0 +1,17 @@ +import { defineSettings, settingsRpc } from "@getpaseo/plugin"; +import { z } from "zod"; + +export const DEFAULT_AUTO_OPEN_EXPLORER = false; + +export const agentCrewSettingsSchema = z.object({ + autoOpenExplorer: z.boolean().default(DEFAULT_AUTO_OPEN_EXPLORER), +}); + +export const agentCrewSettings = defineSettings({ + id: "agent-crew-preferences", + scope: "host", + version: 1, + schema: agentCrewSettingsSchema, +}); + +export const agentCrewSettingsRpc = settingsRpc(agentCrewSettings.id); diff --git a/agent-crew/tests/auto-open.shared.test.ts b/agent-crew/tests/auto-open.shared.test.ts index ecdcc662..67a4d7f5 100644 --- a/agent-crew/tests/auto-open.shared.test.ts +++ b/agent-crew/tests/auto-open.shared.test.ts @@ -1,10 +1,142 @@ -import { describe, expect, test } from "vitest"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import type { PluginClientContext } from "@getpaseo/plugin/client"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { createAutoOpenManager } from "../client/auto-open"; import { createAutoOpenClaimHandler, createFileAutoOpenStore, createMemoryAutoOpenStore, } from "../server/auto-open"; -import { claimUnclaimedWorkspaces } from "../shared/auto-open"; +import { + claimOpenedWorkspaces, + claimUnclaimedWorkspaces, + MAX_AUTO_OPEN_CLAIM_BATCH, +} from "../shared/auto-open"; +import { agentCrewSettingsRpc } from "../shared/settings"; + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +type SettingsReadResult = { + status: "ready"; + values: { autoOpenExplorer: boolean }; + revision: string; +}; + +type ClaimResult = { claimed: string[] }; + +type Deferred = { + promise: Promise; + resolve(value: T): void; + reject(error: unknown): void; +}; + +function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function createMockClient( + options: { + initialEnabled?: boolean; + workspaceIds?: string[]; + settingsRead?: Promise; + claimDeferred?: Deferred; + claimResponder?: ( + workspaceIds: string[], + callIndex: number, + ) => Promise | ClaimResult; + } = {}, +) { + const { + initialEnabled = false, + workspaceIds = [], + settingsRead, + claimDeferred, + claimResponder, + } = options; + let subscriber: ((update: { kind: string; workspace: { id: string } }) => void) | undefined; + let subscribeCalls = 0; + let unsubscribeCalls = 0; + let listCalls = 0; + const claimCalls: string[][] = []; + + const client = { + rpc: vi.fn(async (contract: unknown, input: { workspaceIds?: string[] }) => { + if (contract === agentCrewSettingsRpc.read) { + if (settingsRead) return settingsRead; + return { + status: "ready", + values: { autoOpenExplorer: initialEnabled }, + revision: "rev-1", + } satisfies SettingsReadResult; + } + if (contract === claimOpenedWorkspaces) { + const workspaceIds = input.workspaceIds ?? []; + claimCalls.push(workspaceIds); + if (claimResponder) return claimResponder(workspaceIds, claimCalls.length); + if (claimDeferred) return claimDeferred.promise; + return { claimed: workspaceIds } satisfies ClaimResult; + } + throw new Error("unexpected rpc"); + }), + openPanel: vi.fn(), + paseo: { + workspaces: { + list: vi.fn(async ({ page }: { page?: { limit?: number; cursor?: string } } = {}) => { + listCalls += 1; + const limit = page?.limit ?? workspaceIds.length; + const start = page?.cursor ? Number(page.cursor) : 0; + const entries = workspaceIds.slice(start, start + limit).map((id) => ({ id })); + const next = start + limit; + const hasMore = next < workspaceIds.length; + return { + entries, + pageInfo: { hasMore, nextCursor: hasMore ? String(next) : null }, + }; + }), + subscribe: vi.fn((callback: typeof subscriber) => { + subscriber = callback; + subscribeCalls += 1; + return () => { + unsubscribeCalls += 1; + subscriber = undefined; + }; + }), + }, + }, + } as unknown as PluginClientContext; + + return { + client, + emitWorkspace(workspaceId: string) { + subscriber?.({ kind: "upsert", workspace: { id: workspaceId } }); + }, + get subscribeCalls() { + return subscribeCalls; + }, + get unsubscribeCalls() { + return unsubscribeCalls; + }, + get listCalls() { + return listCalls; + }, + get claimCalls() { + return claimCalls; + }, + }; +} + +function ids(count: number, prefix = "ws"): string[] { + return Array.from({ length: count }, (_, index) => `${prefix}-${index + 1}`); +} describe("claimUnclaimedWorkspaces", () => { test("returns only candidates that were never opened", () => { @@ -71,7 +203,7 @@ describe("createFileAutoOpenStore", () => { }); test("persists and reloads claimed workspaces", async () => { - const directory = await import("node:fs/promises").then((fs) => fs.mkdtemp("/tmp/agent-crew-")); + const directory = await mkdtemp("/tmp/agent-crew-"); const filePath = `${directory}/auto-open.json`; const store = createFileAutoOpenStore(filePath); @@ -81,17 +213,174 @@ describe("createFileAutoOpenStore", () => { await store.persist(new Set(["ws-1"])); expect(await store.load()).toEqual(new Set(["ws-1"])); - await import("node:fs/promises").then((fs) => fs.rm(directory, { recursive: true })); + await rm(directory, { recursive: true }); }); - test("recovers from a corrupted store file", async () => { - const directory = await import("node:fs/promises").then((fs) => fs.mkdtemp("/tmp/agent-crew-")); + test("rejects corrupted JSON instead of reopening every workspace", async () => { + const directory = await mkdtemp("/tmp/agent-crew-"); const filePath = `${directory}/auto-open.json`; - await import("node:fs/promises").then((fs) => fs.writeFile(filePath, "not json", "utf8")); + await writeFile(filePath, "not json", "utf8"); const store = createFileAutoOpenStore(filePath); - expect(await store.load()).toEqual(new Set()); + await expect(store.load()).rejects.toThrow(); + + await rm(directory, { recursive: true }); + }); + + test("rejects non-array state instead of treating it as empty", async () => { + const directory = await mkdtemp("/tmp/agent-crew-"); + const filePath = `${directory}/auto-open.json`; + await writeFile(filePath, "{}", "utf8"); + + const store = createFileAutoOpenStore(filePath); + await expect(store.load()).rejects.toThrow(); + + await rm(directory, { recursive: true }); + }); + + test("rejects arrays with empty or mixed invalid entries", async () => { + const directory = await mkdtemp("/tmp/agent-crew-"); + const filePath = `${directory}/auto-open.json`; + await writeFile(filePath, JSON.stringify(["ws-1", "", null]), "utf8"); + + const store = createFileAutoOpenStore(filePath); + await expect(store.load()).rejects.toThrow(); + + await rm(directory, { recursive: true }); + }); +}); + +describe("createAutoOpenManager", () => { + test("stays idle while the setting is disabled", async () => { + vi.useFakeTimers(); + const harness = createMockClient(); + const manager = createAutoOpenManager(harness.client); + + await vi.advanceTimersByTimeAsync(0); + + expect(harness.client.paseo.workspaces.subscribe).not.toHaveBeenCalled(); + expect(harness.client.rpc).toHaveBeenCalledWith(agentCrewSettingsRpc.read, {}); + expect(harness.client.openPanel).not.toHaveBeenCalled(); + + manager.dispose(); + }); + + test("starts enabled from settings and seeds the explorer once", async () => { + vi.useFakeTimers(); + const harness = createMockClient({ + initialEnabled: true, + workspaceIds: ids(2001), + }); + const manager = createAutoOpenManager(harness.client); + + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(400); + + expect(harness.subscribeCalls).toBe(1); + expect(harness.listCalls).toBe(11); + expect(harness.claimCalls).toHaveLength(3); + expect(harness.claimCalls[0]).toHaveLength(MAX_AUTO_OPEN_CLAIM_BATCH); + expect(harness.claimCalls[1]).toHaveLength(MAX_AUTO_OPEN_CLAIM_BATCH); + expect(harness.claimCalls[2]).toEqual(["ws-2001"]); + expect(harness.client.openPanel).toHaveBeenCalledTimes(2001); + + manager.dispose(); + }); + + test("restarts exactly one subscription across disable and re-enable", async () => { + vi.useFakeTimers(); + const harness = createMockClient(); + const manager = createAutoOpenManager(harness.client); + + await vi.advanceTimersByTimeAsync(0); + + manager.setEnabled(true); + expect(harness.subscribeCalls).toBe(1); + + manager.setEnabled(false); + expect(harness.unsubscribeCalls).toBe(1); + + manager.setEnabled(true); + expect(harness.subscribeCalls).toBe(2); + + manager.dispose(); + }); + + test("finishes an already-started chunk after disable and stops before the next chunk", async () => { + vi.useFakeTimers(); + const claimDeferred = deferred(); + const harness = createMockClient({ + initialEnabled: true, + workspaceIds: ids(MAX_AUTO_OPEN_CLAIM_BATCH + 1), + claimDeferred, + }); + const manager = createAutoOpenManager(harness.client); + + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(400); + + expect(harness.claimCalls).toEqual([ids(MAX_AUTO_OPEN_CLAIM_BATCH)]); + manager.setEnabled(false); + claimDeferred.resolve({ claimed: ids(MAX_AUTO_OPEN_CLAIM_BATCH) }); + await claimDeferred.promise; + await Promise.resolve(); + + expect(harness.claimCalls).toHaveLength(1); + expect(harness.client.openPanel).toHaveBeenCalledTimes(MAX_AUTO_OPEN_CLAIM_BATCH); + + manager.dispose(); + }); + + test("retries chunk 2 and chunk 3 separately when each fails once", async () => { + vi.useFakeTimers(); + const harness = createMockClient({ + initialEnabled: true, + workspaceIds: ids(2501), + claimResponder(workspaceIds, callIndex) { + if (callIndex === 2 || callIndex === 4) { + throw new Error(`chunk ${callIndex} failed`); + } + return { claimed: workspaceIds }; + }, + }); + const manager = createAutoOpenManager(harness.client); + + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(400); + await vi.advanceTimersByTimeAsync(2000); + await vi.advanceTimersByTimeAsync(2000); + + expect(harness.claimCalls).toHaveLength(5); + expect(harness.claimCalls[0]).toHaveLength(MAX_AUTO_OPEN_CLAIM_BATCH); + expect(harness.claimCalls[1]).toHaveLength(MAX_AUTO_OPEN_CLAIM_BATCH); + expect(harness.claimCalls[2]).toHaveLength(MAX_AUTO_OPEN_CLAIM_BATCH); + expect(harness.claimCalls[3]).toHaveLength(501); + expect(harness.claimCalls[4]).toHaveLength(501); + expect(harness.client.openPanel).toHaveBeenCalledTimes(2501); + + manager.dispose(); + }); + + test("ignores a stale rejected settings read after re-enabling", async () => { + vi.useFakeTimers(); + const settingsRead = deferred(); + const harness = createMockClient({ settingsRead: settingsRead.promise }); + const manager = createAutoOpenManager(harness.client); + + await vi.advanceTimersByTimeAsync(0); + manager.setEnabled(true); + settingsRead.reject(new Error("stale settings read")); + await Promise.resolve(); + + harness.emitWorkspace("workspace-1"); + await vi.advanceTimersByTimeAsync(400); + + expect(harness.subscribeCalls).toBe(1); + expect(harness.client.openPanel).toHaveBeenCalledWith("crew", { + workspaceId: "workspace-1", + location: "explorer", + }); - await import("node:fs/promises").then((fs) => fs.rm(directory, { recursive: true })); + manager.dispose(); }); }); From dbc4a1000b38d87aeceef90683d66d8d23a077a1 Mon Sep 17 00:00:00 2001 From: Omer Cohen <639682+omercnet@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:21:33 +0000 Subject: [PATCH 3/5] fix(agent-crew): serialize auto-open claims --- agent-crew/client/auto-open.ts | 105 +++++++++++----------- agent-crew/tests/auto-open.shared.test.ts | 26 ++++++ 2 files changed, 78 insertions(+), 53 deletions(-) diff --git a/agent-crew/client/auto-open.ts b/agent-crew/client/auto-open.ts index 6a5b84f0..bc4d70af 100644 --- a/agent-crew/client/auto-open.ts +++ b/agent-crew/client/auto-open.ts @@ -11,10 +11,15 @@ const RETRY_DELAY_MS = 2000; const PAGE_LIMIT = 200; const SETTINGS_POLL_MS = 15_000; -export interface AutoOpenManager { +type ClaimJob = { + workspaceIds: string[]; + retried: boolean; +}; + +type AutoOpenManager = { setEnabled(enabled: boolean): void; dispose(): void; -} +}; async function listAllWorkspaceIds(paseo: PluginClientContext["paseo"]): Promise { const workspaceIds: string[] = []; @@ -34,53 +39,49 @@ async function listAllWorkspaceIds(paseo: PluginClientContext["paseo"]): Promise return workspaceIds; } -export function startAutoOpen(client: PluginClientContext): () => void { - const seen = new Set(); - const failureCounts = new Map(); - let pending: string[] = []; +function startAutoOpen(client: PluginClientContext): () => void { + const pending = new Set(); + const jobs: ClaimJob[] = []; let flushTimer: NodeJS.Timeout | undefined; + let pumping = false; let closed = false; - function scheduleFlush(delayMs: number, force = false) { - if (closed) return; - if (flushTimer && !force) return; - clearTimeout(flushTimer); + function schedule(delayMs: number) { + if (closed || pumping || flushTimer) return; flushTimer = setTimeout(() => { flushTimer = undefined; - void flush(); + void pump(); }, delayMs); } function enqueue(workspaceId: string) { - if (closed || seen.has(workspaceId)) return; - seen.add(workspaceId); - pending.push(workspaceId); - scheduleFlush(FLUSH_DELAY_MS); + if (closed) return; + pending.add(workspaceId); + schedule(FLUSH_DELAY_MS); } - function queueRetry(workspaceIds: readonly string[]): string[] { - const retryable: string[] = []; - for (const workspaceId of workspaceIds) { - const failures = failureCounts.get(workspaceId) ?? 0; - if (failures >= 1) continue; - failureCounts.set(workspaceId, failures + 1); - seen.delete(workspaceId); - retryable.push(workspaceId); + function drainPending() { + const workspaceIds = [...pending]; + pending.clear(); + for (let index = 0; index < workspaceIds.length; index += MAX_AUTO_OPEN_CLAIM_BATCH) { + jobs.push({ + workspaceIds: workspaceIds.slice(index, index + MAX_AUTO_OPEN_CLAIM_BATCH), + retried: false, + }); } - return retryable; } - async function flush() { - const batch = pending; - pending = []; - if (batch.length === 0 || closed) return; - let chunkIndex = 0; - let currentChunk: string[] = []; - try { - for (chunkIndex = 0; chunkIndex < batch.length; chunkIndex += MAX_AUTO_OPEN_CLAIM_BATCH) { - if (closed) return; - currentChunk = batch.slice(chunkIndex, chunkIndex + MAX_AUTO_OPEN_CLAIM_BATCH); - const { claimed } = await client.rpc(claimOpenedWorkspaces, { workspaceIds: currentChunk }); + async function pump() { + if (closed || pumping) return; + pumping = true; + drainPending(); + while (!closed && jobs.length > 0) { + const job = jobs.shift(); + if (!job) break; + try { + const { claimed } = await client.rpc(claimOpenedWorkspaces, { + workspaceIds: job.workspaceIds, + }); for (const workspaceId of claimed) { try { client.openPanel("crew", { workspaceId, location: "explorer" }); @@ -91,21 +92,22 @@ export function startAutoOpen(client: PluginClientContext): () => void { ); } } + } catch (error) { + console.error("Agent Crew auto-open claim failed", error); + if (!job.retried) { + jobs.unshift({ ...job, retried: true }); + pumping = false; + schedule(RETRY_DELAY_MS); + return; + } } - } catch (error) { - console.error("Agent Crew auto-open claim failed", error); - if (closed) return; - const failedChunk = queueRetry(currentChunk); - const remainder = batch.slice(chunkIndex + MAX_AUTO_OPEN_CLAIM_BATCH); - const spillover = pending; - pending = [...failedChunk, ...remainder, ...spillover]; - if (pending.length > 0) scheduleFlush(RETRY_DELAY_MS, true); } + pumping = false; + if (!closed && pending.size > 0) schedule(FLUSH_DELAY_MS); } const unsubscribeWorkspaces = client.paseo.workspaces.subscribe((update) => { - if (update.kind !== "upsert") return; - enqueue(update.workspace.id); + if (update.kind === "upsert") enqueue(update.workspace.id); }); void listAllWorkspaceIds(client.paseo) @@ -114,12 +116,13 @@ export function startAutoOpen(client: PluginClientContext): () => void { for (const workspaceId of workspaceIds) enqueue(workspaceId); }) .catch((error: unknown) => { - if (closed) return; - console.error("Agent Crew auto-open directory listing failed", error); + if (!closed) console.error("Agent Crew auto-open directory listing failed", error); }); return () => { closed = true; + pending.clear(); + jobs.length = 0; clearTimeout(flushTimer); unsubscribeWorkspaces(); }; @@ -154,11 +157,7 @@ export function createAutoOpenManager(client: PluginClientContext): AutoOpenMana return; } const parsed = agentCrewSettingsSchema.safeParse(result.values); - if (!parsed.success) { - syncEnabled(DEFAULT_AUTO_OPEN_EXPLORER); - return; - } - syncEnabled(parsed.data.autoOpenExplorer); + syncEnabled(parsed.success ? parsed.data.autoOpenExplorer : DEFAULT_AUTO_OPEN_EXPLORER); } catch (error) { if (disposed || requestGeneration !== generation) return; console.error("Agent Crew auto-open settings read failed", error); @@ -171,7 +170,7 @@ export function createAutoOpenManager(client: PluginClientContext): AutoOpenMana void refreshEnabled(); return { - setEnabled(nextEnabled: boolean) { + setEnabled(nextEnabled) { generation += 1; syncEnabled(nextEnabled); }, diff --git a/agent-crew/tests/auto-open.shared.test.ts b/agent-crew/tests/auto-open.shared.test.ts index 67a4d7f5..fe3a687d 100644 --- a/agent-crew/tests/auto-open.shared.test.ts +++ b/agent-crew/tests/auto-open.shared.test.ts @@ -331,6 +331,32 @@ describe("createAutoOpenManager", () => { manager.dispose(); }); + test("does not start a second claim while one is in flight", async () => { + vi.useFakeTimers(); + const claimDeferred = deferred(); + const harness = createMockClient({ claimDeferred }); + const manager = createAutoOpenManager(harness.client); + + await vi.advanceTimersByTimeAsync(0); + manager.setEnabled(true); + harness.emitWorkspace("workspace-1"); + await vi.advanceTimersByTimeAsync(400); + + harness.emitWorkspace("workspace-2"); + await vi.advanceTimersByTimeAsync(400); + expect(harness.claimCalls).toEqual([["workspace-1"]]); + + manager.setEnabled(false); + claimDeferred.resolve({ claimed: ["workspace-1"] }); + await claimDeferred.promise; + await Promise.resolve(); + + expect(harness.claimCalls).toHaveLength(1); + expect(harness.client.openPanel).toHaveBeenCalledTimes(1); + + manager.dispose(); + }); + test("retries chunk 2 and chunk 3 separately when each fails once", async () => { vi.useFakeTimers(); const harness = createMockClient({ From a248b7a5d9b3eab35683e1bbb099f48dce012a37 Mon Sep 17 00:00:00 2001 From: Omer Cohen <639682+omercnet@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:32:59 +0000 Subject: [PATCH 4/5] fix(agent-crew): auto-open future workspaces only --- agent-crew/README.md | 8 +++--- agent-crew/client/auto-open.ts | 28 --------------------- agent-crew/client/settings-screen.tsx | 2 +- agent-crew/tests/auto-open.shared.test.ts | 30 +++++++++++------------ 4 files changed, 20 insertions(+), 48 deletions(-) diff --git a/agent-crew/README.md b/agent-crew/README.md index 07f1a458..27418b65 100644 --- a/agent-crew/README.md +++ b/agent-crew/README.md @@ -22,12 +22,12 @@ that no private organization names remained in the rendered page. ## Auto open -When enabled in the plugin settings screen, Agent Crew opens the Explorer tab for each workspace once on that host. +When enabled in the plugin settings screen, Agent Crew opens the Explorer tab once for each new workspace observed on that host. - Default: off. -- Enabling starts watching the selected host's workspace directory and opens every currently unclaimed workspace, then any new workspaces once. -- Disabling stops new opens. Re-enabling resumes for workspaces that have not already been opened on that host. -- Workspace claims are remembered on the daemon so the same workspace does not reopen after reloads or reconnects. +- Existing workspaces are not opened when the setting is enabled. +- Disabling stops queued and future opens. Re-enabling resumes observation for workspaces created or updated afterward. +- Workspace claims are remembered on the daemon so reconnects and reloads do not reopen a workspace. - A failed claim batch is retried once after two seconds, then dropped with a logged error. ## What it shows diff --git a/agent-crew/client/auto-open.ts b/agent-crew/client/auto-open.ts index bc4d70af..0a9aa24e 100644 --- a/agent-crew/client/auto-open.ts +++ b/agent-crew/client/auto-open.ts @@ -8,7 +8,6 @@ import { const FLUSH_DELAY_MS = 400; const RETRY_DELAY_MS = 2000; -const PAGE_LIMIT = 200; const SETTINGS_POLL_MS = 15_000; type ClaimJob = { @@ -21,24 +20,6 @@ type AutoOpenManager = { dispose(): void; }; -async function listAllWorkspaceIds(paseo: PluginClientContext["paseo"]): Promise { - const workspaceIds: string[] = []; - const seenCursors = new Set(); - let cursor: string | undefined; - while (true) { - const result = await paseo.workspaces.list({ - page: { limit: PAGE_LIMIT, ...(cursor ? { cursor } : {}) }, - }); - for (const workspace of result.entries) workspaceIds.push(workspace.id); - if (!result.pageInfo.hasMore) break; - const nextCursor = result.pageInfo.nextCursor ?? undefined; - if (!nextCursor || seenCursors.has(nextCursor)) break; - seenCursors.add(nextCursor); - cursor = nextCursor; - } - return workspaceIds; -} - function startAutoOpen(client: PluginClientContext): () => void { const pending = new Set(); const jobs: ClaimJob[] = []; @@ -110,15 +91,6 @@ function startAutoOpen(client: PluginClientContext): () => void { if (update.kind === "upsert") enqueue(update.workspace.id); }); - void listAllWorkspaceIds(client.paseo) - .then((workspaceIds) => { - if (closed) return; - for (const workspaceId of workspaceIds) enqueue(workspaceId); - }) - .catch((error: unknown) => { - if (!closed) console.error("Agent Crew auto-open directory listing failed", error); - }); - return () => { closed = true; pending.clear(); diff --git a/agent-crew/client/settings-screen.tsx b/agent-crew/client/settings-screen.tsx index 97746942..163df914 100644 --- a/agent-crew/client/settings-screen.tsx +++ b/agent-crew/client/settings-screen.tsx @@ -56,7 +56,7 @@ export function AgentCrewSettingsScreen({ onAutoOpenChange, theme }: AgentCrewSe { diff --git a/agent-crew/tests/auto-open.shared.test.ts b/agent-crew/tests/auto-open.shared.test.ts index fe3a687d..e182c16c 100644 --- a/agent-crew/tests/auto-open.shared.test.ts +++ b/agent-crew/tests/auto-open.shared.test.ts @@ -265,24 +265,25 @@ describe("createAutoOpenManager", () => { manager.dispose(); }); - test("starts enabled from settings and seeds the explorer once", async () => { + test("starts enabled without seeding existing workspaces", async () => { vi.useFakeTimers(); const harness = createMockClient({ initialEnabled: true, - workspaceIds: ids(2001), + workspaceIds: ["existing-workspace"], }); const manager = createAutoOpenManager(harness.client); await vi.advanceTimersByTimeAsync(0); - await vi.advanceTimersByTimeAsync(400); expect(harness.subscribeCalls).toBe(1); - expect(harness.listCalls).toBe(11); - expect(harness.claimCalls).toHaveLength(3); - expect(harness.claimCalls[0]).toHaveLength(MAX_AUTO_OPEN_CLAIM_BATCH); - expect(harness.claimCalls[1]).toHaveLength(MAX_AUTO_OPEN_CLAIM_BATCH); - expect(harness.claimCalls[2]).toEqual(["ws-2001"]); - expect(harness.client.openPanel).toHaveBeenCalledTimes(2001); + expect(harness.listCalls).toBe(0); + expect(harness.client.openPanel).not.toHaveBeenCalled(); + + harness.emitWorkspace("future-workspace"); + await vi.advanceTimersByTimeAsync(400); + + expect(harness.claimCalls).toEqual([["future-workspace"]]); + expect(harness.client.openPanel).toHaveBeenCalledTimes(1); manager.dispose(); }); @@ -309,14 +310,12 @@ describe("createAutoOpenManager", () => { test("finishes an already-started chunk after disable and stops before the next chunk", async () => { vi.useFakeTimers(); const claimDeferred = deferred(); - const harness = createMockClient({ - initialEnabled: true, - workspaceIds: ids(MAX_AUTO_OPEN_CLAIM_BATCH + 1), - claimDeferred, - }); + const workspaceIds = ids(MAX_AUTO_OPEN_CLAIM_BATCH + 1); + const harness = createMockClient({ initialEnabled: true, claimDeferred }); const manager = createAutoOpenManager(harness.client); await vi.advanceTimersByTimeAsync(0); + for (const workspaceId of workspaceIds) harness.emitWorkspace(workspaceId); await vi.advanceTimersByTimeAsync(400); expect(harness.claimCalls).toEqual([ids(MAX_AUTO_OPEN_CLAIM_BATCH)]); @@ -359,9 +358,9 @@ describe("createAutoOpenManager", () => { test("retries chunk 2 and chunk 3 separately when each fails once", async () => { vi.useFakeTimers(); + const workspaceIds = ids(2501); const harness = createMockClient({ initialEnabled: true, - workspaceIds: ids(2501), claimResponder(workspaceIds, callIndex) { if (callIndex === 2 || callIndex === 4) { throw new Error(`chunk ${callIndex} failed`); @@ -372,6 +371,7 @@ describe("createAutoOpenManager", () => { const manager = createAutoOpenManager(harness.client); await vi.advanceTimersByTimeAsync(0); + for (const workspaceId of workspaceIds) harness.emitWorkspace(workspaceId); await vi.advanceTimersByTimeAsync(400); await vi.advanceTimersByTimeAsync(2000); await vi.advanceTimersByTimeAsync(2000); From 0b0a6c918e810913704f678d99cd750688d9b297 Mon Sep 17 00:00:00 2001 From: Omer Cohen <639682+omercnet@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:02:08 +0000 Subject: [PATCH 5/5] fix(agent-crew): own workspace update stream --- agent-crew/client/auto-open.ts | 62 ++++++++++++++- agent-crew/tests/auto-open.shared.test.ts | 94 ++++++++++++++++------- 2 files changed, 124 insertions(+), 32 deletions(-) diff --git a/agent-crew/client/auto-open.ts b/agent-crew/client/auto-open.ts index 0a9aa24e..eae945ef 100644 --- a/agent-crew/client/auto-open.ts +++ b/agent-crew/client/auto-open.ts @@ -8,6 +8,7 @@ import { const FLUSH_DELAY_MS = 400; const RETRY_DELAY_MS = 2000; +const PAGE_LIMIT = 200; const SETTINGS_POLL_MS = 15_000; type ClaimJob = { @@ -20,10 +21,31 @@ type AutoOpenManager = { dispose(): void; }; +async function listWorkspaceIds(paseo: PluginClientContext["paseo"]): Promise> { + const workspaceIds = new Set(); + const seenCursors = new Set(); + let cursor: string | undefined; + while (true) { + const result = await paseo.workspaces.list({ + page: { limit: PAGE_LIMIT, ...(cursor ? { cursor } : {}) }, + }); + for (const workspace of result.entries) workspaceIds.add(workspace.id); + if (!result.pageInfo.hasMore) return workspaceIds; + const nextCursor = result.pageInfo.nextCursor ?? undefined; + if (!nextCursor || seenCursors.has(nextCursor)) return workspaceIds; + seenCursors.add(nextCursor); + cursor = nextCursor; + } +} + function startAutoOpen(client: PluginClientContext): () => void { const pending = new Set(); + const buffered = new Set(); const jobs: ClaimJob[] = []; + let known: Set | undefined; let flushTimer: NodeJS.Timeout | undefined; + let removeObserver: (() => void) | undefined; + let releaseSubscription: (() => Promise) | undefined; let pumping = false; let closed = false; @@ -41,6 +63,16 @@ function startAutoOpen(client: PluginClientContext): () => void { schedule(FLUSH_DELAY_MS); } + function observe(workspaceId: string) { + if (!known) { + buffered.add(workspaceId); + return; + } + if (known.has(workspaceId)) return; + known.add(workspaceId); + enqueue(workspaceId); + } + function drainPending() { const workspaceIds = [...pending]; pending.clear(); @@ -87,16 +119,38 @@ function startAutoOpen(client: PluginClientContext): () => void { if (!closed && pending.size > 0) schedule(FLUSH_DELAY_MS); } - const unsubscribeWorkspaces = client.paseo.workspaces.subscribe((update) => { - if (update.kind === "upsert") enqueue(update.workspace.id); - }); + void client.paseo.workspaces + .list({ subscribe: {} }) + .then(({ subscription }) => { + if (closed) return subscription.release(); + releaseSubscription = () => subscription.release(); + removeObserver = subscription.subscribe({ + snapshot() {}, + update(message) { + if (message.type !== "workspace_update" || message.payload.kind !== "upsert") return; + observe(message.payload.workspace.id); + }, + }); + return listWorkspaceIds(client.paseo).then((workspaceIds) => { + if (closed) return; + known = workspaceIds; + for (const workspaceId of buffered) observe(workspaceId); + buffered.clear(); + }); + }) + .catch((error: unknown) => { + if (!closed) console.error("Agent Crew auto-open observation failed", error); + }); return () => { closed = true; pending.clear(); + buffered.clear(); jobs.length = 0; clearTimeout(flushTimer); - unsubscribeWorkspaces(); + removeObserver?.(); + void releaseSubscription?.(); + releaseSubscription = undefined; }; } diff --git a/agent-crew/tests/auto-open.shared.test.ts b/agent-crew/tests/auto-open.shared.test.ts index e182c16c..fa57ae26 100644 --- a/agent-crew/tests/auto-open.shared.test.ts +++ b/agent-crew/tests/auto-open.shared.test.ts @@ -62,9 +62,17 @@ function createMockClient( claimDeferred, claimResponder, } = options; - let subscriber: ((update: { kind: string; workspace: { id: string } }) => void) | undefined; + let observer: + | { + snapshot(snapshot: unknown): void; + update(message: { + type: "workspace_update"; + payload: { kind: "upsert"; workspace: { id: string } }; + }): void; + } + | undefined; let subscribeCalls = 0; - let unsubscribeCalls = 0; + let releaseCalls = 0; let listCalls = 0; const claimCalls: string[][] = []; @@ -90,26 +98,46 @@ function createMockClient( openPanel: vi.fn(), paseo: { workspaces: { - list: vi.fn(async ({ page }: { page?: { limit?: number; cursor?: string } } = {}) => { - listCalls += 1; - const limit = page?.limit ?? workspaceIds.length; - const start = page?.cursor ? Number(page.cursor) : 0; - const entries = workspaceIds.slice(start, start + limit).map((id) => ({ id })); - const next = start + limit; - const hasMore = next < workspaceIds.length; - return { - entries, - pageInfo: { hasMore, nextCursor: hasMore ? String(next) : null }, - }; - }), - subscribe: vi.fn((callback: typeof subscriber) => { - subscriber = callback; - subscribeCalls += 1; - return () => { - unsubscribeCalls += 1; - subscriber = undefined; - }; - }), + list: vi.fn( + async ({ + page, + subscribe, + }: { + page?: { limit?: number; cursor?: string }; + subscribe?: object; + } = {}) => { + listCalls += 1; + const limit = page?.limit ?? workspaceIds.length; + const start = page?.cursor ? Number(page.cursor) : 0; + const entries = workspaceIds.slice(start, start + limit).map((id) => ({ id })); + const next = start + limit; + const hasMore = next < workspaceIds.length; + const result = { + entries, + pageInfo: { hasMore, nextCursor: hasMore ? String(next) : null }, + }; + if (!subscribe) return result; + return { + ...result, + subscriptionId: "workspace-subscription", + subscription: { + subscriptionId: "workspace-subscription", + ready: Promise.resolve({ ...result, subscriptionId: "workspace-subscription" }), + subscribe(nextObserver: typeof observer) { + observer = nextObserver; + subscribeCalls += 1; + return () => { + observer = undefined; + }; + }, + async release() { + releaseCalls += 1; + observer = undefined; + }, + }, + }; + }, + ), }, }, } as unknown as PluginClientContext; @@ -117,13 +145,16 @@ function createMockClient( return { client, emitWorkspace(workspaceId: string) { - subscriber?.({ kind: "upsert", workspace: { id: workspaceId } }); + observer?.update({ + type: "workspace_update", + payload: { kind: "upsert", workspace: { id: workspaceId } }, + }); }, get subscribeCalls() { return subscribeCalls; }, - get unsubscribeCalls() { - return unsubscribeCalls; + get releaseCalls() { + return releaseCalls; }, get listCalls() { return listCalls; @@ -258,7 +289,7 @@ describe("createAutoOpenManager", () => { await vi.advanceTimersByTimeAsync(0); - expect(harness.client.paseo.workspaces.subscribe).not.toHaveBeenCalled(); + expect(harness.listCalls).toBe(0); expect(harness.client.rpc).toHaveBeenCalledWith(agentCrewSettingsRpc.read, {}); expect(harness.client.openPanel).not.toHaveBeenCalled(); @@ -276,9 +307,13 @@ describe("createAutoOpenManager", () => { await vi.advanceTimersByTimeAsync(0); expect(harness.subscribeCalls).toBe(1); - expect(harness.listCalls).toBe(0); + expect(harness.listCalls).toBe(2); expect(harness.client.openPanel).not.toHaveBeenCalled(); + harness.emitWorkspace("existing-workspace"); + await vi.advanceTimersByTimeAsync(400); + expect(harness.claimCalls).toEqual([]); + harness.emitWorkspace("future-workspace"); await vi.advanceTimersByTimeAsync(400); @@ -296,12 +331,14 @@ describe("createAutoOpenManager", () => { await vi.advanceTimersByTimeAsync(0); manager.setEnabled(true); + await vi.advanceTimersByTimeAsync(0); expect(harness.subscribeCalls).toBe(1); manager.setEnabled(false); - expect(harness.unsubscribeCalls).toBe(1); + expect(harness.releaseCalls).toBe(1); manager.setEnabled(true); + await vi.advanceTimersByTimeAsync(0); expect(harness.subscribeCalls).toBe(2); manager.dispose(); @@ -338,6 +375,7 @@ describe("createAutoOpenManager", () => { await vi.advanceTimersByTimeAsync(0); manager.setEnabled(true); + await vi.advanceTimersByTimeAsync(0); harness.emitWorkspace("workspace-1"); await vi.advanceTimersByTimeAsync(400);