From a7d9bb34fce2633ecb847786d5bc03ce212f741c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 31 Oct 2026 17:48:42 +0700 Subject: [PATCH 01/58] fix(plugins): keep rewound conversations on their retained branch (#8368) Refresh direct plugin history from authoritative native persistence after conversation/combined rewind, matching the built-in providers' history refresh strategy. Reopen/replay rebuilds translation baselines, rewind tokens and child bindings while preserving outward subscriptions; file-only rewind stays unchanged. Keep replay internal and replace the manager timeline after it completes. Preserve the committed native handle and close routing across refresh failures so retry and reload recover the retained branch. The full native session reload is intentionally inefficient, pending a smarter timeline cut that restores translation state, reused IDs, children and permissions without reopening. Closes #4667 Supersedes #4668 and the rewind-history proposal in #4754. Verified with 72 targeted tests, green CI, and real OMP browser UI A/B/C -> rewind B -> replacement D with native history/timeline equality and a clean browser reload. Co-authored-by: Matt Cowger Co-authored-by: Omer Cohen <639682+omercnet@users.noreply.github.com> --- .../server/src/server/agent/agent-manager.ts | 4 + .../src/server/agent/plugin-provider.test.ts | 568 ++++++++++++++++++ .../src/server/agent/plugin-provider.ts | 193 +++++- public-docs/plugins/providers.md | 5 + 4 files changed, 745 insertions(+), 25 deletions(-) diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index a761e723c1..c69a574849 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -3505,6 +3505,10 @@ export class AgentManager { "agent.rewind.complete", ); } catch (error) { + // Native rewind can commit a fork before history refresh fails. Recovery + // must resume that latest handle rather than the abandoned conversation. + this.refreshSessionPersistence(agent); + await this.persistSnapshot(agent); this.logger.warn( { err: error, agentId, provider: agent.provider, messageId, mode }, "agent.rewind.failed", diff --git a/packages/server/src/server/agent/plugin-provider.test.ts b/packages/server/src/server/agent/plugin-provider.test.ts index 101a19a39f..a9cf3b0ed5 100644 --- a/packages/server/src/server/agent/plugin-provider.test.ts +++ b/packages/server/src/server/agent/plugin-provider.test.ts @@ -53,6 +53,7 @@ const CAPABILITIES = [ interface ProviderHarnessOptions { capabilities?: ProviderConnection["capabilities"]; completeTurn?: boolean; + closeAcknowledgementOnly?: boolean; openChildren?: (rootSessionId: string, emit: (event: ProviderEvent) => void) => void; handleInput?: (input: ProviderInput, emit: (event: ProviderEvent) => void) => Promise; } @@ -208,6 +209,7 @@ function createProviderHarness(options: ProviderHarnessOptions = {}) { } if (input.type === "session.close") { emit({ type: "session.closed", sessionId: input.sessionId }); + if (options.closeAcknowledgementOnly) return; } if ("requestId" in input) { emit({ type: "request.completed", requestId: input.requestId }); @@ -246,6 +248,572 @@ function eventsOfType(events: AgentStreamEvent[], type: AgentStreamEvent["type"] return events.filter((event) => event.type === type); } +function createRewindProviderHarness( + options: { + reuseAnswerId?: boolean; + forkOnRevert?: boolean; + handleInput?: ProviderHarnessOptions["handleInput"]; + } = {}, +) { + const nativeMessages: string[] = []; + let originalMessages: string[] | null = null; + const harness = createProviderHarness({ + closeAcknowledgementOnly: true, + capabilities: [ + ...CAPABILITIES, + "session.revert.conversation", + "session.revert.files", + "session.revert.both", + ], + async handleInput(input, emit) { + if (await options.handleInput?.(input, emit)) return true; + if (input.type === "session.open" && input.history === "replay") { + emit({ + type: "session.opened", + requestId: input.requestId, + sessionId: input.sessionId, + capabilities: [ + ...CAPABILITIES, + "session.revert.conversation", + "session.revert.files", + "session.revert.both", + ], + restoration: "core", + persistence: input.persistence ?? { version: 1, data: { token: "root" } }, + cwd: input.config.cwd, + }); + const messages = + options.forkOnRevert && + (input.persistence?.data as { token?: string })?.token === "root" && + originalMessages + ? originalMessages + : nativeMessages; + for (const [index, text] of messages.entries()) { + emit({ + type: "timeline.item", + sessionId: input.sessionId, + item: { type: "user_message", id: `user-${text}`, text, revertToken: text }, + }); + emit({ + type: "timeline.item", + sessionId: input.sessionId, + item: { + type: "assistant_message", + id: options.reuseAnswerId ? `answer-${index + 1}` : `answer-${text}`, + text: `Reply ${text}`, + }, + }); + } + emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId }); + return true; + } + if (input.type === "session.prompt") { + if (input.prompt.input.type !== "message") throw new Error("Expected a message"); + const text = input.prompt.input.content + .flatMap((part) => (part.type === "text" ? [part.text] : [])) + .join(""); + nativeMessages.push(text); + const turnId = `turn-${text}`; + emit({ + type: "session.prompt_result", + sessionId: input.sessionId, + clientMessageId: input.prompt.clientMessageId, + result: { type: "turn", turnId }, + }); + emit({ type: "session.turn", sessionId: input.sessionId, turnId, state: "started" }); + emit({ + type: "timeline.item", + sessionId: input.sessionId, + item: { + type: "user_message", + id: `user-${text}`, + text, + clientMessageId: input.prompt.clientMessageId, + revertToken: text, + }, + }); + emit({ + type: "timeline.item", + sessionId: input.sessionId, + item: { + type: "assistant_message", + id: options.reuseAnswerId ? `answer-${nativeMessages.length}` : `answer-${text}`, + text: `Reply ${text}`, + }, + }); + emit({ type: "session.turn", sessionId: input.sessionId, turnId, state: "completed" }); + return true; + } + if (input.type === "session.revert") { + if (options.forkOnRevert && input.scope !== "files") { + originalMessages = [...nativeMessages]; + emit({ + type: "session.persistence", + sessionId: input.sessionId, + persistence: { version: 1, data: { token: "fork" } }, + }); + } + if (input.scope !== "files") + nativeMessages.splice(nativeMessages.indexOf(String(input.token))); + emit({ type: "request.completed", requestId: input.requestId }); + return true; + } + return false; + }, + }); + return { ...harness, nativeMessages }; +} + +async function withRewindAgent( + run: (context: { + manager: AgentManager; + agentId: string; + nativeMessages: string[]; + messages: () => string[]; + }) => Promise, + options: Parameters[0] = {}, +) { + const harness = createRewindProviderHarness(options); + const logger = createTestLogger(); + const registry = new PluginAgentClientRegistry(logger); + registry.replace([harness.registration]); + const manager = new AgentManager({ + logger, + clients: registry.clients(), + providerDefinitions: registry.definitions(), + }); + const agent = await manager.createAgent( + { provider: harness.registration.id, cwd: tmpdir() }, + undefined, + { workspaceId: undefined }, + ); + const messages = () => + manager + .getTimeline(agent.id) + .flatMap((item) => + item.type === "user_message" || item.type === "assistant_message" ? [item.text] : [], + ); + try { + await manager.runAgent(agent.id, "A", { clientMessageId: "client-A" }); + await manager.runAgent(agent.id, "B", { clientMessageId: "client-B" }); + await manager.runAgent(agent.id, "C", { clientMessageId: "client-C" }); + expect(messages()).toEqual(["A", "Reply A", "B", "Reply B", "C", "Reply C"]); + await run({ manager, agentId: agent.id, nativeMessages: harness.nativeMessages, messages }); + } finally { + await manager.closeAgent(agent.id); + await registry.shutdown(); + } +} + +test("conversation rewind discards withdrawn plugin turns before and after a replacement prompt", async () => { + await withRewindAgent(async ({ manager, agentId, nativeMessages, messages }) => { + await manager.rewind(agentId, "user-B", "conversation"); + const afterRewind = messages(); + expect(nativeMessages).toEqual(["A"]); + await manager.runAgent(agentId, "D", { clientMessageId: "client-D" }); + const afterReplacement = messages(); + expect(nativeMessages).toEqual(["A", "D"]); + expect({ afterRewind, afterReplacement }).toEqual({ + afterRewind: ["A", "Reply A"], + afterReplacement: ["A", "Reply A", "D", "Reply D"], + }); + }); +}); + +test("file-only plugin rewind preserves the conversation before and after a replacement prompt", async () => { + await withRewindAgent(async ({ manager, agentId, nativeMessages, messages }) => { + await manager.rewind(agentId, "user-B", "files"); + expect(nativeMessages).toEqual(["A", "B", "C"]); + expect(messages()).toEqual(["A", "Reply A", "B", "Reply B", "C", "Reply C"]); + await manager.runAgent(agentId, "D", { clientMessageId: "client-D" }); + expect(messages()).toEqual(["A", "Reply A", "B", "Reply B", "C", "Reply C", "D", "Reply D"]); + }); +}); + +test("plugin rewind restores translation state when a replacement reuses a discarded item id", async () => { + const harness = createRewindProviderHarness({ reuseAnswerId: true }); + const registry = new PluginAgentClientRegistry(createTestLogger()); + registry.replace([harness.registration]); + const client = registry.clients()[harness.registration.id]!; + const session = await client.createSession({ provider: harness.registration.id, cwd: tmpdir() }); + const live: AgentStreamEvent[] = []; + try { + await session.run("A"); + await session.run("B"); + session.subscribe((event) => live.push(event)); + await session.revertConversation!({ messageId: "user-B" }); + live.length = 0; + await session.run("B revised"); + const assistant = live.flatMap((event) => + event.type === "timeline" && event.item.type === "assistant_message" ? [event.item.text] : [], + ); + expect(harness.nativeMessages).toEqual(["A", "B revised"]); + expect(assistant).toEqual(["Reply B revised"]); + } finally { + await session.close(); + await registry.shutdown(); + } +}); + +async function withPluginRewindSession( + options: Parameters[0], + run: ( + session: Awaited>, + harness: ReturnType, + ) => Promise, +) { + const harness = createRewindProviderHarness(options); + const registry = new PluginAgentClientRegistry(createTestLogger()); + registry.replace([harness.registration]); + const client = registry.clients()[harness.registration.id]!; + const session = await client.createSession({ provider: harness.registration.id, cwd: tmpdir() }); + try { + await session.run("A"); + await session.run("B"); + await session.run("C"); + await run(session, harness); + } finally { + await session.close(); + await registry.shutdown(); + } +} + +async function pluginMessages(session: Awaited>) { + const events = await Array.fromAsync(session.streamHistory()); + return events.flatMap((event) => + event.type === "timeline" && + (event.item.type === "user_message" || event.item.type === "assistant_message") + ? [event.item.text] + : [], + ); +} + +test("combined plugin rewind replays the retained native branch", async () => { + await withPluginRewindSession({}, async (session) => { + await session.revertBoth!({ messageId: "user-B" }); + expect(await pluginMessages(session)).toEqual(["A", "Reply A"]); + await session.revertConversation!({ messageId: "user-A" }); + expect(await pluginMessages(session)).toEqual([]); + }); +}); + +test("plugin rewind resumes the latest persistence rather than the original handle", async () => { + await withPluginRewindSession( + { + handleInput: async (input, emit) => { + if (input.type === "session.revert") + emit({ + type: "session.persistence", + sessionId: input.sessionId, + persistence: { version: 1, data: { token: "fork" } }, + }); + return false; + }, + }, + async (session, harness) => { + await session.revertConversation!({ messageId: "user-B" }); + expect(harness.inputs.findLast((input) => input.type === "session.open")).toMatchObject({ + persistence: { version: 1, data: { token: "fork" } }, + history: "replay", + }); + expect(await pluginMessages(session)).toEqual(["A", "Reply A"]); + }, + ); +}); + +test("failed native plugin rewind preserves history and does not reopen", async () => { + await withPluginRewindSession( + { + handleInput: async (input, emit) => { + if (input.type !== "session.revert") return false; + emit({ + type: "request.failed", + requestId: input.requestId, + error: { message: "native rewind failed" }, + }); + return true; + }, + }, + async (session, harness) => { + await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow( + "native rewind failed", + ); + expect(await pluginMessages(session)).toEqual([ + "A", + "Reply A", + "B", + "Reply B", + "C", + "Reply C", + ]); + expect(harness.inputs.filter((input) => input.type === "session.open")).toHaveLength(1); + }, + ); +}); + +test("failed plugin rewind replay blocks prompts until retry without repeating native rewind", async () => { + let failReplay = true; + await withPluginRewindSession( + { + handleInput: async (input, emit) => { + if (input.type !== "session.open" || input.history !== "replay" || !failReplay) + return false; + failReplay = false; + emit({ + type: "request.failed", + requestId: input.requestId, + error: { message: "replay failed" }, + }); + return true; + }, + }, + async (session, harness) => { + await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow( + "replay failed", + ); + await expect(session.run("D")).rejects.toThrow("Retry the rewind or reload"); + await session.revertConversation!({ messageId: "user-B" }); + await session.run("D"); + expect(await pluginMessages(session)).toEqual(["A", "Reply A", "D", "Reply D"]); + expect(harness.inputs.filter((input) => input.type === "session.revert")).toHaveLength(1); + }, + ); +}); + +test("retrying a failed plugin rewind replay replaces the manager timeline before prompting", async () => { + let failReplay = true; + await withRewindAgent( + async ({ manager, agentId, messages }) => { + await expect(manager.rewind(agentId, "user-B", "conversation")).rejects.toThrow( + "replay failed", + ); + await expect(manager.runAgent(agentId, "D")).rejects.toThrow("Retry the rewind or reload"); + await manager.rewind(agentId, "user-B", "conversation"); + expect(messages()).toEqual(["A", "Reply A"]); + await manager.runAgent(agentId, "D"); + expect(messages()).toEqual(["A", "Reply A", "D", "Reply D"]); + }, + { + handleInput: async (input, emit) => { + if (input.type !== "session.open" || input.history !== "replay" || !failReplay) + return false; + failReplay = false; + emit({ + type: "request.failed", + requestId: input.requestId, + error: { message: "replay failed" }, + }); + return true; + }, + }, + ); +}); + +test("reload after failed plugin rewind replay resumes the committed fork", async () => { + let failReplay = true; + await withRewindAgent( + async ({ manager, agentId, messages }) => { + await expect(manager.rewind(agentId, "user-B", "conversation")).rejects.toThrow( + "replay failed", + ); + await manager.reloadAgentSession(agentId, undefined, { rehydrateFromDisk: true }); + await manager.hydrateTimelineFromProvider(agentId, { force: true }); + expect(messages()).toEqual(["A", "Reply A"]); + }, + { + forkOnRevert: true, + handleInput: async (input, emit) => { + if (input.type === "session.close") { + emit({ type: "request.completed", requestId: input.requestId }); + return true; + } + if (input.type !== "session.open" || input.history !== "replay" || !failReplay) + return false; + failReplay = false; + emit({ + type: "request.failed", + requestId: input.requestId, + error: { message: "replay failed" }, + }); + return true; + }, + }, + ); +}); + +test("closing during plugin rewind replay closes each native binding once", async () => { + let opened!: () => void; + let release!: () => void; + const replayStarted = new Promise((resolve) => { + opened = resolve; + }); + const replayAllowed = new Promise((resolve) => { + release = resolve; + }); + await withPluginRewindSession( + { + handleInput: async (input) => { + if (input.type === "session.open" && input.history === "replay") { + opened(); + await replayAllowed; + } + return false; + }, + }, + async (session, harness) => { + const rewind = session.revertConversation!({ messageId: "user-B" }); + await replayStarted; + const closing = session.close(); + release(); + await Promise.all([rewind, closing]); + const closes = harness.inputs.flatMap((input) => + input.type === "session.close" ? [input.sessionId] : [], + ); + expect(closes).toHaveLength(2); + expect(new Set(closes).size).toBe(2); + }, + ); +}); + +test("plugin rewind retries a failed close before reopening the native writer", async () => { + let failClose = true; + await withPluginRewindSession( + { + handleInput: async (input, emit) => { + if (input.type !== "session.close" || !failClose) return false; + failClose = false; + emit({ + type: "request.failed", + requestId: input.requestId, + error: { message: "close failed" }, + }); + return true; + }, + }, + async (session, harness) => { + await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow( + "close failed", + ); + expect(harness.inputs.filter((input) => input.type === "session.open")).toHaveLength(1); + expect(await pluginMessages(session)).toEqual(["A", "Reply A"]); + expect(harness.inputs.filter((input) => input.type === "session.revert")).toHaveLength(1); + }, + ); +}); + +test("plugin rewind retries permission settlement before retiring its session", async () => { + let failPermission = true; + await withPluginRewindSession( + { + handleInput: async (input) => { + if (input.type !== "session.permission" || !failPermission) return false; + failPermission = false; + throw new Error("permission delivery failed"); + }, + }, + async (session, harness) => { + const opened = harness.inputs.find((input) => input.type === "session.open")!; + if (opened.type !== "session.open") throw new Error("Missing root"); + harness.emit({ + type: "session.permission", + sessionId: opened.sessionId, + request: { id: "pending", name: "tool", kind: "tool" }, + }); + await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow( + "permission delivery failed", + ); + expect(session.getPendingPermissions()).toHaveLength(1); + expect(harness.inputs.filter((input) => input.type === "session.close")).toHaveLength(0); + expect(await pluginMessages(session)).toEqual(["A", "Reply A"]); + expect(session.getPendingPermissions()).toEqual([]); + expect(harness.inputs.filter((input) => input.type === "session.permission")).toHaveLength(2); + }, + ); +}); + +test("plugin rewind replaces child listeners and settles old pending permissions", async () => { + await withPluginRewindSession({}, async (session, harness) => { + const root = harness.inputs.find((input) => input.type === "session.open")!; + if (root.type !== "session.open") throw new Error("Missing root"); + harness.emit({ + type: "session.permission", + sessionId: root.sessionId, + request: { id: "old-permission", name: "tool", kind: "tool", title: "Run tool" }, + }); + expect(session.getPendingPermissions()).toHaveLength(1); + await session.revertConversation!({ messageId: "user-B" }); + expect(session.getPendingPermissions()).toEqual([]); + expect(harness.inputs).toContainEqual( + expect.objectContaining({ + type: "session.permission", + permissionId: "old-permission", + response: { behavior: "deny", message: "Session replaced after rewind" }, + }), + ); + harness.emit({ + type: "timeline.item", + sessionId: "child-1", + item: { type: "assistant_message", id: "child-message", text: "Discarded child update" }, + }); + const history = await Array.fromAsync(session.streamHistory()); + expect(history.filter((event) => event.type === "provider_subagent")).toEqual([]); + const reopened = harness.inputs.findLast((input) => input.type === "session.open")!; + if (reopened.type !== "session.open") throw new Error("Missing reopened root"); + harness.emit({ + type: "session.opened", + sessionId: "child-1", + parentSessionId: reopened.sessionId, + capabilities: [], + restoration: "parent", + cwd: tmpdir(), + }); + harness.emit({ + type: "timeline.item", + sessionId: "child-1", + item: { type: "assistant_message", id: "child-message", text: "New child reply" }, + }); + const next = await Array.fromAsync(session.streamHistory()); + expect(next).toContainEqual( + expect.objectContaining({ + type: "provider_subagent", + event: expect.objectContaining({ + type: "timeline", + item: expect.objectContaining({ text: "New child reply" }), + }), + }), + ); + }); +}); + +test("nonpersisted plugin sessions reject conversation rewind before native mutation", async () => { + await withPluginRewindSession( + { + handleInput: async (input, emit) => { + if (input.type !== "session.open") return false; + emit({ + type: "session.opened", + requestId: input.requestId, + sessionId: input.sessionId, + capabilities: [...CAPABILITIES, "session.revert.conversation", "session.revert.files"], + restoration: "core", + cwd: input.config.cwd, + }); + emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId }); + return true; + }, + }, + async (session, harness) => { + expect(session.capabilities.supportsRewindConversation).toBe(false); + await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow( + "requires session persistence", + ); + expect(harness.inputs.filter((input) => input.type === "session.revert")).toEqual([]); + await session.revertFiles!({ messageId: "user-B" }); + expect(harness.nativeMessages).toEqual(["A", "B", "C"]); + }, + ); +}); + function openNestedChildren(rootSessionId: string, emit: (event: ProviderEvent) => void) { for (const [sessionId, parentSessionId] of [ ["a", rootSessionId], diff --git a/packages/server/src/server/agent/plugin-provider.ts b/packages/server/src/server/agent/plugin-provider.ts index f09f6349b0..2d20056930 100644 --- a/packages/server/src/server/agent/plugin-provider.ts +++ b/packages/server/src/server/agent/plugin-provider.ts @@ -603,6 +603,7 @@ class ProviderRuntimeSession { >(); private readonly activeTurnIds = new Set(); private terminal = false; + private closePromise: Promise | null = null; config: ProviderConfigState = { models: [], modes: [], thinkingOptions: [], settings: [] }; commands: Array<{ name: string; description: string; argumentHint?: string }> = []; persistence: ProviderPersistence | null = null; @@ -724,6 +725,14 @@ class ProviderRuntimeSession { this.runtime.removeSession(this.id, this.providerSessionId); return; } + this.closePromise ??= this.closeNativeSession().catch((error) => { + this.closePromise = null; + throw error; + }); + await this.closePromise; + } + + private async closeNativeSession(): Promise { try { await this.runtime.complete({ type: "session.close", @@ -732,9 +741,9 @@ class ProviderRuntimeSession { }); } catch (error) { if (!this.runtime.isClosed) throw error; - } finally { - this.runtime.removeSession(this.id, this.providerSessionId); } + // A failed close still needs its routing for a subsequent session.closed ack. + this.runtime.removeSession(this.id, this.providerSessionId); } beginOpen(requestId: string): Promise { @@ -1092,11 +1101,40 @@ class PluginAgentClient implements AgentClient { persistence: input.persistence, history: input.history, }); - const session = new PluginAgentSession(this.provider, bridge, () => { - this.rootsBySession.delete(bridge.id); - }); - this.rootsBySession.set(bridge.id, session); - this.attachPendingChildren(); + const forget = () => { + for (const [id, owner] of this.rootsBySession) { + if (owner === session) this.rootsBySession.delete(id); + } + }; + const bind = (next: ProviderRuntimeSession) => { + this.rootsBySession.set(next.id, session); + this.attachPendingChildren(); + }; + const config = mapSessionConfig(input.config, input.launchContext, input.persist); + const session = new PluginAgentSession( + this.provider, + bridge, + forget, + async (persistence, state) => { + forget(); + return await this.runtime.openSession({ + sessionId: randomUUID(), + config: { + ...config, + model: state.model, + mode: state.mode, + thinkingOption: state.thinkingOption, + settings: Object.fromEntries( + state.settings.map((setting) => [setting.id, setting.value]), + ), + }, + persistence, + history: "replay", + }); + }, + bind, + ); + bind(bridge); return session; } @@ -1157,16 +1195,32 @@ class PluginAgentSession implements AgentSession { private readonly revertTokens = new Map(); private readonly timelineSnapshots = new Map(); private readonly subagentIdsBySession = new Map(); - private readonly childUnsubscribes = new Map void>(); + private readonly children = new Map< + string, + { session: ProviderRuntimeSession; unsubscribe: () => void } + >(); private readonly childSnapshots = new Map>(); private unsubscribe: (() => void) | null = null; private currentTurnId: string | null = null; private closed = false; + private replay: { + persistence: ProviderPersistence; + config: ProviderConfigState; + messageId: string; + scope: "conversation" | "both"; + closed: boolean; + } | null = null; + private replaying: Promise | null = null; constructor( readonly provider: string, - private readonly bridge: ProviderRuntimeSession, + private bridge: ProviderRuntimeSession, private readonly onClose: () => void, + private readonly reopen: ( + persistence: ProviderPersistence, + state: ProviderConfigState, + ) => Promise, + private readonly onBind: (bridge: ProviderRuntimeSession) => void, ) { this.subagentIdsBySession.set(bridge.id, null); for (const event of bridge.history) this.accept(event, false); @@ -1183,7 +1237,13 @@ class PluginAgentSession implements AgentSession { } get capabilities(): AgentCapabilityFlags { - return agentCapabilities(this.bridge.negotiatedCapabilities); + const capabilities = agentCapabilities(this.bridge.negotiatedCapabilities); + const resumable = this.canReplayHistory; + return { + ...capabilities, + supportsRewindConversation: capabilities.supportsRewindConversation && resumable, + supportsRewindBoth: capabilities.supportsRewindBoth && resumable, + }; } get features(): AgentFeature[] { @@ -1211,6 +1271,7 @@ class PluginAgentSession implements AgentSession { prompt: AgentPromptInput, options: AgentRunOptions = {}, ): Promise<{ turnId: string }> { + this.assertReplayComplete(); const clientMessageId = options.clientMessageId ?? randomUUID(); const result = await this.bridge.prompt({ clientMessageId, @@ -1233,6 +1294,7 @@ class PluginAgentSession implements AgentSession { prompt: AgentPromptInput, options: SteerActiveTurnOptions, ): Promise { + this.assertReplayComplete(); if (!this.bridge.negotiatedCapabilities.includes("prompt.steer")) { return { status: "unavailable" }; } @@ -1256,6 +1318,7 @@ class PluginAgentSession implements AgentSession { } async *streamHistory(): AsyncGenerator { + await this.ensureReplay(); for (const event of this.history.read(this.provider)) yield event; } @@ -1283,6 +1346,7 @@ class PluginAgentSession implements AgentSession { } async setMode(modeId: string): Promise { + this.assertReplayComplete(); await this.bridge.configure({ mode: modeId }); } @@ -1319,17 +1383,10 @@ class PluginAgentSession implements AgentSession { async close(): Promise { if (this.closed) return; this.closed = true; - this.unsubscribe?.(); - this.unsubscribe = null; - for (const unsubscribe of this.childUnsubscribes.values()) unsubscribe(); - this.childUnsubscribes.clear(); - this.subagentIdsBySession.clear(); - this.history.clear(); - this.timelineSnapshots.clear(); - this.childSnapshots.clear(); - this.revertTokens.clear(); - this.pendingPermissions.clear(); - this.permissionResponses.clear(); + this.listeners.clear(); + await this.replaying?.catch(() => undefined); + await this.clearBinding(); + this.replay = null; this.listeners.clear(); this.onClose(); await this.bridge.close(); @@ -1344,14 +1401,17 @@ class PluginAgentSession implements AgentSession { } async setModel(modelId: string | null): Promise { + this.assertReplayComplete(); await this.bridge.configure({ model: modelId }); } async setThinkingOption(thinkingOptionId: string | null): Promise { + this.assertReplayComplete(); await this.bridge.configure({ thinkingOption: thinkingOptionId }); } async setFeature(featureId: string, value: unknown): Promise { + this.assertReplayComplete(); await this.bridge.configure({ settings: { [featureId]: toJsonValue(value, `setting '${featureId}'`) }, }); @@ -1415,10 +1475,10 @@ class PluginAgentSession implements AgentSession { const snapshots = new Map(); this.childSnapshots.set(childId, snapshots); for (const event of child.history) this.acceptChildEvent(childId, event, snapshots); - this.childUnsubscribes.set( - child.id, - child.onEvent((event) => this.acceptChildEvent(childId, event, snapshots)), - ); + this.children.set(child.id, { + session: child, + unsubscribe: child.onEvent((event) => this.acceptChildEvent(childId, event, snapshots)), + }); } private accept(event: ProviderEvent, live: boolean): void { @@ -1636,7 +1696,84 @@ class PluginAgentSession implements AgentSession { } } + private async clearBinding(): Promise { + this.unsubscribe?.(); + this.unsubscribe = null; + for (const child of this.children.values()) { + child.unsubscribe(); + await child.session.close(); + } + this.children.clear(); + this.subagentIdsBySession.clear(); + this.history.clear(); + this.timelineSnapshots.clear(); + this.childSnapshots.clear(); + this.revertTokens.clear(); + this.pendingPermissions.clear(); + this.permissionResponses.clear(); + this.currentTurnId = null; + } + + private assertReplayComplete(): void { + if (this.replay) { + throw new Error( + "Plugin rewind history refresh is incomplete. Retry the rewind or reload the agent before continuing.", + ); + } + } + + // A native rewind has already committed. Until replay succeeds, neither history + // nor a new prompt may use the retired binding. Keep its latest handle for retry. + private async ensureReplay(): Promise { + if (!this.replay) return; + if (this.closed) throw new StaleProviderSessionError(this.id); + if (!this.replaying) { + this.replaying = this.replayBinding().finally(() => { + this.replaying = null; + }); + } + await this.replaying; + } + + private async replayBinding(): Promise { + const replay = this.replay!; + if (!replay.closed) { + for (const request of this.pendingPermissions.values()) { + await this.respondToPermission(request.id, { + behavior: "deny", + message: "Session replaced after rewind", + }); + this.pendingPermissions.delete(request.id); + this.permissionResponses.delete(request.id); + } + await this.clearBinding(); + await this.bridge.close(); + replay.closed = true; + } + const bridge = await this.reopen(replay.persistence, replay.config); + this.bridge = bridge; + this.subagentIdsBySession.set(bridge.id, null); + for (const event of bridge.history) this.accept(event, false); + this.unsubscribe = bridge.onEvent((event) => this.accept(event, true)); + this.onBind(bridge); + this.replay = null; + } + + private get canReplayHistory(): boolean { + return ( + this.bridge.restoration === "core" && + this.bridge.persistence !== null && + this.bridge.negotiatedCapabilities.includes("session.persistence") + ); + } + private async revert(messageId: string, scope: "conversation" | "files" | "both") { + const pending = this.replay; + await this.ensureReplay(); + if (pending?.messageId === messageId && pending.scope === scope) return; + if (scope !== "files" && !this.canReplayHistory) { + throw new Error("Plugin conversation rewind requires session persistence for history replay"); + } const token = this.revertTokens.get(messageId); if (token === undefined) throw new Error(`No provider revert token for message ${messageId}`); await this.bridge.revert({ @@ -1646,6 +1783,12 @@ class PluginAgentSession implements AgentSession { token, scope, }); + if (scope !== "files") { + const persistence = this.bridge.persistence; + if (!persistence) throw new Error("Plugin rewind removed session persistence"); + this.replay = { persistence, config: this.bridge.config, messageId, scope, closed: false }; + await this.ensureReplay(); + } } private publish(event: AgentStreamEvent): void { diff --git a/public-docs/plugins/providers.md b/public-docs/plugins/providers.md index 6fbe766c61..9a87dfb155 100644 --- a/public-docs/plugins/providers.md +++ b/public-docs/plugins/providers.md @@ -287,6 +287,11 @@ Paseo refreshes an agent by closing its current provider session and opening it configuration and persistence. Re-read credentials, environment, global configuration, and MCP servers during `session.open`. There is no separate reload operation. +Conversation and combined rewind require a core-restored session with persistence. After a +successful rewind, Paseo closes the session and reopens its latest persistence value with +`history: "replay"`. Publish the retained native branch before `session.ready`, including its +children. File-only rewind keeps the current session and conversation history. + Use `restoration: "core"` when Paseo can reopen a session from persistence. A provider-owned child uses `restoration: "parent"`; emit it as another `session.opened` event with `parentSessionId` and recreate it while restoring the parent. From 0e5bcbd9f50dfca46a8b5835c4b09f158762003d Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 11:05:06 +0000 Subject: [PATCH 02/58] fix(app): open relative chat links inside nested repositories (#8361) * fix(app): open a nested-repo file when the workspace-root path is missing A workspace that contains another git repo often gets paths written relative to that nested repo. `schema/x.md` is really `repo-a/schema/x.md`. A markdown link was joined onto the workspace root and opened a missing file. Inline code already asked the daemon for a suffix match. Resolved workspace-relative paths that contain a slash now use that same lookup before opening. An exact hit stays on the original path. A suffix hit replaces it and keeps the line range. A failed lookup still opens the original path. Bare names, absolute paths, and `~/` paths are unchanged. Example: - Input: workspace `/repo`, link `schema/x.md`, file at `repo-a/schema/x.md` - Before: open `/repo/schema/x.md` (missing) - After: open `/repo/repo-a/schema/x.md` * fix(app): preserve explicit destinations when finding nested files Relative chat links can name a file inside a nested repository. Retain the suffix lookup while using the original href and document base to preserve absolute paths, preview links, and folder destinations. Co-authored-by: Ouyang Xingyuan * fix(app): keep network share links on their absolute destination Use the shared absolute-path classifier so network share hrefs retain their destination alongside drive and POSIX paths. * fix(app): keep nested-file lookup inside ordered path opening Resolve the original directory before considering suffix matches, and allocate click ordering before asynchronous lookup. The existing opener now owns the complete operation so a slow earlier click cannot replace a later selection. * test(app): load UI modules before test lifecycle hooks Cold module transformation exceeded the lifecycle hook deadline before these suites could run. Complete module initialization before registering the tests, retaining their assertions and timeout settings. --------- Co-authored-by: Ouyang Xingyuan Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../assistant-file-link-tooltip.spec.ts | 54 +++++- packages/app/e2e/browser/folder-links.spec.ts | 5 + packages/app/src/agent-stream/view.tsx | 1 + .../assistant-file-links/path-open.test.ts | 52 +++++- .../app/src/assistant-file-links/path-open.ts | 24 ++- .../src/assistant-file-links/resolver.test.ts | 169 ++++++++++++++++++ .../app/src/assistant-file-links/resolver.ts | 63 ++++++- .../composer/draft/input-draft.live.test.tsx | 14 +- .../app/src/hooks/use-agent-history.test.ts | 16 +- 9 files changed, 364 insertions(+), 34 deletions(-) diff --git a/packages/app/e2e/browser/assistant-file-link-tooltip.spec.ts b/packages/app/e2e/browser/assistant-file-link-tooltip.spec.ts index 69cccc52dd..09a9434995 100644 --- a/packages/app/e2e/browser/assistant-file-link-tooltip.spec.ts +++ b/packages/app/e2e/browser/assistant-file-link-tooltip.spec.ts @@ -1,4 +1,5 @@ -import { writeFile } from "node:fs/promises"; +import { execFileSync } from "node:child_process"; +import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { test, expect, type Page } from "../support/fixtures"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; @@ -67,3 +68,54 @@ test("opening an assistant file link dismisses its tooltip over the file tab", a await agent.cleanup(); } }); + +async function readLinkedGuide(page: Page, linkName: string, content: string): Promise { + await page.getByRole("link", { name: linkName, exact: true }).click(); + await expect(page.getByTestId("markdown-preview-frame").filter({ visible: true })).toContainText( + content, + ); +} + +async function returnToNestedLinks(page: Page): Promise { + await page.getByRole("button", { name: "Nested file links", exact: true }).click(); + await expect(page.getByRole("link", { name: "nested guide", exact: true })).toBeVisible(); +} + +test("assistant directory paths find nested files and preserve exact workspace paths", async ({ + page, +}, testInfo) => { + testInfo.setTimeout(120_000); + const agent = await seedMockAgentWorkspace({ + repoPrefix: "nested-file-link-", + title: "Nested file links", + featureValues: { + mockStreamingAssistantResponse: + "Read [nested guide](schema/x.md), [exact guide](schema/exact.md), and [missing guide](schema/missing.md).", + mockStreamingAssistantIntervalMs: 5, + }, + }); + try { + await mkdir(path.join(agent.cwd, "repo-a/schema"), { recursive: true }); + execFileSync("git", ["init", "-b", "main"], { cwd: path.join(agent.cwd, "repo-a") }); + await writeFile( + path.join(agent.cwd, "repo-a/schema/x.md"), + "# Nested file content\n\nDetails\n", + ); + await mkdir(path.join(agent.cwd, "schema"), { recursive: true }); + await writeFile(path.join(agent.cwd, "schema/exact.md"), "# Exact file content\n"); + await writeFile(path.join(agent.cwd, "repo-a/schema/exact.md"), "# Wrong nested content\n"); + await openAgentRoute(page, agent); + await agent.client.sendAgentMessage(agent.agentId, "Show the file links."); + await agent.client.waitForFinish(agent.agentId, 30_000); + + await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeEnabled(); + await readLinkedGuide(page, "nested guide", "Nested file content"); + await returnToNestedLinks(page); + await readLinkedGuide(page, "exact guide", "Exact file content"); + await returnToNestedLinks(page); + await page.getByRole("link", { name: "missing guide", exact: true }).click(); + await expect(page.getByText("File not found", { exact: true })).toBeVisible(); + } finally { + await agent.cleanup(); + } +}); diff --git a/packages/app/e2e/browser/folder-links.spec.ts b/packages/app/e2e/browser/folder-links.spec.ts index a893fbcb68..358c579357 100644 --- a/packages/app/e2e/browser/folder-links.spec.ts +++ b/packages/app/e2e/browser/folder-links.spec.ts @@ -81,6 +81,8 @@ for (const folder of [ "docs/guides", "docs/guides/", "docs/指南", + "examples/next.js/", + "examples/next.js", ]) { test(`chat folder link opens folder contents: ${folder}`, async ({ page }) => { const session = await seedMockAgentWorkspace({ @@ -92,6 +94,9 @@ for (const folder of [ try { await mkdir(path.join(session.cwd, folder), { recursive: true }); await writeFile(path.join(session.cwd, folder, "guide.md"), "# Folder link destination\n"); + const competingFile = path.join(session.cwd, "repo-a", folder.replace(/\/$/, "")); + await mkdir(path.dirname(competingFile), { recursive: true }); + await writeFile(competingFile, "This is a file, not the requested folder.\n"); await session.client.waitForFinish(session.agentId, 30_000); await page.addInitScript(() => { localStorage.setItem( diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index cb9999745b..72923b4212 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -468,6 +468,7 @@ const AgentStreamViewComponent = forwardRef createInlinePathOpener({ workspaceRoot, + resolveRelativeFiles: true, client, openFile: openInlineFile, openDirectory: openInlineDirectory, diff --git a/packages/app/src/assistant-file-links/path-open.test.ts b/packages/app/src/assistant-file-links/path-open.test.ts index a85d4c8ec0..f54ee88765 100644 --- a/packages/app/src/assistant-file-links/path-open.test.ts +++ b/packages/app/src/assistant-file-links/path-open.test.ts @@ -64,7 +64,10 @@ it("ignores an older probe after a newer file link opens", async () => { const opened: string[] = []; const opener = createInlinePathOpener({ workspaceRoot: "/repo", - client: { listDirectory: () => pendingProbe }, + client: { + listDirectory: () => pendingProbe, + getDirectorySuggestions: async () => ({ entries: [], error: null }), + }, openFile: (location) => { opened.push(location.path); }, @@ -91,7 +94,10 @@ it("ignores a pending probe when its view is left", async () => { const opened: string[] = []; const opener = createInlinePathOpener({ workspaceRoot: "/repo", - client: { listDirectory: () => pendingProbe }, + client: { + listDirectory: () => pendingProbe, + getDirectorySuggestions: async () => ({ entries: [], error: null }), + }, openFile: (location) => { opened.push(location.path); }, @@ -130,3 +136,45 @@ it("sends folders outside the workspace away from the workspace Files panel", as expect(outsideFolders).toEqual(["~/Downloads/contracts", "/tmp/reports"]); expect(workspaceFolders).toEqual(["docs"]); }); + +it("keeps a later selection when an earlier nested-file lookup finishes last", async () => { + const { createInlinePathOpener } = await import("./path-open"); + const lookup = Promise.withResolvers<{ + entries: Array<{ path: string; kind: "file" }>; + error: null; + }>(); + const queries: string[] = []; + const opened: string[] = []; + const opener = createInlinePathOpener({ + workspaceRoot: "/repo", + resolveRelativeFiles: true, + client: { + listDirectory: async () => { + throw new Error("Not a directory"); + }, + getDirectorySuggestions: async ({ query }: { query: string }) => { + queries.push(query); + return lookup.promise; + }, + }, + openFile: (location) => { + opened.push(location.path); + }, + openDirectory: (directory) => { + opened.push(directory); + }, + openOutsideDirectory: (directory) => { + opened.push(directory); + }, + }); + const earlier = opener.open( + { raw: "schema/x.md:2", path: "/repo/schema/x.md", lineStart: 2 }, + "side", + ); + await Promise.resolve(); + expect(queries).toEqual(["schema/x.md"]); + await opener.open({ raw: "./guide.md:2", path: "/repo/guide.md", lineStart: 2 }, "side"); + lookup.resolve({ entries: [{ path: "repo-a/schema/x.md", kind: "file" }], error: null }); + await earlier; + expect(opened).toEqual(["guide.md"]); +}); diff --git a/packages/app/src/assistant-file-links/path-open.ts b/packages/app/src/assistant-file-links/path-open.ts index 7c9cf228f4..7ff50c9a1a 100644 --- a/packages/app/src/assistant-file-links/path-open.ts +++ b/packages/app/src/assistant-file-links/path-open.ts @@ -5,6 +5,7 @@ import { type OpenFileDisposition, } from "@/workspace/file-open"; import { isAbsolutePath } from "@/utils/path"; +import { resolveMissingNestedRelativeFile, type GetDirectorySuggestions } from "./resolver"; interface PathOpenInput { file?: string; @@ -50,7 +51,11 @@ export async function resolveInlinePathOpen(input: PathOpenInput): Promise; + getDirectorySuggestions: GetDirectorySuggestions; + } | null; + resolveRelativeFiles?: boolean; openFile: (location: WorkspaceFileLocation, disposition: OpenFileDisposition) => void; openDirectory: (path: string) => void; openOutsideDirectory: (path: string) => void; @@ -73,10 +78,21 @@ export function createInlinePathOpener(input: { } else if (destination.kind === "outsideDirectory") { input.openOutsideDirectory(destination.directoryPath); } else if (normalized.file) { + const resolvedTarget = + input.resolveRelativeFiles && input.client + ? await resolveMissingNestedRelativeFile({ + target, + workspaceRoot: input.workspaceRoot, + getDirectorySuggestions: input.client.getDirectorySuggestions.bind(input.client), + }) + : target; + if (ownGeneration !== generation) return; + const resolved = normalizeInlinePathTarget(resolvedTarget.path, input.workspaceRoot); + if (!resolved?.file) return; const location = normalizeWorkspaceFileLocation({ - path: normalized.file, - lineStart: target.lineStart, - lineEnd: target.lineEnd, + path: resolved.file, + lineStart: resolvedTarget.lineStart, + lineEnd: resolvedTarget.lineEnd, }); if (location) input.openFile(location, disposition); } diff --git a/packages/app/src/assistant-file-links/resolver.test.ts b/packages/app/src/assistant-file-links/resolver.test.ts index 121c60f5a0..dc38c24c68 100644 --- a/packages/app/src/assistant-file-links/resolver.test.ts +++ b/packages/app/src/assistant-file-links/resolver.test.ts @@ -7,6 +7,7 @@ import { classifyForResolution, fetchDaemonResolution, getAssistantFileLinkToken, + resolveMissingNestedRelativeFile, UnresolvedFileLinkError, type AssistantFileLinkContext, type DirectorySuggestionEntry, @@ -424,6 +425,174 @@ describe("fetchDaemonResolution", () => { }); }); +describe("resolveMissingNestedRelativeFile", () => { + const target = { + raw: "schema/x.md:12-14", + path: "/Users/test/project/schema/x.md", + lineStart: 12, + lineEnd: 14, + }; + + it("opens a nested-repo suffix when the workspace-root path is missing", async () => { + const { getDirectorySuggestions, searches } = suggestionsFromMap({ + "schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }], + }); + + const result = await resolveMissingNestedRelativeFile({ + target, + workspaceRoot: "/Users/test/project", + getDirectorySuggestions, + }); + + expect(searches).toEqual([ + { + query: "schema/x.md", + cwd: "/Users/test/project", + matchMode: "suffix", + limit: 1, + }, + ]); + expect(result).toEqual({ + ...target, + path: "/Users/test/project/repo-a/schema/x.md", + }); + }); + + it("keeps the exact workspace path when suggestions return that relative path", async () => { + const { getDirectorySuggestions } = suggestionsFromMap({ + "schema/x.md": [{ path: "schema/x.md", kind: "file" }], + }); + + const result = await resolveMissingNestedRelativeFile({ + target, + workspaceRoot: "/Users/test/project", + getDirectorySuggestions, + }); + + expect(result).toEqual(target); + }); + + it("keeps the original target when suggestions are empty", async () => { + const { getDirectorySuggestions } = suggestionsFromMap({}); + + await expect( + resolveMissingNestedRelativeFile({ + target, + workspaceRoot: "/Users/test/project", + getDirectorySuggestions, + }), + ).resolves.toEqual(target); + }); + + it("keeps the original target when the daemon throws", async () => { + await expect( + resolveMissingNestedRelativeFile({ + target, + workspaceRoot: "/Users/test/project", + getDirectorySuggestions: unavailableSuggestions, + }), + ).resolves.toEqual(target); + }); + + it.each([ + ["a bare filename", { ...target, path: "/Users/test/project/file.ts" }], + ["a path outside the workspace", { ...target, path: "/tmp/schema/x.md" }], + ["a home-relative path", { ...target, path: "~/schema/x.md" }], + ])("does not search %s", async (_label, candidate) => { + const { getDirectorySuggestions, searches } = suggestionsFromMap({ + "schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }], + }); + + const result = await resolveMissingNestedRelativeFile({ + target: candidate, + workspaceRoot: "/Users/test/project", + getDirectorySuggestions, + }); + + expect(searches).toEqual([]); + expect(result).toEqual(candidate); + }); + + it("does not search when the workspace root is blank", async () => { + const { getDirectorySuggestions, searches } = suggestionsFromMap({}); + + const result = await resolveMissingNestedRelativeFile({ + target, + workspaceRoot: " ", + getDirectorySuggestions, + }); + + expect(searches).toEqual([]); + expect(result).toEqual(target); + }); + + it.each([ + ["/Users/test/project/schema/x.md", "/Users/test/project"], + ["file:///Users/test/project/schema/x.md", "/Users/test/project"], + ["C:/repo/schema/x.md", "C:/repo"], + ["\\\\server\\share\\schema\\x.md", "//server/share"], + ])("keeps absolute destination %s", async (raw, workspaceRoot) => { + const { getDirectorySuggestions, searches } = suggestionsFromMap({ + "schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }], + }); + const absoluteTarget = { raw, path: `${workspaceRoot}/schema/x.md` }; + await expect( + resolveMissingNestedRelativeFile({ + target: absoluteTarget, + workspaceRoot, + getDirectorySuggestions, + }), + ).resolves.toEqual(absoluteTarget); + expect(searches).toEqual([]); + }); + + it.each(["docs/guides", "./docs/guides", "docs/guides/"])( + "keeps folder href %s direct", + async (raw) => { + const { getDirectorySuggestions, searches } = suggestionsFromMap({ + "docs/guides": [{ path: "repo-a/docs/guides", kind: "file" }], + }); + const folderTarget = { raw, path: "/Users/test/project/docs/guides" }; + await expect( + resolveMissingNestedRelativeFile({ + target: folderTarget, + workspaceRoot: "/Users/test/project", + getDirectorySuggestions, + }), + ).resolves.toEqual(folderTarget); + expect(searches).toEqual([]); + }, + ); + + it("normalizes a Windows workspace root before the suffix lookup", async () => { + const windowsTarget = { + raw: "schema/x.md", + path: "C:\\repo\\schema\\x.md", + lineStart: undefined, + lineEnd: undefined, + }; + const { getDirectorySuggestions, searches } = suggestionsFromMap({ + "schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }], + }); + + const result = await resolveMissingNestedRelativeFile({ + target: windowsTarget, + workspaceRoot: "C:\\repo\\", + getDirectorySuggestions, + }); + + expect(searches).toEqual([ + { + query: "schema/x.md", + cwd: "C:\\repo\\", + matchMode: "suffix", + limit: 1, + }, + ]); + expect(result.path).toBe("C:/repo/repo-a/schema/x.md"); + }); +}); + describe("getAssistantFileLinkToken", () => { it("uses rendered text for markdown-it linkified tokens and href for explicit links", () => { expect( diff --git a/packages/app/src/assistant-file-links/resolver.ts b/packages/app/src/assistant-file-links/resolver.ts index 66c4137e2d..f77800b75b 100644 --- a/packages/app/src/assistant-file-links/resolver.ts +++ b/packages/app/src/assistant-file-links/resolver.ts @@ -5,6 +5,7 @@ import { type InlinePathTarget, } from "./parse"; import { i18n } from "@/i18n/i18next"; +import { isAbsolutePath } from "@/utils/path"; export interface AssistantFileLinkSource { href: string; @@ -105,6 +106,62 @@ export async function fetchDaemonResolution({ }; } +/** Only file-looking paths nested inside the workspace need suffix search. */ +function nestedRelativeLookupQuery( + targetPath: string, + workspaceRoot: string | undefined, +): string | null { + const root = workspaceRoot?.trim(); + if (!root) { + return null; + } + const normalizedRoot = root.replace(/\\/g, "/").replace(/\/+$/, ""); + const normalizedPath = targetPath.replace(/\\/g, "/"); + const prefix = `${normalizedRoot}/`; + if (!normalizedPath.startsWith(prefix)) { + return null; + } + const relative = normalizedPath.slice(prefix.length); + if (!relative.includes("/") || !isFileLookingAssistantToken(encodeURI(relative))) { + return null; + } + return relative; +} + +/** When a markdown link points at workspaceRoot/schema/x.md and that file is + * missing, ask the same suffix lookup bare names already use. An exact hit + * stays on that path. A suffix hit such as repo-a/schema/x.md replaces it. + * Lookup failure keeps the original target. + */ +export async function resolveMissingNestedRelativeFile(input: { + target: InlinePathTarget; + workspaceRoot?: string; + getDirectorySuggestions: GetDirectorySuggestions; +}): Promise { + if ( + isAbsoluteInlineCodeToken(input.target.raw) || + input.target.raw.startsWith("~/") || + input.target.raw.endsWith("/") + ) { + return input.target; + } + const query = nestedRelativeLookupQuery(input.target.path, input.workspaceRoot); + if (!query) { + return input.target; + } + try { + return await fetchDaemonResolution({ + ambiguousQuery: query, + token: input.target.raw, + target: input.target, + workspaceRoot: input.workspaceRoot, + getDirectorySuggestions: input.getDirectorySuggestions, + }); + } catch { + return input.target; + } +} + export function classifyForResolution( source: AssistantFileLinkSource, context: AssistantFileLinkContext, @@ -215,11 +272,7 @@ export function shouldResolveDirectFileThroughSuggestions(input: { } function isAbsoluteInlineCodeToken(token: string): boolean { - return ( - token.startsWith("/") || - token.toLowerCase().startsWith("file://") || - /^[A-Za-z]:[\\/]/.test(token) - ); + return isAbsolutePath(token) || token.toLowerCase().startsWith("file://"); } function isLinkifiedSource(source: AssistantFileLinkSource): boolean { diff --git a/packages/app/src/composer/draft/input-draft.live.test.tsx b/packages/app/src/composer/draft/input-draft.live.test.tsx index 8bc31bb176..82de9d3d73 100644 --- a/packages/app/src/composer/draft/input-draft.live.test.tsx +++ b/packages/app/src/composer/draft/input-draft.live.test.tsx @@ -2,7 +2,7 @@ import React, { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useDraftStore } from "@/stores/draft-store"; import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types"; import { createWorkspaceFileAttachment } from "@/attachments/workspace-file"; @@ -126,17 +126,13 @@ afterEach(async () => { }); }); -let useAgentInputDraft: typeof import("./input-draft").useAgentInputDraft; type DraftRecordForTest = ReturnType["drafts"][string]; -beforeAll(async () => { - Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { - value: true, - configurable: true, - }); - - ({ useAgentInputDraft } = await import("./input-draft")); +Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { + value: true, + configurable: true, }); +const { useAgentInputDraft } = await import("./input-draft"); describe("useAgentInputDraft live contract", () => { beforeEach(() => { diff --git a/packages/app/src/hooks/use-agent-history.test.ts b/packages/app/src/hooks/use-agent-history.test.ts index d4da1f01bb..9a5d80e07e 100644 --- a/packages/app/src/hooks/use-agent-history.test.ts +++ b/packages/app/src/hooks/use-agent-history.test.ts @@ -1,4 +1,4 @@ -import { beforeAll, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import type { DaemonClient, FetchAgentHistoryEntry, @@ -13,18 +13,8 @@ import { allAgentHistoryQueryKey } from "./agent-history-query-key"; } ).__DEV__ = false; -type UseAgentHistoryModule = typeof import("./use-agent-history"); - -let fetchAgentHistoryBatch: UseAgentHistoryModule["fetchAgentHistoryBatch"]; -let fetchAgentHistoryPage: UseAgentHistoryModule["fetchAgentHistoryPage"]; -let collectAgentHistoryHostErrors: UseAgentHistoryModule["collectAgentHistoryHostErrors"]; - -beforeAll(async () => { - const module = await import("./use-agent-history"); - fetchAgentHistoryBatch = module.fetchAgentHistoryBatch; - fetchAgentHistoryPage = module.fetchAgentHistoryPage; - collectAgentHistoryHostErrors = module.collectAgentHistoryHostErrors; -}); +const { fetchAgentHistoryBatch, fetchAgentHistoryPage, collectAgentHistoryHostErrors } = + await import("./use-agent-history"); type FetchAgentHistory = DaemonClient["fetchAgentHistory"]; type FetchAgentHistoryResult = Awaited>; From 74613e4bc34313c03c5559a578337ef2a519d004 Mon Sep 17 00:00:00 2001 From: Matt Cowger Date: Sat, 31 Oct 2026 04:55:24 -0700 Subject: [PATCH 03/58] feat(opencode): reuse running OpenCode v2 services (#7435) * feat(opencode): connect v2 client to an existing server * feat(providers): reuse running agent servers Keep shared server sessions isolated and retain native tools while refreshes reuse the running service. * fix(providers): recognize formatted bridge manifests Managed plugin ownership must survive harmless JSON formatting changes. * test(providers): distinguish isolated session bindings Use separate session identities to verify that dedicated helpers keep their environments isolated. --------- Co-authored-by: Mohamed Boudra --- docs/custom-providers.md | 30 +++ docs/providers.md | 2 +- .../scripts/build-opencode-bridge-plugin.mjs | 8 +- .../src/server/agent/provider-runtime.ts | 7 +- .../providers/opencode-bridge-adapter.test.ts | 14 +- .../agent/providers/opencode/bridge.test.ts | 235 +++++++++--------- .../server/agent/providers/opencode/bridge.ts | 20 +- .../providers/opencode/runtime-client.test.ts | 38 +++ .../providers/opencode/runtime-client.ts | 25 ++ .../opencode/test-utils/v2-harness.ts | 3 + .../agent/providers/opencode/v2/agent.test.ts | 20 ++ .../agent/providers/opencode/v2/agent.ts | 21 +- .../providers/opencode/v2/bridge-plugin.mjs | 95 +------ .../opencode/v2/bridge-provisioning.test.ts | 37 +++ .../opencode/v2/bridge-provisioning.ts | 89 +++++++ .../opencode/v2/external-runtime.test.ts | 180 ++++++++++++++ .../opencode/v2/external-server.test.ts | 60 +++++ .../providers/opencode/v2/external-server.ts | 38 +++ .../opencode/v2/owned-runtime.test.ts | 77 ++++++ .../agent/providers/opencode/v2/runtime.ts | 119 ++++++++- .../opencode/v2/service-selection.test.ts | 55 ++++ .../opencode/v2/service-selection.ts | 43 ++++ .../agent/providers/opencode/v2/session.ts | 13 + .../opencode/v2/shared-bridge-plugin.mjs | 207 +++++++++++++++ .../opencode/v2/shared-bridge.test.ts | 168 +++++++++++++ .../providers/opencode/v2/shared-service.ts | 192 ++++++++++++++ .../agent/providers/opencode/v2/state.mjs | 11 + ...ncode-initial-prompt-wait.real.e2e.test.ts | 2 +- .../daemon-e2e/real-provider-test-config.ts | 2 +- 29 files changed, 1578 insertions(+), 233 deletions(-) create mode 100644 packages/server/src/server/agent/providers/opencode/v2/bridge-provisioning.test.ts create mode 100644 packages/server/src/server/agent/providers/opencode/v2/bridge-provisioning.ts create mode 100644 packages/server/src/server/agent/providers/opencode/v2/external-runtime.test.ts create mode 100644 packages/server/src/server/agent/providers/opencode/v2/external-server.test.ts create mode 100644 packages/server/src/server/agent/providers/opencode/v2/external-server.ts create mode 100644 packages/server/src/server/agent/providers/opencode/v2/owned-runtime.test.ts create mode 100644 packages/server/src/server/agent/providers/opencode/v2/service-selection.test.ts create mode 100644 packages/server/src/server/agent/providers/opencode/v2/service-selection.ts create mode 100644 packages/server/src/server/agent/providers/opencode/v2/shared-bridge-plugin.mjs create mode 100644 packages/server/src/server/agent/providers/opencode/v2/shared-bridge.test.ts create mode 100644 packages/server/src/server/agent/providers/opencode/v2/shared-service.ts create mode 100644 packages/server/src/server/agent/providers/opencode/v2/state.mjs diff --git a/docs/custom-providers.md b/docs/custom-providers.md index 615389fe60..1d6d67d453 100644 --- a/docs/custom-providers.md +++ b/docs/custom-providers.md @@ -65,6 +65,7 @@ per-agent examples and provider-specific keys. ## Table of Contents - [Extending a built-in provider](#extending-a-built-in-provider) +- [OpenCode with an existing v2 server](#opencode-with-an-existing-v2-server) - [Z.AI (Zhipu) coding plan](#zai-zhipu-coding-plan) - [Alibaba Cloud (Qwen) coding plan](#alibaba-cloud-qwen-coding-plan) - [Codex with a custom OpenAI-compatible endpoint](#codex-with-a-custom-openai-compatible-endpoint) @@ -107,6 +108,35 @@ See [Codex with a custom OpenAI-compatible endpoint](#codex-with-a-custom-openai --- +## OpenCode with an existing v2 server + +Paseo first uses an explicitly configured server, then a healthy locally registered OpenCode v2 service, then starts its own helper. It never stops a reused service. To select a server explicitly, set its URL in the provider environment: + +```json +{ + "agents": { + "providers": { + "opencode": { + "env": { + "PASEO_OPENCODE_SERVER_URL": "http://127.0.0.1:4096", + "PASEO_OPENCODE_SERVER_PASSWORD": "" + } + } + } + } +} +``` + +`PASEO_OPENCODE_SERVER_URL` must be an absolute `http` or `https` URL. Omit `PASEO_OPENCODE_SERVER_PASSWORD` when the server has no authentication; when set, Paseo authenticates with HTTP Basic auth as `opencode`. + +A configured URL takes precedence even when unreachable; Paseo reports the connection error. The local service registration supplies its own authentication. For a discovered local service, Paseo installs its bridge package in OpenCode's watched global plugin directory without changing the existing configuration. Refresh reads the existing service's catalogs without starting another server. + +An explicitly configured server needs the bridge installed separately. Connect once and start a session: the setup error gives the local package directory. Copy that directory to the server's OpenCode global `plugins` directory, then retry. For a server on another machine, set `PASEO_OPENCODE_BRIDGE_PORT` in the Paseo OpenCode provider environment before starting Paseo, and set `PASEO_OPENCODE_BRIDGE_URL` to the URL the OpenCode server can use to reach that port. For example, an SSH reverse tunnel can expose the Paseo loopback port on the OpenCode host. The bridge token is exchanged through the OpenCode connection and is not written into the plugin package. + +OpenCode's custom MCP registrations are shared by all sessions in a location. Paseo rejects custom MCP servers and per-agent process environment overrides on a reused server rather than applying them to other sessions or ignoring them. Session identity is applied to each session's shell environment. A Paseo-owned helper supports custom process and MCP settings; it is selected when there is no explicit URL or healthy registered service. + +--- + ## Z.AI (Zhipu) coding plan [Z.AI](https://z.ai) is a Chinese AI company (Zhipu AI) that offers an Anthropic-compatible API endpoint. Their GLM Coding Plan provides flat-rate access to GLM models through Claude Code's Anthropic API protocol. These are **not** Anthropic Claude models — they are Zhipu's own GLM models exposed through an Anthropic-compatible API. diff --git a/docs/providers.md b/docs/providers.md index 1a43558ae3..b92e171c12 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -89,7 +89,7 @@ OMP supports native Paseo host tools. The adapter registers the full caller-scop Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them. -OpenCode adapters target v1.14.46 and v2.0.10. V2 rejects binaries older than the tested 2.0.10 SDK at runtime selection. Runtime selection uses the configured command and environment. A recognized version is cached until provider configuration reload; a failed, timed-out, or unrecognized probe retains the v1 path and retries detection on the next operation. Load v2 code and materialize its plugin only after positive v2 selection; v1 sessions remain undecorated. Keep upstream SDK types inside the version-specific adapter. OpenCode owns storage migration; a missing native session must fail resume rather than create a replacement. V2 has no native archive/unarchive operation: archiving affects Paseo only. V1 retains native archiving. +OpenCode adapters target v1.14.46 and v2.0.10. V2 rejects versions older than 2.0.10. Paseo selects an explicit server, then a healthy local registered service, then its own helper. It never stops a reused service. A local service loads the Paseo bridge from its watched plugin directory; sessions bind their own bridge credentials through OpenCode RPC. See [OpenCode with an existing v2 server](custom-providers.md#opencode-with-an-existing-v2-server) for setup and isolation limits. Load v2 code and materialize its plugin only after positive v2 selection; v1 sessions remain undecorated. Keep upstream SDK types inside the version-specific adapter. OpenCode owns storage migration; a missing native session must fail resume rather than create a replacement. V2 has no native archive/unarchive operation: archiving affects Paseo only. V1 retains native archiving. Use OpenCode v2 execution events to trigger turn completion, with active-state and durable-log reconciliation after admission, reconnect, and while a turn remains active. Do not use `session.wait`: a healthy turn exceeding Node's HTTP headers deadline produces a transport error while OpenCode keeps working. The live event feed has no replay, and shutdown interruption preserves the previous idle outcome, so the session snapshot alone cannot recover missed execution events. Quiet streams are healthy: v2 heartbeats are SSE comments, not application events. diff --git a/packages/server/scripts/build-opencode-bridge-plugin.mjs b/packages/server/scripts/build-opencode-bridge-plugin.mjs index 69f1e774f0..33cd3402ce 100644 --- a/packages/server/scripts/build-opencode-bridge-plugin.mjs +++ b/packages/server/scripts/build-opencode-bridge-plugin.mjs @@ -1,4 +1,4 @@ -import { mkdir } from "node:fs/promises"; +import { copyFile, mkdir } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -27,4 +27,10 @@ for (const directory of ["", "v2/"]) { target: "es2022", outfile: outputPath, }); + if (directory === "v2/") { + await copyFile( + new URL("../src/server/agent/providers/opencode/v2/state.mjs", import.meta.url), + path.join(path.dirname(outputPath), "state.mjs"), + ); + } } diff --git a/packages/server/src/server/agent/provider-runtime.ts b/packages/server/src/server/agent/provider-runtime.ts index 398d35746d..c266614273 100644 --- a/packages/server/src/server/agent/provider-runtime.ts +++ b/packages/server/src/server/agent/provider-runtime.ts @@ -22,7 +22,12 @@ interface CreateAgentProviderRuntimeOptions { export async function createAgentProviderRuntime( options: CreateAgentProviderRuntimeOptions, ): Promise { - const bridge = new OpenCodeBridge({ paseoHome: options.paseoHome, logger: options.logger }); + const configuredPort = + options.snapshotManager.runtimeSettings?.opencode?.env?.PASEO_OPENCODE_BRIDGE_PORT; + const port = configuredPort === undefined ? undefined : Number(configuredPort); + if (port !== undefined && (!Number.isInteger(port) || port < 1 || port > 65535)) + throw new Error("PASEO_OPENCODE_BRIDGE_PORT must be an integer between 1 and 65535"); + const bridge = new OpenCodeBridge({ paseoHome: options.paseoHome, logger: options.logger, port }); try { await bridge.start(); const snapshotManager = new ProviderSnapshotManager({ diff --git a/packages/server/src/server/agent/providers/opencode-bridge-adapter.test.ts b/packages/server/src/server/agent/providers/opencode-bridge-adapter.test.ts index 133ac3c992..6cb15ccb6d 100644 --- a/packages/server/src/server/agent/providers/opencode-bridge-adapter.test.ts +++ b/packages/server/src/server/agent/providers/opencode-bridge-adapter.test.ts @@ -80,8 +80,12 @@ describe("OpenCode bridge adapter", () => { await rm(paseoHome, { recursive: true, force: true }); }); const runtime = new TestOpenCodeHarness(); - runtime.enqueueClient(new TestOpenCodeClient()); - runtime.enqueueClient(new TestOpenCodeClient()); + const envClient = new TestOpenCodeClient(); + const mcpClient = new TestOpenCodeClient(); + envClient.sessionCreateResponse = { data: { id: "ses_env" } }; + mcpClient.sessionCreateResponse = { data: { id: "ses_mcp" } }; + runtime.enqueueClient(envClient); + runtime.enqueueClient(mcpClient); const client = new OpenCodeAgentClient(createTestLogger(), undefined, { serverManager: runtime, createClient: runtime.createClient, @@ -102,6 +106,12 @@ describe("OpenCode bridge adapter", () => { ); expect(runtime.acquisitions.map(({ kind }) => kind)).toEqual(["dedicated", "dedicated"]); + await expect(readBridgeContext(bridge, "ses_env")).resolves.toEqual({ + env: { PASEO_AGENT_ID: "one", CUSTOM_TOKEN: "secret" }, + }); + await expect(readBridgeContext(bridge, "ses_mcp")).resolves.toEqual({ + env: { PASEO_AGENT_ID: "two" }, + }); await customEnv.close(); await customMcp.close(); }); diff --git a/packages/server/src/server/agent/providers/opencode/bridge.test.ts b/packages/server/src/server/agent/providers/opencode/bridge.test.ts index 33f43c4f79..468baabe14 100644 --- a/packages/server/src/server/agent/providers/opencode/bridge.test.ts +++ b/packages/server/src/server/agent/providers/opencode/bridge.test.ts @@ -20,6 +20,7 @@ import { createPaseoAgentToolManifest, createPaseoToolCatalog } from "../../tool import { serializePaseoToolInputParameters } from "../../tools/paseo-tool-serialization.js"; import { OpenCodeBridge, + decorateOpenCodeV2Env, loadOpenCodeBridgePluginArtifact, materializeOpenCodeV2Plugin, } from "./bridge.js"; @@ -137,33 +138,28 @@ describe("OpenCodeBridge", () => { }; expect(ajv.compile(createDefinition.inputSchema)(createInput)).toBe(true); - const v2Config = z - .object({ plugins: z.array(z.object({ package: z.string() })) }) - .parse(JSON.parse((await bridge.decorateV2ServerEnv({})).OPENCODE_CONFIG_CONTENT)); - const v2Module: { - default: { setup(context: V2TestPluginContext): Promise<() => Promise> }; - } = await import( - pathToFileURL(path.join(fileURLToPath(v2Config.plugins[0]!.package), "server.js")).href - ); const tools = new Map(); - const dispose = await v2Module.default.setup({ - options: plugin, - tool: { - transform: async (transform) => { - transform({ - add: (tool) => { - tools.set(tool.name, tool); - }, - }); - return { dispose: async () => undefined }; + const dispose = await setupBoundV2Plugin( + bridge, + { + tool: { + transform: async (transform) => { + transform({ + add: (tool) => { + tools.set(tool.name, tool); + }, + }); + return { dispose: async () => undefined }; + }, + }, + session: { + context: async () => [], + get: async () => ({ parentID: "bound" }), + hook: async () => ({ dispose: async () => undefined }), }, }, - session: { - context: async () => [], - get: async () => ({ parentID: "bound" }), - hook: async () => ({ dispose: async () => undefined }), - }, - }); + ["bound"], + ); try { const created = await tools .get("paseo_create_agent")! @@ -402,45 +398,35 @@ describe("OpenCodeBridge", () => { const release = bridge.bindSession({ sessionId: "parent", env: {}, tools: catalog }); const releaseDisabled = bridge.bindSession({ sessionId: "disabled", env: {} }); try { - const env = await bridge.decorateV2ServerEnv({}); - const config = z - .object({ - plugins: z.array( - z.object({ - package: z.string(), - options: z.object({ baseUrl: z.string(), token: z.string() }), - }), - ), - }) - .parse(JSON.parse(env.OPENCODE_CONFIG_CONTENT)); - expect(await bridge.decorateV2ServerEnv(env)).toEqual(env); - const plugin = config.plugins[0]!; + const pluginUrl = await bridge.materializeV2Plugin(); + const env = decorateOpenCodeV2Env({}, pluginUrl); + expect(decorateOpenCodeV2Env(env, pluginUrl)).toEqual(env); const tools = new Map(); let filter!: (input: V2TestContext) => Promise; - const module: { - default: { setup(context: V2TestPluginContext): Promise<() => Promise> }; - } = await import(pathToFileURL(path.join(fileURLToPath(plugin.package), "server.js")).href); - const dispose = await module.default.setup({ - options: plugin.options, - tool: { - transform: async (transform) => { - transform({ - add: (tool) => { - tools.set(tool.name, tool); - }, - }); - return { dispose: async () => undefined }; + const dispose = await setupBoundV2Plugin( + bridge, + { + tool: { + transform: async (transform) => { + transform({ + add: (tool) => { + tools.set(tool.name, tool); + }, + }); + return { dispose: async () => undefined }; + }, + }, + session: { + context: async () => [], + get: async ({ sessionID }) => ({ parentID: sessionID === "child" ? "parent" : "" }), + hook: async (_name, callback) => { + filter = callback; + return { dispose: async () => undefined }; + }, }, }, - session: { - context: async () => [], - get: async () => ({ parentID: "parent" }), - hook: async (_name, callback) => { - filter = callback; - return { dispose: async () => undefined }; - }, - }, - }); + ["parent", "disabled"], + ); const allowed: V2TestContext = { sessionID: "child", tools: { paseo_echo_context: {}, native: {} }, @@ -453,6 +439,12 @@ describe("OpenCodeBridge", () => { }; await filter(disabled); expect(Object.keys(disabled.tools)).toEqual(["native"]); + const unrelated: V2TestContext = { + sessionID: "unrelated", + tools: { paseo_echo_context: {}, native: {} }, + }; + await filter(unrelated); + expect(Object.keys(unrelated.tools)).toEqual(["native"]); await expect( tools.get("paseo_echo_context")!.execute({ value: "child result" }, { sessionID: "child" }), ).resolves.toMatchObject({ content: [{ type: "text", text: "child result" }] }); @@ -486,35 +478,33 @@ describe("OpenCodeBridge", () => { await bridge.start(); const release = bridge.bindSession({ sessionId: "caller", env: {}, tools: catalog }); try { - const env = await bridge.decorateV2ServerEnv({}); - const { plugins } = JSON.parse(env.OPENCODE_CONFIG_CONTENT); - const plugin = plugins[0]; - const module = await import( - pathToFileURL(path.join(fileURLToPath(plugin.package), "server.js")).href - ); + const options = bridge.sharedV2Connection(); const tools = new Map(); - const dispose = await module.default.setup({ - options: plugin.options, - tool: { - transform: async (transform: (editor: { add(tool: V2TestTool): void }) => void) => { - transform({ add: (tool) => tools.set(tool.name, tool) }); - return { dispose: async () => undefined }; + const dispose = await setupBoundV2Plugin( + bridge, + { + tool: { + transform: async (transform: (editor: { add(tool: V2TestTool): void }) => void) => { + transform({ add: (tool) => tools.set(tool.name, tool) }); + return { dispose: async () => undefined }; + }, + }, + session: { + context: async () => [], + get: async () => ({ parentID: "" }), + hook: async () => ({ dispose: async () => undefined }), }, }, - session: { - context: async () => [], - get: async () => ({ parentID: "" }), - hook: async () => ({ dispose: async () => undefined }), - }, - } satisfies V2TestPluginContext); + ["caller"], + ); try { const input = { provider: "codex", title: "Invalid provider", initialPrompt: "Hello" }; const response = await fetch( - `${plugin.options.baseUrl}/_internal/opencode/sessions/caller/tools/create_agent`, + `${options.baseUrl}/_internal/opencode/sessions/caller/tools/create_agent`, { method: "POST", headers: { - Authorization: `Bearer ${plugin.options.token}`, + Authorization: `Bearer ${options.token}`, "Content-Type": "application/json", }, body: JSON.stringify(input), @@ -572,35 +562,24 @@ describe("OpenCodeBridge", () => { await bridge.start(); const release = bridge.bindSession({ sessionId: "session", env: {}, tools: catalog }); try { - const env = await bridge.decorateV2ServerEnv({}); - const plugin = z - .object({ - plugins: z.array( - z.object({ - package: z.string(), - options: z.object({ baseUrl: z.string(), token: z.string() }), - }), - ), - }) - .parse(JSON.parse(env.OPENCODE_CONFIG_CONTENT)).plugins[0]!; const pluginTools = new Map(); - const module: { - default: { setup(context: V2TestPluginContext): Promise<() => Promise> }; - } = await import(pathToFileURL(path.join(fileURLToPath(plugin.package), "server.js")).href); - const dispose = await module.default.setup({ - options: plugin.options, - tool: { - transform: async (transform) => { - transform({ add: (tool) => pluginTools.set(tool.name, tool) }); - return { dispose: async () => undefined }; + const dispose = await setupBoundV2Plugin( + bridge, + { + tool: { + transform: async (transform) => { + transform({ add: (tool) => pluginTools.set(tool.name, tool) }); + return { dispose: async () => undefined }; + }, + }, + session: { + context: async () => [], + get: async () => ({ parentID: undefined }), + hook: async () => ({ dispose: async () => undefined }), }, }, - session: { - context: async () => [], - get: async () => ({ parentID: undefined }), - hook: async () => ({ dispose: async () => undefined }), - }, - }); + ["session"], + ); const result = await pluginTools .get("paseo_browser_screenshot")! .execute({}, { sessionID: "session" }); @@ -642,7 +621,8 @@ describe("OpenCodeBridge", () => { const tools = new Map(); let hook!: (input: V2TestContext) => Promise; const dispose = await module.default.setup({ - options: { baseUrl: "", token: "" }, + options: { mode: "structured-only" }, + location: { directory: root }, tool: { transform: async (transform) => { transform({ @@ -657,7 +637,7 @@ describe("OpenCodeBridge", () => { context: async () => history, get: async () => ({ parentID: "parent" }), hook: async (_name, callback) => { - hook = callback; + hook ??= callback; return { dispose: async () => undefined }; }, }, @@ -701,9 +681,13 @@ describe("OpenCodeBridge", () => { name.startsWith("paseo-v2-"), ), ).toEqual([]); - const env = await bridge.decorateV2ServerEnv({ - OPENCODE_CONFIG_CONTENT: JSON.stringify({ plugins: ["user-plugin"] }), - }); + const pluginUrl = await bridge.materializeV2Plugin(); + const env = decorateOpenCodeV2Env( + { + OPENCODE_CONFIG_CONTENT: JSON.stringify({ plugins: ["user-plugin"] }), + }, + pluginUrl, + ); const config = z .object({ plugins: z.tuple([z.literal("user-plugin"), z.object({ package: z.string() })]) }) .parse(JSON.parse(env.OPENCODE_CONFIG_CONTENT)); @@ -711,6 +695,9 @@ describe("OpenCodeBridge", () => { expect((await stat(directory)).isDirectory()).toBe(true); const entry = await readFile(path.join(directory, "server.js"), "utf8"); expect(entry).toContain('id: "paseo"'); + expect(await readFile(path.join(directory, "state.mjs"), "utf8")).toBe( + await readFile(new URL("./v2/state.mjs", import.meta.url), "utf8"), + ); const manifest = JSON.parse(await readFile(path.join(directory, "package.json"), "utf8")); expect(manifest).toMatchObject({ type: "module", exports: { "./server": "./server.js" } }); } finally { @@ -856,7 +843,8 @@ interface V2TestContext { system?: Array<{ type: "text"; text: string }>; } interface V2TestPluginContext { - options: { baseUrl: string; token: string }; + options: Record; + location?: { directory: string }; tool: { transform( callback: (editor: { add(tool: V2TestTool): void }) => void, @@ -864,10 +852,33 @@ interface V2TestPluginContext { }; session: { context(input: { sessionID: string }): Promise; - get(input: { sessionID: string }): Promise<{ parentID: string }>; + get(input: { sessionID: string }): Promise<{ parentID: string | undefined }>; hook( name: string, callback: (input: V2TestContext) => Promise, ): Promise<{ dispose(): Promise }>; }; } + +async function setupBoundV2Plugin( + bridge: OpenCodeBridge, + context: Pick, + sessionIds: string[], +): Promise<() => Promise> { + const directory = fileURLToPath(await bridge.materializeV2Plugin()); + const module = await import(pathToFileURL(path.join(directory, "server.js")).href); + let bind!: (input: { sessionID: string; baseUrl: string; token: string }) => Promise; + const dispose: () => Promise = await module.default.setup({ + ...context, + options: {}, + location: { directory }, + rpc: { + register: async (_definition: unknown, handlers: { bind: typeof bind }) => { + bind = handlers.bind; + return { dispose: async () => undefined }; + }, + }, + }); + for (const sessionID of sessionIds) await bind({ sessionID, ...bridge.sharedV2Connection() }); + return dispose; +} diff --git a/packages/server/src/server/agent/providers/opencode/bridge.ts b/packages/server/src/server/agent/providers/opencode/bridge.ts index 7abe34475d..87ee07798b 100644 --- a/packages/server/src/server/agent/providers/opencode/bridge.ts +++ b/packages/server/src/server/agent/providers/opencode/bridge.ts @@ -30,6 +30,7 @@ class OpenCodeBridgeClientError extends Error { interface OpenCodeBridgeOptions { paseoHome: string; logger: Logger; + port?: number; } interface OpenCodeSessionBinding { @@ -54,6 +55,7 @@ interface OpenCodeConfig { export class OpenCodeBridge { private readonly paseoHome: string; private readonly logger: Logger; + private readonly port: number; private readonly token = randomBytes(32).toString("hex"); private readonly sessions = new Map(); private server: Server | null = null; @@ -65,6 +67,7 @@ export class OpenCodeBridge { constructor(options: OpenCodeBridgeOptions) { this.paseoHome = options.paseoHome; this.logger = options.logger.child({ module: "agent", component: "opencode-bridge" }); + this.port = options.port ?? 0; } async start(): Promise { @@ -75,7 +78,7 @@ export class OpenCodeBridge { }); await new Promise((resolve, reject) => { server.once("error", reject); - server.listen(0, "127.0.0.1", () => { + server.listen(this.port, "127.0.0.1", () => { server.off("error", reject); resolve(); }); @@ -94,6 +97,8 @@ export class OpenCodeBridge { } bindSession(input: BindOpenCodeSessionInput): () => void { + if (this.sessions.has(input.sessionId)) + throw new Error(`OpenCode session ${input.sessionId} is already bound to a Paseo agent`); const binding: OpenCodeSessionBinding = { env: { ...input.env }, ...(input.tools ? { tools: input.tools } : {}), @@ -127,12 +132,13 @@ export class OpenCodeBridge { }; } - async decorateV2ServerEnv(env: Record): Promise> { + async materializeV2Plugin(): Promise { this.v2PluginUrl ??= await this.materializePlugin(2); - return decorateOpenCodeV2Env(env, this.v2PluginUrl, { - baseUrl: this.requireBaseUrl(), - token: this.token, - }); + return this.v2PluginUrl; + } + + sharedV2Connection(baseUrl = this.requireBaseUrl()): OpenCodePluginOptions { + return { baseUrl, token: this.token }; } async close(): Promise { @@ -282,6 +288,8 @@ export async function materializeOpenCodeV2Plugin(paseoHome: string): Promise { + const { OpenCodeRuntimeClient } = await import("./runtime-client.js"); + const { createTestLogger } = await import("../../../../test-utils/test-logger.js"); + const client = new OpenCodeRuntimeClient(createTestLogger(), { + command: { mode: "replace", argv: [process.execPath, "-e", "process.exit(1)"] }, + env: { PASEO_OPENCODE_SERVER_URL: "http://127.0.0.1:4096" }, + }); + try { + const features = await client.listFeatures({ provider: "opencode", cwd: "/tmp/project" }); + expect(features.map((feature) => feature.label)).toEqual(["Auto-accept"]); + } finally { + await client.shutdown(); + } +}); + +test("keeps native Paseo tools available when an external server is configured", async () => { + const { OpenCodeRuntimeClient } = await import("./runtime-client.js"); + const { createTestLogger } = await import("../../../../test-utils/test-logger.js"); + const { OpenCodeBridge } = await import("./bridge.js"); + const bridge = new OpenCodeBridge({ paseoHome: "/tmp", logger: createTestLogger() }); + const external = new OpenCodeRuntimeClient( + createTestLogger(), + { env: { PASEO_OPENCODE_SERVER_URL: "http://127.0.0.1:4096" } }, + { bridge }, + ); + const local = new OpenCodeRuntimeClient(createTestLogger(), undefined, { bridge }); + try { + expect(external.capabilities.supportsNativePaseoTools).toBe(true); + expect(local.capabilities.supportsNativePaseoTools).toBe(true); + } finally { + await external.shutdown(); + await local.shutdown(); + } +}); diff --git a/packages/server/src/server/agent/providers/opencode/runtime-client.ts b/packages/server/src/server/agent/providers/opencode/runtime-client.ts index 2a34abe35d..e5fa17021a 100644 --- a/packages/server/src/server/agent/providers/opencode/runtime-client.ts +++ b/packages/server/src/server/agent/providers/opencode/runtime-client.ts @@ -19,7 +19,9 @@ import { import { execCommand } from "../../../../utils/spawn.js"; import { OpenCodeAgentClient } from "../opencode-agent.js"; import type { OpenCodeV2AgentClient } from "./v2/agent.js"; +import { resolveExternalOpenCodeServer } from "./v2/external-server.js"; import { withOpenCodeRuntimeNotice } from "./runtime-notice.js"; +import { selectOpenCodeService } from "./v2/service-selection.js"; // Keep the minimum aligned with the SDK and binary exercised by CI. const MINIMUM_V2: readonly [number, number] = [0, 10]; @@ -67,8 +69,25 @@ export class OpenCodeRuntimeClient implements AgentClient { this.resolveCreateConfig = this.legacy.resolveCreateConfig; this.isCreateConfigUnattended = this.legacy.isCreateConfigUnattended; } + private selectService() { + return selectOpenCodeService({ + explicit: resolveExternalOpenCodeServer({ ...globalThis.process.env, ...this.settings?.env }), + env: this.settings?.env, + }); + } private client(): Promise { this.selected ??= (async () => { + const selection = await this.selectService(); + if (selection.kind !== "owned") { + const { OpenCodeV2AgentClient } = await import("./v2/agent.js"); + return new OpenCodeV2AgentClient({ + logger: this.logger, + settings: this.settings, + managedProcesses: this.options.managedProcesses, + bridge: this.options.bridge, + selection, + }); + } let output: string; try { const launch = await resolveProviderLaunch({ @@ -97,6 +116,7 @@ export class OpenCodeRuntimeClient implements AgentClient { settings: this.settings, managedProcesses: this.options.managedProcesses, bridge: this.options.bridge, + selection, }); })().catch((error: unknown) => { // A failed probe must not poison refresh after the user updates the binary. @@ -106,9 +126,14 @@ export class OpenCodeRuntimeClient implements AgentClient { return this.selected; } async isAvailable() { + const selection = await this.selectService(); + if (selection.kind !== "owned") return true; return this.legacy.isAvailable(); } async getDiagnostic() { + const selection = await this.selectService(); + if (selection.kind !== "owned") + return { diagnostic: `OpenCode server: ${selection.server.url}` }; return this.legacy.getDiagnostic(); } async createSession( diff --git a/packages/server/src/server/agent/providers/opencode/test-utils/v2-harness.ts b/packages/server/src/server/agent/providers/opencode/test-utils/v2-harness.ts index 505955f64c..eb06928e63 100644 --- a/packages/server/src/server/agent/providers/opencode/test-utils/v2-harness.ts +++ b/packages/server/src/server/agent/providers/opencode/test-utils/v2-harness.ts @@ -172,6 +172,9 @@ export class V2Harness { event: { subscribe: (options) => this.events(options?.signal) }, }; readonly connection: V2Connection = { + bindSession: async () => {}, + unbindSession: async () => {}, + refreshLocation: async () => {}, client: this.api, release: async () => { this.releases += 1; diff --git a/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts b/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts index 564e1f46c5..e8188730f5 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts @@ -17,6 +17,26 @@ function terminalEvents(events: AgentStreamEvent[]) { } describe("OpenCode v2 session lifecycle", () => { + test("refreshes the existing location before reading a forced catalog", async () => { + const harness = new V2Harness(); + const refreshed: string[] = []; + harness.connection.refreshLocation = async (directory) => { + refreshed.push(directory); + }; + harness.api.model.list = async () => ({ location: harness.info.location, data: [] }); + harness.api.provider.list = async () => ({ + location: harness.info.location, + data: [{ id: "test", name: "Test", activation: "enabled", package: "test" }], + }); + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + await client.fetchCatalog({ scope: "workspace", cwd: "/tmp/project", force: true }); + await client.fetchCatalog({ scope: "workspace", cwd: "/tmp/project", force: false }); + expect(refreshed).toEqual(["/tmp/project"]); + await client.shutdown(); + }); test("completes from execution events without a long-lived HTTP wait", async () => { const harness = new V2Harness(); harness.prompt = async (input) => { diff --git a/packages/server/src/server/agent/providers/opencode/v2/agent.ts b/packages/server/src/server/agent/providers/opencode/v2/agent.ts index 2e50e759bd..119fa0ade7 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/agent.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/agent.ts @@ -8,7 +8,7 @@ import { } from "./configuration.js"; import { commands } from "./commands.js"; -import { waitForLocationReady, awaitPaseoPlugin } from "./readiness.js"; +import { waitForLocationReady } from "./readiness.js"; import type { SessionInfo } from "@opencode/client"; @@ -40,6 +40,7 @@ import { importSessionFromPersistence } from "../../../provider-session-import.j import type { OpenCodeBridge } from "../bridge.js"; import { resolveOpenCodeHomeDir } from "../paths.js"; import { V2Runtime, type V2Connection } from "./runtime.js"; +import type { OpenCodeServiceSelection } from "./service-selection.js"; import { modelRef, modesFromV2, modelsFromV2 } from "./mapping.js"; interface V2AgentOptions { @@ -48,6 +49,7 @@ interface V2AgentOptions { managedProcesses?: ManagedProcessRegistry; bridge?: OpenCodeBridge; runtime?: Pick; + selection?: OpenCodeServiceSelection; } export class OpenCodeV2AgentClient implements AgentClient { @@ -56,12 +58,12 @@ export class OpenCodeV2AgentClient implements AgentClient { private readonly runtime: Pick; private readonly connections = new Map(); constructor(private readonly options: V2AgentOptions) { - this.capabilities = { ...V2_CAPABILITIES, supportsNativePaseoTools: Boolean(options.bridge) }; + const bridge = options.bridge; + this.capabilities = { ...V2_CAPABILITIES, supportsNativePaseoTools: Boolean(bridge) }; this.runtime = options.runtime ?? new V2Runtime({ ...options, - decorateEnv: options.bridge ? (env) => options.bridge!.decorateV2ServerEnv(env) : undefined, }); } async isAvailable() { @@ -85,6 +87,10 @@ export class OpenCodeV2AgentClient implements AgentClient { await activity("plugin.list", () => waitForLocationReady({ client: connection.client, location, signal: request.signal }), ); + if (options.force) + await activity("paseo.bridge.refresh", () => + connection.refreshLocation(location.directory, request.signal), + ); const [models, agents, providers] = await Promise.all([ activity("model.list", () => connection.client.model.list({ location }, request)), activity("agent.list", () => connection.client.agent.list({ location }, request)), @@ -163,7 +169,8 @@ export class OpenCodeV2AgentClient implements AgentClient { requiresDedicatedV2Server(config, launch) ? { env: launch?.env, dedicated: true } : {}, ); let ownedConnection = connection; - const unbind = this.options.bridge?.bindSession({ + const bridge = this.options.bridge; + const unbind = bridge?.bindSession({ sessionId: info.id, env: launch?.env ?? {}, tools: launch?.paseoTools, @@ -172,7 +179,7 @@ export class OpenCodeV2AgentClient implements AgentClient { const bindChild = (childId: string) => { this.connections.set(childId, ownedConnection); if (bound.has(childId)) return; - const childUnbind = this.options.bridge?.bindSession({ + const childUnbind = bridge?.bindSession({ sessionId: childId, env: launch?.env ?? {}, tools: launch?.paseoTools, @@ -204,10 +211,6 @@ export class OpenCodeV2AgentClient implements AgentClient { moved, ); try { - if (this.options.bridge) { - const location = { directory: config.cwd }; - await awaitPaseoPlugin({ client: connection.client, location }); - } await session.initialize( launch?.env ? createProviderEnv({ runtimeSettings: this.options.settings, overlays: [launch.env] }) diff --git a/packages/server/src/server/agent/providers/opencode/v2/bridge-plugin.mjs b/packages/server/src/server/agent/providers/opencode/v2/bridge-plugin.mjs index 1664c0b59f..6971a5b323 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/bridge-plugin.mjs +++ b/packages/server/src/server/agent/providers/opencode/v2/bridge-plugin.mjs @@ -1,100 +1,15 @@ -import { registerStructuredOutput, STRUCTURED_OUTPUT_TOOL } from "./structured-output-plugin.mjs"; +import { registerStructuredOutput } from "./structured-output-plugin.mjs"; +import { registerSharedBridge } from "./shared-bridge-plugin.mjs"; export default { id: "paseo", async setup(context) { const disposeStructuredOutput = await registerStructuredOutput(context); - const { baseUrl, token } = context.options; - if (!baseUrl || !token) return disposeStructuredOutput; - async function request(pathname, body, allowMissing = false) { - const response = await fetch(new URL(`/_internal/opencode${pathname}`, baseUrl), { - method: body === undefined ? "GET" : "POST", - headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, - ...(body === undefined ? {} : { body: JSON.stringify(body) }), - }); - if (allowMissing && response.status === 404) return null; - if (!response.ok) { - const payload = await response.json().catch(() => null); - const detail = formatErrorDetail(payload?.issues ?? payload?.details); - const message = - typeof payload?.error === "string" && payload.error - ? payload.error - : `Paseo tool bridge returned HTTP ${response.status}`; - const error = new Error(detail ? `${message}: ${detail}` : message); - error.status = response.status; - if (detail) error.details = detail; - throw error; - } - return response.json(); - } - async function scope(sessionID) { - const visited = new Set(); - while (sessionID && !visited.has(sessionID)) { - visited.add(sessionID); - const binding = await request( - `/sessions/${encodeURIComponent(sessionID)}/context?tools`, - undefined, - true, - ); - if (binding) return { ...binding, sessionID }; - const session = await context.session.get({ sessionID }); - sessionID = session.parentID; - } - throw new Error("OpenCode session is not bound to a Paseo agent"); - } - const manifest = await request("/tools"); - const registration = await context.tool.transform((editor) => { - for (const definition of manifest.tools) { - editor.add({ - name: `paseo_${definition.name}`, - description: definition.description, - input: definition.inputSchema, - options: { codemode: false }, - async execute(input, call) { - const binding = await scope(call.sessionID); - const result = await request( - `/sessions/${encodeURIComponent(binding.sessionID)}/tools/${encodeURIComponent(definition.name)}`, - input, - ); - return { - content: result.content.map(toOpenCodeContent), - metadata: { paseoTool: definition.name }, - }; - }, - }); - } - }); - const filtering = await context.session.hook("context", async (input) => { - const binding = await scope(input.sessionID); - const allowed = new Set(binding.tools.map((name) => `paseo_${name}`)); - for (const name of Object.keys(input.tools)) { - if (name !== STRUCTURED_OUTPUT_TOOL && name.startsWith("paseo_") && !allowed.has(name)) - delete input.tools[name]; - } - }); + if (context.options.mode === "structured-only") return disposeStructuredOutput; + const disposeBridge = await registerSharedBridge(context); return async () => { + await disposeBridge(); await disposeStructuredOutput(); - await filtering.dispose(); - await registration.dispose(); }; }, }; - -function formatErrorDetail(details) { - if (!Array.isArray(details)) return typeof details === "string" ? details : ""; - return details - .map((issue) => { - const path = - Array.isArray(issue?.path) && issue.path.length > 0 ? issue.path.join(".") : "input"; - return `${path}: ${issue?.message ?? "Invalid value"}`; - }) - .join("; "); -} - -// OpenCode tool content is text or a file; MCP images arrive as base64 data. -function toOpenCodeContent(part) { - if (part.type === "image") { - return { type: "file", uri: `data:${part.mimeType};base64,${part.data}`, mime: part.mimeType }; - } - return part; -} diff --git a/packages/server/src/server/agent/providers/opencode/v2/bridge-provisioning.test.ts b/packages/server/src/server/agent/providers/opencode/v2/bridge-provisioning.test.ts new file mode 100644 index 0000000000..52b483cca3 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/bridge-provisioning.test.ts @@ -0,0 +1,37 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, test } from "vitest"; +import { hasManagedBridge, provisionLocalBridge } from "./bridge-provisioning.js"; + +test("installs a watched plugin without changing existing OpenCode configuration", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-opencode-provision-")); + const source = path.join(root, "source.js"); + const config = path.join(root, "opencode.json"); + await writeFile(source, "export default { id: 'paseo' };"); + await writeFile(path.join(root, "state.mjs"), "export const state = new Map();"); + await writeFile(config, '{"plugins":["existing"]}\n'); + try { + expect(await hasManagedBridge(root)).toBe(false); + await Promise.all([ + provisionLocalBridge({ configDirectory: root, artifact: source }), + provisionLocalBridge({ configDirectory: root, artifact: source }), + ]); + expect(await hasManagedBridge(root)).toBe(true); + const manifest = path.join(root, "plugins/paseo-managed-bridge/package.json"); + const formatted = `${JSON.stringify(JSON.parse(await readFile(manifest, "utf8")), null, 2)}\n`; + await writeFile(manifest, formatted); + expect(await hasManagedBridge(root)).toBe(true); + await provisionLocalBridge({ configDirectory: root, artifact: source }); + expect(await readFile(manifest, "utf8")).toBe(formatted); + expect(await readFile(config, "utf8")).toBe('{"plugins":["existing"]}\n'); + expect(await readFile(path.join(root, "plugins/paseo-managed-bridge/state.mjs"), "utf8")).toBe( + "export const state = new Map();", + ); + expect( + await readFile(path.join(root, "plugins/paseo-managed-bridge/server.js"), "utf8"), + ).toContain("id: 'paseo'"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/agent/providers/opencode/v2/bridge-provisioning.ts b/packages/server/src/server/agent/providers/opencode/v2/bridge-provisioning.ts new file mode 100644 index 0000000000..ce2626c38b --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/bridge-provisioning.ts @@ -0,0 +1,89 @@ +import { mkdir, readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { writeFileAtomic } from "../../../../atomic-file.js"; + +const PACKAGE = JSON.stringify({ + name: "paseo-managed-bridge", + private: true, + type: "module", + exports: { "./server": "./server.js" }, +}); + +export function openCodeConfigDirectory(env: NodeJS.ProcessEnv): string { + return ( + env.OPENCODE_CONFIG_DIR ?? + path.join(env.XDG_CONFIG_HOME ?? path.join(homedir(), ".config"), "opencode") + ); +} + +export async function hasManagedBridge(configDirectory: string): Promise { + const directory = path.join(configDirectory, "plugins", "paseo-managed-bridge"); + const [manifest, artifact] = await Promise.all([ + readFile(path.join(directory, "package.json"), "utf8").catch(() => ""), + readFile(path.join(directory, "server.js")).catch(() => undefined), + ]); + return isManagedPackage(manifest) && Boolean(artifact?.length); +} + +export async function provisionLocalBridge(input: { + configDirectory: string; + artifact: string; +}): Promise { + const directory = path.join(input.configDirectory, "plugins", "paseo-managed-bridge"); + await mkdir(path.dirname(directory), { recursive: true }); + let created = false; + try { + await mkdir(directory); + created = true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + } + const manifest = path.join(directory, "package.json"); + if (created) await writeFileAtomic(manifest, PACKAGE); + else if (!isManagedPackage(await readManagedManifest(manifest))) + throw new Error(`Cannot install OpenCode bridge: ${directory} is not managed by Paseo`); + const artifact = await readFile(input.artifact); + const state = await readFile(path.join(path.dirname(input.artifact), "state.mjs")); + const stateTarget = path.join(directory, "state.mjs"); + const existingState = await readFile(stateTarget).catch(() => undefined); + if (!existingState) await writeFileAtomic(stateTarget, state); + else if (!existingState.equals(state)) + throw new Error(`Cannot update OpenCode bridge state module at ${stateTarget}`); + const target = path.join(directory, "server.js"); + const existing = await readFile(target).catch(() => undefined); + if (!existing?.equals(artifact)) await writeFileAtomic(target, artifact); +} + +function isManagedPackage(content: string): boolean { + let value: unknown; + try { + value = JSON.parse(content); + } catch { + return false; + } + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const manifest = value as Record; + if (!manifest.exports || typeof manifest.exports !== "object" || Array.isArray(manifest.exports)) + return false; + const exports = manifest.exports as Record; + return ( + manifest.name === "paseo-managed-bridge" && + manifest.private === true && + manifest.type === "module" && + exports["./server"] === "./server.js" + ); +} + +async function readManagedManifest(file: string): Promise { + for (let attempt = 0; attempt < 20; attempt++) { + try { + return await readFile(file, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + await delay(25); + } + } + return ""; +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/external-runtime.test.ts b/packages/server/src/server/agent/providers/opencode/v2/external-runtime.test.ts new file mode 100644 index 0000000000..f4f39f4aeb --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/external-runtime.test.ts @@ -0,0 +1,180 @@ +import { createServer, type Server } from "node:http"; +import { createServer as createNetServer } from "node:net"; + +import { afterEach, describe, expect, test } from "vitest"; + +import { createTestLogger } from "../../../../../test-utils/test-logger.js"; +import { V2Runtime } from "./runtime.js"; +import { EXTERNAL_SERVER_PASSWORD_ENV, EXTERNAL_SERVER_URL_ENV } from "./external-server.js"; + +function listen(server: Server): Promise { + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + reject(new Error("Test server did not expose a TCP address")); + return; + } + resolve(address.port); + }); + }); +} + +function close(server: Server): Promise { + return new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + server.closeAllConnections(); + }); +} + +function reserveClosedPort(): Promise { + return new Promise((resolve, reject) => { + const server = createNetServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error("Could not reserve a test port")); + return; + } + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(address.port); + }); + }); + }); +} + +describe("V2Runtime against an external server", () => { + let server: Server | undefined; + + afterEach(async () => { + if (server) await close(server); + server = undefined; + }); + + test("reuses the configured server and never spawns a local one", async () => { + const requests: Array<{ url: string | undefined; authorization: string | undefined }> = []; + server = createServer((request, response) => { + requests.push({ url: request.url, authorization: request.headers.authorization }); + if (request.url !== "/api/info") { + response.writeHead(404); + response.end(); + return; + } + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ version: "2.0.10", pid: 1, urls: [], paths: { tmp: "/tmp" } })); + }); + const port = await listen(server); + const runtime = new V2Runtime({ + logger: createTestLogger(), + externalServer: { url: `http://127.0.0.1:${port}`, password: "secret" }, + }); + + const first = await runtime.acquire(); + const second = await runtime.acquire(); + expect(first.client).toBe(second.client); + expect(requests).toEqual([ + { + url: "/api/info", + authorization: `Basic ${Buffer.from("opencode:secret").toString("base64")}`, + }, + ]); + + await first.release(); + await second.release(); + const refreshed = await runtime.acquire({ fresh: true }); + expect(refreshed.client).toBe(first.client); + await refreshed.release(); + await runtime.shutdown(); + expect(requests).toHaveLength(1); + }); + + test("selects the external server from provider environment", async () => { + const paths: Array = []; + server = createServer((request, response) => { + paths.push(request.url); + if (request.url !== "/api/info") { + response.writeHead(404); + response.end(); + return; + } + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ version: "2.0.10", pid: 1, urls: [], paths: { tmp: "/tmp" } })); + }); + const port = await listen(server); + const runtime = new V2Runtime({ + logger: createTestLogger(), + settings: { + env: { + [EXTERNAL_SERVER_URL_ENV]: `http://127.0.0.1:${port}`, + [EXTERNAL_SERVER_PASSWORD_ENV]: "", + }, + }, + }); + + const connection = await runtime.acquire(); + expect(paths).toEqual(["/api/info"]); + await connection.release(); + await runtime.shutdown(); + }); + + test("reports an unreachable configured server", async () => { + const port = await reserveClosedPort(); + const runtime = new V2Runtime({ + logger: createTestLogger(), + externalServer: { url: `http://127.0.0.1:${port}` }, + }); + + await expect(runtime.acquire()).rejects.toThrow( + `Cannot reach OpenCode v2 server at http://127.0.0.1:${port}`, + ); + await runtime.shutdown(); + }); + + test("rejects session configuration that requires process isolation", async () => { + const runtime = new V2Runtime({ + logger: createTestLogger(), + externalServer: { url: "http://127.0.0.1:1" }, + }); + await expect(runtime.acquire({ dedicated: true })).rejects.toThrow( + "require an isolated server", + ); + await runtime.shutdown(); + }); + + test("rejects provider process environment on an explicit server", async () => { + const runtime = new V2Runtime({ + logger: createTestLogger(), + externalServer: { url: "http://127.0.0.1:1" }, + settings: { env: { XDG_CONFIG_HOME: "/custom", PROVIDER_KEY: "secret" } }, + }); + await expect(runtime.acquire()).rejects.toThrow("XDG_CONFIG_HOME, PROVIDER_KEY"); + await runtime.shutdown(); + }); + + test("rejects an unsupported explicit server version", async () => { + server = createServer((_request, response) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ version: "2.0.9", pid: 1, urls: [], paths: { tmp: "/tmp" } })); + }); + const port = await listen(server); + const runtime = new V2Runtime({ + logger: createTestLogger(), + externalServer: { url: `http://127.0.0.1:${port}` }, + }); + await expect(runtime.acquire()).rejects.toThrow("Unsupported OpenCode version 2.0.9"); + await runtime.shutdown(); + }); +}); diff --git a/packages/server/src/server/agent/providers/opencode/v2/external-server.test.ts b/packages/server/src/server/agent/providers/opencode/v2/external-server.test.ts new file mode 100644 index 0000000000..f9986215ad --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/external-server.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "vitest"; + +import { + EXTERNAL_SERVER_PASSWORD_ENV, + EXTERNAL_SERVER_URL_ENV, + resolveExternalOpenCodeServer, +} from "./external-server.js"; + +describe("resolveExternalOpenCodeServer", () => { + test("returns undefined when no URL is configured", () => { + expect(resolveExternalOpenCodeServer(undefined)).toBeUndefined(); + expect(resolveExternalOpenCodeServer({ [EXTERNAL_SERVER_URL_ENV]: " " })).toBeUndefined(); + }); + + test("reads the URL and password and removes a trailing slash", () => { + expect( + resolveExternalOpenCodeServer({ + [EXTERNAL_SERVER_URL_ENV]: " http://127.0.0.1:4096/ ", + [EXTERNAL_SERVER_PASSWORD_ENV]: "secret", + }), + ).toEqual({ url: "http://127.0.0.1:4096", password: "secret" }); + }); + + test("omits an empty password", () => { + expect( + resolveExternalOpenCodeServer({ + [EXTERNAL_SERVER_URL_ENV]: "https://opencode.example.com", + [EXTERNAL_SERVER_PASSWORD_ENV]: "", + }), + ).toEqual({ url: "https://opencode.example.com" }); + }); + + test("preserves spaces in the password", () => { + expect( + resolveExternalOpenCodeServer({ + [EXTERNAL_SERVER_URL_ENV]: "https://opencode.example.com", + [EXTERNAL_SERVER_PASSWORD_ENV]: " secret ", + }), + ).toEqual({ url: "https://opencode.example.com", password: " secret " }); + }); + + test("rejects relative and non-HTTP URLs", () => { + expect(() => + resolveExternalOpenCodeServer({ [EXTERNAL_SERVER_URL_ENV]: "127.0.0.1:4096" }), + ).toThrow(`${EXTERNAL_SERVER_URL_ENV} must be an absolute URL, received "127.0.0.1:4096"`); + expect(() => + resolveExternalOpenCodeServer({ [EXTERNAL_SERVER_URL_ENV]: "unix:/tmp/opencode.sock" }), + ).toThrow( + `${EXTERNAL_SERVER_URL_ENV} must use http or https, received "unix:/tmp/opencode.sock"`, + ); + }); + + test("rejects credentials embedded in URLs", () => { + expect(() => + resolveExternalOpenCodeServer({ + [EXTERNAL_SERVER_URL_ENV]: "https://user:password@opencode.example.com", + }), + ).toThrow("must not contain credentials"); + }); +}); diff --git a/packages/server/src/server/agent/providers/opencode/v2/external-server.ts b/packages/server/src/server/agent/providers/opencode/v2/external-server.ts new file mode 100644 index 0000000000..c00b158ce3 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/external-server.ts @@ -0,0 +1,38 @@ +export const EXTERNAL_SERVER_URL_ENV = "PASEO_OPENCODE_SERVER_URL"; +export const EXTERNAL_SERVER_PASSWORD_ENV = "PASEO_OPENCODE_SERVER_PASSWORD"; + +export interface ExternalOpenCodeServer { + url: string; + password?: string; +} + +/** + * Reads the optional connection to a user-managed OpenCode v2 server from + * provider environment. When absent, Paseo keeps spawning its own server. + */ +export function resolveExternalOpenCodeServer( + env: Record | undefined, +): ExternalOpenCodeServer | undefined { + const rawUrl = env?.[EXTERNAL_SERVER_URL_ENV]?.trim(); + if (!rawUrl) return undefined; + + let url: URL; + try { + url = new URL(rawUrl); + } catch { + throw new Error(`${EXTERNAL_SERVER_URL_ENV} must be an absolute URL, received "${rawUrl}"`); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error(`${EXTERNAL_SERVER_URL_ENV} must use http or https, received "${rawUrl}"`); + } + if (url.username || url.password) + throw new Error( + `${EXTERNAL_SERVER_URL_ENV} must not contain credentials; use ${EXTERNAL_SERVER_PASSWORD_ENV}`, + ); + + const password = env?.[EXTERNAL_SERVER_PASSWORD_ENV]; + return { + url: url.toString().replace(/\/$/, ""), + ...(password ? { password } : {}), + }; +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/owned-runtime.test.ts b/packages/server/src/server/agent/providers/opencode/v2/owned-runtime.test.ts new file mode 100644 index 0000000000..d60535889b --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/owned-runtime.test.ts @@ -0,0 +1,77 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "vitest"; +import { createTestLogger } from "../../../../../test-utils/test-logger.js"; +import { OpenCodeBridge } from "../bridge.js"; +import { provisionLocalBridge } from "./bridge-provisioning.js"; +import { V2Runtime } from "./runtime.js"; + +test("an owned helper uses an installed bridge once and survives catalog refresh", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-owned-opencode-")); + const config = path.join(root, "config", "opencode"); + const script = path.join(root, "server.cjs"); + const record = path.join(root, "env.json"); + const bridge = new OpenCodeBridge({ paseoHome: root, logger: createTestLogger() }); + await bridge.start(); + const artifact = fileURLToPath(await bridge.materializeV2Plugin()); + await provisionLocalBridge({ + configDirectory: config, + artifact: path.join(artifact, "server.js"), + }); + await writeFile( + script, + ` + const http = require('node:http'); + require('node:fs').writeFileSync(${JSON.stringify(record)}, process.env.OPENCODE_CONFIG_CONTENT); + const server = http.createServer((_request, response) => { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ version: '2.0.10', pid: process.pid, urls: [], paths: { tmp: '/tmp' } })); + }); + server.listen(0, '127.0.0.1', () => console.log('server listening on http://127.0.0.1:' + server.address().port)); + `, + ); + const runtime = new V2Runtime({ + logger: createTestLogger(), + bridge, + selection: { kind: "owned" }, + settings: { + command: { mode: "replace", argv: [process.execPath, script] }, + env: { XDG_CONFIG_HOME: path.join(root, "config"), OPENCODE_CONFIG_CONTENT: "{}" }, + }, + }); + try { + const first = await runtime.acquire(); + const pid = (await first.client.server.info()).pid; + expect(JSON.parse(await readFile(record, "utf8"))).toEqual({}); + await first.release(); + const refreshed = await runtime.acquire({ fresh: true }); + expect((await refreshed.client.server.info()).pid).toBe(pid); + await refreshed.release(); + } finally { + await runtime.shutdown(); + await bridge.close(); + await rm(root, { recursive: true, force: true }); + } +}, 15_000); + +test("reports a bridge port change that has not been applied", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-opencode-port-")); + const bridge = new OpenCodeBridge({ paseoHome: root, logger: createTestLogger() }); + await bridge.start(); + const actual = Number(new URL(bridge.sharedV2Connection().baseUrl).port); + const runtime = new V2Runtime({ + logger: createTestLogger(), + bridge, + selection: { kind: "explicit", server: { url: "http://127.0.0.1:1" } }, + settings: { env: { PASEO_OPENCODE_BRIDGE_PORT: actual === 1 ? "2" : "1" } }, + }); + try { + await expect(runtime.acquire()).rejects.toThrow("restart Paseo to apply the bridge port"); + } finally { + await runtime.shutdown(); + await bridge.close(); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/agent/providers/opencode/v2/runtime.ts b/packages/server/src/server/agent/providers/opencode/v2/runtime.ts index cab5544582..0fe830fe45 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/runtime.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/runtime.ts @@ -16,11 +16,25 @@ import type { ManagedProcessRegistry } from "../../../../managed-processes/manag import { resolveOpenCodeHomeDir } from "../paths.js"; import { OpenCodeHttpError } from "../http-error.js"; import { raceProviderRefreshAbort } from "../../../provider-refresh-deadline.js"; +import { resolveExternalOpenCodeServer, type ExternalOpenCodeServer } from "./external-server.js"; +import { selectOpenCodeService, type OpenCodeServiceSelection } from "./service-selection.js"; +import { bridgeBindings, connectSharedService } from "./shared-service.js"; +import { + hasManagedBridge, + openCodeConfigDirectory, + provisionLocalBridge, +} from "./bridge-provisioning.js"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { OpenCodeBridge } from "../bridge.js"; export interface V2Connection { client: V2Api; release(): Promise; retain(): V2Connection; + bindSession(sessionId: string, cwd: string): Promise; + unbindSession(sessionId: string, cwd: string): Promise; + refreshLocation(cwd: string, signal?: AbortSignal): Promise; readonly exited: Promise; } interface Generation { @@ -28,12 +42,17 @@ interface Generation { users: number; stop(): Promise; exited: Promise; + bindSession(sessionId: string, cwd: string): Promise; + unbindSession(sessionId: string, cwd: string): Promise; + refreshLocation(cwd: string, signal?: AbortSignal): Promise; } export interface V2RuntimeOptions { logger: Logger; settings?: ProviderRuntimeSettings; managedProcesses?: ManagedProcessRegistry; - decorateEnv?: (env: Record) => Promise>; + externalServer?: ExternalOpenCodeServer; + selection?: OpenCodeServiceSelection; + bridge?: OpenCodeBridge; } export interface V2RuntimeAcquireOptions { @@ -49,17 +68,36 @@ export class V2Runtime { private generations = new Set(); private starts = new Set>(); private closed = false; + private readonly selection: Promise; - constructor(private readonly options: V2RuntimeOptions) {} + constructor(private readonly options: V2RuntimeOptions) { + this.selection = options.selection + ? Promise.resolve(options.selection) + : selectOpenCodeService({ + explicit: + options.externalServer ?? + resolveExternalOpenCodeServer({ + ...globalThis.process.env, + ...options.settings?.env, + }), + env: options.settings?.env, + }); + } async acquire(input: V2RuntimeAcquireOptions = {}): Promise { if (this.closed) throw new Error("OpenCode runtime is closed"); input.signal?.throwIfAborted(); + this.assertBridgePort(); + const selection = await raceProviderRefreshAbort(input.signal, this.selection); let pending: Promise; - if (input.env || input.dedicated) { + if (selection.kind !== "owned") { + this.assertSharedConfiguration(input, selection); + this.current ??= this.startTracked(); + pending = this.current; + } else if (input.env || input.dedicated) { pending = this.startTracked(input.env); } else { - if (input.fresh || !this.current) this.current = this.startTracked(); + if (!this.current) this.current = this.startTracked(); pending = this.current; } let generation: Generation; @@ -97,6 +135,39 @@ export class V2Runtime { return connection; } + private assertBridgePort(): void { + const port = this.options.settings?.env?.PASEO_OPENCODE_BRIDGE_PORT; + if (!port || !this.options.bridge) return; + const actual = new URL(this.options.bridge.sharedV2Connection().baseUrl).port; + if (Number(port) !== Number(actual)) + throw new Error("PASEO_OPENCODE_BRIDGE_PORT changed; restart Paseo to apply the bridge port"); + } + + private assertSharedConfiguration( + input: V2RuntimeAcquireOptions, + selection: OpenCodeServiceSelection, + ): void { + const connectionEnv = new Set([ + "PASEO_OPENCODE_SERVER_URL", + "PASEO_OPENCODE_SERVER_PASSWORD", + "PASEO_OPENCODE_BRIDGE_URL", + "PASEO_OPENCODE_BRIDGE_PORT", + ]); + if (selection.kind === "discovered") { + connectionEnv.add("XDG_CONFIG_HOME"); + connectionEnv.add("XDG_STATE_HOME"); + connectionEnv.add("OPENCODE_CONFIG_DIR"); + } + const processOverrides = Object.keys(this.options.settings?.env ?? {}).filter( + (key) => !connectionEnv.has(key), + ); + if (input.env || input.dedicated || processOverrides.length) { + throw new Error( + `Custom OpenCode MCP servers or process environment require an isolated server and cannot be used with a shared OpenCode service${processOverrides.length ? `: ${processOverrides.join(", ")}` : ""}.`, + ); + } + } + private lease(generation: Generation, pending: Promise): V2Connection { if (this.closed || !this.generations.has(generation)) throw new Error("OpenCode helper server is no longer running"); @@ -107,7 +178,7 @@ export class V2Runtime { released = true; generation.users -= 1; if (generation.users !== 0) return; - if (this.current === pending) this.current = null; + if (this.current === pending) return; this.generations.delete(generation); await generation.stop(); }; @@ -115,6 +186,9 @@ export class V2Runtime { client: generation.client, release, retain: () => this.lease(generation, pending), + bindSession: generation.bindSession, + unbindSession: generation.unbindSession, + refreshLocation: generation.refreshLocation, exited: generation.exited, }; } @@ -146,6 +220,16 @@ export class V2Runtime { } private async start(env: Record = {}): Promise { + const selection = await this.selection; + if (selection.kind !== "owned") { + const generation: Generation = { + ...(await connectSharedService(selection, this.options)), + users: 0, + }; + this.generations.add(generation); + return generation; + } + const { settings, managedProcesses, logger } = this.options; const launch = await resolveProviderLaunch({ commandConfig: settings?.command, @@ -160,9 +244,20 @@ export class V2Runtime { ...settings?.env, ...env, }; - const decorated = this.options.decorateEnv - ? await this.options.decorateEnv(configured) - : decorateOpenCodeV2Env(configured, await materializeOpenCodeV2Plugin(resolvePaseoHome())); + const bridge = this.options.bridge; + const plugin = bridge + ? await bridge.materializeV2Plugin() + : await materializeOpenCodeV2Plugin(resolvePaseoHome()); + const globalConfig = openCodeConfigDirectory({ ...globalThis.process.env, ...configured }); + const managed = await hasManagedBridge(globalConfig); + if (managed && bridge) + await provisionLocalBridge({ + configDirectory: globalConfig, + artifact: path.join(fileURLToPath(plugin), "server.js"), + }); + const decorated = managed + ? configured + : decorateOpenCodeV2Env(configured, plugin, bridge ? {} : { mode: "structured-only" }); const args = [...launch.args, "serve", "--hostname", "127.0.0.1", "--port", "0"]; const process = spawnProcess(launch.command, args, { cwd, @@ -253,7 +348,13 @@ export class V2Runtime { }, }); await client.server.info({ signal: AbortSignal.timeout(Math.max(1, deadline - Date.now())) }); - const generation: Generation = { client, users: 0, stop, exited }; + const generation: Generation = { + client, + users: 0, + stop, + exited, + ...bridgeBindings(client, bridge), + }; this.generations.add(generation); process.once("exit", () => { this.generations.delete(generation); diff --git a/packages/server/src/server/agent/providers/opencode/v2/service-selection.test.ts b/packages/server/src/server/agent/providers/opencode/v2/service-selection.test.ts new file mode 100644 index 0000000000..876582fb9c --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/service-selection.test.ts @@ -0,0 +1,55 @@ +import { createServer } from "node:http"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { tmpdir } from "node:os"; +import { expect, test } from "vitest"; +import { selectOpenCodeService } from "./service-selection.js"; + +test("selects the registered service with its credential and ignores stale registration", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-service-")); + const file = path.join(directory, "service.json"); + let requests = 0; + const server = createServer((request, response) => { + requests++; + if ( + request.headers.authorization !== `Basic ${Buffer.from("opencode:secret").toString("base64")}` + ) { + response.writeHead(401).end(); + return; + } + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ version: "2.0.10", pid: process.pid })); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("No test address"); + const url = `http://127.0.0.1:${address.port}`; + try { + await writeFile( + file, + JSON.stringify({ url, password: "secret", pid: process.pid, version: "2.0.10" }), + ); + expect(await selectOpenCodeService({ file })).toEqual({ + kind: "discovered", + server: { url, password: "secret" }, + }); + expect(requests).toBe(1); + expect( + await selectOpenCodeService({ file, explicit: { url: "http://explicit.test" } }), + ).toEqual({ kind: "explicit", server: { url: "http://explicit.test" } }); + expect(requests).toBe(1); + await writeFile(file, JSON.stringify({ url, password: "secret", pid: process.pid + 1 })); + expect(await selectOpenCodeService({ file })).toEqual({ kind: "owned" }); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/agent/providers/opencode/v2/service-selection.ts b/packages/server/src/server/agent/providers/opencode/v2/service-selection.ts new file mode 100644 index 0000000000..eeb2cb0994 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/service-selection.ts @@ -0,0 +1,43 @@ +import { Service } from "@opencode/client/service"; +import { homedir, networkInterfaces } from "node:os"; +import path from "node:path"; +import type { ExternalOpenCodeServer } from "./external-server.js"; + +export type OpenCodeServiceSelection = + | { kind: "owned" } + | { kind: "explicit" | "discovered"; server: ExternalOpenCodeServer }; + +export async function selectOpenCodeService(input: { + explicit?: ExternalOpenCodeServer; + file?: string; + env?: Record; +}): Promise { + if (input.explicit) return { kind: "explicit", server: input.explicit }; + const state = + input.env?.XDG_STATE_HOME ?? + process.env.XDG_STATE_HOME ?? + path.join(homedir(), ".local", "state"); + const endpoint = await Service.discover({ + file: input.file ?? path.join(state, "opencode", "service.json"), + version: compatibleVersion, + }).catch(() => undefined); + if (!endpoint) return { kind: "owned" }; + if (!isLocalOpenCodeHost(new URL(endpoint.url).hostname)) return { kind: "owned" }; + return { + kind: "discovered", + server: { url: endpoint.url, ...(endpoint.auth ? { password: endpoint.auth.password } : {}) }, + }; +} + +export function isLocalOpenCodeHost(input: string): boolean { + const hostname = input.replace(/^\[|\]$/g, ""); + const localAddresses = Object.values(networkInterfaces()).flatMap((items) => + (items ?? []).map((item) => item.address), + ); + return ["localhost", "127.0.0.1", "::1", "0.0.0.0", "::", ...localAddresses].includes(hostname); +} + +export function compatibleVersion(version: string): boolean { + const match = /^2\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(version); + return Boolean(match && (Number(match[1]) > 0 || Number(match[2]) >= 10)); +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/session.ts b/packages/server/src/server/agent/providers/opencode/v2/session.ts index f173ccb301..a167ec0713 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/session.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/session.ts @@ -146,6 +146,7 @@ export class OpenCodeV2Session implements AgentSession { } private async configureConnection() { const location = { directory: this.config.cwd }; + await this.connection.bindSession(this.id, this.config.cwd); await waitForLocationReady({ client: this.client, location, signal: this.abort.signal }); if (this.requiresPaseoPlugin) await awaitPaseoPlugin({ client: this.client, location, signal: this.abort.signal }); @@ -207,6 +208,11 @@ export class OpenCodeV2Session implements AgentSession { if (!this.acquire) throw new Error("OpenCode helper server exited"); await this.stream; await this.sync.catch(() => undefined); + await this.connection + .unbindSession(this.id, this.config.cwd) + .catch((error: unknown) => + this.logger.warn({ error }, "Could not unbind the OpenCode Paseo bridge"), + ); const next = await this.acquire(); if (this.closed) { await next.release(); @@ -386,6 +392,7 @@ export class OpenCodeV2Session implements AgentSession { } async startTurn(prompt: AgentPromptInput, options?: AgentRunOptions) { await this.reconnectIfExited(); + await this.connection.bindSession(this.id, this.config.cwd); return this.turns.startTurn(prompt, options); } steerActiveTurn(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise { @@ -436,6 +443,7 @@ export class OpenCodeV2Session implements AgentSession { } } private async reconcileConnection() { + await this.connection.bindSession(this.id, this.config.cwd); // Session environments are process-local. Reapply this agent's snapshot before // reconciling a new connection, including event-stream reconnections. if (this.launchEnv) @@ -560,6 +568,11 @@ export class OpenCodeV2Session implements AgentSession { await this.stream; await this.reconnecting?.catch(() => undefined); await this.sync.catch(() => undefined); + await this.connection + .unbindSession(this.id, this.config.cwd) + .catch((error: unknown) => + this.logger.warn({ error }, "Could not unbind the OpenCode Paseo bridge"), + ); this.unbind?.(); try { if (!this.persist) await this.client.session.remove({ sessionID: this.id }); diff --git a/packages/server/src/server/agent/providers/opencode/v2/shared-bridge-plugin.mjs b/packages/server/src/server/agent/providers/opencode/v2/shared-bridge-plugin.mjs new file mode 100644 index 0000000000..4be98bfba9 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/shared-bridge-plugin.mjs @@ -0,0 +1,207 @@ +import { STRUCTURED_OUTPUT_TOOL } from "./structured-output-plugin.mjs"; + +// OpenCode owns this plugin for the lifetime of a location. Paseo supplies only +// live, per-session bindings over the authenticated OpenCode RPC channel. +export async function registerSharedBridge(context) { + const state = await import(new URL("./state.mjs", import.meta.url).href); + const { bindings, definitions } = state.stateFor(context.location); + const registrations = new Map(); + let updating = Promise.resolve(); + async function request(binding, pathname, body, allowMissing = false, signal) { + const requestSignal = body === undefined ? AbortSignal.timeout(5_000) : signal; + const response = await fetch(new URL(`/_internal/opencode${pathname}`, binding.baseUrl), { + method: body === undefined ? "GET" : "POST", + headers: { Authorization: `Bearer ${binding.token}`, "Content-Type": "application/json" }, + signal: requestSignal, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + if (allowMissing && response.status === 404) return null; + const payload = await response.json().catch(() => null); + if (!response.ok) { + const detail = formatErrorDetail(payload?.issues ?? payload?.details); + const message = payload?.error ?? `Paseo tool bridge returned HTTP ${response.status}`; + const error = new Error(detail ? `${message}: ${detail}` : message); + error.status = response.status; + if (detail) error.details = detail; + throw error; + } + return payload; + } + async function find(sessionID) { + const seen = new Set(); + while (sessionID && !seen.has(sessionID)) { + seen.add(sessionID); + const binding = bindings.get(sessionID); + if (binding) return { ...binding, sessionID }; + sessionID = (await context.session.get({ sessionID })).parentID; + } + return null; + } + async function install(binding) { + const manifest = await request(binding, "/tools"); + for (const definition of manifest.tools) await installDefinition(definition); + } + async function installDefinition(definition) { + const signature = JSON.stringify(definition); + const known = definitions.get(definition.name); + if (known && JSON.stringify(known) !== signature) + throw new Error(`Conflicting Paseo tool definition: ${definition.name}`); + const old = registrations.get(definition.name); + if (old) { + if (old.signature !== signature) + throw new Error(`Conflicting Paseo tool definition: ${definition.name}`); + return; + } + const registration = await context.tool.transform((editor) => { + editor.add({ + name: `paseo_${definition.name}`, + description: definition.description, + input: definition.inputSchema, + options: { codemode: false }, + async execute(input, call) { + const scope = await find(call.sessionID); + if (!scope) throw new Error("OpenCode session is not bound to a Paseo agent"); + const result = await request( + scope, + `/sessions/${encodeURIComponent(scope.sessionID)}/tools/${encodeURIComponent(definition.name)}`, + input, + false, + call.signal, + ); + return { + content: result.content.map(toOpenCodeContent), + metadata: { paseoTool: definition.name }, + }; + }, + }); + }); + registrations.set(definition.name, { signature, registration }); + definitions.set(definition.name, definition); + } + async function clearUnusedDefinitions() { + if (bindings.size) return; + await Promise.all( + [...registrations.values()].map(({ registration }) => registration.dispose()), + ); + registrations.clear(); + definitions.clear(); + } + // Reinstall live tools before this plugin generation is made visible. + // Definitions are kept in memory, so unavailable owners cannot delay reload. + for (const definition of definitions.values()) await installDefinition(definition); + const object = { type: "object", additionalProperties: true }; + const rpc = await context.rpc.register( + { + id: "paseo.bridge", + methods: { + bind: { input: object, output: object }, + unbind: { input: object, output: object }, + refresh: { input: object, output: object }, + }, + events: {}, + }, + { + bind: async (input) => { + if ( + typeof input.sessionID !== "string" || + typeof input.baseUrl !== "string" || + typeof input.token !== "string" + ) + throw new Error("Invalid Paseo bridge binding"); + const next = updating + .catch(() => undefined) + .then(async () => { + const previous = bindings.get(input.sessionID); + if ( + previous && + (previous.baseUrl !== input.baseUrl || previous.token !== input.token) + ) { + let active; + try { + active = await request( + previous, + `/sessions/${encodeURIComponent(input.sessionID)}/context`, + undefined, + true, + ); + } catch (error) { + // Refusal or rejected credentials mean this endpoint no longer + // serves the previous binding. Other failures are inconclusive. + if ( + error?.status !== 401 && + error?.cause?.code !== "ECONNREFUSED" && + error?.code !== "ConnectionRefused" + ) + throw error; + } + if (active) + return { error: "OpenCode session is already bound to another Paseo bridge" }; + bindings.delete(input.sessionID); + await clearUnusedDefinitions(); + } + await install(input); + bindings.set(input.sessionID, { baseUrl: input.baseUrl, token: input.token }); + return {}; + }); + updating = next; + return await next; + }, + unbind: async (input) => { + const next = updating + .catch(() => undefined) + .then(async () => { + const binding = bindings.get(input.sessionID); + if (binding && binding.token === input.token && binding.baseUrl === input.baseUrl) + bindings.delete(input.sessionID); + await clearUnusedDefinitions(); + return {}; + }); + updating = next; + await next; + return {}; + }, + refresh: async () => { + await context.provider.reload(); + await context.model.reload(); + await context.agent.reload(); + await context.command.reload(); + await context.skill.reload(); + return {}; + }, + }, + ); + const filter = await context.session.hook("context", async (input) => { + const binding = await find(input.sessionID); + const scope = + binding && + (await request(binding, `/sessions/${encodeURIComponent(binding.sessionID)}/context?tools`)); + const allowed = new Set((scope?.tools ?? []).map((name) => `paseo_${name}`)); + for (const name of Object.keys(input.tools)) { + if (name.startsWith("paseo_") && name !== STRUCTURED_OUTPUT_TOOL && !allowed.has(name)) + delete input.tools[name]; + } + }); + return async () => { + await filter.dispose(); + await rpc.dispose(); + await Promise.all( + [...registrations.values()].map(({ registration }) => registration.dispose()), + ); + }; +} + +function toOpenCodeContent(part) { + if (part.type === "image") + return { type: "file", uri: `data:${part.mimeType};base64,${part.data}`, mime: part.mimeType }; + return part; +} + +function formatErrorDetail(details) { + if (!Array.isArray(details)) return typeof details === "string" ? details : ""; + return details + .map((issue) => { + const path = Array.isArray(issue?.path) && issue.path.length ? issue.path.join(".") : "input"; + return `${path}: ${issue?.message ?? "Invalid value"}`; + }) + .join("; "); +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/shared-bridge.test.ts b/packages/server/src/server/agent/providers/opencode/v2/shared-bridge.test.ts new file mode 100644 index 0000000000..c560fa6558 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/shared-bridge.test.ts @@ -0,0 +1,168 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { expect, test } from "vitest"; +import { z } from "zod"; +import { createTestLogger } from "../../../../../test-utils/test-logger.js"; +import { OpenCodeBridge } from "../bridge.js"; + +interface Binding { + sessionID: string; + baseUrl: string; + token: string; +} +interface TestTool { + name: string; + execute(input: { value: string }, call: { sessionID: string }): Promise; +} +interface TestContext { + sessionID: string; + tools: Record; + system: unknown[]; +} + +test("a shared plugin binds Paseo sessions without involving unrelated sessions", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-shared-bridge-")); + const bridge = new OpenCodeBridge({ paseoHome: home, logger: createTestLogger() }); + const tool = { name: "echo", description: "echo", inputSchema: { value: z.string() } }; + const tools = new Map([[tool.name, tool]]); + bridge.setManifestCatalog({ tools }); + await bridge.start(); + const unbind = bridge.bindSession({ + sessionId: "paseo", + env: {}, + tools: { + tools, + getTool: (name) => tools.get(name), + executeTool: async (_name, input) => ({ + content: [{ type: "text", text: String((input as { value: string }).value) }], + }), + }, + }); + expect(() => bridge.bindSession({ sessionId: "paseo", env: {} })).toThrow("already bound"); + try { + const plugin = { + package: await bridge.materializeV2Plugin(), + options: bridge.sharedV2Connection(), + }; + const module = await import( + pathToFileURL(path.join(fileURLToPath(plugin.package), "server.js")).href + ); + const installed = new Map(); + const hooks: Array<(input: TestContext) => Promise> = []; + const refreshed: string[] = []; + let structured = false; + let rpc: Record Promise> = {}; + const context = { + options: {}, + location: { directory: home }, + provider: { + reload: async () => { + refreshed.push("provider"); + }, + }, + model: { + reload: async () => { + refreshed.push("model"); + }, + }, + agent: { + reload: async () => { + refreshed.push("agent"); + }, + }, + command: { + reload: async () => { + refreshed.push("command"); + }, + }, + skill: { + reload: async () => { + refreshed.push("skill"); + }, + }, + rpc: { + register: async (_definition: unknown, handlers: typeof rpc) => { + rpc = handlers; + return { dispose: async () => {} }; + }, + }, + tool: { + transform: async (fn: (editor: { add(item: TestTool): void }) => void) => { + fn({ add: (item) => installed.set(item.name, item) }); + return { dispose: async () => {} }; + }, + }, + session: { + context: async () => + structured ? [{ type: "user", metadata: { paseoOutputSchema: { type: "string" } } }] : [], + get: async () => ({ parentID: "" }), + hook: async (_name: string, fn: (input: TestContext) => Promise) => { + hooks.push(fn); + return { dispose: async () => {} }; + }, + }, + }; + let dispose = await module.default.setup(context); + await rpc.bind({ sessionID: "paseo", ...plugin.options }); + await rpc.refresh({ sessionID: "paseo", ...plugin.options }); + expect(refreshed).toEqual(["provider", "model", "agent", "command", "skill"]); + await expect( + rpc.bind({ sessionID: "paseo", ...plugin.options, token: "another-daemon" }), + ).resolves.toEqual({ error: "OpenCode session is already bound to another Paseo bridge" }); + const own = { sessionID: "paseo", tools: { paseo_echo: {}, native: {} }, system: [] }; + for (const hook of hooks) await hook(own); + expect(Object.keys(own.tools)).toEqual(["paseo_echo", "native"]); + expect( + await installed.get("paseo_echo")!.execute({ value: "ok" }, { sessionID: "paseo" }), + ).toMatchObject({ content: [{ type: "text", text: "ok" }] }); + await dispose(); + hooks.length = 0; + installed.clear(); + dispose = await module.default.setup(context); + const afterReload = { sessionID: "paseo", tools: { paseo_echo: {}, native: {} }, system: [] }; + for (const hook of hooks) await hook(afterReload); + expect(Object.keys(afterReload.tools)).toEqual(["paseo_echo", "native"]); + expect( + await installed.get("paseo_echo")!.execute({ value: "reloaded" }, { sessionID: "paseo" }), + ).toMatchObject({ content: [{ type: "text", text: "reloaded" }] }); + await bridge.close(); + await dispose(); + hooks.length = 0; + installed.clear(); + dispose = await module.default.setup(context); + const unrelated = { sessionID: "other", tools: { paseo_echo: {}, native: {} }, system: [] }; + for (const hook of hooks) await hook(unrelated); + expect(Object.keys(unrelated.tools)).toEqual(["native"]); + structured = true; + const structuredOnly = { + sessionID: "other", + tools: { paseo_structured_output: {} }, + system: [], + }; + for (const hook of hooks) await hook(structuredOnly); + expect(Object.keys(structuredOnly.tools)).toEqual(["paseo_structured_output"]); + const restarted = new OpenCodeBridge({ + paseoHome: home, + logger: createTestLogger(), + port: Number(new URL(plugin.options.baseUrl).port), + }); + restarted.setManifestCatalog({ tools }); + await restarted.start(); + const releaseRestarted = restarted.bindSession({ sessionId: "paseo", env: {} }); + try { + await expect( + rpc.bind({ sessionID: "paseo", ...restarted.sharedV2Connection() }), + ).resolves.toEqual({}); + } finally { + releaseRestarted(); + await restarted.close(); + } + await dispose(); + } finally { + unbind(); + await bridge.close(); + await rm(home, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/agent/providers/opencode/v2/shared-service.ts b/packages/server/src/server/agent/providers/opencode/v2/shared-service.ts new file mode 100644 index 0000000000..26fcbb7d26 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/shared-service.ts @@ -0,0 +1,192 @@ +import { OpenCode } from "@opencode/client"; +import { mkdir } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { Logger } from "pino"; +import type { ProviderRuntimeSettings } from "../../../provider-launch-config.js"; +import type { OpenCodeBridge } from "../bridge.js"; +import { OpenCodeHttpError } from "../http-error.js"; +import { resolveOpenCodeHomeDir } from "../paths.js"; +import { openCodeConfigDirectory, provisionLocalBridge } from "./bridge-provisioning.js"; +import { awaitPaseoPlugin } from "./readiness.js"; +import { + compatibleVersion, + isLocalOpenCodeHost, + type OpenCodeServiceSelection, +} from "./service-selection.js"; +import type { V2Api } from "./api.js"; + +interface SharedServiceOptions { + logger: Logger; + settings?: ProviderRuntimeSettings; + bridge?: OpenCodeBridge; +} + +export interface SharedService { + client: V2Api; + exited: Promise; + stop(): Promise; + bindSession(sessionId: string, cwd: string): Promise; + unbindSession(sessionId: string, cwd: string): Promise; + refreshLocation(cwd: string, signal?: AbortSignal): Promise; +} + +export async function connectSharedService( + selection: Extract, + options: SharedServiceOptions, +): Promise { + const server = selection.server; + const { logger } = options; + const lifetime = new AbortController(); + const closed = new Error(`OpenCode external server connection closed (${server.url})`); + let resolveExited!: (error: Error) => void; + const exited = new Promise((resolve) => { + resolveExited = resolve; + }); + const authorization = server.password + ? `Basic ${Buffer.from(`opencode:${server.password}`).toString("base64")}` + : undefined; + const client = OpenCode.make({ + baseUrl: server.url, + ...(authorization ? { headers: { Authorization: authorization } } : {}), + fetch: async (request, init) => { + const signal = init?.signal + ? AbortSignal.any([init.signal, lifetime.signal]) + : lifetime.signal; + const response = await fetch(request, { ...init, signal }); + const html = response.headers.get("content-type")?.includes("text/html") ?? false; + if (!response.ok || html) { + const requestUrl = request instanceof Request ? request.url : String(request); + throw new OpenCodeHttpError(new URL(requestUrl).pathname, response.status, html); + } + return response; + }, + }); + let info: Awaited>; + try { + info = await client.server.info({ + signal: AbortSignal.any([lifetime.signal, AbortSignal.timeout(30_000)]), + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Cannot reach OpenCode v2 server at ${server.url}: ${message}`, { + cause: error, + }); + } + if (!compatibleVersion(info.version)) + throw new Error( + `Unsupported OpenCode version ${info.version}; version 2.0.10 or newer is required`, + ); + const bridge = options.bridge; + const pluginDirectory = bridge ? fileURLToPath(await bridge.materializeV2Plugin()) : undefined; + if (selection.kind === "discovered" && pluginDirectory) { + const env = { ...globalThis.process.env, ...options.settings?.env }; + const configDirectory = openCodeConfigDirectory(env); + const home = resolveOpenCodeHomeDir(); + await mkdir(home, { recursive: true }); + const entries = await client.config.get({ location: { directory: home } }); + if ( + !entries.some( + (entry) => + entry.type === "directory" && path.resolve(entry.path) === path.resolve(configDirectory), + ) + ) + throw new Error( + `OpenCode service does not use the expected configuration directory ${configDirectory}; configure the bridge on that service explicitly`, + ); + await provisionLocalBridge({ + configDirectory, + artifact: path.join(pluginDirectory, "server.js"), + }); + } + const advertised = + options.settings?.env?.PASEO_OPENCODE_BRIDGE_URL ?? + globalThis.process.env.PASEO_OPENCODE_BRIDGE_URL; + validateBridgeAddress(selection, bridge, advertised); + const generation: SharedService = { + client, + stop: async () => { + if (lifetime.signal.aborted) return; + lifetime.abort(closed); + resolveExited(closed); + }, + exited, + ...bridgeBindings(client, bridge, advertised, pluginDirectory), + }; + logger.info({ url: server.url }, "Using existing OpenCode v2 server"); + return generation; +} + +function validateBridgeAddress( + selection: Extract, + bridge: OpenCodeBridge | undefined, + advertised: string | undefined, +): void { + if (advertised) { + let bridgeUrl: URL; + try { + bridgeUrl = new URL(advertised); + } catch { + throw new Error("PASEO_OPENCODE_BRIDGE_URL must be an absolute HTTP or HTTPS URL"); + } + if (bridgeUrl.protocol !== "http:" && bridgeUrl.protocol !== "https:") + throw new Error("PASEO_OPENCODE_BRIDGE_URL must be an absolute HTTP or HTTPS URL"); + } + const hostname = new URL(selection.server.url).hostname; + if (bridge && selection.kind === "explicit" && !advertised && !isLocalOpenCodeHost(hostname)) + throw new Error( + "Remote OpenCode requires PASEO_OPENCODE_BRIDGE_URL pointing to a reachable Paseo bridge tunnel and the Paseo plugin installed on the server", + ); +} + +export function bridgeBindings( + client: ReturnType, + bridge: OpenCodeBridge | undefined, + advertised?: string, + pluginDirectory?: string, +): Pick { + return { + bindSession: async (sessionId, cwd) => { + if (!bridge) return; + const location = { directory: cwd }; + let result: Awaited>; + try { + await awaitPaseoPlugin({ client, location }); + result = await client.rpc.call({ + location, + rpcID: "paseo.bridge", + method: "bind", + input: { sessionID: sessionId, ...bridge.sharedV2Connection(advertised) }, + }); + } catch (error) { + throw new Error( + `Cannot connect the Paseo tool bridge to OpenCode. Install the plugin from ${pluginDirectory ?? "Paseo's runtime directory"} and configure a reachable bridge URL: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } + if (result.output && typeof result.output === "object" && "error" in result.output) + throw new Error(String(result.output.error)); + }, + unbindSession: async (sessionId, cwd) => { + if (bridge) + await client.rpc.call( + { + location: { directory: cwd }, + rpcID: "paseo.bridge", + method: "unbind", + input: { sessionID: sessionId, ...bridge.sharedV2Connection(advertised) }, + }, + { signal: AbortSignal.timeout(2_000) }, + ); + }, + refreshLocation: async (cwd, signal) => { + if (!bridge) return; + const location = { directory: cwd }; + await awaitPaseoPlugin({ client, location, signal }); + await client.rpc.call( + { location, rpcID: "paseo.bridge", method: "refresh", input: {} }, + { signal }, + ); + }, + }; +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/state.mjs b/packages/server/src/server/agent/providers/opencode/v2/state.mjs new file mode 100644 index 0000000000..858be8f14a --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/state.mjs @@ -0,0 +1,11 @@ +// OpenCode reloads the plugin entry point independently from this module. +// Keep live bindings and tool definitions in memory across those reloads. +const locations = new Map(); + +export function stateFor(location) { + const key = JSON.stringify([location.directory, location.workspaceID ?? null]); + if (!locations.has(key)) { + locations.set(key, { bindings: new Map(), definitions: new Map() }); + } + return locations.get(key); +} diff --git a/packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts index e03c66bca0..db278d0249 100644 --- a/packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts @@ -46,7 +46,7 @@ describe("daemon E2E (real opencode) - initial prompt wait", () => { } }); - test("waitForFinish does not resolve before an initial prompt using opencode/big-pickle actually completes", async () => { + test("waitForFinish does not resolve before an OpenCode initial prompt completes", async () => { const cwd = tmpCwd(); const { client, daemon } = await createHarness(); diff --git a/packages/server/src/server/daemon-e2e/real-provider-test-config.ts b/packages/server/src/server/daemon-e2e/real-provider-test-config.ts index e54b781e65..e15c618fbf 100644 --- a/packages/server/src/server/daemon-e2e/real-provider-test-config.ts +++ b/packages/server/src/server/daemon-e2e/real-provider-test-config.ts @@ -26,7 +26,7 @@ const PI_AUTH_CONFIG_PATH = join(homedir(), ".pi", "agent", "auth.json"); const CODEX_AUTH_CONFIG_PATH = join(homedir(), ".codex", "auth.json"); const CLAUDE_REAL_TEST_MODEL = "haiku"; const CODEX_REAL_TEST_MODEL = "~openai/gpt-latest"; -const OPENCODE_REAL_TEST_MODEL = "openrouter/google/gemini-2.5-flash-lite"; +const OPENCODE_REAL_TEST_MODEL = "openrouter/google/gemini-3.5-flash-lite"; const PI_OPENROUTER_REAL_TEST_MODEL = "openrouter/google/gemini-2.5-flash-lite"; const PI_CODEX_REAL_TEST_MODEL = "openai-codex/gpt-5.4"; const OMP_OPENROUTER_REAL_TEST_MODEL = "openrouter/google/gemini-2.5-flash-lite"; From 1008e9fffc20fc809d5d1023d43642b39df19c39 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 31 Oct 2026 19:25:16 +0700 Subject: [PATCH 04/58] Insert suggested commands into the composer (#8371) * feat(composer): insert suggested commands into drafts * fix(composer): close suggestions after command insertion * fix(composer): place caret after inserted commands --- .../e2e/browser/composer-autocomplete.spec.ts | 56 ++++++- .../cache/index.ts} | 48 ++++++ packages/app/src/agent-commands/index.tsx | 99 +++++++++++ .../query.test.ts} | 52 +++++- .../use-query.test.ts} | 12 +- .../use-query.ts} | 36 +++- .../app/src/components/markdown/images.ts | 10 +- .../src/components/markdown/renderer.test.ts | 24 +++ .../app/src/components/markdown/renderer.tsx | 39 +++-- .../app/src/components/markdown/source.ts | 18 +- packages/app/src/components/message.tsx | 1 + .../app/src/composer/draft/input-draft.ts | 2 +- .../composer/editing-session/index.test.ts | 25 +++ .../src/composer/editing-session/index.tsx | 19 +++ packages/app/src/composer/index.tsx | 2 +- packages/app/src/composer/input/input.tsx | 10 ++ packages/app/src/data/providers-snapshot.ts | 2 +- packages/app/src/data/push-router.ts | 20 ++- packages/app/src/data/query.ts | 4 +- .../app/src/git/checkout-status-cache.test.ts | 2 +- packages/app/src/git/checkout-status-cache.ts | 2 +- .../app/src/hooks/use-agent-autocomplete.ts | 6 +- .../src/hooks/use-providers-snapshot.test.ts | 155 +++++++++++++++++- packages/app/src/panels/agent-panel.tsx | 35 +++- .../provider-selection/provider-selection.ts | 2 +- 25 files changed, 626 insertions(+), 55 deletions(-) rename packages/app/src/{hooks/agent-commands-query.ts => agent-commands/cache/index.ts} (54%) create mode 100644 packages/app/src/agent-commands/index.tsx rename packages/app/src/{hooks/agent-commands-query.test.ts => agent-commands/query.test.ts} (51%) rename packages/app/src/{hooks/use-agent-commands-query.test.ts => agent-commands/use-query.test.ts} (82%) rename packages/app/src/{hooks/use-agent-commands-query.ts => agent-commands/use-query.ts} (70%) diff --git a/packages/app/e2e/browser/composer-autocomplete.spec.ts b/packages/app/e2e/browser/composer-autocomplete.spec.ts index d83046d8b2..f40b51304b 100644 --- a/packages/app/e2e/browser/composer-autocomplete.spec.ts +++ b/packages/app/e2e/browser/composer-autocomplete.spec.ts @@ -1,5 +1,9 @@ import { expect, test, type Page } from "../support/fixtures"; -import { composerLocator, expectComposerVisible } from "../support/helpers/composer"; +import { + attachFileFromMenu, + composerLocator, + expectComposerVisible, +} from "../support/helpers/composer"; import { openAgentRoute, seedMockAgentWorkspace, @@ -365,6 +369,56 @@ function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]): } test.describe("Composer autocomplete", () => { + test("inserts suggested commands from assistant messages without sending", async ({ page }) => { + const requests = recordCommandRequestsByAgentId(page); + const session = await seedMockAgentWorkspace({ + repoPrefix: "suggested-command-", + title: "Suggested commands", + initialPrompt: "Suggest `/mock` without running it", + featureValues: { mockAssistantResponse: "Try `/mock` or [Run mock](/mock)." }, + }); + try { + await openAgentRoute(page, session); + const message = page.getByTestId("assistant-message").filter({ hasText: "Run mock" }); + await expect(message).toBeVisible(); + await expect(page.getByTestId("user-message").getByRole("link")).toHaveCount(0); + await expect.poll(() => requests.length).toBe(1); + await attachFileFromMenu(page, { + name: "note.txt", + mimeType: "text/plain", + buffer: Buffer.from("attachment"), + }); + await expect(page.getByTestId("composer-file-attachment-pill")).toContainText("note.txt"); + await message.getByRole("link", { name: "/mock", exact: true }).click(); + await expect(composerLocator(page)).toHaveValue("/mock "); + await expect( + page.getByTestId("composer-autocomplete-popover").filter({ visible: true }), + ).toHaveCount(0); + await expect(composerLocator(page)).toBeFocused(); + await message.getByRole("link", { name: "Run mock" }).click(); + await expect(composerLocator(page)).toHaveValue("/mock /mock "); + await expect( + page.getByTestId("composer-autocomplete-popover").filter({ visible: true }), + ).toHaveCount(0); + await expect(composerLocator(page)).toBeFocused(); + await expect(page.getByTestId("composer-file-attachment-pill")).toContainText("note.txt"); + await composerLocator(page).fill("draft"); + await composerLocator(page).evaluate((element: HTMLTextAreaElement) => + element.setSelectionRange(0, 0), + ); + await message.getByRole("link", { name: "/mock", exact: true }).click(); + await composerLocator(page).pressSequentially("argument"); + await expect(composerLocator(page)).toHaveValue("draft /mock argument"); + await composerLocator(page).fill("/"); + await expect( + page.getByTestId("composer-autocomplete-popover").filter({ visible: true }), + ).toContainText("/mock"); + expect(requests).toHaveLength(1); + } finally { + await session.cleanup(); + } + }); + test("keeps New workspace off the running-agent command list before a project is chosen", async ({ page, }) => { diff --git a/packages/app/src/hooks/agent-commands-query.ts b/packages/app/src/agent-commands/cache/index.ts similarity index 54% rename from packages/app/src/hooks/agent-commands-query.ts rename to packages/app/src/agent-commands/cache/index.ts index 276c0fd9ba..474491f297 100644 --- a/packages/app/src/hooks/agent-commands-query.ts +++ b/packages/app/src/agent-commands/cache/index.ts @@ -1,8 +1,16 @@ import type { QueryClient } from "@tanstack/react-query"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; +import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; +import equal from "fast-deep-equal"; import { normalizeWorkspacePath } from "@/utils/workspace-identity"; export const AGENT_COMMANDS_QUERY_ROOT = "agentCommands"; +export const sessionCommandQueryPolicy = { + staleTime: 600_000, + gcTime: 1_800_000, + refetchOnMount: true, + refetchOnWindowFocus: false, +} as const; export interface AgentCommandsDraftConfig { provider: AgentProvider; @@ -21,6 +29,46 @@ export function agentCommandsQueryRoot(serverId: string) { return [AGENT_COMMANDS_QUERY_ROOT, serverId] as const; } +/** Snapshot announcements contain provider metadata, not command catalogs. Ignore initial + * discovery and unrelated providers; explicit refresh still invalidates every catalog. */ +export function invalidateCommandsForProviderChange(input: { + queryClient: QueryClient; + serverId: string; + cwd: string | null; + previous: readonly ProviderSnapshotEntry[] | undefined; + current: readonly ProviderSnapshotEntry[]; +}): Promise { + const previous = new Map((input.previous ?? []).map((entry) => [entry.provider, entry])); + const current = new Map(input.current.map((entry) => [entry.provider, entry])); + const relevant = (entry: ProviderSnapshotEntry | undefined) => + entry && { + status: entry.status, + enabled: entry.enabled, + source: entry.source, + models: entry.models, + modes: entry.modes, + defaultModeId: entry.defaultModeId, + }; + return input.queryClient.invalidateQueries({ + queryKey: agentCommandsQueryRoot(input.serverId), + predicate: (query) => { + const isDraft = query.queryKey[2] === "draft"; + const provider = + query.meta?.agentCommandProvider ?? (isDraft ? query.queryKey[6] : undefined); + const cwd = query.meta?.agentCommandCwd ?? (isDraft ? query.queryKey[4] : undefined); + if (input.cwd && typeof cwd === "string" && normalizeAgentCommandsCwd(cwd) !== input.cwd) + return false; + if (typeof provider !== "string") return false; + const before = previous.get(provider); + const after = current.get(provider); + if (!before) return after?.status === "ready" && query.state.status === "error"; + if (before.status !== "ready") + return after?.status === "ready" && query.state.status === "error"; + return !equal(relevant(before), relevant(after)); + }, + }); +} + export function sessionAgentCommandsQueryKey(input: { serverId: string; agentId: string }) { return [...agentCommandsQueryRoot(input.serverId), "session", input.agentId] as const; } diff --git a/packages/app/src/agent-commands/index.tsx b/packages/app/src/agent-commands/index.tsx new file mode 100644 index 0000000000..23d5a61e5f --- /dev/null +++ b/packages/app/src/agent-commands/index.tsx @@ -0,0 +1,99 @@ +import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import type { ComposerEditingSession } from "@/composer/editing-session"; +import { agentCommandsQueryKey } from "./cache"; +import { + useAgentCommandsQuery, + type AgentSlashCommand, + type DraftCommandConfig, +} from "./use-query"; + +export type { + AgentSlashCommand, + DraftCommandConfig, + DraftCommandTarget, + AgentCommandsClient, +} from "./use-query"; +export { fetchAgentCommands } from "./use-query"; + +type CommandQuery = ReturnType; +const QueryContext = createContext(null); +export interface CommandHints { + names: ReadonlySet; + insert(command: string): void; +} +const HintsContext = createContext(null); + +function useLiveAgentCommands() { + return useContext(QueryContext); +} +export function useAutocompleteCommands(input: { + serverId: string; + agentId: string; + enabled: boolean; + draftConfig?: DraftCommandConfig; +}) { + const live = useLiveAgentCommands(); + const fallback = useAgentCommandsQuery({ + serverId: input.serverId, + agentId: input.agentId, + enabled: !live && input.enabled, + draftConfig: input.draftConfig, + }); + return live ?? fallback; +} +export function useAgentCommandHints() { + return useContext(HintsContext); +} +export function matchCommandLink( + url: string, + sourceType: "markdown" | "text" | "inline-code" | undefined, + names: ReadonlySet, +): string | null { + return (sourceType === "markdown" || sourceType === "inline-code") && + url.startsWith("/") && + names.has(url.slice(1)) + ? url + : null; +} + +export function AgentCommandsProvider({ + serverId, + agentId, + editingSession, + enabled, + children, +}: { + serverId: string; + agentId: string; + editingSession: ComposerEditingSession; + enabled: boolean; + children: ReactNode; +}) { + const query = useAgentCommandsQuery({ serverId, agentId, enabled }); + const queryClient = useQueryClient(); + const namesKey = JSON.stringify( + [...new Set(query.commands.map((command) => command.name))].sort(), + ); + const names = useMemo>( + () => new Set(JSON.parse(namesKey) as string[]), + [namesKey], + ); + const insert = useCallback( + (token: string) => { + const commands = queryClient.getQueryData( + agentCommandsQueryKey({ serverId, agentId }), + ); + if (token.startsWith("/") && commands?.some((command) => `/${command.name}` === token)) { + editingSession.insertCommand(token); + } + }, + [agentId, editingSession, queryClient, serverId], + ); + const hints = useMemo(() => ({ names, insert }), [names, insert]); + return ( + + {children} + + ); +} diff --git a/packages/app/src/hooks/agent-commands-query.test.ts b/packages/app/src/agent-commands/query.test.ts similarity index 51% rename from packages/app/src/hooks/agent-commands-query.test.ts rename to packages/app/src/agent-commands/query.test.ts index e8d8ee0b94..33b55b4464 100644 --- a/packages/app/src/hooks/agent-commands-query.test.ts +++ b/packages/app/src/agent-commands/query.test.ts @@ -1,11 +1,61 @@ import { describe, expect, it } from "vitest"; +import { QueryClient, QueryObserver } from "@tanstack/react-query"; +import { fetchQueryOptions } from "@/data/query"; import { agentCommandsQueryKey, agentCommandsQueryRoot, draftAgentCommandsQueryKey, normalizeAgentCommandsCwd, sessionAgentCommandsQueryKey, -} from "./agent-commands-query"; + sessionCommandQueryPolicy, +} from "./cache"; + +it("reuses fresh commands and keeps stale commands during a shared refresh and failure", async () => { + const client = new QueryClient({ defaultOptions: { queries: { gcTime: 300_000 } } }); + const key = sessionAgentCommandsQueryKey({ serverId: "s", agentId: "a" }); + let calls = 0; + let fail = false; + let resolveRefresh: (value: string[]) => void = () => {}; + const refresh = new Promise((resolve) => { + resolveRefresh = resolve; + }); + const options = fetchQueryOptions({ + ...sessionCommandQueryPolicy, + staleTimeMs: sessionCommandQueryPolicy.staleTime, + dataShape: "value", + queryKey: key, + retry: false, + queryFn: async () => { + calls++; + if (fail) throw new Error("offline"); + return refresh; + }, + }); + client.setQueryData(key, ["old"]); + const fresh = new QueryObserver(client, options); + const unsubscribeFresh = fresh.subscribe(() => {}); + expect(calls).toBe(0); + unsubscribeFresh(); + client.setQueryData(key, ["old"], { updatedAt: 1 }); + const first = new QueryObserver(client, options); + const second = new QueryObserver(client, options); + const unsubscribeFirst = first.subscribe(() => {}); + const unsubscribeSecond = second.subscribe(() => {}); + expect(calls).toBe(1); + expect(first.getCurrentResult().data).toEqual(["old"]); + expect(client.getQueryCache().find({ queryKey: key })?.gcTime).toBe(1_800_000); + resolveRefresh(["new"]); + await refresh; + await first.refetch({ cancelRefetch: false }); + expect(second.getCurrentResult().data).toEqual(["new"]); + fail = true; + await client.invalidateQueries({ queryKey: key }); + expect(first.getCurrentResult().data).toEqual(["new"]); + expect(first.getCurrentResult().isError).toBe(true); + unsubscribeFirst(); + unsubscribeSecond(); + client.clear(); +}); describe("agent command query keys", () => { it("shares draft commands across draft tabs with the same provider setup", () => { diff --git a/packages/app/src/hooks/use-agent-commands-query.test.ts b/packages/app/src/agent-commands/use-query.test.ts similarity index 82% rename from packages/app/src/hooks/use-agent-commands-query.test.ts rename to packages/app/src/agent-commands/use-query.test.ts index 87f5919da8..db14491923 100644 --- a/packages/app/src/hooks/use-agent-commands-query.test.ts +++ b/packages/app/src/agent-commands/use-query.test.ts @@ -1,9 +1,5 @@ import { describe, expect, it } from "vitest"; -import { - type AgentCommandsClient, - type DraftCommandConfig, - fetchAgentCommands, -} from "./use-agent-commands-query"; +import { type AgentCommandsClient, type DraftCommandConfig, fetchAgentCommands } from "./use-query"; type ListCommands = AgentCommandsClient["listCommands"]; type ListCommandsResult = Awaited>; @@ -38,6 +34,12 @@ function commandsPayload(commands: ListCommandsResult["commands"]): ListCommands } describe("fetchAgentCommands", () => { + it("rejects a discovery error so a previous catalog is retained", async () => { + const client = createClient({ ...commandsPayload([]), error: "provider unavailable" }); + await expect(fetchAgentCommands({ client, agentId: "agent-1" })).rejects.toThrow( + "provider unavailable", + ); + }); it("loads commands for a draft composer without an agent id", async () => { const client = createClient( commandsPayload([{ name: "compact", description: "Compact context", argumentHint: "" }]), diff --git a/packages/app/src/hooks/use-agent-commands-query.ts b/packages/app/src/agent-commands/use-query.ts similarity index 70% rename from packages/app/src/hooks/use-agent-commands-query.ts rename to packages/app/src/agent-commands/use-query.ts index eddc0ab02b..000e6e8057 100644 --- a/packages/app/src/hooks/use-agent-commands-query.ts +++ b/packages/app/src/agent-commands/use-query.ts @@ -1,12 +1,15 @@ -import { useQuery } from "@tanstack/react-query"; +import { useFetchQuery } from "@/data/query"; import { useTranslation } from "react-i18next"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { useRetainedPanelActive } from "@/components/retained-panel"; +import { useSessionStore } from "@/stores/session-store"; +import { useShallow } from "zustand/shallow"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import { agentCommandsQueryKey, type AgentCommandsDraftConfig } from "@/hooks/agent-commands-query"; - -const DRAFT_COMMANDS_STALE_TIME = Number.POSITIVE_INFINITY; -const SESSION_COMMANDS_STALE_TIME = 60_000; +import { + agentCommandsQueryKey, + sessionCommandQueryPolicy, + type AgentCommandsDraftConfig, +} from "./cache"; export interface AgentSlashCommand { name: string; @@ -41,6 +44,7 @@ export async function fetchAgentCommands(input: { agentId: input.agentId, draftConfig: input.draftConfig, }); + if (response.error) throw new Error(response.error); return response.commands as AgentSlashCommand[]; } @@ -62,8 +66,16 @@ export function useAgentCommandsQuery({ const queryEnabled = enabled && retainedPanelActive; const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); + const { provider, cwd } = useSessionStore( + useShallow((state) => { + const agent = + state.sessions[serverId]?.agents?.get(agentId) ?? + state.sessions[serverId]?.agentDetails?.get(agentId); + return { provider: agent?.provider, cwd: agent?.cwd }; + }), + ); - const query = useQuery({ + const query = useFetchQuery({ queryKey: agentCommandsQueryKey({ serverId, agentId, draftConfig }), queryFn: async () => { if (!client) { @@ -72,7 +84,15 @@ export function useAgentCommandsQuery({ return fetchAgentCommands({ client, agentId, draftConfig }); }, enabled: queryEnabled && !!client && isConnected && (!!agentId || !!draftConfig), - staleTime: draftConfig ? DRAFT_COMMANDS_STALE_TIME : SESSION_COMMANDS_STALE_TIME, + dataShape: "value", + meta: { + agentCommandProvider: draftConfig?.provider ?? provider, + agentCommandCwd: draftConfig?.cwd ?? cwd, + }, + ...(draftConfig + ? { immutableWhen: () => true as const } + : { ...sessionCommandQueryPolicy, staleTimeMs: sessionCommandQueryPolicy.staleTime }), + subscribed: queryEnabled, retry: 3, retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 5000), }); @@ -84,7 +104,7 @@ export function useAgentCommandsQuery({ return { commands: query.data ?? [], isLoading, - isError: query.isError, + isError: query.isError && query.data === undefined, error: query.error, }; } diff --git a/packages/app/src/components/markdown/images.ts b/packages/app/src/components/markdown/images.ts index 83b24b481b..e2f8d07992 100644 --- a/packages/app/src/components/markdown/images.ts +++ b/packages/app/src/components/markdown/images.ts @@ -35,11 +35,15 @@ export interface MarkdownImageContext { filePath?: string; } -export function useMarkdownSource(text: string, context: MarkdownImageContext) { +export function useMarkdownSource( + text: string, + context: MarkdownImageContext, + commandNames?: ReadonlySet, +) { const occurrenceKey = useId(); const prepared = useMemo( - () => prepareMarkdownSource(text, context.workspaceRoot, { supportsSvg: isWeb }), - [text, context.workspaceRoot], + () => prepareMarkdownSource(text, context.workspaceRoot, { supportsSvg: isWeb, commandNames }), + [text, context.workspaceRoot, commandNames], ); const sources = [...new Set(prepared.images.map((image) => image.source))]; const acquisitions = sources.map((source) => { diff --git a/packages/app/src/components/markdown/renderer.test.ts b/packages/app/src/components/markdown/renderer.test.ts index bb5679f6f8..4256685f9c 100644 --- a/packages/app/src/components/markdown/renderer.test.ts +++ b/packages/app/src/components/markdown/renderer.test.ts @@ -2,6 +2,8 @@ import { fromMarkdown } from "mdast-util-from-markdown"; import { toNativeLinkRecognition } from "react-native-enriched-markdown/src/utils/regexParser"; import { parseMarkdown } from "react-native-enriched-markdown/src/web/parseMarkdown"; import { recognizeTextLinks } from "react-native-enriched-markdown/src/web/recognizeTextLinks"; +import { matchCommandLink } from "@/agent-commands"; + import { classifyAssistantFileLink } from "@/assistant-file-links"; import { markInlineImages } from "react-native-enriched-markdown/src/web/utils"; import type { ASTNode } from "react-native-enriched-markdown/src/web/types"; @@ -13,6 +15,28 @@ import { createMarkdownStyles } from "./styles"; import { prepareMarkdownSource, PENDING_MARKDOWN_IMAGE } from "./source"; import { normalizeMarkdownHtml } from "./html"; +it("recognizes complete known commands in inline code", () => { + const names = new Set(["code-review", "review:fast"]); + const prepared = prepareMarkdownSource("`/code-review` `/unknown`", undefined, { + commandNames: names, + }); + expect(prepared.linkRecognition.inlineCode.test("/code-review")).toBe(true); + expect(prepared.linkRecognition.inlineCode.test("/unknown")).toBe( + prepareMarkdownSource("`/unknown`").linkRecognition.inlineCode.test("/unknown"), + ); + const nonPath = prepareMarkdownSource("`/review:fast`", undefined, { commandNames: names }); + expect(nonPath.linkRecognition.inlineCode.test("/review:fast")).toBe(true); + expect(matchCommandLink("/code-review", "inline-code", names)).toBe("/code-review"); +}); + +it("resolves authored root-relative links only when the target is a known command", () => { + const names = new Set(["code-review"]); + expect(matchCommandLink("/code-review", "markdown", names)).toBe("/code-review"); + expect(matchCommandLink("/code-review/file", "markdown", names)).toBeNull(); + expect(matchCommandLink("/code-review", "text", names)).toBeNull(); + expect(matchCommandLink("https://example.com/code-review", "markdown", names)).toBeNull(); +}); + describe("shared Markdown source adaptation", () => { it("keeps paragraphs, fences and quotes together for the library", () => { const source = "First paragraph.\n\nSecond paragraph.\n\n```ts\nconst n = 1;\n```\n\n> Quote"; diff --git a/packages/app/src/components/markdown/renderer.tsx b/packages/app/src/components/markdown/renderer.tsx index 8e51495369..c14ecb5198 100644 --- a/packages/app/src/components/markdown/renderer.tsx +++ b/packages/app/src/components/markdown/renderer.tsx @@ -6,6 +6,7 @@ import { useAssistantFileLinkEnvironment, } from "@/assistant-file-links"; import { useStableEvent } from "@/hooks/use-stable-event"; +import { matchCommandLink, useAgentCommandHints, type CommandHints } from "@/agent-commands"; import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox"; import type { Theme } from "@/styles/theme"; import { isWeb } from "@/constants/platform"; @@ -48,6 +49,7 @@ export interface MarkdownRendererProps extends MarkdownImageContext { selectable?: boolean; streaming?: boolean; onLinkPress?: (url: string) => unknown; + agentCommands?: boolean; } /** All Markdown surfaces share the same engine and source adaptation. */ @@ -59,15 +61,21 @@ export const MarkdownRenderer = memo(function MarkdownRenderer(props: MarkdownRe () => splitStreamingMarkdown(completeLines, true).head.length, [completeLines], ); - if (!streaming || boundary === 0) return ; + const Part = props.agentCommands ? CommandMarkdownPart : MarkdownPart; + if (!streaming || boundary === 0) return ; return ( - - + + ); }); +const CommandMarkdownPart = memo(function CommandMarkdownPart(props: MarkdownRendererProps) { + const commandHints = useAgentCommandHints(); + return ; +}); + const MarkdownPart = memo(function MarkdownPart({ text, compact = false, @@ -75,8 +83,10 @@ const MarkdownPart = memo(function MarkdownPart({ selectable = true, streaming = false, onLinkPress, + agentCommands: _agentCommands, + commandHints = null, ...imageContext -}: MarkdownRendererProps) { +}: MarkdownRendererProps & { commandHints?: CommandHints | null }) { const environment = useAssistantFileLinkEnvironment(); const actions = useAssistantFileLinkActions(); const client = @@ -84,14 +94,23 @@ const MarkdownPart = memo(function MarkdownPart({ (environment.client?.readFile ? { readFile: environment.client.readFile.bind(environment.client) } : undefined); - const { markdown, imageSources, linkRecognition, linkSource } = useMarkdownSource(text, { - workspaceRoot: environment.workspaceRoot, - serverId: environment.serverId, - ...imageContext, - client, - }); + const { markdown, imageSources, linkRecognition, linkSource } = useMarkdownSource( + text, + { + workspaceRoot: environment.workspaceRoot, + serverId: environment.serverId, + ...imageContext, + client, + }, + commandHints?.names, + ); const [image, setImage] = useState(null); const pressLink = useStableEvent(({ url, sourceType }: LinkPressEvent) => { + const command = commandHints && matchCommandLink(url, sourceType, commandHints.names); + if (command) { + commandHints.insert(command); + return; + } const source = linkSource(url, sourceType); if (onLinkPress && !actions.canResolveFile(source)) onLinkPress(url); else actions.open(source, "preferred"); diff --git a/packages/app/src/components/markdown/source.ts b/packages/app/src/components/markdown/source.ts index 5c82088898..3462320d7d 100644 --- a/packages/app/src/components/markdown/source.ts +++ b/packages/app/src/components/markdown/source.ts @@ -56,7 +56,11 @@ function linkMetadata(inlineCodePaths: ReadonlySet) { }; } -function collectInlineCodePaths(source: string, workspaceRoot?: string): ReadonlySet { +function collectInlineCodePaths( + source: string, + workspaceRoot?: string, + commandNames?: ReadonlySet, +): ReadonlySet { const paths = new Set(); const previousEnds = new Map(); // Collect candidates between consecutive runs of equal length. The engine owns @@ -74,7 +78,11 @@ function collectInlineCodePaths(source: string, workspaceRoot?: string): Readonl value = value.slice(1, -1); } const classification = classifyAssistantFileLink(value, { workspaceRoot }); - if (classification && classification.kind !== "external") paths.add(value); + if ( + (classification && classification.kind !== "external") || + (value.startsWith("/") && commandNames?.has(value.slice(1))) + ) + paths.add(value); } return paths; } @@ -112,7 +120,7 @@ function workspaceHoverTitle(link: Link, markup: string, workspaceRoot?: string) export function prepareMarkdownSource( source: string, workspaceRoot?: string, - options: { supportsSvg?: boolean } = {}, + options: { supportsSvg?: boolean; commandNames?: ReadonlySet } = {}, ) { source = normalizeMarkdownHtml(source); const images: MarkdownImage[] = []; @@ -122,7 +130,9 @@ export function prepareMarkdownSource( extensions: [gfmTable()], mdastExtensions: [gfmTableFromMarkdown()], })); - const metadata = linkMetadata(collectInlineCodePaths(source, workspaceRoot)); + const metadata = linkMetadata( + collectInlineCodePaths(source, workspaceRoot, options.commandNames), + ); const edits: { start: number; end: number; text: string }[] = []; if ( !source.includes("![") && diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 2c6165d305..0ec5cbfb44 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -1199,6 +1199,7 @@ export const AssistantMessage = memo(function AssistantMessage({ > { + it("appends commands to the current editor buffer and focuses it", () => { + const session = new ComposerEditingSession(); + let text = "draft typed since publication"; + let focuses = 0; + let selection = { start: 0, end: 0 }; + const detach = session.attachEditor({ + getText: () => text, + replaceText: (next, nextSelection) => { + text = next; + selection = nextSelection; + }, + focus: () => { + focuses += 1; + }, + }); + session.insertCommand("/review"); + expect(selection).toEqual({ start: text.length, end: text.length }); + session.insertCommand("/test"); + expect(text).toBe("draft typed since publication /review /test "); + expect(focuses).toBe(2); + expect(selection).toEqual({ start: text.length, end: text.length }); + detach(); + session.insertCommand("/ignored"); + expect(text).toBe("draft typed since publication /review /test "); + }); it("publishes fullscreen changes once per transition", () => { const session = new ComposerEditingSession(); const changes: boolean[] = []; diff --git a/packages/app/src/composer/editing-session/index.tsx b/packages/app/src/composer/editing-session/index.tsx index 4776b5af3d..237b15251a 100644 --- a/packages/app/src/composer/editing-session/index.tsx +++ b/packages/app/src/composer/editing-session/index.tsx @@ -30,10 +30,29 @@ export interface ComposerRetainedEditor { */ export class ComposerEditingSession { private fullscreen = false; + private editor: { + getText(): string; + replaceText(text: string, selection: ComposerSelection): void; + focus(): void; + } | null = null; private retained: ComposerRetainedEditor | null = null; private listeners = new Set<() => void>(); constructor(private readonly onChange?: () => void) {} getFullscreen = () => this.fullscreen; + attachEditor(editor: NonNullable) { + this.editor = editor; + return () => { + if (this.editor === editor) this.editor = null; + }; + } + insertCommand(command: string) { + const editor = this.editor; + if (!editor) return; + const text = editor.getText(); + const nextText = `${text}${text && !/\s$/.test(text) ? " " : ""}${command} `; + editor.replaceText(nextText, { start: nextText.length, end: nextText.length }); + editor.focus(); + } /** Called by the unmounting editor; the next mount of this session takes it. */ retainEditor(retained: ComposerRetainedEditor) { this.retained = retained; diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index b7088ddeef..d6b4bae63f 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -71,7 +71,7 @@ import { } from "./input/input"; import type { ImageAttachment, MessagePayload, TextReplacement } from "./types"; import { ICON_SIZE, type Theme } from "@/styles/theme"; -import type { DraftCommandTarget } from "@/hooks/use-agent-commands-query"; +import type { DraftCommandTarget } from "@/agent-commands"; import { encodeImages } from "@/utils/encode-images"; import { focusWithRetries } from "@/utils/web-focus"; import { diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index 16acede7e5..8cb8cd4c32 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -1407,6 +1407,16 @@ export const MessageInput = forwardRef( [onChangeText, updateComposerHeightForText, updateLiveTextPresence], ); + useLayoutEffect( + () => + editingSession.attachEditor({ + getText: () => textInputRef.current?.getText() ?? valueRef.current, + replaceText, + focus: () => textInputRef.current?.focus(), + }), + [editingSession, replaceText], + ); + useImperativeHandle(ref, () => ({ focus: () => { textInputRef.current?.focus(); diff --git a/packages/app/src/data/providers-snapshot.ts b/packages/app/src/data/providers-snapshot.ts index 68b389fe15..87c62f81ca 100644 --- a/packages/app/src/data/providers-snapshot.ts +++ b/packages/app/src/data/providers-snapshot.ts @@ -8,7 +8,7 @@ import { } from "./provider-snapshot-cache"; import { queryClient as singletonQueryClient } from "./query-client"; import { replaceProviderSnapshotIcons } from "@/data/provider-icons"; -import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query"; +import { agentCommandsQueryRoot } from "@/agent-commands/cache"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; import { normalizeWorkspacePath } from "@/utils/workspace-identity"; diff --git a/packages/app/src/data/push-router.ts b/packages/app/src/data/push-router.ts index 75ac313169..cc32f8f8e3 100644 --- a/packages/app/src/data/push-router.ts +++ b/packages/app/src/data/push-router.ts @@ -1,11 +1,12 @@ import type { OwnedSubscription } from "@getpaseo/client"; +import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; import type { Query, QueryCacheNotifyEvent, QueryClient, QueryKey } from "@tanstack/react-query"; import type { ListTerminalsResponse, MutableDaemonConfig, SessionOutboundMessage, } from "@getpaseo/protocol/messages"; -import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query"; +import { invalidateCommandsForProviderChange } from "@/agent-commands/cache"; import { shareCheckoutDiff } from "@/git/diff-sharing"; import { orderCheckoutDiffFiles } from "@/git/diff-order"; import { daemonConfigQueryKey } from "@/data/daemon-config"; @@ -186,10 +187,10 @@ export async function applyProvidersSnapshotUpdate(input: { const snapshot = { ...input.message.payload, requestId: "providers_snapshot_update" }; const cwd = normalizeProvidersSnapshotCwd(snapshot.cwd); const queryKey = providersSnapshotQueryKey(input.serverId, cwd); - const previous = input.queryClient.getQueryData<{ snapshotHash?: string }>(queryKey); + const previous = input.queryClient.getQueryData<{ entries: ProviderSnapshotEntry[] }>(queryKey); let announcement: typeof snapshot | undefined = snapshot; void input.queryClient.cancelQueries({ queryKey, exact: true }); - await input.queryClient.fetchQuery({ + const current = await input.queryClient.fetchQuery({ queryKey, staleTime: 0, structuralSharing: false, @@ -205,12 +206,13 @@ export async function applyProvidersSnapshotUpdate(input: { }); }, }); - if (!snapshot.snapshotHash || previous?.snapshotHash !== snapshot.snapshotHash) { - void input.queryClient.invalidateQueries({ - queryKey: agentCommandsQueryRoot(input.serverId), - exact: false, - }); - } + void invalidateCommandsForProviderChange({ + queryClient: input.queryClient, + serverId: input.serverId, + cwd, + previous: previous?.entries, + current: current.entries, + }); } export function mountServerDataPushRouter(input: PushRouterInput): () => void { diff --git a/packages/app/src/data/query.ts b/packages/app/src/data/query.ts index 2a645f8666..e2b25d7b29 100644 --- a/packages/app/src/data/query.ts +++ b/packages/app/src/data/query.ts @@ -27,7 +27,7 @@ type ReplicaQueryInput type FetchQueryInput = Omit< UseQueryOptions, - "initialData" | "placeholderData" | "queryFn" | "refetchOnMount" | "staleTime" + "initialData" | "placeholderData" | "queryFn" | "staleTime" > & { dataShape: "list" | "value"; queryFn: QueryFnOption; @@ -113,7 +113,7 @@ export function fetchQueryOptions< dataShape, }, }, - refetchOnMount: immutableWhen ? true : "always", + refetchOnMount: options.refetchOnMount ?? (immutableWhen ? true : "always"), staleTime: immutableWhen ? (query) => query.state.data !== undefined && immutableWhen(query.state.data) ? Infinity : 0 diff --git a/packages/app/src/git/checkout-status-cache.test.ts b/packages/app/src/git/checkout-status-cache.test.ts index 026ec9d43a..bbfeb6d758 100644 --- a/packages/app/src/git/checkout-status-cache.test.ts +++ b/packages/app/src/git/checkout-status-cache.test.ts @@ -16,7 +16,7 @@ import { resolveWorkingDiffComparison, selectWorkingDiffComparison, } from "@/git/working-diff-comparison"; -import { draftAgentCommandsQueryKey } from "@/hooks/agent-commands-query"; +import { draftAgentCommandsQueryKey } from "@/agent-commands/cache"; import { applyCheckoutStatusUpdateFromEvent, ensureCheckoutStatus, diff --git a/packages/app/src/git/checkout-status-cache.ts b/packages/app/src/git/checkout-status-cache.ts index ca5cbb3a7a..4aaa650c70 100644 --- a/packages/app/src/git/checkout-status-cache.ts +++ b/packages/app/src/git/checkout-status-cache.ts @@ -8,7 +8,7 @@ import { invalidatePrPaneTimelineForCheckout, } from "@/git/query-keys"; import { type CheckoutPrStatusPayload, normalizeCheckoutPrStatusPayload } from "@/git/pr-status"; -import { resetDraftAgentCommandsForCheckout } from "@/hooks/agent-commands-query"; +import { resetDraftAgentCommandsForCheckout } from "@/agent-commands/cache"; export type CheckoutStatusPayload = CheckoutStatusResponse["payload"]; export type { CheckoutPrStatusPayload } from "@/git/pr-status"; diff --git a/packages/app/src/hooks/use-agent-autocomplete.ts b/packages/app/src/hooks/use-agent-autocomplete.ts index fe6f98c3f1..ef144477dc 100644 --- a/packages/app/src/hooks/use-agent-autocomplete.ts +++ b/packages/app/src/hooks/use-agent-autocomplete.ts @@ -4,11 +4,11 @@ import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; import type { AutocompleteOption } from "@/components/ui/autocomplete"; import { - useAgentCommandsQuery, + useAutocompleteCommands, type AgentSlashCommand, type DraftCommandConfig, type DraftCommandTarget, -} from "./use-agent-commands-query"; +} from "@/agent-commands"; import { orderAutocompleteOptions } from "@/components/ui/autocomplete-utils"; import { useAutocomplete } from "./use-autocomplete"; import { useSessionStore } from "@/stores/session-store"; @@ -432,7 +432,7 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut autocompleteCwd, }); - const commandsQuery = useAgentCommandsQuery({ + const commandsQuery = useAutocompleteCommands({ serverId, agentId, enabled: mode === "command" && canLoadCommands && canListCommands, diff --git a/packages/app/src/hooks/use-providers-snapshot.test.ts b/packages/app/src/hooks/use-providers-snapshot.test.ts index 2f3c90b340..0c730cddbd 100644 --- a/packages/app/src/hooks/use-providers-snapshot.test.ts +++ b/packages/app/src/hooks/use-providers-snapshot.test.ts @@ -1,4 +1,4 @@ -import { QueryClient } from "@tanstack/react-query"; +import { QueryClient, QueryObserver } from "@tanstack/react-query"; import { beforeEach, describe, expect, it } from "vitest"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; @@ -8,7 +8,7 @@ import { expandProviderSnapshot, } from "@getpaseo/protocol/provider-snapshot-codec"; import type { CachedProviderSnapshot, ProviderSnapshotCache } from "@/data/provider-snapshot-cache"; -import { draftAgentCommandsQueryKey } from "@/hooks/agent-commands-query"; +import { draftAgentCommandsQueryKey, sessionAgentCommandsQueryKey } from "@/agent-commands/cache"; import { providerSnapshotIcons } from "@/data/provider-icons"; import { applyProvidersSnapshotUpdate, type ProvidersSnapshotUpdate } from "@/data/push-router"; import { @@ -363,6 +363,153 @@ describe("applyProvidersSnapshotUpdate", () => { }; } + it("refreshes active commands only when their provider changes after discovery", async () => { + const key = sessionAgentCommandsQueryKey({ serverId, agentId: "agent-1" }); + let requests = 0; + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: async () => { + requests++; + return [{ name: "review" }]; + }, + staleTime: Infinity, + meta: { agentCommandProvider: "codex", agentCommandCwd: "/repo-a" }, + }); + const unsubscribe = observer.subscribe(() => {}); + await observer.refetch({ cancelRefetch: false }); + expect(requests).toBe(1); + const codex = codexEntry("ready", [readyCodexModel]); + const other = { provider: "claude", status: "loading", enabled: true } as ProviderSnapshotEntry; + await applyProvidersSnapshotUpdate({ + client: createClient(), + serverId, + queryClient, + message: updateMessage([codexEntry("loading"), other], "/repo-a"), + }); + expect(requests).toBe(1); + await applyProvidersSnapshotUpdate({ + client: createClient(), + serverId, + queryClient, + message: updateMessage([codex, other], "/repo-a"), + }); + expect(requests).toBe(1); + await applyProvidersSnapshotUpdate({ + client: createClient(), + serverId, + queryClient, + message: updateMessage([codex, { ...other, status: "ready" }], "/repo-a"), + }); + expect(requests).toBe(1); + await applyProvidersSnapshotUpdate({ + client: createClient(), + serverId, + queryClient, + message: updateMessage( + [ + { ...codex, fetchedAt: "2026-01-01T00:00:02.000Z" }, + { ...other, status: "ready" }, + ], + "/repo-a", + ), + }); + expect(requests).toBe(1); + queryClient.setQueryData( + providersSnapshotQueryKey(serverId, "/repo-b"), + providersSnapshot([codex]), + ); + await applyProvidersSnapshotUpdate({ + client: createClient(), + serverId, + queryClient, + message: updateMessage( + [codexEntry("ready", [{ ...readyCodexModel, label: "Other checkout" }])], + "/repo-b", + ), + }); + expect(requests).toBe(1); + await applyProvidersSnapshotUpdate({ + client: createClient(), + serverId, + queryClient, + message: updateMessage( + [ + codexEntry("ready", [{ ...readyCodexModel, label: "Updated" }]), + { ...other, status: "ready" }, + ], + "/repo-a", + ), + }); + await observer.refetch({ cancelRefetch: false }); + expect(requests).toBe(2); + unsubscribe(); + queryClient.clear(); + }); + + it("retries a failed command discovery when its provider first becomes ready", async () => { + const key = sessionAgentCommandsQueryKey({ serverId, agentId: "agent-1" }); + let ready = false; + let requests = 0; + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: async () => { + requests++; + if (!ready) throw new Error("provider unavailable"); + return [{ name: "review" }]; + }, + retry: false, + meta: { agentCommandProvider: "codex", agentCommandCwd: "/repo-a" }, + }); + const unsubscribe = observer.subscribe(() => {}); + await observer.refetch({ cancelRefetch: false }); + expect(observer.getCurrentResult().isError).toBe(true); + ready = true; + await applyProvidersSnapshotUpdate({ + client: createClient(), + serverId, + queryClient, + message: updateMessage([codexEntry("ready", [readyCodexModel])], "/repo-a"), + }); + await observer.refetch({ cancelRefetch: false }); + expect(requests).toBe(2); + expect(observer.getCurrentResult().data).toEqual([{ name: "review" }]); + unsubscribe(); + queryClient.clear(); + }); + + it("refreshes again when its provider changes during an active refresh", async () => { + const key = sessionAgentCommandsQueryKey({ serverId, agentId: "agent-1" }); + queryClient.setQueryData(key, [{ name: "old" }]); + let requests = 0; + const waiting = new Promise<{ name: string }[]>(() => {}); + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: async () => (++requests === 1 ? waiting : [{ name: "new" }]), + staleTime: Infinity, + meta: { agentCommandProvider: "codex", agentCommandCwd: "/repo-a" }, + }); + const unsubscribe = observer.subscribe(() => {}); + queryClient.setQueryData( + providersSnapshotQueryKey(serverId, "/repo-a"), + providersSnapshot([codexEntry("ready", [readyCodexModel])]), + ); + const send = (label: string) => + applyProvidersSnapshotUpdate({ + client: createClient(), + serverId, + queryClient, + message: updateMessage([codexEntry("ready", [{ ...readyCodexModel, label }])], "/repo-a"), + }); + await send("First change"); + expect(requests).toBe(1); + await send("Second change"); + expect(requests).toBe(2); + await observer.refetch({ cancelRefetch: false }); + expect(observer.getCurrentResult().data).toEqual([{ name: "new" }]); + unsubscribe(); + queryClient.clear(); + }); + it("routes updates to the home query cache when the message carries no cwd", async () => { await applyProvidersSnapshotUpdate({ client: createClient(), @@ -455,6 +602,10 @@ describe("applyProvidersSnapshotUpdate", () => { draftConfig: { provider: "codex", cwd: "/repo-a" }, }); queryClient.setQueryData(commandsKey, [{ name: "compact", description: "", argumentHint: "" }]); + queryClient.setQueryData( + providersSnapshotQueryKey(serverId, "/repo-a"), + providersSnapshot([codexEntry("ready")]), + ); await applyProvidersSnapshotUpdate({ client: createClient(), diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 712d9d65a1..147ab7b260 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -28,6 +28,7 @@ import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { RetainedChatContent } from "./retained-chat-content"; import { Composer } from "@/composer"; +import { AgentCommandsProvider } from "@/agent-commands"; import { useComposerEditingSession, useComposerFullscreen, @@ -1255,7 +1256,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ ); - return ( + const content = ( ); + return wrapAgentCommandProvider({ + enabled: showsComposer && hasActiveComposer, + serverId, + agentId, + editingSession: composerEditingSession, + children: content, + }); }); +function wrapAgentCommandProvider({ + enabled, + serverId, + agentId, + editingSession, + children, +}: { + enabled: boolean; + serverId: string; + agentId: string; + editingSession: ComposerEditingSession; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + function ChatSurface({ children, disabled, diff --git a/packages/app/src/provider-selection/provider-selection.ts b/packages/app/src/provider-selection/provider-selection.ts index e0313c6623..54d75f1da2 100644 --- a/packages/app/src/provider-selection/provider-selection.ts +++ b/packages/app/src/provider-selection/provider-selection.ts @@ -5,7 +5,7 @@ import type { ProviderSnapshotEntry, } from "@getpaseo/protocol/agent-types"; import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; -import type { DraftCommandTarget } from "@/hooks/use-agent-commands-query"; +import type { DraftCommandTarget } from "@/agent-commands"; import { i18n } from "@/i18n/i18next"; import { compareMatchScores, scoreTextFields } from "@getpaseo/protocol/search/text-match"; import { filterSelectableModels } from "./model-catalog"; From 2ac288f4d053eb8bc5ca37113c353c8ceed75593 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 12:26:32 +0000 Subject: [PATCH 05/58] test(plugins): make global switch recovery deterministic (#8342) * test(plugins): trace recurring global recovery failure * test(plugins): control cleanup in global switch recovery --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../src/server/plugins/index.posix.test.ts | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/server/src/server/plugins/index.posix.test.ts b/packages/server/src/server/plugins/index.posix.test.ts index c529bd37d3..1f8b3f124b 100644 --- a/packages/server/src/server/plugins/index.posix.test.ts +++ b/packages/server/src/server/plugins/index.posix.test.ts @@ -1071,9 +1071,38 @@ export default function contribute(plugin: unknown) { "second", `export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`, ); + // Hold cleanup open so synchronous detach and queued recovery are tested + // independently of compiler/process startup speed on the runner. + let finishCleanup = () => undefined; + const cleanup = new Promise((resolve) => { + finishCleanup = resolve; + }); + const running = new Set(); + const runtime: TestPluginRuntime = { + ...emptyUsageRuntime, + catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })), + invoke: async (pluginId) => { + if (!running.has(pluginId)) throw new Error(`Plugin is not available: ${pluginId}`); + return pluginId; + }, + getLogs: () => [], + clearLogs: () => undefined, + startPlugin: async (pluginId, _path, canPublish) => { + if (!canPublish()) throw new Error(`Plugin start cancelled: ${pluginId}`); + running.add(pluginId); + }, + stopPluginById: async (pluginId) => { + const existed = running.delete(pluginId); + await cleanup; + return existed; + }, + stopAll: async () => running.clear(), + subscribe: () => () => undefined, + bindPaseoSessionHost: () => undefined, + }; const store = createStore(home); const service = bindTestSessionHost( - new PluginService(pino({ level: "silent" }), store, "0.4.0"), + new PluginService(pino({ level: "silent" }), store, "0.4.0", { runtime }), ); try { await service.start(); @@ -1087,6 +1116,8 @@ export default function contribute(plugin: unknown) { "Plugin is not available", ); await store.patch({ pluginsEnabled: true }); + expect(service.catalog()).toEqual([]); + finishCleanup(); // Observe recovery itself. Reloading here restarts a healthy plugin and // would also conceal a failure to restart it after the global enable. await expect @@ -1096,6 +1127,7 @@ export default function contribute(plugin: unknown) { { id: "second", status: "running" }, ]); } finally { + finishCleanup(); await service.stopAllPlugins(); } }, 20_000); From b117e3fb0f814730546346ec619720b100f03590 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:03:27 +0000 Subject: [PATCH 06/58] fix(claude): allow conversation rewind to messages sent during a turn (#8372) * fix(server): let a message steered into a running Claude turn be rewound to A steered message never got a provider message id, because Claude takes it into the running turn without echoing it back, so rewinding to it refused with "Cannot rewind before the provider acknowledges the submitted prompt". The steer admission now reports the uuid Claude minted for the message, and a Claude conversation rewind to it forks at the entry Claude read it after. Co-Authored-By: Claude Opus 5.5 * fix(server): keep messages steered into a running Claude turn in the replayed timeline Claude records a steered message as a queued_command attachment rather than a user entry, and the history replay skipped it, so every such message, replies from other agents included, vanished from the timeline after a daemon restart. Co-Authored-By: Claude Opus 5.5 * test(claude): verify steer identities across lifecycle tests Match the accepted-steer contract used by conversation rewind and verify that its identity names the message delivered to the provider. Co-authored-by: tieo <65707274+tieo@users.noreply.github.com> * fix(claude): release transcript streams after rewind lookup Closing readline leaves a partially read transcript open after an early match. Destroy the owned stream on exit and cover repeated lookups with unread data. --------- Co-authored-by: tieo <65707274+tieo@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../src/server/agent/agent-manager.test.ts | 68 ++++++++++++ .../server/src/server/agent/agent-manager.ts | 18 ++- .../src/server/agent/agent-sdk-types.ts | 6 +- .../claude/agent.history-load.test.ts | 37 +++++++ ...agent.interrupt-restart-regression.test.ts | 10 +- .../claude/agent.rewind-anchors.test.ts | 50 +++++++++ .../agent/providers/claude/agent.test.ts | 17 +-- .../server/agent/providers/claude/agent.ts | 38 ++++++- .../agent/providers/claude/rewind.test.ts | 103 +++++++++++++++++- .../server/agent/providers/claude/rewind.ts | 50 +++++++++ 10 files changed, 380 insertions(+), 17 deletions(-) diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 4b3fb5ea46..ccf29b17b0 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -1220,6 +1220,74 @@ test("orders buffered pre-steer output before an immediately accepted steer", as } }); +test("a steer the provider names without echoing it can be rewound to", async () => { + // Claude takes a steered message into the running turn without echoing it back as a user + // message, so the id it minted for the message reaches the manager only through the admission. + class SilentSteeringSession extends SteeringTestSession { + override readonly capabilities = { + ...TEST_CAPABILITIES, + supportsRewindFiles: true, + }; + rewindMessageIds: string[] = []; + + override async steerActiveTurn( + _prompt: AgentPromptInput, + options: import("./agent-sdk-types.js").SteerActiveTurnOptions, + ): Promise { + if (options.expectedTurnId !== `active-turn-${this.startCount}`) { + return { status: "unavailable" }; + } + return { status: "accepted", providerMessageId: `provider-${options.clientMessageId}` }; + } + + override async revertFiles({ messageId }: { messageId: string }): Promise { + this.rewindMessageIds.push(messageId); + } + } + const session = new SilentSteeringSession({ provider: "codex", cwd: process.cwd() }); + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-rewind-")); + const manager = new AgentManager({ + clients: { + codex: new (class extends TestAgentClient { + override async createSession(): Promise { + return session; + } + })(), + }, + logger, + }); + let agentId: string | null = null; + try { + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + agentId = agent.id; + const run = manager.streamAgent(agent.id, "initial"); + void (async () => { + for await (const _event of run) { + } + })(); + await manager.waitForAgentRunStart(agent.id); + + await expect( + manager.steerAgentRun(agent.id, "steered", { clientMessageId: "steer-client" }), + ).resolves.toMatchObject({ status: "accepted" }); + const row = manager + .fetchTimeline(agent.id, { limit: 0 }) + .rows.find((candidate) => candidate.item.type === "user_message"); + expect(row).toMatchObject({ + providerMessageId: "provider-steer-client", + item: { text: "steered", clientMessageId: "steer-client" }, + }); + + await manager.rewind(agent.id, "steer-client", "files"); + expect(session.rewindMessageIds).toEqual(["provider-steer-client"]); + } finally { + if (agentId) await manager.closeAgent(agentId).catch(() => undefined); + rmSync(workdir, { recursive: true, force: true }); + } +}); + test("orders a concurrent replacement after a pending accepted steer", async () => { const entered = deferred(); const release = deferred(); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index c69a574849..94250e5cdd 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -3029,7 +3029,13 @@ export class AgentManager { expectedTurnId, }); if (admission.status === "accepted") { - await this.recordAcceptedSteer(agent, prompt, options?.clientMessageId, expectedTurnId); + await this.recordAcceptedSteer( + agent, + prompt, + options?.clientMessageId, + expectedTurnId, + admission.providerMessageId, + ); } return admission; }); @@ -3062,7 +3068,13 @@ export class AgentManager { expectedTurnId, }); if (admission.status === "accepted") { - await this.recordAcceptedSteer(agent, prompt, options?.clientMessageId, expectedTurnId); + await this.recordAcceptedSteer( + agent, + prompt, + options?.clientMessageId, + expectedTurnId, + admission.providerMessageId, + ); } return admission; }) @@ -3167,6 +3179,7 @@ export class AgentManager { prompt: AgentPromptInput, clientMessageId: string | undefined, expectedTurnId: string, + providerMessageId: string | undefined, ): Promise { if (!clientMessageId) { return; @@ -3174,6 +3187,7 @@ export class AgentManager { this.recordSubmittedPrompt(agent, prompt, clientMessageId, "accepted", { messageId: clientMessageId, turnId: expectedTurnId, + ...(providerMessageId ? { providerMessageId } : {}), }); this.emitState(agent); } diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 6570392311..c31d1afb86 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -208,7 +208,11 @@ export interface AgentSteerOptions extends AgentRunOptions { clearPendingPermissions?: boolean; } -export type SteerResult = { status: "accepted" } | { status: "unavailable" }; +// providerMessageId names the steered message in the provider's own history, when the provider +// mints one. Rewinding to the message needs it. +export type SteerResult = + | { status: "accepted"; providerMessageId?: string } + | { status: "unavailable" }; export interface SteerActiveTurnOptions extends AgentSteerOptions { expectedTurnId: string; diff --git a/packages/server/src/server/agent/providers/claude/agent.history-load.test.ts b/packages/server/src/server/agent/providers/claude/agent.history-load.test.ts index 393fda60de..e2ea6850bb 100644 --- a/packages/server/src/server/agent/providers/claude/agent.history-load.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.history-load.test.ts @@ -614,6 +614,43 @@ describe("ClaudeAgentSession persisted history load", () => { ); }); + test("replays a message steered into a running turn as the user's message", async () => { + const queued = (commandMode: string, prompt: unknown, sourceUuid?: string) => ({ + type: "attachment", + uuid: `attachment-${commandMode}`, + parentUuid: "a1", + attachment: { + type: "queued_command", + commandMode, + prompt, + ...(sourceUuid ? { source_uuid: sourceUuid } : {}), + }, + }); + const entries = [ + { type: "user", uuid: "u1", parentUuid: null, message: { content: "first" } }, + { type: "assistant", uuid: "a1", parentUuid: "u1", message: { content: "working" } }, + queued("prompt", [{ type: "text", text: "steered while working" }], "steered-1"), + queued("task-notification", "t1"), + { type: "assistant", uuid: "a2", parentUuid: "a1", message: { content: "answer" } }, + ]; + mkdirSync(path.dirname(transcriptPath()), { recursive: true }); + writeFileSync(transcriptPath(), entries.map((entry) => JSON.stringify(entry)).join("\n")); + + const session = await resume(); + try { + const users = []; + for await (const event of session.streamHistory()) { + if (event.type === "timeline" && event.item.type === "user_message") users.push(event.item); + } + expect(users).toEqual([ + { type: "user_message", text: "first", messageId: "u1" }, + { type: "user_message", text: "steered while working", messageId: "steered-1" }, + ]); + } finally { + await session.close(); + } + }); + test("loads readable history when the temporary directory is unavailable", async () => { const file = transcriptPath(); const entry = { type: "assistant", message: { content: [{ type: "text", text: "saved" }] } }; diff --git a/packages/server/src/server/agent/providers/claude/agent.interrupt-restart-regression.test.ts b/packages/server/src/server/agent/providers/claude/agent.interrupt-restart-regression.test.ts index 02a09ba44b..fa0266c355 100644 --- a/packages/server/src/server/agent/providers/claude/agent.interrupt-restart-regression.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.interrupt-restart-regression.test.ts @@ -349,8 +349,11 @@ async function startSteeredTurn(sessionId: string): Promise<{ expectedTurnId: start.value.turnId, clientMessageId: "steer-client", }); - expect(steered).toEqual({ status: "accepted" }); await waitFor(() => query?.prompts.length === 2); + expect(steered).toEqual({ + status: "accepted", + providerMessageId: query?.prompts[1]?.uuid, + }); return { session, query: () => query, turn }; } @@ -972,7 +975,10 @@ test("steers an autonomous turn through its existing query without restarting it const steeredTimeline = await autonomousEvents.next(); const completion = await autonomousEvents.next(); - expect(steer).toEqual({ status: "accepted" }); + expect(steer).toEqual({ + status: "accepted", + providerMessageId: queryRef?.prompts[1]?.uuid, + }); expect(queryFactory).toHaveBeenCalledTimes(1); expect(queryRef?.interrupt).not.toHaveBeenCalled(); expect(queryRef?.prompts.map((prompt) => prompt.text)).toEqual(["seed prompt", "steer prompt"]); diff --git a/packages/server/src/server/agent/providers/claude/agent.rewind-anchors.test.ts b/packages/server/src/server/agent/providers/claude/agent.rewind-anchors.test.ts index 1b1932e9cd..ed9b324ff4 100644 --- a/packages/server/src/server/agent/providers/claude/agent.rewind-anchors.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.rewind-anchors.test.ts @@ -1,7 +1,11 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; import { describe, expect, test, vi } from "vitest"; import { createTestLogger } from "../../../../test-utils/test-logger.js"; import { ClaudeAgentClient } from "./agent.js"; +import { claudeProjectDir } from "./project-dir.js"; import { FakeClaudeSdk } from "./test-rewind-claude-sdk.js"; import { streamSession } from "../test-utils/session-stream-adapter.js"; import type { AgentSession } from "../../agent-sdk-types.js"; @@ -267,3 +271,49 @@ describe("Claude rewind after a turn whose last assistant message came from a su expect(rewindSdk.recordedForks).toEqual([{ upToMessageId: "assistant-1" }]); }); }); + +describe("Claude rewind to a message steered into a running turn", () => { + test("forks at the entry Claude read the message after", async () => { + const configDir = mkdtempSync(path.join(tmpdir(), "claude-rewind-steer-")); + const previousConfigDir = process.env.CLAUDE_CONFIG_DIR; + process.env.CLAUDE_CONFIG_DIR = configDir; + const conversation = createConversation([{ assistantMessageId: "assistant-1" }]); + const rewindSdk = new FakeClaudeSdk(); + const session = await createSession(conversation, rewindSdk); + + try { + await runTurns(session, 1); + // A steered message opens no turn: Claude Code records it as a queued_command attachment + // under the uuid Paseo minted for it, hung off the entry it was read after. + const projectDir = await claudeProjectDir(process.cwd(), { configDir }); + mkdirSync(projectDir, { recursive: true }); + const entries = [ + { type: "user", uuid: conversation.userMessageIds[0], parentUuid: null }, + { type: "assistant", uuid: "assistant-1", parentUuid: conversation.userMessageIds[0] }, + { type: "user", uuid: "tool-result-1", parentUuid: "assistant-1" }, + { + type: "attachment", + uuid: "queued-command-1", + parentUuid: "tool-result-1", + attachment: { type: "queued_command", source_uuid: "steered-message-1" }, + }, + ]; + writeFileSync( + path.join(projectDir, `${SESSION_ID}.jsonl`), + entries.map((entry) => `${JSON.stringify(entry)}\n`).join(""), + ); + + await session.revertConversation?.({ messageId: "steered-message-1" }); + } finally { + await session.close(); + if (previousConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = previousConfigDir; + } + rmSync(configDir, { recursive: true, force: true }); + } + + expect(rewindSdk.recordedForks).toEqual([{ upToMessageId: "tool-result-1" }]); + }); +}); diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index deb52f9a6b..758fad2e8f 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -1224,16 +1224,17 @@ describe("ClaudeAgentSession features", () => { const iterator = input[Symbol.asyncIterator](); await expect(iterator.next()).resolves.toMatchObject({ value: { type: "user" } }); - await expect( - session.steerActiveTurn?.("same turn follow-up", { - expectedTurnId: turnId, - clientMessageId: "steer-client-id", - }), - ).resolves.toEqual({ status: "accepted" }); + const admission = await session.steerActiveTurn?.("same turn follow-up", { + expectedTurnId: turnId, + clientMessageId: "steer-client-id", + }); + expect(admission).toEqual({ status: "accepted", providerMessageId: expect.any(String) }); + if (admission?.status !== "accepted") throw new Error("Expected steer admission"); await expect(iterator.next()).resolves.toMatchObject({ value: { type: "user", priority: "next", + uuid: admission.providerMessageId, message: { content: [{ type: "text", text: "same turn follow-up" }] }, }, }); @@ -1291,7 +1292,7 @@ describe("ClaudeAgentSession features", () => { expectedTurnId: turnId, clearPendingPermissions: true, }), - ).resolves.toEqual({ status: "accepted" }); + ).resolves.toEqual({ status: "accepted", providerMessageId: expect.any(String) }); await expect(firstPermission).resolves.toMatchObject({ behavior: "deny", interrupt: undefined, @@ -1350,7 +1351,7 @@ describe("ClaudeAgentSession features", () => { const permission = internal.handlePermissionRequest("Write", {}, { toolUseID: "tool-1" }); await expect( session.steerActiveTurn?.("system notification", { expectedTurnId: turnId }), - ).resolves.toEqual({ status: "accepted" }); + ).resolves.toEqual({ status: "accepted", providerMessageId: expect.any(String) }); expect(session.getPendingPermissions()).toHaveLength(1); const requestId = session.getPendingPermissions()[0]!.id; await session.respondToPermission(requestId, { behavior: "deny", message: "test cleanup" }); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index c675b1ed62..ae48883654 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -82,6 +82,7 @@ import { renderPromptAttachmentAsText } from "../../prompt-attachments.js"; import { discoverClaudeModels } from "./model-discovery.js"; import { claudeQuery, type ClaudeOptions, type ClaudeQueryFactory } from "./query.js"; import { + findSteeredMessageParent, realClaudeRewindSdk, revertClaudeConversation, revertClaudeFiles, @@ -2627,7 +2628,7 @@ class ClaudeAgentSession implements AgentSession { return { status: "unavailable" }; } this.enqueueSteer(input, message, options.clearPendingPermissions === true); - return { status: "accepted" }; + return { status: "accepted", ...(message.uuid ? { providerMessageId: message.uuid } : {}) }; } private enqueueSteer( @@ -3094,7 +3095,7 @@ class ClaudeAgentSession implements AgentSession { async revertConversation(input: { messageId: string }): Promise { await this.ensureHistoryReady(); - const target = this.resolveConversationRewindTarget(input.messageId); + const target = await this.resolveConversationRewindTarget(input.messageId); if (target.kind === "fresh-session") { this.startFreshConversationSession(); return; @@ -3429,12 +3430,24 @@ class ClaudeAgentSession implements AgentSession { return messageId; } - private resolveConversationRewindTarget(messageId: string): ClaudeConversationRewindTarget { + private async resolveConversationRewindTarget( + messageId: string, + ): Promise { const targetUserMessageId = this.resolveClaudeMessageId(messageId); const index = this.rewindTurnAnchors.findIndex( (anchor) => anchor.userMessageId === targetUserMessageId, ); if (index < 0) { + // A message steered into a running turn opens no turn of its own. + const transcriptPath = this.claudeSessionId + ? await this.resolveHistoryPath(this.claudeSessionId) + : null; + const steerParent = transcriptPath + ? await findSteeredMessageParent(transcriptPath, targetUserMessageId) + : null; + if (steerParent) { + return { kind: "fork", messageId: steerParent }; + } throw new Error(`Claude rewind target ${messageId} is not in the tracked conversation`); } @@ -6431,6 +6444,10 @@ function convertClaudeHistoryEntryPreamble( if (entry.type === "user" && isSyntheticHistoryUserEntry(entry)) { return { shortCircuit: [] }; } + const steeredPrompt = readSteeredPromptHistoryItem(entry); + if (steeredPrompt) { + return { shortCircuit: [steeredPrompt] }; + } // Only user and assistant records are conversation turns. Claude Code 2.1.x also journals each // API request as `api-request-blob` records whose message replays the system context. if (entry.type !== "user" && entry.type !== "assistant") { @@ -6457,6 +6474,21 @@ function isProviderImageMessage(item: AgentTimelineItem): boolean { return item.type === "assistant_message" && isProviderImageMarkdown(item.text); } +/** + * A message steered into a running turn. Claude Code gives it no user entry: it records a + * queued_command attachment in prompt mode, under the uuid Paseo minted for the message, which is + * also what a rewind to it resolves. The other modes carry task notifications, not user messages. + */ +function readSteeredPromptHistoryItem(entry: ClaudeHistoryEntry): AgentTimelineItem | null { + if (entry.type !== "attachment") return null; + const attachment = toObjectRecord(entry.attachment); + if (attachment?.type !== "queued_command" || attachment.commandMode !== "prompt") return null; + const text = extractUserMessageText(attachment.prompt); + if (!text) return null; + const messageId = readTrimmedString(attachment.source_uuid); + return { type: "user_message", text, ...(messageId ? { messageId } : {}) }; +} + export async function convertClaudeHistoryEntry( entry: ClaudeHistoryEntry, mapBlocks: ( diff --git a/packages/server/src/server/agent/providers/claude/rewind.test.ts b/packages/server/src/server/agent/providers/claude/rewind.test.ts index 2d6d3853c2..226f41d39c 100644 --- a/packages/server/src/server/agent/providers/claude/rewind.test.ts +++ b/packages/server/src/server/agent/providers/claude/rewind.test.ts @@ -1,11 +1,20 @@ import { randomUUID } from "node:crypto"; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + readlinkSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, test } from "vitest"; import type { Query } from "@anthropic-ai/claude-agent-sdk"; import { + findSteeredMessageParent, revertClaudeConversation, revertClaudeConversationAndFiles, revertClaudeFiles, @@ -217,4 +226,96 @@ describe("Claude rewind against the Claude SDK", () => { "first reply", ]); }); + + test("forks just before a message steered into a running turn", async () => { + tempRoot = mkdtempSync(path.join(tmpdir(), "claude-rewind-steer-")); + delete process.env.CLAUDE_CONFIG_DIR; + const projectDir = path.join(tempRoot, "projects", "-repo"); + mkdirSync(projectDir, { recursive: true }); + const sessionId = randomUUID(); + const firstUser = randomUUID(); + const toolUse = randomUUID(); + const toolResult = randomUUID(); + const steeredMessage = randomUUID(); + const queuedCommand = randomUUID(); + const transcriptPath = path.join(projectDir, `${sessionId}.jsonl`); + writeTranscript(transcriptPath, sessionId, [ + { type: "user", uuid: firstUser, parentUuid: null, text: "first" }, + { type: "assistant", uuid: toolUse, parentUuid: firstUser, text: "running a tool" }, + { type: "user", uuid: toolResult, parentUuid: toolUse, text: "tool output" }, + // What Claude Code writes when it reads a steered message between two tool calls. + JSON.stringify({ + type: "attachment", + uuid: queuedCommand, + parentUuid: toolResult, + sessionId, + attachment: { + type: "queued_command", + prompt: [{ type: "text", text: "steered" }], + source_uuid: steeredMessage, + }, + }), + { type: "assistant", uuid: randomUUID(), parentUuid: queuedCommand, text: "reply to steer" }, + ]); + + const parent = await findSteeredMessageParent(transcriptPath, steeredMessage); + expect(parent).toBe(toolResult); + expect(await findSteeredMessageParent(transcriptPath, randomUUID())).toBeNull(); + + let currentSessionId = sessionId; + await revertClaudeConversation({ + sdk: realClaudeRewindSdk, + sessionId, + transcriptPath, + messageId: parent!, + setSessionId: (nextSessionId) => { + currentSessionId = nextSessionId; + }, + }); + expect(readMessageTexts(path.join(projectDir, `${currentSessionId}.jsonl`))).toEqual([ + "first", + "running a tool", + "tool output", + ]); + }); }); + +test.runIf(process.platform === "linux")( + "releases the transcript descriptor after an early steer match", + async () => { + const artifacts = path.resolve(".artifacts"); + mkdirSync(artifacts, { recursive: true }); + const root = mkdtempSync(path.join(artifacts, "claude-rewind-descriptor-")); + const transcriptPath = path.join(root, "session.jsonl"); + const matchingEntry = { + type: "attachment", + parentUuid: "before-steer", + attachment: { type: "queued_command", source_uuid: "steered-message" }, + }; + const unreadEntry = JSON.stringify({ type: "assistant", message: "x".repeat(1024) }); + writeFileSync( + transcriptPath, + JSON.stringify(matchingEntry) + "\n" + (unreadEntry + "\n").repeat(1200), + ); + const openDescriptors = () => + readdirSync("/proc/self/fd").filter((descriptor) => { + try { + return readlinkSync(path.join("/proc/self/fd", descriptor)) === transcriptPath; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } + }).length; + try { + expect(openDescriptors()).toBe(0); + for (let attempt = 0; attempt < 5; attempt++) { + await expect(findSteeredMessageParent(transcriptPath, "steered-message")).resolves.toBe( + "before-steer", + ); + } + await expect.poll(openDescriptors, { timeout: 1000 }).toBe(0); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, +); diff --git a/packages/server/src/server/agent/providers/claude/rewind.ts b/packages/server/src/server/agent/providers/claude/rewind.ts index 91183687e0..75607c184b 100644 --- a/packages/server/src/server/agent/providers/claude/rewind.ts +++ b/packages/server/src/server/agent/providers/claude/rewind.ts @@ -1,5 +1,7 @@ +import { createReadStream } from "node:fs"; import { appendFile, readFile } from "node:fs/promises"; import path from "node:path"; +import { createInterface } from "node:readline"; import { forkSession as claudeForkSession, type Query, @@ -70,6 +72,54 @@ function isSessionStoreEntry(value: unknown): value is SessionStoreEntry { ); } +/** + * The entry a message steered into a running turn follows. Claude Code gives such a message no + * entry of its own: it records a queued_command attachment, under the uuid Paseo minted for the + * message, hung off the entry it was read after. Forking at that entry drops the message and + * everything after it. Null when the transcript holds no such attachment. + */ +export async function findSteeredMessageParent( + transcriptPath: string, + messageId: string, +): Promise { + const input = createReadStream(transcriptPath, { encoding: "utf8" }); + const lines = createInterface({ + input, + crlfDelay: Infinity, + }); + try { + for await (const line of lines) { + if (!line.includes(messageId) || !line.includes('"queued_command"')) continue; + let entry: unknown; + try { + entry = JSON.parse(line); + } catch { + continue; + } + const record = entry as { + type?: unknown; + parentUuid?: unknown; + attachment?: { type?: unknown; source_uuid?: unknown }; + }; + if ( + record.type === "attachment" && + record.attachment?.type === "queued_command" && + record.attachment.source_uuid === messageId && + typeof record.parentUuid === "string" + ) { + return record.parentUuid; + } + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } finally { + lines.close(); + input.destroy(); + } + return null; +} + export async function revertClaudeConversation(input: { sdk: ClaudeRewindSdk; sessionId: string | null; From e02fa9a5f893ff809f8850d0b9afd9c21c9c7577 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 14:55:17 +0000 Subject: [PATCH 07/58] fix(acp): report provider startup exits without waiting for timeout (#8384) * fix(server): resolve ACP providers installed after the daemon started Windows hands a new process the PATH its parent had when it started. The daemon's supervisor therefore keeps the PATH it inherited at launch, and any CLI installed afterwards stays invisible to it. A provider installed after the daemon started reported "not installed" forever, even though the binary was on PATH in a fresh shell. Refresh the process PATH from the Windows user/machine registry environment when the daemon worker starts. Existing entries keep their order and take precedence; registry entries are appended. This runs once at startup so PATH-isolated availability checks and explicit env overrides stay authoritative. Spawn the absolute path the launcher resolver verified rather than the bare command. The resolver already finds installs outside the daemon's inherited PATH (Winget packages, directories added after startup); the resolved path was previously discarded. An ACP agent that exits during the handshake never rejects the SDK's initialize request, so the provider refresh waited out its full 120s deadline and reported "Timed out refreshing after 120000ms; pending: initialize" instead of the child's stderr. Race a child-exit rejection against initialize so the real failure surfaces. Also repair persisted MiniMax Code commands. The first catalog entry ran `npx -y @minimax-ai/code@ acp`; npx derives the executable name from the package name (`code`), but the package publishes `mcode`, so npm exited before the ACP handshake. The catalog was fixed separately, but commands already persisted by an older client were never migrated. * fix(acp): report startup exits across probes and sessions Extend the shared process failure handler so initialize rejects with stderr after the child exits, including session creation. Keep this fix scoped to startup error reporting; remove the separate PATH and persisted-command changes. Co-authored-by: peanut_hzm * fix(acp): validate provider options before starting probes Reject malformed options before creating a child or its process-failure promise, so cleanup cannot cause an unhandled rejection in the daemon. --------- Co-authored-by: peanut_hzm Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../src/server/agent/providers/acp-agent.ts | 40 ++++--- .../agent/providers/acp-startup-exit.test.ts | 112 ++++++++++++++++++ 2 files changed, 138 insertions(+), 14 deletions(-) create mode 100644 packages/server/src/server/agent/providers/acp-startup-exit.test.ts diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index e26b6e4ade..d2973d42c7 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -142,14 +142,26 @@ function assertChildWithPipes( } } -// Node reports a failed spawn (missing binary or missing cwd) as a child -// `error` event; without a listener it becomes an uncaught exception. -function rejectOnSpawnError(child: ChildProcess, stderrChunks: string[]): Promise { +// Watch process failures as well as spawn errors: the SDK leaves initialize +// pending when the child exits without replying. `close` waits for stderr to +// drain, so the failure includes the launcher's diagnostic. +function rejectOnProcessFailure(child: ChildProcess, stderrChunks: string[]): Promise { return new Promise((_, reject) => { child.once("error", (error) => { const stderr = stderrChunks.join("").trim(); reject(new Error(stderr ? `${String(error)}\n${stderr}` : String(error))); }); + child.once("close", (code, signal) => { + const stderr = stderrChunks.join("").trim(); + const status = signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}`; + reject( + new Error( + `ACP agent process exited before initialization completed (${status})${ + stderr ? `\n${stderr}` : "" + }`, + ), + ); + }); }); } @@ -542,7 +554,7 @@ interface ACPProcessTransport { connection: ClientSideConnection; stderrChunks: string[]; spawnReady: Promise; - spawnError: Promise; + processError: Promise; } interface ACPImportPromptPreviews { @@ -1380,6 +1392,7 @@ export class ACPAgentClient implements AgentClient { client?: ACPClient; }, ): Promise { + const configuredOptions = ACPProviderOptionsSchema.parse(options?.providerOptions ?? {}); const transport = await this.spawnTransport(launchEnv, options?.client); const probe: UninitializedACPProcess = { child: transport.child, @@ -1391,7 +1404,7 @@ export class ACPAgentClient implements AgentClient { const initialize = await this.initializeTransport( transport, options?.initializeTimeoutMs, - options?.providerOptions, + configuredOptions.clientCapabilities, ); const initializedProbe: SpawnedACPProcess = { ...probe, @@ -1425,7 +1438,7 @@ export class ACPAgentClient implements AgentClient { stderrChunks.push(chunk.toString()); }); - const spawnErrorPromise = rejectOnSpawnError(child, stderrChunks); + const processErrorPromise = rejectOnProcessFailure(child, stderrChunks); const spawnReadyPromise = new Promise((resolve) => { child.once("spawn", () => { resolve(); @@ -1444,16 +1457,15 @@ export class ACPAgentClient implements AgentClient { connection, stderrChunks, spawnReady: spawnReadyPromise, - spawnError: spawnErrorPromise, + processError: processErrorPromise, }; } protected async initializeTransport( transport: ACPProcessTransport, initializeTimeoutMs?: number, - providerOptions?: Record, + clientCapabilities?: ACPClientCapabilities, ): Promise { - const configuredOptions = ACPProviderOptionsSchema.parse(providerOptions ?? {}); let timeout: ReturnType | null = null; const initializeTimeoutPromise = initializeTimeoutMs ? new Promise((_, reject) => { @@ -1470,11 +1482,11 @@ export class ACPAgentClient implements AgentClient { protocolVersion: PROTOCOL_VERSION, clientCapabilities: buildACPClientCapabilities( this.clientCapabilityMeta, - configuredOptions.clientCapabilities ?? this.clientCapabilities, + clientCapabilities ?? this.clientCapabilities, ), clientInfo: { name: "Paseo", version: "dev" }, }), - transport.spawnError, + transport.processError, ...(initializeTimeoutPromise ? [initializeTimeoutPromise] : []), ]), ); @@ -1555,7 +1567,7 @@ export class ACPAgentClient implements AgentClient { try { transport = await this.spawnTransport(PROBE_ENV); await withTimeout( - Promise.race([transport.spawnReady, transport.spawnError]), + Promise.race([transport.spawnReady, transport.processError]), phaseTimeoutMs, `ACP spawn timed out after ${phaseTimeoutMs}ms`, ); @@ -2910,7 +2922,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { child.stderr.on("data", (chunk: Buffer | string) => { stderrChunks.push(chunk.toString()); }); - const spawnError = rejectOnSpawnError(child, stderrChunks); + const processError = rejectOnProcessFailure(child, stderrChunks); child.once("exit", (code, signal) => { if (this.closed || this.historyRuntimeReleased) { return; @@ -2947,7 +2959,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { ), clientInfo: { name: "Paseo", version: "dev" }, }), - spawnError, + processError, ]), ); diff --git a/packages/server/src/server/agent/providers/acp-startup-exit.test.ts b/packages/server/src/server/agent/providers/acp-startup-exit.test.ts new file mode 100644 index 0000000000..39ae6f743e --- /dev/null +++ b/packages/server/src/server/agent/providers/acp-startup-exit.test.ts @@ -0,0 +1,112 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { describe, expect, test } from "vitest"; + +import { GenericACPAgentClient } from "./generic-acp-agent.js"; +import { createTestLogger } from "../../../test-utils/test-logger.js"; + +// Exercise the public catalog API with a real child, including stdout EOF and +// stderr draining. No provider installation or external service is involved. +const failingCommand: [string, ...string[]] = [ + process.execPath, + "-e", + "process.stdin.once('data', () => { process.stderr.write('launcher failed: fixture startup error\\n'); process.exitCode = 1; process.stdin.destroy(); });", +]; + +describe("ACP catalog startup failures", () => { + test("reports a process that exits during initialize with its stderr", async () => { + const client = new GenericACPAgentClient({ + logger: createTestLogger(), + command: failingCommand, + }); + await expect( + client.fetchCatalog({ scope: "workspace", cwd: process.cwd(), force: true }), + ).rejects.toThrow(/launcher failed: fixture startup error/); + }, 5_000); + + test("session startup reports the same process exit with its stderr", async () => { + const client = new GenericACPAgentClient({ + logger: createTestLogger(), + command: failingCommand, + }); + await expect(client.createSession({ provider: "acp", cwd: process.cwd() })).rejects.toThrow( + /launcher failed: fixture startup error/, + ); + }, 5_000); + + test("provider diagnostics report the startup exit instead of an initialize timeout", async () => { + const client = new GenericACPAgentClient({ + logger: createTestLogger(), + command: failingCommand, + diagnosticPhaseTimeoutMs: 2_000, + }); + const { diagnostic } = await client.getDiagnostic(); + expect(diagnostic).toContain( + "ACP agent process exited before initialization completed (exit code 1)", + ); + expect(diagnostic).toContain("launcher failed: fixture startup error"); + expect(diagnostic).not.toContain("initialize timed out"); + }); + + test("invalid provider options reject without crashing the host process", async () => { + const clientModule = new URL("./generic-acp-agent.ts", import.meta.url).href; + const loggerModule = new URL("../../../test-utils/test-logger.ts", import.meta.url).href; + const script = ` + import { GenericACPAgentClient } from ${JSON.stringify(clientModule)}; + import { createTestLogger } from ${JSON.stringify(loggerModule)}; + const client = new GenericACPAgentClient({ + logger: createTestLogger(), + command: [process.execPath, '-e', 'setInterval(() => {}, 1000)'], + }); + try { + await client.fetchCatalog({ + scope: 'workspace', cwd: process.cwd(), force: true, + providerOptions: { clientCapabilities: { fs: 'invalid' } }, + }); + throw new Error('Expected invalid options to reject'); + } catch (error) { + if (error.name !== 'ZodError') throw error; + } + // Let child cleanup and Node's unhandled-rejection check complete. + await new Promise(resolve => setTimeout(resolve, 100)); + console.log('host stayed alive'); + `; + const result = await promisify(execFile)( + process.execPath, + ["--import", "tsx", "--input-type=module", "-e", script], + { timeout: 10_000 }, + ); + expect(result.stdout).toContain("host stayed alive"); + expect(result.stderr).toBe(""); + }); + + test("a successful catalog probe still returns its models and closes cleanly", async () => { + const client = new GenericACPAgentClient({ + logger: createTestLogger(), + command: [ + process.execPath, + "-e", + ` + require('node:readline').createInterface({ input: process.stdin }).on('line', (line) => { + const request = JSON.parse(line); + const result = request.method === 'initialize' + ? { protocolVersion: 1, agentCapabilities: {} } + : { sessionId: 'fixture', models: { + currentModelId: 'fixture-model', + availableModels: [{ modelId: 'fixture-model', name: 'Fixture model' }] + } }; + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result }) + '\\n'); + }); + `, + ], + }); + const catalog = await client.fetchCatalog({ + scope: "workspace", + cwd: process.cwd(), + force: true, + }); + expect(catalog.models).toEqual([ + expect.objectContaining({ id: "fixture-model", label: "Fixture model" }), + ]); + }); +}); From eab9ac3b03c04fdf8d3bfb67449ec4d88e7860c0 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 16:07:08 +0000 Subject: [PATCH 08/58] fix(acp): include late-discovered skills in initial command lists (#8387) * fix(providers): wait for the ACP command inventory to settle An ACP agent publishes its slash-command inventory in batches. MiniMax Code sends its built-in commands first, then merges in every discovered skill roughly 350ms later. `listCommands()` settled on the first batch, so a session that had to discover its commands at creation time offered the 10 built-in commands instead of the 396 the agent eventually publishes. Every skill was missing from `/` until the session was reopened and the cached inventory was read again. Wait for the inventory to go quiet (600ms without another batch) instead of for the first batch. An empty batch still settles immediately, and `initialCommandsWaitTimeoutMs` (1500ms default) still bounds the whole wait for providers that publish once or never publish at all. * test(acp): verify late commands through the provider boundary Exercise initial command discovery over real stdio, including a first batch cached before session creation returns. Keep the later skill and subsequent cached read in the same public session contract. Co-authored-by: huazhongmin --------- Co-authored-by: huazhongmin Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../server/agent/providers/acp-agent.test.ts | 68 +++++++++++- .../src/server/agent/providers/acp-agent.ts | 102 +++++++++--------- 2 files changed, 118 insertions(+), 52 deletions(-) diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts index e902493dcb..7e50b32cf5 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -1,6 +1,6 @@ import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process"; import { EventEmitter } from "node:events"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { createRequire } from "node:module"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -2634,6 +2634,72 @@ describe("ACPAgentSession slash commands", () => { }, ]); }); + + test("lists late command batches through the public ACP client", async () => { + const artifacts = path.resolve(".artifacts"); + await mkdir(artifacts, { recursive: true }); + const directory = await mkdtemp(path.join(artifacts, "acp-command-inventory-")); + const script = path.join(directory, "agent.mjs"); + await writeFile( + script, + ` +import { createInterface } from "node:readline"; +const send = (message) => process.stdout.write(JSON.stringify(message) + "\\n"); +const help = { name: "help", description: "Show available commands" }; +const skill = { name: "xlsx", description: "Work with spreadsheets", input: { hint: "[instructions]" } }; +const publish = (commands) => send({ + jsonrpc: "2.0", method: "session/update", params: { + sessionId: "inventory-session", update: { + sessionUpdate: "available_commands_update", availableCommands: commands, + }, + }, +}); +createInterface({ input: process.stdin }).on("line", (line) => { + const request = JSON.parse(line); + if (request.method === "initialize") { + send({ jsonrpc: "2.0", id: request.id, result: { protocolVersion: 1, agentCapabilities: {} } }); + } else if (request.method === "session/new") { + // The first batch is already cached when createSession returns. + publish([help]); + send({ jsonrpc: "2.0", id: request.id, result: { sessionId: "inventory-session" } }); + setTimeout(() => publish([help]), 100); + setTimeout(() => publish([help, skill]), 350); + } else if (request.id !== undefined) { + send({ jsonrpc: "2.0", id: request.id, error: { code: -32601, message: "Method not found" } }); + } +}); +`, + ); + const client = new GenericACPAgentClient({ + logger: createTestLogger(), + command: [process.execPath, script], + }); + try { + const session = await client.createSession({ provider: "acp", cwd: directory }); + try { + const commands = await session.listCommands!(); + expect(commands).toEqual([ + { + name: "help", + description: "Show available commands", + argumentHint: "", + kind: "command", + }, + { + name: "xlsx", + description: "Work with spreadsheets", + argumentHint: "[instructions]", + kind: "command", + }, + ]); + expect(await session.listCommands!()).toEqual(commands); + } finally { + await session.close(); + } + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); }); describe("ACPAgentSession", () => { diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index d2973d42c7..6831274d2d 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -337,6 +337,14 @@ const ACP_CLOSE_REQUEST_TIMEOUT_MS = 2_000; const ACP_IMPORT_HISTORY_LOAD_TIMEOUT_MS = 30_000; const ACP_IMPORT_HISTORY_BUDGET_MS = 60_000; +/** + * An ACP agent publishes its slash-command inventory incrementally. Wait this + * long without another batch before treating the inventory as complete. MiniMax + * publishes its skills about 350ms after its built-in commands. + */ +const COMMAND_INVENTORY_QUIET_MS = 600; +const COMMAND_INVENTORY_POLL_MS = 25; + function summarizeMalformedACPStdoutError(error: unknown): { type: string; message: string } { return { type: error instanceof Error ? error.name : typeof error, @@ -1765,7 +1773,8 @@ export class ACPAgentSession implements AgentSession, ACPClient { private lastActivityAt: string | null = null; private configOptions: SessionConfigOption[] = []; private cachedCommands: AgentSlashCommand[] = []; - private commandsReadyDeferred: { promise: Promise; resolve: () => void } | null = null; + /** When the last command batch arrived; null while nothing has been published. */ + private commandsUpdatedAt: number | null = null; private commandsReadySettled = false; private waitForInitialCommands: boolean; private initialCommandsWaitTimeoutMs: number; @@ -2063,58 +2072,39 @@ export class ACPAgentSession implements AgentSession, ACPClient { ]; } - private ensureCommandsReadyDeferred(): void { - if (this.commandsReadyDeferred || this.commandsReadySettled || this.cachedCommands.length > 0) { - return; - } - - let resolve!: () => void; - const promise = new Promise((r) => { - resolve = r; - }); - this.commandsReadyDeferred = { promise, resolve }; - } - private settleCommandsReady(): void { - if (this.commandsReadySettled) { - return; - } this.commandsReadySettled = true; - this.commandsReadyDeferred?.resolve(); - this.commandsReadyDeferred = null; } - private async waitForCommandsReady(): Promise { - const deferred = this.commandsReadyDeferred; - if (!deferred) { - return; - } - - let timer: ReturnType | null = null; - try { - await Promise.race([ - deferred.promise, - new Promise((resolve) => { - timer = setTimeout(resolve, this.initialCommandsWaitTimeoutMs); - }), - ]); - } finally { - if (timer) { - clearTimeout(timer); - } + /** + * Providers publish their command inventory incrementally. MiniMax sends its + * built-in commands twice, then a third batch about 350ms later that merges in + * every discovered skill. Returning after the first batch loses the skills. + * + * Wait for the inventory to go quiet rather than for the first batch. The + * quiet window must exceed the gap between a provider's own batches; 600ms + * covers MiniMax with margin. `initialCommandsWaitTimeoutMs` still bounds the + * whole wait for providers that publish once or never publish at all. + */ + private async waitForCommandsToSettle(): Promise { + const deadline = Date.now() + this.initialCommandsWaitTimeoutMs; + while (!this.commandsReadySettled && !this.closed) { + const remaining = deadline - Date.now(); + if (remaining <= 0) return; + const quietFor = this.commandsUpdatedAt === null ? 0 : Date.now() - this.commandsUpdatedAt; + if (quietFor >= COMMAND_INVENTORY_QUIET_MS) return; + await new Promise((resolve) => + setTimeout(resolve, Math.min(remaining, COMMAND_INVENTORY_POLL_MS)), + ); } } async listCommands(): Promise { - if (this.cachedCommands.length > 0) { - return this.cachedCommands; - } - if (!this.waitForInitialCommands || this.closed) { + if (this.commandsReadySettled || this.closed || !this.waitForInitialCommands) { return this.cachedCommands; } - this.ensureCommandsReadyDeferred(); - await this.waitForCommandsReady(); + await this.waitForCommandsToSettle(); this.settleCommandsReady(); return this.cachedCommands; } @@ -2745,14 +2735,12 @@ export class ACPAgentSession implements AgentSession, ACPClient { } } - // Cache an asynchronously-delivered slash-command batch and unblock any - // listCommands() call that is waiting on the initial batch. Used when a - // provider supplies an extensionCommandsParser whose result arrives after - // session/new (e.g. via a vendor extension notification). The ready gate is - // always settled — even for an empty batch — so a provider that legitimately - // reports no commands does not leave listCommands() blocked for the full - // initial-commands timeout. An optional sessionId scopes the batch to this - // session; notifications addressed to a different session are ignored. + // Cache an asynchronously-delivered slash-command batch from a provider that + // supplies an extensionCommandsParser (e.g. a vendor extension notification). + // An empty batch is the provider saying it has no commands, so it settles the + // waiter immediately instead of letting it wait out the quiet window. An + // optional sessionId scopes the batch to this session; notifications + // addressed to a different session are ignored. private applyResolvedCommands( commands: AgentSlashCommand[], options?: { sessionId?: string }, @@ -2766,7 +2754,13 @@ export class ACPAgentSession implements AgentSession, ACPClient { } if (commands.length > 0) { + this.commandsUpdatedAt = Date.now(); this.cachedCommands = commands; + // A non-empty batch may be followed by more. Let the quiet window decide + // when the inventory is complete rather than settling here; an empty + // batch is the provider saying it has nothing, which must still unblock + // the waiter immediately. + return; } this.settleCommandsReady(); } @@ -3150,13 +3144,19 @@ export class ACPAgentSession implements AgentSession, ACPClient { this.handleUsageUpdate(update); return pendingUserEvents; case "available_commands_update": + this.commandsUpdatedAt = Date.now(); this.cachedCommands = update.availableCommands.map((command) => ({ name: command.name, description: command.description, argumentHint: command.input?.hint ?? "", kind: "command", })); - this.settleCommandsReady(); + // An empty batch means the agent has no commands and would otherwise + // wait out the whole quiet window. A non-empty batch may be one of + // several, so completion belongs to the quiet window. + if (update.availableCommands.length === 0) { + this.settleCommandsReady(); + } return pendingUserEvents; default: return pendingUserEvents; From 58eafd81e587d758d391c18b6b9b34de86f05962 Mon Sep 17 00:00:00 2001 From: QTom <22166516+DaydreamCoding@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:01:27 +0800 Subject: [PATCH 09/58] fix(app): fall back to native terminal copy/paste without the Clipboard API (#8230) Browsers omit navigator.clipboard outside secure contexts, such as the daemon web UI served over plain http on a Tailscale or LAN address. The non-Mac Ctrl+C/Ctrl+V terminal shortcuts then threw synchronously, and Ctrl+V had already prevented the native paste, so terminal copy/paste broke. When the Clipboard API is unavailable, the shortcuts now leave the default action alone so xterm's native copy/paste event handlers do the work, and the browser clipboard rejects instead of throwing. Co-authored-by: Claude Opus 5.5 --- .../runtime/terminal-emulator-runtime.test.ts | 95 +++++++++++++++++++ .../runtime/terminal-emulator-runtime.ts | 64 +++++++++---- 2 files changed, 140 insertions(+), 19 deletions(-) diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts index a6939aaecb..8b90839154 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts @@ -82,6 +82,7 @@ import { createTerminalResizeEvent, encodeTerminalOutput, TerminalEmulatorRuntime, + type TerminalClipboard, } from "./terminal-emulator-runtime"; interface StubTerminal { @@ -638,3 +639,97 @@ describe("terminal-emulator-runtime", () => { expect(fitAndEmitResize).not.toHaveBeenCalled(); }); }); + +describe("terminal-emulator-runtime clipboard shortcuts", () => { + type KeyHandler = (event: KeyboardEvent) => boolean; + + function createClipboard(available: boolean) { + return { + isAvailable: () => available, + readText: vi.fn(async () => "clipboard text"), + read: vi.fn(async () => []), + writeText: vi.fn(async (_text: string) => undefined), + } satisfies TerminalClipboard; + } + + function attachKeyHandler(input: { clipboard: TerminalClipboard; selection: string }) { + const runtime = new TerminalEmulatorRuntime({ isMac: false }, input.clipboard); + let handler: KeyHandler | null = null; + const paste = vi.fn(); + runtime.attachKeyEventHandler({ + attachCustomKeyEventHandler: (next: KeyHandler) => { + handler = next; + }, + hasSelection: () => input.selection.length > 0, + getSelection: () => input.selection, + paste, + input: vi.fn(), + scrollToBottom: vi.fn(), + }); + if (!handler) throw new Error("key handler was not attached"); + return { handler: handler as KeyHandler, paste }; + } + + function ctrlKey(key: string) { + const preventDefault = vi.fn(); + const event = { + type: "keydown", + key, + isComposing: false, + ctrlKey: true, + shiftKey: false, + altKey: false, + metaKey: false, + preventDefault, + stopPropagation: vi.fn(), + } as unknown as KeyboardEvent; + return { event, preventDefault }; + } + + it("leaves Ctrl+C with a selection to the native copy event without the Clipboard API", () => { + const clipboard = createClipboard(false); + const { handler } = attachKeyHandler({ clipboard, selection: "selected text" }); + const { event, preventDefault } = ctrlKey("c"); + + expect(handler(event)).toBe(false); + expect(clipboard.writeText).not.toHaveBeenCalled(); + expect(preventDefault).not.toHaveBeenCalled(); + }); + + it("leaves Ctrl+V to the native paste event without the Clipboard API", () => { + const clipboard = createClipboard(false); + const { handler, paste } = attachKeyHandler({ clipboard, selection: "" }); + const { event, preventDefault } = ctrlKey("v"); + + expect(handler(event)).toBe(false); + expect(clipboard.readText).not.toHaveBeenCalled(); + expect(preventDefault).not.toHaveBeenCalled(); + expect(paste).not.toHaveBeenCalled(); + }); + + it("lets xterm send ^C for Ctrl+C without a selection when the Clipboard API is missing", () => { + const clipboard = createClipboard(false); + const { handler } = attachKeyHandler({ clipboard, selection: "" }); + const { event, preventDefault } = ctrlKey("c"); + + expect(handler(event)).toBe(true); + expect(clipboard.writeText).not.toHaveBeenCalled(); + expect(preventDefault).not.toHaveBeenCalled(); + }); + + it("copies and pastes through the Clipboard API when it is available", async () => { + const clipboard = createClipboard(true); + const { handler, paste } = attachKeyHandler({ clipboard, selection: "selected text" }); + + const copy = ctrlKey("c"); + expect(handler(copy.event)).toBe(false); + expect(clipboard.writeText).toHaveBeenCalledWith("selected text"); + expect(copy.preventDefault).not.toHaveBeenCalled(); + + const pasteKey = ctrlKey("v"); + expect(handler(pasteKey.event)).toBe(false); + expect(pasteKey.preventDefault).toHaveBeenCalledTimes(1); + expect(clipboard.readText).toHaveBeenCalledTimes(1); + await vi.waitFor(() => expect(paste).toHaveBeenCalledWith("clipboard text")); + }); +}); diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts index a7460f7f80..0e1f88dafb 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts @@ -182,15 +182,26 @@ function withOverviewRulerBorderHidden(theme: ITheme): ITheme { } export interface TerminalClipboard { + /** Whether the async Clipboard API is usable. Browsers omit it outside secure contexts. */ + isAvailable?(): boolean; readText(): Promise; read(): Promise[]>; writeText(text: string): Promise; } +function getBrowserClipboard(): Clipboard | undefined { + return typeof navigator !== "undefined" ? navigator.clipboard : undefined; +} + +function rejectUnavailableClipboard(): Promise { + return Promise.reject(new Error("Clipboard API is unavailable")); +} + const browserClipboard: TerminalClipboard = { - readText: () => navigator.clipboard.readText(), - read: () => navigator.clipboard.read(), - writeText: (text) => navigator.clipboard.writeText(text), + isAvailable: () => !!getBrowserClipboard(), + readText: () => getBrowserClipboard()?.readText() ?? rejectUnavailableClipboard(), + read: () => getBrowserClipboard()?.read() ?? rejectUnavailableClipboard(), + writeText: (text) => getBrowserClipboard()?.writeText(text) ?? rejectUnavailableClipboard(), }; async function readClipboardImage(clipboard: TerminalClipboard): Promise { @@ -368,22 +379,7 @@ export class TerminalEmulatorRuntime { !event.altKey && !event.metaKey ) { - const key = event.key.toLowerCase(); - - // Ctrl+C: copy selection to clipboard if text is selected, otherwise let xterm send SIGINT - if (key === "c" && terminal.hasSelection()) { - void this.clipboard.writeText(terminal.getSelection()); - return false; - } - - // Ctrl+V: paste clipboard text, or the clipboard image when there is no text - if (key === "v") { - event.preventDefault(); - this.pasteClipboard(terminal); - return false; - } - - return true; + return !this.handleCtrlClipboardShortcut(terminal, event); } if (this.maybeSendMacEditingShortcut(terminal, event)) { @@ -432,6 +428,36 @@ export class TerminalEmulatorRuntime { }); } + /** Returns true when a non-Mac Ctrl+C / Ctrl+V clipboard shortcut was handled. */ + private handleCtrlClipboardShortcut( + terminal: Pick, + event: KeyboardEvent, + ): boolean { + const key = event.key.toLowerCase(); + // Without the Clipboard API (e.g. plain http on a LAN address), the browser's native + // copy/paste events still fire and xterm handles them, so leave the default action. + const clipboardAvailable = this.clipboard.isAvailable?.() ?? true; + + // Ctrl+C: copy selection to clipboard if text is selected, otherwise let xterm send SIGINT + if (key === "c" && terminal.hasSelection()) { + if (clipboardAvailable) { + void this.clipboard.writeText(terminal.getSelection()); + } + return true; + } + + // Ctrl+V: paste clipboard text, or the clipboard image when there is no text + if (key === "v") { + if (clipboardAvailable) { + event.preventDefault(); + this.pasteClipboard(terminal); + } + return true; + } + + return false; + } + private handleFindShortcut(event: KeyboardEvent): boolean { if (!isFindShortcut(event, this.options) || !this.callbacks.onFindRequest) return false; event.preventDefault(); From 2dd931c8bdd3714b1c958688b3a3d9f9659fb8fd Mon Sep 17 00:00:00 2001 From: QTom <22166516+DaydreamCoding@users.noreply.github.com> Date: Sun, 1 Nov 2026 02:06:12 +0800 Subject: [PATCH 10/58] fix(app): accept host:port and URLs in the direct connection host field (#8223) The direct connection form treated any host containing a colon as a bare IPv6 address, so entering "100.66.69.36:6767" or "tcp://host:6767" built "tcp://[100.66.69.36:6767]:6767" and surfaced the browser's raw "Invalid URL" error. Normalize the host input first: strip a leading scheme and any path/query/fragment, split "host:port" (the inline port wins), and only treat unbracketed hosts with two or more colons as IPv6. Co-authored-by: Claude Opus 5.5 --- .../app/src/components/add-host-modal.tsx | 37 ++-------- .../components/direct-connection-host.test.ts | 68 ++++++++++++++++++ .../src/components/direct-connection-host.ts | 70 +++++++++++++++++++ 3 files changed, 142 insertions(+), 33 deletions(-) create mode 100644 packages/app/src/components/direct-connection-host.test.ts create mode 100644 packages/app/src/components/direct-connection-host.ts diff --git a/packages/app/src/components/add-host-modal.tsx b/packages/app/src/components/add-host-modal.tsx index 653d69c4f1..aa0c65b638 100644 --- a/packages/app/src/components/add-host-modal.tsx +++ b/packages/app/src/components/add-host-modal.tsx @@ -6,15 +6,12 @@ import { useIsCompactFormFactor } from "@/constants/layout"; import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Link2 } from "lucide-react-native"; import type { HostProfile } from "@/types/host-connection"; import { useHosts, useHostMutations } from "@/runtime/host-runtime"; -import { - parseConnectionUri, - serializeConnectionUri, - serializeConnectionUriForStorage, -} from "@/utils/daemon-endpoints"; +import { parseConnectionUri, serializeConnectionUri } from "@/utils/daemon-endpoints"; import { buildConnectionFailureCopy } from "./connection-failure-copy"; import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet"; import { Button } from "@/components/ui/button"; import { PairingTargetTracker } from "./pair-link-credentials"; +import { buildDirectConnectionUri } from "./direct-connection-host"; const FLEX_ONE_STYLE = { flex: 1 } as const; @@ -139,37 +136,11 @@ const styles = StyleSheet.create((theme) => ({ }, })); -function isIpv6Host(host: string): boolean { - return host.includes(":") && !host.startsWith("[") && !host.endsWith("]"); -} - -function buildConnectionUriFromDraft( - draft: DirectConnectionDraft, - labels: DirectConnectionLabels, -): string { - const host = draft.host.trim(); - const port = Number(draft.port.trim()); - if (!host) { - throw new Error(labels.hostRequired); - } - if (!Number.isInteger(port) || port < 1 || port > 65535) { - throw new Error(labels.invalidPort); - } - - return serializeConnectionUriForStorage({ - host, - port, - isIpv6: isIpv6Host(host), - useTls: draft.useTls, - ...(draft.password ? { password: draft.password } : {}), - }); -} - function prepareDirectConnection( draft: DirectConnectionDraft, labels: DirectConnectionLabels, ): PreparedDirectConnection { - const parsed = parseConnectionUri(buildConnectionUriFromDraft(draft, labels)); + const parsed = parseConnectionUri(buildDirectConnectionUri(draft, labels)); const endpoint = parsed.isIpv6 ? `[${parsed.host}]:${parsed.port}` : `${parsed.host}:${parsed.port}`; @@ -420,7 +391,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod advancedTarget.current = new PairingTargetTracker("", true); try { setAdvancedUri( - buildConnectionUriFromDraft({ host, port, useTls, password }, directConnectionLabels), + buildDirectConnectionUri({ host, port, useTls, password }, directConnectionLabels), ); } catch { setAdvancedUri(""); diff --git a/packages/app/src/components/direct-connection-host.test.ts b/packages/app/src/components/direct-connection-host.test.ts new file mode 100644 index 0000000000..0a2411215a --- /dev/null +++ b/packages/app/src/components/direct-connection-host.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { buildDirectConnectionUri, normalizeDirectConnectionHost } from "./direct-connection-host"; + +const labels = { + hostRequired: "host required", + invalidPort: "invalid port", +}; + +function buildUri(host: string, port = "6767"): string { + return buildDirectConnectionUri({ host, port, useTls: false, password: "" }, labels); +} + +describe("normalizeDirectConnectionHost", () => { + it.each([ + ["100.66.69.36", { host: "100.66.69.36", port: null, isIpv6: false }], + [" 100.66.69.36 ", { host: "100.66.69.36", port: null, isIpv6: false }], + ["100.66.69.36:6767", { host: "100.66.69.36", port: "6767", isIpv6: false }], + ["tcp://100.66.69.36:6767", { host: "100.66.69.36", port: "6767", isIpv6: false }], + ["http://100.66.69.36:6767/path?x=1#y", { host: "100.66.69.36", port: "6767", isIpv6: false }], + ["HTTPS://example.test", { host: "example.test", port: null, isIpv6: false }], + ["my-host.local:7000", { host: "my-host.local", port: "7000", isIpv6: false }], + ["[::1]", { host: "::1", port: null, isIpv6: true }], + ["[::1]:7000", { host: "::1", port: "7000", isIpv6: true }], + ["::1", { host: "::1", port: null, isIpv6: true }], + ["fe80::1", { host: "fe80::1", port: null, isIpv6: true }], + ["", { host: "", port: null, isIpv6: false }], + ])("normalizes %j", (input, expected) => { + expect(normalizeDirectConnectionHost(input)).toEqual(expected); + }); +}); + +describe("buildDirectConnectionUri", () => { + it.each([ + ["100.66.69.36", "tcp://100.66.69.36:6767"], + ["100.66.69.36:6767", "tcp://100.66.69.36:6767"], + ["tcp://100.66.69.36:6767", "tcp://100.66.69.36:6767"], + ["http://100.66.69.36:6767/path?x", "tcp://100.66.69.36:6767"], + ["my-host.local:6767", "tcp://my-host.local:6767"], + ["[::1]", "tcp://[::1]:6767"], + ["[::1]:7000", "tcp://[::1]:7000"], + ["::1", "tcp://[::1]:6767"], + ["fe80::1", "tcp://[fe80::1]:6767"], + ])("builds a tcp URI from host %j", (host, expected) => { + expect(buildUri(host)).toBe(expected); + }); + + it("lets a port typed in the host field override the port field", () => { + expect(buildUri("100.66.69.36:7000", "6767")).toBe("tcp://100.66.69.36:7000"); + }); + + it("keeps TLS and password settings", () => { + expect( + buildDirectConnectionUri( + { host: "tcp://example.test:443", port: "6767", useTls: true, password: "secret" }, + labels, + ), + ).toBe("tcp://example.test:443?ssl=true&password=secret"); + }); + + it.each(["", " ", "tcp://", "http:///path"])("requires a host for %j", (host) => { + expect(() => buildUri(host)).toThrow("host required"); + }); + + it("rejects an invalid port", () => { + expect(() => buildUri("100.66.69.36", "70000")).toThrow("invalid port"); + expect(() => buildUri("100.66.69.36:0")).toThrow("invalid port"); + }); +}); diff --git a/packages/app/src/components/direct-connection-host.ts b/packages/app/src/components/direct-connection-host.ts new file mode 100644 index 0000000000..db694dd7c2 --- /dev/null +++ b/packages/app/src/components/direct-connection-host.ts @@ -0,0 +1,70 @@ +import { serializeConnectionUriForStorage } from "@/utils/daemon-endpoints"; + +export interface DirectConnectionHostInput { + host: string; + port: string; + useTls: boolean; + password: string; +} + +export interface DirectConnectionHostLabels { + hostRequired: string; + invalidPort: string; +} + +export interface NormalizedDirectConnectionHost { + host: string; + /** Port typed into the host field, if any. Overrides the separate port field. */ + port: string | null; + isIpv6: boolean; +} + +const SCHEME_PATTERN = /^[a-z][a-z0-9+.-]*:\/\//i; +const BRACKETED_IPV6_PATTERN = /^\[([^\]]*)\](?::(\d*))?$/; +const HOST_WITH_PORT_PATTERN = /^([^:]+):(\d+)$/; + +/** + * Users often paste `host:port` or a full URL into the host field. + * Reduce it to a bare host (plus an optional port) before building the URI. + */ +export function normalizeDirectConnectionHost(input: string): NormalizedDirectConnectionHost { + let host = input.trim().replace(SCHEME_PATTERN, ""); + const suffixIndex = host.search(/[/?#]/); + if (suffixIndex !== -1) { + host = host.slice(0, suffixIndex); + } + + const bracketed = BRACKETED_IPV6_PATTERN.exec(host); + if (bracketed) { + return { host: bracketed[1] ?? "", port: bracketed[2] || null, isIpv6: true }; + } + + const hostWithPort = HOST_WITH_PORT_PATTERN.exec(host); + if (hostWithPort) { + return { host: hostWithPort[1] ?? "", port: hostWithPort[2] ?? null, isIpv6: false }; + } + + return { host, port: null, isIpv6: host.split(":").length > 2 }; +} + +export function buildDirectConnectionUri( + draft: DirectConnectionHostInput, + labels: DirectConnectionHostLabels, +): string { + const normalized = normalizeDirectConnectionHost(draft.host); + const port = Number((normalized.port ?? draft.port).trim()); + if (!normalized.host) { + throw new Error(labels.hostRequired); + } + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error(labels.invalidPort); + } + + return serializeConnectionUriForStorage({ + host: normalized.host, + port, + isIpv6: normalized.isIpv6, + useTls: draft.useTls, + ...(draft.password ? { password: draft.password } : {}), + }); +} From 85354fcb6aae7320f638685d55b8cf4bb7cb0dd2 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:38:48 +0000 Subject: [PATCH 11/58] test(terminal): verify cleanup by process identity (#8389) * test(terminal): capture recurring worker publication failures * test(terminal): diagnose the remaining process cleanup wait * test(terminal): verify cleanup by process identity * test(terminal): retain native liveness proof during cleanup checks --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../src/terminal/terminal-worker-reaper.test.ts | 7 ++++++- .../src/terminal/worker-terminal-manager.test.ts | 14 +++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/server/src/terminal/terminal-worker-reaper.test.ts b/packages/server/src/terminal/terminal-worker-reaper.test.ts index a872325d31..a20d571b7f 100644 --- a/packages/server/src/terminal/terminal-worker-reaper.test.ts +++ b/packages/server/src/terminal/terminal-worker-reaper.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { Worker } from "node:worker_threads"; import { isPlatform } from "../test-utils/platform.js"; +import { classifyTerminalProgramProcess } from "./terminal-worker-ownership.js"; import { currentBootId } from "../server/pid-lock.js"; import { createTerminalWorkerProcessInspector, @@ -260,15 +261,19 @@ describe.skipIf(isPlatform("win32"))("terminal worker reaper", () => { it("leaves a program alive when its pid now belongs to a process with another birth time", async () => { const home = makeHome(); const unrelated = spawnIdle(); + const original = { id: "t1", pid: unrelated.pid!, startedAt: "Thu Jan 1 00:00:00 1970" }; const recordPath = writeRecord(home, { pid: await goneProcessPid(), - terminals: [{ id: "t1", pid: unrelated.pid!, startedAt: "Thu Jan 1 00:00:00 1970" }], + terminals: [original], }); await reapTerminalWorkers({ paseoHome: home, inspector, gracefulMs: 0 }); expect(isAlive(unrelated.pid!)).toBe(true); expect(existsSync(recordPath)).toBe(false); + expect( + classifyTerminalProgramProcess(original, await inspector.inspect(original.pid)), + ).toMatchObject({ status: "unrelated" }); }); it("retires a record from an earlier boot without signaling anything it names", async () => { diff --git a/packages/server/src/terminal/worker-terminal-manager.test.ts b/packages/server/src/terminal/worker-terminal-manager.test.ts index 7d91ec180e..2605e62965 100644 --- a/packages/server/src/terminal/worker-terminal-manager.test.ts +++ b/packages/server/src/terminal/worker-terminal-manager.test.ts @@ -50,6 +50,7 @@ import type { WorkerTerminalState, TerminalWorkerToParentMessage, } from "./terminal-worker-protocol.js"; +import { classifyTerminalProgramProcess } from "./terminal-worker-ownership.js"; import { createTerminalWorkerProcessInspector } from "./terminal-worker-processes.js"; import { readUnconfirmedTerminalCleanup, @@ -881,8 +882,19 @@ it.each([false, true])( expect(manager.getTerminal(remaining.id)).toBeUndefined(); expect(await manager.getTerminals(cwd)).toEqual([]); - await waitForCondition(() => !isProcessAlive(remainingPid), 10000); expect(await readUnconfirmedTerminalCleanup(paseoHome)).toEqual([]); + const original = record!.terminals.find((entry) => entry.id === remaining.id)!; + // Startup can launch a new worker after cleanup and Windows can reuse the old PID. + // Require the original process to be gone, not the numeric PID to remain unused. + await expect + .poll( + async () => + !isProcessAlive(remainingPid) || + classifyTerminalProgramProcess(original, await inspector.inspect(remainingPid)).status === + "unrelated", + { timeout: 10000 }, + ) + .toBe(true); }, ); From 56c051503b68ae8ad7a5b8baf331f8700b469f24 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:56:22 +0000 Subject: [PATCH 12/58] fix(server): keep promptless Codex agents usable after reopening (#8393) * fix(server): report a Codex thread only once Codex can resume it Codex writes a new thread's rollout at its first turn, but the adapter starts a thread when the agent is created and reported its ID at once. AgentManager stored that ID as the persistence handle, so a Codex agent that was reopened before its first prompt (idle runtime release, daemon restart, plugin tool reload) failed every prompt with "no rollout found". The adapter now reports a thread's ID, as runtime info and as a handle, only once it is resumable: after Codex announces the thread's first turn, or when the thread was resumed or forked. Until then a reopened agent starts a new session, as an unprompted Claude agent does. Document that a persistence handle must be resumable. * test(server): reopen promptless Codex agents without model requests Exercise the real app-server before any first turn, so publishing an unsaved persistence handle fails at the actual resume operation. Co-authored-by: Jeong-Uk,Kim --------- Co-authored-by: Jeong-Uk,Kim Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- docs/providers.md | 2 + .../src/server/agent/agent-sdk-types.ts | 1 + .../codex-app-server-agent.local.e2e.test.ts | 92 +++++++++++++++++++ .../providers/codex-app-server-agent.test.ts | 63 ++++++++++++- .../agent/providers/codex-app-server-agent.ts | 35 ++++++- 5 files changed, 187 insertions(+), 6 deletions(-) diff --git a/docs/providers.md b/docs/providers.md index b92e171c12..60fb75a785 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -507,6 +507,8 @@ interface AgentSession { } ``` +`describePersistence()` returns a handle only once a new provider process could resume it. Reload, idle runtime collection and daemon restart resume whatever handle the agent stored; a stored agent without one starts a new session instead. When there is no handle, `AgentManager` stores `getRuntimeInfo().sessionId` as one, so that ID follows the same rule. Codex, for example, starts a thread when the agent is created but writes its rollout only at its first turn, and `thread/resume` fails for it until then, so its adapter reports neither ID before Codex announces that turn. + `setMode` and `setThinkingOption` may return an `AgentProviderNotice` when the provider knows the change needs user-facing context. For example, providers that stage changes until the next turn should return an `info` notice while a turn is already running. The app renders the notice generically as a toast; provider-specific lifecycle behavior stays in the provider implementation. ### Steps diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index c31d1afb86..903bbe5c99 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -697,6 +697,7 @@ export interface AgentSession { requestId: string, response: AgentPermissionResponse, ): Promise; + /** Null until a new provider process could resume this session; see docs/providers.md. */ describePersistence(): AgentPersistenceHandle | null; /** * Resolve once every foreground turn that predates this call can no longer run or become active. diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.local.e2e.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.local.e2e.test.ts index 0469d9fbe1..dc7f920993 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.local.e2e.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.local.e2e.test.ts @@ -264,6 +264,98 @@ describe("Codex app-server provider (local e2e)", () => { 30_000, ); + test.runIf(isCodexInstalled())( + "reopens a promptless agent repeatedly before its first model request", + async () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-promptless-cwd-")); + const logger = createTestLogger(); + const storage = new AgentStorage(path.join(cwd, "agents"), logger); + const manager = new AgentManager({ + clients: { codex: new CodexAppServerAgentClient(logger) }, + registry: storage, + logger, + }); + let agentId: string | undefined; + try { + const created = await manager.createAgent( + { provider: "codex", cwd, modeId: "auto", model: "mock-model" }, + undefined, + { workspaceId: undefined }, + ); + agentId = created.id; + // A real app-server rejects an unsaved thread on resume. No model request is + // needed to prove that reopening the agent remains safe before its first turn. + for (let i = 0; i < 2; i++) { + const reopened = await manager.reloadAgentSession(agentId); + expect(reopened.lifecycle).toBe("idle"); + expect(reopened.persistence).toBeNull(); + } + await storage.flush(); + expect((await storage.get(agentId))?.persistence).toBeNull(); + } finally { + if (agentId && manager.getAgent(agentId)) await manager.closeAgent(agentId); + await storage.flush(); + rmSync(cwd, { recursive: true, force: true }); + } + }, + 30_000, + ); + + test.runIf(isCodexInstalled())( + "reopens an agent that was never prompted without resuming its unsaved thread", + async () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-unprompted-cwd-")); + const codexHome = mkdtempSync(path.join(os.tmpdir(), "codex-unprompted-home-")); + const mockServer = await startMockResponsesServer([ + assistantMessageSse("first"), + assistantMessageSse("second"), + ]); + const logger = createTestLogger(); + const storage = new AgentStorage(path.join(cwd, "agents"), logger); + const manager = new AgentManager({ + clients: { codex: new CodexAppServerAgentClient(logger) }, + registry: storage, + logger, + }); + let agentId: string | undefined; + try { + writeMockCodexConfig(codexHome, mockServer.url); + vi.stubEnv("CODEX_HOME", codexHome); + const created = await manager.createAgent( + { provider: "codex", cwd, modeId: "auto", model: "mock-model" }, + undefined, + { workspaceId: undefined }, + ); + agentId = created.id; + // Codex writes a thread's rollout at its first turn, so there is nothing to resume yet. + expect(manager.getAgent(agentId)?.persistence).toBeNull(); + await storage.flush(); + expect((await storage.get(agentId))?.persistence).toBeNull(); + + await manager.reloadAgentSession(agentId); + await manager.runAgent(agentId, "first prompt after reopening"); + const threadId = manager.getAgent(agentId)?.persistence?.sessionId; + expect(threadId).toEqual(expect.any(String)); + await storage.flush(); + expect((await storage.get(agentId))?.persistence?.sessionId).toBe(threadId); + + const reloaded = await manager.reloadAgentSession(agentId); + expect(reloaded.persistence?.sessionId).toBe(threadId); + await manager.runAgent(agentId, "continue the saved thread"); + expect(manager.getAgent(agentId)?.lifecycle).toBe("idle"); + expect(mockServer.requestBodies.at(-1)).toContain("first prompt after reopening"); + } finally { + if (agentId && manager.getAgent(agentId)) await manager.closeAgent(agentId); + await storage.flush(); + vi.unstubAllEnvs(); + await mockServer.close(); + rmSync(cwd, { recursive: true, force: true }); + rmSync(codexHome, { recursive: true, force: true }); + } + }, + 30_000, + ); + test.runIf(isCodexInstalled())( "surfaces request_user_input from the app-server as question permissions and timeline tool calls", async () => { diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 62e6bb0404..9986357cf4 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -90,6 +90,7 @@ import { createFakeCodexAppServer, fakeCodexThreadHistory, type FakeCodexAppServer, + waitForNextEvent, waitForNextPermission, waitForNextTimelineItem, waitForProviderSubagent, @@ -1287,6 +1288,9 @@ describe("Codex app-server provider", () => { ); await firstSession.startTurn("first turn"); await expect(beforeRestart.waitForTurnStart()).resolves.toMatchObject({ effort: "medium" }); + const saved = waitForNextEvent(firstSession, "thread_started"); + beforeRestart.startsTurn({ threadId: "thread-1" }); + await saved; const handle = firstSession.describePersistence()!; await firstSession.close(); @@ -1319,6 +1323,9 @@ describe("Codex app-server provider", () => { try { await session.startTurn("persist the configured session"); await appServer.waitForTurnStart(); + const saved = waitForNextEvent(session, "thread_started"); + appServer.startsTurn({ threadId: "thread-1" }); + await saved; const handle = session.describePersistence()!; expect(handle.metadata).not.toHaveProperty("providerOptions"); appServer.assertNoErrors(); @@ -1327,6 +1334,55 @@ describe("Codex app-server provider", () => { } }); + test("reports a new thread only after Codex announces its first turn", async () => { + const appServer = createFakeCodexAppServer(); + const session = await createProviderWithFakeAppServer(appServer).createSession(createConfig()); + try { + // Codex has started the thread but writes its rollout at the first turn, so another + // app-server could not resume it yet. + await expect(session.getRuntimeInfo()).resolves.toMatchObject({ sessionId: null }); + expect(session.describePersistence()).toBeNull(); + expect( + appServer.requests().filter((request) => request.method === "thread/start"), + ).toHaveLength(1); + + await session.startTurn("first prompt"); + await appServer.waitForTurnStart(); + expect(session.describePersistence()).toBeNull(); + + const saved = waitForNextEvent(session, "thread_started"); + appServer.startsTurn({ threadId: "thread-1" }); + await expect(saved).resolves.toMatchObject({ + type: "thread_started", + provider: "codex", + sessionId: "thread-1", + }); + expect(session.describePersistence()).toMatchObject({ + sessionId: "thread-1", + nativeHandle: "thread-1", + }); + await expect(session.getRuntimeInfo()).resolves.toMatchObject({ sessionId: "thread-1" }); + appServer.assertNoErrors(); + } finally { + await session.close(); + } + }); + + test("reports a resumed thread before its next turn", async () => { + const appServer = createFakeCodexAppServer(); + const session = await createProviderWithFakeAppServer(appServer).resumeSession( + { provider: "codex", sessionId: "saved-thread" }, + createConfig(), + ); + try { + expect(session.describePersistence()).toMatchObject({ sessionId: "saved-thread" }); + await expect(session.getRuntimeInfo()).resolves.toMatchObject({ sessionId: "saved-thread" }); + appServer.assertNoErrors(); + } finally { + await session.close(); + } + }); + test("preapproves only granted tools on the injected Codex MCP server", async () => { const session = createSession({ modeId: undefined, @@ -2604,7 +2660,12 @@ describe("Codex app-server provider", () => { clientMessageId: "client-message", }, }); - expect(events.slice(0, 2).map((event) => event.type)).toEqual(["turn_started", "timeline"]); + // The first turn of a new thread also reports the thread, now that Codex has saved it. + expect(events.slice(0, 3).map((event) => event.type)).toEqual([ + "thread_started", + "turn_started", + "timeline", + ]); appServer.completeTurn(); await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve)); diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index ea4e6378d9..2aecd18e21 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -3435,6 +3435,9 @@ export class CodexAppServerAgentSession implements AgentSession { > | null = null; private resolvedSandboxPolicy: Record | null = null; private currentThreadId: string | null = null; + // Codex writes a new thread's rollout at its first turn. Until then the thread exists only + // in this app-server, so no other app-server can resume it and it is not reported. + private savedThreadId: string | null = null; private currentTurnId: string | null = null; private pendingForegroundTurnIdentification: { foregroundTurnId: string; @@ -3572,6 +3575,7 @@ export class CodexAppServerAgentSession implements AgentSession { if (this.resumeHandle?.sessionId) { this.currentThreadId = this.resumeHandle.sessionId; + this.savedThreadId = this.resumeHandle.sessionId; this.historyIndexed = false; // Resuming can start a goal before the caller receives the session. if (this.initialResumePurpose === "interactive") this.startupEvents = []; @@ -4605,7 +4609,7 @@ export class CodexAppServerAgentSession implements AgentSession { } const info: AgentRuntimeInfo = { provider: CODEX_PROVIDER, - sessionId: this.currentThreadId, + sessionId: this.savedCurrentThreadId(), model: this.config.model ?? null, thinkingOptionId: normalizeCodexThinkingOptionId(this.config.thinkingOptionId) ?? null, modeId: this.currentMode ?? null, @@ -4936,17 +4940,18 @@ export class CodexAppServerAgentSession implements AgentSession { nativeHandle: string; metadata: Record; } | null { - if (!this.currentThreadId) return null; + const threadId = this.savedCurrentThreadId(); + if (!threadId) return null; const thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId) ?? null; return { provider: CODEX_PROVIDER, - sessionId: this.currentThreadId, - nativeHandle: this.currentThreadId, + sessionId: threadId, + nativeHandle: threadId, metadata: { provider: CODEX_PROVIDER, cwd: this.config.cwd, title: this.config.title ?? null, - threadId: this.currentThreadId, + threadId, modeId: this.config.modeId, model: this.config.model ?? null, thinkingOptionId, @@ -4958,6 +4963,12 @@ export class CodexAppServerAgentSession implements AgentSession { }; } + private savedCurrentThreadId(): string | null { + return this.currentThreadId !== null && this.currentThreadId === this.savedThreadId + ? this.currentThreadId + : null; + } + async revertConversation(input: { messageId: string }): Promise { await this.connect(); if (!this.client) { @@ -4982,6 +4993,8 @@ export class CodexAppServerAgentSession implements AgentSession { threadRollbackAvailable: this.threadRollbackAvailable, setThreadId: async (threadId) => { this.currentThreadId = threadId; + // Codex writes a forked thread's rollout when it forks it, even before any turn. + this.savedThreadId = threadId; this.cachedRuntimeInfo = null; this.stagedHistory = await this.readThreadHistory(this.client); this.reconcileAsyncQuestionsAfterRewind(this.stagedHistory.timeline); @@ -6155,6 +6168,7 @@ export class CodexAppServerAgentSession implements AgentSession { this.emitSubAgentActivityUpdate(subAgentCallId, "running", { reopen: true }); return; } + this.markCurrentThreadSaved(parsed.threadId); this.currentTurnId = parsed.turnId; const pendingIdentification = this.pendingForegroundTurnIdentification; if ( @@ -6168,6 +6182,17 @@ export class CodexAppServerAgentSession implements AgentSession { this.emitEvent({ type: "turn_started", provider: CODEX_PROVIDER }); } + // Codex has written the rollout by the time it announces a thread's turn, including a + // goal's or a compaction's. From here on the thread can be reported and resumed. + private markCurrentThreadSaved(threadId: string | null): void { + const current = this.currentThreadId; + if (!current || (threadId !== null && threadId !== current)) return; + if (this.savedThreadId === current) return; + this.savedThreadId = current; + this.cachedRuntimeInfo = null; + this.emitEvent({ type: "thread_started", provider: CODEX_PROVIDER, sessionId: current }); + } + private handleTurnCompletedNotification( parsed: Extract, ): void { From f0dfb9134ce08cf99166fa36275a68b69672a75a Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 20:03:31 +0000 Subject: [PATCH 13/58] fix(app): keep the open workspace visible in collapsed groups (#8395) * fix(app): keep the open workspace visible in collapsed sidebar groups A collapsed group showed its first seven rows plus working and needs-attention rows, so an idle open workspace past the limit was hidden behind Show more and no sidebar row was highlighted. selectSidebarGroupVisibility now takes the route selection. When the collapsed limit would hide the open workspace, it is returned as leadingItem and rendered first in project groups, status groups and both Pinned sections. Rows the group already shows stay in place, expanded groups keep group order, and drag lists render the leading row outside their sortable data, so a reorder never saves its borrowed position. Refs #8078 Co-Authored-By: Claude Opus 5.5 * test(app): keep shortcut targets visible in collapsed groups The navigation journey still required the selected overflow workspace to stay hidden. Assert its visible selection while preserving collapsed-group navigation and the seven ordinary rows. Co-authored-by: 0xRoy <1997roylee@gmail.com> --------- Co-authored-by: 0xRoy <1997roylee@gmail.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../app/e2e/browser/sidebar-reorder.spec.ts | 78 ++++++++++ .../workspace-relative-navigation.spec.ts | 20 ++- .../sidebar-workspace-list.test.tsx | 27 +++- .../src/components/sidebar-workspace-list.tsx | 126 ++++++++++------ .../sidebar/sidebar-group-visibility.test.ts | 137 ++++++++++++++++++ .../sidebar/sidebar-group-visibility.ts | 73 ++++++++-- .../sidebar/sidebar-status-list.tsx | 87 +++++++---- .../sidebar/use-limited-sidebar-group.ts | 25 +++- 8 files changed, 466 insertions(+), 107 deletions(-) create mode 100644 packages/app/src/components/sidebar/sidebar-group-visibility.test.ts diff --git a/packages/app/e2e/browser/sidebar-reorder.spec.ts b/packages/app/e2e/browser/sidebar-reorder.spec.ts index 56cdd3dc5a..cf885a1d14 100644 --- a/packages/app/e2e/browser/sidebar-reorder.spec.ts +++ b/packages/app/e2e/browser/sidebar-reorder.spec.ts @@ -426,3 +426,81 @@ test("Manual restores pre-beta order and keeps drag order across sorting and rel await seed.cleanup(); } }); + +function workspaceRows(page: Page, ids: string[]) { + return page.locator( + ids.map((id) => `[data-testid="sidebar-workspace-row-${getServerId()}:${id}"]`).join(", "), + ); +} + +async function openWorkspaceRow(page: Page, workspaceId: string) { + await page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`).click(); + await expect(page).toHaveURL(new RegExp(`/workspace/${workspaceId}`)); +} + +test("collapsed groups lead with the open workspace instead of hiding it behind Show more", async ({ + page, +}) => { + test.setTimeout(120_000); + const { seed, ids } = await seedCompactProject(); + try { + await gotoAppShell(page); + await waitForSidebarHydration(page); + await selectWorkspaceSort(page, "Created"); + const { rows, group } = compactProjectRows(page, seed); + await expectWorkspaceOrder(rows, ids.slice(0, 7)); + await revealWorkspaces(group, "Show more"); + await openWorkspaceRow(page, ids[9]!); + await expectWorkspaceOrder(rows, ids); + await revealWorkspaces(group, "Show less"); + await expectWorkspaceOrder(rows, [ids[9]!, ...ids.slice(0, 7)]); + await expect( + page.getByTestId(`sidebar-workspace-row-${getServerId()}:${ids[9]!}`), + ).toHaveAttribute("aria-selected", "true"); + await reconnectSidebar(page); + await expectWorkspaceOrder(rows, [ids[9]!, ...ids.slice(0, 7)]); + await expect( + page.getByTestId(`sidebar-workspace-row-${getServerId()}:${ids[9]!}`), + ).toHaveAttribute("aria-selected", "true"); + await openWorkspaceRow(page, ids[2]!); + await expectWorkspaceOrder(rows, ids.slice(0, 7)); + await expect( + page.getByTestId(`sidebar-workspace-row-${getServerId()}:${ids[2]!}`), + ).toHaveAttribute("aria-selected", "true"); + } finally { + await seed.cleanup(); + } +}); + +test("a Manual drag in a collapsed group keeps the open workspace's saved place", async ({ + page, +}) => { + test.setTimeout(120_000); + const { seed, ids } = await seedCompactProject(); + try { + await gotoAppShell(page); + await waitForSidebarHydration(page); + const projectKey = projectEquivalenceViewKey(seed.projectKey); + await restorePreBetaOrder( + page, + projectKey, + ids.map((id) => `${getServerId()}:${id}`), + ); + await selectWorkspaceSort(page, "Manual"); + const rows = workspaceRows(page, ids); + const toggle = page.getByTestId(`sidebar-project-show-more-${projectKey}`); + await expectWorkspaceOrder(rows, ids.slice(0, 7)); + await toggle.click(); + await openWorkspaceRow(page, ids[9]!); + await toggle.click(); + await expectWorkspaceOrder(rows, [ids[9]!, ...ids.slice(0, 7)]); + await quickDragFirstRowAfterSecond(workspaceRows(page, [ids[0]!, ids[1]!]), pressWorkspaceRow); + await expectWorkspaceOrder(rows, [ids[9]!, ids[1]!, ids[0]!, ...ids.slice(2, 7)]); + await reconnectSidebar(page); + await expectWorkspaceOrder(rows, [ids[9]!, ids[1]!, ids[0]!, ...ids.slice(2, 7)]); + await toggle.click(); + await expectWorkspaceOrder(rows, [ids[1]!, ids[0]!, ...ids.slice(2)]); + } finally { + await seed.cleanup(); + } +}); diff --git a/packages/app/e2e/browser/workspace-relative-navigation.spec.ts b/packages/app/e2e/browser/workspace-relative-navigation.spec.ts index 909672c489..5066411c78 100644 --- a/packages/app/e2e/browser/workspace-relative-navigation.spec.ts +++ b/packages/app/e2e/browser/workspace-relative-navigation.spec.ts @@ -70,25 +70,31 @@ test("previous and next traverse every visible workspace while digit shortcuts s workspaceId: order[8], }); }); - await test.step("hidden Done rows remain shortcut targets without expanding the project", async () => { - await expectHiddenWorkspaceShortcuts(page, order); + await test.step("collapsed groups keep shortcut targets selected without expanding", async () => { + await expectCollapsedWorkspaceShortcuts(page, order); }); } finally { await project.cleanup(); } }); -async function expectHiddenWorkspaceShortcuts(page: Page, order: string[]) { +async function expectCollapsedWorkspaceShortcuts(page: Page, order: string[]) { await page.getByLabel("Show less", { exact: true }).click(); const rows = page.getByRole("button", { name: /^Workspace \d{2}$/ }); - await expect(rows).toHaveCount(7); + await expect(rows).toHaveCount(8); + await expectSidebarWorkspaceSelected({ page, serverId: getServerId(), workspaceId: order[8] }); await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[0] }); + await expect(rows).toHaveCount(7); await page.keyboard.press("Alt+9"); await expect(page).toHaveURL(new RegExp(`/workspace/${order[8]}$`)); + await expectSidebarWorkspaceSelected({ page, serverId: getServerId(), workspaceId: order[8] }); + await expect(rows).toHaveCount(8); + await page.keyboard.press("Alt+]"); + await expect(page).toHaveURL(new RegExp(`/workspace/${order[9]}$`)); + await expectSidebarWorkspaceSelected({ page, serverId: getServerId(), workspaceId: order[9] }); + await expect(rows).toHaveCount(8); await expect(page.getByTestId(`sidebar-workspace-row-${getServerId()}:${order[8]}`)).toHaveCount( 0, ); - await page.keyboard.press("Alt+]"); - await expect(page).toHaveURL(new RegExp(`/workspace/${order[9]}$`)); - await expect(rows).toHaveCount(7); + await expect(page.getByLabel("Show more", { exact: true })).toBeVisible(); } diff --git a/packages/app/src/components/sidebar-workspace-list.test.tsx b/packages/app/src/components/sidebar-workspace-list.test.tsx index fc57dc2dce..9532c422dd 100644 --- a/packages/app/src/components/sidebar-workspace-list.test.tsx +++ b/packages/app/src/components/sidebar-workspace-list.test.tsx @@ -462,7 +462,7 @@ describe("sidebar group visibility", () => { it.each([0, 7, 8])("limits %i Done rows to seven", (count) => { const items = entries(Array(count).fill("done")); - const { result } = renderHook(() => useLimitedSidebarGroup(items, policy)); + const { result } = renderHook(() => useLimitedSidebarGroup({ items, selection: null, policy })); expect(result.current.visibleItems).toEqual(items.slice(0, 7)); expect(result.current.canToggle).toBe(count > 7); }); @@ -471,7 +471,9 @@ describe("sidebar group visibility", () => { "keeps row twelve visible for %s without filling gaps", (status) => { const items = entries([...Array(11).fill("done"), status]); - const { result } = renderHook(() => useLimitedSidebarGroup(items, policy)); + const { result } = renderHook(() => + useLimitedSidebarGroup({ items, selection: null, policy }), + ); expect(names(result.current.visibleItems)).toEqual([ "row-1", "row-2", @@ -492,7 +494,11 @@ describe("sidebar group visibility", () => { const hydrated = new Map(items.slice(0, 9).map((item) => [item.workspaceKey, item])); for (const entriesByKey of [new Map(), hydrated]) { const { result } = renderHook(() => - useLimitedSidebarGroup(items, createWorkspaceVisibilityPolicy(entriesByKey)), + useLimitedSidebarGroup({ + items, + selection: null, + policy: createWorkspaceVisibilityPolicy(entriesByKey), + }), ); expect(result.current.canToggle).toBe(true); expect(names(result.current.visibleItems)).toEqual( @@ -512,7 +518,7 @@ describe("sidebar group visibility", () => { "needs_input", "failed", ]); - const { result } = renderHook(() => useLimitedSidebarGroup(items, policy)); + const { result } = renderHook(() => useLimitedSidebarGroup({ items, selection: null, policy })); expect(result.current.visibleItems).toEqual(items); expect(result.current.canToggle).toBe(false); }); @@ -527,7 +533,7 @@ describe("sidebar group visibility", () => { "attention", ]); const { result, rerender, unmount } = renderHook( - ({ rows }) => useLimitedSidebarGroup(rows, policy), + ({ rows }) => useLimitedSidebarGroup({ items: rows, selection: null, policy }), { initialProps: { rows: items } }, ); expect(names(result.current.visibleItems)).toEqual([ @@ -568,7 +574,7 @@ describe("sidebar group visibility", () => { expect(result.current.expanded).toBe(false); act(() => result.current.toggleExpanded()); unmount(); - const remounted = renderHook(() => useLimitedSidebarGroup(items, policy)); + const remounted = renderHook(() => useLimitedSidebarGroup({ items, selection: null, policy })); expect(remounted.result.current.expanded).toBe(false); expect(remounted.result.current.visibleItems).toHaveLength(10); }); @@ -592,7 +598,12 @@ describe("sidebar group visibility", () => { lifecycle: "active" as const, }; const { result, rerender } = renderHook( - ({ last }) => useLimitedSidebarGroup([...items.slice(0, 11), last], policy), + ({ last }) => + useLimitedSidebarGroup({ + items: [...items.slice(0, 11), last], + selection: null, + policy, + }), { initialProps: { last: items[11] } }, ); expect(result.current.visibleItems).toHaveLength(7); @@ -630,7 +641,7 @@ describe("sidebar group visibility", () => { it("preserves the twenty-row pinned policy regardless of status", () => { const items = entries(Array(21).fill("running")); - const { result } = renderHook(() => useLimitedSidebarGroup(items)); + const { result } = renderHook(() => useLimitedSidebarGroup({ items, selection: null })); expect(result.current.visibleItems).toEqual(items.slice(0, 20)); expect(result.current.canToggle).toBe(true); act(() => result.current.toggleExpanded()); diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 333eb68221..8697a4b27b 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -1598,11 +1598,16 @@ function ProjectBlock({ [workspaceEntriesByKey], ); const { + leadingItem: leadingWorkspace, visibleItems: visibleWorkspaces, expanded: workspacesExpanded, canToggle: canToggleWorkspaces, toggleExpanded: toggleWorkspacesExpanded, - } = useLimitedSidebarGroup(project.workspaces, visibilityPolicy); + } = useLimitedSidebarGroup({ + items: project.workspaces, + selection: selectionEnabled ? activeWorkspaceSelection : null, + policy: visibilityPolicy, + }); const activeServerId = activeWorkspaceSelection?.serverId ?? null; const activeHostStatus = useHostRuntimeConnectionStatus(activeServerId ?? ""); const preferredServerId = activeHostStatus === "online" ? activeServerId : null; @@ -1761,28 +1766,33 @@ function ProjectBlock({ let projectChildren = null; if (!collapsed) { if (project.workspaces.length > 0) { + const leadingWorkspaceRow = leadingWorkspace ? renderWorkspaceRow(leadingWorkspace) : null; projectChildren = ( <> {manualSort ? ( - + <> + {leadingWorkspaceRow} + + ) : ( + {leadingWorkspaceRow} {visibleWorkspaces.map((item) => renderWorkspaceRow(item))} )} @@ -2188,11 +2198,15 @@ function ProjectModeList({ const activeWorkspaceSelection = useActiveWorkspaceSelection(); const { pinnedChats, unpinnedProjects } = pinnedGroups; const { + leadingItem: leadingPinnedChat, visibleItems: visiblePinnedChats, expanded: pinnedChatsExpanded, canToggle: canTogglePinnedChats, toggleExpanded: togglePinnedChatsExpanded, - } = useLimitedSidebarGroup(pinnedChats); + } = useLimitedSidebarGroup({ + items: pinnedChats, + selection: selectionEnabled ? activeWorkspaceSelection : null, + }); const nativeScrollGestureProps = useMemo( () => parentGestureRef @@ -2395,37 +2409,38 @@ function ProjectModeList({ [renderProjectBlock], ); - const renderPinnedChat = useCallback( - ({ - item: workspace, - drag, - isActive, - dragHandleProps, - }: DraggableRenderItemInfo) => { - return ( - - ); - }, + const renderPinnedChatRow = useCallback( + ( + workspace: SidebarWorkspacePlacement, + input?: { + drag?: () => void; + isDragging?: boolean; + dragHandleProps?: DraggableListDragHandleProps; + }, + ) => ( + + ), [ activeWorkspaceSelection, creatingWorkspaceIds, @@ -2441,6 +2456,22 @@ function ProjectModeList({ ], ); + const renderPinnedChat = useCallback( + ({ + item: workspace, + drag, + isActive, + dragHandleProps, + }: DraggableRenderItemInfo) => { + return renderPinnedChatRow(workspace, { + drag, + isDragging: isActive, + dragHandleProps, + }); + }, + [renderPinnedChatRow], + ); + const projectBody = projects.length === 0 ? ( @@ -2473,6 +2504,7 @@ function ProjectModeList({ /> {pinnedCollapsed ? null : ( <> + {leadingPinnedChat ? renderPinnedChatRow(leadingPinnedChat) : null} ({ + id: index + 1, + serverId: rowServerId, + workspaceId: `workspace-${index + 1}`, + statusBucket, + })); +} + +const workspacePolicy: SidebarGroupVisibilityPolicy = { + kind: "workspaces", + getStatusBucket: (item) => item.statusBucket, +}; + +function selectionFor(item: SidebarGroupItem): ActiveWorkspaceSelection { + return { serverId: item.serverId, workspaceId: item.workspaceId }; +} + +describe("selectSidebarGroupVisibility", () => { + it("leads with a hidden open row while keeping the baseline order", () => { + const items = rows(Array(9).fill("done")); + const result = selectSidebarGroupVisibility({ + items, + expanded: false, + policy: workspacePolicy, + selection: selectionFor(items[8]!), + }); + + expect(result.leadingItem).toBe(items[8]); + expect(result.visibleItems).toEqual(items.slice(0, 7)); + expect(result.canToggle).toBe(true); + }); + + it("leaves selected rows in place when they are already in the baseline", () => { + const items = rows([...Array(8).fill("done"), "running", "done"]); + const expected = [...items.slice(0, 7), items[8]]; + + for (const selectedIndex of [2, 8]) { + const result = selectSidebarGroupVisibility({ + items, + expanded: false, + policy: workspacePolicy, + selection: selectionFor(items[selectedIndex]!), + }); + + expect(result.leadingItem).toBeNull(); + expect(result.visibleItems).toEqual(expected); + expect(result.canToggle).toBe(true); + } + }); + + it("shows the whole group without a toggle when the open row is the only hidden row", () => { + const items = rows(Array(8).fill("done")); + const result = selectSidebarGroupVisibility({ + items, + expanded: false, + policy: workspacePolicy, + selection: selectionFor(items[7]!), + }); + + expect(result.leadingItem).toBeNull(); + expect(result.visibleItems).toEqual(items); + expect(result.canToggle).toBe(false); + }); + + it("keeps expanded groups in their original order", () => { + const items = rows(Array(9).fill("done")); + const result = selectSidebarGroupVisibility({ + items, + expanded: true, + policy: workspacePolicy, + selection: selectionFor(items[8]!), + }); + + expect(result.leadingItem).toBeNull(); + expect(result.visibleItems).toEqual(items); + expect(result.canToggle).toBe(true); + }); + + it("applies the twenty-row limit to pinned groups", () => { + const items = rows(Array(22).fill("done")); + const result = selectSidebarGroupVisibility({ + items, + expanded: false, + policy: { kind: "pinned" }, + selection: selectionFor(items[21]!), + }); + + expect(result.leadingItem).toBe(items[21]); + expect(result.visibleItems).toEqual(items.slice(0, 20)); + expect(result.canToggle).toBe(true); + }); + + it("preserves the current limit when selection is null", () => { + const items = rows(Array(8).fill("done")); + const result = selectSidebarGroupVisibility({ + items, + expanded: false, + policy: workspacePolicy, + selection: null, + }); + + expect(result.leadingItem).toBeNull(); + expect(result.visibleItems).toEqual(items.slice(0, 7)); + expect(result.canToggle).toBe(true); + }); + + it("matches the open row by server and workspace identity", () => { + const items = rows(Array(9).fill("done"), "host-b"); + const result = selectSidebarGroupVisibility({ + items, + expanded: false, + policy: workspacePolicy, + selection: { serverId: serverId, workspaceId: items[8]!.workspaceId }, + }); + + expect(result.leadingItem).toBeNull(); + expect(result.visibleItems).toEqual(items.slice(0, 7)); + expect(result.canToggle).toBe(true); + }); +}); diff --git a/packages/app/src/components/sidebar/sidebar-group-visibility.ts b/packages/app/src/components/sidebar/sidebar-group-visibility.ts index 1bc3018e9a..f109f0114a 100644 --- a/packages/app/src/components/sidebar/sidebar-group-visibility.ts +++ b/packages/app/src/components/sidebar/sidebar-group-visibility.ts @@ -1,4 +1,28 @@ import type { SidebarStateBucket } from "@/hooks/sidebar-workspaces-view-model"; +import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection"; + +export interface SidebarGroupItem { + serverId: string; + workspaceId: string; +} + +export interface SidebarGroupVisibilityInput { + items: readonly T[]; + expanded: boolean; + policy: SidebarGroupVisibilityPolicy; + /** The workspace the group's rows highlight as selected. Null when no row can be selected. */ + selection: ActiveWorkspaceSelection | null; +} + +export interface SidebarGroupVisibility { + /** + * The open workspace when the collapsed limit would hide it. Rendered before `visibleItems`, + * out of group order, outside any drag list. Never also present in `visibleItems`. + */ + leadingItem: T | null; + visibleItems: T[]; + canToggle: boolean; +} export type SidebarGroupVisibilityPolicy = | { kind: "pinned" } @@ -16,18 +40,45 @@ export function createWorkspaceVisibilityPolicy( - items: readonly T[], - expanded: boolean, - policy: SidebarGroupVisibilityPolicy, -) { - const limit = policy.kind === "pinned" ? 20 : 7; - const baseline = items.filter( - (item, index) => - index < limit || (policy.kind === "workspaces" && policy.getStatusBucket(item) !== "done"), +function isOpenWorkspace( + item: SidebarGroupItem, + selection: ActiveWorkspaceSelection | null, +): boolean { + return ( + selection !== null && + item.serverId === selection.serverId && + item.workspaceId === selection.workspaceId ); +} + +export function selectSidebarGroupVisibility( + input: SidebarGroupVisibilityInput, +): SidebarGroupVisibility { + const { items, expanded, policy, selection } = input; + const limit = policy.kind === "pinned" ? 20 : 7; + const baseline: T[] = []; + let leadingItem: T | null = null; + let canToggle = false; + + items.forEach((item, index) => { + const inBaseline = + index < limit || (policy.kind === "workspaces" && policy.getStatusBucket(item) !== "done"); + if (inBaseline) { + baseline.push(item); + } else if (leadingItem === null && isOpenWorkspace(item, selection)) { + leadingItem = item; + } else { + canToggle = true; + } + }); + + if (expanded || !canToggle) { + return { leadingItem: null, visibleItems: items.slice(), canToggle }; + } + return { - visibleItems: expanded ? items.slice() : baseline, - canToggle: baseline.length < items.length, + leadingItem, + visibleItems: baseline, + canToggle, }; } diff --git a/packages/app/src/components/sidebar/sidebar-status-list.tsx b/packages/app/src/components/sidebar/sidebar-status-list.tsx index 44622385a4..ce8ad535c4 100644 --- a/packages/app/src/components/sidebar/sidebar-status-list.tsx +++ b/packages/app/src/components/sidebar/sidebar-status-list.tsx @@ -147,6 +147,7 @@ export function SidebarStatusWorkspaceList({ dragGestureHostActive, }: StatusWorkspaceListProps) { const { t } = useTranslation(); + const activeWorkspaceSelection = useActiveWorkspaceSelection(); const collapsedWorkspaceGroupKeys = useSidebarCollapsedSectionsStore( (state) => state.collapsedWorkspaceGroupKeys, ); @@ -155,23 +156,27 @@ export function SidebarStatusWorkspaceList({ (state) => state.togglePinnedCollapsed, ); const { + leadingItem: leadingPinnedWorkspace, visibleItems: visiblePinnedWorkspaces, expanded: pinnedWorkspacesExpanded, canToggle: canTogglePinnedWorkspaces, toggleExpanded: togglePinnedWorkspacesExpanded, - } = useLimitedSidebarGroup(pinnedWorkspaces); + } = useLimitedSidebarGroup({ items: pinnedWorkspaces, selection: activeWorkspaceSelection }); const statusShortcutIndex = showShortcutBadges ? shortcutIndexByWorkspaceKey : EMPTY_SHORTCUT_INDEX; - const renderPinnedWorkspace = useCallback( - ({ - item: workspace, - drag, - isActive, - dragHandleProps, - }: DraggableRenderItemInfo) => ( + const renderPinnedWorkspaceRow = useCallback( + ( + workspace: SidebarWorkspaceEntry, + input?: { + drag?: () => void; + isDragging?: boolean; + dragHandleProps?: DraggableListDragHandleProps; + }, + ) => ( ), [ @@ -199,6 +204,20 @@ export function SidebarStatusWorkspaceList({ supportsPinningByServerId, ], ); + const renderPinnedWorkspace = useCallback( + ({ + item: workspace, + drag, + isActive, + dragHandleProps, + }: DraggableRenderItemInfo) => + renderPinnedWorkspaceRow(workspace, { + drag, + isDragging: isActive, + dragHandleProps, + }), + [renderPinnedWorkspaceRow], + ); const content = ( <> {pinnedWorkspaces.length > 0 ? ( @@ -211,6 +230,7 @@ export function SidebarStatusWorkspaceList({ /> {pinnedCollapsed ? null : ( <> + {leadingPinnedWorkspace ? renderPinnedWorkspaceRow(leadingPinnedWorkspace) : null} ; onToggleWorkspacePin: ToggleSidebarWorkspacePin; }) { + const activeWorkspaceSelection = useActiveWorkspaceSelection(); const { + leadingItem: leadingWorkspace, visibleItems: visibleWorkspaces, expanded: workspacesExpanded, canToggle: canToggleWorkspaces, toggleExpanded: toggleWorkspacesExpanded, - } = useLimitedSidebarGroup(group.rows, STATUS_GROUP_VISIBILITY); + } = useLimitedSidebarGroup({ + items: group.rows, + selection: activeWorkspaceSelection, + policy: STATUS_GROUP_VISIBILITY, + }); + + function renderWorkspaceRow(workspace: SidebarWorkspaceEntry) { + return ( + + ); + } return ( @@ -360,22 +405,8 @@ function StatusGroupRows({ style={styles.statusWorkspaceListContainer} testID={`sidebar-status-group-rows-${group.key}`} > - {visibleWorkspaces.map((workspace) => ( - - ))} + {leadingWorkspace ? renderWorkspaceRow(leadingWorkspace) : null} + {visibleWorkspaces.map(renderWorkspaceRow)} {canToggleWorkspaces ? ( ( - items: readonly T[], - policy: SidebarGroupVisibilityPolicy = PINNED_POLICY, -) { +interface LimitedSidebarGroupInput { + items: readonly T[]; + selection: ActiveWorkspaceSelection | null; + policy?: SidebarGroupVisibilityPolicy; +} + +export function useLimitedSidebarGroup({ + items, + selection, + policy = PINNED_POLICY, +}: LimitedSidebarGroupInput): SidebarGroupVisibility & { + expanded: boolean; + toggleExpanded: () => void; +} { const [expanded, setExpanded] = useState(false); const visibility = useMemo( - () => selectSidebarGroupVisibility(items, expanded, policy), - [expanded, items, policy], + () => selectSidebarGroupVisibility({ items, expanded, policy, selection }), + [expanded, items, policy, selection], ); const toggleExpanded = useCallback(() => setExpanded((current) => !current), []); From caf9300358ea40a7adf5c888d1b2f5a3cc8bc4c9 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 20:52:07 +0000 Subject: [PATCH 14/58] fix(server): preserve running status after agent creation (#8396) * fix(server): forward a created agent's current state, not its pre-prompt copy create_agent_request forwarded the snapshot createAgent returned when registration finished, after the initial prompt had already published the agent as running. Subscribers received that idle copy last, with an older updatedAt, and providers that report nothing until the turn ends left the agent listed as idle for the whole turn. Forward the agent manager's current state instead. Closes #8022 * test(server): drain agent persistence before fixture cleanup --------- Co-authored-by: luke Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- packages/server/package.json | 2 +- .../daemon-e2e/creation-status.e2e.test.ts | 64 +++++++++ packages/server/src/server/session.ts | 5 +- .../src/server/session.workspaces.test.ts | 130 ++++++++++++++++++ 4 files changed, 199 insertions(+), 2 deletions(-) create mode 100644 packages/server/src/server/daemon-e2e/creation-status.e2e.test.ts diff --git a/packages/server/package.json b/packages/server/package.json index 0fcbacaf24..0feadfe9cd 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -89,7 +89,7 @@ "speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts", "test": "npm run test:unit && npm run test:integration", "test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"", - "test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/usage-sources.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"", + "test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/creation-status.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/usage-sources.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"", "test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts", "test:integration:all": "npm run test:e2e", "test:integration:real": "vitest run real.e2e.test.ts", diff --git a/packages/server/src/server/daemon-e2e/creation-status.e2e.test.ts b/packages/server/src/server/daemon-e2e/creation-status.e2e.test.ts new file mode 100644 index 0000000000..c2293a2bc7 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/creation-status.e2e.test.ts @@ -0,0 +1,64 @@ +import { expect, test } from "vitest"; +import { createDaemonTestContext } from "../test-utils/index.js"; +import { MockLoadTestAgentClient } from "../agent/providers/mock-load-test-agent.js"; +import type { AgentSnapshotPayload } from "../messages.js"; + +test("creation never regresses a running subscription to the pre-prompt idle snapshot", async () => { + const ctx = await createDaemonTestContext({ + agentClients: { mock: new MockLoadTestAgentClient() }, + mcpEnabled: false, + isDev: true, + }); + try { + const workspace = await ctx.client.createWorkspace({ + source: { kind: "directory", path: ctx.daemon.staticDir }, + }); + if (!workspace.workspace) throw new Error(workspace.error ?? "Missing workspace"); + const observations: { + list: string; + kind: string; + agent?: AgentSnapshotPayload; + agentId?: string; + }[] = []; + for (const [list, statuses] of [ + ["running", ["running", "initializing", "error"]], + ["done", ["idle", "closed"]], + ] as const) { + const result = await ctx.client.fetchAgents({ + filter: { statuses: [...statuses] }, + subscribe: {}, + }); + result.subscription.subscribe({ + snapshot() {}, + update(message) { + observations.push({ list, ...message.payload }); + }, + }); + } + const agent = await ctx.client.createAgent({ + workspaceId: workspace.workspace.id, + provider: "mock", + cwd: ctx.daemon.staticDir, + initialPrompt: "Work on this task", + }); + await expect + .poll(() => observations.filter((row) => row.agent?.id === agent.id).length) + .toBeGreaterThan(1); + // Fetching provides a round trip after creation so its queued update reaches the client. + expect((await ctx.client.fetchAgent({ agentId: agent.id }))?.agent.status).toBe("running"); + const relevant = observations.filter( + (row) => row.agent?.id === agent.id || row.agentId === agent.id, + ); + const firstRunning = relevant.findIndex((row) => row.agent?.status === "running"); + expect(firstRunning).toBeGreaterThanOrEqual(0); + expect( + relevant.slice(firstRunning).filter((row) => row.list === "done" && row.kind === "upsert"), + ).toEqual([]); + const timestamps = relevant.flatMap((row) => + row.agent ? [Date.parse(row.agent.updatedAt)] : [], + ); + expect(timestamps).toEqual([...timestamps].sort((a, b) => a - b)); + } finally { + await ctx.cleanup(); + } +}, 60_000); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 42c676b50b..00c8529482 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -4512,7 +4512,10 @@ export class Session { }, ); createdAgentId = snapshot.id; - await this.agentUpdates.forwardLiveAgent(snapshot); + // `snapshot` predates the initial prompt; subscribers keep the last state they receive. + await this.agentUpdates.forwardLiveAgent( + this.agentManager.getAgent(snapshot.id) ?? liveSnapshot, + ); if (resolvedIntent.createdDirectoryWorkspace && trimmedPrompt) { this.workspaceAutoName.scheduleForDirectory( { diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 9e73c9cad4..343c0a8843 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -1393,6 +1393,136 @@ test("create_agent_request does not title an existing workspace from the agent p } }); +test("create_agent_request does not publish the pre-prompt snapshot after the prompt starts", async () => { + vi.useFakeTimers(); + const workdir = mkdtempSync(path.join(tmpdir(), "paseo-create-agent-live-state-")); + const logger = createTestLogger(); + const agentStorage = new AgentStorage(path.join(workdir, "agents"), asSessionLogger(logger)); + // The test session's turn starts and then reports nothing, like a provider + // that stays quiet until its turn ends. + const agentManager = new AgentManager({ + clients: { codex: new CreateAgentTestClient() }, + registry: agentStorage, + logger: asSessionLogger(logger), + idFactory: () => "00000000-0000-4000-8000-000000000553", + }); + try { + const cwd = path.join(workdir, "repo"); + mkdirSync(cwd, { recursive: true }); + + const projectRegistry = new FileBackedProjectRegistry( + path.join(workdir, "projects.json"), + asSessionLogger(logger), + ); + const workspaceRegistry = new FileBackedWorkspaceRegistry( + path.join(workdir, "workspaces.json"), + asSessionLogger(logger), + ); + + await projectRegistry.upsert( + createPersistedProjectRecord({ + projectId: "proj-live-state", + rootPath: cwd, + kind: "git", + displayName: "repo", + createdAt: "2026-05-07T00:00:00.000Z", + updatedAt: "2026-05-07T00:00:00.000Z", + }), + ); + await workspaceRegistry.upsert( + createPersistedWorkspaceRecord({ + workspaceId: "ws-live-state", + projectId: "proj-live-state", + cwd, + kind: "local_checkout", + displayName: "repo", + title: null, + createdAt: "2026-05-07T00:00:00.000Z", + updatedAt: "2026-05-07T00:00:00.000Z", + }), + ); + + const agentUpdates: Extract["payload"][] = []; + const session = asTestSession( + new Session({ + workspaceRuntimeStore: createWorkspaceRuntimeStore(), + messageReceipts: createMessageReceiptsStub(), + agentMessages: createAgentMessageDeliveryStub(), + creationService: createTestCreationService(), + clientId: "test-client", + permissions: OWNER_PERMISSIONS, + appVersion: null, + onMessage: (message) => { + if (message.type === "agent_update") agentUpdates.push(message.payload); + }, + logger: asSessionLogger(logger), + downloadTokenStore: asDownloadTokenStore(), + pushNotifications: asPushNotifications(), + paseoHome: path.join(workdir, "paseo-home"), + agentManager, + agentStorage, + projectRegistry, + workspaceRegistry, + scheduleService: asScheduleService(), + checkoutDiffManager: asCheckoutDiffManager({ + subscribe: async () => ({ + initial: { cwd, files: [], error: null }, + unsubscribe: () => {}, + }), + scheduleRefreshForCwd: () => {}, + onWorkspaceStateMayHaveChanged: () => {}, + invalidateForge: () => {}, + getMetrics: () => ({ + checkoutDiffTargetCount: 0, + checkoutDiffSubscriptionCount: 0, + checkoutDiffWatcherCount: 0, + checkoutDiffFallbackRefreshTargetCount: 0, + }), + dispose: () => {}, + }), + workspaceGitService: createNoopWorkspaceGitService(), + daemonConfigStore: asDaemonConfigStore({ + get: () => ({ mcp: { injectIntoAgents: false }, providers: {} }), + onChange: () => () => {}, + }), + mcpBaseUrl: null, + stt: null, + tts: null, + generateWorkspaceName: async () => null, + providerSnapshotManager: createProviderSnapshotManagerStub().manager, + terminalManager: null, + }), + ); + await activateAgentUpdatesSubscription(session, "sub-live-state"); + + await session.handleMessage({ + type: "create_agent_request", + requestId: "req-create-live-state", + workspaceId: "ws-live-state", + config: { provider: "codex", cwd }, + initialPrompt: "Print the report", + attachments: [], + }); + await vi.runAllTimersAsync(); + + const [createdAgent] = agentManager.listAgents(); + expect(createdAgent?.lifecycle).toBe("running"); + const upserts = agentUpdates.flatMap((update) => + update.kind === "upsert" && update.agent.id === createdAgent?.id ? [update.agent] : [], + ); + expect(upserts.length).toBeGreaterThan(0); + // A client keeps the last update it hears, so the latest one must be the running agent. + expect(upserts.at(-1)).toMatchObject({ status: "running" }); + const updatedAt = upserts.map((agent) => Date.parse(agent.updatedAt)); + expect(updatedAt).toEqual([...updatedAt].sort((left, right) => left - right)); + } finally { + // Drain snapshot writes before removing their storage directory. + await agentManager.flush(); + vi.useRealTimers(); + rmSync(workdir, { recursive: true, force: true }); + } +}); + test("unsupported persisted agents are excluded from active lists but preserved in history payloads", async () => { const session = createSessionForWorkspaceTests({ appVersion: "0.1.45" }); const storedRecord = { From 7d9741ef778b9241b5f7ec2f841737ab74327d11 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:50:27 +0000 Subject: [PATCH 15/58] fix(app): expose the SSL checkbox checked state on web (#8399) The web renderer does not map accessibilityState.checked onto aria-checked. Supply the checked property explicitly so accessibility clients agree with the checkmark and connection URI. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../app/e2e/browser/settings-navigation.spec.ts | 13 +++++++++++++ packages/app/e2e/support/helpers/settings.ts | 15 +++++++++++++++ packages/app/src/components/add-host-modal.tsx | 1 + 3 files changed, 29 insertions(+) diff --git a/packages/app/e2e/browser/settings-navigation.spec.ts b/packages/app/e2e/browser/settings-navigation.spec.ts index 3ec921a663..aa381f1348 100644 --- a/packages/app/e2e/browser/settings-navigation.spec.ts +++ b/packages/app/e2e/browser/settings-navigation.spec.ts @@ -38,6 +38,9 @@ import { fillDirectHostUri, expectDirectHostFormValues, expectDirectHostSslEnabled, + expectDirectHostSslDisabled, + fillDirectHostAddress, + toggleDirectHostSsl, expectDirectHostUriValue, expectDirectHostUriHidden, expectDiagnosticsContent, @@ -284,6 +287,16 @@ test.describe("Settings sidebar navigation", () => { await expectAddHostMethodOptions(page); await selectHostConnectionType(page, "direct"); + await fillDirectHostAddress(page, "example.paseo.test", "7443"); + await expectDirectHostSslDisabled(page); + await toggleDirectHostSsl(page); + await expectDirectHostSslEnabled(page); + await toggleHostAdvanced(page); + await expectDirectHostUriValue(page, "tcp://example.paseo.test:7443?ssl=true"); + await toggleHostAdvanced(page); + await toggleDirectHostSsl(page); + await expectDirectHostSslDisabled(page); + await toggleHostAdvanced(page); await fillDirectHostUri(page, "tcp://example.paseo.test:7443?ssl=true&password=shared-secret"); await toggleHostAdvanced(page); diff --git a/packages/app/e2e/support/helpers/settings.ts b/packages/app/e2e/support/helpers/settings.ts index a2befccfbf..796d0b45f5 100644 --- a/packages/app/e2e/support/helpers/settings.ts +++ b/packages/app/e2e/support/helpers/settings.ts @@ -351,9 +351,24 @@ export async function expectDirectHostFormValues( } export async function expectDirectHostSslEnabled(page: Page): Promise { + await expect(page.getByRole("checkbox", { name: "Use SSL", exact: true })).toBeChecked(); await expect(page.getByTestId("direct-ssl-toggle-checked")).toBeVisible(); } +export async function fillDirectHostAddress(page: Page, host: string, port: string): Promise { + await page.getByRole("textbox", { name: "Host", exact: true }).fill(host); + await page.getByRole("textbox", { name: "Port", exact: true }).fill(port); +} + +export async function toggleDirectHostSsl(page: Page): Promise { + await page.getByRole("checkbox", { name: "Use SSL", exact: true }).click(); +} + +export async function expectDirectHostSslDisabled(page: Page): Promise { + await expect(page.getByRole("checkbox", { name: "Use SSL", exact: true })).not.toBeChecked(); + await expect(page.getByTestId("direct-ssl-toggle-checked")).toHaveCount(0); +} + export async function expectDirectHostUriValue(page: Page, uri: string): Promise { await expect(page.getByTestId("direct-host-uri-input")).toHaveValue(uri); } diff --git a/packages/app/src/components/add-host-modal.tsx b/packages/app/src/components/add-host-modal.tsx index aa0c65b638..5d8fc90cff 100644 --- a/packages/app/src/components/add-host-modal.tsx +++ b/packages/app/src/components/add-host-modal.tsx @@ -476,6 +476,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod accessibilityRole="checkbox" accessibilityLabel={t("pairing.direct.fields.useSsl")} accessibilityState={useTlsAccessibilityState} + aria-checked={useTls} testID="direct-ssl-toggle" > From 2d7adfc450bd41a3771bbaf51a3d797165da6065 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 23:58:14 +0000 Subject: [PATCH 16/58] fix(app): restore directory subscriptions after demand returns (#8403) Invalidate refresh satisfaction when subscriptions are released, and retry if demand returns before the overtaken refresh completes. Otherwise the same connection source can stay satisfied with no live list listeners. Cover both demand sources and completion orderings with live delta checks. Co-authored-by: Okan YILDIRIM <63511519+ragokan@users.noreply.github.com> --- .../src/runtime/directory-sync/index.test.ts | 96 ++++++++++++++++++- .../app/src/runtime/directory-sync/index.ts | 17 ++-- 2 files changed, 103 insertions(+), 10 deletions(-) diff --git a/packages/app/src/runtime/directory-sync/index.test.ts b/packages/app/src/runtime/directory-sync/index.test.ts index 6fae0a7e92..e4db5dfb71 100644 --- a/packages/app/src/runtime/directory-sync/index.test.ts +++ b/packages/app/src/runtime/directory-sync/index.test.ts @@ -16,7 +16,8 @@ import { normalizeWorkspaceDescriptor, useSessionStore, } from "@/stores/session-store"; -import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; +import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snapshots"; +import { deriveProjectPlacementFromCwd } from "@/utils/project-placement"; import { selectWorkspaceDirectoryServerIds } from "@/stores/session-store-hooks/selectors"; import { useWorkspaceLabels } from "@/workspace-labels"; import type { DirectoryReplicaMutation } from "@/runtime/replica-cache"; @@ -41,12 +42,15 @@ class FakeDirectoryClient { supportsWorkspaceLabels = false; listWorkspaceLabelsCalls = 0; fetchAgentsCalls = 0; + observeAgentsCalls = 0; lastAgentOptions: unknown; fetchWorkspacesCalls = 0; + observeWorkspacesCalls = 0; lastWorkspaceOptions: unknown; listProjectsCalls = 0; lastProjectOptions: unknown; projectResult: ProjectListResult | null = null; + readonly listSnapshots: Promise[] = []; private pendingAgentFetch: Promise | null = null; private pendingWorkspaceFetch: Promise | null = null; private pendingProjectFetch: Promise | null = null; @@ -151,15 +155,22 @@ class FakeDirectoryClient { } observeAgents(options: Parameters[0]) { - return subscriptionFixture(this.fetchAgents({ ...options, subscribe: {} }), (receive) => - this.on("agent_update", receive), + this.observeAgentsCalls += 1; + const subscription = subscriptionFixture( + this.fetchAgents({ ...options, subscribe: {} }), + (receive) => this.on("agent_update", receive), ); + this.listSnapshots.push(subscription.ready); + return subscription; } observeWorkspaces(options: Parameters[0]) { - return subscriptionFixture(this.fetchWorkspaces(options), (receive) => + this.observeWorkspacesCalls += 1; + const subscription = subscriptionFixture(this.fetchWorkspaces(options), (receive) => this.on("workspace_update", receive), ); + this.listSnapshots.push(subscription.ready); + return subscription; } observeEvents(events: readonly SessionOutboundMessage["type"][]) { @@ -592,6 +603,83 @@ describe("DirectorySync session readiness", () => { directory.dispose(); }); + it.each([ + ["full", "before"], + ["full", "after"], + ["route", "before"], + ["route", "after"], + ] as const)( + "restores live directory updates when %s demand returns %s refresh completion", + async (demand, timing) => { + const serverId = `released-directory-${demand}-${timing}`; + const { client, directory } = createDirectory(serverId); + const store = useSessionStore.getState(); + store.initializeSession(serverId, client as unknown as DaemonClient, 1); + store.updateSessionServerInfo(serverId, { + serverId, + hostname: null, + version: "test", + features: { workspaceMultiplicity: true, projectList: true }, + }); + const source = {}; + function setDemand(demanded: boolean): void { + if (demand === "full") directory.setDemand(source, demanded); + else directory.setAgentRouteDemand(demanded ? ["agent-1"] : []); + } + const releaseProjects = client.holdProjectFetch(); + const projects: ProjectListResult = { requestId: "projects", projects: [] }; + + try { + setDemand(true); + const refresh = directory.refreshDemand(); + await expect + .poll(() => [client.observeAgentsCalls, client.observeWorkspacesCalls]) + .toEqual([1, 1]); + + await Promise.all(client.listSnapshots); + setDemand(false); + if (timing === "before") setDemand(true); + releaseProjects(projects); + await refresh; + if (timing === "after") setDemand(true); + + await expect + .poll(() => [client.observeAgentsCalls, client.observeWorkspacesCalls]) + .toEqual([2, 2]); + client.emit({ + type: "agent_update", + payload: { + kind: "upsert", + agent: { + ...projectAgentSnapshot(createAgent(serverId, "agent-1")), + title: "Live title", + }, + project: deriveProjectPlacementFromCwd("/repo"), + }, + }); + client.emit({ + type: "workspace_update", + payload: { + kind: "upsert", + workspace: { ...createWorkspaceEntry("workspace-1"), name: "Live workspace" }, + }, + }); + + await expect + .poll(() => { + const session = useSessionStore.getState().sessions[serverId]; + return [ + session.agents.get("agent-1")?.title, + session.workspaces.get("workspace-1")?.name, + ]; + }) + .toEqual(["Live title", "Live workspace"]); + } finally { + directory.dispose(); + } + }, + ); + it("publishes cached directory data before the authoritative request completes", async () => { const serverId = "cached-directory"; serverIds.add(serverId); diff --git a/packages/app/src/runtime/directory-sync/index.ts b/packages/app/src/runtime/directory-sync/index.ts index b59440d782..1ffb4fae5f 100644 --- a/packages/app/src/runtime/directory-sync/index.ts +++ b/packages/app/src/runtime/directory-sync/index.ts @@ -160,6 +160,7 @@ export class DirectorySync { private readonly fullDemandSources = new Set(); private readonly scriptStatusDemand = new Map(); private demandRefresh: Promise | null = null; + private subscriptionGeneration = 0; private satisfiedDemandSource: DirectorySourceToken | null = null; private cursors: DirectoryCheckpoint = {}; @@ -262,6 +263,7 @@ export class DirectorySync { } private releaseSubscriptions(): void { + this.subscriptionGeneration += 1; for (const subscription of [ this.agentSubscription, this.workspaceSubscription, @@ -362,6 +364,7 @@ export class DirectorySync { this.observeDirectoryEvents(); this.observeScriptStatusEvents(); const source = this.connection.source; + const subscriptionGeneration = this.subscriptionGeneration; if ( !force && this.satisfiedDemandSource?.clientGeneration === source.clientGeneration && @@ -378,17 +381,19 @@ export class DirectorySync { ]).then(() => undefined); this.demandRefresh = refresh .then(() => { - this.satisfiedDemandSource = source; + if (this.subscriptionGeneration === subscriptionGeneration) { + this.satisfiedDemandSource = source; + } return undefined; }) .finally(() => { this.demandRefresh = null; const current = this.connection.source; - if ( - this.hasDemand() && - (current.clientGeneration !== source.clientGeneration || - current.connectionEpoch !== source.connectionEpoch) - ) { + const sourceChanged = + current.clientGeneration !== source.clientGeneration || + current.connectionEpoch !== source.connectionEpoch; + const subscriptionsReleased = this.subscriptionGeneration !== subscriptionGeneration; + if (this.hasDemand() && (sourceChanged || subscriptionsReleased)) { void this.requestDemandRefresh().catch(() => undefined); } }); From 5afa1a9f8ef4b069ff84278668c1fd030df42273 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:01:29 +0000 Subject: [PATCH 17/58] test(server): isolate cold Git filter from control work (#8397) Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- ...rkspace-git-service.observation.integration.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/server/src/server/workspace-git-service.observation.integration.test.ts b/packages/server/src/server/workspace-git-service.observation.integration.test.ts index c8d97390ed..371502748d 100644 --- a/packages/server/src/server/workspace-git-service.observation.integration.test.ts +++ b/packages/server/src/server/workspace-git-service.observation.integration.test.ts @@ -583,16 +583,21 @@ test("Git refresh, command tracing and 10,000 ignored alias events do no synchro // the cold filter. Allocations made by the filter still count. global.gc(); await tick(); - // Unrelated callbacks must not consume the synchronous filter's budget. + await marker('FILTER_START'); + // Schedule the control only after marker IO settles, so it cannot run + // and allocate garbage before the cold filter starts. No await may + // separate this scheduling from the synchronous measurement. const threadCpuUsage = Reflect.get(process, 'threadCpuUsage'); + let unrelatedWorkStarted = false; const unrelatedWork = tick().then(() => { + unrelatedWorkStarted = true; const started = threadCpuUsage(); while (true) { const cpu = threadCpuUsage(started); if (cpu.user + cpu.system >= 80000) break; } }); - await marker('FILTER_START'); + assert.equal(unrelatedWorkStarted, false, 'control work must not contaminate the cold filter'); hook.enable(); let blockingMs; try { @@ -609,6 +614,7 @@ test("Git refresh, command tracing and 10,000 ignored alias events do no synchro await marker('FILTER_END'); } await unrelatedWork; + assert.equal(unrelatedWorkStarted, true, 'control work must run outside the filter span'); assert.equal(fsCalls, 0, "filesystem calls"); assert.equal(notifications, 0, "ignored event notification"); assert.ok(blockingMs < 20, 'blocking filter work: ' + blockingMs); From 86c60d367c9dcc2edbfee29a06ee49ab6b77f28f Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:05:02 +0000 Subject: [PATCH 18/58] fix(claude): keep a resumed subagent on its row after the session reopens (#8408) SendMessage resumes a finished Claude subagent under its original task_id but a new tool_use_id, and the child's frames still name the original Task call as their parent. In the same process the live source already maps the new tool id onto the original row. After the session reopens, the child is only known from its transcript, so the resume was declared as a new subagent keyed by the SendMessage id: a second row, and every child frame dropped as undeclared. That row never showed the model or the child's reply. Replay now hands the live source each restored child's task id, id and presentation. The first task_started for that task id adopts the restored row, so the resume takes the same path it takes without a reopen. Co-authored-by: luke Co-authored-by: RoundRedDot Co-authored-by: Claude Opus 5.5 Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../claude/agent.sub-agent-replay.test.ts | 126 ++++++++++++++++++ .../server/agent/providers/claude/agent.ts | 1 + .../claude/subagents/live-source.test.ts | 36 +++++ .../providers/claude/subagents/live-source.ts | 44 +++++- .../claude/subagents/replay-source.test.ts | 44 ++++++ .../claude/subagents/replay-source.ts | 27 ++-- 6 files changed, 268 insertions(+), 10 deletions(-) diff --git a/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts b/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts index 4728767307..91007aa7ce 100644 --- a/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts @@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { createTestLogger } from "../../../../test-utils/test-logger.js"; import type { AgentStreamEvent } from "../../agent-sdk-types.js"; +import { streamSession } from "../test-utils/session-stream-adapter.js"; import { ClaudeAgentClient } from "./agent.js"; import { claudeProjectDirSync } from "./project-dir.js"; @@ -77,6 +78,29 @@ function sidechainEntry(options: { agentId?: string; stopReason?: string | null }); } +function buildQueryMock(events: unknown[]) { + let index = 0; + return { + next: vi.fn(async () => { + if (index >= events.length) return { done: true, value: undefined }; + const value = events[index]; + index += 1; + return { done: false, value }; + }), + interrupt: vi.fn(async () => undefined), + return: vi.fn(async () => undefined), + close: vi.fn(() => undefined), + setPermissionMode: vi.fn(async () => undefined), + setModel: vi.fn(async () => undefined), + supportedModels: vi.fn(async () => []), + supportedCommands: vi.fn(async () => []), + rewindFiles: vi.fn(async () => ({ canRewind: true })), + [Symbol.asyncIterator]() { + return this; + }, + }; +} + describe("ClaudeAgentSession persisted subagent replay", () => { const logger = createTestLogger(); const queryFactory = vi.fn(() => { @@ -1262,4 +1286,106 @@ describe("ClaudeAgentSession persisted subagent replay", () => { ); expect(items).toEqual([{ type: "assistant_message", text: report }]); }); + + test("keeps a subagent resumed after the session reopens on the row its transcript restored", async () => { + // Verified on the wire (Claude Code 2.1.309): SendMessage resumes a finished subagent under its + // original task_id but a new tool_use_id, and the child's frames still name the original Task + // call as their parent. A reopened session only knows that child from its transcript. + writeSession({ + parentLines: [taskToolUse(), taskToolResult()], + meta: JSON.stringify({ agentType: "general-purpose", toolUseId: TOOL_USE_ID }), + sidechainLines: [ + JSON.stringify({ + type: "assistant", + isSidechain: true, + agentId: AGENT_ID, + sessionId: "replay-session", + timestamp: "2026-07-26T06:27:50.000Z", + effort: "high", + message: { + role: "assistant", + model: "claude-sonnet-5", + content: [{ type: "text", text: "summary of the docs" }], + stop_reason: "end_turn", + }, + }), + ], + }); + const liveQuery = vi.fn(() => + buildQueryMock([ + { + type: "system", + subtype: "init", + session_id: "replay-session", + permissionMode: "default", + }, + { + type: "system", + subtype: "task_started", + task_id: AGENT_ID, + tool_use_id: "toolu_send_message", + description: "Summarize the docs", + subagent_type: "general-purpose", + task_type: "local_agent", + is_backgrounded: true, + prompt: "Summarize them again", + }, + { + type: "assistant", + parent_tool_use_id: TOOL_USE_ID, + session_id: "replay-session", + message: { + id: "msg_resumed_child", + role: "assistant", + model: "claude-opus-5", + content: [{ type: "text", text: "summary again" }], + }, + }, + { + type: "system", + subtype: "task_updated", + task_id: AGENT_ID, + patch: { status: "completed" }, + }, + { + type: "result", + subtype: "success", + usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 }, + total_cost_usd: 0, + }, + ]), + ); + const session = await new ClaudeAgentClient({ + logger, + queryFactory: liveQuery, + resolveVersion: async () => "2.1.309", + resolveBinary: async () => "/test/claude/bin", + }).resumeSession({ provider: "claude", sessionId: "replay-session" }, { cwd }); + const events: AgentStreamEvent[] = []; + for await (const event of streamSession(session, "Ask it to summarize again")) { + events.push(event); + if (event.type === "turn_completed" || event.type === "turn_failed") break; + } + await session.close(); + + const live = events.flatMap((event) => + event.type === "provider_subagent" ? [event.event] : [], + ); + expect(new Set(live.map((event) => event.id))).toEqual(new Set([TOOL_USE_ID])); + expect(live).toContainEqual( + expect.objectContaining({ + type: "upsert", + id: TOOL_USE_ID, + subtitle: "general-purpose · Opus 5 · High", + }), + ); + expect(live).toContainEqual( + expect.objectContaining({ + type: "timeline", + id: TOOL_USE_ID, + item: expect.objectContaining({ type: "assistant_message", text: "summary again" }), + }), + ); + expect(live.at(-1)).toMatchObject({ type: "upsert", id: TOOL_USE_ID, status: "completed" }); + }); }); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index ae48883654..d232185cbb 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -5455,6 +5455,7 @@ class ClaudeAgentSession implements AgentSession { convertEntry: (entry) => this.convertHistoryEntry(entry), })), ]; + this.taskProtocolSource.restore(subagentReplay.restored); const restoredProviderSubagentIds = new Set( observations .filter((observation) => observation.kind === "declared") diff --git a/packages/server/src/server/agent/providers/claude/subagents/live-source.test.ts b/packages/server/src/server/agent/providers/claude/subagents/live-source.test.ts index b0bfc3d755..b12ab8eb6a 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/live-source.test.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/live-source.test.ts @@ -156,6 +156,42 @@ describe("ClaudeTaskProtocolSource", () => { ]); }); + it("resumes a child a reopened session restored on its original row", () => { + const source = new ClaudeTaskProtocolSource(); + source.restore([ + { + taskId: "a1730a6215e1f5cf6", + id: "toolu_original", + presentation: { title: "general-purpose", model: "claude-sonnet-5", effort: "high" }, + }, + ]); + + // Restored is not running: a canceled turn and an unannounced frame leave it alone. + expect(source.cancelRunningForegroundTasks()).toEqual([]); + expect(source.resolveSubagentId("toolu_original")).toBeUndefined(); + + expect( + source.observe(taskStarted({ tool_use_id: "toolu_resumed", prompt: "Resumed prompt" })), + ).toEqual([ + { kind: "status", id: "toolu_original", status: "running" }, + { + kind: "timeline", + id: "toolu_original", + item: { type: "user_message", text: "Resumed prompt" }, + }, + ]); + expect(source.resolveSubagentId("toolu_original")).toBe("toolu_original"); + expect(source.resolveSubagentId("toolu_resumed")).toBe("toolu_original"); + expect( + source.observeSidechainFrame( + { type: "assistant", message: { model: "claude-opus-5" } } as unknown as SDKMessage, + "toolu_original", + ), + ).toEqual([ + { kind: "subtitle", id: "toolu_original", subtitle: "general-purpose · Opus 5 · High" }, + ]); + }); + it("declares a distinct task separately", () => { const source = new ClaudeTaskProtocolSource(); source.observe(taskStarted({ task_id: "first", tool_use_id: "toolu_first" })); diff --git a/packages/server/src/server/agent/providers/claude/subagents/live-source.ts b/packages/server/src/server/agent/providers/claude/subagents/live-source.ts index af941aa24b..b7b3201897 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/live-source.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/live-source.ts @@ -121,6 +121,16 @@ function readString(value: unknown): string | undefined { return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; } +/** + * A subagent a reopened session rebuilt from its transcript. Its `taskId` (the transcript's + * agentId) is the task id Claude announces again when SendMessage resumes the child. + */ +export interface ClaudeRestoredSubagent { + taskId: string; + id: string; + presentation: ClaudeSubagentPresentationFacts; +} + export interface ClaudeTaskProtocolSourceInput { /** * The parent's Task tool input, by tool_use id. @@ -162,6 +172,14 @@ export class ClaudeTaskProtocolSource { private readonly backgroundedIds = new Set(); /** Last status emitted per subagent, so a redundant announcement is not re-broadcast. */ private readonly lastStatusById = new Map(); + /** + * Children a reopened session restored from its transcript, by task id. Claude resumes one under + * its old task id with a new tool id, while its frames keep the original Task call as parent. + * Without this the resume reads as a new task: a second row, and every frame dropped as + * undeclared. Held apart from the declared table so a restored child is left alone until + * Claude announces it again. + */ + private readonly restoredByTaskId = new Map(); /** Claude facts stay inside the provider boundary; clients receive one compact subtitle. */ private readonly presentationById = new Map(); private readonly lastSubtitleById = new Map(); @@ -246,6 +264,14 @@ export class ClaudeTaskProtocolSource { } } + /** Remember the children a reopened session restored, for Claude to resume by task id. */ + restore(subagents: Iterable): void { + for (const subagent of subagents) { + if (this.subagentIdByTaskId.has(subagent.taskId)) continue; + this.restoredByTaskId.set(subagent.taskId, subagent); + } + } + /** * Forget the session. * @@ -259,6 +285,7 @@ export class ClaudeTaskProtocolSource { this.ownerSubagentIdByToolUseId.clear(); this.ownerSubagentIdByTaskId.clear(); this.declaredIds.clear(); + this.restoredByTaskId.clear(); this.workflowTaskIds.clear(); this.lastWorkflowResultByTaskId.clear(); this.idsWithExistingParentToolCard.clear(); @@ -315,7 +342,8 @@ export class ClaudeTaskProtocolSource { if (!id || message.skip_transcript === true || !isProviderSubagentTask(message)) return []; this.sawTaskStarted = true; - const existingId = this.subagentIdByTaskId.get(message.task_id); + const existingId = + this.subagentIdByTaskId.get(message.task_id) ?? this.adoptRestored(message.task_id); this.recordBackgrounded(existingId ?? id, message.is_backgrounded); if (existingId) { return this.observeExistingTaskStart(message, id, existingId); @@ -324,6 +352,20 @@ export class ClaudeTaskProtocolSource { return this.observeNewTaskStart(message, id, parentSubagentId); } + /** Declare a restored child under the id its transcript gave it, with what it already showed. */ + private adoptRestored(taskId: string): string | undefined { + const restored = this.restoredByTaskId.get(taskId); + if (!restored) return undefined; + this.restoredByTaskId.delete(taskId); + this.subagentIdByTaskId.set(taskId, restored.id); + this.canonicalIdByToolUseId.set(restored.id, restored.id); + this.declaredIds.add(restored.id); + this.presentationById.set(restored.id, restored.presentation); + const subtitle = buildClaudeSubagentSubtitle(restored.presentation); + if (subtitle) this.lastSubtitleById.set(restored.id, subtitle); + return restored.id; + } + private observeExistingTaskStart( message: TaskStartedMessage, toolUseId: string, diff --git a/packages/server/src/server/agent/providers/claude/subagents/replay-source.test.ts b/packages/server/src/server/agent/providers/claude/subagents/replay-source.test.ts index 3bde4b813b..fc48e9db3a 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/replay-source.test.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/replay-source.test.ts @@ -85,6 +85,50 @@ describe("parseClaudeSubagentMeta", () => { }); describe("observeReplaySubagents", () => { + it("restores a resumed child on its original row after consuming async history", async () => { + async function* entries(): AsyncGenerator { + yield { type: "assistant", message: { model: "claude-opus-5" } }; + yield { + type: "assistant", + effort: "high", + message: { model: "claude-sonnet-5", usage: { input_tokens: 10, output_tokens: 2 } }, + }; + } + const replay = await observeReplaySubagents({ + subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: entries() }], + parent: parentWithTaskCall(), + convertEntry: async () => [], + }); + expect(replay.restored).toEqual([ + { + taskId: AGENT_ID, + id: TOOL_USE_ID, + presentation: { + title: "general-purpose", + model: "claude-sonnet-5", + effort: "high", + usage: { totalTokens: 12 }, + }, + }, + ]); + const live = new ClaudeTaskProtocolSource(); + live.restore(replay.restored); + expect( + live.observe({ + type: "system", + subtype: "task_started", + task_id: AGENT_ID, + tool_use_id: "resumed-tool", + description: "Resume the captured task", + task_type: "local_agent", + is_backgrounded: true, + uuid: "00000000-0000-0000-0000-000000000000", + session_id: "reopened-session", + }), + ).toEqual([{ kind: "status", id: TOOL_USE_ID, status: "running" }]); + expect(live.resolveSubagentId("resumed-tool")).toBe(TOOL_USE_ID); + }); + it("links a subagent to its Task call through meta.toolUseId", async () => { const observations = ( await observeReplaySubagents({ diff --git a/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts b/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts index 02cbb783f0..51e5ff9c09 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts @@ -5,6 +5,7 @@ import { normalizeProviderReplayTimestamp } from "../../../provider-history-time import type { ProviderSubagentStatus } from "../../../provider-subagents/store.js"; import { isClaudeInterruptPlaceholderText } from "../interrupt-placeholder.js"; import { resolveObservedClaudeModelId } from "../models.js"; +import type { ClaudeRestoredSubagent } from "./live-source.js"; import type { SubagentObservation } from "./observation.js"; import { buildClaudeSubagentSubtitle, type ClaudeSubagentUsage } from "./presentation.js"; @@ -246,6 +247,7 @@ async function observeSubagent( parent: ClaudeReplayParentFacts, convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise, toolOwners: Map, + restored: ClaudeRestoredSubagent[], parentSubagentId?: string, ): Promise { const link = resolveParentLink(subagent, parent); @@ -276,13 +278,14 @@ async function observeSubagent( const observations = [ declareSubagent(subagent, link, toolCall, parentSubagentId, firstTimestamp), ]; - const subtitle = observeSubtitle( - link, - toolCall?.title ?? subagent.meta?.agentType, - runtime, - usage, - lastTimestamp, - ); + const title = toolCall?.title ?? subagent.meta?.agentType; + // The agentId is the task_id Claude resumes this child under, so live can find it again. + restored.push({ + taskId: subagent.agentId, + id: link.id, + presentation: { ...(title ? { title } : {}), ...runtime, ...(usage ? { usage } : {}) }, + }); + const subtitle = observeSubtitle(link, title, runtime, usage, lastTimestamp); if (subtitle) observations.push(subtitle); for (const observation of timeline) observations.push(observation); const terminalStatus = link.status ?? childTerminalStatus; @@ -337,9 +340,14 @@ export async function observeReplaySubagents(input: { subagents: readonly ClaudeReplaySubagentInput[]; parent: ClaudeReplayParentFacts; convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise; -}): Promise<{ observations: SubagentObservation[]; toolOwners: ReadonlyMap }> { +}): Promise<{ + observations: SubagentObservation[]; + toolOwners: ReadonlyMap; + restored: readonly ClaudeRestoredSubagent[]; +}> { const observations: SubagentObservation[] = []; const toolOwners = new Map(); + const restored: ClaudeRestoredSubagent[] = []; const unresolved = [...input.subagents].sort( (left, right) => (left.meta?.spawnDepth ?? 1) - (right.meta?.spawnDepth ?? 1), ); @@ -364,6 +372,7 @@ export async function observeReplaySubagents(input: { parent, input.convertEntry, toolOwners, + restored, ownerId, )) { observations.push(observation); @@ -373,7 +382,7 @@ export async function observeReplaySubagents(input: { madeProgress = true; } } - return { observations, toolOwners }; + return { observations, toolOwners, restored }; } function resolveReplayOwner( From da75aa7a4aeca07fde29df038e1aec97d6e7d7e4 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 1 Nov 2026 10:33:59 +0700 Subject: [PATCH 19/58] Fix terminal emoji size and alignment (#8375) * fix(terminal): render emoji at their intended width The native grid scales glyphs to the columns assigned by the terminal buffer. Use grapheme widths across current hosts and clients so emoji fit their slots and replay preserves cursor positions. * fix(terminal): make width regression test portable * test(terminal): wait for replay fixture output to finish A trailing newline could arrive after the readiness marker and change the retained history between replay captures. --- docs/terminal-rendering.md | 16 +++- package-lock.json | 91 ++++++++++--------- .../e2e/mobile/terminal-rendering/check.mjs | 3 + .../src/androidTest/assets/grapheme-rows.json | 20 ++-- .../paseo/terminal/TerminalRowLayoutTest.kt | 8 -- packages/app/package.json | 9 +- .../components/terminal-emulator-contract.ts | 2 + .../terminal-emulator-native-grid.native.tsx | 3 + .../app/src/components/terminal-emulator.tsx | 10 ++ packages/app/src/components/terminal-pane.tsx | 7 ++ packages/app/src/terminal/character-widths.ts | 9 ++ .../headless-terminal-state.test.ts | 48 ++++++++++ .../headless-terminal-state.ts | 10 ++ .../native-renderer/terminal-frame.test.ts | 14 ++- .../terminal-emulator-runtime.browser.test.ts | 61 +++++++++++++ .../runtime/terminal-emulator-runtime.test.ts | 6 ++ .../runtime/terminal-emulator-runtime.ts | 29 ++++-- packages/protocol/src/messages.ts | 2 + packages/server/package.json | 3 +- .../websocket-server.relay-reconnect.test.ts | 1 + .../server/src/server/websocket-server.ts | 2 + .../src/terminal/headless-terminal.test.ts | 60 ++++++++++++ .../server/src/terminal/headless-terminal.ts | 22 +++++ packages/server/src/terminal/terminal.test.ts | 65 +++++++++++++ packages/server/src/terminal/terminal.ts | 12 +-- .../src/terminal/test-utils/terminal-state.ts | 5 +- .../terminal/worker-terminal-manager.test.ts | 3 +- 27 files changed, 422 insertions(+), 99 deletions(-) create mode 100644 packages/app/src/terminal/character-widths.ts create mode 100644 packages/server/src/terminal/headless-terminal.test.ts create mode 100644 packages/server/src/terminal/headless-terminal.ts diff --git a/docs/terminal-rendering.md b/docs/terminal-rendering.md index fd304aa144..4c418595fc 100644 --- a/docs/terminal-rendering.md +++ b/docs/terminal-rendering.md @@ -6,15 +6,25 @@ Web and Electron draw terminals with xterm.js. iOS and Android keep an `@xterm/h xterm decides which column every cell occupies. The native views never let text advance move a cell. Each row is shaped once, one run per font face, so Hangul jamo, ligatures, combining marks, conjuncts and fallback fonts see their neighbours. Each shaped cluster is then drawn at its first cell's column, centred in the columns it owns, and scaled down when its glyphs are wider. Cursor, selection, hit-testing and copy all use xterm's columns, so they stay correct whatever the font does. -A cluster is the run of cells the shaper merged, and it is drawn once from the shaped row. Drawing part of a cluster on its own reshapes it: a half-form conjunct such as `स्ते` comes apart into `स्` with a visible virama. So colour and selection never split a cluster. A cluster whose cells differ in colour is drawn once per colour, clipped to that colour's columns. +A cluster is the run of cells the shaper merged, and it is drawn once from the shaped row. xterm usually keeps a conjunct in one cell (see [Character widths](#character-widths)), but a colour change inside it leaves `क्` and `ष` in separate cells that still shape together. Drawing part of a cluster on its own reshapes it: the conjunct comes apart into `क्` with a visible virama. So colour and selection never split a cluster. A cluster whose cells differ in colour is drawn once per colour, clipped to that colour's columns. -Android also groups cells with `android.icu.text.BreakIterator`, because Minikin's advances and cursor boundaries alone split some conjuncts. Grapheme rules follow the OS ICU version. Android 10 (API 29) splits `स्ते` after the virama and renders it incorrectly. The tested corpus passed on API 33, 34 and 35; API 30–32 have not been tested. We accept that version-dependent limitation to avoid bundling approximately 14 MB of ICU4J data. CoreText keeps this conjunct together on iOS. This renderer does not guarantee identical shaping across OS versions. +Android also groups cells with `android.icu.text.BreakIterator`, because Minikin's advances and cursor boundaries alone split some clusters that span cells. Grouping only merges whole cells, so a conjunct xterm keeps in one cell cannot be split by the OS. Grapheme rules follow the OS ICU version, and we accept that to avoid bundling approximately 14 MB of ICU4J data. Before the buffers used grapheme widths, Android 10 (API 29) split `स्ते` across its two cells. This renderer does not guarantee identical shaping across OS versions. `TerminalRowLayoutTest.kt` is the Android regression test. It draws the rows of `grapheme-rows.json` and compares each with its grapheme clusters drawn one at a time, and with a wrong clustering that must not match. `terminal-frame.test.ts` checks that those rows are what xterm produces and that the clusters are UAX #29 grapheme clusters. Run it with `./gradlew :paseo-terminal:connectedDebugAndroidTest` from `packages/app/android`, with `ANDROID_SERIAL` set. The view reports ready only after it has measured its grid size. The pane attaches with that size, so the daemon replays the terminal at the client's size. Before this, the replay arrived at the PTY's old size, the resize came after it, and the shell's redraw landed on the wrong rows. -Emoji widths come from the headless buffer, which uses xterm's default Unicode 6 table. `👍` gets one column, and `👋🏽` and `🇰🇷` get two cells drawn as one cluster. Web loads the Unicode 11 addon, so the same output can wrap differently on web and native. +## Character widths + +The daemon, native and web buffers all load `@xterm/addon-unicode-graphemes`: the daemon through `packages/server/src/terminal/headless-terminal.ts`, native in `createNativeHeadlessTerminal`, web in `terminal-emulator-runtime.ts`. Each emoji cluster takes one two-column cell, including VS16 (`❤️`), skin tones (`👋🏽`), flags (`🇰🇷`) and ZWJ sequences (`👨‍👩‍👧`). That is the width colour-emoji fonts draw at. Under xterm's default Unicode 6 table `😀` got one column, and the native views scaled it to half size to fit. + +Every buffer must use the same table. The daemon answers cursor-position reports (`CSI 6n`) from its buffer, and a client re-parses the daemon's ANSI replay with its own table. A disagreement moves the cursor and the later cells of any row that holds a disputed cluster: after replay from a Unicode 6 daemon, the cursor that followed `A😀😀😀B` lands inside the third emoji. + +Daemons that use grapheme widths advertise `server_info.features["terminal-grapheme-widths"]`. `terminal-pane.tsx` reads it once and passes `characterWidths` to the emulator. Against an older daemon the app keeps each renderer's previous table: xterm's default on native, Unicode 11 on web. An older app against a newer daemon cannot match it, because every client shares the daemon's one buffer. A terminal kept by a worker from an unreleased pre-grapheme build keeps that worker's table until it exits. + +The table follows Unicode grapheme clusters, as Node's `string-width` does for Ink-based agent TUIs. Indic conjuncts follow the same rule: `क्ष` and `स्ते` take one column, and the native views scale them down to fit it. Shells measure with the C library's `wcwidth` instead. glibc counts `❤️` as one column, `👋🏽` as four and `क्ष` as two, so a line editor that measures those disagrees with the terminal. + +Use `@xterm/addon-unicode-graphemes` 0.5.0-beta.330 or later. Earlier versions read their width table from the wrong offset when Node decodes it into a pooled `Buffer`, which gives wrong widths or a load error depending on earlier allocations. `headless-terminal.test.ts` forces that case in a fresh process. ## Ink outside the row diff --git a/package-lock.json b/package-lock.json index 30ceb1cb4c..49f6258e60 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14740,16 +14740,52 @@ "node": ">=10.0.0" } }, + "node_modules/@xterm/addon-image": { + "version": "0.10.0-beta.331", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.331.tgz", + "integrity": "sha512-ShBTb3QF0ul/i55lfRdJpcu0hlA4aJdqRdzzHdOCws2WUzSsG0SCfMhLjzaoO1dVUGNj2Z7PNLqiXnlyqOinbg==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^6.1.0-beta.336" + } + }, "node_modules/@xterm/addon-serialize": { "version": "0.14.0", "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.14.0.tgz", "integrity": "sha512-uteyTU1EkrQa2Ux6P/uFl2fzmXI46jy5uoQMKEOM0fKTyiW7cSn0WrFenHm5vO5uEXX/GpwW/FgILvv3r0WbkA==", "license": "MIT" }, + "node_modules/@xterm/addon-unicode-graphemes": { + "version": "0.5.0-beta.330", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode-graphemes/-/addon-unicode-graphemes-0.5.0-beta.330.tgz", + "integrity": "sha512-Zoiv/8tmku4LcU7bEL6zer+DNN6p6F8VCkyviSmbDxpA/W9zSnWDiD/ZpwFDQqegwjSptGzBNMhW2Os1sa+lUw==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^6.1.0-beta.336" + } + }, + "node_modules/@xterm/addon-webgl": { + "version": "0.20.0-beta.329", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.329.tgz", + "integrity": "sha512-bwqMma7X4cCBfwmWqKuhxNLkIXKW0lJTapaQ0g/9Kq7n6D6VCL4dYQCQ6DoeHv27ncJD4WzeoDTCEeSn0cspfg==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^6.1.0-beta.336" + } + }, "node_modules/@xterm/headless": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.0.0.tgz", - "integrity": "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==", + "version": "6.1.0-beta.334", + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.334.tgz", + "integrity": "sha512-QkwuIeP9dXlk/eOsAbqX8AvtS/W8EhRo6dkl2YYZr+jWAWSfDDMi+zPlegWUflSdsRKMURMvzvvkVZr6GztK4g==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, + "node_modules/@xterm/xterm": { + "version": "6.1.0-beta.336", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.336.tgz", + "integrity": "sha512-QrvZZsffXsLW4Z3sba9GqimLvVwlb8b3W1NwZ+l6DdRnpH5pg5cFGWg2ceIburmfaGQ07YJHNekwxxlTcHZ3BA==", "license": "MIT", "workspaces": [ "addons/*" @@ -37626,13 +37662,14 @@ "@tanstack/react-virtual": "^3.13.21", "@xterm/addon-clipboard": "^0.3.0-beta.213", "@xterm/addon-fit": "^0.12.0-beta.213", - "@xterm/addon-image": "^0.10.0-beta.213", + "@xterm/addon-image": "0.10.0-beta.331", "@xterm/addon-ligatures": "^0.11.0-beta.213", "@xterm/addon-search": "^0.17.0-beta.213", + "@xterm/addon-unicode-graphemes": "0.5.0-beta.330", "@xterm/addon-unicode11": "^0.10.0-beta.213", "@xterm/addon-web-links": "^0.13.0-beta.213", - "@xterm/addon-webgl": "0.20.0-beta.291", - "@xterm/xterm": "6.1.0-beta.292", + "@xterm/addon-webgl": "0.20.0-beta.329", + "@xterm/xterm": "6.1.0-beta.336", "buffer": "^6.0.3", "expo": "~57.0.30", "expo-asset": "~57.0.20", @@ -37710,7 +37747,7 @@ "@vitest/browser": "^4.1.7", "@vitest/browser-playwright": "^4.1.7", "@xterm/addon-serialize": "0.14.0", - "@xterm/headless": "^6.1.0-beta.213", + "@xterm/headless": "6.1.0-beta.334", "babel-preset-expo": "~57.0.16", "dotenv": "^17.2.3", "eas-cli": "^16.24.1", @@ -38489,15 +38526,6 @@ "@xterm/xterm": "^6.1.0-beta.213" } }, - "packages/app/node_modules/@xterm/addon-image": { - "version": "0.10.0-beta.213", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.213.tgz", - "integrity": "sha512-L3QxLkj8g8TioVea9p1oVze52Jr75rRZPPLm1tgZl0X2EGJGFHnQzE3QRsgmYWI7R5j3kwwi3MRAkOAgvOK8uA==", - "license": "MIT", - "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.213" - } - }, "packages/app/node_modules/@xterm/addon-ligatures": { "version": "0.11.0-beta.213", "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.213.tgz", @@ -38541,34 +38569,6 @@ "@xterm/xterm": "^6.1.0-beta.213" } }, - "packages/app/node_modules/@xterm/addon-webgl": { - "version": "0.20.0-beta.291", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.291.tgz", - "integrity": "sha512-gH7r5g8f2NWMhBtaeApuwLQTJmX4uq7zT0okjeNWkFAD0nxzbUPB+kt6sogZZ5K90XYPt2OLu/bhuvF6DmKqOQ==", - "license": "MIT", - "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.292" - } - }, - "packages/app/node_modules/@xterm/headless": { - "version": "6.1.0-beta.213", - "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.213.tgz", - "integrity": "sha512-D08E4Bl0HRhYZRL2o5PxD4FNQN9qXpI8QSw3CkKrsym5dtTRAqsynVYE8V8echQrxfb+EpR0CB5reM7tEGZ/fA==", - "dev": true, - "license": "MIT", - "workspaces": [ - "addons/*" - ] - }, - "packages/app/node_modules/@xterm/xterm": { - "version": "6.1.0-beta.292", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.292.tgz", - "integrity": "sha512-17zqK5tM/l6qeD7McF42OrEJ6w3XqJ2vFVKdWqu0cYLzdFqMWAHp2oFNc8Fj5DmqDSl1E1FZEg6IFflDllTvLA==", - "license": "MIT", - "workspaces": [ - "addons/*" - ] - }, "packages/app/node_modules/babel-plugin-syntax-hermes-parser": { "version": "0.36.1", "resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.36.1.tgz", @@ -40690,7 +40690,8 @@ "@opencode-ai/sdk": "1.14.46", "@opencode/client": "2.0.10", "@xterm/addon-serialize": "0.14.0", - "@xterm/headless": "^6.0.0", + "@xterm/addon-unicode-graphemes": "0.5.0-beta.330", + "@xterm/headless": "6.1.0-beta.334", "ai": "5.0.78", "ajv": "^8.20.0", "bcryptjs": "^3.0.3", diff --git a/packages/app/e2e/mobile/terminal-rendering/check.mjs b/packages/app/e2e/mobile/terminal-rendering/check.mjs index fbf4529bb3..92287e0586 100644 --- a/packages/app/e2e/mobile/terminal-rendering/check.mjs +++ b/packages/app/e2e/mobile/terminal-rendering/check.mjs @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import { writeFileSync } from "node:fs"; +import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes"; import xterm from "@xterm/headless"; import { PNG } from "pngjs"; import { ACCENT, fixtureLines, fixtureOutput, RULER_CELLS, SELECT_LINE } from "./fixture.mjs"; @@ -82,6 +83,8 @@ async function fixtureTerminal(size) { rows: size.rows, allowProposedApi: true, }); + // The app's buffer uses grapheme widths; the expected cells must too. + terminal.loadAddon(new UnicodeGraphemesAddon()); await new Promise((done) => terminal.write(fixtureOutput(size.columns).replaceAll("\n", "\r\n"), done), ); diff --git a/packages/app/modules/paseo-terminal/android/src/androidTest/assets/grapheme-rows.json b/packages/app/modules/paseo-terminal/android/src/androidTest/assets/grapheme-rows.json index 31c2b0c8d7..351ed32e73 100644 --- a/packages/app/modules/paseo-terminal/android/src/androidTest/assets/grapheme-rows.json +++ b/packages/app/modules/paseo-terminal/android/src/androidTest/assets/grapheme-rows.json @@ -1,21 +1,21 @@ [ { "output": "स्ते", - "row": "2,1,2,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;स्ते ", - "clusterStarts": [0, 2, 3, 4, 5, 6, 7], - "contrastStarts": [0, 1, 2, 3, 4, 5, 6, 7] + "row": "4,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;स्ते ", + "clusterStarts": [0, 1, 2, 3, 4, 5, 6, 7], + "contrastStarts": [0, 2, 3, 4, 5, 6, 7] }, { "output": "क्ष", - "row": "2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;क्ष ", - "clusterStarts": [0, 2, 3, 4, 5, 6, 7], - "contrastStarts": [0, 1, 2, 3, 4, 5, 6, 7] + "row": "3,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;क्ष ", + "clusterStarts": [0, 1, 2, 3, 4, 5, 6, 7], + "contrastStarts": [0, 2, 3, 4, 5, 6, 7] }, { "output": "নক্ষত্র", - "row": "1,1,2,1,1,1,2,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;নক্ষত্র ", - "clusterStarts": [0, 1, 3, 5, 6, 7], - "contrastStarts": [0, 1, 2, 3, 4, 5, 6, 7] + "row": "1,1,3,1,3,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;নক্ষত্র ", + "clusterStarts": [0, 1, 2, 3, 4, 5, 6, 7], + "contrastStarts": [0, 2, 3, 4, 5, 6, 7] }, { "output": "क्अ", @@ -31,7 +31,7 @@ }, { "output": "अ्क", - "row": "2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;अ्क ", + "row": "3,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;अ्क ", "clusterStarts": [0, 1, 2, 3, 4, 5, 6, 7], "contrastStarts": [0, 2, 3, 4, 5, 6, 7] } diff --git a/packages/app/modules/paseo-terminal/android/src/androidTest/java/sh/paseo/terminal/TerminalRowLayoutTest.kt b/packages/app/modules/paseo-terminal/android/src/androidTest/java/sh/paseo/terminal/TerminalRowLayoutTest.kt index 8e889bccf2..632065eab0 100644 --- a/packages/app/modules/paseo-terminal/android/src/androidTest/java/sh/paseo/terminal/TerminalRowLayoutTest.kt +++ b/packages/app/modules/paseo-terminal/android/src/androidTest/java/sh/paseo/terminal/TerminalRowLayoutTest.kt @@ -1,6 +1,5 @@ package sh.paseo.terminal -import android.os.Build import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Paint @@ -44,13 +43,6 @@ class TerminalRowLayoutTest { val output = row.getString("output") val (toExpected, toContrast) = difference(drawn, expected) to difference(drawn, contrast) Log.i("TerminalRowLayoutTest", "$output expected=$toExpected contrast=$toContrast") - // Android 10's platform ICU splits this conjunct. Keep its observed raster baseline - // explicit rather than claiming this supported OS passes the modern grapheme corpus. - if (Build.VERSION.SDK_INT == 29 && output == "स्ते") { - assertTrue("Android 10 unexpectedly matches modern conjunct clustering", toExpected > MATCH_TOLERANCE) - assertTrue("Android 10 split-conjunct baseline changed", toContrast <= MATCH_TOLERANCE) - continue - } assertTrue("$output differs from its grapheme clusters by $toExpected", toExpected <= MATCH_TOLERANCE) assertTrue("$output also matches the wrong clusters", toContrast > MATCH_TOLERANCE) } diff --git a/packages/app/package.json b/packages/app/package.json index 4fcd5d4737..e6cc1d4029 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -71,13 +71,14 @@ "@tanstack/react-virtual": "^3.13.21", "@xterm/addon-clipboard": "^0.3.0-beta.213", "@xterm/addon-fit": "^0.12.0-beta.213", - "@xterm/addon-image": "^0.10.0-beta.213", + "@xterm/addon-image": "0.10.0-beta.331", "@xterm/addon-ligatures": "^0.11.0-beta.213", "@xterm/addon-search": "^0.17.0-beta.213", + "@xterm/addon-unicode-graphemes": "0.5.0-beta.330", "@xterm/addon-unicode11": "^0.10.0-beta.213", "@xterm/addon-web-links": "^0.13.0-beta.213", - "@xterm/addon-webgl": "0.20.0-beta.291", - "@xterm/xterm": "6.1.0-beta.292", + "@xterm/addon-webgl": "0.20.0-beta.329", + "@xterm/xterm": "6.1.0-beta.336", "buffer": "^6.0.3", "expo": "~57.0.30", "expo-asset": "~57.0.20", @@ -155,7 +156,7 @@ "@vitest/browser": "^4.1.7", "@vitest/browser-playwright": "^4.1.7", "@xterm/addon-serialize": "0.14.0", - "@xterm/headless": "^6.1.0-beta.213", + "@xterm/headless": "6.1.0-beta.334", "babel-preset-expo": "~57.0.16", "dotenv": "^17.2.3", "eas-cli": "^16.24.1", diff --git a/packages/app/src/components/terminal-emulator-contract.ts b/packages/app/src/components/terminal-emulator-contract.ts index 12638161a1..b4e3dd93e1 100644 --- a/packages/app/src/components/terminal-emulator-contract.ts +++ b/packages/app/src/components/terminal-emulator-contract.ts @@ -12,6 +12,7 @@ import type { } from "../terminal/local-links/terminal-local-link-provider"; import type { TerminalClipboardWriter } from "../terminal/native-renderer/terminal-selection"; import type { TerminalFile } from "../terminal/file-paste"; +import type { TerminalCharacterWidths } from "../terminal/character-widths"; import type { PendingTerminalModifiers } from "../utils/terminal-keys"; import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness"; @@ -32,6 +33,7 @@ export interface TerminalEmulatorProps { ref: Ref; streamKey: string; supportsTerminalInputModeReplay: boolean; + characterWidths: TerminalCharacterWidths; testId?: string; xtermTheme?: ITheme; scrollbackLines: number; diff --git a/packages/app/src/components/terminal-emulator-native-grid.native.tsx b/packages/app/src/components/terminal-emulator-native-grid.native.tsx index d2b92b213a..ad71316f3c 100644 --- a/packages/app/src/components/terminal-emulator-native-grid.native.tsx +++ b/packages/app/src/components/terminal-emulator-native-grid.native.tsx @@ -93,6 +93,7 @@ function NativeTerminalEmulator({ testId = "terminal-surface", xtermTheme = DEFAULT_XTERM_THEME, scrollbackLines, + characterWidths, fontFamily, fontSize, keyboardInset = 0, @@ -373,6 +374,7 @@ function NativeTerminalEmulator({ rows: size.rows, cols: size.cols, scrollbackLines, + characterWidths, }); terminalRef.current = terminal; screenModelRef.current = createNativeTerminalScreenModel({ terminal }); @@ -387,6 +389,7 @@ function NativeTerminalEmulator({ resetInputModeTracker, resolveTerminalScreen, scrollbackLines, + characterWidths, clearSelection, ]); diff --git a/packages/app/src/components/terminal-emulator.tsx b/packages/app/src/components/terminal-emulator.tsx index fa6ac9faba..7504843c54 100644 --- a/packages/app/src/components/terminal-emulator.tsx +++ b/packages/app/src/components/terminal-emulator.tsx @@ -27,6 +27,7 @@ import { } from "../terminal/runtime/terminal-emulator-runtime"; import { encodeTerminalPaste } from "../terminal/runtime/terminal-paste"; import type { TerminalFile } from "../terminal/file-paste"; +import type { TerminalCharacterWidths } from "../terminal/character-widths"; import type { TerminalLocalFileLinkSource, TerminalLocalFileLinkTarget, @@ -96,6 +97,7 @@ interface TerminalEmulatorProps { ref: Ref; streamKey: string; supportsTerminalInputModeReplay: boolean; + characterWidths: TerminalCharacterWidths; testId?: string; xtermTheme?: ITheme; scrollbackLines: number; @@ -154,6 +156,7 @@ export default function TerminalEmulator({ cursor: "#e6e6e6", }, scrollbackLines, + characterWidths, fontFamily, fontSize, swipeGesturesEnabled = false, @@ -183,6 +186,8 @@ export default function TerminalEmulator({ const fontSizeRef = useRef(fontSize); const scrollbackLinesRef = useRef(scrollbackLines); scrollbackLinesRef.current = scrollbackLines; + const characterWidthsRef = useRef(characterWidths); + characterWidthsRef.current = characterWidths; fontFamilyRef.current = fontFamily; fontSizeRef.current = fontSize; const themeKey = useMemo(() => buildXtermThemeKey(xtermTheme), [xtermTheme]); @@ -315,6 +320,10 @@ export default function TerminalEmulator({ runtimeRef.current?.setScrollback({ lines: scrollbackLines }); }, [scrollbackLines]); + useEffect(() => { + runtimeRef.current?.setCharacterWidths({ widths: characterWidths }); + }, [characterWidths]); + useEffect(() => { const root = rootRef.current; if (!root || !swipeGesturesEnabled) { @@ -447,6 +456,7 @@ export default function TerminalEmulator({ }, }); runtime.setPendingModifiers({ pendingModifiers: pendingModifiersRef.current }); + runtime.setCharacterWidths({ widths: characterWidthsRef.current }); runtime.mount({ root, host, diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index d60556e0fa..4c5e74ef6d 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -1,5 +1,6 @@ import { TerminalFind, type TerminalPaneFindHandle } from "@/terminal/find"; import type { TerminalFindResult } from "@/terminal/runtime/terminal-emulator-runtime"; +import type { TerminalCharacterWidths } from "@/terminal/character-widths"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -304,6 +305,11 @@ export function TerminalPane({ const supportsTerminalSizeOwnership = useSessionStore( (state) => state.sessions[serverId]?.serverInfo?.features?.["terminal-size-ownership"] === true, ); + const characterWidths: TerminalCharacterWidths = useSessionStore((state) => + state.sessions[serverId]?.serverInfo?.features?.["terminal-grapheme-widths"] === true + ? "graphemes" + : "legacy", + ); const setFocusedTerminalId = useSessionStore((state) => state.setFocusedTerminalId); const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]); @@ -1171,6 +1177,7 @@ export function TerminalPane({ dom={TERMINAL_EMULATOR_DOM_PROPS} streamKey={terminalStreamKey} supportsTerminalInputModeReplay={supportsTerminalInputModeReplay} + characterWidths={characterWidths} testId="terminal-surface" xtermTheme={xtermTheme} scrollbackLines={settings.terminalScrollbackLines} diff --git a/packages/app/src/terminal/character-widths.ts b/packages/app/src/terminal/character-widths.ts new file mode 100644 index 0000000000..efb64da6d8 --- /dev/null +++ b/packages/app/src/terminal/character-widths.ts @@ -0,0 +1,9 @@ +/** + * The table a terminal buffer measures characters with. + * + * The daemon answers cursor reports and replays the screen from its own buffer, + * so the app measures with the table the host advertises. `graphemes` gives each + * emoji cluster one two-column cell. `legacy` is what each renderer used before + * hosts advertised grapheme widths. See docs/terminal-rendering.md. + */ +export type TerminalCharacterWidths = "graphemes" | "legacy"; diff --git a/packages/app/src/terminal/native-renderer/headless-terminal-state.test.ts b/packages/app/src/terminal/native-renderer/headless-terminal-state.test.ts index c1025568c9..546982906f 100644 --- a/packages/app/src/terminal/native-renderer/headless-terminal-state.test.ts +++ b/packages/app/src/terminal/native-renderer/headless-terminal-state.test.ts @@ -159,4 +159,52 @@ describe("native headless terminal state", () => { { char: "B", width: 1 }, ]); }); + + // Apple Color Emoji and Noto Color Emoji are two cells wide. The native + // renderer scales a cluster to its column slot, so a one-column emoji + // renders at half size. Each emoji cluster must occupy one wide cell. + test.each([ + ["presentation emoji", "😀"], + ["thumbs up", "👍"], + ["check mark", "✅"], + ["VS16 heart", "❤️"], + ["skin-tone modifier", "👋🏽"], + ["regional-indicator flag", "🇰🇷"], + ["ZWJ family", "👨‍👩‍👧"], + ["ZWJ technologist", "👩‍💻"], + ])("gives a %s cluster one two-column cell", async (_name, cluster) => { + const terminal = createNativeHeadlessTerminal({ rows: 2, cols: 12 }); + + await terminal.write(`A${cluster}B`); + const [row] = terminal.getBufferWindow({ startRow: 0, rowCount: 1 }).rows; + + expect(row.slice(0, 4).map((cell) => ({ char: cell.char, width: cell.width }))).toEqual([ + { char: "A", width: 1 }, + { char: cluster, width: 2 }, + { char: " ", width: 0 }, + { char: "B", width: 1 }, + ]); + expect(terminal.getViewportState().cursor.col).toBe(4); + }); + + // A host without grapheme widths answers cursor reports and replays with + // xterm's default table. The native buffer matches it so the cursor lands + // where the host put it. + test("measures with xterm's default table for hosts without grapheme widths", async () => { + const terminal = createNativeHeadlessTerminal({ + rows: 2, + cols: 12, + characterWidths: "legacy", + }); + + await terminal.write("A😀B"); + const [row] = terminal.getBufferWindow({ startRow: 0, rowCount: 1 }).rows; + + expect(row.slice(0, 3).map((cell) => ({ char: cell.char, width: cell.width }))).toEqual([ + { char: "A", width: 1 }, + { char: "😀", width: 1 }, + { char: "B", width: 1 }, + ]); + expect(terminal.getViewportState().cursor.col).toBe(3); + }); }); diff --git a/packages/app/src/terminal/native-renderer/headless-terminal-state.ts b/packages/app/src/terminal/native-renderer/headless-terminal-state.ts index 5fd87f7315..4347588861 100644 --- a/packages/app/src/terminal/native-renderer/headless-terminal-state.ts +++ b/packages/app/src/terminal/native-renderer/headless-terminal-state.ts @@ -1,3 +1,5 @@ +import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes"; +import type { TerminalCharacterWidths } from "../character-widths"; import { Terminal as HeadlessTerminal, type IBufferCell, @@ -17,6 +19,8 @@ export interface NativeHeadlessTerminalOptions { rows: number; cols: number; scrollbackLines?: number; + /** Defaults to `graphemes`, the table current daemons use. */ + characterWidths?: TerminalCharacterWidths; } export interface NativeHeadlessTerminal { @@ -297,6 +301,12 @@ export function createNativeHeadlessTerminal( scrollback: options.scrollbackLines ?? 1000, allowProposedApi: true, }); + // Grapheme widths give each emoji cluster one two-column cell, the width the + // native renderer draws it at. Legacy hosts measure with xterm's default + // table, so their cursor reports and replay stay consistent with it. + if ((options.characterWidths ?? "graphemes") === "graphemes") { + terminal.loadAddon(new UnicodeGraphemesAddon()); + } const decoder = new TextDecoder(); let coordinateEpoch = 0; const coordinateStorage = new Map(); diff --git a/packages/app/src/terminal/native-renderer/terminal-frame.test.ts b/packages/app/src/terminal/native-renderer/terminal-frame.test.ts index ea8b7a0cf3..850fd4d183 100644 --- a/packages/app/src/terminal/native-renderer/terminal-frame.test.ts +++ b/packages/app/src/terminal/native-renderer/terminal-frame.test.ts @@ -90,18 +90,16 @@ describe("native terminal frame", () => { test("keeps combining marks and joiners inside the cell xterm assigned them", async () => { const { frame } = await frameFor("éx̸ 각 क्ष 👩‍💻"); - expect(cellLayout(frame.rows[0]).slice(0, 11)).toEqual([ + expect(cellLayout(frame.rows[0]).slice(0, 9)).toEqual([ { text: "é", col: 0, span: 1 }, { text: "x̸", col: 1, span: 1 }, { text: " ", col: 2, span: 1 }, { text: "각", col: 3, span: 2 }, { text: " ", col: 5, span: 1 }, - { text: "क्", col: 6, span: 1 }, - { text: "ष", col: 7, span: 1 }, - { text: " ", col: 8, span: 1 }, - { text: "👩‍", col: 9, span: 1 }, - { text: "💻", col: 10, span: 1 }, - { text: " ", col: 11, span: 1 }, + { text: "क्ष", col: 6, span: 1 }, + { text: " ", col: 7, span: 1 }, + { text: "👩‍💻", col: 8, span: 2 }, + { text: " ", col: 10, span: 1 }, ]); }); @@ -237,7 +235,7 @@ test("builds native frames after replay commits at captured geometry and the lat expect(frame.columns).toBe(50); expect(frame.rows).toHaveLength(10); expect(frame.rows[0].text.trimEnd()).toBe("LIVE-界🙂"); - expect(frame.rows[0].styles.slice(0, 4)).toEqual([8, RED, 0, 0]); + expect(frame.rows[0].styles.slice(0, 4)).toEqual([9, RED, 0, 0]); expect(encodeTerminalFrame(frame).rows[0]).toContain("LIVE-界🙂"); } finally { drain.dispose(); diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts index 7f54361265..3b58c54376 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts @@ -821,6 +821,67 @@ describe("incremental replay lifecycle", () => { expect(mounted.inputs).toHaveLength(1); expect(mounted.inputs[0]?.startsWith("\x1b]4;1;rgb:")).toBe(true); }); + + it("measures emoji clusters with the daemon's grapheme widths and copies them intact", async () => { + const mounted = createTerminalHost({ width: 720, height: 360 }); + const text = "A😀B❤️C👋🏽D👨‍👩‍👧E"; + await new Promise((resolve) => + mounted.runtime.write({ data: terminalOutput(text), onCommitted: resolve }), + ); + + const terminal = window.__paseoTerminal; + if (!terminal) { + throw new Error("Expected the browser terminal to be mounted"); + } + const line = terminal.buffer.active.getLine(terminal.buffer.active.cursorY); + const cells = Array.from({ length: 13 }, (_, col) => { + const cell = line?.getCell(col); + return { chars: cell?.getChars(), width: cell?.getWidth() }; + }); + + expect(cells).toEqual([ + { chars: "A", width: 1 }, + { chars: "😀", width: 2 }, + { chars: "", width: 0 }, + { chars: "B", width: 1 }, + { chars: "❤️", width: 2 }, + { chars: "", width: 0 }, + { chars: "C", width: 1 }, + { chars: "👋🏽", width: 2 }, + { chars: "", width: 0 }, + { chars: "D", width: 1 }, + { chars: "👨‍👩‍👧", width: 2 }, + { chars: "", width: 0 }, + { chars: "E", width: 1 }, + ]); + expect(terminal.buffer.active.cursorX).toBe(13); + + terminal.select(0, terminal.buffer.active.cursorY, 13); + expect(terminal.getSelection()).toBe(text); + }); + + // A host without grapheme widths answers cursor reports and replays with its + // own table. The web client keeps the Unicode 11 table it used before. + it("measures with the legacy table for hosts without grapheme widths", async () => { + const mounted = createTerminalHost({ width: 720, height: 360 }); + const terminal = window.__paseoTerminal; + if (!terminal) { + throw new Error("Expected the browser terminal to be mounted"); + } + const buffer = terminal.buffer; + async function cursorAfter(text: string): Promise { + await new Promise((resolve) => + mounted.runtime.write({ data: terminalOutput(`\r\n${text}`), onCommitted: resolve }), + ); + return buffer.active.cursorX; + } + + mounted.runtime.setCharacterWidths({ widths: "legacy" }); + expect([await cursorAfter("A😀B"), await cursorAfter("A❤️B")]).toEqual([4, 3]); + + mounted.runtime.setCharacterWidths({ widths: "graphemes" }); + expect([await cursorAfter("A😀B"), await cursorAfter("A❤️B")]).toEqual([4, 4]); + }); }); it("restores fitted web geometry after replay before live output", async () => { diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts index 8b90839154..7b1568b60e 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts @@ -36,6 +36,12 @@ vi.mock("@xterm/addon-unicode11", () => ({ }, })); +vi.mock("@xterm/addon-unicode-graphemes", () => ({ + UnicodeGraphemesAddon: class UnicodeGraphemesAddon { + dispose(): void {} + }, +})); + vi.mock("@xterm/addon-web-links", () => ({ WebLinksAddon: class WebLinksAddon { dispose(): void {} diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts index 0e1f88dafb..05cf0182d1 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts @@ -3,10 +3,12 @@ import { FitAddon } from "@xterm/addon-fit"; import { ImageAddon } from "@xterm/addon-image"; import { SearchAddon } from "@xterm/addon-search"; import { Unicode11Addon } from "@xterm/addon-unicode11"; +import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes"; import { WebLinksAddon } from "@xterm/addon-web-links"; import { WebglAddon } from "@xterm/addon-webgl"; import { LigaturesAddon } from "@xterm/addon-ligatures/lib/addon-ligatures.mjs"; import { Terminal, type ITheme, type IMarker } from "@xterm/xterm"; +import type { TerminalCharacterWidths } from "../character-widths"; import { type TerminalInputModeState, TerminalInputModeTracker, @@ -232,6 +234,12 @@ function findFileOnlyPaste(transfer: DataTransfer | null): File | null { return null; } +// Grapheme widths match the daemon and native buffers. Legacy hosts keep the +// Unicode 11 table web used before. See docs/terminal-rendering.md. +function applyCharacterWidths(terminal: Terminal, widths: TerminalCharacterWidths): void { + terminal.unicode.activeVersion = widths === "graphemes" ? "15-graphemes" : "11"; +} + export class TerminalEmulatorRuntime { constructor( private readonly options: FindShortcutPlatform = { isMac: isMacUserAgent() }, @@ -244,6 +252,7 @@ export class TerminalEmulatorRuntime { shift: false, alt: false, }; + private characterWidths: TerminalCharacterWidths = "graphemes"; private terminal: Terminal | null = null; private fitAddon: FitAddon | null = null; private searchAddon: SearchAddon | null = null; @@ -331,6 +340,16 @@ export class TerminalEmulatorRuntime { this.pendingModifiers = input.pendingModifiers; } + setCharacterWidths(input: { widths: TerminalCharacterWidths }): void { + this.characterWidths = input.widths; + const terminal = this.terminal; + if (!terminal) { + return; + } + applyCharacterWidths(terminal, input.widths); + this.refreshVisibleRows(); + } + private loadSearchAddon(terminal: Terminal): void { const searchAddon = new SearchAddon({ highlightLimit: FIND_HIGHLIGHT_LIMIT }); terminal.loadAddon(searchAddon); @@ -555,11 +574,12 @@ export class TerminalEmulatorRuntime { theme: withOverviewRulerBorderHidden(input.theme), }); const fitAddon = new FitAddon(); - const unicode11Addon = new Unicode11Addon(); let webglAddon: WebglAddon | null = null; let imageAddon: ImageAddon | null = null; terminal.loadAddon(fitAddon); - terminal.loadAddon(unicode11Addon); + terminal.loadAddon(new Unicode11Addon()); + terminal.loadAddon(new UnicodeGraphemesAddon()); + applyCharacterWidths(terminal, this.characterWidths); terminal.loadAddon(new WebLinksAddon(openExternalLink)); const localFileLinkProvider = terminal.registerLinkProvider( createTerminalLocalFileLinkProvider(terminal, { @@ -594,11 +614,6 @@ export class TerminalEmulatorRuntime { terminal.open(input.host); this.themeBackgroundElements = this.collectThemeBackgroundElements(input); this.applyThemeBackground(input.theme); - try { - terminal.unicode.activeVersion = "11"; - } catch { - // Ignore if unicode API isn't available in this build/runtime. - } const disposeImageAddon = (): void => { try { diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index dc63d56b75..18654bf337 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -3751,6 +3751,8 @@ export const ServerInfoStatusPayloadSchema = z "terminal-input-mode-replay": z.boolean().optional(), // COMPAT(terminalSizeOwnership): added in v0.2.6, remove gate after 2027-02-02. "terminal-size-ownership": z.boolean().optional(), + // COMPAT(terminalGraphemeWidths): added in v0.15.0, remove gate after 2027-05-01. + "terminal-grapheme-widths": z.boolean().optional(), // COMPAT(rewind): added in v0.1.X, drop the gate when floor >= v0.1.X. rewind: z.boolean().optional(), // COMPAT(agentTimelinePromptIndex): added in v0.2.X, drop the gate when floor >= v0.2.X. diff --git a/packages/server/package.json b/packages/server/package.json index 0feadfe9cd..72a21944ef 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -117,7 +117,8 @@ "@opencode-ai/sdk": "1.14.46", "@opencode/client": "2.0.10", "@xterm/addon-serialize": "0.14.0", - "@xterm/headless": "^6.0.0", + "@xterm/addon-unicode-graphemes": "0.5.0-beta.330", + "@xterm/headless": "6.1.0-beta.334", "ai": "5.0.78", "ajv": "^8.20.0", "bcryptjs": "^3.0.3", diff --git a/packages/server/src/server/websocket-server.relay-reconnect.test.ts b/packages/server/src/server/websocket-server.relay-reconnect.test.ts index 2d48a93c87..c99d5ac9cc 100644 --- a/packages/server/src/server/websocket-server.relay-reconnect.test.ts +++ b/packages/server/src/server/websocket-server.relay-reconnect.test.ts @@ -1157,6 +1157,7 @@ describe("relay external socket reconnect behavior", () => { expect(serverInfo.features?.workspaceMarkUnread).toBe(true); expect(serverInfo.features?.["terminal-input-mode-replay"]).toBe(true); expect(serverInfo.features?.["terminal-size-ownership"]).toBe(true); + expect(serverInfo.features?.["terminal-grapheme-widths"]).toBe(true); expect(serverInfo.features?.agentTurnIdentity).toBeUndefined(); expect(serverInfo.permissions).toEqual(DAEMON_PERMISSIONS); await server.close(); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 7bfc4d5a0f..7089812d21 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -2077,6 +2077,8 @@ export class VoiceAssistantWebSocketServer { "terminal-input-mode-replay": true, // COMPAT(terminalSizeOwnership): added in v0.2.6, remove gate after 2027-02-02. "terminal-size-ownership": true, + // COMPAT(terminalGraphemeWidths): added in v0.15.0, remove gate after 2027-05-01. + "terminal-grapheme-widths": true, workspaceTerminals: true, // COMPAT(rewind): added in v0.1.X, drop the gate when floor >= v0.1.X. rewind: true, diff --git a/packages/server/src/terminal/headless-terminal.test.ts b/packages/server/src/terminal/headless-terminal.test.ts new file mode 100644 index 0000000000..d971f0d3f5 --- /dev/null +++ b/packages/server/src/terminal/headless-terminal.test.ts @@ -0,0 +1,60 @@ +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { createHeadlessTerminal } from "./headless-terminal.js"; + +const EMOJI_ROW = "A😀👍❤️👋🏽🇰🇷👨‍👩‍👧B"; +// A, six two-column emoji clusters, B. +const EMOJI_ROW_END_COL = 1 + 6 * 2 + 1; + +async function cursorColAfter(text: string): Promise { + const terminal = createHeadlessTerminal({ rows: 2, cols: 40 }); + try { + await new Promise((resolve) => terminal.write(text, resolve)); + return terminal.buffer.active.cursorX; + } finally { + terminal.dispose(); + } +} + +describe("createHeadlessTerminal", () => { + it("gives each emoji cluster one two-column cell", async () => { + expect(await cursorColAfter(EMOJI_ROW)).toBe(EMOJI_ROW_END_COL); + }); + + it("keeps ASCII and CJK widths", async () => { + expect(await cursorColAfter("ABC")).toBe(3); + expect(await cursorColAfter("中文")).toBe(4); + }); + + // The width table is decoded into a Node Buffer when the module loads. A + // Buffer carved out of Node's shared pool starts at a non-zero byteOffset, + // and an addon that reads the table header from offset 0 gets wrong widths + // or fails to load. Whether the pool offset is zero depends on earlier + // allocations, so the test forces a non-zero offset in a fresh process + // before the module loads. Zero bytes in front of the table make the + // misread header deterministic. + it("decodes widths correctly when Node's buffer pool offset is non-zero", () => { + const moduleUrl = new URL("./headless-terminal.ts", import.meta.url).href; + const script = ` + Buffer.poolSize = 8 * 1024 * 1024; + const occupied = Buffer.from(new Uint8Array(3 * 1024 * 1024)); + if (occupied.byteOffset !== 0 || occupied.buffer.byteLength !== Buffer.poolSize) { + throw new Error("expected a fresh shared pool"); + } + const { createHeadlessTerminal } = await import(${JSON.stringify(moduleUrl)}); + const terminal = createHeadlessTerminal({ rows: 2, cols: 40 }); + await new Promise((resolve) => terminal.write(${JSON.stringify(EMOJI_ROW)}, resolve)); + process.stdout.write(String(terminal.buffer.active.cursorX)); + terminal.dispose(); + `; + const result = spawnSync( + process.execPath, + ["--import", "tsx", "--input-type=module", "--eval", script], + { cwd: fileURLToPath(new URL(".", import.meta.url)), encoding: "utf8" }, + ); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toBe(String(EMOJI_ROW_END_COL)); + }); +}); diff --git a/packages/server/src/terminal/headless-terminal.ts b/packages/server/src/terminal/headless-terminal.ts new file mode 100644 index 0000000000..4e106c243b --- /dev/null +++ b/packages/server/src/terminal/headless-terminal.ts @@ -0,0 +1,22 @@ +import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes"; +import xterm, { type Terminal } from "@xterm/headless"; + +export interface HeadlessTerminalOptions { + rows: number; + cols: number; + scrollback?: number; +} + +/** + * A headless xterm that measures text the way Paseo clients render it. + * + * Grapheme-cluster widths give every emoji cluster (VS16, skin-tone, flags, + * ZWJ sequences) one two-column cell, matching color-emoji fonts. Clients + * replay this buffer's ANSI with the same table, so cells and the cursor line + * up after reconnect. See docs/terminal-rendering.md. + */ +export function createHeadlessTerminal(options: HeadlessTerminalOptions): Terminal { + const terminal = new xterm.Terminal({ ...options, allowProposedApi: true }); + terminal.loadAddon(new UnicodeGraphemesAddon()); + return terminal; +} diff --git a/packages/server/src/terminal/terminal.test.ts b/packages/server/src/terminal/terminal.test.ts index 69e6468673..e4f8544050 100644 --- a/packages/server/src/terminal/terminal.test.ts +++ b/packages/server/src/terminal/terminal.test.ts @@ -563,6 +563,71 @@ describe("createTerminal", () => { expect(state.grid[0][1].char).toBe(""); }); + it("reports the cursor column after an emoji cluster in DSR replies", async () => { + // Programs that measure the cursor (prompts, TUIs) must see the same + // column the client renders: an emoji occupies two cells. + const program = ` + process.stdin.setRawMode(true); + process.stdin.once("data", (reply) => { + process.stdout.write("\\r\\nREPLY " + JSON.stringify(reply.toString()).slice(2, -1)); + }); + process.stdout.write("A😀\\x1b[6n"); + setInterval(() => {}, 100000); + `; + const session = trackSession( + await createTerminal({ + workspaceId: "ws-test", + cwd: realpathSync(tmpdir()), + cols: 40, + rows: 5, + command: process.execPath, + args: ["-e", program], + }), + ); + + const state = await waitForState(session, (current) => + getRowText(current, 1).startsWith("REPLY"), + ); + + expect(getRowText(state, 1)).toBe("REPLY u001b[1;4R"); + }); + + it("replays emoji clusters at their columns with the cursor after them", async () => { + const text = "A😀B❤️C👋🏽D👨‍👩‍👧E"; + const session = trackSession( + await createTerminal({ + workspaceId: "ws-test", + cwd: realpathSync(tmpdir()), + cols: 40, + rows: 5, + command: process.execPath, + args: [ + "-e", + `process.stdout.write(${JSON.stringify(text)}); setInterval(() => {}, 100000);`, + ], + }), + ); + + const state = await waitForState(session, (current) => rowHasChar(current.grid[0], "E")); + + expect(state.grid[0].slice(0, 13).map((cell) => cell.char)).toEqual([ + "A", + "😀", + "", + "B", + "❤️", + "", + "C", + "👋🏽", + "", + "D", + "👨‍👩‍👧", + "", + "E", + ]); + expect(state.cursor).toEqual({ row: 0, col: 13 }); + }); + it("captures exit diagnostics from the terminal buffer", async () => { const session = trackSession( await createTerminal({ diff --git a/packages/server/src/terminal/terminal.ts b/packages/server/src/terminal/terminal.ts index 3294c11b93..b09bc8bbf6 100644 --- a/packages/server/src/terminal/terminal.ts +++ b/packages/server/src/terminal/terminal.ts @@ -1,5 +1,5 @@ import * as pty from "node-pty"; -import xterm, { type Terminal as TerminalType } from "@xterm/headless"; +import type { Terminal as TerminalType } from "@xterm/headless"; import { randomUUID } from "crypto"; import { accessSync, @@ -25,6 +25,7 @@ import { findExecutable, findExecutableWithoutProbing, } from "../executable-resolution/executable-resolution.js"; +import { createHeadlessTerminal } from "./headless-terminal.js"; import { createTerminalSerializer } from "./terminal-serializer.js"; import { captureTerminalLines, @@ -36,7 +37,6 @@ import { TerminalInputModeTracker } from "@getpaseo/protocol/terminal-input-mode import { TerminalActivityTracker } from "./activity/terminal-activity-tracker.js"; import type { TerminalActivity, TerminalActivityState } from "@getpaseo/protocol/terminal-activity"; -const { Terminal } = xterm; const require = createRequire(import.meta.url); let nodePtySpawnHelperChecked = false; const TERMINAL_TITLE_DEBOUNCE_MS = 150; @@ -796,13 +796,7 @@ export async function createTerminal( const activityChangeListeners = new Set<(transition: TerminalActivityTransition) => void>(); let titleChangeSubscription: { dispose(): void } | null = null; - // Create xterm.js headless terminal - const terminal = new Terminal({ - rows, - cols, - scrollback: 10000, - allowProposedApi: true, - }); + const terminal = createHeadlessTerminal({ rows, cols, scrollback: 10000 }); const serialize = createTerminalSerializer(terminal); diff --git a/packages/server/src/terminal/test-utils/terminal-state.ts b/packages/server/src/terminal/test-utils/terminal-state.ts index 0fd4777b4c..fb5f5d610a 100644 --- a/packages/server/src/terminal/test-utils/terminal-state.ts +++ b/packages/server/src/terminal/test-utils/terminal-state.ts @@ -1,15 +1,14 @@ -import xterm from "@xterm/headless"; import type { TerminalState } from "@getpaseo/protocol/messages"; +import { createHeadlessTerminal } from "../headless-terminal.js"; import type { LocalTerminalSession } from "../terminal.js"; /** Observe the public ANSI replay using a real receiver; no production cell extraction API. */ export async function readTerminalState(session: LocalTerminalSession): Promise { const { metadata, data } = session.serializeReplay(); - const terminal = new xterm.Terminal({ + const terminal = createHeadlessTerminal({ rows: metadata.rows, cols: metadata.cols, scrollback: 10000, - allowProposedApi: true, }); try { await new Promise((resolve) => terminal.write(data, resolve)); diff --git a/packages/server/src/terminal/worker-terminal-manager.test.ts b/packages/server/src/terminal/worker-terminal-manager.test.ts index 2605e62965..b0ef983a8e 100644 --- a/packages/server/src/terminal/worker-terminal-manager.test.ts +++ b/packages/server/src/terminal/worker-terminal-manager.test.ts @@ -1863,7 +1863,8 @@ it("completes a replay larger than the socket budget while a sibling stays usabl ...nodeTerminalCommand(` const row = Array.from({length: 1000}, (_, i) => "\\x1b[" + (31 + i % 2) + "mX").join(""); for (let i = 0; i < 900; i++) process.stdout.write(row + "\\r\\n"); - process.stdout.write("READY-WIDE\\r\\n"); + // Seeing the marker must mean no trailing newline can still add history. + process.stdout.write("READY-WIDE"); process.stdin.on("data", data => { if (data.toString().includes("small")) { process.stdout.write("SAME-TERMINAL-LIVE\\r\\n"); return; } process.stdout.write("q".repeat(8 * 1024 * 1024) + "\\r\\nRECOVERED-LIVE\\r\\n", () => require("node:fs").writeFileSync(${JSON.stringify(overflowProgress)}, "done")); }); setInterval(() => {}, 1000); `), From 82d5f6c580fccf500ed718aa8dad1be03639df7a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 1 Nov 2026 10:34:06 +0700 Subject: [PATCH 20/58] fix(app): keep layout sized to the window after rotation (#8377) A style update racing a window resize could recommit old root constraints. Rebuild the style update from the current root on each commit attempt. --- patches/react-native-unistyles+3.2.4.patch | 56 ++++++++++++++++++++++ scripts/postinstall-patches.mjs | 3 ++ 2 files changed, 59 insertions(+) diff --git a/patches/react-native-unistyles+3.2.4.patch b/patches/react-native-unistyles+3.2.4.patch index af825a6afa..c3da63a8ac 100644 --- a/patches/react-native-unistyles+3.2.4.patch +++ b/patches/react-native-unistyles+3.2.4.patch @@ -705,3 +705,59 @@ index a9c1804..f5a3c20 100644 return Insets( this._insets.top / density, +diff --git a/node_modules/react-native-unistyles/cxx/shadowTree/ShadowTreeManager.cpp b/node_modules/react-native-unistyles/cxx/shadowTree/ShadowTreeManager.cpp +index 01026dc..3786ec4 100644 +--- a/node_modules/react-native-unistyles/cxx/shadowTree/ShadowTreeManager.cpp ++++ b/node_modules/react-native-unistyles/cxx/shadowTree/ShadowTreeManager.cpp +@@ -17,13 +17,16 @@ void shadow::ShadowTreeManager::updateShadowTree(jsi::Runtime& rt) { + } + + #if REACT_NATIVE_VERSION_MINOR >= 81 +- std::unordered_map tagToProps; ++ // Paseo backport of https://github.com/jpudysz/react-native-unistyles/pull/1357. ++ // UIManager::updateShadowTree commits a root cloned before the commit, so when a ++ // window-size commit (rotation, keyboard) lands in between, the retried Unistyles ++ // commit puts the previous layout constraints back and the app stays laid out for ++ // the old size. Commit here instead and clone from the root the commit hands us. ++ std::unordered_set affectedSurfaces; + + for (const auto& [family, props] : updates) { + auto safeProps = props.isObject() ? props : folly::dynamic::object(); + +- tagToProps.insert({family->getTag(), safeProps}); +- + auto* mutableFamily = const_cast(family); + + if (mutableFamily->nativeProps_DEPRECATED && mutableFamily->nativeProps_DEPRECATED->isObject()) { +@@ -31,9 +34,30 @@ void shadow::ShadowTreeManager::updateShadowTree(jsi::Runtime& rt) { + } else { + mutableFamily->nativeProps_DEPRECATED = std::make_unique(safeProps); + } ++ ++ affectedSurfaces.insert(family->getSurfaceId()); + } + +- UIManagerBinding::getBinding(rt)->getUIManager().updateShadowTree(std::move(tagToProps)); ++ const auto& shadowTreeRegistry = UIManagerBinding::getBinding(rt)->getUIManager().getShadowTreeRegistry(); ++ ++ shadowTreeRegistry.enumerate([&updates, &affectedSurfaces](const ShadowTree& shadowTree, bool&){ ++ if (!affectedSurfaces.contains(shadowTree.getSurfaceId())) { ++ return; ++ } ++ ++ auto transaction = [&updates](const RootShadowNode& oldRootShadowNode) -> RootShadowNode::Unshared { ++ auto affectedNodes = shadow::ShadowTreeManager::findAffectedNodes(oldRootShadowNode, updates); ++ ++ return std::static_pointer_cast(shadow::ShadowTreeManager::cloneShadowTree( ++ oldRootShadowNode, ++ updates, ++ affectedNodes ++ )); ++ }; ++ ++ // mountSynchronously: this is an update from C++, not React ++ shadowTree.commit(transaction, {false, true}); ++ }); + #else + const auto& shadowTreeRegistry = UIManagerBinding::getBinding(rt)->getUIManager().getShadowTreeRegistry(); + diff --git a/scripts/postinstall-patches.mjs b/scripts/postinstall-patches.mjs index fbf87241c7..3dbbeddcaf 100644 --- a/scripts/postinstall-patches.mjs +++ b/scripts/postinstall-patches.mjs @@ -28,6 +28,9 @@ const patchedPackages = [ // withUnistyles: also keep the uniProps filter until upstream consumes its control prop. // Android configuration: backport 3.5.3; remove the two NativePlatform hunks at >= 3.5.3. // https://github.com/jpudysz/react-native-unistyles/compare/v3.5.2...v3.5.3 + // Layout constraints: backport of https://github.com/jpudysz/react-native-unistyles/pull/1357 + // (commit from the root the commit hands us, so a concurrent window-size commit is kept). + // Remove the ShadowTreeManager hunk when the installed release includes that PR. { nodeModulesPath: "node_modules/react-native-unistyles", patchPrefix: "react-native-unistyles+", From b2666a0aec4a309aa550dd3f344a997eed831cb2 Mon Sep 17 00:00:00 2001 From: "paseo-ai[bot]" <266920839+paseo-ai[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 03:43:24 +0000 Subject: [PATCH 21/58] fix: update lockfile signatures and Nix hash [skip ci] --- nix/npm-deps.hash | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index c7a63567f9..530da9108f 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-zFjdXeRpqrjEZk44ZCvEOTnVVGX0ASfDPktYNiWZpsw= +sha256-WPPLqWQzoUdjZNbZW6XWpy+A6XHXDyc0QW0FH6ubgMw= From 8efb21f55ca55ac8df24ec69ac49651732ea40f7 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 03:55:48 +0000 Subject: [PATCH 22/58] fix(server): close abandoned workspace service connections (#8421) * fix(server): close the upstream connection when a proxied client leaves The service proxy piped the service's response to the client but never destroyed the upstream request when the client went away. The upstream socket stayed open and unread, so a Server-Sent Events service kept writing into it and grew in memory. The proxy now destroys the upstream request when the client response closes before it finished, without logging that deliberate abort as an unreachable upstream, and a WebSocket upgrade closes the service socket when the client socket closes. Fixes #8149 * test(server): publish terminal activity fixture atomically File existence did not mean the child had finished writing its JSON. Publish the completed fixture by rename so a concurrent reader cannot parse an empty file. Co-authored-by: Dominic Anaeto * test(server): keep rollback listener ports distinct Selecting both free ports before binding either lets the OS return the same port twice. Hold the intentionally occupied main port before selecting the standalone port so the rollback probe cannot reach the wrong server. --------- Co-authored-by: Dominic Anaeto Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../server/src/server/bootstrap.smoke.test.ts | 2 +- .../server/src/server/service-proxy.test.ts | 277 ++++++++++++++++-- packages/server/src/server/service-proxy.ts | 10 + .../server/terminal-activity-route.test.ts | 8 +- 4 files changed, 264 insertions(+), 33 deletions(-) diff --git a/packages/server/src/server/bootstrap.smoke.test.ts b/packages/server/src/server/bootstrap.smoke.test.ts index 76fa8cc7d1..ac045741f1 100644 --- a/packages/server/src/server/bootstrap.smoke.test.ts +++ b/packages/server/src/server/bootstrap.smoke.test.ts @@ -670,11 +670,11 @@ describe("paseo daemon bootstrap", () => { test("rolls back standalone listener without starting plugins when main listen fails", async () => { const mainPort = await findFreePort(); - const standalonePort = await findFreePort(); const occupiedMain = http.createServer((_req, res) => { res.end("occupied-main"); }); await new Promise((resolve) => occupiedMain.listen(mainPort, "127.0.0.1", resolve)); + const standalonePort = await findFreePort(); const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-main-rollback-")); const paseoHome = path.join(paseoHomeRoot, ".paseo"); diff --git a/packages/server/src/server/service-proxy.test.ts b/packages/server/src/server/service-proxy.test.ts index 5764b19539..eb679d5629 100644 --- a/packages/server/src/server/service-proxy.test.ts +++ b/packages/server/src/server/service-proxy.test.ts @@ -1,7 +1,9 @@ +import { type EventEmitter, once } from "node:events"; import { readdirSync, readFileSync, statSync } from "node:fs"; import path from "node:path"; import http from "node:http"; import net from "node:net"; +import type { Duplex } from "node:stream"; import express from "express"; import { describe, expect, it } from "vitest"; import pino from "pino"; @@ -332,48 +334,39 @@ describe("service proxy subsystem shape", () => { }); }); -interface ForwardedFixture { +interface ProxiedServiceFixture { upstreamPort: number; daemonPort: number; + daemon: http.Server; hostname: string; close(): Promise; } /** - * Runs a real workspace service behind a real daemon listener. The upstream - * echoes the headers it received so tests can assert what actually crossed the - * proxy, not what a helper returned. + * Runs a real workspace service behind a real daemon listener that serves the + * service proxy for both HTTP and upgrades. */ -async function startForwardedHeadersFixture(upstreamHost = "127.0.0.1"): Promise { - const upstream = http.createServer((req, res) => { - let body = ""; - req.on("data", (chunk: Buffer) => { - body += chunk.toString(); +async function startProxiedService( + upstream: http.Server, + options: { upstreamHost?: string; logger?: pino.Logger } = {}, +): Promise { + const { upstreamHost = "127.0.0.1", logger: proxyLogger = logger } = options; + // Upgraded sockets keep the server alive; close() alone would hang. + const sockets = new Set(); + function trackConnections(server: http.Server) { + server.on("connection", (socket: net.Socket) => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); }); - req.on("end", () => { - res.writeHead(200, { "content-type": "application/json" }); - res.end(JSON.stringify({ ...req.headers, method: req.method, url: req.url, body })); - }); - }); - const upgradeSockets: net.Socket[] = []; - upstream.on("upgrade", (req, socket) => { - upgradeSockets.push(socket); - // The client resets this connection once it has the echo, so the reset - // reaches us as an 'error'. A raw upgrade socket has no default handler — - // without this the event goes unhandled and takes down the test process. - socket.on("error", () => socket.destroy()); - const payload = JSON.stringify(req.headers); - socket.write( - `HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nX-Echo-Length: ${payload.length}\r\n\r\n${payload}`, - ); - }); + } + trackConnections(upstream); await new Promise((resolve, reject) => { upstream.once("error", reject); upstream.listen(0, upstreamHost, resolve); }); const upstreamPort = (upstream.address() as net.AddressInfo).port; - const serviceProxy = createServiceProxySubsystem({ logger }); + const serviceProxy = createServiceProxySubsystem({ logger: proxyLogger }); const route = serviceProxy.registerWorkspaceService({ workspaceId: "workspace-a", projectSlug: "repo", @@ -390,24 +383,51 @@ async function startForwardedHeadersFixture(upstreamHost = "127.0.0.1"): Promise res.status(404).send("404 Not Found"); }); const daemon = http.createServer(app); + trackConnections(daemon); daemon.on("upgrade", serviceProxy.upgradeHandler({ passthroughUnknown: false })); await new Promise((resolve) => daemon.listen(daemonPort, "127.0.0.1", resolve)); return { upstreamPort, daemonPort, + daemon, hostname: route.hostname, async close() { - // Upgraded sockets keep the server alive; close() alone would hang. - for (const socket of upgradeSockets) socket.destroy(); - daemon.closeAllConnections(); + for (const socket of sockets) socket.destroy(); await new Promise((resolve) => daemon.close(() => resolve())); - upstream.closeAllConnections(); await new Promise((resolve) => upstream.close(() => resolve())); }, }; } +/** + * The upstream echoes the headers it received so tests can assert what + * actually crossed the proxy, not what a helper returned. + */ +function startForwardedHeadersFixture(upstreamHost = "127.0.0.1"): Promise { + const upstream = http.createServer((req, res) => { + let body = ""; + req.on("data", (chunk: Buffer) => { + body += chunk.toString(); + }); + req.on("end", () => { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ ...req.headers, method: req.method, url: req.url, body })); + }); + }); + upstream.on("upgrade", (req, socket) => { + // The client resets this connection once it has the echo, so the reset + // reaches us as an 'error'. A raw upgrade socket has no default handler — + // without this the event goes unhandled and takes down the test process. + socket.on("error", () => socket.destroy()); + const payload = JSON.stringify(req.headers); + socket.write( + `HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nX-Echo-Length: ${payload.length}\r\n\r\n${payload}`, + ); + }); + return startProxiedService(upstream, { upstreamHost }); +} + function upgradeThroughProxy( port: number, host: string, @@ -651,3 +671,198 @@ describe("service proxy forwarded headers", () => { } }); }); + +function closed(emitter: EventEmitter): Promise { + return new Promise((resolve) => { + emitter.once("close", () => resolve()); + }); +} + +async function within(promise: Promise, ms: number, failure: string): Promise { + let timer: NodeJS.Timeout | undefined; + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error(`${failure} after ${ms}ms`)), ms); + }); + try { + return await Promise.race([promise, timeout]); + } finally { + clearTimeout(timer); + } +} + +function requestThroughProxy( + fixture: ProxiedServiceFixture, + requestPath: string, +): http.ClientRequest { + return http.get({ + hostname: "127.0.0.1", + port: fixture.daemonPort, + path: requestPath, + headers: { host: fixture.hostname }, + }); +} + +const STRAY_WARNING_GRACE_MS = 100; + +function createUpgradeUpstream(onUpgrade: (socket: Duplex) => void): http.Server { + const upstream = http.createServer(); + upstream.on("upgrade", (_req, socket) => { + socket.on("error", () => socket.destroy()); + socket.on("end", () => socket.end()); + socket.write( + "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n", + ); + onUpgrade(socket); + }); + return upstream; +} + +function openUpgradeThroughProxy(fixture: ProxiedServiceFixture): Promise { + return new Promise((resolve, reject) => { + const socket = net.connect({ host: "127.0.0.1", port: fixture.daemonPort }, () => { + const lines = [ + "GET /ws HTTP/1.1", + `Host: ${fixture.hostname}`, + "Upgrade: websocket", + "Connection: Upgrade", + "", + "", + ]; + socket.write(lines.join("\r\n")); + }); + let raw = ""; + socket.on("data", (chunk: Buffer) => { + raw += chunk.toString(); + if (raw.includes("\r\n\r\n")) resolve(socket); + }); + socket.on("close", () => reject(new Error("upgrade closed before the 101 arrived"))); + socket.on("error", reject); + }); +} + +describe("service proxy connection teardown", () => { + it("closes the upstream connection when a client leaves an event stream", async () => { + const upstreamResponse = Promise.withResolvers(); + const upstream = http.createServer((_req, res) => { + res.writeHead(200, { "content-type": "text/event-stream" }); + res.write("data: open\n\n"); + upstreamResponse.resolve(res); + }); + const fixture = await startProxiedService(upstream); + try { + const request = requestThroughProxy(fixture, "/events"); + const [response] = (await once(request, "response")) as [http.IncomingMessage]; + await once(response, "data"); + const upstreamClosed = closed(await upstreamResponse.promise); + + request.destroy(); + + await within(upstreamClosed, 1000, "the service still holds the event stream"); + } finally { + await fixture.close(); + } + }, 5000); + + it("aborts the upstream request when a client leaves before the response starts", async () => { + const warnings: unknown[] = []; + const proxyLogger = pino( + { level: "warn" }, + { write: (line: string) => warnings.push(JSON.parse(line)) }, + ); + const upstreamResponse = Promise.withResolvers(); + const upstream = http.createServer((_req, res) => { + upstreamResponse.resolve(res); + }); + const fixture = await startProxiedService(upstream, { logger: proxyLogger }); + try { + const request = requestThroughProxy(fixture, "/slow"); + request.on("error", () => undefined); + const upstreamClosed = closed(await upstreamResponse.promise); + + request.destroy(); + + await within(upstreamClosed, 1000, "the service still holds the abandoned request"); + await new Promise((resolve) => setTimeout(resolve, STRAY_WARNING_GRACE_MS)); + expect(warnings).toEqual([]); + } finally { + await fixture.close(); + } + }, 5000); + + it("lets a complete response finish without resetting the service", async () => { + const upstreamResponse = Promise.withResolvers(); + const upstream = http.createServer((_req, res) => { + res.writeHead(200, { "content-type": "text/plain" }); + res.write("first;"); + upstreamResponse.resolve(res); + }); + const fixture = await startProxiedService(upstream); + try { + const response = httpGet(fixture.daemonPort, fixture.hostname, { path: "/" }); + const res = await upstreamResponse.promise; + const upstreamClosed = closed(res); + + res.end("second"); + + await expect(response).resolves.toEqual({ status: 200, body: "first;second" }); + await within(upstreamClosed, 1000, "the service response never closed"); + expect(res.writableFinished).toBe(true); + } finally { + await fixture.close(); + } + }, 5000); + + it("closes the upstream WebSocket when the daemon drops the client socket", async () => { + const upstreamSocket = Promise.withResolvers(); + const fixture = await startProxiedService( + createUpgradeUpstream((socket) => upstreamSocket.resolve(socket)), + ); + const daemonSide = Promise.withResolvers(); + fixture.daemon.on("upgrade", (_req, socket) => daemonSide.resolve(socket)); + try { + const client = await openUpgradeThroughProxy(fixture); + const upstreamClosed = closed(await upstreamSocket.promise); + + (await daemonSide.promise).destroy(); + + await within(upstreamClosed, 1000, "the service still holds the WebSocket"); + client.destroy(); + } finally { + await fixture.close(); + } + }, 5000); + + it("closes the upstream WebSocket when the client disconnects", async () => { + const upstreamSocket = Promise.withResolvers(); + const fixture = await startProxiedService( + createUpgradeUpstream((socket) => upstreamSocket.resolve(socket)), + ); + try { + const client = await openUpgradeThroughProxy(fixture); + const upstreamClosed = closed(await upstreamSocket.promise); + + client.destroy(); + + await within(upstreamClosed, 1000, "the service still holds the WebSocket"); + } finally { + await fixture.close(); + } + }, 5000); + + it("closes the client WebSocket when the service closes it", async () => { + const upstreamSocket = Promise.withResolvers(); + const fixture = await startProxiedService( + createUpgradeUpstream((socket) => upstreamSocket.resolve(socket)), + ); + try { + const client = await openUpgradeThroughProxy(fixture); + const clientClosed = closed(client); + + (await upstreamSocket.promise).destroy(); + + await within(clientClosed, 1000, "the client WebSocket is still open"); + } finally { + await fixture.close(); + } + }, 5000); +}); diff --git a/packages/server/src/server/service-proxy.ts b/packages/server/src/server/service-proxy.ts index ef93a27f9a..24bfd80c93 100644 --- a/packages/server/src/server/service-proxy.ts +++ b/packages/server/src/server/service-proxy.ts @@ -342,7 +342,14 @@ function proxyHttpRequest({ proxyRes.pipe(res, { end: true }); }, ); + let clientClosed = false; + res.on("close", () => { + if (res.writableFinished) return; + clientClosed = true; + proxyReq.destroy(); + }); proxyReq.on("error", (err) => { + if (clientClosed) return; logger.warn( { err, hostname: route.hostname, port: route.port }, "Service proxy: upstream unreachable", @@ -399,6 +406,9 @@ function proxyUpgradeRequest({ socket.on("error", () => { targetSocket.destroy(); }); + socket.on("close", () => { + targetSocket.destroy(); + }); } function sameRouteOwner(left: ServiceProxyRouteEntry, right: ServiceProxyRouteEntry): boolean { diff --git a/packages/server/src/server/terminal-activity-route.test.ts b/packages/server/src/server/terminal-activity-route.test.ts index afc97d77e8..e047c56ab9 100644 --- a/packages/server/src/server/terminal-activity-route.test.ts +++ b/packages/server/src/server/terminal-activity-route.test.ts @@ -95,7 +95,13 @@ it("accepts terminalId and token reports through the route into the tracker", as command: process.execPath, args: [ "-e", - `require("node:fs").writeFileSync(${JSON.stringify(envPath)}, JSON.stringify({ terminalId: process.env.PASEO_TERMINAL_ID, token: process.env.PASEO_ACTIVITY_TOKEN, url: process.env.PASEO_TERMINAL_ACTIVITY_URL })); setInterval(() => {}, 1000);`, + ` + const fs = require("node:fs"); + const envPath = ${JSON.stringify(envPath)}; + fs.writeFileSync(envPath + ".tmp", JSON.stringify({ terminalId: process.env.PASEO_TERMINAL_ID, token: process.env.PASEO_ACTIVITY_TOKEN, url: process.env.PASEO_TERMINAL_ACTIVITY_URL })); + fs.renameSync(envPath + ".tmp", envPath); + setInterval(() => {}, 1000); + `, ], }); await waitForCondition(() => existsSync(envPath), 10000); From 60f29a435073ef50fe3bfacacbb1bdb9c2ee1c02 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 1 Nov 2026 06:31:01 +0100 Subject: [PATCH 23/58] docs(worktrees): add nested worktreeinclude reference --- packages/website/src/docs.ts | 8 ++- .../src/plugin-docs-navigation.test.ts | 19 ++++++ .../{worktrees.md => worktrees/index.md} | 11 +--- .../index.zh-CN.md} | 11 +--- public-docs/worktrees/worktreeinclude.md | 61 +++++++++++++++++++ .../worktrees/worktreeinclude.zh-CN.md | 61 +++++++++++++++++++ 6 files changed, 150 insertions(+), 21 deletions(-) rename public-docs/{worktrees.md => worktrees/index.md} (91%) rename public-docs/{worktrees.zh-CN.md => worktrees/index.zh-CN.md} (92%) create mode 100644 public-docs/worktrees/worktreeinclude.md create mode 100644 public-docs/worktrees/worktreeinclude.zh-CN.md diff --git a/packages/website/src/docs.ts b/packages/website/src/docs.ts index 1325cd933f..896d86daf9 100644 --- a/packages/website/src/docs.ts +++ b/packages/website/src/docs.ts @@ -186,7 +186,13 @@ function insertDocByPath(nodes: DocsNavNode[], doc: Doc, stripTopDirectory = fal // A section's top-level directory and its category name the same thing, so // only directories below it become collapsible groups. - if (stripTopDirectory) directories.shift(); + if ( + stripTopDirectory && + directories[0] && + doc.frontmatter.category?.toLowerCase().endsWith(directories[0].toLowerCase()) + ) { + directories.shift(); + } let current = nodes; let parent: Extract | undefined; diff --git a/packages/website/src/plugin-docs-navigation.test.ts b/packages/website/src/plugin-docs-navigation.test.ts index 93f0cf7736..a3e537620a 100644 --- a/packages/website/src/plugin-docs-navigation.test.ts +++ b/packages/website/src/plugin-docs-navigation.test.ts @@ -2,6 +2,25 @@ import { describe, expect, it } from "vitest"; import { buildDocsNavTree, getDoc, getDocs, getLegacyDocsRedirect, parseHeadings } from "./docs"; describe("plugin documentation", () => { + it.each(["en", "zh-CN"] as const)( + "nests local file copying under Git worktrees in %s", + (locale) => { + const workspaces = buildDocsNavTree(getDocs(locale)).find( + (node) => node.type === "category" && node.label === "Workspaces", + ); + expect(workspaces).toMatchObject({ + children: expect.arrayContaining([ + expect.objectContaining({ + type: "group", + href: "/docs/worktrees", + children: [expect.objectContaining({ href: "/docs/worktrees/worktreeinclude" })], + }), + ]), + }); + expect(getDoc("worktrees/worktreeinclude", locale)).toBeDefined(); + }, + ); + it("groups current guides by task without version navigation", () => { const plugins = buildDocsNavTree(getDocs("en")).find( (node) => node.type === "category" && node.label === "Plugins", diff --git a/public-docs/worktrees.md b/public-docs/worktrees/index.md similarity index 91% rename from public-docs/worktrees.md rename to public-docs/worktrees/index.md index baaea2e177..faabb2d81f 100644 --- a/public-docs/worktrees.md +++ b/public-docs/worktrees/index.md @@ -84,16 +84,7 @@ Add `--forge ` when Paseo cannot infer the forge from the source checkout. ## Copy local files with .worktreeinclude -Create `.worktreeinclude` at the root of your source checkout to copy selected gitignored files into new worktrees: - -```gitignore -.env -config/*.local.json -``` - -Patterns use gitignore syntax. A file must match `.worktreeinclude` and be gitignored to be copied. Tracked files are never copied, and existing destination files are never overwritten. Without `.worktreeinclude`, no local files are copied. Symlinked directories and nested repositories inside an included path cause the copy to fail and abort worktree creation. - -The built-in `worktree-include` plugin copies from the checkout you create the worktree from, including when that checkout is another worktree. Copying finishes before setup, terminals, and the first agent. A copy failure aborts creation and removes the new worktree. Copies use copy-on-write clones on APFS and btrfs when supported, with a regular copy elsewhere. +Use [`.worktreeinclude`](/docs/worktrees/worktreeinclude) to copy selected gitignored files, such as `.env`, from your source checkout before setup runs. ## paseo.json diff --git a/public-docs/worktrees.zh-CN.md b/public-docs/worktrees/index.zh-CN.md similarity index 92% rename from public-docs/worktrees.zh-CN.md rename to public-docs/worktrees/index.zh-CN.md index e84d0a8a25..cce65fc090 100644 --- a/public-docs/worktrees.zh-CN.md +++ b/public-docs/worktrees/index.zh-CN.md @@ -82,16 +82,7 @@ paseo workspace create \ ## 使用 .worktreeinclude 复制本地文件 {#copy-local-files-with-worktreeinclude} -在源检出的根目录创建 `.worktreeinclude`,将选定的 Git 忽略文件复制到新的 worktree: - -```gitignore -.env -config/*.local.json -``` - -模式使用 gitignore 语法。只有同时匹配 `.worktreeinclude` 且被 Git 忽略的文件才会复制。已跟踪的文件不会被复制,目标中已存在的文件不会被覆盖。没有 `.worktreeinclude` 时,不会复制任何本地文件。包含路径中的符号链接目录和嵌套仓库会导致复制失败,并中止 worktree 创建。 - -内置的 `worktree-include` 插件从你创建 worktree 时所选的检出复制文件,即使源检出本身也是 worktree。复制会在 setup、终端和第一个 Agent 运行之前完成。复制失败会中止创建并删除新的 worktree。支持时会在 APFS 和 btrfs 上使用写时复制克隆,否则执行普通复制。 +使用 [`.worktreeinclude`](/docs/worktrees/worktreeinclude),在 setup 运行前将 `.env` 等选定的 Git 忽略文件从源检出复制到新 worktree。 ## paseo.json {#paseojson} diff --git a/public-docs/worktrees/worktreeinclude.md b/public-docs/worktrees/worktreeinclude.md new file mode 100644 index 0000000000..50c5f0370a --- /dev/null +++ b/public-docs/worktrees/worktreeinclude.md @@ -0,0 +1,61 @@ +--- +title: .worktreeinclude +description: Copy gitignored local files into new Paseo worktrees. +nav: .worktreeinclude +order: 12 +category: Workspaces +--- + +# .worktreeinclude + +Add `.worktreeinclude` at the root of your source checkout to copy local configuration into new [Git worktrees](/docs/worktrees). Paseo copies matching gitignored files before setup, terminals, and the first Agent start. + +## Copy your local configuration + +For a project whose `.gitignore` includes `.env` and `config/*.local.json`, create this `.worktreeinclude`: + +```gitignore +.env +config/*.local.json +``` + +When you create a worktree through Paseo, an existing `.env` and matching files such as `config/dev.local.json` are copied to the same relative paths in the new worktree. Their contents stay out of Git. + +Commit `.worktreeinclude` to share the patterns with your team. Each teammate supplies their own ignored files in the checkout they create worktrees from. + +## Pattern syntax + +Use gitignore syntax, with one pattern per line. Comments start with `#`. Prefix a pattern with `!` to exclude a match: + +```gitignore +# Local environment and configuration +.env +config/*.local.json +!config/production.local.json +``` + +A pattern only selects files that Git already ignores. Adding a path here does not ignore it, so also configure `.gitignore` when needed. Tracked files and untracked files that Git does not ignore are excluded. + +## When files are copied + +The built-in `worktree-include` plugin reads the file from the checkout you create the worktree from. That source can itself be another worktree. Keep the plugin enabled for copying to run. + +- Without `.worktreeinclude`, the plugin copies nothing. +- Matching files must exist in the source checkout. +- Existing destination files are skipped, preserving their contents. +- Copies happen during creation, with no ongoing synchronization between checkouts. +- A copy error aborts creation and removes the new worktree. Avoid including symlinked directories or nested repositories, which cannot be copied as files. + +## Run commands during setup + +Use `.worktreeinclude` for files that already exist locally. Use a [setup hook](/docs/worktrees#setup-and-teardown) to install dependencies, generate files, or fetch secrets: + +```json +{ + "worktree": { + "setup": "npm ci" + } +} +``` + +Put this configuration in `paseo.json` on the base branch used for the new worktree. Copied files are available when setup runs. diff --git a/public-docs/worktrees/worktreeinclude.zh-CN.md b/public-docs/worktrees/worktreeinclude.zh-CN.md new file mode 100644 index 0000000000..e80e386a01 --- /dev/null +++ b/public-docs/worktrees/worktreeinclude.zh-CN.md @@ -0,0 +1,61 @@ +--- +title: .worktreeinclude +description: 将 Git 忽略的本地文件复制到新的 Paseo worktree。 +nav: .worktreeinclude +order: 12 +category: Workspaces +--- + +# .worktreeinclude + +在源检出的根目录添加 `.worktreeinclude`,将本地配置复制到新的 [Git worktree](/docs/worktrees)。Paseo 会在 setup、终端和首个 Agent 启动之前复制匹配的 Git 忽略文件。 + +## 复制本地配置 {#copy-your-local-configuration} + +如果项目的 `.gitignore` 包含 `.env` 和 `config/*.local.json`,创建以下 `.worktreeinclude`: + +```gitignore +.env +config/*.local.json +``` + +通过 Paseo 创建 worktree 时,已有的 `.env` 和 `config/dev.local.json` 等匹配文件会被复制到新 worktree 中的相同相对路径。文件内容仍不会纳入 Git。 + +提交 `.worktreeinclude`,与团队共享这些模式。每位团队成员在用于创建 worktree 的检出中提供自己的忽略文件。 + +## 模式语法 {#pattern-syntax} + +使用 gitignore 语法,每行一个模式。注释以 `#` 开头。模式前加 `!` 可排除匹配项: + +```gitignore +# Local environment and configuration +.env +config/*.local.json +!config/production.local.json +``` + +模式只会选择 Git 已忽略的文件。在此添加路径不会使 Git 忽略该文件,需要时还应配置 `.gitignore`。已跟踪的文件和未被 Git 忽略的未跟踪文件都不会被复制。 + +## 文件复制时机 {#when-files-are-copied} + +内置的 `worktree-include` 插件从用于创建 worktree 的检出读取该文件。源检出也可以是另一个 worktree。保持该插件启用才能执行复制。 + +- 没有 `.worktreeinclude` 时,插件不会复制任何文件。 +- 匹配的文件必须存在于源检出中。 +- 目标中已存在的文件会被跳过,保留原有内容。 +- 复制发生在创建期间,之后不会在检出之间持续同步。 +- 复制错误会中止创建并删除新 worktree。避免包含符号链接目录或嵌套仓库,因为它们无法作为文件复制。 + +## 在 setup 期间运行命令 {#run-commands-during-setup} + +使用 `.worktreeinclude` 复制本地已有文件。使用 [setup 钩子](/docs/worktrees#setup-and-teardown) 安装依赖、生成文件或获取密钥: + +```json +{ + "worktree": { + "setup": "npm ci" + } +} +``` + +将此配置放入新 worktree 所用基准分支的 `paseo.json` 中。setup 运行时,复制的文件已可使用。 From ef77b823ae6d4c2c1a11dad3df5a34c6ececa8f3 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 1 Nov 2026 12:56:41 +0700 Subject: [PATCH 24/58] chore(release): reconcile 0.15.0-beta.1 metadata (#8443) * docs(changelog): prepare 0.15.0-beta.1 (cherry picked from commit 7e5ad53491ac38372ead1f9773a82d284ce491fa) * chore(release): cut 0.15.0-beta.1 (cherry picked from commit 8bd38fb23645652078cb629baa2e65e0b6baa65a) * test(docs): follow nested worktrees page --- CHANGELOG.md | 78 +++++++++++++++++++-- CHANGELOG.zh-CN.md | 80 ++++++++++++++++++++-- nix/npm-deps.hash | 2 +- package-lock.json | 56 +++++++-------- package.json | 2 +- packages/app/package.json | 2 +- packages/cli/package.json | 8 +-- packages/client/package.json | 6 +- packages/desktop/package.json | 2 +- packages/expo-two-way-audio/package.json | 2 +- packages/highlight/package.json | 2 +- packages/plugin/package.json | 10 +-- packages/protocol/package.json | 2 +- packages/relay/package.json | 2 +- packages/server/package.json | 12 ++-- packages/website/package.json | 2 +- packages/website/src/schedule-docs.test.ts | 2 +- plugins/package.json | 2 +- 18 files changed, 202 insertions(+), 70 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e47984dbf7..6ee9a07b36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,15 +1,81 @@ # Changelog -Not released yet. These changes are on `main` but not in 0.14.0; the next release entry includes them. +## 0.15.0-beta.1 - 2026-11-01 -Added: +Daemon restarts now keep terminal programs running and automatically resume interrupted agents in their existing conversations. -- Copy gitignored files selected by `.worktreeinclude` into new worktrees before setup and agents, using the built-in `worktree-include` plugin ([#8100](https://github.com/getpaseo/paseo/pull/8100)) -- Let plugins choose worktree paths and creation options through `worktree.create.before`, with listing, archive, deletion, and recovery at custom locations; includes a sibling-directory placement example ([#8117](https://github.com/getpaseo/paseo/pull/8117)) +Mobile terminals have native rendering and draggable text-selection handles. Reconnecting restores up to 10,000 lines of terminal history. -Improved: +Worktree setup can copy ignored files and use plugin-defined locations before agents start. -- Added `server.addHook` with `subject.operation.position` names and the awaited `worktree.create.after` hook before setup and agents; deprecated the previous plugin hook registration methods without changing their behavior ([#8081](https://github.com/getpaseo/paseo/pull/8081)) +### Added + +- Insert known agent commands from replies into the Composer draft ([#8371](https://github.com/getpaseo/paseo/pull/8371)) +- Reuse running OpenCode v2 services ([#7435](https://github.com/getpaseo/paseo/pull/7435) by [@mcowger](https://github.com/mcowger)) +- Kept terminal programs running across daemon restarts, crashes and supported updates ([#7644](https://github.com/getpaseo/paseo/pull/7644), [#8155](https://github.com/getpaseo/paseo/pull/8155) by [@sebastianarena](https://github.com/sebastianarena)) +- Automatically resume interrupted agents in their existing conversations after a host restart ([#8127](https://github.com/getpaseo/paseo/pull/8127) by [@coolwindX](https://github.com/coolwindX), [@suntianc](https://github.com/suntianc)) +- Added draggable terminal text-selection handles on iOS and Android ([#8348](https://github.com/getpaseo/paseo/pull/8348)) +- Let phones and tablets rotate with the system orientation setting ([#8256](https://github.com/getpaseo/paseo/pull/8256) by [@magiclaw](https://github.com/magiclaw), [@yzim](https://github.com/yzim)) +- Copy ignored files selected by `.worktreeinclude` into new worktrees before setup and agents start ([#8100](https://github.com/getpaseo/paseo/pull/8100) by [@cprecioso](https://github.com/cprecioso)) +- Added daemon-wide support for `HTTP_PROXY`, `HTTPS_PROXY` and `NO_PROXY` ([#8177](https://github.com/getpaseo/paseo/pull/8177)) +- Fetch usage only while displayed, with a host setting to disable usage checks entirely ([#8181](https://github.com/getpaseo/paseo/pull/8181)) + +### Improved + +- Replaced mobile terminal text rendering to align CJK characters, emoji, styled text and the cursor with terminal columns ([#8336](https://github.com/getpaseo/paseo/pull/8336), [#8375](https://github.com/getpaseo/paseo/pull/8375)) +- Restore the configured terminal scrollback on reconnect, up to 10,000 retained lines ([#8282](https://github.com/getpaseo/paseo/pull/8282) by [@DrakenKor](https://github.com/DrakenKor), [@lennartschoch](https://github.com/lennartschoch)) +- Load paginated Codex histories when importing or reopening sessions ([#8171](https://github.com/getpaseo/paseo/pull/8171) by [@dundunHa](https://github.com/dundunHa), [@hr95savage](https://github.com/hr95savage)) +- Stopped rereading and retaining an unused copy of Codex history when reopening a cached chat ([#8132](https://github.com/getpaseo/paseo/pull/8132)) +- Split large daemon responses across relay frames instead of disconnecting at the frame limit ([#8302](https://github.com/getpaseo/paseo/pull/8302)) +- Truncate oversized chat entries before sending them to the app, including large tool results and embedded data ([#8274](https://github.com/getpaseo/paseo/pull/8274)) +- Show subagent activity in its own panel instead of duplicating it inside the parent’s tool card ([#8276](https://github.com/getpaseo/paseo/pull/8276)) +- Show exact usage reset times alongside more precise countdowns ([#8162](https://github.com/getpaseo/paseo/pull/8162)) + +### Plugins + +- Fixed discarded messages returning after rewinding plugin conversations ([#8368](https://github.com/getpaseo/paseo/pull/8368) by [@mcowger](https://github.com/mcowger), [@omercnet](https://github.com/omercnet)) +- Reduced plugin downloads with smaller client bundles and a cache reused across reconnects and app launches ([#8040](https://github.com/getpaseo/paseo/pull/8040), [#8076](https://github.com/getpaseo/paseo/pull/8076) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev)) +- Preserved plugin screens and local state during temporary host disconnections ([#8105](https://github.com/getpaseo/paseo/pull/8105) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev)) +- Let plugins choose worktree locations and creation options through `worktree.create.before` ([#8117](https://github.com/getpaseo/paseo/pull/8117) by [@mcowger](https://github.com/mcowger)) +- Added `server.addHook` with an awaited `worktree.create.after` hook before workspace setup and agents start ([#8081](https://github.com/getpaseo/paseo/pull/8081)) +- Released redundant plugin streaming snapshots instead of retaining them for the session’s lifetime ([#7675](https://github.com/getpaseo/paseo/pull/7675) by [@suntianc](https://github.com/suntianc)) + +### Fixed + +- Fixed rewinding Claude conversations to messages sent during an active turn ([#8372](https://github.com/getpaseo/paseo/pull/8372) by [@tieo](https://github.com/tieo)) +- Fixed duplicate rows and missing replies when resuming Claude subagents after reopening a session ([#8408](https://github.com/getpaseo/paseo/pull/8408) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev)) +- Fixed Codex agents created without a prompt failing after their runtime reopens ([#8393](https://github.com/getpaseo/paseo/pull/8393) by [@babo-jeonguk](https://github.com/babo-jeonguk)) +- Fixed newly running agents appearing as finished immediately after creation ([#8396](https://github.com/getpaseo/paseo/pull/8396) by [@luoph](https://github.com/luoph)) +- Fixed ACP startup exits waiting for a timeout instead of reporting the process error ([#8384](https://github.com/getpaseo/paseo/pull/8384) by [@Peanut-HZM](https://github.com/Peanut-HZM)) +- Fixed skills missing from new ACP agents when commands arrive in multiple batches ([#8387](https://github.com/getpaseo/paseo/pull/8387) by [@Peanut-HZM](https://github.com/Peanut-HZM)) +- Fixed sidebar titles and statuses stopping updates after directory subscriptions are released ([#8403](https://github.com/getpaseo/paseo/pull/8403) by [@ragokan](https://github.com/ragokan)) +- Kept the open workspace visible in collapsed sidebar groups ([#8395](https://github.com/getpaseo/paseo/pull/8395) by [@0xroylee](https://github.com/0xroylee)) +- Fixed relative chat links opening missing files instead of files in nested repositories ([#8361](https://github.com/getpaseo/paseo/pull/8361) by [@apple-ouyang](https://github.com/apple-ouyang)) +- Accepted host:port and pasted URLs in the Direct connection Host field ([#8223](https://github.com/getpaseo/paseo/pull/8223) by [@DaydreamCoding](https://github.com/DaydreamCoding)) +- Fixed terminal Ctrl+V paste over plain HTTP on non-Mac keyboards ([#8230](https://github.com/getpaseo/paseo/pull/8230) by [@DaydreamCoding](https://github.com/DaydreamCoding)) +- Stopped failed history loads from repeatedly reopening the provider, with Retry available in the chat ([#8278](https://github.com/getpaseo/paseo/pull/8278)) +- Fixed Claude settings files being discarded when Paseo adds its own launch settings ([#8148](https://github.com/getpaseo/paseo/pull/8148) by [@yayugu](https://github.com/yayugu)) +- Fixed steering messages replacing a turn that was still starting and losing its original prompt ([#6192](https://github.com/getpaseo/paseo/pull/6192)) +- Fixed Pi tool-result images missing during streaming and after reopening the chat ([#8280](https://github.com/getpaseo/paseo/pull/8280) by [@duyiliu](https://github.com/duyiliu)) +- Fixed completed `edxeth/pi-subagents` children remaining marked as working ([#8176](https://github.com/getpaseo/paseo/pull/8176) by [@Zeno2019](https://github.com/Zeno2019)) +- Fixed Claude background subagent status after reopening a conversation ([#6259](https://github.com/getpaseo/paseo/pull/6259)) +- Fixed reused plugin subagents showing their previous completed status during new work ([#8080](https://github.com/getpaseo/paseo/pull/8080) by [@bccy](https://github.com/bccy)) +- Fixed custom Codex providers with reserved names such as `ollama` ([#6954](https://github.com/getpaseo/paseo/pull/6954) by [@mr860819](https://github.com/mr860819)) +- Fixed the MiniMax Code catalog command failing to select an executable ([#8201](https://github.com/getpaseo/paseo/pull/8201) by [@Peanut-HZM](https://github.com/Peanut-HZM)) +- Fixed Android keeping the wrong layout after folding or unfolding while backgrounded ([#8285](https://github.com/getpaseo/paseo/pull/8285)) +- Fixed Android workspace layouts and header fades retaining their old size after rotation ([#8349](https://github.com/getpaseo/paseo/pull/8349), [#8377](https://github.com/getpaseo/paseo/pull/8377)) +- Fixed deleted agents leaving unclosable tabs in another window ([#8248](https://github.com/getpaseo/paseo/pull/8248) by [@loganmeyer](https://github.com/loganmeyer)) +- Fixed transcript text retaining old theme colors after the theme changes while the window is hidden ([#8199](https://github.com/getpaseo/paseo/pull/8199) by [@Nurshot](https://github.com/Nurshot), [@dansabin](https://github.com/dansabin)) +- Fixed web menus snapping back to their loading size after content arrives ([#8180](https://github.com/getpaseo/paseo/pull/8180) by [@babo-jeonguk](https://github.com/babo-jeonguk)) +- Fixed split dividers drifting ahead of or behind the pointer while dragging ([#6011](https://github.com/getpaseo/paseo/pull/6011)) +- Fixed delayed native filesystem watcher startup leaving Git updates unobserved ([#8107](https://github.com/getpaseo/paseo/pull/8107)) +- Fixed bundled skill arguments being passed incorrectly to Codex and Pi ([#8252](https://github.com/getpaseo/paseo/pull/8252) by [@AspireOne](https://github.com/AspireOne)) + +### Notes + +- The first upgrade from 0.14.0 still closes terminals; terminal preservation starts once the new daemon is installed, and Windows desktop app upgrades still close them +- Automatic agent recovery is on by default and sends `Resume working.`; change the prompt or disable it in Host settings → Agents +- Before downgrading to 0.14.0, restore a backed-up `config.json` or remove the saved agent-resume and usage-check settings ## 0.14.0 - 2026-10-29 diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index f87cb58805..532ed24887 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -1,15 +1,81 @@ -# 更新日志 +# Changelog -尚未发布。以下变更已合入 `main`,但不包含在 0.14.0 中;下一个版本的条目会包含它们。 +## 0.15.0-beta.1 - 2026-11-01 -新增: +daemon 重启后,终端中的程序继续运行,被中断的 Agent 会自动在原有会话中恢复工作。 -- 在 setup 和 Agent 启动前,使用内置的 `worktree-include` 插件将 `.worktreeinclude` 选中的被 gitignore 忽略的文件复制到新工作树 ([#8100](https://github.com/getpaseo/paseo/pull/8100)) -- 插件可通过 `worktree.create.before` 选择工作树路径和创建选项,自定义位置的工作树支持列出、归档、删除和恢复;附带同级目录放置示例 ([#8117](https://github.com/getpaseo/paseo/pull/8117)) +移动端终端采用原生渲染,并提供可拖动的文本选择手柄。重新连接时最多恢复 10,000 行终端历史。 -改进: +worktree 初始化支持复制被忽略的文件,并在 Agent 启动前使用插件指定的位置。 -- 新增采用 `subject.operation.position` 命名的 `server.addHook` 和在 setup 及 Agent 之前等待的 `worktree.create.after` 钩子,弃用原有的插件钩子注册方法并保留原有行为 ([#8081](https://github.com/getpaseo/paseo/pull/8081)) +### 新增 + +- 点击回复中的已知 Agent 命令,将其插入 Composer 草稿 ([#8371](https://github.com/getpaseo/paseo/pull/8371)) +- 复用已运行的 OpenCode v2 服务 ([#7435](https://github.com/getpaseo/paseo/pull/7435),贡献者:[@mcowger](https://github.com/mcowger)) +- 让终端中的程序在 daemon 重启、崩溃和支持的更新过程中继续运行 ([#7644](https://github.com/getpaseo/paseo/pull/7644), [#8155](https://github.com/getpaseo/paseo/pull/8155),贡献者:[@sebastianarena](https://github.com/sebastianarena)) +- 主机重启后,自动在原有会话中恢复被中断的 Agent ([#8127](https://github.com/getpaseo/paseo/pull/8127),贡献者:[@coolwindX](https://github.com/coolwindX), [@suntianc](https://github.com/suntianc)) +- 在 iOS 和 Android 终端中新增可拖动的文本选择手柄 ([#8348](https://github.com/getpaseo/paseo/pull/8348)) +- 让手机和平板遵循系统屏幕旋转设置 ([#8256](https://github.com/getpaseo/paseo/pull/8256),贡献者:[@magiclaw](https://github.com/magiclaw), [@yzim](https://github.com/yzim)) +- 通过 `.worktreeinclude` 选择要复制到新 worktree 的被忽略文件,在初始化和 Agent 启动前完成复制 ([#8100](https://github.com/getpaseo/paseo/pull/8100),贡献者:[@cprecioso](https://github.com/cprecioso)) +- 让 daemon 的网络请求支持 `HTTP_PROXY`、`HTTPS_PROXY` 和 `NO_PROXY` ([#8177](https://github.com/getpaseo/paseo/pull/8177)) +- 仅在显示用量时查询用量,并提供主机设置以完全关闭用量查询 ([#8181](https://github.com/getpaseo/paseo/pull/8181)) + +### 改进 + +- 更换移动端终端文本渲染方式,让中日韩字符、表情符号、带样式文本和光标对齐终端列 ([#8336](https://github.com/getpaseo/paseo/pull/8336), [#8375](https://github.com/getpaseo/paseo/pull/8375)) +- 重新连接时恢复设置指定的终端回滚历史,最多保留 10,000 行 ([#8282](https://github.com/getpaseo/paseo/pull/8282),贡献者:[@DrakenKor](https://github.com/DrakenKor), [@lennartschoch](https://github.com/lennartschoch)) +- 导入或重新打开 Codex 会话时加载分页历史 ([#8171](https://github.com/getpaseo/paseo/pull/8171),贡献者:[@dundunHa](https://github.com/dundunHa), [@hr95savage](https://github.com/hr95savage)) +- 重新打开已有缓存的 Codex 聊天时,不再重复读取并保留未使用的历史副本 ([#8132](https://github.com/getpaseo/paseo/pull/8132)) +- 将 daemon 的大型响应拆分为多个 relay 帧,避免达到单帧限制时断开连接 ([#8302](https://github.com/getpaseo/paseo/pull/8302)) +- 向应用发送聊天条目前截断超大内容,包括大型工具结果和内嵌数据 ([#8274](https://github.com/getpaseo/paseo/pull/8274)) +- 在子 Agent 自己的面板中显示活动,不再在父 Agent 的工具卡片中重复显示 ([#8276](https://github.com/getpaseo/paseo/pull/8276)) +- 在更精确的倒计时旁显示用量限制的具体重置时间 ([#8162](https://github.com/getpaseo/paseo/pull/8162)) + +### 插件 + +- 修复了插件会话回退后已丢弃的消息重新出现的问题 ([#8368](https://github.com/getpaseo/paseo/pull/8368),贡献者:[@mcowger](https://github.com/mcowger), [@omercnet](https://github.com/omercnet)) +- 通过缩小客户端代码包并在重连和应用启动时复用缓存,减少插件下载量 ([#8040](https://github.com/getpaseo/paseo/pull/8040), [#8076](https://github.com/getpaseo/paseo/pull/8076),贡献者:[@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev)) +- 在主机暂时断开连接期间保留插件页面和本地状态 ([#8105](https://github.com/getpaseo/paseo/pull/8105),贡献者:[@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev)) +- 让插件通过 `worktree.create.before` 指定 worktree 的位置和创建选项 ([#8117](https://github.com/getpaseo/paseo/pull/8117),贡献者:[@mcowger](https://github.com/mcowger)) +- 新增 `server.addHook`,并在工作区初始化和 Agent 启动前等待 `worktree.create.after` hook 完成 ([#8081](https://github.com/getpaseo/paseo/pull/8081)) +- 释放多余的插件流式快照,不再将其保留到会话结束 ([#7675](https://github.com/getpaseo/paseo/pull/7675),贡献者:[@suntianc](https://github.com/suntianc)) + +### 修复 + +- 修复了无法将 Claude 会话回退到运行过程中发送的消息的问题 ([#8372](https://github.com/getpaseo/paseo/pull/8372),贡献者:[@tieo](https://github.com/tieo)) +- 修复了重新打开会话后恢复 Claude 子 Agent 时出现重复行和回复缺失的问题 ([#8408](https://github.com/getpaseo/paseo/pull/8408),贡献者:[@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev)) +- 修复了未发送提示词的 Codex Agent 在运行时重新打开后无法使用的问题 ([#8393](https://github.com/getpaseo/paseo/pull/8393),贡献者:[@babo-jeonguk](https://github.com/babo-jeonguk)) +- 修复了刚开始运行的 Agent 在创建后立即显示为已完成的问题 ([#8396](https://github.com/getpaseo/paseo/pull/8396),贡献者:[@luoph](https://github.com/luoph)) +- 修复了 ACP 启动进程退出后仍等待超时而未及时报告错误的问题 ([#8384](https://github.com/getpaseo/paseo/pull/8384),贡献者:[@Peanut-HZM](https://github.com/Peanut-HZM)) +- 修复了命令分批到达时新 ACP Agent 的技能列表不完整的问题 ([#8387](https://github.com/getpaseo/paseo/pull/8387),贡献者:[@Peanut-HZM](https://github.com/Peanut-HZM)) +- 修复了目录订阅释放后侧栏标题和状态停止更新的问题 ([#8403](https://github.com/getpaseo/paseo/pull/8403),贡献者:[@ragokan](https://github.com/ragokan)) +- 在折叠的侧栏分组中保持当前工作区可见 ([#8395](https://github.com/getpaseo/paseo/pull/8395),贡献者:[@0xroylee](https://github.com/0xroylee)) +- 修复了聊天中的相对链接未打开嵌套仓库中的文件而指向不存在文件的问题 ([#8361](https://github.com/getpaseo/paseo/pull/8361),贡献者:[@apple-ouyang](https://github.com/apple-ouyang)) +- 在直接连接的主机字段中支持 host:port 和粘贴的 URL ([#8223](https://github.com/getpaseo/paseo/pull/8223),贡献者:[@DaydreamCoding](https://github.com/DaydreamCoding)) +- 修复了非 Mac 键盘通过普通 HTTP 访问网页终端时 Ctrl+V 无法粘贴的问题 ([#8230](https://github.com/getpaseo/paseo/pull/8230),贡献者:[@DaydreamCoding](https://github.com/DaydreamCoding)) +- 停止在历史加载失败后反复重新打开 Provider,可在聊天中手动重试 ([#8278](https://github.com/getpaseo/paseo/pull/8278)) +- 修复了 Paseo 添加自身启动设置时丢弃 Claude 设置文件的问题 ([#8148](https://github.com/getpaseo/paseo/pull/8148),贡献者:[@yayugu](https://github.com/yayugu)) +- 修复了引导消息替换尚在启动的轮次并丢失原始提示词的问题 ([#6192](https://github.com/getpaseo/paseo/pull/6192)) +- 修复了 Pi 工具结果中的图片在流式输出和重新打开聊天后缺失的问题 ([#8280](https://github.com/getpaseo/paseo/pull/8280),贡献者:[@duyiliu](https://github.com/duyiliu)) +- 修复了 `edxeth/pi-subagents` 子 Agent 完成后仍显示为工作中的问题 ([#8176](https://github.com/getpaseo/paseo/pull/8176),贡献者:[@Zeno2019](https://github.com/Zeno2019)) +- 修复了重新打开会话后的 Claude 后台子 Agent 状态 ([#6259](https://github.com/getpaseo/paseo/pull/6259)) +- 修复了复用的插件子 Agent 开始新任务时仍显示上次已完成状态的问题 ([#8080](https://github.com/getpaseo/paseo/pull/8080),贡献者:[@bccy](https://github.com/bccy)) +- 修复了使用 `ollama` 等保留名称的自定义 Codex Provider ([#6954](https://github.com/getpaseo/paseo/pull/6954),贡献者:[@mr860819](https://github.com/mr860819)) +- 修复了 MiniMax Code 目录命令无法选定可执行文件的问题 ([#8201](https://github.com/getpaseo/paseo/pull/8201),贡献者:[@Peanut-HZM](https://github.com/Peanut-HZM)) +- 修复了 Android 应用在后台折叠或展开后保留错误布局的问题 ([#8285](https://github.com/getpaseo/paseo/pull/8285)) +- 修复了 Android 屏幕旋转后工作区布局和顶栏渐变保留旧尺寸的问题 ([#8349](https://github.com/getpaseo/paseo/pull/8349), [#8377](https://github.com/getpaseo/paseo/pull/8377)) +- 修复了删除 Agent 后,另一个窗口留下无法关闭的标签页的问题 ([#8248](https://github.com/getpaseo/paseo/pull/8248),贡献者:[@loganmeyer](https://github.com/loganmeyer)) +- 修复了窗口隐藏期间切换主题后,聊天文本保留旧主题颜色的问题 ([#8199](https://github.com/getpaseo/paseo/pull/8199),贡献者:[@Nurshot](https://github.com/Nurshot), [@dansabin](https://github.com/dansabin)) +- 修复了网页菜单加载内容后跳回加载时尺寸的问题 ([#8180](https://github.com/getpaseo/paseo/pull/8180),贡献者:[@babo-jeonguk](https://github.com/babo-jeonguk)) +- 修复了拖动分隔线时,分隔线偏离指针的问题 ([#6011](https://github.com/getpaseo/paseo/pull/6011)) +- 修复了原生文件监听器启动延迟导致 Git 更新未被监听的问题 ([#8107](https://github.com/getpaseo/paseo/pull/8107)) +- 修复了内置 skill 参数未正确传递给 Codex 和 Pi 的问题 ([#8252](https://github.com/getpaseo/paseo/pull/8252),贡献者:[@AspireOne](https://github.com/AspireOne)) + +### 说明 + +- 首次从 0.14.0 升级时仍会关闭终端;安装新 daemon 后才支持保留终端,Windows 桌面应用升级仍会关闭终端 +- Agent 自动恢复默认开启,并发送 `Resume working.`;可在主机设置 → Agent 中修改提示词或关闭此功能 +- 降级到 0.14.0 前,请恢复备份的 `config.json`,或删除已保存的 Agent 恢复和用量查询设置 ## 0.14.0 - 2026-10-29 diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index 530da9108f..3efbb30b52 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-WPPLqWQzoUdjZNbZW6XWpy+A6XHXDyc0QW0FH6ubgMw= +sha256-CeT2PtYIAFwvBC9LIku8VkTl0pvKxYzSCn0QW2ManfU= diff --git a/package-lock.json b/package-lock.json index 49f6258e60..4b9481ff8e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.14.0", + "version": "0.15.0-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.14.0", + "version": "0.15.0-beta.1", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ @@ -37632,7 +37632,7 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.14.0", + "version": "0.15.0-beta.1", "dependencies": { "@codemirror/commands": "6.10.4", "@codemirror/language": "6.12.4", @@ -39800,12 +39800,12 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.14.0", + "version": "0.15.0-beta.1", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.14.0", - "@getpaseo/protocol": "0.14.0", - "@getpaseo/server": "0.14.0", + "@getpaseo/client": "0.15.0-beta.1", + "@getpaseo/protocol": "0.15.0-beta.1", + "@getpaseo/server": "0.15.0-beta.1", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", @@ -40052,10 +40052,10 @@ }, "packages/client": { "name": "@getpaseo/client", - "version": "0.14.0", + "version": "0.15.0-beta.1", "dependencies": { - "@getpaseo/protocol": "0.14.0", - "@getpaseo/relay": "0.14.0", + "@getpaseo/protocol": "0.15.0-beta.1", + "@getpaseo/relay": "0.15.0-beta.1", "ws": "^8.20.0", "zod": "^4.4.3" }, @@ -40067,7 +40067,7 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.14.0", + "version": "0.15.0-beta.1", "license": "Apache-2.0", "dependencies": { "@getpaseo/cli": "*", @@ -40311,7 +40311,7 @@ }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", - "version": "0.14.0", + "version": "0.15.0-beta.1", "license": "MIT", "devDependencies": { "@types/jest": "^29.5.14", @@ -40357,7 +40357,7 @@ }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.14.0", + "version": "0.15.0-beta.1", "dependencies": { "@codemirror/language": "6.12.4", "@codemirror/legacy-modes": "^6.5.3", @@ -40386,15 +40386,15 @@ }, "packages/plugin": { "name": "@getpaseo/plugin", - "version": "0.14.0", + "version": "0.15.0-beta.1", "dependencies": { "@agentclientprotocol/sdk": "^1.4.0", "i18next": "^26.3.0", "use-sync-external-store": "^1.6.0" }, "devDependencies": { - "@getpaseo/client": "0.14.0", - "@getpaseo/protocol": "0.14.0", + "@getpaseo/client": "0.15.0-beta.1", + "@getpaseo/protocol": "0.15.0-beta.1", "@types/node": "^20.9.0", "@types/react": "~19.2.0", "react": "19.2.3", @@ -40404,8 +40404,8 @@ "zod": "^4.4.3" }, "peerDependencies": { - "@getpaseo/client": "0.14.0", - "@getpaseo/protocol": "0.14.0", + "@getpaseo/client": "0.15.0-beta.1", + "@getpaseo/protocol": "0.15.0-beta.1", "react": ">=19.1.0 <20", "react-native": ">=0.81.5", "zod": "^4.4.3" @@ -40427,7 +40427,7 @@ }, "packages/protocol": { "name": "@getpaseo/protocol", - "version": "0.14.0", + "version": "0.15.0-beta.1", "dependencies": { "semver": "^7.8.5", "zod": "^4.4.3" @@ -40454,7 +40454,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.14.0", + "version": "0.15.0-beta.1", "dependencies": { "@noble/ciphers": "^2.4.0", "@noble/curves": "^2.4.0", @@ -40675,16 +40675,16 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.14.0", + "version": "0.15.0-beta.1", "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "0.3.246", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.14.0", - "@getpaseo/highlight": "0.14.0", - "@getpaseo/plugin": "0.14.0", - "@getpaseo/protocol": "0.14.0", - "@getpaseo/relay": "0.14.0", + "@getpaseo/client": "0.15.0-beta.1", + "@getpaseo/highlight": "0.15.0-beta.1", + "@getpaseo/plugin": "0.15.0-beta.1", + "@getpaseo/protocol": "0.15.0-beta.1", + "@getpaseo/relay": "0.15.0-beta.1", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "1.29.0", "@opencode-ai/sdk": "1.14.46", @@ -41631,7 +41631,7 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.14.0", + "version": "0.15.0-beta.1", "dependencies": { "@cloudflare/vite-plugin": "^1.29.1", "@cloudflare/workers-types": "^4.20260317.1", @@ -43241,7 +43241,7 @@ }, "plugins": { "name": "@getpaseo/builtin-plugins", - "version": "0.14.0", + "version": "0.15.0-beta.1", "dependencies": { "@getpaseo/plugin": "*", "zod": "^4.4.3" diff --git a/package.json b/package.json index 938497a362..cbcb507539 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.14.0", + "version": "0.15.0-beta.1", "private": true, "description": "Paseo: voice-controlled development environment for local AI coding agents", "keywords": [ diff --git a/packages/app/package.json b/packages/app/package.json index e6cc1d4029..e269af14ae 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/app", - "version": "0.14.0", + "version": "0.15.0-beta.1", "private": true, "main": "index.ts", "scripts": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 45113dd1bc..6c7fa3155d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.14.0", + "version": "0.15.0-beta.1", "description": "Paseo CLI - control your AI coding agents from the command line", "bin": { "paseo": "bin/paseo" @@ -28,9 +28,9 @@ }, "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.14.0", - "@getpaseo/protocol": "0.14.0", - "@getpaseo/server": "0.14.0", + "@getpaseo/client": "0.15.0-beta.1", + "@getpaseo/protocol": "0.15.0-beta.1", + "@getpaseo/server": "0.15.0-beta.1", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", diff --git a/packages/client/package.json b/packages/client/package.json index 6e10c76089..19c0490021 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/client", - "version": "0.14.0", + "version": "0.15.0-beta.1", "description": "Paseo client SDK package", "files": [ "dist", @@ -47,8 +47,8 @@ "test": "vitest run" }, "dependencies": { - "@getpaseo/protocol": "0.14.0", - "@getpaseo/relay": "0.14.0", + "@getpaseo/protocol": "0.15.0-beta.1", + "@getpaseo/relay": "0.15.0-beta.1", "ws": "^8.20.0", "zod": "^4.4.3" }, diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 074e79b64d..6319805fc3 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.14.0", + "version": "0.15.0-beta.1", "private": true, "description": "Paseo desktop app (Electron wrapper)", "homepage": "https://paseo.sh", diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index aa86be129b..ff08d35c76 100644 --- a/packages/expo-two-way-audio/package.json +++ b/packages/expo-two-way-audio/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/expo-two-way-audio", - "version": "0.14.0", + "version": "0.15.0-beta.1", "description": "Native module for two way audio streaming", "keywords": [ "ExpoTwoWayAudio", diff --git a/packages/highlight/package.json b/packages/highlight/package.json index 6b1d924e05..dfc43982bf 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.14.0", + "version": "0.15.0-beta.1", "files": [ "dist", "!dist/**/*.map", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 32cb48e026..8ed758eb5f 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/plugin", - "version": "0.14.0", + "version": "0.15.0-beta.1", "description": "Paseo plugin SDK package", "files": [ "dist", @@ -77,8 +77,8 @@ "use-sync-external-store": "^1.6.0" }, "devDependencies": { - "@getpaseo/client": "0.14.0", - "@getpaseo/protocol": "0.14.0", + "@getpaseo/client": "0.15.0-beta.1", + "@getpaseo/protocol": "0.15.0-beta.1", "@types/node": "^20.9.0", "@types/react": "~19.2.0", "react": "19.2.3", @@ -88,8 +88,8 @@ "zod": "^4.4.3" }, "peerDependencies": { - "@getpaseo/client": "0.14.0", - "@getpaseo/protocol": "0.14.0", + "@getpaseo/client": "0.15.0-beta.1", + "@getpaseo/protocol": "0.15.0-beta.1", "react": ">=19.1.0 <20", "react-native": ">=0.81.5", "zod": "^4.4.3" diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 21d500fd4f..70d783f701 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/protocol", - "version": "0.14.0", + "version": "0.15.0-beta.1", "description": "Paseo shared protocol schemas and wire types", "files": [ "dist", diff --git a/packages/relay/package.json b/packages/relay/package.json index ec1deebff7..86bde64a75 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.14.0", + "version": "0.15.0-beta.1", "description": "Paseo relay for bridging daemon and client connections", "files": [ "dist", diff --git a/packages/server/package.json b/packages/server/package.json index 72a21944ef..191aee2945 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.14.0", + "version": "0.15.0-beta.1", "description": "Paseo backend server", "files": [ "dist/server", @@ -107,11 +107,11 @@ "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "0.3.246", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.14.0", - "@getpaseo/highlight": "0.14.0", - "@getpaseo/plugin": "0.14.0", - "@getpaseo/protocol": "0.14.0", - "@getpaseo/relay": "0.14.0", + "@getpaseo/client": "0.15.0-beta.1", + "@getpaseo/highlight": "0.15.0-beta.1", + "@getpaseo/plugin": "0.15.0-beta.1", + "@getpaseo/protocol": "0.15.0-beta.1", + "@getpaseo/relay": "0.15.0-beta.1", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "1.29.0", "@opencode-ai/sdk": "1.14.46", diff --git a/packages/website/package.json b/packages/website/package.json index 7e0635c2e9..b45ec39396 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.14.0", + "version": "0.15.0-beta.1", "private": true, "type": "module", "scripts": { diff --git a/packages/website/src/schedule-docs.test.ts b/packages/website/src/schedule-docs.test.ts index 015d85fe6c..76fca93287 100644 --- a/packages/website/src/schedule-docs.test.ts +++ b/packages/website/src/schedule-docs.test.ts @@ -4,7 +4,7 @@ import { expect, it } from "vitest"; const docsRoot = path.resolve(import.meta.dirname, "../../../public-docs"); -it.each(["schedules.md", "schedules-cli.md", "mcp.md", "worktrees.md"])( +it.each(["schedules.md", "schedules-cli.md", "mcp.md", "worktrees/index.md"])( "%s makes schedule run retention discoverable", async (file) => { const markdown = await readFile(path.join(docsRoot, file), "utf8"); diff --git a/plugins/package.json b/plugins/package.json index aa09dfb568..e7d0a6019f 100644 --- a/plugins/package.json +++ b/plugins/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/builtin-plugins", - "version": "0.14.0", + "version": "0.15.0-beta.1", "private": true, "type": "module", "scripts": { From 6ae6308cfbae5a72c11f2e54eebd32811744a490 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 06:01:30 +0000 Subject: [PATCH 25/58] test(server): control failed Git clone responses in credential test (#8428) Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../plugins/managed-source.posix.test.ts | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/server/src/server/plugins/managed-source.posix.test.ts b/packages/server/src/server/plugins/managed-source.posix.test.ts index 466780b79a..7b694f1ef3 100644 --- a/packages/server/src/server/plugins/managed-source.posix.test.ts +++ b/packages/server/src/server/plugins/managed-source.posix.test.ts @@ -61,10 +61,24 @@ describe("managed Git plugin sources", () => { roots.push(home); const sources = new ManagedPluginSources(home); - const failure = sources.prepareInstall({ - source: "git:https://oauth2:super-secret@127.0.0.1:1/missing.git", + // Own the failure response instead of relying on a closed port and OS connect timing. + const server = createServer((_request, response) => { + response.writeHead(500, { "Content-Type": "text/plain" }); + response.end("clone rejected for oauth2:super-secret\n"); }); - await expect(failure).rejects.not.toThrow(/oauth2|super-secret/); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address() as { port: number }; + try { + const failure = sources.prepareInstall({ + source: `git:http://oauth2:super-secret@127.0.0.1:${address.port}/missing.git`, + }); + await expect(failure).rejects.toThrow("500"); + await expect(failure).rejects.toThrow("clone rejected for [redacted]:[redacted]"); + await expect(failure).rejects.not.toThrow(/oauth2|super-secret/); + } finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } }); it("normalizes explicit GitHub and Git identifiers to the same Git source", async () => { From 891e62309cd241ad89ac6d960692e746cce8e25c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 1 Nov 2026 13:35:11 +0700 Subject: [PATCH 26/58] fix(release): use the keychain password for macOS signing setup (#8445) Backport the pinned packaging tool correction in the workflow so existing release tags can be rebuilt without changing their dependencies. --- .github/workflows/desktop-release.yml | 26 ++++++++++++++ scripts/ci-workflow.test.mjs | 52 ++++++++++++++++++++++++++- 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 26be12291b..bdbeb9f139 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -135,6 +135,32 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Backport electron-builder#10172 to the pinned build tool only. + # Remove this step when app-builder-lib is upgraded to a release containing it. + - name: Repair temporary keychain password in signing tool + shell: bash + run: | + node <<'KEYCHAIN_FIX' + const fs = require('node:fs'); + const path = require('node:path'); + const manifestPath = require.resolve('app-builder-lib/package.json'); + if (JSON.parse(fs.readFileSync(manifestPath, 'utf8')).version !== '26.8.1') { + throw new Error('Reassess/remove the signing backport for the new app-builder-lib version'); + } + const sourcePath = path.join(path.dirname(manifestPath), 'out/codeSign/macCodeSign.js'); + let source = fs.readFileSync(sourcePath, 'utf8'); + const replacements = [ + ['importCerts(keychainFile, certPaths, cscPasswords)', 'importCerts(keychainFile, certPaths, cscPasswords, keychainPassword)'], + ['importCerts(keychainFile, paths, keyPasswords)', 'importCerts(keychainFile, paths, keyPasswords, keychainPassword)'], + ['["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", password, keychainFile]', '["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", keychainPassword, keychainFile]'], + ]; + for (const [before, after] of replacements) { + if (source.split(before).length !== 2) throw new Error('Unexpected signing tool source; refusing to patch'); + source = source.replace(before, after); + } + fs.writeFileSync(sourcePath, source); + KEYCHAIN_FIX + - name: Set desktop package version from tag shell: bash run: | diff --git a/scripts/ci-workflow.test.mjs b/scripts/ci-workflow.test.mjs index 73a9003312..381f826540 100644 --- a/scripts/ci-workflow.test.mjs +++ b/scripts/ci-workflow.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { readFileSync, readdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { readFileSync, readdirSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; import { spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -679,3 +679,53 @@ test("macOS jobs use latest runners while preserving release architectures", () // GitHub has no standard macos-latest-intel alias; use the newest Intel image. assert.match(release, /runner: macos-26-intel\n\s+electron_arch: x64/); }); + +test("macOS signing repair keeps certificate and keychain passwords separate", () => { + const workflow = readFileSync(new URL(".github/workflows/desktop-release.yml", repoRoot), "utf8"); + const match = workflow.match(/<<'KEYCHAIN_FIX'\n([\s\S]*?)\n KEYCHAIN_FIX/); + assert.ok(match, "the release workflow repairs the pinned signing tool"); + const script = match[1].replace(/^ /gm, ""); + const original = [ + "return await importCerts(keychainFile, certPaths, cscPasswords);", + "async function importCerts(keychainFile, paths, keyPasswords) {", + '["import", paths[i], "-P", password]', + '["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", password, keychainFile]', + "}", + ].join("\n"); + const directory = mkdtempSync(join(tmpdir(), "signing-repair-")); + try { + const packageDirectory = join(directory, "node_modules/app-builder-lib"); + mkdirSync(join(packageDirectory, "out/codeSign"), { recursive: true }); + const manifest = join(packageDirectory, "package.json"); + const sourcePath = join(packageDirectory, "out/codeSign/macCodeSign.js"); + for (const [version, source, succeeds] of [ + ["26.8.1", original, true], + ["26.8.1", original.replace("cscPasswords);", "differentArguments);"), false], + ["26.18.0", original, false], + ]) { + writeFileSync(manifest, JSON.stringify({ name: "app-builder-lib", version })); + writeFileSync(sourcePath, source); + const result = spawnSync(process.execPath, ["-e", script], { + cwd: directory, + encoding: "utf8", + }); + const actual = readFileSync(sourcePath, "utf8"); + if (succeeds) { + assert.equal(result.status, 0, result.stderr); + assert.ok( + actual.includes("importCerts(keychainFile, certPaths, cscPasswords, keychainPassword)"), + ); + assert.ok( + actual.includes("importCerts(keychainFile, paths, keyPasswords, keychainPassword)"), + ); + assert.ok(actual.includes('["import", paths[i], "-P", password]')); + assert.ok(actual.includes('"-k", keychainPassword, keychainFile]')); + } else { + assert.notEqual(result.status, 0); + assert.equal(actual, source, "unexpected tool inputs must fail without a partial write"); + } + } + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); From 14519bdb5bb8306179e351ea568bd757bf59234a Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 06:52:48 +0000 Subject: [PATCH 27/58] Read archived Pi chats after their working folder is removed (#8442) * fix(pi): open an archived Pi agent's history after its folder is removed Opening an archived agent's timeline resumes its provider for history. Pi's resumeSession never received the resume purpose, so it launched the Pi RPC process, and its MCP support probe, in the agent's stored working directory. When that folder had been removed, for example with a merged worktree, Node failed the spawn with "spawn ENOENT" and the history could not be read. Starting the process elsewhere is not enough: with --session, Pi checks the cwd stored in the session file's header and, outside interactive mode, exits when that folder is missing. A history resume whose stored folder is no longer a directory now starts Pi from the home directory on a temporary copy of the session file whose header names that directory. The copy streams everything after the header unchanged and is deleted when the session closes. The agent keeps reporting its stored folder and original session file, and the original file is not written to. The directory check moves out of the ACP provider, which already starts history loads from the home directory (#6228), into a shared helper both providers use. Fixes #7969 * test(terminal): keep the argv test child alive through its assertion The POSIX argv test reads the terminal screen while its child can exit and dispose the parser. Keep the child alive until the assertion completes, as other screen tests do, and let the existing session cleanup terminate it. Co-authored-by: Dominic Anaeto --------- Co-authored-by: Dominic Anaeto Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../src/server/agent/providers/acp-agent.ts | 20 +- .../server/agent/providers/pi/agent.test.ts | 320 +++++++++++++++++- .../src/server/agent/providers/pi/agent.ts | 56 ++- .../providers/pi/session-descriptor.test.ts | 104 +++++- .../agent/providers/pi/session-descriptor.ts | 59 +++- .../agent/providers/pi/test-utils/fake-pi.ts | 29 ++ .../agent/providers/resume-process-cwd.ts | 26 ++ packages/server/src/terminal/terminal.test.ts | 7 +- 8 files changed, 586 insertions(+), 35 deletions(-) create mode 100644 packages/server/src/server/agent/providers/resume-process-cwd.ts diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index 6831274d2d..339c491e46 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -120,6 +120,7 @@ import { } from "../provider-launch-config.js"; import { renderPromptAttachmentAsText } from "../prompt-attachments.js"; import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js"; +import { resolveResumeProcessCwd } from "./resume-process-cwd.js"; import { buildStringCommandShellInvocation, createStringCommandShellEnvOverlay, @@ -165,18 +166,6 @@ function rejectOnProcessFailure(child: ChildProcess, stderrChunks: string[]): Pr }); } -async function isDirectory(target: string): Promise { - try { - return (await fs.stat(target)).isDirectory(); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT" || code === "ENOTDIR") { - return false; - } - throw error; - } -} - function isRecord(value: unknown): value is Record { return value != null && typeof value === "object" && !Array.isArray(value); } @@ -2883,11 +2872,8 @@ export class ACPAgentSession implements AgentSession, ACPClient { // Loading an archived agent's history runs nothing in its working directory, which // may have been removed with its worktree. session/load still names the original // directory; only the process starts from the home directory. - private async resolveProcessCwd(): Promise { - if (this.resumePurpose !== "history" || (await isDirectory(this.config.cwd))) { - return this.config.cwd; - } - return homedir(); + private resolveProcessCwd(): Promise { + return resolveResumeProcessCwd(this.config.cwd, this.resumePurpose); } private async spawnProcess(): Promise { diff --git a/packages/server/src/server/agent/providers/pi/agent.test.ts b/packages/server/src/server/agent/providers/pi/agent.test.ts index 936bdbc973..7dc3fd184c 100644 --- a/packages/server/src/server/agent/providers/pi/agent.test.ts +++ b/packages/server/src/server/agent/providers/pi/agent.test.ts @@ -5,11 +5,13 @@ import { mkdirSync, mkdtempSync, openSync, + readFileSync, readSync, + realpathSync, rmSync, writeFileSync, } from "node:fs"; -import { tmpdir } from "node:os"; +import { homedir, tmpdir } from "node:os"; import path from "node:path"; import pino from "pino"; import { setImmediate as waitForImmediate } from "node:timers/promises"; @@ -23,6 +25,7 @@ import { PiRpcAgentSession, transformPiModels, } from "./agent.js"; +import { PiCliRuntime } from "./cli-runtime.js"; import { FakePi } from "./test-utils/fake-pi.js"; import { createPiExtensionHost } from "./extensions/index.js"; import { PiExtensionHost } from "./extensions/host.js"; @@ -2719,6 +2722,7 @@ describe("PiRpcAgentClient", () => { const root = mkdtempSync(path.join(tmpdir(), "paseo-pi-import-config-")); const cwd = path.join(root, "workspace"); const sessionsDir = path.join(root, "sessions"); + mkdirSync(cwd, { recursive: true }); mkdirSync(sessionsDir, { recursive: true }); const sessionFile = path.join(sessionsDir, "20260103_session.jsonl"); writeFileSync( @@ -3532,6 +3536,320 @@ describe("PiRpcAgentClient", () => { }); }); +describe("PiRpcAgentClient history resume", () => { + const BUILTIN_MCP_COMMAND = { + name: "mcp", + source: "extension", + sourceInfo: { path: "builtin:mcp" }, + } as const; + const PASEO_MCP_SERVERS = { + paseo: { type: "http", url: "http://127.0.0.1:6767/mcp/agents" }, + } as const; + + interface ArchivedPiSession { + handle: { + provider: string; + sessionId: string; + nativeHandle: string; + metadata: { cwd: string; model?: string }; + }; + sessionFile: string; + header: Record; + body: string; + originalBytes: Buffer; + } + + function createDeletedFolder(): string { + const folder = mkdtempSync(path.join(tmpdir(), "paseo-pi-deleted-cwd-")); + rmSync(folder, { recursive: true, force: true }); + return folder; + } + + function createFileWhereFolderWas(): string { + const folder = createDeletedFolder(); + writeFileSync(folder, "not a directory"); + onTestFinished(() => rmSync(folder, { force: true })); + return folder; + } + + function createExistingFolder(): string { + const folder = mkdtempSync(path.join(tmpdir(), "paseo-pi-existing-cwd-")); + onTestFinished(() => rmSync(folder, { recursive: true, force: true })); + return folder; + } + + function writeArchivedSession(cwd: string, metadata: { model?: string } = {}): ArchivedPiSession { + const dir = mkdtempSync(path.join(tmpdir(), "paseo-pi-archived-session-")); + onTestFinished(() => rmSync(dir, { recursive: true, force: true })); + const sessionFile = path.join(dir, "2026-01-01_pi-session-1.jsonl"); + const header = { + type: "session", + version: 3, + id: "pi-session-1", + timestamp: "2026-01-01T00:00:00.000Z", + cwd, + }; + const body = [ + JSON.stringify({ + type: "message", + id: "entry-1", + parentId: null, + message: { role: "user", content: "Où est le café ☕ ?" }, + }), + JSON.stringify({ + type: "message", + id: "entry-2", + parentId: "entry-1", + message: { role: "assistant", content: [{ type: "text", text: "Dans la cuisine." }] }, + }), + ] + .map((line) => `${line}\n`) + .join(""); + writeFileSync(sessionFile, `${JSON.stringify(header)}\n${body}`); + return { + handle: { + provider: "pi", + sessionId: "pi-session-1", + nativeHandle: sessionFile, + metadata: { cwd, ...metadata }, + }, + sessionFile, + header, + body, + originalBytes: readFileSync(sessionFile), + }; + } + + function readSessionCopy(sessionFile: string): { header: unknown; body: string } { + const content = readUtf8File(sessionFile); + const headerEnd = content.indexOf("\n"); + return { + header: JSON.parse(content.slice(0, headerEnd)), + body: content.slice(headerEnd + 1), + }; + } + + function resumeArchived( + client: PiRpcAgentClient, + archived: ArchivedPiSession, + purpose: "history" | "interactive", + overrides: Partial = {}, + ): Promise { + return client.resumeSession(archived.handle, overrides, undefined, { purpose }); + } + + function writeFakePiProcess(): { + command: [string, ...string[]]; + readLaunches: () => Array<{ cwd: string; session: string | null }>; + } { + const dir = mkdtempSync(path.join(tmpdir(), "paseo-pi-process-")); + onTestFinished(() => rmSync(dir, { recursive: true, force: true })); + const scriptPath = path.join(dir, "fake-pi.cjs"); + const launchesPath = path.join(dir, "launches.jsonl"); + writeFileSync( + scriptPath, + ` +const fs = require("node:fs"); +const readline = require("node:readline"); +const [launchesPath, ...piArgs] = process.argv.slice(2); +const sessionFlag = piArgs.indexOf("--session"); +const sessionFile = sessionFlag === -1 ? null : piArgs[sessionFlag + 1]; +fs.appendFileSync(launchesPath, JSON.stringify({ cwd: process.cwd(), session: sessionFile }) + "\\n"); +if (sessionFile) { + const header = JSON.parse(fs.readFileSync(sessionFile, "utf8").split("\\n", 1)[0]); + if (header.cwd && !fs.existsSync(header.cwd)) { + fs.writeSync( + 2, + "Stored session working directory does not exist: " + header.cwd + "\\nSession file: " + sessionFile + "\\nCurrent working directory: " + process.cwd() + "\\n", + ); + process.exit(1); + } +} +const models = [ + { provider: "openrouter", id: "a" }, + { provider: "openrouter", id: "b" }, +]; +const state = { + sessionId: "pi-session-1", + sessionFile, + model: models[0], + thinkingLevel: "medium", + isStreaming: false, + isCompacting: false, + messageCount: 0, + pendingMessageCount: 0, +}; +const commands = [${JSON.stringify(BUILTIN_MCP_COMMAND)}]; +function handle(request) { + switch (request.type) { + case "get_state": + return state; + case "get_commands": + return { commands }; + case "get_available_models": + return { models }; + case "set_model": + fs.appendFileSync( + sessionFile, + JSON.stringify({ type: "model_change", id: "model-1", provider: request.provider, modelId: request.modelId }) + "\\n", + ); + state.model = models.find((model) => model.id === request.modelId); + return state.model; + default: + return {}; + } +} +readline.createInterface({ input: process.stdin }).on("line", (line) => { + const request = JSON.parse(line); + process.stdout.write( + JSON.stringify({ id: request.id, type: "response", command: request.type, success: true, data: handle(request) }) + "\\n", + ); +}); +`, + ); + return { + command: [process.execPath, scriptPath, launchesPath], + readLaunches: () => + readUtf8File(launchesPath) + .trim() + .split("\n") + .map((line) => JSON.parse(line) as { cwd: string; session: string | null }), + }; + } + + test.each([ + ["is gone", createDeletedFolder], + ["is now a file", createFileWhereFolderWas], + ])( + "a history resume of an agent whose folder %s starts Pi from the home directory on a copy of its session", + async (_folderState, createFolder) => { + const archived = writeArchivedSession(createFolder()); + const pi = new FakePi(); + + const session = await resumeArchived(createClient(pi), archived, "history"); + onTestFinished(() => session.close()); + + expect(pi.recordedLaunches).toHaveLength(1); + const launch = pi.recordedLaunches[0]!; + expect(launch.cwd).toBe(homedir()); + expect(launch.session).not.toBe(archived.sessionFile); + expect(readSessionCopy(launch.session!)).toEqual({ + header: { ...archived.header, cwd: homedir() }, + body: archived.body, + }); + }, + ); + + test("a history resume of an agent whose folder is gone removes its session copy on close and leaves the original unchanged", async () => { + const archived = writeArchivedSession(createDeletedFolder()); + const pi = new FakePi(); + + const session = await resumeArchived(createClient(pi), archived, "history"); + const sessionCopy = pi.recordedLaunches[0]!.session!; + expect(existsSync(sessionCopy)).toBe(true); + await session.close(); + + expect(existsSync(path.dirname(sessionCopy))).toBe(false); + expect(readFileSync(archived.sessionFile)).toEqual(archived.originalBytes); + }); + + test("a history resume of an agent whose folder is gone persists the original session file while Pi reports the copy", async () => { + const storedCwd = createDeletedFolder(); + const archived = writeArchivedSession(storedCwd); + const pi = new FakePi(); + + const session = await resumeArchived(createClient(pi), archived, "history"); + onTestFinished(() => session.close()); + await session.getRuntimeInfo(); + + expect(pi.latestSession().state.sessionFile).toBe(pi.recordedLaunches[0]!.session); + expect(session.describePersistence()).toMatchObject({ + nativeHandle: archived.sessionFile, + metadata: { cwd: storedCwd }, + }); + }); + + test("a history resume of an agent whose folder is gone removes its session copy when Pi fails to start", async () => { + const archived = writeArchivedSession(createDeletedFolder()); + const pi = new FakePi(); + pi.queueSessionSetup((session) => { + session.getStateError = new Error("Pi RPC process exited with code 1 and signal null"); + }); + + await expect(resumeArchived(createClient(pi), archived, "history")).rejects.toThrow( + "Pi RPC process exited with code 1", + ); + + expect(pi.recordedLaunches).toHaveLength(1); + expect(pi.recordedLaunches[0]!.session).not.toBe(archived.sessionFile); + expect(existsSync(path.dirname(pi.recordedLaunches[0]!.session!))).toBe(false); + }); + + test("a history resume of an agent whose folder is gone checks MCP support from the home directory", async () => { + const archived = writeArchivedSession(createDeletedFolder()); + const pi = new FakePi(); + pi.queueCommands([BUILTIN_MCP_COMMAND]); + + const session = await resumeArchived(createClient(pi), archived, "history", { + mcpServers: PASEO_MCP_SERVERS, + }); + onTestFinished(() => session.close()); + + expect(pi.recordedLaunches.map((launch) => launch.cwd)).toEqual([homedir(), homedir()]); + expect(session.capabilities.supportsMcpServers).toBe(true); + }); + + test("an interactive resume of an agent whose folder is gone still fails in its stored folder", async () => { + const storedCwd = createDeletedFolder(); + const archived = writeArchivedSession(storedCwd); + const pi = new FakePi(); + + await expect(resumeArchived(createClient(pi), archived, "interactive")).rejects.toThrow( + `Stored session working directory does not exist: ${storedCwd}`, + ); + + expect(pi.recordedLaunches).toMatchObject([{ cwd: storedCwd, session: archived.sessionFile }]); + }); + + test("a history resume of an agent whose folder exists keeps the stored folder and session file", async () => { + const storedCwd = createExistingFolder(); + const archived = writeArchivedSession(storedCwd); + const pi = new FakePi(); + + const session = await resumeArchived(createClient(pi), archived, "history"); + onTestFinished(() => session.close()); + + expect(pi.recordedLaunches).toMatchObject([{ cwd: storedCwd, session: archived.sessionFile }]); + }); + + test("a history resume of an agent whose folder is gone opens its session in a real Pi process started from the home directory", async () => { + const archived = writeArchivedSession(createDeletedFolder(), { model: "openrouter/b" }); + const fakePiProcess = writeFakePiProcess(); + const logger = pino({ level: "silent" }); + const client = new PiRpcAgentClient({ + logger, + runtime: new PiCliRuntime({ logger, command: fakePiProcess.command }), + }); + + const session = await resumeArchived(client, archived, "history", { + mcpServers: PASEO_MCP_SERVERS, + }); + onTestFinished(() => session.close()); + + const launches = fakePiProcess.readLaunches(); + const home = realpathSync.native(homedir()); + expect(launches.map((launch) => realpathSync.native(launch.cwd))).toEqual([home, home]); + expect(session.capabilities.supportsMcpServers).toBe(true); + const sessionCopy = launches[1]!.session!; + expect(readUtf8File(sessionCopy)).toContain('"type":"model_change"'); + expect(session.describePersistence()?.nativeHandle).toBe(archived.sessionFile); + + await session.close(); + expect(existsSync(path.dirname(sessionCopy))).toBe(false); + expect(readFileSync(archived.sessionFile)).toEqual(archived.originalBytes); + }); +}); + describe("transformPiModels", () => { test("normalizes labels that include the upstream provider prefix", () => { expect( diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts index 9474e98cc8..8051c57fa9 100644 --- a/packages/server/src/server/agent/providers/pi/agent.ts +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -2,7 +2,7 @@ import { mapCustomMessageToToolCall } from "../custom-message.js"; import { randomUUID } from "node:crypto"; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; -import { join, resolve as resolvePath } from "node:path"; +import { basename, join, resolve as resolvePath } from "node:path"; import type { Logger } from "pino"; import stripAnsi from "strip-ansi"; import { z } from "zod"; @@ -22,6 +22,7 @@ import { type AgentPersistenceHandle, type AgentPromptInput, type AgentProvider, + type AgentResumeSessionOptions, type AgentRunOptions, type AgentRunResult, type AgentRuntimeInfo, @@ -75,8 +76,13 @@ import { piExtensionRuntimeBridge, type PiExtensionEventOutput, } from "./extensions/index.js"; +import { resolveResumeProcessCwd } from "../resume-process-cwd.js"; import { revertPiConversation } from "./rewind.js"; -import { listPiImportableSessions, readPiImportSessionConfig } from "./session-descriptor.js"; +import { + copyPiSessionFileWithCwd, + listPiImportableSessions, + readPiImportSessionConfig, +} from "./session-descriptor.js"; import type { PiRuntime, PiRuntimeSession, PiStartSessionInput } from "./runtime.js"; import type { PiAgentSessionEvent, @@ -245,6 +251,7 @@ interface PiRpcAgentSessionOptions { initialState: PiSessionState; capabilities: AgentCapabilityFlags; currentModeId?: string | null; + persistedSessionFile?: string; cleanup?: () => void; extensionTimeoutMs?: number; logger: Logger; @@ -492,13 +499,14 @@ function buildResumeConfig( function buildResumeStartInput(input: { resumeConfig: PiResumeConfig; + processCwd: string; sessionFile: string; launchContext: AgentLaunchContext | undefined; mcpConfigFile: PiTempFile | null; paseoExtension: PiTempFile | null; }): PiStartSessionInput { return { - cwd: input.resumeConfig.cwd, + cwd: input.processCwd, env: input.launchContext?.env, session: input.sessionFile, thinkingOptionId: normalizePiThinkingOption(input.resumeConfig.thinkingOptionId) ?? undefined, @@ -618,6 +626,19 @@ function createPiMcpConfigFile( }; } +async function createPiSessionCopy(sessionFile: string, cwd: string): Promise { + const dir = mkdtempSync(join(tmpdir(), "paseo-pi-session-")); + const cleanup = () => rmSync(dir, { recursive: true, force: true }); + const filePath = join(dir, basename(sessionFile)); + try { + await copyPiSessionFileWithCwd({ sourceFile: sessionFile, targetFile: filePath, cwd }); + } catch (error) { + cleanup(); + throw error; + } + return { path: filePath, cleanup }; +} + function createPiPaseoExtensionFile({ systemPrompt, mcpServers = {}, @@ -1232,6 +1253,7 @@ export class PiRpcAgentSession implements AgentSession { this.capabilities = options.capabilities; this.provider = PI_PROVIDER; this.currentModeId = options.currentModeId ?? null; + this.persistedSessionFile = options.persistedSessionFile; this.cleanup = options.cleanup; this.extensionTimeoutMs = options.extensionTimeoutMs ?? DEFAULT_PI_EXTENSION_RESULT_TIMEOUT_MS; this.logger = options.logger; @@ -1262,6 +1284,7 @@ export class PiRpcAgentSession implements AgentSession { private readonly runtimeSession: PiRuntimeSession; private readonly config: AgentSessionConfig; + private readonly persistedSessionFile?: string; private readonly cleanup?: () => void; private readonly extensionTimeoutMs: number; @@ -1496,7 +1519,7 @@ export class PiRpcAgentSession implements AgentSession { return { provider: this.provider, sessionId: this.state.sessionId, - nativeHandle: this.state.sessionFile, + nativeHandle: this.persistedSessionFile ?? this.state.sessionFile, metadata: { cwd: this.config.cwd, ...(this.config.model ? { model: this.config.model } : {}), @@ -2645,6 +2668,7 @@ export class PiRpcAgentClient implements AgentClient { handle: AgentPersistenceHandle, overrides?: Partial, launchContext?: AgentLaunchContext, + options?: AgentResumeSessionOptions, ): Promise { const sessionFile = handle.nativeHandle; if (!sessionFile) { @@ -2653,6 +2677,7 @@ export class PiRpcAgentClient implements AgentClient { const persistenceMetadata = parsePersistenceMetadata(handle.metadata); const resumeConfig = buildResumeConfig(persistenceMetadata, overrides, this.provider); + const processCwd = await resolveResumeProcessCwd(resumeConfig.cwd, options?.purpose); const providerOptions = PiProviderOptionsSchema.parse( resumeConfig.config.providerOptions ?? {}, @@ -2663,7 +2688,7 @@ export class PiRpcAgentClient implements AgentClient { ...launchContext?.env, }; const mcp = await this.prepareMcpInjection( - resumeConfig.cwd, + processCwd, resumeConfig.config.mcpServers, mcpEnv, runtime, @@ -2676,20 +2701,29 @@ export class PiRpcAgentClient implements AgentClient { ), mcpServers: mcp?.kind === "builtin" ? mcp.servers : undefined, }); + const tempFiles = mcpConfigFile ? [mcpConfigFile, paseoExtension] : [paseoExtension]; + const cleanupTempFiles = () => { + for (const tempFile of tempFiles) tempFile.cleanup(); + }; + let sessionCopy: PiTempFile | null = null; let runtimeSession: PiRuntimeSession; try { + if (processCwd !== resumeConfig.cwd) { + sessionCopy = await createPiSessionCopy(sessionFile, processCwd); + tempFiles.push(sessionCopy); + } runtimeSession = await runtime.startSession( buildResumeStartInput({ resumeConfig, - sessionFile, + processCwd, + sessionFile: sessionCopy?.path ?? sessionFile, launchContext, mcpConfigFile, paseoExtension, }), ); } catch (error) { - mcpConfigFile?.cleanup(); - paseoExtension?.cleanup(); + cleanupTempFiles(); throw error; } try { @@ -2702,15 +2736,15 @@ export class PiRpcAgentClient implements AgentClient { }, initialState, capabilities: capabilitiesForSession(mcp !== null), - cleanup: combineCleanup([mcpConfigFile?.cleanup, paseoExtension?.cleanup]), + persistedSessionFile: sessionCopy ? sessionFile : undefined, + cleanup: cleanupTempFiles, extensionTimeoutMs: providerOptions.extensionTimeoutMs, logger: this.logger, usagePollScheduler: this.usagePollScheduler, }); } catch (error) { await runtimeSession.close().catch(() => undefined); - mcpConfigFile?.cleanup(); - paseoExtension?.cleanup(); + cleanupTempFiles(); throw error; } } diff --git a/packages/server/src/server/agent/providers/pi/session-descriptor.test.ts b/packages/server/src/server/agent/providers/pi/session-descriptor.test.ts index 280b558739..c6b0df5dbf 100644 --- a/packages/server/src/server/agent/providers/pi/session-descriptor.test.ts +++ b/packages/server/src/server/agent/providers/pi/session-descriptor.test.ts @@ -1,9 +1,13 @@ -import { mkdtemp, mkdir, utimes, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, rm, utimes, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import { expect, test } from "vitest"; +import { expect, onTestFinished, test } from "vitest"; -import { listPiImportableSessions, readPiImportSessionConfig } from "./session-descriptor.js"; +import { + copyPiSessionFileWithCwd, + listPiImportableSessions, + readPiImportSessionConfig, +} from "./session-descriptor.js"; async function writeSession(root: string, lines: unknown[]): Promise { const sessionsDir = path.join(root, "sessions", "project"); @@ -216,3 +220,97 @@ test("Pi import config preserves thinking before a later model in large sessions thinkingOptionId: "low", }); }); + +test("a Pi session copy names the new cwd in its header and keeps every later byte", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-pi-session-copy-")); + onTestFinished(() => rm(root, { recursive: true, force: true })); + const header = { + type: "session", + version: 3, + id: "pi-session-1", + timestamp: "2026-01-01T00:00:00.000Z", + cwd: path.join(root, "removed-worktree"), + }; + const body = Buffer.from( + Array.from( + { length: 2_000 }, + (_, index) => + `${JSON.stringify({ type: "message", id: `entry-${index}`, message: { role: "user", content: `café ☕ ${index}` } })}\n`, + ).join(""), + ); + expect(body.length).toBeGreaterThan(64 * 1024); + const sourceFile = path.join(root, "source.jsonl"); + const targetFile = path.join(root, "copy.jsonl"); + await writeFile(sourceFile, Buffer.concat([Buffer.from(`${JSON.stringify(header)}\n`), body])); + + await copyPiSessionFileWithCwd({ sourceFile, targetFile, cwd: "/home/user" }); + + const copy = await readFile(targetFile); + const headerEnd = copy.indexOf(0x0a); + expect(JSON.parse(copy.subarray(0, headerEnd).toString("utf8"))).toEqual({ + ...header, + cwd: "/home/user", + }); + expect(copy.subarray(headerEnd + 1).equals(body)).toBe(true); +}); + +test("a Pi session copy refuses a file without a session header", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-pi-session-copy-")); + onTestFinished(() => rm(root, { recursive: true, force: true })); + const sourceFile = path.join(root, "source.jsonl"); + await writeFile(sourceFile, `${JSON.stringify({ type: "message", id: "entry-1" })}\n`); + + await expect( + copyPiSessionFileWithCwd({ sourceFile, targetFile: path.join(root, "copy.jsonl"), cwd: "/" }), + ).rejects.toThrow(`Pi session file has no session header: ${sourceFile}`); +}); + +const COPY_BODY = [ + { type: "message", id: "entry-1", message: { role: "user", content: "café ☕" } }, + { type: "message", id: "entry-2", message: { role: "assistant", content: "Dans la cuisine." } }, +] + .map((entry) => `${JSON.stringify(entry)}\n`) + .join(""); + +const COPY_HEADER = { + type: "session", + version: 3, + id: "pi-session-1", + timestamp: "2026-01-01T00:00:00.000Z", + cwd: "/removed-worktree", +}; + +async function copySessionContent(content: string): Promise { + const root = await mkdtemp(path.join(tmpdir(), "paseo-pi-session-copy-")); + onTestFinished(() => rm(root, { recursive: true, force: true })); + const sourceFile = path.join(root, "source.jsonl"); + const targetFile = path.join(root, "copy.jsonl"); + await writeFile(sourceFile, content); + await copyPiSessionFileWithCwd({ sourceFile, targetFile, cwd: "/home/user" }); + return readFile(targetFile); +} + +test.each([ + ["a header line longer than 64 KiB", "", { ...COPY_HEADER, padding: "x".repeat(70 * 1024) }], + ["a header with an empty cwd", "", { ...COPY_HEADER, cwd: "" }], + ["blank lines before the header", "\n\r\n", COPY_HEADER], +])("a Pi session copy accepts %s", async (_headerShape, prefix, header) => { + const copy = await copySessionContent(`${prefix}${JSON.stringify(header)}\n${COPY_BODY}`); + + const headerEnd = copy.indexOf(0x0a); + expect(JSON.parse(copy.subarray(0, headerEnd).toString("utf8"))).toEqual({ + ...header, + cwd: "/home/user", + }); + expect(copy.subarray(headerEnd + 1).equals(Buffer.from(COPY_BODY))).toBe(true); +}); + +test("a Pi session copy reports a missing session file", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-pi-session-copy-")); + onTestFinished(() => rm(root, { recursive: true, force: true })); + const sourceFile = path.join(root, "missing.jsonl"); + + await expect( + copyPiSessionFileWithCwd({ sourceFile, targetFile: path.join(root, "copy.jsonl"), cwd: "/" }), + ).rejects.toThrow(`Pi session file is missing: ${sourceFile}`); +}); diff --git a/packages/server/src/server/agent/providers/pi/session-descriptor.ts b/packages/server/src/server/agent/providers/pi/session-descriptor.ts index 01f55bc0ce..6c0cc300f2 100644 --- a/packages/server/src/server/agent/providers/pi/session-descriptor.ts +++ b/packages/server/src/server/agent/providers/pi/session-descriptor.ts @@ -1,7 +1,8 @@ -import type { Dirent } from "node:fs"; -import { open, readdir, readFile, stat } from "node:fs/promises"; +import { createReadStream, createWriteStream, type Dirent } from "node:fs"; +import { open, readdir, readFile, stat, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import path from "node:path"; +import { pipeline } from "node:stream/promises"; import type { ImportableProviderSession, @@ -18,6 +19,7 @@ const PI_SESSION_DIR_ENV = "PI_CODING_AGENT_SESSION_DIR"; const HEAD_BYTES = 64 * 1024; const TAIL_BYTES = 256 * 1024; const FULL_SCAN_LINE_LIMIT = 2_000; +const PI_SESSION_HEADER_SCAN_BYTES = 1024 * 1024; // Rank all discovered files cheaply, then parse only a bounded recent window. const IMPORT_CANDIDATE_OVERSCAN = 40; const IMPORT_CANDIDATE_MIN = 400; @@ -100,6 +102,59 @@ export async function listPiImportableSessions( ); } +export async function copyPiSessionFileWithCwd(input: { + sourceFile: string; + targetFile: string; + cwd: string; +}): Promise { + const found = findSessionHeaderLine( + await readSessionFileStart(input.sourceFile, PI_SESSION_HEADER_SCAN_BYTES), + ); + if (!found) { + throw new Error(`Pi session file has no session header: ${input.sourceFile}`); + } + await writeFile(input.targetFile, `${JSON.stringify({ ...found.header, cwd: input.cwd })}\n`, { + mode: 0o600, + }); + await pipeline( + createReadStream(input.sourceFile, { start: found.bodyStart }), + createWriteStream(input.targetFile, { flags: "a" }), + ); +} + +function findSessionHeaderLine( + scanned: Buffer, +): { header: Record; bodyStart: number } | null { + let lineStart = 0; + while (lineStart < scanned.length) { + const newline = scanned.indexOf(0x0a, lineStart); + const bodyStart = newline === -1 ? scanned.length : newline + 1; + const line = scanned.subarray(lineStart, bodyStart).toString("utf8").trim(); + if (line) { + const header = parseJsonRecord(line); + return header?.type === "session" ? { header, bodyStart } : null; + } + lineStart = bodyStart; + } + return null; +} + +async function readSessionFileStart(filePath: string, maxBytes: number): Promise { + const handle = await open(filePath, "r").catch((error: unknown) => { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw new Error(`Pi session file is missing: ${filePath}`); + } + throw error; + }); + try { + const buffer = Buffer.alloc(maxBytes); + const { bytesRead } = await handle.read(buffer, 0, maxBytes, 0); + return buffer.subarray(0, bytesRead); + } finally { + await handle.close().catch(() => undefined); + } +} + export async function readPiImportSessionConfig(filePath: string): Promise { const descriptor = await readPiSessionDescriptor(filePath); if (!descriptor) return {}; diff --git a/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts b/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts index 983b86c19d..90a80e4c66 100644 --- a/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts +++ b/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts @@ -1,3 +1,5 @@ +import { existsSync, readFileSync } from "node:fs"; + import type { PiRuntime, PiRuntimeLaunch, @@ -75,6 +77,12 @@ export class FakePi implements PiRuntime { const session = new FakePiSession(launch, this.removedModels); session.commands = this.queuedCommands.shift() ?? []; this.queuedSessionSetups.shift()?.(session); + const missingSessionCwd = launch.noSession ? null : readMissingSessionCwd(launch.session); + if (missingSessionCwd !== null) { + session.getStateError = new Error( + `Pi RPC process exited with code 1 and signal null\nStored session working directory does not exist: ${missingSessionCwd}\nSession file: ${launch.session}\nCurrent working directory: ${launch.cwd}`, + ); + } this.sessions.push(session); return session; } @@ -101,6 +109,27 @@ export class FakePi implements PiRuntime { } } +function readMissingSessionCwd(sessionFile: string | undefined): string | null { + if (!sessionFile || !existsSync(sessionFile)) { + return null; + } + const [headerLine = ""] = readFileSync(sessionFile, "utf8").split("\n", 1); + let header: unknown; + try { + header = JSON.parse(headerLine); + } catch { + return null; + } + if (typeof header !== "object" || header === null || !("cwd" in header)) { + return null; + } + const { type, cwd } = header as { type?: unknown; cwd?: unknown }; + if (type !== "session" || typeof cwd !== "string" || !cwd || existsSync(cwd)) { + return null; + } + return cwd; +} + export class FakePiSession implements PiRuntimeSession { readonly environment: Record; readonly prompts: Array<{ message: string; imageCount: number }> = []; diff --git a/packages/server/src/server/agent/providers/resume-process-cwd.ts b/packages/server/src/server/agent/providers/resume-process-cwd.ts new file mode 100644 index 0000000000..d32637a9a6 --- /dev/null +++ b/packages/server/src/server/agent/providers/resume-process-cwd.ts @@ -0,0 +1,26 @@ +import { stat } from "node:fs/promises"; +import { homedir } from "node:os"; + +import type { AgentResumePurpose } from "../agent-sdk-types.js"; + +export async function resolveResumeProcessCwd( + cwd: string, + purpose: AgentResumePurpose | undefined, +): Promise { + if (purpose !== "history" || (await isDirectory(cwd))) { + return cwd; + } + return homedir(); +} + +async function isDirectory(target: string): Promise { + try { + return (await stat(target)).isDirectory(); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT" || code === "ENOTDIR") { + return false; + } + throw error; + } +} diff --git a/packages/server/src/terminal/terminal.test.ts b/packages/server/src/terminal/terminal.test.ts index e4f8544050..3d1fcca545 100644 --- a/packages/server/src/terminal/terminal.test.ts +++ b/packages/server/src/terminal/terminal.test.ts @@ -410,7 +410,12 @@ describe("createTerminal", () => { cwd: realpathSync(tmpdir()), cols: 500, command: process.execPath, - args: ["-e", "process.stdout.write(JSON.stringify(process.argv.slice(1)))", nastyPrompt], + // Keep the PTY alive until the screen assertion; exit disposes its parser. + args: [ + "-e", + "process.stdout.write(JSON.stringify(process.argv.slice(1))); setInterval(() => {}, 100000)", + nastyPrompt, + ], }), ); From fa89738e3a937e83d10b8998ee0d96f3708947dd Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 1 Nov 2026 14:46:52 +0700 Subject: [PATCH 28/58] chore(desktop): upgrade packaging tool and remove signing backport (#8450) * chore(desktop): upgrade packaging tool and remove signing backport * fix(nix): resolve the desktop workspace packaging tool --- .github/workflows/desktop-release.yml | 26 - nix/desktop-package.nix | 2 +- nix/npm-deps.hash | 2 +- package-lock.json | 2313 +++++++++++-------------- packages/desktop/package.json | 2 +- scripts/ci-workflow.test.mjs | 83 +- 6 files changed, 1060 insertions(+), 1368 deletions(-) diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index bdbeb9f139..26be12291b 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -135,32 +135,6 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Backport electron-builder#10172 to the pinned build tool only. - # Remove this step when app-builder-lib is upgraded to a release containing it. - - name: Repair temporary keychain password in signing tool - shell: bash - run: | - node <<'KEYCHAIN_FIX' - const fs = require('node:fs'); - const path = require('node:path'); - const manifestPath = require.resolve('app-builder-lib/package.json'); - if (JSON.parse(fs.readFileSync(manifestPath, 'utf8')).version !== '26.8.1') { - throw new Error('Reassess/remove the signing backport for the new app-builder-lib version'); - } - const sourcePath = path.join(path.dirname(manifestPath), 'out/codeSign/macCodeSign.js'); - let source = fs.readFileSync(sourcePath, 'utf8'); - const replacements = [ - ['importCerts(keychainFile, certPaths, cscPasswords)', 'importCerts(keychainFile, certPaths, cscPasswords, keychainPassword)'], - ['importCerts(keychainFile, paths, keyPasswords)', 'importCerts(keychainFile, paths, keyPasswords, keychainPassword)'], - ['["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", password, keychainFile]', '["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", keychainPassword, keychainFile]'], - ]; - for (const [before, after] of replacements) { - if (source.split(before).length !== 2) throw new Error('Unexpected signing tool source; refusing to patch'); - source = source.replace(before, after); - } - fs.writeFileSync(sourcePath, source); - KEYCHAIN_FIX - - name: Set desktop package version from tag shell: bash run: | diff --git a/nix/desktop-package.nix b/nix/desktop-package.nix index 00ed6cd926..48cbd9bbb7 100644 --- a/nix/desktop-package.nix +++ b/nix/desktop-package.nix @@ -127,7 +127,7 @@ buildNpmPackage { # The Nix output is not a distributable DMG, so leave it unsigned and # disable the hardened runtime that requires a matching signature. CSC_IDENTITY_AUTO_DISCOVERY=false \ - ../../node_modules/.bin/electron-builder \ + npm exec --offline -- electron-builder \ --config electron-builder.yml \ --dir \ --mac \ diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index 3efbb30b52..7e151568f6 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-CeT2PtYIAFwvBC9LIku8VkTl0pvKxYzSCn0QW2ManfU= +sha256-KN/AVZV/o0TleLLpmggSrdJqjFDJ/NJYVHiC54xb4jQ= diff --git a/package-lock.json b/package-lock.json index 4b9481ff8e..dbb64e68a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2788,58 +2788,6 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, - "node_modules/@develar/schema-utils": { - "version": "2.6.5", - "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", - "integrity": "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.0", - "ajv-keywords": "^3.4.1" - }, - "engines": { - "node": ">= 8.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/@develar/schema-utils/node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@develar/schema-utils/node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "ajv": "^6.9.1" - } - }, - "node_modules/@develar/schema-utils/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, "node_modules/@dnd-kit/accessibility": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", @@ -3131,84 +3079,6 @@ "url": "https://github.com/sponsors/gjtorikian/" } }, - "node_modules/@electron/rebuild": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.0.3.tgz", - "integrity": "sha512-u9vpTHRMkOYCs/1FLiSVAFZ7FbjsXK+bQuzviJZa+lG7BHZl1nz52/IcGvwa3sk80/fc3llutBkbCq10Vh8WQA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@malept/cross-spawn-promise": "^2.0.0", - "debug": "^4.1.1", - "detect-libc": "^2.0.1", - "got": "^11.7.0", - "graceful-fs": "^4.2.11", - "node-abi": "^4.2.0", - "node-api-version": "^0.2.1", - "node-gyp": "^11.2.0", - "ora": "^5.1.0", - "read-binary-file-arch": "^1.0.6", - "semver": "^7.3.5", - "tar": "^7.5.6", - "yargs": "^17.0.1" - }, - "bin": { - "electron-rebuild": "lib/cli.js" - }, - "engines": { - "node": ">=22.12.0" - } - }, - "node_modules/@electron/rebuild/node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/@electron/rebuild/node_modules/minizlib": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", - "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.1.2" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@electron/rebuild/node_modules/tar": { - "version": "7.5.12", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.12.tgz", - "integrity": "sha512-9TsuLcdhOn4XztcQqhNyq1KOwOOED/3k58JAvtULiYqbO8B/0IBAAIE1hj0Svmm58k27TmcigyDI0deMlgG3uw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.1.0", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@electron/rebuild/node_modules/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, "node_modules/@electron/universal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.3.tgz", @@ -3259,6 +3129,45 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@electron/windows-sign": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", + "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "peer": true, + "dependencies": { + "cross-dirname": "^0.1.0", + "debug": "^4.3.4", + "fs-extra": "^11.1.1", + "minimist": "^1.2.8", + "postject": "^1.0.0-alpha.6" + }, + "bin": { + "electron-windows-sign": "bin/electron-windows-sign.js" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/windows-sign/node_modules/fs-extra": { + "version": "11.4.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.1.tgz", + "integrity": "sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, "node_modules/@emnapi/core": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", @@ -9135,81 +9044,6 @@ "node": ">=12.4.0" } }, - "node_modules/@npmcli/agent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-3.0.0.tgz", - "integrity": "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==", - "dev": true, - "license": "ISC", - "dependencies": { - "agent-base": "^7.1.0", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.1", - "lru-cache": "^10.0.1", - "socks-proxy-agent": "^8.0.3" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@npmcli/agent/node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/@npmcli/agent/node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/@npmcli/agent/node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/@npmcli/agent/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/@npmcli/fs": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-4.0.0.tgz", - "integrity": "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==", - "dev": true, - "license": "ISC", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, "node_modules/@oclif/core": { "version": "1.26.2", "resolved": "https://registry.npmjs.org/@oclif/core/-/core-1.26.2.tgz", @@ -10499,6 +10333,61 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.11.1.tgz", + "integrity": "sha512-jaU9OaCcbZUdyXx9hMETZB9xoNmxTGjOGFqd3cOwKFkpKnyKd9WxF3uqj4PvX4cyOMP9bSdHg5YyML2qtx/d/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/json-schema": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", + "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/webcrypto": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.7.2.tgz", + "integrity": "sha512-ktTSACRJ5WdpfjHjFT2EWJQQPacGv1cPCDfEbdmpAHOxdFiYqPe4lfnXGA3D8Dg9/0kHPYSvyRlgujIOVlTdvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/json-schema": "^1.1.12", + "@peculiar/utils": "^2.0.2", + "tslib": "^2.8.1", + "webcrypto-core": "^1.9.2" + }, + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", @@ -13470,18 +13359,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/plist": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz", - "integrity": "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*", - "xmlbuilder": ">=11.0.1" - } - }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -13632,14 +13509,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/verror": { - "version": "1.10.11", - "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", - "integrity": "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -14798,13 +14667,6 @@ "dev": true, "license": "BSD-2-Clause" }, - "node_modules/7zip-bin": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", - "integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==", - "dev": true, - "license": "MIT" - }, "node_modules/abab": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", @@ -15089,276 +14951,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/app-builder-bin": { - "version": "5.0.0-alpha.12", - "resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-5.0.0-alpha.12.tgz", - "integrity": "sha512-j87o0j6LqPL3QRr8yid6c+Tt5gC7xNfYo6uQIQkorAC6MpeayVMZrEDzKmJJ/Hlv7EnOQpaRm53k6ktDYZyB6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/app-builder-lib": { - "version": "26.8.1", - "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.8.1.tgz", - "integrity": "sha512-p0Im/Dx5C4tmz8QEE1Yn4MkuPC8PrnlRneMhWJj7BBXQfNTJUshM/bp3lusdEsDbvvfJZpXWnYesgSLvwtM2Zw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@develar/schema-utils": "~2.6.5", - "@electron/asar": "3.4.1", - "@electron/fuses": "^1.8.0", - "@electron/get": "^3.0.0", - "@electron/notarize": "2.5.0", - "@electron/osx-sign": "1.3.3", - "@electron/rebuild": "^4.0.3", - "@electron/universal": "2.0.3", - "@malept/flatpak-bundler": "^0.4.0", - "@types/fs-extra": "9.0.13", - "async-exit-hook": "^2.0.1", - "builder-util": "26.8.1", - "builder-util-runtime": "9.5.1", - "chromium-pickle-js": "^0.2.0", - "ci-info": "4.3.1", - "debug": "^4.3.4", - "dotenv": "^16.4.5", - "dotenv-expand": "^11.0.6", - "ejs": "^3.1.8", - "electron-publish": "26.8.1", - "fs-extra": "^10.1.0", - "hosted-git-info": "^4.1.0", - "isbinaryfile": "^5.0.0", - "jiti": "^2.4.2", - "js-yaml": "^4.1.0", - "json5": "^2.2.3", - "lazy-val": "^1.0.5", - "minimatch": "^10.0.3", - "plist": "3.1.0", - "proper-lockfile": "^4.1.2", - "resedit": "^1.7.0", - "semver": "~7.7.3", - "tar": "^7.5.7", - "temp-file": "^3.4.0", - "tiny-async-pool": "1.3.0", - "which": "^5.0.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "dmg-builder": "26.8.1", - "electron-builder-squirrel-windows": "26.8.1" - } - }, - "node_modules/app-builder-lib/node_modules/@electron/get": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@electron/get/-/get-3.1.0.tgz", - "integrity": "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.1.1", - "env-paths": "^2.2.0", - "fs-extra": "^8.1.0", - "got": "^11.8.5", - "progress": "^2.0.3", - "semver": "^6.2.0", - "sumchecker": "^3.0.1" - }, - "engines": { - "node": ">=14" - }, - "optionalDependencies": { - "global-agent": "^3.0.0" - } - }, - "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/app-builder-lib/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/app-builder-lib/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/app-builder-lib/node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/app-builder-lib/node_modules/ci-info": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", - "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/app-builder-lib/node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/app-builder-lib/node_modules/isexe": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", - "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/app-builder-lib/node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, - "license": "MIT", - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/app-builder-lib/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/app-builder-lib/node_modules/minizlib": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", - "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.1.2" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/app-builder-lib/node_modules/tar": { - "version": "7.5.12", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.12.tgz", - "integrity": "sha512-9TsuLcdhOn4XztcQqhNyq1KOwOOED/3k58JAvtULiYqbO8B/0IBAAIE1hj0Svmm58k27TmcigyDI0deMlgG3uw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.1.0", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/app-builder-lib/node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/app-builder-lib/node_modules/which": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", - "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^3.1.1" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/app-builder-lib/node_modules/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -15598,15 +15190,19 @@ "safer-buffer": "~2.1.0" } }, - "node_modules/assert-plus": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "node_modules/asn1js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.1.0.tgz", + "integrity": "sha512-HWYi1THkOqguCiO+vwj09ZkQFD2kKEsejYHAYCtX0D80WVdoDnvRc9IukXX/jeb0T9NRWJnRtqwdbHltnFjwXA==", "dev": true, - "license": "MIT", - "optional": true, + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.2.0", + "tslib": "^2.8.1" + }, "engines": { - "node": ">=0.8" + "node": ">=12.0.0" } }, "node_modules/assertion-error": { @@ -15711,6 +15307,13 @@ "integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==", "license": "MIT" }, + "node_modules/aws4": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "dev": true, + "license": "MIT" + }, "node_modules/axios": { "version": "1.13.6", "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", @@ -16153,6 +15756,13 @@ "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", "license": "MIT" }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true, + "license": "MIT" + }, "node_modules/body-parser": { "version": "1.20.4", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", @@ -16354,31 +15964,6 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" }, - "node_modules/builder-util": { - "version": "26.8.1", - "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.8.1.tgz", - "integrity": "sha512-pm1lTYbGyc90DHgCDO7eo8Rl4EqKLciayNbZqGziqnH9jrlKe8ZANGdityLZU+pJh16dfzjAx2xQq9McuIPEtw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/debug": "^4.1.6", - "7zip-bin": "~5.2.0", - "app-builder-bin": "5.0.0-alpha.12", - "builder-util-runtime": "9.5.1", - "chalk": "^4.1.2", - "cross-spawn": "^7.0.6", - "debug": "^4.3.4", - "fs-extra": "^10.1.0", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.0", - "js-yaml": "^4.1.0", - "sanitize-filename": "^1.6.3", - "source-map-support": "^0.5.19", - "stat-mode": "^1.0.0", - "temp-file": "^3.4.0", - "tiny-async-pool": "1.3.0" - } - }, "node_modules/builder-util-runtime": { "version": "9.5.1", "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.5.1.tgz", @@ -16392,44 +15977,6 @@ "node": ">=12.0.0" } }, - "node_modules/builder-util/node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/builder-util/node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/builder-util/node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/bunyan": { "version": "1.8.15", "resolved": "https://registry.npmjs.org/bunyan/-/bunyan-1.8.15.tgz", @@ -16458,98 +16005,14 @@ "node": ">= 0.8" } }, - "node_modules/cacache": { - "version": "19.0.1", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz", - "integrity": "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==", + "node_modules/bytestreamjs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz", + "integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==", "dev": true, - "license": "ISC", - "dependencies": { - "@npmcli/fs": "^4.0.0", - "fs-minipass": "^3.0.0", - "glob": "^10.2.2", - "lru-cache": "^10.0.1", - "minipass": "^7.0.3", - "minipass-collect": "^2.0.1", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "p-map": "^7.0.2", - "ssri": "^12.0.0", - "tar": "^7.4.3", - "unique-filename": "^4.0.0" - }, + "license": "BSD-3-Clause", "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/cacache/node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/cacache/node_modules/fs-minipass": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", - "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/cacache/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/cacache/node_modules/minizlib": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", - "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.1.2" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/cacache/node_modules/tar": { - "version": "7.5.12", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.12.tgz", - "integrity": "sha512-9TsuLcdhOn4XztcQqhNyq1KOwOOED/3k58JAvtULiYqbO8B/0IBAAIE1hj0Svmm58k27TmcigyDI0deMlgG3uw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.1.0", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/cacache/node_modules/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" + "node": ">=6.0.0" } }, "node_modules/cacheable-lookup": { @@ -16986,40 +16449,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/cli-truncate": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", - "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "slice-ansi": "^3.0.0", - "string-width": "^4.2.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate/node_modules/slice-ansi": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", - "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "astral-regex": "^2.0.0", - "is-fullwidth-code-point": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -17405,43 +16834,6 @@ "layout-base": "^1.0.0" } }, - "node_modules/crc": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", - "integrity": "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "buffer": "^5.1.0" - } - }, - "node_modules/crc/node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "optional": true, - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, "node_modules/create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -17478,6 +16870,15 @@ "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", "license": "MIT" }, + "node_modules/cross-dirname": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", + "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/cross-env": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", @@ -18680,76 +18081,6 @@ "node": ">=8" } }, - "node_modules/dmg-builder": { - "version": "26.8.1", - "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.8.1.tgz", - "integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "app-builder-lib": "26.8.1", - "builder-util": "26.8.1", - "fs-extra": "^10.1.0", - "iconv-lite": "^0.6.2", - "js-yaml": "^4.1.0" - }, - "optionalDependencies": { - "dmg-license": "^1.0.11" - } - }, - "node_modules/dmg-license": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/dmg-license/-/dmg-license-1.0.11.tgz", - "integrity": "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==", - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "dependencies": { - "@types/plist": "^3.0.1", - "@types/verror": "^1.10.3", - "ajv": "^6.10.0", - "crc": "^3.8.0", - "iconv-corefoundation": "^1.1.7", - "plist": "^3.0.4", - "smart-buffer": "^4.0.2", - "verror": "^1.10.0" - }, - "bin": { - "dmg-license": "bin/dmg-license.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/dmg-license/node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/dmg-license/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/dnssd-advertise": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/dnssd-advertise/-/dnssd-advertise-1.1.6.tgz", @@ -18941,6 +18272,16 @@ "node": ">= 0.4" } }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, "node_modules/eas-cli": { "version": "16.32.0", "resolved": "https://registry.npmjs.org/eas-cli/-/eas-cli-16.32.0.tgz", @@ -19261,48 +18602,6 @@ "node": ">= 22.12.0" } }, - "node_modules/electron-builder": { - "version": "26.8.1", - "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.8.1.tgz", - "integrity": "sha512-uWhx1r74NGpCagG0ULs/P9Nqv2nsoo+7eo4fLUOB8L8MdWltq9odW/uuLXMFCDGnPafknYLZgjNX0ZIFRzOQAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "app-builder-lib": "26.8.1", - "builder-util": "26.8.1", - "builder-util-runtime": "9.5.1", - "chalk": "^4.1.2", - "ci-info": "^4.2.0", - "dmg-builder": "26.8.1", - "fs-extra": "^10.1.0", - "lazy-val": "^1.0.5", - "simple-update-notifier": "2.0.0", - "yargs": "^17.6.2" - }, - "bin": { - "electron-builder": "cli.js", - "install-app-deps": "install-app-deps.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/electron-builder/node_modules/ci-info": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", - "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/electron-log": { "version": "5.4.3", "resolved": "https://registry.npmjs.org/electron-log/-/electron-log-5.4.3.tgz", @@ -19312,36 +18611,6 @@ "node": ">= 14" } }, - "node_modules/electron-publish": { - "version": "26.8.1", - "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.8.1.tgz", - "integrity": "sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/fs-extra": "^9.0.11", - "builder-util": "26.8.1", - "builder-util-runtime": "9.5.1", - "chalk": "^4.1.2", - "form-data": "^4.0.5", - "fs-extra": "^10.1.0", - "lazy-val": "^1.0.5", - "mime": "^2.5.2" - } - }, - "node_modules/electron-publish/node_modules/mime": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", - "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", - "dev": true, - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/electron-to-chromium": { "version": "1.5.307", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz", @@ -19364,6 +18633,66 @@ "tiny-typed-emitter": "^2.1.0" } }, + "node_modules/electron-winstaller": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz", + "integrity": "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@electron/asar": "^3.2.1", + "debug": "^4.1.1", + "fs-extra": "^7.0.1", + "lodash": "^4.17.21", + "temp": "^0.9.0" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "@electron/windows-sign": "^1.1.2" + } + }, + "node_modules/electron-winstaller/node_modules/fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/electron-winstaller/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "peer": true, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-winstaller/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/electron/node_modules/@types/node": { "version": "24.13.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", @@ -21719,17 +21048,6 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, - "node_modules/extsprintf": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", - "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", - "dev": true, - "engines": [ - "node >=0.6.0" - ], - "license": "MIT", - "optional": true - }, "node_modules/fake-indexeddb": { "version": "6.2.5", "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", @@ -23439,32 +22757,6 @@ } } }, - "node_modules/iconv-corefoundation": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", - "integrity": "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==", - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "dependencies": { - "cli-truncate": "^2.1.0", - "node-addon-api": "^1.6.3" - }, - "engines": { - "node": "^8.11.2 || >=10" - } - }, - "node_modules/iconv-corefoundation/node_modules/node-addon-api": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz", - "integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -27064,49 +26356,6 @@ "dev": true, "license": "ISC" }, - "node_modules/make-fetch-happen": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-14.0.3.tgz", - "integrity": "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "@npmcli/agent": "^3.0.0", - "cacache": "^19.0.1", - "http-cache-semantics": "^4.1.1", - "minipass": "^7.0.2", - "minipass-fetch": "^4.0.0", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^1.0.0", - "proc-log": "^5.0.0", - "promise-retry": "^2.0.1", - "ssri": "^12.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/make-fetch-happen/node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/make-fetch-happen/node_modules/proc-log": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz", - "integrity": "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, "node_modules/makeerror": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", @@ -29307,115 +28556,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/minipass-collect": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", - "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/minipass-fetch": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-4.0.1.tgz", - "integrity": "sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.0.3", - "minipass-sized": "^1.0.3", - "minizlib": "^3.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - }, - "optionalDependencies": { - "encoding": "^0.1.13" - } - }, - "node_modules/minipass-flush": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", - "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minipass-flush/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-pipeline": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", - "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-pipeline/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-sized": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", - "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-sized/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/minizlib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.1.tgz", @@ -29826,31 +28966,6 @@ "node": ">= 6.13.0" } }, - "node_modules/node-gyp": { - "version": "11.5.0", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-11.5.0.tgz", - "integrity": "sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "env-paths": "^2.2.0", - "exponential-backoff": "^3.1.1", - "graceful-fs": "^4.2.6", - "make-fetch-happen": "^14.0.3", - "nopt": "^8.0.0", - "proc-log": "^5.0.0", - "semver": "^7.3.5", - "tar": "^7.4.3", - "tinyglobby": "^0.2.12", - "which": "^5.0.0" - }, - "bin": { - "node-gyp": "bin/node-gyp.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, "node_modules/node-gyp-build": { "version": "4.8.4", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", @@ -29878,92 +28993,6 @@ "node-gyp-build-optional-packages-test": "build-test.js" } }, - "node_modules/node-gyp/node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/node-gyp/node_modules/isexe": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", - "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/node-gyp/node_modules/minizlib": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", - "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.1.2" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/node-gyp/node_modules/proc-log": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz", - "integrity": "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/node-gyp/node_modules/tar": { - "version": "7.5.12", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.12.tgz", - "integrity": "sha512-9TsuLcdhOn4XztcQqhNyq1KOwOOED/3k58JAvtULiYqbO8B/0IBAAIE1hj0Svmm58k27TmcigyDI0deMlgG3uw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.1.0", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/node-gyp/node_modules/which": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", - "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^3.1.1" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/node-gyp/node_modules/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -30627,19 +29656,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-map": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", - "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/p-memoize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/p-memoize/-/p-memoize-8.0.0.tgz", @@ -31145,6 +30161,40 @@ "node": ">=8" } }, + "node_modules/pkijs": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.3.tgz", + "integrity": "sha512-V9kOzaItwDgqlryhXysQsAXUEKi5O7kqWUaP3dXI7MLvkPqWWMb4wVb1QAtmEDCKI+XIAINsZoNKMFF7sZb1eg==", + "dev": true, + "license": "BSD-3-Clause", + "workspaces": [ + "website" + ], + "dependencies": { + "@noble/hashes": "1.8.0", + "asn1js": "^3.0.6", + "bytestreamjs": "^2.0.1", + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/pkijs/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/playwright": { "version": "1.58.2", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", @@ -31294,6 +30344,36 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, + "node_modules/postject": { + "version": "1.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", + "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "commander": "^9.4.0" + }, + "bin": { + "postject": "dist/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/postject/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -31565,6 +30645,26 @@ ], "license": "MIT" }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz", + "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/qrcode": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", @@ -33156,6 +32256,70 @@ "node": ">=0.10.0" } }, + "node_modules/rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/roarr": { "version": "2.15.4", "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", @@ -34120,17 +33284,6 @@ "node": ">=8.0.0" } }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, "node_modules/smol-toml": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz", @@ -34144,46 +33297,6 @@ "url": "https://github.com/sponsors/cyyynthia" } }, - "node_modules/socks": { - "version": "2.8.7", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", - "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ip-address": "^10.0.1", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks-proxy-agent": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", - "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "socks": "^2.8.3" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/socks-proxy-agent/node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "node_modules/sonic-boom": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", @@ -34312,19 +33425,6 @@ "node": ">=20.16.0" } }, - "node_modules/ssri": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz", - "integrity": "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -35071,6 +34171,21 @@ "node": ">=8" } }, + "node_modules/temp": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", + "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "mkdirp": "^0.5.1", + "rimraf": "~2.6.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/temp-dir": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", @@ -35092,6 +34207,20 @@ "fs-extra": "^10.0.0" } }, + "node_modules/temp/node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/terminal-link": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", @@ -36074,32 +35203,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/unique-filename": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-4.0.0.tgz", - "integrity": "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "unique-slug": "^5.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/unique-slug": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-5.0.0.tgz", - "integrity": "sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg==", - "dev": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, "node_modules/unique-string": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", @@ -36283,6 +35386,35 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/unzipper": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.5.tgz", + "integrity": "sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "bluebird": "~3.7.2", + "duplexer2": "~0.1.4", + "fs-extra": "11.3.1", + "graceful-fs": "^4.2.2", + "node-int64": "^0.4.0" + } + }, + "node_modules/unzipper/node_modules/fs-extra": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.1.tgz", + "integrity": "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -36496,22 +35628,6 @@ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, - "node_modules/verror": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", - "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "assert-plus": "^1.0.0", - "core-util-is": "1.0.2", - "extsprintf": "^1.2.0" - }, - "engines": { - "node": ">=0.6.0" - } - }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -36919,6 +36035,20 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/webcrypto-core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz", + "integrity": "sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/json-schema": "^1.1.12", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -40082,13 +39212,94 @@ "@types/node": "24.6.0", "@types/ws": "^8.5.14", "electron": "44.2.0", - "electron-builder": "26.8.1", + "electron-builder": "26.18.0", "typescript": "5.9.3", "unzip-crx-3": "^0.2.0", "vitest": "^4.1.6", "wait-on": "8.0.5" } }, + "packages/desktop/node_modules/@electron/get": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-3.1.0.tgz", + "integrity": "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=14" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "packages/desktop/node_modules/@electron/get/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "packages/desktop/node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "packages/desktop/node_modules/@electron/rebuild": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.2.1.tgz", + "integrity": "sha512-IS4pG3BysRrCLJ8xTAfvg+IQDbiUoAzdJy2SfjcYKnLDy5DpCdjnPdhS5Zf5UMfrkB24xu0KC+TqcOxg+eG+6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.1.1", + "node-abi": "^4.2.0", + "node-api-version": "^0.2.1", + "node-gyp": "^12.2.0", + "read-binary-file-arch": "^1.0.6" + }, + "bin": { + "electron-rebuild": "lib/cli.js" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "packages/desktop/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "packages/desktop/node_modules/@types/node": { "version": "24.6.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.6.0.tgz", @@ -40185,6 +39396,482 @@ "url": "https://opencollective.com/vitest" } }, + "packages/desktop/node_modules/abbrev": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "packages/desktop/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "packages/desktop/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "packages/desktop/node_modules/app-builder-lib": { + "version": "26.18.0", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.18.0.tgz", + "integrity": "sha512-lx+KkuCSZHcbECf4R3IAcC9VOFk0+cMUwljO63s5y8yqS1Lw0sRGmjPaWsPwP3zv3ohZYbMhUvjXeiQXXZhJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "3.4.1", + "@electron/fuses": "^1.8.0", + "@electron/get": "^3.0.0", + "@electron/notarize": "2.5.0", + "@electron/osx-sign": "1.3.3", + "@electron/rebuild": "^4.2.1", + "@electron/universal": "2.0.3", + "@malept/flatpak-bundler": "^0.4.0", + "@noble/hashes": "^1.8.0", + "@peculiar/webcrypto": "^1.7.1", + "@types/fs-extra": "9.0.13", + "ajv": "^8.18.0", + "asn1js": "^3.0.10", + "async-exit-hook": "^2.0.1", + "builder-util": "26.16.0", + "builder-util-runtime": "9.7.0", + "chromium-pickle-js": "^0.2.0", + "ci-info": "4.3.1", + "debug": "^4.3.4", + "dotenv": "^16.4.5", + "dotenv-expand": "^11.0.6", + "ejs": "^3.1.8", + "electron-publish": "26.18.0", + "fs-extra": "^10.1.0", + "hosted-git-info": "^4.1.0", + "isbinaryfile": "^5.0.0", + "jiti": "^2.4.2", + "js-yaml": "^4.1.0", + "json5": "^2.2.3", + "lazy-val": "^1.0.5", + "minimatch": "^10.2.5", + "pkijs": "^3.4.0", + "plist": "3.1.0", + "proper-lockfile": "^4.1.2", + "resedit": "^1.7.0", + "semver": "~7.7.3", + "tar": "^7.5.7", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0", + "unzipper": "^0.12.3", + "which": "^5.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "dmg-builder": "26.18.0", + "electron-builder-squirrel-windows": "26.18.0" + } + }, + "packages/desktop/node_modules/app-builder-lib/node_modules/ci-info": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "packages/desktop/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "packages/desktop/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "packages/desktop/node_modules/builder-util": { + "version": "26.16.0", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.16.0.tgz", + "integrity": "sha512-RLyJhB7Si3YkzKR9ubQslWuXW3Vhs3CGe1i+SeixBZ0qTd1mk3XBmssvY22TlB6CS5blyko8Gu1JzpYk8UkYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.6", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.6", + "debug": "^4.3.4", + "fs-extra": "^10.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "js-yaml": "^4.1.0", + "sanitize-filename": "^1.6.3", + "source-map-support": "^0.5.19", + "stat-mode": "^1.0.0", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "packages/desktop/node_modules/builder-util-runtime": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz", + "integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "packages/desktop/node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "packages/desktop/node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "packages/desktop/node_modules/dmg-builder": { + "version": "26.18.0", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.18.0.tgz", + "integrity": "sha512-ozCGC54wi2C1RkS2v69y9lQxvoQ1Coh+iT8CmWiyY8y1ZuOKVvuNson2Hd+HEOs9+EZNNsOBbzKgQZ4SNqWd0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.18.0", + "builder-util": "26.16.0", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0" + } + }, + "packages/desktop/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "packages/desktop/node_modules/electron-builder": { + "version": "26.18.0", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.18.0.tgz", + "integrity": "sha512-ASgIuQnOq4RuD8jMhS9hI0LS3EMRT1I/M5R6utqifB/BHN80IcCjBdt+DED+NmNPvmBgEtrlExXZitNdA7kXOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.18.0", + "builder-util": "26.16.0", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "dmg-builder": "26.18.0", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "simple-update-notifier": "2.0.0", + "yargs": "^17.6.2" + }, + "bin": { + "electron-builder": "cli.js", + "install-app-deps": "install-app-deps.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "packages/desktop/node_modules/electron-builder-squirrel-windows": { + "version": "26.18.0", + "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.18.0.tgz", + "integrity": "sha512-a2elgTipboXNIsxqS1HOmHMGp4Pd3LJgIZOdIFTOskBSawvxKyZWYWQ1d7KKA4CtINSchYCYEKzhxRqiKZev9Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "app-builder-lib": "26.18.0", + "builder-util": "26.16.0", + "electron-winstaller": "5.4.0" + } + }, + "packages/desktop/node_modules/electron-publish": { + "version": "26.18.0", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.18.0.tgz", + "integrity": "sha512-MagnqaZSALSWjcdKu5HEVhYUZD/2BDL3q7XzrqWwub6gf/UfD3ItMgmthVDv9w5y9E0gH1C3JWqKaXwf6f4ROg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^9.0.11", + "aws4": "^1.13.2", + "builder-util": "26.16.0", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "form-data": "^4.0.5", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "mime": "^2.5.2" + } + }, + "packages/desktop/node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "packages/desktop/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "packages/desktop/node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "packages/desktop/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "packages/desktop/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "packages/desktop/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "packages/desktop/node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "packages/desktop/node_modules/node-gyp": { + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", + "integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "undici": "^6.25.0", + "which": "^6.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "packages/desktop/node_modules/node-gyp/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "packages/desktop/node_modules/node-gyp/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "packages/desktop/node_modules/nopt": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^4.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "packages/desktop/node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "packages/desktop/node_modules/tar": { + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "packages/desktop/node_modules/undici": { + "version": "6.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.1.tgz", + "integrity": "sha512-EvRlUt/QqAEvXdQeRMYUfwOQsfzpDztykrG2owGEE/rBOBoYv9uF39ZzsNDh4lTFHhTr2XOZjKkYgueD12ZmGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "packages/desktop/node_modules/undici-types": { "version": "7.13.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.13.0.tgz", @@ -40192,6 +39879,16 @@ "dev": true, "license": "MIT" }, + "packages/desktop/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "packages/desktop/node_modules/vitest": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz", @@ -40309,6 +40006,32 @@ } } }, + "packages/desktop/node_modules/which": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/desktop/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", "version": "0.15.0-beta.1", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 6319805fc3..23198969dc 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -41,7 +41,7 @@ "@types/node": "24.6.0", "@types/ws": "^8.5.14", "electron": "44.2.0", - "electron-builder": "26.8.1", + "electron-builder": "26.18.0", "typescript": "5.9.3", "unzip-crx-3": "^0.2.0", "vitest": "^4.1.6", diff --git a/scripts/ci-workflow.test.mjs b/scripts/ci-workflow.test.mjs index 381f826540..97720f46ec 100644 --- a/scripts/ci-workflow.test.mjs +++ b/scripts/ci-workflow.test.mjs @@ -680,51 +680,46 @@ test("macOS jobs use latest runners while preserving release architectures", () assert.match(release, /runner: macos-26-intel\n\s+electron_arch: x64/); }); -test("macOS signing repair keeps certificate and keychain passwords separate", () => { - const workflow = readFileSync(new URL(".github/workflows/desktop-release.yml", repoRoot), "utf8"); - const match = workflow.match(/<<'KEYCHAIN_FIX'\n([\s\S]*?)\n KEYCHAIN_FIX/); - assert.ok(match, "the release workflow repairs the pinned signing tool"); - const script = match[1].replace(/^ /gm, ""); - const original = [ - "return await importCerts(keychainFile, certPaths, cscPasswords);", - "async function importCerts(keychainFile, paths, keyPasswords) {", - '["import", paths[i], "-P", password]', - '["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", password, keychainFile]', - "}", - ].join("\n"); - const directory = mkdtempSync(join(tmpdir(), "signing-repair-")); +test("Nix desktop packaging resolves the workspace-local builder", () => { + const source = readFileSync(new URL("nix/desktop-package.nix", repoRoot), "utf8"); + const invocation = source.match(/^\s+([^\n]*electron-builder) \\\n/m)?.[1]; + assert.ok(invocation, "Nix invokes the desktop packaging tool"); + const directory = mkdtempSync(join(tmpdir(), "nix-builder-resolution-")); try { - const packageDirectory = join(directory, "node_modules/app-builder-lib"); - mkdirSync(join(packageDirectory, "out/codeSign"), { recursive: true }); - const manifest = join(packageDirectory, "package.json"); - const sourcePath = join(packageDirectory, "out/codeSign/macCodeSign.js"); - for (const [version, source, succeeds] of [ - ["26.8.1", original, true], - ["26.8.1", original.replace("cscPasswords);", "differentArguments);"), false], - ["26.18.0", original, false], - ]) { - writeFileSync(manifest, JSON.stringify({ name: "app-builder-lib", version })); - writeFileSync(sourcePath, source); - const result = spawnSync(process.execPath, ["-e", script], { - cwd: directory, - encoding: "utf8", - }); - const actual = readFileSync(sourcePath, "utf8"); - if (succeeds) { - assert.equal(result.status, 0, result.stderr); - assert.ok( - actual.includes("importCerts(keychainFile, certPaths, cscPasswords, keychainPassword)"), - ); - assert.ok( - actual.includes("importCerts(keychainFile, paths, keyPasswords, keychainPassword)"), - ); - assert.ok(actual.includes('["import", paths[i], "-P", password]')); - assert.ok(actual.includes('"-k", keychainPassword, keychainFile]')); - } else { - assert.notEqual(result.status, 0); - assert.equal(actual, source, "unexpected tool inputs must fail without a partial write"); - } - } + const desktop = join(directory, "packages/desktop"); + mkdirSync(join(desktop, "node_modules/.bin"), { recursive: true }); + mkdirSync(join(desktop, "node_modules/electron-builder")); + writeFileSync( + join(desktop, "node_modules/electron-builder/package.json"), + JSON.stringify({ + name: "electron-builder", + version: "1.0.0", + bin: { "electron-builder": "../.bin/electron-builder" }, + }), + ); + writeFileSync( + join(directory, "package.json"), + JSON.stringify({ private: true, workspaces: ["packages/desktop"] }), + ); + writeFileSync( + join(desktop, "package.json"), + JSON.stringify({ + name: "desktop-fixture", + private: true, + devDependencies: { "electron-builder": "1.0.0" }, + }), + ); + writeFileSync( + join(desktop, "node_modules/.bin/electron-builder"), + '#!/bin/sh\nprintf "workspace-builder\\n"\n', + { mode: 0o755 }, + ); + const result = spawnSync("sh", ["-c", `${invocation} --version`], { + cwd: desktop, + encoding: "utf8", + }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout.trim(), "workspace-builder"); } finally { rmSync(directory, { recursive: true, force: true }); } From 81301474265fbe3b0a798d519b71ffc31e6d255d Mon Sep 17 00:00:00 2001 From: Dominic <33105147+DAnaeto@users.noreply.github.com> Date: Sun, 1 Nov 2026 05:04:25 -0500 Subject: [PATCH 29/58] fix(app): stop the terminal from typing its version reply into the shell (#8419) The browser terminal is a renderer and leaves protocol replies to the daemon, but it still answered XTVERSION (CSI > q) and DECRQSS (DCS $ q ... ST). Those replies went out through onData as if the user had typed them, so a query from a program in the shell left text like ">|xterm.js(6.1.0-beta.292)" on the next input line. Both queries are now swallowed with the other protocol queries. Fixes #8111 --- .../terminal/runtime/terminal-emulator-runtime.browser.test.ts | 2 ++ packages/app/src/terminal/runtime/terminal-emulator-runtime.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts index 3b58c54376..bf044237b0 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts @@ -717,11 +717,13 @@ describe("terminal emulator runtime in a real browser", () => { { name: "DA1-zero", bytes: "\x1b[0c" }, { name: "DA2", bytes: "\x1b[>c" }, { name: "DA3", bytes: "\x1b[=c" }, + { name: "XTVERSION", bytes: "\x1b[>q" }, { name: "DSR-5", bytes: "\x1b[5n" }, { name: "DSR-6", bytes: "\x1b[6n" }, { name: "DSR-?6", bytes: "\x1b[?6n" }, { name: "DECRQM", bytes: "\x1b[1$p" }, { name: "DECRQM-?", bytes: "\x1b[?1$p" }, + { name: "DECRQSS", bytes: "\x1bP$qm\x1b\\" }, { name: "OSC-10-foreground-color", bytes: "\x1b]10;?\x07" }, { name: "OSC-11-background-color", bytes: "\x1b]11;?\x07" }, { name: "OSC-12-cursor-color", bytes: "\x1b]12;?\x07" }, diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts index 05cf0182d1..b1cab84d0e 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts @@ -647,6 +647,7 @@ export class TerminalEmulatorRuntime { terminal.parser.registerCsiHandler({ final: "c" }, () => true); terminal.parser.registerCsiHandler({ prefix: ">", final: "c" }, () => true); terminal.parser.registerCsiHandler({ prefix: "=", final: "c" }, () => true); + terminal.parser.registerCsiHandler({ prefix: ">", final: "q" }, () => true); terminal.parser.registerCsiHandler({ final: "n" }, () => true); terminal.parser.registerCsiHandler({ prefix: "?", final: "n" }, () => true); terminal.parser.registerCsiHandler({ final: "R" }, () => true); @@ -655,6 +656,7 @@ export class TerminalEmulatorRuntime { { prefix: "?", intermediates: "$", final: "p" }, () => true, ); + terminal.parser.registerDcsHandler({ intermediates: "$", final: "q" }, () => true); for (const code of [10, 11, 12]) { terminal.parser.registerOscHandler(code, (data) => data.trim() === "?"); } From 9b0542d127ec6452e09c5f4d01e7da328aea5fc0 Mon Sep 17 00:00:00 2001 From: Dominic <33105147+DAnaeto@users.noreply.github.com> Date: Sun, 1 Nov 2026 05:59:33 -0500 Subject: [PATCH 30/58] fix(server): retry a connected host that failed the capability check (#8423) * fix(server): retry a connected host that failed the capability check Every failure in a connected host's acceptance step was terminal: the row went to "error" with no retry, and the status listener ignored later reconnects. A host rejected with unsupported_capability stayed unusable after it was updated, until the registration was saved again or the managing daemon restarted. Capability and other non-identity acceptance failures now go "offline" with nextRetryAt and retry with capped backoff (1.5 s doubling to 30 s), so an updated host comes online by itself. Self, duplicate, changed-identity and authentication failures stay terminal. Fixes #7971 * fix(server): keep a retried host's relay security floor and update guidance A scheduled capability retry built a new transport factory, which dropped the relay security floor learned from a protected handshake, so the retry could negotiate legacy encryption and send credentials. Retries now reuse the registration's transport factory (createHostClient accepts one). The update guidance also stays visible while a retry connects, the backoff resets after a successful acceptance, and disabling or removing a host cancels its pending retry. --- docs/connected-hosts.md | 5 +- packages/client/src/node/index.ts | 7 +- .../server/connected-hosts/hosts.e2e.test.ts | 205 ++++++++++++++++++ .../src/server/connected-hosts/index.ts | 90 +++++--- 4 files changed, 278 insertions(+), 29 deletions(-) diff --git a/docs/connected-hosts.md b/docs/connected-hosts.md index 20c86c1295..7e8b67ee62 100644 --- a/docs/connected-hosts.md +++ b/docs/connected-hosts.md @@ -74,7 +74,10 @@ registrations remain inspectable; a changed destination requires explicit identi Both daemons advertise `connectedHosts`; the shared link additionally requires `hostMultiplexing`. A completes capability and pinned-identity checks on each physical recovery before admitting channels. There is no local fallback. An older daemon continues to serve existing local -operations; requesting this feature gives update guidance. +operations; requesting this feature gives update guidance. The registry retries an older destination +with capped backoff, so it comes online once updated. Self, duplicate, changed-identity, and +authentication failures stay in `error` until an update changes the connection, `enabled`, or +`replaceIdentity`, or A restarts. Disposing a routed API releases its observations and session. It does not remove the saved host or affect sibling API scopes. Disposing the origin cascades to its derived scopes. Observations restore diff --git a/packages/client/src/node/index.ts b/packages/client/src/node/index.ts index 30d2b8744f..464d16332d 100644 --- a/packages/client/src/node/index.ts +++ b/packages/client/src/node/index.ts @@ -204,15 +204,16 @@ export function createHostTransportFactory( } export function createHostClient( connection: string, - config: Omit & HostTransportOptions, + config: Omit & HostTransportOptions, budget?: HostTransportBudget, ): DaemonClient { const target = parseHostTarget(connection); - const { webSocketAgent, ...clientConfig } = config; + const { webSocketAgent, transportFactory, ...clientConfig } = config; return new DaemonClient({ ...clientConfig, url: "ws://host-connector/ws", ...(target.password ? { password: target.password } : {}), - transportFactory: createHostTransportFactory(connection, budget, { webSocketAgent }), + transportFactory: + transportFactory ?? createHostTransportFactory(connection, budget, { webSocketAgent }), }); } diff --git a/packages/server/src/server/connected-hosts/hosts.e2e.test.ts b/packages/server/src/server/connected-hosts/hosts.e2e.test.ts index 4c6d79aced..264589f82e 100644 --- a/packages/server/src/server/connected-hosts/hosts.e2e.test.ts +++ b/packages/server/src/server/connected-hosts/hosts.e2e.test.ts @@ -784,6 +784,211 @@ test("old capabilities fail before execution and authentication errors recover t expect((await online(paseo)).hosts[0]!.serverId).toBe(b.daemon.getServerId()); }); +async function updatableDestination() { + let features: { connectedHosts: boolean; hostMultiplexing: boolean } | undefined; + let holding = false; + const held: (() => void)[] = []; + let connections = 0; + const http = createServer(); + const ws = new WebSocketServer({ server: http }); + ws.on("connection", (socket) => { + connections++; + socket.on("message", (data) => { + const frame = JSON.parse(data.toString()); + if (frame.type === "ping") socket.send(JSON.stringify({ type: "pong" })); + if (frame.type !== "hello") return; + const reply = () => + socket.send( + JSON.stringify({ + type: "session", + message: { + type: "status", + payload: { + status: "server_info", + serverId: "srv_updated", + hostname: "updated", + version: "0.12.0", + ...(features ? { features } : {}), + }, + }, + }), + ); + if (holding) held.push(reply); + else reply(); + }); + }); + http.listen(0, "127.0.0.1"); + await once(http, "listening"); + cleanup.push(async () => { + for (const socket of ws.clients) socket.terminate(); + await new Promise((resolve) => ws.close(() => resolve())); + await new Promise((resolve) => http.close(() => resolve())); + }); + const address = http.address(); + if (!address || typeof address === "string") throw new Error("Missing destination port"); + return { + port: address.port, + connections: () => connections, + hold: () => { + holding = true; + }, + update: () => { + features = { connectedHosts: true, hostMultiplexing: true }; + holding = false; + for (const reply of held.splice(0)) reply(); + }, + }; +} +async function capabilityRetry(paseo: ReturnType): Promise { + let retryAt = ""; + await expect + .poll( + async () => { + const row = (await paseo.hosts.list()).hosts[0]; + retryAt = row?.nextRetryAt ?? ""; + return row; + }, + { timeout: 5000 }, + ) + .toMatchObject({ + status: "offline", + nextRetryAt: expect.any(String), + error: { code: "unsupported_capability" }, + }); + return Date.parse(retryAt); +} + +test("a destination updated past unsupported_capability keeps its guidance while retrying and comes online, while duplicate_host stays terminal", async () => { + const destination = await updatableDestination(); + const a = await daemon(); + const { paseo } = await api(a); + const host = await paseo.hosts.add({ connection: `127.0.0.1:${destination.port}` }); + await capabilityRetry(paseo); + destination.hold(); + await expect + .poll(async () => (await paseo.hosts.list()).hosts[0], { timeout: 5000 }) + .toMatchObject({ status: "connecting", error: { code: "unsupported_capability" } }); + await expect(paseo.hosts.client("srv_updated")).rejects.toMatchObject({ + code: "unsupported_capability", + }); + destination.update(); + expect((await online(paseo)).hosts[0]).toMatchObject({ + registrationId: host.registrationId, + serverId: "srv_updated", + nextRetryAt: null, + error: null, + }); + await paseo.hosts.add({ connection: `ws://127.0.0.1:${destination.port}/ws` }); + await expect + .poll(async () => (await paseo.hosts.list()).hosts[1], { timeout: 5000 }) + .toMatchObject({ status: "error", nextRetryAt: null, error: { code: "duplicate_host" } }); +}); + +test("disabling or removing a host cancels its pending capability retry", async () => { + const destination = await updatableDestination(); + const a = await daemon(); + const { paseo } = await api(a); + const host = await paseo.hosts.add({ connection: `127.0.0.1:${destination.port}` }); + let retryAt = await capabilityRetry(paseo); + let connections = destination.connections(); + await paseo.hosts.update(host.registrationId, { enabled: false }); + await new Promise((resolve) => setTimeout(resolve, retryAt - Date.now() + 500)); + expect(destination.connections()).toBe(connections); + await paseo.hosts.update(host.registrationId, { enabled: true }); + retryAt = await capabilityRetry(paseo); + connections = destination.connections(); + await paseo.hosts.remove(host.registrationId); + await new Promise((resolve) => setTimeout(resolve, retryAt - Date.now() + 500)); + expect(destination.connections()).toBe(connections); +}); + +import { + createDaemonChannel, + exportPublicKey, + generateKeyPair, + openDaemonSecureChannel, + type Transport, +} from "@getpaseo/relay/e2ee"; +test("a capability retry over a relay keeps the protected endpoint security it negotiated", async () => { + const keys = generateKeyPair(); + const legacyReceived: unknown[] = []; + let connections = 0; + let retryClosed = false; + const http = createServer(); + const ws = new WebSocketServer({ server: http }); + ws.on("connection", (socket) => { + connections++; + const wire: Transport = { + send: (data) => socket.send(data), + close: (code, reason) => socket.close(code, reason), + onmessage: null, + onclose: null, + onerror: null, + }; + socket.on("message", (data, isBinary) => { + const bytes = Buffer.from(data as Buffer); + wire.onmessage?.({ + data: isBinary + ? bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) + : bytes.toString(), + isBinary, + }); + }); + socket.on("close", (code, reason) => wire.onclose?.(code, reason.toString())); + if (connections > 1) { + socket.on("close", () => { + retryClosed = true; + }); + void createDaemonChannel(wire, keys, { + onmessage: (data) => legacyReceived.push(data), + }).catch(() => {}); + return; + } + const channel = openDaemonSecureChannel(wire, { + daemonKeyPair: keys, + events: { + onmessage: (data) => { + if (typeof data !== "string" || JSON.parse(data).type !== "hello") return; + void channel.then((secure) => + secure.send( + JSON.stringify({ + type: "session", + message: { + type: "status", + payload: { + status: "server_info", + serverId: "srv_relayed", + hostname: "relayed", + version: "0.12.0", + }, + }, + }), + ), + ); + }, + }, + }); + void channel.catch(() => {}); + }); + http.listen(0, "127.0.0.1"); + await once(http, "listening"); + cleanup.push(async () => { + for (const socket of ws.clients) socket.terminate(); + await new Promise((resolve) => ws.close(() => resolve())); + await new Promise((resolve) => http.close(() => resolve())); + }); + const address = http.address(); + if (!address || typeof address === "string") throw new Error("Missing relay port"); + const a = await daemon(); + const { paseo } = await api(a); + await paseo.hosts.add({ + connection: `relay://127.0.0.1:${address.port}/srv_relayed?key=${encodeURIComponent(exportPublicKey(keys.publicKey))}`, + }); + await capabilityRetry(paseo); + await expect.poll(() => retryClosed, { timeout: 15000 }).toBe(true); + expect(legacyReceived).toEqual([]); +}); + test("an unreachable saved target becomes online after its daemon starts without being added again", async () => { const reservation = createServer(); reservation.listen(0, "127.0.0.1"); diff --git a/packages/server/src/server/connected-hosts/index.ts b/packages/server/src/server/connected-hosts/index.ts index 15b1e16b97..bfff76239c 100644 --- a/packages/server/src/server/connected-hosts/index.ts +++ b/packages/server/src/server/connected-hosts/index.ts @@ -7,10 +7,12 @@ import { z } from "zod"; import { createHostClient, createHostChannelTransport, + createHostTransportFactory, parseHostTarget, HostTransportBudget, } from "@getpaseo/client/node"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { DaemonTransportFactory } from "@getpaseo/client/internal/daemon-client-transport-types"; import { HostSummarySchema, HostAddSchema, @@ -235,23 +237,30 @@ export class ConnectedHosts { if (route.registrationId === id) route.close(code, "Host access was disabled, replaced or removed"); } - private start(row: Saved): void { + private start( + row: Saved, + resume?: { failures: number; transports: DaemonTransportFactory }, + ): void { if (!row.enabled || this.closed) return; - let stopped = false; - const client = createHostClient( - row.connection, - { - clientId: `host-link-${this.self.serverId}-${row.registrationId}`, - connectTimeoutMs: 10000, + const transports = + resume?.transports ?? + createHostTransportFactory(row.connection, this.budget, { webSocketAgent: webSocketProxyAgent, - }, - this.budget, - ); + }); + let stopped = false; + let attempts = resume?.failures ?? 0; + let retry: ReturnType | undefined; + const client = createHostClient(row.connection, { + clientId: `host-link-${this.self.serverId}-${row.registrationId}`, + connectTimeoutMs: 10000, + transportFactory: transports, + }); const live: Live = { client, accepted: false, stop: () => { stopped = true; + clearTimeout(retry); void client.close(); }, }; @@ -285,7 +294,7 @@ export class ConnectedHosts { if (info.features?.connectedHosts !== true || info.features?.hostMultiplexing !== true) throw new HostError( "unsupported_capability", - "Update the destination daemon to use connected hosts.", + "Update the destination daemon to use connected hosts; retrying automatically.", ); // Pin and persist under the same writer as management to prevent discovery races. await this.mutate(async () => { @@ -304,16 +313,22 @@ export class ConnectedHosts { if (stopped || client.getLastServerInfoMessage() !== info || !client.isConnected) return; Object.assign(row, next); live.accepted = true; + attempts = 0; this.changed(); }); } catch (error) { if (stopped) return; - row.status = "error"; row.error = connectionFailure(error, client.authFailureReason !== null) ?? { code: "host_offline", message: "Host connection failed", }; - row.nextRetryAt = null; + if (terminalFailures.has(row.error.code)) { + row.status = "error"; + row.nextRetryAt = null; + } else { + stopped = true; + retry = this.scheduleAcceptanceRetry(row, attempts, transports); + } this.changed(); await client.close(); } @@ -329,18 +344,17 @@ export class ConnectedHosts { if (route.registrationId === row.registrationId) route.close(); row.status = state.status === "connecting" ? "connecting" : "offline"; row.nextRetryAt = state.status === "disconnected" ? (state.nextRetryAt ?? null) : null; - row.error = - state.status === "disconnected" - ? (connectionFailure( - Object.assign(new Error("Check the SSH configuration and daemon credentials"), { - code: state.failureCode, - }), - client.authFailureReason !== null, - ) ?? { - code: "host_offline", - message: "Cannot reach host. Check the connection; retrying automatically.", - }) - : null; + if (state.status === "disconnected") + row.error = connectionFailure( + Object.assign(new Error("Check the SSH configuration and daemon credentials"), { + code: state.failureCode, + }), + client.authFailureReason !== null, + ) ?? { + code: "host_offline", + message: "Cannot reach host. Check the connection; retrying automatically.", + }; + else if (row.error?.code !== "unsupported_capability") row.error = null; if (row.error?.code === "authentication_failed") { row.status = "error"; row.nextRetryAt = null; @@ -351,6 +365,18 @@ export class ConnectedHosts { }); void client.connect().catch(() => {}); } + private scheduleAcceptanceRetry( + row: Saved, + failures: number, + transports: DaemonTransportFactory, + ): ReturnType { + const delay = acceptanceRetryDelayMs(failures); + row.status = "offline"; + row.nextRetryAt = new Date(Date.now() + delay).toISOString(); + const retry = setTimeout(() => this.start(row, { failures: failures + 1, transports }), delay); + retry.unref(); + return retry; + } async openRoute(input: { serverId: string; scopeId: string; @@ -531,6 +557,20 @@ export class ConnectedHosts { } } +const ACCEPTANCE_RETRY_BASE_MS = 1500; +const ACCEPTANCE_RETRY_MAX_MS = 30_000; + +function acceptanceRetryDelayMs(failures: number): number { + return Math.min(ACCEPTANCE_RETRY_BASE_MS * 2 ** failures, ACCEPTANCE_RETRY_MAX_MS); +} + +const terminalFailures = new Set([ + "server_id_mismatch", + "self_host", + "duplicate_host", + "authentication_failed", +]); + function connectionFailure(error: unknown, authenticationFailed: boolean): HostSummary["error"] { if (error instanceof HostError) return { code: error.code, message: error.message }; if (error instanceof Error && "code" in error && error.code === "authentication_failed") From 4fe44038e3fd31200bdd88fd13d6ec8617051e5c Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 12:35:50 +0000 Subject: [PATCH 31/58] test(server): check subdirectory diffs before and after commit (#8466) Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../checkout-diff-content.e2e.test.ts | 72 +++++++++---------- 1 file changed, 34 insertions(+), 38 deletions(-) diff --git a/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts b/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts index fe83c21305..56e7cdefb8 100644 --- a/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts @@ -88,13 +88,9 @@ test.each(["uncommitted", "base", "commit"] as const)( 60_000, ); -test.each( - (["uncommitted", "base"] as const).flatMap((mode) => - (["single", "batch", "rename"] as const).map((shape) => ({ mode, shape })), - ), -)( - "$mode $shape diff includes hunks from a repository subdirectory", - async ({ mode, shape }) => { +test.each(["single", "batch", "rename"] as const)( + "%s diff includes hunks from a repository subdirectory before and after commit", + async (shape) => { const ctx = await createDaemonTestContext(); const cwd = mkdtempSync(join(tmpdir(), "checkout-diff-subdirectory-")); @@ -112,9 +108,7 @@ test.each( } git(cwd, "add", "."); git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "initial"); - if (mode === "base") { - git(cwd, "checkout", "-b", "feature"); - } + git(cwd, "checkout", "-b", "feature"); const filename = shape === "rename" ? "renamed.txt" : "normal.txt"; if (shape === "rename") { git(cwd, "mv", "sub/normal.txt", `sub/${filename}`); @@ -123,36 +117,38 @@ test.each( if (shape === "batch") { writeFileSync(join(cwd, "sibling.txt"), "new sibling\n"); } - if (mode === "base") { - git(cwd, "add", "."); - git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit"); + async function expectSubdirectoryDiff(mode: "uncommitted" | "base") { + const compare = { mode, baseRef: "main" }; + const rootDiff = await ctx.client.getCheckoutDiff(cwd, compare); + const subdirectoryDiff = await ctx.client.getCheckoutDiff(subdirectory, compare); + const subscription = ctx.client.observeCheckoutDiff(subdirectory, compare); + const liveDiff = await subscription.ready; + await subscription.release(); + + expect(rootDiff.error).toBeNull(); + expect(subdirectoryDiff.error).toBeNull(); + expect(liveDiff.error).toBeNull(); + expect(liveDiff.files).toEqual(rootDiff.files); + expect(rootDiff.files).toHaveLength(shape === "batch" ? 2 : 1); + const file = rootDiff.files.find((candidate) => candidate.path === `sub/${filename}`); + expect(file).toMatchObject({ + additions: 1, + deletions: 1, + ...(shape === "rename" ? { oldPath: "sub/normal.txt" } : {}), + }); + expect(file?.hunks.flatMap((hunk) => hunk.lines)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "remove", content: "before" }), + expect.objectContaining({ type: "add", content: "after" }), + ]), + ); + expect(subdirectoryDiff.files).toEqual(rootDiff.files); } - const compare = { mode, baseRef: "main" }; - const rootDiff = await ctx.client.getCheckoutDiff(cwd, compare); - const subdirectoryDiff = await ctx.client.getCheckoutDiff(subdirectory, compare); - const subscription = ctx.client.observeCheckoutDiff(subdirectory, compare); - const liveDiff = await subscription.ready; - await subscription.release(); - - expect(rootDiff.error).toBeNull(); - expect(subdirectoryDiff.error).toBeNull(); - expect(liveDiff.error).toBeNull(); - expect(liveDiff.files).toEqual(rootDiff.files); - expect(rootDiff.files).toHaveLength(shape === "batch" ? 2 : 1); - const file = rootDiff.files.find((candidate) => candidate.path === `sub/${filename}`); - expect(file).toMatchObject({ - additions: 1, - deletions: 1, - ...(shape === "rename" ? { oldPath: "sub/normal.txt" } : {}), - }); - expect(file?.hunks.flatMap((hunk) => hunk.lines)).toEqual( - expect.arrayContaining([ - expect.objectContaining({ type: "remove", content: "before" }), - expect.objectContaining({ type: "add", content: "after" }), - ]), - ); - expect(subdirectoryDiff.files).toEqual(rootDiff.files); + await expectSubdirectoryDiff("uncommitted"); + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit"); + await expectSubdirectoryDiff("base"); } finally { await ctx.cleanup(); rmSync(cwd, { recursive: true, force: true }); From 919e44b3195452a8dcdc0bbbfa7e7a7d3b8ca252 Mon Sep 17 00:00:00 2001 From: Dominic <33105147+DAnaeto@users.noreply.github.com> Date: Sun, 1 Nov 2026 07:59:34 -0500 Subject: [PATCH 32/58] fix(server): start configured plugins concurrently (#8427) The daemon accepts connections only after its configured plugins have started, and PluginService.start started them one at a time, so each plugin's build and process launch added to daemon startup. Configured plugins now start together. Their providers and usage sources are still published in config order: a plugin that finishes early waits for the plugins listed before it, so when two plugins claim the same provider or usage-source ID, the one listed first keeps it, as before. A failing plugin still fails on its own. Fixes #7979 --- .../src/server/plugins/index.posix.test.ts | 168 +++++++++++++++++- packages/server/src/server/plugins/index.ts | 28 ++- 2 files changed, 185 insertions(+), 11 deletions(-) diff --git a/packages/server/src/server/plugins/index.posix.test.ts b/packages/server/src/server/plugins/index.posix.test.ts index 1f8b3f124b..60216ff0fa 100644 --- a/packages/server/src/server/plugins/index.posix.test.ts +++ b/packages/server/src/server/plugins/index.posix.test.ts @@ -20,6 +20,7 @@ import { DaemonConfigStore } from "../daemon-config-store.js"; import { PluginService } from "./index.js"; import { BuiltinPluginLoader } from "./builtin/index.js"; import { ManagedPluginSources } from "./managed-source.js"; +import type { PluginProviderMetadata } from "./plugin-process-protocol.js"; import { startNpmRegistry, npmPluginPackages, @@ -179,6 +180,55 @@ function createPluginSelectivePausedRuntime(pausedPluginId: string) { return { runtime, started, releaseStart, starts }; } +function createGatedRuntime(providers: Record = {}) { + const gates = new Map>(); + const gate = (pluginId: string) => { + const existing = gates.get(pluginId); + if (existing) return existing; + const created = Promise.withResolvers(); + gates.set(pluginId, created); + return created; + }; + const begun: string[] = []; + const running = new Set(); + const runtime: TestPluginRuntime = { + ...emptyUsageRuntime, + catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })), + invoke: async () => undefined, + getLogs: () => [], + clearLogs: () => undefined, + getProviderRegistrations: (pluginId) => + running.has(pluginId) ? (providers[pluginId] ?? []) : [], + startPlugin: async (pluginId) => { + begun.push(pluginId); + await gate(pluginId).promise; + running.add(pluginId); + }, + stopPluginById: async (pluginId) => running.delete(pluginId), + stopAll: async () => { + running.clear(); + }, + subscribe: () => () => undefined, + bindPaseoSessionHost: () => undefined, + }; + return { + runtime, + begun, + finish: (pluginId: string) => gate(pluginId).resolve(), + fail: (pluginId: string, error: Error) => gate(pluginId).reject(error), + }; +} + +function nextNotification(service: PluginService, pluginId: string): Promise { + return new Promise((resolve) => { + const unsubscribe = service.subscribe((notified) => { + if (notified !== pluginId) return; + unsubscribe(); + resolve(); + }); + }); +} + describe("PluginService", () => { it.each([ { shadow: false, runtime: "subprocess" }, @@ -372,15 +422,127 @@ describe("PluginService", () => { "startup-first": { source: "directory", path: first }, "startup-second": { source: "directory", path: second }, }); - const snapshots: string[][] = []; - service.subscribe(() => snapshots.push(catalogIds(service))); + const notifications: { pluginId: string; running: boolean }[] = []; + service.subscribe((pluginId) => + notifications.push({ pluginId, running: catalogIds(service).includes(pluginId) }), + ); await service.start(); - expect(snapshots).toEqual([["startup-first"], ["startup-first", "startup-second"]]); + expect(notifications).toEqual([ + { pluginId: "startup-first", running: true }, + { pluginId: "startup-second", running: true }, + ]); + expect(catalogIds(service)).toEqual(["startup-first", "startup-second"]); await service.stopAllPlugins(); }, 20_000); + it("starts configured plugins concurrently and publishes them in config order", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); + roots.push(home); + const gated = createGatedRuntime(); + const service = createService( + home, + { + alpha: { source: "directory", path: "/plugins/alpha" }, + beta: { source: "directory", path: "/plugins/beta" }, + gamma: { source: "directory", path: "/plugins/gamma" }, + }, + { runtime: gated.runtime }, + ); + const notified: string[] = []; + service.subscribe((pluginId) => notified.push(pluginId)); + let started = false; + const starting = (async () => { + await service.start(); + started = true; + })(); + + await expect.poll(() => gated.begun).toEqual(["alpha", "beta", "gamma"]); + + gated.finish("gamma"); + const alphaPublished = nextNotification(service, "alpha"); + gated.finish("alpha"); + await alphaPublished; + expect(notified).toEqual(["alpha"]); + expect(started).toBe(false); + + gated.finish("beta"); + await starting; + expect(notified).toEqual(["alpha", "beta", "gamma"]); + expect(catalogIds(service)).toEqual(["alpha", "beta", "gamma"]); + await service.stopAllPlugins(); + }); + + it("starts the other configured plugins when one fails while they are still starting", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); + roots.push(home); + const gated = createGatedRuntime(); + const service = createService( + home, + { + broken: { source: "directory", path: "/plugins/broken" }, + healthy: { source: "directory", path: "/plugins/healthy" }, + }, + { runtime: gated.runtime }, + ); + const notified: string[] = []; + service.subscribe((pluginId) => notified.push(pluginId)); + const starting = service.start(); + + await expect.poll(() => gated.begun).toEqual(["broken", "healthy"]); + const brokenPublished = nextNotification(service, "broken"); + gated.fail("broken", new Error("startup exploded")); + await brokenPublished; + gated.finish("healthy"); + await starting; + + expect(notified).toEqual(["broken", "healthy"]); + expect(catalogIds(service)).toEqual(["healthy"]); + expect(await service.listPlugins()).toMatchObject([ + { id: "broken", status: "failed", error: "startup exploded" }, + { id: "healthy", status: "running" }, + ]); + await service.stopAllPlugins(); + }); + + it("keeps a provider ID with the plugin listed first when both start at once", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); + roots.push(home); + const gated = createGatedRuntime({ + first: [{ id: "shared-agent", label: "First agent" }], + second: [{ id: "shared-agent", label: "Second agent" }], + }); + const service = createService( + home, + { + first: { source: "directory", path: "/plugins/first" }, + second: { source: "directory", path: "/plugins/second" }, + }, + { runtime: gated.runtime }, + ); + const starting = service.start(); + + await expect.poll(() => gated.begun).toEqual(["first", "second"]); + gated.finish("second"); + gated.finish("first"); + await starting; + + expect(service.getProviderRegistrations()).toMatchObject([ + { id: "shared-agent", label: "First agent" }, + ]); + expect(catalogIds(service)).toEqual(["first"]); + expect(await service.listPlugins()).toMatchObject([ + { id: "first", status: "running" }, + { + id: "second", + status: "failed", + error: 'Plugin second cannot register provider ID "shared-agent" twice', + }, + ]); + await service.stopAllPlugins(); + }); + it("uses an explicit config key, exposes reload failure, and retries from disk", async () => { const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); roots.push(home); diff --git a/packages/server/src/server/plugins/index.ts b/packages/server/src/server/plugins/index.ts index 5dad547d5c..65faa39f75 100644 --- a/packages/server/src/server/plugins/index.ts +++ b/packages/server/src/server/plugins/index.ts @@ -230,11 +230,18 @@ export class PluginService { } }); if (config.pluginsEnabled === true) { - for (const [pluginId, source] of Object.entries(config.plugins ?? {})) { - if (source.enabled === false) continue; - await this.startConfigured(pluginId); - this.notify(pluginId); - } + let earlierPublished: Promise = Promise.resolve(); + await Promise.all( + Object.entries(config.plugins ?? {}) + .filter(([, source]) => source.enabled !== false) + .map(async ([pluginId]) => { + const publishTurn = earlierPublished; + const starting = this.startConfigured(pluginId, publishTurn); + earlierPublished = Promise.all([publishTurn, starting]); + await starting; + this.notify(pluginId); + }), + ); } this.configStore.onFieldChange("pluginsEnabled", (value) => { this.handleGlobalSwitch(value === true); @@ -560,12 +567,12 @@ export class PluginService { }).catch((error) => this.logger.error({ err: error }, "Failed to enable plugins")); } - private async startConfigured(pluginId: string): Promise { + private async startConfigured(pluginId: string, publishTurn?: Promise): Promise { const source = this.configStore.get().plugins?.[pluginId]; if (!source || source.enabled === false || !this.canPublish(pluginId)) return; this.errors.delete(pluginId); try { - await this.startPlugin(pluginId, source.path); + await this.startPlugin(pluginId, source.path, publishTurn); } catch (error) { if (this.canPublish(pluginId)) this.recordFailure(pluginId, error); } @@ -594,8 +601,13 @@ export class PluginService { ); } - private async startPlugin(pluginId: string, sourcePath: string): Promise { + private async startPlugin( + pluginId: string, + sourcePath: string, + publishTurn?: Promise, + ): Promise { await this.runtime.startPlugin(pluginId, sourcePath, () => this.canPublish(pluginId)); + await publishTurn; try { await this.publishProviderRegistrations(pluginId, sourcePath); this.publishUsageSources(pluginId); From 674530152e99b3023c1587211d0c891d6e3e3a2c Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:31:08 +0000 Subject: [PATCH 33/58] test(server): cover filename downloads in one workspace journey (#8475) Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../daemon-e2e/file-download.e2e.test.ts | 96 +++++++++---------- 1 file changed, 47 insertions(+), 49 deletions(-) diff --git a/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts b/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts index 862923dec6..54ec1964c1 100644 --- a/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts @@ -102,53 +102,52 @@ describe("daemon E2E", () => { }, ); - test.each([ - { fileName: "download.txt", disposition: 'attachment; filename="download.txt"' }, - { - fileName: "café.txt", - disposition: "attachment; filename=\"caf?.txt\"; filename*=UTF-8''caf%C3%A9.txt", - }, - { - fileName: "中文报告 (最终版).txt", - disposition: - "attachment; filename=\"???? (???).txt\"; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%8A%A5%E5%91%8A%20%28%E6%9C%80%E7%BB%88%E7%89%88%29.txt", - }, - { - fileName: "報告書.md", - disposition: - "attachment; filename=\"???.md\"; filename*=UTF-8''%E5%A0%B1%E5%91%8A%E6%9B%B8.md", - }, - { - fileName: "보고서.txt", - disposition: - "attachment; filename=\"???.txt\"; filename*=UTF-8''%EB%B3%B4%EA%B3%A0%EC%84%9C.txt", - }, - { - fileName: "report-📄.txt", - disposition: - "attachment; filename=\"report-??.txt\"; filename*=UTF-8''report-%F0%9F%93%84.txt", - }, - ])( - "issues token over WS and downloads $fileName via HTTP", - async ({ fileName, disposition }) => { - const cwd = tmpCwd(); - try { + test("downloads ASCII and Unicode filenames through one workspace", async () => { + const cwd = tmpCwd(); + try { + const agent = await ctx.client.createAgent({ + provider: "codex", + model: CODEX_TEST_MODEL, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, + cwd, + title: "Download Token Test Agent", + }); + + expect(agent.id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + + for (const { fileName, disposition } of [ + { fileName: "download.txt", disposition: 'attachment; filename="download.txt"' }, + { + fileName: "café.txt", + disposition: "attachment; filename=\"caf?.txt\"; filename*=UTF-8''caf%C3%A9.txt", + }, + { + fileName: "中文报告 (最终版).txt", + disposition: + "attachment; filename=\"???? (???).txt\"; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%8A%A5%E5%91%8A%20%28%E6%9C%80%E7%BB%88%E7%89%88%29.txt", + }, + { + fileName: "報告書.md", + disposition: + "attachment; filename=\"???.md\"; filename*=UTF-8''%E5%A0%B1%E5%91%8A%E6%9B%B8.md", + }, + { + fileName: "보고서.txt", + disposition: + "attachment; filename=\"???.txt\"; filename*=UTF-8''%EB%B3%B4%EA%B3%A0%EC%84%9C.txt", + }, + { + fileName: "report-📄.txt", + disposition: + "attachment; filename=\"report-??.txt\"; filename*=UTF-8''report-%F0%9F%93%84.txt", + }, + ]) { const filePath = path.join(cwd, fileName); - const fileContents = "download test payload"; + const fileContents = `download test payload: ${fileName}`; writeFileSync(filePath, fileContents, "utf-8"); - const agent = await ctx.client.createAgent({ - provider: "codex", - model: CODEX_TEST_MODEL, - thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, - cwd, - title: "Download Token Test Agent", - }); - - expect(agent.id).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, - ); - const tokenResponse = await ctx.client.requestDownloadToken(cwd, fileName); expect(tokenResponse.error).toBeNull(); @@ -167,12 +166,11 @@ describe("daemon E2E", () => { const body = await response.text(); expect(body).toBe(fileContents); - } finally { - rmSync(cwd, { recursive: true, force: true }); } - }, - 60000, - ); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, 60000); test("rejects invalid token", async () => { const response = await fetch( From 3e83f433da29b87a73da2550e34898e070b9856a Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:56:42 +0000 Subject: [PATCH 34/58] Import large Claude sessions without exceeding the string limit (#8457) * fix(claude): read the session rename from the end of a large transcript Importing a Claude session read the whole transcript into one UTF-8 string to look for its `custom-title` record, so a transcript past V8's maximum string length failed the import with `RangeError: Invalid string length` before the incremental history reader ever ran. The scan now reads the file backwards a chunk at a time, which keeps the original intent of not walking the transcript and never holds it as one string. Records without the needle are not decoded, so a transcript with no rename costs a byte search per chunk. Closes #8451 Co-Authored-By: Claude Opus 5 * test(app): wait for send readiness in composer recall The previous prompt can appear and clear its draft before the send acknowledgement arrives. Wait for Send to be enabled before pressing Enter so recall setup does not silently leave the next prompt unsent. Co-authored-by: qwist1233-cpu * fix(claude): scan large transcript records once Retain unfinished record chunks and assemble each record once instead of copying and rescanning a growing buffer on every backward read. Cover import of a large record after the last explicit rename. --------- Co-authored-by: qwist1233-cpu Co-authored-by: Claude Opus 5 Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../app/e2e/browser/composer-recall.spec.ts | 12 ++- .../claude/agent.import-titles.test.ts | 61 ++++++++++++ .../server/agent/providers/claude/agent.ts | 97 +++++++++++++------ 3 files changed, 137 insertions(+), 33 deletions(-) diff --git a/packages/app/e2e/browser/composer-recall.spec.ts b/packages/app/e2e/browser/composer-recall.spec.ts index 368d23409c..a58a8ff59e 100644 --- a/packages/app/e2e/browser/composer-recall.spec.ts +++ b/packages/app/e2e/browser/composer-recall.spec.ts @@ -3,7 +3,7 @@ import { composerLocator, expectComposerDraft, expectComposerVisible, - submitMessage, + fillComposerDraft, typeIntoFocusedComposer, } from "../support/helpers/composer"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; @@ -12,8 +12,14 @@ function userMessages(page: Page) { return page.getByTestId("user-message"); } +async function sendDraftWhenReady(page: Page) { + await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled(); + await pressInComposer(page, "Enter"); +} + async function sendAndWaitForMessage(page: Page, text: string, expectedCount: number) { - await submitMessage(page, text); + await fillComposerDraft(page, text); + await sendDraftWhenReady(page); await expect(userMessages(page)).toHaveCount(expectedCount, { timeout: 30_000 }); await expectComposerDraft(page, ""); } @@ -49,7 +55,7 @@ test.describe("Composer recall", () => { await pressInComposer(page, "ArrowUp"); await expectComposerDraft(page, "add tests for login"); - await pressInComposer(page, "Enter"); + await sendDraftWhenReady(page); await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 }); await expect(userMessages(page).last()).toContainText("add tests for login"); } finally { diff --git a/packages/server/src/server/agent/providers/claude/agent.import-titles.test.ts b/packages/server/src/server/agent/providers/claude/agent.import-titles.test.ts index e20b8d6faf..a826645d4c 100644 --- a/packages/server/src/server/agent/providers/claude/agent.import-titles.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.import-titles.test.ts @@ -1,3 +1,4 @@ +import { constants as bufferConstants } from "node:buffer"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; @@ -236,4 +237,64 @@ describe("importing a renamed Claude session", () => { await expect(importSession(sessionId)).rejects.toThrow(); }); + + test("reads the rename from a transcript too large to hold as one string", async () => { + const sessionId = "oversized-transcript"; + const projectDir = claudeProjectDirSync(SESSION_CWD, { configDir }); + await fs.mkdir(projectDir, { recursive: true }); + const transcript = path.join(projectDir, `${sessionId}.jsonl`); + // A hole stands in for the long session this used to fail on: the file only + // has to be longer than a string can be, and the rename sits at its end. + await fs.writeFile(transcript, ""); + await fs.truncate(transcript, bufferConstants.MAX_STRING_LENGTH + 1); + await fs.appendFile( + transcript, + `\n${[ + userRecord({ sessionId, content: "Trace the slow query" }), + { type: "custom-title", customTitle: "Slow query hunt", sessionId }, + ] + .map((record) => JSON.stringify(record)) + .join("\n")}\n`, + ); + + const imported = await importSession(sessionId); + + expect(imported.config.title).toBe("Slow query hunt"); + }); + + test("reads the rename from behind more records than one read covers", async () => { + const sessionId = "late-records-rename"; + await writeSession({ + sessionId, + records: [ + userRecord({ sessionId, content: "Review this project" }), + { type: "custom-title", customTitle: "Scrollback review", sessionId }, + // One record longer than a single read, then enough records to leave the + // rename several reads back from the end of the file. + userRecord({ sessionId, content: "x".repeat(128 * 1024) }), + ...Array.from({ length: 64 }, (_, index) => + userRecord({ sessionId, content: `${"y".repeat(4 * 1024)} ${index}` }), + ), + ], + }); + + const imported = await importSession(sessionId); + + expect(imported.config.title).toBe("Scrollback review"); + }); + + test("imports a large record after the last rename without repeatedly copying it", async () => { + const sessionId = "large-record-after-rename"; + await writeSession({ + sessionId, + records: [ + { type: "custom-title", customTitle: "Large attachment review", sessionId }, + userRecord({ sessionId, content: "x".repeat(96 * 1024 * 1024) }), + ], + }); + + const imported = await importSession(sessionId); + + expect(imported.config.title).toBe("Large attachment review"); + }); }); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index d232185cbb..ebb37e13f8 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -6853,40 +6853,79 @@ function normalizeClaudeSessionTitle(title: string | null): string | null { } const CLAUDE_CUSTOM_TITLE_TYPE = "custom-title"; +const CLAUDE_CUSTOM_TITLE_NEEDLE = Buffer.from(`"${CLAUDE_CUSTOM_TITLE_TYPE}"`); +const CLAUDE_TITLE_SCAN_CHUNK_BYTES = 64 * 1024; + +function readClaudeCustomTitleFromRecord(record: Buffer): string | null { + // The needle also matches a prompt that quotes it, so a record still has to + // parse as a `custom-title` entry carrying a usable name. Records without the + // needle are never decoded, so a long one costs a byte search and nothing more. + if (!record.includes(CLAUDE_CUSTOM_TITLE_NEEDLE)) { + return null; + } + let parsed: unknown; + try { + parsed = JSON.parse(record.toString("utf8").trim()); + } catch { + return null; + } + const entry = toObjectRecord(parsed); + if (!entry || entry.isSidechain || entry.type !== CLAUDE_CUSTOM_TITLE_TYPE) { + return null; + } + return normalizeClaudeSessionTitle( + typeof entry.customTitle === "string" ? entry.customTitle : null, + ); +} /** * The `custom-title` record is re-emitted every time the session is saved, so - * the current name is the last usable one in the file. Searching backwards finds it without - * walking the transcript, which matters because sessions reach tens of - * megabytes. + * the current name is the last usable one in the file. Reading backwards a chunk + * at a time finds it without walking the transcript, which matters because + * sessions reach tens of megabytes, and never holds the file as one string, so a + * transcript past V8's maximum string length still imports. */ -function readLastClaudeCustomTitle(content: string): string | null { - const needle = `"${CLAUDE_CUSTOM_TITLE_TYPE}"`; - let at = content.lastIndexOf(needle); - while (at >= 0) { - const lineStart = content.lastIndexOf("\n", at) + 1; - const lineEnd = content.indexOf("\n", at); - const line = lineEnd === -1 ? content.slice(lineStart) : content.slice(lineStart, lineEnd); - let parsed: unknown; - try { - parsed = JSON.parse(line.trim()); - } catch { - parsed = undefined; - } - const entry = toObjectRecord(parsed); - if (entry && !entry.isSidechain && entry.type === CLAUDE_CUSTOM_TITLE_TYPE) { - const title = normalizeClaudeSessionTitle( - typeof entry.customTitle === "string" ? entry.customTitle : null, +async function readLastClaudeCustomTitle(filePath: string): Promise { + const handle = await fsPromises.open(filePath, "r"); + try { + const stats = await handle.stat(); + if (!stats.isFile()) { + // Opening a directory succeeds on Windows even though reading it does not, + // so reject the way reading the transcript as a file would. + throw Object.assign( + new Error(`EISDIR: illegal operation on a directory, read '${filePath}'`), + { code: "EISDIR", path: filePath }, ); - if (title) { - return title; - } } - // The needle also matches a prompt that quotes it, and a record can carry no - // usable name, so keep looking backwards for one that does. - at = at <= 0 ? -1 : content.lastIndexOf(needle, at - 1); + let end = stats.size; + // Retain unfinished record pieces in reverse file order. Assemble each + // complete record once, so a large tool/image record costs linear work. + let pieces: Buffer[] = []; + while (end > 0) { + const length = Math.min(CLAUDE_TITLE_SCAN_CHUNK_BYTES, end); + const chunk = Buffer.alloc(length); + await handle.read(chunk, 0, length, end - length); + let recordEnd = chunk.length; + for ( + let at = chunk.lastIndexOf(10); + at >= 0; + at = at === 0 ? -1 : chunk.lastIndexOf(10, at - 1) + ) { + pieces.push(chunk.subarray(at + 1, recordEnd)); + const record = Buffer.concat(pieces.toReversed()); + const title = readClaudeCustomTitleFromRecord(record); + if (title) return title; + pieces = []; + recordEnd = at; + } + if (recordEnd > 0) pieces.push(chunk.subarray(0, recordEnd)); + end -= length; + } + // The first record in the file has no newline in front of it. + return readClaudeCustomTitleFromRecord(Buffer.concat(pieces.toReversed())); + } finally { + await handle.close(); } - return null; } function isErrnoException(error: unknown): error is NodeJS.ErrnoException { @@ -6898,9 +6937,8 @@ async function readClaudeRenamedSessionTitle(input: { sessionId: string; configDir: string; }): Promise { - let content: string; try { - content = await fsPromises.readFile(await claudeTranscriptPath(input), "utf8"); + return await readLastClaudeCustomTitle(await claudeTranscriptPath(input)); } catch (error) { // A session with no transcript on disk has no rename to carry over. Every // other read failure is a real fault and stays explicit. @@ -6909,7 +6947,6 @@ async function readClaudeRenamedSessionTitle(input: { } throw error; } - return readLastClaudeCustomTitle(content); } function normalizeImportablePromptPreview(text: string): string | null { From 34f996b32aba3b03a6a89ce3e8031b80825677bc Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 17:11:42 +0000 Subject: [PATCH 35/58] Preserve pending questions and approvals after peer messages (#8453) * fix(server): keep a recipient's pending permission when another agent messages it A message from another agent (send_agent_message with sourceAgentId) was enqueued with clearPendingPermissions: true, so it superseded the recipient's pending permission request and the request was denied before a human saw it. Agent-sourced messages now leave pending permissions alone. Typed messages and sourceless `paseo send` still clear them. Fixes #7602 * test(server): verify peer messages preserve answerable questions Exercise message delivery through the daemon and provider interfaces so the regression checks that the question survives and its answer reaches the provider. Retain the human-steer cancellation behavior. Co-authored-by: Dominic Anaeto * test(terminal): make runtime copy fixture independent of umask Set the source helper permissions after creation so the copy test measures preservation instead of inheriting the shell file-creation mask. The existing test fails under umask 077 without this setup correction. Fixes #8175 --------- Co-authored-by: Dominic Anaeto Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../peer-message-permissions.test.ts | 129 ++++++++++++++++++ packages/server/src/server/session.ts | 2 +- .../terminal/terminal-worker-runtime.test.ts | 2 + 3 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 packages/server/src/server/daemon-e2e/peer-message-permissions.test.ts diff --git a/packages/server/src/server/daemon-e2e/peer-message-permissions.test.ts b/packages/server/src/server/daemon-e2e/peer-message-permissions.test.ts new file mode 100644 index 0000000000..bc2803a65e --- /dev/null +++ b/packages/server/src/server/daemon-e2e/peer-message-permissions.test.ts @@ -0,0 +1,129 @@ +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import path from "node:path"; +import type { FormInfo } from "@opencode/client"; +import { expect, test } from "vitest"; + +import { createTestLogger } from "../../test-utils/test-logger.js"; +import { OpenCodeV2AgentClient } from "../agent/providers/opencode/v2/agent.js"; +import { V2Harness } from "../agent/providers/opencode/test-utils/v2-harness.js"; +import { createTestAgentClients } from "../test-utils/fake-agent-client.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; + +async function createQuestionHarness() { + const artifacts = path.resolve(".artifacts"); + await mkdir(artifacts, { recursive: true }); + const root = await mkdtemp(path.join(artifacts, "peer-permissions-")); + const runtime = new V2Harness(); + runtime.autoComplete = false; + runtime.api.model.list = async () => ({ location: runtime.info.location, data: [] }); + runtime.api.provider.list = async () => ({ + location: runtime.info.location, + data: [{ id: "test", name: "Test", activation: "enabled", package: "test" }], + }); + let form: FormInfo | null = null; + const answers: Array> = []; + runtime.api.session.form.list = async () => (form ? [form] : []); + runtime.api.session.form.cancel = async () => { + form = null; + runtime.finishExecution("interrupted"); + }; + runtime.api.session.form.reply = async ({ answer }) => { + answers.push(answer); + form = null; + runtime.finishExecution(); + }; + // A steer is admitted to the existing execution, rather than starting another one. + runtime.prompt = async (input) => { + runtime.prompts.push(input.text); + if (runtime.prompts.length === 1) runtime.startExecution(); + }; + const provider = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: runtime.runtime, + }); + const daemon = await createTestPaseoDaemon({ + agentClients: { ...createTestAgentClients(), opencode: provider }, + paseoHomeRoot: path.join(root, "daemon"), + staticDir: path.join(root, "static"), + cleanup: false, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + const recipient = await client.createAgent({ + provider: "opencode", + cwd: root, + title: "Waiting for a human answer", + }); + const sender = await client.createAgent({ provider: "claude", cwd: root }); + await client.sendMessage(recipient.id, "Ask which color to use"); + await expect.poll(() => runtime.prompts).toHaveLength(1); + form = { + id: "color-question", + sessionID: "session", + title: "Color", + fields: [{ key: "color", title: "Color", type: "string" }], + }; + runtime.push({ id: "question-created", created: 2, type: "form.created", data: { form } }); + await client.waitForAgentUpsert(recipient.id, (agent) => + (agent.pendingPermissions ?? []).some((request) => request.id === "color-question"), + ); + return { + client, + recipient, + sender, + answers, + async close() { + await client.close(); + await daemon.close(); + await rm(root, { recursive: true, force: true }); + }, + }; + } catch (error) { + await client.close(); + await daemon.close(); + await rm(root, { recursive: true, force: true }); + throw error; + } +} + +test("an agent-sourced steer leaves the question pending and answerable", async () => { + const { client, recipient, sender, answers, close } = await createQuestionHarness(); + try { + await client.sendAgentMessage(recipient.id, "Peer status check", { + sourceAgentId: sender.id, + }); + // The public wait includes admitted message delivery before reporting permission status. + const waiting = await client.waitForFinish(recipient.id); + expect(waiting.status).toBe("permission"); + expect(waiting.final?.pendingPermissions).toMatchObject([ + { id: "color-question", kind: "question" }, + ]); + await client.respondToPermission(recipient.id, "color-question", { + behavior: "allow", + updatedInput: { answers: { Color: "Blue" } }, + }); + await client.waitForAgentUpsert( + recipient.id, + (agent) => (agent.pendingPermissions ?? []).length === 0 && agent.status === "idle", + ); + expect(answers).toEqual([{ color: "Blue" }]); + } finally { + await close(); + } +}); + +test("a human steer still dismisses the question", async () => { + const { client, recipient, answers, close } = await createQuestionHarness(); + try { + await client.sendAgentMessage(recipient.id, "Use red instead", { activeTurnBehavior: "steer" }); + const finished = await client.waitForFinish(recipient.id); + expect(finished.status).toBe("idle"); + expect(finished.final?.pendingPermissions ?? []).toEqual([]); + expect(answers).toEqual([]); + } finally { + await close(); + } +}); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 00c8529482..0af650273b 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -8318,7 +8318,7 @@ export class Session { }, activeTurnBehavior: msg.activeTurnBehavior ?? "steer", sessionMode: msg.sessionMode, - clearPendingPermissions: true, + clearPendingPermissions: false, }); deliveryStatus = "queued"; queued(); diff --git a/packages/server/src/terminal/terminal-worker-runtime.test.ts b/packages/server/src/terminal/terminal-worker-runtime.test.ts index feb4bf3309..257787b944 100644 --- a/packages/server/src/terminal/terminal-worker-runtime.test.ts +++ b/packages/server/src/terminal/terminal-worker-runtime.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it } from "vitest"; import { + chmodSync, existsSync, mkdirSync, mkdtempSync, @@ -54,6 +55,7 @@ function makeSources(bundleSource = "export {};\n"): { mkdirSync(native, { recursive: true }); writeFileSync(path.join(native, "pty.node"), "native"); writeFileSync(path.join(native, "spawn-helper"), "#!/bin/sh\n", { mode: 0o755 }); + chmodSync(path.join(native, "spawn-helper"), 0o755); // Another platform's build is not copied. mkdirSync(path.join(nodePtyDirectory, "prebuilds", "other-platform")); return { bundleDirectory, nodePtyDirectory }; From bfac1329204f9aaeb6d47742885c51551d64f70b Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:02:26 +0000 Subject: [PATCH 36/58] Stop Git polling while workspace folders are missing (#8462) * fix(server): stop polling git for a workspace whose folder is gone When a workspace's folder disappears together with its project (the repository deleted, a volume unmounted), reconciliation leaves the workspace registered, and the daemon kept polling it: a failed git command and a warning about once a minute, and a working-tree watcher retry every minute or two, for as long as the daemon ran. The degraded poll now checks that the workspace folder exists before refreshing. A workspace whose folder is gone is suspended once: it is detached from its repository and working-tree targets, its timers stop, and one warning is logged. A worktree that shared a repository target hands the repository's polling and background fetch to a surviving worktree. Nothing is archived. A working-tree watch that an open diff still holds skips its consumer notifications and watcher recovery while its folder is missing. While suspended, the workspace runs no git. A stat every 60 seconds, a new subscription, a forced snapshot or a refresh request checks whether the folder is back; when it is, the workspace reloads its git facts, refreshes, and rebuilds its observation. Fixes #7856 * fix(server): keep missing workspaces suspended during refresh Admit Git refreshes only after the folder has returned. Cover missing-folder polling, explicit refreshes, and restoration with real Git and the Linux observer. Co-authored-by: Dominic Anaeto * fix(server): preserve workspace snapshots through suspension Drop queued refreshes and withhold publication when a folder disappears during an active read. Wait for that read before restoring observation so its stale result cannot overwrite the recovered snapshot. --------- Co-authored-by: Dominic Anaeto Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- ...it-service.observation.integration.test.ts | 101 +++ .../workspace-git-service.observation.test.ts | 1 + .../src/server/workspace-git-service.test.ts | 606 +++++++++++++++++- .../src/server/workspace-git-service.ts | 204 +++++- 4 files changed, 891 insertions(+), 21 deletions(-) diff --git a/packages/server/src/server/workspace-git-service.observation.integration.test.ts b/packages/server/src/server/workspace-git-service.observation.integration.test.ts index 371502748d..1ae04bfc2f 100644 --- a/packages/server/src/server/workspace-git-service.observation.integration.test.ts +++ b/packages/server/src/server/workspace-git-service.observation.integration.test.ts @@ -1,3 +1,4 @@ +import { flushGitCommandTrace } from "../utils/git-command-trace.js"; import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { execFileSync } from "node:child_process"; @@ -730,3 +731,103 @@ test("Git event identity retains a symlink's literal target before canonicalizin rmSync(root, { recursive: true, force: true }); } }); + +test.skipIf(process.platform !== "linux")( + "missing folders stop degraded Git polling and explicit refreshes, then recover", + async () => { + const artifacts = path.resolve(".artifacts"); + mkdirSync(artifacts, { recursive: true }); + const root = mkdtempSync(path.join(artifacts, "workspace-git-missing-")); + const cwd = path.join(root, "repo"); + const initializeRepo = (branch: string) => { + mkdirSync(cwd); + execFileSync("git", ["init", "-b", branch], { cwd, stdio: "pipe" }); + writeFileSync(path.join(cwd, "README.md"), "fixture\n"); + execFileSync("git", ["add", "."], { cwd, stdio: "pipe" }); + execFileSync( + "git", + [ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "-m", + "fixture", + ], + { cwd, stdio: "pipe" }, + ); + }; + initializeRepo("main"); + // Exceed the real Linux observer's directory limit to enter degraded polling. + // No watcher errors or Git responses are injected. + for (let index = 0; index < 5100; index++) { + mkdirSync(path.join(cwd, `generated-${index}`)); + } + const trace = path.join(root, "git.jsonl"); + writeFileSync(trace, ""); + const previousTrace = process.env.PASEO_GIT_TRACE_FILE; + process.env.PASEO_GIT_TRACE_FILE = trace; + let degraded = false; + let suspended = false; + const logger = pino( + { level: "warn" }, + { + write(line) { + const message = JSON.parse(line).msg; + if (message === "Failed to start working tree watcher; using degraded polling") + degraded = true; + if ( + message === + "Workspace folder no longer exists; suspended git observation until it is back" + ) + suspended = true; + }, + }, + ); + const service = new WorkspaceGitServiceImpl({ logger, paseoHome: path.join(root, "home") }); + const subscription = service.registerWorkspace({ cwd }, () => {}); + const commandCount = async () => { + await flushGitCommandTrace(); + return readFileSync(trace, "utf8").trim().split("\n").filter(Boolean).length; + }; + try { + await expect + .poll(() => degraded && service.getMetrics().workspaceRefreshInFlightCount === 0, { + timeout: 15_000, + }) + .toBe(true); + rmSync(cwd, { recursive: true, force: true }); + await expect.poll(() => suspended, { timeout: 15_000 }).toBe(true); + await expect.poll(() => service.getMetrics().workspaceRefreshInFlightCount).toBe(0); + const commandsWhileMissing = await commandCount(); + await service.getSnapshot(cwd, { + force: true, + includeForge: false, + reason: "refresh-button", + }); + await service.refresh(cwd); + expect(await commandCount()).toBe(commandsWhileMissing); + await new Promise((resolve) => setTimeout(resolve, 16_000)); + expect(await commandCount()).toBe(commandsWhileMissing); + + initializeRepo("restored"); + const snapshot = await service.getSnapshot(cwd, { + force: true, + includeForge: false, + reason: "refresh-button", + }); + expect(snapshot.git).toMatchObject({ isGit: true, currentBranch: "restored" }); + await expect.poll(() => service.getMetrics().repositoryWorkspaceLinkCount).toBe(1); + } finally { + subscription.unsubscribe(); + await service.dispose(); + if (previousTrace === undefined) delete process.env.PASEO_GIT_TRACE_FILE; + else process.env.PASEO_GIT_TRACE_FILE = previousTrace; + rmSync(root, { recursive: true, force: true }); + } + }, + 60_000, +); diff --git a/packages/server/src/server/workspace-git-service.observation.test.ts b/packages/server/src/server/workspace-git-service.observation.test.ts index ab93558793..04604cd1b5 100644 --- a/packages/server/src/server/workspace-git-service.observation.test.ts +++ b/packages/server/src/server/workspace-git-service.observation.test.ts @@ -244,6 +244,7 @@ function createService( filterEvents: (events) => events, verify: vi.fn(async () => {}), })), + workspaceFolderExists: vi.fn(async () => true), ...overrides, } as never, }); diff --git a/packages/server/src/server/workspace-git-service.test.ts b/packages/server/src/server/workspace-git-service.test.ts index 4e6acff9cd..a7a3b49f06 100644 --- a/packages/server/src/server/workspace-git-service.test.ts +++ b/packages/server/src/server/workspace-git-service.test.ts @@ -1,5 +1,7 @@ import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { existsSync } from "node:fs"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path, { join } from "node:path"; import type pino from "pino"; @@ -10,11 +12,14 @@ import type { PullRequestStatusResult, } from "../utils/checkout-git.js"; import { + BACKGROUND_GIT_FETCH_INTERVAL_MS, + WORKSPACE_GIT_OBSERVATION_REENSURE_INTERVAL_MS, WORKSPACE_GIT_OBSERVATION_SETUP_CONCURRENCY, WORKSPACE_GIT_REFRESH_CONCURRENCY, WORKSPACE_GIT_WATCHER_SUBSCRIBE_TIMEOUT_MS, WorkspaceGitServiceImpl, type WorkspaceGitRuntimeSnapshot, + workspaceFolderExists, } from "./workspace-git-service.js"; const REPO_CWD = path.resolve("/tmp/repo"); @@ -23,6 +28,7 @@ function createLogger() { const logger = { child: () => logger, debug: vi.fn(), + info: vi.fn(), warn: vi.fn(), }; return logger; @@ -251,7 +257,7 @@ function createGitHubServiceStub(): ForgeService { interface CreateServiceTestOptions { subscribe?: ReturnType; getCheckoutStatus?: ReturnType; - getCheckoutSnapshotFacts?: ReturnType; + getCheckoutSnapshotFacts?: (cwd: string) => Promise; getCheckoutShortstat?: ReturnType; getCheckoutWorktreeState?: ReturnType; getPullRequestStatus?: ReturnType; @@ -261,6 +267,7 @@ interface CreateServiceTestOptions { runGitFetch?: ReturnType; runGitCommand?: ReturnType; getWorkspaceGitSelfHealPhaseMs?: (cwd: string) => number; + workspaceFolderExists?: (cwd: string) => Promise; now?: () => Date; } @@ -294,6 +301,7 @@ function buildDefaultTestServiceDeps() { signal: null, })), getWorkspaceGitSelfHealPhaseMs: vi.fn(() => 30_000), + workspaceFolderExists: vi.fn(async () => true), createWatcherLivenessCanary: vi.fn(() => ({ path: "", filterEvents: (events) => events, @@ -324,6 +332,145 @@ function createService(options?: CreateServiceTestOptions) { }); } +type WatchCallback = (error: Error | null, events: Array<{ path: string; type: "update" }>) => void; + +interface FolderBackedGitOptions { + facts?: (cwd: string) => CheckoutSnapshotFacts; + unwatchableDirectories?: string[]; + heldWatch?: { directory: string; release: Promise }; +} + +function createFolderBackedGit(options?: FolderBackedGitOptions) { + const watchCallbacks = new Map(); + const subscribe = vi.fn(async (directory: string, callback: WatchCallback) => { + if (!existsSync(directory) || options?.unwatchableDirectories?.includes(directory)) { + throw new Error(`Cannot watch ${directory}`); + } + if (options?.heldWatch?.directory === directory) { + await options.heldWatch.release; + } + watchCallbacks.set(directory, callback); + return createAsyncSubscription(); + }); + const getCheckoutSnapshotFacts = vi.fn( + async (cwd: string): Promise => + existsSync(cwd) ? (options?.facts ?? createCheckoutSnapshotFacts)(cwd) : { isGit: false }, + ); + const getCheckoutStatus = vi.fn(async (cwd: string) => + existsSync(cwd) ? createCheckoutStatus(cwd) : ({ isGit: false } as const), + ); + const getCheckoutShortstat = vi.fn(async (_cwd: string) => ({ additions: 1, deletions: 0 })); + const getCheckoutWorktreeState = vi.fn(async (_cwd: string) => ({ + isDirty: false, + diffStat: { additions: 1, deletions: 0 }, + })); + const runGitCommand = vi.fn(async (args: string[], runOptions: { cwd: string }) => ({ + stdout: args[0] === "rev-parse" ? `${runOptions.cwd}\n` : "", + stderr: "", + truncated: false, + exitCode: 0, + signal: null, + })); + const runGitFetch = vi.fn(async (_cwd: string) => ({ changes: [], error: null })); + const callsIn = (fake: ReturnType, cwd: string) => + fake.mock.calls.filter(([calledCwd]) => calledCwd === cwd).length; + + return { + deps: { + subscribe, + getCheckoutSnapshotFacts, + getCheckoutStatus, + getCheckoutShortstat, + getCheckoutWorktreeState, + runGitCommand, + runGitFetch, + workspaceFolderExists: async (cwd: string) => existsSync(cwd), + now: () => new Date(), + }, + watchCallbackFor(directory: string): WatchCallback { + const callback = watchCallbacks.get(directory); + if (!callback) { + throw new Error(`No watcher was started for ${directory}`); + } + return callback; + }, + watchAttemptsFor: (directory: string) => callsIn(subscribe, directory), + pollsOf: (cwd: string) => callsIn(getCheckoutShortstat, cwd), + fetchesIn: (cwd: string) => callsIn(runGitFetch, cwd), + gitCommandsIn(cwd: string): number { + const cwdCommands = [ + getCheckoutSnapshotFacts, + getCheckoutStatus, + getCheckoutShortstat, + getCheckoutWorktreeState, + runGitFetch, + ].reduce((count, fake) => count + callsIn(fake, cwd), 0); + const commandsRunInCwd = runGitCommand.mock.calls.filter( + ([, runOptions]) => runOptions.cwd === cwd, + ).length; + return cwdCommands + commandsRunInCwd; + }, + }; +} + +function linkedWorktreeFacts(gitCommonDir: string) { + return (cwd: string): CheckoutSnapshotFacts => ({ + ...createCheckoutSnapshotFacts(cwd), + absoluteGitDir: join(gitCommonDir, "worktrees", path.basename(cwd)), + gitCommonDir, + }); +} + +const SETTLE_ATTEMPTS = 1_000; + +async function settle(assertion: () => void): Promise { + let lastFailure: unknown = null; + for (let attempt = 0; attempt < SETTLE_ATTEMPTS; attempt += 1) { + try { + assertion(); + return; + } catch (failure) { + lastFailure = failure; + } + await vi.advanceTimersByTimeAsync(0); + } + throw lastFailure; +} + +async function waitForRefreshesToSettle(service: WorkspaceGitServiceImpl): Promise { + await settle(() => { + expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0); + }); +} + +async function waitForObservationToSettle( + service: WorkspaceGitServiceImpl, + repositoryWorkspaceLinkCount: number, +): Promise { + await settle(() => { + const metrics = service.getMetrics(); + expect(metrics.repositoryWorkspaceLinkCount).toBe(repositoryWorkspaceLinkCount); + expect(metrics.workspaceObservationSetupInFlightCount).toBe(0); + expect(metrics.workspaceRefreshInFlightCount).toBe(0); + }); +} + +async function advanceThroughDegradedPolls(service: WorkspaceGitServiceImpl): Promise { + for (const delayMs of [5_000, 10_000, 20_000, 40_000, 60_000, 60_000]) { + await vi.advanceTimersByTimeAsync(delayMs); + await waitForRefreshesToSettle(service); + } +} + +async function advanceThroughWorkingTreeRecoveryAttempts( + service: WorkspaceGitServiceImpl, +): Promise { + for (const delayMs of [30_000, 60_000, 120_000]) { + await vi.advanceTimersByTimeAsync(delayMs); + await waitForRefreshesToSettle(service); + } +} + describe("WorkspaceGitServiceImpl", () => { beforeEach(() => { vi.useFakeTimers(); @@ -1553,3 +1700,460 @@ describe("WorkspaceGitServiceImpl", () => { service.dispose(); }); }); + +describe("WorkspaceGitServiceImpl with workspace folders that no longer exist", () => { + const temporaryFolders: string[] = []; + + async function createTemporaryFolder(): Promise { + const folder = await mkdtemp(join(os.tmpdir(), "paseo-workspace-git-")); + temporaryFolders.push(folder); + return folder; + } + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(async () => { + vi.useRealTimers(); + await Promise.all( + temporaryFolders.splice(0).map((folder) => rm(folder, { recursive: true, force: true })), + ); + }); + + test("polling stops for a workspace whose folder no longer exists", async () => { + const workspace = await createTemporaryFolder(); + const git = createFolderBackedGit(); + const service = createService(git.deps); + const subscription = service.registerWorkspace({ cwd: workspace }, vi.fn()); + await waitForObservationToSettle(service, 1); + + await rm(workspace, { recursive: true, force: true }); + const gitCommandsAtDeletion = git.gitCommandsIn(workspace); + await advanceThroughDegradedPolls(service); + + expect(git.gitCommandsIn(workspace)).toBe(gitCommandsAtDeletion); + expect(service.getMetrics()).toMatchObject({ + repositoryTargetCount: 0, + workingTreeWatchTargetCount: 0, + }); + expect(vi.getTimerCount()).toBe(1); + + subscription.unsubscribe(); + await service.dispose(); + }); + + test("a deleted worktree leaves its shared repository polling and fetch to the surviving worktree", async () => { + const gitCommonDir = join(await createTemporaryFolder(), ".git"); + const deletedWorktree = await createTemporaryFolder(); + const survivingWorktree = await createTemporaryFolder(); + const git = createFolderBackedGit({ facts: linkedWorktreeFacts(gitCommonDir) }); + let survivingWorktreeEdits = 0; + git.deps.getCheckoutShortstat.mockImplementation(async (cwd: string) => ({ + additions: cwd === survivingWorktree ? ++survivingWorktreeEdits : 1, + deletions: 0, + })); + const service = createService(git.deps); + const deletedSubscription = service.registerWorkspace({ cwd: deletedWorktree }, vi.fn()); + await waitForObservationToSettle(service, 1); + const survivingSubscription = service.registerWorkspace({ cwd: survivingWorktree }, vi.fn()); + await waitForObservationToSettle(service, 2); + + await rm(deletedWorktree, { recursive: true, force: true }); + await vi.advanceTimersByTimeAsync(60_000); + await waitForRefreshesToSettle(service); + const deletedWorktreeCommands = git.gitCommandsIn(deletedWorktree); + + for (let tick = 0; tick < 3; tick += 1) { + const survivingWorktreePolls = git.pollsOf(survivingWorktree); + await vi.advanceTimersByTimeAsync(5_000); + await settle(() => { + expect(git.pollsOf(survivingWorktree)).toBe(survivingWorktreePolls + 1); + }); + } + expect(git.gitCommandsIn(deletedWorktree)).toBe(deletedWorktreeCommands); + + const fetchesBefore = git.deps.runGitFetch.mock.calls.length; + await vi.advanceTimersByTimeAsync(2 * BACKGROUND_GIT_FETCH_INTERVAL_MS); + expect(git.deps.runGitFetch.mock.calls.slice(fetchesBefore).map(([cwd]) => cwd)).toEqual([ + survivingWorktree, + survivingWorktree, + ]); + expect(git.gitCommandsIn(deletedWorktree)).toBe(deletedWorktreeCommands); + + deletedSubscription.unsubscribe(); + survivingSubscription.unsubscribe(); + await service.dispose(); + }); + + test("deleting every worktree of a repository stops its polling and background fetch", async () => { + const gitCommonDir = join(await createTemporaryFolder(), ".git"); + const firstWorktree = await createTemporaryFolder(); + const secondWorktree = await createTemporaryFolder(); + const git = createFolderBackedGit({ facts: linkedWorktreeFacts(gitCommonDir) }); + const service = createService(git.deps); + const firstSubscription = service.registerWorkspace({ cwd: firstWorktree }, vi.fn()); + await waitForObservationToSettle(service, 1); + const secondSubscription = service.registerWorkspace({ cwd: secondWorktree }, vi.fn()); + await waitForObservationToSettle(service, 2); + + await rm(firstWorktree, { recursive: true, force: true }); + await rm(secondWorktree, { recursive: true, force: true }); + const commandsAtDeletion = [ + git.gitCommandsIn(firstWorktree), + git.gitCommandsIn(secondWorktree), + ]; + await advanceThroughDegradedPolls(service); + + expect([git.gitCommandsIn(firstWorktree), git.gitCommandsIn(secondWorktree)]).toEqual( + commandsAtDeletion, + ); + expect(service.getMetrics()).toMatchObject({ + repositoryTargetCount: 0, + workingTreeWatchTargetCount: 0, + }); + expect(vi.getTimerCount()).toBe(2); + + firstSubscription.unsubscribe(); + secondSubscription.unsubscribe(); + await service.dispose(); + }); + + test("working tree fallback polling and watcher recovery stop for a workspace whose folder no longer exists", async () => { + const workspace = await createTemporaryFolder(); + await mkdir(join(workspace, ".git")); + const git = createFolderBackedGit(); + const service = createService(git.deps); + const subscription = service.registerWorkspace({ cwd: workspace }, vi.fn()); + await waitForObservationToSettle(service, 1); + + await rm(workspace, { recursive: true, force: true }); + git.watchCallbackFor(workspace)(new Error("watched directory was deleted"), []); + await vi.advanceTimersByTimeAsync(1_000); + await waitForRefreshesToSettle(service); + const commandsAfterWatcherError = git.gitCommandsIn(workspace); + const watchAttemptsAfterWatcherError = git.watchAttemptsFor(workspace); + await advanceThroughDegradedPolls(service); + + expect(git.gitCommandsIn(workspace)).toBe(commandsAfterWatcherError); + expect(git.watchAttemptsFor(workspace)).toBe(watchAttemptsAfterWatcherError); + expect(vi.getTimerCount()).toBe(1); + + subscription.unsubscribe(); + await service.dispose(); + }); + + test("a workspace whose folder comes back resumes polling on its next subscription", async () => { + const workspace = await createTemporaryFolder(); + const git = createFolderBackedGit(); + const service = createService(git.deps); + const firstSubscription = service.registerWorkspace({ cwd: workspace }, vi.fn()); + await waitForObservationToSettle(service, 1); + await rm(workspace, { recursive: true, force: true }); + const commandsWhileMissing = git.gitCommandsIn(workspace); + await advanceThroughDegradedPolls(service); + expect(git.gitCommandsIn(workspace)).toBe(commandsWhileMissing); + + await mkdir(workspace); + const secondSubscription = service.registerWorkspace({ cwd: workspace }, vi.fn()); + await waitForObservationToSettle(service, 1); + const pollsAfterResume = git.pollsOf(workspace); + await vi.advanceTimersByTimeAsync(5_000); + await waitForRefreshesToSettle(service); + + expect(git.pollsOf(workspace)).toBe(pollsAfterResume + 1); + + firstSubscription.unsubscribe(); + secondSubscription.unsubscribe(); + await service.dispose(); + }); + + test("a suspended workspace costs no git while its folder is missing, even when a refresh is requested", async () => { + const workspace = await createTemporaryFolder(); + const git = createFolderBackedGit(); + const service = createService(git.deps); + const subscription = service.registerWorkspace({ cwd: workspace }, vi.fn()); + await waitForObservationToSettle(service, 1); + await rm(workspace, { recursive: true, force: true }); + await advanceThroughDegradedPolls(service); + const commandsWhileMissing = git.gitCommandsIn(workspace); + + for (let probe = 0; probe < 10; probe += 1) { + await vi.advanceTimersByTimeAsync(WORKSPACE_GIT_OBSERVATION_REENSURE_INTERVAL_MS); + await waitForRefreshesToSettle(service); + } + service.scheduleRefreshForCwd(workspace); + service.onWorkspaceStateMayHaveChanged(workspace); + await vi.advanceTimersByTimeAsync(5_000); + await waitForRefreshesToSettle(service); + + expect(git.gitCommandsIn(workspace)).toBe(commandsWhileMissing); + expect(vi.getTimerCount()).toBe(1); + + subscription.unsubscribe(); + await service.dispose(); + }); + + test("a suspended worktree resumes polling and its repository fetch once its folder is back, without a new subscription", async () => { + const gitCommonDir = join(await createTemporaryFolder(), ".git"); + const worktree = await createTemporaryFolder(); + const git = createFolderBackedGit({ facts: linkedWorktreeFacts(gitCommonDir) }); + const service = createService(git.deps); + const subscription = service.registerWorkspace({ cwd: worktree }, vi.fn()); + await waitForObservationToSettle(service, 1); + await rm(worktree, { recursive: true, force: true }); + await advanceThroughDegradedPolls(service); + await waitForObservationToSettle(service, 0); + + await mkdir(worktree); + const fetchesBeforeRestore = git.fetchesIn(worktree); + await vi.advanceTimersByTimeAsync(WORKSPACE_GIT_OBSERVATION_REENSURE_INTERVAL_MS); + await waitForObservationToSettle(service, 1); + expect(git.fetchesIn(worktree)).toBe(fetchesBeforeRestore + 1); + + const pollsAfterRestore = git.pollsOf(worktree); + await vi.advanceTimersByTimeAsync(5_000); + await waitForRefreshesToSettle(service); + expect(git.pollsOf(worktree)).toBe(pollsAfterRestore + 1); + + subscription.unsubscribe(); + await service.dispose(); + }); + + test("a forced snapshot resumes a suspended workspace whose folder is back", async () => { + const workspace = await createTemporaryFolder(); + const git = createFolderBackedGit(); + const service = createService(git.deps); + const subscription = service.registerWorkspace({ cwd: workspace }, vi.fn()); + await waitForObservationToSettle(service, 1); + await rm(workspace, { recursive: true, force: true }); + await advanceThroughDegradedPolls(service); + await waitForObservationToSettle(service, 0); + + await mkdir(workspace); + const snapshot = await service.getSnapshot(workspace, { + force: true, + reason: "refresh-button", + }); + await waitForObservationToSettle(service, 1); + + expect(snapshot.git.isGit).toBe(true); + + subscription.unsubscribe(); + await service.dispose(); + }); + + test("a resumed workspace rebuilds observation from fresh facts, not the non-git facts cached while its folder was gone", async () => { + const workspace = await createTemporaryFolder(); + const git = createFolderBackedGit(); + const service = createService(git.deps); + const firstSubscription = service.registerWorkspace({ cwd: workspace }, vi.fn()); + await waitForObservationToSettle(service, 1); + await rm(workspace, { recursive: true, force: true }); + service.onWorkspaceStateMayHaveChanged(workspace); + await vi.advanceTimersByTimeAsync(1_000); + await waitForRefreshesToSettle(service); + expect((await service.getSnapshot(workspace)).git.isGit).toBe(false); + await advanceThroughDegradedPolls(service); + await waitForObservationToSettle(service, 0); + + await mkdir(workspace); + const secondSubscription = service.registerWorkspace({ cwd: workspace }, vi.fn()); + await waitForObservationToSettle(service, 1); + + expect((await service.getSnapshot(workspace)).git.isGit).toBe(true); + + firstSubscription.unsubscribe(); + secondSubscription.unsubscribe(); + await service.dispose(); + }); + + test("resuming waits for an observation setup that was still running when the folder vanished", async () => { + const workspace = await createTemporaryFolder(); + const gitDir = join(workspace, ".git"); + await mkdir(gitDir); + const metadataWatchRelease = createDeferred(); + const git = createFolderBackedGit({ + unwatchableDirectories: [workspace], + heldWatch: { directory: gitDir, release: metadataWatchRelease.promise }, + }); + const service = createService(git.deps); + const firstSubscription = service.registerWorkspace({ cwd: workspace }, vi.fn()); + await settle(() => { + expect(git.watchAttemptsFor(gitDir)).toBe(1); + }); + + await rm(workspace, { recursive: true, force: true }); + await vi.advanceTimersByTimeAsync(5_000); + await settle(() => { + expect(service.getMetrics()).toMatchObject({ + repositoryTargetCount: 0, + workingTreeWatchTargetCount: 0, + }); + }); + await mkdir(workspace); + const secondSubscription = service.registerWorkspace({ cwd: workspace }, vi.fn()); + metadataWatchRelease.resolve(); + await waitForObservationToSettle(service, 1); + + expect(service.getMetrics().workingTreeWatchTargetCount).toBe(1); + + firstSubscription.unsubscribe(); + secondSubscription.unsubscribe(); + await service.dispose(); + }); + + test("an open working tree watch neither notifies nor recovers while its folder is missing, and picks up once it is back", async () => { + const workspace = await createTemporaryFolder(); + await mkdir(join(workspace, ".git")); + const git = createFolderBackedGit(); + const service = createService(git.deps); + const workspaceSubscription = service.registerWorkspace({ cwd: workspace }, vi.fn()); + await waitForObservationToSettle(service, 1); + const diffListener = vi.fn(); + const diffWatch = await service.requestWorkingTreeWatch(workspace, diffListener); + + await rm(workspace, { recursive: true, force: true }); + git.watchCallbackFor(workspace)(new Error("watched directory was deleted"), []); + await vi.advanceTimersByTimeAsync(1_000); + await waitForRefreshesToSettle(service); + const commandsAfterWatcherError = git.gitCommandsIn(workspace); + const notificationsAfterWatcherError = diffListener.mock.calls.length; + const watchAttemptsAfterWatcherError = git.watchAttemptsFor(workspace); + await advanceThroughWorkingTreeRecoveryAttempts(service); + + expect(git.gitCommandsIn(workspace)).toBe(commandsAfterWatcherError); + expect(diffListener.mock.calls.length).toBe(notificationsAfterWatcherError); + expect(git.watchAttemptsFor(workspace)).toBe(watchAttemptsAfterWatcherError); + expect(service.getMetrics().workingTreeWatchTargetCount).toBe(1); + + await mkdir(join(workspace, ".git"), { recursive: true }); + await vi.advanceTimersByTimeAsync(60_000); + await waitForRefreshesToSettle(service); + expect(diffListener.mock.calls.length).toBe(notificationsAfterWatcherError + 1); + + await vi.advanceTimersByTimeAsync(240_000); + await waitForRefreshesToSettle(service); + expect(git.watchAttemptsFor(workspace)).toBe(watchAttemptsAfterWatcherError + 1); + const notificationsAfterResubscribe = diffListener.mock.calls.length; + await vi.advanceTimersByTimeAsync(120_000); + await waitForRefreshesToSettle(service); + expect(diffListener.mock.calls.length).toBe(notificationsAfterResubscribe); + + diffWatch.unsubscribe(); + workspaceSubscription.unsubscribe(); + await service.dispose(); + }); + + test("a refresh crossing suspension preserves the cached snapshot and drops queued work", async () => { + const workspace = await createTemporaryFolder(); + const git = createFolderBackedGit(); + const started = createDeferred(); + const release = createDeferred(); + let holdNextRead = false; + let factsReads = 0; + const service = createService({ + ...git.deps, + getCheckoutSnapshotFacts: async (cwd: string) => { + factsReads++; + if (holdNextRead) { + holdNextRead = false; + started.resolve(); + await release.promise; + } + return git.deps.getCheckoutSnapshotFacts(cwd); + }, + }); + const updates: WorkspaceGitRuntimeSnapshot[] = []; + const subscription = service.registerWorkspace({ cwd: workspace }, (snapshot) => + updates.push(snapshot), + ); + await waitForObservationToSettle(service, 1); + const cached = await service.getSnapshot(workspace); + holdNextRead = true; + const active = service.getSnapshot(workspace, { + force: true, + includeForge: false, + reason: "overlapping-refresh", + }); + await started.promise; + const queued = service.getSnapshot(workspace, { + force: true, + includeForge: true, + reason: "queued-refresh", + }); + await rm(workspace, { recursive: true, force: true }); + await vi.advanceTimersByTimeAsync(5_000); + await settle(() => expect(service.getMetrics().repositoryWorkspaceLinkCount).toBe(0)); + const readsAtSuspension = factsReads; + const updatesAtSuspension = updates.length; + release.resolve(); + expect(await active).toEqual(cached); + expect(await queued).toEqual(cached); + expect(await service.getSnapshot(workspace)).toEqual(cached); + expect(updates).toHaveLength(updatesAtSuspension); + expect(factsReads).toBe(readsAtSuspension); + subscription.unsubscribe(); + await service.dispose(); + }); + + test("restoration settles a suspended refresh before reloading facts", async () => { + const workspace = await createTemporaryFolder(); + const git = createFolderBackedGit(); + const staleFacts = createDeferred(); + const started = createDeferred(); + let holdNextRead = false; + const service = createService({ + ...git.deps, + getCheckoutSnapshotFacts: async (cwd: string) => { + if (holdNextRead) { + holdNextRead = false; + started.resolve(); + return staleFacts.promise; + } + return git.deps.getCheckoutSnapshotFacts(cwd); + }, + }); + const updates: WorkspaceGitRuntimeSnapshot[] = []; + const subscription = service.registerWorkspace({ cwd: workspace }, (snapshot) => + updates.push(snapshot), + ); + await waitForObservationToSettle(service, 1); + const cached = await service.getSnapshot(workspace); + holdNextRead = true; + const active = service.getSnapshot(workspace, { + force: true, + includeForge: false, + reason: "overlapping-refresh", + }); + await started.promise; + await rm(workspace, { recursive: true, force: true }); + await vi.advanceTimersByTimeAsync(5_000); + await settle(() => expect(service.getMetrics().repositoryWorkspaceLinkCount).toBe(0)); + const updatesAtSuspension = updates.length; + await mkdir(workspace); + const restored = service.getSnapshot(workspace, { + force: true, + includeForge: false, + reason: "folder-returned", + }); + await vi.advanceTimersByTimeAsync(0); + staleFacts.resolve({ isGit: false }); + expect(await active).toEqual(cached); + expect((await restored).git.isGit).toBe(true); + expect(updates.slice(updatesAtSuspension).every((snapshot) => snapshot.git.isGit)).toBe(true); + await waitForObservationToSettle(service, 1); + subscription.unsubscribe(); + await service.dispose(); + }); + + test("workspaceFolderExists reports a missing folder and a path under a file as missing", async () => { + const folder = await createTemporaryFolder(); + const file = join(folder, "file.txt"); + await writeFile(file, ""); + + expect(await workspaceFolderExists(folder)).toBe(true); + expect(await workspaceFolderExists(join(folder, "missing"))).toBe(false); + expect(await workspaceFolderExists(join(file, "nested"))).toBe(false); + }); +}); diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index b5672846bd..26a7f16419 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { readFile } from "node:fs/promises"; +import { readFile, stat } from "node:fs/promises"; import { basename, dirname, join, resolve } from "node:path"; import { LRUCache } from "lru-cache"; import { CheckoutDiffCache } from "./checkout-diff-cache.js"; @@ -74,7 +74,7 @@ import { checkoutLiteFromGitSnapshot } from "./workspace-registry-model.js"; import { createWatcherLivenessCanary } from "./watcher-liveness-canary.js"; const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 1_000; -const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000; +export const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000; const FETCH_METADATA_ECHO_TTL_MS = 5_000; export const WORKSPACE_GIT_OBSERVATION_REENSURE_INTERVAL_MS = 60_000; const FORGE_PR_STATUS_POLL_FAST_INTERVAL_MS = 20_000; @@ -384,6 +384,7 @@ interface WorkspaceGitServiceDependencies { runGitCommand: typeof runGitCommand; getWorkspaceGitSelfHealPhaseMs: typeof getWorkspaceGitObservationReensurePhaseMs; createWatcherLivenessCanary: typeof createWatcherLivenessCanary; + workspaceFolderExists: (cwd: string) => Promise; now: () => Date; } @@ -435,6 +436,8 @@ interface WorkspaceGitTarget { repoGitRoot: string | null; observationSetupPromise: Promise | null; observationSetupComplete: boolean; + folderMissing: boolean; + folderRestorePromise: Promise | null; closed: boolean; } @@ -571,10 +574,21 @@ function buildDefaultWorkspaceGitServiceDeps( runGitCommand, getWorkspaceGitSelfHealPhaseMs: getWorkspaceGitObservationReensurePhaseMs, createWatcherLivenessCanary, + workspaceFolderExists, now: () => new Date(), }; } +export async function workspaceFolderExists(cwd: string): Promise { + try { + await stat(cwd); + return true; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + return code !== "ENOENT" && code !== "ENOTDIR"; + } +} + function resolveWorkspaceGitServiceDeps( subscribe: SubscribeToFileChanges, deps: Partial | undefined, @@ -660,6 +674,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { const cwd = resolve(params.cwd); const target = this.ensureWorkspaceTarget(cwd); target.listeners.add(listener); + if (target.folderMissing) { + void this.resumeWorkspaceIfFolderRestored(target); + } if (target.listeners.size === 1) { this.startWorkspaceSubscriptionTimers(target); } @@ -751,6 +768,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { if (!request.force && target.latestSnapshot) { return target.latestSnapshot; } + if (target.folderMissing) { + await this.resumeWorkspaceIfFolderRestored(target); + } return this.requestWorkspaceSnapshot(target, request); } @@ -972,6 +992,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { this.assertNotDisposed(); cwd = resolve(cwd); const target = this.ensureWorkspaceTarget(cwd); + if (target.folderMissing) { + await this.resumeWorkspaceIfFolderRestored(target); + } await this.refreshWorkspaceTarget(target, { force: false, refreshStructure: true, @@ -1005,9 +1028,14 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { this.assertNotDisposed(); cwd = resolve(cwd); const target = this.workspaceTargets.get(cwd); - if (target) { - this.scheduleWorkspaceRefresh(target, { queueIfBusy: false }); + if (!target) { + return; } + if (target.folderMissing) { + void this.resumeWorkspaceIfFolderRestored(target); + return; + } + this.scheduleWorkspaceRefresh(target, { queueIfBusy: false }); } onWorkspaceStateMayHaveChanged(cwd: string): void { @@ -1018,6 +1046,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return; } this.invalidateForge(normalizedCwd); + if (target.folderMissing) { + void this.resumeWorkspaceIfFolderRestored(target); + return; + } this.scheduleWorkspaceRefresh(target, { force: true, includeForge: true, @@ -1227,6 +1259,8 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { repoGitRoot: null, observationSetupPromise: null, observationSetupComplete: false, + folderMissing: false, + folderRestorePromise: null, closed: false, }; @@ -1358,6 +1392,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private isActiveObservedWorkspaceTarget(target: WorkspaceGitTarget): boolean { return ( !target.closed && + !target.folderMissing && target.listeners.size > 0 && this.workspaceTargets.get(target.cwd) === target ); @@ -1612,11 +1647,110 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { workspaceTarget: WorkspaceGitTarget, request: WorkspaceGitRefreshRequest, ): Promise { + if (await this.suspendWorkspaceIfFolderMissing(workspaceTarget)) { + return false; + } const fingerprintBefore = workspaceTarget.latestFingerprint; await this.refreshWorkspaceTarget(workspaceTarget, request); return workspaceTarget.latestFingerprint !== fingerprintBefore; } + private async suspendWorkspaceIfFolderMissing(target: WorkspaceGitTarget): Promise { + if (target.folderMissing) { + return true; + } + if (await this.deps.workspaceFolderExists(target.cwd)) { + return false; + } + this.suspendWorkspaceWithMissingFolder(target); + return true; + } + + private suspendWorkspaceWithMissingFolder(target: WorkspaceGitTarget): void { + if (target.folderMissing || target.closed || this.workspaceTargets.get(target.cwd) !== target) { + return; + } + target.folderMissing = true; + if (target.refreshState.status === "in-flight") { + target.refreshState.queued = null; + } + this.detachWorkspaceFromRepoTarget(target); + target.repoGitRoot = null; + this.stopWorkspaceObservation(target); + target.observationSetupComplete = false; + this.scheduleMissingFolderProbe(target); + this.logger.warn( + { cwd: target.cwd }, + "Workspace folder no longer exists; suspended git observation until it is back", + ); + } + + private isSuspendedWorkspaceTarget(target: WorkspaceGitTarget): boolean { + return ( + target.folderMissing && !target.closed && this.workspaceTargets.get(target.cwd) === target + ); + } + + private scheduleMissingFolderProbe(target: WorkspaceGitTarget): void { + if ( + !this.isSuspendedWorkspaceTarget(target) || + target.observationReensureTimer || + target.folderRestorePromise + ) { + return; + } + target.observationReensureTimer = setTimeout(() => { + target.observationReensureTimer = null; + void this.resumeWorkspaceIfFolderRestored(target); + }, WORKSPACE_GIT_OBSERVATION_REENSURE_INTERVAL_MS); + } + + private resumeWorkspaceIfFolderRestored(target: WorkspaceGitTarget): Promise { + if (!target.folderMissing) { + return Promise.resolve(); + } + target.folderRestorePromise ??= this.resumeWorkspaceWithRestoredFolder(target).finally(() => { + target.folderRestorePromise = null; + this.scheduleMissingFolderProbe(target); + }); + return target.folderRestorePromise; + } + + private async resumeWorkspaceWithRestoredFolder(target: WorkspaceGitTarget): Promise { + if (!(await this.deps.workspaceFolderExists(target.cwd))) { + return; + } + await target.observationSetupPromise; + if (target.refreshState.status === "in-flight") { + await target.refreshState.promise.catch(() => undefined); + } + if (!this.isSuspendedWorkspaceTarget(target)) { + return; + } + target.latestFacts = null; + target.folderMissing = false; + await this.refreshWorkspaceTarget(target, { + force: false, + refreshStructure: true, + refreshWorktree: true, + includeForge: false, + reason: "workspace-folder-restored", + queueIfBusy: true, + movedRemoteRefs: new Set(), + }); + if (target.closed || this.workspaceTargets.get(target.cwd) !== target) { + return; + } + target.observationSetupComplete = false; + if (target.observationReensureTimer) { + clearTimeout(target.observationReensureTimer); + target.observationReensureTimer = null; + } + this.logger.info({ cwd: target.cwd }, "Workspace folder is back; resumed git observation"); + this.startWorkspaceSubscriptionTimers(target); + this.scheduleWorkspaceObservationSetup(target); + } + private startWorkingTreeWatchFallback( target: WorkingTreeWatchTarget, reason: WorkingTreeWatchFallbackReason, @@ -1654,7 +1788,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return changed; }), ); - this.notifyWorkingTreeConsumers(target); + if (await this.deps.workspaceFolderExists(target.watchPath)) { + this.notifyWorkingTreeConsumers(target); + } if (!target.closed && (target.subscription === null || target.repoRoot === null)) { target.fallbackPollQuietTickCount = changes.some(Boolean) ? 0 @@ -1731,6 +1867,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { if (target.closed || target.subscription) { return; } + if (!(await this.deps.workspaceFolderExists(target.watchPath))) { + this.scheduleWorkingTreeWatchRecovery(target); + return; + } await this.refreshWorkingTreeIgnoredDirectories(target); if (target.closed || target.subscription) { return; @@ -2786,7 +2926,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } private updateForgePrStatusPollForTarget(target: WorkspaceGitTarget): void { - if (target.listeners.size === 0) { + if (!this.isActiveObservedWorkspaceTarget(target)) { this.stopForgePrStatusPollForTarget(target); return; } @@ -3049,6 +3189,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { target: WorkspaceGitTarget, request: WorkspaceGitRefreshRequest, ): Promise { + if (target.folderMissing) { + return Promise.resolve(target.latestSnapshot ?? buildNotGitSnapshot(target.cwd)); + } if (target.refreshState.status === "in-flight") { const active = target.refreshState.request; const addsWork = @@ -3191,11 +3334,19 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { try { const runRefreshGitCommand = createRunGitCommand(`workspace-refresh:${request.reason}`); const admittedSnapshot = await this.workspaceRefreshLimit(() => { - if (target.closed || this.workspaceTargets.get(target.cwd) !== target) { + if ( + target.closed || + target.folderMissing || + this.workspaceTargets.get(target.cwd) !== target + ) { return null; } return this.refreshSnapshot(target, request, runRefreshGitCommand); }); + if (target.folderMissing) { + failure = null; + break; + } if (!admittedSnapshot) { break; } @@ -3209,6 +3360,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { failure = { error }; } + if (target.folderMissing) { + failure = null; + break; + } if (this.disposed || target.closed || this.workspaceTargets.get(target.cwd) !== target) { break; } @@ -3504,7 +3659,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { snapshot: WorkspaceGitRuntimeSnapshot, options?: { forceEmit?: boolean }, ): void { - if (target.closed || this.workspaceTargets.get(target.cwd) !== target) { + if (target.closed || target.folderMissing || this.workspaceTargets.get(target.cwd) !== target) { return; } target.latestSnapshot = snapshot; @@ -3660,21 +3815,25 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } private removeWorkspaceTarget(target: WorkspaceGitTarget): void { - if (target.repoGitRoot) { - const repoTarget = this.repoTargets.get(target.repoGitRoot); - repoTarget?.workspaceKeys.delete(target.cwd); - if (repoTarget && repoTarget.workspaceKeys.size === 0) { - this.closeRepoTarget(repoTarget); - this.repoTargets.delete(target.repoGitRoot); - } else if (repoTarget?.cwd === target.cwd) { - repoTarget.cwd = repoTarget.workspaceKeys.values().next().value ?? repoTarget.cwd; - } - } - + this.detachWorkspaceFromRepoTarget(target); this.closeWorkspaceTarget(target); this.workspaceTargets.delete(target.cwd); } + private detachWorkspaceFromRepoTarget(target: WorkspaceGitTarget): void { + if (!target.repoGitRoot) { + return; + } + const repoTarget = this.repoTargets.get(target.repoGitRoot); + repoTarget?.workspaceKeys.delete(target.cwd); + if (repoTarget && repoTarget.workspaceKeys.size === 0) { + this.closeRepoTarget(repoTarget); + this.repoTargets.delete(target.repoGitRoot); + } else if (repoTarget?.cwd === target.cwd) { + repoTarget.cwd = repoTarget.workspaceKeys.values().next().value ?? repoTarget.cwd; + } + } + private removeWorkingTreeWatchListener(cwd: string, listener: () => void): void { const target = this.workingTreeWatchTargets.get(cwd); if (!target) { @@ -3719,6 +3878,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private closeWorkspaceTarget(target: WorkspaceGitTarget): void { target.closed = true; + this.stopWorkspaceObservation(target); + target.listeners.clear(); + } + + private stopWorkspaceObservation(target: WorkspaceGitTarget): void { if (target.workingTreeWatchTarget) { this.removeWorkspaceWorkingTreeLink(target.workingTreeWatchTarget, target.cwd); target.workingTreeWatchTarget = null; @@ -3727,12 +3891,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { clearTimeout(target.debounceTimer); target.debounceTimer = null; } + target.pendingDebounceRequest = null; if (target.observationReensureTimer) { clearTimeout(target.observationReensureTimer); target.observationReensureTimer = null; } this.stopForgePrStatusPollForTarget(target); - target.listeners.clear(); } private closeWorkingTreeWatchTarget(target: WorkingTreeWatchTarget): void { From 2d75cc80b2608ca4604cee54bb8ae4c5cfe956c8 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:34:57 +0000 Subject: [PATCH 37/58] test(server): cover routed permissions in one host journey (#8486) Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../server/connected-hosts/hosts.e2e.test.ts | 36 ++++++------------- 1 file changed, 10 insertions(+), 26 deletions(-) diff --git a/packages/server/src/server/connected-hosts/hosts.e2e.test.ts b/packages/server/src/server/connected-hosts/hosts.e2e.test.ts index 264589f82e..ec47461386 100644 --- a/packages/server/src/server/connected-hosts/hosts.e2e.test.ts +++ b/packages/server/src/server/connected-hosts/hosts.e2e.test.ts @@ -183,7 +183,7 @@ test("ordinary routed APIs isolate scopes and enforce one-hop, disable and origi await paseo.dispose(); }); -test("routed authority is bounded by the caller, with isolated simultaneous sessions", async () => { +test("routed authority isolates simultaneous sessions and preserves ordinary permission RPC errors", async () => { const b = await daemon(); const a = await daemon(); const owner = await api(a); @@ -210,33 +210,16 @@ test("routed authority is bounded by the caller, with isolated simultaneous sess code: "permission_denied", }); expect((await owner.paseo.hosts.list()).hosts).toHaveLength(1); - await remote.dispose(); -}); - -test("SDK host clients preserve ordinary permission RPC errors", async () => { - const b = await daemon(); - const a = await daemon(); - const owner = await api(a); - await owner.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` }); - const serverId = (await online(owner.paseo)).hosts[0]!.serverId!; - const clients = [a, b].map((host) => - createHostClient(`127.0.0.1:${host.port}`, { - clientId: crypto.randomUUID(), - sessionPermissions: ["daemon.read", "workspace.read"], - reconnect: { enabled: false }, - }), - ); - for (const client of clients) { - cleanup.push(() => client.close()); - await client.connect(); - } - const origin = createPaseoApi(clients[0]!); - const direct = createPaseoApi(clients[1]!); - const remote = await origin.hosts.client(serverId); + const directClient = createHostClient(`127.0.0.1:${b.port}`, { + clientId: crypto.randomUUID(), + sessionPermissions: ["daemon.read", "workspace.read"], + reconnect: { enabled: false }, + }); + await directClient.connect(); + const direct = createPaseoApi(directClient); cleanup.push(async () => { - await remote.dispose(); await direct.dispose(); - await origin.dispose(); + await directClient.close(); }); const input = { source: { kind: "directory" as const, path: b.staticDir } }; const directError = (await direct.workspaces.create(input).catch((error: unknown) => error)) as { @@ -247,6 +230,7 @@ test("SDK host clients preserve ordinary permission RPC errors", async () => { }; expect(directError.code).toBe("access_denied"); expect(remoteError.code).toBe(directError.code); + await remote.dispose(); }); test("distinct origin sessions cannot collide through a caller-selected route scope, including terminal binary output", async () => { From 04fe4be40f88db94ee701dbd7148c4bf04283d35 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:59:04 +0000 Subject: [PATCH 38/58] fix(claude): retain configured model metadata before a turn (#8465) Rebuilding an idle session cleared its observed model and published null despite retaining an explicit model in its configuration. Fall back to that configuration until the next turn supplies an observed model. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../claude/agent.runtime-info.test.ts | 48 +++++++++++++++++++ .../server/agent/providers/claude/agent.ts | 2 +- 2 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 packages/server/src/server/agent/providers/claude/agent.runtime-info.test.ts diff --git a/packages/server/src/server/agent/providers/claude/agent.runtime-info.test.ts b/packages/server/src/server/agent/providers/claude/agent.runtime-info.test.ts new file mode 100644 index 0000000000..a23c95bc64 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/agent.runtime-info.test.ts @@ -0,0 +1,48 @@ +import pino from "pino"; +import { describe, expect, test } from "vitest"; + +import { ClaudeAgentClient } from "./agent.js"; + +describe("Claude runtime info before a turn", () => { + const client = new ClaudeAgentClient({ logger: pino({ level: "silent" }) }); + + test("reports the explicitly configured model before the first turn", async () => { + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + model: "claude-haiku-5-5", + }); + try { + expect(await session.getRuntimeInfo()).toMatchObject({ model: "claude-haiku-5-5" }); + } finally { + await session.close(); + } + }); + + test("reports the persisted configured model after rebuilding an idle session", async () => { + const session = await client.resumeSession({ + provider: "claude", + sessionId: "runtime-model-before-turn", + metadata: { cwd: process.cwd(), model: "claude-haiku-5-5" }, + }); + try { + expect(await session.getRuntimeInfo()).toMatchObject({ + sessionId: "runtime-model-before-turn", + model: "claude-haiku-5-5", + }); + expect((await session.getRuntimeInfo()).model).toBe("claude-haiku-5-5"); + expect((await session.getRuntimeInfo()).extra?.runtimeModel).toBeUndefined(); + } finally { + await session.close(); + } + }); + + test("leaves the model unknown when Claude will choose its default", async () => { + const session = await client.createSession({ provider: "claude", cwd: process.cwd() }); + try { + expect((await session.getRuntimeInfo()).model).toBeNull(); + } finally { + await session.close(); + } + }); +}); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index ebb37e13f8..9895f433a8 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -2460,7 +2460,7 @@ class ClaudeAgentSession implements AgentSession { const info: AgentRuntimeInfo = { provider: "claude", sessionId: this.claudeSessionId, - model: this.lastOptionsModel, + model: this.lastOptionsModel ?? this.config.model ?? null, modeId: this.currentMode ?? null, ...(this.lastRuntimeModel ? { From 7f318bf7cf32690436c8623f1277521538178942 Mon Sep 17 00:00:00 2001 From: Thomas Van <9481872+thomasvan@users.noreply.github.com> Date: Mon, 2 Nov 2026 02:58:12 +0700 Subject: [PATCH 39/58] fix(codex): end an interrupted turn's shell commands (#7525) `turn/interrupt` ends a Codex turn and leaves the shell commands it started running: a command the user just stopped runs on to its own end, alongside the next turn. Once Codex accepts the interrupt, the provider now ends each command the interrupted turn started that has not exited, by process id, with `thread/backgroundTerminals/terminate`, and a turn Paseo starts waits for those calls before its `turn/start`. - Attribution: `thread/backgroundTerminals/list` carries no turn, so the provider records each root-thread `commandExecution` process id under the turn of its `item/started`. A `completed` item drops its id; a `failed` one keeps it, because Codex reports an interrupted turn's still-running command as failed. A turn that ends any way but `interrupted` forgets its ids, so what an earlier turn left running on purpose is never ended. At most 16 turns are remembered. - A late command: the turn keeps its accepted interrupt, so a command whose `item/started` arrives after Codex accepted the interrupt (one queued behind an earlier notification's conversion, for instance) is ended as soon as it is seen. Each process is terminated once, and a turn start waits for every pending sweep. - It never sends `thread/backgroundTerminals/clean`, which ends every terminal on the thread. It ends nothing after an already-idle interrupt, nothing on a subagent's thread, and nothing after an interrupt of a turn Codex never identified. - Bounds: each terminate times out after 2 s; a failure is logged (`provider.codex.interrupt.terminal_terminate_failed`) and fails nothing. `startTurn` waits at most the interrupt's bound plus one terminate's plus 500 ms. Tests (codex-interrupted-turn-terminals.test.ts) drive a real CodexAppServerAgentSession against the scripted app-server in codex/test-utils/fake-app-server.ts: exactly the interrupted turn's processes are terminated, by id, and `clean` is never sent; a turn Codex starts before acknowledging the interrupt keeps its commands; the next `turn/start` waits for the terminates; a command announced after the interrupt was accepted is terminated once and the next turn waits for both sweeps, whichever answers first; a completed command is forgotten and a failed one kept; only the latest sixteen accepted interrupts are kept; a failing or unanswered terminate does not block the next turn. The harness waits for the interrupted turn's `turn_canceled` before it starts the next turn, as a caller does: notifications are handled asynchronously, so the interrupt's answer can arrive before the turn has ended. Fixes #7522 Claude-Session: https://claude.ai/code/session_01Ay568T2kmtotoh23Dtzi5K Co-authored-by: Claude Opus 5.5 --- .../agent/providers/codex-app-server-agent.ts | 186 ++++++++- .../codex-interrupted-turn-terminals.test.ts | 374 ++++++++++++++++++ 2 files changed, 552 insertions(+), 8 deletions(-) create mode 100644 packages/server/src/server/agent/providers/codex-interrupted-turn-terminals.test.ts diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 2aecd18e21..cc667b2494 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -150,6 +150,15 @@ function isCodexAlreadyUnarchivedError(error: unknown, threadId: string): boolea const TURN_START_TIMEOUT_MS = 90 * 1000; const INTERRUPT_TIMEOUT_MS = 2_000; +// `turn/interrupt` ends a turn but leaves the shell commands it started running. +// Each terminate is bounded by the first constant. A turn start waits for the +// interrupted turn's commands to end, at most the interrupt's own bound plus one +// terminate's and a margin, so it gives up only when a request outlives its timeout. +const INTERRUPTED_TURN_TERMINATE_TIMEOUT_MS = 2_000; +const INTERRUPTED_TURN_CLEANUP_WAIT_MS = + INTERRUPT_TIMEOUT_MS + INTERRUPTED_TURN_TERMINATE_TIMEOUT_MS + 500; +// Turns whose commands are remembered; older turns are forgotten first. +const INTERRUPTED_TURN_TRACKED_TURNS = 16; const CODEX_PROVIDER = "codex" as const; // These native provider identities belong to Codex, not to an older custom // Paseo provider. Never include them in a custom provider's legacy import scan. @@ -3476,6 +3485,14 @@ export class CodexAppServerAgentSession implements AgentSession { private pendingFileChangeOutputDeltas = new Map(); private pendingAssistantMessageBoundary = false; private terminalCommandByProcessId = new Map(); + // The root thread's still-running command process ids, keyed by the native turn that + // started them. Codex's `thread/backgroundTerminals/list` carries no turn, so + // `item/started` is the only record of which turn owns a process. + private terminalProcessIdsByTurnId = new Map< + string, + { processIds: Set; terminatedProcessIds: Set; interruptAccepted: boolean } + >(); + private interruptedTurnCleanup: Promise | null = null; private pendingUnlabeledTerminalInteractions = new Map< string, Array<{ callId: string; stdin: string | null }> @@ -4431,6 +4448,9 @@ export class CodexAppServerAgentSession implements AgentSession { hasDeveloperInstructions: turnStart.hasDeveloperInstructions, hasCodexConfig: turnStart.hasCodexConfig, }); + // The interrupted turn's commands end before this turn begins, so none of them + // outlives it into the new turn. + await this.waitForInterruptedTurnCleanup(); if (pendingStart.cancelRequested) { throw new Error("Codex turn start was interrupted before reaching Codex"); } @@ -5036,15 +5056,27 @@ export class CodexAppServerAgentSession implements AgentSession { if (!turnId || (foregroundTurnId && this.activeForegroundTurnId !== foregroundTurnId)) { throw new Error("Cannot interrupt Codex before turn/started identifies the active turn"); } + const client = this.client; + const threadId = this.currentThreadId; + const interruptRequest = client.request( + "turn/interrupt", + { + threadId, + turnId, + }, + INTERRUPT_TIMEOUT_MS, + ); + // Once Codex accepts the interrupt, end the commands this turn started. Registered + // before the await, so a turn start racing this interrupt waits for it. An already-idle + // turn was not interrupted, and what it left running stays. + this.trackInterruptedTurnCleanup( + interruptRequest.then( + () => this.endInterruptedTurnProcesses(client, threadId, turnId), + () => undefined, + ), + ); try { - await this.client.request( - "turn/interrupt", - { - threadId: this.currentThreadId, - turnId, - }, - INTERRUPT_TIMEOUT_MS, - ); + await interruptRequest; } catch (error) { if (!isCodexAlreadyIdleInterrupt(error)) { throw error; @@ -5085,6 +5117,137 @@ export class CodexAppServerAgentSession implements AgentSession { return !this.client.hasPendingRequests(); } + // Remember which turn started a command, so an interrupt ends that turn's commands and + // no other turn's. Only the root thread's: a subagent's commands live on its own thread. + // A command that starts after its turn's interrupt was accepted is ended as soon as it + // is seen. + private recordTurnTerminalProcess( + parsed: Extract, + ): void { + const processId = parsed.item.processId; + if ( + parsed.source !== "item" || + !parsed.turnId || + !parsed.threadId || + parsed.threadId !== this.currentThreadId || + typeof processId !== "string" || + !processId || + normalizeCodexThreadItemType( + typeof parsed.item.type === "string" ? parsed.item.type : undefined, + ) !== "commandExecution" + ) { + return; + } + const record = this.terminalProcessIdsByTurnId.get(parsed.turnId); + if (parsed.kind === "item_completed") { + // Only a completed command has exited. A failed one can still be running: + // Codex reports an interrupted turn's live command as failed. + if (parsed.item.status === "completed") record?.processIds.delete(processId); + return; + } + const current = record ?? this.getTurnTerminalRecord(parsed.turnId); + current.processIds.add(processId); + if (current.interruptAccepted && this.client) { + this.trackInterruptedTurnCleanup( + this.endInterruptedTurnProcesses(this.client, parsed.threadId, parsed.turnId), + ); + } + } + + // An accepted interrupt needs a record even before any of its turn's commands is seen: + // an `item/started` can still be queued behind an earlier notification's conversion. + private getTurnTerminalRecord(turnId: string) { + const existing = this.terminalProcessIdsByTurnId.get(turnId); + if (existing) return existing; + const record = { + processIds: new Set(), + terminatedProcessIds: new Set(), + interruptAccepted: false, + }; + this.terminalProcessIdsByTurnId.set(turnId, record); + for (const oldestTurnId of this.terminalProcessIdsByTurnId.keys()) { + if (this.terminalProcessIdsByTurnId.size <= INTERRUPTED_TURN_TRACKED_TURNS) break; + this.terminalProcessIdsByTurnId.delete(oldestTurnId); + } + return record; + } + + // A later sweep joins the pending one, so a turn start waits for both. + private trackInterruptedTurnCleanup(cleanup: Promise): void { + const tracked = Promise.all([this.interruptedTurnCleanup, cleanup]) + .then(() => undefined) + .finally(() => { + if (this.interruptedTurnCleanup === tracked) this.interruptedTurnCleanup = null; + }); + this.interruptedTurnCleanup = tracked; + } + + // Terminate each process the interrupted turn started, by its own id, once each. + // `thread/backgroundTerminals/clean` is never used: it ends every terminal on the thread, + // an earlier turn's included. A process already gone answers `terminated: false`. Never + // rejects; failures are logged. + private async endInterruptedTurnProcesses( + client: CodexAppServerClient, + threadId: string | null, + turnId: string, + ): Promise { + if (!threadId) return; + const record = this.getTurnTerminalRecord(turnId); + record.interruptAccepted = true; + const processIds = [...record.processIds].filter( + (processId) => !record.terminatedProcessIds.has(processId), + ); + for (const processId of processIds) record.terminatedProcessIds.add(processId); + const logContext = { agentId: this.agentId, provider: CODEX_PROVIDER, threadId, turnId }; + await Promise.all( + processIds.map(async (processId) => { + try { + const response = await client.request( + "thread/backgroundTerminals/terminate", + { threadId, processId }, + INTERRUPTED_TURN_TERMINATE_TIMEOUT_MS, + ); + this.logger.info( + { ...logContext, processId, terminated: toObjectRecord(response)?.terminated ?? null }, + "provider.codex.interrupt.terminal_terminated", + ); + } catch (error) { + this.logger.warn( + { ...logContext, processId, err: error }, + "provider.codex.interrupt.terminal_terminate_failed", + ); + } + }), + ); + } + + // A new turn starts only after every pending sweep of interrupted turns' commands has + // ended, or after the bound, whichever is first. + private async waitForInterruptedTurnCleanup(): Promise { + if (!this.interruptedTurnCleanup) return; + let timer: ReturnType | undefined; + const timedOut = await Promise.race([ + (async () => { + while (this.interruptedTurnCleanup) await this.interruptedTurnCleanup; + return false; + })(), + new Promise((resolve) => { + timer = setTimeout(() => resolve(true), INTERRUPTED_TURN_CLEANUP_WAIT_MS); + }), + ]); + clearTimeout(timer); + if (timedOut) { + this.logger.warn( + { + agentId: this.agentId, + provider: CODEX_PROVIDER, + waitMs: INTERRUPTED_TURN_CLEANUP_WAIT_MS, + }, + "provider.codex.interrupt.terminal_cleanup_timed_out", + ); + } + } + async close(): Promise { this.closed = true; this.clearPendingPermissions(); @@ -6209,6 +6372,11 @@ export class CodexAppServerAgentSession implements AgentSession { return; } this.completePendingRootCompactions(); + // What a turn that was not interrupted leaves running, it left on purpose; no later + // interrupt may end it. + if (parsed.status !== "interrupted" && this.currentTurnId) { + this.terminalProcessIdsByTurnId.delete(this.currentTurnId); + } if (parsed.status === "failed") { this.emitEvent({ type: "turn_failed", @@ -6613,6 +6781,7 @@ export class CodexAppServerAgentSession implements AgentSession { private async handleItemCompletedNotification( parsed: Extract, ): Promise { + this.recordTurnTerminalProcess(parsed); // Codex emits mirrored lifecycle notifications via both `codex/event/item_*` // and canonical `item/*`. Render ordinary items only from the canonical // channel, but accept a legacy-only child announcement so it can establish @@ -6775,6 +6944,7 @@ export class CodexAppServerAgentSession implements AgentSession { private async handleItemStartedNotification( parsed: Extract, ): Promise { + this.recordTurnTerminalProcess(parsed); if (shouldIgnoreMirroredLifecycleItem(parsed.source, parsed.item)) { return; } diff --git a/packages/server/src/server/agent/providers/codex-interrupted-turn-terminals.test.ts b/packages/server/src/server/agent/providers/codex-interrupted-turn-terminals.test.ts new file mode 100644 index 0000000000..3d2ea64f9a --- /dev/null +++ b/packages/server/src/server/agent/providers/codex-interrupted-turn-terminals.test.ts @@ -0,0 +1,374 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; + +import type { AgentSession } from "../agent-sdk-types.js"; +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { CodexAppServerAgentSession } from "./codex-app-server-agent.js"; +import { + createFakeCodexAppServer, + type FakeCodexAppServer, + waitForNextEvent, + waitForTimelineToolCall, +} from "./codex/test-utils/fake-app-server.js"; + +const THREAD = "thread-1"; +const TERMINATE = "thread/backgroundTerminals/terminate"; + +type Handler = (params: unknown) => unknown; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +function paramsOf(value: unknown): Record { + return value && typeof value === "object" ? (value as Record) : {}; +} + +let sessions: AgentSession[] = []; + +afterEach(async () => { + for (const session of sessions) await session.close().catch(() => undefined); + sessions = []; +}); + +/** + * A Codex app-server whose interrupt ends the turn the way Codex does: an `interrupted` + * turn completion, then the response. `terminate` answers per process. + */ +function createHarness( + options: { + terminate?: (processId: string) => unknown; + interruptResponse?: (completeTurn: () => void) => unknown; + } = {}, +) { + const handlers: Record = { + "turn/interrupt": () => { + const completeTurn = () => + appServer.completeTurn({ threadId: THREAD, status: "interrupted" }); + if (options.interruptResponse) return options.interruptResponse(completeTurn); + completeTurn(); + return {}; + }, + [TERMINATE]: (params) => + options.terminate?.(String(paramsOf(params).processId)) ?? { terminated: true }, + }; + const appServer: FakeCodexAppServer = createFakeCodexAppServer(handlers); + const session = new CodexAppServerAgentSession( + { + provider: "codex", + cwd: "/workspace/project", + modeId: "auto", + model: "gpt-5.4", + }, + null, + createTestLogger(), + async () => appServer.child, + ); + sessions.push(session); + + function requests(method: string): Record[] { + return appServer + .requests() + .filter((message) => message.method === method && "id" in message) + .map((message) => paramsOf(message.params)); + } + + function terminated(): string[] { + return requests(TERMINATE) + .map((params) => String(params.processId)) + .sort(); + } + + // Starts a turn and lets Codex identify it as `nativeTurnId`. + async function startTurn(prompt: string, nativeTurnId: string): Promise { + const before = requests("turn/start").length; + const started = session.startTurn(prompt); + await vi.waitFor(() => expect(requests("turn/start")).toHaveLength(before + 1), { + timeout: 5_000, + }); + const turnStarted = waitForNextEvent(session, "turn_started"); + appServer.startsTurn({ threadId: THREAD, turnId: nativeTurnId }); + await started; + await turnStarted; + } + + async function runsCommand(params: { turnId: string; processId: string }): Promise { + const itemId = `exec-${params.processId}`; + const shown = waitForTimelineToolCall(session, itemId); + appServer.startsTerminalCommand({ + threadId: THREAD, + turnId: params.turnId, + itemId, + processId: params.processId, + command: `sleep ${params.processId}`, + }); + await shown; + } + + async function completesTurn(): Promise { + const completed = waitForNextEvent(session, "turn_completed"); + appServer.completeTurn({ threadId: THREAD }); + await completed; + } + + // Interrupts the running turn and waits for the session to report it canceled, as a + // caller does before it starts the next turn: Codex notifications are handled in order + // but asynchronously, so the interrupt's answer can arrive before the turn has ended. + async function interrupts(): Promise { + const canceled = waitForNextEvent(session, "turn_canceled"); + await session.interrupt(); + await canceled; + } + + return { + appServer, + session, + requests, + terminated, + startTurn, + runsCommand, + completesTurn, + interrupts, + }; +} + +// Turn A leaves a server running on purpose and completes. Turn B starts two +// commands, and a subagent on its own thread starts a third, then B is interrupted. +async function interruptTurnB(harness: ReturnType): Promise { + await harness.startTurn("start a server", "native-A"); + await harness.runsCommand({ turnId: "native-A", processId: "101" }); + await harness.completesTurn(); + + await harness.startTurn("run two commands", "native-B"); + await harness.runsCommand({ turnId: "native-B", processId: "201" }); + // A subagent thread's command shows no row here; the next command's row shows it + // was read, because notifications are read in order. + harness.appServer.startsTerminalCommand({ + threadId: "child-thread", + turnId: "native-B", + itemId: "exec-209", + processId: "209", + command: "sleep 209", + }); + await harness.runsCommand({ turnId: "native-B", processId: "202" }); + await harness.interrupts(); +} + +describe("an interrupted Codex turn's commands end before the next turn", () => { + test("terminates exactly the processes the interrupted turn started, by id, and never cleans", async () => { + const harness = createHarness(); + await interruptTurnB(harness); + await harness.startTurn("correction", "native-C"); + + expect(harness.terminated()).toEqual(["201", "202"]); + expect(harness.requests(TERMINATE).every((params) => params.threadId === THREAD)).toBe(true); + // `clean` would end every terminal on the thread, an earlier turn's included. + expect( + harness.appServer + .requests() + .filter((message) => String(message.method).startsWith("thread/backgroundTerminals/")) + .map((message) => message.method), + ).toEqual([TERMINATE, TERMINATE]); + harness.appServer.assertNoErrors(); + }); + + test("leaves the next turn's commands running when Codex starts that turn before acknowledging the interrupt", async () => { + const acknowledge = deferred(); + const harness = createHarness({ + // Codex ends B, starts a turn of its own (a goal continuation), and only then + // answers the interrupt. + interruptResponse: (completeTurn) => { + completeTurn(); + return acknowledge.promise.then(() => ({})); + }, + }); + await harness.startTurn("run two commands", "native-B"); + await harness.runsCommand({ turnId: "native-B", processId: "201" }); + await harness.runsCommand({ turnId: "native-B", processId: "202" }); + const interrupting = harness.session.interrupt(); + await vi.waitFor(() => expect(harness.requests("turn/interrupt")).toHaveLength(1)); + const autonomousTurn = waitForNextEvent(harness.session, "turn_started"); + harness.appServer.startsTurn({ threadId: THREAD, turnId: "native-C" }); + await autonomousTurn; + await harness.runsCommand({ turnId: "native-C", processId: "301" }); + + acknowledge.resolve(); + await interrupting; + await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"])); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(harness.terminated()).toEqual(["201", "202"]); + }); + + test("sends the next turn/start only after the terminates are answered", async () => { + const answer = deferred(); + const harness = createHarness({ + terminate: () => answer.promise.then(() => ({ terminated: true })), + }); + await interruptTurnB(harness); + const next = harness.startTurn("correction", "native-C"); + await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"])); + await new Promise((resolve) => setTimeout(resolve, 200)); + expect(harness.requests("turn/start")).toHaveLength(2); + + answer.resolve(); + await next; + expect(harness.requests("turn/start")).toHaveLength(3); + }); + + test.each(["initial", "late"] as const)( + "tracks both cleanup sweeps when the %s answer arrives first", + async (answeredFirst) => { + const first = deferred(); + const second = deferred(); + const harness = createHarness({ + terminate: (processId) => + (processId === "201" ? first : second).promise.then(() => ({ terminated: true })), + }); + await harness.startTurn("run commands", "native-B"); + await harness.runsCommand({ turnId: "native-B", processId: "201" }); + await harness.interrupts(); + await vi.waitFor(() => expect(harness.terminated()).toEqual(["201"])); + const next = harness.startTurn("correction", "native-C"); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(harness.requests("turn/start")).toHaveLength(1); + // The next turn is already waiting when another process is announced. + harness.appServer.startsTerminalCommand({ + threadId: THREAD, + turnId: "native-B", + itemId: "late-202", + processId: "202", + command: "sleep 202", + }); + harness.appServer.startsTerminalCommand({ + threadId: THREAD, + turnId: "native-B", + itemId: "duplicate-202", + processId: "202", + command: "sleep 202", + }); + await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"])); + (answeredFirst === "initial" ? first : second).resolve(); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(harness.requests("turn/start")).toHaveLength(1); + (answeredFirst === "initial" ? second : first).resolve(); + await next; + expect(harness.terminated()).toEqual(["201", "202"]); + // Accepted state survives the completed sweep and a newer foreground turn. + harness.appServer.startsTerminalCommand({ + threadId: THREAD, + turnId: "native-B", + itemId: "late-203", + processId: "203", + command: "sleep 203", + }); + await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202", "203"])); + harness.appServer.assertNoErrors(); + }, + ); + + test("keeps the first sweep pending when a late sweep answers before the next start", async () => { + const first = deferred(); + const harness = createHarness({ + terminate: (processId) => + processId === "201" + ? first.promise.then(() => ({ terminated: true })) + : { terminated: true }, + }); + await harness.startTurn("run commands", "native-B"); + await harness.runsCommand({ turnId: "native-B", processId: "201" }); + await harness.interrupts(); + harness.appServer.startsTerminalCommand({ + threadId: THREAD, + turnId: "native-B", + itemId: "late-202", + processId: "202", + command: "sleep 202", + }); + await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"])); + const next = harness.startTurn("correction", "native-C"); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(harness.requests("turn/start")).toHaveLength(1); + first.resolve(); + await next; + harness.appServer.assertNoErrors(); + }); + + test("forgets completed commands but retains failed commands at interrupt", async () => { + const harness = createHarness(); + await harness.startTurn("run commands", "native-B"); + for (const processId of ["201", "202"]) { + await harness.runsCommand({ turnId: "native-B", processId }); + harness.appServer.child.stdout.write( + JSON.stringify({ + method: "item/completed", + params: { + threadId: THREAD, + turnId: "native-B", + item: { + id: `exec-${processId}`, + type: "commandExecution", + processId, + command: `sleep ${processId}`, + status: processId === "201" ? "completed" : "failed", + exitCode: processId === "201" ? 0 : null, + }, + }, + }) + "\n", + ); + } + // A following command's row is a barrier for both completion handlers. + await harness.runsCommand({ turnId: "native-B", processId: "203" }); + await harness.interrupts(); + await harness.startTurn("correction", "native-C"); + expect(harness.terminated()).toEqual(["202", "203"]); + harness.appServer.assertNoErrors(); + }); + + test("retains accepted interrupts for only the latest sixteen turns", async () => { + const harness = createHarness(); + for (let index = 0; index < 17; index++) { + await harness.startTurn("run a command", `native-${index}`); + await harness.interrupts(); + } + await harness.startTurn("correction", "native-current"); + harness.appServer.startsTerminalCommand({ + threadId: THREAD, + turnId: "native-0", + itemId: "evicted-201", + processId: "201", + command: "sleep 201", + }); + harness.appServer.startsTerminalCommand({ + threadId: THREAD, + turnId: "native-16", + itemId: "retained-202", + processId: "202", + command: "sleep 202", + }); + await harness.runsCommand({ turnId: "native-current", processId: "301" }); + expect(harness.terminated()).toEqual(["202"]); + harness.appServer.assertNoErrors(); + }); + + test("a failing or unanswered terminate does not block or fail the next turn", async () => { + const harness = createHarness({ + // 201 is refused while the next turn waits; 202 is never answered. + terminate: (processId) => + processId === "201" + ? new Promise((resolve) => setTimeout(resolve, 300)).then(() => ({ + __jsonRpcError: { code: -32600, message: "invalid background terminal process id" }, + })) + : new Promise(() => {}), + }); + await interruptTurnB(harness); + const startedAt = Date.now(); + await harness.startTurn("correction", "native-C"); + + expect(Date.now() - startedAt).toBeLessThan(5_000); + expect(harness.requests("turn/start")).toHaveLength(3); + expect(harness.terminated()).toEqual(["201", "202"]); + }, 15_000); +}); From dbbcb103ba0ab06b0ac2f3a39d7b1c92920816be Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 23:50:55 +0000 Subject: [PATCH 40/58] fix(opencode): preserve denial reasons when a message supersedes approval (#8477) Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../providers/opencode/v2/permissions.test.ts | 96 +++++++++++++++++++ .../providers/opencode/v2/permissions.ts | 3 + .../agent/providers/opencode/v2/session.ts | 6 +- 3 files changed, 104 insertions(+), 1 deletion(-) diff --git a/packages/server/src/server/agent/providers/opencode/v2/permissions.test.ts b/packages/server/src/server/agent/providers/opencode/v2/permissions.test.ts index 69da960c5c..2bf197c88d 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/permissions.test.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/permissions.test.ts @@ -4,6 +4,102 @@ import { createTestLogger } from "../../../../../test-utils/test-logger.js"; import type { V2Api } from "./api.js"; import { OpenCodeV2AgentClient } from "./agent.js"; import { V2Harness } from "../test-utils/v2-harness.js"; +import type { AgentPermissionResponse } from "../../../agent-sdk-types.js"; + +describe("OpenCode v2 denial reasons", () => { + test.each<{ + response: AgentPermissionResponse; + decision: "reject" | "once" | "always"; + message?: string; + }>([ + { + response: { behavior: "deny", message: "Use the existing file instead." }, + decision: "reject", + message: "Use the existing file instead.", + }, + { response: { behavior: "deny" }, decision: "reject" }, + { response: { behavior: "allow" }, decision: "once" }, + { response: { behavior: "allow", selectedActionId: "always" }, decision: "always" }, + ])("preserves a permission response: $response", async ({ response, decision, message }) => { + const harness = new V2Harness(); + let pending = true; + harness.api.permission.list = async () => + pending + ? [{ id: "approval", sessionID: "session", action: "shell", resources: ["pwd"] }] + : []; + const replies: Parameters[0][] = []; + harness.api.permission.reply = async (input) => { + replies.push(input); + pending = false; + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + try { + await session.respondToPermission("approval", response); + expect(replies).toEqual([ + { + sessionID: "session", + requestID: "approval", + decision, + ...(message === undefined ? {} : { message }), + }, + ]); + expect(session.getPendingPermissions()).toEqual([]); + } finally { + await session.close(); + } + }); + + test.each([true, false])( + "explains cards superseded by a steer when clearing is %s", + async (clearPendingPermissions) => { + const harness = new V2Harness(); + harness.autoComplete = false; + let pending = true; + harness.api.permission.list = async () => + pending + ? [{ id: "approval", sessionID: "session", action: "shell", resources: ["pwd"] }] + : []; + const replies: Parameters[0][] = []; + harness.api.permission.reply = async (input) => { + replies.push(input); + pending = false; + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + try { + const { turnId } = await session.startTurn("Run pwd"); + await expect.poll(() => harness.prompts).toEqual(["Run pwd"]); + await expect( + session.steerActiveTurn!("go ahead", { expectedTurnId: turnId, clearPendingPermissions }), + ).resolves.toEqual({ status: "accepted" }); + expect(harness.prompts).toEqual(["Run pwd", "go ahead"]); + expect(replies).toEqual( + clearPendingPermissions + ? [ + { + sessionID: "session", + requestID: "approval", + decision: "reject", + message: + "The user answered with a message instead of approving. Their message follows.", + }, + ] + : [], + ); + expect(session.getPendingPermissions()).toHaveLength(clearPendingPermissions ? 0 : 1); + } finally { + await session.close(); + } + }, + ); +}); describe("OpenCode v2 questions", () => { test("maps selected labels to native values and parses numeric and boolean answers", async () => { diff --git a/packages/server/src/server/agent/providers/opencode/v2/permissions.ts b/packages/server/src/server/agent/providers/opencode/v2/permissions.ts index cb33e0652e..ec77052cb5 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/permissions.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/permissions.ts @@ -68,6 +68,9 @@ export class SessionPermissions { sessionID: this.permissionOwners.get(requestId) ?? this.id, requestID: requestId, decision: permissionReply(response), + ...(response.behavior === "deny" && response.message !== undefined + ? { message: response.message } + : {}), }); } this.resolvePending(requestId, response); diff --git a/packages/server/src/server/agent/providers/opencode/v2/session.ts b/packages/server/src/server/agent/providers/opencode/v2/session.ts index a167ec0713..7d03e9baaa 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/session.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/session.ts @@ -100,7 +100,11 @@ export class OpenCodeV2Session implements AgentSession { ), clearPermissions: async () => { for (const request of this.permissions.list()) - await this.permissions.respondToPermission(request.id, { behavior: "deny" }); + await this.permissions.respondToPermission(request.id, { + behavior: "deny", + message: + "The user answered with a message instead of approving. Their message follows.", + }); }, }); this.usage = new SessionUsage({ From 7efa14238d0d95e814fc68b82584ca1deab1a456 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 01:07:00 +0000 Subject: [PATCH 41/58] fix(claude): report API-error turns as failed (#8480) * fix(claude): fail a turn that Claude Code ends on an API error Claude Code reports a turn that stopped on an API error (a session or usage limit, an invalid key, an overloaded request) as a `success` result with `is_error: true`, the error text in `result`, after a synthetic assistant reply carrying the same text and an `error` field. The provider read only the subtype, so the turn completed with the error as its answer: the agent went idle with no lastError and showed as finished in the sidebar. Treat such a result as a failed turn with the result text as its error. Hold the synthetic error reply back until the result arrives so the failure row does not repeat it; a reply the turn moves past, or a result without is_error, still shows it. Co-authored-by: RoundRedDot * fix(claude): retain final usage when an API-error turn fails Publish the converted result totals before the failure event so the existing usage handler preserves work done earlier in the turn. --------- Co-authored-by: luke Co-authored-by: RoundRedDot Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../claude/agent.error-result.test.ts | 190 ++++++++++++++++++ .../server/agent/providers/claude/agent.ts | 54 ++++- 2 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 packages/server/src/server/agent/providers/claude/agent.error-result.test.ts diff --git a/packages/server/src/server/agent/providers/claude/agent.error-result.test.ts b/packages/server/src/server/agent/providers/claude/agent.error-result.test.ts new file mode 100644 index 0000000000..9bbaa49186 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/agent.error-result.test.ts @@ -0,0 +1,190 @@ +import { expect, test } from "vitest"; +import { createTestLogger } from "../../../../test-utils/test-logger.js"; +import { collectSessionTurnEvents } from "../test-utils/session-stream-adapter.js"; +import { ClaudeAgentClient } from "./agent.js"; +import type { ClaudeQueryFactory } from "./query.js"; + +const LIMIT = "You've hit your session limit · resets 5:20pm (Asia/Singapore)"; + +interface ScriptedTurn { + // An API error reply as Claude Code writes it: a synthetic assistant message with `error`. + errorReply?: string; + // Assistant text the model wrote before or after it. + before?: string; + after?: string; + isError: boolean; + usage?: { + inputTokens: number; + cachedInputTokens: number; + outputTokens: number; + totalCostUsd: number; + }; +} + +// A query dependency that speaks Claude Code's stream interface, one scripted turn per prompt. +function scriptedQuery(turns: ScriptedTurn[]): ClaudeQueryFactory { + return ({ prompt }) => { + let index = 0; + const assistant = (id: string, text: string, error?: string) => ({ + type: "assistant", + session_id: "error-session", + parent_tool_use_id: null, + uuid: id, + message: { + id, + role: "assistant", + model: error ? "" : "claude-opus-5-5", + content: [{ type: "text", text }], + stop_reason: "stop_sequence", + usage: { input_tokens: 0, output_tokens: 0 }, + }, + ...(error ? { error } : {}), + }); + const messages = (async function* () { + if (typeof prompt === "string") throw new Error("Expected streaming input"); + for await (const _message of prompt) { + const turn = turns[index]; + index += 1; + yield { + type: "system", + subtype: "init", + session_id: "error-session", + permissionMode: "default", + model: "claude-opus-5-5", + }; + if (turn.before) yield assistant(`before-${index}`, turn.before); + if (turn.errorReply) yield assistant(`error-${index}`, turn.errorReply, "rate_limit"); + if (turn.after) yield assistant(`after-${index}`, turn.after); + yield { + type: "result", + subtype: "success", + session_id: "error-session", + duration_ms: 1, + duration_api_ms: 0, + is_error: turn.isError, + num_turns: 1, + result: turn.isError ? (turn.errorReply ?? "") : (turn.after ?? turn.errorReply ?? ""), + stop_reason: "stop_sequence", + total_cost_usd: turn.usage?.totalCostUsd ?? 0, + usage: { + input_tokens: turn.usage?.inputTokens ?? 0, + output_tokens: turn.usage?.outputTokens ?? 0, + cache_read_input_tokens: turn.usage?.cachedInputTokens ?? 0, + }, + modelUsage: {}, + permission_denials: [], + uuid: `result-${index}`, + }; + } + })(); + return Object.assign(messages, { + close: () => { + void messages.return(); + }, + interrupt: async () => undefined, + setModel: async () => undefined, + setPermissionMode: async () => undefined, + supportedModels: async () => [], + supportedCommands: async () => [], + }) as ReturnType; + }; +} + +async function runTurns(turns: ScriptedTurn[]) { + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + queryFactory: scriptedQuery(turns), + resolveBinary: async () => process.execPath, + }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + }); + try { + const results = []; + for (const [index] of turns.entries()) { + results.push(await collectSessionTurnEvents(session, `turn ${index + 1}`)); + } + return results; + } finally { + await session.close(); + } +} + +function assistantTexts(events: Awaited>[number]): string[] { + return events.flatMap((event) => + event.type === "timeline" && event.item.type === "assistant_message" ? [event.item.text] : [], + ); +} + +function terminal(events: Awaited>[number]) { + return events.find( + (event) => + event.type === "turn_completed" || + event.type === "turn_failed" || + event.type === "turn_canceled", + ); +} + +test("a turn Claude Code ends on an API error fails with the error, shown once", async () => { + const [limited, overloaded, next] = await runTurns([ + { errorReply: LIMIT, isError: true }, + { + before: "Checking the deploy doc.", + errorReply: "API Error: 529 Overloaded", + isError: true, + }, + { after: "Done.", isError: false }, + ]); + + expect(terminal(limited)).toMatchObject({ + type: "turn_failed", + error: LIMIT, + }); + expect(assistantTexts(limited)).toEqual([]); + + expect(terminal(overloaded)).toMatchObject({ + type: "turn_failed", + error: "API Error: 529 Overloaded", + }); + expect(assistantTexts(overloaded)).toEqual(["Checking the deploy doc."]); + + // The session keeps working after a failed turn. + expect(terminal(next)).toMatchObject({ type: "turn_completed" }); + expect(assistantTexts(next)).toEqual(["Done."]); +}); + +test("a failed turn retains its final token counts and cost", async () => { + const usage = { + inputTokens: 120, + cachedInputTokens: 80, + outputTokens: 30, + totalCostUsd: 0.012, + }; + const [failed] = await runTurns([ + { before: "Checking the deploy doc.", errorReply: LIMIT, isError: true, usage }, + ]); + + expect(failed).toContainEqual( + expect.objectContaining({ type: "usage_updated", usage: expect.objectContaining(usage) }), + ); + expect(terminal(failed)).toMatchObject({ type: "turn_failed", error: LIMIT }); + expect(assistantTexts(failed)).toEqual(["Checking the deploy doc."]); +}); + +test("an error reply the turn moves past is still shown", async () => { + const [recovered, completed] = await runTurns([ + { + errorReply: "API Error: 529 Overloaded", + after: "Recovered.", + isError: false, + }, + { errorReply: LIMIT, isError: false }, + ]); + + expect(terminal(recovered)).toMatchObject({ type: "turn_completed" }); + expect(assistantTexts(recovered)).toEqual(["API Error: 529 Overloaded", "Recovered."]); + + expect(terminal(completed)).toMatchObject({ type: "turn_completed" }); + expect(assistantTexts(completed)).toEqual([LIMIT]); +}); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 9895f433a8..749bbe016f 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -2389,6 +2389,12 @@ class ClaudeAgentSession implements AgentSession { private mainTurnInFlight = false; private foregroundHasVisibleActivity = false; private activeTurnHasAssistantText = false; + /** + * The reply Claude Code writes when a turn stops on an API error ("You've hit your session + * limit", "API Error: 529 ..."), held back until the result says whether the turn failed: a + * failed turn shows the same text as its error, so showing both would repeat it. + */ + private withheldErrorReply: AgentTimelineItem[] = []; private readonly contextUsage: ClaudeContextUsageState; private userMessageIds: string[] = []; private readonly emittedUserMessageIds = new Set(); @@ -2531,6 +2537,7 @@ class ClaudeAgentSession implements AgentSession { this.activeForegroundTurnId = turnId; this.foregroundHasVisibleActivity = false; this.activeTurnHasAssistantText = false; + this.withheldErrorReply = []; this.contextUsage.beginTurn(); this.transitionTurnState("foreground", "foreground turn started"); this.clearRecentStderr(); @@ -4419,6 +4426,21 @@ class ClaudeAgentSession implements AgentSession { }) satisfies AgentStreamEvent, ); + if (!readClaudeParentToolUseId(message)) { + if (message.type === "assistant" && message.error) { + this.withheldErrorReply.push(...assistantTimelineEvents.map((event) => event.item)); + return messageEvents; + } + if ( + message.type === "assistant" || + message.type === "user" || + message.type === "stream_event" + ) { + const released: AgentStreamEvent[] = []; + this.releaseWithheldErrorReply(released); + return [...released, ...messageEvents, ...assistantTimelineEvents]; + } + } return [...messageEvents, ...assistantTimelineEvents]; } @@ -4966,6 +4988,23 @@ class ClaudeAgentSession implements AgentSession { events: AgentStreamEvent[], ): void { const usage = this.convertUsage(message); + // Claude Code reports a turn that stopped on an API error (a usage limit, an overloaded or + // rejected request) as `success` with `is_error`, carrying the error text in `result`. + if (message.subtype === "success" && message.is_error) { + const withheld = this.withheldErrorReply; + this.withheldErrorReply = []; + const resultText = typeof message.result === "string" ? message.result.trim() : ""; + const withheldText = withheld + .map((item) => (item.type === "assistant_message" ? item.text : "")) + .join("\n") + .trim(); + if (usage) events.push({ type: "usage_updated", provider: "claude", usage }); + events.push(...this.sidechainTracker.finishAll("failed")); + events.push(this.buildTurnFailedEvent(resultText || withheldText || "Claude run failed")); + return; + } + const releasedErrorReply = this.withheldErrorReply.length > 0; + this.releaseWithheldErrorReply(events); if (message.subtype === "success") { events.push(...this.sidechainTracker.finishAll("completed")); // Built-in slash commands (e.g. /voice, /usage, "Unknown command: …") @@ -4975,7 +5014,12 @@ class ClaudeAgentSession implements AgentSession { // accounting from provider gateways does not duplicate streamed output. const resultText = typeof message.result === "string" ? message.result.trim() : ""; const outputTokens = message.usage?.output_tokens; - if (resultText.length > 0 && outputTokens === 0 && !this.activeTurnHasAssistantText) { + if ( + resultText.length > 0 && + outputTokens === 0 && + !this.activeTurnHasAssistantText && + !releasedErrorReply + ) { events.push({ type: "timeline", provider: "claude", @@ -4997,6 +5041,14 @@ class ClaudeAgentSession implements AgentSession { events.push(this.buildTurnFailedEvent(errorMessage)); } + private releaseWithheldErrorReply(events: AgentStreamEvent[]): void { + if (this.withheldErrorReply.length === 0) return; + for (const item of this.withheldErrorReply) { + events.push({ type: "timeline", item, provider: "claude" }); + } + this.withheldErrorReply = []; + } + private createClaudeSessionChangedNotice( oldSessionId: string, newSessionId: string, From efc64a5443dfe166dfb0d80434ecb48516b8fbe2 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 02:08:31 +0000 Subject: [PATCH 42/58] Explain the mode required to create agents on another provider (#8483) * fix(server): say when create_agent needs an explicit mode * test(server): verify cross-provider mode discovery through the daemon Exercise the MCP contract, refusal without a child, provider inspection, and explicit-mode creation through the public daemon interfaces. Qualify unattended advice because targets without an unattended mode still require an explicit choice. Co-authored-by: Noah * test(server): run mode discovery in the integration suite The unit command excludes E2E files, and CI integration uses an explicit list. Add the cross-provider MCP regression to that existing list so CI exercises its guidance, refusal, discovery, and retry assertions. --------- Co-authored-by: Noah Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- packages/server/package.json | 2 +- .../server/agent/create-agent-mode.test.ts | 8 +-- .../src/server/agent/create-agent-mode.ts | 2 +- .../src/server/agent/tools/paseo-tools.ts | 2 +- .../daemon-e2e/create-agent-mode.e2e.test.ts | 67 +++++++++++++++++++ 5 files changed, 74 insertions(+), 7 deletions(-) create mode 100644 packages/server/src/server/daemon-e2e/create-agent-mode.e2e.test.ts diff --git a/packages/server/package.json b/packages/server/package.json index 191aee2945..261b20775f 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -89,7 +89,7 @@ "speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts", "test": "npm run test:unit && npm run test:integration", "test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"", - "test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/creation-status.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/usage-sources.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"", + "test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/create-agent-mode.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/creation-status.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/usage-sources.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"", "test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts", "test:integration:all": "npm run test:e2e", "test:integration:real": "vitest run real.e2e.test.ts", diff --git a/packages/server/src/server/agent/create-agent-mode.test.ts b/packages/server/src/server/agent/create-agent-mode.test.ts index dd7d83d8a4..1662ec4c25 100644 --- a/packages/server/src/server/agent/create-agent-mode.test.ts +++ b/packages/server/src/server/agent/create-agent-mode.test.ts @@ -78,7 +78,7 @@ describe("resolveAndValidateCreateAgentMode", () => { availableModes: OPENCODE_MODES, }), ).toThrow( - "cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'opencode'). Pass an explicit mode. Available modes for 'opencode': build, plan", + "cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'opencode'). Pass an explicit mode in settings.modeId. Available modes for 'opencode': build, plan", ); }); @@ -92,7 +92,7 @@ describe("resolveAndValidateCreateAgentMode", () => { availableModes: CODEX_MODES, }), ).toThrow( - "cannot inherit mode '' from caller (provider 'opencode') for new agent (provider 'codex'). Pass an explicit mode. Available modes for 'codex': auto, full-access", + "cannot inherit mode '' from caller (provider 'opencode') for new agent (provider 'codex'). Pass an explicit mode in settings.modeId. Available modes for 'codex': auto, full-access", ); }); @@ -180,7 +180,7 @@ describe("resolveAndValidateCreateAgentMode", () => { targetUnattendedMode: "full-access", }), ).toThrow( - "cannot inherit mode 'default' from caller (provider 'claude') for new agent (provider 'codex'). Pass an explicit mode. Available modes for 'codex': auto, full-access", + "cannot inherit mode 'default' from caller (provider 'claude') for new agent (provider 'codex'). Pass an explicit mode in settings.modeId. Available modes for 'codex': auto, full-access", ); }); @@ -195,7 +195,7 @@ describe("resolveAndValidateCreateAgentMode", () => { targetUnattendedMode: undefined, }), ).toThrow( - "cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'zai-custom'). Pass an explicit mode. Available modes for 'zai-custom': unknown", + "cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'zai-custom'). Pass an explicit mode in settings.modeId. Available modes for 'zai-custom': unknown", ); }); diff --git a/packages/server/src/server/agent/create-agent-mode.ts b/packages/server/src/server/agent/create-agent-mode.ts index 973642e475..048da2814d 100644 --- a/packages/server/src/server/agent/create-agent-mode.ts +++ b/packages/server/src/server/agent/create-agent-mode.ts @@ -77,7 +77,7 @@ export function resolveAndValidateCreateAgentMode( } throw new Error( - `cannot inherit mode '${formatCreateConfigParentMode(parent)}' from ${formatCreateConfigParentSource(parent)} for new agent (provider '${targetProvider}'). Pass an explicit mode. Available modes for '${targetProvider}': ${listModes(availableModes)}`, + `cannot inherit mode '${formatCreateConfigParentMode(parent)}' from ${formatCreateConfigParentSource(parent)} for new agent (provider '${targetProvider}'). Pass an explicit mode in settings.modeId. Available modes for '${targetProvider}': ${listModes(availableModes)}`, ); } diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index 0ca6c5a3e4..a5569685e2 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -1590,7 +1590,7 @@ function createToolCatalog( { title: "Create agent", description: - "Create an agent. Agent-scoped creation defaults to your workspace and creates your subagent. Top-level creation without workspaceId creates a new local workspace. Requires provider/model (for example codex/gpt-5.4) and an initial prompt. Do not guess; call list_providers and list_models first if uncertain.", + "Create an agent. Agent-scoped creation defaults to your workspace and creates your subagent. Top-level creation without workspaceId creates a new local workspace. Requires provider/model (for example codex/gpt-5.4) and an initial prompt. For a different provider, set settings.modeId from inspect_provider; omission is accepted for targets with no modes and for unattended execution supported by the target. Do not guess; call list_providers and list_models first if uncertain.", inputSchema: createAgentInputSchema, outputSchema: { agentId: z.string(), diff --git a/packages/server/src/server/daemon-e2e/create-agent-mode.e2e.test.ts b/packages/server/src/server/daemon-e2e/create-agent-mode.e2e.test.ts new file mode 100644 index 0000000000..e60645c2bf --- /dev/null +++ b/packages/server/src/server/daemon-e2e/create-agent-mode.e2e.test.ts @@ -0,0 +1,67 @@ +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import path from "node:path"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { expect, test } from "vitest"; +import { createDaemonTestContext } from "../test-utils/index.js"; + +test("an attended MCP caller can discover and supply a cross-provider child mode", async () => { + const artifacts = path.join(process.cwd(), ".artifacts"); + await mkdir(artifacts, { recursive: true }); + const root = await mkdtemp(path.join(artifacts, "create-agent-mode-")); + const ctx = await createDaemonTestContext({ paseoHomeRoot: root, mcpEnabled: true }); + const mcp = new Client({ name: "mode-contract-test", version: "1" }); + try { + const parent = await ctx.client.createAgent({ + provider: "claude", + cwd: root, + modeId: "default", + title: "Attended parent", + }); + await mcp.connect( + new StreamableHTTPClientTransport( + new URL(`http://127.0.0.1:${ctx.daemon.port}/mcp/agents?callerAgentId=${parent.id}`), + ), + ); + const { tools } = await mcp.listTools(); + const create = tools.find((tool) => tool.name === "create_agent"); + expect.soft(create?.description).toContain("settings.modeId"); + + const args = { + provider: "codex/gpt-5.4-mini", + title: "Cross-provider child", + initialPrompt: "Reply with OK", + }; + const refused = await mcp.callTool({ name: "create_agent", arguments: args }); + expect(refused.isError).toBe(true); + expect.soft(refused.content).toEqual([ + expect.objectContaining({ + type: "text", + text: expect.stringContaining("Pass an explicit mode in settings.modeId"), + }), + ]); + expect((await ctx.client.fetchAgents({ subscribe: {} })).entries).toHaveLength(1); + + const inspected = await mcp.callTool({ + name: "inspect_provider", + arguments: { provider: "codex" }, + }); + expect(inspected.isError).not.toBe(true); + expect(inspected.structuredContent).toMatchObject({ + modes: expect.arrayContaining([expect.objectContaining({ id: "auto" })]), + }); + const created = await mcp.callTool({ + name: "create_agent", + arguments: { ...args, settings: { modeId: "auto" } }, + }); + expect(created.isError).not.toBe(true); + expect(created.structuredContent).toMatchObject({ type: "codex", currentModeId: "auto" }); + const childId = (created.structuredContent as { agentId: string }).agentId; + expect((await ctx.client.fetchAgent(childId))?.agent.currentModeId).toBe("auto"); + expect((await ctx.client.waitForFinish(childId, 5000)).status).toBe("idle"); + } finally { + await mcp.close(); + await ctx.cleanup(); + await rm(root, { recursive: true, force: true }); + } +}, 30_000); From a166a3317a8fef67adc475e1379249c57bafc173 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 03:01:36 +0000 Subject: [PATCH 43/58] test(terminal): check original processes after protocol cleanup (#8502) Startup launches another worker after closing obsolete protocols, so a reused numeric PID does not prove the original process survived. Check the recorded birth identities and require cleanup to be confirmed. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../terminal/worker-terminal-manager.test.ts | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/server/src/terminal/worker-terminal-manager.test.ts b/packages/server/src/terminal/worker-terminal-manager.test.ts index b0ef983a8e..2a584087a3 100644 --- a/packages/server/src/terminal/worker-terminal-manager.test.ts +++ b/packages/server/src/terminal/worker-terminal-manager.test.ts @@ -1623,13 +1623,26 @@ it.each([1, 4])( async (version) => { const paseoHome = createTemporaryHome(); const old = await leaveWorkerWithTerminal(paseoHome, version); + const [record] = await workerRecords(paseoHome); + const program = record!.terminals.find((entry) => entry.id === old.terminalId)!; + const inspector = createTerminalWorkerProcessInspector(); manager = await startRealWorkerManager({ paseoHome }); - await waitForCondition( - () => !isProcessAlive(old.workerPid) && !isProcessAlive(old.programPid), - 10000, - ); + expect(await readUnconfirmedTerminalCleanup(paseoHome)).toEqual([]); + // Startup launches a replacement worker after cleanup. Either old PID may already + // belong to another process, so verify that the recorded birth identities are gone. + for (const original of [{ pid: record!.pid, ...record!.identity }, program]) { + await expect + .poll( + async () => + !isProcessAlive(original.pid) || + classifyTerminalProgramProcess(original, await inspector.inspect(original.pid)) + .status === "unrelated", + { timeout: 10000 }, + ) + .toBe(true); + } expect(manager.getTerminal(old.terminalId)).toBeUndefined(); const workers = await workerRecords(paseoHome); expect(workers).toHaveLength(1); From e2f07793797bed67aabcfaa0b7dffb0a6a021101 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 03:30:52 +0000 Subject: [PATCH 44/58] Render Markdown tables written directly below prose (#8485) * fix(app): render tables immediately after paragraphs Insert a paragraph break before GFM tables adjacent to prose so MD4C recognizes them, preserving quote/list containers and line endings. * fix(app): preserve table headers during source adaptation Replace the preceding line ending when inserting a table separator. This keeps the separator before the header's source range, so entity and local image edits cannot consume the paragraph break. --------- Co-authored-by: Okan YILDIRIM <63511519+ragokan@users.noreply.github.com> Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> Co-authored-by: thsrite Co-authored-by: Woosuk Nam --- .../src/components/markdown/renderer.test.ts | 180 ++++++++++++++++++ .../app/src/components/markdown/source.ts | 29 ++- 2 files changed, 204 insertions(+), 5 deletions(-) diff --git a/packages/app/src/components/markdown/renderer.test.ts b/packages/app/src/components/markdown/renderer.test.ts index 4256685f9c..1722cd4343 100644 --- a/packages/app/src/components/markdown/renderer.test.ts +++ b/packages/app/src/components/markdown/renderer.test.ts @@ -130,6 +130,186 @@ it("preserves empty table cells while escaping spoilers in cell text", () => { ); }); +it("renders a table directly after a paragraph through the library parser", async () => { + const source = "**Notes**\n| Item | Detail |\n| --- | --- |\n| A | one |\n| B | two |"; + const separated = "**Notes**\n\n| Item | Detail |\n| --- | --- |\n| A | one |\n| B | two |"; + const flags = { hardSoftBreaks: true, latexMath: false }; + const expected = await parseMarkdown(separated, flags); + expect(expected.children?.map((node) => node.type)).toEqual(["Paragraph", "Table"]); + expect(await parseMarkdown(prepareMarkdownSource(source).render(), flags)).toEqual(expected); +}); + +it.each([ + [ + "a quote", + "> Notes\n> | Item |\n> | --- |\n> | A |", + "> Notes\n>\n> | Item |\n> | --- |\n> | A |", + ["Document", ["Blockquote", "Paragraph", "Table"]], + ], + [ + "a list item", + "- Notes\n | Item |\n | --- |\n | A |\n- Next", + "- Notes\n\n | Item |\n | --- |\n | A |\n- Next", + ["Document", ["UnorderedList", ["ListItem", "Paragraph", "Table"], ["ListItem", "Paragraph"]]], + ], + [ + "a quoted list item", + "> - Notes\n> | Item |\n> | --- |\n> | A |", + "> - Notes\n>\n> | Item |\n> | --- |\n> | A |", + ["Document", ["Blockquote", ["UnorderedList", ["ListItem", "Paragraph", "Table"]]]], + ], + [ + "a quote inside a list item", + "- > Notes\n > | Item |\n > | --- |\n > | A |", + "- > Notes\n >\n > | Item |\n > | --- |\n > | A |", + ["Document", ["UnorderedList", ["ListItem", ["Blockquote", "Paragraph", "Table"]]]], + ], + [ + "aligned columns without outer pipes", + "Notes\nItem | Detail\n:--- | ---:\nA | one", + "Notes\n\nItem | Detail\n:--- | ---:\nA | one", + ["Document", "Paragraph", "Table"], + ], + [ + "an entity at the start of a header without outer pipes", + "Notes\nAT&T | Detail\n--- | ---\nA | one", + "Notes\n\nAT\\&T | Detail\n--- | ---\nA | one", + ["Document", "Paragraph", "Table"], + ], + [ + "a local image at the start of a header without outer pipes", + "Notes\n![Local](assets/a.png) | Detail\n--- | ---\nA | one", + "Notes\n\nLocal | Detail\n--- | ---\nA | one", + ["Document", "Paragraph", "Table"], + ], + [ + "CRLF lines", + "Notes\r\n| Item |\r\n| --- |\r\n| A |", + "Notes\r\n\r\n| Item |\r\n| --- |\r\n| A |", + ["Document", "Paragraph", "Table"], + ], + [ + "CR lines", + "Notes\r| Item |\r| --- |\r| A |", + "Notes\r\r| Item |\r| --- |\r| A |", + ["Document", "Paragraph", "Table"], + ], + [ + "a quote with CRLF lines", + "> Notes\r\n> | Item |\r\n> | --- |\r\n> | A |", + "> Notes\r\n>\r\n> | Item |\r\n> | --- |\r\n> | A |", + ["Document", ["Blockquote", "Paragraph", "Table"]], + ], + [ + "a list item with CRLF lines", + "- Notes\r\n | Item |\r\n | --- |\r\n | A |\r\n- Next", + "- Notes\r\n\r\n | Item |\r\n | --- |\r\n | A |\r\n- Next", + ["Document", ["UnorderedList", ["ListItem", "Paragraph", "Table"], ["ListItem", "Paragraph"]]], + ], + [ + "a quote with CR lines", + "> Notes\r> | Item |\r> | --- |\r> | A |", + "> Notes\r>\r> | Item |\r> | --- |\r> | A |", + ["Document", ["Blockquote", "Paragraph", "Table"]], + ], + [ + "a list item with CR lines", + "- Notes\r | Item |\r | --- |\r | A |\r- Next", + "- Notes\r\r | Item |\r | --- |\r | A |\r- Next", + ["Document", ["UnorderedList", ["ListItem", "Paragraph", "Table"], ["ListItem", "Paragraph"]]], + ], + [ + "a non-interrupting ordered marker with tildes", + "Intro\n2. ~~~\n Notes\n | A |\n | --- |\n | x |", + "Intro\n2. ~~~\n Notes\n\n | A |\n | --- |\n | x |", + ["Document", "Paragraph", "Table"], + ], + [ + "a non-interrupting ordered marker with backticks", + "Notes\n2. ```\n | Item |\n | --- |\n | A |", + "Notes\n2. ```\n\n | Item |\n | --- |\n | A |", + ["Document", "Paragraph", "Table"], + ], +])("preserves tables after paragraphs in %s", async (_name, source, separated, expected) => { + const rendered = prepareMarkdownSource(source).render(); + expect(rendered).toEqual(separated); + const flags = { hardSoftBreaks: true, latexMath: false }; + function blocks(node: ASTNode): unknown { + if (node.type === "Paragraph" || node.type === "Table") return node.type; + return [node.type, ...(node.children ?? []).map(blocks)]; + } + expect(blocks(await parseMarkdown(rendered, flags))).toEqual(expected); +}); + +it.each([ + [ + "a literal backslash", + "Notes\\\n| Item |\n| --- |\n| A |", + "Notes\\\n\n| Item |\n| --- |\n| A |", + "Notes\\", + ], + [ + "an entity and literal backslash with LF lines", + "AT&T\\\n| Item |\n| --- |\n| A |", + "AT\\&T\\\n\n| Item |\n| --- |\n| A |", + "AT&T\\", + ], + [ + "an entity and literal backslash with CRLF lines", + "AT&T\\\r\n| Item |\r\n| --- |\r\n| A |", + "AT\\&T\\\r\n\r\n| Item |\r\n| --- |\r\n| A |", + "AT&T\\", + ], + [ + "an entity and literal backslash with CR lines", + "AT&T\\\r| Item |\r| --- |\r| A |", + "AT\\&T\\\r\r| Item |\r| --- |\r| A |", + "AT&T\\", + ], +])( + "preserves paragraph text before an interrupting table in %s", + async (_name, source, separated, text) => { + const rendered = prepareMarkdownSource(source).render(); + expect(rendered).toEqual(separated); + const tree = await parseMarkdown(rendered, { hardSoftBreaks: true, latexMath: false }); + expect(tree.children?.map((node) => node.type)).toEqual(["Paragraph", "Table"]); + expect(tree.children?.[0]?.children?.map((node) => node.content).join("")).toEqual(text); + }, +); + +it.each([ + "```yaml\n---\nkey: value\n---\n```\n", + "~~~md\n| Item |\n| --- |\n| A |\n~~~\n", + "````md\n```\n| Item |\n| --- |\n```\n`````\n", + "> ```md\n> | Item |\n> | --- |\n> ```\n", + "> ```md\n> Code\n", + "- ```md\n Code\n", + " ```\n\n", + "```not`a-fence\n", +])("renders a table after a fence without changing its code: %s", async (prefix) => { + const table = "| Item |\n| --- |\n| A |"; + const rendered = prepareMarkdownSource(`${prefix}Notes\n${table}`).render(); + expect(rendered).toEqual(`${prefix}Notes\n\n${table}`); + const tree = await parseMarkdown(rendered, { hardSoftBreaks: true, latexMath: false }); + expect(tree.children?.slice(-2).map((node) => node.type)).toEqual(["Paragraph", "Table"]); +}); + +it.each([ + "| Item | Detail |\n| --- | --- |\n" + "| A | one |\n".repeat(100), + "Notes\n\n| Item | Detail |\n| --- | --- |\n" + "| A | one |\n".repeat(100), + "Notes\n\n| Item |\n| --- |\n| A |", + "| Item |\n| --- |\n| A |", + "```md\nNotes\n| Item |\n| --- |\n| A |\n```", + "```yaml\n---\nkey: value\n---\n```", + "```yaml\n---\nkey: value", + "~~~md\n| Item |\n| --- |\n| A |\n~~~", + "````md\n```\n| Item |\n| --- |\n```\n````", + " Notes\n | Item |\n | --- |\n | A |", + "Notes\n| Item | Detail |\n| --- |\n| A |", +])("leaves separated tables and table-like text unchanged: %s", (source) => { + expect(prepareMarkdownSource(source).render()).toEqual(source); +}); + it.each([ "src/components/message.tsx", "C:\\repo\\file.ts", diff --git a/packages/app/src/components/markdown/source.ts b/packages/app/src/components/markdown/source.ts index 3462320d7d..2ca3ebc474 100644 --- a/packages/app/src/components/markdown/source.ts +++ b/packages/app/src/components/markdown/source.ts @@ -138,6 +138,10 @@ export function prepareMarkdownSource( !source.includes("![") && !source.includes("||") && !source.includes("&") && + // Match content, header, and delimiter lines; quote-only lines count as blank. + !/^[\t >]*[^\t >\r\n][^\r\n]*(?:\r\n|\r|\n)[^\r\n]+(?:\r\n|\r|\n)(?=[\t >:|-]*\|)[\t >|]*:?-[-\t :|]*$/m.test( + source, + ) && !(workspaceRoot && source.includes("](")) ) { return { images, ...metadata, render: () => source }; @@ -212,11 +216,25 @@ export function prepareMarkdownSource( if (lineEnd < end) split(lineEnd); } } - function visit(node: Nodes) { - const start = node.position?.start.offset; - const end = node.position?.end.offset; - if (start !== undefined && end !== undefined) { + function visit(node: Nodes, previous?: Nodes) { + const position = node.position; + if (position?.start.offset !== undefined && position.end.offset !== undefined) { + const start = position.start.offset; + const end = position.end.offset; if (node.type === "paragraph") splitStandaloneImages(node, start, end); + if ( + node.type === "table" && + previous?.type === "paragraph" && + previous.position?.end.line === position.start.line - 1 + ) { + // MD4C only recognizes a table header on the first line of a paragraph. + const lineStart = + Math.max(source.lastIndexOf("\n", start - 1), source.lastIndexOf("\r", start - 1)) + 1; + const newline = source.slice(lineStart - 2, lineStart).replace(/[^\r\n]/g, ""); + const blank = source.slice(lineStart, start).replace(/[^>]/g, " ").trimEnd(); + const boundary = lineStart - newline.length; + edits.push({ start: boundary, end: lineStart, text: `${newline}${blank}${newline}` }); + } if (node.type === "link") { const title = workspaceHoverTitle(node, source.slice(start, end), workspaceRoot); if (title !== null) @@ -244,7 +262,8 @@ export function prepareMarkdownSource( if (text !== source.slice(start, end)) edits.push({ start, end, text }); } } - if ("children" in node) node.children.forEach(visit); + if ("children" in node) + node.children.forEach((child, index) => visit(child, node.children[index - 1])); } visit(getTree()); return { From b474beedce0d8d17861f25094a9e6d24aff16413 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 2 Nov 2026 10:50:12 +0700 Subject: [PATCH 45/58] Homepage shows an agent building a plugin live (#8515) * feat(website): show agents building plugins on the homepage The plugins section now leads with the workflow instead of the API list: you ask an agent for a panel and it appears, without a restart. It plays as a timeline (prompt, tool calls, result) and then slides in the panel the agent built, followed by the 400+ community plugins and their categories. It moves above the automation section. Mock tool calls on the homepage now match the app's collapsed timeline rows: same labels and icons, muted text in the UI font, and Paseo MCP tools named by action ("Create agent"). The hero pills are now nouns. * fix(website): show the finished plugin demo when motion is reduced The demo staged its items and slid the panel across its width even for visitors who ask for reduced motion. It now starts in its finished state for them, matching the hero mockup. --- packages/website/e2e/mockups.spec.ts | 41 ++- .../website/src/components/landing-page.tsx | 256 ++++++++++++++---- .../website/src/components/mockup/chat.tsx | 86 +++--- .../website/src/components/mockup/index.ts | 1 + packages/website/src/i18n/resources/en.json | 27 +- .../website/src/i18n/resources/zh-CN.json | 27 +- 6 files changed, 315 insertions(+), 123 deletions(-) diff --git a/packages/website/e2e/mockups.spec.ts b/packages/website/e2e/mockups.spec.ts index 8b0ba65857..41570439bf 100644 --- a/packages/website/e2e/mockups.spec.ts +++ b/packages/website/e2e/mockups.spec.ts @@ -6,7 +6,7 @@ const desktopName = async function openHomepage(page: Page) { await page.setViewportSize({ width: 1512, height: 930 }); await page.goto("/"); - await expect(page.getByRole("button", { name: "Build", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Parallel agents", exact: true })).toBeVisible(); } async function viewPhones(page: Page) { @@ -59,3 +59,42 @@ test("desktop mockup corners shrink in proportion on a phone", async ({ page }) await resizeToPhone(page); await expect.poll(() => cornerProportion(mockup)).toBeCloseTo(desktopCorner, 3); }); + +/** Opacity as rendered: the element's own times every ancestor's. */ +async function renderedOpacity(locator: Locator) { + return locator.evaluate((element) => { + let opacity = 1; + for (let node: Element | null = element; node; node = node.parentElement) { + opacity *= Number.parseFloat(getComputedStyle(node).opacity); + } + return opacity; + }); +} + +test("the plugin demo plays through to the panel the agent built", async ({ page }) => { + await openHomepage(page); + const builtPanelRow = page.getByText("Add Gruvbox theme", { exact: true }); + expect(await renderedOpacity(builtPanelRow)).toBe(0); + + // Hydration can reset the scroll position, so keep the demo in view until it finishes. + const heading = page.getByRole("heading", { name: "Ask Paseo to change Paseo" }); + await expect(async () => { + await heading.scrollIntoViewIfNeeded(); + expect(await renderedOpacity(builtPanelRow)).toBe(1); + }).toPass({ timeout: 15_000 }); + expect(await renderedOpacity(page.getByText("Review queue is in your sidebar."))).toBe(1); +}); + +test("the plugin demo shows its finished state when motion is reduced", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await openHomepage(page); + const builtPanelRow = page.getByText("Add Gruvbox theme", { exact: true }); + const panel = builtPanelRow.locator("xpath=ancestor::div[contains(@class, 'md:border-l')]"); + const heading = page.getByRole("heading", { name: "Ask Paseo to change Paseo" }); + + await expect(async () => { + await heading.scrollIntoViewIfNeeded(); + expect(await renderedOpacity(builtPanelRow)).toBe(1); + }).toPass({ timeout: 2_000 }); + expect(await panel.evaluate((element) => getComputedStyle(element).transform)).toBe("none"); +}); diff --git a/packages/website/src/components/landing-page.tsx b/packages/website/src/components/landing-page.tsx index 93d43afa26..77869e7379 100644 --- a/packages/website/src/components/landing-page.tsx +++ b/packages/website/src/components/landing-page.tsx @@ -1,4 +1,6 @@ import { PLUGINS_LINKED } from "~/plugins/launch"; +import { categoryHref } from "~/plugins/links"; +import type { CategorySlug } from "~/plugins/categories"; import * as React from "react"; import { Trans, useTranslation } from "react-i18next"; import { @@ -10,7 +12,7 @@ import { ExternalLink, Gem, GitFork, - LayoutPanelLeft, + GitPullRequest, Laptop, Lock, Merge, @@ -19,16 +21,17 @@ import { Smartphone, Terminal, Users, - Workflow, type LucideIcon, } from "lucide-react"; import { motion, AnimatePresence, useInView, + useReducedMotion, useScroll, useTransform, type Transition, + type Variants, } from "framer-motion"; // Shared motion presets — hoisted so every JSX site receives the same object @@ -86,7 +89,14 @@ import { PiIcon, } from "~/components/agent-icons"; import { DiscordIcon, GitHubIcon, SlackIcon } from "~/components/brand-icons"; -import { MobileChat, MobileDiff, MobileSidebar, PhoneFrame } from "~/components/mockup"; +import { + MobileChat, + MobileDiff, + MobileSidebar, + PhoneFrame, + type ToolCall, + ToolCallRow, +} from "~/components/mockup"; import { FAQItem } from "~/components/faq-item"; import { SiteFooter } from "~/components/site-footer"; import { SponsorSection, SponsorsSection } from "~/components/sponsorship"; @@ -132,8 +142,8 @@ export function LandingPage({ title, subtitle }: LandingPageProps) { - + @@ -703,41 +713,50 @@ function AutomationDetail({ kind }: { kind: AutomationKind }) { ); } +const MCP_AGENT_CALLS: ToolCall[] = [ + { kind: "paseo", label: "Create agent", detail: "Fix #412: retry flaky relay handshake" }, + { kind: "paseo", label: "Create agent", detail: "Fix #417: group schedules by project" }, +]; + function McpAutomationTranscript() { const { t } = useTranslation(); + return ( + + {t("landing.automation.transcriptPlan")} + + {t("landing.automation.transcriptDone")} + + ); +} + +/** A user message and the agent's reply, laid out like the app's timeline. */ +function AgentTurn({ request, children }: { request: string; children: React.ReactNode }) { return (
-
-

- {t("landing.automation.transcriptRequest")} -

-
-
- -
-

- {t("landing.automation.transcriptPlan")} -

-
- - -
-

- {t("landing.automation.transcriptDone")} -

-
-
+ {request} +
{children}
); } -function McpAgentCall({ issue, provider }: { issue: string; provider: string }) { +function UserMessage({ children }: { children: React.ReactNode }) { return ( -
- create_agent - {issue} - {provider} - worktree +
+

{children}

+
+ ); +} + +function AgentText({ children }: { children: React.ReactNode }) { + return

{children}

; +} + +function ToolCalls({ calls }: { calls: ReadonlyArray }) { + return ( +
+ {calls.map((call) => ( + + ))}
); } @@ -842,17 +861,16 @@ function ExtensibleSection() { title={t("landing.plugins.title")} description={t("landing.plugins.description")} > -
- -
- {PLUGIN_CAPABILITIES.map((capability) => ( - - ))} +
+ +
+ +

{t("landing.plugins.license")}

@@ -872,6 +890,156 @@ function ExtensibleSection() { ); } +const PLUGIN_BUILD_CALLS: ToolCall[] = [ + { kind: "read", detail: "paseo-plugin/SKILL.md" }, + { kind: "shell", detail: "paseo plugin init review-queue" }, + { kind: "write", detail: "review-queue/index.ts" }, + { kind: "write", detail: "review-queue/review-queue.client.tsx" }, + { kind: "shell", detail: "paseo plugin install ./review-queue" }, +]; + +// The demo plays once when it scrolls into view: the prompt, then each timeline +// item in turn, then the panel the agent built slides in and fills. +const DEMO_STEP_SECONDS = 0.45; +const DEMO_STEPS = { + prompt: 0, + plan: 1.4, + firstCall: 2.2, +} as const; +const DEMO_DONE_STEP = DEMO_STEPS.firstCall + PLUGIN_BUILD_CALLS.length; +const DEMO_PANEL_STEP = DEMO_DONE_STEP + 1; + +const DEMO_ITEM_VARIANTS: Variants = { + hidden: { opacity: 0, y: 6 }, + shown: (step: number) => ({ + opacity: 1, + y: 0, + transition: { delay: step * DEMO_STEP_SECONDS, duration: 0.35, ease: "easeOut" }, + }), +}; +const DEMO_PANEL_VARIANTS: Variants = { + hidden: { opacity: 0, x: "100%" }, + shown: { + opacity: 1, + x: 0, + transition: { + delay: DEMO_PANEL_STEP * DEMO_STEP_SECONDS, + duration: 0.6, + ease: [0.22, 0.61, 0.36, 1], + }, + }, +}; + +/** One piece of the demo that fades in at its step. */ +function DemoItem({ step, children }: { step: number; children: React.ReactNode }) { + return ( + + {children} + + ); +} + +/** The ask on the left, the panel it produced on the right. */ +function SelfModifyDemo() { + const { t } = useTranslation(); + // Visitors who ask for reduced motion get the finished demo, with no slide or staging. + const reduceMotion = useReducedMotion(); + return ( + +
+ + {t("landing.plugins.request")} + +
+ + {t("landing.plugins.plan")} + +
+ {PLUGIN_BUILD_CALLS.map((call, index) => ( + + + + ))} +
+ + {t("landing.plugins.done")} + +
+
+ +
+ ); +} + +const REVIEW_QUEUE = [ + { number: "#418", title: "Retry flaky relay handshake", author: "ana", age: "12m" }, + { number: "#415", title: "Group schedules by project", author: "kenji", age: "1h" }, + { number: "#409", title: "Add Gruvbox theme", author: "lea", age: "3h" }, +] as const; + +/** The panel the agent just built, docked like a real sidebar panel. */ +function ReviewQueuePreview() { + const { t } = useTranslation(); + return ( + +
+ + {t("landing.plugins.panelTitle")} + + {REVIEW_QUEUE.length} + +
+
+ {REVIEW_QUEUE.map((pr, index) => ( + +
+

{pr.title}

+

+ {pr.number} · {pr.author} · {pr.age} +

+
+
+ ))} +
+
+ ); +} + +const SHOWCASE_CATEGORIES = [ + "themes", + "sidebar", + "workspaces", + "providers", + "orchestration", + "git", +] as const satisfies ReadonlyArray; + +function PluginCategoryLinks() { + const { t } = useTranslation(); + const localize = useLocalizePath(); + return ( +
+ {SHOWCASE_CATEGORIES.map((category) => ( + + {t(`plugins.categories.${category}.label`)} + + ))} +
+ ); +} + const PLUGINS_BROWSE = PLUGINS_LINKED ? { href: "/plugins", external: false } : { href: "https://paseo.cafe", external: true }; @@ -899,12 +1067,6 @@ function PluginActions() { ); } -const PLUGIN_CAPABILITIES = [ - { icon: LayoutPanelLeft, key: "ui" }, - { icon: Workflow, key: "lifecycle" }, - { icon: Bot, key: "providers" }, -] as const; - const PRINCIPLES = [ { icon: Lock, key: "private" }, { icon: Compass, key: "independent" }, diff --git a/packages/website/src/components/mockup/chat.tsx b/packages/website/src/components/mockup/chat.tsx index f944839cf8..dc951a8517 100644 --- a/packages/website/src/components/mockup/chat.tsx +++ b/packages/website/src/components/mockup/chat.tsx @@ -1,14 +1,14 @@ import { AudioLines, - BookOpen, Brain, ChevronDown, CircleDot, Copy, - FilePenLine, + Eye, LoaderCircle, ListTodo, Mic, + Pencil, Plus, ShieldOff, Split, @@ -18,7 +18,7 @@ import { import type * as React from "react"; import { useTranslation } from "react-i18next"; import { DiffStat } from "./atoms"; -import { CodexIcon } from "./icons"; +import { CodexIcon, PaseoMark } from "./icons"; // Icons passed as props are hoisted so they are not rebuilt on every render. const MODEL_ICON = ; @@ -94,70 +94,62 @@ export function AgentTranscript() { ); } -interface PluginToolCall { - kind: "read" | "shell" | "write"; - label: string; - detail: string; +/** + * A collapsed tool call as the app's timeline shows it. Built-in tools carry a + * fixed label; Paseo MCP tools name their action, such as "Create agent". + */ +export type ToolCall = + | { kind: "read" | "write" | "shell"; detail: string } + | { kind: "paseo"; label: string; detail: string }; + +const BUILT_IN_LABELS = { read: "Read", write: "Write", shell: "Shell" } as const; + +function toolCallLabel(call: ToolCall): string { + return call.kind === "paseo" ? call.label : BUILT_IN_LABELS[call.kind]; } -const PLUGIN_TOOL_CALLS: PluginToolCall[] = [ - { kind: "read", label: "Read", detail: "paseo-plugin/SKILL.md" }, - { - kind: "shell", - label: "Shell", - detail: "paseo plugin init ~/dev/release-radar", - }, - { - kind: "write", - label: "Write", - detail: "~/dev/release-radar/index.ts", - }, - { - kind: "write", - label: "Write", - detail: "~/dev/release-radar/release-radar.client.tsx", - }, - { - kind: "shell", - label: "Shell", - detail: "npm install && npm run typecheck", - }, - { - kind: "shell", - label: "Shell", - detail: "paseo plugin install ~/dev/release-radar", - }, +const PLUGIN_TOOL_CALLS: ToolCall[] = [ + { kind: "read", detail: "paseo-plugin/SKILL.md" }, + { kind: "shell", detail: "paseo plugin init ~/dev/release-radar" }, + { kind: "write", detail: "~/dev/release-radar/index.ts" }, + { kind: "write", detail: "~/dev/release-radar/release-radar.client.tsx" }, + { kind: "shell", detail: "npm install && npm run typecheck" }, + { kind: "shell", detail: "paseo plugin install ~/dev/release-radar" }, ]; const PLUGIN_DISCOVERY_CALLS = PLUGIN_TOOL_CALLS.slice(0, 1); const PLUGIN_BUILD_CALLS = PLUGIN_TOOL_CALLS.slice(1, 4); const PLUGIN_INSTALL_CALLS = PLUGIN_TOOL_CALLS.slice(4); -function PluginToolIcon({ kind }: { kind: PluginToolCall["kind"] }) { - if (kind === "read") return ; - if (kind === "write") return ; +function ToolCallIcon({ kind }: { kind: ToolCall["kind"] }) { + if (kind === "read") return ; + if (kind === "write") return ; + if (kind === "paseo") return ; return ; } -function PluginToolRow({ call }: { call: PluginToolCall }) { +/** + * One tool call row, drawn like the app's collapsed timeline item + * (`packages/app/src/components/message.tsx`): icon, label, then the summary, + * all muted and in the UI font. + */ +export function ToolCallRow({ call }: { call: ToolCall }) { return ( -
- - - - {call.label} - - {call.detail} +
+ + + {toolCallLabel(call)} + {call.detail}
); } -function PluginToolGroup({ calls }: { calls: PluginToolCall[] }) { +function PluginToolGroup({ calls }: { calls: ToolCall[] }) { return (
{calls.map((call) => ( - + ))}
); diff --git a/packages/website/src/components/mockup/index.ts b/packages/website/src/components/mockup/index.ts index 71ffed8b8d..d07c4d1f56 100644 --- a/packages/website/src/components/mockup/index.ts +++ b/packages/website/src/components/mockup/index.ts @@ -1,4 +1,5 @@ export { ClaudeIcon, CodexIcon, SourceControlIcon } from "./icons"; +export { type ToolCall, ToolCallRow } from "./chat"; export { DESIGN_HEIGHT, DESIGN_WIDTH } from "./geometry"; export { PhoneFrame } from "./mobile/frame"; export { MobileChat } from "./mobile/chat"; diff --git a/packages/website/src/i18n/resources/en.json b/packages/website/src/i18n/resources/en.json index 055229ca47..3365a9fffa 100644 --- a/packages/website/src/i18n/resources/en.json +++ b/packages/website/src/i18n/resources/en.json @@ -375,19 +375,18 @@ "otherHostComment": "# Target another host" }, "plugins": { - "title": "Extend it with plugins", - "description": "Add new panels and functionality that work in desktop, web and mobile", - "lead": "Plugins", + "title": "Ask Paseo to change Paseo", + "description": "Agents in Paseo can change the whole UI without a restart, even on mobile.", + "request": "Add a sidebar panel with the PRs waiting on my review.", + "plan": "I'll build it as a local plugin and install it.", + "done": "Review queue is in your sidebar.", + "panelTitle": "Review queue", + "libraryTitle": "400+ community plugins", + "libraryDescription": "Built by developers on the same plugin API your agents use", "browse": "Browse plugins", "docs": "Plugin docs", "license": "Paseo is Apache 2.0.", - "fork": "Fork the repo", - "uiTitle": "UI", - "uiDescription": "Screens, workspace panels, sidebar items, slash commands, and themes", - "lifecycleTitle": "Agent lifecycle", - "lifecycleDescription": "Change config and MCP servers, answer permissions, and follow up", - "providersTitle": "Providers", - "providersDescription": "Add a coding agent as a provider, used like the built-in ones" + "fork": "Fork the repo" }, "philosophy": { "title": "Philosophy", @@ -447,10 +446,10 @@ }, "mockup": { "alt": "Paseo desktop app with coding agents, a conversation, and a code diff open side by side", - "build": "Build", - "review": "Review", - "ship": "Ship", - "extend": "Extend", + "build": "Parallel agents", + "review": "Code review", + "ship": "Pull requests", + "extend": "Plugins", "app": { "newWorkspace": "New workspace", "history": "History", diff --git a/packages/website/src/i18n/resources/zh-CN.json b/packages/website/src/i18n/resources/zh-CN.json index 048d351e4d..2cb1cf4f3f 100644 --- a/packages/website/src/i18n/resources/zh-CN.json +++ b/packages/website/src/i18n/resources/zh-CN.json @@ -375,19 +375,18 @@ "otherHostComment": "# 指定另一台主机" }, "plugins": { - "title": "用插件扩展", - "description": "添加新的面板和功能,在桌面端、网页端和移动端都能使用", - "lead": "插件", + "title": "让 Paseo 修改 Paseo", + "description": "Paseo 里的 Agent 可以修改整个界面,无需重启,在移动端也一样。", + "request": "在侧边栏加一个面板,列出等待我审查的 PR。", + "plan": "我会把它做成本地插件并安装。", + "done": "审查队列已出现在你的侧边栏。", + "panelTitle": "审查队列", + "libraryTitle": "400 多个社区插件", + "libraryDescription": "开发者用的是和你的 Agent 相同的插件 API", "browse": "浏览插件", "docs": "插件文档", "license": "Paseo 采用 Apache 2.0 许可证。", - "fork": "Fork 仓库", - "uiTitle": "界面", - "uiDescription": "页面、工作区面板、侧边栏项目、斜杠命令和主题", - "lifecycleTitle": "Agent 生命周期", - "lifecycleDescription": "修改配置和 MCP 服务器、响应权限请求、发送后续消息", - "providersTitle": "Provider", - "providersDescription": "把编程 Agent 添加为 Provider,用法与内置 Provider 相同" + "fork": "Fork 仓库" }, "philosophy": { "title": "理念", @@ -447,10 +446,10 @@ }, "mockup": { "alt": "Paseo 桌面应用,并排打开了编程 Agent、一段对话和一份代码 diff", - "build": "构建", - "review": "审查", - "ship": "发布", - "extend": "扩展", + "build": "并行 Agent", + "review": "代码审查", + "ship": "Pull Request", + "extend": "插件", "app": { "newWorkspace": "新建工作区", "history": "历史", From 7c2812dde688a1403fc7464bf36f7802060c8ad6 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 2 Nov 2026 11:07:48 +0700 Subject: [PATCH 46/58] fix(app): add hosts without leaving the current screen (#8512) * fix(app): add hosts without leaving the current screen A retained Settings route replayed its addHost query after appearance remounts. Own the connection flow outside screen lifetimes and let each consumer choose its completion navigation. * fix(app): release the project dialog before adding a host * fix(app): size the QR scanner viewport on wide layouts * test(app): align creation and desktop setup with in-place host flow --- .oxlintrc.json | 8 -- docs/design.md | 8 +- docs/expo-router.md | 6 + .../app/e2e/browser/add-project-flow.spec.ts | 15 +++ .../file-explorer-context-actions.spec.ts | 15 ++- .../e2e/browser/new-workspace-entry.spec.ts | 6 +- .../e2e/browser/settings-host-page.spec.ts | 73 ++++++++++++ .../e2e/support/helpers/add-project-flow.ts | 4 + packages/app/e2e/support/helpers/hosts.ts | 22 ++++ packages/app/src/app/_layout.tsx | 15 ++- packages/app/src/app/settings/[section].tsx | 5 +- .../app/src/components/add-project-flow.tsx | 9 +- packages/app/src/components/left-sidebar.tsx | 13 +-- .../app/src/components/welcome-screen.tsx | 55 ++------- packages/app/src/hosts/add-host/index.tsx | 89 +++++++++++++++ .../internal}/add-host-method-modal.tsx | 21 ++-- .../add-host/internal}/add-host-modal.tsx | 77 ++++++++----- .../internal}/add-remote-ssh-host-modal.tsx | 10 +- .../src/hosts/add-host/internal/model.test.ts | 49 ++++++++ .../app/src/hosts/add-host/internal/model.ts | 47 ++++++++ .../add-host/internal}/pair-link-modal.tsx | 27 +++-- .../internal}/pair-scan-model.test.ts | 0 .../internal}/pair-scan-model.ts | 0 .../add-host/internal/scan.tsx} | 83 ++++---------- packages/app/src/hosts/host-chooser.tsx | 15 +-- .../app/src/screens/new-workspace-screen.tsx | 7 +- packages/app/src/screens/settings-screen.tsx | 105 ++---------------- packages/app/src/utils/host-routes.test.ts | 9 -- packages/app/src/utils/host-routes.ts | 4 - packages/desktop/e2e/browser-tabs.e2e.mjs | 16 +++ 30 files changed, 486 insertions(+), 327 deletions(-) create mode 100644 packages/app/src/hosts/add-host/index.tsx rename packages/app/src/{components => hosts/add-host/internal}/add-host-method-modal.tsx (92%) rename packages/app/src/{components => hosts/add-host/internal}/add-host-modal.tsx (89%) rename packages/app/src/{components => hosts/add-host/internal}/add-remote-ssh-host-modal.tsx (97%) create mode 100644 packages/app/src/hosts/add-host/internal/model.test.ts create mode 100644 packages/app/src/hosts/add-host/internal/model.ts rename packages/app/src/{components => hosts/add-host/internal}/pair-link-modal.tsx (92%) rename packages/app/src/hosts/{ => add-host/internal}/pair-scan-model.test.ts (100%) rename packages/app/src/hosts/{ => add-host/internal}/pair-scan-model.ts (100%) rename packages/app/src/{app/pair-scan.tsx => hosts/add-host/internal/scan.tsx} (71%) diff --git a/.oxlintrc.json b/.oxlintrc.json index c82b517844..ae335c0e48 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -406,10 +406,7 @@ { "files": [ "packages/app/src/app/_layout.tsx", - "packages/app/src/app/pair-scan.tsx", "packages/app/src/components/adaptive-modal-sheet.tsx", - "packages/app/src/components/add-host-method-modal.tsx", - "packages/app/src/components/add-host-modal.tsx", "packages/app/src/components/agent-list.tsx", "packages/app/src/components/agent-status-dot.tsx", "packages/app/src/components/attachment-lightbox.tsx", @@ -428,7 +425,6 @@ "packages/app/src/components/hosts/host-picker.tsx", "packages/app/src/components/icons/paseo-logo.tsx", "packages/app/src/components/left-sidebar.tsx", - "packages/app/src/components/pair-link-modal.tsx", "packages/app/src/components/plan-card.tsx", "packages/app/src/components/provider-diagnostic-sheet.tsx", "packages/app/src/components/question-form-card.tsx", @@ -543,14 +539,10 @@ "packages/app/src/agent-skills/selection-sheet.tsx", "packages/app/src/agent-stream/chat-outline/rail.web.tsx", "packages/app/src/agent-stream/view.tsx", - "packages/app/src/app/pair-scan.tsx", "packages/app/src/changelog/internal/changelog-sheet.tsx", "packages/app/src/command-center/command-center.tsx", "packages/app/src/components/adaptive-modal-sheet.tsx", - "packages/app/src/components/add-host-method-modal.tsx", - "packages/app/src/components/add-host-modal.tsx", "packages/app/src/components/add-project-flow.tsx", - "packages/app/src/components/add-remote-ssh-host-modal.tsx", "packages/app/src/components/agent-list.tsx", "packages/app/src/components/app-diagnostic-sheet.tsx", "packages/app/src/components/attachment-lightbox.tsx", diff --git a/docs/design.md b/docs/design.md index a336b80a64..dbf4c028ec 100644 --- a/docs/design.md +++ b/docs/design.md @@ -35,7 +35,7 @@ The authored interface ramp uses a 14px base. New native installs default to 15p Weight has three tiers, applied by role: - **Screen titles** — the title in app chrome — use `` (`packages/app/src/components/headers/screen-title.tsx`), which renders `fontSize.base` at weight `400` on compact and `300` on desktop. The New workspace hero is the only larger product title; it uses `fontSize["2xl"]` (`packages/app/src/screens/new-workspace-screen.tsx`). -- **Structural labels** use `fontWeight.medium`. This applies to section labels above a stack of rows (`packages/app/src/components/agent-list.tsx:519-523`, `packages/app/src/components/keyboard-shortcuts-dialog.tsx:63-67`), form field labels above an input inside a modal (`packages/app/src/components/add-host-modal.tsx:19-23`, `packages/app/src/components/pair-link-modal.tsx:24-28`), the title at the top of a modal/sheet/dialog (`packages/app/src/components/adaptive-modal-sheet.tsx:90-94`, `packages/app/src/components/ui/combobox.tsx:1607-1611`, `packages/app/src/components/welcome-screen.tsx:48-53`), action button labels in tight components such as the sidebar callout actions (`packages/app/src/components/sidebar-callout.tsx:218-221`), and inline data emphasis on dense metadata rows (`packages/app/src/components/git-diff-pane.tsx:2322-2327`, `packages/app/src/components/file-explorer-pane.tsx:1115-1122`). +- **Structural labels** use `fontWeight.medium`. This applies to section labels above a stack of rows (`packages/app/src/components/agent-list.tsx:519-523`, `packages/app/src/components/keyboard-shortcuts-dialog.tsx:63-67`), form field labels above an input inside a modal (`packages/app/src/hosts/add-host/internal/add-host-modal.tsx:19-23`, `packages/app/src/hosts/add-host/internal/pair-link-modal.tsx:24-28`), the title at the top of a modal/sheet/dialog (`packages/app/src/components/adaptive-modal-sheet.tsx:90-94`, `packages/app/src/components/ui/combobox.tsx:1607-1611`, `packages/app/src/components/welcome-screen.tsx:48-53`), action button labels in tight components such as the sidebar callout actions (`packages/app/src/components/sidebar-callout.tsx:218-221`), and inline data emphasis on dense metadata rows (`packages/app/src/components/git-diff-pane.tsx:2322-2327`, `packages/app/src/components/file-explorer-pane.tsx:1115-1122`). - **Content** uses `fontWeight.normal`. This applies to settings rows (`packages/app/src/styles/settings.ts`), sidebar primary list-item titles (`packages/app/src/components/sidebar-workspace-list.tsx:2680-2686`, `packages/app/src/components/agent-list.tsx:572-578`), ` {appVersionText} - - - - - - ); diff --git a/packages/app/src/hosts/add-host/index.tsx b/packages/app/src/hosts/add-host/index.tsx new file mode 100644 index 0000000000..42b37c6d8e --- /dev/null +++ b/packages/app/src/hosts/add-host/index.tsx @@ -0,0 +1,89 @@ +import { createContext, useCallback, useContext, useState, useMemo, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { useStore } from "zustand"; +import { AddHostMethodModal } from "./internal/add-host-method-modal"; +import { AddHostModal } from "./internal/add-host-modal"; +import { AddRemoteSshHostModal } from "./internal/add-remote-ssh-host-modal"; +import { PairLinkModal } from "./internal/pair-link-modal"; +import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; +import { PairScanContent } from "./internal/scan"; +import { createAddHostFlow, type AddHostOptions, type AddHostSession } from "./internal/model"; + +const Context = createContext | null>(null); + +/** Owns the flow outside screen and appearance lifetimes. Callers own success navigation. */ +export function AddHostProvider({ children }: { children: ReactNode }) { + const [flow] = useState(createAddHostFlow); + return {children}; +} + +export function AddHostFlowHost() { + const flow = useContext(Context); + if (!flow) throw new Error("AddHostFlowHost requires AddHostProvider"); + const session = useStore(flow.store, (state) => state.session); + return session ? : null; +} + +export function useAddHost({ method, onAdded }: AddHostOptions = {}) { + const flow = useContext(Context); + if (!flow) throw new Error("useAddHost requires AddHostProvider"); + return useCallback(() => flow.open({ method, onAdded }), [flow, method, onAdded]); +} + +function AddHostSessionView({ session }: { session: AddHostSession }) { + const { t } = useTranslation(); + const method = useStore(session.step, (state) => state.method); + const back = useCallback(() => session.choose(null), [session]); + const direct = useCallback(() => session.choose("direct"), [session]); + const ssh = useCallback(() => session.choose("ssh"), [session]); + const link = useCallback(() => session.choose("link"), [session]); + const scan = useCallback(() => session.choose("scan"), [session]); + const scanned = useCallback((serverId: string) => session.complete({ serverId }), [session]); + const header = useMemo( + () => ({ title: t("pairing.scan.title"), back: { onPress: back } }), + [t, back], + ); + return ( + <> + + + + + {method === "scan" ? ( + + + + ) : null} + + ); +} +const SCAN_SNAP_POINTS = ["90%"]; diff --git a/packages/app/src/components/add-host-method-modal.tsx b/packages/app/src/hosts/add-host/internal/add-host-method-modal.tsx similarity index 92% rename from packages/app/src/components/add-host-method-modal.tsx rename to packages/app/src/hosts/add-host/internal/add-host-method-modal.tsx index b97c529bef..35e61a5d17 100644 --- a/packages/app/src/components/add-host-method-modal.tsx +++ b/packages/app/src/hosts/add-host/internal/add-host-method-modal.tsx @@ -1,9 +1,10 @@ +import { PressHighlight } from "@/components/ui/press-highlight"; import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { Pressable, Text, View } from "react-native"; +import { Text, View } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { QrCode, Link2, ClipboardPaste, Terminal } from "lucide-react-native"; -import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet"; +import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { isFdroidBuild } from "@/constants/build-profile"; import { isNative } from "@/constants/platform"; import { isElectronRuntime } from "@/desktop/host"; @@ -84,7 +85,7 @@ export function AddHostMethodModal({ onClose={onClose} testID="add-host-method-modal" > - - + {isElectronRuntime() ? ( - - + ) : null} {isNative && !isFdroidBuild ? ( - - + ) : null} - - + ); } diff --git a/packages/app/src/components/add-host-modal.tsx b/packages/app/src/hosts/add-host/internal/add-host-modal.tsx similarity index 89% rename from packages/app/src/components/add-host-modal.tsx rename to packages/app/src/hosts/add-host/internal/add-host-modal.tsx index 5d8fc90cff..5379e5f4bd 100644 --- a/packages/app/src/components/add-host-modal.tsx +++ b/packages/app/src/hosts/add-host/internal/add-host-modal.tsx @@ -1,17 +1,22 @@ +import { PressHighlight } from "@/components/ui/press-highlight"; import { useCallback, useMemo, useReducer, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Alert, Pressable, Text, View } from "react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { Alert, Text, View } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Link2 } from "lucide-react-native"; import type { HostProfile } from "@/types/host-connection"; import { useHosts, useHostMutations } from "@/runtime/host-runtime"; import { parseConnectionUri, serializeConnectionUri } from "@/utils/daemon-endpoints"; -import { buildConnectionFailureCopy } from "./connection-failure-copy"; -import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet"; +import { buildConnectionFailureCopy } from "@/components/connection-failure-copy"; +import { + AdaptiveModalSheet, + AdaptiveTextInput, + type SheetHeader, +} from "@/components/adaptive-modal-sheet"; import { Button } from "@/components/ui/button"; -import { PairingTargetTracker } from "./pair-link-credentials"; -import { buildDirectConnectionUri } from "./direct-connection-host"; +import { PairingTargetTracker } from "@/components/pair-link-credentials"; +import { buildDirectConnectionUri } from "@/components/direct-connection-host"; const FLEX_ONE_STYLE = { flex: 1 } as const; @@ -188,7 +193,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod } function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostModalProps) { - const { theme } = useUnistyles(); const { t } = useTranslation(); const daemons = useHosts(); const { probeAndUpsertDirectConnection, beginLinkPairing } = useHostMutations(); @@ -207,10 +211,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod const [inputResetKey, bumpInputResetKey] = useReducer((key: number) => key + 1, 0); const advancedTarget = useRef(new PairingTargetTracker("", true)); - const connectIcon = useMemo( - () => , - [theme.colors.accentForeground], - ); + const connectIcon = useMemo(() => , []); const hostFieldStyle = useMemo(() => [styles.field, styles.hostField], []); const portFieldStyle = useMemo(() => [styles.field, styles.portField], []); const checkboxStyle = useMemo( @@ -415,8 +416,8 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod setIsAdvancedOpen(false); }, [advancedUri, directConnectionLabels, host, isAdvancedOpen, password, port, useTls]); - const AdvancedIcon = isAdvancedOpen ? ChevronDown : ChevronRight; - const PasswordIcon = isPasswordVisible ? EyeOff : Eye; + const AdvancedIcon = isAdvancedOpen ? ThemedChevronDown : ThemedChevronRight; + const PasswordIcon = isPasswordVisible ? ThemedEyeOff : ThemedEye; return ( {t("pairing.direct.fields.host")} - {t("pairing.direct.fields.port")} - - {useTls ? ( - + ) : null} {t("pairing.direct.fields.useSsl")} - + {t("pairing.direct.fields.password")} - - - - + + - - + {t("pairing.direct.advanced.label")} - + {isAdvancedOpen ? ( - ); } + +const ThemedLink2 = withUnistyles(Link2, (theme) => ({ color: theme.colors.accentForeground })); + +const ThemedCheck = withUnistyles(Check, (theme) => ({ color: theme.colors.accentForeground })); + +const ThemedChevronDown = withUnistyles(ChevronDown, (theme) => ({ + color: theme.colors.foregroundMuted, +})); + +const ThemedChevronRight = withUnistyles(ChevronRight, (theme) => ({ + color: theme.colors.foregroundMuted, +})); + +const ThemedEye = withUnistyles(Eye, (theme) => ({ color: theme.colors.foregroundMuted })); + +const ThemedEyeOff = withUnistyles(EyeOff, (theme) => ({ color: theme.colors.foregroundMuted })); + +const ThemedAdaptiveTextInput = withUnistyles(AdaptiveTextInput, (theme) => ({ + placeholderTextColor: theme.colors.foregroundMuted, +})); diff --git a/packages/app/src/components/add-remote-ssh-host-modal.tsx b/packages/app/src/hosts/add-host/internal/add-remote-ssh-host-modal.tsx similarity index 97% rename from packages/app/src/components/add-remote-ssh-host-modal.tsx rename to packages/app/src/hosts/add-host/internal/add-remote-ssh-host-modal.tsx index c7e25bce64..b3d1cf7829 100644 --- a/packages/app/src/components/add-remote-ssh-host-modal.tsx +++ b/packages/app/src/hosts/add-host/internal/add-remote-ssh-host-modal.tsx @@ -1,7 +1,7 @@ +import { PressHighlight } from "@/components/ui/press-highlight"; import { useCallback, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { - Pressable, Text, View, type PressableStateCallbackType, @@ -23,7 +23,7 @@ import { import type { EditingTextInputHandle } from "@/components/ui/text-input"; import { useIsCompactFormFactor } from "@/constants/layout"; import { DaemonConnectionTestError } from "@/utils/test-daemon-connection"; -import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet"; +import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; const FLEX_ONE_STYLE = { flex: 1 } as const; const ThemedTerminal = withUnistyles(Terminal); @@ -174,11 +174,11 @@ export function AddRemoteSshHostModal({ } clear(); - onClose(); onSaved?.({ ...result, isNewHost: !hosts.some((profile) => profile.serverId === result.serverId), }); + onClose(); }, [clear, hosts, isSaving, onClose, onSaved, probeAndUpsertRemoteSshConnection, t]); const handleTargetChange = useCallback((value: string) => { targetRef.current = value; @@ -260,7 +260,7 @@ export function AddRemoteSshHostModal({ onSubmitEditing={handleSubmit} /> - {isPasswordVisible ? : } - + diff --git a/packages/app/src/hosts/add-host/internal/model.test.ts b/packages/app/src/hosts/add-host/internal/model.test.ts new file mode 100644 index 0000000000..a878a5b41d --- /dev/null +++ b/packages/app/src/hosts/add-host/internal/model.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { createAddHostFlow } from "./model"; + +describe("add host flow", () => { + it("closes before notifying the consumer and ignores duplicate completion", () => { + const flow = createAddHostFlow(); + const added: string[] = []; + flow.open({ + onAdded: (serverId) => { + expect(flow.store.getState().session).toBeNull(); + added.push(serverId); + }, + }); + const session = flow.store.getState().session!; + session.choose("direct"); + session.complete({ serverId: "new-host" }); + session.complete({ serverId: "new-host" }); + expect(added).toEqual(["new-host"]); + }); + + it("cancels without a success action and accepts a fresh request", () => { + const flow = createAddHostFlow(); + const added: string[] = []; + flow.open({ onAdded: (id) => added.push(id) }); + const cancelled = flow.store.getState().session!; + cancelled.choose("scan"); + cancelled.close(); + expect(flow.store.getState().session).toBeNull(); + flow.open({ method: "link" }); + const current = flow.store.getState().session!; + cancelled.complete({ serverId: "late-result" }); + cancelled.close(); + expect(flow.store.getState().session).toBe(current); + expect(current.step.getState()).toEqual({ method: "link" }); + expect(added).toEqual([]); + current.complete({ serverId: "new-host" }); + expect(flow.store.getState().session).toBeNull(); + }); + + it("returns to method selection within the same request", () => { + const flow = createAddHostFlow(); + flow.open(); + const session = flow.store.getState().session!; + session.choose("ssh"); + session.choose(null); + expect(flow.store.getState().session).toBe(session); + expect(session.step.getState()).toEqual({ method: null }); + }); +}); diff --git a/packages/app/src/hosts/add-host/internal/model.ts b/packages/app/src/hosts/add-host/internal/model.ts new file mode 100644 index 0000000000..614d9dddc0 --- /dev/null +++ b/packages/app/src/hosts/add-host/internal/model.ts @@ -0,0 +1,47 @@ +import { createStore, type StoreApi } from "zustand/vanilla"; + +export type AddHostMethod = "direct" | "ssh" | "link" | "scan"; +export interface AddHostOptions { + method?: AddHostMethod; + onAdded?: (serverId: string) => void; +} + +export interface AddHostSession { + id: number; + step: StoreApi<{ method: AddHostMethod | null }>; + choose(method: AddHostMethod | null): void; + close(): void; + complete(result: { serverId: string }): void; +} + +export function createAddHostFlow() { + const store = createStore<{ session: AddHostSession | null }>(() => ({ session: null })); + let nextId = 0; + function createSession(options: AddHostOptions) { + const step = createStore<{ method: AddHostMethod | null }>(() => ({ + method: options.method ?? null, + })); + const session = { + id: ++nextId, + step, + choose(method: AddHostMethod | null) { + step.setState({ method }); + }, + close() { + if (store.getState().session === session) store.setState({ session: null }); + }, + complete({ serverId }: { serverId: string }) { + if (store.getState().session !== session) return; + session.close(); + options.onAdded?.(serverId); + }, + }; + return session; + } + return { + store, + open(options: AddHostOptions = {}) { + store.setState({ session: createSession(options) }); + }, + }; +} diff --git a/packages/app/src/components/pair-link-modal.tsx b/packages/app/src/hosts/add-host/internal/pair-link-modal.tsx similarity index 92% rename from packages/app/src/components/pair-link-modal.tsx rename to packages/app/src/hosts/add-host/internal/pair-link-modal.tsx index 8ed9cefba9..f817bdc5cf 100644 --- a/packages/app/src/components/pair-link-modal.tsx +++ b/packages/app/src/hosts/add-host/internal/pair-link-modal.tsx @@ -1,16 +1,20 @@ import { useCallback, useMemo, useReducer, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Alert, Text, View } from "react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { Link } from "lucide-react-native"; import type { HostProfile } from "@/types/host-connection"; import { useHosts, useHostMutations, type PasswordRequiredPairing } from "@/runtime/host-runtime"; import { parseRelayConnectionUri } from "@/utils/daemon-endpoints"; import { parseConnectionOfferFromUrl } from "@getpaseo/protocol/connection-offer"; -import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet"; +import { + AdaptiveModalSheet, + AdaptiveTextInput, + type SheetHeader, +} from "@/components/adaptive-modal-sheet"; import { getConnectionAuthFailureReason } from "@/utils/test-daemon-connection"; -import { PairingTargetTracker } from "./pair-link-credentials"; +import { PairingTargetTracker } from "@/components/pair-link-credentials"; import { Button } from "@/components/ui/button"; import type { EditingTextInputHandle } from "@/components/ui/text-input"; @@ -100,7 +104,6 @@ function PairLinkModalContent({ onCancel, onSaved, }: PairLinkModalProps) { - const { theme } = useUnistyles(); const { t } = useTranslation(); const daemons = useHosts(); const { beginLinkPairing } = useHostMutations(); @@ -126,10 +129,7 @@ function PairLinkModalContent({ resetPasswordInput(); }, []); - const pairIcon = useMemo( - () => , - [theme.colors.accentForeground], - ); + const pairIcon = useMemo(() => , []); const handleClose = useCallback(() => { if (isSaving) return; @@ -212,7 +212,7 @@ function PairLinkModalContent({ {t("pairing.link.label")} - {t("pairing.hostPassword.title", { host: parsedHostLabel(offerUrlRef.current) })} - ); } + +const ThemedLink = withUnistyles(Link, (theme) => ({ color: theme.colors.accentForeground })); + +const ThemedAdaptiveTextInput = withUnistyles(AdaptiveTextInput, (theme) => ({ + placeholderTextColor: theme.colors.foregroundMuted, +})); diff --git a/packages/app/src/hosts/pair-scan-model.test.ts b/packages/app/src/hosts/add-host/internal/pair-scan-model.test.ts similarity index 100% rename from packages/app/src/hosts/pair-scan-model.test.ts rename to packages/app/src/hosts/add-host/internal/pair-scan-model.test.ts diff --git a/packages/app/src/hosts/pair-scan-model.ts b/packages/app/src/hosts/add-host/internal/pair-scan-model.ts similarity index 100% rename from packages/app/src/hosts/pair-scan-model.ts rename to packages/app/src/hosts/add-host/internal/pair-scan-model.ts diff --git a/packages/app/src/app/pair-scan.tsx b/packages/app/src/hosts/add-host/internal/scan.tsx similarity index 71% rename from packages/app/src/app/pair-scan.tsx rename to packages/app/src/hosts/add-host/internal/scan.tsx index 5d352140ee..1d96e326df 100644 --- a/packages/app/src/app/pair-scan.tsx +++ b/packages/app/src/hosts/add-host/internal/scan.tsx @@ -1,17 +1,14 @@ import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react"; import { useTranslation } from "react-i18next"; -import { Pressable, Text, View } from "react-native"; -import { useLocalSearchParams, useRouter, type Href } from "expo-router"; +import { Text, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet } from "react-native-unistyles"; import { CameraView, useCameraPermissions } from "expo-camera"; import type { BarcodeScanningResult, BarcodeSettings } from "expo-camera"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; -import { openPairScan } from "@/hosts/pair-scan-model"; -import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes"; +import { openPairScan } from "./pair-scan-model"; import { isWeb } from "@/constants/platform"; -import { BackHeader } from "@/components/headers/back-header"; -import { PairLinkModal } from "@/components/pair-link-modal"; +import { PairLinkModal } from "./pair-link-modal"; import { Button } from "@/components/ui/button"; const styles = StyleSheet.create((theme) => ({ @@ -113,50 +110,26 @@ const styles = StyleSheet.create((theme) => ({ }, })); -export default function PairScanScreen() { - const { theme } = useUnistyles(); +export function PairScanContent({ + onConnected, + onClose, +}: { + onConnected: (serverId: string) => void; + onClose: () => void; +}) { const { t } = useTranslation(); const insets = useSafeAreaInsets(); - const router = useRouter(); - const params = useLocalSearchParams<{ - source?: string; - }>(); - const source = typeof params.source === "string" ? params.source : "settings"; - const [permission, requestPermission] = useCameraPermissions(); - const navigateToPairedHost = useCallback( - (serverId: string) => { - if (source === "onboarding") { - router.replace(buildHostRootRoute(serverId)); - return; - } - router.replace(buildSettingsHostRoute(serverId)); - }, - [router, source], - ); - const [scan] = useState(() => openPairScan({ - importConnectionLink: (link) => - getHostRuntimeStore().importConnectionLink( - link, - source === "onboarding" ? "hostRoot" : "hostSettings", - ), - onConnected: navigateToPairedHost, + importConnectionLink: (link) => getHostRuntimeStore().importConnectionLink(link, "hostRoot"), + onConnected, }), ); const scanState = useSyncExternalStore(scan.subscribe, scan.getState, scan.getState); useEffect(() => () => scan.close(), [scan]); - const closeToSource = useCallback(() => { - try { - router.back(); - } catch { - router.replace("/" as Href); - } - }, [router]); - useEffect(() => { if (isWeb) return; if (permission && permission.granted) return; @@ -169,35 +142,29 @@ export default function PairScanScreen() { [scan], ); - const handleRouterBack = useCallback(() => router.back(), [router]); const savePasswordPairing = useCallback( - ({ serverId }: { serverId: string }) => navigateToPairedHost(serverId), - [navigateToPairedHost], + ({ serverId }: { serverId: string }) => onConnected(serverId), + [onConnected], ); const handleRequestPermission = useCallback(() => { void requestPermission(); }, [requestPermission]); const bodyStyle = useMemo( - () => [styles.body, { paddingBottom: insets.bottom + theme.spacing[6] }], - [insets.bottom, theme.spacing], - ); - const helperTextStyle = useMemo( - () => [styles.helperText, { color: theme.colors.foreground }], - [theme.colors.foreground], + () => [styles.body, { paddingBottom: insets.bottom + 24 }], + [insets.bottom], ); if (isWeb) { return ( - {t("pairing.scan.webUnavailableTitle")} {t("pairing.scan.webUnavailableBody")} - - {t("pairing.scan.backToSettings")} - + @@ -208,16 +175,14 @@ export default function PairScanScreen() { return ( - - {!granted ? ( {t("pairing.scan.cameraPermissionTitle")} {t("pairing.scan.cameraPermissionBody")} - + ) : ( @@ -235,10 +200,10 @@ export default function PairScanScreen() { {scanState.status === "pairing" ? ( - {t("pairing.scan.pairing")} + {t("pairing.scan.pairing")} ) : null} {scanState.status === "stopped" && scanState.error ? ( - {scanState.error} + {scanState.error} ) : null} {scanState.status === "stopped" ? (