From 304cbde651a73e95e0077b21ade240637a7d03ba Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 29 Oct 2026 10:17:11 +0700 Subject: [PATCH 01/95] fix(server): reopening a Codex agent no longer reads and holds its full history (#8132) * fix(server): read Codex history only when Paseo replays it Reopening a Codex agent whose timeline Paseo still held read the full thread on connect and kept it on the session until the next replay, which never came, so every reopened agent held a second copy of its transcript. thread/resume also returned every turn, and only the sandbox policy was used from it. Resume now sends excludeTurns (older Codex ignores the unknown field) and the history read moves into streamHistory, whose caller owns the result. A resumed session still needs the earlier turns for rewind targets and the sub-agent routes that keep an earlier child's interactions on its original card, so the first turn, goal or rewind indexes the thread and drops the timeline it read. * test(server): run reopened Codex agent tests without the Codex CLI installed --- .../providers/codex-app-server-agent.test.ts | 235 ++++++++++++++++-- .../agent/providers/codex-app-server-agent.ts | 100 ++++---- 2 files changed, 271 insertions(+), 64 deletions(-) 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 1d113fb27a..2169a059f2 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 @@ -16,6 +16,7 @@ import type { AgentSessionConfig, AgentSlashCommand, AgentStreamEvent, + AgentTimelineItem, } from "../agent-sdk-types.js"; import { buildCodexAppServerEnv, @@ -114,7 +115,6 @@ interface CodexSessionTestAccess { ensureThreadLoaded(): Promise; handleToolApprovalRequest(params: unknown): Promise; handleNotification(method: string, params: unknown): Promise; - loadPersistedHistory(client: CodexClientLike | null): Promise; refreshResolvedCollaborationMode(): void; serviceTier: "fast" | null; planModeEnabled: boolean; @@ -503,6 +503,81 @@ function archivedThreadErrorMessage(threadId: string): string { ); } +function codexUserItem(id: string, text: string) { + return { type: "userMessage", id, content: [{ type: "text", text }] }; +} + +function codexAgentItem(text: string) { + return { type: "agentMessage", id: `agent-${text}`, text }; +} + +// An AgentManager over a real Codex provider whose every spawn gets a fresh fake +// app-server serving `threads` from thread/read. +function createReopenableCodexAgent(threads: Record) { + const appServers: FakeCodexAppServer[] = []; + const provider = new CodexAppServerAgentClient(createTestLogger()); + const internals = castInternals<{ + goalsEnabledPromise: Promise | null; + autoReviewEnabledPromise: Promise | null; + spawnAppServer: () => Promise; + }>(provider); + internals.goalsEnabledPromise = Promise.resolve(false); + internals.autoReviewEnabledPromise = Promise.resolve(false); + internals.spawnAppServer = async () => { + const appServer = createFakeCodexAppServer({ + "thread/read": (params) => { + const { threadId } = params as { threadId: string }; + return { thread: { id: threadId, turns: threads[threadId] ?? [] } }; + }, + }); + appServers.push(appServer); + return appServer.child; + }; + // The fake app-server stands in for the Codex CLI, which test machines may not have. + const client: AgentClient = { + provider: "codex", + capabilities: provider.capabilities, + isAvailable: async () => true, + fetchCatalog: async () => ({ models: [], modes: [] }), + createSession: async () => { + throw new Error("Unexpected create"); + }, + resumeSession: (...args) => provider.resumeSession(...args), + }; + const manager = new AgentManager({ clients: { codex: client }, logger: createTestLogger() }); + const agentId = "00000000-0000-4000-8000-000000000001"; + const handle = { provider: "codex", sessionId: "saved-thread" }; + const config = createConfig({ cwd: process.cwd() }); + + async function open(): Promise { + await manager.resumeAgentFromPersistence(handle, config, agentId); + await manager.hydrateTimelineFromProvider(agentId); + } + + return { + manager, + agentId, + latestAppServer: () => appServers[appServers.length - 1], + threadMethods: (index: number) => + appServers[index] + .requests() + .map((request) => request.method) + .filter((method) => typeof method === "string" && method.startsWith("thread/")), + // Cold-loads the agent, closes its runtime, and reopens it while Paseo keeps its timeline. + async loadCloseAndReopen(): Promise { + await open(); + const projected = manager.getTimeline(agentId); + await manager.closeAgent(agentId); + await open(); + return projected; + }, + async close(): Promise { + await manager.closeAgent(agentId); + for (const appServer of appServers) appServer.assertNoErrors(); + }, + }; +} + function asInternals(session: CodexTestSession): CodexSessionTestAccess { return castInternals(session); } @@ -1897,12 +1972,147 @@ describe("Codex app-server provider", () => { "thread/resume", "thread/unarchive", "thread/resume", - "thread/read", ]); await session.close(); appServer.assertNoErrors(); }); + test("resumes a Codex thread without asking for its turns", async () => { + const appServer = createFakeCodexAppServer(); + const provider = createProviderWithFakeAppServer(appServer); + + const session = await provider.resumeSession({ sessionId: "saved-thread" }); + + expect( + appServer + .requests() + .filter((request) => request.method === "thread/resume") + .map((request) => request.params), + ).toEqual([expect.objectContaining({ threadId: "saved-thread", excludeTurns: true })]); + await session.close(); + appServer.assertNoErrors(); + }); + + test("reopening an agent whose timeline Paseo still holds does not read Codex history", async () => { + const codex = createReopenableCodexAgent({ + "saved-thread": [ + { id: "turn-1", items: [codexUserItem("user-1", "Hello"), codexAgentItem("Hi there.")] }, + ], + }); + try { + const projected = await codex.loadCloseAndReopen(); + + expect(projected).toEqual([ + expect.objectContaining({ type: "user_message", messageId: "user-1", text: "Hello" }), + expect.objectContaining({ type: "assistant_message", text: "Hi there." }), + ]); + expect(codex.threadMethods(0)).toEqual([ + "thread/loaded/list", + "thread/resume", + "thread/read", + ]); + expect(codex.threadMethods(1)).toEqual(["thread/loaded/list", "thread/resume"]); + expect(codex.manager.getTimeline(codex.agentId)).toEqual(projected); + } finally { + await codex.close(); + } + }); + + test("rewinds an agent reopened without reading its Codex history", async () => { + const firstTurn = { + id: "turn-1", + items: [codexUserItem("user-1", "First"), codexAgentItem("First answer.")], + }; + const codex = createReopenableCodexAgent({ + "saved-thread": [ + firstTurn, + { id: "turn-2", items: [codexUserItem("user-2", "Second"), codexAgentItem("Second.")] }, + ], + "forked-thread": [firstTurn], + }); + try { + await codex.loadCloseAndReopen(); + + await codex.manager.rewind(codex.agentId, "user-2", "conversation"); + + expect(codex.latestAppServer().recordedRollbacks).toEqual([ + { threadId: "forked-thread", numTurns: 1 }, + ]); + expect(codex.manager.getTimeline(codex.agentId)).toEqual([ + expect.objectContaining({ type: "user_message", messageId: "user-1", text: "First" }), + expect.objectContaining({ type: "assistant_message", text: "First answer." }), + ]); + } finally { + await codex.close(); + } + }); + + test("keeps a reopened agent's interaction with an earlier sub-agent on its original card", async () => { + const codex = createReopenableCodexAgent({ + "saved-thread": [ + { + id: "turn-1", + items: [ + codexUserItem("user-1", "Delegate"), + { + type: "subAgentActivity", + id: "spawn-child", + kind: "started", + agentThreadId: "child-thread", + agentPath: "/root/child", + }, + ], + }, + ], + "child-thread": [{ id: "child-turn-1", items: [codexAgentItem("Child started.")] }], + }); + try { + await codex.loadCloseAndReopen(); + const appServer = codex.latestAppServer(); + + const run = codex.manager.runAgent(codex.agentId, "Ask the child again"); + await appServer.waitForTurnStart(); + appServer.startsTurn({ threadId: "saved-thread", turnId: "turn-2" }); + appServer.beginsSubAgentActivity({ + callId: "interact-child", + threadId: "child-thread", + parentThreadId: "saved-thread", + kind: "interacted", + agentPath: "/root/child", + }); + appServer.says({ threadId: "child-thread", itemId: "child-reply", text: "Child replied." }); + appServer.completesSubAgentActivity({ + callId: "interact-child", + threadId: "child-thread", + parentThreadId: "saved-thread", + kind: "interacted", + agentPath: "/root/child", + }); + appServer.completeTurn({ threadId: "saved-thread" }); + await run; + + const toolCalls = codex.manager + .getTimeline(codex.agentId) + .filter((item) => item.type === "tool_call"); + expect(new Set(toolCalls.map((item) => item.callId))).toEqual(new Set(["spawn-child"])); + expect(toolCalls.at(-1)).toMatchObject({ callId: "spawn-child", status: "completed" }); + expect(codex.manager.getProviderSubagent(codex.agentId, "child-thread")).toMatchObject({ + toolCallId: "spawn-child", + status: "completed", + }); + expect( + codex.manager + .fetchProviderSubagentTimeline(codex.agentId, "child-thread") + .rows.map((row) => row.item), + ).toEqual([ + expect.objectContaining({ type: "assistant_message", text: "Child started." }), + expect.objectContaining({ type: "assistant_message", text: "Child replied." }), + ]); + } finally { + await codex.close(); + } + }); + test("closes Codex app-server when an interactive resume fails", async () => { const appServer = createFakeCodexAppServer({ "thread/resume": () => Promise.reject(new Error("thread resume failed")), @@ -4673,8 +4883,6 @@ describe("Codex app-server provider", () => { }), }; - await asInternals(session).loadPersistedHistory(session.client); - const history: AgentStreamEvent[] = []; for await (const event of session.streamHistory()) { history.push(event); @@ -4727,7 +4935,7 @@ describe("Codex app-server provider", () => { })), }; - await asInternals(session).loadPersistedHistory(session.client); + await Array.fromAsync(session.streamHistory()); expect(asInternals(session).codexUserMessageTurns().resolve("message-history")).toEqual({ index: 0, @@ -4796,8 +5004,6 @@ describe("Codex app-server provider", () => { }), }; - await asInternals(session).loadPersistedHistory(session.client); - const history: AgentStreamEvent[] = []; for await (const event of session.streamHistory()) { history.push(event); @@ -4958,8 +5164,6 @@ describe("Codex app-server provider", () => { }), }; - await asInternals(session).loadPersistedHistory(session.client); - const history: AgentStreamEvent[] = []; for await (const event of session.streamHistory()) { history.push(event); @@ -5023,8 +5227,6 @@ describe("Codex app-server provider", () => { }), }; - await asInternals(session).loadPersistedHistory(session.client); - const history: AgentStreamEvent[] = []; for await (const event of session.streamHistory()) { history.push(event); @@ -5200,8 +5402,6 @@ describe("Codex app-server provider", () => { }), }; - await asInternals(session).loadPersistedHistory(session.client); - const history: AgentStreamEvent[] = []; for await (const event of session.streamHistory()) { history.push(event); @@ -5261,8 +5461,6 @@ describe("Codex app-server provider", () => { }), }; - await asInternals(session).loadPersistedHistory(session.client); - const history: AgentStreamEvent[] = []; for await (const event of session.streamHistory()) { history.push(event); @@ -5564,7 +5762,10 @@ describe("Codex app-server provider", () => { expect(session.currentThreadId).toBe("archived-thread-id"); expect(requests).toEqual([ { method: "thread/loaded/list", params: {} }, - { method: "thread/resume", params: { threadId: "archived-thread-id" } }, + { + method: "thread/resume", + params: { threadId: "archived-thread-id", excludeTurns: true }, + }, ]); }); @@ -6399,8 +6600,6 @@ describe("Codex app-server provider", () => { })), }; - await asInternals(session).loadPersistedHistory(session.client); - const history: AgentStreamEvent[] = []; for await (const event of session.streamHistory()) { history.push(event); 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 291841c730..cdad017d45 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 @@ -496,6 +496,11 @@ interface PersistedTimelineEntry { providerTurnId?: string; } +interface CodexThreadHistory { + timeline: PersistedTimelineEntry[]; + providerSubagentEvents: AgentStreamEvent[]; +} + interface PersistedSubAgentRoute { childThreadId: string; toolCall: ToolCallTimelineItem; @@ -3464,10 +3469,12 @@ export class CodexAppServerAgentSession implements AgentSession { private serviceTier: string | null = null; private speedModels: CodexModel[] = []; private planModeEnabled = false; - private historyPending = false; - private persistedHistory: PersistedTimelineEntry[] = []; - private loadingPersistedHistory = false; - private persistedProviderSubagentEvents: AgentStreamEvent[] = []; + // History read by rewind or an archived resume, held until streamHistory consumes it. + private stagedHistory: CodexThreadHistory | null = null; + // Collects provider subagent events emitted while a thread history read rebuilds routes. + private historySubagentEvents: AgentStreamEvent[] | null = null; + // A resumed thread's earlier turns and sub-agents are known only after reading its history. + private historyIndexed = true; private pendingPermissions = new Map(); private mcpElicitationPermissionIds = new Map(); private pendingPermissionHandlers = new Map(); @@ -3574,7 +3581,7 @@ export class CodexAppServerAgentSession implements AgentSession { if (this.resumeHandle?.sessionId) { this.currentThreadId = this.resumeHandle.sessionId; - this.historyPending = true; + this.historyIndexed = false; // Resuming can start a goal before the caller receives the session. if (this.initialResumePurpose === "interactive") this.startupEvents = []; } @@ -3622,7 +3629,7 @@ export class CodexAppServerAgentSession implements AgentSession { private async establishConnection(): Promise { if (this.initialResumePurpose === "history") { - await this.readArchivedHistory(); + this.stagedHistory = await this.readArchivedHistory(); this.connectionState = "history-ready"; return; } @@ -3655,7 +3662,6 @@ export class CodexAppServerAgentSession implements AgentSession { if (this.currentThreadId) { await this.ensureThreadLoaded(); - await this.loadPersistedHistory(this.client); await this.applyDefaultModelAndThinking(); } @@ -3970,39 +3976,40 @@ export class CodexAppServerAgentSession implements AgentSession { ); } - private async readArchivedHistory(): Promise { + private async readArchivedHistory(): Promise { const child = await this.spawnAppServer(); const client = new CodexAppServerClient(child, this.logger, () => this.traceContext()); try { await client.request("initialize", buildCodexAppServerInitializeParams()); client.notify("initialized", {}); - await this.loadPersistedHistory(client); + return await this.readThreadHistory(client); } finally { await client.dispose(); } } - private async loadPersistedHistory(client: CodexAppServerClientLike | null): Promise { - if (!client || !this.currentThreadId) return; - const threadId = this.currentThreadId; - - const history = await loadCodexThreadHistoryTimeline({ - threadId, + // Reads the thread and its sub-agent threads, rebuilding the turn index and + // sub-agent routes. The caller owns the returned history; nothing retains it. + private async readThreadHistory( + client: CodexAppServerClientLike | null, + ): Promise { + if (!client || !this.currentThreadId) { + return { timeline: [], providerSubagentEvents: [] }; + } + const { timeline, subAgentRoutes } = await loadCodexThreadHistoryTimeline({ + threadId: this.currentThreadId, cwd: this.config.cwd ?? null, - requestThread: (threadIdToRead) => { - return readCodexThread(client, threadIdToRead); - }, + requestThread: (threadIdToRead) => readCodexThread(client, threadIdToRead), }); - const { timeline, subAgentRoutes } = history; this.subAgentCallsByCallId.clear(); this.subAgentCallIdByChildThreadId.clear(); this.pendingSubAgentNotificationsByThreadId.clear(); - this.persistedProviderSubagentEvents = []; - this.loadingPersistedHistory = true; + const providerSubagentEvents: AgentStreamEvent[] = []; + this.historySubagentEvents = providerSubagentEvents; try { await this.loadPersistedSubAgentHistories(client, subAgentRoutes); } finally { - this.loadingPersistedHistory = false; + this.historySubagentEvents = null; } this.resetCodexUserMessageTurns(); for (const entry of timeline) { @@ -4013,8 +4020,15 @@ export class CodexAppServerAgentSession implements AgentSession { this.rememberCodexUserMessageTurn(entry.item.messageId, entry.providerTurnId); } } - this.persistedHistory = timeline; - this.historyPending = timeline.length > 0; + this.historyIndexed = true; + return { timeline, providerSubagentEvents }; + } + + // Turns and rewind route by the thread's earlier turns and sub-agents. A resumed + // session whose projection Paseo already holds reads them here and drops the rest. + private async ensureHistoryIndexed(): Promise { + if (this.historyIndexed) return; + await this.readThreadHistory(this.client); } private async loadPersistedSubAgentHistories( @@ -4066,7 +4080,8 @@ export class CodexAppServerAgentSession implements AgentSession { private async ensureThreadLoaded(): Promise { if (!this.client || !this.currentThreadId) return; - const params: Record = { threadId: this.currentThreadId }; + // Paseo reads history only when it replays it; the resume response needs no turns. + const params: Record = { threadId: this.currentThreadId, excludeTurns: true }; const developerInstructions = composeSystemPromptParts( this.config.systemPrompt, this.config.daemonAppendSystemPrompt, @@ -4386,6 +4401,7 @@ export class CodexAppServerAgentSession implements AgentSession { if (this.currentThreadId) { await this.ensureThreadLoaded(); + await this.ensureHistoryIndexed(); } else { await this.ensureThread(); } @@ -4559,21 +4575,12 @@ export class CodexAppServerAgentSession implements AgentSession { } async *streamHistory(): AsyncGenerator { - if ( - (!this.historyPending || this.persistedHistory.length === 0) && - this.persistedProviderSubagentEvents.length === 0 - ) { - return; - } - const history = this.persistedHistory; - const providerSubagents = this.persistedProviderSubagentEvents; - this.persistedHistory = []; - this.persistedProviderSubagentEvents = []; - this.historyPending = false; - for (const event of providerSubagents) { + const history = this.stagedHistory ?? (await this.readThreadHistory(this.client)); + this.stagedHistory = null; + for (const event of history.providerSubagentEvents) { yield event; } - for (const entry of history) { + for (const entry of history.timeline) { yield { type: "timeline", provider: CODEX_PROVIDER, @@ -4956,6 +4963,7 @@ export class CodexAppServerAgentSession implements AgentSession { } else { await this.ensureThread(); } + await this.ensureHistoryIndexed(); await revertCodexConversation({ client: this.client, @@ -4970,10 +4978,8 @@ export class CodexAppServerAgentSession implements AgentSession { setThreadId: async (threadId) => { this.currentThreadId = threadId; this.cachedRuntimeInfo = null; - this.persistedHistory = []; - this.historyPending = false; - await this.loadPersistedHistory(this.client); - this.reconcileAsyncQuestionsAfterRewind(); + this.stagedHistory = await this.readThreadHistory(this.client); + this.reconcileAsyncQuestionsAfterRewind(this.stagedHistory.timeline); }, }); } @@ -5211,6 +5217,8 @@ export class CodexAppServerAgentSession implements AgentSession { await this.connect(); if (this.currentThreadId) { await this.ensureThreadLoaded(); + // A goal runs turns of its own. + await this.ensureHistoryIndexed(); } else { await this.ensureThread(); } @@ -5404,8 +5412,8 @@ export class CodexAppServerAgentSession implements AgentSession { } private emitEvent(event: AgentStreamEvent): void { - if (this.loadingPersistedHistory && event.type === "provider_subagent") { - this.persistedProviderSubagentEvents.push(event); + if (this.historySubagentEvents && event.type === "provider_subagent") { + this.historySubagentEvents.push(event); return; } this.notifySubscribers(event); @@ -6583,9 +6591,9 @@ export class CodexAppServerAgentSession implements AgentSession { } } - private reconcileAsyncQuestionsAfterRewind(): void { + private reconcileAsyncQuestionsAfterRewind(timeline: readonly PersistedTimelineEntry[]): void { const retainedIds = new Set( - this.persistedHistory.flatMap(({ item }) => + timeline.flatMap(({ item }) => item.type === "tool_call" && item.name === "request_user_input_async" ? [item.callId] : [], ), ); From e12feb633f340a88effe04a7822da678c685f75b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 29 Oct 2026 11:24:34 +0700 Subject: [PATCH 02/95] feat(usage): show when each limit resets, and make usage rows easier to read (#8162) The reset countdown floored to one unit, so a weekly window read "resets 2d" from 2d 23h down to 2d 0h and looked stuck for a day; the session window had the same problem with hours. Countdowns now use two units ("2d 23h", "4h 59m"), and every row also shows the clock time it resets ("7:20 AM", "Sat 8:00 AM", "Oct 31, 3:00 PM"), so users can plan work around a reset without hovering. Rows now put the window name and percent on one line, the bar under it, and the reset on its own full-width line, so the exact time fits at phone width in longer languages. The pinned-row background is gone; the pin glyph, drawn in the foreground colour when pinned, is the only pinned signal. Bars are 6px. --- packages/app/e2e/browser/usage-modal.spec.ts | 8 +- packages/app/src/i18n/resources/ar.ts | 3 + packages/app/src/i18n/resources/en.ts | 7 +- packages/app/src/i18n/resources/es.ts | 3 + packages/app/src/i18n/resources/fr.ts | 3 + packages/app/src/i18n/resources/ja.ts | 3 + packages/app/src/i18n/resources/ko.ts | 3 + packages/app/src/i18n/resources/pt-BR.ts | 3 + packages/app/src/i18n/resources/ru.ts | 3 + packages/app/src/i18n/resources/zh-CN.ts | 3 + packages/app/src/usage/balance-bar.tsx | 99 +++++---------- packages/app/src/usage/format.test.ts | 69 ++++++++++- packages/app/src/usage/format.ts | 103 +++++++++------ packages/app/src/usage/meter.tsx | 4 +- packages/app/src/usage/reset-line.tsx | 48 +++++++ packages/app/src/usage/sidebar-item.tsx | 11 +- packages/app/src/usage/window-bar.tsx | 124 ++++++------------- packages/app/src/utils/time.ts | 7 +- 18 files changed, 291 insertions(+), 213 deletions(-) create mode 100644 packages/app/src/usage/reset-line.tsx diff --git a/packages/app/e2e/browser/usage-modal.spec.ts b/packages/app/e2e/browser/usage-modal.spec.ts index 1d98f6c591..6835e01bcb 100644 --- a/packages/app/e2e/browser/usage-modal.spec.ts +++ b/packages/app/e2e/browser/usage-modal.spec.ts @@ -717,15 +717,15 @@ test("open usage clocks advance without refreshing or reopening", async ({ page await openCompactUsageWithClock(page, now); const card = page.getByTestId("usage-report-login-journey:account"); await expect(card.getByTestId("usage-freshness")).toHaveText("Updated just now"); - await expect(card.getByText("resets 30m", { exact: false }).first()).toBeVisible(); + await expect(card.getByText("resets in 30m", { exact: false }).first()).toBeVisible(); // The shared minute clock is wall-aligned, and the daemon timestamps use real time. // After two browser minutes the latest displayed tick is one or two minutes old. await advanceUsageClock(page, 120_000); await expect(card.getByTestId("usage-freshness")).toHaveText(/^Updated [12]m ago$/); - await expect(card.getByText(/resets (28|29)m/).first()).toBeVisible(); - await expect(card.getByText(/runs out (28|29)m/)).toBeVisible(); + await expect(card.getByText(/resets in (28|29)m/).first()).toBeVisible(); + await expect(card.getByText(/runs out in (28|29)m/)).toBeVisible(); await expect(card.getByTestId("provider-usage-balance-credits-reset")).toHaveText( - /^resets (28|29)m$/, + /^resets in (28|29)m\s·\s/, ); } finally { await login.cleanup(); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 2f285ebcdb..1cb6f0da06 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -2157,6 +2157,7 @@ export const ar: TranslationResources = { minutes_few: "{{value}} دقائق", minutes_many: "{{value}} دقيقة", minutes_other: "{{value}} دقيقة", + pair: "{{first}} و{{second}}", }, timing: { lastUpdated: "آخر تحديث {{relativeTime}}", @@ -2164,6 +2165,8 @@ export const ar: TranslationResources = { resetTimePassed: "مضى وقت إعادة التعيين", resettingNow: "تتم إعادة التعيين الآن", resetsIn: "تتم إعادة التعيين خلال {{duration}}", + resetsAtWeekday: "{{weekday}} {{time}}", + resetsAtDate: "{{date}}، {{time}}", runsOutNow: "ينفد الآن", runsOutIn: "ينفد خلال {{duration}}", justNow: "الآن", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 896e8ebac7..0511af34e2 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -2164,15 +2164,18 @@ export const en = { days: "{{value}}d", hours: "{{value}}h", minutes: "{{value}}m", + pair: "{{first}} {{second}}", }, timing: { lastUpdated: "Last updated {{relativeTime}}", noSuccessfulUpdate: "No successful update", resetTimePassed: "Reset time passed", resettingNow: "resetting now", - resetsIn: "resets {{duration}}", + resetsIn: "resets in {{duration}}", + resetsAtWeekday: "{{weekday}} {{time}}", + resetsAtDate: "{{date}}, {{time}}", runsOutNow: "runs out now", - runsOutIn: "runs out {{duration}}", + runsOutIn: "runs out in {{duration}}", justNow: "just now", daysAgo: "{{value}}d ago", hoursAgo: "{{value}}h ago", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 016eb99c6e..ffe9cedc1e 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -2187,6 +2187,7 @@ export const es: TranslationResources = { days: "{{value}} d", hours: "{{value}} h", minutes: "{{value}} min", + pair: "{{first}} {{second}}", }, timing: { lastUpdated: "Última actualización {{relativeTime}}", @@ -2194,6 +2195,8 @@ export const es: TranslationResources = { resetTimePassed: "Pasó la hora de restablecimiento", resettingNow: "se restablece ahora", resetsIn: "se restablece en {{duration}}", + resetsAtWeekday: "{{weekday}} {{time}}", + resetsAtDate: "{{date}}, {{time}}", runsOutNow: "se agota ahora", runsOutIn: "se agota en {{duration}}", justNow: "ahora mismo", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index cbbf2d9ec8..6c9df18211 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -2187,6 +2187,7 @@ export const fr: TranslationResources = { days: "{{value}} j", hours: "{{value}} h", minutes: "{{value}} min", + pair: "{{first}} {{second}}", }, timing: { lastUpdated: "Dernière mise à jour {{relativeTime}}", @@ -2194,6 +2195,8 @@ export const fr: TranslationResources = { resetTimePassed: "Heure de réinitialisation dépassée", resettingNow: "réinitialisation en cours", resetsIn: "se réinitialise dans {{duration}}", + resetsAtWeekday: "{{weekday}} {{time}}", + resetsAtDate: "{{date}}, {{time}}", runsOutNow: "s’épuise maintenant", runsOutIn: "s’épuise dans {{duration}}", justNow: "à l’instant", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 05a7aa457e..dd214ebac9 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -2160,6 +2160,7 @@ export const ja: TranslationResources = { days: "{{value}}日", hours: "{{value}}時間", minutes: "{{value}}分", + pair: "{{first}}{{second}}", }, timing: { lastUpdated: "最終更新: {{relativeTime}}", @@ -2167,6 +2168,8 @@ export const ja: TranslationResources = { resetTimePassed: "リセット時刻を過ぎています", resettingNow: "まもなくリセット", resetsIn: "{{duration}}後にリセット", + resetsAtWeekday: "{{weekday}} {{time}}", + resetsAtDate: "{{date}} {{time}}", runsOutNow: "まもなく上限に到達", runsOutIn: "{{duration}}後に上限に到達", justNow: "たった今", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index d8a7491e92..d30635b2a5 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -2149,6 +2149,7 @@ export const ko: TranslationResources = { days: "{{value}}일", hours: "{{value}}시간", minutes: "{{value}}분", + pair: "{{first}} {{second}}", }, timing: { lastUpdated: "마지막 업데이트: {{relativeTime}}", @@ -2156,6 +2157,8 @@ export const ko: TranslationResources = { resetTimePassed: "초기화 시간이 지났습니다", resettingNow: "지금 재설정 중", resetsIn: "{{duration}} 후 재설정", + resetsAtWeekday: "{{weekday}} {{time}}", + resetsAtDate: "{{date}} {{time}}", runsOutNow: "지금 한도에 도달", runsOutIn: "{{duration}} 후 한도에 도달", justNow: "방금", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index b2abd39b25..1d9ce0cb95 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -2172,6 +2172,7 @@ export const ptBR: TranslationResources = { days: "{{value}} d", hours: "{{value}} h", minutes: "{{value}} min", + pair: "{{first}} {{second}}", }, timing: { lastUpdated: "Última atualização {{relativeTime}}", @@ -2179,6 +2180,8 @@ export const ptBR: TranslationResources = { resetTimePassed: "O horário de redefinição passou", resettingNow: "redefinindo agora", resetsIn: "redefine em {{duration}}", + resetsAtWeekday: "{{weekday}} {{time}}", + resetsAtDate: "{{date}}, {{time}}", runsOutNow: "esgota agora", runsOutIn: "esgota em {{duration}}", justNow: "agora mesmo", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index cdacf42173..766f8bdae7 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -2172,6 +2172,7 @@ export const ru: TranslationResources = { days: "{{value}} дн.", hours: "{{value}} ч", minutes: "{{value}} мин", + pair: "{{first}} {{second}}", }, timing: { lastUpdated: "Последнее обновление {{relativeTime}}", @@ -2179,6 +2180,8 @@ export const ru: TranslationResources = { resetTimePassed: "Время сброса прошло", resettingNow: "сброс сейчас", resetsIn: "сброс через {{duration}}", + resetsAtWeekday: "{{weekday}} {{time}}", + resetsAtDate: "{{date}}, {{time}}", runsOutNow: "закончится сейчас", runsOutIn: "закончится через {{duration}}", justNow: "только что", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index f8131c7dca..64867d7d21 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -2117,6 +2117,7 @@ export const zhCN: TranslationResources = { days: "{{value}}天", hours: "{{value}}小时", minutes: "{{value}}分钟", + pair: "{{first}}{{second}}", }, timing: { lastUpdated: "上次更新于 {{relativeTime}}", @@ -2124,6 +2125,8 @@ export const zhCN: TranslationResources = { resetTimePassed: "重置时间已过", resettingNow: "正在重置", resetsIn: "将在 {{duration}}后重置", + resetsAtWeekday: "{{weekday}} {{time}}", + resetsAtDate: "{{date}} {{time}}", runsOutNow: "即将用尽", runsOutIn: "将在 {{duration}}后用尽", justNow: "刚刚", diff --git a/packages/app/src/usage/balance-bar.tsx b/packages/app/src/usage/balance-bar.tsx index def723c554..bcd33873cc 100644 --- a/packages/app/src/usage/balance-bar.tsx +++ b/packages/app/src/usage/balance-bar.tsx @@ -1,10 +1,16 @@ -import { useMemo } from "react"; import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; -import { Text, View, type StyleProp, type ViewStyle } from "react-native"; +import { Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; -import { clampPct, formatAmount, formatProviderUsageLabel, formatResetLabel } from "./format"; -import type { UsageBalance, UsageTone } from "./types"; +import { + formatAmount, + formatProviderUsageLabel, + formatResetLabel, + formatResetTime, +} from "./format"; +import { UsageMeter } from "./meter"; +import { UsageResetLine } from "./reset-line"; +import type { UsageBalance } from "./types"; import { useRelativeTimeTick } from "./use-relative-time-tick"; interface ResolvedBalance { @@ -41,19 +47,6 @@ function resolveBalance({ return { amountText: "—", usedPct: null }; } -function fillToneStyle(tone: UsageTone) { - switch (tone) { - case "ok": - return styles.fillOk; - case "warning": - return styles.fillWarning; - case "danger": - return styles.fillDanger; - default: - return styles.fillDefault; - } -} - export function UsageBalanceBar({ balance, retained = false, @@ -66,11 +59,7 @@ export function UsageBalanceBar({ const { amountText, usedPct } = resolveBalance({ balance, t, locale: i18n.resolvedLanguage }); const tone = balance.tone ?? "default"; const resetLabel = formatResetLabel(balance.resetsAt, retained, now, t); - - const fillStyle = useMemo>( - () => [styles.fill, fillToneStyle(tone), { width: `${clampPct(usedPct ?? 0)}%` }], - [usedPct, tone], - ); + const resetTime = formatResetTime(balance.resetsAt, now, t); return ( @@ -78,22 +67,21 @@ export function UsageBalanceBar({ {formatProviderUsageLabel(balance.id, balance.label, t)} - - - {amountText} - - {resetLabel ? ( - - {resetLabel} - - ) : null} - + + {amountText} + - {usedPct != null ? ( - - - - ) : null} + {usedPct != null ? : null} + ); } @@ -110,44 +98,13 @@ const styles = StyleSheet.create((theme) => ({ }, label: { flexShrink: 1, - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - }, - value: { - flexShrink: 1, - alignItems: "flex-end", - }, - amount: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.medium, }, - reset: { - color: theme.colors.foregroundMuted, + amount: { + flexShrink: 1, + color: theme.colors.foreground, fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.normal, - textAlign: "right", - }, - track: { - height: 4, - borderRadius: 2, - backgroundColor: theme.colors.surface3, - overflow: "hidden", - }, - fill: { - height: 4, - borderRadius: 2, - }, - fillDefault: { - backgroundColor: theme.colors.foregroundMuted, - }, - fillOk: { - backgroundColor: theme.colors.statusSuccess, - }, - fillWarning: { - backgroundColor: theme.colors.statusWarning, - }, - fillDanger: { - backgroundColor: theme.colors.statusDanger, }, })); diff --git a/packages/app/src/usage/format.test.ts b/packages/app/src/usage/format.test.ts index b7b81f0e5a..89fb9a7ed6 100644 --- a/packages/app/src/usage/format.test.ts +++ b/packages/app/src/usage/format.test.ts @@ -7,6 +7,7 @@ import { formatPct, formatProviderUsageLabel, formatResetLabel, + formatResetTime, formatRunsOutLabel, } from "./format"; @@ -36,8 +37,8 @@ describe("formatAmount", () => { const threeDaysAgo = new Date(NOW - 3 * 24 * 60 * 60 * 1000).toISOString(); expect(formatPct(7, "en")).toBe("7%"); - expect(formatResetLabel(twoHoursFromNow, false, NOW)).toBe("resets 2h"); - expect(formatRunsOutLabel(twoHoursFromNow, NOW)).toBe("runs out 2h"); + expect(formatResetLabel(twoHoursFromNow, false, NOW)).toBe("resets in 2h"); + expect(formatRunsOutLabel(twoHoursFromNow, NOW)).toBe("runs out in 2h"); await i18n.changeLanguage("ja"); expect(formatResetLabel(twoHoursFromNow, false, NOW)).toBe("2時間後にリセット"); @@ -94,6 +95,70 @@ describe("formatAmount", () => { }); }); +describe("reset countdown", () => { + const MINUTE = 60_000; + const HOUR = 60 * MINUTE; + const DAY = 24 * HOUR; + const countdown = (ms: number) => formatResetLabel(new Date(NOW + ms).toISOString(), false, NOW); + + beforeEach(async () => { + await i18n.changeLanguage("en"); + }); + + it("keeps counting down inside a day instead of sitting on the rounded-down day", () => { + expect(countdown(2 * DAY + 23 * HOUR + 10 * MINUTE)).toBe("resets in 2d 23h"); + expect(countdown(2 * DAY + 22 * HOUR + 10 * MINUTE)).toBe("resets in 2d 22h"); + }); + + it("says two units, dropping a unit that is zero", () => { + expect(countdown(DAY + 30 * MINUTE)).toBe("resets in 1d"); + expect(countdown(4 * HOUR + 59 * MINUTE)).toBe("resets in 4h 59m"); + expect(countdown(HOUR)).toBe("resets in 1h"); + expect(countdown(37 * MINUTE)).toBe("resets in 37m"); + expect(countdown(59 * 1000)).toBe("resets in 0m"); + }); + + it("joins the at-risk countdown the same way", () => { + const iso = new Date(NOW + 3 * HOUR + 5 * MINUTE).toISOString(); + expect(formatRunsOutLabel(iso, NOW)).toBe("runs out in 3h 5m"); + }); +}); + +describe("exact reset time", () => { + // Local-time dates, so the thresholds hold in any timezone the tests run in. + const now = new Date(2026, 9, 29, 12, 0).getTime(); + const at = (...args: [number, number, number, number, number]) => + formatResetTime(new Date(...args).toISOString(), now)?.replace(/\s/g, " "); + + beforeEach(async () => { + await i18n.changeLanguage("en"); + }); + + it("shows only the time within 24 hours", () => { + expect(at(2026, 9, 29, 15, 0)).toMatch(/^(3:00 PM|15:00)$/); + expect(at(2026, 9, 30, 11, 59)).toMatch(/^(11:59 AM|11:59)$/); + }); + + it("adds the weekday within 7 days", () => { + expect(at(2026, 9, 30, 12, 0)).toMatch(/^Fri (12:00 PM|12:00)$/); + expect(at(2026, 10, 5, 11, 59)).toMatch(/^Thu (11:59 AM|11:59)$/); + }); + + it("adds the date after 7 days", () => { + expect(at(2026, 10, 5, 12, 0)).toMatch(/^Nov 5, (12:00 PM|12:00)$/); + }); + + it("names the weekday in the app language", async () => { + await i18n.changeLanguage("fr"); + expect(at(2026, 9, 31, 15, 0)).toMatch(/^sam\.? /); + }); + + it("says nothing for a time that has passed", () => { + expect(formatResetTime(new Date(now - 1).toISOString(), now)).toBeNull(); + expect(formatResetTime(null, now)).toBeNull(); + }); +}); + it("retained reset times that have passed do not claim the quota is resetting", () => { const past = "2000-01-01T00:00:00.000Z"; expect(formatResetLabel(past, true)).toBe("Reset time passed"); diff --git a/packages/app/src/usage/format.ts b/packages/app/src/usage/format.ts index e7a6ba6f7b..867976a586 100644 --- a/packages/app/src/usage/format.ts +++ b/packages/app/src/usage/format.ts @@ -1,5 +1,6 @@ import type { TFunction } from "i18next"; import { i18n } from "@/i18n/i18next"; +import { formatTimeOfDay } from "@/utils/time"; import type { UsageDisplayAs } from "./preferences"; import type { UsageBalanceUnit } from "./types"; @@ -50,7 +51,17 @@ export function formatDisplayPct( : t("providerUsage.values.remaining", { amount: percentage }); } -type RelativeDuration = { unit: "now" } | { unit: "minutes" | "hours" | "days"; count: number }; +type DurationUnit = "minutes" | "hours" | "days"; +interface DurationPart { + unit: DurationUnit; + count: number; +} +/** Two units, floored, so a countdown keeps moving: "2d 23h", "4h 59m", "37m". */ +type RelativeDuration = { kind: "now" } | { kind: "remaining"; parts: DurationPart[] }; + +const MINUTE_MS = 60_000; +const HOUR_MS = 60 * MINUTE_MS; +const DAY_MS = 24 * HOUR_MS; function formatCount(value: number): string { return new Intl.NumberFormat(i18n.resolvedLanguage).format(value); @@ -59,35 +70,34 @@ function formatCount(value: number): string { function relativeDuration(iso: string, now: number): RelativeDuration | null { const diffMs = new Date(iso).getTime() - now; if (!Number.isFinite(diffMs)) return null; - if (diffMs <= 0) return { unit: "now" }; - const diffMinutes = Math.floor(diffMs / 60_000); - const diffHours = Math.floor(diffMinutes / 60); - const diffDays = Math.floor(diffHours / 24); - if (diffDays > 0) return { unit: "days", count: diffDays }; - if (diffHours > 0) return { unit: "hours", count: diffHours }; - return { unit: "minutes", count: diffMinutes }; + if (diffMs <= 0) return { kind: "now" }; + const days = Math.floor(diffMs / DAY_MS); + const hours = Math.floor((diffMs % DAY_MS) / HOUR_MS); + const minutes = Math.floor((diffMs % HOUR_MS) / MINUTE_MS); + if (days > 0) return remaining({ unit: "days", count: days }, { unit: "hours", count: hours }); + if (hours > 0) + return remaining({ unit: "hours", count: hours }, { unit: "minutes", count: minutes }); + return remaining({ unit: "minutes", count: minutes }); } -function formatRelativeDuration(duration: RelativeDuration, t: TFunction): string | null { - switch (duration.unit) { - case "now": - return null; - case "days": - return t("providerUsage.duration.days", { - count: duration.count, - value: formatCount(duration.count), - }); - case "hours": - return t("providerUsage.duration.hours", { - count: duration.count, - value: formatCount(duration.count), - }); - case "minutes": - return t("providerUsage.duration.minutes", { - count: duration.count, - value: formatCount(duration.count), - }); - } +/** The major unit, plus the minor one unless it is zero. */ +function remaining(major: DurationPart, minor?: DurationPart): RelativeDuration { + return { kind: "remaining", parts: minor && minor.count > 0 ? [major, minor] : [major] }; +} + +function formatDurationPart(part: DurationPart, t: TFunction): string { + return t(`providerUsage.duration.${part.unit}`, { + count: part.count, + value: formatCount(part.count), + }); +} + +function formatRelativeDuration( + duration: Extract, + t: TFunction, +): string { + const [first, second] = duration.parts.map((part) => formatDurationPart(part, t)); + return second ? t("providerUsage.duration.pair", { first, second }) : first; } export function formatResetLabel( @@ -99,10 +109,34 @@ export function formatResetLabel( if (!iso) return null; const rel = relativeDuration(iso, now); if (!rel) return null; - if (rel.unit === "now" && retained) return t("providerUsage.timing.resetTimePassed"); - if (rel.unit === "now") return t("providerUsage.timing.resettingNow"); - const duration = formatRelativeDuration(rel, t); - return duration ? t("providerUsage.timing.resetsIn", { duration }) : null; + if (rel.kind === "now" && retained) return t("providerUsage.timing.resetTimePassed"); + if (rel.kind === "now") return t("providerUsage.timing.resettingNow"); + return t("providerUsage.timing.resetsIn", { duration: formatRelativeDuration(rel, t) }); +} + +/** + * When a window resets on the clock: "3:00 PM" within a day, "Fri 3:00 PM" within a week, + * "Oct 31, 3:00 PM" after that. Names follow the app language; 12/24h follows the device. + * Nothing for a time that has passed, where the countdown already says so. + */ +export function formatResetTime( + iso: string | null | undefined, + now = Date.now(), + t: TFunction = i18n.t, +): string | null { + if (!iso) return null; + const at = new Date(iso); + const diffMs = at.getTime() - now; + if (!Number.isFinite(diffMs) || diffMs <= 0) return null; + const time = formatTimeOfDay(at); + const locale = i18n.resolvedLanguage; + if (diffMs < DAY_MS) return time; + if (diffMs < 7 * DAY_MS) { + const weekday = at.toLocaleDateString(locale, { weekday: "short" }); + return t("providerUsage.timing.resetsAtWeekday", { weekday, time }); + } + const date = at.toLocaleDateString(locale, { month: "short", day: "numeric" }); + return t("providerUsage.timing.resetsAtDate", { date, time }); } export function formatRunsOutLabel( @@ -113,9 +147,8 @@ export function formatRunsOutLabel( if (!iso) return null; const rel = relativeDuration(iso, now); if (!rel) return null; - if (rel.unit === "now") return t("providerUsage.timing.runsOutNow"); - const duration = formatRelativeDuration(rel, t); - return duration ? t("providerUsage.timing.runsOutIn", { duration }) : null; + if (rel.kind === "now") return t("providerUsage.timing.runsOutNow"); + return t("providerUsage.timing.runsOutIn", { duration: formatRelativeDuration(rel, t) }); } export function formatAgo( diff --git a/packages/app/src/usage/meter.tsx b/packages/app/src/usage/meter.tsx index 37842cafb5..4150477801 100644 --- a/packages/app/src/usage/meter.tsx +++ b/packages/app/src/usage/meter.tsx @@ -46,8 +46,8 @@ export function UsageMeter({ const styles = StyleSheet.create((theme) => ({ track: { - height: 4, - borderRadius: 2, + height: 6, + borderRadius: 3, backgroundColor: theme.colors.surface3, overflow: "hidden", }, diff --git a/packages/app/src/usage/reset-line.tsx b/packages/app/src/usage/reset-line.tsx new file mode 100644 index 0000000000..8016df2926 --- /dev/null +++ b/packages/app/src/usage/reset-line.tsx @@ -0,0 +1,48 @@ +import { Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; + +/** + * The reset data of a window or balance: the countdown first, then the exact time. When the line + * is too narrow the exact time gives way (truncates) and the countdown stays whole. + */ +export function UsageResetLine({ + countdown, + exactTime, + atRisk = false, + testID, +}: { + countdown: string | null; + exactTime: string | null; + /** The countdown is a runs-out warning rather than a reset. */ + atRisk?: boolean; + testID?: string; +}) { + if (!countdown) return null; + return ( + + {countdown} + {exactTime ? ( + + {`\u00a0·\u00a0${exactTime}`} + + ) : null} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + line: { flexDirection: "row", alignItems: "center" }, + text: { + flexShrink: 0, + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.normal, + }, + atRisk: { + flexShrink: 0, + color: theme.colors.statusDanger, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.normal, + }, + exactTime: { flexShrink: 1 }, +})); diff --git a/packages/app/src/usage/sidebar-item.tsx b/packages/app/src/usage/sidebar-item.tsx index 45a8a8b293..2ca99c4e29 100644 --- a/packages/app/src/usage/sidebar-item.tsx +++ b/packages/app/src/usage/sidebar-item.tsx @@ -192,12 +192,7 @@ function PinnedUsageLine({ {layout === "meters" ? ( - + ) : null} ({ minWidth: MIN_METER_WIDTH, marginRight: METER_GAP, }, - meter: { - height: 6, - borderRadius: 3, - }, percent: { flexShrink: 0, color: theme.colors.foreground, diff --git a/packages/app/src/usage/window-bar.tsx b/packages/app/src/usage/window-bar.tsx index 99c1eb9f21..b837872e01 100644 --- a/packages/app/src/usage/window-bar.tsx +++ b/packages/app/src/usage/window-bar.tsx @@ -1,29 +1,24 @@ import { Pin } from "lucide-react-native"; import { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native"; +import { Pressable, Text, View } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { formatDisplayPct, formatProviderUsageLabel, formatResetLabel, + formatResetTime, formatRunsOutLabel, } from "./format"; import { UsageMeter } from "./meter"; import { displayPercent, usageWindowRowLabel } from "./model"; +import { UsageResetLine } from "./reset-line"; import type { UsageDisplayAs } from "./preferences"; import { windowTone } from "./tone"; import type { UsageTone, UsageWindow } from "./types"; import { useRelativeTimeTick } from "./use-relative-time-tick"; -// Pinned rows carry the pinned surface; hovering an unpinned row previews it at half strength, -// so a hover never reads as the selection. Pinned rows do not react to hover. -function highlightStyle(pinned: boolean, hovered: boolean) { - if (pinned) return styles.highlightPinned; - return hovered ? styles.highlightHovered : styles.highlightNone; -} - export function UsageWindowBar({ window, displayAs, @@ -54,9 +49,12 @@ export function UsageWindowBar({ window.runsOutAt != null && window.shortfallPct != null && (!retained || Date.parse(window.runsOutAt) > now); - const trailing = isAtRisk + const countdown = isAtRisk ? formatRunsOutLabel(window.runsOutAt, now, t) : formatResetLabel(window.resetsAt, retained, now, t); + // The exact time belongs to the reset; beside a runs-out warning it would read as the moment + // the quota runs out. + const exactTime = isAtRisk ? null : formatResetTime(window.resetsAt, now, t); const value = shownPct != null ? formatDisplayPct(shownPct, displayAs, i18n.resolvedLanguage, t) : "—"; @@ -64,60 +62,57 @@ export function UsageWindowBar({ const content = { label: formatProviderUsageLabel(window.id, window.label, t), value, - trailing, + countdown, + exactTime, isAtRisk, percent: shownPct ?? 0, tone, pinned, }; - // Same padding as the pinnable row, so bars line up in both modes. if (!pinnable) { return ( - + ); } - // The whole row pins the window to the sidebar Usage item. Pinned or not, it keeps the same - // padding so toggling only changes the background. + // The whole row pins the window to the sidebar Usage item; the pin glyph is the only sign of it. return ( - {({ hovered }: { hovered?: boolean }) => ( - - )} + ); } function WindowRowContent({ - highlight, label, value, - trailing, + countdown, + exactTime, isAtRisk, percent, tone, pinnable, pinned, }: { - highlight: StyleProp; label: string; value: string; - trailing: string | null | undefined; + countdown: string | null; + exactTime: string | null; isAtRisk: boolean; percent: number; tone: UsageTone; @@ -126,24 +121,17 @@ function WindowRowContent({ }) { return ( <> - - - - - - {label} - - - {value} - {trailing ? ( - {` · ${trailing}`} - ) : null} - - - - + + + {label} + + + {value} + {pinnable ? : null} + + ); } @@ -153,9 +141,9 @@ const ThemedPin = withUnistyles(Pin); function UsagePinGlyph({ pinned }: { pinned: boolean }) { const { t } = useTranslation(); const iconMapping = useMemo( - () => (theme: { colors: { foregroundMuted: string } }) => ({ - color: theme.colors.foregroundMuted, - fill: pinned ? theme.colors.foregroundMuted : "none", + () => (theme: { colors: { foreground: string; foregroundMuted: string } }) => ({ + color: pinned ? theme.colors.foreground : theme.colors.foregroundMuted, + fill: pinned ? theme.colors.foreground : "none", }), [pinned], ); @@ -179,59 +167,23 @@ function UsagePinGlyph({ pinned }: { pinned: boolean }) { } const styles = StyleSheet.create((theme) => ({ - contentRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] }, - windowContent: { flex: 1, gap: 3 }, + row: { gap: 3, paddingVertical: theme.spacing[1.5] }, pin: { width: 12, alignItems: "center" }, tooltipText: { color: theme.colors.popoverForeground, fontSize: theme.fontSize.sm }, - row: { - gap: 3, - // The highlight bleeds into the card padding so the label and bar stay on the card's rail. - marginHorizontal: -theme.spacing[2], - paddingHorizontal: theme.spacing[2], - paddingVertical: theme.spacing[1.5], - // Its own stacking context, so the highlight layer paints above the card and below the text. - zIndex: 0, - }, - highlightNone: { - display: "none", - }, - highlightPinned: { - ...StyleSheet.absoluteFill, - zIndex: -1, - borderRadius: theme.borderRadius.md, - backgroundColor: theme.colors.surface2, - }, - // The pinned surface at half strength: a separate layer, so the text keeps full opacity. - highlightHovered: { - ...StyleSheet.absoluteFill, - zIndex: -1, - borderRadius: theme.borderRadius.md, - backgroundColor: theme.colors.surface2, - opacity: theme.opacity[50], - }, labelRow: { flexDirection: "row", - justifyContent: "space-between", alignItems: "center", gap: theme.spacing[2], }, label: { flex: 1, - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - }, - value: { - flexShrink: 1, color: theme.colors.foreground, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.medium, }, - reset: { - color: theme.colors.foregroundMuted, - fontWeight: theme.fontWeight.normal, - }, - atRisk: { - color: theme.colors.statusDanger, - fontWeight: theme.fontWeight.normal, + value: { + flexShrink: 0, + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, }, })); diff --git a/packages/app/src/utils/time.ts b/packages/app/src/utils/time.ts index 948ee8eeb3..1c063d1a27 100644 --- a/packages/app/src/utils/time.ts +++ b/packages/app/src/utils/time.ts @@ -125,6 +125,11 @@ function getTimeFormatter(): Intl.DateTimeFormat { return cachedTimeFormatter; } +/** A clock time in the device's 12h/24h preference: "3:00 PM" or "15:00". */ +export function formatTimeOfDay(date: Date): string { + return getTimeFormatter().format(date); +} + /** * Format a chat-message timestamp for hover-revealed UI. * - Same day: "10:11 PM" or "22:11" depending on user preference @@ -132,7 +137,7 @@ function getTimeFormatter(): Intl.DateTimeFormat { * - Older, including today's weekday last week: "14 May 2026, 10:11 PM" */ export function formatMessageTimestamp(date: Date, now: Date = new Date()): string { - const time = getTimeFormatter().format(date); + const time = formatTimeOfDay(date); const daysAgo = localCalendarDaysBetween(date, now); if (daysAgo === 0) { From e8201ab66b79a2c6438d3a7eb1edba688ccb7307 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 29 Oct 2026 12:45:41 +0700 Subject: [PATCH 03/95] Make Claude session entrypoint relabeling optional (#8164) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(claude): make session entrypoint relabeling opt-in Keep inherited SDK and parent identities cleared. Honor the explicit session-open entrypoint contribution so users can opt into the label through a plugin instead of every session receiving it unconditionally. * test(hub): use standard budget for restart and worktree recovery The Windows CI scenario timed out at its custom 20-second limit while the other Hub cases took 4–10 seconds each. This scenario starts two daemons and creates, archives and restores a worktree. Use the existing 30-second suite budget without changing any assertions. --- .../agent/providers/claude/agent.env.test.ts | 17 +++-- .../server/agent/providers/claude/query.ts | 5 +- .../hub/execution-session.websocket.test.ts | 2 +- .../plugins/agent-configuration.e2e.test.ts | 72 ++++++++++++++++++- 4 files changed, 86 insertions(+), 10 deletions(-) diff --git a/packages/server/src/server/agent/providers/claude/agent.env.test.ts b/packages/server/src/server/agent/providers/claude/agent.env.test.ts index 0806a0dbc6..6d268e932c 100644 --- a/packages/server/src/server/agent/providers/claude/agent.env.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.env.test.ts @@ -30,16 +30,20 @@ function createQueryMock(events: unknown[]): Query { } describe("Claude SDK env", () => { - test.each(["node", "bun", process.execPath])( - "identifies the spawned %s process as Paseo after clearing parent session env", - async (command) => { + test.each( + ["node", "bun", process.execPath].flatMap((command) => + [undefined, "Paseo", "custom-client"].map((entrypoint) => ({ command, entrypoint })), + ), + )( + "preserves explicit launch identity $entrypoint in $command while clearing inherited session env", + async ({ command, entrypoint }) => { let output = ""; let exited = Promise.resolve([]); const query = claudeQuery( { prompt: "", options: {} }, { runtimeSettings: { env: { CLAUDE_CODE_ENTRYPOINT: "sdk-ts" } }, - launchEnv: { CLAUDE_CODE_ENTRYPOINT: "sdk-cli" }, + launchEnv: entrypoint === undefined ? {} : { CLAUDE_CODE_ENTRYPOINT: entrypoint }, queryFactory: ({ options }) => { if (!options.spawnClaudeCodeProcess) { throw new Error("Missing Claude process launcher"); @@ -65,7 +69,10 @@ describe("Claude SDK env", () => { try { expect(await exited).toEqual([0, null]); - expect(JSON.parse(output)).toEqual({ entrypoint: "Paseo", parentSession: null }); + expect(JSON.parse(output)).toEqual({ + ...(entrypoint === undefined ? {} : { entrypoint }), + parentSession: null, + }); } finally { query.close(); } diff --git a/packages/server/src/server/agent/providers/claude/query.ts b/packages/server/src/server/agent/providers/claude/query.ts index c8fe4ba036..0f9525d5b1 100644 --- a/packages/server/src/server/agent/providers/claude/query.ts +++ b/packages/server/src/server/agent/providers/claude/query.ts @@ -73,8 +73,9 @@ function applyRuntimeSettingsToClaudeOptions( runtimeSettings, overlays: [launchEnv], }); - // Set our identity after sanitization clears the parent session's entrypoint. - providerEnvSpec.envOverlay.CLAUDE_CODE_ENTRYPOINT = "Paseo"; + // Session-open hooks may explicitly supply a client identity. Keep inherited + // SDK/parent identities cleared while honoring this launch-only contribution. + providerEnvSpec.envOverlay.CLAUDE_CODE_ENTRYPOINT = launchEnv?.CLAUDE_CODE_ENTRYPOINT; const providerEnv = createExternalProcessEnv(spawnOptions.env, providerEnvSpec.envOverlay); const selfNodeCommand = isDefaultRuntime ? buildSelfNodeCommand(resolved.args, providerEnv) diff --git a/packages/server/src/server/hub/execution-session.websocket.test.ts b/packages/server/src/server/hub/execution-session.websocket.test.ts index 88f376b3ba..266c55e196 100644 --- a/packages/server/src/server/hub/execution-session.websocket.test.ts +++ b/packages/server/src/server/hub/execution-session.websocket.test.ts @@ -248,7 +248,7 @@ test("ordinary Hub requests survive daemon restart and restore an archived works }), ).toMatchObject({ payload: { agentId, accepted: true } }); expect(hub.providerPromptTexts().filter((text) => text === "follow up")).toHaveLength(1); -}, 20_000); +}); test("Hub completes the standard hello before rejecting a second hello", async () => { const hub = await launchRelationship(); diff --git a/packages/server/src/server/plugins/agent-configuration.e2e.test.ts b/packages/server/src/server/plugins/agent-configuration.e2e.test.ts index 9f62075125..a3e4e273a4 100644 --- a/packages/server/src/server/plugins/agent-configuration.e2e.test.ts +++ b/packages/server/src/server/plugins/agent-configuration.e2e.test.ts @@ -1,10 +1,10 @@ -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, 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 { DaemonClient } from "../test-utils/daemon-client.js"; -import type { AgentSessionConfig } from "../agent/agent-sdk-types.js"; +import type { AgentClient, AgentSessionConfig } from "../agent/agent-sdk-types.js"; import { createTestAgentClient } from "../test-utils/fake-agent-client.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; @@ -72,3 +72,71 @@ test("the configuration example saves Codex options and injects launch-only MCP await rm(directory, { recursive: true, force: true }); } }, 60_000); + +test("session-open entrypoint contributions are optional and rederived for existing agents", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-entrypoint-hook-")); + const launches: { provider: string; env?: Record }[] = []; + function recordingProvider(provider: "claude" | "codex"): AgentClient { + const inner = createTestAgentClient(provider); + return { + provider: inner.provider, + capabilities: inner.capabilities, + isAvailable: () => inner.isAvailable(), + fetchCatalog: (options) => inner.fetchCatalog(options), + createSession: async (...args: Parameters) => { + launches.push({ provider, env: args[1]?.env }); + return inner.createSession(...args); + }, + resumeSession: async (...args: Parameters) => { + launches.push({ provider, env: args[2]?.env }); + return inner.resumeSession(...args); + }, + }; + } + const daemon = await createTestPaseoDaemon({ + daemonVersion: "0.14.0-beta.2", + agentClients: { claude: recordingProvider("claude"), codex: recordingProvider("codex") }, + mcpEnabled: false, + }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.14.0-beta.2", + }); + try { + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: "entrypoint-test", requirements: { paseo: ">=0.14.0-beta.2" } }), + ); + await writeFile( + path.join(directory, "index.server.ts"), + ` +export default function contribute(server) { + return server.before("agent.session_open", ({ request }) => { + if (request.provider !== "claude") return; + return { ...request, env: { ...request.env, CLAUDE_CODE_ENTRYPOINT: "custom-client" } }; + }); +} +`, + ); + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + const existing = await client.createAgent({ provider: "claude", cwd: directory }); + expect(launches.at(-1)?.env?.CLAUDE_CODE_ENTRYPOINT).toBeUndefined(); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(directory); + await daemon.daemon.agentManager.reloadAgentSession(existing.id); + expect(launches.at(-1)?.env?.CLAUDE_CODE_ENTRYPOINT).toBe("custom-client"); + await client.createAgent({ provider: "claude", cwd: directory }); + expect(launches.at(-1)?.env?.CLAUDE_CODE_ENTRYPOINT).toBe("custom-client"); + await client.createAgent({ provider: "codex", cwd: directory }); + expect(launches.at(-1)).toMatchObject({ provider: "codex" }); + expect(launches.at(-1)?.env?.CLAUDE_CODE_ENTRYPOINT).toBeUndefined(); + await client.disablePlugin("entrypoint-test"); + await daemon.daemon.agentManager.reloadAgentSession(existing.id); + expect(launches.at(-1)?.env?.CLAUDE_CODE_ENTRYPOINT).toBeUndefined(); + } finally { + await client.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true }); + } +}, 60_000); From 870a44bc7f04b2fafd0f2cb73a911125ecd15043 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 05:55:50 +0000 Subject: [PATCH 04/95] Deliver a steer message that arrives while the previous turn is still starting (#6192) * Deliver a steer message that arrives while the previous turn is still starting A steer send that reached an agent before its previous turn had a turn id found no turn to steer and replaced the starting turn, so the earlier message never reached the provider. Steering now waits for the starting turn to be accepted (or fail) and steers the turn it became. Fixes #6186 * Wake a waiting steer only once the starting turn has an id or its run has ended A failed start used to wake the waiting steer before its run was cleared, leaving the later send to replace a run that had already failed. * Clear a failed start's run even when reporting the failure throws A steer waiting on that start is released only when the run is cleared. * Test that a failed start keeps a waiting steer until its run is cleared * fix(server): steer Codex turns while native identification is pending Wait for the accepted foreground turn to receive its native ID before steering it. Returning unavailable in this window replaced the turn even after the manager had waited for startup. --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../src/server/agent/agent-manager.test.ts | 125 ++++++++++++++++++ .../server/src/server/agent/agent-manager.ts | 35 +++-- .../src/server/agent/agent-run-state.test.ts | 51 +++++++ .../src/server/agent/agent-run-state.ts | 40 +++++- .../providers/codex-app-server-agent.test.ts | 58 ++++++++ .../agent/providers/codex-app-server-agent.ts | 13 +- .../codex-steer-starting.real.e2e.test.ts | 80 +++++++++++ 7 files changed, 382 insertions(+), 20 deletions(-) create mode 100644 packages/server/src/server/agent/agent-run-state.test.ts create mode 100644 packages/server/src/server/daemon-e2e/codex-steer-starting.real.e2e.test.ts diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 1d6f323deb..bb521abc71 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -956,6 +956,131 @@ test("unavailable steer interrupts once and starts one replacement turn", async } }); +test("steers a send that arrives while the previous turn is still starting", async () => { + const startHeld = deferred(); + class SlowStartSteeringSession extends SteeringTestSession { + override async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> { + await startHeld.promise; + return await super.startTurn(prompt); + } + } + const session = new SlowStartSteeringSession({ + provider: "codex", + cwd: process.cwd(), + }); + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-starting-")); + 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 steerOptions = (clientMessageId: string) => ({ + replaceRunning: true, + activeTurnBehavior: "steer" as const, + runOptions: { clientMessageId }, + }); + await startAgentRun(manager, agent.id, "FIRST", logger, steerOptions("first-client")); + const second = startAgentRun( + manager, + agent.id, + "SECOND", + logger, + steerOptions("second-client"), + ); + + startHeld.resolve(); + await expect(second).resolves.toEqual({ disposition: "steered" }); + + expect(session.startPrompts).toEqual(["FIRST"]); + expect(session.interruptCount).toBe(0); + expect( + manager + .fetchTimeline(agent.id, { limit: 0 }) + .rows.filter((row) => row.item.type === "user_message") + .map((row) => ({ + text: row.item.type === "user_message" ? row.item.text : null, + turnId: row.turnId, + })), + ).toEqual([ + { text: "FIRST", turnId: "active-turn-1" }, + { text: "SECOND", turnId: "active-turn-1" }, + ]); + } finally { + startHeld.resolve(); + if (agentId) await manager.closeAgent(agentId).catch(() => undefined); + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test("starts its own turn when the previous turn fails to start under a steer send", async () => { + const startHeld = deferred(); + class FailingFirstStartSession extends SteeringTestSession { + override async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> { + if (prompt === "FIRST") { + this.startPrompts.push(prompt); + await startHeld.promise; + throw new Error("first start failed"); + } + return await super.startTurn(prompt); + } + } + const session = new FailingFirstStartSession({ + provider: "codex", + cwd: process.cwd(), + }); + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-failed-start-")); + 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 steerOptions = (clientMessageId: string) => ({ + replaceRunning: true, + activeTurnBehavior: "steer" as const, + runOptions: { clientMessageId }, + }); + await startAgentRun(manager, agent.id, "FIRST", logger, steerOptions("first-client")); + const second = startAgentRun( + manager, + agent.id, + "SECOND", + logger, + steerOptions("second-client"), + ); + + startHeld.resolve(); + await expect(second).resolves.toEqual({ disposition: "turn_started" }); + await vi.waitFor(() => expect(session.startPrompts).toEqual(["FIRST", "SECOND"])); + expect(session.interruptCount).toBe(0); + } finally { + startHeld.resolve(); + if (agentId) await manager.closeAgent(agentId).catch(() => undefined); + rmSync(workdir, { recursive: true, force: true }); + } +}); + test("orders an accepted steer before output emitted while acknowledgement is pending", 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 46c822ea2d..ae9e424d78 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -2734,7 +2734,7 @@ export class AgentManager { throw error; } if (isStaleProviderSessionError(error)) { - pendingRun.start = { status: "failed", error: error.message }; + this.runs.settleForegroundStart(pendingRun, { status: "failed", error: error.message }); agent.pendingReplacement = false; if (!agent.activeForegroundTurnId) agent.lifecycle = "idle"; this.runs.settleForegroundRun(agentId, pendingRun.token); @@ -2742,20 +2742,24 @@ export class AgentManager { } agent.pendingReplacement = false; const errorMsg = error instanceof Error ? error.message : "Failed to start turn"; - pendingRun.start = { status: "failed", error: errorMsg }; - // A terminal rejection belongs after the submitted prompt even though no provider turn exists. - if (options?.clientMessageId) { - this.recordSubmittedPrompt(agent, prompt, options.clientMessageId, "rejected", { - messageId: options.clientMessageId, + this.runs.settleForegroundStart(pendingRun, { status: "failed", error: errorMsg }); + try { + // A terminal rejection belongs after the submitted prompt even though no provider turn exists. + if (options?.clientMessageId) { + this.recordSubmittedPrompt(agent, prompt, options.clientMessageId, "rejected", { + messageId: options.clientMessageId, + }); + } + await this.handleStreamEvent(agent, { + type: "turn_failed", + provider: agent.provider, + error: errorMsg, }); + this.finalizeForegroundTurn(agent); + } finally { + // A steer waiting on this start is released only once the run is cleared. + this.runs.settleForegroundRun(agentId, pendingRun.token); } - await this.handleStreamEvent(agent, { - type: "turn_failed", - provider: agent.provider, - error: errorMsg, - }); - this.finalizeForegroundTurn(agent); - this.runs.settleForegroundRun(agentId, pendingRun.token); throw error; } } @@ -2832,7 +2836,7 @@ export class AgentManager { agent.pendingReplacement = false; } const turnStartedAt = new Date(); - pendingRun.start = { status: "started", turnId }; + this.runs.settleForegroundStart(pendingRun, { status: "started", turnId }); agent.activeForegroundTurnId = turnId; this.openActiveTurn(agent, turnId, turnStartedAt); agent.lifecycle = "running"; @@ -3041,6 +3045,9 @@ export class AgentManager { prompt: AgentPromptInput, options?: AgentSteerOptions, ): Promise { + // A turn that is still starting has no id to steer yet. Replacing it would drop its + // prompt, so wait until it is accepted (or fails) and steer the turn it became. + await this.runs.waitForForegroundStart(agentId); const agent = this.requireSessionAgent(agentId); const expectedTurnId = agent.activeForegroundTurnId ?? agent.activeTurnId; if (!expectedTurnId) { diff --git a/packages/server/src/server/agent/agent-run-state.test.ts b/packages/server/src/server/agent/agent-run-state.test.ts new file mode 100644 index 0000000000..29e09a5f94 --- /dev/null +++ b/packages/server/src/server/agent/agent-run-state.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "vitest"; + +import { AgentRunState } from "./agent-run-state.js"; + +function track(promise: Promise): { readonly resolved: boolean } { + const state = { resolved: false }; + void (async () => { + await promise; + state.resolved = true; + })(); + return state; +} + +async function flushMicrotasks(): Promise { + await new Promise((resolve) => setImmediate(resolve)); +} + +describe("waitForForegroundStart", () => { + test("resolves once the starting turn has an id", async () => { + const runs = new AgentRunState(); + const run = runs.createPendingRun("agent"); + const wait = track(runs.waitForForegroundStart("agent")); + + await flushMicrotasks(); + expect(wait.resolved).toBe(false); + + runs.settleForegroundStart(run, { status: "started", turnId: "turn-1" }); + await flushMicrotasks(); + expect(wait.resolved).toBe(true); + }); + + test("waits for a failed start's run to be cleared", async () => { + const runs = new AgentRunState(); + const run = runs.createPendingRun("agent"); + const wait = track(runs.waitForForegroundStart("agent")); + + runs.settleForegroundStart(run, { status: "failed", error: "start failed" }); + await flushMicrotasks(); + expect(wait.resolved).toBe(false); + expect(runs.hasPendingRun("agent")).toBe(true); + + runs.settleForegroundRun("agent", run.token); + await flushMicrotasks(); + expect(wait.resolved).toBe(true); + }); + + test("resolves at once when no turn is starting", async () => { + const runs = new AgentRunState(); + await expect(runs.waitForForegroundStart("agent")).resolves.toBeUndefined(); + }); +}); diff --git a/packages/server/src/server/agent/agent-run-state.ts b/packages/server/src/server/agent/agent-run-state.ts index ad1eb0af34..0ea85f6d81 100644 --- a/packages/server/src/server/agent/agent-run-state.ts +++ b/packages/server/src/server/agent/agent-run-state.ts @@ -10,14 +10,18 @@ export interface ForegroundTurnWaiter { resolveSettled: () => void; } +type ForegroundRunStart = + | { status: "pending" } + | { status: "started"; turnId: string } + | { status: "failed"; error: string }; + export interface PendingForegroundRun { token: string; kind: "foreground"; stagedEvents: AgentStreamEvent[]; - start: - | { status: "pending" } - | { status: "started"; turnId: string } - | { status: "failed"; error: string }; + start: ForegroundRunStart; + turnStartedPromise: Promise; + resolveTurnStarted: () => void; settled: boolean; settledPromise: Promise; resolveSettled: () => void; @@ -54,6 +58,28 @@ export class AgentRunState { return run?.kind === "foreground" ? run : null; } + settleForegroundStart( + run: PendingForegroundRun, + start: Exclude, + ): void { + run.start = start; + if (start.status === "started") { + run.resolveTurnStarted(); + } + } + + /** + * Resolves once the agent's foreground run has a turn id or has ended. A failed start + * resolves only once its run is cleared, so callers never see a failed run still tracked. + */ + async waitForForegroundStart(agentId: string): Promise { + const run = this.getPendingRun(agentId); + if (run?.start.status !== "pending") { + return; + } + await Promise.race([run.turnStartedPromise, run.settledPromise]); + } + hasPendingRun(agentId: string): boolean { return this.getPendingRun(agentId) !== null; } @@ -277,10 +303,16 @@ export class ForegroundTurnStream { } function createPendingForegroundRun(): PendingForegroundRun { + let resolveTurnStarted!: () => void; + const turnStartedPromise = new Promise((resolvePromise) => { + resolveTurnStarted = resolvePromise; + }); return { ...createTrackedRunState(), kind: "foreground", start: { status: "pending" }, + turnStartedPromise, + resolveTurnStarted, stagedEvents: [], }; } 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 2169a059f2..e709fd490c 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 @@ -226,6 +226,64 @@ function deferred() { } describe("Codex active-turn steering admission", () => { + test("steers an accepted turn whose native id arrives after startTurn returns", async () => { + const appServer = createFakeCodexAppServer({ + "turn/steer": () => ({ turn: { id: "native-A" } }), + }); + const session = new CodexAppServerAgentSession( + createConfig({ cwd: "/workspace/project" }), + null, + createTestLogger(), + async () => appServer.child, + ); + try { + const { turnId } = await session.startTurn("FIRST"); + const steer = session.steerActiveTurn("SECOND", { expectedTurnId: turnId }); + appServer.startsTurn({ threadId: "thread-1", turnId: "native-A" }); + + await expect(steer).resolves.toEqual({ status: "accepted" }); + expect( + appServer.requests().filter((request) => request.method === "turn/start"), + ).toHaveLength(1); + expect(appServer.requests().filter((request) => request.method === "turn/interrupt")).toEqual( + [], + ); + expect(appServer.requests().find((request) => request.method === "turn/steer")).toMatchObject( + { + params: { + threadId: "thread-1", + expectedTurnId: "native-A", + input: [{ type: "text", text: "SECOND" }], + }, + }, + ); + appServer.assertNoErrors(); + } finally { + await session.close(); + } + }); + + test("releases a waiting steer when the accepted turn ends before identification", async () => { + const appServer = createFakeCodexAppServer(); + const session = new CodexAppServerAgentSession( + createConfig({ cwd: "/workspace/project" }), + null, + createTestLogger(), + async () => appServer.child, + ); + try { + const { turnId } = await session.startTurn("FIRST"); + const steer = session.steerActiveTurn("SECOND", { expectedTurnId: turnId }); + appServer.completeTurn(); + + await expect(steer).resolves.toEqual({ status: "unavailable" }); + expect(appServer.requests().filter((request) => request.method === "turn/steer")).toEqual([]); + appServer.assertNoErrors(); + } finally { + await session.close(); + } + }); + test("a steer without the clearing contract leaves permissions open", async () => { const appServer = createFakeCodexAppServer({ "turn/steer": () => ({ turn: { id: "native-A" } }), 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 cdad017d45..5e43f582c5 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 @@ -4456,9 +4456,18 @@ export class CodexAppServerAgentSession implements AgentSession { ): Promise { const client = this.client; const threadId = this.currentThreadId; - const nativeTurnId = this.currentTurnId; + let nativeTurnId = this.currentTurnId; const foregroundTurnId = this.activeForegroundTurnId; - if (!client || !threadId || !nativeTurnId || foregroundTurnId !== options.expectedTurnId) { + if (!client || !threadId || foregroundTurnId !== options.expectedTurnId) { + return { status: "unavailable" }; + } + const pendingIdentification = this.pendingForegroundTurnIdentification; + // The start acknowledgement precedes the notification that identifies the native + // turn. Keep this steer attached to the accepted foreground turn while it arrives. + if (!nativeTurnId && pendingIdentification?.foregroundTurnId === foregroundTurnId) { + nativeTurnId = await pendingIdentification.promise; + } + if (!nativeTurnId) { return { status: "unavailable" }; } if (await this.resolveSlashCommandInvocation(prompt)) return { status: "unavailable" }; diff --git a/packages/server/src/server/daemon-e2e/codex-steer-starting.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/codex-steer-starting.real.e2e.test.ts new file mode 100644 index 0000000000..60508205f2 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/codex-steer-starting.real.e2e.test.ts @@ -0,0 +1,80 @@ +import { expect, test } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import pino from "pino"; + +import { CodexAppServerAgentClient } from "../agent/providers/codex-app-server-agent.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createMessageCollector } from "../test-utils/message-collector.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { canRunRealProvider } from "./real-provider-test-config.js"; + +test("keeps two concurrent startup steer messages in one real Codex turn", async (context) => { + if (!(await canRunRealProvider("codex"))) { + context.skip(); + return; + } + const cwd = mkdtempSync(path.join(tmpdir(), "codex-steer-starting-")); + const logger = pino({ level: "silent" }); + const daemon = await createTestPaseoDaemon({ + agentClients: { codex: new CodexAppServerAgentClient(logger) }, + logger, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + const collector = createMessageCollector(client); + const subscriptions: ReturnType[] = []; + try { + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + for (let attempt = 0; attempt < 3; attempt += 1) { + const agent = await client.createAgent({ + cwd, + provider: "codex", + modeId: "full-access", + }); + const subscription = client.subscribeAgentTimeline(agent.id, () => {}); + subscriptions.push(subscription); + await subscription.ready; + await Promise.all([ + client.sendAgentMessage(agent.id, "Reply exactly: FIRST", { + messageId: `first-${attempt}`, + activeTurnBehavior: "steer", + }), + client.sendAgentMessage(agent.id, "Reply exactly: SECOND", { + messageId: `second-${attempt}`, + activeTurnBehavior: "steer", + }), + ]); + await client.waitForFinish(agent.id, 90_000); + const timeline = await client.fetchAgentTimeline(agent.id, { limit: 100 }); + const users = timeline.entries.filter((entry) => entry.item.type === "user_message"); + const events = collector.messages.flatMap((message) => + message.type === "agent_stream" && message.payload.agentId === agent.id + ? [message.payload.event] + : [], + ); + expect(users.map((entry) => entry.item.text).sort()).toEqual([ + "Reply exactly: FIRST", + "Reply exactly: SECOND", + ]); + expect(new Set(users.map((entry) => entry.turnId)).size).toBe(1); + expect(events.filter((event) => event.type === "turn_started")).toHaveLength(1); + expect( + events.filter((event) => event.type === "turn_canceled" || event.type === "turn_failed"), + ).toHaveLength(0); + expect( + timeline.entries.some( + (entry) => + entry.item.type === "assistant_message" && entry.item.text.includes("[System Error]"), + ), + ).toBe(false); + } + } finally { + await Promise.all(subscriptions.map((subscription) => subscription.release())); + collector.unsubscribe(); + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } +}, 300_000); From 3196260bd440d56ab5b3adda3daa8afbd8a08d0d Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 07:06:46 +0000 Subject: [PATCH 05/95] Show completed edxeth Pi children as finished, live and after restart (#8176) * fix(pi): settle edxeth subagent lifecycle events * test(pi): preserve completed children when restoring captured histories Verify the completed child and assistant transcript through history replay and the store consumed by clients. Both captured completion protocols otherwise leave a running descriptor. Co-authored-by: Zeno_ --------- Co-authored-by: Zeno_ Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../fixtures/background-child-session.jsonl | 8 + .../fixtures/background.json | 263 ++++++++++++++ .../fixtures/foreground-child-session.jsonl | 8 + .../fixtures/foreground.json | 156 ++++++++ .../edxeth-pi-subagents/index.test.ts | 336 ++++++++++++++++++ .../extensions/edxeth-pi-subagents/index.ts | 119 +++++++ .../agent/providers/pi/extensions/registry.ts | 2 + 7 files changed, 892 insertions(+) create mode 100644 packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/fixtures/background-child-session.jsonl create mode 100644 packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/fixtures/background.json create mode 100644 packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/fixtures/foreground-child-session.jsonl create mode 100644 packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/fixtures/foreground.json create mode 100644 packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/index.test.ts create mode 100644 packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/index.ts diff --git a/packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/fixtures/background-child-session.jsonl b/packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/fixtures/background-child-session.jsonl new file mode 100644 index 0000000000..ed668e807f --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/fixtures/background-child-session.jsonl @@ -0,0 +1,8 @@ +{"type": "session", "version": 3, "id": "e56a0006-f421-45be-9aef-e4019db1cf24", "timestamp": "2026-10-21T13:05:57.384Z", "cwd": "/tmp/paseo-edxeth-fixture/lean/workspace", "name": "[marker] Fixture marker verification", "parentSession": "/tmp/paseo-edxeth-fixture/lean/sessions/2026-10-21T13-05-53-085Z_01a15e91-73bd-7282-82c6-d3974711a941.jsonl"} +{"type": "custom", "customType": "pi-subagents_launch_metadata", "data": {"version": 1, "timestamp": "2026-10-21T13:05:57.385Z", "name": "fixture-marker", "title": "Fixture marker verification", "sessionTitle": "[marker] Fixture marker verification", "agent": "marker", "mode": "background", "sessionMode": "lineage-only", "autoExit": true, "parentClosePolicy": "terminate", "reportContextUsage": true, "async": true, "model": "openai-codex/gpt-5.6-sol", "thinking": "high", "modelRef": "openai-codex/gpt-5.6-sol:high", "definitionModel": "openai-codex/gpt-5.6-sol", "definitionThinking": "high", "allowModelOverride": true, "modelSource": "agent", "tools": "read", "denyTools": ["subagent", "subagent_resume", "subagent_kill"], "spawnableAgents": [], "spawnBudget": null, "extensions": [], "noContextFiles": false, "inheritAppendSystem": false, "noSession": false, "trustProject": false, "agentConfigDir": "/tmp/paseo-edxeth-fixture/lean/agent", "cwd": "/tmp/paseo-edxeth-fixture/lean/workspace", "boundarySystemPrompt": false}, "id": "57355eeb", "parentId": null, "timestamp": "2026-10-21T13:05:57.386Z"} +{"type": "session_info", "id": "847415e4", "parentId": "57355eeb", "timestamp": "2026-10-21T13:05:57.566Z", "name": "[marker] Fixture marker verification"} +{"type": "model_change", "id": "d4ad59be", "parentId": "847415e4", "timestamp": "2026-10-21T13:05:57.672Z", "provider": "openai-codex", "modelId": "gpt-5.6-sol"} +{"type": "thinking_level_change", "id": "7f6bf9d2", "parentId": "d4ad59be", "timestamp": "2026-10-21T13:05:57.672Z", "thinkingLevel": "high"} +{"type": "message", "id": "737b762e", "parentId": "6b436446", "timestamp": "2026-10-21T13:05:57.679Z", "message": {"role": "user", "content": [{"type": "text", "text": "\n\n\nReturn only the exact marker supplied by the user. Do not use tools.\n\nComplete your task autonomously.\n\nReturn only PASEO_EDXETH_LEAN_OK\n\nYour FINAL assistant message should summarize what you accomplished.\n\n"}], "timestamp": 1792587957678}} +{"type": "message", "id": "100b110d", "parentId": "737b762e", "timestamp": "2026-10-21T13:06:02.050Z", "message": {"role": "assistant", "content": [{"type": "text", "text": "PASEO_EDXETH_LEAN_OK"}], "api": "openai-codex-responses", "provider": "openai-codex", "model": "gpt-5.6-sol", "usage": {"input": 1713, "output": 13, "cacheRead": 0, "cacheWrite": 0, "reasoning": 0, "totalTokens": 1726, "cost": {"input": 0.006852, "output": 0.00026000000000000003, "cacheRead": 0, "cacheWrite": 0, "total": 0.007111999999999999}}, "stopReason": "stop", "timestamp": 1792587957684, "responseId": "resp_0bc8bfaee079148a016ad8b8b7edb08191924676ed8dcfd447", "rawStopReason": "completed"}} +{"type": "custom", "customType": "pi-subagent-completion", "data": {"reason": "normal"}, "id": "80bbd3bb", "parentId": "100b110d", "timestamp": "2026-10-21T13:06:02.054Z"} diff --git a/packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/fixtures/background.json b/packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/fixtures/background.json new file mode 100644 index 0000000000..fe4913f6b6 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/fixtures/background.json @@ -0,0 +1,263 @@ +{ + "provenance": { + "package": "Zeno2019/pi-subagents-lean", + "version": "2.9.2-lean.1", + "sourceCommit": "e10fe855808abcae193a205ea98f8ccd70d25620", + "piVersion": "0.87.1", + "captureDate": "2026-10-21" + }, + "events": [ + { + "type": "tool_execution_start", + "toolCallId": "call_f28b261de3c6440b953f0f56c3fec7d0|fc_0d35cf51b3ed48b9016ad8b8b4a63c81918a55903d0a4f12cb", + "toolName": "subagent", + "args": { + "agent": "marker", + "name": "fixture-marker", + "title": "Fixture marker verification", + "task": "Return only PASEO_EDXETH_LEAN_OK" + } + }, + { + "type": "tool_execution_end", + "toolCallId": "call_f28b261de3c6440b953f0f56c3fec7d0|fc_0d35cf51b3ed48b9016ad8b8b4a63c81918a55903d0a4f12cb", + "toolName": "subagent", + "result": { + "content": [ + { + "type": "text", + "text": "Sub-agent \"fixture-marker\" launched async with id 1bfdf3b3. Results will be delivered automatically as a steer message when it finishes. Use this exact id if you need to resume or stop this child.\n\nActivity: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl\nRun evidence: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json\nRead these on demand for progress or failure diagnosis; activity is evidence, not a completion verdict." + } + ], + "details": { + "evidence": { + "runId": "1bfdf3b3", + "cwd": "/tmp/paseo-edxeth-fixture/lean/workspace", + "activityFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl", + "resultFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json" + }, + "id": "1bfdf3b3", + "name": "fixture-marker", + "title": "Fixture marker verification", + "task": "Return only PASEO_EDXETH_LEAN_OK", + "agent": "marker", + "sessionFile": "/tmp/paseo-edxeth-fixture/background/child-session.jsonl", + "noSession": false, + "status": "started", + "mode": "background", + "deliveryState": "detached", + "parentClosePolicy": "terminate", + "async": true, + "autoExit": true + } + }, + "isError": false + }, + { + "type": "message_end", + "message": { + "role": "custom", + "customType": "subagent_result", + "content": "Sub-agent \"fixture-marker\" completed (7s).\n\nPASEO_EDXETH_LEAN_OK\n\nSession: /tmp/paseo-edxeth-fixture/background/child-session.jsonl\nResume: pi --session /tmp/paseo-edxeth-fixture/background/child-session.jsonl\n\nSub-agent context: 2K/372K tokens (0%) used at finish.\n\nActivity: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl\nRun evidence: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json\nRead these on demand for progress or failure diagnosis; activity is evidence, not a completion verdict.", + "display": true, + "details": { + "evidence": { + "runId": "1bfdf3b3", + "cwd": "/tmp/paseo-edxeth-fixture/lean/workspace", + "activityFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl", + "resultFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json", + "finalized": true + }, + "id": "1bfdf3b3", + "name": "fixture-marker", + "task": "Return only PASEO_EDXETH_LEAN_OK", + "agent": "marker", + "mode": "background", + "status": "completed", + "deliveryState": "detached", + "parentClosePolicy": "terminate", + "async": true, + "exitCode": 0, + "elapsed": 7, + "outputTokens": 13, + "contextTokens": 1775, + "contextWindow": 372000, + "sessionFile": "/tmp/paseo-edxeth-fixture/background/child-session.jsonl" + }, + "timestamp": 1792587964414 + } + } + ], + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "toolCall", + "id": "call_f28b261de3c6440b953f0f56c3fec7d0|fc_0d35cf51b3ed48b9016ad8b8b4a63c81918a55903d0a4f12cb", + "name": "subagent", + "arguments": { + "agent": "marker", + "name": "fixture-marker", + "title": "Fixture marker verification", + "task": "Return only PASEO_EDXETH_LEAN_OK" + } + } + ], + "api": "openai-codex-responses", + "provider": "openai-codex", + "model": "gpt-5.6-sol", + "usage": { + "input": 2020, + "output": 43, + "cacheRead": 0, + "cacheWrite": 0, + "reasoning": 0, + "totalTokens": 2063, + "cost": { + "input": 0.00808, + "output": 0.0008600000000000001, + "cacheRead": 0, + "cacheWrite": 0, + "total": 0.00894 + } + }, + "stopReason": "toolUse", + "timestamp": 1792587953256, + "responseId": "resp_0d35cf51b3ed48b9016ad8b8b3698481918f82962b6eaf2788", + "rawStopReason": "completed" + }, + { + "role": "toolResult", + "toolCallId": "call_f28b261de3c6440b953f0f56c3fec7d0|fc_0d35cf51b3ed48b9016ad8b8b4a63c81918a55903d0a4f12cb", + "toolName": "subagent", + "content": [ + { + "type": "text", + "text": "Sub-agent \"fixture-marker\" launched async with id 1bfdf3b3. Results will be delivered automatically as a steer message when it finishes. Use this exact id if you need to resume or stop this child.\n\nActivity: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl\nRun evidence: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json\nRead these on demand for progress or failure diagnosis; activity is evidence, not a completion verdict." + } + ], + "details": { + "evidence": { + "runId": "1bfdf3b3", + "cwd": "/tmp/paseo-edxeth-fixture/lean/workspace", + "activityFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl", + "resultFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json", + "finalized": true + }, + "id": "1bfdf3b3", + "name": "fixture-marker", + "title": "Fixture marker verification", + "task": "Return only PASEO_EDXETH_LEAN_OK", + "agent": "marker", + "sessionFile": "/tmp/paseo-edxeth-fixture/background/child-session.jsonl", + "noSession": false, + "status": "started", + "mode": "background", + "deliveryState": "detached", + "parentClosePolicy": "terminate", + "async": true, + "autoExit": true + }, + "isError": false, + "timestamp": 1792587957399 + }, + { + "role": "assistant", + "content": [ + { + "type": "thinking", + "thinking": "**Identifying need for await keyword**" + }, + { + "type": "text", + "text": "DONE" + } + ], + "api": "openai-codex-responses", + "provider": "openai-codex", + "model": "gpt-5.6-sol", + "usage": { + "input": 2258, + "output": 18, + "cacheRead": 0, + "cacheWrite": 0, + "reasoning": 11, + "totalTokens": 2276, + "cost": { + "input": 0.009032, + "output": 0.00036, + "cacheRead": 0, + "cacheWrite": 0, + "total": 0.009392 + } + }, + "stopReason": "stop", + "timestamp": 1792587957402, + "responseId": "resp_0d35cf51b3ed48b9016ad8b8b602108191a8e8f7fd77fbe5b5", + "rawStopReason": "completed" + }, + { + "role": "custom", + "customType": "subagent_result", + "content": "Sub-agent \"fixture-marker\" completed (7s).\n\nPASEO_EDXETH_LEAN_OK\n\nSession: /tmp/paseo-edxeth-fixture/background/child-session.jsonl\nResume: pi --session /tmp/paseo-edxeth-fixture/background/child-session.jsonl\n\nSub-agent context: 2K/372K tokens (0%) used at finish.\n\nActivity: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl\nRun evidence: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json\nRead these on demand for progress or failure diagnosis; activity is evidence, not a completion verdict.", + "display": true, + "details": { + "evidence": { + "runId": "1bfdf3b3", + "cwd": "/tmp/paseo-edxeth-fixture/lean/workspace", + "activityFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl", + "resultFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json", + "finalized": true + }, + "id": "1bfdf3b3", + "name": "fixture-marker", + "task": "Return only PASEO_EDXETH_LEAN_OK", + "agent": "marker", + "mode": "background", + "status": "completed", + "deliveryState": "detached", + "parentClosePolicy": "terminate", + "async": true, + "exitCode": 0, + "elapsed": 7, + "outputTokens": 13, + "contextTokens": 1775, + "contextWindow": 372000, + "sessionFile": "/tmp/paseo-edxeth-fixture/background/child-session.jsonl" + }, + "timestamp": 1792587964414 + }, + { + "role": "assistant", + "content": [ + { + "type": "text", + "text": "DONE" + } + ], + "api": "openai-codex-responses", + "provider": "openai-codex", + "model": "gpt-5.6-sol", + "usage": { + "input": 389, + "output": 5, + "cacheRead": 2176, + "cacheWrite": 0, + "reasoning": 0, + "totalTokens": 2570, + "cost": { + "input": 0.0015559999999999999, + "output": 0.0001, + "cacheRead": 0.0008704000000000001, + "cacheWrite": 0, + "total": 0.0025264 + } + }, + "stopReason": "stop", + "timestamp": 1792587964417, + "responseId": "resp_0d35cf51b3ed48b9016ad8b8bd0c548191bddd1fe3410a1c82", + "rawStopReason": "completed" + } + ] +} diff --git a/packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/fixtures/foreground-child-session.jsonl b/packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/fixtures/foreground-child-session.jsonl new file mode 100644 index 0000000000..a607a1032d --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/fixtures/foreground-child-session.jsonl @@ -0,0 +1,8 @@ +{"type": "session", "version": 3, "id": "afbea6e0-7858-4ecf-8ef1-e26af9b7d6e0", "timestamp": "2026-10-21T13:05:17.628Z", "cwd": "/tmp/paseo-edxeth-fixture/upstream/workspace", "name": "[marker] Fixture marker verification", "parentSession": "/tmp/paseo-edxeth-fixture/upstream/sessions/2026-10-21T13-05-12-212Z_01a15e90-d414-7243-a701-4eb4b0bec880.jsonl"} +{"type": "custom", "customType": "pi-subagents_launch_metadata", "data": {"version": 1, "timestamp": "2026-10-21T13:05:17.628Z", "name": "fixture-marker", "title": "Fixture marker verification", "sessionTitle": "[marker] Fixture marker verification", "agent": "marker", "mode": "background", "sessionMode": "lineage-only", "autoExit": true, "parentClosePolicy": "terminate", "reportContextUsage": true, "async": false, "model": "openai-codex/gpt-5.6-sol", "thinking": "high", "modelRef": "openai-codex/gpt-5.6-sol:high", "definitionModel": "openai-codex/gpt-5.6-sol", "definitionThinking": "high", "allowModelOverride": true, "modelSource": "agent", "tools": "read", "denyTools": ["subagent", "subagent_resume", "subagent_kill"], "spawnableAgents": [], "spawnBudget": null, "extensions": [], "noContextFiles": false, "inheritAppendSystem": false, "noSession": false, "trustProject": false, "agentConfigDir": "/tmp/paseo-edxeth-fixture/upstream/agent", "cwd": "/tmp/paseo-edxeth-fixture/upstream/workspace", "boundarySystemPrompt": false}, "id": "3f7d3c3f", "parentId": null, "timestamp": "2026-10-21T13:05:17.629Z"} +{"type": "session_info", "id": "6e198450", "parentId": "3f7d3c3f", "timestamp": "2026-10-21T13:05:17.825Z", "name": "[marker] Fixture marker verification"} +{"type": "model_change", "id": "94bf4c0b", "parentId": "6e198450", "timestamp": "2026-10-21T13:05:17.962Z", "provider": "openai-codex", "modelId": "gpt-5.6-sol"} +{"type": "thinking_level_change", "id": "57cc6428", "parentId": "94bf4c0b", "timestamp": "2026-10-21T13:05:17.962Z", "thinkingLevel": "high"} +{"type": "message", "id": "a9e47f08", "parentId": "8c186624", "timestamp": "2026-10-21T13:05:17.970Z", "message": {"role": "user", "content": [{"type": "text", "text": "\n\n\nReturn only the exact marker supplied by the user. Do not use tools.\n\nComplete your task autonomously.\n\nReturn only PASEO_EDXETH_UPSTREAM_OK\n\nYour FINAL assistant message should summarize what you accomplished.\n\n"}], "timestamp": 1792587917968}} +{"type": "message", "id": "bc221a13", "parentId": "a9e47f08", "timestamp": "2026-10-21T13:05:23.476Z", "message": {"role": "assistant", "content": [{"type": "thinking", "thinking": "**Following user file marker exactly**"}, {"type": "text", "text": "PASEO_EDXETH_UPSTREAM_OK"}], "api": "openai-codex-responses", "provider": "openai-codex", "model": "gpt-5.6-sol", "usage": {"input": 1673, "output": 49, "cacheRead": 0, "cacheWrite": 0, "reasoning": 34, "totalTokens": 1722, "cost": {"input": 0.006692, "output": 0.0009800000000000002, "cacheRead": 0, "cacheWrite": 0, "total": 0.007672}}, "stopReason": "stop", "timestamp": 1792587917974, "responseId": "resp_09445c1733d532e7016ad8b8909c908191ba803c059497945e", "rawStopReason": "completed"}} +{"type": "custom", "customType": "pi-subagent-completion", "data": {"reason": "normal"}, "id": "832fc8c9", "parentId": "bc221a13", "timestamp": "2026-10-21T13:05:23.477Z"} diff --git a/packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/fixtures/foreground.json b/packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/fixtures/foreground.json new file mode 100644 index 0000000000..c8df8eb5c2 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/fixtures/foreground.json @@ -0,0 +1,156 @@ +{ + "provenance": { + "package": "edxeth/pi-subagents", + "version": "2.9.2", + "sourceCommit": "cf6dbf41c17986f3882e6804a68e8fb8282d25e6", + "piVersion": "0.87.1", + "captureDate": "2026-10-21" + }, + "events": [ + { + "type": "tool_execution_start", + "toolCallId": "call_0bc6fc2213cd49aa92950947bde5dd8a|fc_0a0ca4aa78cd296a016ad8b88d018481919eadbf09eb191a1a", + "toolName": "subagent", + "args": { + "agent": "marker", + "name": "fixture-marker", + "title": "Fixture marker verification", + "task": "Return only PASEO_EDXETH_UPSTREAM_OK" + } + }, + { + "type": "tool_execution_end", + "toolCallId": "call_0bc6fc2213cd49aa92950947bde5dd8a|fc_0a0ca4aa78cd296a016ad8b88d018481919eadbf09eb191a1a", + "toolName": "subagent", + "result": { + "content": [ + { + "type": "text", + "text": "Sub-agent \"fixture-marker\" completed (7s).\n\nPASEO_EDXETH_UPSTREAM_OK\n\nSession: /tmp/paseo-edxeth-fixture/foreground/child-session.jsonl\nResume: pi --session /tmp/paseo-edxeth-fixture/foreground/child-session.jsonl\n\nSub-agent context: 2K/372K tokens (0%) used at finish." + } + ], + "details": { + "id": "dd7f94e8", + "name": "fixture-marker", + "agent": "marker", + "status": "completed", + "mode": "background", + "parentClosePolicy": "terminate", + "deliveryState": "awaited", + "async": false, + "autoExit": true, + "exitCode": 0, + "elapsed": 7, + "outputTokens": 49, + "contextTokens": 1777, + "contextWindow": 372000, + "summary": "PASEO_EDXETH_UPSTREAM_OK", + "sessionFile": "/tmp/paseo-edxeth-fixture/foreground/child-session.jsonl" + } + }, + "isError": false + } + ], + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "toolCall", + "id": "call_0bc6fc2213cd49aa92950947bde5dd8a|fc_0a0ca4aa78cd296a016ad8b88d018481919eadbf09eb191a1a", + "name": "subagent", + "arguments": { + "agent": "marker", + "name": "fixture-marker", + "title": "Fixture marker verification", + "task": "Return only PASEO_EDXETH_UPSTREAM_OK" + } + } + ], + "api": "openai-codex-responses", + "provider": "openai-codex", + "model": "gpt-5.6-sol", + "usage": { + "input": 3256, + "output": 43, + "cacheRead": 0, + "cacheWrite": 0, + "reasoning": 0, + "totalTokens": 3299, + "cost": { + "input": 0.013023999999999999, + "output": 0.0008600000000000001, + "cacheRead": 0, + "cacheWrite": 0, + "total": 0.013883999999999999 + } + }, + "stopReason": "toolUse", + "timestamp": 1792587912428, + "responseId": "resp_0a0ca4aa78cd296a016ad8b88bd52881918cbb9b8326b3060a", + "rawStopReason": "completed" + }, + { + "role": "toolResult", + "toolCallId": "call_0bc6fc2213cd49aa92950947bde5dd8a|fc_0a0ca4aa78cd296a016ad8b88d018481919eadbf09eb191a1a", + "toolName": "subagent", + "content": [ + { + "type": "text", + "text": "Sub-agent \"fixture-marker\" completed (7s).\n\nPASEO_EDXETH_UPSTREAM_OK\n\nSession: /tmp/paseo-edxeth-fixture/foreground/child-session.jsonl\nResume: pi --session /tmp/paseo-edxeth-fixture/foreground/child-session.jsonl\n\nSub-agent context: 2K/372K tokens (0%) used at finish." + } + ], + "details": { + "id": "dd7f94e8", + "name": "fixture-marker", + "agent": "marker", + "status": "completed", + "mode": "background", + "parentClosePolicy": "terminate", + "deliveryState": "awaited", + "async": false, + "autoExit": true, + "exitCode": 0, + "elapsed": 7, + "outputTokens": 49, + "contextTokens": 1777, + "contextWindow": 372000, + "summary": "PASEO_EDXETH_UPSTREAM_OK", + "sessionFile": "/tmp/paseo-edxeth-fixture/foreground/child-session.jsonl" + }, + "isError": false, + "timestamp": 1792587924671 + }, + { + "role": "assistant", + "content": [ + { + "type": "text", + "text": "DONE" + } + ], + "api": "openai-codex-responses", + "provider": "openai-codex", + "model": "gpt-5.6-sol", + "usage": { + "input": 388, + "output": 5, + "cacheRead": 3072, + "cacheWrite": 0, + "reasoning": 0, + "totalTokens": 3465, + "cost": { + "input": 0.001552, + "output": 0.0001, + "cacheRead": 0.0012288000000000002, + "cacheWrite": 0, + "total": 0.0028808 + } + }, + "stopReason": "stop", + "timestamp": 1792587924675, + "responseId": "resp_0a0ca4aa78cd296a016ad8b896af2c81919c0ef586dd50f2f0", + "rawStopReason": "completed" + } + ] +} diff --git a/packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/index.test.ts b/packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/index.test.ts new file mode 100644 index 0000000000..86692ab32e --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/index.test.ts @@ -0,0 +1,336 @@ +import { describe, expect, test } from "vitest"; +import { fileURLToPath } from "node:url"; +import { createPiExtensionHost } from "../index.js"; +import { readSubagentFixture, verifySubagentFixture } from "../subagent-fixture-test.js"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { edxethPiSubagents } from "./index.js"; +import { streamPiHistory } from "../../history-mapper.js"; +import { ProviderSubagentStore } from "../../../../provider-subagents/store.js"; + +const args = { + agent: "scout", + name: "status-scout", + title: "Check child status", + task: "Return the completion marker", +}; + +describe("edxeth/pi-subagents adapter", () => { + test.each(["tool", "notification"])("ends a child that requested help via %s", (delivery) => { + const host = createPiExtensionHost(); + const call = { callId: "spawn-1", toolName: "subagent", args, status: "completed" as const }; + host.mapToolCall({ + ...call, + result: { details: { id: "run-1", name: args.name, status: "started" } }, + }); + const details = { id: "run-1", name: args.name, message: "Please clarify the task" }; + const result = + delivery === "tool" + ? host.mapToolCall({ ...call, result: { details: { ...details, status: "pinged" } } }) + : host.mapCustomMessage({ + role: "custom", + customType: "subagent_ping", + content: "Child requested help and exited", + details, + }); + expect(result?.subagents).toEqual([ + expect.objectContaining({ + type: "upsert", + id: "spawn-1", + status: "canceled", + subtitle: "Needs help", + }), + ]); + }); + + test("leaves unrelated tools and other subagent protocols to their adapters", () => { + const host = createPiExtensionHost(undefined, [edxethPiSubagents]); + const call = { callId: "other", toolName: "subagent", args, status: "completed" as const }; + for (const details of [null, {}, { mode: "single", results: [] }, { id: "run-1" }]) { + expect(host.mapToolCall({ ...call, result: { details } })).toBeUndefined(); + } + expect( + host.mapToolCall({ + ...call, + toolName: "unrelated", + result: { details: { id: "run-1", name: args.name, status: "completed" } }, + }), + ).toBeUndefined(); + for (const details of [{ id: "run-1" }, { id: "run-1", status: "unknown" }]) { + expect( + host.mapCustomMessage({ + role: "custom", + customType: "subagent_result", + content: "Unrecognized result", + details, + }), + ).toBeUndefined(); + } + }); + + test("waits for cancellation after a kill acknowledgement reports stopping", () => { + const host = createPiExtensionHost(); + host.mapToolCall({ + callId: "spawn-1", + toolName: "subagent", + args, + status: "completed", + result: { details: { id: "run-1", name: args.name, status: "started" } }, + }); + const stopping = host.mapToolCall({ + callId: "kill-1", + toolName: "subagent_kill", + args: { id: "run-1" }, + status: "completed", + result: { details: { id: "run-1", name: args.name, status: "stopping" } }, + }); + expect(stopping?.subagents ?? []).toEqual([]); + expect( + host.mapCustomMessage({ + role: "custom", + customType: "subagent_result", + content: "Cancelled", + details: { id: "run-1", status: "cancelled" }, + })?.subagents, + ).toEqual([{ type: "upsert", id: "spawn-1", status: "canceled" }]); + }); + + test("preserves a completion that arrives before its launch acknowledgement", () => { + const host = createPiExtensionHost(); + const early = host.mapCustomMessage({ + role: "custom", + customType: "subagent_result", + content: "Already done", + details: { id: "fast-run", status: "completed" }, + }); + expect(early?.subagents ?? []).toEqual([]); + const started = host.mapToolCall({ + callId: "fast-call", + toolName: "subagent", + args, + status: "completed", + result: { + details: { id: "fast-run", name: args.name, agent: args.agent, status: "started" }, + }, + }); + expect(started?.subagents).toEqual([ + { + type: "upsert", + id: "fast-call", + title: "scout", + description: args.task, + toolCallId: "fast-call", + status: "completed", + }, + ]); + }); + + test.each([ + { details: { status: "completed" }, expected: "completed" }, + { details: { status: "failed" }, expected: "failed" }, + { details: { status: "cancelled" }, expected: "canceled" }, + { details: { error: "Child watcher exited unexpectedly" }, expected: "failed" }, + ])("maps completion $details to $expected", ({ details, expected }) => { + const host = createPiExtensionHost(); + host.mapToolCall({ + callId: "spawn-1", + toolName: "subagent", + args, + status: "completed", + result: { details: { id: "run-1", name: args.name, status: "started" } }, + }); + expect( + host.mapCustomMessage({ + role: "custom", + customType: "subagent_result", + content: "Child result", + details: { id: "run-1", ...details }, + })?.subagents, + ).toEqual([{ type: "upsert", id: "spawn-1", status: expected }]); + }); + + test("a resume gets its own run and reads the retained child session", async () => { + const dir = await mkdtemp(join(tmpdir(), "paseo-edxeth-resume-")); + const file = join(dir, "child.jsonl"); + const host = createPiExtensionHost(); + try { + await writeFile( + file, + JSON.stringify({ + type: "message", + message: { role: "assistant", content: [{ type: "text", text: "RESUMED" }] }, + }) + "\n", + ); + const original = host.mapToolCall({ + callId: "spawn-1", + toolName: "subagent", + args, + status: "completed", + result: { + details: { id: "run-1", name: args.name, status: "completed", sessionFile: file }, + }, + }); + await original?.hydration; + const resumed = host.mapToolCall({ + callId: "resume-1", + toolName: "subagent_resume", + args: { sessionFile: file, task: "Continue" }, + status: "completed", + result: { + details: { id: "run-2", name: args.name, status: "started", sessionFile: file }, + }, + }); + expect(resumed?.childSessions).toEqual([{ id: "resume-1", file }]); + expect(await resumed?.hydration).toEqual([ + { + type: "provider_subagent", + provider: "pi", + event: { + type: "timeline", + id: "resume-1", + item: expect.objectContaining({ type: "assistant_message", text: "RESUMED" }), + }, + }, + ]); + expect( + host.mapCustomMessage({ + role: "custom", + customType: "subagent_result", + content: "Resumed run complete", + details: { id: "run-2", status: "completed", sessionFile: file }, + })?.subagents, + ).toEqual([{ type: "upsert", id: "resume-1", status: "completed" }]); + } finally { + host.close(); + await rm(dir, { recursive: true, force: true }); + } + }); + + test("settles each batched child by run id without creating a parent-call card", () => { + const host = createPiExtensionHost(); + const result = host.mapToolCall({ + callId: "batch-1", + toolName: "subagent", + args: { children: [args, { ...args, agent: "reviewer", name: "status-reviewer" }] }, + status: "completed", + result: { + details: { + status: "batch", + children: [ + { id: "run-1", ...args, mode: "background", status: "completed" }, + { id: "run-2", name: "status-reviewer", agent: "reviewer", status: "started" }, + ], + }, + }, + }); + expect(result?.subagents).toEqual([ + { + type: "upsert", + id: "batch-1:0", + title: "scout", + description: args.task, + toolCallId: "batch-1", + status: "completed", + }, + { + type: "upsert", + id: "batch-1:1", + title: "reviewer", + description: undefined, + toolCallId: "batch-1", + status: "running", + }, + ]); + expect( + host.mapCustomMessage({ + role: "custom", + customType: "subagent_result", + content: "Done", + details: { id: "run-2", status: "completed" }, + })?.subagents, + ).toEqual([{ type: "upsert", id: "batch-1:1", status: "completed" }]); + }); + + test.each([ + { mode: "foreground", statuses: ["running", "completed"] }, + { mode: "background", statuses: ["running", "running", "completed"] }, + ])("settles the captured $mode run live and on replay", async ({ mode, statuses }) => { + const fixture = readSubagentFixture(new URL(`./fixtures/${mode}.json`, import.meta.url), { + from: `/tmp/paseo-edxeth-fixture/${mode}/child-session.jsonl`, + to: fileURLToPath(new URL(`./fixtures/${mode}-child-session.jsonl`, import.meta.url)), + }); + const events = await verifySubagentFixture(fixture); + const updates = events.flatMap(({ event }) => (event.type === "upsert" ? [event] : [])); + expect(updates.map((event) => event.status)).toEqual(statuses); + expect(new Set(updates.map((event) => event.id)).size).toBe(1); + expect(events.some(({ event }) => event.type === "timeline")).toBe(true); + }); + + test.each(["foreground", "background"])( + "restores a completed %s child and its transcript to the consumer store", + async (mode) => { + const fixture = readSubagentFixture(new URL(`./fixtures/${mode}.json`, import.meta.url), { + from: `/tmp/paseo-edxeth-fixture/${mode}/child-session.jsonl`, + to: fileURLToPath(new URL(`./fixtures/${mode}-child-session.jsonl`, import.meta.url)), + }); + const store = new ProviderSubagentStore(); + for await (const event of streamPiHistory("pi", fixture.messages)) { + if (event.type === "provider_subagent") store.apply("parent", "pi", event.event); + } + const children = store.list("parent"); + expect(children).toEqual([expect.objectContaining({ status: "completed", title: "marker" })]); + const transcript = store.fetchTimeline("parent", children[0]!.id); + expect(transcript.rows).toContainEqual( + expect.objectContaining({ + item: expect.objectContaining({ + type: "assistant_message", + text: mode === "foreground" ? "PASEO_EDXETH_UPSTREAM_OK" : "PASEO_EDXETH_LEAN_OK", + }), + }), + ); + }, + ); + + test("settles a synchronous result on the card created when the tool started", () => { + const host = createPiExtensionHost(); + const call = { callId: "spawn-1", toolName: "subagent", args }; + const start = host.mapToolCall({ ...call, status: "running", result: null }); + expect(start?.subagents).toEqual([ + { + type: "upsert", + id: "spawn-1", + title: "scout", + description: args.task, + toolCallId: "spawn-1", + status: "running", + }, + ]); + const end = host.mapToolCall({ + ...call, + status: "completed", + result: { + content: [{ type: "text", text: "DONE" }], + details: { + id: "run-1", + name: args.name, + agent: args.agent, + mode: "background", + status: "completed", + deliveryState: "awaited", + exitCode: 0, + }, + }, + }); + expect(end?.subagents).toEqual([ + { + type: "upsert", + id: "spawn-1", + title: "scout", + description: args.task, + toolCallId: "spawn-1", + status: "completed", + }, + ]); + }); +}); diff --git a/packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/index.ts b/packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/index.ts new file mode 100644 index 0000000000..c00e39aaad --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/index.ts @@ -0,0 +1,119 @@ +import { z } from "zod"; +import { extractTextFromToolResult } from "../../tool-call-mapper.js"; +import type { PiChildSessionFile, PiExtension } from "../contract.js"; +import type { ProviderSubagentInputEvent } from "../../../../provider-subagents/store.js"; + +const Run = z + .object({ + id: z.string().trim().min(1), + name: z.string().trim().min(1), + agent: z.string().trim().min(1).optional(), + task: z.string().optional(), + status: z.enum(["started", "completed", "failed", "cancelled", "pinged"]), + sessionFile: z.string().trim().min(1).optional(), + }) + .passthrough(); +const Args = z.object({ task: z.string().optional() }).passthrough(); +const Batch = z.object({ status: z.enum(["started", "batch"]), children: z.array(Run).min(1) }); +const Completion = z + .object({ + id: z.string().trim().min(1), + status: z.enum(["completed", "failed", "cancelled", "pinged"]).optional(), + error: z.string().optional(), + sessionFile: z.string().trim().min(1).optional(), + }) + .passthrough(); +const Ping = Completion.extend({ message: z.string() }); + +function childStatus(status: z.infer["status"]) { + if (status === "started") return "running"; + // A ping ends the child process and needs a new resume run. It is not success. + if (status === "cancelled" || status === "pinged") return "canceled"; + return status; +} + +export const edxethPiSubagents: PiExtension = { + id: "edxeth/pi-subagents", + createSession: () => { + const callsByRun = new Map(); + const completions = new Map>(); + const readSessions = new Map(); + const childSession = (id: string, file: string | undefined): PiChildSessionFile[] => { + if (!file || readSessions.get(id) === file) return []; + readSessions.set(id, file); + return [{ id, file }]; + }; + const completeRun = (result: z.infer) => { + const status = result.status ?? (result.error === undefined ? undefined : "failed"); + if (!status) return undefined; + // A fast child can finish before the tool returns its run id. Keep the + // terminal outcome so a later "started" acknowledgement cannot regress it. + completions.set(result.id, { ...result, status }); + const id = callsByRun.get(result.id); + if (!id) return undefined; + const subagents: ProviderSubagentInputEvent[] = [ + { + type: "upsert", + id, + status: childStatus(status), + ...(status === "pinged" ? { subtitle: "Needs help" } : {}), + }, + ]; + return { subagents, childSessions: childSession(id, result.sessionFile) }; + }; + return { + mapToolCall(call) { + const isLaunch = call.toolName === "subagent" || call.toolName === "subagent_resume"; + if (!isLaunch || call.status === "running") return undefined; + const details = typeof call.result === "object" ? call.result?.details : null; + const single = Run.safeParse(details); + let runs: z.infer[]; + if (single.success) { + runs = [single.data]; + } else { + const batch = Batch.safeParse(details); + if (!batch.success) return undefined; + runs = batch.data.children; + } + const args = Args.safeParse(call.args); + const description = args.success ? args.data.task : undefined; + const subagents: ProviderSubagentInputEvent[] = []; + const childSessions: PiChildSessionFile[] = []; + for (const [index, run] of runs.entries()) { + const id = runs.length === 1 ? call.callId : `${call.callId}:${index}`; + const completion = completions.get(run.id); + const status = completion?.status ?? run.status; + callsByRun.set(run.id, id); + subagents.push({ + type: "upsert", + id, + title: run.agent ?? run.name, + description: run.task ?? description, + toolCallId: call.callId, + status: childStatus(status), + ...(status === "pinged" ? { subtitle: "Needs help" } : {}), + }); + childSessions.push(...childSession(id, completion?.sessionFile ?? run.sessionFile)); + } + return { + detail: { + type: "sub_agent", + description, + log: extractTextFromToolResult(call.result)?.trim() ?? "", + }, + subagents, + childSessions, + }; + }, + mapCustomMessage(message) { + if (message.customType === "subagent_ping") { + const ping = Ping.safeParse(message.details); + return ping.success ? completeRun({ ...ping.data, status: "pinged" }) : undefined; + } + if (message.customType !== "subagent_result") return undefined; + const parsed = Completion.safeParse(message.details); + return parsed.success ? completeRun(parsed.data) : undefined; + }, + }; + }, +}; diff --git a/packages/server/src/server/agent/providers/pi/extensions/registry.ts b/packages/server/src/server/agent/providers/pi/extensions/registry.ts index c66fabad80..ca273b83bb 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/registry.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/registry.ts @@ -1,5 +1,6 @@ import { piAskUser } from "./pi-ask-user/index.js"; import { piSubagents } from "./pi-subagents/index.js"; +import { edxethPiSubagents } from "./edxeth-pi-subagents/index.js"; import { piMcpAdapter } from "./pi-mcp-adapter/index.js"; import { rpivTodo } from "./rpiv-todo/index.js"; import { piExampleTodo } from "./pi-example-todo/index.js"; @@ -10,6 +11,7 @@ import { rpivAskUserQuestion } from "./rpiv-ask-user-question/index.js"; export const piExtensions = [ piAskUser, // Dialog correlation + edxethPiSubagents, // Structured results before the generic subagent tool matcher piSubagents, // Delegation calls tintinwebPiSubagents, gotgenesPiSubagents, From 7c88a48be47341f4dd70c155880f9351a9408330 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 29 Oct 2026 14:39:42 +0700 Subject: [PATCH 06/95] Publish store screenshots from the repo on each stable release (#8186) * feat(release): publish store screenshots from the repo Stable releases now upload the committed store renders: the App Store review lane attaches the iPhone and iPad sets to the version it submits, and a new Play lane replaces the phone and tablet screenshots after the Android submit. F-Droid reads the same Android files, which now link to the store renders instead of the website images. The Play lane needs its own copy of the submit service-account key (GOOGLE_PLAY_JSON_KEY in the EAS production environment) because the key Expo stores for eas submit is not available to custom jobs, and EAS Metadata does not handle screenshots for either store. * fix(release): rewrite the Android screenshot links on every render Google Play and F-Droid read numbered links to the Google renders. The renderer now rewrites them from the panels it exported, so adding or renaming a panel reaches all three stores. --- assets/store-screenshots/README.md | 4 ++ assets/store-screenshots/render.mjs | 21 +++++++- docs/android.md | 5 +- docs/release.md | 15 +++++- .../en-US/images/phoneScreenshots/1.png | 2 +- .../en-US/images/phoneScreenshots/2.png | 2 +- .../en-US/images/phoneScreenshots/3.png | 2 +- .../en-US/images/phoneScreenshots/4.png | 1 + .../en-US/images/phoneScreenshots/5.png | 1 + .../en-US/images/tenInchScreenshots/1.png | 1 + .../en-US/images/tenInchScreenshots/2.png | 1 + .../en-US/images/tenInchScreenshots/3.png | 1 + .../en-US/images/tenInchScreenshots/4.png | 1 + .../en-US/images/tenInchScreenshots/5.png | 1 + .../app/.eas/workflows/release-mobile.yml | 14 ++++++ packages/app/fastlane/Fastfile | 49 ++++++++++++++++++- 16 files changed, 112 insertions(+), 9 deletions(-) create mode 120000 fastlane/metadata/android/en-US/images/phoneScreenshots/4.png create mode 120000 fastlane/metadata/android/en-US/images/phoneScreenshots/5.png create mode 120000 fastlane/metadata/android/en-US/images/tenInchScreenshots/1.png create mode 120000 fastlane/metadata/android/en-US/images/tenInchScreenshots/2.png create mode 120000 fastlane/metadata/android/en-US/images/tenInchScreenshots/3.png create mode 120000 fastlane/metadata/android/en-US/images/tenInchScreenshots/4.png create mode 120000 fastlane/metadata/android/en-US/images/tenInchScreenshots/5.png diff --git a/assets/store-screenshots/README.md b/assets/store-screenshots/README.md index 6db3d236ec..fc37d98bd8 100644 --- a/assets/store-screenshots/README.md +++ b/assets/store-screenshots/README.md @@ -95,4 +95,8 @@ Callout crops are in native capture pixels. To find them, overlay a 100 px grid 6. Copy the captures into `raw/iphone/` or `raw/ipad/` under the names `render.mjs` uses, render, and check the preview. +## Publish + +The stable release uploads these exports to the App Store and Google Play, and F-Droid reads the Google ones from the repo. See [Store screenshots](../../docs/release.md#store-screenshots). The Android metadata holds links to the Google exports, and every render rewrites them, so commit the links with the exports. + Dimensions are checked against [Apple's screenshot specifications](https://developer.apple.com/help/app-store-connect/reference/app-information/screenshot-specifications/) and [Google Play's preview asset requirements](https://support.google.com/googleplay/android-developer/answer/9866151?hl=en). diff --git a/assets/store-screenshots/render.mjs b/assets/store-screenshots/render.mjs index 9e32384a60..6695eac559 100644 --- a/assets/store-screenshots/render.mjs +++ b/assets/store-screenshots/render.mjs @@ -9,7 +9,7 @@ // elsewhere point SF_FONT_DIR at a folder holding SFNS.ttf copied from a Mac. import { existsSync } from "node:fs"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -23,6 +23,7 @@ const root = path.dirname(fileURLToPath(import.meta.url)); const repo = path.resolve(root, "../.."); const raw = (device, name) => path.join(root, "raw", device, `${name}.png`); const output = path.join(root, "output"); +const androidImages = path.join(repo, "fastlane/metadata/android/en-US/images"); // Native captures per device: iPhone 16 Pro Max at 3x and iPad Pro 13-inch at // 2x. The frame's screen radius and Dynamic Island are in capture pixels. @@ -116,6 +117,7 @@ const STORES = { }, google: { device: "iphone", + androidScreenshots: "phoneScreenshots", width: 1440, height: 2560, textTop: 150, @@ -134,6 +136,7 @@ const STORES = { }, "google-tablet": { device: "ipad", + androidScreenshots: "tenInchScreenshots", width: 1800, height: 3200, textTop: 200, @@ -352,6 +355,18 @@ async function expectLines(view, selector, expected, label) { if (lines !== expected) throw new Error(`${label} wraps to ${lines} lines, expected ${expected}`); } +// Google Play and F-Droid read their screenshots from the Android metadata, +// which holds links to these renders. Rewriting the links on every render keeps +// added, removed and renamed panels in step with the App Store. +async function linkAndroidScreenshots(folder, files) { + const dir = path.join(androidImages, folder); + await rm(dir, { recursive: true, force: true }); + await mkdir(dir, { recursive: true }); + for (const [index, file] of files.entries()) { + await symlink(path.relative(dir, file), path.join(dir, `${index + 1}.png`)); + } +} + async function renderPreview(rows) { const tile = 440; const gap = 40; @@ -387,7 +402,9 @@ try { const marks = await loadAgentMarks(work); const rows = []; for (const [name, store] of Object.entries(STORES)) { - rows.push(await renderStore(browser, name, store, font, marks, work)); + const files = await renderStore(browser, name, store, font, marks, work); + if (store.androidScreenshots) await linkAndroidScreenshots(store.androidScreenshots, files); + rows.push(files); } await renderPreview(rows); } finally { diff --git a/docs/android.md b/docs/android.md index 546ba92a3e..6188589eb8 100644 --- a/docs/android.md +++ b/docs/android.md @@ -215,7 +215,8 @@ fastlane/metadata/android/ │ ├── images/ │ │ ├── icon.png (512x512) │ │ ├── featureGraphic.png (1024x500) -│ │ └── phoneScreenshots/ (1.png, 2.png, ...) +│ │ ├── phoneScreenshots/ (1.png, 2.png, ... linked to the store renders) +│ │ └── tenInchScreenshots/ (1.png, 2.png, ... linked to the store renders) │ └── changelogs/ (generated — see below) ├── ja/ └── zh-CN/ @@ -223,6 +224,8 @@ fastlane/metadata/android/ Locale directories generally match `packages/app/src/i18n/locales.ts`, but note that `en` becomes `en-US`. +Google Play reads its screenshots from the same folders; see [Store screenshots](release.md#store-screenshots). + F-Droid changelogs are generated from `CHANGELOG.md`. Run `npm run fdroid:changelogs`; `npm run fdroid:changelogs:check` verifies without writing. It is wired into the npm `version` lifecycle, so a release picks it up automatically and `git add -A` stages the result. One changelog must be generated per-ABI-split, so each version will create **four** identical version-coded entries. F-Droid caps changelogs at 500 characters, so the generator strips some content and adds a link to the full notes. diff --git a/docs/release.md b/docs/release.md index 83a419b8ce..ce0857abb3 100644 --- a/docs/release.md +++ b/docs/release.md @@ -305,8 +305,8 @@ macOS 13 maps to Darwin 22. The two values use different version domains; do not iOS and Android store builds are not in `.github/workflows`. They are triggered by the EAS GitHub app the moment the `v*` tag is pushed: -- **Android (Play Store)** — EAS builds with profile `production` and auto-submits to the Play Store via `eas submit` (EAS-managed credentials, no Fastlane). -- **iOS (TestFlight + App Store)** — EAS builds with profile `production`, uploads once through a `testflight` job, distributes the build to the `Paseo Beta` external group with Beta App Review submission, and a Fastlane lane submits that same build for App Store review. +- **Android (Play Store)** — EAS builds with profile `production` and auto-submits to the Play Store via `eas submit` (EAS-managed credentials). A Fastlane lane then replaces the store screenshots. +- **iOS (TestFlight + App Store)** — EAS builds with profile `production`, uploads once through a `testflight` job, distributes the build to the `Paseo Beta` external group with Beta App Review submission, and a Fastlane lane uploads the store screenshots and submits that same build for App Store review. - **Android APK (GitHub Release asset)** — separate, via `.github/workflows/android-apk-release.yml`. This is the only Android-related workflow that lives in this repo. EAS uses the local app version source. `packages/app/app.config.js` derives the native version from the package version. Android `versionCode` is `major * 1_000_000 + minor * 1_000 + patch`. iOS reserves 1,000 build slots per app version: beta `N` uses slot `N`, and stable uses slot `999`. For example, `0.2.6-beta.2` appears in App Store Connect as version `0.2.6` build `2006002`; stable uses build `2006999`. Rebuilding the same tag produces the same native build number; if a store has already accepted a binary and you need a different binary, cut the next beta or patch instead of relying on EAS remote auto-increment. @@ -317,6 +317,16 @@ Stable iOS releases use one binary for two Apple review paths: Beta App Review f There is no mobile-release workflow under `.github/workflows`. The EAS GitHub app reads the workflows under `packages/app/.eas/workflows` and handles tag triggering directly. +### Store screenshots + +Each stable release publishes the screenshots committed under `assets/store-screenshots/output/` (see [its README](../assets/store-screenshots/README.md) for how they are made). To change the store screenshots, re-render and commit them before you tag. + +- **App Store:** `submit_review` uploads `output/apple/` and `output/apple-ipad/` to the version it submits, replacing that version's `en-US` screenshots. Apple ties screenshots to an app version, so a version that is already live keeps the ones it shipped with. `Resubmit iOS for App Store review` uploads them again. +- **Google Play:** `upload_android_screenshots` runs `fastlane android upload_screenshots` after `submit_android`. It replaces only the phone and 10-inch tablet screenshots, never the listing text or changelogs, and skips images whose checksum already matches. +- **F-Droid:** reads the same files as Google Play. `fastlane/metadata/android/en-US/images/phoneScreenshots/` and `tenInchScreenshots/` hold symlinks to the Google renders. `render.mjs` rewrites them on every render, so added or renamed panels reach Google Play and F-Droid as well. + +Fastlane cannot use the Play key Expo stores for `eas submit`. The Play lane reads its own copy of the same service-account key from the `GOOGLE_PLAY_JSON_KEY` file secret in the EAS `production` environment, and that service account needs the Play Console permission **Manage store presence**. + ### Watching mobile builds from the terminal Use the EAS CLI from `packages/app/`: @@ -359,6 +369,7 @@ For the `Release Mobile` EAS workflow, these jobs must pass: - `submit_ios_for_review` — iOS build submitted for App Store review via Fastlane - `build_android` — Android store binary built - `submit_android` — Android binary submitted to the Play Store +- `upload_android_screenshots` — Google Play screenshots replaced from the repo Do not treat `build_ios: SUCCESS` or `distribute_ios: SUCCESS` as a completed iOS release. `submit_ios_for_review: FAILURE` means the iOS release is blocked even if the build is visible in TestFlight. diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png index 9f633ca58f..ca3a9fa2a8 120000 --- a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png +++ b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png @@ -1 +1 @@ -../../../../../../packages/website/public/phone-1.png \ No newline at end of file +../../../../../../assets/store-screenshots/output/google/01-sidebar.png \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png index 6d2fbd14d7..a0748cf426 120000 --- a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png +++ b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png @@ -1 +1 @@ -../../../../../../packages/website/public/phone-2.png \ No newline at end of file +../../../../../../assets/store-screenshots/output/google/02-chat.png \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png index e23c27ced5..0e7dba6b41 120000 --- a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png +++ b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png @@ -1 +1 @@ -../../../../../../packages/website/public/phone-3.png \ No newline at end of file +../../../../../../assets/store-screenshots/output/google/03-plugins.png \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png new file mode 120000 index 0000000000..e89becb9bc --- /dev/null +++ b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png @@ -0,0 +1 @@ +../../../../../../assets/store-screenshots/output/google/04-review.png \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png new file mode 120000 index 0000000000..c067990032 --- /dev/null +++ b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png @@ -0,0 +1 @@ +../../../../../../assets/store-screenshots/output/google/05-terminal.png \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/images/tenInchScreenshots/1.png b/fastlane/metadata/android/en-US/images/tenInchScreenshots/1.png new file mode 120000 index 0000000000..401053965e --- /dev/null +++ b/fastlane/metadata/android/en-US/images/tenInchScreenshots/1.png @@ -0,0 +1 @@ +../../../../../../assets/store-screenshots/output/google-tablet/01-sidebar.png \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/images/tenInchScreenshots/2.png b/fastlane/metadata/android/en-US/images/tenInchScreenshots/2.png new file mode 120000 index 0000000000..2454381850 --- /dev/null +++ b/fastlane/metadata/android/en-US/images/tenInchScreenshots/2.png @@ -0,0 +1 @@ +../../../../../../assets/store-screenshots/output/google-tablet/02-chat.png \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/images/tenInchScreenshots/3.png b/fastlane/metadata/android/en-US/images/tenInchScreenshots/3.png new file mode 120000 index 0000000000..1b31012051 --- /dev/null +++ b/fastlane/metadata/android/en-US/images/tenInchScreenshots/3.png @@ -0,0 +1 @@ +../../../../../../assets/store-screenshots/output/google-tablet/03-plugins.png \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/images/tenInchScreenshots/4.png b/fastlane/metadata/android/en-US/images/tenInchScreenshots/4.png new file mode 120000 index 0000000000..13d8545d92 --- /dev/null +++ b/fastlane/metadata/android/en-US/images/tenInchScreenshots/4.png @@ -0,0 +1 @@ +../../../../../../assets/store-screenshots/output/google-tablet/04-review.png \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/images/tenInchScreenshots/5.png b/fastlane/metadata/android/en-US/images/tenInchScreenshots/5.png new file mode 120000 index 0000000000..b0905beafc --- /dev/null +++ b/fastlane/metadata/android/en-US/images/tenInchScreenshots/5.png @@ -0,0 +1 @@ +../../../../../../assets/store-screenshots/output/google-tablet/05-terminal.png \ No newline at end of file diff --git a/packages/app/.eas/workflows/release-mobile.yml b/packages/app/.eas/workflows/release-mobile.yml index 26790ef22f..e57260898c 100644 --- a/packages/app/.eas/workflows/release-mobile.yml +++ b/packages/app/.eas/workflows/release-mobile.yml @@ -42,6 +42,20 @@ jobs: build_id: ${{ needs.build_android.outputs.build_id }} profile: production + upload_android_screenshots: + name: Upload Google Play screenshots + needs: [build_android, submit_android] + environment: production + runs_on: macos-medium + steps: + - uses: eas/checkout + - name: Install fastlane + run: bundle install + - name: Upload screenshots + run: | + export ANDROID_VERSION_CODE="${{ needs.build_android.outputs.app_build_version }}" + bundle exec fastlane android upload_screenshots + submit_ios_for_review: name: Submit iOS for App Store review needs: [build_ios, distribute_ios] diff --git a/packages/app/fastlane/Fastfile b/packages/app/fastlane/Fastfile index dc7508b456..065ca48b7f 100644 --- a/packages/app/fastlane/Fastfile +++ b/packages/app/fastlane/Fastfile @@ -1,7 +1,31 @@ +require "fileutils" +require "tmpdir" + default_platform(:ios) APP_IDENTIFIER = "sh.paseo" +# Store screenshots are rendered into assets/store-screenshots/output; see the +# README there. Google Play and F-Droid both read fastlane/metadata/android at +# the repo root, whose screenshot files link to those renders. +REPO_ROOT = File.expand_path("../../..", __dir__) +STORE_SCREENSHOTS = File.join(REPO_ROOT, "assets/store-screenshots/output") +ANDROID_METADATA = File.join(REPO_ROOT, "fastlane/metadata/android") + +# deliver reads screenshots from one folder per language and picks the device +# slot from each image's size, so both Apple sets go into en-US together. +def stage_app_store_screenshots + root = Dir.mktmpdir("app-store-screenshots") + locale = File.join(root, "en-US") + FileUtils.mkdir_p(locale) + { "apple" => "iphone", "apple-ipad" => "ipad" }.each do |set, device| + Dir.glob(File.join(STORE_SCREENSHOTS, set, "*.png")).sort.each do |file| + FileUtils.cp(file, File.join(locale, "#{device}-#{File.basename(file)}")) + end + end + root +end + platform :ios do desc "Submit the latest TestFlight build to App Store Review" lane :submit_review do @@ -87,7 +111,8 @@ platform :ios do }, force: true, skip_binary_upload: true, - skip_screenshots: true, + screenshots_path: stage_app_store_screenshots, + overwrite_screenshots: true, precheck_include_in_app_purchases: false, submission_information: { add_id_info_uses_idfa: false, @@ -96,3 +121,25 @@ platform :ios do ) end end + +platform :android do + desc "Replace the Google Play store screenshots with the ones in the repo" + lane :upload_screenshots do + # supply attaches listing changes to a release on the production track. + # The release workflow passes the version code it just submitted; without + # it, supply uses the track's only release. + version_code = ENV["ANDROID_VERSION_CODE"].to_s.strip + supply( + package_name: APP_IDENTIFIER, + json_key: ENV.fetch("GOOGLE_PLAY_JSON_KEY"), + metadata_path: ANDROID_METADATA, + version_code: version_code.empty? ? nil : version_code.to_i, + skip_upload_apk: true, + skip_upload_aab: true, + skip_upload_metadata: true, + skip_upload_changelogs: true, + skip_upload_images: true, + sync_image_upload: true, + ) + end +end From 61841d1ed5ec96a9561053fad9bc682c9dc8a07e Mon Sep 17 00:00:00 2001 From: babo-jeonguk Date: Thu, 29 Oct 2026 16:58:09 +0900 Subject: [PATCH 07/95] fix(app): stop web menus snapping back to their entering-animation rect (#8180) Reanimated's web path saves a custom keyframe's element rect when the animation ends, and writes it back as inline top/left/width/height when it cleans the keyframe up 750ms after the start. A menu whose content resized in between, such as a plugin popover whose data had just arrived, was left clipped to its loading-state size or hanging over its anchor. Play the same keyframes through the Web Animations API on web, which leaves no inline style, and drop the height release that only undid part of that write. Native keeps the Reanimated Keyframe. --- docs/menus.md | 16 ++- .../ui/menu/menu-overlay.browser.test.tsx | 124 ++++++++++++++++++ .../src/components/ui/menu/menu-overlay.tsx | 105 ++++++--------- .../src/components/ui/menu/menu-surface.tsx | 1 - 4 files changed, 173 insertions(+), 73 deletions(-) create mode 100644 packages/app/src/components/ui/menu/menu-overlay.browser.test.tsx diff --git a/docs/menus.md b/docs/menus.md index 15ea6b5a5d..590def2b33 100644 --- a/docs/menus.md +++ b/docs/menus.md @@ -200,13 +200,15 @@ its own. ## Gotchas -- **Released height.** Reanimated's web entering animation leaves an inline height snapshot on - the surface. `AnchoredSurface` clears it, and a `revision` prop re-clears it when content - identity changes — a pushed page taller than the one it replaced is clipped without that. -- **Animate only once placed.** The same snapshot carries top/left, and Reanimated writes it back - 750ms after mount. `AnchoredSurface` remounts the surface when its position resolves so the - entering animation never ends at the off-screen measuring position; on a slow machine that - write moved the open menu back off-screen. +- **Web enters without Reanimated.** For a custom keyframe, Reanimated's web path snapshots the + surface's rect when the animation ends and writes it back as inline top/left/width/height 750ms + after the start. Whatever moved or resized the surface in between was undone: content that + grew once its data arrived left the menu clipped, or hanging over its anchor, until it moved + again. `AnchoredSurface` plays the same keyframes through the Web Animations API on web, which + leaves no inline style; native keeps the Reanimated `Keyframe`. +- **Animate only once placed.** `AnchoredSurface` remounts the surface when its position resolves, + so the entering animation runs at the final position rather than ending unseen at the + off-screen measuring position. - **Sheets size to content.** `enableDynamicSizing`, not fixed snap points. A pushed page is rarely the height of the page before it. A fill page is the one exception. - **The sheet's content is teleported out of the menu's subtree**, so `MenuSheetSurface` rebuilds diff --git a/packages/app/src/components/ui/menu/menu-overlay.browser.test.tsx b/packages/app/src/components/ui/menu/menu-overlay.browser.test.tsx new file mode 100644 index 0000000000..9d60353a52 --- /dev/null +++ b/packages/app/src/components/ui/menu/menu-overlay.browser.test.tsx @@ -0,0 +1,124 @@ +import React, { act, useEffect, useState, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { Text, View } from "react-native"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { page } from "vitest/browser"; +import { MenuRoot, MenuSurface, MenuTrigger, type Size } from "./index"; + +// App sources compile against the classic JSX runtime, which expects React on the global. +beforeEach(() => vi.stubGlobal("React", React)); + +/** + * A real browser, because the regression lives in Reanimated's web entering path: it snapshots + * the surface's rect when a custom keyframe ends and writes that rect back as inline + * top/left/width/height when it cleans the keyframe up, 750ms after the animation starts. + * Anything that moved or resized the surface in between is undone by that write. + */ + +interface Mounted { + root: Root; + container: HTMLDivElement; +} + +const mounted: Mounted[] = []; + +function mount(node: ReactNode): void { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + act(() => root.render(node)); + mounted.push({ root, container }); +} + +afterEach(() => { + for (const entry of mounted.splice(0)) { + act(() => entry.root.unmount()); + entry.container.remove(); + } +}); + +const OFFSET = 12; +const PILL_POSITION = { position: "absolute", top: 700, left: 40 } as const; +const SHORT: Size = { width: 200, height: 40 }; +const TALL: Size = { width: 200, height: 240 }; +const WIDE: Size = { width: 480, height: 40 }; +/** Inside the window between the entering animation ending and Reanimated's cleanup. */ +const GROWS_BEFORE_CLEANUP_MS = 400; +/** Past Reanimated's cleanup, where the surface used to keep a fixed height. */ +const GROWS_AFTER_CLEANUP_MS = 1_000; +/** Long enough after the content grew for anything that would write to the surface to land. */ +const SETTLE_MS = 1_200; + +/** Plain timers, not `act`: an async `act` holds every update until it resolves. */ +function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function element(testID: string): HTMLElement { + const found = document.querySelector(`[data-testid="${testID}"]`); + if (!(found instanceof HTMLElement)) throw new Error(`${testID} did not render`); + return found; +} + +/** Stands in for a popover whose data lands after it opened, like a plugin's first RPC. */ +function GrowsAfterOpen({ afterMs, from, to }: { afterMs: number; from: Size; to: Size }) { + const [grown, setGrown] = useState(false); + useEffect(() => { + const timer = setTimeout(() => setGrown(true), afterMs); + return () => clearTimeout(timer); + }, [afterMs]); + return ; +} + +function mountMenuAbovePill(body: ReactNode): void { + mount( + + + + Open panel + + + {body} + + + , + ); + act(() => element("pill").click()); +} + +describe("anchored menu surface on web", () => { + beforeEach(async () => { + await page.viewport(1280, 900); + }); + + it.each([GROWS_BEFORE_CLEANUP_MS, GROWS_AFTER_CLEANUP_MS])( + "stays on its anchor when its content grows %ims after opening", + async (afterMs) => { + mountMenuAbovePill(); + + await wait(afterMs + SETTLE_MS); + + const pill = element("pill").getBoundingClientRect(); + const panel = element("panel").getBoundingClientRect(); + expect(panel.height).toBeGreaterThan(240); + expect(panel.bottom).toBeLessThanOrEqual(pill.top); + }, + ); + + it("widens when its content widens after the entering animation", async () => { + mountMenuAbovePill(); + + await wait(GROWS_BEFORE_CLEANUP_MS + SETTLE_MS); + + const body = element("body").getBoundingClientRect(); + const panel = element("panel").getBoundingClientRect(); + expect(panel.width).toBeGreaterThanOrEqual(body.width); + }); +}); diff --git a/packages/app/src/components/ui/menu/menu-overlay.tsx b/packages/app/src/components/ui/menu/menu-overlay.tsx index 9631dbbe9a..c6935bfd91 100644 --- a/packages/app/src/components/ui/menu/menu-overlay.tsx +++ b/packages/app/src/components/ui/menu/menu-overlay.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useId, + useLayoutEffect, useMemo, useState, type ReactElement, @@ -47,56 +48,41 @@ const contentEntering = new Keyframe({ 100: { opacity: 1, transform: [{ scale: 1 }] }, }).duration(CONTENT_ENTERING_DURATION_MS); +/** `contentEntering` for the browser's own animation engine. Both default to linear easing. */ +const WEB_CONTENT_ENTERING_KEYFRAMES: PropertyIndexedKeyframes = { + opacity: [0, 1], + transform: ["scale(0.97)", "scale(1)"], +}; + +/** Web plays the same keyframes itself; see `useWebContentEntering`. */ +const nativeContentEntering = isWeb ? undefined : contentEntering; + const contentExiting = new Keyframe({ 0: { opacity: 1, transform: [{ scale: 1 }] }, 100: { opacity: 0, transform: [{ scale: 0.97 }] }, }).duration(100); -function releaseFixedMenuHeight(surfaceNativeID: string): void { - if (!isWeb) return; - document.getElementById(surfaceNativeID)?.style.removeProperty("height"); -} - /** - * Reanimated's web entering animation leaves an inline height snapshot on the measured surface. - * Once the menu is open, height must return to content-sized so rows can grow in place — and so - * a submenu page taller than the page it replaced is not clipped to the old height. - * - * `revision` is what makes that second case work: bump it whenever the rendered content changes - * identity (a page push, for instance) and the snapshot is released again. + * On web the surface enters through the Web Animations API rather than Reanimated. Reanimated's + * web path snapshots an element's rect when a custom keyframe ends and, when it cleans the keyframe + * up 750ms after the start, writes that rect back as inline top/left/width/height. A surface that + * moved or resized in between — content that grew once its data arrived — snapped back to the old + * rect and stayed there, clipped or hanging past its anchor. A Web Animation leaves no inline style. */ -function useReleaseFixedMenuHeight({ - contentSize, - enabled, +function useWebContentEntering({ + placed, surfaceNativeID, - revision, }: { - contentSize: Size | null; - enabled: boolean; + placed: boolean; surfaceNativeID: string; - revision?: string | number; }): void { - useEffect(() => { - if (!enabled) return undefined; - - const release = () => { - releaseFixedMenuHeight(surfaceNativeID); - if (typeof requestAnimationFrame === "function") { - requestAnimationFrame(() => releaseFixedMenuHeight(surfaceNativeID)); - } - }; - const timers: ReturnType[] = [ - setTimeout(release, CONTENT_ENTERING_DURATION_MS), - ]; - - if (contentSize) { - timers.push(setTimeout(release, 0)); - } - - return () => { - for (const timer of timers) clearTimeout(timer); - }; - }, [contentSize, enabled, surfaceNativeID, revision]); + useLayoutEffect(() => { + if (!isWeb || !placed || typeof document === "undefined") return undefined; + const animation = document + .getElementById(surfaceNativeID) + ?.animate(WEB_CONTENT_ENTERING_KEYFRAMES, CONTENT_ENTERING_DURATION_MS); + return () => animation?.cancel(); + }, [placed, surfaceNativeID]); } /** @@ -224,8 +210,6 @@ export interface AnchoredSurfaceProps { fullWidth?: boolean; horizontalPadding?: number; scrollable?: boolean; - /** Bump when the rendered content changes identity, so the height snapshot is released. */ - revision?: string | number; /** A submenu sits inside its parent's overlay and must not paint a second backdrop. */ backdrop?: boolean; /** @@ -259,7 +243,6 @@ export function AnchoredSurface({ fullWidth = false, horizontalPadding = 16, scrollable = false, - revision, backdrop = true, onPointerEnter, onPointerLeave, @@ -269,33 +252,25 @@ export function AnchoredSurface({ }: AnchoredSurfaceProps): ReactElement | null { const { t } = useTranslation(); const surfaceNativeID = useId(); - const { position, actualPlacement, contentSize, visibleContentSize, onContentLayout } = - useAnchoredPosition({ - open, - anchorRect, - anchorRef, - side, - align, - offset, - scrollable, - maxHeight, - }); - - useReleaseFixedMenuHeight({ - contentSize, - enabled: open, - surfaceNativeID, - revision, + const { position, actualPlacement, visibleContentSize, onContentLayout } = useAnchoredPosition({ + open, + anchorRect, + anchorRef, + side, + align, + offset, + scrollable, + maxHeight, }); // The surface is placed once both measurements land. Until then it sits off-screen so it can - // be measured, and it must not animate: Reanimated's web entering path snapshots the element's - // rect when the animation ends and writes that rect back as inline top/left when it cleans the - // keyframe up 750ms later. If the animation ends before placement, the snapshot is the - // off-screen rect and the menu jumps back off-screen. Remounting on placement starts the - // animation from the final position. + // be measured, and it must not animate: an entering animation that ran there would be over + // before anyone saw the menu. Remounting on placement starts the native animation from the + // final position; web starts its own animation on placement. const placed = position !== null; + useWebContentEntering({ placed, surfaceNativeID }); + useEffect(() => { if (!isWeb || !open || !placed || typeof document === "undefined") return undefined; const frame = requestAnimationFrame(() => { @@ -383,7 +358,7 @@ export function AnchoredSurface({ dataSet={surfaceDataSet} style={styles.content} frameStyle={frameStyle} - entering={placed ? contentEntering : undefined} + entering={placed ? nativeContentEntering : undefined} exiting={ !placed || isWeb || !onExited ? undefined diff --git a/packages/app/src/components/ui/menu/menu-surface.tsx b/packages/app/src/components/ui/menu/menu-surface.tsx index 56ff2c0578..581b620499 100644 --- a/packages/app/src/components/ui/menu/menu-surface.tsx +++ b/packages/app/src/components/ui/menu/menu-surface.tsx @@ -344,7 +344,6 @@ function MenuFlyout({ maxHeight={maxHeight} scrollable={scrollable} backdrop={false} - revision={page.id} onPointerEnter={cancelHoverClose} onPointerLeave={handleHoverOut} testID={testID} From 7d74ae5d6cc057e98fc95a86b28b11215e4a5f79 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 29 Oct 2026 15:08:36 +0700 Subject: [PATCH 08/95] docs(release): release from tags with selected cherry-picks while main stays open (#8188) Replace the next-branch discipline with the main-first workflow: normal PRs always merge into main, each release builds in a short-lived release workspace from origin/main or a release tag plus approved cherry-picks, promotions take an inventory that defaults to defer, and a reconcile PR brings the shipped changelog, version and F-Droid files back into main. --- .github/workflows/ci.yml | 2 +- CLAUDE.md | 7 +- docs/release.md | 205 +++++++++++++++++++++++++-------------- 3 files changed, 137 insertions(+), 77 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f61d514727..7cb267c2dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,7 @@ name: CI on: push: branches: [main] - # Every base branch, so stacked PRs and PRs into `next` get the same checks. + # Every base branch, so stacked PRs and PRs into any base get the same checks. pull_request: merge_group: workflow_dispatch: diff --git a/CLAUDE.md b/CLAUDE.md index d72205a01e..9a2c70de29 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,10 +117,9 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir ## Release branches -When the user says "this goes to next", create or -retarget the PR to `next` and preserve that destination through delivery. Follow -[release branch discipline](docs/release.md#release-branch-discipline) for creating -and updating `next`, integrating it after a release, and releasing a hotfix from a tag. +Merge normal PRs into `main`, including while a beta soaks or a release is being finalized. Each +release builds in a short-lived release workspace from `origin/main` or a release tag; see +[release base and source](docs/release.md#release-base-and-source). ## Critical rules diff --git a/docs/release.md b/docs/release.md index ce0857abb3..07c6e1b16f 100644 --- a/docs/release.md +++ b/docs/release.md @@ -6,10 +6,10 @@ All workspaces share one version and release together. A release has exactly two steps. The agent does the first, the user authorizes the second. -**Preparation** (local, reversible — agent does this): +**Preparation** (reversible — agent does this): - format, lint, typecheck all green -- resolve the release source to one commit and confirm that commit's existing CI is green +- resolve the [release base](#release-base-and-source), get the [release inventory](#release-inventory) approved when one applies, and confirm CI for the resulting release source is green - classify the diff from the previous stable to the release source as patch or minor, then show the target version and rationale to the user - draft the changelog, show it to the user, wait for review @@ -17,9 +17,10 @@ A release has exactly two steps. The agent does the first, the user authorizes t **Go-ahead** (user says "go ahead"): -- commit the approved release inputs locally -- run the release, which publishes npm and pushes the prepared branch and tag +- commit the approved release inputs locally on the release branch +- run the release, which publishes npm and pushes the release branch and tag - create the release heartbeat immediately and babysit it to completion +- [reconcile `main`](#reconcile-main) with what shipped Rules that apply to both steps: @@ -27,56 +28,128 @@ Rules that apply to both steps: - No code changes bundled into the changelog commit or the release commit. Code shims live in their own commit, reviewed on their own merits. - A sanity-check finding is information, not a directive. The agent surfaces it; the user decides. - Invoking a release skill is intent to start the flow, not blanket authorization to publish. +- An approval the user already gave covers what it names, such as an explicit PR selection or authorization to release autonomously. Do not ask for it again. - If the user asks for a release preview, show the prospective changelog/release contents and answer questions, but do not commit, tag, publish, or run release commands until they explicitly authorize the release. -## Release source and CI +## Main stays open -The default release source is `origin/main`. Fetch `origin`, then record the -resolved commit. The default release checkout is a clean local `main` whose -`HEAD` equals `origin/main`. +Merge normal PRs into `main` at any time, including while a beta soaks or a release is being +finalized. Each release builds in a short-lived release workspace, and its published tag records +the shipped source. -An explicit user instruction can select another ref, such as a hotfix commit or -tag. Resolve that ref once and apply every source, diff, and CI check to that -commit instead of `origin/main`. +## Choosing a path -Before making release-preparation commits, confirm the existing CI run for the -resolved commit is green. Pending CI is watched to completion. Release -preparation then stays local through the changelog, any explicitly requested ACP -catalog update, lockfile preparation, and the version commit. After approval, -commit the prepared inputs locally and run the release command. Its branch and -tag push is the one remote release batch and starts CI for the complete release -commit. +The user decides when the changes on `main` warrant a release and how much risk it takes: -## Release branch discipline +1. **Direct stable release** for a low-risk batch of polish and bug fixes. Stable desktop updates + already ramp over 36 hours (see [Staged rollout](#staged-rollout-stable-channel)), and store + review often takes about a day, with no guaranteed timing. +2. **Beta flow**, the default for changes that need testing. Release candidates on the `beta` + channel. Each beta folds into the series' single changelog entry, publishes npm only on the + explicit `beta` dist-tag, and stays behind the Stable/Beta switch on `/download`. -While you finalize a release on `main`, use a temporary `next` branch for work intended for the following -release. This applies to both beta and stable releases. +A beta follows these defaults, which the user can explicitly override: -- Create each new `next` from freshly fetched `origin/main`. Reuse it while active. -- "This goes to next" means create the PR against `next` or retarget an existing - PR, and keep that destination through delivery. -- Keep `next` current by merging `origin/main` into it as release fixes land. - Avoid rebasing this shared branch because agents and open PRs depend on its history. -- After the release ships, bring `next` up to date and open a `next` → `main` PR. - Pass CI and merge without squashing away the individual PR commits needed for - the changelog. Retarget remaining PRs based on `next` to `main` and delete the integrated - `next`. Create it fresh when needed again. +- Soak a beta at least 24 hours before promotion. Soak longer for risky or breaking changes, or + while feedback is open. Changes added in a later beta get their own soak; earlier soak time does + not count for them. +- A follow-up beta carries regressions, fixes, and polish for what that beta delivers. Unrelated + work on `main` and pre-existing bugs wait for a later release unless the user explicitly selects + them. +- Promotion ships the tested beta's product content unchanged and does not pull from `main`. + Adding a product change needs the user's explicit selection and a new beta, which soaks again. +- During a soak, ship urgent stable regressions and compatibility or provider model updates as a + [stable hotfix](#release-base-and-source) from the latest stable tag. The beta keeps its own + pace. -**Setup still needed:** PR checks run for every base branch, but CI, Docker, and Nix -push runs target only `main`, and GitHub permits only squash merges. Enable -required-check protection for `next`, CI on its pushes, and merge commits for the -integration PR. Handle PR -base changes (`edited` events) so retargeting runs checks against the new base; -GitHub's default PR events do not cover this. Deployment triggers stay unchanged. +npm dist-tags are independent pointers: -### Hotfix from a release tag +- A beta release publishes with `--tag beta` and moves only `beta`; `latest` remains on the + newest stable. +- A stable release publishes to `latest`. Move `beta` to it as well when the stable version is + newer than the version `beta` points to, which covers promotions and direct stable releases, so + `@getpaseo/cli@beta` users never sit behind stable. A stable hotfix on an older line leaves + `beta` on the active newer beta. -If `main` contains changes you do not want to release, branch from the affected -release tag and cherry-pick only the required fixes. Run CI on that branch, then -use the normal release flow with it as the explicit source, choosing a new patch -or beta version. Ensure the fixes and changelog also reach `main` and any active -`next`, preserving newer development and version changes there. This is a -short-lived hotfix branch, not another maintained release track. +## Release base and source + +The release base is the tag or commit a release workspace starts from. The release source is the +base plus any approved cherry-picks, and every source, diff, and CI check uses it. + +| Release | Base | Changes on top | +| -------------------------------------------------------- | ------------------------------------------------------------ | ---------------------------- | +| Starting release: first beta of a line, or direct stable | `origin/main`, captured once | None | +| Follow-up beta | The selected published beta tag, normally the series' latest | Approved commits from `main` | +| Promotion | The beta tag being promoted | None by default | +| Stable hotfix | The latest appropriate stable tag | Approved commits from `main` | + +An explicit user instruction can select another base. + +1. Fetch `origin` with tags and resolve the base to one commit. +2. Create the release workspace through Paseo MCP on a new branch from the base, named + `release/vX.Y.Z` or `release/vX.Y.Z-beta.N` for the version being cut. Run the release from + this workspace: `release:push` pushes `HEAD` to the current branch. +3. Cherry-pick the approved commits in their `main` order with `git cherry-pick -x`, so each + commit records the `main` commit it came from. Include the prerequisites the inventory listed. + Resolve conflicts within the approved changes and report how. A resolution that needs new + behavior is a scope addition: surface it for selection. +4. Confirm CI for the release source. When the source is a commit whose CI already passed (the + captured `origin/main`, or the beta tag being promoted), use that run and watch pending CI to + completion. Otherwise push the release branch and run `gh workflow run ci.yml --ref `, + because CI push runs target only `main`. +5. Keep the changelog, any explicitly requested ACP catalog update, lockfile preparation, and the + version commit local until the go-ahead. The release command pushes the release branch and tag + as one batch, and the tag push starts the release workflows. Then run + `gh workflow run ci.yml --ref ` so CI covers the complete release commit. +6. [Reconcile `main`](#reconcile-main). + +A stable hotfix can take the version a patch beta line would promote to, such as `0.13.3` +shipping while `0.13.3-beta.2` soaks. Never republish or reuse a shipped version. Continue the line +as the next unused patch beta, `0.13.4-beta.1`, from the tested beta tag. No `version:all:*` mode +computes that from a prerelease, so follow the [manual steps](#manual-step-by-step) with +`npm version 0.13.4-beta.1 --include-workspace-root --message "chore(release): cut %s"` and +`npm run release:publish:beta`. Record the tested beta tag as its source so its soak still counts +for the unchanged content. + +## Release inventory + +When the user asks for a follow-up beta or a promotion, list what `main` has that the base does +not, then get the batch approved. There is no release label or queue. + +1. Find the commits on `origin/main` whose patches are not in the base tag: + + ```bash + git log --cherry-pick --right-only --no-merges --format='%h %s' ...origin/main + ``` + + Patch equivalence recognizes commits already cherry-picked into the tag, so trust the tag + content over PR state or branch names. + +2. Present a short include/defer list. Link each PR and give one plain reason. List separately the + prerequisites an included PR depends on and anything that adds scope beyond what the beta + delivers. For each fix that shipped in a stable hotfix after the beta's base, confirm the beta + preserves its behavior, directly or through a superseding change. List the missing ones for + explicit selection; adding one to a promotion needs a new beta. +3. Wait for the user to approve the batch, unless an explicit selection or autonomous + authorization they already gave covers it. + +For a promotion, every item defaults to defer. + +## Reconcile main + +After the release push, open a PR into `main` that brings in what shipped and keeps newer +development on `main`: + +- The shipped `CHANGELOG.md` and `CHANGELOG.zh-CN.md` entries, exactly as tagged. A beta or + promotion entry replaces the series entry on `main`. A stable hotfix on an older line goes + below any newer entries. +- The shipped version in every `package.json` and `package-lock.json`, unless `main` already + carries a newer version, plus the F-Droid changelog files a stable release generates. +- No claims about unshipped work: a shipped entry describes only the tagged content, and notes + `main` holds for changes that have not shipped stay out of it. + +Delete the release branch once the PR merges. The tag is the record of shipped source; Paseo +maintains no release track besides `main`. ## ACP catalog updates @@ -91,21 +164,6 @@ ACP catalog work enters a release through an explicit user request: The release authorization covers the requested ACP commit. It ships in the same release push as the changelog and version commit. -## Two paths - -There are two supported release paths: - -1. **Direct stable release**: you are ready to ship the resolved release source to everyone immediately (default `origin/main`). -2. **Beta flow**: release candidates on the `beta` channel. Each beta folds into the series' single changelog entry, publishes npm only on the explicit `beta` dist-tag, and stays behind the Stable/Beta switch on `/download`. - -Paseo has one linear release track even though npm dist-tags are independent -pointers. The npm invariant is: - -- A beta release moves only `beta`; `latest` remains on the newest stable. -- A stable release moves both `latest` and `beta` to that stable version. This - keeps users who install `@getpaseo/cli@beta` on the newest Paseo release after - a beta is promoted or superseded by a direct stable release. - ## Release version decision Every fresh release starts by classifying the full diff from the previous @@ -133,7 +191,7 @@ as described in **Fixing a failed release build**. Before running any stable release command: -- Make sure the resolved release source passed CI, the approved release inputs are committed locally on the intended branch, and the working tree is clean. +- Make sure the release source passed CI, the approved release inputs are committed locally on the release branch, and the working tree is clean. - **Run `npm run format`, `npm run lint`, and `npm run typecheck` and commit any resulting changes BEFORE you start any `release:*` command.** `release:check` runs `npm install --workspaces --include-workspace-root` as part of `release:prepare`, which can mutate `package-lock.json` (e.g. churning `"dev": true` markers on optional deps). The next step, `version:all:*`, runs `npm version` which aborts when the working tree is dirty. If this happens mid-flight you have to commit the lockfile churn before retrying — and the pre-commit format hook will reject a lockfile-only commit because oxfmt internally skips `package-lock.json` while lefthook's glob still matches it. Avoid the whole mess by running format/lint/typecheck first, then `release:prepare` once on its own to absorb any lockfile churn into a normal commit, then start the release. - Do not use a release command as a substitute for checking whether the current commit is actually ready. @@ -145,9 +203,9 @@ npm run release:minor This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. The workflows create the GitHub Release as a draft while builds and release-note sync run. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no mobile-release workflow under `.github/workflows`. -After the stable release succeeds, move npm's `beta` pointer to the new stable -version for every published package. This changes dist-tags only; do not -republish the packages: +After the stable release succeeds, check `npm view @getpaseo/cli dist-tags`. When the new stable +is newer than `beta`, move `beta` to it for every published package. This changes dist-tags only; +do not republish the packages: ```bash PASEO_VERSION=$(node -p "require('./package.json').version") @@ -156,8 +214,8 @@ for package in highlight relay protocol client plugin server cli; do done ``` -Verify both npm tags now resolve to `PASEO_VERSION` before considering the -stable release complete. +Verify `latest` resolves to `PASEO_VERSION`, and `beta` resolves to `PASEO_VERSION` or the +active newer beta, before considering the stable release complete. The Docker workflow builds images from the checked-out source tree on pull requests and on `main` as non-publishing checks. Stable `vX.Y.Z` tag pushes publish `ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`; beta `vX.Y.Z-beta.N` tag pushes publish only `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` and never move `latest`. @@ -175,7 +233,7 @@ npm run version:all:patch npm run version:all:minor npm run release:publish # Publish to npm npm run release:push # Push HEAD + tag (triggers CI workflows) -# Then move npm's beta dist-tag to this stable version using the command above. +# Then move npm's beta dist-tag to this stable version when it is newer than beta (see above). ``` ## Beta flow @@ -488,7 +546,7 @@ git tag -f android-v0.1.28 HEAD && git push origin android-v0.1.28 --force git tag -f v0.1.29-beta.2 HEAD && git push origin v0.1.29-beta.2 --force ``` -This ensures the checkout ref matches the actual code on `main` with the fix included. +This ensures the checkout ref matches the code you want built, with the fix included. - `vX.Y.Z` or `vX.Y.Z-beta.N` rebuilds the full tagged release - `desktop-vX.Y.Z` rebuilds desktop for all desktop platforms only @@ -631,7 +689,7 @@ Rules: This returns every distinct GitHub login that authored or co-authored a commit in the PR. Use those logins for attribution. Fall back to `gh pr view N --json author` only if the commits command returns nothing (which should not happen for merged PRs). - When listing PR numbers, `git log --format='%H %s' v.. | grep -E '\(#[0-9]+\)$'` pulls the PR number out of squash commit subjects. + When listing PR numbers, `git log --cherry-pick --right-only --no-merges --format='%H %s' v... | grep -E '\(#[0-9]+\)$'` pulls the PR number out of squash commit subjects and skips commits the previous stable already shipped as cherry-picks. ## Changelog ordering @@ -663,11 +721,11 @@ Every entry covers `previous stable release → release source`: first beta, lat ### Beta release -- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green +- [ ] The release base and changes match the [release base table](#release-base-and-source) and any approved [inventory](#release-inventory), and CI for the release source is green - [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog - [ ] Fold this beta into the series' single `CHANGELOG.md` entry (heading `## X.Y.Z-beta.N - YYYY-MM-DD`, previous stable → release source), review it against the changelog policy, get approval, and commit it with its [`CHANGELOG.zh-CN.md` translation](#changelog-translations) before cutting the release - [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved -- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag +- [ ] The changelog, ACP, lockfile, and version commits stayed local until the approved release command pushed the release branch and tag - [ ] `npm run release:beta:patch`, `npm run release:beta:minor`, or `npm run release:beta:next` completes successfully - [ ] Every GitHub Actions run for the complete release commit and tag is green - [ ] npm shows the version under the `beta` dist-tag, not `latest` @@ -678,22 +736,24 @@ Every entry covers `previous stable release → release source`: first beta, lat - [ ] GitHub `Docker` workflow is green and the versioned beta image is published without moving `latest` - [ ] GitHub `Release Notes Sync` mirrored the beta entry into the prerelease body - [ ] EAS `Release iOS Beta` completed its build, TestFlight distribution, external beta group, and Beta App Review path +- [ ] The [reconcile PR](#reconcile-main) brought the shipped changelog entries and version into `main` - [ ] The release heartbeat was created after the tag push and deleted only after every item above passed ### Stable release (or promotion) - [ ] Run the pre-release sanity check (see above) and address any findings - [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved -- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green +- [ ] The release base and changes match the [release base table](#release-base-and-source) and any approved [inventory](#release-inventory), and CI for the release source is green +- [ ] A promotion ships the tested beta's product content after its soak, unless the user explicitly overrode either default - [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog - [ ] Ensure the approved release inputs are committed locally and the git worktree is clean before running any release command - [ ] Ensure local `npm run typecheck` passes on that exact commit before running any release command - [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). Promotion renames the series' `## X.Y.Z-beta.N` entry to `## X.Y.Z - YYYY-MM-DD` and re-checks it covers the full release. Commit the matching [`CHANGELOG.zh-CN.md` entry](#changelog-translations) with it - [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format -- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag +- [ ] The changelog, ACP, lockfile, and version commits stayed local until the approved release command pushed the release branch and tag - [ ] `npm run release:patch`, `npm run release:minor`, or `npm run release:promote` completes successfully - [ ] Every GitHub Actions run for the complete release commit and tag is green -- [ ] Move npm's `beta` dist-tag to the new stable version for every published package and verify both `latest` and `beta` resolve to it +- [ ] `latest` resolves to the new stable, and `beta` resolves to it too unless an active newer beta holds `beta` - [ ] The GitHub Release was published only after the three stable manifests were uploaded, and it has the changelog body and every expected macOS, Linux, Windows, and Android APK asset - [ ] GitHub `Desktop Release` workflow for the `v*` tag is green - [ ] The GitHub Release contains `latest-mac.yml`, `latest-linux.yml`, and `latest.yml` @@ -707,4 +767,5 @@ Every entry covers `previous stable release → release source`: first beta, lat - [ ] EAS iOS `submit_ios_for_review` succeeds, putting the build into App Store review - [ ] EAS Android `build_android` completes for the same tag - [ ] EAS Android `submit_android` succeeds, putting the build on its Play Store track +- [ ] The [reconcile PR](#reconcile-main) brought the shipped changelog entries and version into `main` - [ ] The release heartbeat was created after the tag push and deleted only after every item above passed From f7eef9dd0c69591b34dde2de1116e1e8651ee8bf Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 29 Oct 2026 15:40:29 +0700 Subject: [PATCH 09/95] chore(release): reconcile main with v0.14.0 (#8193) Bring in what v0.14.0 shipped: the 0.14.0 entry in CHANGELOG.md and CHANGELOG.zh-CN.md exactly as tagged (replacing the 0.14.0-beta.2 series entry), the 0.14.0 version in every package.json and package-lock.json, and the generated F-Droid changelogs. The unreleased notes on main stay above the entry and now say they are not in 0.14.0. Release commit: 8c6de842a3157530c26a7a94c78a3d647fb96ce9 (tag v0.14.0) --- CHANGELOG.md | 8 +-- CHANGELOG.zh-CN.md | 8 +-- .../android/en-US/changelogs/140001.txt | 10 ++++ .../android/en-US/changelogs/140002.txt | 10 ++++ .../android/en-US/changelogs/140003.txt | 10 ++++ .../android/en-US/changelogs/140004.txt | 10 ++++ 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 +- plugins/package.json | 2 +- 20 files changed, 103 insertions(+), 63 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/140001.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/140002.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/140003.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/140004.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a11f55d5b..e47984dbf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -Not released yet. These changes are on `main` but not in 0.14.0-beta.2; the next release entry includes them. +Not released yet. These changes are on `main` but not in 0.14.0; the next release entry includes them. Added: @@ -11,7 +11,7 @@ Improved: - 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)) -## 0.14.0-beta.2 - 2026-10-28 +## 0.14.0 - 2026-10-29 Plugins can now add tools to individual agents and configure their MCP servers when sessions open. @@ -43,7 +43,6 @@ Voice settings now let you configure speech providers and download local models - Reduced daemon stalls when opening and paging large Claude and OMP histories ([#7789](https://github.com/getpaseo/paseo/pull/7789), [#7940](https://github.com/getpaseo/paseo/pull/7940), [#7948](https://github.com/getpaseo/paseo/pull/7948), [#7958](https://github.com/getpaseo/paseo/pull/7958) by [@ragokan](https://github.com/ragokan)) - Reduced daemon stalls during plugin builds, image processing and config reloads ([#7967](https://github.com/getpaseo/paseo/pull/7967), [#7954](https://github.com/getpaseo/paseo/pull/7954), [#7959](https://github.com/getpaseo/paseo/pull/7959)) - Reduced blocking filesystem work during workspace and project refreshes ([#7941](https://github.com/getpaseo/paseo/pull/7941), [#7943](https://github.com/getpaseo/paseo/pull/7943), [#7944](https://github.com/getpaseo/paseo/pull/7944)) -- Added a workaround for Claude Code sessions consuming subscription limits faster in Paseo ([#7976](https://github.com/getpaseo/paseo/pull/7976) by [@david828019-oss](https://github.com/david828019-oss)) ### Fixed @@ -72,7 +71,8 @@ Voice settings now let you configure speech providers and download local models - Voice mode now defaults to off; enable it in Voice settings - If environment variables select OpenAI speech, move keys and endpoints stored only in legacy `providers.openai` into Voice settings -- Before downgrading to 0.13.2, restore a backed-up `config.json` or convert speech credentials and idle-cleanup settings saved by the beta +- Before downgrading to 0.13.2, restore a backed-up `config.json` or convert speech credentials and idle-cleanup settings saved by 0.14.0 +- Install the optional [Claude Code entrypoint plugin](https://paseo.sh/plugins/getpaseo/claude-code-entrypoint-env) to launch Claude sessions with `CLAUDE_CODE_ENTRYPOINT=Paseo`, a community workaround for subscription limits draining faster ([#7976](https://github.com/getpaseo/paseo/pull/7976), [#8164](https://github.com/getpaseo/paseo/pull/8164) by [@david828019-oss](https://github.com/david828019-oss)) ## 0.13.2 - 2026-10-26 diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index a089401f39..f87cb58805 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -1,6 +1,6 @@ # 更新日志 -尚未发布。以下变更已合入 `main`,但不包含在 0.14.0-beta.2 中;下一个版本的条目会包含它们。 +尚未发布。以下变更已合入 `main`,但不包含在 0.14.0 中;下一个版本的条目会包含它们。 新增: @@ -11,7 +11,7 @@ - 新增采用 `subject.operation.position` 命名的 `server.addHook` 和在 setup 及 Agent 之前等待的 `worktree.create.after` 钩子,弃用原有的插件钩子注册方法并保留原有行为 ([#8081](https://github.com/getpaseo/paseo/pull/8081)) -## 0.14.0-beta.2 - 2026-10-28 +## 0.14.0 - 2026-10-29 插件现在可以为单个 Agent 添加工具,并在会话打开时配置其 MCP 服务器。 @@ -43,7 +43,6 @@ - 减少打开和分页加载大型 Claude 和 OMP 历史时的 daemon 卡顿 ([#7789](https://github.com/getpaseo/paseo/pull/7789), [#7940](https://github.com/getpaseo/paseo/pull/7940), [#7948](https://github.com/getpaseo/paseo/pull/7948), [#7958](https://github.com/getpaseo/paseo/pull/7958) by [@ragokan](https://github.com/ragokan)) - 减少插件构建、图片处理和重新加载配置时的 daemon 卡顿 ([#7967](https://github.com/getpaseo/paseo/pull/7967), [#7954](https://github.com/getpaseo/paseo/pull/7954), [#7959](https://github.com/getpaseo/paseo/pull/7959)) - 减少刷新工作区和项目时由文件系统操作造成的阻塞 ([#7941](https://github.com/getpaseo/paseo/pull/7941), [#7943](https://github.com/getpaseo/paseo/pull/7943), [#7944](https://github.com/getpaseo/paseo/pull/7944)) -- 为 Claude Code 会话在 Paseo 中更快消耗订阅额度的问题添加了变通方案 ([#7976](https://github.com/getpaseo/paseo/pull/7976) by [@david828019-oss](https://github.com/david828019-oss)) ### 修复 @@ -72,7 +71,8 @@ - 语音模式默认关闭,可在语音设置中启用 - 如果通过环境变量选择 OpenAI 语音,请将仅保存在旧版 `providers.openai` 中的密钥和服务地址移至语音设置 -- 降级至 0.13.2 前,请恢复备份的 `config.json` 或转换 beta 保存的语音凭据和空闲清理设置 +- 降级至 0.13.2 前,请恢复备份的 `config.json` 或转换 0.14.0 保存的语音凭据和空闲清理设置 +- 安装可选的 [Claude Code entrypoint 插件](https://paseo.sh/plugins/getpaseo/claude-code-entrypoint-env),即可使用 `CLAUDE_CODE_ENTRYPOINT=Paseo` 启动 Claude 会话;这是针对订阅额度消耗过快问题的社区变通方案 ([#7976](https://github.com/getpaseo/paseo/pull/7976), [#8164](https://github.com/getpaseo/paseo/pull/8164) by [@david828019-oss](https://github.com/david828019-oss)) ## 0.13.2 - 2026-10-26 diff --git a/fastlane/metadata/android/en-US/changelogs/140001.txt b/fastlane/metadata/android/en-US/changelogs/140001.txt new file mode 100644 index 0000000000..32cf0aa48b --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/140001.txt @@ -0,0 +1,10 @@ +Plugins can now add tools to individual agents and configure their MCP servers when sessions open. + +Completed conversation turns can be collapsed into summaries and expanded to read the full exchange. + +Voice settings now let you configure speech providers and download local models with one tap, including SenseVoice and Whisper for Chinese dictation. Voice mode is now opt-in. + +Added +- Let plugins give tools to specific agents + +Full notes: https://paseo.sh/changelog diff --git a/fastlane/metadata/android/en-US/changelogs/140002.txt b/fastlane/metadata/android/en-US/changelogs/140002.txt new file mode 100644 index 0000000000..32cf0aa48b --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/140002.txt @@ -0,0 +1,10 @@ +Plugins can now add tools to individual agents and configure their MCP servers when sessions open. + +Completed conversation turns can be collapsed into summaries and expanded to read the full exchange. + +Voice settings now let you configure speech providers and download local models with one tap, including SenseVoice and Whisper for Chinese dictation. Voice mode is now opt-in. + +Added +- Let plugins give tools to specific agents + +Full notes: https://paseo.sh/changelog diff --git a/fastlane/metadata/android/en-US/changelogs/140003.txt b/fastlane/metadata/android/en-US/changelogs/140003.txt new file mode 100644 index 0000000000..32cf0aa48b --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/140003.txt @@ -0,0 +1,10 @@ +Plugins can now add tools to individual agents and configure their MCP servers when sessions open. + +Completed conversation turns can be collapsed into summaries and expanded to read the full exchange. + +Voice settings now let you configure speech providers and download local models with one tap, including SenseVoice and Whisper for Chinese dictation. Voice mode is now opt-in. + +Added +- Let plugins give tools to specific agents + +Full notes: https://paseo.sh/changelog diff --git a/fastlane/metadata/android/en-US/changelogs/140004.txt b/fastlane/metadata/android/en-US/changelogs/140004.txt new file mode 100644 index 0000000000..32cf0aa48b --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/140004.txt @@ -0,0 +1,10 @@ +Plugins can now add tools to individual agents and configure their MCP servers when sessions open. + +Completed conversation turns can be collapsed into summaries and expanded to read the full exchange. + +Voice settings now let you configure speech providers and download local models with one tap, including SenseVoice and Whisper for Chinese dictation. Voice mode is now opt-in. + +Added +- Let plugins give tools to specific agents + +Full notes: https://paseo.sh/changelog diff --git a/package-lock.json b/package-lock.json index 0930535606..3351d15ac9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.14.0-beta.2", + "version": "0.14.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.14.0-beta.2", + "version": "0.14.0", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ @@ -37590,7 +37590,7 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.14.0-beta.2", + "version": "0.14.0", "dependencies": { "@codemirror/commands": "6.10.4", "@codemirror/language": "6.12.4", @@ -39793,12 +39793,12 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.14.0-beta.2", + "version": "0.14.0", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.14.0-beta.2", - "@getpaseo/protocol": "0.14.0-beta.2", - "@getpaseo/server": "0.14.0-beta.2", + "@getpaseo/client": "0.14.0", + "@getpaseo/protocol": "0.14.0", + "@getpaseo/server": "0.14.0", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", @@ -40045,10 +40045,10 @@ }, "packages/client": { "name": "@getpaseo/client", - "version": "0.14.0-beta.2", + "version": "0.14.0", "dependencies": { - "@getpaseo/protocol": "0.14.0-beta.2", - "@getpaseo/relay": "0.14.0-beta.2", + "@getpaseo/protocol": "0.14.0", + "@getpaseo/relay": "0.14.0", "ws": "^8.20.0", "zod": "^4.4.3" }, @@ -40060,7 +40060,7 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.14.0-beta.2", + "version": "0.14.0", "license": "Apache-2.0", "dependencies": { "@getpaseo/cli": "*", @@ -40304,7 +40304,7 @@ }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", - "version": "0.14.0-beta.2", + "version": "0.14.0", "license": "MIT", "devDependencies": { "@types/jest": "^29.5.14", @@ -40350,7 +40350,7 @@ }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.14.0-beta.2", + "version": "0.14.0", "dependencies": { "@codemirror/language": "6.12.4", "@codemirror/legacy-modes": "^6.5.3", @@ -40379,15 +40379,15 @@ }, "packages/plugin": { "name": "@getpaseo/plugin", - "version": "0.14.0-beta.2", + "version": "0.14.0", "dependencies": { "@agentclientprotocol/sdk": "^1.4.0", "i18next": "^26.3.0", "use-sync-external-store": "^1.6.0" }, "devDependencies": { - "@getpaseo/client": "0.14.0-beta.2", - "@getpaseo/protocol": "0.14.0-beta.2", + "@getpaseo/client": "0.14.0", + "@getpaseo/protocol": "0.14.0", "@types/node": "^20.9.0", "@types/react": "~19.2.0", "react": "19.2.3", @@ -40397,8 +40397,8 @@ "zod": "^4.4.3" }, "peerDependencies": { - "@getpaseo/client": "0.14.0-beta.2", - "@getpaseo/protocol": "0.14.0-beta.2", + "@getpaseo/client": "0.14.0", + "@getpaseo/protocol": "0.14.0", "react": ">=19.1.0 <20", "react-native": ">=0.81.5", "zod": "^4.4.3" @@ -40420,7 +40420,7 @@ }, "packages/protocol": { "name": "@getpaseo/protocol", - "version": "0.14.0-beta.2", + "version": "0.14.0", "dependencies": { "semver": "^7.8.5", "zod": "^4.4.3" @@ -40447,7 +40447,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.14.0-beta.2", + "version": "0.14.0", "dependencies": { "@noble/ciphers": "^2.4.0", "@noble/curves": "^2.4.0", @@ -40668,16 +40668,16 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.14.0-beta.2", + "version": "0.14.0", "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-beta.2", - "@getpaseo/highlight": "0.14.0-beta.2", - "@getpaseo/plugin": "0.14.0-beta.2", - "@getpaseo/protocol": "0.14.0-beta.2", - "@getpaseo/relay": "0.14.0-beta.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", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "1.29.0", "@opencode-ai/sdk": "1.14.46", @@ -41598,7 +41598,7 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.14.0-beta.2", + "version": "0.14.0", "dependencies": { "@cloudflare/vite-plugin": "^1.29.1", "@cloudflare/workers-types": "^4.20260317.1", @@ -43208,7 +43208,7 @@ }, "plugins": { "name": "@getpaseo/builtin-plugins", - "version": "0.14.0-beta.2", + "version": "0.14.0", "dependencies": { "@getpaseo/plugin": "*", "zod": "^4.4.3" diff --git a/package.json b/package.json index 0065786340..938497a362 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.14.0-beta.2", + "version": "0.14.0", "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 1a348e819f..42294ae6a1 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/app", - "version": "0.14.0-beta.2", + "version": "0.14.0", "private": true, "main": "index.ts", "scripts": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 0d0d5cb61d..45113dd1bc 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.14.0-beta.2", + "version": "0.14.0", "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-beta.2", - "@getpaseo/protocol": "0.14.0-beta.2", - "@getpaseo/server": "0.14.0-beta.2", + "@getpaseo/client": "0.14.0", + "@getpaseo/protocol": "0.14.0", + "@getpaseo/server": "0.14.0", "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 e15265c8f1..6e10c76089 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/client", - "version": "0.14.0-beta.2", + "version": "0.14.0", "description": "Paseo client SDK package", "files": [ "dist", @@ -47,8 +47,8 @@ "test": "vitest run" }, "dependencies": { - "@getpaseo/protocol": "0.14.0-beta.2", - "@getpaseo/relay": "0.14.0-beta.2", + "@getpaseo/protocol": "0.14.0", + "@getpaseo/relay": "0.14.0", "ws": "^8.20.0", "zod": "^4.4.3" }, diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 8a77957fba..074e79b64d 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.14.0-beta.2", + "version": "0.14.0", "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 016a28132e..aa86be129b 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-beta.2", + "version": "0.14.0", "description": "Native module for two way audio streaming", "keywords": [ "ExpoTwoWayAudio", diff --git a/packages/highlight/package.json b/packages/highlight/package.json index da80a7421c..6b1d924e05 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.14.0-beta.2", + "version": "0.14.0", "files": [ "dist", "!dist/**/*.map", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 5fa8449b2b..32cb48e026 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/plugin", - "version": "0.14.0-beta.2", + "version": "0.14.0", "description": "Paseo plugin SDK package", "files": [ "dist", @@ -77,8 +77,8 @@ "use-sync-external-store": "^1.6.0" }, "devDependencies": { - "@getpaseo/client": "0.14.0-beta.2", - "@getpaseo/protocol": "0.14.0-beta.2", + "@getpaseo/client": "0.14.0", + "@getpaseo/protocol": "0.14.0", "@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-beta.2", - "@getpaseo/protocol": "0.14.0-beta.2", + "@getpaseo/client": "0.14.0", + "@getpaseo/protocol": "0.14.0", "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 8829e183a3..21d500fd4f 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/protocol", - "version": "0.14.0-beta.2", + "version": "0.14.0", "description": "Paseo shared protocol schemas and wire types", "files": [ "dist", diff --git a/packages/relay/package.json b/packages/relay/package.json index 5daed2afce..ec1deebff7 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.14.0-beta.2", + "version": "0.14.0", "description": "Paseo relay for bridging daemon and client connections", "files": [ "dist", diff --git a/packages/server/package.json b/packages/server/package.json index 7f8a6c074a..90e2ccace8 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.14.0-beta.2", + "version": "0.14.0", "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-beta.2", - "@getpaseo/highlight": "0.14.0-beta.2", - "@getpaseo/plugin": "0.14.0-beta.2", - "@getpaseo/protocol": "0.14.0-beta.2", - "@getpaseo/relay": "0.14.0-beta.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", "@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 9b115fbfe7..7e0635c2e9 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.14.0-beta.2", + "version": "0.14.0", "private": true, "type": "module", "scripts": { diff --git a/plugins/package.json b/plugins/package.json index 9a816eacbd..aa09dfb568 100644 --- a/plugins/package.json +++ b/plugins/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/builtin-plugins", - "version": "0.14.0-beta.2", + "version": "0.14.0", "private": true, "type": "module", "scripts": { From fb69bf331420361edc34287167390f41c878a467 Mon Sep 17 00:00:00 2001 From: "paseo-ai[bot]" <266920839+paseo-ai[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 08:49:01 +0000 Subject: [PATCH 10/95] 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 eae1803a42..a6061301b1 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-AmMp/4ugSUKXTL6H0oJaGJMztVGiYCPXeWteQaaaguw= +sha256-6C4Rjh0mRVrC9akBVGK0NZaCaLJhwKObT7HrgPmcvyw= From 75f58c4fe7d3180e3ff74e10480bf0dc1111f37d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 29 Oct 2026 15:57:08 +0700 Subject: [PATCH 11/95] fix(release): upload only en-US screenshots to Google Play (#8195) supply uploads every locale folder under fastlane/metadata/android in one edit. Play rejects the F-Droid-only ja locale, which failed the whole edit in the 0.14.0 release ("ja - Invalid request"), so no screenshots were replaced. Stage only the en-US images, add a standalone workflow to rerun the upload for the current release, and document the App Store associatedErrors gotcha. --- docs/release.md | 3 ++- .../workflows/upload-android-screenshots.yml | 27 +++++++++++++++++++ packages/app/fastlane/Fastfile | 18 ++++++++++++- 3 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 packages/app/.eas/workflows/upload-android-screenshots.yml diff --git a/docs/release.md b/docs/release.md index 07c6e1b16f..328c189f7b 100644 --- a/docs/release.md +++ b/docs/release.md @@ -380,7 +380,8 @@ There is no mobile-release workflow under `.github/workflows`. The EAS GitHub ap Each stable release publishes the screenshots committed under `assets/store-screenshots/output/` (see [its README](../assets/store-screenshots/README.md) for how they are made). To change the store screenshots, re-render and commit them before you tag. - **App Store:** `submit_review` uploads `output/apple/` and `output/apple-ipad/` to the version it submits, replacing that version's `en-US` screenshots. Apple ties screenshots to an app version, so a version that is already live keeps the ones it shipped with. `Resubmit iOS for App Store review` uploads them again. -- **Google Play:** `upload_android_screenshots` runs `fastlane android upload_screenshots` after `submit_android`. It replaces only the phone and 10-inch tablet screenshots, never the listing text or changelogs, and skips images whose checksum already matches. + - When `submit_review` fails with `appStoreVersions … is not in valid state`, the reason is in the API error's `meta.associatedErrors`, which fastlane does not print. Read it with a `POST /v1/reviewSubmissionItems` against the version. A required age-rating question that Apple added, such as `socialMedia`, fails this way; answer it under App Information > Age Rating in App Store Connect, then resubmit. +- **Google Play:** `upload_android_screenshots` runs `fastlane android upload_screenshots` after `submit_android`. It replaces only the `en-US` phone and 10-inch tablet screenshots, never the listing text or changelogs, and skips images whose checksum already matches. The lane stages only `en-US`: supply uploads every locale folder in one edit, and Play rejects F-Droid locales such as `ja`, which fails the whole edit. `Upload Google Play screenshots` reruns the step for the current release without a rebuild. - **F-Droid:** reads the same files as Google Play. `fastlane/metadata/android/en-US/images/phoneScreenshots/` and `tenInchScreenshots/` hold symlinks to the Google renders. `render.mjs` rewrites them on every render, so added or renamed panels reach Google Play and F-Droid as well. Fastlane cannot use the Play key Expo stores for `eas submit`. The Play lane reads its own copy of the same service-account key from the `GOOGLE_PLAY_JSON_KEY` file secret in the EAS `production` environment, and that service account needs the Play Console permission **Manage store presence**. diff --git a/packages/app/.eas/workflows/upload-android-screenshots.yml b/packages/app/.eas/workflows/upload-android-screenshots.yml new file mode 100644 index 0000000000..dbd2fdddca --- /dev/null +++ b/packages/app/.eas/workflows/upload-android-screenshots.yml @@ -0,0 +1,27 @@ +name: Upload Google Play screenshots + +# Standalone re-trigger for the release workflow's Google Play screenshot step. +# Replaces the en-US phone and tablet screenshots with the committed renders, +# without rebuilding or resubmitting the app. + +on: + workflow_dispatch: + inputs: + android_version_code: + type: string + required: false + description: "versionCode of the production release to attach the change to, e.g. 14000. Leave empty when the production track has a single release." + +jobs: + upload_android_screenshots: + name: Upload Google Play screenshots + environment: production + runs_on: macos-medium + steps: + - uses: eas/checkout + - name: Install fastlane + run: bundle install + - name: Upload screenshots + run: | + export ANDROID_VERSION_CODE="${{ inputs.android_version_code }}" + bundle exec fastlane android upload_screenshots diff --git a/packages/app/fastlane/Fastfile b/packages/app/fastlane/Fastfile index 065ca48b7f..f9f11a0e9a 100644 --- a/packages/app/fastlane/Fastfile +++ b/packages/app/fastlane/Fastfile @@ -26,6 +26,22 @@ def stage_app_store_screenshots root end +# Google Play only has en-US screenshots, and some F-Droid locales in +# ANDROID_METADATA are not Play languages (Play rejects "ja"). supply uploads +# every locale folder in one edit, so give it only the en-US images. +def stage_google_play_screenshots + root = Dir.mktmpdir("google-play-screenshots") + images = File.join(ANDROID_METADATA, "en-US/images") + %w[phoneScreenshots tenInchScreenshots].each do |kind| + target = File.join(root, "en-US/images", kind) + FileUtils.mkdir_p(target) + Dir.glob(File.join(images, kind, "*.png")).sort.each do |file| + FileUtils.cp(File.realpath(file), File.join(target, File.basename(file))) + end + end + root +end + platform :ios do desc "Submit the latest TestFlight build to App Store Review" lane :submit_review do @@ -132,7 +148,7 @@ platform :android do supply( package_name: APP_IDENTIFIER, json_key: ENV.fetch("GOOGLE_PLAY_JSON_KEY"), - metadata_path: ANDROID_METADATA, + metadata_path: stage_google_play_screenshots, version_code: version_code.empty? ? nil : version_code.to_i, skip_upload_apk: true, skip_upload_aab: true, From c4b4516890cc8d5c78911a314cfb2c1257a26db8 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:10:25 +0000 Subject: [PATCH 12/95] Keep split dividers under the pointer across mouse and gesture input (#6011) * fix(app): keep split dividers under the mouse before Explorer is first opened The main row only left the Explorer pane out once the Explorer had been shown, because the split container read the registered pane id directly. Before that, the hidden Explorer stayed in the row with its share of the sizes, and every divider drag moved about 1.28 times the mouse distance. Resolve the pane the same way the workspace screen does, so the default Explorer pane is left out from the start. Closes #5998 * fix(app): keep split dividers under gesture input Measure the gesture from its initial press so activation does not discard the movement before its threshold. Cover coarse-pointer mouse and touch input alongside app-created workspaces, Explorer changes and reload. --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../browser/split-pane-drag-tracking.spec.ts | 134 ++++++++++++++++++ packages/app/src/components/resize-handle.tsx | 35 +++-- .../app/src/components/split-container.tsx | 5 +- 3 files changed, 158 insertions(+), 16 deletions(-) create mode 100644 packages/app/e2e/browser/split-pane-drag-tracking.spec.ts diff --git a/packages/app/e2e/browser/split-pane-drag-tracking.spec.ts b/packages/app/e2e/browser/split-pane-drag-tracking.spec.ts new file mode 100644 index 0000000000..90420c6fd9 --- /dev/null +++ b/packages/app/e2e/browser/split-pane-drag-tracking.spec.ts @@ -0,0 +1,134 @@ +import { expect, type Locator, type Page } from "@playwright/test"; +import { test } from "../support/fixtures"; +import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions"; +import { createCreationScenario } from "../support/helpers/creation"; +import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs"; + +const DRAG_DISTANCE = -40; +const TOLERANCE_PX = 2; + +function splitDividers(page: Page): Locator { + return page.getByTestId("workspace-split-resize-handle").filter({ visible: true }); +} + +async function dividerX(divider: Locator): Promise { + const box = await divider.boundingBox(); + if (!box) throw new Error("Divider has no bounding box"); + return box.x; +} + +async function dragDivider(divider: Locator, deltaX: number): Promise { + const page = divider.page(); + const box = await divider.boundingBox(); + if (!box) throw new Error("Divider has no bounding box"); + const x = box.x + box.width / 2; + const y = box.y + box.height / 2; + await page.mouse.move(x, y); + await page.mouse.down(); + await page.mouse.move(x + deltaX, y, { steps: 8 }); + await page.mouse.up(); +} + +async function dragDividerWithTouch(divider: Locator, deltaX: number): Promise { + const page = divider.page(); + const box = await divider.boundingBox(); + if (!box) throw new Error("Divider has no bounding box"); + const x = box.x + box.width / 2; + const y = box.y + box.height / 2; + const input = await page.context().newCDPSession(page); + try { + await input.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] }); + for (let step = 1; step <= 8; step++) { + await input.send("Input.dispatchTouchEvent", { + type: "touchMove", + touchPoints: [{ x: x + (deltaX * step) / 8, y }], + }); + } + await input.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] }); + } finally { + await input.detach(); + } +} + +async function splitIntoFourPanes(page: Page): Promise { + for (const expectedDividers of [1, 2, 3]) { + await runWorkspaceActionFromCommandCenter(page, "Split pane right"); + await expect(splitDividers(page)).toHaveCount(expectedDividers); + } +} + +async function expectDividersTrackPointer( + page: Page, + distance = DRAG_DISTANCE, + drag = dragDivider, +): Promise { + for (const index of [0, 1, 2]) { + const divider = splitDividers(page).nth(index); + const before = await dividerX(divider); + await drag(divider, distance); + await expect + .poll(async () => Math.abs((await dividerX(divider)) - before - distance), { + message: `divider ${index + 1} should stay under the pointer`, + }) + .toBeLessThanOrEqual(TOLERANCE_PX); + } +} + +for (const hasTouch of [false, true]) { + test.describe(`Split pane drag tracking (${hasTouch ? "coarse" : "fine"} pointer)`, () => { + test.use({ hasTouch }); + + test("a divider moves as far as the pointer in the main row", async ({ page }) => { + await page.setViewportSize({ width: 1600, height: 900 }); + const workspace = await seedWorkspace({ repoPrefix: "split-drag-tracking-" }); + try { + await gotoWorkspace(page, workspace.workspaceId); + await waitForWorkspaceTabsVisible(page); + await clickNewChat(page); + await splitIntoFourPanes(page); + await expectDividersTrackPointer( + page, + DRAG_DISTANCE, + hasTouch ? dragDividerWithTouch : dragDivider, + ); + } finally { + await workspace.cleanup(); + } + }); + + test("dividers track the pointer after app creation, Explorer changes and reload", async ({ + page, + }) => { + await page.setViewportSize({ width: 1600, height: 900 }); + const creation = await createCreationScenario(page); + try { + await creation.openWorkspaceForm("local"); + await creation.submitPrompt("Reply briefly.", "Create"); + await creation.expectAssistantReply(); + await waitForWorkspaceTabsVisible(page); + await splitIntoFourPanes(page); + await expectDividersTrackPointer(page); + await page + .getByTestId("workspace-explorer-toggle") + .filter({ visible: true }) + .first() + .click(); + await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible(); + await expectDividersTrackPointer(page, -DRAG_DISTANCE); + await page + .getByTestId("workspace-explorer-toggle") + .filter({ visible: true }) + .first() + .click(); + await expect(page.getByTestId("workspace-explorer-sidebar")).not.toBeVisible(); + await page.reload(); + await expect(splitDividers(page)).toHaveCount(3); + await expectDividersTrackPointer(page); + } finally { + await creation.cleanup(); + } + }); + }); +} diff --git a/packages/app/src/components/resize-handle.tsx b/packages/app/src/components/resize-handle.tsx index 6576728384..c3930688c2 100644 --- a/packages/app/src/components/resize-handle.tsx +++ b/packages/app/src/components/resize-handle.tsx @@ -49,7 +49,7 @@ export function ResizeHandle({ const { theme } = useUnistyles(); const finePointer = useHasFinePointer(); const pointerStatesRef = useRef(new Map()); - const touchDragRef = useRef(null); + const touchStateRef = useRef(null); const cursorBeforeDragRef = useRef(null); const hoverTimerRef = useRef | null>(null); const [active, setActive] = useState(false); @@ -151,23 +151,30 @@ export function ResizeHandle({ const touchGesture = useMemo(() => { const gesture = Gesture.Pan() .runOnJS(true) - .onBegin(() => setDragging(true)) - .onStart(() => { - touchDragRef.current = startResizeHandleDrag({ - sizes, - index, - preview: (nextSizes) => onPreviewResizeSplit(groupId, nextSizes), - commit: (nextSizes) => onResizeSplit(groupId, nextSizes), - }); + .onBegin((event) => { + setDragging(true); + touchStateRef.current = { + containerSize, + pointerStart: direction === "horizontal" ? event.absoluteX : event.absoluteY, + drag: startResizeHandleDrag({ + sizes, + index, + preview: (nextSizes) => onPreviewResizeSplit(groupId, nextSizes), + commit: (nextSizes) => onResizeSplit(groupId, nextSizes), + }), + }; }) .onUpdate((event) => { - if (containerSize <= 0) return; - const translation = direction === "horizontal" ? event.translationX : event.translationY; - touchDragRef.current?.move(translation / containerSize); + const state = touchStateRef.current; + if (!state || state.containerSize <= 0) return; + // Gesture Handler resets web translation when the pan activates. Measure from + // the initial press so the activation distance is included on every platform. + const pointerCurrent = direction === "horizontal" ? event.absoluteX : event.absoluteY; + state.drag.move((pointerCurrent - state.pointerStart) / state.containerSize); }) - .onEnd(() => touchDragRef.current?.finish()) + .onEnd(() => touchStateRef.current?.drag.finish()) .onFinalize(() => { - touchDragRef.current = null; + touchStateRef.current = null; setDragging(false); }); diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index 660b5f4e6b..278317243a 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -48,6 +48,7 @@ import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs- import { collectAllPanes, createDefaultLayout, + selectExplorerSidebarPaneId, useWorkspaceLayoutStore, type SplitNode, type SplitPane, @@ -183,8 +184,8 @@ export function SplitContainer({ } | null>(null); const maximizedPaneId = maximizedPane?.workspaceKey === workspaceKey ? maximizedPane.paneId : null; - const explorerSidebarPaneId = useWorkspaceLayoutStore( - (state) => state.explorerSidebarPaneIdByWorkspace[workspaceKey] ?? null, + const explorerSidebarPaneId = useWorkspaceLayoutStore((state) => + selectExplorerSidebarPaneId(state, workspaceKey), ); const mainRoot = useMemo( From ac6e4e9f96121b8954594ce3f166551a3f04649a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 29 Oct 2026 16:56:12 +0700 Subject: [PATCH 13/95] Fix flaky 64 MiB image materialization test on Windows CI (#8198) The responsiveness test timed wall-clock gaps between 1 ms ticks, which reached 50.35 ms on the shared Windows runner for the same code. It now measures main-thread CPU time between ticks and still fails when the payload decodes in one chunk. --- .../agent/providers/provider-image-output.test.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/server/src/server/agent/providers/provider-image-output.test.ts b/packages/server/src/server/agent/providers/provider-image-output.test.ts index 0606c59de3..3388edb6d5 100644 --- a/packages/server/src/server/agent/providers/provider-image-output.test.ts +++ b/packages/server/src/server/agent/providers/provider-image-output.test.ts @@ -184,17 +184,24 @@ test("repeated content is reused without rewriting the attachment", async () => test("64 MiB materialization leaves the event loop responsive and preserves bytes", async () => { const bytes = Buffer.alloc(64 * 1024 * 1024, 37); const data = bytes.toString("base64"); - let previous = performance.now(); + // Main-thread CPU time between ticks bounds the decode and hash work done on the loop; + // wall-clock gaps on shared CI runners also count preemption and timer granularity. + // File writes stay on fs/promises, so they never block the loop. + const threadCpuMs = () => { + const { user, system } = process.threadCpuUsage(); + return (user + system) / 1000; + }; + let previous = threadCpuMs(); let longest = 0; const probe = setInterval(() => { - const now = performance.now(); + const now = threadCpuMs(); longest = Math.max(longest, now - previous); previous = now; }, 1); try { const result = await materializeProviderImage({ data, mimeType: "image/png" }); await new Promise((resolve) => setTimeout(resolve, 5)); - console.log(`64 MiB attachment longest loop gap: ${longest.toFixed(2)} ms`); + console.log(`64 MiB attachment longest blocking stretch: ${longest.toFixed(2)} ms CPU`); expect(longest).toBeLessThan(50); expect(readFileSync(result.path).equals(bytes)).toBe(true); rmSync(result.path, { force: true }); From 946b6e58fd58897036f8591a84ab0061cc5dcfee Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:16:48 +0000 Subject: [PATCH 14/95] Keep transcript colors in sync after a hidden theme change (#8199) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(app): preserve adaptive theme events while hidden Read the current scheme through fresh matchMedia lists so hidden runtime reads do not consume the pending events on listener-owned lists. Carry Dan Sabin's fix from #7172 into the current combined Unistyles patch, and cover both transition directions with a real Electron regression in the existing desktop browser suite. Co-authored-by: Dan Sabin * test(desktop): run adaptive theme regression on Linux Electron retains --no-sandbox in argv, so the positional lookup used the script filename as the artifact directory. Read the final argument so the same real Electron regression exercises Linux as well as macOS. Co-authored-by: Nurşat Bülbül <85260051+Nurshot@users.noreply.github.com> Co-authored-by: Dan Sabin --------- Co-authored-by: Nurshot <85260051+Nurshot@users.noreply.github.com> Co-authored-by: Dan Sabin Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- docs/unistyles.md | 8 + .../desktop/e2e/appearance-theme.electron.mjs | 158 ++++++++++++++++++ packages/desktop/e2e/browser-tabs.e2e.mjs | 9 +- patches/react-native-unistyles+3.2.4.patch | 71 ++++++++ scripts/postinstall-patches.mjs | 1 + 5 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 packages/desktop/e2e/appearance-theme.electron.mjs diff --git a/docs/unistyles.md b/docs/unistyles.md index 3daae333df..6be82f70b0 100644 --- a/docs/unistyles.md +++ b/docs/unistyles.md @@ -358,6 +358,14 @@ export to `src`, but browser and Electron Metro resolve its `browser` export to `lib/module`; CommonJS consumers use `lib/commonjs`. A source-only patch leaves Electron running the old code. +The adaptive-theme patch keeps `colorScheme` reads off the media-query lists +owned by Unistyles' change listeners. Chromium defers their events while a window +is hidden; reading `matches` on those same lists can consume the pending change. +CSS variables then follow the OS theme while `withUnistyles` props retain the old +colors. Keep this patch until upstream reads the scheme through separate lists. +The desktop browser E2E checks both transition directions in an isolated Electron +process, because Playwright's focus emulation prevents real hidden-page behavior. + Register every dependency patch in `scripts/postinstall-patches.mjs`. A file in `patches/` is inert unless that script knows which installed package activates it. diff --git a/packages/desktop/e2e/appearance-theme.electron.mjs b/packages/desktop/e2e/appearance-theme.electron.mjs new file mode 100644 index 0000000000..426c50f355 --- /dev/null +++ b/packages/desktop/e2e/appearance-theme.electron.mjs @@ -0,0 +1,158 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import electronPath from "electron"; +import { build } from "esbuild"; + +export async function runAdaptiveThemeRegression(artifactDir) { + const { outputFiles } = await build({ + stdin: { + resolveDir: fileURLToPath(new URL("../../app", import.meta.url)), + contents: ` + import { createElement } from "react"; + import { createRoot } from "react-dom/client"; + import { StyleSheet, UnistylesRuntime, withUnistyles } from "react-native-unistyles"; + StyleSheet.configure({ + themes: { + dark: { foreground: "#fafafa", background: "#181b1a" }, + light: { foreground: "#1a1a1e", background: "#ffffff" }, + }, + settings: { adaptiveThemes: true }, + }); + const Message = withUnistyles(({ color }) => + createElement("p", { style: { color } }, "Theme-reactive message")); + createRoot(document.getElementById("root")).render(createElement(Message, { + style: { margin: 0 }, + uniProps: theme => ({ color: theme.foreground }), + })); + window.readThemeForTest = () => UnistylesRuntime.themeName; + `, + }, + bundle: true, + write: false, + format: "iife", + platform: "browser", + alias: { "react-native": "react-native-web" }, + define: { __DEV__: "false" }, + resolveExtensions: [".web.js", ".js", ".json"], + }); + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-adaptive-theme-")); + try { + await fs.writeFile(path.join(directory, "renderer.js"), outputFiles[0].text); + await fs.writeFile(path.join(directory, "main.cjs"), `(${electronRegression.toString()})()`); + // A separate process avoids Playwright's focus emulation, which keeps hidden pages visible. + const env = { ...process.env }; + delete env.ELECTRON_RUN_AS_NODE; + const args = [ + ...(process.platform === "linux" ? ["--no-sandbox"] : []), + path.join(directory, "main.cjs"), + artifactDir, + ]; + const command = process.platform === "linux" ? "xvfb-run" : electronPath; + const commandArgs = + process.platform === "linux" + ? ["-a", "--server-args=-screen 0 1280x800x24", electronPath, ...args] + : args; + await promisify(execFile)(command, commandArgs, { + env, + timeout: 30_000, + }); + return JSON.parse(await fs.readFile(path.join(artifactDir, "adaptive-theme.json"), "utf8")); + } finally { + await fs.rm(directory, { recursive: true, force: true }); + } +} + +async function electronRegression() { + const { app, BrowserWindow, nativeTheme } = require("electron"); + const assert = require("node:assert/strict"); + const files = require("node:fs"); + const paths = require("node:path"); + const { setTimeout: delay } = require("node:timers/promises"); + app.setPath("userData", paths.join(__dirname, "profile")); + app.on("window-all-closed", () => {}); + // Electron keeps Linux's --no-sandbox in argv; the artifact directory is always last. + const artifactDir = process.argv.at(-1); + let window; + const observations = []; + const colors = { + dark: { foreground: "rgb(250, 250, 250)", background: "rgb(24, 27, 26)" }, + light: { foreground: "rgb(26, 26, 30)", background: "rgb(255, 255, 255)" }, + }; + async function read(expression) { + return window.webContents.executeJavaScript(expression); + } + async function expectValue(expression, expected) { + let actual; + for (let attempt = 0; attempt < 50; attempt++) { + actual = await read(expression); + if (actual === expected) return; + await delay(50); + } + assert.equal(actual, expected, expression); + } + try { + await app.whenReady(); + if (process.platform === "darwin") { + app.setActivationPolicy("accessory"); + app.dock.hide(); + } + for (const [from, to] of [ + ["dark", "light"], + ["light", "dark"], + ]) { + nativeTheme.themeSource = from; + window = new BrowserWindow({ width: 600, height: 200, show: false, skipTaskbar: true }); + await window.loadURL( + "data:text/html," + + encodeURIComponent(` + +
`), + ); + await read(files.readFileSync(paths.join(__dirname, "renderer.js"), "utf8")); + window.showInactive(); + const foreground = + "document.querySelector('p') && getComputedStyle(document.querySelector('p')).color"; + const background = "getComputedStyle(document.body).backgroundColor"; + await expectValue(foreground, colors[from].foreground); + await expectValue(background, colors[from].background); + window.hide(); + await expectValue("document.visibilityState", "hidden"); + nativeTheme.themeSource = to; + await expectValue(`matchMedia('(prefers-color-scheme: ${to})').matches`, true); + // Reading the public runtime while hidden must not consume the pending theme notification. + assert.equal(await read("window.readThemeForTest()"), to); + window.showInactive(); + await expectValue("document.visibilityState", "visible"); + await expectValue(background, colors[to].background); + try { + await expectValue(foreground, colors[to].foreground); + } finally { + files.writeFileSync( + paths.join(artifactDir, `theme-${from}-to-${to}.png`), + (await window.webContents.capturePage()).toPNG(), + ); + } + observations.push({ + from, + to, + foreground: await read(foreground), + background: await read(background), + }); + window.destroy(); + window = undefined; + } + files.writeFileSync( + paths.join(artifactDir, "adaptive-theme.json"), + JSON.stringify(observations, null, 2), + ); + app.exit(0); + } catch (error) { + console.error(error); + window?.destroy(); + app.exit(1); + } +} diff --git a/packages/desktop/e2e/browser-tabs.e2e.mjs b/packages/desktop/e2e/browser-tabs.e2e.mjs index 78d2fa978e..28afbab94f 100644 --- a/packages/desktop/e2e/browser-tabs.e2e.mjs +++ b/packages/desktop/e2e/browser-tabs.e2e.mjs @@ -14,6 +14,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/ import { chromium } from "playwright"; import { hideDeveloperToasts } from "../../app/e2e/support/developer-ui.mjs"; import { runAppearanceFontSizeRegression } from "./appearance-font-size.electron.mjs"; +import { runAdaptiveThemeRegression } from "./appearance-theme.electron.mjs"; import { runSettingsScrollRegression } from "./settings-scroll.electron.mjs"; import { runSettingsMemoryRegression } from "./settings-memory.electron.mjs"; @@ -1241,6 +1242,7 @@ async function main() { return; } + const adaptiveTheme = await runAdaptiveThemeRegression(artifactDir); await runAppearanceFontSizeRegression(page); const callerAgentId = await createCallerAgent(daemonPort); @@ -1265,7 +1267,12 @@ async function main() { artifactDir, }); const pluginLinks = await checkPluginLinks(); - writeJson(path.join(artifactDir, "result.json"), { ...report, settingsMemory, pluginLinks }); + writeJson(path.join(artifactDir, "result.json"), { + ...report, + settingsMemory, + pluginLinks, + adaptiveTheme, + }); console.log( `Browser desktop browser E2E passed: WebContents ${report.originalWebContentsId} remained ${report.finalWebContentsId}; viewport, inactive capture, focus continuity, list, snapshot, click, local-page selectors passed.`, ); diff --git a/patches/react-native-unistyles+3.2.4.patch b/patches/react-native-unistyles+3.2.4.patch index 7f40053f19..1930a4db84 100644 --- a/patches/react-native-unistyles+3.2.4.patch +++ b/patches/react-native-unistyles+3.2.4.patch @@ -486,3 +486,74 @@ diff --git a/node_modules/react-native-unistyles/lib/commonjs/core/withUnistyles ...(narrowedProps.style ? { style: styleSecrets.styles } : {}), +diff --git a/node_modules/react-native-unistyles/lib/commonjs/web/runtime.js b/node_modules/react-native-unistyles/lib/commonjs/web/runtime.js +index 305fa49..5957aa5 100644 +--- a/node_modules/react-native-unistyles/lib/commonjs/web/runtime.js ++++ b/node_modules/react-native-unistyles/lib/commonjs/web/runtime.js +@@ -35,10 +35,17 @@ class UnistylesRuntime { + return this.darkMedia; + } + get colorScheme() { ++ // Read the scheme through fresh lists, never through lightMedia/darkMedia. Chromium defers ++ // MediaQueryList change events while the document is hidden and drops the pending event ++ // of any list whose `matches` was read before the document became visible again, which ++ // would silence the adaptive-theme listeners registered on those two lists. ++ if ((0, _utils.isServer)()) { ++ return _types.ColorScheme.Unspecified; ++ } + switch (true) { +- case this.getLightMedia()?.matches: ++ case window.matchMedia('(prefers-color-scheme: light)').matches: + return _types.ColorScheme.Light; +- case this.getDarkMedia()?.matches: ++ case window.matchMedia('(prefers-color-scheme: dark)').matches: + return _types.ColorScheme.Dark; + default: + return _types.ColorScheme.Unspecified; +diff --git a/node_modules/react-native-unistyles/lib/module/web/runtime.js b/node_modules/react-native-unistyles/lib/module/web/runtime.js +index 7f13a06..c487bee 100644 +--- a/node_modules/react-native-unistyles/lib/module/web/runtime.js ++++ b/node_modules/react-native-unistyles/lib/module/web/runtime.js +@@ -31,10 +31,17 @@ export class UnistylesRuntime { + return this.darkMedia; + } + get colorScheme() { ++ // Read the scheme through fresh lists, never through lightMedia/darkMedia. Chromium defers ++ // MediaQueryList change events while the document is hidden and drops the pending event ++ // of any list whose `matches` was read before the document became visible again, which ++ // would silence the adaptive-theme listeners registered on those two lists. ++ if (isServer()) { ++ return ColorScheme.Unspecified; ++ } + switch (true) { +- case this.getLightMedia()?.matches: ++ case window.matchMedia('(prefers-color-scheme: light)').matches: + return ColorScheme.Light; +- case this.getDarkMedia()?.matches: ++ case window.matchMedia('(prefers-color-scheme: dark)').matches: + return ColorScheme.Dark; + default: + return ColorScheme.Unspecified; +diff --git a/node_modules/react-native-unistyles/src/web/runtime.ts b/node_modules/react-native-unistyles/src/web/runtime.ts +index a16dac0..6519858 100644 +--- a/node_modules/react-native-unistyles/src/web/runtime.ts ++++ b/node_modules/react-native-unistyles/src/web/runtime.ts +@@ -41,8 +41,16 @@ export class UnistylesRuntime { + get colorScheme() { ++ // Read the scheme through fresh lists, never through lightMedia/darkMedia. Chromium defers ++ // MediaQueryList change events while the document is hidden and drops the pending event ++ // of any list whose `matches` was read before the document became visible again, which ++ // would silence the adaptive-theme listeners registered on those two lists. ++ if (isServer()) { ++ return ColorScheme.Unspecified ++ } ++ + switch (true) { +- case this.getLightMedia()?.matches: ++ case window.matchMedia('(prefers-color-scheme: light)').matches: + return ColorScheme.Light +- case this.getDarkMedia()?.matches: ++ case window.matchMedia('(prefers-color-scheme: dark)').matches: + return ColorScheme.Dark + default: + return ColorScheme.Unspecified diff --git a/scripts/postinstall-patches.mjs b/scripts/postinstall-patches.mjs index 435f6331e0..eca0fde25f 100644 --- a/scripts/postinstall-patches.mjs +++ b/scripts/postinstall-patches.mjs @@ -24,6 +24,7 @@ const patchedPackages = [ }, // Registry cleanup: remove after react-native-unistyles ships // https://github.com/jpudysz/react-native-unistyles/pull/1203. + // Adaptive themes: keep until colorScheme reads no longer consume listener-owned media changes. // withUnistyles: also keep the uniProps filter until upstream consumes its control prop. { nodeModulesPath: "node_modules/react-native-unistyles", From 1aef1ec6625f2c32cbd62cfbeb02cffb43a275d2 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:21:29 +0000 Subject: [PATCH 15/95] test(server): isolate timeline fetch responsiveness from runner work (#8197) * test(server): isolate timeline fetch responsiveness from runner work Measure the existing cold tail workload in a separate process after fixture collection. Keep the 50ms gate and row assertions, and reproduce unrelated callback CPU contaminating the old measurement. * test(server): keep timeline probe private and exercise overlapping CPU --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../server/agent/agent-timeline-store.test.ts | 61 ++++++++++++++----- .../test-utils/timeline-responsiveness.ts | 25 ++++++++ 2 files changed, 71 insertions(+), 15 deletions(-) create mode 100644 packages/server/src/server/agent/test-utils/timeline-responsiveness.ts diff --git a/packages/server/src/server/agent/agent-timeline-store.test.ts b/packages/server/src/server/agent/agent-timeline-store.test.ts index 4baaf89edb..5d3c836e65 100644 --- a/packages/server/src/server/agent/agent-timeline-store.test.ts +++ b/packages/server/src/server/agent/agent-timeline-store.test.ts @@ -1,4 +1,6 @@ -import { measureMainThreadWork } from "../../test-utils/main-thread-work.js"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { selectReferenceProjectedTimelinePage } from "./test-utils/timeline-page-reference.js"; import type { AgentTimelineItem } from "./agent-sdk-types.js"; @@ -251,23 +253,52 @@ it("includes transitive tool updates in a contiguous projected tail", () => { expect(tail).toMatchObject({ startSeq: 1, endSeq: 6, hasOlder: false }); }); +async function measureTimelineTail(wholeTurns: boolean) { + const fixture = fileURLToPath( + new URL("./test-utils/timeline-responsiveness.ts", import.meta.url), + ); + const { stdout, stderr } = await promisify(execFile)( + process.execPath, + ["--expose-gc", "--import", "tsx", fixture, String(wholeTurns)], + { timeout: 15_000 }, + ); + process.stderr.write(stderr); + return JSON.parse(stdout) as { + result: ReturnType; + blockingMs: number; + }; +} + +it("excludes unrelated test-runner CPU from the timeline fetch budget", async () => { + let unrelatedWork: ReturnType; + const burnCpu = () => { + const threadCpuUsage: (previous?: NodeJS.CpuUsage) => NodeJS.CpuUsage = Reflect.get( + process, + "threadCpuUsage", + ); + const start = threadCpuUsage(); + let elapsed: NodeJS.CpuUsage; + do { + elapsed = threadCpuUsage(start); + } while (elapsed.user + elapsed.system < 80_000); + unrelatedWork = setImmediate(burnCpu); + }; + unrelatedWork = setImmediate(burnCpu); + try { + const { result: tail, blockingMs } = await measureTimelineTail(false); + expect(tail.rows).toHaveLength(200); + expect(tail.startSeq).toBe(99_801); + expect(tail.endSeq).toBe(100_000); + expect(blockingMs).toBeLessThan(50); + } finally { + clearImmediate(unrelatedWork); + } +}); + it.each([false, true])( "serves tail 200 from 100,000 retained rows without stalling the loop (wholeTurns=%s)", async (wholeTurns) => { - const store = new InMemoryAgentTimelineStore(); - store.initialize("large"); - for (let index = 0; index < 100_000; index++) { - store.append("large", { type: "user_message", text: String(index) }); - } - const { result: tail, blockingMs } = await measureMainThreadWork( - `timeline tail main-thread work (wholeTurns=${wholeTurns})`, - () => { - let page = store.fetch("large", { limit: 200, wholeTurns }); - for (let repeat = 1; repeat < 8; repeat++) - page = store.fetch("large", { limit: 200, wholeTurns }); - return page; - }, - ); + const { result: tail, blockingMs } = await measureTimelineTail(wholeTurns); expect(tail.rows).toHaveLength(200); expect(tail.startSeq).toBe(99_801); expect(tail.endSeq).toBe(100_000); diff --git a/packages/server/src/server/agent/test-utils/timeline-responsiveness.ts b/packages/server/src/server/agent/test-utils/timeline-responsiveness.ts new file mode 100644 index 0000000000..bfe5ee59c4 --- /dev/null +++ b/packages/server/src/server/agent/test-utils/timeline-responsiveness.ts @@ -0,0 +1,25 @@ +import { setImmediate as nextTurn } from "node:timers/promises"; +import { measureMainThreadWork } from "../../../test-utils/main-thread-work.js"; +import { InMemoryAgentTimelineStore } from "../agent-timeline-store.js"; + +// Measure a cold fetch on its own thread, without Vitest reporting or callbacks. +const wholeTurns = process.argv[2] === "true"; +const store = new InMemoryAgentTimelineStore(); +store.initialize("large"); +for (let index = 0; index < 100_000; index++) { + store.append("large", { type: "user_message", text: String(index) }); +} +// Finish fixture allocation and collect its temporary garbage before measuring. +globalThis.gc!(); +await nextTurn(); +const measured = await measureMainThreadWork( + `timeline tail main-thread work (wholeTurns=${wholeTurns})`, + () => { + let page = store.fetch("large", { limit: 200, wholeTurns }); + for (let repeat = 1; repeat < 8; repeat++) + page = store.fetch("large", { limit: 200, wholeTurns }); + return page; + }, +); + +process.stdout.write(JSON.stringify(measured)); From 9503537041002d63d9078dced49257c8d8a903d2 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:02:58 +0000 Subject: [PATCH 16/95] test(server): exclude loader activity from config responsiveness (#8218) * test(server): trace recurring config responsiveness failures * test(server): keep loader messages out of config responsiveness probes --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../src/utils/paseo-config-file.test.ts | 52 ++++++++++++++----- .../test-fixtures/config-responsiveness.ts | 16 ++++-- 2 files changed, 52 insertions(+), 16 deletions(-) diff --git a/packages/server/src/utils/paseo-config-file.test.ts b/packages/server/src/utils/paseo-config-file.test.ts index 186a5259ed..6a7e43c757 100644 --- a/packages/server/src/utils/paseo-config-file.test.ts +++ b/packages/server/src/utils/paseo-config-file.test.ts @@ -5,7 +5,8 @@ import { fileURLToPath } from "node:url"; import { writeFile, truncate, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { build } from "esbuild"; import { isPlatform } from "../test-utils/platform.js"; import { readPaseoConfig, @@ -20,21 +21,40 @@ import { writePaseoConfigForEdit, } from "./paseo-config-file.js"; +let probeDirectory: string; +let probePath: string; + +beforeAll(async () => { + probeDirectory = mkdtempSync(join(tmpdir(), "paseo-config-probe-")); + probePath = join(probeDirectory, "config-responsiveness.mjs"); + // Compile before starting the measured process. A runtime TypeScript loader + // owns MessagePorts that the responsiveness probe otherwise charges as work. + await build({ + entryPoints: [ + fileURLToPath(new URL("./test-fixtures/config-responsiveness.ts", import.meta.url)), + ], + outfile: probePath, + bundle: true, + platform: "node", + format: "esm", + }); +}); + +afterAll(() => { + rmSync(probeDirectory, { recursive: true, force: true }); +}); + async function measureConfigRead(directory: string) { - const fixture = fileURLToPath( - new URL("./test-fixtures/config-responsiveness.ts", import.meta.url), - ); - const { stdout, stderr } = await promisify(execFile)( - process.execPath, - ["--import", "tsx", fixture, directory], - { timeout: 15_000 }, - ); + const { stdout, stderr } = await promisify(execFile)(process.execPath, [probePath, directory], { + timeout: 15_000, + }); process.stderr.write(stderr); return JSON.parse(stdout) as { result: Awaited>; blockingMs: number; filesystemRequests: number; yields: number; + workerMessages: number; }; } @@ -84,7 +104,7 @@ describe("paseo config file substrate", () => { ); }); - it("excludes unrelated test-runner CPU from the config read budget", async () => { + it("excludes test-runner and loader activity from the config read budget", async () => { await writeFile(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "npm ci" } })); const unrelatedWork = new Promise((resolve) => { setImmediate(() => { @@ -100,8 +120,10 @@ describe("paseo config file substrate", () => { resolve(); }); }); - const { result, blockingMs, filesystemRequests } = await measureConfigRead(tempDir); + const { result, blockingMs, filesystemRequests, workerMessages } = + await measureConfigRead(tempDir); await unrelatedWork; + expect(workerMessages).toBe(0); expect(result).toEqual({ ok: true, config: { worktree: { setup: "npm ci" } }, @@ -119,11 +141,13 @@ describe("paseo config file substrate", () => { worktree: { setup: "npm ci" }, }; await writeFile(join(tempDir, "paseo.json"), JSON.stringify(config)); - const { result, blockingMs, filesystemRequests, yields } = await measureConfigRead(tempDir); + const { result, blockingMs, filesystemRequests, yields, workerMessages } = + await measureConfigRead(tempDir); expect(result).toEqual({ ok: true, config, revision: await statPaseoConfigPath(tempDir) }); expect(blockingMs).toBeLessThan(20); expect(filesystemRequests).toBeGreaterThan(0); expect(yields).toBeGreaterThan(0); + expect(workerMessages).toBe(0); }); it("rejects a 32MiB config with main-thread work below 20ms", async () => { @@ -131,9 +155,11 @@ describe("paseo config file substrate", () => { join(tempDir, "paseo.json"), JSON.stringify({ extra: "x".repeat(32 * 1024 * 1024) }), ); - const { result, blockingMs, filesystemRequests } = await measureConfigRead(tempDir); + const { result, blockingMs, filesystemRequests, workerMessages } = + await measureConfigRead(tempDir); expect(blockingMs).toBeLessThan(20); expect(filesystemRequests).toBeGreaterThan(0); + expect(workerMessages).toBe(0); expect(result).toEqual({ ok: false, error: { code: "invalid_project_config" } }); }); diff --git a/packages/server/src/utils/test-fixtures/config-responsiveness.ts b/packages/server/src/utils/test-fixtures/config-responsiveness.ts index 656c5a2864..9e151c805a 100644 --- a/packages/server/src/utils/test-fixtures/config-responsiveness.ts +++ b/packages/server/src/utils/test-fixtures/config-responsiveness.ts @@ -1,14 +1,24 @@ +import { createHook, executionAsyncResource } from "node:async_hooks"; +import { MessagePort } from "node:worker_threads"; import assert from "node:assert/strict"; import { setImmediate as nextTurn } from "node:timers/promises"; import { measureMainThreadWork } from "../../test-utils/main-thread-work.js"; import { readPaseoConfigForEdit } from "../paseo-config-file.js"; -// Keep Vitest transforms, reporting, and unrelated callbacks outside the measured -// thread. Read the real file once, without warming the config snapshot or schema. +// The parent bundles this fixture before launching plain Node, keeping test +// runner callbacks and runtime loader messages outside the measured thread. +// Read the real file once, without warming the config snapshot or schema. const directory = process.argv[2]; assert.ok(directory, "Config directory is required"); await nextTurn(); +let workerMessages = 0; +const activity = createHook({ + before() { + if (executionAsyncResource() instanceof MessagePort) workerMessages++; + }, +}).enable(); const measured = await measureMainThreadWork("project config main-thread work", () => readPaseoConfigForEdit(directory), ); -process.stdout.write(JSON.stringify(measured)); +activity.disable(); +process.stdout.write(JSON.stringify({ ...measured, workerMessages })); From 4351445467e2af8a103c366e353eff4d0e4b3abc Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 00:05:34 +0000 Subject: [PATCH 17/95] Keep other windows in sync when an agent is deleted (#8248) * fix(server): broadcast agent removals across logical sessions * test(server): make subscription fixtures portable on Windows The newly enabled integration suite assumed /bin/cat and a one-second reply deadline, so Windows CI failed before completing its subscription coverage. Use the running Node executable for echo terminals and let diagnostics finish their bounded external tool probes. Co-authored-by: loganmeyer <50949542+loganmeyer@users.noreply.github.com> --------- Co-authored-by: loganmeyer <50949542+loganmeyer@users.noreply.github.com> Co-authored-by: paseo-bot[bot] --- packages/server/package.json | 2 +- .../src/server/agent/agent-manager.test.ts | 54 +++++++ .../server/src/server/agent/agent-manager.ts | 13 +- .../server/owned-subscriptions.e2e.test.ts | 134 ++++++++++++++++-- packages/server/src/server/session.ts | 32 +++-- 5 files changed, 209 insertions(+), 26 deletions(-) diff --git a/packages/server/package.json b/packages/server/package.json index 90e2ccace8..13e5c9b49c 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/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.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/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/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/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index bb521abc71..902fd79ebc 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -8059,6 +8059,60 @@ test("global subscribers receive internal agent events only when they opt in", a unsubscribeOptedIn(); }); +test("agent removal notifications respect scoped subscriptions and internal visibility", () => { + const manager = new AgentManager({ clients: {}, logger }); + const removedId = "00000000-0000-4000-8000-000000000901"; + const internalId = "00000000-0000-4000-8000-000000000902"; + const otherId = "00000000-0000-4000-8000-000000000903"; + const publicEvents: AgentManagerEvent[] = []; + const allEvents: AgentManagerEvent[] = []; + const scopedEvents: AgentManagerEvent[] = []; + const internalEvents: AgentManagerEvent[] = []; + const unrelatedEvents: AgentManagerEvent[] = []; + const releases = [ + manager.subscribe((event) => publicEvents.push(event), { replayState: false }), + manager.subscribe((event) => allEvents.push(event), { + replayState: false, + includeInternal: true, + }), + manager.subscribe((event) => scopedEvents.push(event), { + replayState: false, + agentId: removedId, + }), + manager.subscribe((event) => internalEvents.push(event), { + replayState: false, + agentId: internalId, + }), + manager.subscribe((event) => unrelatedEvents.push(event), { + replayState: false, + agentId: otherId, + }), + ]; + try { + manager.notifyAgentRemoved({ id: removedId, workspaceId: "workspace" }); + manager.notifyAgentRemoved({ id: internalId, workspaceId: "workspace", internal: true }); + const publicRemoval = { + type: "agent_removed", + agentId: removedId, + workspaceId: "workspace", + internal: false, + }; + const internalRemoval = { + type: "agent_removed", + agentId: internalId, + workspaceId: "workspace", + internal: true, + }; + expect(publicEvents).toEqual([publicRemoval]); + expect(allEvents).toEqual([publicRemoval, internalRemoval]); + expect(scopedEvents).toEqual([publicRemoval]); + expect(internalEvents).toEqual([internalRemoval]); + expect(unrelatedEvents).toEqual([]); + } finally { + for (const release of releases) release(); + } +}); + test("getAgent returns internal agents by ID", async () => { const internalAgentId = "00000000-0000-4000-8000-000000000107"; const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index ae9e424d78..a761e723c1 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -236,6 +236,7 @@ export type { export type AgentManagerEvent = | { type: "agent_state"; agent: ManagedAgent } + | { type: "agent_removed"; agentId: string; workspaceId?: string; internal: boolean } | { type: "provider_subagent"; event: ProviderSubagentStoreEvent } | { type: "timeline_replacement"; agentId: string; epoch: string } | { @@ -3519,6 +3520,15 @@ export class AgentManager { await this.deleteCommittedTimeline(agentId); } + notifyAgentRemoved(agent: Pick): void { + this.dispatch({ + type: "agent_removed", + agentId: agent.id, + workspaceId: agent.workspaceId, + internal: agent.internal === true, + }); + } + async deleteCommittedTimeline(agentId: string): Promise { await this.durableTimelineStore?.deleteAgent(agentId); } @@ -5376,7 +5386,7 @@ export class AgentManager { for (const subscriber of this.subscribers) { if ( subscriber.agentId && - event.type === "agent_stream" && + (event.type === "agent_stream" || event.type === "agent_removed") && subscriber.agentId !== event.agentId ) { continue; @@ -5412,6 +5422,7 @@ export class AgentManager { private eventBelongsToInternalAgent(event: AgentManagerEvent): boolean { if (event.type === "agent_state") return event.agent.internal === true; + if (event.type === "agent_removed") return event.internal; if (event.type === "agent_stream") return this.agents.get(event.agentId)?.internal === true; if (event.type !== "provider_subagent") return false; const parentAgentId = diff --git a/packages/server/src/server/owned-subscriptions.e2e.test.ts b/packages/server/src/server/owned-subscriptions.e2e.test.ts index 68437daf70..2a3128ea9f 100644 --- a/packages/server/src/server/owned-subscriptions.e2e.test.ts +++ b/packages/server/src/server/owned-subscriptions.e2e.test.ts @@ -1,7 +1,8 @@ import { MockLoadTestAgentClient } from "./agent/providers/mock-load-test-agent.js"; import { createPaseoApi } from "@getpaseo/client"; +import type { AgentManager } from "./agent/agent-manager.js"; import { execFileSync } from "node:child_process"; -import { mkdir, writeFile } from "node:fs/promises"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { expect, test } from "vitest"; import type { z } from "zod"; @@ -71,15 +72,18 @@ class SubscriptionPeer { async request(message: z.input): Promise { this.socket.send(JSON.stringify({ type: "session", message })); await expect - .poll(() => - this.frames.some( - (frame) => - frame.type === "session" && - "payload" in frame.message && - "requestId" in frame.message.payload && - "requestId" in message && - frame.message.payload.requestId === message.requestId, - ), + .poll( + () => + this.frames.some( + (frame) => + frame.type === "session" && + "payload" in frame.message && + "requestId" in frame.message.payload && + "requestId" in message && + frame.message.payload.requestId === message.requestId, + ), + // Diagnostics invoke external tools with their own multi-second deadlines. + { timeout: 10_000 }, ) .toBe(true); } @@ -119,6 +123,16 @@ class SubscriptionPeer { } } +function removedAgentIds(peer: SubscriptionPeer): string[] { + return peer.frames.flatMap((frame) => + frame.type === "session" && + frame.message.type === "agent_update" && + frame.message.payload.kind === "remove" + ? [frame.message.payload.agentId] + : [], + ); +} + test("a pure list reply belongs only to its source socket in a shared logical session", async () => { const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); const peers: SubscriptionPeer[] = []; @@ -225,6 +239,96 @@ test("combined filtered agent lists create independent server-owned observations } }); +test.each([ + { + state: "live", + prepare: async (_manager: AgentManager, _agentId: string) => {}, + }, + { + state: "stored-only", + prepare: (manager: AgentManager, agentId: string) => manager.closeAgent(agentId), + }, +])( + "$state agent deletion reaches directory observers across logical sessions without broadcasting its reply", + async ({ prepare }) => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const admin = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.8.0", + }); + const peers: SubscriptionPeer[] = []; + try { + await admin.connect(); + const workspace = ( + await admin.createWorkspace({ source: { kind: "directory", path: daemon.staticDir } }) + ).workspace; + if (!workspace) throw new Error("Workspace creation failed"); + const removed = await admin.createAgent({ + provider: "codex", + cwd: daemon.staticDir, + workspaceId: workspace.id, + }); + const survivor = await admin.createAgent({ + provider: "codex", + cwd: daemon.staticDir, + workspaceId: workspace.id, + }); + const sentinel = path.join(daemon.staticDir, "keep-project-file.txt"); + await writeFile(sentinel, "keep this project file\n"); + await prepare(daemon.daemon.agentManager, removed.id); + const requester = await SubscriptionPeer.connect(daemon.port, "deletion-origin"); + const sibling = await SubscriptionPeer.connect(daemon.port, "deletion-origin"); + const observer = await SubscriptionPeer.connect(daemon.port, "deletion-observer"); + const legacy = await SubscriptionPeer.connect(daemon.port, "deletion-legacy", { + owned_subscriptions: false, + }); + peers.push(requester, sibling, observer, legacy); + for (const [index, peer] of peers.entries()) { + await peer.request({ + type: "fetch_agents_request", + requestId: `directory-${index}`, + subscribe: {}, + }); + const entries = peer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "fetch_agents_response" + ? frame.message.payload.entries.map((entry) => entry.agent.id) + : [], + ); + expect(entries.sort()).toEqual([removed.id, survivor.id].sort()); + peer.frames.length = 0; + } + await requester.request({ + type: "delete_agent_request", + requestId: "delete-observed-agent", + agentId: removed.id, + }); + await expect + .poll(() => peers.map(removedAgentIds)) + .toEqual([[removed.id], [removed.id], [removed.id], [removed.id]]); + const replies = peers.map((peer) => + peer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "agent_deleted" + ? [frame.message.payload] + : [], + ), + ); + expect(replies).toEqual([ + [{ agentId: removed.id, requestId: "delete-observed-agent" }], + [], + [], + [], + ]); + expect(await daemon.daemon.agentStorage.get(removed.id)).toBeNull(); + expect((await daemon.daemon.agentStorage.get(survivor.id))?.id).toBe(survivor.id); + expect(await readFile(sentinel, "utf8")).toBe("keep this project file\n"); + } finally { + for (const peer of peers) peer.close(); + await admin.close(); + await daemon.close(); + } + }, +); + test("workspace filters and identical queries coexist until their own release", async () => { const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); @@ -679,7 +783,8 @@ test("same-workspace terminal directory observers do not share a teardown slot", peer.frames.length = 0; const result = await admin.createTerminal(daemon.staticDir, "Observed terminal", undefined, { workspaceId: workspace.id, - command: "/bin/cat", + command: process.execPath, + args: ["-e", "process.stdin.on('data', data => process.stdout.write(data))"], }); expect(result.error).toBe(null); await expect @@ -728,7 +833,8 @@ test("terminal output has separate server IDs and binary slots, including in a s ).workspace!; const result = await admin.createTerminal(daemon.staticDir, "Output ownership", undefined, { workspaceId: workspace.id, - command: "/bin/cat", + command: process.execPath, + args: ["-e", "process.stdin.on('data', data => process.stdout.write(data))"], }); expect(result.error).toBe(null); const idle = await SubscriptionPeer.connect(daemon.port, "terminal-output-session"); @@ -1423,7 +1529,7 @@ test("request outcomes for import and attention reach only their requesting sock }); const requester = await SubscriptionPeer.connect(daemon.port, "reply-shared"); const sibling = await SubscriptionPeer.connect(daemon.port, "reply-shared"); - const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); try { await admin.connect(); await requester.request({ @@ -1462,7 +1568,7 @@ test("request outcomes for import and attention reach only their requesting sock }), ); const agent = await admin.createAgent({ - provider: "codex", + provider: "mock", cwd: daemon.staticDir, title: "Attention", }); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 3eac37cb7b..cca0b4f3fa 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1992,6 +1992,24 @@ export class Session { return; } + if (event.type === "agent_removed") { + if (event.internal) return; + void this.agentUpdates + .removeAgent(event.agentId) + .then(() => { + if (event.workspaceId) + return this.emitWorkspaceUpdateForWorkspaceId(event.workspaceId); + return undefined; + }) + .catch((error) => { + this.sessionLogger.error( + { err: error, agentId: event.agentId }, + "Failed to emit agent removal", + ); + }); + return; + } + if (event.type === "agent_state") { this.sessionLogger.trace( { @@ -3337,10 +3355,8 @@ export class Session { private async handleDeleteAgentRequest(agentId: string, requestId: string): Promise { this.sessionLogger.info({ agentId }, `Deleting agent ${agentId} from registry`); - const knownWorkspaceId = - this.agentManager.getAgent(agentId)?.workspaceId ?? - (await this.agentStorage.get(agentId))?.workspaceId ?? - null; + const knownAgent = + this.agentManager.getAgent(agentId) ?? (await this.agentStorage.get(agentId)); // File-backed storage still needs an early delete fence before closeAgent(). beginAgentDeleteIfSupported(this.agentStorage, agentId); @@ -3361,6 +3377,8 @@ export class Session { try { await this.agentStorage.remove(agentId); await this.agentManager.deleteAgentState(agentId); + // Directory removal is daemon-wide; agent_deleted below stays a source-scoped RPC reply. + if (knownAgent) this.agentManager.notifyAgentRemoved(knownAgent); } catch (error) { this.sessionLogger.error({ err: error, agentId }, `Failed to fully delete agent ${agentId}`); } @@ -3372,12 +3390,6 @@ export class Session { requestId, }, }); - - await this.agentUpdates.removeAgent(agentId); - - if (knownWorkspaceId) { - await this.emitWorkspaceUpdateForWorkspaceId(knownWorkspaceId); - } } private async handleArchiveAgentRequest(agentId: string, requestId: string): Promise { From 9c7925de5818a01ac60ce2343b8bff81f40746f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Pe=C5=A1l?= Date: Fri, 30 Oct 2026 04:01:41 +0100 Subject: [PATCH 18/95] fix(skills): make invocation input portable across harnesses (#8252) Read invocation input and conversation context without Claude argument substitution. Recognize Pi skill references when forwarding advisor requests. --- skills/paseo-advisor/SKILL.md | 8 +++++--- skills/paseo-committee/SKILL.md | 2 +- skills/paseo-handoff/SKILL.md | 2 +- skills/paseo-help/SKILL.md | 2 +- skills/paseo-plugin/SKILL.md | 2 +- 5 files changed, 9 insertions(+), 7 deletions(-) diff --git a/skills/paseo-advisor/SKILL.md b/skills/paseo-advisor/SKILL.md index 94d4558e5a..23868d9a2d 100644 --- a/skills/paseo-advisor/SKILL.md +++ b/skills/paseo-advisor/SKILL.md @@ -9,7 +9,7 @@ argument-hint: "[--profile ] " Single agent. Reads the situation you're in. Gives a judgment. You decide what to do — the advisor doesn't drive the work. -**User's request:** $ARGUMENTS +Use the user's request and current conversation as input, including any arguments supplied with the skill invocation. Optional syntax: `[--profile ] `. ## Prerequisites @@ -40,13 +40,15 @@ This is analysis only. Do NOT edit, create, or delete any files. Do NOT write co ## Forwarded skills -If `$ARGUMENTS` contains another skill reference — `/unslop`, `/unslop-risk`, `$unslop`, etc. — the user is asking the advisor to run that skill against the current task. Examples: +If the user's request contains another skill reference, such as `/unslop`, `/skill:unslop`, or `$unslop`, the user is asking the advisor to run that skill against the current task. Examples: - `/paseo-advisor /unslop` → advisor runs `/unslop` on the current diff. - `/paseo-advisor /unslop-risk` → advisor does an unslop-risk review. - `/paseo-advisor $diagnose this build failure` → advisor invokes `/diagnose`. +- `/skill:paseo-advisor /skill:unslop` → advisor runs the `unslop` skill on the current diff in Pi. +- `$paseo-advisor $unslop` → advisor runs the `unslop` skill on the current diff in Codex. -Parse the forwarded skill name out of `$ARGUMENTS` (`/` or `$`). In the briefing, tell the advisor explicitly: +Parse the forwarded skill name from the user's request (`/`, `/skill:`, or `$`), excluding this skill's own invocation. In the briefing, tell the advisor explicitly: ``` Invoke the `` skill against this task. Load it via the Skill tool before doing anything else. diff --git a/skills/paseo-committee/SKILL.md b/skills/paseo-committee/SKILL.md index 2b16189556..e41ca8ba81 100644 --- a/skills/paseo-committee/SKILL.md +++ b/skills/paseo-committee/SKILL.md @@ -8,7 +8,7 @@ user-invocable: true Two agents from contrasting profiles, fresh context, planning a solution in parallel. -**User's additional context:** $ARGUMENTS +Use the user's request and current conversation as input, including any arguments supplied with the skill invocation. ## Prerequisites diff --git a/skills/paseo-handoff/SKILL.md b/skills/paseo-handoff/SKILL.md index b321365306..fac6793e5e 100644 --- a/skills/paseo-handoff/SKILL.md +++ b/skills/paseo-handoff/SKILL.md @@ -8,7 +8,7 @@ user-invocable: true Transfer the current task — context, decisions, failed attempts, constraints — to a fresh agent. The receiving agent starts with **zero context**, so the handoff prompt must be a self-contained briefing. -**User's arguments:** $ARGUMENTS +Use the user's request and current conversation as input, including any arguments supplied with the skill invocation. ## Prerequisites diff --git a/skills/paseo-help/SKILL.md b/skills/paseo-help/SKILL.md index 951c1207bc..52dcf57a44 100644 --- a/skills/paseo-help/SKILL.md +++ b/skills/paseo-help/SKILL.md @@ -7,7 +7,7 @@ description: Answer questions about the Paseo product and app, including setup, You are helping a user understand, configure, or troubleshoot Paseo itself. Answer their question directly, verify the answer against the current public documentation, and include the relevant documentation link. Do not send the user away to read the docs in place of helping them. -**User's question:** $ARGUMENTS +Use the user's question and current conversation as input, including any arguments supplied with the skill invocation. ## Use current documentation diff --git a/skills/paseo-plugin/SKILL.md b/skills/paseo-plugin/SKILL.md index 0a36b5b3eb..b54938ad53 100644 --- a/skills/paseo-plugin/SKILL.md +++ b/skills/paseo-plugin/SKILL.md @@ -7,7 +7,7 @@ description: Build and manage trusted local Paseo plugins. Use when the user ask Build or manage the requested plugin directly. Use the current public docs to catch contract changes, but keep working from this skill if the network is unavailable. -**User's request:** $ARGUMENTS +Use the user's request and current conversation as input, including any arguments supplied with the skill invocation. ## Check current documentation From deb1bdc2666c7fdf964933875635767fe8b66c20 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 03:38:37 +0000 Subject: [PATCH 19/95] test(plugins): wait for global recovery without an extra restart (#8250) * test(plugins): observe global recovery without reloading again * test(plugins): leave time for recovery failure cleanup --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../src/server/plugins/index.posix.test.ts | 38 +++++++++++-------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/packages/server/src/server/plugins/index.posix.test.ts b/packages/server/src/server/plugins/index.posix.test.ts index 364e24e40b..c529bd37d3 100644 --- a/packages/server/src/server/plugins/index.posix.test.ts +++ b/packages/server/src/server/plugins/index.posix.test.ts @@ -1075,23 +1075,29 @@ export default function contribute(plugin: unknown) { const service = bindTestSessionHost( new PluginService(pino({ level: "silent" }), store, "0.4.0"), ); - await service.start(); - await service.installDirectory({ path: first }); - await service.installDirectory({ path: second }); + try { + await service.start(); + await service.installDirectory({ path: first }); + await service.installDirectory({ path: second }); + expect(catalogIds(service)).toEqual(["first", "second"]); + await store.patch({ pluginsEnabled: false }); - await store.patch({ pluginsEnabled: false }); - - expect(service.catalog()).toEqual([]); - await expect(service.invokePluginRpc("second", "anything", {})).rejects.toThrow( - "Plugin is not available", - ); - await store.patch({ pluginsEnabled: true }); - await service.reloadPlugin("first"); - expect((await service.listPlugins()).map(({ id, status }) => ({ id, status }))).toEqual([ - { id: "first", status: "running" }, - { id: "second", status: "running" }, - ]); - await service.stopAllPlugins(); + expect(service.catalog()).toEqual([]); + await expect(service.invokePluginRpc("second", "anything", {})).rejects.toThrow( + "Plugin is not available", + ); + await store.patch({ pluginsEnabled: true }); + // Observe recovery itself. Reloading here restarts a healthy plugin and + // would also conceal a failure to restart it after the global enable. + await expect + .poll(() => service.listPlugins(), { timeout: 10_000 }) + .toMatchObject([ + { id: "first", status: "running" }, + { id: "second", status: "running" }, + ]); + } finally { + await service.stopAllPlugins(); + } }, 20_000); it("does not publish an in-flight start after a later global disable", async () => { From c663630bbd78fe629c04439063d9b7f8c5f44b7e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 30 Oct 2026 12:57:21 +0700 Subject: [PATCH 20/95] fix(server): read Codex history in pages instead of one whole-thread reply (#8171) Opening a Codex agent whose timeline the daemon doesn't hold asked Codex for the whole thread in one thread/read reply. Past the 128 MiB provider line cap that reply killed the session; before the cap it crashed the daemon. Codex 0.145 and newer reject that read on paginated threads, so importing them failed. Paginated threads now load through thread/items/list pages. A legacy thread keeps its single read. A reply over the line cap fails only its own request: for a legacy read, a warning row names the archive, `codex migrate-rollouts`, unarchive steps; for a page, a warning row ends the history there. Items are capped as they are projected, and rewind's fork no longer returns the thread's turns. Co-authored-by: hunterricks Co-authored-by: dundunHa --- .../reproduce-codex-history-string-limit.mts | 108 +++++++++ .../providers/codex-app-server-agent.test.ts | 23 +- .../agent/providers/codex-app-server-agent.ts | 162 ++++++------- ...odex-history-string-limit.real.e2e.test.ts | 53 +++++ .../codex/app-server-transport.test.ts | 31 ++- .../providers/codex/app-server-transport.ts | 32 ++- .../agent/providers/codex/rewind.test.ts | 4 +- .../server/agent/providers/codex/rewind.ts | 24 +- .../codex/test-utils/fake-app-server.ts | 104 ++++++++- .../providers/codex/thread-history.test.ts | 220 ++++++++++++++++++ .../agent/providers/codex/thread-history.ts | 168 +++++++++++++ .../agent/providers/jsonl-frame-decoder.ts | 47 +++- 12 files changed, 839 insertions(+), 137 deletions(-) create mode 100644 packages/server/scripts/reproduce-codex-history-string-limit.mts create mode 100644 packages/server/src/server/agent/providers/codex-history-string-limit.real.e2e.test.ts create mode 100644 packages/server/src/server/agent/providers/codex/thread-history.test.ts create mode 100644 packages/server/src/server/agent/providers/codex/thread-history.ts diff --git a/packages/server/scripts/reproduce-codex-history-string-limit.mts b/packages/server/scripts/reproduce-codex-history-string-limit.mts new file mode 100644 index 0000000000..b0f49e4faf --- /dev/null +++ b/packages/server/scripts/reproduce-codex-history-string-limit.mts @@ -0,0 +1,108 @@ +// Opt-in diagnostic for #3905. Run from the repository root: +// node --import tsx packages/server/scripts/reproduce-codex-history-string-limit.mts 520 [legacy|paginated] +// Requires Codex 0.148+ on PATH and approximately 2 GiB of available memory. +// `paginated` migrates the synthetic rollout with `codex migrate-rollouts` first. +import { execFileSync, spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { mkdtemp, mkdir, open, rm } from "node:fs/promises"; +import path from "node:path"; +import pino from "pino"; +import { CodexAppServerAgentSession } from "../src/server/agent/providers/codex-app-server-agent.js"; + +const megabytes = Number(process.argv[2] ?? "520"); +if (!Number.isSafeInteger(megabytes) || megabytes < 1 || megabytes > 600) { + throw new Error("Expected an integer size between 1 and 600 MiB"); +} +const historyMode = process.argv[3] ?? "legacy"; +if (historyMode !== "legacy" && historyMode !== "paginated") { + throw new Error("Expected history mode legacy or paginated"); +} +const root = await mkdtemp(path.join(process.cwd(), ".codex-history-repro-")); +const home = path.join(root, "codex-home"); +const now = new Date(); +const timestamp = now.toISOString(); +const threadId = randomUUID(); +const turnId = randomUUID(); +const sessions = path.join(home, "sessions", ...timestamp.slice(0, 10).split("-")); +await mkdir(sessions, { recursive: true }); +const rollout = await open( + path.join(sessions, `rollout-${timestamp.slice(0, 19).replaceAll(":", "-")}-${threadId}.jsonl`), + "w", +); +async function write(type: string, payload: unknown) { + await rollout.write(`${JSON.stringify({ timestamp, type, payload })}\n`); +} +await write("session_meta", { + id: threadId, + timestamp, + cwd: root, + originator: "codex_cli_rs", + cli_version: "0.159.0", + source: "cli", + model_provider: "openai", + history_mode: "legacy", +}); +await write("event_msg", { type: "task_started", turn_id: turnId, model_context_window: 200000 }); +await write("event_msg", { + type: "user_message", + message: "Synthetic oversized history", + images: [], + local_images: [], + text_elements: [], +}); +for (let index = 0; index < megabytes; index++) { + await write("event_msg", { + type: "agent_message", + message: "x".repeat(1024 * 1024), + phase: "commentary", + }); +} +await write("event_msg", { type: "task_complete", turn_id: turnId, last_agent_message: "done" }); +await rollout.close(); +if (historyMode === "paginated") { + execFileSync("codex", ["migrate-rollouts", "--apply", "--thread", threadId], { + env: { ...process.env, CODEX_HOME: home }, + stdio: "ignore", + }); +} +console.log( + `Opening synthetic ${megabytes} MiB ${historyMode} history through the real Codex app-server`, +); +const session = new CodexAppServerAgentSession( + { provider: "codex", cwd: root }, + { sessionId: threadId }, + pino({ level: "silent" }), + async () => + spawn( + "codex", + ["app-server", "--disable", "plugins", "--disable", "apps", "--disable", "remote_plugin"], + { + stdio: ["pipe", "pipe", "pipe"], + env: { ...process.env, CODEX_HOME: home }, + }, + ), + { codexHome: home }, + false, + false, + false, + undefined, + "history", +); +try { + await session.connect(); + let count = 0; + for await (const event of session.streamHistory()) { + if (event.type !== "timeline") continue; + count++; + if (event.item.type === "notification") console.log(event.item.message); + } + console.log(`History loaded: ${count} timeline rows`); +} catch (error) { + // A recoverable session error is acceptable for the crash-isolation assertion. + console.log( + `History rejected without crashing: ${error instanceof Error ? error.message : String(error)}`, + ); +} finally { + await session.close(); + await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); +} 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 e709fd490c..cb8cb2684b 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 @@ -88,6 +88,7 @@ describe("Codex executable discovery", () => { import { CodexAppServerClient } from "./codex/app-server-transport.js"; import { createFakeCodexAppServer, + fakeCodexThreadHistory, type FakeCodexAppServer, waitForNextPermission, waitForNextTimelineItem, @@ -1991,7 +1992,7 @@ describe("Codex app-server provider", () => { purpose: "history", }); - expect(threadRequests).toEqual(["thread/read"]); + expect(threadRequests).toEqual(["thread/read", "thread/read"]); await session.close(); appServer.assertNoErrors(); }); @@ -2068,6 +2069,7 @@ describe("Codex app-server provider", () => { "thread/loaded/list", "thread/resume", "thread/read", + "thread/read", ]); expect(codex.threadMethods(1)).toEqual(["thread/loaded/list", "thread/resume"]); expect(codex.manager.getTimeline(codex.agentId)).toEqual(projected); @@ -2427,7 +2429,7 @@ describe("Codex app-server provider", () => { cwd: "/workspace/project", model: "gpt-5.4", serviceTier: null, - excludeTurns: false, + excludeTurns: true, persistExtendedHistory: true, }, ]); @@ -2441,8 +2443,9 @@ describe("Codex app-server provider", () => { test("rewinds a paginated conversation through the public session capability", async () => { const appServer = createFakeCodexAppServer({ - "thread/read": () => ({ - thread: { id: "thread-1", historyMode: "paginated", turns: [] }, + ...fakeCodexThreadHistory({ + "thread-1": { historyMode: "paginated", turns: [] }, + "forked-thread": { historyMode: "paginated", turns: [] }, }), "thread/rollback": () => { throw new Error("paginated threads do not support thread/rollback"); @@ -2487,7 +2490,7 @@ describe("Codex app-server provider", () => { cwd: "/workspace/project", model: "gpt-5.4", serviceTier: null, - excludeTurns: false, + excludeTurns: true, persistExtendedHistory: true, }, ]); @@ -2504,11 +2507,10 @@ describe("Codex app-server provider", () => { async (historyMode) => { const appServer = createFakeCodexAppServer( historyMode === "paginated" - ? { - "thread/read": () => ({ - thread: { id: "thread-1", historyMode: "paginated", turns: [] }, - }), - } + ? fakeCodexThreadHistory({ + "thread-1": { historyMode: "paginated", turns: [] }, + "forked-thread": { historyMode: "paginated", turns: [] }, + }) : undefined, ); const customCodexConfig = { @@ -4947,6 +4949,7 @@ describe("Codex app-server provider", () => { } expect(requests.map((request) => [request.method, request.params])).toEqual([ + ["thread/read", { threadId: "test-thread", includeTurns: false }], ["thread/read", { threadId: "test-thread", includeTurns: true }], ]); expect(history).toEqual([ 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 5e43f582c5..f7d4f50215 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 @@ -92,12 +92,18 @@ import { type CodexAppServerTraceContext, } from "./codex/app-server-transport.js"; import { type CodexUserMessageTurnIndex, revertCodexConversation } from "./codex/rewind.js"; +import { + type CodexHistoryRow, + CodexThreadHistoryTooLargeError, + readCodexThreadHistory, +} from "./codex/thread-history.js"; import { materializeProviderImage, renderProviderImageOutputAsAssistantMarkdown, type ProviderImageOutput, } from "./provider-image-output.js"; import { normalizeProviderReplayTimestamp } from "../provider-history-timestamps.js"; +import { limitAgentTimelineItemContent } from "../agent-timeline-content.js"; import { formatProviderDiagnostic, formatProviderDiagnosticError, @@ -1804,22 +1810,13 @@ function readCodexHistoryTimestamp(item: unknown): string | null { ); } -function readCodexTurnHistoryTimestamp( - turn: unknown, +/** Falls back to the row's start time for a user message and its end time otherwise. */ +function readCodexHistoryRowTimestamp( + row: CodexHistoryRow, timelineItem: AgentTimelineItem, ): string | null { - const record = toObjectRecord(turn); - if (!record) { - return null; - } - - const startedAt = - normalizeProviderReplayTimestamp(record.startedAt) ?? - normalizeProviderReplayTimestamp(record.started_at); - const completedAt = - normalizeProviderReplayTimestamp(record.completedAt) ?? - normalizeProviderReplayTimestamp(record.completed_at); - + const startedAt = normalizeProviderReplayTimestamp(row.startedAt); + const completedAt = normalizeProviderReplayTimestamp(row.completedAt); if (timelineItem.type === "user_message") { return startedAt ?? completedAt; } @@ -2063,84 +2060,19 @@ async function threadItemToTimelineEntries( return [timelineItem, ...(await mcpToolResultImagesToTimeline(item))]; } -const CodexThreadReadResponseSchema = z - .object({ - thread: z - .object({ - turns: z - .array( - z - .object({ - items: z.array(z.unknown()).default([]), - }) - .passthrough(), - ) - .default([]), - }) - .passthrough() - .default({ turns: [] }), - }) - .passthrough(); - -type CodexThreadReadResponse = z.infer; -type CodexThreadReadRequest = (threadId: string) => Promise; - -async function requestCodexThreadHistory( - requestThread: CodexThreadReadRequest, - threadId: string, -): Promise { - const response = await requestThread(threadId); - return CodexThreadReadResponseSchema.parse(response); -} - async function loadCodexThreadHistoryTimeline(params: { + client: CodexAppServerClientLike; threadId: string; cwd: string | null; - requestThread: CodexThreadReadRequest; }): Promise { - const response = await requestCodexThreadHistory(params.requestThread, params.threadId); const timeline: PersistedTimelineEntry[] = []; const subAgentTimelineIndexByThreadId = new Map(); - for (const turn of response.thread.turns) { - for (const item of turn.items) { - const historicalSubAgentActivity = readCodexSubAgentActivity(item); - if (historicalSubAgentActivity) { - const existingIndex = subAgentTimelineIndexByThreadId.get( - historicalSubAgentActivity.agentThreadId, - ); - if (existingIndex !== undefined) { - const activityTimelineItem = await threadItemToTimeline(item, { cwd: params.cwd }); - updateHistoricalSubAgentActivity( - timeline, - existingIndex, - historicalSubAgentActivity.kind, - activityTimelineItem?.type === "tool_call" && - activityTimelineItem.detail.type === "sub_agent" - ? activityTimelineItem.detail.subAgentType - : undefined, - ); - continue; - } - } - for (const timelineItem of await threadItemToTimelineEntries(item, { cwd: params.cwd })) { - const timestamp = - readCodexHistoryTimestamp(item) ?? readCodexTurnHistoryTimestamp(turn, timelineItem); - const settledTimelineItem = - historicalSubAgentActivity && timelineItem.type === "tool_call" - ? settleHistoricalSubAgentActivity(timelineItem, historicalSubAgentActivity.kind) - : timelineItem; - timeline.push({ - item: settledTimelineItem, - timestamp: timestamp ?? undefined, - ...(timelineItem.type === "user_message" && typeof turn.id === "string" - ? { providerTurnId: turn.id } - : {}), - }); - for (const childThreadId of readCodexHistoricalSubAgentThreadIds(item)) { - subAgentTimelineIndexByThreadId.set(childThreadId, timeline.length - 1); - } - } - } + try { + await projectCodexThreadHistory(params, { timeline, subAgentTimelineIndexByThreadId }); + } catch (error) { + if (!(error instanceof CodexThreadHistoryTooLargeError)) throw error; + // The session stays usable; the row says why the history stops here. + timeline.push({ item: { type: "notification", level: "warning", message: error.message } }); } const subAgentRoutes = Array.from(subAgentTimelineIndexByThreadId.entries()).flatMap( ([childThreadId, timelineIndex]): PersistedSubAgentRoute[] => { @@ -2153,11 +2085,55 @@ async function loadCodexThreadHistoryTimeline(params: { return { timeline, subAgentRoutes }; } -function readCodexThread(client: CodexAppServerClientLike, threadId: string): Promise { - return client.request("thread/read", { - threadId, - includeTurns: true, - }); +async function projectCodexThreadHistory( + params: { client: CodexAppServerClientLike; threadId: string; cwd: string | null }, + projection: { + timeline: PersistedTimelineEntry[]; + subAgentTimelineIndexByThreadId: Map; + }, +): Promise { + const { timeline, subAgentTimelineIndexByThreadId } = projection; + for await (const row of readCodexThreadHistory(params.client, params.threadId)) { + const { item } = row; + const historicalSubAgentActivity = readCodexSubAgentActivity(item); + if (historicalSubAgentActivity) { + const existingIndex = subAgentTimelineIndexByThreadId.get( + historicalSubAgentActivity.agentThreadId, + ); + if (existingIndex !== undefined) { + const activityTimelineItem = await threadItemToTimeline(item, { cwd: params.cwd }); + updateHistoricalSubAgentActivity( + timeline, + existingIndex, + historicalSubAgentActivity.kind, + activityTimelineItem?.type === "tool_call" && + activityTimelineItem.detail.type === "sub_agent" + ? activityTimelineItem.detail.subAgentType + : undefined, + ); + continue; + } + } + for (const timelineItem of await threadItemToTimelineEntries(item, { cwd: params.cwd })) { + const timestamp = + readCodexHistoryTimestamp(item) ?? readCodexHistoryRowTimestamp(row, timelineItem); + const settledTimelineItem = + historicalSubAgentActivity && timelineItem.type === "tool_call" + ? settleHistoricalSubAgentActivity(timelineItem, historicalSubAgentActivity.kind) + : timelineItem; + timeline.push({ + // Cap each item as it is projected so a large history is never held uncapped. + item: limitAgentTimelineItemContent(settledTimelineItem), + timestamp: timestamp ?? undefined, + ...(timelineItem.type === "user_message" && row.turnId !== undefined + ? { providerTurnId: row.turnId } + : {}), + }); + for (const childThreadId of readCodexHistoricalSubAgentThreadIds(item)) { + subAgentTimelineIndexByThreadId.set(childThreadId, timeline.length - 1); + } + } + } } export async function forkCodexThread( @@ -3997,9 +3973,9 @@ export class CodexAppServerAgentSession implements AgentSession { return { timeline: [], providerSubagentEvents: [] }; } const { timeline, subAgentRoutes } = await loadCodexThreadHistoryTimeline({ + client, threadId: this.currentThreadId, cwd: this.config.cwd ?? null, - requestThread: (threadIdToRead) => readCodexThread(client, threadIdToRead), }); this.subAgentCallsByCallId.clear(); this.subAgentCallIdByChildThreadId.clear(); @@ -4055,9 +4031,9 @@ export class CodexAppServerAgentSession implements AgentSession { }); try { const childHistory = await loadCodexThreadHistoryTimeline({ + client, threadId: next.route.childThreadId, cwd: this.config.cwd ?? null, - requestThread: (childThreadId) => readCodexThread(client, childThreadId), }); for (const entry of childHistory.timeline) { this.emitProviderSubagentTimeline(next.route.childThreadId, entry.item, entry.timestamp); diff --git a/packages/server/src/server/agent/providers/codex-history-string-limit.real.e2e.test.ts b/packages/server/src/server/agent/providers/codex-history-string-limit.real.e2e.test.ts new file mode 100644 index 0000000000..fba51b9e1d --- /dev/null +++ b/packages/server/src/server/agent/providers/codex-history-string-limit.real.e2e.test.ts @@ -0,0 +1,53 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "vitest"; + +// Regression for #3905. Real Codex, synthetic on-disk history, no model turns. +// Opt in because the oversized cases need approximately 2 GiB of memory and over +// 1 GiB of temporary storage. +const enabled = process.env.PASEO_CODEX_HISTORY_STRING_LIMIT_REPRO === "1"; +const script = fileURLToPath( + new URL("../../../../scripts/reproduce-codex-history-string-limit.mts", import.meta.url), +); + +test.runIf(enabled).each([ + { megabytes: 2, historyMode: "legacy", expected: "History loaded: 3 timeline rows" }, + { megabytes: 520, historyMode: "legacy", expected: "codex migrate-rollouts --apply --thread" }, + { megabytes: 520, historyMode: "paginated", expected: "History loaded: 521 timeline rows" }, +])( + "opening $megabytes MiB of $historyMode Codex history does not crash its hosting process", + async ({ megabytes, historyMode, expected }) => { + const root = await mkdtemp(path.join(process.cwd(), ".codex-history-test-")); + try { + const result = await new Promise<{ code: number; stdout: string; stderr: string }>( + (resolve) => { + const child = execFile( + process.execPath, + ["--import", "tsx", script, String(megabytes), historyMode], + { + cwd: root, + detached: true, + timeout: 120_000, + maxBuffer: 1024 * 1024, + }, + (error, stdout, stderr) => { + try { + if (child.pid) process.kill(-child.pid, "SIGKILL"); + } catch (killError) { + if ((killError as NodeJS.ErrnoException).code !== "ESRCH") throw killError; + } + resolve({ code: error ? Number(error.code ?? 1) : 0, stdout, stderr }); + }, + ); + }, + ); + expect(result.code, result.stderr).toBe(0); + expect(result.stdout).toContain(expected); + } finally { + await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); + } + }, + 180_000, +); diff --git a/packages/server/src/server/agent/providers/codex/app-server-transport.test.ts b/packages/server/src/server/agent/providers/codex/app-server-transport.test.ts index 3018461ba9..84b82b431b 100644 --- a/packages/server/src/server/agent/providers/codex/app-server-transport.test.ts +++ b/packages/server/src/server/agent/providers/codex/app-server-transport.test.ts @@ -185,14 +185,41 @@ describe("Codex app-server transport", () => { }); describe("Codex stdout byte admission", () => { - test("fails pending requests and the session before joining an oversized line", async () => { + test("an oversized response fails only its own request", async () => { + const child = createCodexAppServerChildProcess(); + const client = new CodexAppServerClient(child, createTestLogger()); + const terminations: Error[] = []; + client.setUnexpectedTerminationHandler((error) => terminations.push(error)); + const oversized = client.request("thread/items/list", {}).catch((error: Error) => error); + const other = client.request("model/list", {}); + const chunk = Buffer.alloc(64 * 1024, "x"); + child.stdout.write('{"id":1,"result":"'); + for (let index = 0; index < 2049; index++) child.stdout.write(chunk); + child.stdout.write('"}\n{"id":2,"result":{"data":[]}}\n'); + + expect(await oversized).toMatchObject({ + name: "CodexAppServerResponseTooLargeError", + method: "thread/items/list", + limitBytes: 128 * 1024 * 1024, + }); + await expect(other).resolves.toEqual({ data: [] }); + const next = client.request("model/list", {}); + child.stdout.write('{"id":3,"result":{"data":["next"]}}\n'); + await expect(next).resolves.toEqual({ data: ["next"] }); + expect(terminations).toEqual([]); + await client.dispose(); + child.stdout.end(); + child.stderr.end(); + }); + + test("an oversized notification fails pending requests and the session", async () => { const child = createCodexAppServerChildProcess(); const client = new CodexAppServerClient(child, createTestLogger()); const terminations: Error[] = []; client.setUnexpectedTerminationHandler((error) => terminations.push(error)); const request = client.request("thread/resume", {}, 1000).catch((error: Error) => error); const chunk = Buffer.alloc(64 * 1024, "x"); - child.stdout.write('{"id":1,"result":"'); + child.stdout.write('{"method":"item/completed","params":"'); for (let index = 0; index < 2048; index++) child.stdout.write(chunk); const error = await request; expect(error).toMatchObject({ diff --git a/packages/server/src/server/agent/providers/codex/app-server-transport.ts b/packages/server/src/server/agent/providers/codex/app-server-transport.ts index dfd4adfac4..5a218e2edb 100644 --- a/packages/server/src/server/agent/providers/codex/app-server-transport.ts +++ b/packages/server/src/server/agent/providers/codex/app-server-transport.ts @@ -33,11 +33,26 @@ interface JsonRpcNotification { } interface PendingRequest { + method: string; resolve: (value: unknown) => void; reject: (error: Error) => void; timer: NodeJS.Timeout; } +/** A response past the provider line cap. Only its request fails; the session stays up. */ +export class CodexAppServerResponseTooLargeError extends Error { + constructor( + readonly method: string, + readonly limitBytes: number, + ) { + super(`Codex app-server response to ${method} exceeds ${limitBytes} bytes`); + this.name = "CodexAppServerResponseTooLargeError"; + } +} + +// Codex serializes a response with `id` first, so its head names the request. +const OVERSIZED_RESPONSE_HEAD = /^\{"id":(\d+),"result":/; + export class CodexAppServerRpcError extends Error { constructor( message: string, @@ -258,7 +273,7 @@ export class CodexAppServerClient { this.pending.delete(id); reject(new Error(`Codex app-server request timed out for ${method}`)); }, timeoutMs); - this.pending.set(id, { resolve, reject, timer }); + this.pending.set(id, { method, resolve, reject, timer }); }); } @@ -304,8 +319,21 @@ export class CodexAppServerClient { } private handleStreamLimit(failure: ProviderStreamLimitError): void { - // A rejected response/notification cannot be replayed safely. Fail the session, + const responseId = OVERSIZED_RESPONSE_HEAD.exec(failure.lineHead)?.[1]; + const pending = responseId === undefined ? undefined : this.pending.get(Number(responseId)); + if (pending) { + clearTimeout(pending.timer); + this.pending.delete(Number(responseId)); + this.logger.warn( + { method: pending.method, limitBytes: failure.limitBytes }, + "Codex app-server response exceeded the line cap", + ); + pending.reject(new CodexAppServerResponseTooLargeError(pending.method, failure.limitBytes)); + return; + } + // A lost notification or server request cannot be replayed. Fail the session, // including live turns with no pending RPC, rather than dropping a stream unit. + this.stdoutLineReader.close(); this.handleUnexpectedTermination(failure); void this.dispose().catch((err) => { this.logger.warn({ err }, "Failed to terminate oversized Codex stdout producer"); diff --git a/packages/server/src/server/agent/providers/codex/rewind.test.ts b/packages/server/src/server/agent/providers/codex/rewind.test.ts index da7fe6a8f5..7a0b2dc0f9 100644 --- a/packages/server/src/server/agent/providers/codex/rewind.test.ts +++ b/packages/server/src/server/agent/providers/codex/rewind.test.ts @@ -108,7 +108,7 @@ describe("Codex Rewind", () => { cwd: "/workspace/project", model: "gpt-5.4-mini", serviceTier: null, - excludeTurns: false, + excludeTurns: true, persistExtendedHistory: true, }, ]); @@ -190,7 +190,7 @@ describe("Codex Rewind", () => { cwd: "/workspace/project", model: "gpt-5.4-mini", serviceTier: null, - excludeTurns: false, + excludeTurns: true, persistExtendedHistory: true, }, ]); diff --git a/packages/server/src/server/agent/providers/codex/rewind.ts b/packages/server/src/server/agent/providers/codex/rewind.ts index 021d57645c..a22931b92b 100644 --- a/packages/server/src/server/agent/providers/codex/rewind.ts +++ b/packages/server/src/server/agent/providers/codex/rewind.ts @@ -8,6 +8,7 @@ import { parseCodexThreadForkResponse, parseCodexThreadRollbackResponse, } from "./app-server-transport.js"; +import { readCodexThreadHistoryMode } from "./thread-history.js"; export interface CodexRewindClient { forkThread?(params: CodexThreadForkParams): Promise; @@ -20,26 +21,6 @@ export interface CodexUserMessageTurnIndex { count(): number; } -type CodexThreadHistoryMode = "legacy" | "paginated"; - -async function readCodexThreadHistoryMode( - client: CodexRewindClient, - threadId: string, -): Promise { - const response = await client.request("thread/read", { threadId, includeTurns: false }); - if (typeof response !== "object" || response === null || !("thread" in response)) { - throw new Error("Codex thread/read did not return thread metadata"); - } - const thread = response.thread; - if (typeof thread !== "object" || thread === null || !("historyMode" in thread)) { - return "legacy"; - } - if (thread.historyMode === "legacy" || thread.historyMode === "paginated") { - return thread.historyMode; - } - throw new Error(`Codex thread/read returned unknown history mode ${String(thread.historyMode)}`); -} - async function forkCodexThread( client: CodexRewindClient, params: CodexThreadForkParams, @@ -95,7 +76,8 @@ export async function revertCodexConversation(input: { model: input.model ?? null, serviceTier: input.serviceTier ?? null, ...(input.config ? { config: input.config } : {}), - excludeTurns: false, + // Paseo re-reads the rewound thread through paged history, not the fork reply. + excludeTurns: true, persistExtendedHistory: true, }; diff --git a/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts b/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts index 226eeb9738..99e6a73050 100644 --- a/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts +++ b/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts @@ -3,6 +3,10 @@ import { EventEmitter } from "node:events"; import { PassThrough } from "node:stream"; import type { AgentSession, AgentStreamEvent } from "../../../agent-sdk-types.js"; +import { CODEX_PROVIDER_LINE_BYTES } from "../app-server-transport.js"; + +/** A handler result that the fake writes as a response line past the provider line cap. */ +export const OVERSIZED_RESPONSE = Symbol("oversized Codex response"); type JsonObject = Record; type FakeCodexAppServerHandler = (params: unknown) => unknown; @@ -229,8 +233,18 @@ export function createFakeCodexAppServer( return; } - Promise.resolve(handler(message.params)) + let response: unknown; + try { + response = handler(message.params); + } catch (error) { + response = Promise.reject(error); + } + Promise.resolve(response) .then((result) => { + if (result === OVERSIZED_RESPONSE) { + writeOversizedResponse(message.id as number); + return undefined; + } const rpcError = toJsonObject(result).__jsonRpcError; if (rpcError) { child.stdout.write(`${JSON.stringify({ id: message.id, error: rpcError })}\n`); @@ -313,6 +327,16 @@ export function createFakeCodexAppServer( }); } + // Codex writes `id` first, then a result past the provider line cap. + function writeOversizedResponse(id: number): void { + child.stdout.write(`{"id":${id},"result":{"data":[{"item":{"text":"`); + const chunk = Buffer.alloc(1024 * 1024, "x"); + for (let written = 0; written <= CODEX_PROVIDER_LINE_BYTES; written += chunk.byteLength) { + child.stdout.write(chunk); + } + child.stdout.write('"}}]}}\n'); + } + function writeNotification(method: string, params: JsonObject): void { child.stdout.write(`${JSON.stringify({ method, params })}\n`); } @@ -696,3 +720,81 @@ export function waitForProviderSubagent( ): Promise { return waitForNextEvent(session, "provider_subagent", (event) => event.event.id === id); } + +export interface FakeCodexHistoryTurn { + id: string; + startedAt?: number; + completedAt?: number; + items: JsonObject[]; +} + +export interface FakeCodexHistoryThread { + /** Omitted on Codex builds older than 0.143, which only have legacy history. */ + historyMode?: "legacy" | "paginated"; + turns: FakeCodexHistoryTurn[]; +} + +export interface FakeCodexHistoryOptions { + /** Any response carrying one of these items is written past the line cap. */ + oversizedItemIds?: string[]; +} + +/** + * Codex's persisted thread history: `thread/read` for metadata and legacy full + * reads, `thread/items/list` for paginated threads. Paginated threads reject + * the full read as Codex 0.145 and 0.146 do. + */ +export function fakeCodexThreadHistory( + threads: Record, + options: FakeCodexHistoryOptions = {}, +): Record { + const oversizedItemIds = new Set(options.oversizedItemIds ?? []); + const isOversized = (items: JsonObject[]) => + items.some((item) => oversizedItemIds.has(String(item.id))); + + function readThread(params: unknown): { id: string; thread: FakeCodexHistoryThread } { + const id = String(toJsonObject(params).threadId); + const thread = threads[id]; + if (!thread) throw new Error(`thread not found: ${id}`); + return { id, thread }; + } + + return { + "thread/read": (params) => { + const { id, thread } = readThread(params); + if (!toJsonObject(params).includeTurns) { + return { + thread: { id, ...(thread.historyMode ? { historyMode: thread.historyMode } : {}) }, + }; + } + if (thread.historyMode === "paginated") { + throw new Error("paginated threads do not support thread/read(includeTurns=true)"); + } + if (isOversized(thread.turns.flatMap((turn) => turn.items))) return OVERSIZED_RESPONSE; + return { thread: { id, turns: thread.turns } }; + }, + "thread/items/list": (params) => { + const { thread } = readThread(params); + if (thread.historyMode !== "paginated") { + throw new Error("thread/items/list is not supported for legacy threads"); + } + const request = toJsonObject(params); + // Codex records each item's own times; the fake gives every item its turn's. + const rows = thread.turns.flatMap((turn) => + turn.items.map((item) => ({ + turnId: turn.id, + item, + startedAtMs: turn.startedAt === undefined ? null : turn.startedAt * 1000, + completedAtMs: turn.completedAt === undefined ? null : turn.completedAt * 1000, + })), + ); + const limit = Math.min(typeof request.limit === "number" ? request.limit : 25, 100); + const start = + typeof request.cursor === "string" ? Number(request.cursor.slice("cursor-".length)) : 0; + const end = Math.min(rows.length, start + limit); + const data = rows.slice(start, end); + if (isOversized(data.map((row) => row.item))) return OVERSIZED_RESPONSE; + return { data, nextCursor: end < rows.length ? `cursor-${end}` : null }; + }, + }; +} diff --git a/packages/server/src/server/agent/providers/codex/thread-history.test.ts b/packages/server/src/server/agent/providers/codex/thread-history.test.ts new file mode 100644 index 0000000000..8d3d8fce3d --- /dev/null +++ b/packages/server/src/server/agent/providers/codex/thread-history.test.ts @@ -0,0 +1,220 @@ +import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import { describe, expect, test } from "vitest"; + +import { createTestLogger } from "../../../../test-utils/test-logger.js"; +import type { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js"; +import { CodexAppServerAgentClient } from "../codex-app-server-agent.js"; +import { + createFakeCodexAppServer, + type FakeCodexAppServer, + type FakeCodexHistoryOptions, + type FakeCodexHistoryThread, + fakeCodexThreadHistory, +} from "./test-utils/fake-app-server.js"; + +function userItem(id: string, text: string) { + return { type: "userMessage", id, content: [{ type: "text", text }] }; +} + +function agentItem(id: string, text: string) { + return { type: "agentMessage", id, text }; +} + +async function openCodexThread( + thread: FakeCodexHistoryThread, + options?: FakeCodexHistoryOptions, +): Promise<{ appServer: FakeCodexAppServer; session: AgentSession }> { + const appServer = createFakeCodexAppServer( + fakeCodexThreadHistory({ "saved-thread": thread }, options), + ); + const provider = new CodexAppServerAgentClient(createTestLogger()); + const internals = provider as unknown as { + goalsEnabledPromise: Promise | null; + autoReviewEnabledPromise: Promise | null; + spawnAppServer: () => Promise; + }; + internals.goalsEnabledPromise = Promise.resolve(false); + internals.autoReviewEnabledPromise = Promise.resolve(false); + internals.spawnAppServer = async () => appServer.child; + const session = await provider.resumeSession({ sessionId: "saved-thread" }); + return { appServer, session }; +} + +async function readHistory(session: AgentSession): Promise { + const events: AgentStreamEvent[] = []; + for await (const event of session.streamHistory()) events.push(event); + return events; +} + +function timelineItems(events: AgentStreamEvent[]) { + return events.flatMap((event) => (event.type === "timeline" ? [event.item] : [])); +} + +function historyRequests(appServer: FakeCodexAppServer) { + return appServer + .requests() + .filter( + (request) => + request.method === "thread/read" || + request.method === "thread/turns/list" || + request.method === "thread/items/list", + ) + .map((request) => ({ method: request.method, params: request.params })); +} + +describe("Codex thread history", () => { + test("loads a paginated thread through pages", async () => { + const answers = Array.from({ length: 150 }, (_, index) => + agentItem(`agent-${index}`, `Answer ${index}`), + ); + const { appServer, session } = await openCodexThread({ + historyMode: "paginated", + turns: [ + { + id: "turn-1", + startedAt: 1_778_832_941, + completedAt: 1_778_833_000, + items: [userItem("user-1", "First"), ...answers.slice(0, 120)], + }, + { + id: "turn-2", + startedAt: 1_778_833_094, + completedAt: 1_778_833_100, + items: [userItem("user-2", "Second"), ...answers.slice(120)], + }, + ], + }); + try { + const events = await readHistory(session); + + expect(timelineItems(events)).toEqual([ + { type: "user_message", text: "First", messageId: "user-1" }, + ...answers.slice(0, 120).map((answer) => ({ + type: "assistant_message", + text: answer.text, + messageId: answer.id, + })), + { type: "user_message", text: "Second", messageId: "user-2" }, + ...answers.slice(120).map((answer) => ({ + type: "assistant_message", + text: answer.text, + messageId: answer.id, + })), + ]); + const timestamps = events.flatMap((event) => + event.type === "timeline" ? [event.timestamp] : [], + ); + expect(timestamps[0]).toBe("2026-05-15T08:15:41.000Z"); + expect(timestamps[1]).toBe("2026-05-15T08:16:40.000Z"); + expect(timestamps[121]).toBe("2026-05-15T08:18:14.000Z"); + const requests = historyRequests(appServer); + expect(requests).not.toContainEqual( + expect.objectContaining({ params: expect.objectContaining({ includeTurns: true }) }), + ); + const itemPages = requests.filter((request) => request.method === "thread/items/list"); + expect(itemPages.length).toBeGreaterThan(1); + for (const page of itemPages) { + expect(page.params).toMatchObject({ threadId: "saved-thread", sortDirection: "asc" }); + expect((page.params as { limit: number }).limit).toBeLessThanOrEqual(100); + } + } finally { + await session.close(); + } + appServer.assertNoErrors(); + }); + + test("loads a paginated thread whose full read Codex 0.145 rejects", async () => { + const { appServer, session } = await openCodexThread({ + historyMode: "paginated", + turns: [{ id: "turn-1", items: [userItem("user-1", "Hello"), agentItem("agent-1", "Hi")] }], + }); + try { + expect(timelineItems(await readHistory(session))).toEqual([ + { type: "user_message", text: "Hello", messageId: "user-1" }, + { type: "assistant_message", text: "Hi", messageId: "agent-1" }, + ]); + } finally { + await session.close(); + } + appServer.assertNoErrors(); + }); + + test("a page too big to send shows a warning row and stops loading", async () => { + const answers = Array.from({ length: 120 }, (_, index) => + agentItem(`agent-${index}`, `Answer ${index}`), + ); + const { appServer, session } = await openCodexThread( + { + historyMode: "paginated", + turns: [ + { id: "turn-1", items: [userItem("user-1", "One"), ...answers] }, + { id: "turn-2", items: [userItem("user-2", "Two"), agentItem("huge", "never sent")] }, + ], + }, + { oversizedItemIds: ["huge"] }, + ); + const loaded = [ + { type: "user_message", text: "One", messageId: "user-1" }, + ...answers.slice(0, 99).map((answer) => ({ + type: "assistant_message", + text: answer.text, + messageId: answer.id, + })), + { + type: "notification", + level: "warning", + message: "The rest of this Codex thread's history is too large to load.", + }, + ]; + try { + expect(timelineItems(await readHistory(session))).toEqual(loaded); + // The oversized reply failed only its own request; the session still answers. + expect(timelineItems(await readHistory(session))).toEqual(loaded); + } finally { + await session.close(); + } + appServer.assertNoErrors(); + }); + + test("loads a legacy thread that fits in one read", async () => { + const { appServer, session } = await openCodexThread({ + historyMode: "legacy", + turns: [{ id: "turn-1", items: [userItem("user-1", "Hello"), agentItem("agent-1", "Hi")] }], + }); + try { + expect(timelineItems(await readHistory(session))).toEqual([ + { type: "user_message", text: "Hello", messageId: "user-1" }, + { type: "assistant_message", text: "Hi", messageId: "agent-1" }, + ]); + expect(historyRequests(appServer).map((request) => request.method)).not.toContain( + "thread/items/list", + ); + } finally { + await session.close(); + } + appServer.assertNoErrors(); + }); + + test("a legacy thread too big for one read shows the migration command", async () => { + const { appServer, session } = await openCodexThread( + { + historyMode: "legacy", + turns: [{ id: "turn-1", items: [userItem("user-1", "Hello"), agentItem("huge", "x")] }], + }, + { oversizedItemIds: ["huge"] }, + ); + const migrationRow = { + type: "notification", + level: "warning", + message: expect.stringContaining("codex migrate-rollouts --apply --thread saved-thread"), + }; + try { + expect(timelineItems(await readHistory(session))).toEqual([migrationRow]); + // The session stays up for the user to prompt until they archive it to migrate. + expect(timelineItems(await readHistory(session))).toEqual([migrationRow]); + } finally { + await session.close(); + } + appServer.assertNoErrors(); + }); +}); diff --git a/packages/server/src/server/agent/providers/codex/thread-history.ts b/packages/server/src/server/agent/providers/codex/thread-history.ts new file mode 100644 index 0000000000..bf7461deb6 --- /dev/null +++ b/packages/server/src/server/agent/providers/codex/thread-history.ts @@ -0,0 +1,168 @@ +import { z } from "zod"; + +import { CodexAppServerResponseTooLargeError } from "./app-server-transport.js"; + +// Codex clamps history pages to 100 rows. +const PAGE_SIZE = 100; + +export interface CodexThreadHistoryClient { + request(method: string, params?: unknown): Promise; +} + +export type CodexThreadHistoryMode = "legacy" | "paginated"; + +export interface CodexHistoryRow { + item: unknown; + turnId: string | undefined; + /** The item's times on a paginated thread; its turn's on a legacy thread. */ + startedAt: unknown; + completedAt: unknown; +} + +/** A history reply over the line cap; the rows before it were already read. */ +export class CodexThreadHistoryTooLargeError extends Error { + constructor(message: string) { + super(message); + this.name = "CodexThreadHistoryTooLargeError"; + } +} + +const CodexThreadMetadataSchema = z + .object({ + thread: z.object({ historyMode: z.enum(["legacy", "paginated"]).optional() }).passthrough(), + }) + .passthrough(); + +const CodexLegacyThreadReadSchema = z + .object({ + thread: z + .object({ + turns: z + .array( + z + .object({ + id: z.string().optional(), + startedAt: z.unknown().optional(), + completedAt: z.unknown().optional(), + items: z.array(z.unknown()).default([]), + }) + .passthrough(), + ) + .default([]), + }) + .passthrough() + .default({ turns: [] }), + }) + .passthrough(); + +const CodexItemsPageSchema = z + .object({ + data: z.array( + z + .object({ + turnId: z.string(), + item: z.unknown(), + startedAtMs: z.number().nullable().optional(), + completedAtMs: z.number().nullable().optional(), + }) + .passthrough(), + ), + nextCursor: z.string().nullable().optional(), + }) + .passthrough(); + +/** Codex builds before 0.143 report no history mode; their threads are legacy. */ +export async function readCodexThreadHistoryMode( + client: CodexThreadHistoryClient, + threadId: string, +): Promise { + const response = CodexThreadMetadataSchema.parse( + await client.request("thread/read", { threadId, includeTurns: false }), + ); + return response.thread.historyMode ?? "legacy"; +} + +/** + * Reads a thread's persisted items in order. Paginated threads are read in + * pages; a legacy thread arrives in one `thread/read` reply, as Codex offers + * no pages for it. + */ +export async function* readCodexThreadHistory( + client: CodexThreadHistoryClient, + threadId: string, +): AsyncGenerator { + if ((await readCodexThreadHistoryMode(client, threadId)) === "paginated") { + yield* readPaginatedHistory(client, threadId); + } else { + yield* readLegacyHistory(client, threadId); + } +} + +async function* readLegacyHistory( + client: CodexThreadHistoryClient, + threadId: string, +): AsyncGenerator { + const response = await requestHistory( + client, + "thread/read", + { threadId, includeTurns: true }, + `This Codex thread's history is too large to load in one read. ` + + `To move it to paginated history, archive this agent, ` + + `run \`codex migrate-rollouts --apply --thread ${threadId}\` (Codex 0.148 or newer), ` + + `then unarchive the agent.`, + ); + for (const turn of CodexLegacyThreadReadSchema.parse(response).thread.turns) { + for (const item of turn.items) { + yield { item, turnId: turn.id, startedAt: turn.startedAt, completedAt: turn.completedAt }; + } + } +} + +async function* readPaginatedHistory( + client: CodexThreadHistoryClient, + threadId: string, +): AsyncGenerator { + const seenCursors = new Set(); + let cursor: string | undefined; + for (;;) { + const page = CodexItemsPageSchema.parse( + await requestHistory( + client, + "thread/items/list", + { threadId, limit: PAGE_SIZE, sortDirection: "asc", cursor }, + "The rest of this Codex thread's history is too large to load.", + ), + ); + for (const row of page.data) { + yield { + item: row.item, + turnId: row.turnId, + startedAt: row.startedAtMs, + completedAt: row.completedAtMs, + }; + } + if (page.nextCursor == null) return; + // Codex's own client guards against a cursor that does not advance. + if (seenCursors.has(page.nextCursor)) { + throw new Error(`Codex repeated history cursor for thread ${threadId}`); + } + seenCursors.add(page.nextCursor); + cursor = page.nextCursor; + } +} + +async function requestHistory( + client: CodexThreadHistoryClient, + method: string, + params: unknown, + tooLargeMessage: string, +): Promise { + try { + return await client.request(method, params); + } catch (error) { + if (error instanceof CodexAppServerResponseTooLargeError) { + throw new CodexThreadHistoryTooLargeError(tooLargeMessage); + } + throw error; + } +} diff --git a/packages/server/src/server/agent/providers/jsonl-frame-decoder.ts b/packages/server/src/server/agent/providers/jsonl-frame-decoder.ts index 8b1b9767de..ff8fa83489 100644 --- a/packages/server/src/server/agent/providers/jsonl-frame-decoder.ts +++ b/packages/server/src/server/agent/providers/jsonl-frame-decoder.ts @@ -1,10 +1,17 @@ // Provider-process stdout only; these are not app/daemon wire limits. export const JSONL_PROVIDER_LINE_BYTES = 64 * 1024 * 1024; +// Enough of an oversized line to read which response it was, e.g. `{"id":12,"result":`. +const OVERSIZED_LINE_HEAD_BYTES = 64; + export class ProviderStreamLimitError extends Error { readonly code = "PROVIDER_STREAM_LINE_TOO_LARGE"; - constructor(readonly limitBytes: number) { + constructor( + readonly limitBytes: number, + /** The first bytes of the oversized line; the rest is discarded unread. */ + readonly lineHead = "", + ) { super( `Provider stdout line exceeds ${limitBytes} bytes. Start a new thread or reduce the provider response size.`, ); @@ -12,10 +19,26 @@ export class ProviderStreamLimitError extends Error { } } -/** Admits raw UTF-8 bytes before retaining, decoding or joining a complete line. */ +function readLineHead(parts: Buffer[]): string { + const head: Buffer[] = []; + let bytes = 0; + for (const part of parts) { + if (bytes >= OVERSIZED_LINE_HEAD_BYTES) break; + head.push(part.subarray(0, OVERSIZED_LINE_HEAD_BYTES - bytes)); + bytes += head[head.length - 1].byteLength; + } + return Buffer.concat(head).toString("utf8"); +} + +/** + * Admits raw UTF-8 bytes before retaining, decoding or joining a complete line. + * An oversized line is reported with its head and skipped up to its newline; + * the owner closes the reader if the stream cannot continue without it. + */ export class ProviderLineReader { private parts: Buffer[] = []; private bytes = 0; + private skippingOversizedLine = false; private stopped = false; constructor( @@ -31,11 +54,20 @@ export class ProviderLineReader { while (start < buffer.byteLength && !this.stopped) { const newline = buffer.indexOf(10, start); const end = newline === -1 ? buffer.byteLength : newline; + if (this.skippingOversizedLine) { + if (newline === -1) return; + this.skippingOversizedLine = false; + start = newline + 1; + continue; + } const length = end - start; if (length > this.limitBytes - this.bytes) { - this.close(); - this.onLimit(new ProviderStreamLimitError(this.limitBytes)); - return; + const head = readLineHead([...this.parts, buffer.subarray(start, end)]); + this.parts = []; + this.bytes = 0; + this.skippingOversizedLine = true; + this.onLimit(new ProviderStreamLimitError(this.limitBytes, head)); + continue; } if (length) this.parts.push(buffer.subarray(start, end)); this.bytes += length; @@ -150,7 +182,10 @@ export class JsonlFrameDecoder { (line) => { if (line.trim()) this.consumeLine(line); }, - (error) => this.drop("line-too-large", error), + (error) => { + this.lines.close(); + this.drop("line-too-large", error); + }, ); } From a8e0effb325d07dbe02a998b6ff6fce623b3fec1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 30 Oct 2026 13:42:38 +0700 Subject: [PATCH 21/95] Follow system rotation on mobile devices (#8256) The app no longer locks to portrait. Phones and tablets on Android and iOS follow the device rotation and respect the system rotation lock, so Android tablets fill the display in landscape instead of letterboxing. Modals pass all orientations explicitly because React Native presents iPhone modals in portrait by default. Closes #1669 Co-authored-by: yz <43207690+yzim@users.noreply.github.com> Co-authored-by: magiclaw <930198090@qq.com> --- packages/app/app.config.js | 2 +- packages/app/app.config.test.ts | 35 +++++++++++++++++++ .../app/src/command-center/command-center.tsx | 2 +- .../app/src/components/add-project-flow.tsx | 10 ++---- packages/app/src/components/agent-list.tsx | 2 +- .../src/components/attachment-lightbox.tsx | 3 +- .../fence/mermaid/fullscreen-viewer.web.tsx | 3 +- .../markdown/fence/mermaid/host.native.tsx | 2 +- packages/app/src/components/ui/combobox.tsx | 2 +- .../src/components/ui/menu/menu-overlay.tsx | 2 +- packages/app/src/components/ui/modal.tsx | 13 +++++++ packages/app/src/components/ui/tooltip.tsx | 2 +- packages/app/src/hosts/host-chooser.tsx | 10 ++---- 13 files changed, 63 insertions(+), 25 deletions(-) create mode 100644 packages/app/src/components/ui/modal.tsx diff --git a/packages/app/app.config.js b/packages/app/app.config.js index 7ddfc95029..eb7907e5ce 100644 --- a/packages/app/app.config.js +++ b/packages/app/app.config.js @@ -102,7 +102,7 @@ export default { name: variant.name, slug: "voice-mobile", version: nativeReleaseVersion.appVersion, - orientation: "portrait", + orientation: "default", icon: "./assets/images/icon.png", scheme: "paseo", userInterfaceStyle: "automatic", diff --git a/packages/app/app.config.test.ts b/packages/app/app.config.test.ts index b98258ff9a..d0de16bd03 100644 --- a/packages/app/app.config.test.ts +++ b/packages/app/app.config.test.ts @@ -59,3 +59,38 @@ describe("Android app config", () => { 30_000, ); }); + +it("follows system rotation on Android", async () => { + const manifest = await resolveAndroidManifest(); + const activity = manifest.application[0].activity.find( + (entry: ManifestEntry) => entry.$["android:name"] === ".MainActivity", + ); + expect(activity.$["android:screenOrientation"]).toBe("unspecified"); +}, 30_000); + +it("allows all orientations on iPhone and iPad", async () => { + const { mkdtemp, rm } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const root = await mkdtemp(join(tmpdir(), "paseo-ios-config-")); + try { + const { exp } = await getPrebuildConfigAsync(__dirname, { platforms: ["ios"] }); + await compileModsAsync(exp, { + projectRoot: root, + introspect: true, + platforms: ["ios"], + assertMissingModProviders: false, + }); + const plist = exp._internal.modResults.ios.infoPlist; + const orientations = [ + "UIInterfaceOrientationPortrait", + "UIInterfaceOrientationPortraitUpsideDown", + "UIInterfaceOrientationLandscapeLeft", + "UIInterfaceOrientationLandscapeRight", + ]; + expect(plist.UISupportedInterfaceOrientations).toEqual(orientations); + expect(plist["UISupportedInterfaceOrientations~ipad"]).toEqual(orientations); + } finally { + await rm(root, { recursive: true, force: true }); + } +}, 30_000); diff --git a/packages/app/src/command-center/command-center.tsx b/packages/app/src/command-center/command-center.tsx index 7a44f8ccd9..309564bcea 100644 --- a/packages/app/src/command-center/command-center.tsx +++ b/packages/app/src/command-center/command-center.tsx @@ -1,8 +1,8 @@ +import { Modal } from "@/components/ui/modal"; import equal from "fast-deep-equal"; import { recordRenderProfileReasons } from "@/utils/render-profiler"; import { FlatList, - Modal, Pressable, Text, View, diff --git a/packages/app/src/components/add-project-flow.tsx b/packages/app/src/components/add-project-flow.tsx index a1cb5cf779..7fdf21dd83 100644 --- a/packages/app/src/components/add-project-flow.tsx +++ b/packages/app/src/components/add-project-flow.tsx @@ -1,3 +1,4 @@ +import { Modal } from "@/components/ui/modal"; import { usePanelStore } from "@/stores/panel-store"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { router } from "expo-router"; @@ -22,14 +23,7 @@ import { type ComponentType, type RefObject, } from "react"; -import { - Modal, - Pressable, - ScrollView, - Text, - View, - type PressableStateCallbackType, -} from "react-native"; +import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native"; import { EditingTextInput as TextInput, type EditingTextInputHandle, diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index b487e53660..8921102f26 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -1,8 +1,8 @@ +import { Modal } from "@/components/ui/modal"; import { View, Text, Pressable, - Modal, RefreshControl, FlatList, type ListRenderItem, diff --git a/packages/app/src/components/attachment-lightbox.tsx b/packages/app/src/components/attachment-lightbox.tsx index 0f14f2c515..08c6042bf8 100644 --- a/packages/app/src/components/attachment-lightbox.tsx +++ b/packages/app/src/components/attachment-lightbox.tsx @@ -1,5 +1,6 @@ +import { Modal } from "@/components/ui/modal"; import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; -import { Modal, Pressable, Text, View } from "react-native"; +import { Pressable, Text, View } from "react-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { StyleSheet } from "react-native-unistyles"; import { SafeAreaProvider, useSafeAreaInsets } from "react-native-safe-area-context"; diff --git a/packages/app/src/components/markdown/fence/mermaid/fullscreen-viewer.web.tsx b/packages/app/src/components/markdown/fence/mermaid/fullscreen-viewer.web.tsx index 8b68ae61cb..02ca7108d8 100644 --- a/packages/app/src/components/markdown/fence/mermaid/fullscreen-viewer.web.tsx +++ b/packages/app/src/components/markdown/fence/mermaid/fullscreen-viewer.web.tsx @@ -1,5 +1,6 @@ +import { Modal } from "@/components/ui/modal"; import { useCallback, useEffect, useMemo, useRef } from "react"; -import { Modal, View } from "react-native"; +import { View } from "react-native"; import { X } from "lucide-react-native"; import { useTranslation } from "react-i18next"; import { StyleSheet } from "react-native-unistyles"; diff --git a/packages/app/src/components/markdown/fence/mermaid/host.native.tsx b/packages/app/src/components/markdown/fence/mermaid/host.native.tsx index 001ec5cf1c..5f86efe05c 100644 --- a/packages/app/src/components/markdown/fence/mermaid/host.native.tsx +++ b/packages/app/src/components/markdown/fence/mermaid/host.native.tsx @@ -1,6 +1,6 @@ +import { Modal } from "@/components/ui/modal"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { - Modal, Pressable, StyleSheet as RNStyleSheet, ScrollView, diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index d436758d45..adcd91afcb 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -1,3 +1,4 @@ +import { Modal } from "@/components/ui/modal"; import { useCallback, useEffect, @@ -12,7 +13,6 @@ import { View, Text, Pressable, - Modal, ScrollView, Platform, StatusBar, diff --git a/packages/app/src/components/ui/menu/menu-overlay.tsx b/packages/app/src/components/ui/menu/menu-overlay.tsx index c6935bfd91..3979c5b633 100644 --- a/packages/app/src/components/ui/menu/menu-overlay.tsx +++ b/packages/app/src/components/ui/menu/menu-overlay.tsx @@ -1,3 +1,4 @@ +import { Modal } from "@/components/ui/modal"; import { useCallback, useEffect, @@ -12,7 +13,6 @@ import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { Dimensions, - Modal, Platform, Pressable, View, diff --git a/packages/app/src/components/ui/modal.tsx b/packages/app/src/components/ui/modal.tsx new file mode 100644 index 0000000000..884377fd8c --- /dev/null +++ b/packages/app/src/components/ui/modal.tsx @@ -0,0 +1,13 @@ +import { Modal as NativeModal, type ModalProps } from "react-native"; + +// React Native defaults iPhone modals to portrait even when the app supports rotation. +const orientations: NonNullable = [ + "portrait", + "portrait-upside-down", + "landscape-left", + "landscape-right", +]; + +export function Modal(props: ModalProps) { + return ; +} diff --git a/packages/app/src/components/ui/tooltip.tsx b/packages/app/src/components/ui/tooltip.tsx index 697a80fdcf..99f446001f 100644 --- a/packages/app/src/components/ui/tooltip.tsx +++ b/packages/app/src/components/ui/tooltip.tsx @@ -1,3 +1,4 @@ +import { Modal } from "@/components/ui/modal"; import { Children, cloneElement, @@ -16,7 +17,6 @@ import { createPortal } from "react-dom"; import { Dimensions, Platform, - Modal, Pressable, View, type PressableProps, diff --git a/packages/app/src/hosts/host-chooser.tsx b/packages/app/src/hosts/host-chooser.tsx index 5e4d7f053c..3d807ba3a1 100644 --- a/packages/app/src/hosts/host-chooser.tsx +++ b/packages/app/src/hosts/host-chooser.tsx @@ -1,13 +1,7 @@ +import { Modal } from "@/components/ui/modal"; import { usePanelStore } from "@/stores/panel-store"; import { createElement, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - Modal, - Pressable, - ScrollView, - Text, - View, - type PressableStateCallbackType, -} from "react-native"; +import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native"; import { EditingTextInput as TextInput, type EditingTextInputHandle, From e3682ba18c20b1f5f5b0b29d14e2e29fdb1a0dc0 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 30 Oct 2026 14:32:19 +0700 Subject: [PATCH 22/95] Usage fetches only when shown, and can be turned off (#8181) * fix(usage): fetch only when shown and add a host-wide off switch * fix(usage): restore policy defaults and isolate usage test state * refactor(usage): give the sidebar footer sole ownership of its summary --- docs/glossary.md | 1 + docs/plugins.md | 2 +- packages/app/e2e/browser/usage-off.spec.ts | 69 +++++++++++++++++++ .../e2e/browser/usage-sidebar-item.spec.ts | 28 ++++++++ .../app/e2e/support/helpers/seed-client.ts | 4 ++ .../app/e2e/support/helpers/usage-reports.ts | 10 ++- packages/app/src/components/left-sidebar.tsx | 13 ++-- packages/app/src/i18n/resources/ar.ts | 5 ++ packages/app/src/i18n/resources/en.ts | 5 ++ packages/app/src/i18n/resources/es.ts | 5 ++ packages/app/src/i18n/resources/fr.ts | 5 ++ packages/app/src/i18n/resources/ja.ts | 5 ++ packages/app/src/i18n/resources/ko.ts | 5 ++ packages/app/src/i18n/resources/pt-BR.ts | 5 ++ packages/app/src/i18n/resources/ru.ts | 5 ++ packages/app/src/i18n/resources/zh-CN.ts | 5 ++ packages/app/src/usage/controls.tsx | 13 +++- packages/app/src/usage/display.ts | 8 --- packages/app/src/usage/host-usage-section.tsx | 4 +- packages/app/src/usage/index.ts | 2 +- packages/app/src/usage/model.test.ts | 12 ++++ packages/app/src/usage/model.ts | 3 + packages/app/src/usage/options.tsx | 37 +++++++--- packages/app/src/usage/queries.ts | 12 +++- packages/app/src/usage/setting.ts | 26 +++++++ packages/app/src/usage/sidebar-item.tsx | 19 +++-- packages/app/src/usage/types.ts | 1 + packages/app/src/usage/usage-modal.tsx | 4 +- packages/app/src/usage/usage-section.tsx | 14 +++- packages/protocol/src/messages.ts | 5 ++ packages/server/src/server/bootstrap.ts | 2 + packages/server/src/server/config.ts | 6 ++ .../src/server/daemon-config-store.test.ts | 33 +++++++++ .../server/src/server/daemon-config-store.ts | 5 ++ .../server/src/server/persisted-config.ts | 1 + packages/server/src/server/session.ts | 1 + .../session/usage/usage-session.test.ts | 36 ++++++++++ .../src/server/session/usage/usage-session.ts | 14 ++++ .../server/src/server/websocket-server.ts | 1 + .../public/schemas/paseo.config.v1.json | 8 +++ 40 files changed, 386 insertions(+), 53 deletions(-) create mode 100644 packages/app/e2e/browser/usage-off.spec.ts create mode 100644 packages/app/src/usage/setting.ts diff --git a/docs/glossary.md b/docs/glossary.md index 8c9f54896c..c7fd1398c5 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -38,6 +38,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here - **Usage source** — Plugin registration that discovers accounts and fetches plan usage. Code: `UsageSourceRegistration` (`packages/plugin/src/server/usage.ts`). - **Report ID** — Stable daemon-composed identity for one source account: `:`. The source owns the account key; clients treat the whole ID as opaque. Code: `UsageReportEntry.id` (`packages/protocol/src/messages.ts`). - **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Each report renders as one card in the Usage modal, in host Settings, and in an agent's context window details (a hover card on desktop web, a tooltip on native wide screens, a sheet on compact ones), which show only the account that agent runs under, without pins. The hover card and the sheet have Refresh; the native tooltip takes no presses. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`), `UsageCard` (`packages/app/src/usage/card.tsx`), `AgentUsage` (`packages/app/src/usage/agent-usage.tsx`). +- **Check usage** — Host-wide switch in Usage Settings, on by default. Turning it off stops login discovery and vendor requests on that host for every device. Usage shows an off state with **Turn on**; the sidebar summary and agent usage sections disappear. - **Usage (sidebar item)** — The sidebar footer item showing each pinned window's source icon and percent, hidden while no pinned window has data (the footer's Usage icon remains available); both entry points open the Usage modal over the current screen: a dialog on wide layouts, a bottom sheet on compact ones. Usage has no route of its own. Off until the user turns on **Summary in sidebar** (Usage Settings) or the Usage row in Settings > Sidebar; window rows pin only while it is on, and saved pins come back when it is turned on again. A pin is `{ sourceId, windowId }` and shows once per account of that source. Pins and the used/remaining choice are one per-device preference (`AppSettings.usage`) that every usage surface follows. It reads the active workspace's host, else the first connected host with usage sources. Code: `packages/app/src/usage/sidebar-item.tsx`, `usage/usage-modal.tsx`, `usage/pinned.ts`, `usage/preferences.ts`. - **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`). - **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`). diff --git a/docs/plugins.md b/docs/plugins.md index fcc7ed7fdb..ffae136cc6 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -550,7 +550,7 @@ string is never inline SVG or a URL. Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery, harness labels, and credential-store reads; the daemon owns account grouping and the fetch cache. Agent popovers use only the agent's own login. The host-wide Usage modal falls back across an account's logins; any success shows usage only, and all failures show every login's labeled error and remedy. Follow the [usage source discovery contract](../public-docs/plugins/reference.md#usage-sources). The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same SVG file rules as provider icons. -The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients. +When the host’s **Check usage** switch is off, the daemon rejects usage requests before discovery, without reading logins or calling vendors. Otherwise, the daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients. ## Contribute sidebar items diff --git a/packages/app/e2e/browser/usage-off.spec.ts b/packages/app/e2e/browser/usage-off.spec.ts new file mode 100644 index 0000000000..a0114c8e33 --- /dev/null +++ b/packages/app/e2e/browser/usage-off.spec.ts @@ -0,0 +1,69 @@ +import { expect, test } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; +import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings"; +import { installUsageReportsFixture } from "../support/helpers/usage-reports"; +import { + claudeAndCodexReports, + openUsageFromIcon, + openUsageOptions, + closeUsageOptions, + closeUsage, + expectNoUsageItem, + expectPinnedUsage, +} from "../support/helpers/usage-sidebar-item"; + +test("Check usage applies to the host and Turn on restores cards", async ({ + page, + e2eWorkerClient, +}) => { + test.setTimeout(180_000); + const previous = (await e2eWorkerClient.getDaemonConfig()).config.usage ?? { enabled: true }; + try { + const usage = await installUsageReportsFixture(page, { + lists: [() => claudeAndCodexReports()], + }); + await page.setViewportSize({ width: 1440, height: 1000 }); + await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]); + await gotoAppShell(page); + await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]); + await openUsageFromIcon(page); + await usage.waitForListRequests(1); + await openUsageOptions(page); + await page.getByRole("switch", { name: "Check usage", exact: true }).click(); + await closeUsageOptions(page); + await expect(page.getByRole("button", { name: "Turn on", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Refresh all", exact: true })).toHaveCount(0); + expect((await e2eWorkerClient.getDaemonConfig()).config.usage).toEqual({ enabled: false }); + await expectNoUsageItem(page); + const before = usage.listRequests().length; + await closeUsage(page); + await page.reload(); + await openUsageFromIcon(page); + await expect(page.getByRole("button", { name: "Turn on", exact: true })).toBeVisible(); + expect(usage.listRequests()).toHaveLength(before); + await page.getByRole("button", { name: "Turn on", exact: true }).click(); + await expect(page.getByText("Claude", { exact: true })).toBeVisible(); + expect((await e2eWorkerClient.getDaemonConfig()).config.usage).toEqual({ enabled: true }); + // A separate client changes the policy, as another device would. + await e2eWorkerClient.patchDaemonConfig({ usage: { enabled: false } }); + await expect(page.getByRole("button", { name: "Turn on", exact: true })).toBeVisible(); + await e2eWorkerClient.patchDaemonConfig({ usage: { enabled: true } }); + await expect(page.getByText("Claude", { exact: true })).toBeVisible(); + } finally { + await e2eWorkerClient.patchDaemonConfig({ usage: previous }); + } +}); + +test("older hosts keep usage without a Check usage switch", async ({ page }) => { + test.setTimeout(180_000); + await installUsageReportsFixture(page, { + usageSettingSupported: false, + lists: [() => claudeAndCodexReports()], + }); + await page.setViewportSize({ width: 1440, height: 1000 }); + await gotoAppShell(page); + await openUsageFromIcon(page); + await expect(page.getByText("Claude", { exact: true })).toBeVisible(); + await openUsageOptions(page); + await expect(page.getByRole("switch", { name: "Check usage", exact: true })).toHaveCount(0); +}); diff --git a/packages/app/e2e/browser/usage-sidebar-item.spec.ts b/packages/app/e2e/browser/usage-sidebar-item.spec.ts index 27fac6691c..9151403904 100644 --- a/packages/app/e2e/browser/usage-sidebar-item.spec.ts +++ b/packages/app/e2e/browser/usage-sidebar-item.spec.ts @@ -441,3 +441,31 @@ test("released hosts supply source logos through the client conversion", async ( ).toHaveAttribute("d", codexPath); await qaScreenshot(page, "released-host-usage-modal"); }); + +test("hidden usage sends no requests until opened", async ({ page }) => { + test.setTimeout(180_000); + const usage = await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] }); + await page.setViewportSize(WIDE); + await gotoAppShell(page); + await expectNoUsageItem(page); + await expect(page.getByRole("button", { name: "Usage", exact: true })).toBeVisible({ + timeout: 90_000, + }); + await openSidebarNavSettings(page); + await expectFooterItemSetting(page, "usage", false); + await leaveSettings(page); + expect(usage.listRequests()).toHaveLength(0); + await openUsageFromIcon(page); + await usage.waitForListRequests(1); + expect(usage.listRequests()).toHaveLength(1); +}); + +test("visible usage summary requests reports on load", async ({ page }) => { + test.setTimeout(180_000); + const usage = await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] }); + await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]); + await page.setViewportSize(WIDE); + await gotoAppShell(page); + await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]); + expect(usage.listRequests()).toHaveLength(1); +}); diff --git a/packages/app/e2e/support/helpers/seed-client.ts b/packages/app/e2e/support/helpers/seed-client.ts index f57beb5783..fe6af133f9 100644 --- a/packages/app/e2e/support/helpers/seed-client.ts +++ b/packages/app/e2e/support/helpers/seed-client.ts @@ -28,6 +28,10 @@ interface SeedProjectDescriptor { * prefer those wrappers over reaching for this client directly. */ export interface SeedDaemonClient { + patchDaemonConfig( + config: import("@getpaseo/protocol/messages").MutableDaemonConfigPatch, + ): Promise<{ config: import("@getpaseo/protocol/messages").MutableDaemonConfig }>; + getDaemonConfig(): Promise<{ config: import("@getpaseo/protocol/messages").MutableDaemonConfig }>; connect(): Promise; close(): Promise; addProject(cwd: string): Promise<{ diff --git a/packages/app/e2e/support/helpers/usage-reports.ts b/packages/app/e2e/support/helpers/usage-reports.ts index 86b74c6ea3..bb46e52f95 100644 --- a/packages/app/e2e/support/helpers/usage-reports.ts +++ b/packages/app/e2e/support/helpers/usage-reports.ts @@ -25,6 +25,7 @@ interface UsageReportsFixtureOptions { * app connects, so a reload can switch it. */ usageSupported?: boolean | (() => boolean); + usageSettingSupported?: boolean; /** Released hosts expose provider.usage.list with no source icons. */ providerUsageListOnly?: boolean; /** The host daemon's port; defaults to the E2E daemon. */ @@ -63,6 +64,7 @@ function withUsageSupportFeature( message: WebSocketMessage, enabled: boolean, providerUsageListOnly: boolean, + usageSettingSupported: boolean, ): string | null { const envelope = parseJson(message) as { type?: unknown; @@ -86,6 +88,7 @@ function withUsageSupportFeature( ...payload, features: { ...features, + usageSetting: usageSettingSupported ? true : undefined, usageSources: enabled && !providerUsageListOnly, providerUsageList: enabled, }, @@ -229,7 +232,12 @@ export async function installUsageReportsFixture( server.onMessage((message) => { const serverInfo = typeof message === "string" - ? withUsageSupportFeature(message, isUsageSupported(), providerUsageListOnly) + ? withUsageSupportFeature( + message, + isUsageSupported(), + providerUsageListOnly, + options.usageSettingSupported ?? true, + ) : null; ws.send(serverInfo ?? message); }); diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index dbc501b5c7..983d142beb 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -64,12 +64,7 @@ import { useCloseAgentListGesture } from "@/mobile-panels/gestures"; import { MobilePanelOverlay } from "@/mobile-panels/presentation"; import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes"; import { openHostOverview } from "@/navigation/settings-navigation"; -import { - UsageSidebarItem, - UsageSidebarRoot, - useHasUsageSummary, - useOpenSidebarUsage, -} from "@/usage"; +import { UsageSidebarItem, UsageSidebarRoot, useUsageSummary, useOpenSidebarUsage } from "@/usage"; import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton"; import { SidebarCalloutSlot } from "./sidebar-callout-slot"; import { SidebarWorkspaceList } from "./sidebar-workspace-list"; @@ -480,10 +475,10 @@ function SidebarUsageIcon({ label }: { label: string }) { */ function SidebarFooterRows({ onBeforeNavigate }: { onBeforeNavigate?: () => void }) { const { items } = useSidebarNavItems("footer"); - const hasUsageSummary = useHasUsageSummary(); + const usageSummary = useUsageSummary(); const rowsRef = useRef(null); const visibleItems = items.filter( - (item) => item.visible && (item.kind === "plugin" || hasUsageSummary), + (item) => item.visible && (item.kind === "plugin" || usageSummary.length > 0), ); if (visibleItems.length === 0) return null; return ( @@ -499,7 +494,7 @@ function SidebarFooterRows({ onBeforeNavigate }: { onBeforeNavigate?: () => void onBeforeNavigate={onBeforeNavigate} /> ) : ( - + ), )} diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 1cb6f0da06..99d5a11701 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -2092,6 +2092,11 @@ export const ar: TranslationResources = { }, }, providerUsage: { + checkUsage: "التحقق من الاستخدام", + checkUsageHint: "ينطبق على {{host}} على جميع الأجهزة", + off: "التحقق من الاستخدام متوقف على {{host}}.", + turnOn: "تشغيل", + options: "الإعدادات", refreshFailed: "تعذّر تحديث الاستخدام", noHosts: "لا توجد مضيفات متصلة", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 0511af34e2..636fe42d5a 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -2117,6 +2117,11 @@ export const en = { }, }, providerUsage: { + checkUsage: "Check usage", + checkUsageHint: "Applies to {{host}} on every device", + off: "Usage is off on {{host}}.", + turnOn: "Turn on", + options: "Settings", refreshFailed: "Unable to refresh usage", noHosts: "No connected hosts", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index ffe9cedc1e..abd5320fe9 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -2140,6 +2140,11 @@ export const es: TranslationResources = { }, }, providerUsage: { + checkUsage: "Consultar uso", + checkUsageHint: "Se aplica a {{host}} en todos los dispositivos", + off: "El uso está desactivado en {{host}}.", + turnOn: "Activar", + options: "Configuración", refreshFailed: "No se pudo actualizar el uso", noHosts: "No hay hosts conectados", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 6c9df18211..848666b2bb 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -2140,6 +2140,11 @@ export const fr: TranslationResources = { }, }, providerUsage: { + checkUsage: "Vérifier l’utilisation", + checkUsageHint: "S’applique à {{host}} sur tous les appareils", + off: "L’utilisation est désactivée sur {{host}}.", + turnOn: "Activer", + options: "Paramètres", refreshFailed: "Impossible d’actualiser l’utilisation", noHosts: "Aucun hôte connecté", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index dd214ebac9..eb6423913e 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -2113,6 +2113,11 @@ export const ja: TranslationResources = { }, }, providerUsage: { + checkUsage: "使用状況を確認", + checkUsageHint: "すべてのデバイスで {{host}} に適用", + off: "{{host}} の使用状況確認はオフです。", + turnOn: "オンにする", + options: "設定", refreshFailed: "使用状況を更新できません", noHosts: "接続中のホストがありません", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index d30635b2a5..54605df4a6 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -2102,6 +2102,11 @@ export const ko: TranslationResources = { }, }, providerUsage: { + checkUsage: "사용량 확인", + checkUsageHint: "모든 기기에서 {{host}}에 적용", + off: "{{host}}의 사용량 확인이 꺼져 있습니다.", + turnOn: "켜기", + options: "설정", refreshFailed: "사용량을 새로 고칠 수 없습니다", noHosts: "연결된 호스트가 없습니다", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 1d9ce0cb95..ecedcb5c9e 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -2125,6 +2125,11 @@ export const ptBR: TranslationResources = { }, }, providerUsage: { + checkUsage: "Consultar uso", + checkUsageHint: "Aplica-se a {{host}} em todos os dispositivos", + off: "O uso está desativado em {{host}}.", + turnOn: "Ativar", + options: "Configurações", refreshFailed: "Não foi possível atualizar o uso", noHosts: "Nenhum host conectado", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 766f8bdae7..d9c7afdedd 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -2125,6 +2125,11 @@ export const ru: TranslationResources = { }, }, providerUsage: { + checkUsage: "Проверять использование", + checkUsageHint: "Применяется к {{host}} на всех устройствах", + off: "Проверка использования отключена на {{host}}.", + turnOn: "Включить", + options: "Настройки", refreshFailed: "Не удалось обновить использование", noHosts: "Нет подключённых хостов", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 64867d7d21..e0b5bbcdb4 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -2070,6 +2070,11 @@ export const zhCN: TranslationResources = { }, }, providerUsage: { + checkUsage: "检查用量", + checkUsageHint: "在所有设备上应用于 {{host}}", + off: "{{host}} 的用量检查已关闭。", + turnOn: "开启", + options: "设置", refreshFailed: "无法刷新用量", noHosts: "没有已连接的主机", diff --git a/packages/app/src/usage/controls.tsx b/packages/app/src/usage/controls.tsx index 20297c9df0..f0a8c73f5e 100644 --- a/packages/app/src/usage/controls.tsx +++ b/packages/app/src/usage/controls.tsx @@ -21,10 +21,12 @@ export interface UsageHostSelection { */ export function UsageControls({ view, + serverId, onRefresh, hostSelection, }: { view: UsageView; + serverId: string; onRefresh: () => void; hostSelection?: UsageHostSelection; }) { @@ -43,8 +45,8 @@ export function UsageControls({ ) : null} {view.kind === "unavailable" ? null : ( <> - - + {view.kind === "off" ? null : } + )}
@@ -84,7 +86,12 @@ export function useHostUsageWithControls(hostSelection: UsageHostSelection): { const controls = useMemo( () => hasControls ? ( - + ) : null, [hasControls, hostSelection, refresh, view], ); diff --git a/packages/app/src/usage/display.ts b/packages/app/src/usage/display.ts index 5b4e534693..c62ed78fd7 100644 --- a/packages/app/src/usage/display.ts +++ b/packages/app/src/usage/display.ts @@ -1,7 +1,5 @@ import { useCallback, useMemo } from "react"; import { useAppSettings } from "@/hooks/use-settings"; -import { useUsageHostId } from "./hosts"; -import { useUsageHostReports } from "./queries"; import { isUsagePinned, setUsageDisplayAs, @@ -25,12 +23,6 @@ interface UsagePreferencesState { display: UsageDisplay; } -/** The device's usage preferences, with pins resolved against a host's reports. */ -export function useUsagePreferences(serverId?: string): UsagePreferencesState { - const defaultServerId = useUsageHostId(); - return useUsageDisplay(useUsageHostReports(serverId ?? defaultServerId)); -} - /** The device's usage preferences, with pins resolved against the given reports. */ export function useUsageDisplay(reports: readonly UsageReportEntry[]): UsagePreferencesState { const { settings, updateSettings } = useAppSettings(); diff --git a/packages/app/src/usage/host-usage-section.tsx b/packages/app/src/usage/host-usage-section.tsx index e80e839680..4921c7f954 100644 --- a/packages/app/src/usage/host-usage-section.tsx +++ b/packages/app/src/usage/host-usage-section.tsx @@ -1,5 +1,5 @@ import { useTranslation } from "react-i18next"; -import { useUsagePreferences } from "./display"; +import { useUsageDisplay } from "./display"; import { useHostUsage } from "./queries"; import { UsageSection } from "./usage-section"; @@ -7,7 +7,7 @@ import { UsageSection } from "./usage-section"; export function HostUsageSection({ serverId }: { serverId: string }) { const { t } = useTranslation(); const { view, refresh } = useHostUsage(serverId); - const { display } = useUsagePreferences(serverId); + const { display } = useUsageDisplay(view.kind === "ready" ? view.reports : []); return ( { + expect( + resolveUsageView({ + hostLabel: "Laptop", + isConnected: true, + supportsUsage: true, + enabled: false, + query: { data: [], error: null, isFetching: false }, + }), + ).toMatchObject({ kind: "off" }); +}); diff --git a/packages/app/src/usage/model.ts b/packages/app/src/usage/model.ts index 48143ad23f..bbe183dcd0 100644 --- a/packages/app/src/usage/model.ts +++ b/packages/app/src/usage/model.ts @@ -106,6 +106,7 @@ export interface UsageQueryState { export function resolveUsageView( input: { + enabled?: boolean; hostLabel: string; isConnected: boolean; supportsUsage: boolean; @@ -125,6 +126,8 @@ export function resolveUsageView( message: t("providerUsage.hostUpgradeRequired", { host: hostLabel }), }; } + if (input.enabled === false) + return { kind: "off", message: t("providerUsage.off", { host: hostLabel }) }; if (query?.data) { return { kind: "ready", reports: query.data, isRefreshing: query.isFetching }; } diff --git a/packages/app/src/usage/options.tsx b/packages/app/src/usage/options.tsx index b735b612aa..44205fbddd 100644 --- a/packages/app/src/usage/options.tsx +++ b/packages/app/src/usage/options.tsx @@ -8,15 +8,20 @@ import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane- import { useIsCompactFormFactor } from "@/constants/layout"; import { SettingsRow, SettingsSwitch } from "@/components/settings"; import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control"; +import { Alert } from "@/components/ui/alert"; +import { useHosts } from "@/runtime/host-runtime"; import { settingsStyles } from "@/styles/settings"; -import { useUsagePreferences } from "./display"; +import { useUsageSetting } from "./setting"; +import { useUsageDisplay } from "./display"; import { useUsageInSidebar } from "./in-sidebar"; import type { UsageDisplayAs } from "./preferences"; const ThemedSettings = withUnistyles(Settings); /** The shared usage preferences, opened from a cog as a popover or compact sheet. */ -export function UsageOptions() { +export function UsageOptions({ serverId }: { serverId: string | null }) { + const setting = useUsageSetting(serverId); + const hostLabel = useHosts().find((host) => host.serverId === serverId)?.label ?? serverId; const { t } = useTranslation(); const displayAsOptions: SegmentedControlOption[] = [ { value: "used", label: t("providerUsage.displayUsed"), testID: "usage-display-used" }, @@ -27,7 +32,7 @@ export function UsageOptions() { }, ]; const compact = useIsCompactFormFactor(); - const { display } = useUsagePreferences(); + const { display } = useUsageDisplay([]); const { inSidebar, setInSidebar } = useUsageInSidebar(); return ( @@ -49,13 +54,25 @@ export function UsageOptions() { testID="usage-options-surface" > - + {setting.error ? : null} + {setting.supported ? ( + + ) : null} + + + void } { + const setting = useUsageSetting(serverId); const queryClient = useQueryClient(); const isConnected = useHostRuntimeIsConnected(serverId); const isSupported = useSessionStore((state) => supportsUsage(state.sessions[serverId])); const query = useFetchQuery({ queryKey: usageReportsQueryKey(serverId), queryFn: () => listReports(queryClient, serverId), - enabled: isConnected && isSupported, + enabled: isConnected && isSupported && setting.enabled, dataShape: "list", staleTimeMs: REPORTS_STALE_TIME_MS, }); @@ -150,6 +152,7 @@ export function useHostUsage(serverId: string): { view: UsageView; refresh: () = const view = resolveUsageView( { hostLabel, + enabled: setting.ready ? setting.enabled : undefined, isConnected, supportsUsage: isSupported, query: toQueryState(query), @@ -166,14 +169,16 @@ const NO_REPORTS: UsageReportEntry[] = []; * load, or without a host. */ export function useUsageHostReports(serverId: string | null): UsageReportEntry[] { + const setting = useUsageSetting(serverId); const queryClient = useQueryClient(); const query = useFetchQuery({ + enabled: setting.enabled, queryKey: usageReportsQueryKey(serverId ?? ""), queryFn: serverId ? () => listReports(queryClient, serverId) : skipToken, dataShape: "list", staleTimeMs: REPORTS_STALE_TIME_MS, }); - return query.data ?? NO_REPORTS; + return setting.enabled ? (query.data ?? NO_REPORTS) : NO_REPORTS; } /** @@ -209,7 +214,8 @@ export function useAgentUsage(serverId: string, agentId: string): AgentUsageView export function useHostReportsUsage(serverId: string): boolean { const isConnected = useHostRuntimeIsConnected(serverId); const isSupported = useSessionStore((state) => supportsUsage(state.sessions[serverId])); - return isConnected && isSupported; + const setting = useUsageSetting(serverId); + return isConnected && isSupported && setting.enabled; } /** Every host with whether it is connected and reports usage, in host order. */ diff --git a/packages/app/src/usage/setting.ts b/packages/app/src/usage/setting.ts new file mode 100644 index 0000000000..e6916cf8c8 --- /dev/null +++ b/packages/app/src/usage/setting.ts @@ -0,0 +1,26 @@ +import { useMutation } from "@tanstack/react-query"; +import { useCallback } from "react"; +import { useDaemonConfig } from "@/hooks/use-daemon-config"; +import { useSessionStore } from "@/stores/session-store"; + +/** Host policy comes from the live daemon config replica. */ +export function useUsageSetting(serverId: string | null) { + const supported = useSessionStore((state) => + Boolean(serverId && state.sessions[serverId]?.serverInfo?.features?.usageSetting), + ); + const { config, patchConfig } = useDaemonConfig(supported ? serverId : null); + const mutation = useMutation({ + mutationFn: (enabled: boolean) => patchConfig({ usage: { enabled } }), + }); + const setEnabled = mutation.mutate; + const turnOn = useCallback(() => setEnabled(true), [setEnabled]); + return { + supported, + error: mutation.error, + // COMPAT(usageSetting): added in v0.14, remove after 2027-05-01. + enabled: !supported || (config !== null && config.usage?.enabled !== false), + ready: !supported || config !== null, + setEnabled, + turnOn, + }; +} diff --git a/packages/app/src/usage/sidebar-item.tsx b/packages/app/src/usage/sidebar-item.tsx index 2ca99c4e29..437c0a929c 100644 --- a/packages/app/src/usage/sidebar-item.tsx +++ b/packages/app/src/usage/sidebar-item.tsx @@ -17,7 +17,8 @@ import { } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model"; -import { useUsagePreferences } from "./display"; +import { useUsageDisplay } from "./display"; +import { useUsageInSidebar } from "./in-sidebar"; import { useUsageHostId } from "./hosts"; import { useUsageHostReports } from "./queries"; import { UsageSourceIcon } from "./source-icon"; @@ -33,28 +34,24 @@ import { import { UsageModal } from "./usage-modal"; /** Each summary window with data on the usage host, under its source; empty while none has. */ -function useUsageSummary(): readonly PinnedUsageSource[] { +export function useUsageSummary(): readonly PinnedUsageSource[] { const { t } = useTranslation(); - const { preferences } = useUsagePreferences(); - const reports = useUsageHostReports(useUsageHostId()); + const { inSidebar } = useUsageInSidebar(); + const serverId = useUsageHostId(); + const reports = useUsageHostReports(inSidebar ? serverId : null); + const { preferences } = useUsageDisplay(reports); return useMemo(() => resolvePinnedUsage(reports, preferences, t), [preferences, reports, t]); } -/** Whether the sidebar Usage item has anything to show. */ -export function useHasUsageSummary(): boolean { - return useUsageSummary().length > 0; -} - /** * The sidebar footer's usage entry: each summary window's source icon and percent, and nothing * while no summary window has data, since the footer's Usage icon already opens Usage. * Pressing it opens the Usage modal. */ -export function UsageSidebarItem() { +export function UsageSidebarItem({ sources }: { sources: readonly PinnedUsageSource[] }) { const { t } = useTranslation(); const label = t(builtinSidebarNavLabelKey("usage")); const openUsage = useOpenSidebarUsage(); - const sources = useUsageSummary(); if (sources.length === 0) return null; return ; } diff --git a/packages/app/src/usage/types.ts b/packages/app/src/usage/types.ts index 58b8e31c98..9e36f58c84 100644 --- a/packages/app/src/usage/types.ts +++ b/packages/app/src/usage/types.ts @@ -10,6 +10,7 @@ export type UsageBalanceUnit = UsageBalance["unit"]; /** What a host's usage surface shows. `unavailable` is a host state, not a failed request. */ export type UsageView = + | { kind: "off"; message: string } | { kind: "unavailable"; message: string } | { kind: "loading" } | { kind: "error"; message: string } diff --git a/packages/app/src/usage/usage-modal.tsx b/packages/app/src/usage/usage-modal.tsx index bf92eaa60f..4e049c0b58 100644 --- a/packages/app/src/usage/usage-modal.tsx +++ b/packages/app/src/usage/usage-modal.tsx @@ -4,7 +4,7 @@ import { View } from "react-native"; import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model"; import { useHostUsageWithControls } from "./controls"; -import { useUsagePreferences } from "./display"; +import { useUsageDisplay } from "./display"; import { useUsageHostSelection } from "./hosts"; import type { UsageHost } from "./model"; import { UsageBody, UsageMessage } from "./usage-section"; @@ -52,12 +52,12 @@ function HostUsageModal({ hosts: UsageHost[]; onSelectHost: (serverId: string) => void; }) { - const { display } = useUsagePreferences(); const hostSelection = useMemo( () => ({ hosts, serverId, onSelect: onSelectHost }), [hosts, onSelectHost, serverId], ); const { view, refresh, controls } = useHostUsageWithControls(hostSelection); + const { display } = useUsageDisplay(view.kind === "ready" ? view.reports : []); return ( diff --git a/packages/app/src/usage/usage-section.tsx b/packages/app/src/usage/usage-section.tsx index 26699fd447..9c1e9354c1 100644 --- a/packages/app/src/usage/usage-section.tsx +++ b/packages/app/src/usage/usage-section.tsx @@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button"; import { settingsStyles } from "@/styles/settings"; import { SettingsSection } from "@/components/settings/headings/settings-section"; import type { UsageDisplay } from "./display"; +import { useUsageSetting } from "./setting"; import { UsageControls } from "./controls"; import { UsageList } from "./list"; import type { UsageView } from "./types"; @@ -27,8 +28,8 @@ export function UsageSection({ testID?: string; }) { const trailing = useMemo( - () => , - [onRefresh, view], + () => , + [onRefresh, view, serverId], ); return ( @@ -51,6 +52,15 @@ export function UsageBody({ onRefresh: () => void; }) { const { t } = useTranslation(); + const setting = useUsageSetting(serverId); + if (view.kind === "off") + return ( + + + {setting.error ? : null} + + + ); if (view.kind === "unavailable") { return ; } diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index d20c91d773..18c3da9b67 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -229,6 +229,8 @@ export const MutableDaemonConfigSchema = z .optional(), app: z.object({ baseUrl: z.string() }).optional(), catalogRefreshTimeoutMs: z.number().int().positive().optional(), + // COMPAT(usageSetting): added in v0.14, remove after 2027-05-01. + usage: z.object({ enabled: z.boolean() }).optional(), browserTools: MutableBrowserToolsConfigSchema.default({ enabled: false }), providers: z.record(z.string(), MutableDaemonProviderConfigSchema).default({}), metadataGeneration: MutableMetadataGenerationConfigSchema.default({ providers: [] }), @@ -256,6 +258,7 @@ export const MutableDaemonConfigPatchSchema = z .object({ relay: MutableRelayConfigSchema.partial().optional(), mcp: z.object({ injectIntoAgents: z.boolean().optional() }).passthrough().optional(), + usage: z.object({ enabled: z.boolean() }).optional(), browserTools: MutableBrowserToolsConfigSchema.partial().optional(), providers: z .record(z.string(), MutableDaemonProviderConfigSchema.partial().passthrough()) @@ -3673,6 +3676,8 @@ export const ServerInfoStatusPayloadSchema = z providersSnapshot: z.boolean().optional(), connectedHosts: z.boolean().optional(), hostMultiplexing: z.boolean().optional(), + // COMPAT(usageSetting): added in v0.14, remove after 2027-05-01. + usageSetting: z.boolean().optional(), usageSources: z.boolean().optional(), // COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10. providersSnapshotCwd: z.boolean().optional(), diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 8fe0f05b14..fa896ef898 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -420,6 +420,7 @@ export interface PaseoDaemonConfig { trustedProxies?: true | string[]; mcpEnabled?: boolean; mcpInjectIntoAgents?: boolean; + usageEnabled?: boolean; browserToolsEnabled?: boolean; git?: { maxProcessesPerSecond: number; @@ -590,6 +591,7 @@ function createInitialMutableDaemonConfig(config: ResolvedDaemonSettings): Mutab ...(config.providerCatalogRefreshTimeoutMs !== undefined ? { catalogRefreshTimeoutMs: config.providerCatalogRefreshTimeoutMs } : {}), + usage: { enabled: config.usageEnabled !== false }, browserTools: { enabled: config.browserToolsEnabled ?? false }, providers, metadataGeneration: { diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index 3149fc6eb9..07c5c287b8 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -523,6 +523,10 @@ function resolveAppendSystemPrompt(persisted: ReturnType): boolean { + return persisted.daemon?.usage?.enabled !== false; +} + function resolveBrowserToolsEnabled(persisted: ReturnType): boolean { return persisted.daemon?.browserTools?.enabled ?? false; } @@ -548,6 +552,7 @@ function resolveStaticLoadConfigSettings( mcpEnabled: cli?.mcpEnabled ?? persisted.daemon?.mcp?.enabled ?? true, mcpInjectIntoAgents: cli?.mcpInjectIntoAgents ?? persisted.daemon?.mcp?.injectIntoAgents ?? false, + usageEnabled: resolveUsageEnabled(persisted), browserToolsEnabled: resolveBrowserToolsEnabled(persisted), autoArchiveAfterMerge: persisted.daemon?.autoArchiveAfterMerge ?? false, appendSystemPrompt: resolveAppendSystemPrompt(persisted), @@ -576,6 +581,7 @@ export type ResolvedDaemonSettings = Pick< | "trustedProxies" | "mcpEnabled" | "mcpInjectIntoAgents" + | "usageEnabled" | "browserToolsEnabled" | "git" | "autoArchiveAfterMerge" diff --git a/packages/server/src/server/daemon-config-store.test.ts b/packages/server/src/server/daemon-config-store.test.ts index 89c3c7f977..d9b2e94181 100644 --- a/packages/server/src/server/daemon-config-store.test.ts +++ b/packages/server/src/server/daemon-config-store.test.ts @@ -10,6 +10,10 @@ import { readSpeechSettings } from "./speech/speech-settings.js"; import type { PersistedConfig } from "./persisted-config.js"; import type { MutableDaemonConfig } from "@getpaseo/protocol/messages"; +function usageEnabled(persisted: PersistedConfig): boolean { + return persisted.daemon?.usage?.enabled !== false; +} + function reloadableConfig( persisted: PersistedConfig, options: { relayEnabledFallback?: boolean } = {}, @@ -23,6 +27,7 @@ function reloadableConfig( enabled: relay.enabled ?? options.relayEnabledFallback ?? true, }, mcp: { enabled: true, injectIntoAgents: false }, + usage: { enabled: usageEnabled(persisted) }, browserTools: { enabled: daemon.browserTools?.enabled ?? false }, providers: (agents.providers ?? {}) as MutableDaemonConfig["providers"], metadataGeneration: { providers: agents.metadataGeneration?.providers ?? [] }, @@ -1572,3 +1577,31 @@ describe("DaemonConfigStore speech settings", () => { expect(JSON.stringify(other.get())).not.toContain("secret"); }); }); + +test("usage policy persists across restart and live reload", async () => { + const paseoHome = mkdtempSync(path.join(tmpdir(), "usage-policy-")); + try { + const initial = reloadableConfig({}); + const store = new DaemonConfigStore(paseoHome, initial, undefined, { + reloadSource: { + resolve: (persisted) => ({ + mutable: reloadableConfig(persisted), + overrideControlledPaths: [], + }), + }, + }); + await store.patch({ usage: { enabled: false } }); + expect(loadConfig(paseoHome, { env: {} }).usageEnabled).toBe(false); + const persisted = loadPersistedConfig(paseoHome); + const restarted = new DaemonConfigStore(paseoHome, reloadableConfig(persisted)); + expect(restarted.get().usage).toEqual({ enabled: false }); + writeFileSync( + path.join(paseoHome, "config.json"), + JSON.stringify({ daemon: { usage: { enabled: true } } }), + ); + await store.reload(); + expect(store.get().usage).toEqual({ enabled: true }); + } finally { + rmSync(paseoHome, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/daemon-config-store.ts b/packages/server/src/server/daemon-config-store.ts index 1bd67822a3..e83874235f 100644 --- a/packages/server/src/server/daemon-config-store.ts +++ b/packages/server/src/server/daemon-config-store.ts @@ -22,6 +22,7 @@ type ProviderOverride = import("./agent/provider-launch-config.js").ProviderOver interface SupportedMutableConfigPatch { relay?: { enabled?: boolean; requireProtectedSecurity?: boolean }; mcp?: { injectIntoAgents?: boolean }; + usage?: { enabled: boolean }; browserTools?: { enabled?: boolean }; providers?: MutableDaemonConfig["providers"]; removeProviders?: string[]; @@ -182,6 +183,7 @@ const RELOADABLE_PATHS = [ "daemon.relay.requireProtectedSecurity", "daemon.mcp.enabled", "daemon.mcp.injectIntoAgents", + "daemon.usage.enabled", "daemon.browserTools.enabled", "daemon.hostnames", "daemon.cors.allowedOrigins", @@ -209,6 +211,7 @@ const PERSISTED_TO_MUTABLE_PATH = new Map([ ["daemon.relay.requireProtectedSecurity", "relay.requireProtectedSecurity"], ["daemon.mcp.enabled", "mcp.enabled"], ["daemon.mcp.injectIntoAgents", "mcp.injectIntoAgents"], + ["daemon.usage.enabled", "usage.enabled"], ["daemon.browserTools.enabled", "browserTools.enabled"], ["daemon.hostnames", "hostnames"], ["daemon.cors.allowedOrigins", "cors.allowedOrigins"], @@ -281,6 +284,7 @@ function pickRelayPatch( function pickSupportedPatchFields(patch: MutableDaemonConfigPatch): SupportedMutableConfigPatch { return { ...pickRelayPatch(patch.relay), + usage: patch.usage, ...(patch.mcp?.injectIntoAgents !== undefined ? { mcp: { injectIntoAgents: patch.mcp.injectIntoAgents } } : {}), @@ -767,6 +771,7 @@ function mergeMutableDaemonPatch( if (patch.mcp?.injectIntoAgents !== undefined) { next.mcp = { ...next.mcp, injectIntoAgents: patch.mcp.injectIntoAgents }; } + if (patch.usage) next.usage = patch.usage; if (patch.browserTools?.enabled !== undefined) { next.browserTools = { ...next.browserTools, enabled: patch.browserTools.enabled }; } diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index f7b3982513..3138ec9132 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -259,6 +259,7 @@ export const PersistedConfigSchema = z }) .passthrough() .optional(), + usage: z.object({ enabled: z.boolean().optional() }).optional(), browserTools: z .object({ enabled: z.boolean().optional(), diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index cca0b4f3fa..09681de8ba 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1042,6 +1042,7 @@ export class Session { logger: this.sessionLogger, }); this.usageSession = new UsageSession({ + isEnabled: () => daemonConfigStore.get().usage?.enabled !== false, emit: (msg) => this.emit(msg), runtime: pluginRuntime, logger: this.sessionLogger, diff --git a/packages/server/src/server/session/usage/usage-session.test.ts b/packages/server/src/server/session/usage/usage-session.test.ts index 2aa65992b8..3a397094a3 100644 --- a/packages/server/src/server/session/usage/usage-session.test.ts +++ b/packages/server/src/server/session/usage/usage-session.test.ts @@ -15,6 +15,7 @@ test("lists reports from usage sources", async () => { report: { status: "available" as const, windows: [] }, }; const usage = new UsageSession({ + isEnabled: () => true, emit: (message) => emitted.push(message), runtime: { async listUsageReports(options) { @@ -40,6 +41,7 @@ test("lists reports from usage sources", async () => { test("surfaces a legacy usage-list failure as an rpc_error envelope", async () => { const emitted: SessionOutboundMessage[] = []; const usage = new UsageSession({ + isEnabled: () => true, emit: (message) => emitted.push(message), runtime: { async listUsageReports() { @@ -61,6 +63,7 @@ test("surfaces a legacy usage-list failure as an rpc_error envelope", async () = test("request failures terminate with an error response and no updates", async () => { const emitted: SessionOutboundMessage[] = []; const usage = new UsageSession({ + isEnabled: () => true, emit: (message) => emitted.push(message), logger: pino({ level: "silent" }), }); @@ -72,3 +75,36 @@ test("request failures terminate with an error response and no updates", async ( }, ]); }); + +test("off rejects both protocols before runtime discovery and turning on restores reports", async () => { + let enabled = false; + let calls = 0; + const emitted: SessionOutboundMessage[] = []; + const usage = new UsageSession({ + isEnabled: () => enabled, + emit: (message) => emitted.push(message), + logger: pino({ level: "silent" }), + runtime: { + async listUsageReports() { + calls++; + return []; + }, + async listLegacyUsage() { + calls++; + return { fetchedAt: "now", providers: [] }; + }, + }, + }); + await usage.handleListReports({ type: "usage.list_reports.request", requestId: "off" }); + await usage.handleLegacyList({ type: "provider.usage.list.request", requestId: "old" }); + expect(calls).toBe(0); + expect(emitted[0]).toMatchObject({ payload: { error: "Usage is off on this host." } }); + expect(emitted[1]).toMatchObject({ + type: "rpc_error", + payload: { error: "Usage is off on this host." }, + }); + enabled = true; + await usage.handleListReports({ type: "usage.list_reports.request", requestId: "on" }); + expect(calls).toBe(1); + expect(emitted[2]).toMatchObject({ payload: { error: null } }); +}); diff --git a/packages/server/src/server/session/usage/usage-session.ts b/packages/server/src/server/session/usage/usage-session.ts index 500c03a088..cd1e2e8801 100644 --- a/packages/server/src/server/session/usage/usage-session.ts +++ b/packages/server/src/server/session/usage/usage-session.ts @@ -9,6 +9,7 @@ export interface UsageSessionOptions { listUsageReports(options: ListUsageReportsOptions): Promise; listLegacyUsage(): Promise<{ fetchedAt: string; providers: ProviderUsage[] }>; }; + isEnabled: () => boolean; logger: pino.Logger; } @@ -19,6 +20,7 @@ export class UsageSession { msg: Extract, ): Promise { try { + if (!this.options.isEnabled()) throw new Error("Usage is off on this host."); if (!this.options.runtime) throw new Error("Plugin runtime is unavailable"); await this.options.runtime.listUsageReports({ forceRefresh: msg.forceRefresh, @@ -49,6 +51,18 @@ export class UsageSession { async handleLegacyList( msg: Extract, ): Promise { + if (!this.options.isEnabled()) { + this.options.emit({ + type: "rpc_error", + payload: { + requestId: msg.requestId, + requestType: msg.type, + error: "Usage is off on this host.", + code: "provider_usage_list_failed", + }, + }); + return; + } try { if (!this.options.runtime) throw new Error("Plugin runtime is unavailable"); const usage = await this.options.runtime.listLegacyUsage(); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index b7024a638b..96c81ab49a 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1949,6 +1949,7 @@ export class VoiceAssistantWebSocketServer { hostMultiplexing: true, // COMPAT(relayProtectedSecurity): added in v0.13.0, remove gate after 2027-04-21. relayProtectedSecurity: true, + usageSetting: true, usageSources: true, ownedSubscriptions: true, agentRequestReceipts: true, diff --git a/packages/website/public/schemas/paseo.config.v1.json b/packages/website/public/schemas/paseo.config.v1.json index 1b74066bae..bae380d4c8 100644 --- a/packages/website/public/schemas/paseo.config.v1.json +++ b/packages/website/public/schemas/paseo.config.v1.json @@ -70,6 +70,14 @@ }, "additionalProperties": {} }, + "usage": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + } + }, "browserTools": { "type": "object", "properties": { From 1f79efcd629283ccdc56bb070287c1537a004f3a Mon Sep 17 00:00:00 2001 From: Peanut <34237301+Peanut-HZM@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:08:14 +0800 Subject: [PATCH 23/95] fix(providers): name the MiniMax Code binary so npx can resolve it (#8201) The MiniMax Code catalog entry ran `npx -y @minimax-ai/code@0.6.13 acp`. npx derives the executable from the unscoped package name, which is `code`, but the package publishes `mcode` and `mcode-tools`. npm therefore exits with "could not determine executable to run" before the agent starts. Paseo reports this as a provider that timed out during `initialize`, so the entry looks like a broken agent rather than a malformed command. Name the package and the binary separately. The ACP handshake then completes and the provider reports its models and modes. Co-authored-by: Peanut-HZM --- packages/app/src/data/acp-provider-catalog.ts | 5 ++++- packages/app/src/hooks/use-acp-provider-catalog.test.ts | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/app/src/data/acp-provider-catalog.ts b/packages/app/src/data/acp-provider-catalog.ts index f0210f1402..46b7d81a2d 100644 --- a/packages/app/src/data/acp-provider-catalog.ts +++ b/packages/app/src/data/acp-provider-catalog.ts @@ -292,7 +292,10 @@ const CATALOG_DATA = [ version: "0.6.13", iconId: "minimax-code", installLink: "https://agent.minimax.io", - command: ["npx", "-y", "@minimax-ai/code@0.6.13", "acp"], + // `-p ` names the package and `mcode` names its binary. Without the + // explicit binary, npx resolves the executable from the unscoped package + // name (`code`), which this package does not publish. + command: ["npx", "-y", "-p", "@minimax-ai/code@0.6.13", "mcode", "acp"], }, { id: "minion-code", diff --git a/packages/app/src/hooks/use-acp-provider-catalog.test.ts b/packages/app/src/hooks/use-acp-provider-catalog.test.ts index a30b27ce8f..41806de307 100644 --- a/packages/app/src/hooks/use-acp-provider-catalog.test.ts +++ b/packages/app/src/hooks/use-acp-provider-catalog.test.ts @@ -62,7 +62,9 @@ describe("ACP provider catalog", () => { expect(findProvider("minimax-code")).toMatchObject({ title: "MiniMax Code", version: "0.6.13", - command: ["npx", "-y", "@minimax-ai/code@0.6.13", "acp"], + // The package publishes `mcode`, not `code`, so npx needs the package and + // the binary named separately. + command: ["npx", "-y", "-p", "@minimax-ai/code@0.6.13", "mcode", "acp"], }); expect(findProvider("minimax-code").iconSvg).toContain(" Date: Fri, 30 Oct 2026 09:17:50 +0000 Subject: [PATCH 24/95] test(server): measure Git filter work within its callback (#8270) Exclude unrelated callbacks and fixture garbage from the cold event-filter budget. Keep the 20ms limit and IO assertions, with unrelated CPU work as a regression control. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- ...it-service.observation.integration.test.ts | 41 ++++++++++++++----- 1 file changed, 31 insertions(+), 10 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 a2cb27739d..c8d97390ed 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 @@ -536,7 +536,6 @@ test("Git refresh, command tracing and 10,000 ignored alias events do no synchro const repositoryRoot = fileURLToPath(new URL("../../../../", import.meta.url)); const source = ` import assert from 'node:assert/strict'; - import { measureMainThreadWork } from './packages/server/src/test-utils/main-thread-work.ts'; import { write } from 'node:fs'; import { mkdtemp, mkdir, writeFile, symlink, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; @@ -580,17 +579,36 @@ test("Git refresh, command tracing and 10,000 ignored alias events do no synchro const events = Array.from({ length: process.env.GIT_IO_TRACE ? 2 : 10000 }, (_, i) => ({ type: 'update', path: join(i % 2 ? alias : worktree, 'ignored', 'noise') })); let fsCalls = 0; const hook = createHook({ init(_id, type) { if (type.startsWith('FSREQ')) fsCalls++; } }); + // Collect repository setup and event-fixture garbage before measuring + // the cold filter. Allocations made by the filter still count. + global.gc(); await tick(); - const { blockingMs } = await measureMainThreadWork('Git filter main-thread work', async () => { - await marker('FILTER_START'); - hook.enable(); - try { - callback(null, events); - } finally { - hook.disable(); - await marker('FILTER_END'); + // Unrelated callbacks must not consume the synchronous filter's budget. + const threadCpuUsage = Reflect.get(process, 'threadCpuUsage'); + const unrelatedWork = tick().then(() => { + const started = threadCpuUsage(); + while (true) { + const cpu = threadCpuUsage(started); + if (cpu.user + cpu.system >= 80000) break; } }); + await marker('FILTER_START'); + hook.enable(); + let blockingMs; + try { + // This callback is synchronous: measure its own span, without waiting + // for marker IO, loader messages or other event-loop callbacks. + const startedAt = performance.now(); + const startedCpu = threadCpuUsage(); + callback(null, events); + const cpu = threadCpuUsage(startedCpu); + // Cap coarse Windows CPU accounting by elapsed time; exclude descheduling. + blockingMs = Math.min(performance.now() - startedAt, (cpu.user + cpu.system) / 1000); + } finally { + hook.disable(); + await marker('FILTER_END'); + } + await unrelatedWork; assert.equal(fsCalls, 0, "filesystem calls"); assert.equal(notifications, 0, "ignored event notification"); assert.ok(blockingMs < 20, 'blocking filter work: ' + blockingMs); @@ -614,6 +632,7 @@ test("Git refresh, command tracing and 10,000 ignored alias events do no synchro const { stdout, stderr } = await promisify(execFile)( process.execPath, [ + "--expose-gc", "--import", "tsx", ...(tracing ? ["--trace-sync-io"] : []), @@ -634,7 +653,9 @@ test("Git refresh, command tracing and 10,000 ignored alias events do no synchro const output = stderr.split(`${phase}_START\n`)[1]?.split(`${phase}_END\n`)[0]; expect(output, `${phase}: synchronous filesystem IO`).toBe(""); } - expect(JSON.parse(stdout.trim().split("\n")[0])).toMatchObject({ fsCalls: 0 }); + const measurement = JSON.parse(stdout.trim().split("\n")[0]); + console.info("Git filter budget", { tracing, ...measurement }); + expect(measurement).toMatchObject({ fsCalls: 0 }); } }, 60_000); From 531370c4ce98c1e32a0c0a5c4baf4b31501999a4 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:10:39 +0000 Subject: [PATCH 25/95] fix(pi): show tool-result images live and after reload (#8280) * pi: render tool-result images as inline image rows (paseo #1717 parity) * fix(pi): preserve turn ownership while rendering images * fix(pi): preserve metadata when stripping nested image payloads --------- Co-authored-by: be-my-bot Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> Co-authored-by: duyiliu --- .../server/agent/providers/pi/agent.test.ts | 111 ++++++++++++++++++ .../src/server/agent/providers/pi/agent.ts | 68 ++++++++++- .../providers/pi/extensions/child-session.ts | 11 +- .../providers/pi/extensions/index.test.ts | 6 +- .../rpiv-ask-user-question/index.test.ts | 4 +- .../pi/extensions/task-fixture-test.ts | 2 +- .../agent/providers/pi/history-mapper.test.ts | 72 ++++++++++++ .../agent/providers/pi/history-mapper.ts | 36 ++++-- .../providers/pi/tool-call-mapper.test.ts | 82 ++++++++++++- .../agent/providers/pi/tool-call-mapper.ts | 63 ++++++++++ 10 files changed, 429 insertions(+), 26 deletions(-) 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 82887bf199..936bdbc973 100644 --- a/packages/server/src/server/agent/providers/pi/agent.test.ts +++ b/packages/server/src/server/agent/providers/pi/agent.test.ts @@ -3643,3 +3643,114 @@ test("closing Pi during image conversion rejects its pending start", async () => await session.close(); await rejected; }); + +describe("Pi tool-result images", () => { + test.each([false, true])( + "tool-result images precede the reply and completion (isError=%s)", + async (isError) => { + const { pi, session, events } = await createSession(); + onTestFinished(() => session.close()); + const fakeSession = pi.latestSession(); + await session.startTurn("run"); + fakeSession.emit({ + type: "tool_execution_start", + toolCallId: "shot-1", + toolName: "screenshot", + args: {}, + }); + fakeSession.emit({ + type: "tool_execution_end", + toolCallId: "shot-1", + toolName: "screenshot", + result: { + content: [ + { type: "text", text: "captured" }, + { type: "image", data: Buffer.from("hello").toString("base64"), mimeType: "image/png" }, + ], + structuredContent: { + type: "image", + data: Buffer.from("hello").toString("base64"), + mimeType: "image/png", + note: "captured", + }, + }, + isError, + }); + fakeSession.emit({ + type: "message_update", + assistantMessageEvent: { type: "text_delta", delta: "Image read complete." }, + }); + fakeSession.finishTurn(); + await events.nextTurnCompletion(); + + const items = events.timelineItems(); + const toolIndex = items.findIndex( + (item) => + item.type === "tool_call" && + item.callId === "shot-1" && + item.status === (isError ? "failed" : "completed"), + ); + expect(toolIndex).toBeGreaterThanOrEqual(0); + // Base64 must never reach the tool output stringifier. + expect(JSON.stringify(items[toolIndex])).not.toContain( + Buffer.from("hello").toString("base64"), + ); + expect(items.slice(toolIndex + 1)).toEqual([ + expect.objectContaining({ + type: "assistant_message", + text: expect.stringContaining("![Image]("), + }), + expect.objectContaining({ type: "assistant_message", text: "Image read complete." }), + ]); + expect(events.timelineAndCompletionEvents().at(-1)).toEqual({ type: "turn_completed" }); + }, + ); +}); + +test("Pi image rendering preserves turn ownership across an interrupt and follow-up", async () => { + const { pi, session, events } = await createSession(); + onTestFinished(() => session.close()); + const emitted: AgentStreamEvent[] = []; + session.subscribe((event) => emitted.push(event)); + const native = pi.latestSession(); + const first = await session.startTurn("read image"); + native.emit({ + type: "tool_execution_end", + toolCallId: "large-image", + toolName: "screenshot", + result: { + content: [ + { + type: "image", + data: Buffer.alloc(8 * 1024 * 1024, 73).toString("base64"), + mimeType: "image/png", + }, + ], + }, + isError: false, + }); + native.finishTurn(); + await session.interrupt(); + const followup = await session.startTurn("follow-up after image"); + await waitForImmediate(); + await waitForImmediate(); + await expect + .poll(() => + events + .timelineItems() + .some((item) => item.type === "assistant_message" && item.text.includes("![Image](")), + ) + .toBe(true); + expect(events.turnCompletedEvents().some((event) => event.turnId === followup.turnId)).toBe( + false, + ); + const imageEvent = emitted.find( + (event) => + event.type === "timeline" && + event.item.type === "assistant_message" && + event.item.text.includes("![Image]("), + ); + expect(imageEvent).toMatchObject({ turnId: first.turnId }); + native.finishTurn(); + await events.nextTurnCompletion(); +}); diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts index 5fbffde84b..9474e98cc8 100644 --- a/packages/server/src/server/agent/providers/pi/agent.ts +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -64,7 +64,11 @@ import { streamPiHistory, type PiCapturedUserMessageEntry, } from "./history-mapper.js"; -import { materializeProviderImage } from "../provider-image-output.js"; +import { + materializeProviderImage, + renderProviderImageOutputAsAssistantMarkdown, + type ProviderImageOutput, +} from "../provider-image-output.js"; import { PiCliRuntime } from "./cli-runtime.js"; import { createPiExtensionHost, @@ -89,6 +93,7 @@ import { mapToolDetail, parseToolArgs, parseToolResult, + splitPiToolResultImages, type PiToolResult, type PiTrackedToolCall, } from "./tool-call-mapper.js"; @@ -1201,6 +1206,7 @@ export class PiRpcAgentSession implements AgentSession { private closed = false; private readonly closeController = new AbortController(); private readonly pendingExtensionHydrations = new Set>(); + private pendingOutput: Promise | null = null; // Pi publishes the terminal before acknowledging abort. Autonomous runs have no // turn ID; retain their errors too until the cancellation request settles. private interruptingTurn: { turnId: string | undefined; error: string | null } | null = null; @@ -1602,6 +1608,7 @@ export class PiRpcAgentSession implements AgentSession { this.pendingExtensionUiRequests.size || this.pendingExtensionResults.size || this.pendingExtensionHydrations.size || + this.pendingOutput || this.pendingSteerSubmissions.length || this.pendingNoTurnOutputs.length || this.interruptingTurn @@ -1622,7 +1629,7 @@ export class PiRpcAgentSession implements AgentSession { try { await this.runtimeSession.close(); } finally { - await Promise.all(this.pendingExtensionHydrations); + await Promise.all([this.pendingOutput, ...this.pendingExtensionHydrations]); this.rejectAllExtensionResults(new Error("Pi session closed")); this.cleanup?.(); } @@ -1686,9 +1693,31 @@ export class PiRpcAgentSession implements AgentSession { } private emit(event: AgentStreamEvent): void { - for (const subscriber of this.subscribers) { - subscriber(event); + if (this.pendingOutput) { + this.emitAsync(() => Promise.resolve([event])); + return; } + this.publish(event); + } + + private publish(event: AgentStreamEvent): void { + for (const subscriber of this.subscribers) subscriber(event); + } + + // Keep timeline order while images are materialized, without delaying provider state changes. + private emitAsync(render: () => Promise): void { + const pending = (this.pendingOutput ?? Promise.resolve()) + .then(render) + .then((events) => { + if (!this.closed) for (const event of events) this.publish(event); + return undefined; + }) + .catch((error) => this.logger.error({ err: error }, "Pi output rendering failed")); + this.pendingOutput = pending; + void pending.then(() => { + if (this.pendingOutput === pending) this.pendingOutput = null; + return undefined; + }); } private emitExtensionOutput(output: PiExtensionEventOutput | undefined, turnId?: string): void { @@ -2315,8 +2344,11 @@ export class PiRpcAgentSession implements AgentSession { this.activeToolCalls.get(event.toolCallId) ?? parseToolArgs(event.toolName, null); this.activeToolCalls.delete(event.toolCallId); - const result = parseToolResult(event.result); - const error = event.isError ? event.result : null; + // Pull image blocks out of the result so base64 never reaches the tool output, then render + // each one as an assistant_message markdown image after the tool_call (mirroring the Claude + // provider, upstream #1717). + const { images, result } = splitPiToolResultImages(parseToolResult(event.result)); + const error = event.isError ? result : null; const status = event.isError ? "failed" : "completed"; this.extensionHost.onToolEnd({ callId: event.toolCallId, @@ -2326,6 +2358,30 @@ export class PiRpcAgentSession implements AgentSession { result, }); this.emitToolCallEvent(event.toolCallId, toolCall, status, result, error); + if (images.length === 0) return; + const turnId = this.currentTurnIdForEvent(); + this.emitAsync(() => this.renderToolResultImages(images, turnId)); + } + + private async renderToolResultImages( + images: ProviderImageOutput[], + turnId: string | undefined, + ): Promise { + const events: AgentStreamEvent[] = []; + for (const image of images) { + const imageItem = await renderProviderImageOutputAsAssistantMarkdown(image, { + materialize: materializeProviderImage, + }); + if (imageItem && !this.closed) { + events.push({ + type: "timeline", + provider: this.provider, + turnId, + item: imageItem, + }); + } + } + return events; } private emitCompactionTimeline(input: { diff --git a/packages/server/src/server/agent/providers/pi/extensions/child-session.ts b/packages/server/src/server/agent/providers/pi/extensions/child-session.ts index 2013b831be..0c7c7df9aa 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/child-session.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/child-session.ts @@ -21,11 +21,11 @@ export function mapPiChildSession( return new PiChildSessionFollower(id, file, maxBytes).readNew(); } -function mapChildLine( +async function mapChildLine( mapper: PiHistoryMapper, id: string, line: string, -): ProviderSubagentInputEvent[] { +): Promise { let entry: { message?: PiAgentMessage; timestamp?: string }; try { entry = JSON.parse(line); @@ -33,7 +33,7 @@ function mapChildLine( return []; } if (!entry.message || typeof entry.message !== "object" || !("role" in entry.message)) return []; - return mapper.mapMessages([entry.message]).flatMap((mapped) => + return (await mapper.mapMessages([entry.message])).flatMap((mapped) => mapped.type === "timeline" ? [ { @@ -83,7 +83,10 @@ export class PiChildSessionFollower { const events: ProviderSubagentInputEvent[] = []; for (const line of data.subarray(0, lastNewline).toString("utf8").split("\n")) { if (this.items >= MAX_ITEMS) break; - const mapped = mapChildLine(this.mapper, this.id, line).slice(0, MAX_ITEMS - this.items); + const mapped = (await mapChildLine(this.mapper, this.id, line)).slice( + 0, + MAX_ITEMS - this.items, + ); this.items += mapped.length; events.push(...mapped); } diff --git a/packages/server/src/server/agent/providers/pi/extensions/index.test.ts b/packages/server/src/server/agent/providers/pi/extensions/index.test.ts index 9afacae977..0bf32a9f2f 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/index.test.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/index.test.ts @@ -83,7 +83,7 @@ describe("Pi extension host", () => { test("declines a throwing adapter and preserves generic history", async () => { const host = createPiExtensionHost(undefined, [throwingAdapter]); const mapper = new PiHistoryMapper("pi", [], {}, host); - const events = mapper.mapMessages([ + const events = await mapper.mapMessages([ { role: "assistant", content: [{ type: "toolCall", id: "call-1", name: "other", arguments: {} }], @@ -193,7 +193,7 @@ describe("Pi extension host", () => { await output?.hydration; expect(reads).toHaveLength(8); }); - test("claims a tool, declines an unrelated one, and maps history like live events", () => { + test("claims a tool, declines an unrelated one, and maps history like live events", async () => { const host = createPiExtensionHost(); const args = { agent: "scout", task: "Inspect files" }; const result = parseToolResult({ content: [{ type: "text", text: "Found two files" }] }); @@ -225,7 +225,7 @@ describe("Pi extension host", () => { ).toBeUndefined(); const mapper = new PiHistoryMapper("pi"); - const events = mapper.mapMessages([ + const events = await mapper.mapMessages([ { role: "assistant", content: [{ type: "toolCall", id: "call-1", name: "subagent", arguments: args }], diff --git a/packages/server/src/server/agent/providers/pi/extensions/rpiv-ask-user-question/index.test.ts b/packages/server/src/server/agent/providers/pi/extensions/rpiv-ask-user-question/index.test.ts index 3ec9256a29..d3c9fb7a9e 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/rpiv-ask-user-question/index.test.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/rpiv-ask-user-question/index.test.ts @@ -79,7 +79,7 @@ test("cancellation declines each remaining real RPC dialog", () => { } }); -test("captured result renders in live and history tool rows, while foreign shapes decline", () => { +test("captured result renders in live and history tool rows, while foreign shapes decline", async () => { const call = { callId: end.toolCallId, toolName: end.toolName, @@ -93,7 +93,7 @@ test("captured result renders in live and history tool rows, while foreign shape label: "Questions", text: "Which snack?: Cookie\nWhich colors?: Red, Blue\nWhat project name?: Nebula", }); - const history = new PiHistoryMapper("pi").mapMessages([ + const history = await new PiHistoryMapper("pi").mapMessages([ { role: "assistant", content: [ diff --git a/packages/server/src/server/agent/providers/pi/extensions/task-fixture-test.ts b/packages/server/src/server/agent/providers/pi/extensions/task-fixture-test.ts index a3b2114cab..f702e0d7cf 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/task-fixture-test.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/task-fixture-test.ts @@ -89,7 +89,7 @@ export async function verifyTaskFixture(path: URL, expectedStatuses: string[]): for (const event of fixture.events) pi.latestSession().emit(event); pi.latestSession().finishTurn(); expect(todos(live)).toEqual(mapped); - const replay = new PiHistoryMapper("pi").mapMessages(fixture.messages); + const replay = await new PiHistoryMapper("pi").mapMessages(fixture.messages); expect(todos(replay)).toEqual(mapped); expect( replay.filter( diff --git a/packages/server/src/server/agent/providers/pi/history-mapper.test.ts b/packages/server/src/server/agent/providers/pi/history-mapper.test.ts index 24d18306b2..f4136956eb 100644 --- a/packages/server/src/server/agent/providers/pi/history-mapper.test.ts +++ b/packages/server/src/server/agent/providers/pi/history-mapper.test.ts @@ -221,3 +221,75 @@ describe("Pi history mapper", () => { ]); }); }); + +describe("Pi tool-result image replay", () => { + test("recreates an image from native tool-result history without its source file", async () => { + const events = await collectHistory([ + { + role: "assistant", + content: [ + { + type: "toolCall", + id: "image-read", + name: "read", + arguments: { path: "missing-preview.png" }, + }, + ], + }, + { + role: "toolResult", + toolCallId: "image-read", + toolName: "read", + content: [ + { type: "text", text: "Read image file [image/png]" }, + { type: "image", data: "aGVsbG8=", mimeType: "image/png" }, + ], + }, + ]); + expect(events.map((event) => event.item?.type)).toEqual([ + "tool_call", + "tool_call", + "assistant_message", + ]); + const image = events.find( + (event) => event.type === "timeline" && event.item.type === "assistant_message", + ); + expect(image).toMatchObject({ + type: "timeline", + item: { type: "assistant_message", text: expect.stringContaining("![Image](file://") }, + }); + if (image?.type !== "timeline" || image.item.type !== "assistant_message") + throw new Error("Missing image"); + const url = image.item.text.match(/\((file:[^)]+)\)/)![1]; + const { readFile, unlink } = await import("node:fs/promises"); + expect(await readFile(new URL(url))).toEqual(Buffer.from("hello")); + await unlink(new URL(url)); + const replay = await collectHistory([ + { + role: "toolResult", + toolCallId: "image-read", + toolName: "read", + content: [{ type: "image", data: "aGVsbG8=", mimeType: "image/png" }], + }, + ]); + expect(replay).toContainEqual(image); + expect(await readFile(new URL(url))).toEqual(Buffer.from("hello")); + }); +}); + +test("replayed Pi images keep their position before the following reply", async () => { + const events = await collectHistory([ + { + role: "toolResult", + toolCallId: "image-position", + toolName: "read", + content: [{ type: "image", data: "aGVsbG8=", mimeType: "image/png" }], + }, + { role: "assistant", content: [{ type: "text", text: "Image read complete." }] }, + ]); + expect( + events.map((event) => + event.item?.type === "assistant_message" ? event.item.text : event.item?.type, + ), + ).toEqual(["tool_call", expect.stringContaining("![Image]("), "Image read complete."]); +}); diff --git a/packages/server/src/server/agent/providers/pi/history-mapper.ts b/packages/server/src/server/agent/providers/pi/history-mapper.ts index 27a0250b00..89d9fcedce 100644 --- a/packages/server/src/server/agent/providers/pi/history-mapper.ts +++ b/packages/server/src/server/agent/providers/pi/history-mapper.ts @@ -1,5 +1,9 @@ import { mapCustomMessageToToolCall } from "../custom-message.js"; import type { AgentStreamEvent, AgentTimelineItem, ToolCallDetail } from "../../agent-sdk-types.js"; +import { + materializeProviderImage, + renderProviderImageOutputAsAssistantMarkdown, +} from "../provider-image-output.js"; import { createPiExtensionHost, type PiExtensionEventOutput, @@ -11,6 +15,7 @@ import { mapToolDetail, parseToolArgs, parseToolResult, + splitPiToolResultImages, type PiToolResult, type PiTrackedToolCall, } from "./tool-call-mapper.js"; @@ -71,7 +76,7 @@ export class PiHistoryMapper { private readonly extensionHost: PiExtensionHost = createPiExtensionHost(), ) {} - mapMessages(messages: readonly PiAgentMessage[]): AgentStreamEvent[] { + async mapMessages(messages: readonly PiAgentMessage[]): Promise { const events: AgentStreamEvent[] = []; for (const message of messages) { @@ -86,7 +91,7 @@ export class PiHistoryMapper { events.push(...this.mapAssistantMessage(message)); break; case "toolResult": { - events.push(...this.mapToolResultMessage(message)); + events.push(...(await this.mapToolResultMessage(message))); break; } case "bashExecution": @@ -207,24 +212,35 @@ export class PiHistoryMapper { return events; } - private mapToolResultMessage( + private async mapToolResultMessage( message: Extract, - ): AgentStreamEvent[] { + ): Promise { const tracked = this.pendingToolCalls.get(message.toolCallId) ?? parseToolArgs(message.toolName, null); this.pendingToolCalls.delete(message.toolCallId); const result = parseToolResult({ content: message.content, details: message.details }); + // Pull image blocks out of the result so base64 never reaches the tool output, then render + // each one as an assistant_message markdown image after the tool_call (mirroring the live + // path and the Claude provider, upstream #1717). + const { images, result: splitResult } = splitPiToolResultImages(result); const mapping = this.extensionHost.mapToolCall({ callId: message.toolCallId, toolName: tracked.toolName, args: tracked.args, status: message.isError ? "failed" : "completed", - result, + result: splitResult, }); - const detail = this.mapToolDetail(message.toolCallId, tracked, result, mapping?.detail); + const detail = this.mapToolDetail(message.toolCallId, tracked, splitResult, mapping?.detail); if (!detail) { return []; } + const imageEvents: AgentStreamEvent[] = []; + for (const image of images) { + const item = await renderProviderImageOutputAsAssistantMarkdown(image, { + materialize: materializeProviderImage, + }); + if (item) imageEvents.push({ type: "timeline", provider: this.provider, item }); + } return [ { type: "timeline", @@ -234,9 +250,10 @@ export class PiHistoryMapper { name: mapping?.name ?? tracked.toolName, isError: Boolean(message.isError), detail, - errorText: extractTextFromToolResult(result) ?? "Tool call failed", + errorText: extractTextFromToolResult(splitResult) ?? "Tool call failed", }), }, + ...imageEvents, ...this.extensionEvents(mapping), ]; } @@ -297,9 +314,10 @@ export async function* streamPiHistory( signal?: AbortSignal, ): AsyncGenerator { const mapper = new PiHistoryMapper(provider, userEntries, hooks, extensionHost); - for (const event of mapper.mapMessages(messages)) { + for (const message of messages) { if (signal?.aborted) return; - if (event) { + for (const event of await mapper.mapMessages([message])) { + if (signal?.aborted) return; yield event; } } diff --git a/packages/server/src/server/agent/providers/pi/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/pi/tool-call-mapper.test.ts index 63b557f994..0c065e27cd 100644 --- a/packages/server/src/server/agent/providers/pi/tool-call-mapper.test.ts +++ b/packages/server/src/server/agent/providers/pi/tool-call-mapper.test.ts @@ -1,6 +1,12 @@ import { describe, expect, test } from "vitest"; -import { mapToolDetail, parseToolArgs, parseToolResult } from "./tool-call-mapper.js"; +import { + extractTextFromToolResult, + mapToolDetail, + parseToolArgs, + parseToolResult, + splitPiToolResultImages, +} from "./tool-call-mapper.js"; describe("Pi tool call mapper", () => { test("maps bash args and result to shell detail", () => { @@ -56,3 +62,77 @@ describe("Pi tool call mapper", () => { }); }); }); + +describe("splitPiToolResultImages", () => { + test("extracts image blocks and replaces them with [image] placeholders", () => { + const parsed = parseToolResult({ + content: [ + { type: "text", text: "shot taken" }, + { type: "image", data: "aGVsbG8=", mimeType: "image/png" }, + ], + }); + const { images, result } = splitPiToolResultImages(parsed); + + expect(images).toEqual([{ data: "aGVsbG8=", mimeType: "image/png" }]); + expect(result).toEqual({ + content: [ + { type: "text", text: "shot taken" }, + { type: "text", text: "[image]" }, + ], + }); + }); + + test("replaces image-only results with a non-empty placeholder", () => { + const parsed = parseToolResult({ + content: [{ type: "image", data: "aGVsbG8=", mimeType: "image/jpeg" }], + }); + const { images, result } = splitPiToolResultImages(parsed); + + expect(images).toHaveLength(1); + expect(extractTextFromToolResult(result)).toBe("[image]"); + }); + + test("passes through string results and results without images", () => { + expect(splitPiToolResultImages("plain output")).toEqual({ + images: [], + result: "plain output", + }); + const textOnly = parseToolResult({ content: [{ type: "text", text: "no images" }] }); + expect(splitPiToolResultImages(textOnly)).toEqual({ images: [], result: textOnly }); + expect(splitPiToolResultImages(null)).toEqual({ images: [], result: null }); + }); + + test("keeps other fields on the result object", () => { + const parsed = parseToolResult({ + exitCode: 0, + content: [{ type: "image", data: "aGVsbG8=" }], + }); + const { result } = splitPiToolResultImages(parsed); + expect(result).toMatchObject({ exitCode: 0 }); + }); +}); + +test("tool-result images remove nested structured image data while preserving metadata", () => { + const data = Buffer.from("nested screenshot bytes").toString("base64"); + const result = parseToolResult({ + content: [{ type: "image", data, mimeType: "image/png" }], + structuredContent: { + content: [{ type: "image", data, mimeType: "image/png", width: 64 }], + captureId: "capture-1", + read: { type: "image", data, mimeType: "image/png", note: "Read image file" }, + extra: { data: "keep ordinary data" }, + }, + }); + const split = splitPiToolResultImages(result); + const detail = mapToolDetail(parseToolArgs("screenshot", {}), split.result); + expect(JSON.stringify(detail)).not.toContain(data); + expect(split.images).toEqual([{ data, mimeType: "image/png" }]); + expect(split.result).toMatchObject({ + structuredContent: { + content: [{ type: "image", data: "[image]", mimeType: "image/png", width: 64 }], + captureId: "capture-1", + read: { type: "image", data: "[image]", mimeType: "image/png", note: "Read image file" }, + extra: { data: "keep ordinary data" }, + }, + }); +}); diff --git a/packages/server/src/server/agent/providers/pi/tool-call-mapper.ts b/packages/server/src/server/agent/providers/pi/tool-call-mapper.ts index 8c18a8fdbf..8fbba690b8 100644 --- a/packages/server/src/server/agent/providers/pi/tool-call-mapper.ts +++ b/packages/server/src/server/agent/providers/pi/tool-call-mapper.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import type { ToolCallDetail } from "../../agent-sdk-types.js"; +import type { ProviderImageOutput } from "../provider-image-output.js"; interface BashToolInput { command: string; @@ -52,6 +53,7 @@ interface PiToolResultObject { stdout?: string; text?: string; content?: PiToolResultContent[]; + structuredContent?: unknown; exitCode?: number; code?: number; details?: PiToolResultDetails; @@ -67,6 +69,12 @@ interface PiToolResultTextContent { text: string; } +const PiToolResultImageContentSchema = z.object({ + type: z.literal("image"), + data: z.string().min(1), + mimeType: z.string().optional(), +}); + interface PiToolResultUnknownContent { type: string; } @@ -236,6 +244,61 @@ export function parseToolResult(rawResult: unknown): PiToolResult { return null; } +// Pi returns images inside tool-result content as base64 blocks. Left in place they reach the +// tool-output stringifier, which dumps base64 into the tool output. We pull those blocks out to +// render them as image markdown and leave a "[image]" placeholder so image-only results still +// produce non-empty output (mirroring splitClaudeToolResultImages). +export function splitPiToolResultImages(result: PiToolResult): { + images: ProviderImageOutput[]; + result: PiToolResult; +} { + if (!result || typeof result === "string" || !result.content) { + return { images: [], result }; + } + const images: ProviderImageOutput[] = []; + let replaced = false; + const content = result.content.map((block) => { + const image = toPiImageOutput(block); + if (image) { + images.push(image); + replaced = true; + return { type: "text", text: "[image]" } satisfies PiToolResultTextContent; + } + return block; + }); + if (!replaced) { + return { images: [], result }; + } + return { + images, + result: { + ...result, + content, + ...(result.structuredContent === undefined + ? {} + : { structuredContent: withoutImageData(result.structuredContent) }), + }, + }; +} + +function withoutImageData(value: unknown): unknown { + if (Array.isArray(value)) return value.map(withoutImageData); + if (value === null || typeof value !== "object") return value; + const image = toPiImageOutput(value); + return Object.fromEntries( + Object.entries(value).map(([key, child]) => [ + key, + image && key === "data" ? "[image]" : withoutImageData(child), + ]), + ); +} + +function toPiImageOutput(block: unknown): ProviderImageOutput | null { + const parsed = PiToolResultImageContentSchema.safeParse(block); + if (!parsed.success) return null; + return { data: parsed.data.data, mimeType: parsed.data.mimeType ?? null }; +} + export function extractTextFromToolResult(result: PiToolResult): string | undefined { if (typeof result === "string") { return result; From 9477fe0c0053b40be41b0a690cdb987e815f0901 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 30 Oct 2026 17:22:53 +0700 Subject: [PATCH 26/95] fix(app): refresh Android layout after background fold changes (#8285) Backport the Unistyles 3.5.3 Activity configuration fix while keeping 3.2.4 and its existing fixes. Application resources could leave the responsive shell on the previous fold size after resume. Cover the real device-state sequence with a native UI regression. --- docs/mobile-testing.md | 14 ++ packages/app/e2e/mobile/fold/android.mjs | 104 +++++++++++++++ patches/react-native-unistyles+3.2.4.patch | 148 +++++++++++++++++++++ scripts/postinstall-patches.mjs | 2 + 4 files changed, 268 insertions(+) create mode 100644 packages/app/e2e/mobile/fold/android.mjs diff --git a/docs/mobile-testing.md b/docs/mobile-testing.md index 169803c0eb..903244e3d9 100644 --- a/docs/mobile-testing.md +++ b/docs/mobile-testing.md @@ -35,6 +35,20 @@ PASEO_MOBILE_E2E_METRO_PORT=62093 npm run test:e2e:mobile [native-terminal-basic.ios.ad](../packages/app/e2e/mobile/agent-device/native-terminal-basic.ios.ad) and [native-terminal-basic.android.ad](../packages/app/e2e/mobile/agent-device/native-terminal-basic.android.ad) are the smallest examples. Each opens a fresh terminal, types a command at zero delay, submits it, and asserts its distinct output. The app must be connected to a daemon with an active workspace. +For Android background fold recovery, install the release APK on a foldable AVD, open a +connected workspace, close the keyboard, and run: + +```bash +ANDROID_SERIAL=emulator-5574 node packages/app/e2e/mobile/fold/android.mjs +``` + +The AVD must expose `CLOSED` and `OPENED` device states. The test backgrounds the app, +changes posture, resumes immediately, and asserts compact/wide workspace controls without +restarting the process. It writes snapshots and native failure state under +`.dev/agent-device-artifacts/android-fold`. Set `PASEO_MOBILE_APP_ID=sh.paseo` for a +production package. This needs a native rebuild when the Unistyles patch changes; a Metro +reload cannot update it. + For Android keyboard continuity, run the current checkout in the app, open an idle terminal with an empty prompt, hide its keyboard, and run: diff --git a/packages/app/e2e/mobile/fold/android.mjs b/packages/app/e2e/mobile/fold/android.mjs new file mode 100644 index 0000000000..93f23dc111 --- /dev/null +++ b/packages/app/e2e/mobile/fold/android.mjs @@ -0,0 +1,104 @@ +// Run a release build with a connected workspace open and the keyboard hidden. +// ANDROID_SERIAL=emulator-5574 node packages/app/e2e/mobile/fold/android.mjs +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; + +const serial = process.env.ANDROID_SERIAL; +assert(serial, "Set ANDROID_SERIAL to a booted Android foldable emulator"); +const appId = process.env.PASEO_MOBILE_APP_ID ?? "sh.paseo.debug"; +const session = "android-fold"; +const artifacts = resolve(".dev/agent-device-artifacts/android-fold"); +const env = { + ...process.env, + AGENT_DEVICE_STATE_DIR: resolve(".dev/agent-device-android-fold"), +}; +mkdirSync(artifacts, { recursive: true }); + +function run(binary, args) { + return execFileSync(binary, args, { env, encoding: "utf8", timeout: 60_000 }); +} +function adb(...args) { + return run("adb", ["-s", serial, ...args]); +} +function ad(...args) { + return run("agent-device", [...args, "--session", session]); +} +function pid() { + return adb("shell", "pidof", appId).trim(); +} +function open() { + ad("open", appId, "--platform", "android", "--serial", serial, "--no-test-ime"); +} +function stateId(name) { + const states = adb("shell", "cmd", "device_state", "print-states"); + const match = states.match(new RegExp(`identifier=(\\d+), name='${name}'`)); + assert(match, `Emulator must expose the ${name} posture: ${states}`); + return match[1]; +} + +async function expectLayout(posture) { + const compact = posture === "CLOSED"; + const expectedId = compact ? "workspace-tab-switcher-trigger" : "workspace-tabs-scroll"; + const unexpectedId = compact ? "workspace-tabs-scroll" : "workspace-tab-switcher-trigger"; + const deadline = Date.now() + 15_000; + let nodes = []; + do { + let data; + try { + ({ data } = JSON.parse(ad("snapshot", "-i", "--json"))); + } catch (error) { + // During display handoff Android briefly has no capturable app window. + if (!error.stdout?.includes("insufficient foreground app content")) throw error; + await delay(200); + continue; + } + assert.equal(data.appBundleId, appId, "Snapshot must belong to Paseo"); + nodes = data.nodes; + if ( + nodes.some((node) => node.identifier === expectedId) && + !nodes.some((node) => node.identifier === unexpectedId) + ) { + writeFileSync(resolve(artifacts, `${posture}.json`), JSON.stringify(data, null, 2)); + console.log(`PASS: ${posture} uses ${compact ? "compact" : "wide"} workspace controls`); + return; + } + await delay(200); + } while (Date.now() < deadline); + writeFileSync(resolve(artifacts, `${posture}-failure.json`), JSON.stringify(nodes, null, 2)); + writeFileSync( + resolve(artifacts, "native-failure.txt"), + adb("shell", "dumpsys", "activity", "top"), + ); + assert.fail(`${posture}: expected ${expectedId}, without ${unexpectedId}`); +} + +const closed = stateId("CLOSED"); +const opened = stateId("OPENED"); +const initialState = adb("shell", "cmd", "device_state", "state").match( + /Committed state: DeviceState\{identifier=(\d+)/, +)?.[1]; +assert(initialState, "Could not read the initial fold state"); + +try { + adb("shell", "cmd", "device_state", "state", opened); + open(); + await expectLayout("OPENED"); + const initialPid = pid(); + for (const [posture, id] of [ + ["CLOSED", closed], + ["OPENED", opened], + ]) { + adb("shell", "input", "keyevent", "KEYCODE_HOME"); + adb("shell", "cmd", "device_state", "state", id); + // Resume immediately: waiting for configuration delivery hides the regression. + open(); + await expectLayout(posture); + assert.equal(pid(), initialPid, "Fold/resume must preserve the app process"); + } +} finally { + adb("shell", "cmd", "device_state", "state", initialState); + ad("close"); +} diff --git a/patches/react-native-unistyles+3.2.4.patch b/patches/react-native-unistyles+3.2.4.patch index 1930a4db84..af825a6afa 100644 --- a/patches/react-native-unistyles+3.2.4.patch +++ b/patches/react-native-unistyles+3.2.4.patch @@ -557,3 +557,151 @@ index a16dac0..6519858 100644 return ColorScheme.Dark default: return ColorScheme.Unspecified +diff --git a/node_modules/react-native-unistyles/android/src/main/java/com/unistyles/NativePlatform+android.kt b/node_modules/react-native-unistyles/android/src/main/java/com/unistyles/NativePlatform+android.kt +index 0f8d1f0..154a12a 100644 +--- a/node_modules/react-native-unistyles/android/src/main/java/com/unistyles/NativePlatform+android.kt ++++ b/node_modules/react-native-unistyles/android/src/main/java/com/unistyles/NativePlatform+android.kt +@@ -3,11 +3,16 @@ package com.unistyles + import android.os.Build + import android.content.Context + import android.content.res.Configuration ++import android.content.res.Resources ++import android.os.Handler ++import android.os.Looper + import android.util.DisplayMetrics + import android.view.View + import android.view.WindowManager + import androidx.annotation.Keep ++import androidx.core.content.OnConfigurationChangedProvider + import androidx.core.text.TextUtilsCompat ++import androidx.core.util.Consumer + import androidx.core.view.ViewCompat + import androidx.core.view.WindowCompat + import androidx.core.view.WindowInsetsCompat +@@ -22,6 +27,7 @@ import com.margelo.nitro.unistyles.Insets + import com.margelo.nitro.unistyles.Orientation + import com.margelo.nitro.unistyles.UnistyleDependency + import com.margelo.nitro.unistyles.UnistylesNativeMiniRuntime ++import java.lang.ref.WeakReference + import java.util.Locale + + @Keep +@@ -30,6 +36,14 @@ class NativePlatformAndroid(private val reactContext: ReactApplicationContext): + private val _insets = NativePlatformInsets(reactContext, this::getMiniRuntime) { this.onConfigChange() } + private var _miniRuntime: UnistylesNativeMiniRuntime = buildMiniRuntime() + private val _listener = NativePlatformListener(reactContext, this::getMiniRuntime) { this.diffMiniRuntime() } ++ private var _configurationProvider: WeakReference? = null ++ private val _activityConfigurationListener = Consumer { ++ Handler(Looper.getMainLooper()).post { this.onConfigChange() } ++ } ++ ++ // Application resources can lag behind configuration changes ++ private val configurationResources: Resources ++ get() = reactContext.currentActivity?.resources ?: reactContext.resources + + init { + checkEdgeToEdge() +@@ -37,12 +51,16 @@ class NativePlatformAndroid(private val reactContext: ReactApplicationContext): + } + + fun onDestroy() { ++ stopObservingActivityConfiguration() + _listener.onDestroy() + _insets.onDestroy() + reactContext.removeLifecycleEventListener(this) + } + + override fun onHostResume() { ++ observeActivityConfiguration() ++ // catch configuration changes missed while no Activity was attached ++ onConfigChange() + _insets.getInitialInsets(false) + _insets.startInsetsListener() + } +@@ -51,7 +69,26 @@ class NativePlatformAndroid(private val reactContext: ReactApplicationContext): + _insets.stopInsetsListener() + } + +- override fun onHostDestroy() {} ++ override fun onHostDestroy() { ++ stopObservingActivityConfiguration() ++ } ++ ++ private fun observeActivityConfiguration() { ++ val provider = reactContext.currentActivity as? OnConfigurationChangedProvider ++ ++ if (_configurationProvider?.get() === provider) { ++ return ++ } ++ ++ stopObservingActivityConfiguration() ++ provider?.addOnConfigurationChangedListener(_activityConfigurationListener) ++ _configurationProvider = provider?.let { WeakReference(it) } ++ } ++ ++ private fun stopObservingActivityConfiguration() { ++ _configurationProvider?.get()?.removeOnConfigurationChangedListener(_activityConfigurationListener) ++ _configurationProvider = null ++ } + + override val memorySize: Long + get() = 0 +@@ -61,7 +98,7 @@ class NativePlatformAndroid(private val reactContext: ReactApplicationContext): + } + + override fun getColorScheme(): ColorScheme { +- val uiMode = reactContext.resources.configuration.uiMode ++ val uiMode = configurationResources.configuration.uiMode + + val colorScheme = when (uiMode.and(Configuration.UI_MODE_NIGHT_MASK)) { + Configuration.UI_MODE_NIGHT_YES -> ColorScheme.DARK +@@ -73,15 +110,15 @@ class NativePlatformAndroid(private val reactContext: ReactApplicationContext): + } + + override fun getFontScale(): Double { +- return reactContext.resources.configuration.fontScale.toDouble() ++ return configurationResources.configuration.fontScale.toDouble() + } + + override fun getPixelRatio(): Double { +- return reactContext.resources.displayMetrics.density.toDouble() ++ return configurationResources.displayMetrics.density.toDouble() + } + + override fun getOrientation(): Orientation { +- val orientation = when (reactContext.resources.configuration.orientation) { ++ val orientation = when (configurationResources.configuration.orientation) { + Configuration.ORIENTATION_PORTRAIT -> Orientation.PORTRAIT + Configuration.ORIENTATION_LANDSCAPE -> Orientation.LANDSCAPE + else -> Orientation.PORTRAIT +@@ -122,7 +159,7 @@ class NativePlatformAndroid(private val reactContext: ReactApplicationContext): + return Dimensions(screenWidth, screenHeight) + } + else -> { +- val displayMetrics = reactContext.resources.displayMetrics ++ val displayMetrics = configurationResources.displayMetrics + + reactContext.currentActivity?.windowManager?.currentWindowMetrics?.bounds?.let { + val boundsWidth = (it.width() / displayMetrics.density).toDouble() +@@ -157,7 +194,7 @@ class NativePlatformAndroid(private val reactContext: ReactApplicationContext): + "com.facebook.react.modules.i18nmanager.I18nUtil", + Context.MODE_PRIVATE + ) +- val resourcesLocale = reactContext.resources.configuration.locales[0] ++ val resourcesLocale = configurationResources.configuration.locales[0] + val hasForcedRtl = sharedPrefs.getBoolean("RCTI18nUtil_forceRTL", false) + // user preferences + val isRtl = TextUtilsCompat.getLayoutDirectionFromLocale(Locale.getDefault()) == ViewCompat.LAYOUT_DIRECTION_RTL +diff --git a/node_modules/react-native-unistyles/android/src/main/java/com/unistyles/NativePlatform+insets.kt b/node_modules/react-native-unistyles/android/src/main/java/com/unistyles/NativePlatform+insets.kt +index a9c1804..f5a3c20 100644 +--- a/node_modules/react-native-unistyles/android/src/main/java/com/unistyles/NativePlatform+insets.kt ++++ b/node_modules/react-native-unistyles/android/src/main/java/com/unistyles/NativePlatform+insets.kt +@@ -38,7 +38,7 @@ class NativePlatformInsets( + } + + fun getInsets(): Insets { +- val density = reactContext.resources.displayMetrics.density ++ val density = (reactContext.currentActivity?.resources ?: reactContext.resources).displayMetrics.density + + return Insets( + this._insets.top / density, diff --git a/scripts/postinstall-patches.mjs b/scripts/postinstall-patches.mjs index eca0fde25f..312bb90724 100644 --- a/scripts/postinstall-patches.mjs +++ b/scripts/postinstall-patches.mjs @@ -26,6 +26,8 @@ const patchedPackages = [ // https://github.com/jpudysz/react-native-unistyles/pull/1203. // Adaptive themes: keep until colorScheme reads no longer consume listener-owned media changes. // 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 { nodeModulesPath: "node_modules/react-native-unistyles", patchPrefix: "react-native-unistyles+", From 385c29b655a784a236c0f580ec4893500de3af1a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 30 Oct 2026 17:57:52 +0700 Subject: [PATCH 27/95] Keep failed chat history available for retry (#8278) * fix(app): keep failed chat history until retry Automatic retries repeatedly reopen provider sessions after persistent failures. Keep the error visible until the user retries, while retaining reconnect recovery. * fix(app): catch up chats after subscription failure A failed replacement subscription leaves chats unsubscribed, so discard its old catch-up state and fetch missed history after retry. * fix(app): recover gaps during history retry Keep gap recovery active while a user-initiated retry is running so the chat is not marked current before its follow-up fetch. * fix(app): show history errors in archived chats Cached archived chats need the same manual retry affordance when history synchronization fails. --- packages/app/src/contexts/session-context.tsx | 4 - .../use-agent-screen-state-machine.test.ts | 17 ++ .../hooks/use-agent-screen-state-machine.ts | 8 +- .../src/stores/workspace-layout-store.test.ts | 1 - packages/app/src/timeline/replica.test.ts | 2 - .../src/timeline/viewed-timeline-sync.test.ts | 226 +++++++++--------- .../app/src/timeline/viewed-timeline-sync.ts | 87 ++----- 7 files changed, 155 insertions(+), 190 deletions(-) diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index bccd4d3ee0..89c32c0bd5 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -455,10 +455,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider reportError: (error) => { console.warn("[Session] viewed timeline synchronization failed", { serverId, error }); }, - schedule: (task, delayMs) => { - const timeout = setTimeout(task, delayMs); - return () => clearTimeout(timeout); - }, }); viewedTimelineSyncRef.current = sync; setViewedTimelineSync(serverId, sync); diff --git a/packages/app/src/hooks/use-agent-screen-state-machine.test.ts b/packages/app/src/hooks/use-agent-screen-state-machine.test.ts index 19caef4f8b..68102ed131 100644 --- a/packages/app/src/hooks/use-agent-screen-state-machine.test.ts +++ b/packages/app/src/hooks/use-agent-screen-state-machine.test.ts @@ -422,6 +422,23 @@ describe("deriveAgentScreenViewState", () => { expect(result.memory.lastReadyAgent).toBeNull(); }); + it.each(["error", "retrying"] as const)("shows archived history sync %s", (status) => { + const result = deriveAgentScreenViewState({ + input: { + ...createBaseInput(), + agent: createAgent("agent-1"), + isArchived: true, + hasHydratedHistoryBefore: true, + visibilityCatchUpStatus: status, + }, + memory: createBaseMemory(), + }); + expect(expectReadyState(result.state).sync).toEqual({ + status: "sync_error", + isRetrying: status === "retrying", + }); + }); + it("renders an archived agent before provider history is initialized", () => { const result = deriveAgentScreenViewState({ input: { diff --git a/packages/app/src/hooks/use-agent-screen-state-machine.ts b/packages/app/src/hooks/use-agent-screen-state-machine.ts index 591fd1abb1..e0cc75ee06 100644 --- a/packages/app/src/hooks/use-agent-screen-state-machine.ts +++ b/packages/app/src/hooks/use-agent-screen-state-machine.ts @@ -175,10 +175,14 @@ function resolveAgentScreenSync(args: { hadInitialSyncFailure: boolean; }): AgentScreenReadySyncState { const { input, hadInitialSyncFailure } = args; - if (input.isArchived) { + if ( + input.isArchived && + input.visibilityCatchUpStatus !== "error" && + input.visibilityCatchUpStatus !== "retrying" + ) { return { status: "idle" }; } - if (!input.isConnected) { + if (!input.isConnected && !input.isArchived) { return { status: "reconnecting" }; } if (input.missingAgentState.kind === "error") { diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index 1946586e84..8b478b7d27 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -174,7 +174,6 @@ it("feeds restored layout to timeline sync as a release signal, not a subscripti fetchLatestTail: async () => ({ hasNewer: false, endCursor: null }), onCatchUpEnded: () => undefined, reportError: () => undefined, - schedule: () => () => undefined, }); // The edge session-context.tsx owns. Pointing it at subscription instead would resume every // agent behind a restored tab on the daemon. diff --git a/packages/app/src/timeline/replica.test.ts b/packages/app/src/timeline/replica.test.ts index 0d825673e9..859ae8e6b7 100644 --- a/packages/app/src/timeline/replica.test.ts +++ b/packages/app/src/timeline/replica.test.ts @@ -57,7 +57,6 @@ function createOwner(storage: TimelineReplicaStorage): ViewedTimelineOwner { fetchPage: async () => ({ hasNewer: false, endCursor: null }), fetchLatestTail: async () => ({ hasNewer: false, endCursor: null }), reportError: () => undefined, - schedule: () => () => undefined, }, }); } @@ -617,7 +616,6 @@ describe("create handoff lifetime", () => { }, fetchLatestTail: async () => ({ hasNewer: false, endCursor: null }), reportError: () => undefined, - schedule: () => () => undefined, }, }); diff --git a/packages/app/src/timeline/viewed-timeline-sync.test.ts b/packages/app/src/timeline/viewed-timeline-sync.test.ts index a6e7cbeb33..e556da612f 100644 --- a/packages/app/src/timeline/viewed-timeline-sync.test.ts +++ b/packages/app/src/timeline/viewed-timeline-sync.test.ts @@ -90,7 +90,7 @@ test("visible catch-up settles before hidden open chats start fetching", async ( world.sync.dispose(); }); -test("a failed visible catch-up releases hidden chats without bypassing its retry delay", async () => { +test("a failed visible catch-up releases hidden chats without bypassing its error", async () => { const world = new TimelineWorld(); world.openedEarlier(["a-hidden"]); world.sync.replaceVisibleAgentIds("pane", ["z-visible"]); @@ -103,7 +103,7 @@ test("a failed visible catch-up releases hidden chats without bypassing its retr hidden.respond({ hasNewer: false }); await world.nextError(); world.expectNoPendingFetch(); - (await world.nextRetry())(); + world.sync.retryVisibleAgentTimeline("z-visible"); (await world.nextFetch("z-visible")).respond({ hasNewer: false }); world.sync.dispose(); }); @@ -200,20 +200,6 @@ class TimelineWorld { const waiter = this.errorWaiters.shift(); if (waiter) waiter(this.errors.at(-1) ?? ""); }, - schedule: (task, delayMs) => { - const scheduled = { task, delayMs }; - this.scheduled.push(scheduled); - const waiterIndex = this.retryWaiters.findIndex((waiter) => waiter.delayMs === delayMs); - if (waiterIndex >= 0) { - const [waiter] = this.retryWaiters.splice(waiterIndex, 1); - this.scheduled.splice(this.scheduled.indexOf(scheduled), 1); - waiter.resolve(task); - } - return () => { - const index = this.scheduled.indexOf(scheduled); - if (index >= 0) this.scheduled.splice(index, 1); - }; - }, }); show(sourceId: string, agentIds: string[]): void { @@ -235,12 +221,6 @@ class TimelineWorld { }> = []; private readonly errorWaiters: Array<(message: string) => void> = []; private readonly cacheRequestWaiters: Array<(agentId: string) => void> = []; - private readonly scheduled: Array<{ task: () => void; delayMs: number }> = []; - private readonly retryWaiters: Array<{ - delayMs: number; - resolve(retry: () => void): void; - }> = []; - get pendingFetchCount(): number { return this.fetches.length; } @@ -300,22 +280,6 @@ class TimelineWorld { return new Promise((resolve) => this.errorWaiters.push(resolve)); } - nextRetry(delayMs = 1_000): Promise<() => void> { - const index = this.scheduled.findIndex((entry) => entry.delayMs === delayMs); - if (index >= 0) return Promise.resolve(this.scheduled.splice(index, 1)[0].task); - return new Promise((resolve) => this.retryWaiters.push({ delayMs, resolve })); - } - - elapse(elapsedMs: number): void { - const due = this.scheduled.filter((entry) => entry.delayMs <= elapsedMs); - this.scheduled.splice( - 0, - this.scheduled.length, - ...this.scheduled.filter((entry) => entry.delayMs > elapsedMs), - ); - for (const entry of due) entry.task(); - } - private releaseMembershipWaiter(): void { const waiter = this.membershipWaiters.shift(); if (!waiter) return; @@ -483,6 +447,28 @@ test("reports a chat current only once its parked follow-up page completes", asy world.sync.dispose(); }); +test("a gap during manual retry is recovered before reporting ready", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.show("workspace", ["agent-a"]); + (await world.nextMembership()).succeed(); + (await world.nextFetch("agent-a")).fail("unavailable"); + await world.nextError(); + world.sync.retryVisibleAgentTimeline("agent-a"); + const retry = await world.nextFetch("agent-a"); + world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 9 }); + retry.respond({ hasNewer: false }); + await vi.waitFor(() => expect(world.pendingFetchCount).toBe(1)); + const recovery = await world.nextFetch("agent-a"); + expect(recovery.request).toMatchObject({ + direction: "after", + cursor: { epoch: "epoch-agent-a", seq: 9 }, + }); + expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("retrying"); + recovery.respond({ hasNewer: false }); + await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); +}); + test("reports a chat current once the latest-tail fallback replaces an overflowing catch-up", async () => { const world = new TimelineWorld(); world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 42 }); @@ -780,6 +766,21 @@ test("overlapping sources deduplicate membership and retain open hidden chats", world.expectNoPendingFetch(); }); +test("failed timeline fetch does not schedule a retry", async () => { + vi.useFakeTimers(); + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.show("workspace", ["agent-a"]); + (await world.nextMembership()).succeed(); + (await world.nextFetch("agent-a")).fail("unavailable"); + await world.nextError(); + expect(vi.getTimerCount()).toBe(0); + await vi.advanceTimersByTimeAsync(120_000); + world.expectNoPendingFetch(); + world.expectNoPendingMembership(); + vi.useRealTimers(); +}); + test("a failed catch-up reports once and retries through the explicit retry policy", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); @@ -788,12 +789,12 @@ test("a failed catch-up reports once and retries through the explicit retry poli membership.succeed(); const failed = await world.nextFetch("agent-a"); failed.fail("timeline unavailable"); - const [error, retryCatchUp] = await Promise.all([world.nextError(), world.nextRetry()]); + const error = await world.nextError(); expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error"); - retryCatchUp(); + world.sync.retryVisibleAgentTimeline("agent-a"); const retry = await world.nextFetch("agent-a"); - expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error"); + expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("retrying"); retry.respond({ hasNewer: false }); await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); @@ -804,35 +805,6 @@ test("a failed catch-up reports once and retries through the explicit retry poli world.expectNoPendingMembership(); }); -test("a failed catch-up retries with exponential backoff", async () => { - const world = new TimelineWorld(); - world.sync.setConnected(true); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); - const membership = await world.nextMembership(); - membership.succeed(); - - const first = await world.nextFetch("agent-a"); - first.fail("timeline unavailable"); - const [firstError, retryAfterFirstFailure] = await Promise.all([ - world.nextError(), - world.nextRetry(), - ]); - expect(firstError).toBe("timeline unavailable"); - retryAfterFirstFailure(); - - const second = await world.nextFetch("agent-a"); - second.fail("timeline unavailable"); - await world.nextError(); - - const retryAfterSecondFailure = await world.nextRetry(2_000); - expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error"); - - retryAfterSecondFailure(); - const third = await world.nextFetch("agent-a"); - third.respond({ hasNewer: false }); - await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); -}); - test("manual retries can immediately re-attempt a failed catch-up", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); @@ -842,7 +814,7 @@ test("manual retries can immediately re-attempt a failed catch-up", async () => const failed = await world.nextFetch("agent-a"); failed.fail("timeline unavailable"); - await world.nextRetry(); + await world.nextError(); expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error"); world.sync.retryVisibleAgentTimeline("agent-a"); @@ -853,7 +825,7 @@ test("manual retries can immediately re-attempt a failed catch-up", async () => await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); }); -test("redeclaring unchanged visibility does not bypass catch-up backoff", async () => { +test("redeclaring unchanged visibility does not bypass catch-up error", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); @@ -862,9 +834,11 @@ test("redeclaring unchanged visibility does not bypass catch-up backoff", async const failed = await world.nextFetch("agent-a"); failed.fail("timeline unavailable"); - await world.nextRetry(); + await world.nextError(); world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.setActive(false); + world.sync.setActive(true); world.expectNoPendingMembership(); world.expectNoPendingFetch(); @@ -880,7 +854,7 @@ test("a manual retry that fails returns to the error state", async () => { const failed = await world.nextFetch("agent-a"); failed.fail("timeline unavailable"); - await world.nextRetry(); + await world.nextError(); world.sync.retryVisibleAgentTimeline("agent-a"); expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("retrying"); @@ -943,20 +917,34 @@ test("repeated recovery for the same running gap reuses the in-flight fetch", as gapPage.respond({ hasNewer: false }); }); -test("membership failure autonomously retries without another visibility declaration", async () => { +test("failed membership does not schedule a retry", async () => { + vi.useFakeTimers(); + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.show("workspace", ["agent-a"]); + (await world.nextMembership()).fail("unavailable"); + await world.nextError(); + expect(vi.getTimerCount()).toBe(0); + await vi.advanceTimersByTimeAsync(120_000); + world.expectNoPendingFetch(); + world.expectNoPendingMembership(); + vi.useRealTimers(); +}); + +test("membership failure waits for manual retry", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); const failed = await world.nextMembership(); failed.fail("subscription unavailable"); - const [error, retryMembership] = await Promise.all([world.nextError(), world.nextRetry()]); + const error = await world.nextError(); expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error"); - retryMembership(); + world.sync.retryVisibleAgentTimeline("agent-a"); const retry = await world.nextMembership(); retry.succeed(); const catchUp = await world.nextFetch("agent-a"); - expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error"); + expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("retrying"); catchUp.respond({ hasNewer: false }); await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); @@ -967,44 +955,68 @@ test("membership failure autonomously retries without another visibility declara }); }); -test("membership failures retry with exponential backoff", async () => { +test("retrying membership restores a previously loaded chat", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); - - const first = await world.nextMembership(); - first.fail("subscription unavailable"); - const [firstError, retryAfterFirstFailure] = await Promise.all([ - world.nextError(), - world.nextRetry(), - ]); - expect(firstError).toBe("subscription unavailable"); - - retryAfterFirstFailure(); - const second = await world.nextMembership(); - second.fail("subscription unavailable again"); - - const retryAfterSecondFailure = await world.nextRetry(2_000); - expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error"); - - retryAfterSecondFailure(); - const retry = await world.nextMembership(); - retry.succeed(); - const catchUp = await world.nextFetch("agent-a"); - catchUp.respond({ hasNewer: false }); + world.show("workspace", ["agent-a"]); + (await world.nextMembership()).succeed(); + (await world.nextFetch("agent-a")).respond({ hasNewer: false }); + await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); + world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }); + world.show("workspace", ["agent-b"]); + (await world.nextMembership()).fail("subscription unavailable"); + await world.nextError(); + world.sync.retryVisibleAgentTimeline("agent-a"); + (await world.nextMembership()).succeed(); + (await world.nextFetch("agent-b")).respond({ hasNewer: false }); + const resumed = await world.nextFetch("agent-a"); + expect(resumed.request).toMatchObject({ + direction: "after", + cursor: { epoch: "epoch-agent-a", seq: 10 }, + }); + resumed.respond({ hasNewer: false }); await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); }); -test("redeclaring unchanged visibility does not bypass membership backoff", async () => { +test("a fetch that finishes after membership failure cannot clear the error", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.show("workspace", ["agent-a"]); + (await world.nextMembership()).succeed(); + const inFlight = await world.nextFetch("agent-a"); + world.show("workspace", ["agent-b"]); + (await world.nextMembership()).fail("subscription unavailable"); + await world.nextError(); + inFlight.respond({ hasNewer: false }); + await Promise.resolve(); + await Promise.resolve(); + expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error"); +}); + +test("a failed membership retry returns to error", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.show("workspace", ["agent-a"]); + (await world.nextMembership()).fail("unavailable"); + await world.nextError(); + world.sync.retryVisibleAgentTimeline("agent-a"); + expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("retrying"); + (await world.nextMembership()).fail("still unavailable"); + await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error")); +}); + +test("redeclaring unchanged visibility does not bypass membership error", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); const failed = await world.nextMembership(); failed.fail("subscription unavailable"); - await world.nextRetry(); + await world.nextError(); world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.setActive(false); + world.sync.setActive(true); world.expectNoPendingMembership(); world.expectNoPendingFetch(); @@ -1050,7 +1062,6 @@ test("backgrounding preserves subscriptions and returning verifies the visible c world.sync.setActive(false); world.expectNoPendingMembership(); - world.elapse(60_000); world.sync.setActive(true); world.expectNoPendingMembership(); @@ -1067,11 +1078,10 @@ test("stale membership retry cannot overwrite a newer effective set", async () = world.show("workspace", ["agent-a"]); const failed = await world.nextMembership(); failed.fail("subscription unavailable"); - const staleRetry = await world.nextRetry(); + await world.nextError(); world.show("workspace", ["agent-b"]); const current = await world.nextMembership(); - staleRetry(); current.succeed(); const agentB = await world.nextFetch("agent-b"); agentB.respond({ hasNewer: false }); @@ -1088,10 +1098,9 @@ test("membership retry cannot run while disconnected", async () => { world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); const failed = await world.nextMembership(); failed.fail("subscription unavailable"); - const disconnectedRetry = await world.nextRetry(); + await world.nextError(); world.sync.setConnected(false); - disconnectedRetry(); world.expectNoPendingMembership(); world.sync.setConnected(true); const restored = await world.nextMembership(); @@ -1118,7 +1127,6 @@ test("returning to an open hidden agent stays live after inactivity", async () = agentBCatchUp.respond({ hasNewer: false }); await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-b")).toBe("ready")); - world.elapse(60_000); world.show("workspace", ["agent-a"]); world.expectNoPendingMembership(); diff --git a/packages/app/src/timeline/viewed-timeline-sync.ts b/packages/app/src/timeline/viewed-timeline-sync.ts index 2e14a42093..67defcec04 100644 --- a/packages/app/src/timeline/viewed-timeline-sync.ts +++ b/packages/app/src/timeline/viewed-timeline-sync.ts @@ -329,7 +329,6 @@ export interface ViewedTimelineSyncPorts { */ onCatchUpEnded(agentId: string): void; reportError(error: unknown): void; - schedule(task: () => void, delayMs: number): () => void; } export type ViewedTimelineStatus = "ready" | "pending" | "error" | "retrying"; @@ -423,26 +422,14 @@ export function createViewedTimelineOwner(input: { }; } -const RETRY_DELAY_MS = 1_000; -const MAX_RETRY_DELAY_MS = 30_000; - type CatchUpStatus = "running" | "complete" | "error"; interface CatchUpState { generation: number; status: CatchUpStatus; request?: ProjectedTimelineForwardFetchPlan; - cancelRetry?: () => void; - retryDelayMs?: number; } -const getNextRetryDelayMs = (previousDelayMs: number | undefined): number => { - if (previousDelayMs == null) { - return RETRY_DELAY_MS; - } - return Math.min(previousDelayMs * 2, MAX_RETRY_DELAY_MS); -}; - function isSameCatchUpRequest( left: ProjectedTimelineForwardFetchPlan | undefined, right: ProjectedTimelineForwardFetchPlan | undefined, @@ -472,9 +459,7 @@ function decideCatchUp(input: { } return "replace"; } - return input.current.status === "running" || input.current.status === "complete" - ? "keep" - : "replace"; + return "keep"; } function normalizeAgentIds(agentIds: string[]): string[] { @@ -494,8 +479,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed const pendingCatchUps = new Map(); const visibilityCatchUpPending = new Set(); const visibilityCatchUpErrors = new Map(); - // User-initiated retries only. Background retries stay silent; a retry the user asked for - // owes them a pending state until it settles. + // A user-initiated retry owes a pending state until it settles. const manualRetries = new Set(); const loadedCache = new Set(); const cacheLoads = new Map>(); @@ -511,8 +495,6 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed let acknowledged: string[] = []; let membershipGeneration = 0; let membershipNeedsRetry = false; - let membershipRetryDelayMs: number | undefined; - let cancelMembershipRetry: (() => void) | null = null; // Chats this session has opened. Visibility is the only way in, so an agent is never // subscribed — and never resumed on the daemon — until the user actually opens it. // Membership then survives hiding, workspace switches, backgrounding and reconnect; @@ -563,7 +545,6 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed const cancelCatchUp = (agentId: string) => { catchUpGenerations.set(agentId, (catchUpGenerations.get(agentId) ?? 0) + 1); - catchUps.get(agentId)?.cancelRetry?.(); catchUps.delete(agentId); pendingCatchUps.delete(agentId); }; @@ -607,19 +588,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed setVisibilityCatchUpReady(agentId); } catch (error) { if (catchUps.get(agentId)?.generation === generation) { - const nextRetryDelayMs = getNextRetryDelayMs(catchUps.get(agentId)?.retryDelayMs); - const cancelRetry = ports.schedule(() => { - const current = catchUps.get(agentId); - if (current?.generation !== generation || current.status !== "error") return; - startCatchUp(agentId); - }, nextRetryDelayMs); - catchUps.set(agentId, { - generation, - status: "error", - request, - cancelRetry, - retryDelayMs: nextRetryDelayMs, - }); + catchUps.set(agentId, { generation, status: "error", request }); setVisibilityCatchUpError([agentId], error); ports.reportError(error); } @@ -686,17 +655,9 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed if (decision === "keep") { return; } - current?.cancelRetry?.(); const generation = (catchUpGenerations.get(agentId) ?? 0) + 1; catchUpGenerations.set(agentId, generation); - const retryDelayMs = - supersede || current?.status !== "error" ? undefined : current.retryDelayMs; - catchUps.set(agentId, { - generation, - status: "running", - request: nextRequest, - retryDelayMs, - }); + catchUps.set(agentId, { generation, status: "running", request: nextRequest }); pendingCatchUps.delete(agentId); void fetchUntilCurrent( agentId, @@ -715,7 +676,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed for (const agentId of ordered) { const pendingCatchUp = pendingCatchUps.get(agentId); const current = catchUps.get(agentId); - // Completion of a different chat must not bypass a failed chat's backoff or + // Completion of a different chat must not bypass a failed chat's error or // supersede an in-flight tail that owns its parked gap recovery. if (current && !(current.status === "complete" && pendingCatchUp)) continue; startCatchUp(agentId, { @@ -741,42 +702,31 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed } catch (error) { if (disposed || !connected || generation !== membershipGeneration) return; membershipNeedsRetry = true; + acknowledged = []; + for (const agentId of requested) cancelCatchUp(agentId); setVisibilityCatchUpError(requested, error); - cancelMembershipRetry?.(); - const nextRetryDelayMs = getNextRetryDelayMs(membershipRetryDelayMs); - cancelMembershipRetry = ports.schedule(() => { - cancelMembershipRetry = null; - if (disposed || !connected || membershipGeneration !== generation) return; - void reconcileMembership(); - }, nextRetryDelayMs); - membershipRetryDelayMs = nextRetryDelayMs; ports.reportError(error); return; } if (disposed || !connected || generation !== membershipGeneration) return; - cancelMembershipRetry?.(); - cancelMembershipRetry = null; - membershipRetryDelayMs = undefined; acknowledged = requested; startAcknowledgedCatchUps(); }; const retryVisibleAgentTimeline = (agentId: string) => { - if (!isDesired(agentId) || manualRetries.has(agentId)) return; + if (!connected || !isDesired(agentId) || manualRetries.has(agentId)) return; const catchUp = catchUps.get(agentId); const membershipRetryable = membershipNeedsRetry && connected; if (catchUp?.status !== "error" && !membershipRetryable) return; manualRetries.add(agentId); notifyListeners(); - if (catchUp?.status === "error") { - catchUp.cancelRetry?.(); - startCatchUp(agentId, { request: catchUp.request, supersede: true }); + if (membershipRetryable) { + void reconcileMembership(); return; } - cancelMembershipRetry?.(); - cancelMembershipRetry = null; - membershipRetryDelayMs = undefined; - void reconcileMembership(); + if (catchUp?.status === "error") { + startCatchUp(agentId, { request: catchUp.request, supersede: true }); + } }; const commitDesiredMembership = ( @@ -817,8 +767,6 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed ensureCacheLoaded(agentId); } } - cancelMembershipRetry?.(); - cancelMembershipRetry = null; desired = nextDesired; ports.replaceDemandedAgentIds(desired); membershipGeneration += 1; @@ -869,6 +817,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed return; } for (const agentId of visibleAgentIds()) { + if (visibilityCatchUpErrors.has(agentId)) continue; visibilityCatchUpPending.add(agentId); visibilityCatchUpErrors.delete(agentId); startCatchUp(agentId, { supersede: true }); @@ -880,9 +829,6 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed connected = nextConnected; if (!connected) { commitDesiredMembership(desired, { resetCatchUpStatus: true }); - cancelMembershipRetry?.(); - cancelMembershipRetry = null; - membershipRetryDelayMs = undefined; void observation?.handle.release().catch(ports.reportError); observation = null; acknowledged = []; @@ -894,7 +840,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed void reconcileMembership(); }, recoverGap(agentId, cursor) { - if (!isDesired(agentId)) return; + if (!isDesired(agentId) || catchUps.get(agentId)?.status === "error") return; startCatchUp(agentId, { request: planTimelineCatchUpAfter({ epoch: cursor.epoch, seq: cursor.endSeq }), supersede: true, @@ -904,10 +850,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed disposed = true; void observation?.handle.release().catch(ports.reportError); observation = null; - cancelMembershipRetry?.(); - cancelMembershipRetry = null; membershipNeedsRetry = false; - membershipRetryDelayMs = undefined; sources.clear(); membershipGeneration += 1; for (const agentId of desired) cancelCatchUp(agentId); From 787edb1bf942d981a019bd8e661a9700dc7edb3e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 30 Oct 2026 17:57:57 +0700 Subject: [PATCH 28/95] Respect proxy variables throughout the daemon (#8177) * fix(server): respect environment proxies across daemon traffic Install proxy routing at daemon and plugin process startup, with a separate HTTP agent for outbound WebSockets. Always exempt loopback so local provider bridges remain reachable. * test(server): run usage source regressions in CI * fix(server): validate proxy settings without blocking startup Normalize scheme-less proxies and share HTTP-only validation across fetch and WebSockets. Record ignored variables without credentials in the daemon log. Separate built-in and directory plugin regressions through a typed fixture. * refactor(server): treat ignored proxy variables as unset * fix(server): read Windows proxy environment entries once * fix(server): apply proxy policy to connected host links * test(server): isolate proxy fixture credential directories * fix(server): keep proxy routing and authentication consistent Normalize Windows environment names and wildcard bypass rules at the network boundary. Compare bypass ports numerically and keep Basic proxy credentials on the CONNECT pool, including empty credential fields. --- docs/architecture.md | 10 + package-lock.json | 24 ++ packages/client/src/node/index.ts | 20 +- packages/server/package.json | 4 +- .../src/server/connected-hosts/index.ts | 2 + packages/server/src/server/daemon-worker.ts | 6 + .../src/server/hub/relationship-remote.ts | 2 + packages/server/src/server/network/index.ts | 116 ++++++ .../server/src/server/network/network.test.ts | 341 ++++++++++++++++++ .../src/server/plugins/plugin-process.ts | 2 + .../test-fixtures/usage-proxy-daemon.ts | 31 ++ .../server/plugins/usage-sources.e2e.test.ts | 75 ++++ packages/server/src/server/relay-transport.ts | 3 +- 13 files changed, 628 insertions(+), 8 deletions(-) create mode 100644 packages/server/src/server/network/index.ts create mode 100644 packages/server/src/server/network/network.test.ts create mode 100644 packages/server/src/server/plugins/test-fixtures/usage-proxy-daemon.ts diff --git a/docs/architecture.md b/docs/architecture.md index 3c5678b0ab..a7ad69c14d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -493,6 +493,16 @@ The supervisor alone publishes its ready worker's endpoint in `paseo.pid`, clear Worker restart retains supervisor arguments/environment and rereads the configuration file. Updating a package and observing its new worker version do not refresh the running supervisor code; the launcher owns full-process replacement. See [CLI lifecycle contracts](../public-docs/cli.md#daemon-management). +## Outbound networking + +The daemon and its plugin processes honor `HTTPS_PROXY`, `HTTP_PROXY`, and `NO_PROXY` +(including lowercase forms, which take precedence). Fetch and relay, Hub, and connected-host WebSockets +share this policy. Loopback (`localhost`, `127.0.0.1`, `::1`) always goes direct. +A proxy without a scheme uses HTTP. Invalid values and unsupported proxy schemes +are treated as unset, with one warning naming the variable in `daemon.log`. +Agent and terminal environments are unchanged. System proxy settings and SOCKS-only +`ALL_PROXY` configurations are not supported. + ## Deployment models 1. **Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767` diff --git a/package-lock.json b/package-lock.json index 3351d15ac9..80355dbb21 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40690,6 +40690,7 @@ "esbuild": "^0.25.12", "express": "^4.18.2", "fast-deep-equal": "^3.1.3", + "https-proxy-agent": "^7.0.6", "lru-cache": "^11.5.1", "markdown-it": "^10.0.0", "mnemonic-id": "^3.2.7", @@ -40707,6 +40708,7 @@ "strip-ansi": "^7.1.2", "tree-kill": "^1.2.2", "typescript": "^5.9.3", + "undici": "^7.24.8", "uuid": "^9.0.1", "which": "^5.0.0", "ws": "^8.14.2", @@ -41280,6 +41282,15 @@ "url": "https://opencollective.com/vitest" } }, + "packages/server/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==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "packages/server/node_modules/ajv": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", @@ -41349,6 +41360,19 @@ "@esbuild/win32-x64": "0.25.12" } }, + "packages/server/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==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "packages/server/node_modules/isexe": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", diff --git a/packages/client/src/node/index.ts b/packages/client/src/node/index.ts index 7132d09f10..46b5cff17a 100644 --- a/packages/client/src/node/index.ts +++ b/packages/client/src/node/index.ts @@ -2,6 +2,7 @@ export { createHostChannelTransport } from "../hosts/index.js"; import { HostTransportBudget } from "./buffering.js"; export { HostTransportBudget } from "./buffering.js"; import { homedir } from "node:os"; +import type { Agent } from "node:http"; import { WebSocket } from "ws"; import { parseHostTarget, type HostTarget } from "@getpaseo/protocol/host-target"; import { createSshTunnel, type SshTunnel } from "./ssh-tunnel.js"; @@ -19,10 +20,15 @@ export { parseHostTarget }; export type { SshTunnel } from "./ssh-tunnel.js"; export { createSshTunnel, resolveSshFailureDetail } from "./ssh-tunnel.js"; +export interface HostTransportOptions { + webSocketAgent?: (url: string) => Agent | undefined; +} + /** A factory owns one attempt, including a fresh SSH process and its teardown. */ export function createHostTransportFactory( connection: string, budget = new HostTransportBudget(), + options: HostTransportOptions = {}, ): DaemonTransportFactory { const target = parseHostTarget(connection); // Rises to "protected" once the daemon negotiates it, so a later attempt @@ -68,12 +74,13 @@ export function createHostTransportFactory( return; } const socketPath = socketTarget.socketPath?.replace(/^~(?=\/)/, homedir()); - let factory = createWebSocketTransportFactory((url, options) => { + let factory = createWebSocketTransportFactory((url, socketOptions) => { const socket = new WebSocket( socketPath && url.startsWith("ws+unix:") ? `ws+unix://${socketPath}:/ws` : url, - options?.protocols, + socketOptions?.protocols, { - headers: options?.headers, + headers: socketOptions?.headers, + agent: socketPath ? undefined : options.webSocketAgent?.(url), ...(socketPath ? { socketPath } : {}), maxPayload: 100 * 1024 * 1024, }, @@ -191,14 +198,15 @@ export function createHostTransportFactory( } export function createHostClient( connection: string, - config: Omit, + config: Omit & HostTransportOptions, budget?: HostTransportBudget, ): DaemonClient { const target = parseHostTarget(connection); + const { webSocketAgent, ...clientConfig } = config; return new DaemonClient({ - ...config, + ...clientConfig, url: "ws://host-connector/ws", ...(target.password ? { password: target.password } : {}), - transportFactory: createHostTransportFactory(connection, budget), + transportFactory: createHostTransportFactory(connection, budget, { webSocketAgent }), }); } diff --git a/packages/server/package.json b/packages/server/package.json index 13e5c9b49c..a31ce8d02a 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/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/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", @@ -124,6 +124,7 @@ "esbuild": "^0.25.12", "express": "^4.18.2", "fast-deep-equal": "^3.1.3", + "https-proxy-agent": "^7.0.6", "lru-cache": "^11.5.1", "markdown-it": "^10.0.0", "mnemonic-id": "^3.2.7", @@ -141,6 +142,7 @@ "strip-ansi": "^7.1.2", "tree-kill": "^1.2.2", "typescript": "^5.9.3", + "undici": "^7.24.8", "uuid": "^9.0.1", "which": "^5.0.0", "ws": "^8.14.2", diff --git a/packages/server/src/server/connected-hosts/index.ts b/packages/server/src/server/connected-hosts/index.ts index 1a8f7c2731..96d6ac35d2 100644 --- a/packages/server/src/server/connected-hosts/index.ts +++ b/packages/server/src/server/connected-hosts/index.ts @@ -21,6 +21,7 @@ import { type HostUpdate, } from "@getpaseo/protocol/hosts"; import type { DaemonPermission, WSHelloMessage } from "@getpaseo/protocol/messages"; +import { webSocketProxyAgent } from "../network/index.js"; export class HostError extends Error { constructor( @@ -242,6 +243,7 @@ export class ConnectedHosts { { clientId: `host-link-${this.self.serverId}-${row.registrationId}`, connectTimeoutMs: 10000, + webSocketAgent: webSocketProxyAgent, }, this.budget, ); diff --git a/packages/server/src/server/daemon-worker.ts b/packages/server/src/server/daemon-worker.ts index 317903a592..8af60cd7bc 100644 --- a/packages/server/src/server/daemon-worker.ts +++ b/packages/server/src/server/daemon-worker.ts @@ -1,5 +1,6 @@ import { appendFileSync, mkdirSync } from "node:fs"; import path from "node:path"; +import { initializeEnvironmentProxy } from "./network/index.js"; import { createPaseoDaemon, formatListenTarget } from "./bootstrap.js"; import { loadConfig } from "./config.js"; import { resolvePaseoHome } from "./paseo-home.js"; @@ -132,6 +133,11 @@ function applyCliFlagOverrides(config: ReturnType): void { async function main() { const { paseoHome, logger, config } = bootstrapFromEnvironment(); + for (const variable of initializeEnvironmentProxy()) { + writeWorkerLifecycleLog(paseoHome, "Ignoring invalid or unsupported HTTP proxy variable", { + variable, + }); + } let daemon: Awaited> | null = null; let shutdownPromise: Promise | null = null; let exitHookInstalled = false; diff --git a/packages/server/src/server/hub/relationship-remote.ts b/packages/server/src/server/hub/relationship-remote.ts index 9c6f88a63d..6427b71fa6 100644 --- a/packages/server/src/server/hub/relationship-remote.ts +++ b/packages/server/src/server/hub/relationship-remote.ts @@ -1,5 +1,6 @@ import { WebSocket } from "ws"; import { z } from "zod"; +import { webSocketProxyAgent } from "../network/index.js"; import type { WebSocketLike } from "../websocket-server.js"; const SOCKET_PING_INTERVAL_MS = 10_000; @@ -166,6 +167,7 @@ export class DirectHubRelationshipRemote implements HubRelationshipRemote { openSocket(input: HubSocketCredentials, events: HubSocketEvents): HubSocketConnection { let sessionProtocol: "legacy" | "session-v1" = "legacy"; const socket = new WebSocket(input.webSocketUrl, { + agent: webSocketProxyAgent(input.webSocketUrl), handshakeTimeout: this.requestTimeoutMs, headers: { authorization: `Bearer ${input.credential}`, diff --git a/packages/server/src/server/network/index.ts b/packages/server/src/server/network/index.ts new file mode 100644 index 0000000000..46565d801a --- /dev/null +++ b/packages/server/src/server/network/index.ts @@ -0,0 +1,116 @@ +import type { Agent } from "node:http"; +import { EnvHttpProxyAgent, Pool, setGlobalDispatcher } from "undici"; +import { HttpsProxyAgent } from "https-proxy-agent"; + +interface ProxyEnvironment { + httpProxy: string; + httpsProxy: string; + noProxy: string; +} + +let environment = readEnvironment(process.env, process.platform).environment; + +function readEnvironment( + env: NodeJS.ProcessEnv, + platform: NodeJS.Platform, +): { + environment: ProxyEnvironment; + ignored: string[]; +} { + // process.env lookups are case-insensitive on Windows. Read each actual entry once. + const values = new Map( + Object.entries(env).map(([name, value]) => [ + platform === "win32" ? name.toLowerCase() : name, + value, + ]), + ); + const ignored: string[] = []; + function proxy(variable: string): string | undefined { + const value = values.get(variable); + if (value === undefined) return undefined; + if (!value.trim()) return undefined; + try { + const url = new URL(value.includes("://") ? value : `http://${value}`); + if (url.protocol !== "http:" && url.protocol !== "https:") + throw new Error("Unsupported proxy protocol"); + decodeURIComponent(url.username); + decodeURIComponent(url.password); + return url.href; + } catch { + ignored.push(variable); + return undefined; + } + } + const httpProxy = proxy("http_proxy") ?? proxy("HTTP_PROXY") ?? ""; + return { + environment: { + httpProxy, + httpsProxy: proxy("https_proxy") ?? proxy("HTTPS_PROXY") ?? httpProxy, + noProxy: values.get("no_proxy") ?? values.get("NO_PROXY") ?? "", + }, + ignored, + }; +} + +function bypassList(noProxy: string): string { + return noProxy.split(/[,\s]+/).includes("*") ? "*" : `${noProxy},localhost,127.0.0.1,[::1],::1`; +} + +function proxyClient(origin: URL, options: object) { + const pool = new Pool(origin, options); + if (!origin.username && !origin.password) return pool; + const credentials = `${decodeURIComponent(origin.username)}:${decodeURIComponent(origin.password)}`; + const authorization = `Basic ${Buffer.from(credentials).toString("base64")}`; + // This pool only sends CONNECT requests to this proxy, never requests to the origin. + // undici's URL auth requires both fields; HTTP Basic also permits either field to be empty. + return pool.compose( + (dispatch) => (request, handler) => + dispatch( + { + ...request, + headers: { ...request.headers, "proxy-authorization": authorization }, + }, + handler, + ), + ); +} + +/** Install once at startup; return ignored variable names for logging without exposing credentials. */ +export function initializeEnvironmentProxy( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, +): string[] { + const resolved = readEnvironment(env, platform); + environment = resolved.environment; + setGlobalDispatcher( + new EnvHttpProxyAgent({ + httpProxy: environment.httpProxy, + httpsProxy: environment.httpsProxy, + noProxy: bypassList(environment.noProxy), + clientFactory: proxyClient, + }), + ); + return resolved.ignored; +} + +/** ws uses Node HTTP agents rather than the fetch dispatcher. */ +export function webSocketProxyAgent(address: string): Agent | undefined { + const url = new URL(address); + const hostname = url.hostname.toLowerCase(); + const port = url.port || (url.protocol === "wss:" ? "443" : "80"); + const entries = bypassList(environment.noProxy) + .toLowerCase() + .split(/[,\s]+/); + if ( + entries.some((entry) => { + if (entry === "*") return true; + const match = entry.match(/^(.+):(\d+)$/); + if (match && Number(match[2]) !== Number(port)) return false; + const host = (match ? match[1]! : entry).replace(/^\*?\./, ""); + return host !== "" && (hostname === host || hostname.endsWith(`.${host}`)); + }) + ) + return undefined; + const proxy = url.protocol === "wss:" ? environment.httpsProxy : environment.httpProxy; + return proxy ? new HttpsProxyAgent(proxy) : undefined; +} diff --git a/packages/server/src/server/network/network.test.ts b/packages/server/src/server/network/network.test.ts new file mode 100644 index 0000000000..88e7d61aa6 --- /dev/null +++ b/packages/server/src/server/network/network.test.ts @@ -0,0 +1,341 @@ +import { fork } from "node:child_process"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createServer, type Server } from "node:http"; +import { once } from "node:events"; +import { connect } from "node:net"; +import { afterEach, expect, test } from "vitest"; +import { WebSocket, WebSocketServer } from "ws"; +import { getGlobalDispatcher, setGlobalDispatcher } from "undici"; +import { initializeEnvironmentProxy, webSocketProxyAgent } from "./index.js"; +import { ConnectedHosts } from "../connected-hosts/index.js"; +const original = getGlobalDispatcher(); +const servers: Server[] = []; +afterEach(async () => { + const installed = getGlobalDispatcher(); + setGlobalDispatcher(original); + if (installed !== original) await installed.close(); + await Promise.all( + servers.splice(0).map( + (server) => + new Promise((resolve) => { + server.closeAllConnections(); + server.close(() => resolve()); + }), + ), + ); +}); +async function listen(server: Server) { + servers.push(server); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing address"); + return `127.0.0.1:${address.port}`; +} +async function proxy() { + const tunnels: string[] = []; + const server = createServer(); + server.on("connect", (request, socket) => { + tunnels.push(request.url!); + socket.end("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n"); + }); + return { url: `http://${await listen(server)}`, tunnels }; +} +test("loopback fetch and WebSockets bypass HTTP_PROXY with empty NO_PROXY", async () => { + const recorder = await proxy(); + initializeEnvironmentProxy({ HTTP_PROXY: recorder.url, NO_PROXY: "" }); + const server = createServer((_, response) => response.end("direct")); + const sockets = new WebSocketServer({ server }); + sockets.on("connection", (socket) => socket.send("direct")); + const address = await listen(server); + expect(await (await fetch(`http://${address}`)).text()).toBe("direct"); + const socket = new WebSocket(`ws://${address}`, { + agent: webSocketProxyAgent(`ws://${address}`), + }); + expect((await once(socket, "message"))[0].toString()).toBe("direct"); + socket.close(); + await once(socket, "close"); + await new Promise((resolve) => sockets.close(() => resolve())); + for (const host of ["localhost", "127.0.0.1", "[::1]"]) + expect(webSocketProxyAgent(`wss://${host}:1234`)).toBeUndefined(); + expect(recorder.tunnels).toEqual([]); +}); +test("fetch and WebSockets send non-loopback traffic through CONNECT", async () => { + const recorder = await proxy(); + initializeEnvironmentProxy({ http_proxy: recorder.url }); + await expect( + fetch("https://vendor.invalid/", { signal: AbortSignal.timeout(500) }), + ).rejects.toThrow(); + const socket = new WebSocket("wss://vendor.invalid", { + agent: webSocketProxyAgent("wss://vendor.invalid"), + }); + await once(socket, "error"); + expect( + recorder.tunnels.filter((host) => host === "vendor.invalid:443").length, + ).toBeGreaterThanOrEqual(2); +}); +test("NO_PROXY and absent variables keep matching WebSockets direct", async () => { + const recorder = await proxy(); + initializeEnvironmentProxy({ + HTTPS_PROXY: recorder.url, + NO_PROXY: "vendor.invalid,.example.invalid:443", + }); + expect(webSocketProxyAgent("wss://vendor.invalid")).toBeUndefined(); + await expect( + fetch("https://vendor.invalid/", { signal: AbortSignal.timeout(500) }), + ).rejects.toThrow(); + expect(webSocketProxyAgent("wss://sub.example.invalid")).toBeUndefined(); + expect(webSocketProxyAgent("wss://example.invalid:444")).toBeDefined(); + initializeEnvironmentProxy({ HTTP_PROXY: recorder.url, no_proxy: "*" }); + expect(webSocketProxyAgent("ws://vendor.invalid")).toBeUndefined(); + initializeEnvironmentProxy({}); + expect(webSocketProxyAgent("wss://vendor.invalid")).toBeUndefined(); + const address = await listen(createServer((_, response) => response.end("direct"))); + expect(await (await fetch(`http://${address}`)).text()).toBe("direct"); + expect(recorder.tunnels).toEqual([]); +}); + +test.each(["127.0.0.1", "localhost"])( + "scheme-less %s proxies work for fetch and WebSockets", + async (host) => { + const recorder = await proxy(); + const address = new URL(recorder.url); + initializeEnvironmentProxy({ https_proxy: `${host}:${address.port}` }); + await expect( + fetch("https://vendor.invalid", { signal: AbortSignal.timeout(500) }), + ).rejects.toThrow(); + const socket = new WebSocket("wss://vendor.invalid", { + agent: webSocketProxyAgent("wss://vendor.invalid"), + }); + await once(socket, "error"); + expect(recorder.tunnels).toContain("vendor.invalid:443"); + }, +); + +test.each([ + "http://[", + "http://user%zz:secret@proxy.corp", + "socks5://127.0.0.1:7890", + "ftp://proxy.corp:8080", +])("ignores invalid or unsupported proxy %s without stopping startup", async (value) => { + const recorder = await proxy(); + expect(initializeEnvironmentProxy({ HTTP_PROXY: recorder.url, https_proxy: value })).toEqual([ + "https_proxy", + ]); + expect(webSocketProxyAgent("wss://vendor.invalid")).toBeDefined(); + await expect( + fetch("https://vendor.invalid", { signal: AbortSignal.timeout(500) }), + ).rejects.toThrow(); + const socket = new WebSocket("wss://vendor.invalid", { + agent: webSocketProxyAgent("wss://vendor.invalid"), + }); + await once(socket, "error"); + expect(recorder.tunnels).toEqual(["vendor.invalid:443", "vendor.invalid:443"]); +}); + +test("daemon starts with an ignored proxy and writes one redacted warning to daemon.log", async () => { + const home = await mkdtemp(path.join(os.tmpdir(), "daemon-invalid-proxy-")); + const env = Object.fromEntries( + Object.entries(process.env).filter( + ([key]) => !["http_proxy", "https_proxy"].includes(key.toLowerCase()), + ), + ); + const worker = fork(fileURLToPath(new URL("../daemon-worker.ts", import.meta.url)), [], { + execArgv: ["--import", "tsx"], + silent: true, + env: { + ...env, + PASEO_HOME: home, + HOME: home, + USERPROFILE: home, + PASEO_LISTEN: "127.0.0.1:0", + PASEO_RELAY_ENABLED: "0", + https_proxy: "socks5://private:secret@127.0.0.1:7890", + }, + }); + worker.stdout?.resume(); + let stderr = ""; + worker.stderr?.on("data", (chunk) => { + stderr += String(chunk); + }); + try { + await new Promise((resolve, reject) => { + worker.on("message", (message) => { + if ( + typeof message === "object" && + message !== null && + "type" in message && + message.type === "paseo:ready" + ) + resolve(); + }); + worker.on("exit", (code) => reject(new Error(`Worker exited ${code}: ${stderr}`))); + }); + const log = await readFile(path.join(home, "daemon.log"), "utf8"); + const warnings = log + .split("\n") + .filter((line) => line.includes("Ignoring invalid or unsupported HTTP proxy variable")); + expect(warnings).toHaveLength(1); + expect(JSON.parse(warnings[0]!).variable).toBe("https_proxy"); + expect(warnings[0]).not.toContain("secret"); + } finally { + if (worker.exitCode === null && worker.signalCode === null) { + const exited = once(worker, "exit"); + worker.kill("SIGTERM"); + await exited; + } + await rm(home, { recursive: true, force: true }); + } +}, 60_000); + +test("accepts a scheme-less corporate proxy hostname", () => { + expect(initializeEnvironmentProxy({ https_proxy: "proxy.corp:8080" })).toEqual([]); + expect(webSocketProxyAgent("wss://vendor.invalid")).toBeDefined(); +}); + +test("an ignored lowercase variable falls back to the uppercase variable", async () => { + const recorder = await proxy(); + expect( + initializeEnvironmentProxy( + { + https_proxy: "socks5://proxy.invalid", + HTTPS_PROXY: recorder.url, + }, + "linux", + ), + ).toEqual(["https_proxy"]); + await expect( + fetch("https://vendor.invalid", { signal: AbortSignal.timeout(500) }), + ).rejects.toThrow(); + const socket = new WebSocket("wss://vendor.invalid", { + agent: webSocketProxyAgent("wss://vendor.invalid"), + }); + await once(socket, "error"); + expect(recorder.tunnels).toEqual(["vendor.invalid:443", "vendor.invalid:443"]); +}); + +test("case-insensitive environment access reports a variable only once", () => { + const env = new Proxy( + { https_proxy: "socks5://127.0.0.1:7890" }, + { + get(target, key) { + return Reflect.get(target, typeof key === "string" ? key.toLowerCase() : key); + }, + }, + ); + expect(initializeEnvironmentProxy(env)).toEqual(["https_proxy"]); +}); + +test("connected-host links send remote WebSockets through the environment proxy", async () => { + const recorder = await proxy(); + initializeEnvironmentProxy({ HTTPS_PROXY: recorder.url }); + const home = await mkdtemp(path.join(os.tmpdir(), "connected-host-proxy-")); + const hosts = new ConnectedHosts(home, { serverId: "self", label: "Self" }); + try { + await hosts.add( + { connection: "wss://remote.invalid/ws" }, + { sourceId: "test", manageSources: true }, + ); + await expect.poll(() => recorder.tunnels).toContain("remote.invalid:443"); + } finally { + await hosts.close(); + await rm(home, { recursive: true, force: true }); + } +}); + +test.each(["corp.internal,*", "vendor.invalid:0443"])( + "fetch and WebSockets share NO_PROXY=%s", + async (noProxy) => { + const recorder = await proxy(); + initializeEnvironmentProxy({ HTTPS_PROXY: recorder.url, NO_PROXY: noProxy }); + expect(webSocketProxyAgent("wss://vendor.invalid")).toBeUndefined(); + await expect( + fetch("https://vendor.invalid", { signal: AbortSignal.timeout(500) }), + ).rejects.toThrow(); + expect(recorder.tunnels).toEqual([]); + }, +); + +test("Windows mixed-case environment names route and bypass both transports", async () => { + const recorder = await proxy(); + // Windows preserves spelling but treats environment names without regard to case. + initializeEnvironmentProxy({ Https_Proxy: recorder.url, No_Proxy: "other.invalid" }, "win32"); + expect(webSocketProxyAgent("wss://other.invalid")).toBeUndefined(); + await expect( + fetch("https://other.invalid", { signal: AbortSignal.timeout(500) }), + ).rejects.toThrow(); + expect(webSocketProxyAgent("wss://vendor.invalid")).toBeDefined(); + await expect( + fetch("https://vendor.invalid", { signal: AbortSignal.timeout(500) }), + ).rejects.toThrow(); + const socket = new WebSocket("wss://vendor.invalid", { + agent: webSocketProxyAgent("wss://vendor.invalid"), + }); + await once(socket, "error"); + expect(recorder.tunnels).toEqual(["vendor.invalid:443", "vendor.invalid:443"]); +}); + +test("POSIX mixed-case names do not override standard proxy names", async () => { + const recorder = await proxy(); + initializeEnvironmentProxy( + { Https_Proxy: recorder.url, HTTPS_PROXY: recorder.url, No_Proxy: "*" }, + "linux", + ); + expect(webSocketProxyAgent("wss://vendor.invalid")).toBeDefined(); + await expect( + fetch("https://vendor.invalid", { signal: AbortSignal.timeout(500) }), + ).rejects.toThrow(); + expect(recorder.tunnels).toEqual(["vendor.invalid:443"]); +}); + +test("proxy credentials stay on CONNECT and do not reach the HTTP origin", async () => { + let originAuthorization: string | undefined; + const origin = createServer((request, response) => { + originAuthorization = request.headers["proxy-authorization"]; + response.end("through proxy"); + }); + const originAddress = new URL(`http://${await listen(origin)}`); + const authorizations: (string | undefined)[] = []; + const server = createServer(); + server.on("connect", (request, socket, head) => { + authorizations.push(request.headers["proxy-authorization"]); + const upstream = connect(Number(originAddress.port), "127.0.0.1", () => { + socket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + if (head.length) upstream.write(head); + socket.pipe(upstream).pipe(socket); + }); + socket.on("error", () => upstream.destroy()); + upstream.on("error", () => socket.destroy()); + socket.on("close", () => upstream.destroy()); + }); + initializeEnvironmentProxy({ HTTP_PROXY: `http://user:@${await listen(server)}` }); + const response = await fetch("http://vendor.invalid", { signal: AbortSignal.timeout(3000) }); + expect(await response.text()).toBe("through proxy"); + expect(authorizations).toEqual([`Basic ${Buffer.from("user:").toString("base64")}`]); + expect(originAuthorization).toBeUndefined(); +}); + +test.each(["user:@", ":secret@", "user:secret@"])( + "fetch and WebSockets authenticate proxy credentials %s", + async (credentials) => { + const authorizations: (string | undefined)[] = []; + const server = createServer(); + server.on("connect", (request, socket) => { + authorizations.push(request.headers["proxy-authorization"]); + socket.end("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n"); + }); + initializeEnvironmentProxy({ HTTPS_PROXY: `http://${credentials}${await listen(server)}` }); + await expect( + fetch("https://vendor.invalid", { signal: AbortSignal.timeout(500) }), + ).rejects.toThrow(); + const socket = new WebSocket("wss://vendor.invalid", { + agent: webSocketProxyAgent("wss://vendor.invalid"), + }); + await once(socket, "error"); + const expected = `Basic ${Buffer.from(credentials.slice(0, -1)).toString("base64")}`; + expect(authorizations).toEqual([expected, expected]); + }, +); diff --git a/packages/server/src/server/plugins/plugin-process.ts b/packages/server/src/server/plugins/plugin-process.ts index a72e268e4f..62514cd0b2 100644 --- a/packages/server/src/server/plugins/plugin-process.ts +++ b/packages/server/src/server/plugins/plugin-process.ts @@ -24,6 +24,7 @@ import { createPluginClientId } from "./plugin-session-identity.js"; import { PluginSettingsStore } from "./settings/index.js"; import { readPluginProviderIcon } from "./provider-icon.js"; +import { initializeEnvironmentProxy } from "../network/index.js"; function describeError(error: unknown): string { return error instanceof Error ? error.message : String(error); } @@ -606,6 +607,7 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) { const parsed = PluginProcessRequestSchema.safeParse(raw); if (!worker && parsed.success && parsed.data.type === "initialize") { try { + initializeEnvironmentProxy(); worker = createPluginWorker({ channel, contribute: evaluateBundle(parsed.data.bundle) }); } catch (error) { channel.send({ type: "fatal", error: describeError(error) }); diff --git a/packages/server/src/server/plugins/test-fixtures/usage-proxy-daemon.ts b/packages/server/src/server/plugins/test-fixtures/usage-proxy-daemon.ts new file mode 100644 index 0000000000..23364d3fda --- /dev/null +++ b/packages/server/src/server/plugins/test-fixtures/usage-proxy-daemon.ts @@ -0,0 +1,31 @@ +import path from "node:path"; +import { initializeEnvironmentProxy } from "../../network/index.js"; +import { DaemonClient } from "../../test-utils/daemon-client.js"; +import { createTestPaseoDaemon } from "../../test-utils/paseo-daemon.js"; +import { BuiltinPluginLoader, resolveBuiltinPluginsRoot } from "../builtin/index.js"; + +const mode = process.argv[2]; +if (mode !== "builtin" && mode !== "directory") throw new Error("Expected builtin or directory"); +const sources = ["claude-usage-source", "codex-usage-source"]; +// Built-ins run inside the daemon; directory plugins initialize their own subprocess network. +if (mode === "builtin") initializeEnvironmentProxy(); +const daemon = await createTestPaseoDaemon({ + pluginsEnabled: mode === "directory", + builtinPlugins: new BuiltinPluginLoader( + resolveBuiltinPluginsRoot(), + mode === "builtin" ? sources : [], + ), +}); +const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); +try { + await client.connect(); + if (mode === "directory") { + for (const source of sources) { + await client.installDirectoryPlugin(path.join(resolveBuiltinPluginsRoot(), source), source); + } + } + await client.listUsageReports(); +} finally { + await client.close(); + await daemon.close(); +} diff --git a/packages/server/src/server/plugins/usage-sources.e2e.test.ts b/packages/server/src/server/plugins/usage-sources.e2e.test.ts index 037da5e0c6..46dc134ff6 100644 --- a/packages/server/src/server/plugins/usage-sources.e2e.test.ts +++ b/packages/server/src/server/plugins/usage-sources.e2e.test.ts @@ -1,7 +1,11 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; import { cp, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; +import { createServer } from "node:http"; +import { once } from "node:events"; import { expect, test } from "vitest"; import { DaemonClient } from "../test-utils/daemon-client.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; @@ -335,3 +339,74 @@ export default function contribute(server) { await rm(root, { recursive: true, force: true }); } }, 60_000); + +test.each(["builtin", "directory"])( + "%s usage plugins reach the environment proxy", + async (mode) => { + const root = await mkdtemp(path.join(os.tmpdir(), "usage-proxy-")); + const tunnels: string[] = []; + const proxy = createServer(); + proxy.on("connect", (request, socket) => { + tunnels.push(request.url!); + socket.end("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n"); + }); + proxy.listen(0, "127.0.0.1"); + await once(proxy, "listening"); + const address = proxy.address(); + if (!address || typeof address === "string") throw new Error("Missing proxy address"); + const guard = path.join(root, "network-guard.cjs"); + // Prevent vendor DNS/network access even when proxy support is broken. + await writeFile( + guard, + `const dns = require('node:dns'); +const lookup = dns.lookup; +dns.lookup = function(host, ...args) { + if (host === 'localhost' || host === '127.0.0.1' || host === '::1') return lookup.call(this, host, ...args); + args.at(-1)(Object.assign(new Error('External DNS forbidden in proxy test'), { code: 'ENOTFOUND' })); +};`, + ); + await mkdir(path.join(root, ".claude")); + await mkdir(path.join(root, ".codex")); + await writeFile( + path.join(root, ".claude", ".credentials.json"), + JSON.stringify({ claudeAiOauth: { accessToken: "fixture-claude" } }), + ); + await writeFile( + path.join(root, ".codex", "auth.json"), + JSON.stringify({ tokens: { access_token: "fixture-codex", account_id: "fixture-account" } }), + ); + try { + await promisify(execFile)( + process.execPath, + [ + "--import", + "tsx", + fileURLToPath(new URL("./test-fixtures/usage-proxy-daemon.ts", import.meta.url)), + mode, + ], + { + timeout: 45_000, + maxBuffer: 1024 * 1024, + env: { + ...process.env, + HOME: root, + USERPROFILE: root, + CODEX_HOME: path.join(root, ".codex"), + CLAUDE_CONFIG_DIR: path.join(root, ".claude"), + NODE_OPTIONS: `--require=${JSON.stringify(guard.replaceAll("\\", "/"))}`, + https_proxy: `http://127.0.0.1:${address.port}`, + no_proxy: "", + NO_PROXY: "", + }, + }, + ); + expect(tunnels).toContain("api.anthropic.com:443"); + expect(tunnels).toContain("chatgpt.com:443"); + } finally { + proxy.closeAllConnections(); + await new Promise((resolve) => proxy.close(() => resolve())); + await rm(root, { recursive: true, force: true }); + } + }, + 60_000, +); diff --git a/packages/server/src/server/relay-transport.ts b/packages/server/src/server/relay-transport.ts index 08fcd826d5..a0898df247 100644 --- a/packages/server/src/server/relay-transport.ts +++ b/packages/server/src/server/relay-transport.ts @@ -2,6 +2,7 @@ import { WebSocket } from "ws"; import type pino from "pino"; import { buildRelayWebSocketUrl } from "@getpaseo/protocol/daemon-endpoints"; +import { webSocketProxyAgent } from "./network/index.js"; import type { ExternalSocketMetadata } from "./websocket-server.js"; export interface RelayTransportOptions { @@ -56,7 +57,7 @@ const RELAY_WEBSOCKET_OPTIONS = { } as const; function createDefaultRelayWebSocket(url: string): RelayWebSocketLike { - return new WebSocket(url, RELAY_WEBSOCKET_OPTIONS); + return new WebSocket(url, { ...RELAY_WEBSOCKET_OPTIONS, agent: webSocketProxyAgent(url) }); } function isRecord(value: unknown): value is Record { From fbe24fcdb67147d6003a27732a60bd49e71ec4e5 Mon Sep 17 00:00:00 2001 From: "paseo-ai[bot]" <266920839+paseo-ai[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 11:06:08 +0000 Subject: [PATCH 29/95] 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 a6061301b1..35593d9f96 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-6C4Rjh0mRVrC9akBVGK0NZaCaLJhwKObT7HrgPmcvyw= +sha256-8NOrrCAxraQ74lP3Vy59MRhloBfzStTc4rW+ltc42Fg= From 184ab66cdea7680018f06c490c3f056cc194f8a2 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 30 Oct 2026 21:26:38 +0700 Subject: [PATCH 30/95] Show sub-agents as launch markers in the parent timeline (#8276) * fix: make sub-agent cards launch markers Sub-agent tool calls no longer accumulate the child's activity into `log`. Claude, Codex, OpenCode, OMP and Pi emit the card when the child starts and when its label or status changes; the child's messages and tool calls stay in the sub-agent's own timeline. The app no longer renders a sub-agent log. The wire schema makes `log` optional. Daemons still send `log: ""` so apps that require the field keep parsing the card. * fix(server): put a tintinweb foreground answer in the child timeline A foreground @tintinweb/pi-subagents child has no session file, so its answer only exists in the Agent tool result. Emit that text as the child's assistant message when the child finishes. Tag every sub_agent producer's `log: ""` with COMPAT(subagent-log) so the shim removal finds them. * fix(server): settle child compactions and keep sub-agent cards stable - Codex: a child compaction shown as loading now completes on the child's thread/compacted or turn end, once. - Claude: a streamed child tool call's empty start input is a placeholder, so the assistant message's input reaches its card. - OMP: the task card follows only its first child, so later children no longer re-send an unchanged card. * fix(server): keep a gotgenes foreground answer on replay A foreground @gotgenes/pi-subagents child finds its session file only through the live runtime marker, which history does not keep. Without a file, emit the spawn result text as the child's assistant message. --- .../app/src/components/tool-call-details.tsx | 222 +---------- packages/app/src/i18n/resources.test.ts | 1 - packages/app/src/i18n/resources/ar.ts | 1 - packages/app/src/i18n/resources/en.ts | 1 - packages/app/src/i18n/resources/es.ts | 1 - packages/app/src/i18n/resources/fr.ts | 1 - packages/app/src/i18n/resources/ja.ts | 1 - packages/app/src/i18n/resources/ko.ts | 1 - packages/app/src/i18n/resources/pt-BR.ts | 1 - packages/app/src/i18n/resources/ru.ts | 1 - packages/app/src/i18n/resources/zh-CN.ts | 1 - .../app/src/utils/tool-call-detail-state.ts | 12 +- packages/protocol/src/agent-types.ts | 2 +- packages/protocol/src/messages.ts | 4 +- .../protocol/src/messages.wire-compat.test.ts | 24 +- .../src/server/agent/activity-curator.test.ts | 8 +- .../src/server/agent/activity-curator.ts | 3 - .../src/server/agent/agent-sdk-types.ts | 5 +- .../claude/agent.background-subagent.test.ts | 6 +- .../claude/agent.sub-agent-sidechain.test.ts | 48 ++- .../server/agent/providers/claude/agent.ts | 3 +- .../claude/sidechain-tracker.test.ts | 78 +++- .../providers/claude/sidechain-tracker.ts | 161 ++------ .../codex-app-server-agent.real.e2e.test.ts | 18 +- .../providers/codex-app-server-agent.test.ts | 174 ++++++-- .../agent/providers/codex-app-server-agent.ts | 138 +++---- .../agent/providers/codex/tool-call-mapper.ts | 2 + .../src/server/agent/providers/omp/agent.ts | 15 +- .../providers/omp/history-mapper.test.ts | 2 +- .../omp/subagent-card-tracker.test.ts | 224 +++-------- .../providers/omp/subagent-card-tracker.ts | 373 +++--------------- .../providers/omp/tool-call-mapper.test.ts | 4 +- .../agent/providers/omp/tool-call-mapper.ts | 4 +- .../server/agent/providers/opencode-agent.ts | 9 - .../opencode/tool-call-detail-parser.ts | 23 +- .../opencode/tool-call-mapper.test.ts | 10 +- .../providers/opencode/tool-call-mapper.ts | 2 +- .../extensions/edxeth-pi-subagents/index.ts | 4 +- .../gotgenes-pi-subagents/index.test.ts | 12 + .../extensions/gotgenes-pi-subagents/index.ts | 21 +- .../providers/pi/extensions/index.test.ts | 2 +- .../pi/extensions/pi-subagents/index.test.ts | 2 +- .../pi/extensions/pi-subagents/index.ts | 3 +- .../pi/extensions/subagent-fixture-test.ts | 2 - .../tintinweb-pi-subagents/index.test.ts | 21 +- .../tintinweb-pi-subagents/index.ts | 21 +- ...round-subagent-action-log.real.e2e.test.ts | 24 +- 47 files changed, 578 insertions(+), 1118 deletions(-) diff --git a/packages/app/src/components/tool-call-details.tsx b/packages/app/src/components/tool-call-details.tsx index a10bd7b0c6..5e08f944df 100644 --- a/packages/app/src/components/tool-call-details.tsx +++ b/packages/app/src/components/tool-call-details.tsx @@ -64,7 +64,7 @@ function resolveShouldFill( ): boolean { if (!fillAvailableHeight) return false; const t = detail?.type; - return t === "shell" || t === "edit" || t === "write" || t === "read" || t === "sub_agent"; + return t === "shell" || t === "edit" || t === "write" || t === "read"; } function useDetailStyles( @@ -186,184 +186,6 @@ function ShellDetailSection({ command, output, ds }: ShellDetailProps) { ); } -function resolveSubAgentFallbackHeader( - subAgentType: string | null | undefined, - description: string | null | undefined, - fallbackText: string, -): string { - if (subAgentType && description) { - return `${subAgentType}: ${description}`; - } - return subAgentType ?? description ?? fallbackText; -} - -interface SubAgentDetailProps { - log: string; - childSessionId: string | null | undefined; - subAgentType: string | null | undefined; - description: string | null | undefined; - ds: DetailStyles; -} - -interface SubAgentActivityRow { - index: number; - toolName: string; - summary?: string; -} - -interface ParsedSubAgentLog { - actions: SubAgentActivityRow[]; - remainingLog: string; -} - -function parseBracketedSubAgentLine(line: string, index: number): SubAgentActivityRow | null { - const match = line.match(/^\[([^\]]+)\](?:\s+(.*))?$/); - if (!match) { - return null; - } - const toolName = match[1]?.trim(); - if (!toolName) { - return null; - } - const summary = match[2]?.trim(); - return { - index, - toolName, - ...(summary ? { summary } : {}), - }; -} - -function parseSubAgentLog(log: string): ParsedSubAgentLog { - const actions: SubAgentActivityRow[] = []; - const remainingLines: string[] = []; - for (const line of log.replace(/^\n+/, "").split("\n")) { - const normalizedLine = line.trim(); - if (!normalizedLine) { - continue; - } - const parsedAction = parseBracketedSubAgentLine(normalizedLine, actions.length + 1); - if (parsedAction) { - actions.push(parsedAction); - } else { - remainingLines.push(line); - } - } - return { - actions, - remainingLog: remainingLines.join("\n").replace(/^\n+/, ""), - }; -} - -function SubAgentActionRow({ action }: { action: SubAgentActivityRow }) { - return ( - - - {formatSubAgentToolName(action.toolName)} - - {action.summary ? ( - - {action.summary} - - ) : null} - - ); -} - -function formatSubAgentToolName(toolName: string): string { - const trimmed = toolName.trim(); - if (!trimmed) { - return toolName; - } - return trimmed - .replace(/[._-]+/g, " ") - .split(" ") - .filter((segment) => segment.length > 0) - .map((segment) => `${segment[0]?.toUpperCase() ?? ""}${segment.slice(1)}`) - .join(" "); -} - -function SubAgentLogText({ - activityLog, - fallbackHeader, - hasActions, -}: { - activityLog: string; - fallbackHeader: string; - hasActions: boolean; -}) { - if (activityLog.length > 0) { - return ( - - {activityLog} - - ); - } - if (!hasActions) { - return ( - - {fallbackHeader} - - ); - } - return null; -} - -function SubAgentDetailSection({ - log, - childSessionId, - subAgentType, - description, - ds, -}: SubAgentDetailProps) { - const { t } = useTranslation(); - const { actions, remainingLog } = useMemo(() => parseSubAgentLog(log), [log]); - const fallbackHeader = resolveSubAgentFallbackHeader( - subAgentType, - description, - t("toolCallDetails.subAgentActivity"), - ); - const hasActions = actions.length > 0; - return ( - - - - - - {childSessionId ? ( - - session {childSessionId} - - ) : null} - {hasActions ? ( - - {actions.map((action) => ( - - ))} - - ) : null} - - - - - - - ); -} - interface EditDetailProps { diffLines: DiffLine[] | undefined; ds: DetailStyles; @@ -636,19 +458,7 @@ function buildDetailSections( , ]; } - if (detail.type === "worktree_setup") return []; - if (detail.type === "sub_agent") { - return [ - , - ]; - } + if (detail.type === "worktree_setup" || detail.type === "sub_agent") return []; if (detail.type === "edit") { return []; } @@ -900,34 +710,6 @@ const styles = StyleSheet.create((theme) => { shellPrompt: { color: theme.colors.foregroundMuted, }, - subAgentSessionText: { - fontFamily: theme.fontFamily.mono, - fontSize: theme.fontSize.code, - color: theme.colors.foregroundMuted, - lineHeight: 18, - marginBottom: theme.spacing[2], - }, - subAgentActions: { - gap: theme.spacing[1], - marginBottom: theme.spacing[2], - }, - subAgentActionRow: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - }, - subAgentActionTool: { - fontFamily: theme.fontFamily.mono, - fontSize: theme.fontSize.code, - color: theme.colors.foregroundMuted, - lineHeight: 18, - }, - subAgentActionSummary: { - fontFamily: theme.fontFamily.mono, - fontSize: theme.fontSize.code, - color: theme.colors.foreground, - lineHeight: 18, - }, jsonScroll: { borderWidth: theme.borderWidth[1], borderColor: theme.colors.border, diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts index 18ba3f041e..b73b2c4fec 100644 --- a/packages/app/src/i18n/resources.test.ts +++ b/packages/app/src/i18n/resources.test.ts @@ -832,7 +832,6 @@ describe("translation resources", () => { expect(en.common.placeholders.search).toBe("Search..."); expect(en.common.empty.noResults).toBe("No results found"); expect(en.common.empty.noOptionsMatchSearch).toBe("No options match your search."); - expect(en.toolCallDetails.subAgentActivity).toBe("Sub-agent activity"); expect(en.panels.file.failedToLoadPreview).toBe("Failed to load file preview"); }); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 99d5a11701..7c9e13cce1 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -2013,7 +2013,6 @@ export const ar: TranslationResources = { toolCallDetails: { error: "خطأ", empty: "لا توجد تفاصيل إضافية متاحة", - subAgentActivity: "نشاط الوكيل الفرعي", input: "مدخل", output: "الإخراج", }, diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 636fe42d5a..7022dc9a3d 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -2038,7 +2038,6 @@ export const en = { toolCallDetails: { error: "Error", empty: "No additional details available", - subAgentActivity: "Sub-agent activity", input: "Input", output: "Output", }, diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index abd5320fe9..1c2c07ade0 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -2061,7 +2061,6 @@ export const es: TranslationResources = { toolCallDetails: { error: "Error", empty: "No hay detalles adicionales disponibles", - subAgentActivity: "Actividad de subagente", input: "Aporte", output: "Producción", }, diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 848666b2bb..bf1c3b6f5e 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -2060,7 +2060,6 @@ export const fr: TranslationResources = { toolCallDetails: { error: "Erreur", empty: "Aucun détail supplémentaire disponible", - subAgentActivity: "Activité du sous-agent", input: "Entrée", output: "Sortie", }, diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index eb6423913e..5147134716 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -2033,7 +2033,6 @@ export const ja: TranslationResources = { toolCallDetails: { error: "エラー", empty: "追加の詳細はありません", - subAgentActivity: "サブエージェントアクティビティ", input: "入力", output: "出力", }, diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 54605df4a6..632bd1eeed 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -2023,7 +2023,6 @@ export const ko: TranslationResources = { toolCallDetails: { error: "오류", empty: "추가 세부 정보가 없습니다", - subAgentActivity: "서브에이전트 활동", input: "입력", output: "출력", }, diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index ecedcb5c9e..a80ded2f45 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -2046,7 +2046,6 @@ export const ptBR: TranslationResources = { toolCallDetails: { error: "Erro", empty: "Nenhum detalhe adicional disponível", - subAgentActivity: "Atividade do subagente", input: "Entrada", output: "Saída", }, diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index d9c7afdedd..d4a23a4770 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -2046,7 +2046,6 @@ export const ru: TranslationResources = { toolCallDetails: { error: "Ошибка", empty: "Дополнительные сведения отсутствуют", - subAgentActivity: "Активность субагента", input: "Входные данные", output: "Выходные данные", }, diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index e0b5bbcdb4..2ce5f7bb9e 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1991,7 +1991,6 @@ export const zhCN: TranslationResources = { toolCallDetails: { error: "错误", empty: "没有可用的更多详情", - subAgentActivity: "Sub-agent 活动", input: "输入", output: "输出", }, diff --git a/packages/app/src/utils/tool-call-detail-state.ts b/packages/app/src/utils/tool-call-detail-state.ts index e2d983d8cb..b28848af8e 100644 --- a/packages/app/src/utils/tool-call-detail-state.ts +++ b/packages/app/src/utils/tool-call-detail-state.ts @@ -40,12 +40,6 @@ function hasMeaningfulFetchDetail(detail: Extract, -): boolean { - return Boolean(detail.subAgentType || detail.description || detail.log); -} - function hasMeaningfulPlainTextDetail( detail: Extract, ): boolean { @@ -75,9 +69,9 @@ export function hasMeaningfulToolCallDetail(detail: ToolCallDetail | undefined): case "fetch": return hasMeaningfulFetchDetail(detail); case "worktree_setup": - return false; + // A sub-agent card is a launch marker; the child's activity lives in the sub-agent panel. case "sub_agent": - return hasMeaningfulSubAgentDetail(detail); + return false; case "plain_text": return hasMeaningfulPlainTextDetail(detail); case "plan": @@ -92,7 +86,7 @@ export function isPendingToolCallDetail(params: { status: "executing" | "running" | "completed" | "failed" | "canceled"; error: unknown; }): boolean { - if (params.detail?.type === "worktree_setup") return false; + if (params.detail?.type === "worktree_setup" || params.detail?.type === "sub_agent") return false; const isRunning = params.status === "running" || params.status === "executing"; return isRunning && params.error == null && !hasMeaningfulToolCallDetail(params.detail); } diff --git a/packages/protocol/src/agent-types.ts b/packages/protocol/src/agent-types.ts index c7fffe8ce9..ca04655430 100644 --- a/packages/protocol/src/agent-types.ts +++ b/packages/protocol/src/agent-types.ts @@ -288,7 +288,7 @@ export type ToolCallDetail = subAgentType?: string; description?: string; childSessionId?: string; - log: string; + log?: string; actions?: Array<{ index: number; toolName: string; diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 18c3da9b67..8d8f852988 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -641,7 +641,9 @@ const ToolCallDetailPayloadSchema: z.ZodType = z.discri subAgentType: z.string().optional(), description: z.string().optional(), childSessionId: z.string().optional(), - log: z.string(), + // COMPAT(subagent-log): added in v0.14.1, remove after 2027-04-30. A sub-agent card is a + // launch marker; apps before v0.14.1 required `log`, so daemons still send it empty. + log: z.string().optional(), // Compat cruft for clients <= 0.1.65-beta.3 that required this field. Producers still // emit `[]`; nothing reads it. Drop the field (and the `[]` emissions) once those // clients are no longer in the field. diff --git a/packages/protocol/src/messages.wire-compat.test.ts b/packages/protocol/src/messages.wire-compat.test.ts index d6b2bc02ff..abc89b06aa 100644 --- a/packages/protocol/src/messages.wire-compat.test.ts +++ b/packages/protocol/src/messages.wire-compat.test.ts @@ -200,8 +200,8 @@ describe("wire schema compatibility", () => { }); }); - test("sub_agent tool-call payload still parses against the v0.1.65-beta.3 schema", () => { - const parsed = LegacySubAgentToolCallSchema.parse({ + test("a sub-agent launch marker still parses against the v0.1.65-beta.3 schema", () => { + const card = { type: "tool_call", callId: "call-sub-agent-1", name: "Task", @@ -212,12 +212,26 @@ describe("wire schema compatibility", () => { subAgentType: "Explore", description: "Inspect repository structure", childSessionId: "child-session-1", - log: "[Read] README.md", + log: "", actions: [], }, - }); + } as const; - expect(parsed.detail.actions).toEqual([]); + const parsed = LegacySubAgentToolCallSchema.parse(JSON.parse(JSON.stringify(card))); + expect(parsed.detail).toMatchObject({ log: "", actions: [] }); + const { log: _log, ...withoutLog } = card.detail; + expect(LegacySubAgentToolCallSchema.safeParse({ ...card, detail: withoutLog }).success).toBe( + false, + ); + expect(AgentTimelineItemPayloadSchema.parse({ ...card, detail: withoutLog })).toMatchObject({ + detail: { type: "sub_agent", description: "Inspect repository structure" }, + }); + expect( + AgentTimelineItemPayloadSchema.parse({ + ...card, + detail: { ...card.detail, log: "[Read] README.md" }, + }), + ).toMatchObject({ detail: { log: "[Read] README.md" } }); }); test("old clients parse agent snapshots with rewind capabilities", () => { diff --git a/packages/server/src/server/agent/activity-curator.test.ts b/packages/server/src/server/agent/activity-curator.test.ts index c98bbae54d..4e9ce49528 100644 --- a/packages/server/src/server/agent/activity-curator.test.ts +++ b/packages/server/src/server/agent/activity-curator.test.ts @@ -247,7 +247,7 @@ second line'`, ]); }); - it("keeps nested sub-agent logs beside the sub-agent that produced them", () => { + it("lists each sub-agent as its launch marker", () => { const timeline: AgentTimelineItem[] = [ { type: "assistant_message", text: "Before first child." }, toolCallItem({ @@ -257,7 +257,7 @@ second line'`, type: "sub_agent", subAgentType: "Child one", description: "First investigation", - log: "[Assistant] First child result.", + log: "", }, }), { type: "assistant_message", text: "Between children." }, @@ -268,7 +268,7 @@ second line'`, type: "sub_agent", subAgentType: "Child two", description: "Second investigation", - log: "[Assistant] Second child result.", + log: "", }, }), { type: "assistant_message", text: "After second child." }, @@ -279,10 +279,8 @@ second line'`, expect(result.split("\n")).toEqual([ "[Assistant] Before first child.", "[Child one] First investigation", - "[Assistant] First child result.", "[Assistant] Between children.", "[Child two] Second investigation", - "[Assistant] Second child result.", "[Assistant] After second child.", ]); }); diff --git a/packages/server/src/server/agent/activity-curator.ts b/packages/server/src/server/agent/activity-curator.ts index 488867c657..2a7cbff5e6 100644 --- a/packages/server/src/server/agent/activity-curator.ts +++ b/packages/server/src/server/agent/activity-curator.ts @@ -164,9 +164,6 @@ function curateProjectedActivityEntries( break; } entries.push(formatToolCallEntry(item, options)); - if (item.detail.type === "sub_agent" && item.detail.log.trim()) { - entries.push(activityEntry(item.detail.log.trim())); - } break; } case "todo": diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 507cfbceb8..6570392311 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -313,7 +313,10 @@ export type ToolCallDetail = subAgentType?: string; description?: string; childSessionId?: string; - log: string; + // COMPAT(subagent-log): added in v0.14.1, remove after 2027-04-30. Apps before v0.14.1 + // require `log`; every producer sends it empty and carries the same tag. Optional here because + // protocol timeline items, where `log` is optional, flow into this type. + log?: string; actions?: Array<{ index: number; toolName: string; diff --git a/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts b/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts index 8530e76844..8aa15581ae 100644 --- a/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts @@ -466,13 +466,13 @@ describe("background Claude subagents", () => { type: "sub_agent", subAgentType: "general-purpose", description: "Count files here", - log: "[Bash] ls -1", + log: "", }, }); }); test.each(["before", "after"])( - "keeps one logged Task card when child tools start %s the parent finishes", + "keeps one Task card when child tools start %s the parent finishes", async (timing) => { // A child can stream across the main session's turn boundary. Claude opens the next // main-session turn with system/init; bare child frames do not open a turn. @@ -605,7 +605,7 @@ describe("background Claude subagents", () => { type: "sub_agent", subAgentType: "general-purpose", description: "Count files here", - log: "[Bash] ls -1", + log: "", }, }); }, diff --git a/packages/server/src/server/agent/providers/claude/agent.sub-agent-sidechain.test.ts b/packages/server/src/server/agent/providers/claude/agent.sub-agent-sidechain.test.ts index 84293afa6a..f219baa1b1 100644 --- a/packages/server/src/server/agent/providers/claude/agent.sub-agent-sidechain.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.sub-agent-sidechain.test.ts @@ -273,7 +273,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => { queryFactory.mockReset(); }); - test("accumulates lightweight sub_agent detail and preserves callId lifecycle collapse", async () => { + test("labels the Task card from its input and preserves callId lifecycle collapse", async () => { const session = await new ClaudeAgentClient({ logger, queryFactory, @@ -307,9 +307,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => { expect(latest.detail.subAgentType).toBe("Explore"); expect(latest.detail.description).toBe("Inspect repository structure"); - expect(latest.detail.log).toContain("[Read] README.md"); - expect(latest.detail.log).toContain("[Edit] src/index.ts"); - expect(latest.detail.log).not.toContain("VERY_LARGE_OLD_STRING"); + expect(latest.detail.log).toBe(""); const rows: AgentTimelineRow[] = timelineToolCalls.map((item, index) => ({ seq: index + 1, @@ -396,10 +394,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => { .map((event) => event.item) .at(-1); - expect(latestSubAgentUpdate?.detail).toMatchObject({ - type: "sub_agent", - log: expect.stringContaining("[Read] README.md"), - }); + expect(latestSubAgentUpdate?.detail).toMatchObject({ type: "sub_agent", log: "" }); }); test("routes a resumed SendMessage sidechain to the original child without a second Task card", async () => { @@ -553,7 +548,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => { ).toMatchObject({ type: "upsert", id: "task-tail-1", status: "failed" }); }); - test("tails sub-agent actions instead of dropping latest entries at cap", async () => { + test("keeps the Task card the same size while the child runs", async () => { queryFactory.mockImplementation(() => buildQueryMock(buildTailScenarioEvents(205))); const session = await new ClaudeAgentClient({ @@ -568,24 +563,27 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => { const events = await collectUntilTerminal(streamSession(session, "delegate work")); await session.close(); - const timelineToolCalls = events - .filter( - (event): event is Extract => - event.type === "timeline" && event.item.type === "tool_call", + const runningCards = events + .flatMap((event) => + event.type === "timeline" && + event.item.type === "tool_call" && + event.item.callId === "task-tail-1" && + event.item.status === "running" && + event.item.detail.type === "sub_agent" + ? [event.item] + : [], ) - .map((event) => event.item) - .filter((item) => item.callId === "task-tail-1"); - const subAgentUpdates = timelineToolCalls.filter((item) => item.detail.type === "sub_agent"); - const latest = subAgentUpdates[subAgentUpdates.length - 1]; - expect(latest).toBeDefined(); - if (!latest || latest.detail.type !== "sub_agent") { - throw new Error("expected sub_agent detail"); - } + .map((item) => JSON.stringify(item)); + expect(runningCards).toHaveLength(1); - expect(latest.detail.log).not.toContain("[Read] file-1.md"); - expect(latest.detail.log).not.toContain("[Read] file-5.md"); - expect(latest.detail.log).toContain("[Read] file-6.md"); - expect(latest.detail.log).toContain("[Read] file-205.md"); + const childToolCalls = events.filter( + (event) => + event.type === "provider_subagent" && + event.event.type === "timeline" && + event.event.id === "task-tail-1" && + event.event.item.type === "tool_call", + ); + expect(childToolCalls).toHaveLength(205); }); test("keeps a long main-thread tool call out of the subagents track", async () => { diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 824a93f3da..c675b1ed62 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -4667,7 +4667,7 @@ class ClaudeAgentSession implements AgentSession { /** * The Task card in the parent transcript, built from the declaration. * - * The sidechain tracker also emits this card, enriched with the child's action log, and the two + * The sidechain tracker also emits this card when it first sees the child, and the two * collapse on the shared tool-call id. Emitting it at declaration matters because a * backgrounded subagent produces no sidechain frames at all — verified on the wire — so the * tracker never runs for one and its card would otherwise stay an unlabeled "Task". @@ -6276,6 +6276,7 @@ function buildClaudeSubagentCardDetail( type: "sub_agent", ...(facts.title ? { subAgentType: facts.title } : {}), ...(facts.description ? { description: facts.description } : {}), + // COMPAT(subagent-log): remove after 2027-04-30. log: "", actions: [], }; diff --git a/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts b/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts index a17c1cd58c..427f279bea 100644 --- a/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts +++ b/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts @@ -4,7 +4,7 @@ import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; import { ClaudeSidechainTracker } from "./sidechain-tracker.js"; describe("ClaudeSidechainTracker", () => { - it("retains a background child's actions across the parent finishing", () => { + it("keeps a background child's card across the parent finishing", () => { const tracker = new ClaudeSidechainTracker({ getToolInput: () => ({ subagent_type: "general-purpose", description: "List files" }), isDescriptorOwnedElsewhere: () => true, @@ -15,27 +15,67 @@ describe("ClaudeSidechainTracker", () => { parent_tool_use_id: "task-1", message: { content: [{ type: "tool_use", id, name: "Bash", input: { command } }] }, }) as unknown as SDKMessage; - tracker.handleMessage(action("ls", "ls -1"), "task-1"); + const parentCards = (events: ReturnType) => + events.filter((event) => event.type === "timeline"); + const childCallIds = (events: ReturnType) => + events.flatMap((event) => + event.type === "provider_subagent" && + event.event.type === "timeline" && + event.event.item.type === "tool_call" + ? [event.event.item.callId] + : [], + ); + + expect(parentCards(tracker.handleMessage(action("ls", "ls -1"), "task-1"))).toHaveLength(1); expect(tracker.finishAll("completed")).toEqual([]); - const events = tracker.handleMessage(action("pwd", "pwd"), "task-1"); - expect(events).toContainEqual( - expect.objectContaining({ - type: "timeline", - item: expect.objectContaining({ - detail: expect.objectContaining({ log: "[Bash] ls -1\n[Bash] pwd" }), - }), - }), - ); + const afterFinish = tracker.handleMessage(action("pwd", "pwd"), "task-1"); + expect(parentCards(afterFinish)).toEqual([]); + expect(childCallIds(afterFinish)).toEqual(["pwd"]); + tracker.delete("task-1"); const next = tracker.handleMessage(action("next", "ls"), "task-1"); - expect(next).toContainEqual( - expect.objectContaining({ - type: "timeline", - item: expect.objectContaining({ - detail: expect.objectContaining({ log: "[Bash] ls" }), - }), - }), - ); + expect(parentCards(next)).toHaveLength(1); + expect(childCallIds(next)).toEqual(["next"]); + }); + + it("shows a streamed child tool call's input once the assistant message brings it", () => { + const tracker = new ClaudeSidechainTracker({ getToolInput: () => null }); + const frames = [ + { + type: "stream_event", + parent_tool_use_id: "task-1", + event: { + type: "content_block_start", + index: 0, + content_block: { type: "tool_use", id: "bash-1", name: "Bash", input: {} }, + }, + }, + { + type: "assistant", + parent_tool_use_id: "task-1", + message: { + content: [ + { type: "tool_use", id: "bash-1", name: "Bash", input: { command: "echo child" } }, + ], + }, + }, + ] as unknown as SDKMessage[]; + + const childCards = frames + .flatMap((frame) => tracker.handleMessage(frame, "task-1")) + .flatMap((event) => + event.type === "provider_subagent" && + event.event.type === "timeline" && + event.event.item.type === "tool_call" + ? [event.event.item] + : [], + ); + + expect(childCards.at(-1)).toMatchObject({ + callId: "bash-1", + status: "running", + detail: { type: "shell", command: "echo child" }, + }); }); it("uses Claude's native agent name for the provider subagent title", () => { diff --git a/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts b/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts index 083b82cea6..5ca79641f9 100644 --- a/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts +++ b/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts @@ -5,7 +5,6 @@ import { mapClaudeFailedToolCall, mapClaudeRunningToolCall, } from "./tool-call-mapper.js"; -import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display"; import { isClaudeSubagentHandbackToolName, readClaudeSubagentHandback, @@ -20,21 +19,17 @@ interface ClaudeContentChunk { type ClaudeToolCallItem = Extract; -interface SubAgentActionEntry { - index: number; +interface SubAgentChildCall { toolName: string; input: unknown; - summary?: string; } interface SubAgentActivityState { name?: string; subAgentType?: string; description?: string; - actions: SubAgentActionEntry[]; - actionKeys: string[]; - nextActionIndex: number; - actionIndexByKey: Map; + /** The child's tool calls awaiting results, which carry no input of their own. */ + runningCalls: Map; completedActionKeys: Set; } @@ -44,7 +39,6 @@ interface SubAgentActionCandidate { input: unknown; } -const MAX_SUB_AGENT_LOG_ENTRIES = 200; const MAX_SUB_AGENT_SUMMARY_CHARS = 160; function readTrimmedString(value: unknown): string | undefined { @@ -79,8 +73,7 @@ export class ClaudeSidechainTracker { needsSyntheticParentToolCard?: (toolUseId: string) => boolean; /** * The parent's tool call once Claude has answered it. A background child streams its frames - * after that answer, so its card keeps the settled status and output and gains the child's - * action log. + * after that answer, so its card keeps the settled status and output. */ getSettledParentToolCall?: (toolUseId: string) => ClaudeToolCallItem | undefined; }) { @@ -91,28 +84,27 @@ export class ClaudeSidechainTracker { } handleMessage(message: SDKMessage, parentToolUseId: string): AgentStreamEvent[] { + const existingState = this.activeSidechains.get(parentToolUseId); const state = - this.activeSidechains.get(parentToolUseId) ?? + existingState ?? ({ - actions: [], - actionKeys: [], - nextActionIndex: 1, - actionIndexByKey: new Map(), + runningCalls: new Map(), completedActionKeys: new Set(), } satisfies SubAgentActivityState); this.activeSidechains.set(parentToolUseId, state); - const contextUpdated = this.updateSubAgentContextFromTaskInput(state, parentToolUseId); + // The parent card is a launch marker: it changes only when the child is first seen or the + // task input names it differently, never with the child's activity. + const cardUpdated = + this.updateSubAgentContextFromTaskInput(state, parentToolUseId) || !existingState; const actionCandidates = this.extractSubAgentActionCandidates(message); const childTimelineItems = [ ...this.extractSubAgentTimelineItems(message, state), ...this.extractSubAgentToolResults(message, state), ]; - let actionUpdated = false; for (const action of actionCandidates) { if (state.completedActionKeys.has(action.key)) continue; - if (this.appendSubAgentAction(state, action)) { - actionUpdated = true; + if (this.trackChildCall(state, action)) { const toolCall = mapClaudeRunningToolCall({ name: action.toolName, callId: action.key, @@ -125,7 +117,7 @@ export class ClaudeSidechainTracker { } } - if (!contextUpdated && !actionUpdated && childTimelineItems.length === 0) { + if (!cardUpdated && childTimelineItems.length === 0) { return []; } @@ -145,11 +137,8 @@ export class ClaudeSidechainTracker { type: "sub_agent", ...(state.subAgentType ? { subAgentType: state.subAgentType } : {}), ...(state.description ? { description: state.description } : {}), - log: state.actions - .map((action) => - action.summary ? `[${action.toolName}] ${action.summary}` : `[${action.toolName}]`, - ) - .join("\n"), + // COMPAT(subagent-log): remove after 2027-04-30. + log: "", actions: [], }; @@ -170,18 +159,19 @@ export class ClaudeSidechainTracker { }, ]; - const parentCard: AgentStreamEvent[] = this.needsSyntheticParentToolCard(parentToolUseId) - ? [ - { - type: "timeline", - item: { - ...toolCall, - detail, + const parentCard: AgentStreamEvent[] = + cardUpdated && this.needsSyntheticParentToolCard(parentToolUseId) + ? [ + { + type: "timeline", + item: { + ...toolCall, + detail, + }, + provider: "claude", }, - provider: "claude", - }, - ] - : []; + ] + : []; return [ ...descriptorEvents, @@ -303,8 +293,7 @@ export class ClaudeSidechainTracker { if (!isClaudeContentChunk(block) || !block.type.endsWith("tool_result")) continue; const callId = readTrimmedString(block.tool_use_id); if (!callId || state.completedActionKeys.has(callId)) continue; - const actionIndex = state.actionIndexByKey.get(callId); - const action = actionIndex === undefined ? undefined : state.actions[actionIndex]; + const action = state.runningCalls.get(callId); const toolName = action?.toolName ?? readTrimmedString(block.tool_name); if (!toolName) continue; const params = { @@ -317,6 +306,7 @@ export class ClaudeSidechainTracker { ? mapClaudeFailedToolCall({ ...params, error: block }) : mapClaudeCompletedToolCall(params); if (toolCall) { + state.runningCalls.delete(callId); state.completedActionKeys.add(callId); items.push(toolCall); } @@ -412,13 +402,12 @@ export class ClaudeSidechainTracker { const key = readTrimmedString(block.id) ?? `stream:${block.name}:${typeof event.index === "number" ? event.index : 0}`; - return [ - { - key, - toolName: block.name, - input: block.input ?? null, - }, - ]; + // A streamed call starts with `{}` until its input arrives, so an empty input is not final. + const input = + block.input && typeof block.input === "object" && Object.keys(block.input).length > 0 + ? block.input + : null; + return [{ key, toolName: block.name, input }]; } private extractSubAgentActionCandidates(message: SDKMessage): SubAgentActionCandidate[] { @@ -448,84 +437,20 @@ export class ClaudeSidechainTracker { return []; } - private appendSubAgentAction( + /** Returns whether the call is new or has gained its input, so its running card changes. */ + private trackChildCall( state: SubAgentActivityState, candidate: SubAgentActionCandidate, ): boolean { - const normalizedToolName = readTrimmedString(candidate.toolName); - if (!normalizedToolName) { + const toolName = readTrimmedString(candidate.toolName); + if (!toolName) { return false; } - - const summary = this.deriveSubAgentActionSummary(normalizedToolName, candidate.input); - const existingIndex = state.actionIndexByKey.get(candidate.key); - - if (existingIndex !== undefined) { - const existing = state.actions[existingIndex]; - if (!existing) { - return false; - } - const nextSummary = existing.summary ?? summary; - if (existing.toolName === normalizedToolName && existing.summary === nextSummary) { - return false; - } - state.actions[existingIndex] = { - ...existing, - toolName: normalizedToolName, - input: existing.input ?? candidate.input, - ...(nextSummary ? { summary: nextSummary } : {}), - }; - return true; + const existing = state.runningCalls.get(candidate.key); + if (existing && (existing.input !== null || candidate.input === null)) { + return false; } - - state.actions.push({ - index: state.nextActionIndex, - toolName: normalizedToolName, - input: candidate.input, - ...(summary ? { summary } : {}), - }); - state.nextActionIndex += 1; - state.actionKeys.push(candidate.key); - this.trimSubAgentTail(state); - this.rebuildSubAgentActionIndex(state); + state.runningCalls.set(candidate.key, { toolName, input: candidate.input }); return true; } - - private trimSubAgentTail(state: SubAgentActivityState): void { - while (state.actions.length > MAX_SUB_AGENT_LOG_ENTRIES) { - state.actions.shift(); - const removedKey = state.actionKeys.shift(); - if (removedKey) state.completedActionKeys.delete(removedKey); - } - } - - private rebuildSubAgentActionIndex(state: SubAgentActivityState): void { - state.actionIndexByKey.clear(); - for (let index = 0; index < state.actionKeys.length; index += 1) { - const key = state.actionKeys[index]; - if (key) { - state.actionIndexByKey.set(key, index); - } - } - } - - private deriveSubAgentActionSummary(toolName: string, input: unknown): string | undefined { - const runningToolCall = mapClaudeRunningToolCall({ - name: toolName, - callId: `sub-agent-summary-${toolName}`, - input, - output: null, - }); - if (!runningToolCall) { - return undefined; - } - const display = buildToolCallDisplayModel({ - name: runningToolCall.name, - status: runningToolCall.status, - error: runningToolCall.error, - detail: runningToolCall.detail, - metadata: runningToolCall.metadata, - }); - return this.normalizeSubAgentText(display.summary); - } } diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.real.e2e.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.real.e2e.test.ts index 5a6592862c..f564b7bf94 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.real.e2e.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.real.e2e.test.ts @@ -77,12 +77,19 @@ CHILD_SENTINEL in your own response. const completedChildIndex = events.findIndex( (event) => - event.type === "timeline" && - event.item.type === "tool_call" && - event.item.status === "completed" && - event.item.detail.type === "sub_agent" && - event.item.detail.log.includes("CHILD_SENTINEL"), + event.type === "provider_subagent" && + event.event.type === "upsert" && + event.event.status === "completed", ); + const childAssistantText = events + .flatMap((event) => + event.type === "provider_subagent" && + event.event.type === "timeline" && + event.event.item.type === "assistant_message" + ? [event.event.item.text] + : [], + ) + .join(""); const rootMessageIndexes = events.flatMap((event, index) => event.type === "timeline" && event.item.type === "assistant_message" ? [index] : [], ); @@ -105,6 +112,7 @@ CHILD_SENTINEL in your own response. .map((item) => item.text) .join(""); expect(topLevelAssistantText).not.toContain("CHILD_SENTINEL"); + expect(childAssistantText).toContain("CHILD_SENTINEL"); expect(completedChildIndex).toBeGreaterThanOrEqual(0); expect(rootMessageIndexes[0]).toBeGreaterThan(completedChildIndex); expect(terminalIndexes).toHaveLength(1); 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 cb8cb2684b..61a86ccb8f 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 @@ -3524,7 +3524,7 @@ describe("Codex app-server provider", () => { ]); }); - test("folds child-thread Codex activity into the parent sub-agent tool call", async () => { + test("keeps child-thread Codex activity out of the parent sub-agent card", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); @@ -3562,7 +3562,6 @@ describe("Codex app-server provider", () => { }); const timelineEvents = events.filter((event) => event.type === "timeline"); - expect(timelineEvents).toHaveLength(4); expect(timelineEvents.every((event) => event.item.type === "tool_call")).toBe(true); const finalItem = timelineEvents.at(-1)?.item; expect(finalItem).toMatchObject({ @@ -3574,7 +3573,7 @@ describe("Codex app-server provider", () => { type: "sub_agent", subAgentType: "Sub-agent", description: "Report findings.", - log: "[Assistant] Found the path.", + log: "", actions: [], }, }); @@ -3651,13 +3650,9 @@ describe("Codex app-server provider", () => { id: "settled-child-thread", status: "completed", }); - expect(events.at(-1)).toMatchObject({ + expect(events.findLast((event) => event.type === "timeline")).toMatchObject({ type: "timeline", - item: { - callId: "call-settled-child", - status: "completed", - detail: { type: "sub_agent", log: "[Assistant] Late trailing output." }, - }, + item: { callId: "call-settled-child", status: "completed" }, }); appServer.completesSubAgentActivity({ @@ -3962,6 +3957,8 @@ describe("Codex app-server provider", () => { createTestLogger(), async () => appServer.child, ); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); try { const resultPromise = session.run("Delegate the investigation, then report the result."); @@ -4005,11 +4002,18 @@ describe("Codex app-server provider", () => { type: "tool_call", callId: "spawn-child-early", status: "completed", - detail: { - type: "sub_agent", - log: "[Assistant] Early child findings.", - }, + detail: { type: "sub_agent", log: "" }, }); + expect(events).toContainEqual( + expect.objectContaining({ + type: "provider_subagent", + event: expect.objectContaining({ + type: "timeline", + id: "child-thread-early", + item: expect.objectContaining({ type: "assistant_message" }), + }), + }), + ); appServer.assertNoErrors(); } finally { await session.close(); @@ -4080,10 +4084,7 @@ describe("Codex app-server provider", () => { expect(toolCalls.at(-1)).toMatchObject({ callId: "spawn-child-stable", status: "canceled", - detail: { - type: "sub_agent", - log: "[Assistant] Still on the same card.", - }, + detail: { type: "sub_agent", log: "" }, }); appServer.assertNoErrors(); } finally { @@ -4163,11 +4164,10 @@ describe("Codex app-server provider", () => { const result = await resultPromise; const toolCalls = result.timeline.filter((item) => item.type === "tool_call"); - expect(toolCalls.map((item) => item.status)).toEqual(["running", "completed", "completed"]); + expect(toolCalls.map((item) => item.status)).toEqual(["running", "completed"]); expect(toolCalls.at(-1)).toMatchObject({ callId: "spawn-child-late-compaction", status: "completed", - detail: { type: "sub_agent", log: "[Compacted]" }, }); appServer.assertNoErrors(); } finally { @@ -4175,7 +4175,7 @@ describe("Codex app-server provider", () => { } }); - test("projects legacy child tools into one stable sub-agent log", async () => { + test("routes legacy child tools to the child timeline", async () => { const appServer = createFakeCodexAppServer(); const session = new CodexAppServerAgentSession( createConfig({ cwd: "/workspace/project" }), @@ -4183,6 +4183,8 @@ describe("Codex app-server provider", () => { createTestLogger(), async () => appServer.child, ); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); try { const resultPromise = session.run("Delegate the implementation."); @@ -4225,19 +4227,26 @@ describe("Codex app-server provider", () => { expect(finalToolCall).toMatchObject({ callId: "spawn-legacy-tool-child", status: "completed", - detail: { type: "sub_agent" }, + detail: { type: "sub_agent", log: "" }, }); - if (finalToolCall?.detail.type === "sub_agent") { - expect(finalToolCall.detail.log.match(/\[Shell\]/g)).toHaveLength(1); - expect(finalToolCall.detail.log).toContain("[Edit]"); - } + const childToolCalls = events.flatMap((event) => + event.type === "provider_subagent" && + event.event.type === "timeline" && + event.event.id === "legacy-tool-child" && + event.event.item.type === "tool_call" + ? [event.event.item] + : [], + ); + expect(new Set(childToolCalls.map((item) => item.detail.type))).toEqual( + new Set(["shell", "edit"]), + ); appServer.assertNoErrors(); } finally { await session.close(); } }); - test("keeps nested MultiAgentV2 output inside the root sub-agent card", async () => { + test("keeps nested MultiAgentV2 output out of the root sub-agent card", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); @@ -4297,8 +4306,22 @@ describe("Codex app-server provider", () => { expect(beforeParentCompletes.at(-1)).toMatchObject({ callId: "spawn-child-root", status: "running", - detail: { type: "sub_agent", log: expect.stringContaining("Grandchild findings.") }, + detail: { type: "sub_agent", log: "" }, }); + expect(events).toContainEqual( + expect.objectContaining({ + type: "provider_subagent", + event: { + type: "timeline", + id: "grandchild-thread", + item: { + type: "assistant_message", + messageId: "grandchild-message", + text: "Grandchild findings.", + }, + }, + }), + ); await asInternals(session).handleNotification("turn/completed", { threadId: "child-thread-root", @@ -4365,13 +4388,20 @@ describe("Codex app-server provider", () => { "child-thread-root", "child-thread-root", ]); - expect(events.at(-1)).toMatchObject({ - type: "timeline", - item: { - callId: "spawn-child-root", - detail: { type: "sub_agent", log: expect.stringContaining("Still nested.") }, - }, - }); + expect(events).toContainEqual( + expect.objectContaining({ + type: "provider_subagent", + event: { + type: "timeline", + id: "grandchild-thread", + item: { + type: "assistant_message", + messageId: "grandchild-after-wait", + text: "Still nested.", + }, + }, + }), + ); }); test("never treats an unmapped foreign terminal as the root terminal", async () => { @@ -4791,7 +4821,7 @@ describe("Codex app-server provider", () => { expect(events).toHaveLength(eventCountAfterSpawn); }); - test("keeps child context compaction inside the child card", async () => { + test("keeps child context compaction on the child timeline", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); @@ -4824,10 +4854,70 @@ describe("Codex app-server provider", () => { (item) => item.type === "tool_call" && item.callId === "spawn-child-compaction", ), ).toBe(true); - expect(timelineItems.at(-1)).toMatchObject({ - type: "tool_call", - detail: { type: "sub_agent", log: "[Compacted]" }, + expect( + events.flatMap((event) => + event.type === "provider_subagent" && event.event.type === "timeline" ? [event.event] : [], + ), + ).toEqual([ + { + type: "timeline", + id: "child-thread-compaction", + item: { type: "compaction", status: "loading" }, + }, + { + type: "timeline", + id: "child-thread-compaction", + item: { type: "compaction", status: "completed" }, + }, + ]); + }); + + test("settles a child compaction once when the item completion is missing or duplicated", async () => { + const session = createSession(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + for (const child of ["compacted-child", "turn-ended-child"]) { + await asInternals(session).handleNotification("item/completed", { + threadId: "test-thread", + item: { + type: "subAgentActivity", + id: `spawn-${child}`, + kind: "started", + agentThreadId: child, + agentPath: `/root/${child}`, + }, + }); + await asInternals(session).handleNotification("item/started", { + threadId: child, + item: { type: "contextCompaction", id: `${child}-compaction` }, + }); + } + await asInternals(session).handleNotification("thread/compacted", { + threadId: "compacted-child", }); + await asInternals(session).handleNotification("item/completed", { + threadId: "compacted-child", + item: { type: "contextCompaction", id: "compacted-child-compaction" }, + }); + await asInternals(session).handleNotification("turn/completed", { + threadId: "turn-ended-child", + turn: { status: "completed" }, + }); + + const compactions = events.flatMap((event) => + event.type === "provider_subagent" && event.event.type === "timeline" + ? [ + `${event.event.id} ${event.event.item.type === "compaction" ? event.event.item.status : ""}`, + ] + : [], + ); + expect(compactions).toEqual([ + "compacted-child loading", + "turn-ended-child loading", + "compacted-child completed", + "turn-ended-child completed", + ]); }); test("keeps the parent sub-agent running when a child command fails during the child turn", async () => { @@ -4862,7 +4952,7 @@ describe("Codex app-server provider", () => { }, }); - expect(events.at(-1)?.item).toMatchObject({ + expect(events.findLast((event) => event.type === "timeline")?.item).toMatchObject({ type: "tool_call", callId: "call-sub-agent-child-command-failure", name: "Sub-agent", @@ -5157,7 +5247,7 @@ describe("Codex app-server provider", () => { ); expect(liveToolCalls.at(-1)).toMatchObject({ status: "running", - detail: { type: "sub_agent", log: "[Assistant] More findings after resume." }, + detail: { type: "sub_agent", log: "" }, }); liveEvents.length = 0; @@ -5170,12 +5260,12 @@ describe("Codex app-server provider", () => { itemId: "legacy-child-message-after-resume", delta: "Legacy findings after resume.", }); - expect(liveEvents.at(-1)).toMatchObject({ + expect(liveEvents.findLast((event) => event.type === "timeline")).toMatchObject({ type: "timeline", item: { callId: "legacy-spawn-history", status: "running", - detail: { type: "sub_agent", log: "[Assistant] Legacy findings after resume." }, + detail: { type: "sub_agent", log: "" }, }, }); }); 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 f7d4f50215..75370f0b5b 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 @@ -49,10 +49,10 @@ import { Dirent } from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { isDeepStrictEqual } from "node:util"; import { z } from "zod"; import { renderPromptAttachmentAsText } from "../prompt-attachments.js"; import { composeSystemPromptParts } from "../system-prompt.js"; -import { curateAgentActivity } from "../activity-curator.js"; import { CodexAsyncQuestions, codexAsyncQuestionToTimeline } from "./codex/async-questions.js"; import { mapCodexToolCallEnvelope, @@ -3384,8 +3384,6 @@ interface CodexSubAgentCallState { activityItemIds: Set; pendingCommandOutputDeltas: Map; pendingFileChangeOutputDeltas: Map; - childItemOrder: string[]; - childItems: Map; childThreadIds: Set; } @@ -3487,6 +3485,9 @@ export class CodexAppServerAgentSession implements AgentSession { private compactionTriggerByItemId = new Map(); private pendingRootCompactionItemIds = new Set(); private pendingAnonymousRootCompactions = 0; + // Child threads with a compaction shown as loading. Some Codex builds settle it only through + // `thread/compacted` or the end of the child's turn. + private pendingChildCompactionThreadIds = new Set(); // Codex can report one completed compaction through both channels: // `thread/compacted` and a completed `contextCompaction` item. private unpairedCompactionNotificationCompletions = 0; @@ -5516,6 +5517,9 @@ export class CodexAppServerAgentSession implements AgentSession { case "patch_apply_completed": this.handlePatchApplyCompletedNotification(parsed, callId); return; + case "context_compacted": + this.settleChildCompaction(parsed.threadId); + return; default: // Aggregate child telemetry is redundant and must not leak into the // root timeline. Concrete legacy tools are projected above for Codex @@ -5692,20 +5696,10 @@ export class CodexAppServerAgentSession implements AgentSession { activityItemIds: new Set(), pendingCommandOutputDeltas: new Map(), pendingFileChangeOutputDeltas: new Map(), - childItemOrder: [], - childItems: new Map(), childThreadIds: new Set(), } satisfies CodexSubAgentCallState); - state.toolCall = { - ...timelineItem, - detail: { - ...timelineItem.detail, - log: - timelineItem.detail.log || - (state.toolCall.detail.type === "sub_agent" ? state.toolCall.detail.log : ""), - }, - }; + state.toolCall = timelineItem; state.parentCallId ??= parentCallId; state.parentSubagentId ??= parentSubagentId; const activity = readCodexSubAgentActivity(rawItem); @@ -5758,25 +5752,17 @@ export class CodexAppServerAgentSession implements AgentSession { const activityToolCall = mapCodexToolCallFromThreadItem(rawItem, { cwd: this.config.cwd ?? null, }); - if ( - activityToolCall?.detail.type === "sub_agent" && - state.toolCall.detail.type === "sub_agent" - ) { - state.toolCall = { - ...state.toolCall, - detail: { - ...state.toolCall.detail, - subAgentType: activityToolCall.detail.subAgentType, - }, - }; - } + const subAgentType = + activityToolCall?.detail.type === "sub_agent" + ? activityToolCall.detail.subAgentType + : undefined; let nextStatus: ToolCallTimelineItem["status"] | undefined = "running"; if (activity.kind === "interrupted") { nextStatus = "canceled"; } else if (isTerminalSubAgentStatus(state.toolCall.status)) { nextStatus = undefined; } - this.emitSubAgentActivityUpdate(callId, nextStatus); + this.emitSubAgentActivityUpdate(callId, nextStatus, { subAgentType }); return true; } @@ -5817,7 +5803,13 @@ export class CodexAppServerAgentSession implements AgentSession { ): boolean { if ( childSubAgentCallId && - this.handleSubAgentContextCompactionItem(childSubAgentCallId, parsed.item, "completed") + parsed.threadId && + this.handleSubAgentContextCompactionItem( + childSubAgentCallId, + parsed.threadId, + parsed.item, + "completed", + ) ) { return true; } @@ -5827,17 +5819,6 @@ export class CodexAppServerAgentSession implements AgentSession { ); } - private upsertSubAgentChildItem(callId: string, itemId: string, item: AgentTimelineItem): void { - const state = this.subAgentCallsByCallId.get(callId); - if (!state) { - return; - } - if (!state.childItems.has(itemId)) { - state.childItemOrder.push(itemId); - } - state.childItems.set(itemId, item); - } - private emitCodexToolTimelineItem( timelineItem: ToolCallTimelineItem, subAgentCallId: string | null, @@ -5847,7 +5828,6 @@ export class CodexAppServerAgentSession implements AgentSession { this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem }); return; } - this.upsertSubAgentChildItem(subAgentCallId, timelineItem.callId, timelineItem); const state = this.subAgentCallsByCallId.get(subAgentCallId); if (state) { const targetThreadIds = childThreadId ? [childThreadId] : state.childThreadIds; @@ -5861,26 +5841,15 @@ export class CodexAppServerAgentSession implements AgentSession { ); } - private getSubAgentChildTimeline(state: CodexSubAgentCallState): AgentTimelineItem[] { - return state.childItemOrder - .map((itemId) => state.childItems.get(itemId)) - .filter((item): item is AgentTimelineItem => Boolean(item)); - } - private emitSubAgentActivityUpdate( callId: string, status?: ToolCallTimelineItem["status"], - options?: { reopen?: boolean }, + options?: { reopen?: boolean; subAgentType?: string }, ): void { const state = this.subAgentCallsByCallId.get(callId); if (!state || state.toolCall.detail.type !== "sub_agent") { return; } - const childTimeline = this.getSubAgentChildTimeline(state); - const log = - childTimeline.length > 0 - ? curateAgentActivity(childTimeline, { labelAssistantMessages: true }) - : ""; let resolvedStatus = status ?? state.toolCall.status; if ( status === "running" && @@ -5889,16 +5858,12 @@ export class CodexAppServerAgentSession implements AgentSession { ) { resolvedStatus = state.toolCall.status; } - for (const childThreadId of state.childThreadIds) { - this.emitProviderSubagentUpsert(childThreadId, state, resolvedStatus); - } - const baseToolCall = { - ...state.toolCall, - detail: { - ...state.toolCall.detail, - log, - }, - }; + const baseToolCall = options?.subAgentType + ? { + ...state.toolCall, + detail: { ...state.toolCall.detail, subAgentType: options.subAgentType }, + } + : state.toolCall; const nextToolCall: ToolCallTimelineItem = resolvedStatus === "failed" ? { @@ -5911,10 +5876,14 @@ export class CodexAppServerAgentSession implements AgentSession { status: resolvedStatus, error: null, }; + // The card is a launch marker: the child's activity streams to its own timeline, so the card + // is re-sent only when its status or label changes. + const cardChanged = !isDeepStrictEqual(nextToolCall, state.toolCall); state.toolCall = nextToolCall; - if (state.parentCallId && state.parentCallId !== callId) { - this.upsertSubAgentChildItem(state.parentCallId, state.callId, nextToolCall); - this.emitSubAgentActivityUpdate(state.parentCallId); + for (const childThreadId of state.childThreadIds) { + this.emitProviderSubagentUpsert(childThreadId, state, resolvedStatus); + } + if (!cardChanged || (state.parentCallId && state.parentCallId !== callId)) { return; } this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: nextToolCall }); @@ -6022,7 +5991,6 @@ export class CodexAppServerAgentSession implements AgentSession { ): void { this.applyBufferedDeltaTextToTimelineItem(timelineItem, itemId); if (itemId) { - this.upsertSubAgentChildItem(callId, itemId, timelineItem); this.pendingAgentMessages.delete(itemId); this.pendingReasoning.delete(itemId); this.pendingCommandOutputDeltas.delete(itemId); @@ -6033,19 +6001,29 @@ export class CodexAppServerAgentSession implements AgentSession { private handleSubAgentContextCompactionItem( callId: string, + childThreadId: string, item: { id?: string; type?: string; [key: string]: unknown }, status: "loading" | "completed", ): boolean { if (!this.isContextCompactionItem(item)) { return false; } - if (item.id) { - this.upsertSubAgentChildItem(callId, item.id, { type: "compaction", status }); + if (status === "loading") { + this.pendingChildCompactionThreadIds.add(childThreadId); + this.emitProviderSubagentTimeline(childThreadId, { type: "compaction", status }); + } else { + this.settleChildCompaction(childThreadId); } this.emitSubAgentActivityUpdate(callId); return true; } + private settleChildCompaction(childThreadId: string): void { + if (this.pendingChildCompactionThreadIds.delete(childThreadId)) { + this.emitProviderSubagentTimeline(childThreadId, { type: "compaction", status: "completed" }); + } + } + private shouldSkipCompletedThreadItem( timelineItem: AgentTimelineItem, normalizedItemType: string | undefined, @@ -6076,11 +6054,6 @@ export class CodexAppServerAgentSession implements AgentSession { text: parsed.delta, }); } - this.upsertSubAgentChildItem(subAgentCallId, parsed.itemId, { - type: "assistant_message", - messageId: parsed.itemId, - text, - }); this.emitSubAgentActivityUpdate(subAgentCallId, "running"); return; } @@ -6114,10 +6087,6 @@ export class CodexAppServerAgentSession implements AgentSession { text: parsed.delta, }); } - this.upsertSubAgentChildItem(subAgentCallId, parsed.itemId, { - type: "reasoning", - text: prev.join(""), - }); this.emitSubAgentActivityUpdate(subAgentCallId, "running"); return; } @@ -6185,6 +6154,7 @@ export class CodexAppServerAgentSession implements AgentSession { ): void { const subAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId); if (subAgentCallId) { + if (parsed.threadId) this.settleChildCompaction(parsed.threadId); let status: ToolCallTimelineItem["status"] = "completed"; if (parsed.status === "failed") { status = "failed"; @@ -6771,7 +6741,13 @@ export class CodexAppServerAgentSession implements AgentSession { const childSubAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId); if ( childSubAgentCallId && - this.handleSubAgentContextCompactionItem(childSubAgentCallId, parsed.item, "loading") + parsed.threadId && + this.handleSubAgentContextCompactionItem( + childSubAgentCallId, + parsed.threadId, + parsed.item, + "loading", + ) ) { return; } @@ -6802,9 +6778,6 @@ export class CodexAppServerAgentSession implements AgentSession { }); if (childSubAgentCallId) { this.emitStartedProviderSubagentItem(parsed.threadId, timelineItem); - if (parsed.item.id) { - this.upsertSubAgentChildItem(childSubAgentCallId, parsed.item.id, timelineItem); - } this.emitSubAgentActivityUpdate(childSubAgentCallId, "running"); await this.replayPendingSubAgentNotifications(registeredChildThreadIds); return; @@ -6858,9 +6831,6 @@ export class CodexAppServerAgentSession implements AgentSession { if (parsed.threadId) { this.emitProviderSubagentTimeline(parsed.threadId, timelineItem); } - if (itemId) { - this.upsertSubAgentChildItem(childSubAgentCallId, itemId, timelineItem); - } this.emitSubAgentActivityUpdate(childSubAgentCallId, "running"); return; } diff --git a/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts b/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts index 64629bc5d9..4e77dc9367 100644 --- a/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts +++ b/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts @@ -950,6 +950,7 @@ function mapCollabAgentToolCallItem( type: "sub_agent", subAgentType: "Sub-agent", ...(item.prompt ? { description: item.prompt } : {}), + // COMPAT(subagent-log): remove after 2027-04-30. log: "", actions: [], }; @@ -1006,6 +1007,7 @@ function mapSubAgentActivityItem( type: "sub_agent", subAgentType: nativeName, description, + // COMPAT(subagent-log): remove after 2027-04-30. log: "", actions: [], }, diff --git a/packages/server/src/server/agent/providers/omp/agent.ts b/packages/server/src/server/agent/providers/omp/agent.ts index f2f405540b..c8b44c0bd8 100644 --- a/packages/server/src/server/agent/providers/omp/agent.ts +++ b/packages/server/src/server/agent/providers/omp/agent.ts @@ -69,7 +69,7 @@ import { type OmpRuntimeOptions, } from "./provider-config.js"; export { formatOmpVersionSupport, resolveOmpDiagnosticPaths } from "./provider-config.js"; -import { OmpSubagentCardTracker, type OmpSubagentCardScheduler } from "./subagent-card-tracker.js"; +import { OmpSubagentCardTracker } from "./subagent-card-tracker.js"; import { ompSkillPromptUserText, shouldDisplayOmpCustomMessage } from "./custom-message.js"; import { getUserMessageText } from "./message-history.js"; import { mapOmpSystemNoticeToNotification } from "./system-notice.js"; @@ -130,7 +130,6 @@ export interface OmpAgentClientOptions { logger: Logger; runtimeSettings?: ProviderRuntimeSettings; runtime?: OmpRuntime; - subagentCardScheduler?: OmpSubagentCardScheduler; providerIdleScheduler?: OmpProviderIdleScheduler; noTurnScheduler?: OmpNoTurnScheduler; usagePollScheduler?: OmpUsagePollScheduler; @@ -205,7 +204,6 @@ interface OmpAgentSessionOptions { initialState: OmpSessionState; currentModeId?: string | null; logger: Logger; - subagentCardScheduler?: OmpSubagentCardScheduler; providerIdleScheduler?: OmpProviderIdleScheduler; noTurnScheduler?: OmpNoTurnScheduler; usagePollScheduler?: OmpUsagePollScheduler; @@ -773,9 +771,7 @@ export class OmpAgentSession implements AgentSession { this.logger.debug({ err: error }, "OMP context usage poll failed"); }, }); - this.subagentCardTracker = new OmpSubagentCardTracker({ - scheduler: options.subagentCardScheduler, - }); + this.subagentCardTracker = new OmpSubagentCardTracker(); this.lastKnownThinkingOptionId = normalizeOmpThinkingOption(options.config.thinkingOptionId) ?? this.state.thinkingLevel ?? @@ -2155,8 +2151,7 @@ export class OmpAgentSession implements AgentSession { return mapOmpToolDetail(toolCall, result, { toolCallId, bridgedTool: this.hostTools?.bridge?.tool(toolCall.toolName), - mapSubagentDetail: (detail) => - this.subagentCardTracker.detailFor(toolCallId, detail) ?? detail, + mapSubagentDetail: (detail) => this.subagentCardTracker.detailFor(toolCallId, detail), }); } @@ -2246,7 +2241,6 @@ export class OmpAgentClient implements AgentClient { private readonly logger: Logger; private readonly runtimeSettings?: ProviderRuntimeSettings; - private readonly subagentCardScheduler?: OmpSubagentCardScheduler; private readonly providerIdleScheduler?: OmpProviderIdleScheduler; private readonly noTurnScheduler?: OmpNoTurnScheduler; private readonly usagePollScheduler?: OmpUsagePollScheduler; @@ -2265,7 +2259,6 @@ export class OmpAgentClient implements AgentClient { ); this.logger = options.logger; this.runtimeSettings = runtimeSettings; - this.subagentCardScheduler = options.subagentCardScheduler; this.providerIdleScheduler = options.providerIdleScheduler; this.noTurnScheduler = options.noTurnScheduler; this.usagePollScheduler = options.usagePollScheduler; @@ -2349,7 +2342,6 @@ export class OmpAgentClient implements AgentClient { initialState, currentModeId: launchMode.modeId, logger: this.logger, - subagentCardScheduler: this.subagentCardScheduler, providerIdleScheduler: this.providerIdleScheduler, noTurnScheduler: this.noTurnScheduler, usagePollScheduler: this.usagePollScheduler, @@ -2421,7 +2413,6 @@ export class OmpAgentClient implements AgentClient { initialState, currentModeId: launchMode.modeId, logger: this.logger, - subagentCardScheduler: this.subagentCardScheduler, providerIdleScheduler: this.providerIdleScheduler, noTurnScheduler: this.noTurnScheduler, usagePollScheduler: this.usagePollScheduler, diff --git a/packages/server/src/server/agent/providers/omp/history-mapper.test.ts b/packages/server/src/server/agent/providers/omp/history-mapper.test.ts index 03c0ebe5ae..6d39c21c2d 100644 --- a/packages/server/src/server/agent/providers/omp/history-mapper.test.ts +++ b/packages/server/src/server/agent/providers/omp/history-mapper.test.ts @@ -637,7 +637,7 @@ describe("OMP history mapper", () => { subAgentType: "explore", description: "Inspect files", childSessionId: "/tmp/omp-task/Explore.jsonl", - log: "done\ntranscript: /tmp/omp-task/Explore.jsonl", + log: "", }, error: null, }, diff --git a/packages/server/src/server/agent/providers/omp/subagent-card-tracker.test.ts b/packages/server/src/server/agent/providers/omp/subagent-card-tracker.test.ts index 5164097661..6819292d33 100644 --- a/packages/server/src/server/agent/providers/omp/subagent-card-tracker.test.ts +++ b/packages/server/src/server/agent/providers/omp/subagent-card-tracker.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; -import { OmpSubagentCardTracker, type OmpSubagentCardScheduler } from "./subagent-card-tracker.js"; +import { OmpSubagentCardTracker } from "./subagent-card-tracker.js"; import type { OmpSubagentLifecyclePayload, OmpSubagentProgressPayload } from "./rpc-types.js"; const PARENT_TOOL_CALL_ID = "task-1"; @@ -59,184 +59,84 @@ const PROGRESS: OmpSubagentProgressPayload[] = [ }, ]; -class ManualScheduler implements OmpSubagentCardScheduler { - private currentMs = 0; - private nextId = 1; - private readonly timers = new Map void }>(); - - now(): number { - return this.currentMs; - } - - setTimeout(callback: () => void, delayMs: number) { - const id = this.nextId; - this.nextId += 1; - this.timers.set(id, { dueMs: this.currentMs + delayMs, callback }); - return { token: id }; - } - - clearTimeout(timer: { token: unknown }): void { - if (typeof timer.token === "number") { - this.timers.delete(timer.token); - } - } - - advance(ms: number): void { - this.currentMs += ms; - const dueTimers = [...this.timers.entries()] - .filter(([, timer]) => timer.dueMs <= this.currentMs) - .sort((left, right) => left[1].dueMs - right[1].dueMs); - for (const [id, timer] of dueTimers) { - if (this.timers.delete(id)) { - timer.callback(); - } - } - } -} - describe("OmpSubagentCardTracker", () => { - test("folds lifecycle and progress into one throttled sub-agent detail", () => { - const scheduler = new ManualScheduler(); + test("keeps the card a launch marker while the child runs", () => { const emitted: string[] = []; - const tracker = new OmpSubagentCardTracker({ - scheduler, - emitToolCall: (toolCallId) => { - emitted.push(toolCallId); - return true; - }, - }); - tracker.handleLifecycle(LIFECYCLE); + const emit = (toolCallId: string) => { + emitted.push(toolCallId); + return true; + }; + const tracker = new OmpSubagentCardTracker(); + tracker.handleLifecycle(LIFECYCLE, emit); for (const payload of PROGRESS) { - tracker.handleProgress(payload); + tracker.handleProgress(payload, emit); } expect(emitted).toEqual([PARENT_TOOL_CALL_ID]); - const detailBeforeTrailing = tracker.detailFor(PARENT_TOOL_CALL_ID, { - type: "sub_agent", - subAgentType: "task", - description: "Task arg description wins", - log: "", - }); - expect(detailBeforeTrailing).toEqual({ - type: "sub_agent", - subAgentType: "task", - description: "Task arg description wins", - childSessionId: SESSION_FILE, - log: [ - "EchoSubagent started", - "[bash] echo subagent-hi", - "[yield]", - "EchoSubagent completed", - ].join("\n"), - }); - - scheduler.advance(500); - - expect(emitted).toEqual([PARENT_TOOL_CALL_ID, PARENT_TOOL_CALL_ID]); - }); - - test("aggregates batch progress streams into one index-prefixed log", () => { - const scheduler = new ManualScheduler(); - const emitted: string[] = []; - const tracker = new OmpSubagentCardTracker({ - scheduler, - emitToolCall: (toolCallId) => { - emitted.push(toolCallId); - return true; - }, - }); - - tracker.handleLifecycle({ - id: "Explore", - agent: "task", - description: "Inspect files", - status: "started", - sessionFile: "/tmp/one.jsonl", - parentToolCallId: "task.batch-1", - index: 0, - }); - scheduler.advance(600); - tracker.handleProgress({ - index: 5, - agent: "task", - task: "Run tests", - parentToolCallId: "task.batch-1", - progress: { - id: "Test", - status: "running", - description: "Run tests", - recentTools: [{ tool: "bash", args: "npm test", endMs: 10 }], - }, - sessionFile: "/tmp/two.jsonl", - }); - - expect(emitted).toEqual(["task.batch-1", "task.batch-1"]); expect( - tracker.detailFor("task.batch-1", { + tracker.detailFor(PARENT_TOOL_CALL_ID, { type: "sub_agent", - subAgentType: "batch", - actions: [], + subAgentType: "task", + description: "Task arg description wins", log: "", }), ).toEqual({ type: "sub_agent", - subAgentType: "batch", - description: "Inspect files", - childSessionId: "/tmp/one.jsonl", - log: "[1/6] Explore started\n[6/6] [bash] npm test", - actions: [], + subAgentType: "task", + description: "Task arg description wins", + childSessionId: SESSION_FILE, + log: "", }); }); - test("keeps dirty trailing state available for final detail and cancels it on cleanup", () => { - const scheduler = new ManualScheduler(); + test("re-emits the card when a later event names the child", () => { const emitted: string[] = []; - const tracker = new OmpSubagentCardTracker({ - scheduler, - emitToolCall: (toolCallId) => { - emitted.push(toolCallId); - return true; + const emit = (toolCallId: string) => { + emitted.push(toolCallId); + return true; + }; + const tracker = new OmpSubagentCardTracker(); + tracker.handleLifecycle({ ...LIFECYCLE, description: undefined, sessionFile: undefined }, emit); + tracker.handleProgress(PROGRESS[0], emit); + + expect(emitted).toEqual([PARENT_TOOL_CALL_ID, PARENT_TOOL_CALL_ID]); + expect(tracker.detailFor(PARENT_TOOL_CALL_ID, { type: "sub_agent", log: "" })).toEqual({ + type: "sub_agent", + childSessionId: SESSION_FILE, + log: "", + }); + + tracker.delete(PARENT_TOOL_CALL_ID); + expect(tracker.detailFor(PARENT_TOOL_CALL_ID, { type: "sub_agent", log: "" })).toEqual({ + type: "sub_agent", + log: "", + }); + }); + test("leaves the card alone when a later child of the same task starts", () => { + const emitted: string[] = []; + const emit = (toolCallId: string) => { + emitted.push(toolCallId); + return true; + }; + const tracker = new OmpSubagentCardTracker(); + tracker.handleLifecycle(LIFECYCLE, emit); + tracker.handleLifecycle( + { + ...LIFECYCLE, + id: "Second", + index: 1, + description: "Second child", + sessionFile: "/s.jsonl", }, + emit, + ); + + expect(emitted).toEqual([PARENT_TOOL_CALL_ID]); + expect(tracker.detailFor(PARENT_TOOL_CALL_ID, { type: "sub_agent", log: "" })).toEqual({ + type: "sub_agent", + description: "Run echo in subagent", + childSessionId: SESSION_FILE, + log: "", }); - - tracker.handleLifecycle({ - id: "Explore", - agent: "task", - description: "Inspect files", - status: "started", - parentToolCallId: "task-1", - index: 0, - }); - scheduler.advance(100); - tracker.handleProgress({ - index: 0, - agent: "task", - task: "Inspect files", - parentToolCallId: "task-1", - progress: { - id: "Explore", - status: "running", - recentOutput: ["found target file"], - }, - }); - - expect(emitted).toEqual(["task-1"]); - expect( - tracker.detailFor("task-1", { - type: "sub_agent", - log: "", - }).log, - ).toBe("Explore started\nfound target file"); - - tracker.delete("task-1"); - scheduler.advance(500); - - expect(emitted).toEqual(["task-1"]); - expect( - tracker.detailFor("task-1", { - type: "sub_agent", - log: "static", - }).log, - ).toBe("static"); }); }); diff --git a/packages/server/src/server/agent/providers/omp/subagent-card-tracker.ts b/packages/server/src/server/agent/providers/omp/subagent-card-tracker.ts index 7034c4c506..60ad291058 100644 --- a/packages/server/src/server/agent/providers/omp/subagent-card-tracker.ts +++ b/packages/server/src/server/agent/providers/omp/subagent-card-tracker.ts @@ -1,361 +1,106 @@ import type { ToolCallDetail } from "../../agent-sdk-types.js"; import type { OmpSubagentLifecyclePayload, OmpSubagentProgressPayload } from "./rpc-types.js"; -export interface OmpSubagentCardTimer { - readonly token: unknown; -} - -export interface OmpSubagentCardScheduler { - now(): number; - setTimeout(callback: () => void, delayMs: number): OmpSubagentCardTimer; - clearTimeout(timer: OmpSubagentCardTimer): void; -} - -export interface OmpSubagentCardTrackerOptions { - scheduler?: OmpSubagentCardScheduler; - emitToolCall?: (toolCallId: string) => boolean; -} - -interface OmpSubagentLogLine { - key: string; - text: string; -} - -interface OmpSubagentCardItem { +interface OmpSubagentCardChild { index: number; description?: string; - agent?: string; - status?: "pending" | "running" | "completed" | "failed" | "aborted"; childSessionId?: string; - lines: OmpSubagentLogLine[]; - lineKeys: Set; -} - -interface OmpSubagentCardState { - items: Map; - emitToolCall: (toolCallId: string) => boolean; - lastEmitMs: number | null; - trailingTimer: OmpSubagentCardTimer | null; - dirty: boolean; -} - -const MAX_LOG_LINES_PER_ITEM = 200; -const MAX_LOG_LINE_CHARS = 240; -const THROTTLE_INTERVAL_MS = 500; - -export function createOmpSubagentCardScheduler(): OmpSubagentCardScheduler { - return { - now: () => Date.now(), - setTimeout: (callback, delayMs) => ({ token: setTimeout(callback, delayMs) }), - clearTimeout: (timer) => { - clearTimeout(timer.token as ReturnType); - }, - }; } +/** + * Fills a task card's description and child session from OMP's sub-agent events. The card is a + * launch marker that names the task's first child; the children's activity belongs to their own + * timelines. + */ export class OmpSubagentCardTracker { - private readonly scheduler: OmpSubagentCardScheduler; - private readonly emitToolCall: (toolCallId: string) => boolean; - private readonly states = new Map(); - - constructor(options: OmpSubagentCardTrackerOptions) { - this.scheduler = options.scheduler ?? createOmpSubagentCardScheduler(); - this.emitToolCall = options.emitToolCall ?? (() => false); - } + private readonly firstChildren = new Map(); handleLifecycle( payload: OmpSubagentLifecyclePayload, - emitToolCall: (toolCallId: string) => boolean = this.emitToolCall, + emitToolCall: (toolCallId: string) => boolean, ): void { - const parentToolCallId = readTrimmedString(payload.parentToolCallId); - if (!parentToolCallId) { - return; - } - this.stateFor(parentToolCallId).emitToolCall = emitToolCall; - const item = this.upsertItem(parentToolCallId, { - index: payload.index, - agent: payload.agent, - description: payload.description, - status: payload.status === "started" ? "running" : payload.status, - childSessionId: payload.sessionFile, - }); - this.appendLine( - item, - `lifecycle:${payload.status}:${payload.id}`, - `${payload.id} ${payload.status}`, + this.update( + payload.parentToolCallId, + { + index: payload.index, + description: payload.description, + childSessionId: payload.sessionFile, + }, + emitToolCall, ); - this.requestEmit(parentToolCallId); } handleProgress( payload: OmpSubagentProgressPayload, - emitToolCall: (toolCallId: string) => boolean = this.emitToolCall, + emitToolCall: (toolCallId: string) => boolean, ): void { - const parentToolCallId = readTrimmedString(payload.parentToolCallId); - if (!parentToolCallId) { - return; - } - this.stateFor(parentToolCallId).emitToolCall = emitToolCall; - const item = this.upsertItem(parentToolCallId, { - index: payload.index, - agent: payload.agent, - description: payload.progress.description, - status: payload.progress.status, - childSessionId: payload.sessionFile, - }); - - const currentToolLine = summarizeTool(readRecord(payload.progress.currentTool)); - if (currentToolLine) { - this.appendLine(item, `current:${currentToolLine.key}`, currentToolLine.text); - } - - for (const tool of readRecordArray(payload.progress.recentTools)) { - const line = summarizeTool(tool); - if (line) { - this.appendLine(item, `tool:${line.key}`, line.text); - } - } - - for (const output of readOutputLines(payload.progress.recentOutput)) { - this.appendLine(item, `output:${output}`, output); - } - - if (payload.progress.status !== "running") { - this.appendLine( - item, - `status:${payload.progress.status}:${payload.progress.id}`, - `${payload.progress.id} ${payload.progress.status}`, - ); - } - - this.requestEmit(parentToolCallId); + this.update( + payload.parentToolCallId, + { + index: payload.index, + description: payload.progress.description, + childSessionId: payload.sessionFile, + }, + emitToolCall, + ); } detailFor(toolCallId: string, baseDetail: ToolCallDetail): ToolCallDetail { if (baseDetail.type !== "sub_agent") { return baseDetail; } - const state = this.states.get(toolCallId); - if (!state) { + const first = this.firstChildren.get(toolCallId); + if (!first) { return baseDetail; } - const items = [...state.items.values()].sort((left, right) => left.index - right.index); - const firstItem = items[0]; - if (!firstItem) { - return baseDetail; - } - - const detail: ToolCallDetail = { - type: "sub_agent", - ...(baseDetail.subAgentType ? { subAgentType: baseDetail.subAgentType } : {}), - ...((baseDetail.description ?? firstItem.description) - ? { description: baseDetail.description ?? firstItem.description } - : {}), - ...((firstItem.childSessionId ?? baseDetail.childSessionId) - ? { childSessionId: firstItem.childSessionId ?? baseDetail.childSessionId } - : {}), - log: buildLog(items, baseDetail.log), + const description = baseDetail.description ?? first.description; + const childSessionId = first.childSessionId ?? baseDetail.childSessionId; + return { + ...baseDetail, + ...(description ? { description } : {}), + ...(childSessionId ? { childSessionId } : {}), }; - return baseDetail.actions ? { ...detail, actions: baseDetail.actions } : detail; - } - - flush(toolCallId: string): void { - const state = this.states.get(toolCallId); - if (!state || !state.dirty) { - return; - } - this.emitNow(toolCallId, state); } delete(toolCallId: string): void { - const state = this.states.get(toolCallId); - if (state?.trailingTimer) { - this.scheduler.clearTimeout(state.trailingTimer); - } - this.states.delete(toolCallId); + this.firstChildren.delete(toolCallId); } clear(): void { - for (const toolCallId of this.states.keys()) { - this.delete(toolCallId); - } + this.firstChildren.clear(); } - private upsertItem( - parentToolCallId: string, - input: { - index: number; - agent?: string; - status?: "pending" | "running" | "completed" | "failed" | "aborted"; - description?: string; - childSessionId?: string; - }, - ): OmpSubagentCardItem { - const state = this.stateFor(parentToolCallId); - const existing = state.items.get(input.index); - if (existing) { - existing.agent = readTrimmedString(input.agent) ?? existing.agent; - existing.status = input.status ?? existing.status; - existing.description = readTrimmedString(input.description) ?? existing.description; - existing.childSessionId = readTrimmedString(input.childSessionId) ?? existing.childSessionId; - return existing; + private update( + parentToolCallId: string | undefined, + input: { index: number; description?: string; childSessionId?: string }, + emitToolCall: (toolCallId: string) => boolean, + ): void { + const toolCallId = readTrimmedString(parentToolCallId); + if (!toolCallId) { + return; } - - const item: OmpSubagentCardItem = { + const first = this.firstChildren.get(toolCallId); + if (first && input.index > first.index) { + return; + } + const existing = first?.index === input.index ? first : undefined; + const next: OmpSubagentCardChild = { index: input.index, - lines: [], - lineKeys: new Set(), - ...(readTrimmedString(input.agent) ? { agent: readTrimmedString(input.agent) } : {}), - ...(input.status ? { status: input.status } : {}), - ...(readTrimmedString(input.description) - ? { description: readTrimmedString(input.description) } - : {}), - ...(readTrimmedString(input.childSessionId) - ? { childSessionId: readTrimmedString(input.childSessionId) } - : {}), + description: readTrimmedString(input.description) ?? existing?.description, + childSessionId: readTrimmedString(input.childSessionId) ?? existing?.childSessionId, }; - state.items.set(input.index, item); - return item; - } - - private stateFor(parentToolCallId: string): OmpSubagentCardState { - const existing = this.states.get(parentToolCallId); - if (existing) { - return existing; - } - const state: OmpSubagentCardState = { - items: new Map(), - emitToolCall: this.emitToolCall, - lastEmitMs: null, - trailingTimer: null, - dirty: false, - }; - this.states.set(parentToolCallId, state); - return state; - } - - private appendLine(item: OmpSubagentCardItem, key: string, text: string): void { - const normalizedText = normalizeLine(text); - if (!normalizedText || item.lineKeys.has(key)) { + if ( + existing && + existing.description === next.description && + existing.childSessionId === next.childSessionId + ) { return; } - item.lines.push({ key, text: normalizedText }); - item.lineKeys.add(key); - while (item.lines.length > MAX_LOG_LINES_PER_ITEM) { - const removed = item.lines.shift(); - if (removed) { - item.lineKeys.delete(removed.key); - } - } + this.firstChildren.set(toolCallId, next); + emitToolCall(toolCallId); } - - private requestEmit(toolCallId: string): void { - const state = this.stateFor(toolCallId); - const now = this.scheduler.now(); - const elapsedMs = state.lastEmitMs === null ? THROTTLE_INTERVAL_MS : now - state.lastEmitMs; - if (elapsedMs >= THROTTLE_INTERVAL_MS) { - this.emitNow(toolCallId, state); - return; - } - - state.dirty = true; - if (state.trailingTimer) { - return; - } - state.trailingTimer = this.scheduler.setTimeout(() => { - state.trailingTimer = null; - if (state.dirty) { - this.emitNow(toolCallId, state); - } - }, THROTTLE_INTERVAL_MS - elapsedMs); - } - - private emitNow(toolCallId: string, state: OmpSubagentCardState): void { - if (state.trailingTimer) { - this.scheduler.clearTimeout(state.trailingTimer); - state.trailingTimer = null; - } - state.dirty = false; - state.lastEmitMs = this.scheduler.now(); - state.emitToolCall(toolCallId); - } -} - -function buildLog(items: OmpSubagentCardItem[], fallback: string): string { - const itemCount = Math.max(...items.map((item) => item.index)) + 1; - const lines = items.flatMap((item) => { - const prefix = itemCount > 1 ? `[${item.index + 1}/${itemCount}] ` : ""; - return item.lines.map((line) => `${prefix}${line.text}`); - }); - return lines.length > 0 ? lines.join("\n") : fallback; -} - -function summarizeTool(tool: Record | null): OmpSubagentLogLine | null { - if (!tool) { - return null; - } - const toolName = readTrimmedString(tool.tool) ?? readTrimmedString(tool.name); - if (!toolName) { - return null; - } - const args = readTrimmedString(tool.args) ?? summarizeUnknown(tool.input); - const endMs = typeof tool.endMs === "number" ? `:${tool.endMs}` : ""; - return { - key: `${toolName}:${args ?? ""}${endMs}`, - text: args ? `[${toolName}] ${args}` : `[${toolName}]`, - }; -} - -function readOutputLines(value: unknown): string[] { - if (!Array.isArray(value)) { - return []; - } - return value.flatMap((item) => { - const text = - readTrimmedString(item) ?? - (isRecord(item) - ? (readTrimmedString(item.text) ?? - readTrimmedString(item.output) ?? - readTrimmedString(item.content)) - : undefined); - return text ? [text] : []; - }); -} - -function readRecord(value: unknown): Record | null { - return isRecord(value) ? value : null; -} - -function readRecordArray(value: unknown): Record[] { - return Array.isArray(value) ? value.filter(isRecord) : []; -} - -function summarizeUnknown(value: unknown): string | undefined { - if (value === undefined || value === null) { - return undefined; - } - if (typeof value === "string") { - return readTrimmedString(value); - } - const json = JSON.stringify(value); - return typeof json === "string" ? normalizeLine(json) : undefined; -} - -function normalizeLine(value: string): string | undefined { - const normalized = value.trim().replace(/\s+/g, " "); - if (!normalized) { - return undefined; - } - if (normalized.length <= MAX_LOG_LINE_CHARS) { - return normalized; - } - return `${normalized.slice(0, MAX_LOG_LINE_CHARS)}...`; } function readTrimmedString(value: unknown): string | undefined { return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; } - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} diff --git a/packages/server/src/server/agent/providers/omp/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/omp/tool-call-mapper.test.ts index ae4c0d0d77..cc13f9001f 100644 --- a/packages/server/src/server/agent/providers/omp/tool-call-mapper.test.ts +++ b/packages/server/src/server/agent/providers/omp/tool-call-mapper.test.ts @@ -85,7 +85,7 @@ describe("OMP tool call mapper", () => { expect(mapOmpToolDetail(parseToolArgs("todo", { op: "view" }), null)).toBeNull(); }); - test("uses task result text and transcript path as the best static replay detail", () => { + test("takes the child session from the task transcript path", () => { expect( mapOmpToolDetail( parseToolArgs("task", { @@ -106,7 +106,7 @@ describe("OMP tool call mapper", () => { subAgentType: "explore", description: "Inspect the target files", childSessionId: "/tmp/omp-task-static/Explore.jsonl", - log: "done\ntranscript: /tmp/omp-task-static/Explore.jsonl", + log: "", }); }); diff --git a/packages/server/src/server/agent/providers/omp/tool-call-mapper.ts b/packages/server/src/server/agent/providers/omp/tool-call-mapper.ts index 86b1ee53ef..500123758a 100644 --- a/packages/server/src/server/agent/providers/omp/tool-call-mapper.ts +++ b/packages/server/src/server/agent/providers/omp/tool-call-mapper.ts @@ -179,7 +179,6 @@ const OPAQUE_TEXT_TOOLS = new Set([ function mapOmpTaskDetail(args: unknown, result: OmpToolResult): ToolCallDetail { const argRecord = isRecord(args) ? args : {}; const firstTask = Array.isArray(argRecord.tasks) ? argRecord.tasks.find(isRecord) : undefined; - const resultText = extractTextFromToolResult(result); const childSessionId = readChildSessionId(result); return { type: "sub_agent", @@ -200,7 +199,8 @@ function mapOmpTaskDetail(args: unknown, result: OmpToolResult): ToolCallDetail argRecord.assignment, ), ...(childSessionId ? { childSessionId } : {}), - log: resultText?.trim() ?? "", + // COMPAT(subagent-log): remove after 2027-04-30. + log: "", }; } diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 81288f3e4f..6fef290d91 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -4846,15 +4846,6 @@ class OpenCodeAgentSession implements AgentSession { ...item, status: "failed", error, - detail: - item.detail.type === "sub_agent" - ? { - ...item.detail, - log: [item.detail.log, error.message] - .filter((entry) => entry.trim().length > 0) - .join("\n"), - } - : item.detail, }, }, turnId, diff --git a/packages/server/src/server/agent/providers/opencode/tool-call-detail-parser.ts b/packages/server/src/server/agent/providers/opencode/tool-call-detail-parser.ts index 8214ebb1d0..b3eb432f86 100644 --- a/packages/server/src/server/agent/providers/opencode/tool-call-detail-parser.ts +++ b/packages/server/src/server/agent/providers/opencode/tool-call-detail-parser.ts @@ -153,21 +153,6 @@ const OpencodeGrepOutputSchema = z ]) .nullable(); -function formatLogEntry(value: unknown): string | undefined { - const outputText = readOutputText(value); - if (outputText) { - return outputText; - } - if (value === null || value === undefined) { - return undefined; - } - try { - return JSON.stringify(value, null, 2); - } catch { - return String(value); - } -} - function extractOpenCodeTaskSessionId(value: unknown): string | undefined { const text = readOutputText(value); if (text) { @@ -195,7 +180,6 @@ function extractOpenCodeTaskSessionId(value: unknown): string | undefined { function deriveOpencodeTaskDetail( input: unknown, output: unknown, - error: unknown, metadata: Record | undefined, ): ToolCallDetail | null { if (!isRecord(input)) { @@ -208,7 +192,6 @@ function deriveOpencodeTaskDetail( return null; } - const log = [formatLogEntry(output), formatLogEntry(error)].filter((entry) => entry).join("\n"); const childSessionId = normalizeSubAgentText(metadata?.sessionId) ?? extractOpenCodeTaskSessionId(output); return { @@ -216,7 +199,8 @@ function deriveOpencodeTaskDetail( ...(subAgentType ? { subAgentType } : {}), ...(description ? { description } : {}), ...(childSessionId ? { childSessionId } : {}), - log, + // COMPAT(subagent-log): remove after 2027-04-30. + log: "", actions: [], }; } @@ -373,11 +357,10 @@ export function deriveOpencodeToolDetail( toolName: string, input: unknown, output: unknown, - error: unknown = null, metadata?: Record, ): ToolCallDetail { if (toolName.trim().toLowerCase() === "task") { - const taskDetail = deriveOpencodeTaskDetail(input, output, error, metadata); + const taskDetail = deriveOpencodeTaskDetail(input, output, metadata); if (taskDetail) { return taskDetail; } diff --git a/packages/server/src/server/agent/providers/opencode/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/opencode/tool-call-mapper.test.ts index 1c9ba212e1..eb38a04ac6 100644 --- a/packages/server/src/server/agent/providers/opencode/tool-call-mapper.test.ts +++ b/packages/server/src/server/agent/providers/opencode/tool-call-mapper.test.ts @@ -460,7 +460,7 @@ describe("opencode tool-call mapper", () => { }); }); - it("maps completed task calls with final output log to sub_agent detail", () => { + it("maps completed task calls to sub_agent detail", () => { const item = expectMapped( mapOpencodeToolCall({ toolName: "task", @@ -480,7 +480,7 @@ describe("opencode tool-call mapper", () => { type: "sub_agent", subAgentType: "explore", description: "Explore agent-tools codebase", - log: "Found the CLI entrypoint and provider registry.", + log: "", actions: [], }); }); @@ -505,12 +505,12 @@ describe("opencode tool-call mapper", () => { subAgentType: "explore", description: "Explore current directory", childSessionId: "ses_2268db431ffe299vL1bbot8R7Z", - log: 'done', + log: "", actions: [], }); }); - it("maps aborted task calls with preserved error log to sub_agent detail", () => { + it("maps aborted task calls to sub_agent detail", () => { const item = expectMapped( mapOpencodeToolCall({ toolName: "task", @@ -531,7 +531,7 @@ describe("opencode tool-call mapper", () => { type: "sub_agent", subAgentType: "explore", description: "Explore agent-tools codebase", - log: "Tool execution aborted", + log: "", actions: [], }); }); diff --git a/packages/server/src/server/agent/providers/opencode/tool-call-mapper.ts b/packages/server/src/server/agent/providers/opencode/tool-call-mapper.ts index 8fbc861a84..261a4ec827 100644 --- a/packages/server/src/server/agent/providers/opencode/tool-call-mapper.ts +++ b/packages/server/src/server/agent/providers/opencode/tool-call-mapper.ts @@ -43,7 +43,7 @@ export function mapOpencodeToolCall(params: OpencodeToolCallParams): ToolCallTim const error = raw.error ?? null; const rawStatus = typeof raw.status === "string" ? raw.status : undefined; const status = normalizeToolCallStatus(rawStatus, error, output); - const detail = deriveOpencodeToolDetail(name, input, output, error, raw.metadata); + const detail = deriveOpencodeToolDetail(name, input, output, raw.metadata); if (status === "failed") { return { diff --git a/packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/index.ts b/packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/index.ts index c00e39aaad..c58f0faf15 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/index.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/index.ts @@ -1,5 +1,4 @@ import { z } from "zod"; -import { extractTextFromToolResult } from "../../tool-call-mapper.js"; import type { PiChildSessionFile, PiExtension } from "../contract.js"; import type { ProviderSubagentInputEvent } from "../../../../provider-subagents/store.js"; @@ -99,7 +98,8 @@ export const edxethPiSubagents: PiExtension = { detail: { type: "sub_agent", description, - log: extractTextFromToolResult(call.result)?.trim() ?? "", + // COMPAT(subagent-log): remove after 2027-04-30. + log: "", }, subagents, childSessions, diff --git a/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.test.ts b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.test.ts index f89a9aefd3..7bb62a9d1f 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.test.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.test.ts @@ -130,6 +130,18 @@ describe("@gotgenes/pi-subagents adapter", () => { .filter((event) => event.event.type === "upsert") .map((event) => (event.event.type === "upsert" ? event.event.status : null)), ).toEqual(["running", "completed"]); + expect( + events.flatMap((event) => (event.event.type === "timeline" ? [event.event] : [])), + ).toEqual([ + { + type: "timeline", + id: "call_502323", + item: { + type: "assistant_message", + text: "Agent completed in 4.7s (1 tool uses, 9.0k token).\nAgent ID: 848583f5-6749-4a1\n\nThe workspace is empty.", + }, + }, + ]); }); test("correlates a background notification and result collection", async () => { const source = readSubagentFixture(new URL("./fixtures/background.json", import.meta.url)); diff --git a/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.ts b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.ts index e1869705f8..9383911c3d 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.ts @@ -64,7 +64,8 @@ export const gotgenesPiSubagents: PiExtension = { type: "sub_agent" as const, subAgentType: title, description, - log: extractTextFromToolResult(call.result)?.trim() ?? "", + // COMPAT(subagent-log): remove after 2027-04-30. + log: "", }; if (call.status === "running") return { @@ -92,6 +93,10 @@ export const gotgenesPiSubagents: PiExtension = { outputFileFromToolResult(call.result) ?? pendingFilesByAgent.get(details.data.agentId); pendingFilesByAgent.delete(details.data.agentId); const childSessions = childSession(call.callId, file); + const childStatus = status(details.data.status); + // Without a session file, as on replay, the child's answer is the tool result. + const answer = + !file && childStatus !== "running" ? extractTextFromToolResult(call.result)?.trim() : ""; return { detail, subagents: [ @@ -101,8 +106,17 @@ export const gotgenesPiSubagents: PiExtension = { title, description, toolCallId: call.callId, - status: status(details.data.status), + status: childStatus, }, + ...(answer + ? [ + { + type: "timeline" as const, + id: call.callId, + item: { type: "assistant_message" as const, text: answer }, + }, + ] + : []), ], childSessions, }; @@ -118,7 +132,8 @@ export const gotgenesPiSubagents: PiExtension = { const detail = { type: "sub_agent" as const, description: details.success ? details.data.description : undefined, - log: extractTextFromToolResult(call.result)?.trim() ?? "", + // COMPAT(subagent-log): remove after 2027-04-30. + log: "", }; if (call.status === "running" || !details.success) return { detail }; const file = details.data.transcriptPath; diff --git a/packages/server/src/server/agent/providers/pi/extensions/index.test.ts b/packages/server/src/server/agent/providers/pi/extensions/index.test.ts index 0bf32a9f2f..d374c1a02d 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/index.test.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/index.test.ts @@ -210,7 +210,7 @@ describe("Pi extension host", () => { type: "sub_agent", subAgentType: "scout", description: "Inspect files", - log: "Found two files", + log: "", }, }), ); diff --git a/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts b/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts index 8e9db54047..740d66aadc 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts @@ -137,7 +137,7 @@ describe("pi-subagents adapter", () => { type: "sub_agent", subAgentType: "reviewer", description: "Review the Pi mapper change", - log: "The mapper change preserves provider status.", + log: "", }); }); diff --git a/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.ts b/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.ts index 64e814af3f..058171e8df 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.ts @@ -131,7 +131,8 @@ export const piSubagents: PiExtension = { type: "sub_agent" as const, subAgentType: args.data.agent, description: args.data.task, - log: extractTextFromToolResult(call.result)?.trim() ?? "", + // COMPAT(subagent-log): remove after 2027-04-30. + log: "", }; const base = { type: "upsert" as const, diff --git a/packages/server/src/server/agent/providers/pi/extensions/subagent-fixture-test.ts b/packages/server/src/server/agent/providers/pi/extensions/subagent-fixture-test.ts index 8a9ca61d08..96ba771594 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/subagent-fixture-test.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/subagent-fixture-test.ts @@ -54,8 +54,6 @@ export async function verifySubagentFixture( try { await session.startTurn("Delegate work"); for (const event of fixture.events) pi.latestSession().emit(event); - const immediate = live.filter((event) => event.type === "provider_subagent"); - expect(immediate.some((event) => event.event.type === "timeline")).toBe(false); pi.latestSession().finishTurn(); const replay: AgentStreamEvent[] = []; for await (const event of streamPiHistory("pi", fixture.messages)) replay.push(event); diff --git a/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.test.ts b/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.test.ts index ad666bb4d3..60f3b12869 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.test.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.test.ts @@ -36,12 +36,17 @@ describe("@tintinweb/pi-subagents adapter", () => { }); expect(mapping?.subagents).toEqual([ expect.objectContaining({ id: "call-1", status: "completed" }), + { + type: "timeline", + id: "call-1", + item: { type: "assistant_message", text: "Wrapped up (turn limit)" }, + }, ]); expect(mapping?.detail).toEqual({ type: "sub_agent", subAgentType: "Explore", description: "Inspect", - log: "Wrapped up (turn limit)", + log: "", }); }); test("maps captured foreground lifecycle live and on replay", async () => { @@ -53,6 +58,18 @@ describe("@tintinweb/pi-subagents adapter", () => { .filter((event) => event.event.type === "upsert") .map((event) => (event.event.type === "upsert" ? event.event.status : null)), ).toEqual(["running", "completed"]); + expect( + events.flatMap((event) => (event.event.type === "timeline" ? [event.event] : [])), + ).toEqual([ + { + type: "timeline", + id: "call_458926", + item: { + type: "assistant_message", + text: "Agent completed in 6.6s (1 tool uses, 18.8k token).\n\nThe workspace directory `/tmp/paseo-pi-extension-phase3/workspace` is empty (no files or hidden dotfiles).", + }, + }, + ]); }); test("uses a structured notification to complete a background child", async () => { const source = readSubagentFixture(new URL("./fixtures/background.json", import.meta.url)); @@ -147,7 +164,7 @@ describe("@tintinweb/pi-subagents adapter", () => { expect(mapping?.detail).toEqual({ type: "sub_agent", description: undefined, - log: "Wrapped up (turn limit)", + log: "", }); }, ); diff --git a/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.ts b/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.ts index 56b5935172..6858dec8be 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.ts @@ -57,7 +57,8 @@ export const tintinwebPiSubagents: PiExtension = { type: "sub_agent" as const, subAgentType: title, description, - log: extractTextFromToolResult(call.result)?.trim() ?? "", + // COMPAT(subagent-log): remove after 2027-04-30. + log: "", }; if (call.status === "running") return { @@ -84,6 +85,10 @@ export const tintinwebPiSubagents: PiExtension = { const file = outputFileFromToolResult(call.result); const childSessions = file && !readSessions.has(file) ? [{ id: call.callId, file }] : []; if (file) readSessions.add(file); + const childStatus = status(details.data.status); + // A foreground child has no session file; its answer is the tool result. + const answer = + !file && childStatus !== "running" ? extractTextFromToolResult(call.result)?.trim() : ""; return { detail, subagents: [ @@ -93,8 +98,17 @@ export const tintinwebPiSubagents: PiExtension = { title, description, toolCallId: call.callId, - status: status(details.data.status), + status: childStatus, }, + ...(answer + ? [ + { + type: "timeline" as const, + id: call.callId, + item: { type: "assistant_message" as const, text: answer }, + }, + ] + : []), ], childSessions, }; @@ -110,7 +124,8 @@ export const tintinwebPiSubagents: PiExtension = { const detail = { type: "sub_agent" as const, description: details.success ? details.data.description : undefined, - log: extractTextFromToolResult(call.result)?.trim() ?? "", + // COMPAT(subagent-log): remove after 2027-04-30. + log: "", }; if (call.status === "running" || !details.success) return { detail }; return { diff --git a/packages/server/src/server/daemon-e2e/background-subagent-action-log.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/background-subagent-action-log.real.e2e.test.ts index 7eba32f22a..557f8360de 100644 --- a/packages/server/src/server/daemon-e2e/background-subagent-action-log.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/background-subagent-action-log.real.e2e.test.ts @@ -1,6 +1,3 @@ -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; -import { fileURLToPath } from "node:url"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -10,7 +7,7 @@ import { ClaudeAgentClient } from "../agent/providers/claude/agent.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; import { DaemonClient } from "../test-utils/daemon-client.js"; -test("background cards retain child actions after the parent finishes (#6253)", async () => { +test("background children keep their actions after the parent finishes (#6253)", async () => { const cwd = mkdtempSync(path.join(tmpdir(), "paseo-background-card-")); const logger = pino({ level: "silent" }); const daemon = await createTestPaseoDaemon({ @@ -62,22 +59,6 @@ test("background cards retain child actions after the parent finishes (#6253)", limit: 0, projection: "projected", }); - const { stdout } = await promisify(execFile)( - process.execPath, - [ - "--conditions=source", - "--import", - "tsx", - fileURLToPath(new URL("../../../../cli/src/index.ts", import.meta.url)), - "--host", - `127.0.0.1:${daemon.port}`, - "logs", - agent.id, - ], - { env: { ...process.env, PASEO_HOME: daemon.paseoHome }, timeout: 30_000 }, - ); - expect(stdout).toContain("[Bash] ls -1"); - expect(stdout).toContain("[Bash] pwd"); expect( atFinish.entries.filter( (row) => row.item.type === "tool_call" && row.item.detail?.type === "sub_agent", @@ -94,9 +75,6 @@ test("background cards retain child actions after the parent finishes (#6253)", ), ).toBe(true); } - for (const card of cards) { - expect(card.detail?.type === "sub_agent" ? card.detail.log : "").toContain("[Bash]"); - } } finally { await client.close(); await daemon.close(); From a4f696620b5d248598a882b1a78d82ec5038c74b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 30 Oct 2026 21:27:27 +0700 Subject: [PATCH 31/95] Keep big agents loadable by bounding every timeline item (#8274) * fix(server): bound every timeline item so one fetch cannot close the socket A timeline page is one WebSocket message. Only three tool-call fields were capped, so MCP results, file contents, errors and merged assistant rows could make a 20-entry or even a 1-entry fetch pass the outbound high-water mark. limitAgentTimelineItemContent is now one budgeted walk over any item: 64 KiB for tool calls, 256 KiB for other items. Over budget, the largest strings are cut to a shared length with a visible marker and oversized data URLs become a marker. Fetches also apply it to merged assistant and reasoning rows. * fix(server): keep short strings whole and typed arrays valid when limiting items Strings up to 1 KiB are never cut, so a tool call keeps its callId and an item limits to the same result twice. Arrays drop trailing elements without inserting a marker element, which typed arrays such as todo items would reject. --- docs/timeline-sync.md | 14 +- .../agent-manager-stream-coalescing.test.ts | 29 +-- .../src/server/agent/agent-manager.test.ts | 12 +- .../agent/agent-timeline-content.test.ts | 133 ++++++++++--- .../server/agent/agent-timeline-content.ts | 179 ++++++++++++------ .../server/agent/agent-timeline-store.test.ts | 14 ++ .../src/server/agent/agent-timeline-store.ts | 8 +- .../agent/provider-subagents/store.test.ts | 2 +- .../claude/agent.sub-agent-replay.test.ts | 7 +- .../daemon-e2e/oversized-timeline.e2e.test.ts | 46 +++++ 10 files changed, 328 insertions(+), 116 deletions(-) create mode 100644 packages/server/src/server/daemon-e2e/oversized-timeline.e2e.test.ts diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index f205d78924..3d6af249bf 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -21,10 +21,16 @@ The invariants are: > agent establishes the daemon's current tail in one complete-turn request, with older history reachable > through backward pagination. -Tool output is bounded before it enters either delivery path. Canonical shell tool output is sliced -to 64 KiB, and the same bounded item is used for runtime timeline rows and live stream events. -Provider history hydration applies the same rule so reopening an agent cannot restore an oversized -tool payload. +Every item is bounded before it enters either delivery path, because a timeline page is one +WebSocket message and the daemon closes a socket whose outbound buffer passes 64 MiB. Tool calls get +64 KiB and other items 256 KiB. Over budget, the largest strings are cut to a shared length with a +`…[truncated 12.3 MB]` marker, and an oversized `data:` URL is replaced by the marker. Strings up +to 1 KiB stay whole, so ids and paths survive. Arrays drop their trailing elements without a marker +element, so typed arrays still match the wire schema. Limiting an item twice gives the same item. The +budget does not bound wide objects or multi-byte text. The same +bounded item is used for runtime timeline rows and live stream events. Provider history hydration +applies the same rule so reopening an agent cannot restore an oversized payload. Fetches apply it +again to assistant and reasoning rows the projection merges from many chunks. ## Presence is not delivery diff --git a/packages/server/src/server/agent/agent-manager-stream-coalescing.test.ts b/packages/server/src/server/agent/agent-manager-stream-coalescing.test.ts index 9dc194fa11..c423264869 100644 --- a/packages/server/src/server/agent/agent-manager-stream-coalescing.test.ts +++ b/packages/server/src/server/agent/agent-manager-stream-coalescing.test.ts @@ -6,6 +6,7 @@ import { afterEach, describe, expect, test, vi } from "vitest"; import { createTestLogger } from "../../test-utils/test-logger.js"; import { AgentManager, type AgentManagerEvent } from "./agent-manager.js"; import { AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS } from "./agent-stream-coalescer.js"; +import { limitAgentTimelineItemContent } from "./agent-timeline-content.js"; import type { AgentTimelineRow } from "./agent-timeline-store-types.js"; import { projectTimelineRows } from "./timeline-projection.js"; import type { @@ -31,7 +32,6 @@ import type { const COALESCE_WINDOW_MS = AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS; const BEFORE_COALESCE_WINDOW_MS = Math.max(COALESCE_WINDOW_MS - 1, 0); -const TOOL_CALL_CONTENT_MAX_LENGTH = 64 * 1024; const TEST_CAPABILITIES: AgentCapabilityFlags = { supportsStreaming: false, @@ -400,10 +400,7 @@ describe("target coalesced behavior", () => { try { const { agentId, session } = await createManagedSession(harness); const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`; - const expectedItem = toolCall({ - status: "completed", - output: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH), - }); + const expectedItem = limitAgentTimelineItemContent(toolCall({ status: "completed", output })); session.pushEvent(timelineEvent(toolCall({ status: "completed", output }))); await waitForSessionEventQueue(); @@ -425,10 +422,7 @@ describe("target coalesced behavior", () => { try { const { agentId } = await createManagedSession(harness); const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`; - const expectedItem = toolCall({ - status: "completed", - output: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH), - }); + const expectedItem = limitAgentTimelineItemContent(toolCall({ status: "completed", output })); await harness.manager.appendTimelineItem(agentId, toolCall({ status: "completed", output })); @@ -449,10 +443,7 @@ describe("target coalesced behavior", () => { try { const { agentId, session } = await createManagedSession(harness); const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`; - const expectedItem = toolCall({ - status: "completed", - output: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH), - }); + const expectedItem = limitAgentTimelineItemContent(toolCall({ status: "completed", output })); session.setHistory([timelineEvent(toolCall({ status: "completed", output }))]); await harness.manager.hydrateTimelineFromProvider(agentId, { force: true }); @@ -470,10 +461,7 @@ describe("target coalesced behavior", () => { try { const { agentId } = await createManagedSession(harness); const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`; - const expectedItem = toolCall({ - status: "completed", - output: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH), - }); + const expectedItem = limitAgentTimelineItemContent(toolCall({ status: "completed", output })); await harness.manager.emitLiveTimelineItem( agentId, @@ -495,10 +483,9 @@ describe("target coalesced behavior", () => { try { const { agentId, session } = await createManagedSession(harness); const content = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`; - const expectedItem = toolCall({ - status: "failed", - error: { content: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH) }, - }); + const expectedItem = limitAgentTimelineItemContent( + toolCall({ status: "failed", error: { content } }), + ); session.pushEvent(timelineEvent(toolCall({ status: "failed", error: { content } }))); await waitForSessionEventQueue(); diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 902fd79ebc..4b3fb5ea46 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -21,6 +21,7 @@ import { } from "./agent-manager.js"; import { AgentStorage } from "./agent-storage.js"; import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js"; +import { limitAgentTimelineItemContent } from "./agent-timeline-content.js"; import { toAgentPayload } from "./agent-projections.js"; import { projectTimelineRows } from "./timeline-projection.js"; import { getOpenAgentTabLabel, PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; @@ -4592,7 +4593,7 @@ test("importProviderSession imports the selected session without listing and pub expect(manager.getTimeline(imported.id)).toEqual([ { type: "user_message", text: "Trace provider imports" }, { type: "assistant_message", text: "Done" }, - { + limitAgentTimelineItemContent({ type: "tool_call", callId: "large-shell-result", name: "shell", @@ -4601,10 +4602,10 @@ test("importProviderSession imports the selected session without listing and pub detail: { type: "shell", command: "print output", - output: "x".repeat(64 * 1024), + output: "x".repeat(1024 * 1024), exitCode: 0, }, - }, + }), ]); expect(manager.listProviderSubagents(imported.id)).toEqual([ expect.objectContaining({ id: "thread-child", title: "Imported child", status: "completed" }), @@ -11957,10 +11958,7 @@ test("commits startup notices once on create and after restored history", async exitCode: 0, }, }; - const limitedToolOutput: AgentTimelineItem = { - ...toolOutput, - detail: { type: "shell", command: "print output", output: "x".repeat(64 * 1024), exitCode: 0 }, - }; + const limitedToolOutput = limitAgentTimelineItemContent(toolOutput); class NotifyingSession extends TestAgentSession { readonly initialTimeline = [{ item: notice, timestamp: "2026-09-22T00:00:00.000Z" }]; override async *streamHistory(): AsyncGenerator { diff --git a/packages/server/src/server/agent/agent-timeline-content.test.ts b/packages/server/src/server/agent/agent-timeline-content.test.ts index 1ccb78c67a..1461d0aeef 100644 --- a/packages/server/src/server/agent/agent-timeline-content.test.ts +++ b/packages/server/src/server/agent/agent-timeline-content.test.ts @@ -1,12 +1,30 @@ +import { AgentTimelineItemPayloadSchema } from "@getpaseo/protocol/messages"; import { describe, expect, test } from "vitest"; +import type { AgentTimelineItem } from "./agent-sdk-types.js"; import { limitAgentTimelineItemContent } from "./agent-timeline-content.js"; +function serializedBytes(item: AgentTimelineItem): number { + return Buffer.byteLength(JSON.stringify(item), "utf8"); +} + describe("agent timeline content", () => { + test("returns an item within budget unchanged", () => { + const item: AgentTimelineItem = { + type: "tool_call", + callId: "small", + name: "Bash", + status: "completed", + error: null, + detail: { type: "shell", command: "ls", output: "x".repeat(60 * 1024) }, + }; + expect(limitAgentTimelineItemContent(item)).toBe(item); + }); + test.each([false, true])( - "preserves the exact UTF-16 prefix for oversized shell output (failed=%s)", + "cuts oversized shell output and keeps short fields whole (failed=%s)", (failed) => { - const text = "x".repeat(64 * 1024 - 1) + "🙂"; + const text = "x".repeat(1024 * 1024) + "🙂"; const item = limitAgentTimelineItemContent({ type: "tool_call", callId: "large-shell", @@ -15,45 +33,116 @@ describe("agent timeline content", () => { detail: { type: "shell", command: "echo large", output: text }, error: failed ? { content: text } : null, }); - expect(item).toEqual({ - type: "tool_call", + expect(item).toMatchObject({ callId: "large-shell", name: "Bash", - status: failed ? "failed" : "completed", - detail: { type: "shell", command: "echo large", output: text.slice(0, 64 * 1024) }, - error: failed ? { content: text.slice(0, 64 * 1024) } : null, + detail: { type: "shell", command: "echo large" }, }); + if (item.type !== "tool_call" || item.detail.type !== "shell") throw new Error("shape"); + expect(item.detail.output).toMatch(/^x+…\[truncated \d{3}\.\d KB\]$/); + expect(serializedBytes(item)).toBeLessThanOrEqual(64 * 1024); }, ); - test("marks truncated plain-text tool content within the content budget", () => { - const oversizedInput = "x".repeat(64 * 1024 + 1); - + test("replaces an oversized data URL in an unknown tool result with a marker", () => { + const url = `data:image/jpeg;base64,${"A".repeat(14 * 1024 * 1024)}`; const item = limitAgentTimelineItemContent({ type: "tool_call", - callId: "terminal-session-4242", - name: "terminal", + callId: "screenshot", + name: "node_repl.js", status: "completed", error: null, detail: { - type: "plain_text", - text: oversizedInput, - icon: "square_terminal", + type: "unknown", + input: { index: 1 }, + output: { content: [{ type: "image", url }], note: "captured" }, }, }); - - expect(limitAgentTimelineItemContent(item)).toEqual(item); expect(item).toEqual({ type: "tool_call", - callId: "terminal-session-4242", - name: "terminal", + callId: "screenshot", + name: "node_repl.js", status: "completed", error: null, detail: { - type: "plain_text", - text: "x".repeat(64 * 1024 - "\n\n[Content truncated]".length) + "\n\n[Content truncated]", - icon: "square_terminal", + type: "unknown", + input: { index: 1 }, + output: { content: [{ type: "image", url: "…[truncated 14.0 MB]" }], note: "captured" }, }, }); }); + + test("gives non-tool items a 256 KiB budget", () => { + const text = "a".repeat(10 * 1024 * 1024); + const item = limitAgentTimelineItemContent({ type: "assistant_message", text }); + if (item.type !== "assistant_message") throw new Error("shape"); + expect(item.text).toMatch(/^a+…\[truncated 9\.\d MB\]$/); + expect(item.text.length).toBeGreaterThan(200 * 1024); + expect(serializedBytes(item)).toBeLessThanOrEqual(256 * 1024); + }); + + test("bounds an item whose structure alone exceeds the budget", () => { + const item = limitAgentTimelineItemContent({ + type: "tool_call", + callId: "many", + name: "query", + status: "completed", + error: null, + detail: { type: "unknown", input: null, output: Array.from({ length: 1_000_000 }, () => 1) }, + }); + if (item.type !== "tool_call" || item.detail.type !== "unknown") throw new Error("shape"); + const output = item.detail.output as unknown[]; + expect(output.length).toBeLessThan(1_000_000); + expect(output.every((value) => value === 1)).toBe(true); + expect(serializedBytes(item)).toBeLessThanOrEqual(64 * 1024); + }); + + test("keeps short strings whole and limits idempotently when an item has many strings", () => { + const callId = "3f2b8c1e-9a4d-4e6f-8b2a-1c3d5e7f9a0b"; + const item = limitAgentTimelineItemContent({ + type: "tool_call", + callId, + name: "mcp__search__files", + status: "completed", + error: null, + detail: { + type: "unknown", + input: { query: "useWorkspaceSomethingVeryDescriptive" }, + output: Array.from( + { length: 10_000 }, + (_, index) => `${String(index).padStart(5, "0")}${"p".repeat(28)}`, + ), + }, + }); + if (item.type !== "tool_call" || item.detail.type !== "unknown") throw new Error("shape"); + expect(item.callId).toBe(callId); + expect(item.detail.input).toEqual({ query: "useWorkspaceSomethingVeryDescriptive" }); + const output = item.detail.output as string[]; + expect(output.length).toBeLessThan(10_000); + expect(output.every((path) => path.length === 33)).toBe(true); + expect(serializedBytes(item)).toBeLessThanOrEqual(64 * 1024); + expect(limitAgentTimelineItemContent(item)).toBe(item); + }); + + test("keeps a long typed array valid against the wire schema", () => { + const item = limitAgentTimelineItemContent({ + type: "todo", + items: Array.from({ length: 20_000 }, (_, index) => ({ + text: `Task ${index}`, + completed: false, + })), + }); + if (item.type !== "todo") throw new Error("shape"); + expect(item.items.length).toBeLessThan(20_000); + expect(AgentTimelineItemPayloadSchema.safeParse(item).success).toBe(true); + expect(serializedBytes(item)).toBeLessThanOrEqual(256 * 1024); + }); + + test("is stable on an already-limited item", () => { + const item = limitAgentTimelineItemContent({ + type: "reasoning", + text: "r".repeat(1024 * 1024), + }); + expect(limitAgentTimelineItemContent(item)).toBe(item); + }); }); diff --git a/packages/server/src/server/agent/agent-timeline-content.ts b/packages/server/src/server/agent/agent-timeline-content.ts index 003239b283..5117073e14 100644 --- a/packages/server/src/server/agent/agent-timeline-content.ts +++ b/packages/server/src/server/agent/agent-timeline-content.ts @@ -1,9 +1,19 @@ import type { AgentTimelineItem } from "./agent-sdk-types.js"; import type { JsonValue } from "@getpaseo/protocol/agent-types"; -const TOOL_CALL_CONTENT_MAX_LENGTH = 64 * 1024; export const PLUGIN_TIMELINE_DATA_MAX_BYTES = 64 * 1024; +// One timeline fetch sends its entries in one WebSocket message, so every item must stay small. +// Budgets count UTF-16 units plus a fixed cost per node; neither is exact JSON bytes. They bound +// long strings and long arrays, not wide objects (every entry is kept) or multi-byte text. +const TOOL_CALL_BUDGET = 64 * 1024; +const ITEM_BUDGET = 256 * 1024; +const NODE_COST = 8; +const MARKER_COST = 32; +// Ids, names, paths and queries stay whole, so an item keeps its identity across passes. +const SHORT_STRING = 1024; +const MAX_DEPTH = 64; + export function assertPluginTimelineDataSize(data: JsonValue): void { const serializedBytes = Buffer.byteLength(JSON.stringify(data), "utf8"); if (serializedBytes > PLUGIN_TIMELINE_DATA_MAX_BYTES) { @@ -17,66 +27,119 @@ function retainTextPrefix(text: string, length: number): string { return Buffer.from(text.slice(0, length), "utf16le").toString("utf16le"); } -function limitFailedShellError(item: AgentTimelineItem): AgentTimelineItem { - if ( - item.type !== "tool_call" || - item.detail.type !== "shell" || - item.status !== "failed" || - typeof item.error !== "object" || - item.error === null || - !("content" in item.error) || - typeof item.error.content !== "string" || - item.error.content.length <= TOOL_CALL_CONTENT_MAX_LENGTH - ) { - return item; - } - return { - ...item, - error: { - ...item.error, - content: retainTextPrefix(item.error.content, TOOL_CALL_CONTENT_MAX_LENGTH), - }, - }; +function formatSize(units: number): string { + if (units < 1024) return `${units} B`; + if (units < 1024 * 1024) return `${(units / 1024).toFixed(1)} KB`; + return `${(units / (1024 * 1024)).toFixed(1)} MB`; } -function limitPlainText(item: AgentTimelineItem): AgentTimelineItem { - if ( - item.type !== "tool_call" || - item.detail.type !== "plain_text" || - typeof item.detail.text !== "string" || - item.detail.text.length <= TOOL_CALL_CONTENT_MAX_LENGTH - ) { - return item; - } - const notice = "\n\n[Content truncated]"; - return { - ...item, - detail: { - ...item.detail, - text: - retainTextPrefix(item.detail.text, TOOL_CALL_CONTENT_MAX_LENGTH - notice.length) + notice, - }, - }; +function truncatedMarker(units: number): string { + return `…[truncated ${formatSize(units)}]`; } +interface ItemSize { + nodeCost: number; + stringLengths: number[]; + tooDeep: boolean; +} + +function measure(value: unknown, depth: number, size: ItemSize): void { + size.nodeCost += NODE_COST; + if (depth > MAX_DEPTH) { + size.tooDeep = true; + } else if (typeof value === "string") { + size.stringLengths.push(value.length); + } else if (Array.isArray(value)) { + for (const element of value) measure(element, depth + 1, size); + } else if (typeof value === "object" && value !== null) { + for (const [key, child] of Object.entries(value)) { + size.nodeCost += key.length; + measure(child, depth + 1, size); + } + } +} + +/** The largest per-string length that keeps all strings, once cut, within `available`. */ +function findStringCap(lengths: readonly number[], available: number): number { + let low = 0; + let high = 0; + for (const length of lengths) high = Math.max(high, length); + while (low < high) { + const cap = Math.ceil((low + high) / 2); + let total = 0; + for (const length of lengths) total += Math.min(length, cap); + if (total <= available) low = cap; + else high = cap - 1; + } + return low; +} + +interface CutState { + stringCap: number; + remaining: number; +} + +function cutString(text: string, cap: number): string { + if (text.length <= cap) return text; + // A partial data URL is useless to every reader, so drop it whole. + if (text.startsWith("data:")) return truncatedMarker(text.length); + return retainTextPrefix(text, cap) + truncatedMarker(text.length - cap); +} + +function cut(value: unknown, depth: number, state: CutState): unknown { + state.remaining -= NODE_COST; + if (depth > MAX_DEPTH) return "…[truncated]"; + if (typeof value === "string") { + const text = cutString(value, state.stringCap); + state.remaining -= text.length; + return text; + } + if (Array.isArray(value)) { + let changed = false; + const output: unknown[] = []; + for (const element of value) { + const remainingBefore = state.remaining; + const next = cut(element, depth + 1, state); + if (state.remaining < 0) { + // Drop this and later elements. A marker element would break typed arrays. + state.remaining = remainingBefore; + changed = true; + break; + } + changed ||= next !== element; + output.push(next); + } + return changed ? output : value; + } + if (typeof value === "object" && value !== null) { + let changed = false; + const output: Record = {}; + for (const [key, child] of Object.entries(value)) { + state.remaining -= key.length; + const next = cut(child, depth + 1, state); + changed ||= next !== child; + output[key] = next; + } + return changed ? output : value; + } + return value; +} + +/** Returns the item itself when it fits its budget; otherwise a copy with its largest strings cut. */ export function limitAgentTimelineItemContent(item: AgentTimelineItem): AgentTimelineItem { - item = limitFailedShellError(item); - item = limitPlainText(item); - if ( - item.type !== "tool_call" || - item.detail.type !== "shell" || - typeof item.detail.output !== "string" - ) { - return item; - } - if (item.detail.output.length <= TOOL_CALL_CONTENT_MAX_LENGTH) { - return item; - } - return { - ...item, - detail: { - ...item.detail, - output: retainTextPrefix(item.detail.output, TOOL_CALL_CONTENT_MAX_LENGTH), - }, - }; + const budget = item.type === "tool_call" ? TOOL_CALL_BUDGET : ITEM_BUDGET; + const size: ItemSize = { nodeCost: 0, stringLengths: [], tooDeep: false }; + measure(item, 0, size); + let stringTotal = 0; + for (const length of size.stringLengths) stringTotal += length; + if (!size.tooDeep && size.nodeCost + stringTotal <= budget) return item; + + // Strings get what structure leaves, but at least half the budget, less a margin for JSON + // escaping. Arrays drop the elements that do not fit, which bounds items whose short strings + // or structure alone are too big. + const markerCost = + MARKER_COST * size.stringLengths.filter((length) => length > SHORT_STRING).length; + const available = (budget * 15) / 16 - Math.min(size.nodeCost, budget / 2) - markerCost; + const stringCap = Math.max(SHORT_STRING, findStringCap(size.stringLengths, available)); + return cut(item, 0, { stringCap, remaining: budget }) as AgentTimelineItem; } diff --git a/packages/server/src/server/agent/agent-timeline-store.test.ts b/packages/server/src/server/agent/agent-timeline-store.test.ts index 5d3c836e65..9ec4df4d55 100644 --- a/packages/server/src/server/agent/agent-timeline-store.test.ts +++ b/packages/server/src/server/agent/agent-timeline-store.test.ts @@ -181,6 +181,20 @@ describe("projected timeline retention", () => { }, ]); }); + + it("serves a merged assistant message within the item budget", () => { + const store = new InMemoryAgentTimelineStore(); + store.initialize("agent", { epoch: "e" }); + const chunk = "a".repeat(200 * 1024); + for (let index = 0; index < 10; index++) { + store.append("agent", { type: "assistant_message", messageId: "m", text: chunk }); + } + const [row] = store.fetch("agent", { limit: 1 }).rows; + expect(row).toMatchObject({ seqStart: 1, seqEnd: 10 }); + expect(row.item).toMatchObject({ type: "assistant_message", messageId: "m" }); + expect(Buffer.byteLength(JSON.stringify(row.item))).toBeLessThanOrEqual(256 * 1024); + expect(store.getLastAssistantMessage("agent")).toHaveLength(10 * chunk.length); + }); }); describe("projected sequence ownership", () => { diff --git a/packages/server/src/server/agent/agent-timeline-store.ts b/packages/server/src/server/agent/agent-timeline-store.ts index 49b5013fd4..4c3b9c5c3a 100644 --- a/packages/server/src/server/agent/agent-timeline-store.ts +++ b/packages/server/src/server/agent/agent-timeline-store.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import { TimelineProjection, type ProjectedTimelineRow } from "./timeline-projection.js"; +import { limitAgentTimelineItemContent } from "./agent-timeline-content.js"; import type { AgentTimelineItem } from "./agent-sdk-types.js"; import type { AgentTimelineFetchOptions, @@ -120,7 +121,12 @@ export class InMemoryAgentTimelineStore { hasNewer: page.hasNewer, startSeq: page.startSeq, endSeq: page.endSeq, - rows: page.entries.map((entry) => Object.assign({ seq: entry.seqEnd }, entry)), + // Stored rows are already within budget; this bounds assistant and reasoning merges. + rows: page.entries.map((entry) => + Object.assign({ seq: entry.seqEnd }, entry, { + item: limitAgentTimelineItemContent(entry.item), + }), + ), }; } diff --git a/packages/server/src/server/agent/provider-subagents/store.test.ts b/packages/server/src/server/agent/provider-subagents/store.test.ts index dc71c0f5c6..5c2ca55174 100644 --- a/packages/server/src/server/agent/provider-subagents/store.test.ts +++ b/packages/server/src/server/agent/provider-subagents/store.test.ts @@ -80,7 +80,7 @@ describe("ProviderSubagentStore", () => { const [row] = subagents.fetchTimeline("parent-a", "child-1").rows; expect(row?.item).toMatchObject({ type: "tool_call", - detail: { type: "shell", output: "x".repeat(64 * 1024) }, + detail: { type: "shell", output: expect.stringMatching(/^x+…\[truncated \d+\.\d KB\]$/) }, }); }); 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 9fc82d6de8..4728767307 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 @@ -737,7 +737,7 @@ describe("ClaudeAgentSession persisted subagent replay", () => { ); }, 120_000); - test("limits retained child shell output to the timeline's existing content limit", async () => { + test("limits retained child shell output to the timeline item budget", async () => { const output = "x".repeat(128 * 1024); writeSession({ parentLines: [taskToolUse(), taskToolResult()], @@ -782,7 +782,10 @@ describe("ClaudeAgentSession persisted subagent replay", () => { type: "timeline", id: TOOL_USE_ID, item: expect.objectContaining({ - detail: expect.objectContaining({ type: "shell", output: output.slice(0, 64 * 1024) }), + detail: expect.objectContaining({ + type: "shell", + output: expect.stringMatching(/^x+…\[truncated \d+\.\d KB\]$/), + }), }), }), ]); diff --git a/packages/server/src/server/daemon-e2e/oversized-timeline.e2e.test.ts b/packages/server/src/server/daemon-e2e/oversized-timeline.e2e.test.ts new file mode 100644 index 0000000000..bc78db2eab --- /dev/null +++ b/packages/server/src/server/daemon-e2e/oversized-timeline.e2e.test.ts @@ -0,0 +1,46 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, test } from "vitest"; +import { createDaemonTestContext } from "../test-utils/index.js"; + +// #2220: a timeline page is one WebSocket message, so a few huge entries used to close the socket. +test.each([40, 0])( + "fetching image-heavy history with limit %i keeps the direct connection usable", + async (limit) => { + const ctx = await createDaemonTestContext(); + const cwd = mkdtempSync(path.join(tmpdir(), "oversized-timeline-")); + try { + const agent = await ctx.client.createAgent({ provider: "codex", cwd }); + const imageUrl = `data:image/jpeg;base64,${"A".repeat(14 * 1024 * 1024)}`; + for (let index = 0; index < 5; index += 1) { + await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, { + type: "tool_call", + callId: `screenshot-${index}`, + name: "node_repl.js", + status: "completed", + error: null, + detail: { + type: "unknown", + input: { index }, + output: { _meta: { "codex/toolSurface": { screenshot: { url: imageUrl } } } }, + }, + }); + } + + const page = await ctx.client.fetchAgentTimeline(agent.id, { direction: "tail", limit }); + const screenshots = page.entries.filter((entry) => entry.item.type === "tool_call"); + expect(screenshots).toHaveLength(5); + for (const entry of screenshots) { + expect(Buffer.byteLength(JSON.stringify(entry.item))).toBeLessThanOrEqual(64 * 1024); + } + + const next = await ctx.client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 1 }); + expect(next.entries).toHaveLength(1); + } finally { + await ctx.cleanup(); + rmSync(cwd, { recursive: true, force: true }); + } + }, + 60_000, +); From 186c132151ff99889db0572534bd400acdf2cc5b Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 00:33:29 +0000 Subject: [PATCH 32/95] test(server): isolate the reconciliation responsiveness budget (#8317) * test(server): isolate the reconciliation responsiveness budget Measure the cold reconciliation in compiled plain Node so unrelated runner callbacks cannot consume its 20ms budget. Retain real filesystem admission and every archival assertion, with unrelated CPU work in the parent as a regression check. * test(server): initialize probe filesystem support before measurement --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../reconciliation-registries.ts | 117 ++++++++++ .../reconciliation-responsiveness.ts | 75 ++++++ .../workspace-reconciliation-service.test.ts | 214 ++++-------------- 3 files changed, 242 insertions(+), 164 deletions(-) create mode 100644 packages/server/src/server/test-fixtures/reconciliation-registries.ts create mode 100644 packages/server/src/server/test-fixtures/reconciliation-responsiveness.ts diff --git a/packages/server/src/server/test-fixtures/reconciliation-registries.ts b/packages/server/src/server/test-fixtures/reconciliation-registries.ts new file mode 100644 index 0000000000..2d020ec23f --- /dev/null +++ b/packages/server/src/server/test-fixtures/reconciliation-registries.ts @@ -0,0 +1,117 @@ +import type pino from "pino"; +import { createPersistedProjectRecord } from "../workspace-registry.js"; +import type { + PersistedProjectRecord, + PersistedWorkspaceRecord, + ProjectRegistry, + WorkspaceRegistry, +} from "../workspace-registry.js"; + +export function createTestRegistries() { + let archiveGate: { + started: ReturnType; + release: ReturnType; + } | null = null; + const holdNextArchive = () => { + archiveGate = { started: deferred(), release: deferred() }; + return archiveGate; + }; + const projects = new Map(); + const workspaces = new Map(); + + const projectRegistry: ProjectRegistry = { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => Array.from(projects.values()), + get: async (id: string) => projects.get(id) ?? null, + getOrCreateActiveByRoot: async (input) => { + const existing = Array.from(projects.values()).find( + (project) => !project.archivedAt && project.rootPath === input.rootPath, + ); + if (existing) return existing; + const record = createPersistedProjectRecord({ + projectId: `prj_${projects.size}`, + rootPath: input.rootPath, + kind: input.kind, + displayName: input.displayName, + createdAt: input.timestamp, + updatedAt: input.timestamp, + }); + projects.set(record.projectId, record); + return record; + }, + update: async (id, updater) => { + const current = projects.get(id); + if (!current) return null; + const updated = updater(current); + projects.set(id, updated); + return updated; + }, + upsert: async (record: PersistedProjectRecord) => { + projects.set(record.projectId, record); + }, + archive: async (id: string, archivedAt: string) => { + const existing = projects.get(id); + if (existing) { + projects.set(id, { ...existing, archivedAt, updatedAt: archivedAt }); + } + }, + remove: async (id: string) => { + projects.delete(id); + }, + }; + + const workspaceRegistry: WorkspaceRegistry = { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => Array.from(workspaces.values()), + get: async (id: string) => workspaces.get(id) ?? null, + update: async (id, updater) => { + const existing = workspaces.get(id); + if (!existing) return null; + const updated = updater(existing); + workspaces.set(id, updated); + return updated; + }, + upsert: async (record: PersistedWorkspaceRecord) => { + workspaces.set(record.workspaceId, record); + }, + archive: async (id: string, archivedAt: string) => { + const gate = archiveGate; + archiveGate = null; + if (gate) { + gate.started.resolve(); + await gate.release.promise; + } + const existing = workspaces.get(id); + if (!existing || existing.archivedAt) return false; + workspaces.set(id, { ...existing, archivedAt, updatedAt: archivedAt }); + return true; + }, + remove: async (id: string) => { + workspaces.delete(id); + }, + }; + + return { projects, workspaces, projectRegistry, workspaceRegistry, holdNextArchive }; +} + +export function createTestLogger() { + const logger = { + child: () => logger, + trace: () => undefined, + debug: () => undefined, + info: () => undefined, + warn: () => undefined, + error: () => undefined, + }; + return logger as unknown as pino.Logger; +} + +export function deferred(): { promise: Promise; resolve(): void } { + let resolve!: () => void; + const promise = new Promise((accept) => { + resolve = accept; + }); + return { promise, resolve }; +} diff --git a/packages/server/src/server/test-fixtures/reconciliation-responsiveness.ts b/packages/server/src/server/test-fixtures/reconciliation-responsiveness.ts new file mode 100644 index 0000000000..7df5414d2d --- /dev/null +++ b/packages/server/src/server/test-fixtures/reconciliation-responsiveness.ts @@ -0,0 +1,75 @@ +import path from "node:path"; +import { stat } from "node:fs/promises"; +import { setImmediate as nextTurn } from "node:timers/promises"; +import { measureMainThreadWork } from "../../test-utils/main-thread-work.js"; +import { createTestRegistries, createTestLogger } from "./reconciliation-registries.js"; +import { + createPersistedProjectRecord, + createPersistedWorkspaceRecord, +} from "../workspace-registry.js"; +import { WorkspaceReconciliationService } from "../workspace-reconciliation-service.js"; +import { deriveProjectKey } from "../project-key.js"; + +const rootPath = process.argv[2]; +if (!rootPath) throw new Error("Project root is required"); +const timestamp = "2025-01-01T00:00:00.000Z"; +const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); +projects.set( + "p1", + createPersistedProjectRecord({ + projectId: "p1", + rootPath, + projectKey: deriveProjectKey({ + rootPath, + remoteUrl: null, + worktreeRoot: null, + mainRepoRoot: null, + }), + kind: "non_git", + displayName: "project", + createdAt: timestamp, + updatedAt: timestamp, + }), +); +for (let i = 0; i < 20000; i++) { + const workspaceId = `w${i}`; + workspaces.set( + workspaceId, + createPersistedWorkspaceRecord({ + workspaceId, + projectId: "p1", + cwd: path.join(rootPath, workspaceId), + kind: "directory", + displayName: workspaceId, + createdAt: timestamp, + updatedAt: timestamp, + }), + ); +} +const service = new WorkspaceReconciliationService({ + projectRegistry, + workspaceRegistry, + logger: createTestLogger(), +}); + +// Compile before launch and measure in plain Node, outside the test runner's +// thread and TypeScript loader. Keep the first real reconciliation cold. +// Initialize async filesystem support using the bundle, leaving every project +// and workspace path untouched until the first reconciliation. +await stat(process.argv[1]); +await nextTurn(); +try { + const measured = await measureMainThreadWork("workspace reconciliation main-thread work", () => + service.runOnce(), + ); + process.stdout.write( + JSON.stringify({ + blockingMs: measured.blockingMs, + filesystemRequests: measured.filesystemRequests, + changesApplied: measured.result.changesApplied.length, + allArchived: [...workspaces.values()].every((workspace) => workspace.archivedAt !== null), + }), + ); +} finally { + service.dispose(); +} diff --git a/packages/server/src/server/workspace-reconciliation-service.test.ts b/packages/server/src/server/workspace-reconciliation-service.test.ts index 0e9f5a2c2d..7ef187d16e 100644 --- a/packages/server/src/server/workspace-reconciliation-service.test.ts +++ b/packages/server/src/server/workspace-reconciliation-service.test.ts @@ -1,27 +1,45 @@ -import { measureMainThreadWork } from "../test-utils/main-thread-work.js"; -import { execFileSync } from "node:child_process"; +import { execFile, execFileSync } from "node:child_process"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; +import { build } from "esbuild"; +import { + createTestRegistries, + createTestLogger, + deferred, +} from "./test-fixtures/reconciliation-registries.js"; import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages"; import type pino from "pino"; -import { afterEach, describe, expect, test } from "vitest"; +import { afterAll, beforeAll, afterEach, describe, expect, test } from "vitest"; import { createPersistedProjectRecord, createPersistedWorkspaceRecord, } from "./workspace-registry.js"; -import type { - PersistedProjectRecord, - PersistedWorkspaceRecord, - ProjectRegistry, - WorkspaceRegistry, -} from "./workspace-registry.js"; import { type ReconciliationChange, WorkspaceReconciliationService, } from "./workspace-reconciliation-service.js"; import { deriveProjectKey } from "./project-key.js"; +let probeDirectory: string; +let probePath: string; +beforeAll(async () => { + probeDirectory = mkdtempSync(path.join(tmpdir(), "reconciliation-probe-")); + probePath = path.join(probeDirectory, "reconciliation.mjs"); + await build({ + entryPoints: [ + fileURLToPath(new URL("./test-fixtures/reconciliation-responsiveness.ts", import.meta.url)), + ], + outfile: probePath, + bundle: true, + platform: "node", + format: "esm", + }); +}); +afterAll(() => rmSync(probeDirectory, { recursive: true, force: true })); + function canonicalLocalProjectKey(rootPath: string): string { return deriveProjectKey({ rootPath, @@ -31,107 +49,6 @@ function canonicalLocalProjectKey(rootPath: string): string { }); } -function createTestRegistries() { - let archiveGate: { - started: ReturnType; - release: ReturnType; - } | null = null; - const holdNextArchive = () => { - archiveGate = { started: deferred(), release: deferred() }; - return archiveGate; - }; - const projects = new Map(); - const workspaces = new Map(); - - const projectRegistry: ProjectRegistry = { - initialize: async () => {}, - existsOnDisk: async () => true, - list: async () => Array.from(projects.values()), - get: async (id: string) => projects.get(id) ?? null, - getOrCreateActiveByRoot: async (input) => { - const existing = Array.from(projects.values()).find( - (project) => !project.archivedAt && project.rootPath === input.rootPath, - ); - if (existing) return existing; - const record = createPersistedProjectRecord({ - projectId: `prj_${projects.size}`, - rootPath: input.rootPath, - kind: input.kind, - displayName: input.displayName, - createdAt: input.timestamp, - updatedAt: input.timestamp, - }); - projects.set(record.projectId, record); - return record; - }, - update: async (id, updater) => { - const current = projects.get(id); - if (!current) return null; - const updated = updater(current); - projects.set(id, updated); - return updated; - }, - upsert: async (record: PersistedProjectRecord) => { - projects.set(record.projectId, record); - }, - archive: async (id: string, archivedAt: string) => { - const existing = projects.get(id); - if (existing) { - projects.set(id, { ...existing, archivedAt, updatedAt: archivedAt }); - } - }, - remove: async (id: string) => { - projects.delete(id); - }, - }; - - const workspaceRegistry: WorkspaceRegistry = { - initialize: async () => {}, - existsOnDisk: async () => true, - list: async () => Array.from(workspaces.values()), - get: async (id: string) => workspaces.get(id) ?? null, - update: async (id, updater) => { - const existing = workspaces.get(id); - if (!existing) return null; - const updated = updater(existing); - workspaces.set(id, updated); - return updated; - }, - upsert: async (record: PersistedWorkspaceRecord) => { - workspaces.set(record.workspaceId, record); - }, - archive: async (id: string, archivedAt: string) => { - const gate = archiveGate; - archiveGate = null; - if (gate) { - gate.started.resolve(); - await gate.release.promise; - } - const existing = workspaces.get(id); - if (!existing || existing.archivedAt) return false; - workspaces.set(id, { ...existing, archivedAt, updatedAt: archivedAt }); - return true; - }, - remove: async (id: string) => { - workspaces.delete(id); - }, - }; - - return { projects, workspaces, projectRegistry, workspaceRegistry, holdNextArchive }; -} - -function createTestLogger() { - const logger = { - child: () => logger, - trace: () => undefined, - debug: () => undefined, - info: () => undefined, - warn: () => undefined, - error: () => undefined, - }; - return logger as unknown as pino.Logger; -} - interface CapturedLogRecord { message: string; payload: unknown; @@ -210,14 +127,6 @@ function createCheckout( }; } -function deferred(): { promise: Promise; resolve(): void } { - let resolve!: () => void; - const promise = new Promise((accept) => { - resolve = accept; - }); - return { promise, resolve }; -} - class TestCheckouts { readonly reads: string[] = []; private readonly checkouts = new Map(); @@ -626,53 +535,30 @@ describe("WorkspaceReconciliationService", () => { test("keeps the event loop responsive while inspecting 20000 real workspace paths", async () => { const rootPath = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-many-"))); tempDirs.push(rootPath); - const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); - projects.set( - "p1", - createPersistedProjectRecord({ - projectId: "p1", - rootPath, - projectKey: canonicalLocalProjectKey(rootPath), - kind: "non_git", - displayName: "project", - createdAt: timestamp, - updatedAt: timestamp, - }), - ); - for (let i = 0; i < 20000; i++) { - const workspaceId = `w${i}`; - workspaces.set( - workspaceId, - createPersistedWorkspaceRecord({ - workspaceId, - projectId: "p1", - cwd: path.join(rootPath, workspaceId), - kind: "directory", - displayName: workspaceId, - createdAt: timestamp, - updatedAt: timestamp, - }), - ); - } - const service = new WorkspaceReconciliationService({ - projectRegistry, - workspaceRegistry, - logger: createTestLogger(), + // Unrelated runner work must not consume the reconciliation budget. + const unrelatedWork = new Promise((resolve) => { + setImmediate(() => { + const cpu = Reflect.get(process, "threadCpuUsage") as typeof process.cpuUsage; + const start = cpu(); + while (cpu(start).user + cpu(start).system < 80_000) {} + resolve(); + }); }); - try { - const { result, blockingMs, filesystemRequests } = await measureMainThreadWork( - "workspace reconciliation main-thread work", - () => service.runOnce(), - ); - expect(blockingMs).toBeLessThan(20); - expect(filesystemRequests).toBe(20001); - expect(result.changesApplied).toHaveLength(20000); - expect([...workspaces.values()].every((workspace) => workspace.archivedAt !== null)).toBe( - true, - ); - } finally { - service.dispose(); - } + const { stdout, stderr } = await promisify(execFile)(process.execPath, [probePath, rootPath], { + timeout: 25_000, + }); + process.stderr.write(stderr); + const { changesApplied, blockingMs, filesystemRequests, allArchived } = JSON.parse(stdout) as { + changesApplied: number; + blockingMs: number; + filesystemRequests: number; + allArchived: boolean; + }; + await unrelatedWork; + expect(blockingMs).toBeLessThan(20); + expect(filesystemRequests).toBe(20001); + expect(changesApplied).toBe(20000); + expect(allArchived).toBe(true); }, 30000); test("preserves workspace archival that lands during boot reconciliation", async () => { From 0be49c06206578b57be6d04b1ea781d6a71d007b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 31 Oct 2026 09:26:24 +0700 Subject: [PATCH 33/95] feat(transport): keep large messages intact across relay connections (#8302) Negotiate shared binary chunk framing below application messages and routing, with bounded logical assembly and paced writes that respect encrypted relay limits. Preserve legacy admission, reconnect state, sibling route isolation, and native single-frame upload behavior. --- docs/protocol-compatibility.md | 27 ++ .../e2e/browser/composer-diff-stat.spec.ts | 3 +- .../new-workspace-composer-draft.spec.ts | 3 + .../src/utils/test-daemon-connection.test.ts | 19 + .../app/src/utils/test-daemon-connection.ts | 2 + packages/client/src/connection/index.ts | 1 + .../src/daemon-client-relay-e2ee-transport.ts | 17 +- .../src/daemon-client-transport-types.ts | 17 +- .../src/daemon-client-transport.test.ts | 15 + .../src/daemon-client-websocket-transport.ts | 20 + packages/client/src/daemon-client.test.ts | 451 +++++++++++++++--- packages/client/src/daemon-client.ts | 165 ++++--- packages/client/src/hosts/index.ts | 7 +- packages/client/src/node/connector.test.ts | 16 +- packages/client/src/node/index.ts | 8 +- .../protocol/src/binary-frames/host-route.ts | 5 + .../protocol/src/chunked-transport.test.ts | 392 +++++++++++++++ packages/protocol/src/chunked-transport.ts | 214 +++++++++ packages/protocol/src/client-capabilities.ts | 1 + packages/protocol/src/messages.ts | 1 + .../server/connected-hosts/hosts.e2e.test.ts | 77 +++ .../src/server/connected-hosts/index.ts | 9 +- .../server/src/server/connected-hosts/link.ts | 7 +- .../daemon-e2e/relay-transport.e2e.test.ts | 166 ++++--- .../server/daemon-e2e/terminal.e2e.test.ts | 55 +++ ...websocket-server.file-transfer.e2e.test.ts | 102 +++- .../server/src/server/websocket-server.ts | 128 +++-- .../websocket/encrypted-relay-socket.test.ts | 71 +++ .../websocket/encrypted-relay-socket.ts | 4 + .../server/websocket/physical-socket.test.ts | 93 ++++ 30 files changed, 1843 insertions(+), 253 deletions(-) create mode 100644 packages/protocol/src/chunked-transport.test.ts create mode 100644 packages/protocol/src/chunked-transport.ts diff --git a/docs/protocol-compatibility.md b/docs/protocol-compatibility.md index f7244e67ba..74650255a5 100644 --- a/docs/protocol-compatibility.md +++ b/docs/protocol-compatibility.md @@ -52,6 +52,33 @@ On capable daemons, connecting creates no timeline or event demand. Client subsc release it on unsubscribe, and restore it after reconnect. Raw message observers inspect traffic without requesting streams. Application caches and which agents are visible remain caller-owned. +## Binary chunking + +`binary_chunking_v1` in hello and optional `server_info.features.binaryChunkingV1` +negotiate connection-scoped framing. Application serializers and routed envelopes use +the same boundary. Small messages and handshakes keep their existing representation. +Older peers retain existing frame limits. + +The reserved binary opcode `0x31` is followed by version `1`, original kind +(`0` text, `1` binary), and START/END flag bits `0`/`1`. Remaining bytes are an +unchanged slice. There is no total length or chunk count; a future receiver can +consume slices incrementally. Today the receiver buffers up to the existing +100 MiB logical limit and decodes UTF-8 only after END. Transfers cannot interleave. +Disconnect clears partial state and negotiation. + +The lower transport supplies its plaintext frame budget, including encryption +overhead. The sender waits for drain between chunks while ordinary messages keep +the existing delivery path. Slow-client protections count messages queued behind +an active transfer as well as bytes already in the socket. +The framing module never selects an encryption construction. + +Native uploads retain the platform's existing queue limits. React Native's +WebSocket does not report its queued bytes, so the client cannot wait for native +drain. Android's installed OkHttp transport closes when a send would put more +than 16 MiB in its queue. The native transport disables outbound chunking, including +on routed channels, and retains its existing single-frame admission errors. +Inbound chunking remains enabled; daemon-to-native responses use the daemon's paced writes. + ## Owned observations `owned_subscriptions` and `server_info.features.ownedSubscriptions` negotiate the source-owned diff --git a/packages/app/e2e/browser/composer-diff-stat.spec.ts b/packages/app/e2e/browser/composer-diff-stat.spec.ts index 9b80222432..959567f58b 100644 --- a/packages/app/e2e/browser/composer-diff-stat.spec.ts +++ b/packages/app/e2e/browser/composer-diff-stat.spec.ts @@ -18,7 +18,7 @@ function visibleMainPane(page: Page) { } function composerChangesPill(page: Page) { - return page.getByTestId("composer-diff-stat-pill"); + return page.getByRole("button", { name: "Open Changes tab", exact: true }); } async function revealComposerChangesInExplorer(page: Page) { @@ -498,6 +498,7 @@ test.describe("send review feedback", () => { test("only open tabs in this workspace appear in compact and wide recipient menus", async ({ page, }) => { + test.setTimeout(120000); const workspace = await seedChangedAgent("review-feedback-multiple-"); const other = await seedChangedAgent("review-feedback-other-"); try { diff --git a/packages/app/e2e/browser/new-workspace-composer-draft.spec.ts b/packages/app/e2e/browser/new-workspace-composer-draft.spec.ts index 906efcfb42..01fd4638eb 100644 --- a/packages/app/e2e/browser/new-workspace-composer-draft.spec.ts +++ b/packages/app/e2e/browser/new-workspace-composer-draft.spec.ts @@ -1,3 +1,4 @@ +import { chooseModel, startWithoutRememberedModel } from "../support/helpers/model-memory"; import { composerLocator, enterComposerFullscreen, @@ -97,6 +98,7 @@ test.describe("New workspace composer draft", () => { const project = await seedWorkspace({ repoPrefix: "new-workspace-submitted-draft-" }); try { + await startWithoutRememberedModel(page); await gotoAppShell(page); await waitForSidebarHydration(page); await openNewWorkspaceComposer(page, { @@ -104,6 +106,7 @@ test.describe("New workspace composer draft", () => { projectDisplayName: project.projectDisplayName, }); + await chooseModel(page, "mock", "Ten second stream"); const composer = page.getByRole("textbox", { name: "Message agent..." }); const createButton = page.getByTestId("workspace-create-submit"); await expect(composer).toBeEditable({ timeout: 30_000 }); diff --git a/packages/app/src/utils/test-daemon-connection.test.ts b/packages/app/src/utils/test-daemon-connection.test.ts index f528065437..29382b5c89 100644 --- a/packages/app/src/utils/test-daemon-connection.test.ts +++ b/packages/app/src/utils/test-daemon-connection.test.ts @@ -76,6 +76,25 @@ describe("test-daemon-connection connectToDaemon", () => { probe = new FakeDaemonProbe(); }); + it.each([ + { id: "direct:lan:6767", type: "directTcp" as const, endpoint: "lan:6767" }, + { + id: "relay:test", + type: "relay" as const, + relayEndpoint: "relay.paseo.sh:443", + daemonPublicKeyB64: "pubkey", + }, + ])("adoptable $type probe clients use the app WebSocket factory", async (connection) => { + const { connectToDaemon } = await import("./test-daemon-connection"); + const { createAppWebSocketFactory } = await import("@/runtime/websocket-factory"); + const result = await connectToDaemon(connection, { serverId: "srv_probe_test" }, probe.deps); + try { + expect(result.client.config.webSocketFactory).toBe(createAppWebSocketFactory()); + } finally { + await result.client.close(); + } + }); + it("reuses the app clientId for direct connections", async () => { const { connectToDaemon } = await import("./test-daemon-connection"); const first = await connectToDaemon( diff --git a/packages/app/src/utils/test-daemon-connection.ts b/packages/app/src/utils/test-daemon-connection.ts index d292c67001..1d1605af62 100644 --- a/packages/app/src/utils/test-daemon-connection.ts +++ b/packages/app/src/utils/test-daemon-connection.ts @@ -6,6 +6,7 @@ import { } from "@getpaseo/client/internal/daemon-client"; import type { DaemonClientConfig } from "@getpaseo/client/internal/daemon-client"; import type { HostConnection } from "@/types/host-connection"; +import { createAppWebSocketFactory } from "@/runtime/websocket-factory"; import { getOrCreateClientId } from "./client-id"; import { resolveAppVersion } from "./app-version"; import { @@ -156,6 +157,7 @@ export async function buildClientConfig( clientType: "mobile" as const, appVersion: deps.resolveAppVersion() ?? undefined, suppressSendErrors: true, + webSocketFactory: createAppWebSocketFactory(), reconnect: { enabled: false }, ...resolveConnectionCredentials(connection, options), ...(options.capabilities ? { capabilities: options.capabilities } : {}), diff --git a/packages/client/src/connection/index.ts b/packages/client/src/connection/index.ts index eda2fec26b..1ad4b34dee 100644 --- a/packages/client/src/connection/index.ts +++ b/packages/client/src/connection/index.ts @@ -125,6 +125,7 @@ export class ConnectionSubscriptions extends OwnedSubscriptions { // Protocol support belongs to the installed client. Only browser hosting needs // a resource supplied by the caller. Keep this exhaustive as the protocol evolves. export const DEFAULT_CLIENT_CAPABILITIES = { + [CLIENT_CAPS.binaryChunkingV1]: true, [CLIENT_CAPS.helloRejection]: true, [CLIENT_CAPS.ownedSubscriptions]: true, [CLIENT_CAPS.allProviders]: true, diff --git a/packages/client/src/daemon-client-relay-e2ee-transport.ts b/packages/client/src/daemon-client-relay-e2ee-transport.ts index 258c0aa9b5..0faecdcb5e 100644 --- a/packages/client/src/daemon-client-relay-e2ee-transport.ts +++ b/packages/client/src/daemon-client-relay-e2ee-transport.ts @@ -1,3 +1,4 @@ +import { maxSecuredPlaintextByteLength } from "@getpaseo/relay"; import { MAX_RELAY_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit"; import { openClientSecureChannel, @@ -155,18 +156,15 @@ export function createEncryptedTransport( const relayTransport: RelayTransport = { send: (data) => { if (typeof data === "string") { - base.send(data); - return; + return base.send(data); } if (ArrayBuffer.isView(data)) { - base.send(normalizeTransportPayload(data)); - return; + return base.send(normalizeTransportPayload(data)); } if (data instanceof ArrayBuffer) { - base.send(data); - return; + return base.send(data); } - base.send(String(data)); + return base.send(String(data)); }, // The channel decides the close code. Report it now: a socket close event // arrives asynchronously, and the consumer must not act on an earlier @@ -226,6 +224,8 @@ export function createEncryptedTransport( }); return { + supportsDrain: base.supportsDrain, + maxFrameBytes: maxSecuredPlaintextByteLength(MAX_RELAY_WEBSOCKET_MESSAGE_BYTES), send: (data) => { if (!channel) { throw new Error("Encrypted channel not ready"); @@ -239,8 +239,9 @@ export function createEncryptedTransport( // Encryption and its transport handoff are synchronous up to send's first await. // Reject capacity failures here so a routed sender loses only its own channel. assertOutboundCapacity(wireBytes); - void channel.send(payload).catch((error) => { + return channel.send(payload).catch((error) => { emitError(error); + throw error; }); }, close: (code?: number, reason?: string) => { diff --git a/packages/client/src/daemon-client-transport-types.ts b/packages/client/src/daemon-client-transport-types.ts index ddcb57246b..64f84cf8c1 100644 --- a/packages/client/src/daemon-client-transport-types.ts +++ b/packages/client/src/daemon-client-transport-types.ts @@ -1,5 +1,8 @@ export interface DaemonTransport { - send: (data: string | Uint8Array | ArrayBuffer) => void; + /** False when the underlying socket cannot report write drain. */ + supportsDrain?: boolean; + maxFrameBytes?: number; + send: (data: string | Uint8Array | ArrayBuffer) => void | Promise; close: (code?: number, reason?: string) => void; onMessage: (handler: (data: unknown, isBinary: boolean) => void) => () => void; onOpen: (handler: () => void) => () => void; @@ -13,12 +16,16 @@ export type DaemonTransportFactory = (options: { protocols?: string[]; }) => DaemonTransport; -export type WebSocketFactory = ( - url: string, - options?: { headers?: Record; protocols?: string[] }, -) => WebSocketLike; +export interface WebSocketFactory { + ( + url: string, + options?: { headers?: Record; protocols?: string[] }, + ): WebSocketLike; + supportsDrain?: boolean; +} export interface WebSocketLike { + bufferedAmount?: number; readyState: number; send: (data: string | Uint8Array | ArrayBuffer) => void; close: (code?: number, reason?: string) => void; diff --git a/packages/client/src/daemon-client-transport.test.ts b/packages/client/src/daemon-client-transport.test.ts index 963a8b541c..c95c534129 100644 --- a/packages/client/src/daemon-client-transport.test.ts +++ b/packages/client/src/daemon-client-transport.test.ts @@ -175,3 +175,18 @@ describe("daemon-client transport helpers", () => { expect(decodeMessageData(bytes)).toBe("hello"); }); }); + +test("native drain capability survives WebSocket and encryption wrappers", async () => { + const { nativeWebSocketFactory } = await import("./daemon-client-websocket-transport.js"); + expect(nativeWebSocketFactory.supportsDrain).toBe(false); + const factory = Object.assign(() => ({ readyState: 1, send() {}, close() {} }), { + supportsDrain: false, + }); + const base = createWebSocketTransportFactory(factory)({ url: "ws://test" }); + expect(base.supportsDrain).toBe(false); + const encrypted = createEncryptedTransport(base, { + daemonPublicKeyB64: "unused-key", + logger: { warn() {} }, + }); + expect(encrypted.supportsDrain).toBe(false); +}); diff --git a/packages/client/src/daemon-client-websocket-transport.ts b/packages/client/src/daemon-client-websocket-transport.ts index f5734b4b51..c7ec87555d 100644 --- a/packages/client/src/daemon-client-websocket-transport.ts +++ b/packages/client/src/daemon-client-websocket-transport.ts @@ -39,6 +39,9 @@ export function nativeWebSocketFactory( return new (getGlobalWebSocket())(url, options?.protocols, { headers: options?.headers }); } +// React Native leaves bufferedAmount at zero, including while native output is queued. +nativeWebSocketFactory.supportsDrain = false; + export function createWebSocketTransportFactory(factory: WebSocketFactory): DaemonTransportFactory { return ({ url, headers, protocols }) => { const ws = factory(url, { headers, protocols }); @@ -50,11 +53,28 @@ export function createWebSocketTransportFactory(factory: WebSocketFactory): Daem } } return { + supportsDrain: factory.supportsDrain !== false, send: (data) => { if (typeof ws.readyState === "number" && ws.readyState !== 1) { throw new Error(`WebSocket not open (readyState=${ws.readyState})`); } ws.send(data); + if ((ws.bufferedAmount ?? 0) > 1024 * 1024) { + return new Promise((resolve, reject) => { + const drain = () => { + if (ws.readyState !== 1) { + reject(new Error("WebSocket closed while draining")); + return; + } + if ((ws.bufferedAmount ?? 0) <= 1024 * 1024) { + resolve(); + return; + } + setTimeout(drain, 10); + }; + drain(); + }); + } }, close: (code?: number, reason?: string) => { // Node's `ws` may emit an `error` when a connecting socket is closed before the diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 112113caf6..6ef72a0e1a 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -1094,6 +1094,7 @@ test("advertises client capabilities in hello", async () => { provider_snapshot_references: true, explicit_event_subscriptions: true, owned_subscriptions: true, + binary_chunking_v1: true, compact_provider_snapshots: true, custom_mode_icons: true, project_updates: true, @@ -6817,91 +6818,102 @@ test("drops legacy agent_stream tool_call payloads and logs validation warning", expect(logger.warn).toHaveBeenCalled(); }); -test("parses canonical fetch_agent_timeline_response payloads without crashing", async () => { - const logger = createMockLogger(); - const mock = createMockTransport(); +test.each([false, true])( + "parses canonical timeline responses with chunking=%s", + async (chunked) => { + const logger = createMockLogger(); + const mock = createMockTransport(); - const client = new DaemonClient({ - url: "ws://test", - clientId: "clsk_unit_test", - logger, - reconnect: { enabled: false }, - transportFactory: () => mock.transport, - }); - clients.push(client); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); - const connectPromise = client.connect(); - mock.triggerOpen(); - await connectPromise; + const connectPromise = client.connect(); + mock.triggerOpen({ features: { ownedSubscriptions: true, binaryChunkingV1: chunked } }); + await connectPromise; - const received: unknown[] = []; - const unsubscribe = client.on("fetch_agent_timeline_response", (msg) => { - received.push(msg); - }); + const received: unknown[] = []; + const unsubscribe = client.on("fetch_agent_timeline_response", (msg) => { + received.push(msg); + }); - mock.triggerMessage( - wrapSessionMessage({ - type: "fetch_agent_timeline_response", + const command = chunked ? "x".repeat(50 * 1024 * 1024) : "pwd"; + const { ChunkedTransport } = await import("@getpaseo/protocol/chunked-transport"); + const sender = new ChunkedTransport({ + frameBytes: 24 * 1024 * 1024, + send: (frame) => mock.triggerMessage(frame), + deliver: () => {}, + }); + sender.enabled = chunked; + await sender.send( + wrapSessionMessage({ + type: "fetch_agent_timeline_response", + payload: { + requestId: "req-1", + agentId: "agent_cli", + agent: null, + direction: "tail", + projection: "projected", + epoch: "epoch-1", + reset: false, + staleCursor: false, + gap: false, + window: { minSeq: 1, maxSeq: 1, nextSeq: 2 }, + startCursor: { epoch: "epoch-1", seq: 1 }, + endCursor: { epoch: "epoch-1", seq: 1 }, + hasOlder: false, + hasNewer: false, + entries: [ + { + timestamp: "2026-02-08T20:20:00.000Z", + provider: "codex", + seqStart: 1, + seqEnd: 1, + sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }], + collapsed: [], + item: { + type: "tool_call", + callId: "call_cli_snapshot", + name: "shell", + status: "running", + detail: { + type: "shell", + command, + }, + error: null, + }, + }, + ], + error: null, + }, + }), + ); + + unsubscribe(); + + expect(received).toHaveLength(1); + expect(received[0]).toMatchObject({ payload: { - requestId: "req-1", - agentId: "agent_cli", - agent: null, - direction: "tail", - projection: "projected", - epoch: "epoch-1", - reset: false, - staleCursor: false, - gap: false, - window: { minSeq: 1, maxSeq: 1, nextSeq: 2 }, - startCursor: { epoch: "epoch-1", seq: 1 }, - endCursor: { epoch: "epoch-1", seq: 1 }, - hasOlder: false, - hasNewer: false, entries: [ { - timestamp: "2026-02-08T20:20:00.000Z", - provider: "codex", - seqStart: 1, - seqEnd: 1, - sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }], - collapsed: [], item: { type: "tool_call", - callId: "call_cli_snapshot", - name: "shell", status: "running", - detail: { - type: "shell", - command: "pwd", - }, error: null, + detail: { type: "shell", command }, }, }, ], - error: null, }, - }), - ); - - unsubscribe(); - - expect(received).toHaveLength(1); - expect(received[0]).toMatchObject({ - payload: { - entries: [ - { - item: { - type: "tool_call", - status: "running", - error: null, - detail: { type: "shell" }, - }, - }, - ], - }, - }); - expect(logger.warn).not.toHaveBeenCalled(); -}); + }); + expect(logger.warn).not.toHaveBeenCalled(); + }, +); test("rejects and logs a correlated response that violates the protocol schema", async () => { const mock = createMockTransport(); @@ -8471,3 +8483,302 @@ test("closes before admission when a protected-capable daemon is reached in lega expect(daemonReceived).toHaveLength(1); expect(JSON.parse(daemonReceived[0] as string)).toMatchObject({ type: "hello" }); }); + +test("uses the frame budget selected by an asynchronously opened transport", async () => { + const mock = createMockTransport(); + mock.transport.maxFrameBytes = 1024; + const client = new DaemonClient({ + url: "ws://test", + clientId: "dynamic-budget", + transportFactory: () => mock.transport, + reconnect: { enabled: false }, + }); + clients.push(client); + const connecting = client.connect(); + mock.transport.maxFrameBytes = 16; + mock.triggerOpen({ features: { binaryChunkingV1: true } }); + await connecting; + client.sendHostFrame("route", new Uint8Array(100)); + await vi.waitFor(() => expect(mock.sent.length).toBeGreaterThan(1)); + expect(mock.sent.every((frame) => typeof frame !== "string" && frame.byteLength <= 16)).toBe( + true, + ); +}); + +test.each([false, true])("routed sends wait for origin drain (binary=%s)", async (binary) => { + const mock = createMockTransport(); + let drain!: () => void; + let blocked = false; + const send = mock.transport.send; + mock.transport.send = (frame) => { + send(frame); + if (blocked) + return new Promise((resolve) => { + drain = resolve; + }); + }; + const client = new DaemonClient({ + url: "ws://test", + clientId: "routed-drain", + transportFactory: () => mock.transport, + reconnect: { enabled: false }, + }); + clients.push(client); + const connecting = client.connect(); + mock.triggerOpen({ features: { binaryChunkingV1: true } }); + await connecting; + blocked = true; + let finished = false; + const write = Promise.resolve( + client.sendHostFrame("route", binary ? new Uint8Array([1]) : "hello"), + ).then(() => { + finished = true; + return undefined; + }); + await Promise.resolve(); + expect(finished).toBe(false); + drain(); + await write; + expect(finished).toBe(true); +}); + +test("a queued admission failure rejects its RPC without closing the client", async () => { + const mock = createMockTransport(); + const send = mock.transport.send; + let block = false; + let drain!: () => void; + mock.transport.send = (frame) => { + if (!block) return send(frame); + if (typeof frame !== "string") + return new Promise((resolve) => { + drain = resolve; + }); + throw new Error("capacity"); + }; + const client = new DaemonClient({ + url: "ws://test", + clientId: "queued-admission", + transportFactory: () => mock.transport, + reconnect: { enabled: false }, + }); + clients.push(client); + const connecting = client.connect(); + mock.triggerOpen(); + await connecting; + block = true; + const first = client.sendHostFrame("route", new Uint8Array([1])); + const ping = client.ping({ timeoutMs: 1000 }); + void ping.catch(() => {}); + drain(); + await first; + await expect(ping).rejects.toThrow("capacity"); + expect(client.isConnected).toBe(true); +}); + +test("RPC deadlines remain active while a preceding frame waits for drain", async () => { + const mock = createMockTransport(); + const send = mock.transport.send; + let blocked = false; + let drain!: () => void; + mock.transport.send = (frame) => { + if (!blocked) return send(frame); + if (typeof frame !== "string") + return new Promise((resolve) => { + drain = resolve; + }); + }; + const client = new DaemonClient({ + url: "ws://test", + clientId: "drain-deadline", + transportFactory: () => mock.transport, + reconnect: { enabled: false }, + }); + clients.push(client); + const connecting = client.connect(); + mock.triggerOpen(); + await connecting; + blocked = true; + const first = client.sendHostFrame("route", new Uint8Array([1])); + try { + await expect(client.ping({ timeoutMs: 20 })).rejects.toThrow("Timeout waiting for message"); + expect(client.isConnected).toBe(true); + } finally { + drain(); + await first; + } +}); + +test.each([false, true])( + "event listener errors do not close the transport (chunked=%s)", + async (chunked) => { + const mock = createMockTransport(); + const closed = vi.fn(); + mock.transport.close = closed; + const client = new DaemonClient({ + url: "ws://test", + clientId: "listener-error", + transportFactory: () => mock.transport, + reconnect: { enabled: false }, + }); + clients.push(client); + const connecting = client.connect(); + mock.triggerOpen({ features: { binaryChunkingV1: chunked } }); + await connecting; + const unsubscribe = client.subscribe(() => { + throw new Error("listener failed"); + }); + const text = wrapSessionMessage({ + type: "agent_deleted", + payload: { agentId: "agent-test", requestId: "listener-test" }, + }); + const payload = new TextEncoder().encode(text); + const frame = chunked ? new Uint8Array([0x31, 1, 0, 3, ...payload]) : text; + expect(() => mock.triggerMessage(frame)).toThrow("listener failed"); + expect(closed).not.toHaveBeenCalled(); + unsubscribe(); + const events: string[] = []; + client.subscribe((event) => { + events.push(event.type); + }); + mock.triggerMessage(frame); + expect(events).toEqual(["agent_deleted"]); + await client.sendHostFrame("route", "still connected"); + expect(client.isConnected).toBe(true); + }, +); + +test("malformed chunk metadata still closes the client transport", async () => { + const mock = createMockTransport(); + const closed = vi.fn(); + mock.transport.close = closed; + const client = new DaemonClient({ + url: "ws://test", + clientId: "malformed-chunk", + transportFactory: () => mock.transport, + reconnect: { enabled: false }, + }); + clients.push(client); + const connecting = client.connect(); + mock.triggerOpen({ features: { binaryChunkingV1: true } }); + await connecting; + mock.triggerMessage(new Uint8Array([0x31, 2, 0, 3, 65])); + expect(closed).toHaveBeenCalledWith(4003, "Invalid chunked transport"); +}); + +test.each([FileTransferOpcode.FileBegin, FileTransferOpcode.FileChunk, FileTransferOpcode.FileEnd])( + "upload stops at a queued pre-write rejection (opcode=%s)", + async (opcode) => { + const mock = createMockTransport(); + const send = mock.transport.send; + let drain!: () => void; + let blocked = false; + const attempts: number[] = []; + mock.transport.send = (frame) => { + if (typeof frame !== "string") { + const decoded = decodeFileTransferFrame(assertUint8Array(frame)); + if (decoded) { + attempts.push(decoded.opcode); + if (decoded.opcode === opcode) throw new Error("upload capacity"); + } else if (blocked) + return new Promise((resolve) => { + drain = resolve; + }); + } + return send(frame); + }; + const client = new DaemonClient({ + url: "ws://test", + clientId: "upload-admission", + transportFactory: () => mock.transport, + reconnect: { enabled: false }, + }); + clients.push(client); + const connecting = client.connect(); + mock.triggerOpen(); + await connecting; + blocked = true; + const first = client.sendHostFrame("route", new Uint8Array([1])); + const upload = client.uploadFile({ + fileName: "file.bin", + mimeType: "application/octet-stream", + bytes: new Uint8Array([1, 2]), + chunkSize: 1, + }); + let rejected: unknown; + void upload.catch((error) => { + rejected = error; + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + drain(); + await first; + await vi.waitFor(() => expect(rejected).toMatchObject({ message: "upload capacity" }), { + timeout: 500, + }); + expect(attempts.at(-1)).toBe(opcode); + expect(client.isConnected).toBe(true); + }, +); + +test.each(["latency", "dictation start", "dictation finish"])( + "queued control send rejection reaches its caller (%s)", + async (operation) => { + const mock = createMockTransport(); + const send = mock.transport.send; + let blocked = false; + let drain!: () => void; + mock.transport.send = (frame) => { + if (!blocked) return send(frame); + if (typeof frame !== "string") + return new Promise((resolve) => { + drain = resolve; + }); + throw new Error("control capacity"); + }; + const client = new DaemonClient({ + url: "ws://test", + clientId: "control-admission", + transportFactory: () => mock.transport, + reconnect: { enabled: false }, + }); + clients.push(client); + const connecting = client.connect(); + mock.triggerOpen(); + await connecting; + blocked = true; + const first = client.sendHostFrame("route", new Uint8Array([1])); + let result: Promise; + if (operation === "latency") result = client.measureLatency({ timeoutMs: 1000 }); + else if (operation === "dictation start") + result = client.startDictationStream("dictation", "pcm"); + else result = client.finishDictationStream("dictation", 0); + const rejected = expect(result).rejects.toThrow("control capacity"); + drain(); + await first; + await rejected; + expect(client.isConnected).toBe(true); + }, +); + +test("routed channels inherit an origin without drain support", async () => { + const { createHostChannelTransport } = await import("./hosts/index.js"); + const mock = createMockTransport(); + mock.transport.supportsDrain = false; + const client = new DaemonClient({ + url: "ws://test", + clientId: "unpaced-route", + transportFactory: () => mock.transport, + reconnect: { enabled: false }, + }); + clients.push(client); + const connecting = client.connect(); + mock.triggerOpen(); + await connecting; + const route = createHostChannelTransport( + client, + "srv_destination", + "scope", + "route", + )({ url: "ws://route" }); + expect(route.supportsDrain).toBe(false); + route.close(); +}); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index d4b0f28a39..9152159f1c 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -1,3 +1,7 @@ +import { + ChunkedTransport, + ChunkedTransportProtocolError, +} from "@getpaseo/protocol/chunked-transport"; import { HOST_ROUTE_OPCODE, encodeHostRouteFrame, @@ -1266,7 +1270,7 @@ function toReasonCode(reason: string | null | undefined): string | null { } interface PendingSend { - send: () => void; + send: () => void | Promise; resolve: () => void; reject: (error: Error) => void; timeoutHandle: ReturnType; @@ -1366,6 +1370,7 @@ export class DaemonClient { failed: (error) => this.logger.error({ err: error }, "Subscription failed"), }); private transport: DaemonTransport | null = null; + private chunkedTransport: ChunkedTransport | null = null; private helloAuth: HelloAuth; private transportCleanup: Array<() => void> = []; private rawMessageListeners: Set<(message: SessionOutboundMessage) => void> = new Set(); @@ -1560,6 +1565,17 @@ export class DaemonClient { }); this.traceConnectionAttempt += 1; this.transport = transport; + this.chunkedTransport = new ChunkedTransport({ + get supportsDrain() { + return transport.supportsDrain; + }, + get frameBytes() { + return transport.maxFrameBytes ?? 32 * 1024 * 1024; + }, + send: (frame) => transport.send(frame), + onFailure: () => transport.close(4003, "Transport send failed"), + deliver: (frame) => this.handleTransportMessage(frame), + }); this.lastServerInfoMessage = null; this.authFailureReasonValue = null; @@ -1666,7 +1682,16 @@ export class DaemonClient { }); }), transport.onMessage((data) => { - if (this.transport === transport) this.handleTransportMessage(data); + if (this.transport !== transport) return; + const bytes = asUint8Array(data); + try { + if (typeof data === "string" || bytes) + this.chunkedTransport!.receive(typeof data === "string" ? data : bytes!); + else this.handleTransportMessage(data); + } catch (error) { + if (!(error instanceof ChunkedTransportProtocolError)) throw error; + transport.close(4003, "Invalid chunked transport"); + } }), ]; } catch (error) { @@ -1867,9 +1892,9 @@ export class DaemonClient { }) ).routeId; } - sendHostFrame(routeId: string, data: string | Uint8Array): void { + sendHostFrame(routeId: string, data: string | Uint8Array): Promise { if (typeof data === "string") { - this.sendSessionMessageStrict({ + return this.sendSessionMessageStrict({ type: "hosts.route.frame.request", routeId, data, @@ -1877,7 +1902,7 @@ export class DaemonClient { }); } else { if (!this.isConnected || !this.transport) throw this.unavailableError(); - this.sendTransportFrame(encodeHostRouteFrame(routeId, data)); + return this.sendTransportFrame(encodeHostRouteFrame(routeId, data)); } } private readonly hostFrameListeners = new Set<(frame: HostRouteFrame) => void>(); @@ -1935,6 +1960,11 @@ export class DaemonClient { // Message Subscription // ============================================================================ + /** Drain capability inherited by transports routed through this connection. */ + get supportsDrain(): boolean { + return this.transport?.supportsDrain !== false; + } + subscribe(handler: DaemonEventHandler): () => void { this.eventListeners.add(handler); return () => { @@ -2043,7 +2073,7 @@ export class DaemonClient { messageType: string, message: unknown, submitted?: () => void, - ): void { + ): Promise { const isOpen = this.config.trace?.isEnabled() === true && this.beginTraceSection("paseo.ws.message.outbound", { @@ -2052,25 +2082,30 @@ export class DaemonClient { ...describeRequestIdentity(message), }); try { - this.sendTransportFrame(JSON.stringify(message), submitted); + return this.sendTransportFrame(JSON.stringify(message), submitted); } finally { this.endTraceSection(isOpen); } } - private sendSessionPayload(payload: SessionInboundMessage): void { - this.sendJsonMessage("session", payload.type, { type: "session", message: payload }, () => { - if (!("requestId" in payload) || typeof payload.requestId !== "string") return; - for (const waiter of this.waiters) { - if (waiter.requestId === payload.requestId) waiter.submitted = true; - } - }); + private sendSessionPayload(payload: SessionInboundMessage): Promise { + return this.sendJsonMessage( + "session", + payload.type, + { type: "session", message: payload }, + () => { + if (!("requestId" in payload) || typeof payload.requestId !== "string") return; + for (const waiter of this.waiters) { + if (waiter.requestId === payload.requestId) waiter.submitted = true; + } + }, + ); } private sendTransportFrame( frame: string | Uint8Array | ArrayBuffer, submitted?: () => void, - ): void { + ): Promise { if (!this.transport) { throw this.unavailableError(); } @@ -2083,7 +2118,8 @@ export class DaemonClient { }); try { submitted?.(); - this.transport.send(frame); + const payload = typeof frame === "string" ? frame : asUint8Array(frame)!; + return this.chunkedTransport!.send(payload); } finally { this.endTraceSection(isOpen); } @@ -2112,10 +2148,10 @@ export class DaemonClient { } } - private sendBinaryFrame(frame: Uint8Array): void { + private sendBinaryFrame(frame: Uint8Array): Promise { if (!this.transport || this.connectionState.status !== "connected") { if (this.config.suppressSendErrors) { - return; + return Promise.resolve(); } throw new Error(`Transport not connected (status: ${this.connectionState.status})`); } @@ -2124,10 +2160,10 @@ export class DaemonClient { envelopeType: "binary", messageType: "binary", }); - this.sendTransportFrame(frame); + return this.sendTransportFrame(frame); } catch (error) { if (this.config.suppressSendErrors) { - return; + return Promise.resolve(); } throw error instanceof Error ? error : new Error(String(error)); } @@ -2142,7 +2178,7 @@ export class DaemonClient { private sendSessionMessageOrThrow(message: SessionInboundMessage): Promise { return this.sendWhenConnected(() => { const payload = SessionInboundMessageSchema.parse(message); - this.sendSessionPayload(payload); + return this.sendSessionPayload(payload); }); } @@ -2151,13 +2187,12 @@ export class DaemonClient { return this.sendWhenConnected(() => undefined); } - private sendWhenConnected(send: () => void): Promise { + private sendWhenConnected(send: () => void | Promise): Promise { const status = this.connectionState.status; // If connected, send immediately if (this.transport && status === "connected") { - send(); - return Promise.resolve(); + return Promise.resolve(send()); } // If connecting, queue the message to be sent once connected @@ -2196,8 +2231,7 @@ export class DaemonClient { clearTimeout(pending.timeoutHandle); try { if (this.transport && this.connectionState.status === "connected") { - pending.send(); - pending.resolve(); + void Promise.resolve(pending.send()).then(pending.resolve, pending.reject); } else { pending.reject(new DaemonConnectionError("Connection lost before message could be sent")); } @@ -2253,19 +2287,17 @@ export class DaemonClient { { ...params.options, requestId: params.requestId }, ); + // The response deadline still applies while the transport waits for drain. + void promise.catch(() => undefined); try { - await this.sendSessionMessageOrThrow(wire.message); + const sent = this.sendSessionMessageOrThrow(wire.message); + const result = await Promise.race([promise, sent.then(() => promise)]); + if (result.kind === "error") throw result.error; + return result.value; } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); cancel(err); - void promise.catch(() => undefined); throw err; - } - - try { - const result = await promise; - if (result.kind === "error") throw result.error; - return result.value; } finally { await wire.finish(); } @@ -2355,13 +2387,13 @@ export class DaemonClient { }); } - private sendSessionMessageStrict(message: SessionInboundMessage): void { + private sendSessionMessageStrict(message: SessionInboundMessage): Promise { if (!this.transport || this.connectionState.status !== "connected") { throw this.unavailableError(); } const payload = SessionInboundMessageSchema.parse(message); try { - this.sendSessionPayload(payload); + return this.sendSessionPayload(payload); } catch (error) { throw error instanceof Error ? error : new Error(String(error)); } @@ -2559,15 +2591,16 @@ export class DaemonClient { }; this.pingProbe = probe; - try { - this.sendJsonMessage("ping", "ping", { type: "ping" }); - } catch (error) { - this.clearPingProbe(); + const rejectSend = (error: unknown) => { + if (this.pingProbe !== probe) return; const sendError = error instanceof Error ? error : new Error(String(error)); - if (probe.drivesLivenessFailure) { - this.recordLivenessFailure(sendError); - } - return Promise.reject(sendError); + this.rejectPingProbe(sendError); + if (probe.drivesLivenessFailure) this.recordLivenessFailure(sendError); + }; + try { + void this.sendJsonMessage("ping", "ping", { type: "ping" }).catch(rejectSend); + } catch (error) { + rejectSend(error); } return promise; @@ -4222,7 +4255,7 @@ export class DaemonClient { } async sendVoiceAudioChunk(audio: string, format: string, isLast = false): Promise { - this.sendSessionMessage({ type: "voice_audio_chunk", audio, format, isLast }); + return this.sendSessionMessageStrict({ type: "voice_audio_chunk", audio, format, isLast }); } async startDictationStream(dictationId: string, format: string): Promise { @@ -4263,8 +4296,13 @@ export class DaemonClient { const cleanupError = new Error("Cancelled dictation start waiter"); try { - this.sendSessionMessageStrict({ type: "dictation_stream_start", dictationId, format }); - await Promise.race([ackPromise, errorPromise]); + const sent = this.sendSessionMessageStrict({ + type: "dictation_stream_start", + dictationId, + format, + }); + const response = Promise.race([ackPromise, errorPromise]); + await Promise.race([response, sent.then(() => response)]); } finally { ack.cancel(cleanupError); streamError.cancel(cleanupError); @@ -4399,12 +4437,17 @@ export class DaemonClient { const cleanupError = new Error("Cancelled dictation finish waiter"); try { - this.sendSessionMessageStrict({ type: "dictation_stream_finish", dictationId, finalSeq }); - const firstOutcome = await Promise.race([ + const sent = this.sendSessionMessageStrict({ + type: "dictation_stream_finish", + dictationId, + finalSeq, + }); + const response = Promise.race([ finalOutcomePromise, errorOutcomePromise, finishAcceptedOutcomePromise, ]); + const firstOutcome = await Promise.race([response, sent.then(() => response)]); if (firstOutcome.kind === "final") { return firstOutcome.payload; @@ -5320,8 +5363,10 @@ export class DaemonClient { return undefined; }, ); + const sendFrame = (frame: Uint8Array) => + Promise.race([this.sendBinaryFrame(frame), responsePromise]); try { - this.sendBinaryFrame( + await sendFrame( encodeFileTransferFrame({ opcode: FileTransferOpcode.FileBegin, requestId: resolvedRequestId, @@ -5335,6 +5380,7 @@ export class DaemonClient { }), ); + if (settled) return await responsePromise; const chunkSize = input.chunkSize ?? 128 * 1024; for (let offset = 0; offset < bytes.byteLength; offset += chunkSize) { // Native WebSocket.send encodes binary synchronously. Let rendering and @@ -5344,7 +5390,7 @@ export class DaemonClient { if (this.transport !== uploadTransport || this.connectionState.status !== "connected") { throw new DaemonConnectionError("Connection changed during file upload"); } - this.sendBinaryFrame( + await sendFrame( encodeFileTransferFrame({ opcode: FileTransferOpcode.FileChunk, requestId: resolvedRequestId, @@ -5353,7 +5399,7 @@ export class DaemonClient { ); } - this.sendBinaryFrame( + await sendFrame( encodeFileTransferFrame({ opcode: FileTransferOpcode.FileEnd, requestId: resolvedRequestId, @@ -6763,6 +6809,8 @@ export class DaemonClient { } private disposeTransport(code = 1001, reason = "Reconnecting"): void { + this.chunkedTransport?.close(); + this.chunkedTransport = null; this.owned.disconnected(); this.providerSnapshotUpdates.clear(); this.stopLivenessHeartbeat(); @@ -7232,15 +7280,6 @@ export class DaemonClient { probe.resolve(perfNow() - probe.startedAt); } - private clearPingProbe(): void { - const probe = this.pingProbe; - if (!probe) { - return; - } - this.pingProbe = null; - clearTimeout(probe.timeoutHandle); - } - private rejectPingProbe(error: Error): void { const probe = this.pingProbe; if (!probe) { @@ -7289,6 +7328,10 @@ export class DaemonClient { if (consumerMessage.type === "status") { const serverInfo = parseServerInfoStatusPayload(consumerMessage.payload); if (serverInfo) { + if (this.chunkedTransport) + this.chunkedTransport.enabled = + serverInfo.features?.binaryChunkingV1 === true && + this.config.capabilities?.[CLIENT_CAPS.binaryChunkingV1] !== false; this.lastServerInfoMessage = serverInfo; this.destinationServerId = serverInfo.serverId; this.connectionFailure = undefined; diff --git a/packages/client/src/hosts/index.ts b/packages/client/src/hosts/index.ts index 1baa9ebe43..aa5c910158 100644 --- a/packages/client/src/hosts/index.ts +++ b/packages/client/src/hosts/index.ts @@ -1,3 +1,4 @@ +import { MAX_HOST_CHANNEL_FRAME_BYTES } from "@getpaseo/protocol/binary-frames/index"; import { safeRandomId } from "../daemon-client-transport-utils.js"; import { DaemonClient } from "../daemon-client.js"; import type { DaemonTransportFactory } from "../daemon-client-transport-types.js"; @@ -84,13 +85,17 @@ export function createHostChannelTransport( }) .catch(close); return { + get supportsDrain() { + return origin.supportsDrain; + }, + maxFrameBytes: MAX_HOST_CHANNEL_FRAME_BYTES, send(data) { if (closed || !routeId) throw Object.assign(new Error(`Host ${serverId} is offline`), { code: "host_offline", serverId, }); - origin.sendHostFrame( + return origin.sendHostFrame( routeId, typeof data === "string" || data instanceof Uint8Array ? data : new Uint8Array(data), ); diff --git a/packages/client/src/node/connector.test.ts b/packages/client/src/node/connector.test.ts index 6e916d4bec..882a2f5706 100644 --- a/packages/client/src/node/connector.test.ts +++ b/packages/client/src/node/connector.test.ts @@ -44,12 +44,14 @@ test("a slow consumer cannot exceed the shared byte budget and close releases re socket.pause(); await ready; const frame = new Uint8Array(1024 * 1024); - for (let index = 0; index < 4; index++) transport.send(frame); + const writes = Array.from({ length: 4 }, () => transport.send(frame)); + const drained = Promise.allSettled(writes); expect(budget.bufferedBytes).toBe(4 * 1024 * 1024); expect(() => transport.send(frame)).toThrow("Connected host buffer limit reached"); expect(budget.bufferedBytes).toBeLessThanOrEqual(budget.limit); transport.close(); await expect.poll(() => budget.bufferedBytes).toBe(0); + await drained; }); test("direct connector preserves 25 MiB binary frames in both directions", async () => { const server = await endpoint(); @@ -65,12 +67,13 @@ test("direct connector preserves 25 MiB binary frames in both directions", async await ready; const bytes = new Uint8Array(25 * 1024 * 1024).fill(173); const echoed = once(socket, "message"); - transport.send(bytes); + const sending = transport.send(bytes); const [sent, binary] = await echoed; expect(binary).toBe(true); expect(Buffer.from(sent as Uint8Array).equals(bytes)).toBe(true); socket.send(bytes); expect(Buffer.from(await received).equals(bytes)).toBe(true); + await sending; }); import { @@ -124,24 +127,29 @@ test("encrypted connector rejects overload synchronously and preserves the physi }); await new Promise((resolve) => transport.onOpen(resolve)); await ready; + expect(transport.maxFrameBytes).toBeLessThan(32 * 1024 * 1024); peer.pause(); const frame = new Uint8Array(1024 * 1024).fill(173); let rejected: unknown; + const writes: (void | Promise)[] = []; for (let index = 0; index < 8; index++) { try { - transport.send(frame); + writes.push(transport.send(frame)); } catch (error) { rejected = error; break; } } + const drained = Promise.allSettled(writes); expect(rejected).toMatchObject({ code: "resource_limit" }); expect(budget.bufferedBytes).toBeLessThanOrEqual(budget.limit); await new Promise((resolve) => setImmediate(resolve)); expect(errors).toEqual([]); expect(closed).toBe(false); - transport.send("sibling control"); + const sibling = transport.send("sibling control"); peer.resume(); + await drained; + await sibling; await expect.poll(() => received.includes("sibling control")).toBe(true); expect(received.filter((data) => data instanceof ArrayBuffer).length).toBeGreaterThan(0); expect( diff --git a/packages/client/src/node/index.ts b/packages/client/src/node/index.ts index 46b5cff17a..30d2b8744f 100644 --- a/packages/client/src/node/index.ts +++ b/packages/client/src/node/index.ts @@ -172,9 +172,15 @@ export function createHostTransportFactory( close(); }); return { + get supportsDrain() { + return transport?.supportsDrain; + }, + get maxFrameBytes() { + return transport?.maxFrameBytes; + }, send(data) { if (closed || !transport) throw new Error("Host transport is unavailable"); - transport.send(data); + return transport.send(data); }, close, onOpen(handler) { diff --git a/packages/protocol/src/binary-frames/host-route.ts b/packages/protocol/src/binary-frames/host-route.ts index f45cd714f9..643dcb4e98 100644 --- a/packages/protocol/src/binary-frames/host-route.ts +++ b/packages/protocol/src/binary-frames/host-route.ts @@ -3,6 +3,11 @@ import { MAX_WEBSOCKET_MESSAGE_BYTES } from "../websocket-message-limit.js"; export const HOST_ROUTE_OPCODE = 0x30; export const MAX_HOST_ROUTE_ID_BYTES = 256; export const MAX_HOST_ROUTE_HEADER_BYTES = 3 + MAX_HOST_ROUTE_ID_BYTES; +// A logical host channel crosses the 32 MiB route admission boundary. Text can +// expand sixfold when enclosed in JSON; binary adds the bounded route header. +export const MAX_HOST_CHANNEL_FRAME_BYTES = Math.floor( + (32 * 1024 * 1024 - MAX_HOST_ROUTE_HEADER_BYTES) / 6, +); export const MAX_ROUTED_PAYLOAD_BYTES = MAX_WEBSOCKET_MESSAGE_BYTES - MAX_HOST_ROUTE_HEADER_BYTES; export interface HostRouteFrame { routeId: string; diff --git a/packages/protocol/src/chunked-transport.test.ts b/packages/protocol/src/chunked-transport.test.ts new file mode 100644 index 0000000000..3979b48885 --- /dev/null +++ b/packages/protocol/src/chunked-transport.test.ts @@ -0,0 +1,392 @@ +import { describe, expect, it } from "vitest"; +import { ChunkedTransport } from "./chunked-transport.js"; + +describe("chunked transport", () => { + it("delivers a large UTF-8 logical message once, preserving order", async () => { + const delivered: (string | Uint8Array)[] = []; + const receiver = new ChunkedTransport({ + frameBytes: 17, + send: () => {}, + deliver: (v) => delivered.push(v), + }); + receiver.enabled = true; + const frames: (string | Uint8Array)[] = []; + const sender = new ChunkedTransport({ + frameBytes: 17, + send: (v) => { + frames.push(v); + receiver.receive(v); + }, + deliver: () => {}, + }); + sender.enabled = true; + const text = "🌍".repeat(100); + await sender.send(text); + expect(delivered).toEqual([text]); + expect(frames.length).toBeGreaterThan(1); + expect(frames.every((v) => v instanceof Uint8Array && v.byteLength <= 17)).toBe(true); + }); +}); + +it("preserves binary kind and leaves legacy frames unchanged", async () => { + const delivered: (string | Uint8Array)[] = []; + const receiver = new ChunkedTransport({ + frameBytes: 8, + send: () => {}, + deliver: (v) => delivered.push(v), + }); + const frames: (string | Uint8Array)[] = []; + const sender = new ChunkedTransport({ + frameBytes: 8, + send: (v) => { + frames.push(v); + receiver.receive(v); + }, + deliver: () => {}, + }); + const data = new Uint8Array(40).fill(5); + await sender.send(data); + expect(frames).toEqual([data]); + frames.length = 0; + sender.enabled = receiver.enabled = true; + await sender.send(data); + expect(frames).toHaveLength(10); + expect(delivered).toEqual([data, data]); +}); + +it("rejects malformed sequences and drops incomplete transfers on close", () => { + const delivered: unknown[] = []; + const receiver = new ChunkedTransport({ + frameBytes: 8, + send: () => {}, + deliver: (v) => delivered.push(v), + }); + receiver.enabled = true; + receiver.receive(new Uint8Array([0x31, 1, 0, 1, 65])); + expect(() => receiver.receive("interrupted")).toThrow("Interrupted"); + receiver.receive(new Uint8Array([0x31, 1, 0, 2, 66])); + expect(delivered).toEqual([]); +}); + +it("waits for drain before the next chunk and preserves logical ordering", async () => { + const frames: (string | Uint8Array)[] = []; + let drain!: () => void; + const sender = new ChunkedTransport({ + frameBytes: 8, + send: (v) => { + frames.push(v); + if (frames.length === 1) + return new Promise((resolve) => { + drain = resolve; + }); + }, + deliver: () => {}, + }); + sender.enabled = true; + const first = sender.send("abcdefghij"); + const next = new Uint8Array([1, 2, 3, 4]); + const second = sender.send(next); + await Promise.resolve(); + expect(frames).toHaveLength(1); + expect(sender.queuedByteLength).toBe(4); + drain(); + await Promise.all([first, second]); + expect(frames).toHaveLength(4); + expect(frames[3]).toEqual(next); + expect(sender.queuedByteLength).toBe(0); +}); + +it.each([ + [0x31, 2, 0, 3, 65], + [0x31, 1, 2, 3, 65], + [0x31, 1, 0, 4, 65], + [0x31, 1, 0, 2, 65], + [0x31, 1, 0, 1], +])("rejects malformed chunk metadata %j", (...bytes) => { + const receiver = new ChunkedTransport({ + frameBytes: 8, + send: () => {}, + deliver: () => { + throw new Error("Unexpected delivery"); + }, + }); + receiver.enabled = true; + expect(() => receiver.receive(new Uint8Array(bytes))).toThrow(); +}); + +it("reconnect discards partial state and requires negotiation again", () => { + const delivered: unknown[] = []; + const create = () => + new ChunkedTransport({ frameBytes: 8, send: () => {}, deliver: (v) => delivered.push(v) }); + const old = create(); + old.enabled = true; + old.receive(new Uint8Array([0x31, 1, 0, 1, 65])); + old.close(); + old.receive(new Uint8Array([0x31, 1, 0, 2, 66])); + const fresh = create(); + expect(fresh.enabled).toBe(false); + fresh.enabled = true; + fresh.receive(new Uint8Array([0x31, 1, 0, 3, 67])); + expect(delivered).toEqual(["C"]); +}); + +it("bounds logical messages waiting behind a blocked writer", async () => { + let drain!: () => void; + const transport = new ChunkedTransport({ + frameBytes: 32 * 1024 * 1024, + send: () => + new Promise((resolve) => { + drain = resolve; + }), + deliver: () => {}, + }); + const first = transport.send(new Uint8Array(100 * 1024 * 1024)); + const overflow = transport.send("one more byte"); + try { + await expect(overflow).rejects.toThrow("queue"); + } finally { + drain(); + await first; + } +}); + +it("requests drain for chunks while ordinary frames keep the immediate write path", async () => { + const waits: boolean[] = []; + const transport = new ChunkedTransport({ + frameBytes: 8, + send: (_frame, wait) => { + waits.push(wait); + }, + deliver: () => {}, + }); + transport.enabled = true; + await transport.send("small"); + await transport.send("abcdefghij"); + expect(waits).toEqual([false, true, true, true]); +}); + +it("charges queued ASCII messages by their UTF-8 bytes", async () => { + let drain!: () => void; + let blocked = true; + const transport = new ChunkedTransport({ + frameBytes: 32 * 1024 * 1024, + send: () => { + if (blocked) { + blocked = false; + return new Promise((resolve) => { + drain = resolve; + }); + } + }, + deliver: () => {}, + }); + transport.enabled = true; + const first = transport.send(new Uint8Array([1])); + const text = "x".repeat(10 * 1024 * 1024); + const queued = Array.from({ length: 4 }, () => transport.send(text)); + const settled = Promise.allSettled(queued); + expect(transport.queuedByteLength).toBe(40 * 1024 * 1024); + drain(); + await first; + expect((await settled).map((result) => result.status)).toEqual([ + "fulfilled", + "fulfilled", + "fulfilled", + "fulfilled", + ]); +}); + +it("notifies the socket owner when an awaited send overflows the queue", async () => { + let drain!: () => void; + const failures: unknown[] = []; + const transport = new ChunkedTransport({ + frameBytes: 32 * 1024 * 1024, + send: () => + new Promise((resolve) => { + drain = resolve; + }), + deliver: () => {}, + onFailure: (error) => { + failures.push(error); + }, + }); + const first = transport.send(new Uint8Array(100 * 1024 * 1024)); + const overflow = transport.send("extra"); + await expect(overflow).rejects.toThrow("queue"); + drain(); + await first; + expect(failures).toHaveLength(1); +}); + +it("bounds retained reassembly memory for many one-byte chunks", async () => { + const { spawnSync } = await import("node:child_process"); + // Exercise the built package as a Node consumer; CI builds it before protocol tests. + const entry = new URL("../dist/chunked-transport.js", import.meta.url).href; + const result = spawnSync( + process.execPath, + [ + "--expose-gc", + "--input-type=module", + "-e", + ` + import { ChunkedTransport } from ${JSON.stringify(entry)}; + let delivered = 0; + const receiver = new ChunkedTransport({ frameBytes: 8, send() {}, deliver(frame) { delivered = frame.byteLength; } }); + receiver.enabled = true; + global.gc(); + const before = process.memoryUsage().heapUsed; + receiver.receive(new Uint8Array([0x31, 1, 1, 1, 65])); + for (let index = 0; index < 300000; index++) receiver.receive(new Uint8Array([0x31, 1, 1, 0, 65])); + global.gc(); + const growth = process.memoryUsage().heapUsed - before; + receiver.receive(new Uint8Array([0x31, 1, 1, 2, 66])); + console.log(JSON.stringify({ growth, delivered })); + `, + ], + { encoding: "utf8", timeout: 15000 }, + ); + expect(result.status, result.stderr).toBe(0); + const observed = JSON.parse(result.stdout); + expect(observed.delivered).toBe(300002); + expect(observed.growth).toBeLessThan(8 * 1024 * 1024); +}, 20000); + +it.each(["ASCII", "中", "🌍", "\ud800", "\udc00", "\ud800x"])( + "accounts for queued UTF-8 text %j exactly", + async (text) => { + let drain!: () => void; + let blocked = true; + const transport = new ChunkedTransport({ + frameBytes: 1024, + send: () => { + if (blocked) { + blocked = false; + return new Promise((resolve) => { + drain = resolve; + }); + } + }, + deliver: () => {}, + }); + const first = transport.send(new Uint8Array([1])); + const second = transport.send(text); + expect(transport.queuedByteLength).toBe(new TextEncoder().encode(text).byteLength); + drain(); + await Promise.all([first, second]); + }, +); + +it("notifies the owner once when an accepted writer fails", async () => { + const failures: unknown[] = []; + const error = new Error("write failed"); + const transport = new ChunkedTransport({ + frameBytes: 1024, + deliver: () => {}, + onFailure: (failure) => { + failures.push(failure); + }, + send: () => Promise.reject(error), + }); + await expect(transport.send("hello", { waitForDrain: true })).rejects.toThrow("write failed"); + expect(failures).toEqual([error]); + expect(() => transport.send("again")).toThrow("Transport closed"); + expect(failures).toEqual([error]); +}); + +it.each([false, true])( + "a pre-write rejection preserves the sender (chunked=%s)", + async (chunked) => { + const failures: unknown[] = []; + let reject = true; + const transport = new ChunkedTransport({ + frameBytes: 8, + deliver: () => {}, + onFailure: (error) => { + failures.push(error); + }, + send: () => { + if (reject) throw new Error("capacity"); + }, + }); + transport.enabled = chunked; + expect(() => transport.send("abcdefghij")).toThrow("capacity"); + expect(failures).toEqual([]); + reject = false; + await transport.send("sibling"); + }, +); + +it("a queued pre-write rejection does not poison the next logical send", async () => { + let drain!: () => void; + const failures: unknown[] = []; + const delivered: string[] = []; + const transport = new ChunkedTransport({ + frameBytes: 1024, + deliver: () => {}, + onFailure: (error) => { + failures.push(error); + }, + send: (frame) => { + if (frame === "blocked") + return new Promise((resolve) => { + drain = resolve; + }); + if (frame === "reject") throw new Error("capacity"); + delivered.push(frame as string); + }, + }); + const first = transport.send("blocked"); + const rejected = transport.send("reject"); + const sibling = transport.send("sibling"); + const results = Promise.allSettled([first, rejected, sibling]); + drain(); + expect((await results).map((result) => result.status)).toEqual([ + "fulfilled", + "rejected", + "fulfilled", + ]); + expect(delivered).toEqual(["sibling"]); + expect(failures).toEqual([]); +}); + +it.each([false, true])("delivery errors leave framing usable (chunked=%s)", async (chunked) => { + let fail = true; + const delivered: (string | Uint8Array)[] = []; + const transport = new ChunkedTransport({ + frameBytes: 64, + send: () => {}, + deliver: (frame) => { + if (fail) throw new Error("listener failed"); + delivered.push(frame); + }, + }); + transport.enabled = chunked; + const frame = chunked ? new Uint8Array([0x31, 1, 0, 3, 65]) : "A"; + expect(() => transport.receive(frame)).toThrow("listener failed"); + fail = false; + transport.receive(frame); + expect(delivered).toEqual(["A"]); + await transport.send("still connected"); +}); + +it("unpaced transports retain ordinary outbound admission and accept inbound chunks", async () => { + const frames: unknown[] = []; + const delivered: unknown[] = []; + const transport = new ChunkedTransport({ + frameBytes: 8, + supportsDrain: false, + send: (frame) => { + frames.push(frame); + if (typeof frame === "string" && frame.length > 8) throw new Error("single frame limit"); + }, + deliver: (frame) => { + delivered.push(frame); + }, + }); + transport.enabled = true; + expect(() => transport.send("too large for one frame")).toThrow("single frame limit"); + expect(frames).toEqual(["too large for one frame"]); + transport.receive(new Uint8Array([0x31, 1, 0, 3, 65])); + expect(delivered).toEqual(["A"]); + await transport.send("small"); +}); diff --git a/packages/protocol/src/chunked-transport.ts b/packages/protocol/src/chunked-transport.ts new file mode 100644 index 0000000000..a32d68424f --- /dev/null +++ b/packages/protocol/src/chunked-transport.ts @@ -0,0 +1,214 @@ +import { MAX_WEBSOCKET_MESSAGE_BYTES } from "./websocket-message-limit.js"; + +type Frame = string | Uint8Array; + +/** A malformed wire frame, distinct from a consumer delivery failure. */ +export class ChunkedTransportProtocolError extends Error {} +// Reserved outside the application binary opcode space. Version, kind, flags follow. +const OPCODE = 0x31; +const HEADER = 4; + +/** Connection-scoped ordered framing, below serialization and routing, above encryption. */ +export class ChunkedTransport { + enabled = false; + private assembly: Uint8Array | undefined; + private size = 0; + private kind = 0; + private closed = false; + private pending: Promise | undefined; + private queuedBytes = 0; + private activeBytes = 0; + + constructor( + private readonly io: { + frameBytes: number; + supportsDrain?: boolean; + send: (frame: Frame, waitForDrain: boolean) => void | Promise; + deliver: (frame: Frame) => void; + onFailure?: (error: unknown) => void; + }, + ) { + if (io.frameBytes <= HEADER) throw new Error("Invalid transport frame budget"); + } + + /** Bytes waiting behind the active write; physical buffering owns the active frame. */ + get queuedByteLength(): number { + return this.queuedBytes - this.activeBytes; + } + get isWriting(): boolean { + return this.pending !== undefined; + } + + send(frame: Frame, options: { waitForDrain?: boolean; byteLength?: number } = {}): Promise { + const byteLength = options.byteLength ?? frameByteLength(frame); + if (byteLength > MAX_WEBSOCKET_MESSAGE_BYTES) throw new Error("Logical message too large"); + const chunk = + this.enabled && this.io.supportsDrain !== false && byteLength > this.io.frameBytes; + if (this.closed) throw new Error("Transport closed"); + const write = () => { + if (this.closed) throw new Error("Transport closed"); + this.activeBytes = byteLength; + // A synchronous rejection did not admit a frame. Only accepted writes can + // fail the connection; queued admission failures still belong to their sender. + const result = chunk + ? this.sendChunks( + typeof frame === "string" ? new TextEncoder().encode(frame) : frame, + typeof frame === "string", + ) + : this.io.send(frame, options.waitForDrain === true); + if (!result) return; + return result.catch((error) => { + this.fail(error); + throw error; + }); + }; + if (this.queuedBytes + byteLength > MAX_WEBSOCKET_MESSAGE_BYTES) { + const error = new Error("Logical output queue exceeded its byte limit"); + this.fail(error); + const rejected = Promise.reject(error); + void rejected.catch(() => {}); + return rejected; + } + this.queuedBytes += byteLength; + let result: void | Promise; + try { + result = this.pending ? this.pending.then(write) : write(); + } catch (error) { + this.queuedBytes -= byteLength; + this.activeBytes = 0; + throw error; + } + if (!result) { + this.queuedBytes -= byteLength; + this.activeBytes = 0; + return Promise.resolve(); + } + const completion = Promise.resolve(result).finally(() => { + this.queuedBytes -= byteLength; + this.activeBytes = 0; + }); + const tail = completion.then( + () => undefined, + () => undefined, + ); + this.pending = tail; + void tail.then(() => { + if (this.pending === tail) this.pending = undefined; + return undefined; + }); + return completion; + } + + private sendChunks(bytes: Uint8Array, text: boolean): Promise { + const capacity = this.io.frameBytes - HEADER; + const writeChunk = (offset: number) => { + if (this.closed) throw new Error("Transport closed"); + const end = Math.min(bytes.byteLength, offset + capacity); + const wire = new Uint8Array(HEADER + end - offset); + wire.set([ + OPCODE, + 1, + text ? 0 : 1, + (offset === 0 ? 1 : 0) | (end === bytes.byteLength ? 2 : 0), + ]); + wire.set(bytes.subarray(offset, end), HEADER); + return this.io.send(wire, true); + }; + const first = writeChunk(0); + return (async () => { + await first; + for (let offset = capacity; offset < bytes.byteLength; offset += capacity) + await writeChunk(offset); + })(); + } + + receive(frame: Frame): void { + if (this.closed) return; + let message: Frame | undefined; + try { + message = this.assemble(frame); + } catch (error) { + this.close(); + throw new ChunkedTransportProtocolError( + error instanceof Error ? error.message : String(error), + { cause: error }, + ); + } + if (message !== undefined) this.io.deliver(message); + } + + private assemble(frame: Frame): Frame | undefined { + if (typeof frame === "string" || frame[0] !== OPCODE) { + if (this.assembly) throw new Error("Interrupted chunked message"); + return frame; + } + if ( + !this.enabled || + frame.length <= HEADER || + frame[1] !== 1 || + frame[2]! > 1 || + (frame[3]! & ~3) !== 0 + ) + throw new Error("Invalid chunk header"); + const start = (frame[3]! & 1) !== 0; + const end = (frame[3]! & 2) !== 0; + if (start === !!this.assembly) throw new Error("Invalid chunk sequence"); + if (start) { + this.assembly = new Uint8Array(0); + this.kind = frame[2]!; + this.size = 0; + } + if (frame[2] !== this.kind) throw new Error("Chunk kind changed"); + this.size += frame.length - HEADER; + if (this.size > MAX_WEBSOCKET_MESSAGE_BYTES) throw new Error("Logical message too large"); + const payload = frame.subarray(HEADER); + if (this.assembly!.byteLength < this.size) { + const capacity = Math.min( + MAX_WEBSOCKET_MESSAGE_BYTES, + Math.max(this.size, this.assembly!.byteLength * 2, 16384), + ); + const grown = new Uint8Array(capacity); + grown.set(this.assembly!.subarray(0, this.size - payload.byteLength)); + this.assembly = grown; + } + this.assembly!.set(payload, this.size - payload.byteLength); + if (!end) return; + const data = this.assembly!.subarray(0, this.size); + this.assembly = undefined; + this.size = 0; + return this.kind === 0 ? new TextDecoder("utf-8", { fatal: true }).decode(data) : data.slice(); + } + + private fail(error: unknown): void { + if (this.closed) return; + this.close(); + this.io.onFailure?.(error); + } + + close(): void { + this.closed = true; + this.enabled = false; + this.assembly = undefined; + this.size = 0; + } +} + +function frameByteLength(frame: Frame): number { + if (typeof frame !== "string") return frame.byteLength; + let bytes = 0; + for (let index = 0; index < frame.length; index++) { + const code = frame.charCodeAt(index); + if (code <= 0x7f) bytes++; + else if (code <= 0x7ff) bytes += 2; + else if ( + code >= 0xd800 && + code <= 0xdbff && + frame.charCodeAt(index + 1) >= 0xdc00 && + frame.charCodeAt(index + 1) <= 0xdfff + ) { + bytes += 4; + index++; + } else bytes += 3; + } + return bytes; +} diff --git a/packages/protocol/src/client-capabilities.ts b/packages/protocol/src/client-capabilities.ts index a35852cc06..28ec8456cb 100644 --- a/packages/protocol/src/client-capabilities.ts +++ b/packages/protocol/src/client-capabilities.ts @@ -1,4 +1,5 @@ export const CLIENT_CAPS = { + binaryChunkingV1: "binary_chunking_v1", helloRejection: "hello_rejection", // COMPAT(ownedSubscriptions): added in v0.8.0, remove legacy ownership after 2027-03-09. ownedSubscriptions: "owned_subscriptions", diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 8d8f852988..dc63d56b75 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -3834,6 +3834,7 @@ export const ServerInfoStatusPayloadSchema = z selectiveAgentTimeline: z.boolean().optional(), explicitEventSubscriptions: z.boolean().optional(), ownedSubscriptions: z.boolean().optional(), + binaryChunkingV1: z.boolean().optional(), workspaceSetupObservation: z.boolean().optional(), workspaceSortTimes: z.boolean().optional(), // COMPAT(canonicalSubmittedPrompts): added in v0.2.6, remove gate after 2027-01-30. 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 b6765051f6..4c6d79aced 100644 --- a/packages/server/src/server/connected-hosts/hosts.e2e.test.ts +++ b/packages/server/src/server/connected-hosts/hosts.e2e.test.ts @@ -2091,3 +2091,80 @@ test.each(["removed", "disabled"] as const)( ).toEqual([]); }, ); + +test( + "a 50 MiB timeline crosses a routed host without dropping content or the route", + { timeout: 30000 }, + async () => { + const b = await daemon(); + const a = await daemon(); + const destination = await api(b); + const origin = await api(a); + const workspace = await destination.paseo.workspaces.create({ + source: { kind: "directory", path: b.staticDir }, + }); + const agent = await workspace.agents.create({ + config: { provider: "claude/claude-test", modeId: "full-access" }, + title: "Large history", + }); + // Keep each row below the established item budget; exercise transport size across rows. + const text = "x".repeat(240 * 1024); + const rowCount = 220; + for (let index = 0; index < rowCount; index++) { + await b.daemon.agentManager.appendTimelineItem(agent.id, { type: "user_message", text }); + } + await origin.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` }); + const serverId = (await online(origin.paseo)).hosts[0]!.serverId!; + const remote = await origin.paseo.hosts.client(serverId); + const response = await remote.agents.ref(agent.id).timeline.refetch({ limit: 0 }); + const messages = response.entries.flatMap((row) => + row.item.type === "user_message" ? [row.item.text] : [], + ); + expect(messages).toHaveLength(rowCount); + expect(messages.every((message) => message === text)).toBe(true); + expect( + messages.reduce((bytes, message) => bytes + Buffer.byteLength(message), 0), + ).toBeGreaterThan(50 * 1024 * 1024); + expect((await remote.workspaces.list()).entries).toHaveLength(1); + await remote.dispose(); + }, +); + +test("a pre-write host budget rejection leaves sibling routes connected", async () => { + const { HostTransportBudget } = await import("@getpaseo/client/node"); + const b = await daemon(); + const origin = createHostClient( + `127.0.0.1:${b.port}`, + { clientId: "budget-origin", reconnect: { enabled: false } }, + new HostTransportBudget(1024 * 1024), + ); + cleanup.push(() => origin.close()); + await origin.connect(); + const serverId = origin.getLastServerInfoMessage()!.serverId; + const routeIds = [ + await origin.openHostLink("budget-first"), + await origin.openHostLink("budget-sibling"), + ]; + const peers = routeIds.map( + (id, index) => + new DaemonClient({ + url: "ws://host-link/ws", + clientId: `budget-child-${index}`, + reconnect: { enabled: false }, + transportFactory: createHostChannelTransport(origin, serverId, `budget-${index}`, id), + }), + ); + for (const peer of peers) { + cleanup.push(() => peer.close()); + await peer.connect(); + } + await expect(async () => + origin.sendHostFrame(routeIds[0]!, new Uint8Array(2 * 1024 * 1024)), + ).rejects.toMatchObject({ code: "resource_limit" }); + await peers[1]!.ping({ timeoutMs: 2000 }); + expect([origin.isConnected, ...peers.map((peer) => peer.isConnected)]).toEqual([ + true, + true, + true, + ]); +}, 30000); diff --git a/packages/server/src/server/connected-hosts/index.ts b/packages/server/src/server/connected-hosts/index.ts index 96d6ac35d2..15b1e16b97 100644 --- a/packages/server/src/server/connected-hosts/index.ts +++ b/packages/server/src/server/connected-hosts/index.ts @@ -510,10 +510,13 @@ export class ConnectedHosts { return; } if (!verified) throw new HostError("host_offline", "Host identity has not been verified"); - try { - transport.send(data); - } catch { + const rejectWrite = () => route.close("resource_limit", "Host buffer limit reached or transport unavailable"); + try { + const write = transport.send(data); + if (write) void write.catch(rejectWrite); + } catch { + rejectWrite(); } }, }; diff --git a/packages/server/src/server/connected-hosts/link.ts b/packages/server/src/server/connected-hosts/link.ts index 85fe06ed4e..a18c89aeb0 100644 --- a/packages/server/src/server/connected-hosts/link.ts +++ b/packages/server/src/server/connected-hosts/link.ts @@ -1,7 +1,11 @@ import type { SessionDelivery } from "../session/owned-subscriptions/index.js"; import { EventEmitter } from "node:events"; import { createHash } from "node:crypto"; -import { encodeHostRouteFrame, type HostRouteFrame } from "@getpaseo/protocol/binary-frames/index"; +import { + MAX_HOST_CHANNEL_FRAME_BYTES, + encodeHostRouteFrame, + type HostRouteFrame, +} from "@getpaseo/protocol/binary-frames/index"; import type { DaemonPermission, SessionInboundMessage, @@ -10,6 +14,7 @@ import type { /** A logical socket has no network resources or authority of its own. */ export class VirtualHostSocket extends EventEmitter { + readonly maxFrameBytes = MAX_HOST_CHANNEL_FRAME_BYTES; readyState = 1; constructor( private readonly output: (data: string | Uint8Array) => Promise, diff --git a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts index 32d3a1f36c..f47accaa55 100644 --- a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts @@ -9,7 +9,7 @@ import { } from "@getpaseo/protocol/binary-frames/index"; import { getDaemonAuthFailureReason, - type DaemonClient as HostClient, + DaemonClient as HostClient, } from "@getpaseo/client/internal/daemon-client"; import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; import { describe, expect, test } from "vitest"; @@ -593,72 +593,112 @@ async function waitForCapturedLog( } }, 90000); - test("oversized attachments fail without disconnecting or resubmitting over the relay", async () => { - await startRelay({ useLocalRelay: true }); - const daemon = await createTestPaseoDaemon({ - relayEnabled: true, - relayEndpoint: `127.0.0.1:${relayPort}`, - relayUseTls: false, - relayPublicUseTls: false, - }); - let client: DaemonClient | null = null; - try { - const offerUrl = await getPairingOfferUrl({ - paseoHome: daemon.paseoHome, + test.each([true, false])( + "large messages cross the relay with unpaced upload admission preserved (supportsDrain=%s)", + async (supportsDrain) => { + await startRelay({ useLocalRelay: true }); + const daemon = await createTestPaseoDaemon({ relayEnabled: true, - relayEndpoint: daemon.config.relayEndpoint, + relayEndpoint: `127.0.0.1:${relayPort}`, + relayUseTls: false, + relayPublicUseTls: false, }); - const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl); - client = new DaemonClient({ - url: buildRelayWebSocketUrl({ - endpoint: `127.0.0.1:${relayPort}`, - useTls: false, - serverId, - role: "client", - }), - e2ee: { enabled: true, daemonPublicKeyB64 }, - reconnect: { baseDelayMs: 100, maxDelayMs: 100 }, - }); - await client.connect(); - const statuses: string[] = []; - client.subscribeConnectionStatus((state) => statuses.push(state.status)); - for (const imageMiB of [30, 61]) { - // Attachments are base64 in the JSON, then E2EE base64 expands the frame again. - const images = [ - { data: "A".repeat(4 * Math.ceil((imageMiB * 1024 * 1024) / 3)), mimeType: "image/png" }, - ]; + let client: HostClient | null = null; + try { + const offerUrl = await getPairingOfferUrl({ + paseoHome: daemon.paseoHome, + relayEnabled: true, + relayEndpoint: daemon.config.relayEndpoint, + }); + const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl); + client = new HostClient({ + clientId: "paced-relay-regression", + webSocketFactory: Object.assign((url: string) => new WebSocket(url), { supportsDrain }), + url: buildRelayWebSocketUrl({ + endpoint: `127.0.0.1:${relayPort}`, + useTls: false, + serverId, + role: "client", + }), + e2ee: { enabled: true, daemonPublicKeyB64 }, + reconnect: { baseDelayMs: 100, maxDelayMs: 100 }, + }); + await client.connect(); + const statuses: string[] = []; + client.subscribeConnectionStatus((state) => statuses.push(state.status)); + for (const imageMiB of [30, 61]) { + const images = [ + { + data: "A".repeat(4 * Math.ceil((imageMiB * 1024 * 1024) / 3)), + mimeType: "image/png", + }, + ]; + const creation = client.createWorkspace({ + source: { kind: "directory", path: daemon.staticDir }, + agent: { + provider: "claude", + cwd: daemon.staticDir, + initialPrompt: "Describe these screens.", + images, + }, + }); + if (supportsDrain) + await withTimeout(creation, 30000, "Chunked workspace creation did not settle"); + else await expect(creation).rejects.toThrow(/too large.*32\.0 MiB/); + } + expect((await client.fetchAgents()).entries).toHaveLength(supportsDrain ? 2 : 0); await expect( - withTimeout( - client.createWorkspace({ - source: { kind: "directory", path: daemon.staticDir }, - agent: { - provider: "claude", - cwd: daemon.staticDir, - initialPrompt: "Describe these screens.", - images, - }, - }), - 10000, - "Workspace creation did not settle", - ), - ).rejects.toThrow(/too large.*32\.0 MiB/); - expect((await client.fetchAgents()).entries).toEqual([]); - } - const agent = await client.createAgent({ provider: "claude", cwd: daemon.staticDir }); - await expect( - client.sendMessage(agent.id, "Describe these screens.", { + client.createWorkspace({ + source: { kind: "directory", path: daemon.staticDir }, + agent: { + provider: "claude", + cwd: daemon.staticDir, + initialPrompt: "Describe these screens.", + images: [{ data: "A".repeat(101 * 1024 * 1024), mimeType: "image/png" }], + }, + }), + ).rejects.toThrow(/too large.*100\.0 MiB/); + expect((await client.fetchAgents()).entries).toHaveLength(supportsDrain ? 2 : 0); + const agent = await client.createAgent({ provider: "claude", cwd: daemon.staticDir }); + const attachment = client.sendMessage(agent.id, "Describe these screens.", { images: [{ data: "A".repeat(40 * 1024 * 1024), mimeType: "image/png" }], - }), - ).rejects.toThrow(/too large.*32\.0 MiB/); - await client.sendMessage(agent.id, "A small follow-up still works."); - expect((await client.fetchAgents()).entries).toHaveLength(1); - expect(statuses).toEqual(["connected"]); - } finally { - await client?.close(); - await daemon.close(); - await stopRelay(); - } - }, 60000); + }); + if (supportsDrain) await attachment; + else await expect(attachment).rejects.toThrow(/too large.*32\.0 MiB/); + await expect( + client.sendMessage(agent.id, "Describe these screens.", { + images: [{ data: "A".repeat(101 * 1024 * 1024), mimeType: "image/png" }], + }), + ).rejects.toThrow(/too large.*100\.0 MiB/); + await client.sendMessage(agent.id, "A small follow-up still works."); + expect((await client.fetchAgents()).entries).toHaveLength(supportsDrain ? 3 : 1); + // Respect the per-item content budget while exceeding the relay frame limit. + const text = "x".repeat(240 * 1024); + for (let row = 0; row < 220; row++) + await daemon.daemon.agentManager.appendTimelineItem(agent.id, { + type: "user_message", + text, + }); + const timeline = await client.fetchAgentTimeline(agent.id, { + projection: "canonical", + limit: 0, + timeout: 30000, + }); + const delivered = timeline.entries.filter( + (row) => row.item.type === "user_message" && row.item.text === text, + ); + expect(delivered).toHaveLength(220); + expect(delivered.length * Buffer.byteLength(text)).toBeGreaterThan(50 * 1024 * 1024); + await client.ping(); + expect(statuses).toEqual(["connected"]); + } finally { + await client?.close(); + await daemon.close(); + await stopRelay(); + } + }, + 120000, + ); test("daemon connects to relay and client ping/pong works through relay", async () => { process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12"; diff --git a/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts b/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts index 633bd3bc1c..3c131ecab3 100644 --- a/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts @@ -1836,3 +1836,58 @@ describe("list terminals across directories", () => { ]); }); }); + +test("a paused terminal consumer catches up through a snapshot instead of retaining every output frame", async () => { + const { existsSync } = await import("node:fs"); + const cwd = tmpCwd(); + tempDirs.push(cwd); + const marker = path.join(cwd, "output-complete"); + const created = await createTerminalInWorkspace(ctx.client, { + cwd, + options: { + command: process.execPath, + args: [ + "-e", + ` + const fs = require('node:fs'); + process.stdin.setRawMode?.(true); + process.stdin.resume(); + process.stdin.once('data', () => { + const chunk = Buffer.alloc(256 * 1024, 120); + let remaining = 128; + const write = () => { + if (remaining-- === 0) { fs.writeFileSync(${JSON.stringify(marker)}, 'done'); return; } + process.stdout.write(chunk, write); + }; + write(); + }); + `, + ], + }, + }); + const ws = await connectRawWebSocket(ctx.daemon.port); + try { + const slot = await subscribeRawTerminal(ws, created.terminal!.id, "slow-terminal"); + await waitForRawBinaryFrame( + ws, + (frame) => frame.slot === slot && frame.opcode === TerminalStreamOpcode.Snapshot, + ); + const capture = startTerminalOutputCapture(ws, slot); + try { + ws.pause(); + sendRawTerminalInput(ws, slot, "go\n"); + await waitForCondition(() => existsSync(marker), 20000); + ws.resume(); + await waitForCondition(() => capture.snapshotCount > 0, 10000); + expect(ws.readyState).toBe(WebSocket.OPEN); + expect(capture.frames.reduce((total, frame) => total + frame.payloadBytes, 0)).toBeLessThan( + 32 * 1024 * 1024, + ); + } finally { + ws.resume(); + capture.stop(); + } + } finally { + await closeWebSocket(ws); + } +}, 40000); diff --git a/packages/server/src/server/websocket-server.file-transfer.e2e.test.ts b/packages/server/src/server/websocket-server.file-transfer.e2e.test.ts index 5131456ee9..abce664ffd 100644 --- a/packages/server/src/server/websocket-server.file-transfer.e2e.test.ts +++ b/packages/server/src/server/websocket-server.file-transfer.e2e.test.ts @@ -90,7 +90,11 @@ test( TEST_TIMEOUT_MS, ); -async function connectSocket(port: number, clientId: string): Promise { +async function connectSocket( + port: number, + clientId: string, + capabilities?: Record, +): Promise { const socket = new WebSocket(`ws://127.0.0.1:${port}/ws`); await new Promise((resolve, reject) => { socket.once("open", resolve); @@ -98,7 +102,7 @@ async function connectSocket(port: number, clientId: string): Promise }); await sendAndWait( socket, - { type: "hello", clientId, clientType: "browser", protocolVersion: 1 }, + { type: "hello", clientId, clientType: "browser", protocolVersion: 1, capabilities }, (message) => message.type === "session" && message.message.type === "status" && @@ -166,3 +170,97 @@ function sendAndWait( socket.send(JSON.stringify(message)); return response; } + +test("an awaited timeline framing overflow closes the actual daemon socket", async () => { + const { createHostClient } = await import("@getpaseo/client/node"); + const { createPaseoApi } = await import("@getpaseo/client"); + const { default: pino } = await import("pino"); + const { Writable } = await import("node:stream"); + const messages: string[] = []; + const logger = pino( + { level: "warn" }, + new Writable({ + write(chunk, _encoding, done) { + messages.push(JSON.parse(chunk.toString()).msg); + done(); + }, + }), + ); + daemon = await createTestPaseoDaemon({ logger }); + const client = createHostClient(`127.0.0.1:${daemon.port}`, { + clientId: "framing-seed", + reconnect: { enabled: false }, + }); + await client.connect(); + const api = createPaseoApi(client); + try { + const workspace = await api.workspaces.create({ + source: { kind: "directory", path: daemon.staticDir }, + }); + const agents = []; + const text = "x".repeat(240 * 1024); + for (const [index, rows] of [350, 90].entries()) { + const agent = await workspace.agents.create({ + config: { provider: "claude/claude-test", modeId: "full-access" }, + title: `Framing overflow ${index}`, + }); + agents.push(agent.id); + for (let row = 0; row < rows; row++) + await daemon.daemon.agentManager.appendTimelineItem(agent.id, { + type: "user_message", + text, + }); + } + const source = await connectSocket(daemon.port, "framing-overflow", { + binary_chunking_v1: true, + }); + sockets.push(source); + const closed = new Promise((resolve) => source.once("close", () => resolve())); + const firstChunk = new Promise((resolve) => { + const listener = (data: RawData, binary: boolean) => { + if (!binary || (data as Buffer)[0] !== 0x31) return; + source.off("message", listener); + source.pause(); + resolve(); + }; + source.on("message", listener); + }); + const fetch = (agentId: string, requestId: string) => + source.send( + JSON.stringify({ + type: "session", + message: { + type: "fetch_agent_timeline_request", + requestId, + agentId, + direction: "tail", + projection: "canonical", + limit: 0, + }, + }), + ); + fetch(agents[0]!, "first"); + await firstChunk; + fetch(agents[1]!, "overflow"); + await expect + .poll(() => messages.includes("Closing physical WebSocket after outbound framing failure"), { + timeout: 15000, + }) + .toBe(true); + expect(messages).not.toContain("Closing physical WebSocket at outbound high-water mark"); + source.resume(); + await closed; + } finally { + await api.dispose(); + await client.close(); + } +}, 60000); + +test("malformed chunk metadata closes the daemon socket with a protocol error", async () => { + daemon = await createTestPaseoDaemon(); + const source = await connectSocket(daemon.port, "invalid-framing", { binary_chunking_v1: true }); + sockets.push(source); + const closed = new Promise((resolve) => source.once("close", (code) => resolve(code))); + source.send(new Uint8Array([0x31, 2, 0, 3, 65])); + expect(await closed).toBe(1002); +}); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 96c81ab49a..7bfc4d5a0f 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1,3 +1,7 @@ +import { + ChunkedTransport, + ChunkedTransportProtocolError, +} from "@getpaseo/protocol/chunked-transport"; import { type WorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import { createAgentMessageDelivery, @@ -124,8 +128,8 @@ import { MAX_PHYSICAL_SOCKET_BUFFERED_BYTES, outboundFrameByteLength, physicalSocketHasCapacity, - sendBoundedPhysicalFrame, sendBoundedPhysicalFrameAndWait, + sendBoundedPhysicalFrame, } from "./websocket/physical-socket.js"; const WS_CLOSE_DAEMON_AUTH_FAILED = 4401; @@ -440,6 +444,7 @@ function bufferFromWsData(data: Buffer | ArrayBuffer | Buffer[] | string): Buffe } export interface WebSocketLike { + maxFrameBytes?: number; readyState: number; bufferedAmount?: number; send: ( @@ -1273,6 +1278,67 @@ export class VoiceAssistantWebSocketServer { return { sockets: writableSockets, payload, bytes: outboundFrameByteLength(payload) }; } + private sendHelloResponse(ws: WebSocketLike, session: Session, hello: WSHelloMessage): void { + // The peer learns our support from this ordinary response before receiving chunks. + this.sendToClient(ws, this.createServerInfoMessage(session)); + this.chunkedSocket(ws).enabled = hello.capabilities?.[CLIENT_CAPS.binaryChunkingV1] === true; + } + + private readonly chunkedSockets = new WeakMap(); + + private chunkedSocket(ws: WebSocketLike): ChunkedTransport { + let transport = this.chunkedSockets.get(ws); + if (!transport) { + transport = new ChunkedTransport({ + frameBytes: ws.maxFrameBytes ?? 32 * 1024 * 1024, + onFailure: (err) => + this.closePhysicalSocket({ + ws, + logMessage: "Closing physical WebSocket after outbound framing failure", + logFields: { err }, + }), + send: (frame, waitForDrain) => { + const params = { + socket: ws, + frame, + onHighWater: () => this.closeAtOutboundHighWater(ws), + }; + if (waitForDrain) + return sendBoundedPhysicalFrameAndWait(params).then((sent) => { + if (!sent) throw new Error("Source is closed"); + return undefined; + }); + if (!sendBoundedPhysicalFrame(params)) throw new Error("Source is closed"); + }, + deliver: (frame) => + this.handleRawMessage(ws, typeof frame === "string" ? frame : Buffer.from(frame)), + }); + this.chunkedSockets.set(ws, transport); + } + return transport; + } + + private socketBufferedAmount(ws: WebSocketLike): number | null { + const queued = this.chunkedSockets.get(ws)?.queuedByteLength ?? 0; + if (typeof ws.bufferedAmount === "number") return ws.bufferedAmount + queued; + return queued > 0 ? queued : null; + } + + private sendSocketFrame( + ws: WebSocketLike, + frame: string | Uint8Array, + bytes: number, + waitForDrain = false, + ): Promise { + const transport = this.chunkedSocket(ws); + const admittedBytes = transport.isWriting + ? bytes + : Math.min(bytes, ws.maxFrameBytes ?? 32 * 1024 * 1024); + if (!this.ensureOutboundCapacity(ws, admittedBytes)) + return Promise.reject(new Error("Source is closed")); + return transport.send(frame, { waitForDrain, byteLength: bytes }); + } + private async sendMessageToClientAndWait( ws: WebSocketLike, message: WSOutboundMessage, @@ -1280,13 +1346,7 @@ export class VoiceAssistantWebSocketServer { const prepared = this.prepareJsonDelivery([ws], message, true); if (!prepared) throw new Error("Source message delivery unavailable"); try { - const sent = await sendBoundedPhysicalFrameAndWait({ - socket: ws, - frame: prepared.payload, - frameBytes: prepared.bytes, - onHighWater: () => this.closeAtOutboundHighWater(ws), - }); - if (!sent) throw new Error("Source is closed"); + await this.sendSocketFrame(ws, prepared.payload, prepared.bytes, true); this.runtimeMetrics.recordOutboundMessage(message, ws.bufferedAmount); } catch (err) { this.logger.warn({ err }, "ws_send_failed"); @@ -1313,14 +1373,7 @@ export class VoiceAssistantWebSocketServer { return; } try { - const sent = await sendBoundedPhysicalFrameAndWait({ - socket: ws, - frame, - onHighWater: () => this.closeAtOutboundHighWater(ws), - }); - if (!sent) { - throw new Error("Physical WebSocket is not open"); - } + await this.sendSocketFrame(ws, frame, frame.byteLength, true); this.runtimeMetrics.recordOutboundBinaryFrame(ws.bufferedAmount); } catch (err) { this.logger.warn({ err }, "ws_send_failed"); @@ -1335,13 +1388,9 @@ export class VoiceAssistantWebSocketServer { recordSent: () => void, ): void { try { - const sent = sendBoundedPhysicalFrame({ - socket: ws, - frame, - frameBytes, - onHighWater: () => this.closeAtOutboundHighWater(ws), + void this.sendSocketFrame(ws, frame, frameBytes).then(recordSent, (err) => { + this.logger.warn({ err }, "ws_send_failed"); }); - if (sent) recordSent(); } catch (err) { this.logger.warn({ err }, "ws_send_failed"); } @@ -1349,7 +1398,13 @@ export class VoiceAssistantWebSocketServer { private ensureOutboundCapacity(ws: WebSocketLike, frameBytes: number): boolean { if (ws.readyState !== 1) return false; - if (physicalSocketHasCapacity(ws, frameBytes)) return true; + if ( + physicalSocketHasCapacity( + { bufferedAmount: this.socketBufferedAmount(ws) ?? undefined }, + frameBytes, + ) + ) + return true; this.closeAtOutboundHighWater(ws); return false; @@ -1522,16 +1577,15 @@ export class VoiceAssistantWebSocketServer { if (!connection) { return null; } - if (source) return (source as WebSocketLike).bufferedAmount ?? null; + if (source) return this.socketBufferedAmount(source as WebSocketLike); // Relay-attached sockets are a WebSocketLike that doesn't expose // bufferedAmount. Return null when no socket gives a signal so the // terminal fallback can't mistake "no signal" for "client keeping up"; // a direct ws reports its real buffered bytes (0 when drained). let maxBuffered: number | null = null; for (const socket of connection.sockets) { - if (typeof socket.bufferedAmount === "number") { - maxBuffered = Math.max(maxBuffered ?? 0, socket.bufferedAmount); - } + const buffered = this.socketBufferedAmount(socket); + if (buffered !== null) maxBuffered = Math.max(maxBuffered ?? 0, buffered); } return maxBuffered; }, @@ -1568,8 +1622,9 @@ export class VoiceAssistantWebSocketServer { let total = 0; for (const socket of this.sessions.keys()) { if (socket instanceof VirtualHostSocket) continue; - if (typeof socket.bufferedAmount !== "number") return null; - total += socket.bufferedAmount; + const buffered = this.socketBufferedAmount(socket); + if (buffered === null) return null; + total += buffered; } return total; }, @@ -1770,7 +1825,7 @@ export class VoiceAssistantWebSocketServer { this.externalSessionsByKey.set(sessionKey, connection); } pending.identity.sessionId = connection.session.getSessionId(); - this.sendToClient(ws, this.createServerInfoMessage(connection.session)); + this.sendHelloResponse(ws, connection.session, message); connection.connectionLogger.info( { ...toConnectionLogFields(pending.identity), @@ -1906,7 +1961,7 @@ export class VoiceAssistantWebSocketServer { existing.sockets.add(ws); this.sessions.set(ws, existing); pending.identity.sessionId = existing.session.getSessionId(); - this.sendToClient(ws, this.createServerInfoMessage(existing.session)); + this.sendHelloResponse(ws, existing.session, message); pending.connectionLogger.info( { ...toConnectionLogFields(pending.identity), @@ -1952,6 +2007,7 @@ export class VoiceAssistantWebSocketServer { usageSetting: true, usageSources: true, ownedSubscriptions: true, + binaryChunkingV1: true, agentRequestReceipts: true, workspaceRequestReceipts: true, creationLifecycle: true, @@ -2165,10 +2221,16 @@ export class VoiceAssistantWebSocketServer { private bindSocketHandlers(ws: WebSocketLike): void { ws.on("message", (...args: unknown[]) => { const data = args[0] as Buffer | ArrayBuffer | Buffer[] | string; - this.handleRawMessage(ws, data); + try { + this.chunkedSocket(ws).receive(typeof data === "string" ? data : bufferFromWsData(data)); + } catch (error) { + if (!(error instanceof ChunkedTransportProtocolError)) throw error; + ws.close(1002, "Invalid chunked transport"); + } }); ws.on("close", async (...args: unknown[]) => { + this.chunkedSockets.get(ws)?.close(); const code = args[0]; const reason = args[1]; await this.detachSocket(ws, { @@ -2568,7 +2630,7 @@ export class VoiceAssistantWebSocketServer { identity: JSON.stringify([connection.principalId, connection.clientId]), permissions: () => connection.session.getPermissions(), routed: () => connection.session.isRouted(), - bufferedAmount: () => ws.bufferedAmount ?? null, + bufferedAmount: () => this.socketBufferedAmount(ws), delivery: connection.session.delivery, admit: (socket, permissions) => this.attachSocket(socket, undefined, undefined, false, { diff --git a/packages/server/src/server/websocket/encrypted-relay-socket.test.ts b/packages/server/src/server/websocket/encrypted-relay-socket.test.ts index f2a880f15f..3e17f0cbb4 100644 --- a/packages/server/src/server/websocket/encrypted-relay-socket.test.ts +++ b/packages/server/src/server/websocket/encrypted-relay-socket.test.ts @@ -140,3 +140,74 @@ test("encrypted sockets do not double-count bytes already buffered by the transp expect(socket.bufferedAmount).toBe(payload.byteLength + 40); }); + +test("50 MiB logical text and routed binary cross encrypted relay frames in both directions", async () => { + const { ChunkedTransport } = await import("@getpaseo/protocol/chunked-transport"); + const { openClientSecureChannel, openDaemonSecureChannel } = await import("@getpaseo/relay/e2ee"); + const { generateKeyPair, exportPublicKey, maxSecuredPlaintextByteLength } = + await import("@getpaseo/relay"); + const { encodeHostRouteFrame, decodeHostRouteFrame } = + await import("@getpaseo/protocol/binary-frames/index"); + type Transport = import("@getpaseo/relay/e2ee").Transport; + const limit = 32 * 1024 * 1024; + const wireSizes: number[] = []; + const endpoint = (): Transport => ({ + send: () => {}, + close: () => {}, + onmessage: null, + onclose: null, + onerror: null, + }); + const left = endpoint(); + const right = endpoint(); + const wire = (destination: Transport) => (data: string | ArrayBuffer) => { + const size = typeof data === "string" ? Buffer.byteLength(data) : data.byteLength; + expect(size).toBeLessThanOrEqual(limit); + wireSizes.push(size); + queueMicrotask(() => destination.onmessage?.({ data, isBinary: typeof data !== "string" })); + }; + left.send = wire(right); + right.send = wire(left); + const key = generateKeyPair(); + const delivered: (string | Uint8Array)[][] = [[], []]; + let receivers: InstanceType[] = []; + const incoming = (index: number) => (data: string | ArrayBuffer) => + receivers[index]!.receive(typeof data === "string" ? data : new Uint8Array(data)); + const daemonPromise = openDaemonSecureChannel(right, { + daemonKeyPair: key, + events: { onmessage: incoming(1) }, + }); + const client = await openClientSecureChannel(left, { + daemonPublicKeyB64: exportPublicKey(key.publicKey), + events: { onmessage: incoming(0) }, + }); + const daemon = await daemonPromise; + receivers = [client, daemon].map( + (channel, index) => + new ChunkedTransport({ + frameBytes: maxSecuredPlaintextByteLength(limit), + send: (frame) => channel.send(typeof frame === "string" ? frame : frame.slice().buffer), + deliver: (frame) => { + delivered[index]!.push(frame); + }, + }), + ); + for (const receiver of receivers) receiver.enabled = true; + const text = "x".repeat(50 * 1024 * 1024); + wireSizes.length = 0; + await receivers[1]!.send(text); + await new Promise((resolve) => setImmediate(resolve)); + expect(delivered[0]).toEqual([text]); + expect(wireSizes.length).toBeGreaterThan(1); + const routed = encodeHostRouteFrame("route", new Uint8Array(50 * 1024 * 1024).fill(7)); + await receivers[0]!.send(routed); + await new Promise((resolve) => setImmediate(resolve)); + expect(delivered[1]).toHaveLength(1); + const received = decodeHostRouteFrame(delivered[1]![0] as Uint8Array)!; + expect(received.routeId).toBe("route"); + expect( + Buffer.from(received.payload).equals(Buffer.from(new Uint8Array(50 * 1024 * 1024).fill(7))), + ).toBe(true); + client.close(); + daemon.close(); +}, 30_000); diff --git a/packages/server/src/server/websocket/encrypted-relay-socket.ts b/packages/server/src/server/websocket/encrypted-relay-socket.ts index f11162761c..794789718c 100644 --- a/packages/server/src/server/websocket/encrypted-relay-socket.ts +++ b/packages/server/src/server/websocket/encrypted-relay-socket.ts @@ -1,3 +1,5 @@ +import { maxSecuredPlaintextByteLength } from "@getpaseo/relay"; +import { MAX_RELAY_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit"; import { EventEmitter } from "node:events"; import { MAX_PHYSICAL_SOCKET_BUFFERED_BYTES } from "./physical-socket.js"; @@ -8,6 +10,7 @@ export interface EncryptedRelayChannel { } export interface EncryptedRelaySocket { + readonly maxFrameBytes: number; readonly readyState: number; readonly bufferedAmount: number; send: (data: string | Uint8Array | ArrayBuffer) => void | Promise; @@ -43,6 +46,7 @@ export function createEncryptedRelaySocket(params: { }); return { + maxFrameBytes: maxSecuredPlaintextByteLength(MAX_RELAY_WEBSOCKET_MESSAGE_BYTES), get readyState() { return readyState; }, diff --git a/packages/server/src/server/websocket/physical-socket.test.ts b/packages/server/src/server/websocket/physical-socket.test.ts index c81b1410d6..6dce3e151d 100644 --- a/packages/server/src/server/websocket/physical-socket.test.ts +++ b/packages/server/src/server/websocket/physical-socket.test.ts @@ -120,3 +120,96 @@ test("the awaitable physical send rejects callback errors", async () => { }), ).rejects.toThrow("send failed"); }); + +test("ordinary frames expose a paused real socket's backlog and terminate at the physical backstop", async () => { + const { once } = await import("node:events"); + const { WebSocket, WebSocketServer } = await import("ws"); + const { ChunkedTransport } = await import("@getpaseo/protocol/chunked-transport"); + const server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + await once(server, "listening"); + const connected = once(server, "connection"); + const address = server.address(); + if (typeof address === "string" || !address) throw new Error("Missing server address"); + const client = new WebSocket(`ws://127.0.0.1:${address.port}`); + await once(client, "open"); + const [socket] = (await connected) as [InstanceType]; + const tcp = (socket as unknown as { _socket: import("node:net").Socket })._socket; + let terminated = false; + const transport = new ChunkedTransport({ + frameBytes: 32 * 1024 * 1024, + deliver: () => {}, + send: (frame, wait) => { + const options = { + socket, + frame, + onHighWater: () => { + terminated = true; + socket.terminate(); + }, + }; + if (wait) return sendBoundedPhysicalFrameAndWait(options).then(() => undefined); + sendBoundedPhysicalFrame(options); + }, + }); + try { + client.pause(); + tcp.cork(); + const frame = new Uint8Array(256 * 1024); + for (let count = 0; count < 200; count++) await transport.send(frame); + expect(socket.bufferedAmount).toBeGreaterThan(4 * 1024 * 1024); + expect(transport.queuedByteLength).toBe(0); + for (let count = 0; count < 100; count++) { + if (terminated) break; + await transport.send(frame); + } + expect(terminated).toBe(true); + } finally { + transport.close(); + client.terminate(); + socket.terminate(); + await new Promise((resolve) => server.close(() => resolve())); + } +}); + +test("an awaited framing failure terminates its real socket immediately", async () => { + const { once } = await import("node:events"); + const { WebSocket, WebSocketServer } = await import("ws"); + const { ChunkedTransport } = await import("@getpaseo/protocol/chunked-transport"); + const server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + await once(server, "listening"); + const connected = once(server, "connection"); + const address = server.address(); + if (typeof address === "string" || !address) throw new Error("Missing server address"); + const client = new WebSocket(`ws://127.0.0.1:${address.port}`); + await once(client, "open"); + const [socket] = (await connected) as [InstanceType]; + const tcp = (socket as unknown as { _socket: import("node:net").Socket })._socket; + const transport = new ChunkedTransport({ + frameBytes: 32 * 1024 * 1024, + deliver: () => {}, + onFailure: () => socket.terminate(), + send: (frame) => + sendBoundedPhysicalFrameAndWait({ + socket, + frame, + onHighWater: () => socket.terminate(), + }).then(() => undefined), + }); + transport.enabled = true; + try { + client.pause(); + tcp.cork(); + const closed = once(socket, "close"); + const first = transport.send(new Uint8Array(100 * 1024 * 1024), { waitForDrain: true }); + const overflow = transport.send(new Uint8Array([1]), { waitForDrain: true }); + const settled = Promise.allSettled([first, overflow]); + await closed; + expect(socket.readyState).toBe(WebSocket.CLOSED); + expect((await settled).map((result) => result.status)).toEqual(["rejected", "rejected"]); + } finally { + transport.close(); + client.terminate(); + socket.terminate(); + await new Promise((resolve) => server.close(() => resolve())); + } +}); From 0a549945b550fe7c40c154e43e74026f44445f35 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 31 Oct 2026 09:45:57 +0700 Subject: [PATCH 34/95] fix(terminal): keep native text aligned with terminal cells (#8336) Shape rows in native Canvas and CoreText, then place clusters on xterm columns so fallback font advances cannot separate text from its cursor. Wait for measured dimensions before attachment and repaint overhanging ink with affected rows. Use Android platform ICU to avoid bundling approximately 14 MB of data; retain and document the Android 10 conjunct limitation. --- CLAUDE.md | 1 + docs/terminal-rendering.md | 65 +++ .../e2e/mobile/terminal-rendering/check.mjs | 311 +++++++++++++ .../e2e/mobile/terminal-rendering/fixture.mjs | 59 +++ .../mobile/terminal-rendering/identity.mjs | 423 +++++++++++++++++ .../mobile/terminal-rendering/overhang.mjs | 236 ++++++++++ .../mobile/terminal-rendering/reattach.mjs | 149 ++++++ .../e2e/mobile/terminal-rendering/render.mjs | 197 ++++++++ .../paseo-terminal/android/build.gradle | 25 ++ .../src/androidTest/assets/grapheme-rows.json | 38 ++ .../paseo/terminal/TerminalRowLayoutTest.kt | 105 +++++ .../android/src/main/AndroidManifest.xml | 1 + .../sh/paseo/terminal/PaseoTerminalModule.kt | 24 + .../sh/paseo/terminal/PaseoTerminalView.kt | 196 ++++++++ .../sh/paseo/terminal/TerminalCustomGlyphs.kt | 76 ++++ .../java/sh/paseo/terminal/TerminalFont.kt | 61 +++ .../java/sh/paseo/terminal/TerminalFrame.kt | 100 +++++ .../sh/paseo/terminal/TerminalRowLayout.kt | 306 +++++++++++++ .../app/modules/paseo-terminal/contract.ts | 60 +++ .../paseo-terminal/custom-glyphs.test.ts} | 20 +- .../paseo-terminal/custom-glyphs.ts} | 52 ++- .../paseo-terminal/expo-module.config.json | 9 + .../paseo-terminal/frame-encoding.test.ts | 34 ++ .../modules/paseo-terminal/frame-encoding.ts | 27 ++ packages/app/modules/paseo-terminal/index.tsx | 47 ++ .../paseo-terminal/ios/PaseoTerminal.podspec | 22 + .../ios/PaseoTerminalModule.swift | 21 + .../ios/PaseoTerminalView.swift | 179 ++++++++ .../ios/TerminalCustomGlyphs.swift | 73 +++ .../paseo-terminal/ios/TerminalFont.swift | 67 +++ .../paseo-terminal/ios/TerminalFrame.swift | 122 +++++ .../ios/TerminalRowLayout.swift | 335 ++++++++++++++ .../app/modules/paseo-terminal/package.json | 5 + .../terminal-emulator-native-grid.native.tsx | 13 +- .../src/terminal/native-renderer/colors.ts | 253 +++++------ .../native-renderer/terminal-frame.test.ts | 199 ++++++++ .../native-renderer/terminal-frame.ts | 84 ++++ .../terminal-grid-metrics.test.ts | 62 +-- .../native-renderer/terminal-grid-metrics.ts | 54 --- .../terminal-grid-view.native.tsx | 425 ++---------------- .../terminal-row-model.test.ts | 120 ----- .../native-renderer/terminal-row-model.ts | 253 ----------- packages/app/vitest.config.ts | 1 + 43 files changed, 3888 insertions(+), 1022 deletions(-) create mode 100644 docs/terminal-rendering.md create mode 100644 packages/app/e2e/mobile/terminal-rendering/check.mjs create mode 100644 packages/app/e2e/mobile/terminal-rendering/fixture.mjs create mode 100644 packages/app/e2e/mobile/terminal-rendering/identity.mjs create mode 100644 packages/app/e2e/mobile/terminal-rendering/overhang.mjs create mode 100644 packages/app/e2e/mobile/terminal-rendering/reattach.mjs create mode 100644 packages/app/e2e/mobile/terminal-rendering/render.mjs create mode 100644 packages/app/modules/paseo-terminal/android/build.gradle create mode 100644 packages/app/modules/paseo-terminal/android/src/androidTest/assets/grapheme-rows.json create mode 100644 packages/app/modules/paseo-terminal/android/src/androidTest/java/sh/paseo/terminal/TerminalRowLayoutTest.kt create mode 100644 packages/app/modules/paseo-terminal/android/src/main/AndroidManifest.xml create mode 100644 packages/app/modules/paseo-terminal/android/src/main/java/sh/paseo/terminal/PaseoTerminalModule.kt create mode 100644 packages/app/modules/paseo-terminal/android/src/main/java/sh/paseo/terminal/PaseoTerminalView.kt create mode 100644 packages/app/modules/paseo-terminal/android/src/main/java/sh/paseo/terminal/TerminalCustomGlyphs.kt create mode 100644 packages/app/modules/paseo-terminal/android/src/main/java/sh/paseo/terminal/TerminalFont.kt create mode 100644 packages/app/modules/paseo-terminal/android/src/main/java/sh/paseo/terminal/TerminalFrame.kt create mode 100644 packages/app/modules/paseo-terminal/android/src/main/java/sh/paseo/terminal/TerminalRowLayout.kt create mode 100644 packages/app/modules/paseo-terminal/contract.ts rename packages/app/{src/terminal/native-renderer/terminal-custom-glyph.test.ts => modules/paseo-terminal/custom-glyphs.test.ts} (81%) rename packages/app/{src/terminal/native-renderer/terminal-custom-glyph.ts => modules/paseo-terminal/custom-glyphs.ts} (66%) create mode 100644 packages/app/modules/paseo-terminal/expo-module.config.json create mode 100644 packages/app/modules/paseo-terminal/frame-encoding.test.ts create mode 100644 packages/app/modules/paseo-terminal/frame-encoding.ts create mode 100644 packages/app/modules/paseo-terminal/index.tsx create mode 100644 packages/app/modules/paseo-terminal/ios/PaseoTerminal.podspec create mode 100644 packages/app/modules/paseo-terminal/ios/PaseoTerminalModule.swift create mode 100644 packages/app/modules/paseo-terminal/ios/PaseoTerminalView.swift create mode 100644 packages/app/modules/paseo-terminal/ios/TerminalCustomGlyphs.swift create mode 100644 packages/app/modules/paseo-terminal/ios/TerminalFont.swift create mode 100644 packages/app/modules/paseo-terminal/ios/TerminalFrame.swift create mode 100644 packages/app/modules/paseo-terminal/ios/TerminalRowLayout.swift create mode 100644 packages/app/modules/paseo-terminal/package.json create mode 100644 packages/app/src/terminal/native-renderer/terminal-frame.test.ts create mode 100644 packages/app/src/terminal/native-renderer/terminal-frame.ts delete mode 100644 packages/app/src/terminal/native-renderer/terminal-row-model.test.ts delete mode 100644 packages/app/src/terminal/native-renderer/terminal-row-model.ts diff --git a/CLAUDE.md b/CLAUDE.md index 9a2c70de29..71682af7e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,6 +52,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the | [docs/connected-hosts.md](docs/connected-hosts.md) | Daemon-owned host registrations, routed SDK scopes, identity and authority boundaries | | [docs/permissions.md](docs/permissions.md) | Semantic daemon permissions, principals, credentials, pairing invitations, and Hub authority | | [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage | +| [docs/terminal-rendering.md](docs/terminal-rendering.md) | Native terminal view: xterm owns the grid, cluster placement, emoji widths, mixed-direction limits, render regression | | [docs/agent-stream-performance.md](docs/agent-stream-performance.md) | Assistant text pipeline — coalescing window, paced reveal, why arrival lumps are smoothed at render | | [docs/markdown-renderer.md](docs/markdown-renderer.md) | Shared Markdown engine, native selection, links, images and library constraints | | [docs/native-text-selection.md](docs/native-text-selection.md) | Native diagram grouping, selection boundaries and leading inline image limitations | diff --git a/docs/terminal-rendering.md b/docs/terminal-rendering.md new file mode 100644 index 0000000000..fd304aa144 --- /dev/null +++ b/docs/terminal-rendering.md @@ -0,0 +1,65 @@ +# Terminal rendering + +Web and Electron draw terminals with xterm.js. iOS and Android keep an `@xterm/headless` buffer in JS and draw it with the `PaseoTerminal` native view in `packages/app/modules/paseo-terminal`: CoreText on iOS, `Canvas.drawTextRun` on Android. `src/terminal/native-renderer/terminal-frame.ts` turns the visible buffer into frames, and both native views take the same props. + +## xterm owns the grid + +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. + +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. + +`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. + +## Ink outside the row + +Marks stacked above or below a letter, Arabic letter tails and some fallback glyphs ink into the rows next to theirs. Neither view clips a row to its own bounds, because a row clip also cuts marks and Arabic dots. Rows draw top to bottom, so a later row's cell backgrounds cover the ink that reaches into it. + +Android redraws every row on each frame, so overhanging ink cannot go stale. Its row `RenderNode`s are created with `setClipToBounds(false)`. The default would clip only unselected rows, because selected rows are drawn straight onto the view's canvas. + +iOS repaints only the rows that changed. Each `TerminalRowLayout` records how far its glyphs ink above and below the row. A changed row invalidates its old and new ink extent, and `draw(_:)` redraws every row whose ink extent meets the dirty rect. Invalidating only the row's own rect left an erased row's marks in the row next to it, erased a neighbour's marks when a row was rewritten, and showed or hid overhang depending on which rows changed together. + +## Mixed-direction text + +Rows draw left to right in logical order on both platforms. Terminal bidi escape modes are not implemented. On iOS the row needs both the `kCTWritingDirection` override and an LTR paragraph base direction. Without the base direction, bidi rule L1 moves trailing spaces ahead of a row that starts with Arabic or Hebrew, and the whole row shifts. + +Arabic still gets the contextual form its logical neighbours select, but in left-to-right order, so letters join on the wrong side. Correct visual Arabic needs implicit bidi per row: reorder RTL runs for display and map cursor, selection and hit-testing through the visual-to-logical index. That changes which column a character appears in, so it has to be decided as product behaviour first. Shaping alone cannot fix it. + +## Render regression + +The scripts live in `packages/app/e2e/mobile/terminal-rendering`. Run them from `packages/app` on the daemon's host, starting from an idle, focused terminal with an empty prompt. + +`render.mjs` prints a mixed-script fixture, screenshots it with agent-device, and checks the geometry with `check.mjs`. The check rebuilds the expected buffer with `@xterm/headless` and measures the grid from a ruler row. It checks that: + +- every grapheme cluster has ink inside its own columns, and blank cells stay blank; +- on the accent rows, every cell is drawn in its own colour, including cells inside one cluster; +- the cursor is drawn after the text; +- every row is exposed as `terminal-row-`; +- selecting one cell of a conjunct highlights only that cell. + +On iOS the screenshot is taken at 3 pixels per point (`PASEO_TERMINAL_RENDERING_PIXEL_DENSITY`), because the simulator's default is one pixel per point and a glyph is then about 12 pixels. Focus the terminal before a run: with the simulator's hardware keyboard connected the key bar shows without a focused input, and `type` fails. In a debug build, the dev client's floating gear button covers the right end of the first row, so ruler cells there fail. + +```bash +PLATFORM=android ANDROID_SERIAL=emulator-5554 node e2e/mobile/terminal-rendering/render.mjs +PLATFORM=ios IOS_DEVICE="iPhone 17" node e2e/mobile/terminal-rendering/render.mjs +``` + +Geometry passes when a cell holds the wrong glyph. `identity.mjs` judges each cluster of a `render.mjs` run against pango's rendering of the same characters in the device's fonts. The reference is cut at the edges of the cluster's columns, placed as the renderer places it, because glyphs such as Devanagari letters ink past their advance and the device cuts them there too. A shape passes when its coverage correlates with its own reference at 0.6 or more, and no other cluster of the corpus matches it better. Box and block characters are judged against their Unicode definition. Shades are judged by even density over a 4×4 grid, because the device and pango fall back to different shade fonts. Clusters that pango draws without ink are reported as unjudged instead: flags on both platforms and Apple Color Emoji. The `rect` and `swap` controls fill every cluster with white, or judge each one against another cluster. Both must fail every cluster, which shows the oracle refuses ink that is present but wrong. An Arabic letter is compared with all four of its positional forms, so the oracle shows the right letter is drawn, not that it has the right form. Ink that a neighbouring row draws into a cluster's cells counts as that cluster's ink. On iOS the tail of `م` in the Arabic row reaches one pixel into the `न` below it, so `न` fails although both rows are drawn correctly. + +```bash +FONTCONFIG_FILE=fonts.conf node e2e/mobile/terminal-rendering/identity.mjs [none|rect|swap] +``` + +`fonts.conf` points fontconfig at the device's font files and lists, for `monospace`, the terminal font followed by the device's fallbacks for Hangul, Arabic, Devanagari, CJK, emoji and symbols: + +- **Android:** pull the fonts from `/system/fonts` with `adb pull`. The terminal font is Droid Sans Mono. +- **iOS:** copy the simulator runtime's fonts from `RuntimeRoot/System/Library/Fonts`. The terminal font is Menlo. PingFang stores its outlines in Apple's `hvgl` table, which FreeType cannot draw, so the CJK references come from Apple SD Gothic Neo instead. `語` then scores below the threshold although the device draws it correctly. + +`overhang.mjs` prints rows whose marks reach the rows next to them. In one update it writes into two rows that hold a neighbour's marks and erases two rows whose marks reach a blank row. The marks must be drawn before the update, stay drawn in the written rows, and leave no ink behind the erased rows. Run it like `render.mjs`. + +`reattach.mjs` checks the restore size. It changes the PTY size by opening the keyboard (`RESIZE=keyboard`) or folding an Android foldable (`RESIZE=fold`), relaunches the app, and compares the client's rows with `paseo terminal capture`. A trial where no terminal rows appear, for example after a launch crash, counts as never attached and fails the run. diff --git a/packages/app/e2e/mobile/terminal-rendering/check.mjs b/packages/app/e2e/mobile/terminal-rendering/check.mjs new file mode 100644 index 0000000000..fbf4529bb3 --- /dev/null +++ b/packages/app/e2e/mobile/terminal-rendering/check.mjs @@ -0,0 +1,311 @@ +import assert from "node:assert/strict"; +import { writeFileSync } from "node:fs"; +import xterm from "@xterm/headless"; +import { PNG } from "pngjs"; +import { ACCENT, fixtureLines, fixtureOutput, RULER_CELLS, SELECT_LINE } from "./fixture.mjs"; + +/** A pixel is ink when one channel differs from the terminal background by more than this. */ +const INK_THRESHOLD = 70; +/** Ink pixels a printed grapheme cluster needs inside its own columns. */ +const MIN_CLUSTER_INK = 3; +/** How far a pixel may sit from the background-to-color blend line and still count as that color. */ +const BLEND_TOLERANCE = 40; +/** Pixels of another cell's color tolerated in a cell, for antialiasing at the cell edge. */ +const MAX_FOREIGN_INK = 2; + +const graphemes = new Intl.Segmenter("und", { granularity: "grapheme" }); + +/** + * Checks a screenshot of the fixture against the cells xterm assigns: every printed grapheme + * cluster has ink inside its own columns, blank cells stay blank, and the cursor sits right after + * the text. A cluster that spans cells, such as a Devanagari conjunct, is shaped as one unit, so + * it is judged across all its columns. On rows that use the fixture's accent color, every cell + * must be drawn in its own color, including cells that share a shaped cluster. + * + * These are geometry and color checks: they cannot tell a correct glyph from a wrong one. + * + * `rows` maps a terminal row index to its rect in screenshot pixels. + */ +export async function checkTerminalScreenshot({ png, size, rows }) { + const terminal = await fixtureTerminal(size); + assert.ok(rows.get(0), "The ruler row is not exposed as terminal-row-0"); + const grid = measureGrid(png, rows.get(0)); + const { failures, slots } = checkRows({ png, terminal, rows, grid }); + terminal.dispose(); + return { grid, failures, slots }; +} + +/** Where the harness selects: the first cell of the conjunct that starts the `select` line. */ +export async function selectionTarget({ size }) { + const terminal = await fixtureTerminal(size); + const row = lineNames(terminal.buffer.active, terminal.buffer.active.cursorY + 1).indexOf( + SELECT_LINE, + ); + terminal.dispose(); + return { row, col: 0, clusterColumns: 2 }; +} + +/** + * Checks a screenshot taken with only the first cell of the `select` conjunct selected: that cell + * has the selection background and the conjunct's second, unselected cell keeps its accent color. + */ +export function checkPartialSelection({ png, rows, grid, target }) { + const rect = rows.get(target.row); + if (!rect) return [{ row: target.row, kind: "missing-row" }]; + const cell = (col) => ({ + left: grid.left + col * grid.cellWidth + 1, + right: grid.left + (col + 1) * grid.cellWidth - 1, + top: rect.y, + bottom: rect.y + rect.height, + }); + const selected = cell(target.col); + const unselected = cell(target.col + 1); + const selectionBackground = pixelAt(png, selected.left + 1, selected.top + 1); + const unselectedBackground = pixelAt(png, unselected.left + 1, unselected.top + 1); + if ( + !differs(selectionBackground, grid.background) || + differs(unselectedBackground, grid.background) + ) { + return [{ row: target.row, col: target.col, kind: "selection-not-set-up" }]; + } + const failures = []; + const ownInk = countColor(png, unselected, grid.background, ACCENT); + if (ownInk < MIN_CLUSTER_INK) { + failures.push({ row: target.row, col: target.col + 1, kind: "unselected-cell-lost-color" }); + } + return failures; +} + +async function fixtureTerminal(size) { + const terminal = new xterm.Terminal({ + cols: size.columns, + rows: size.rows, + allowProposedApi: true, + }); + await new Promise((done) => + terminal.write(fixtureOutput(size.columns).replaceAll("\n", "\r\n"), done), + ); + return terminal; +} + +export function writeOverlay(png, slots, path) { + const out = new PNG({ width: png.width, height: png.height }); + png.data.copy(out.data); + for (const slot of slots) { + const color = slot.ok ? [0, 200, 0] : [255, 0, 0]; + const { left, right, top, bottom } = slot.box; + for (let x = Math.ceil(left); x < right; x += 1) { + for (const y of [Math.ceil(top), Math.floor(bottom) - 1]) { + const offset = (out.width * y + x) * 4; + out.data.set(color, offset); + } + } + if (!slot.ok) { + for (let y = Math.ceil(top); y < bottom; y += 1) { + for (const x of [Math.ceil(left), Math.floor(right) - 1]) { + out.data.set(color, (out.width * y + x) * 4); + } + } + } + } + writeFileSync(path, PNG.sync.write(out)); +} + +function inkAt(png, x, y, background) { + const offset = (png.width * Math.round(y) + Math.round(x)) * 4; + return ( + Math.abs(png.data[offset] - background[0]) > INK_THRESHOLD || + Math.abs(png.data[offset + 1] - background[1]) > INK_THRESHOLD || + Math.abs(png.data[offset + 2] - background[2]) > INK_THRESHOLD + ); +} + +/** Pixels in `box` that differ from `background` in some channel by more than the ink threshold. */ +export function countInk(png, box, background) { + let count = 0; + for (let y = Math.ceil(box.top); y < Math.floor(box.bottom); y += 1) { + for (let x = Math.ceil(box.left); x < Math.floor(box.right); x += 1) { + if (inkAt(png, x, y, background)) count += 1; + } + } + return count; +} + +function differs(a, b) { + return a.some((channel, index) => Math.abs(channel - b[index]) > INK_THRESHOLD); +} + +/** Distance from `pixel` to the nearest blend of `background` toward `color`. */ +function blendDistance(pixel, background, color) { + const direction = color.map((channel, index) => channel - background[index]); + const length = direction.reduce((sum, value) => sum + value * value, 0); + const offset = pixel.map((channel, index) => channel - background[index]); + const along = offset.reduce((sum, value, index) => sum + value * direction[index], 0); + const alpha = length === 0 ? 0 : Math.min(1, Math.max(0, along / length)); + return Math.hypot(...offset.map((value, index) => value - alpha * direction[index])); +} + +/** Ink pixels in `box` that are a blend of the background toward `color`. */ +function countColor(png, box, background, color, other) { + let count = 0; + for (let y = Math.ceil(box.top); y < Math.floor(box.bottom); y += 1) { + for (let x = Math.ceil(box.left); x < Math.floor(box.right); x += 1) { + if (!inkAt(png, x, y, background)) continue; + const pixel = pixelAt(png, x, y); + const distance = blendDistance(pixel, background, color); + if (distance > BLEND_TOLERANCE) continue; + if (other && blendDistance(pixel, background, other) <= distance) continue; + count += 1; + } + } + return count; +} + +function pixelAt(png, x, y) { + const offset = (png.width * Math.round(y) + Math.round(x)) * 4; + return [png.data[offset], png.data[offset + 1], png.data[offset + 2]]; +} + +/** Reads the grid origin and cell width from a ruler row of `RULER_CELLS` full blocks. */ +export function measureGrid(png, rulerRect) { + const y = rulerRect.y + rulerRect.height / 2; + const background = pixelAt(png, rulerRect.x + rulerRect.width - 2, y); + let left = -1; + let right = -1; + for (let x = Math.ceil(rulerRect.x); x < rulerRect.x + rulerRect.width; x += 1) { + if (!inkAt(png, x, y, background)) continue; + if (left < 0) left = x; + right = x + 1; + } + assert.ok(left >= 0, "The ruler row has no ink"); + const foreground = pixelAt(png, Math.round((left + right) / 2), y); + return { left, cellWidth: (right - left) / RULER_CELLS, background, foreground }; +} + +function lineNames(buffer, count) { + const names = fixtureLines(0).map((line) => line.name); + const result = []; + let index = -1; + for (let row = 0; row < count; row += 1) { + if (!buffer.getLine(row).isWrapped) index += 1; + result.push(names[index] ?? `line-${index}`); + } + return result; +} + +/** The row's printed cells, consecutive cells of one grapheme cluster grouped together. */ +function rowClusters(line, columns, cell) { + const cells = []; + let text = ""; + for (let col = 0; col < columns; col += 1) { + line.getCell(col, cell); + const width = cell.getWidth(); + if (width === 0) continue; + const chars = cell.getChars() || " "; + cells.push({ + col, + width, + chars, + offset: text.length, + painted: cell.isInverse() || !cell.isBgDefault(), + accent: cell.isFgRGB() && cell.getFgColor() === rgbValue(ACCENT), + }); + text += chars; + } + const clusterEnds = [...graphemes.segment(text)].map( + (segment) => segment.index + segment.segment.length, + ); + const clusters = []; + let end = 0; + for (const entry of cells) { + while (entry.offset >= clusterEnds[end]) end += 1; + const last = clusters.at(-1); + if (last && last.end === end) last.cells.push(entry); + else clusters.push({ end, cells: [entry] }); + } + return clusters.map((cluster) => cluster.cells); +} + +function rgbValue([red, green, blue]) { + return (red << 16) | (green << 8) | blue; +} + +/** Each cell of a cluster that uses the accent color holds ink of its own color only. */ +function checkClusterColors({ png, cluster, rect, grid }) { + const kinds = []; + for (const entry of cluster) { + const box = { + left: grid.left + entry.col * grid.cellWidth + 1, + right: grid.left + (entry.col + entry.width) * grid.cellWidth - 1, + top: rect.y, + bottom: rect.y + rect.height, + }; + const [color, other] = entry.accent ? [ACCENT, grid.foreground] : [grid.foreground, ACCENT]; + const own = countColor(png, box, grid.background, color, other); + const foreign = countColor(png, box, grid.background, other, color); + if (own < MIN_CLUSTER_INK || foreign > MAX_FOREIGN_INK) { + kinds.push({ col: entry.col, kind: entry.accent ? "accent-color-lost" : "color-leaked" }); + } + } + return kinds; +} + +function checkRows({ png, terminal, rows, grid }) { + const buffer = terminal.buffer.active; + const lastRow = buffer.cursorY; + const names = lineNames(buffer, lastRow + 1); + const failures = []; + const slots = []; + const cell = buffer.getNullCell(); + for (let row = 0; row <= lastRow; row += 1) { + const rect = rows.get(row); + if (!rect) { + failures.push({ row, line: names[row], kind: "missing-row" }); + continue; + } + const clusters = rowClusters(buffer.getLine(row), terminal.cols, cell); + // Color is judged on whole rows that use the accent, so a color leaking into a neighbouring + // default-colored cell fails too. + const checksColor = clusters.some((cluster) => cluster.some((entry) => entry.accent)); + for (const cluster of clusters) { + const first = cluster[0]; + const columns = cluster.reduce((sum, entry) => sum + entry.width, 0); + const left = grid.left + first.col * grid.cellWidth; + const right = left + columns * grid.cellWidth; + const box = { left: left + 1, right: right - 1, top: rect.y, bottom: rect.y + rect.height }; + const chars = cluster.map((entry) => entry.chars).join(""); + const isCursor = row === lastRow && first.col === buffer.cursorX; + let kind = null; + if (isCursor) { + const covered = countInk(png, box, grid.background); + const area = (box.right - box.left) * (box.bottom - box.top); + if (covered < area * 0.25) kind = "cursor-not-drawn"; + } else if (chars.trim() !== "") { + if (countInk(png, box, grid.background) < MIN_CLUSTER_INK) kind = "missing-ink"; + } else if (!cluster.some((entry) => entry.painted)) { + const quarter = (right - left) / 4; + const center = { ...box, left: left + quarter, right: right - quarter }; + if (countInk(png, center, grid.background) > 0) kind = "ink-in-blank"; + } + const colorFailures = + kind === null && checksColor && chars.trim() !== "" + ? checkClusterColors({ png, cluster, rect, grid }) + : []; + slots.push({ + row, + line: names[row], + col: first.col, + width: columns, + chars, + isCursor, + box, + ok: kind === null && colorFailures.length === 0, + }); + if (kind) failures.push({ row, line: names[row], col: first.col, char: chars, kind }); + for (const failure of colorFailures) { + failures.push({ row, line: names[row], char: chars, ...failure }); + } + } + } + return { failures, slots }; +} diff --git a/packages/app/e2e/mobile/terminal-rendering/fixture.mjs b/packages/app/e2e/mobile/terminal-rendering/fixture.mjs new file mode 100644 index 0000000000..e4ff5c2bb4 --- /dev/null +++ b/packages/app/e2e/mobile/terminal-rendering/fixture.mjs @@ -0,0 +1,59 @@ +// Terminal output covering the scripts and styles the native renderer must place on the grid. +// Each entry is one line of output; `ruler` is drawn with full blocks so the harness can read +// the grid origin and cell width from pixels, independent of the renderer under test. + +const ESC = "\u001b"; +const sgr = (codes, text) => `${ESC}[${codes}m${text}${ESC}[0m`; + +export const RULER_CELLS = 20; +/** A foreground no theme uses, so its pixels can be told apart from default text. */ +export const ACCENT = [255, 64, 160]; +const accent = (text) => sgr(`38;2;${ACCENT.join(";")}`, text); +/** + * The `select` line starts with a conjunct whose first cell the harness selects on its own. Its + * second cell uses the accent, which no theme's selection foreground matches. + */ +export const SELECT_LINE = "select"; + +export function fixtureLines(columns) { + const wrapSyllables = Math.ceil(columns / 2) + 6; + return [ + { name: "ruler", text: "█".repeat(RULER_CELLS) }, + { name: "ascii", text: "ASCII abcdefghij 0123456789 end" }, + { name: "hangul-mixed", text: "한글 문장 Hello end" }, + { name: "hangul-color-boundary", text: `안녕하세요${sgr(31, "NEXT")} end` }, + { name: "hangul-bold-boundary", text: `${sgr(1, "안녕하세요")}NEXT end` }, + { name: "hangul-decomposed", text: "한글 end" }, + { name: "cjk", text: "日本語テキスト 中文字符 end" }, + { name: "combining", text: "é ä ñ x̣̂ end" }, + { name: "arabic", text: "مرحبا لا 123 abc end" }, + { name: "devanagari", text: "नमस्ते क्षत्रिय end" }, + { name: "emoji", text: "👍 ❤️ 👋🏽 👩‍💻 🇰🇷 end" }, + // One shaped cluster over two cells, colored per cell: the color must follow the cell. + { name: "split-color", text: `क्${accent("ष")} ${accent("क्")}ष ल${accent("ا")} end` }, + { name: SELECT_LINE, text: `क्${accent("ष")} end` }, + { name: "box", text: "╭──┬──╮ │ab│cd│ ╰──┴──╯ ▀▄▌▐░▒▓ end" }, + { + name: "styles", + text: [ + sgr(1, "bold"), + sgr(3, "italic"), + sgr(4, "under"), + sgr(7, "inverse"), + sgr(42, "bg"), + sgr(9, "strike"), + "end", + ].join(" "), + }, + { name: "hangul-wrap", text: "가".repeat(wrapSyllables) }, + // The last line has no newline: the shell sleeps with the cursor right after it. + { name: "cursor", text: "> 안녕하세요" }, + ]; +} + +/** The bytes `cat` writes; the tty turns each newline into CR LF. */ +export function fixtureOutput(columns) { + return fixtureLines(columns) + .map((line) => line.text) + .join("\n"); +} diff --git a/packages/app/e2e/mobile/terminal-rendering/identity.mjs b/packages/app/e2e/mobile/terminal-rendering/identity.mjs new file mode 100644 index 0000000000..a0a6246a3d --- /dev/null +++ b/packages/app/e2e/mobile/terminal-rendering/identity.mjs @@ -0,0 +1,423 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { PNG } from "pngjs"; +import { checkTerminalScreenshot } from "./check.mjs"; + +// Checks that every printed cluster of a `render.mjs` run is the right glyph. `check.mjs` proves +// where ink lands and what color it has; this compares each cluster's ink with pango's (HarfBuzz) +// rendering of the same characters in the device's own fonts, so a wrong glyph, tofu, a solid +// block, a split conjunct or a cluster from elsewhere fails. +// +// FONTCONFIG_FILE= node e2e/mobile/terminal-rendering/identity.mjs [control] +// +// The fonts.conf lists the device's font files and prefers, for `monospace`, the terminal font +// followed by the device's fallbacks; `docs/terminal-rendering.md` says how to build one. Needs +// `pango-view` on the PATH. +// +// control: +// none judge the screenshot (default); exits 1 when a cluster fails. +// rect first fill every judged cluster's box with white; exits 1 unless every cluster fails. +// swap judge each cluster against a different cluster of the corpus; exits 1 unless every +// cluster fails. +// The two controls prove the oracle refuses ink that is present but wrong. + +/** Coverage grid side used to compare shapes. */ +const GRID = 16; +/** A pixel at or above this ink strength (0..1) is part of a glyph's bounding box. */ +const INK = 0.25; +/** Lowest shape correlation a cluster may have with its own reference. */ +const THRESHOLD = 0.6; +/** How much better another cluster may match before the cluster's own match stops counting. */ +const MARGIN = 0.02; +/** Lines whose cells carry decorations or a custom-drawn ruler; they stay geometry-only. */ +const SKIPPED_LINES = new Set(["ruler", "styles"]); +const ZWJ = "‍"; + +/** + * The renderer draws box and block characters to fill the cell, so they are judged against their + * Unicode definition. Shades come from the font, whose fallback differs between the device and + * pango, so a shade is judged by how evenly and densely it inks its own bounding box. Fonts dither + * differently: ░ ▒ ▓ measure 0.16, 0.31, 0.75 in Droid Sans Mono and 0.19, 0.50, 0.81 in Menlo. The + * bands split between those, and do not overlap, so one shade never passes as another. + */ +const CELL_GLYPHS = { + "─": { arms: "LR" }, + "│": { arms: "UD" }, + "┬": { arms: "LRD" }, + "┴": { arms: "LRU" }, + "╭": { arms: "DR" }, + "╮": { arms: "DL" }, + "╰": { arms: "UR" }, + "╯": { arms: "UL" }, + "▀": { fill: "top" }, + "▄": { fill: "bottom" }, + "▌": { fill: "left" }, + "▐": { fill: "right" }, + "░": { shade: [0.08, 0.25] }, + "▒": { shade: [0.25, 0.625] }, + "▓": { shade: [0.625, 0.9] }, +}; +/** A shade must ink each region of this grid over its bounding box within its band. */ +const SHADE_GRID = 4; +/** Regions of the cell, as fractions [left, top, right, bottom], that a line to each edge crosses. */ +const ARM_REGIONS = { + U: [0.3, 0, 0.7, 0.2], + D: [0.3, 0.8, 0.7, 1], + L: [0, 0.3, 0.2, 0.7], + R: [0.8, 0.3, 1, 0.7], +}; +/** [filled, empty] halves of each half block. */ +const HALVES = { + top: [ + [0, 0, 1, 0.5], + [0, 0.5, 1, 1], + ], + bottom: [ + [0, 0.5, 1, 1], + [0, 0, 1, 0.5], + ], + left: [ + [0, 0, 0.5, 1], + [0.5, 0, 1, 1], + ], + right: [ + [0.5, 0, 1, 1], + [0, 0, 0.5, 1], + ], +}; + +const [dirArg, control = "none"] = process.argv.slice(2); +if (!dirArg || !["none", "rect", "swap"].includes(control)) { + throw new Error("Usage: identity.mjs [none|rect|swap]"); +} +const dir = resolve(dirArg); +const report = JSON.parse(readFileSync(resolve(dir, "report.json"), "utf8")); +const png = PNG.sync.read(readFileSync(resolve(dir, "terminal.png"))); +const rows = new Map(report.rows.map((row) => [row.index, row.rect])); +const { grid, slots } = await checkTerminalScreenshot({ png, size: report.size, rows }); + +const printed = slots.filter( + (slot) => !SKIPPED_LINES.has(slot.line) && !slot.isCursor && slot.chars.trim() !== "", +); +/** The columns xterm gives each printed cluster. */ +const columnsOf = new Map(printed.map((slot) => [slot.chars, slot.width])); + +if (control === "rect") { + for (const { box } of printed) { + for (let y = Math.ceil(box.top); y < Math.floor(box.bottom); y += 1) { + for (let x = Math.ceil(box.left); x < Math.floor(box.right); x += 1) { + png.data.set([255, 255, 255, 255], (png.width * y + x) * 4); + } + } + } +} + +/** Ink strength 0..1 of each pixel in a box: the largest channel distance from the background. */ +function screenshotInk(box) { + const left = Math.ceil(box.left); + const top = Math.ceil(box.top); + const width = Math.floor(box.right) - left; + const height = Math.floor(box.bottom) - top; + const ink = new Float32Array(width * height); + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const offset = (png.width * (top + y) + left + x) * 4; + let distance = 0; + for (let channel = 0; channel < 3; channel += 1) { + distance = Math.max( + distance, + Math.abs(png.data[offset + channel] - grid.background[channel]), + ); + } + ink[y * width + x] = distance / 255; + } + } + return { ink, width, height }; +} + +/** Ink of the slot's whole cells, scaled so the foreground color is 1. */ +function cellInk(slot) { + const left = Math.round(grid.left + slot.col * grid.cellWidth); + const right = Math.round(grid.left + (slot.col + slot.width) * grid.cellWidth); + const cells = screenshotInk({ left, right, top: slot.box.top, bottom: slot.box.bottom }); + const full = + Math.max(...[0, 1, 2].map((c) => Math.abs(grid.foreground[c] - grid.background[c]))) / 255; + for (let i = 0; i < cells.ink.length; i += 1) cells.ink[i] = Math.min(1, cells.ink[i] / full); + return cells; +} + +const scratch = mkdtempSync(resolve(tmpdir(), "terminal-identity-")); +/** pango-view sizes its image to the text's advance plus this margin on each side. */ +const REFERENCE_MARGIN = 48; +function pangoInk(text) { + const out = resolve(scratch, "reference.png"); + execFileSync("pango-view", [ + "--no-display", + "--dpi=72", + "--font=monospace 96px", + `--text=${text}`, + "--background=black", + "--foreground=white", + `--margin=${REFERENCE_MARGIN}`, + "-o", + out, + ]); + const image = PNG.sync.read(readFileSync(out)); + const ink = new Float32Array(image.width * image.height); + for (let i = 0; i < ink.length; i += 1) { + const o = i * 4; + ink[i] = Math.max(image.data[o], image.data[o + 1], image.data[o + 2]) / 255; + } + return { + ink, + width: image.width, + height: image.height, + advance: image.width - 2 * REFERENCE_MARGIN, + }; +} + +/** The terminal font's cell advance at the reference size. */ +const referenceCell = pangoInk("M").advance; + +/** + * The text as the renderer places it in `columns` cells: centred, scaled down when its advance is + * wider, and cut at the cells' edges. Glyphs whose ink overhangs their advance, such as Devanagari + * headlines, are cut on the device too. + */ +function referenceInk(text, columns) { + const image = pangoInk(text); + const box = Math.max(image.advance, columns * referenceCell); + const left = Math.round(REFERENCE_MARGIN + (image.advance - box) / 2); + const right = Math.round(REFERENCE_MARGIN + (image.advance + box) / 2); + for (let y = 0; y < image.height; y += 1) { + for (let x = 0; x < image.width; x += 1) { + if (x < left || x >= right) image.ink[y * image.width + x] = 0; + } + } + return image; +} + +function regionInk({ ink, width, height }, [x0, y0, x1, y1]) { + let [sum, max, count] = [0, 0, 0]; + for (let y = Math.floor(y0 * height); y < Math.ceil(y1 * height); y += 1) { + for (let x = Math.floor(x0 * width); x < Math.ceil(x1 * width); x += 1) { + const value = ink[y * width + x] ?? 0; + sum += value; + max = Math.max(max, value); + count += 1; + } + } + return { mean: sum / count, max }; +} + +function inkBox({ ink, width, height }) { + let [minX, minY, maxX, maxY] = [width, height, -1, -1]; + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + if (ink[y * width + x] < INK) continue; + minX = Math.min(minX, x); + maxX = Math.max(maxX, x); + minY = Math.min(minY, y); + maxY = Math.max(maxY, y); + } + } + return maxX < 0 ? null : { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1 }; +} + +/** Whether an unbroken line crosses the region to the cell edge: a row for L/R, a column for U/D. */ +function lineCrosses({ ink, width, height }, arm) { + const [x0, y0, x1, y1] = ARM_REGIONS[arm]; + const [left, top, right, bottom] = [ + Math.floor(x0 * width), + Math.floor(y0 * height), + Math.ceil(x1 * width), + Math.ceil(y1 * height), + ]; + const horizontal = arm === "L" || arm === "R"; + const [outerFrom, outerTo, innerFrom, innerTo] = horizontal + ? [top, bottom, left, right] + : [left, right, top, bottom]; + for (let outer = outerFrom; outer < outerTo; outer += 1) { + let unbroken = true; + for (let inner = innerFrom; inner < innerTo && unbroken; inner += 1) { + const [x, y] = horizontal ? [inner, outer] : [outer, inner]; + unbroken = (ink[y * width + x] ?? 0) >= 0.5; + } + if (unbroken) return true; + } + return false; +} + +/** 1 when the cell matches the character's definition, 0 otherwise. */ +function cellGlyphScore(slot, spec) { + const ink = cellInk(slot); + if (spec.arms) { + const matches = Object.keys(ARM_REGIONS).every((arm) => + spec.arms.includes(arm) ? lineCrosses(ink, arm) : regionInk(ink, ARM_REGIONS[arm]).max < 0.3, + ); + return matches ? 1 : 0; + } + if (spec.fill) { + const [filled, empty] = HALVES[spec.fill].map((region) => regionInk(ink, region).mean); + return filled >= 0.8 && empty <= 0.2 ? 1 : 0; + } + const box = inkBox(ink); + if (!box || box.width < 0.7 * ink.width || box.height < 0.5 * ink.height) return 0; + const [x0, y0] = [box.x / ink.width, box.y / ink.height]; + const [x1, y1] = [(box.x + box.width) / ink.width, (box.y + box.height) / ink.height]; + const [w, h] = [(x1 - x0) / SHADE_GRID, (y1 - y0) / SHADE_GRID]; + const regions = Array.from({ length: SHADE_GRID * SHADE_GRID }, (_, index) => { + const [gx, gy] = [index % SHADE_GRID, Math.floor(index / SHADE_GRID)]; + return [x0 + gx * w, y0 + gy * h, x0 + (gx + 1) * w, y0 + (gy + 1) * h]; + }); + const means = regions.map((region) => regionInk(ink, region).mean); + return means.every((mean) => mean >= spec.shade[0] && mean < spec.shade[1]) ? 1 : 0; +} + +/** Coverage of the ink's bounding box, centered in a square that keeps its aspect, at GRID². */ +function coverage(image) { + const box = inkBox(image); + if (!box) return null; + const side = Math.max(box.width, box.height); + const originX = box.x - (side - box.width) / 2; + const originY = box.y - (side - box.height) / 2; + const vector = new Float32Array(GRID * GRID); + const step = side / GRID; + const samples = 4; + for (let gy = 0; gy < GRID; gy += 1) { + for (let gx = 0; gx < GRID; gx += 1) { + let sum = 0; + for (let sample = 0; sample < samples * samples; sample += 1) { + const x = Math.floor(originX + (gx + ((sample % samples) + 0.5) / samples) * step); + const y = Math.floor( + originY + (gy + (Math.floor(sample / samples) + 0.5) / samples) * step, + ); + sum += + x >= 0 && y >= 0 && x < image.width && y < image.height + ? image.ink[y * image.width + x] + : 0; + } + vector[gy * GRID + gx] = sum / (samples * samples); + } + } + return vector; +} + +/** Pearson correlation of two coverage vectors; -1 when either has no ink or no contrast. */ +function correlation(a, b) { + if (!a || !b) return -1; + const n = a.length; + let [ma, mb] = [0, 0]; + for (let i = 0; i < n; i += 1) { + ma += a[i]; + mb += b[i]; + } + ma /= n; + mb /= n; + let [num, da, db] = [0, 0, 0]; + for (let i = 0; i < n; i += 1) { + num += (a[i] - ma) * (b[i] - mb); + da += (a[i] - ma) ** 2; + db += (b[i] - mb) ** 2; + } + return da === 0 || db === 0 ? -1 : num / Math.sqrt(da * db); +} + +const references = new Map(); +/** + * The cluster alone and with a zero-width joiner before, after and on both sides, so any Arabic + * positional form of the right letter matches. + */ +function referenceVectors(chars) { + if (!references.has(chars)) { + const forms = [chars, ZWJ + chars, chars + ZWJ, ZWJ + chars + ZWJ]; + references.set( + chars, + forms.map((text) => coverage(referenceInk(text, columnsOf.get(chars)))), + ); + } + return references.get(chars); +} + +function shapeScore(vector, chars) { + return Math.max(...referenceVectors(chars).map((reference) => correlation(vector, reference))); +} + +/** + * pango draws no ink for Apple Color Emoji, and draws flags on neither device. Those clusters have + * no reference and are reported instead of judged. + */ +const hasReference = (chars) => + CELL_GLYPHS[chars] || + (!/^\p{Regional_Indicator}{2}$/u.test(chars) && referenceVectors(chars).some(Boolean)); +const judged = printed.filter((slot) => hasReference(slot.chars)); +const unjudged = printed + .filter((slot) => !hasReference(slot.chars)) + .map((slot) => ({ + line: slot.line, + col: slot.col, + chars: slot.chars, + reason: "no reference ink", + })); + +const distinct = [...new Set(judged.map((slot) => slot.chars))]; +const shapes = distinct.filter((chars) => !CELL_GLYPHS[chars]); + +/** The cluster a slot is judged against: its own, or under `swap` a different one. */ +function expectedChars(slot, index) { + if (control !== "swap") return slot.chars; + const own = distinct.indexOf(slot.chars); + const other = distinct[(own + 1 + (index % (distinct.length - 1))) % distinct.length]; + return other === slot.chars ? distinct[(own + 1) % distinct.length] : other; +} + +function judge(slot, index) { + const expected = expectedChars(slot, index); + const result = { line: slot.line, row: slot.row, col: slot.col, chars: slot.chars, expected }; + const spec = CELL_GLYPHS[expected]; + if (spec) { + result.score = cellGlyphScore(slot, spec); + return result; + } + // The shape must also resemble its own cluster at least as much as any other cluster in the + // corpus, within MARGIN, so a near-lookalike such as g judged as 8 fails. + const vector = coverage(screenshotInk(slot.box)); + const score = shapeScore(vector, expected); + let nearest = { chars: expected, score }; + for (const other of shapes) { + if (other === expected) continue; + const otherScore = shapeScore(vector, other); + if (otherScore > nearest.score) nearest = { chars: other, score: otherScore }; + } + const distinctive = nearest.chars === expected || nearest.score - score <= MARGIN; + result.score = distinctive ? score : Math.min(score, 0); + result.nearest = nearest; + return result; +} + +const results = judged.map(judge); +rmSync(scratch, { recursive: true, force: true }); + +const passed = results.filter((result) => result.score >= THRESHOLD); +const failed = results.filter((result) => result.score < THRESHOLD); +const outDir = resolve(dir, "identity"); +mkdirSync(outDir, { recursive: true }); +writeFileSync( + resolve(outDir, `${control}.json`), + `${JSON.stringify({ control, threshold: THRESHOLD, unjudged, results }, null, 2)}\n`, +); + +const format = (result) => + `${result.line}@${result.col}(${result.chars}${result.expected === result.chars ? "" : `→${result.expected}`})=${result.score.toFixed(2)}${result.nearest && result.nearest.chars !== result.expected ? ` nearest ${result.nearest.chars}` : ""}`; +console.log( + `${report.label} ${control}: ${results.length} clusters judged, ${passed.length} pass, ${failed.length} fail, ${unjudged.length} unjudged (${outDir})`, +); +if (control === "none") { + for (const result of failed) console.log(` fail ${format(result)}`); + process.exitCode = failed.length === 0 ? 0 : 1; +} else { + for (const result of passed) console.log(` accepted ${format(result)}`); + process.exitCode = passed.length === 0 ? 0 : 1; +} diff --git a/packages/app/e2e/mobile/terminal-rendering/overhang.mjs b/packages/app/e2e/mobile/terminal-rendering/overhang.mjs new file mode 100644 index 0000000000..da977d78d3 --- /dev/null +++ b/packages/app/e2e/mobile/terminal-rendering/overhang.mjs @@ -0,0 +1,236 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { setTimeout } from "node:timers/promises"; +import { PNG } from "pngjs"; +import { countInk, measureGrid } from "./check.mjs"; +import { RULER_CELLS } from "./fixture.mjs"; + +// Checks ink that leaves its row: stacked marks above and below a row reach into the rows next to +// it. The script prints such rows, then in one update writes into two rows that hold another row's +// overhang and erases two rows whose overhang reaches a blank row. The overhang must be drawn +// before the update, stay drawn in the written rows, and leave no ink behind the erased rows. +// +// Preconditions are the same as `render.mjs`: the app runs the build being verified against a +// daemon on the same host as this script, and shows an idle, focused terminal with an empty prompt. +// +// PLATFORM=android ANDROID_SERIAL=emulator-5554 node e2e/mobile/terminal-rendering/overhang.mjs +// PLATFORM=ios IOS_DEVICE="iPhone 17" node e2e/mobile/terminal-rendering/overhang.mjs +const platform = process.env.PLATFORM ?? "android"; +const appId = process.env.PASEO_TERMINAL_RENDERING_APP_ID ?? "sh.paseo.debug"; +const label = process.env.PASEO_TERMINAL_RENDERING_LABEL ?? "overhang"; +const session = process.env.AGENT_DEVICE_SESSION ?? `terminal-rendering-${platform}`; +const artifacts = resolve( + process.env.PASEO_TERMINAL_RENDERING_ARTIFACTS ?? + resolve(homedir(), ".paseo/artifacts/terminal-rendering", platform), + label, +); +const fixtureDir = process.env.PASEO_TERMINAL_RENDERING_FIXTURE_DIR ?? tmpdir(); +const screenshotArgs = + platform === "ios" + ? ["--pixel-density", process.env.PASEO_TERMINAL_RENDERING_PIXEL_DENSITY ?? "3"] + : []; +const env = { + ...process.env, + AGENT_DEVICE_STATE_DIR: + process.env.AGENT_DEVICE_STATE_DIR ?? + resolve(homedir(), ".paseo/artifacts/terminal-rendering/device-state"), +}; +mkdirSync(artifacts, { recursive: true }); + +/** Marks stacked below the row: Arabic dots, rings below `g`, a vowel sign below a consonant. */ +const BELOW = "پپپ ييي ججج g̥̥̥̥ कृ"; +/** Marks stacked above the row: accents over `e` and `s`, candrabindu, om. */ +const ABOVE = "é́́́ ŝ̂̂ कँ ॐ"; +/** The marks rows stay left of this column; the update writes at it, outside the checked boxes. */ +const CHECKED_COLUMNS = 26; +const WRITE_COLUMN = 30; +/** 0-based rows. Each source row's overhang reaches the row next to it. */ +const SOURCES = [ + { row: 2, text: BELOW, overhangRow: 3, update: "write" }, + { row: 6, text: ABOVE, overhangRow: 5, update: "erase" }, + { row: 9, text: BELOW, overhangRow: 10, update: "erase" }, + { row: 13, text: ABOVE, overhangRow: 12, update: "write" }, +]; +const PARK_ROW = 16; + +function ad(...args) { + return execFileSync("agent-device", [...args, "--session", session], { + env, + encoding: "utf8", + timeout: 60_000, + }); +} + +function deviceArgs() { + if (platform === "android") { + return [ + "--platform", + "android", + ...(process.env.ANDROID_SERIAL ? ["--serial", process.env.ANDROID_SERIAL] : []), + ]; + } + return [ + "--platform", + "ios", + ...(process.env.IOS_DEVICE ? ["--device", process.env.IOS_DEVICE] : []), + ]; +} + +function snapshotNodes() { + return JSON.parse(ad("snapshot", "--json")).data.nodes; +} + +function pressControl(id) { + const node = JSON.parse(ad("snapshot", "-i", "--json")).data.nodes.find( + (candidate) => candidate.identifier === id, + ); + assert.ok(node?.rect, `No ${id} control on screen`); + const { x, y, width, height } = node.rect; + ad("press", String(Math.round(x + width / 2)), String(Math.round(y + height / 2))); +} + +async function pressEnter() { + pressControl("terminal-key-enter"); + await setTimeout(2_000); +} + +async function runCommand(command) { + ad("type", command, "--delay-ms", "0"); + await pressEnter(); +} + +async function terminalSize() { + await runCommand("clear; stty size"); + for (const node of snapshotNodes()) { + const match = /^(\d+) (\d+)$/.exec((node.label ?? "").trim()); + if (match && (node.identifier ?? "").startsWith("terminal-row-")) { + return { rows: Number(match[1]), columns: Number(match[2]) }; + } + } + throw new Error("No terminal row showed the `stty size` output"); +} + +function shellQuote(text) { + return `'${text.replaceAll("'", "'\\''")}'`; +} + +/** Prints the source rows, waits for Enter, applies the update, and waits for Enter again. */ +function fixtureScript() { + const at = (row, col) => `\\033[${row + 1};${col + 1}H`; + const print = (row, text) => `printf '${at(row, 0)}%s' ${shellQuote(text)}`; + const update = SOURCES.map((source) => + source.update === "write" + ? `${at(source.overhangRow, WRITE_COLUMN)}x` + : `${at(source.row, 0)}\\033[2K`, + ).join(""); + return [ + "stty -echo", + "printf '\\033[2J\\033[3J'", + print(0, "█".repeat(RULER_CELLS)), + ...SOURCES.map((source) => print(source.row, source.text)), + `printf '${at(PARK_ROW, 0)}'`, + "read -r _", + `printf '${update}${at(PARK_ROW, 0)}'`, + "read -r _", + "stty echo", + "", + ].join("\n"); +} + +function screenshot(name) { + const path = resolve(artifacts, name); + ad("screenshot", path, ...screenshotArgs); + return PNG.sync.read(readFileSync(path)); +} + +/** Pixels in `box` where the two screenshots differ by more than the ink threshold. */ +function changedPixels(before, after, box) { + const difference = new PNG({ width: before.width, height: before.height }); + for (let index = 0; index < before.data.length; index += 1) { + difference.data[index] = Math.abs(before.data[index] - after.data[index]); + } + return countInk(difference, box, [0, 0, 0]); +} + +ad("open", appId, ...deviceArgs()); +const controls = JSON.parse(ad("snapshot", "-i", "--json")).data.nodes; +const hasControl = (id) => controls.some((node) => node.identifier === id); +assert.ok(hasControl("terminal-keyboard-toggle"), "Open a terminal first"); +if (!hasControl("terminal-key-enter")) pressControl("terminal-keyboard-toggle"); + +const size = await terminalSize(); +assert.ok( + size.rows > PARK_ROW && size.columns > WRITE_COLUMN, + `Terminal too small: ${JSON.stringify(size)}`, +); +const scriptPath = resolve(fixtureDir, "paseo-terminal-overhang.sh"); +writeFileSync(scriptPath, fixtureScript()); +await runCommand(`sh ${scriptPath}`); + +const nodes = snapshotNodes(); +const before = screenshot("before.png"); +await pressEnter(); +const after = screenshot("after.png"); +await pressEnter(); + +// Snapshot rects are in points on iOS and pixels on Android; the checks work in screenshot pixels. +const scale = before.width / Math.max(...nodes.map((node) => node.rect?.width ?? 0)); +const ruler = nodes.find((node) => node.identifier === "terminal-row-0")?.rect; +assert.ok(ruler, "The ruler row is not exposed as terminal-row-0"); +const rulerRect = { + x: ruler.x * scale, + y: ruler.y * scale, + width: ruler.width * scale, + height: ruler.height * scale, +}; +const grid = measureGrid(before, rulerRect); +const rowBox = (row) => ({ + left: grid.left, + right: grid.left + CHECKED_COLUMNS * grid.cellWidth, + top: rulerRect.y + row * rulerRect.height, + bottom: rulerRect.y + (row + 1) * rulerRect.height, +}); + +/** Measures one source row's overhang before and after the update. */ +function measure(source) { + const box = rowBox(source.overhangRow); + const overhangBefore = countInk(before, box, grid.background); + if (source.update === "write") { + const changedAfterWrite = changedPixels(before, after, box); + const failures = [ + ...(overhangBefore === 0 ? ["overhang-not-drawn"] : []), + ...(changedAfterWrite > 0 ? ["overhang-lost-after-write"] : []), + ]; + return { ...source, overhangBefore, changedAfterWrite, failures }; + } + const inkAfterErase = countInk(after, box, grid.background); + const sourceInkAfterErase = countInk(after, rowBox(source.row), grid.background); + const failures = [ + ...(overhangBefore === 0 ? ["overhang-not-drawn"] : []), + ...(inkAfterErase > 0 ? ["overhang-left-after-erase"] : []), + ...(sourceInkAfterErase > 0 ? ["source-left-after-erase"] : []), + ]; + return { ...source, overhangBefore, inkAfterErase, sourceInkAfterErase, failures }; +} + +const results = SOURCES.map(measure); + +const report = { platform, label, size, grid, rulerRect, results }; +writeFileSync(resolve(artifacts, "report.json"), `${JSON.stringify(report, null, 2)}\n`); +const failures = results.flatMap((result) => + result.failures.map((failure) => `row ${result.overhangRow}: ${failure}`), +); +console.log(`${label}: ${failures.length} failures (${artifacts})`); +for (const result of results) { + console.log( + ` row ${result.row} -> ${result.overhangRow} (${result.update}): overhang ${result.overhangBefore}` + + (result.update === "write" + ? `, changed after write ${result.changedAfterWrite}` + : `, left after erase ${result.inkAfterErase}, source left ${result.sourceInkAfterErase}`), + ); +} +for (const failure of failures) console.log(` ${failure}`); +process.exitCode = failures.length === 0 ? 0 : 1; diff --git a/packages/app/e2e/mobile/terminal-rendering/reattach.mjs b/packages/app/e2e/mobile/terminal-rendering/reattach.mjs new file mode 100644 index 0000000000..c5cb7734e9 --- /dev/null +++ b/packages/app/e2e/mobile/terminal-rendering/reattach.mjs @@ -0,0 +1,149 @@ +import { homedir } from "node:os"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { setTimeout } from "node:timers/promises"; + +// Reattaches to a terminal whose PTY has a different size than the app will measure, and checks +// that the client rows equal the daemon's rows. RESIZE=keyboard opens the keyboard, and the +// relaunched app starts with it closed. RESIZE=fold folds an Android foldable emulator and +// relaunches the app unfolded. Either way the shell redraws its prompt on attach. +// +// Preconditions: the app shows terminal TERMINAL_ID, idle at an empty shell prompt, against the +// daemon at PASEO_HOST. +// +// PLATFORM=android TERMINAL_ID=511fd9f0 PASEO_HOST=localhost:6870 \ +// node e2e/mobile/terminal-rendering/reattach.mjs +// +// The client used to be sized after the replay arrived, so the shell's redraw landed on the +// wrong rows in most trials. TRIALS sets how many reattaches to check. +const platform = process.env.PLATFORM ?? "android"; +const appId = process.env.PASEO_TERMINAL_RENDERING_APP_ID ?? "sh.paseo.debug"; +const appUrl = process.env.PASEO_TERMINAL_RENDERING_APP_URL; +const terminalId = process.env.TERMINAL_ID; +const host = process.env.PASEO_HOST; +const trials = Number(process.env.TRIALS ?? 5); +const resize = process.env.RESIZE ?? "keyboard"; +const label = process.env.PASEO_TERMINAL_RENDERING_LABEL ?? "reattach"; +const session = process.env.AGENT_DEVICE_SESSION ?? `terminal-rendering-${platform}`; +const artifacts = resolve( + process.env.PASEO_TERMINAL_RENDERING_ARTIFACTS ?? + resolve(homedir(), ".paseo/artifacts/terminal-rendering", platform), + label, +); +const env = { + ...process.env, + AGENT_DEVICE_STATE_DIR: + process.env.AGENT_DEVICE_STATE_DIR ?? + resolve(homedir(), ".paseo/artifacts/terminal-rendering/device-state"), +}; +if (!terminalId || !host) throw new Error("Set TERMINAL_ID and PASEO_HOST"); +mkdirSync(artifacts, { recursive: true }); + +function ad(...args) { + return execFileSync("agent-device", [...args, "--session", session], { + env, + encoding: "utf8", + timeout: 120_000, + }); +} + +function paseo(...args) { + return execFileSync("npm", ["run", "-s", "cli", "--", ...args, "--host", host], { + cwd: resolve(import.meta.dirname, "../../../../.."), + encoding: "utf8", + }); +} + +function pressControl(id) { + const node = JSON.parse(ad("snapshot", "-i", "--json")).data.nodes.find( + (candidate) => candidate.identifier === id, + ); + if (!node?.rect) throw new Error(`No ${id} control on screen`); + const { x, y, width, height } = node.rect; + ad("press", String(Math.round(x + width / 2)), String(Math.round(y + height / 2))); +} + +async function resizePty() { + if (resize === "keyboard") { + pressControl("terminal-keyboard-toggle"); + await setTimeout(2_000); + return; + } + const adb = (...args) => + execFileSync("adb", [ + ...(process.env.ANDROID_SERIAL ? ["-s", process.env.ANDROID_SERIAL] : []), + ...args, + ]); + adb("emu", "fold"); + await setTimeout(5_000); + adb("shell", "am", "force-stop", appId); + adb("emu", "unfold"); + await setTimeout(3_000); +} + +function clientRows() { + const nodes = JSON.parse(ad("snapshot", "--json")).data.nodes; + const byIndex = new Map(nodes.map((node) => [node.index, node])); + const rows = []; + for (const node of nodes) { + const match = /^terminal-row-(\d+)$/.exec(node.identifier ?? ""); + if (!match) continue; + // agent-device leaves the label on the parent when the row inherits it. + const text = node.label ?? (node.inheritsLabel ? byIndex.get(node.parentIndex)?.label : ""); + rows[Number(match[1])] = (text ?? "").trim(); + } + return rows; +} + +/** + * Waits for the relaunched app to show terminal rows, then for the shell's redraw to land. + * Returns null when no row appears, for example when the app crashed on launch. + */ +async function attachedClientRows() { + for (let attempt = 0; attempt < 30; attempt += 1) { + await setTimeout(2_000); + if (clientRows().some((text) => text !== "")) { + await setTimeout(3_000); + return clientRows(); + } + } + return null; +} + +function mismatches(client, server) { + const rows = []; + for (let row = 0; row < Math.max(client.length, server.length); row += 1) { + const expected = (server[row] ?? "").trim(); + const actual = client[row] ?? ""; + if (expected !== actual) rows.push({ row, server: expected, client: actual }); + } + return rows; +} + +const results = []; +for (let trial = 1; trial <= trials; trial += 1) { + paseo("terminal", "send-keys", terminalId, "clear", "Enter"); + await setTimeout(1_500); + await resizePty(); + ad("open", appId, ...(appUrl ? [appUrl] : []), "--relaunch"); + const client = await attachedClientRows(); + ad("screenshot", resolve(artifacts, `trial-${trial}.png`)); + if (!client) { + results.push({ trial, attached: false }); + console.log(`${label} trial ${trial}: no terminal rows appeared`); + continue; + } + const server = paseo("terminal", "capture", terminalId).split("\n"); + const rows = mismatches(client, server); + results.push({ trial, attached: true, mismatchedRows: rows }); + console.log(`${label} trial ${trial}: ${rows.length} mismatched rows`); +} + +writeFileSync(resolve(artifacts, "report.json"), `${JSON.stringify(results, null, 2)}\n`); +const attached = results.filter((result) => result.attached); +const failed = attached.filter((result) => result.mismatchedRows.length > 0).length; +console.log( + `${label}: ${failed} of ${attached.length} reattaches differ from the daemon, ${trials - attached.length} never attached (${artifacts})`, +); +process.exitCode = failed === 0 && attached.length === trials ? 0 : 1; diff --git a/packages/app/e2e/mobile/terminal-rendering/render.mjs b/packages/app/e2e/mobile/terminal-rendering/render.mjs new file mode 100644 index 0000000000..7aacfcaa8e --- /dev/null +++ b/packages/app/e2e/mobile/terminal-rendering/render.mjs @@ -0,0 +1,197 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { setTimeout } from "node:timers/promises"; +import { PNG } from "pngjs"; +import { + checkPartialSelection, + checkTerminalScreenshot, + selectionTarget, + writeOverlay, +} from "./check.mjs"; +import { fixtureOutput } from "./fixture.mjs"; + +// Renders the fixture in a real Paseo terminal and checks the screenshot with `check.mjs`, then +// selects one cell of a conjunct and checks a second screenshot. +// +// Preconditions: the app runs the build being verified against a daemon on the same host as +// this script, and shows an idle, focused terminal with an empty shell prompt. +// +// PLATFORM=android ANDROID_SERIAL=emulator-5554 node e2e/mobile/terminal-rendering/render.mjs +// PLATFORM=ios IOS_DEVICE="iPhone 17" node e2e/mobile/terminal-rendering/render.mjs +const platform = process.env.PLATFORM ?? "android"; +const appId = process.env.PASEO_TERMINAL_RENDERING_APP_ID ?? "sh.paseo.debug"; +const label = process.env.PASEO_TERMINAL_RENDERING_LABEL ?? "run"; +const session = process.env.AGENT_DEVICE_SESSION ?? `terminal-rendering-${platform}`; +const artifacts = resolve( + process.env.PASEO_TERMINAL_RENDERING_ARTIFACTS ?? + resolve(homedir(), ".paseo/artifacts/terminal-rendering", platform), + label, +); +const fixtureDir = process.env.PASEO_TERMINAL_RENDERING_FIXTURE_DIR ?? tmpdir(); +// iOS simulators screenshot at one pixel per point unless asked; glyph identity needs the +// device's real density. Android screenshots are always in device pixels. +const screenshotArgs = + platform === "ios" + ? ["--pixel-density", process.env.PASEO_TERMINAL_RENDERING_PIXEL_DENSITY ?? "3"] + : []; +const env = { + ...process.env, + AGENT_DEVICE_STATE_DIR: + process.env.AGENT_DEVICE_STATE_DIR ?? + resolve(homedir(), ".paseo/artifacts/terminal-rendering/device-state"), +}; +mkdirSync(artifacts, { recursive: true }); + +function ad(...args) { + return execFileSync("agent-device", [...args, "--session", session], { + env, + encoding: "utf8", + timeout: 60_000, + }); +} + +function deviceArgs() { + if (platform === "android") { + return [ + "--platform", + "android", + ...(process.env.ANDROID_SERIAL ? ["--serial", process.env.ANDROID_SERIAL] : []), + ]; + } + return [ + "--platform", + "ios", + ...(process.env.IOS_DEVICE ? ["--device", process.env.IOS_DEVICE] : []), + ]; +} + +function snapshotNodes() { + return JSON.parse(ad("snapshot", "--json")).data.nodes; +} + +function rowNodes(nodes) { + const rows = new Map(); + for (const node of nodes) { + const match = /^terminal-row-(\d+)$/.exec(node.identifier ?? ""); + if (match && node.rect) rows.set(Number(match[1]), node); + } + return rows; +} + +/** + * Taps a control at its center. On iOS the keyboard toolbar is a full-screen accessibility node + * listed after the key bar, so pressing by selector reports the keys as covered. + */ +function pressControl(id) { + const node = JSON.parse(ad("snapshot", "-i", "--json")).data.nodes.find( + (candidate) => candidate.identifier === id, + ); + assert.ok(node?.rect, `No ${id} control on screen`); + const { x, y, width, height } = node.rect; + ad("press", String(Math.round(x + width / 2)), String(Math.round(y + height / 2))); +} + +async function runCommand(command) { + ad("type", command, "--delay-ms", "0"); + pressControl("terminal-key-enter"); + await setTimeout(2_000); +} + +async function terminalSize() { + await runCommand("clear; stty size"); + for (const node of snapshotNodes()) { + const match = /^(\d+) (\d+)$/.exec((node.label ?? "").trim()); + if (match && (node.identifier ?? "").startsWith("terminal-row-")) { + return { rows: Number(match[1]), columns: Number(match[2]) }; + } + } + throw new Error("No terminal row showed the `stty size` output"); +} + +ad("open", appId, ...deviceArgs()); +const controls = JSON.parse(ad("snapshot", "-i", "--json")).data.nodes; +const hasControl = (id) => controls.some((node) => node.identifier === id); +assert.ok(hasControl("terminal-keyboard-toggle"), "Open a terminal first"); +if (!hasControl("terminal-key-enter")) pressControl("terminal-keyboard-toggle"); + +const size = await terminalSize(); +const fixturePath = resolve(fixtureDir, "paseo-terminal-rendering.txt"); +const scriptPath = resolve(fixtureDir, "paseo-terminal-rendering.sh"); +writeFileSync(fixturePath, fixtureOutput(size.columns)); +writeFileSync(scriptPath, `printf '\\033[2J\\033[3J\\033[H'; cat '${fixturePath}'; sleep 120\n`); +await runCommand(`sh ${scriptPath}`); + +const nodes = snapshotNodes(); +const screenshotPath = resolve(artifacts, "terminal.png"); +ad("screenshot", screenshotPath, ...screenshotArgs); +const png = PNG.sync.read(readFileSync(screenshotPath)); +// Snapshot rects are in points on iOS and pixels on Android; the checks work in screenshot pixels. +const scale = png.width / Math.max(...nodes.map((node) => node.rect?.width ?? 0)); +const toPixels = ({ x, y, width, height }) => ({ + x: x * scale, + y: y * scale, + width: width * scale, + height: height * scale, +}); +const rows = rowNodes(nodes); +const rects = new Map([...rows].map(([index, node]) => [index, toPixels(node.rect)])); +const { grid, failures, slots } = await checkTerminalScreenshot({ png, size, rows: rects }); + +// Long-press selects the conjunct as a word; dragging back from its second cell leaves the first. +const target = await selectionTarget({ size }); +const targetRect = rects.get(target.row); +assert.ok(targetRect, "The select line is not on screen"); +const y = Math.round((targetRect.y + targetRect.height / 2) / scale); +const cellX = (offset) => Math.round((grid.left + (target.col + offset) * grid.cellWidth) / scale); +ad("longpress", String(cellX(0.5)), String(y), "800"); +ad("gesture", "pan", String(cellX(1.75)), String(y), String(cellX(0.5) - cellX(1.75)), "0", "600"); +const selectionPath = resolve(artifacts, "selection.png"); +ad("screenshot", selectionPath, ...screenshotArgs); +const selectionFailures = checkPartialSelection({ + png: PNG.sync.read(readFileSync(selectionPath)), + rows: rects, + grid, + target, +}); +for (const failure of selectionFailures) failures.push({ line: "select", ...failure }); +// A tap clears the selection; it would otherwise survive into the next run's screenshot. +const lastRect = rects.get(size.rows - 1); +if (lastRect) { + ad("press", String(cellX(4)), String(Math.round((lastRect.y + lastRect.height / 2) / scale))); +} +try { + pressControl("terminal-key-ctrl"); + ad("type", "c", "--delay-ms", "0"); +} catch (error) { + console.warn(`Could not interrupt the fixture: ${error.message}`); +} + +writeOverlay(png, slots, resolve(artifacts, "overlay.png")); +const report = { + platform, + label, + size, + grid, + checkedClusters: slots.length, + failures, + rows: [...rows.keys()].map((index) => ({ + index, + label: rows.get(index).label, + rect: rects.get(index), + })), +}; +writeFileSync(resolve(artifacts, "report.json"), `${JSON.stringify(report, null, 2)}\n`); + +const byLine = Map.groupBy(failures, (failure) => failure.line); +console.log( + `${label}: ${slots.length} clusters checked, ${failures.length} failures (${artifacts})`, +); +for (const [line, list] of byLine) { + console.log( + ` ${line}: ${list.map((failure) => `${failure.kind}@${failure.col ?? "-"}${failure.char ? `(${failure.char})` : ""}`).join(" ")}`, + ); +} +process.exitCode = failures.length === 0 ? 0 : 1; diff --git a/packages/app/modules/paseo-terminal/android/build.gradle b/packages/app/modules/paseo-terminal/android/build.gradle new file mode 100644 index 0000000000..3be89253be --- /dev/null +++ b/packages/app/modules/paseo-terminal/android/build.gradle @@ -0,0 +1,25 @@ +apply plugin: 'com.android.library' + +group = 'sh.paseo.terminal' +version = '0.1.0' + +def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") +apply from: expoModulesCorePlugin +applyKotlinExpoModulesCorePlugin() +useCoreDependencies() +useExpoPublishing() +useDefaultAndroidSdkVersions() + +android { + namespace "sh.paseo.terminal" + defaultConfig { + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } +} + +dependencies { + implementation "androidx.customview:customview:1.1.0" + testImplementation "junit:junit:4.13.2" + androidTestImplementation "androidx.test:runner:1.6.2" + androidTestImplementation "androidx.test.ext:junit:1.2.1" +} 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 new file mode 100644 index 0000000000..31c2b0c8d7 --- /dev/null +++ b/packages/app/modules/paseo-terminal/android/src/androidTest/assets/grapheme-rows.json @@ -0,0 +1,38 @@ +[ + { + "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] + }, + { + "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] + }, + { + "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] + }, + { + "output": "क्अ", + "row": "2,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, 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, 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 new file mode 100644 index 0000000000..e9b8ab1edf --- /dev/null +++ b/packages/app/modules/paseo-terminal/android/src/androidTest/java/sh/paseo/terminal/TerminalRowLayoutTest.kt @@ -0,0 +1,105 @@ +package sh.paseo.terminal + +import android.os.Build +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import android.util.Log +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +private const val COLUMNS = 8 + +/** Rasters that draw the same clusters match exactly; a wrong clustering differs by 0.12 or more. */ +private const val MATCH_TOLERANCE = 0.01f + +@RunWith(AndroidJUnit4::class) +class TerminalRowLayoutTest { + private val font = TerminalFont("monospace", 40f, 1f) + private val paints = TerminalPaints() + + /** + * Draws each row of `grapheme-rows.json` and compares it with its grapheme clusters drawn one at a + * time, each centred in its own columns. `terminal-frame.test.ts` checks that the rows are what + * xterm produces and that the clusters are UAX #29 grapheme clusters. + */ + @Test + fun drawsEachGraphemeClusterInItsOwnColumns() { + val assets = InstrumentationRegistry.getInstrumentation().context.assets + val rows = JSONArray(assets.open("grapheme-rows.json").bufferedReader().readText()) + for (index in 0 until rows.length()) { + val row = rows.getJSONObject(index) + val source = TerminalRowSource.decode(row.getString("row")) + val layout = TerminalRowLayout(source, font, TerminalCustomGlyphs.EMPTY) + // Selection covers row 0 only, so drawing as row 1 draws nothing selected. + val drawn = raster { layout.drawSelected(it, 1, TerminalSelection(TerminalSelectionRecord()), paints) } + val expected = raster { drawClusters(it, source, starts(row, "clusterStarts")) } + val contrast = raster { drawClusters(it, source, starts(row, "contrastStarts")) } + 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) + } + } + + private fun starts(row: JSONObject, key: String): List = + row.getJSONArray(key).let { starts -> List(starts.length()) { starts.getInt(it) } } + + private fun raster(draw: (Canvas) -> Unit): Bitmap { + val bitmap = Bitmap.createBitmap(COLUMNS * font.cellWidthPx, font.cellHeightPx, Bitmap.Config.ARGB_8888) + draw(Canvas(bitmap)) + return bitmap + } + + /** Draws each cluster as its own text, centred in its columns and scaled down when wider. */ + private fun drawClusters(canvas: Canvas, source: TerminalRowSource, starts: List) { + val cellCount = source.cells.size / 2 + val paint = Paint(font.paint(0)).apply { color = source.styles[1].toLong().toInt() } + val offsets = IntArray(cellCount + 1) + val cols = IntArray(cellCount + 1) + for (cell in 0 until cellCount) { + offsets[cell + 1] = offsets[cell] + source.cells[cell * 2] + cols[cell + 1] = cols[cell] + source.cells[cell * 2 + 1] + } + for ((index, first) in starts.withIndex()) { + val end = starts.getOrElse(index + 1) { cellCount } + val text = source.text.substring(offsets[first], offsets[end]) + if (text.isBlank()) continue + val slot = (cols[end] - cols[first]) * font.cellWidthPx.toFloat() + val advance = paint.measureText(text) + val x = cols[first] * font.cellWidthPx + if (advance > slot + 1f) 0f else (slot - advance) / 2f + canvas.save() + if (advance > slot + 1f) canvas.scale(slot / advance, slot / advance, x, font.cellHeightPx / 2f) + canvas.drawText(text, x, font.baselinePx.toFloat(), paint) + canvas.restore() + } + } + + /** Share of inked pixels, in either image, whose coverage differs by more than a quarter. */ + private fun difference(a: Bitmap, b: Bitmap): Float { + var inked = 0 + var differing = 0 + for (y in 0 until a.height) { + for (x in 0 until a.width) { + val (left, right) = (a.getPixel(x, y) ushr 24) to (b.getPixel(x, y) ushr 24) + if (left < 64 && right < 64) continue + inked += 1 + if (kotlin.math.abs(left - right) > 64) differing += 1 + } + } + return if (inked == 0) 0f else differing.toFloat() / inked + } +} diff --git a/packages/app/modules/paseo-terminal/android/src/main/AndroidManifest.xml b/packages/app/modules/paseo-terminal/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..94cbbcfc39 --- /dev/null +++ b/packages/app/modules/paseo-terminal/android/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/packages/app/modules/paseo-terminal/android/src/main/java/sh/paseo/terminal/PaseoTerminalModule.kt b/packages/app/modules/paseo-terminal/android/src/main/java/sh/paseo/terminal/PaseoTerminalModule.kt new file mode 100644 index 0000000000..e6250ec280 --- /dev/null +++ b/packages/app/modules/paseo-terminal/android/src/main/java/sh/paseo/terminal/PaseoTerminalModule.kt @@ -0,0 +1,24 @@ +package sh.paseo.terminal + +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +class PaseoTerminalModule : Module() { + override fun definition() = ModuleDefinition { + Name("PaseoTerminal") + + View(PaseoTerminalView::class) { + Events("onCellMetrics") + + Prop("frame") { view: PaseoTerminalView, frame: TerminalFrameRecord -> view.setFrame(frame) } + Prop("theme") { view: PaseoTerminalView, theme: TerminalThemeRecord -> view.setTheme(theme) } + Prop("fontFamily") { view: PaseoTerminalView, family: String -> view.setFontFamily(family) } + Prop("fontSize") { view: PaseoTerminalView, size: Double -> view.setFontSize(size) } + Prop("customGlyphs") { view: PaseoTerminalView, glyphs: List -> + view.setCustomGlyphs(glyphs) + } + + OnViewDidUpdateProps { view: PaseoTerminalView -> view.commit() } + } + } +} diff --git a/packages/app/modules/paseo-terminal/android/src/main/java/sh/paseo/terminal/PaseoTerminalView.kt b/packages/app/modules/paseo-terminal/android/src/main/java/sh/paseo/terminal/PaseoTerminalView.kt new file mode 100644 index 0000000000..3fcf012e03 --- /dev/null +++ b/packages/app/modules/paseo-terminal/android/src/main/java/sh/paseo/terminal/PaseoTerminalView.kt @@ -0,0 +1,196 @@ +package sh.paseo.terminal + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Rect +import android.os.Bundle +import android.view.MotionEvent +import android.widget.TextView +import androidx.core.view.ViewCompat +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import androidx.customview.widget.ExploreByTouchHelper +import expo.modules.kotlin.AppContext +import expo.modules.kotlin.viewevent.EventDispatcher +import expo.modules.kotlin.views.ExpoView + +private const val MIN_CACHED_ROWS = 256 +private const val CURSOR_ALPHA = 0.45f + +/** + * Paints terminal frames on the cell grid. Row shaping is cached by row content, so cursor and + * selection changes repaint without reshaping, and unchanged rows replay their display lists. + */ +@SuppressLint("ViewConstructor") +class PaseoTerminalView(context: Context, appContext: AppContext) : ExpoView(context, appContext) { + private val onCellMetrics by EventDispatcher>() + + private var fontFamily = "monospace" + private var fontSize = 12.0 + private var font: TerminalFont? = null + private var customGlyphs = TerminalCustomGlyphs.EMPTY + private var frame: TerminalFrameRecord? = null + private var frameChanged = false + + private var rows: List = emptyList() + private var columns = 0 + private var cursor: TerminalCursorRecord? = null + private var selection: TerminalSelection? = null + private var backgroundColor = Color.BLACK + private var cursorColor = Color.WHITE + private val paints = TerminalPaints() + private val rowCache = object : LinkedHashMap(64, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry): Boolean { + if (size <= maxOf(MIN_CACHED_ROWS, rows.size * 2)) return false + eldest.value.release() + return true + } + } + private val accessibility = RowAccessibility() + + init { + setWillNotDraw(false) + ViewCompat.setAccessibilityDelegate(this, accessibility) + } + + fun setFrame(value: TerminalFrameRecord) { + frame = value + frameChanged = true + } + + fun setTheme(value: TerminalThemeRecord) { + backgroundColor = argb(value.background) + cursorColor = argb(value.cursor) + paints.selectionBackground = argb(value.selectionBackground) + paints.selectionForeground = argb(value.selectionForeground) + invalidate() + } + + fun setFontFamily(value: String) { + fontFamily = value + } + + fun setFontSize(value: Double) { + fontSize = value + } + + fun setCustomGlyphs(value: List) { + customGlyphs = TerminalCustomGlyphs(value) + clearRowCache() + frameChanged = true + } + + /** Applies a batch of prop updates: font first, then the frame laid out with it. */ + fun commit() { + val density = resources.displayMetrics.density + val sizePx = (fontSize * density).toFloat() + val current = font + if (current == null || !current.sameAs(fontFamily, sizePx, density)) { + val next = TerminalFont(fontFamily, sizePx, density) + font = next + clearRowCache() + frameChanged = true + onCellMetrics( + mapOf( + "cellWidth" to next.cellWidthPx / density, + "cellHeight" to next.cellHeightPx / density, + ), + ) + } + if (frameChanged) { + frameChanged = false + layoutFrame() + } + invalidate() + } + + private fun layoutFrame() { + val currentFont = font ?: return + val record = frame ?: return + columns = record.columns + cursor = record.cursor + selection = record.selection?.let(::TerminalSelection) + rows = record.rows.map { encoded -> + rowCache.getOrPut(encoded) { + TerminalRowLayout(TerminalRowSource.decode(encoded), currentFont, customGlyphs) + } + } + accessibility.invalidateRoot() + } + + private fun clearRowCache() { + for (layout in rowCache.values) layout.release() + rowCache.clear() + rows = emptyList() + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + canvas.drawColor(backgroundColor) + val currentFont = font ?: return + val cellWidth = currentFont.cellWidthPx + val cellHeight = currentFont.cellHeightPx + val gridWidth = columns * cellWidth + canvas.save() + canvas.clipRect(0, 0, minOf(width, gridWidth), height) + val activeSelection = selection + for ((index, row) in rows.withIndex()) { + canvas.save() + canvas.translate(0f, (index * cellHeight).toFloat()) + if (activeSelection != null && activeSelection.intersectsRow(index)) { + row.drawSelected(canvas, index, activeSelection, paints) + } else { + row.draw(canvas, gridWidth, paints) + } + canvas.restore() + } + cursor?.let { position -> + paints.fill.color = withAlpha(cursorColor, CURSOR_ALPHA) + val left = (position.col * cellWidth).toFloat() + val top = (position.row * cellHeight).toFloat() + canvas.drawRect(left, top, left + cellWidth, top + cellHeight, paints.fill) + } + canvas.restore() + } + + override fun dispatchHoverEvent(event: MotionEvent): Boolean = + accessibility.dispatchHoverEvent(event) || super.dispatchHoverEvent(event) + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + for (layout in rowCache.values) layout.release() + } + + private fun withAlpha(color: Int, alpha: Float): Int = + Color.argb((Color.alpha(color) * alpha).toInt(), Color.red(color), Color.green(color), Color.blue(color)) + + /** Exposes each non-blank row as a virtual text node, `terminal-row-`. */ + private inner class RowAccessibility : ExploreByTouchHelper(this@PaseoTerminalView) { + override fun getVirtualViewAt(x: Float, y: Float): Int { + val cellHeight = font?.cellHeightPx ?: return INVALID_ID + val row = (y / cellHeight).toInt() + return if (row in rows.indices && rows[row].describe().isNotEmpty()) row else INVALID_ID + } + + override fun getVisibleVirtualViews(virtualViewIds: MutableList) { + for ((index, row) in rows.withIndex()) { + if (row.describe().isNotEmpty()) virtualViewIds.add(index) + } + } + + @Suppress("DEPRECATION") + override fun onPopulateNodeForVirtualView(virtualViewId: Int, node: AccessibilityNodeInfoCompat) { + val cellHeight = font?.cellHeightPx ?: 1 + val label = rows.getOrNull(virtualViewId)?.describe().orEmpty() + node.className = TextView::class.java.name + node.text = label + node.contentDescription = label + node.viewIdResourceName = "terminal-row-$virtualViewId" + val top = virtualViewId * cellHeight + node.setBoundsInParent(Rect(0, top, minOf(width, columns * (font?.cellWidthPx ?: 1)), top + cellHeight)) + } + + override fun onPerformActionForVirtualView(virtualViewId: Int, action: Int, arguments: Bundle?): Boolean = false + } +} diff --git a/packages/app/modules/paseo-terminal/android/src/main/java/sh/paseo/terminal/TerminalCustomGlyphs.kt b/packages/app/modules/paseo-terminal/android/src/main/java/sh/paseo/terminal/TerminalCustomGlyphs.kt new file mode 100644 index 0000000000..ce1e8d5229 --- /dev/null +++ b/packages/app/modules/paseo-terminal/android/src/main/java/sh/paseo/terminal/TerminalCustomGlyphs.kt @@ -0,0 +1,76 @@ +package sh.paseo.terminal + +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Path + +/** + * Box drawing and block elements painted as cell geometry instead of font glyphs, so frames and + * blocks join across cells regardless of the font's own metrics. + */ +internal class TerminalCustomGlyph(record: TerminalCustomGlyphRecord) { + private val rects: FloatArray = FloatArray(record.rects.size) { record.rects[it].toFloat() } + private val commands: DoubleArray = record.path + private val strokeWidth: Float = record.strokeWidth.toFloat() + private var scaledPath: Path? = null + private var scaledWidth = 0f + private var scaledHeight = 0f + + fun draw(canvas: Canvas, x: Float, width: Float, height: Float, density: Float, paint: Paint) { + paint.style = Paint.Style.FILL + var index = 0 + while (index + 3 < rects.size) { + canvas.drawRect( + x + rects[index] * width, + rects[index + 1] * height, + x + (rects[index] + rects[index + 2]) * width, + (rects[index + 1] + rects[index + 3]) * height, + paint, + ) + index += 4 + } + if (commands.isEmpty()) return + val path = pathFor(width, height) + paint.style = Paint.Style.STROKE + paint.strokeWidth = strokeWidth * density + paint.strokeCap = Paint.Cap.BUTT + paint.strokeJoin = Paint.Join.MITER + canvas.save() + canvas.translate(x, 0f) + canvas.drawPath(path, paint) + canvas.restore() + paint.style = Paint.Style.FILL + } + + private fun pathFor(width: Float, height: Float): Path { + val cached = scaledPath + if (cached != null && scaledWidth == width && scaledHeight == height) return cached + val path = Path() + var index = 0 + while (index < commands.size) { + val x = { offset: Int -> commands[index + offset].toFloat() * width } + val y = { offset: Int -> commands[index + offset].toFloat() * height } + when (commands[index].toInt()) { + 0 -> { path.moveTo(x(1), y(2)); index += 3 } + 1 -> { path.lineTo(x(1), y(2)); index += 3 } + 2 -> { path.quadTo(x(1), y(2), x(3), y(4)); index += 5 } + else -> index = commands.size + } + } + scaledPath = path + scaledWidth = width + scaledHeight = height + return path + } +} + +internal class TerminalCustomGlyphs(records: List) { + private val glyphs: Map = + records.associate { it.char to TerminalCustomGlyph(it) } + + operator fun get(text: String): TerminalCustomGlyph? = glyphs[text] + + companion object { + val EMPTY = TerminalCustomGlyphs(emptyList()) + } +} diff --git a/packages/app/modules/paseo-terminal/android/src/main/java/sh/paseo/terminal/TerminalFont.kt b/packages/app/modules/paseo-terminal/android/src/main/java/sh/paseo/terminal/TerminalFont.kt new file mode 100644 index 0000000000..046efd1e18 --- /dev/null +++ b/packages/app/modules/paseo-terminal/android/src/main/java/sh/paseo/terminal/TerminalFont.kt @@ -0,0 +1,61 @@ +package sh.paseo.terminal + +import android.graphics.Paint +import android.graphics.Typeface +import kotlin.math.max +import kotlin.math.roundToInt + +private const val MEASURE_TEXT = "mmmmmmmmmm" + +/** + * The terminal font at one pixel size: a paint per face (regular, bold, italic, bold italic) and + * the cell those faces are laid out on. Cells are whole device pixels so every column origin is + * exact. Platform fallback fonts cover characters the family lacks. + */ +internal class TerminalFont(val family: String, val sizePx: Float, val density: Float) { + private val faces: Array = Array(4) { face -> + Paint(Paint.ANTI_ALIAS_FLAG or Paint.SUBPIXEL_TEXT_FLAG).apply { + typeface = Typeface.create(family, typefaceStyle(face)) + textSize = sizePx + } + } + + val cellWidthPx: Int + val cellHeightPx: Int + val baselinePx: Int + val underlineOffsetPx: Float + val underlineThicknessPx: Float + val strikeOffsetPx: Float + val strikeThicknessPx: Float + + init { + val regular = faces[0] + cellWidthPx = max(1, (regular.measureText(MEASURE_TEXT) / MEASURE_TEXT.length).roundToInt()) + val metrics = regular.fontMetricsInt + cellHeightPx = max(1, metrics.descent - metrics.ascent) + baselinePx = -metrics.ascent + underlineOffsetPx = regular.underlinePosition + underlineThicknessPx = max(1f, regular.underlineThickness) + strikeOffsetPx = regular.strikeThruPosition + strikeThicknessPx = max(1f, regular.strikeThruThickness) + } + + /** Paint for a style's face; callers only change its color. */ + fun paint(flags: Int): Paint = faces[face(flags)] + + fun sameAs(family: String, sizePx: Float, density: Float): Boolean = + this.family == family && this.sizePx == sizePx && this.density == density + + companion object { + fun face(flags: Int): Int = + (if (flags and TerminalStyleFlags.BOLD != 0) 1 else 0) + + (if (flags and TerminalStyleFlags.ITALIC != 0) 2 else 0) + + private fun typefaceStyle(face: Int): Int = when (face) { + 1 -> Typeface.BOLD + 2 -> Typeface.ITALIC + 3 -> Typeface.BOLD_ITALIC + else -> Typeface.NORMAL + } + } +} diff --git a/packages/app/modules/paseo-terminal/android/src/main/java/sh/paseo/terminal/TerminalFrame.kt b/packages/app/modules/paseo-terminal/android/src/main/java/sh/paseo/terminal/TerminalFrame.kt new file mode 100644 index 0000000000..c27be7cc7a --- /dev/null +++ b/packages/app/modules/paseo-terminal/android/src/main/java/sh/paseo/terminal/TerminalFrame.kt @@ -0,0 +1,100 @@ +package sh.paseo.terminal + +import expo.modules.kotlin.records.Field +import expo.modules.kotlin.records.Record + +class TerminalCursorRecord : Record { + @Field val row: Int = 0 + @Field val col: Int = 0 +} + +class TerminalSelectionRecord : Record { + @Field val startRow: Int = 0 + @Field val startCol: Int = 0 + @Field val endRow: Int = 0 + @Field val endCol: Int = 0 +} + +class TerminalFrameRecord : Record { + @Field val columns: Int = 0 + /** Encoded rows, `cells;styles;text`; see `frame-encoding.ts`. */ + @Field val rows: List = emptyList() + @Field val cursor: TerminalCursorRecord? = null + @Field val selection: TerminalSelectionRecord? = null +} + +class TerminalThemeRecord : Record { + @Field val background: Double = 0.0 + @Field val cursor: Double = 0.0 + @Field val selectionBackground: Double = 0.0 + @Field val selectionForeground: Double = 0.0 +} + +class TerminalCustomGlyphRecord : Record { + @Field val char: String = "" + @Field val rects: DoubleArray = DoubleArray(0) + @Field val path: DoubleArray = DoubleArray(0) + @Field val strokeWidth: Double = 0.0 +} + +internal object TerminalStyleFlags { + const val BOLD = 1 + const val ITALIC = 2 + const val UNDERLINE = 4 + const val STRIKETHROUGH = 8 +} + +/** JS sends colors as unsigned ARGB numbers; Android paints signed ARGB ints. */ +internal fun argb(value: Double): Int = value.toLong().toInt() + +/** One row as the caller described it: cell text, column spans and style spans. */ +internal class TerminalRowSource private constructor( + val text: String, + val cells: IntArray, + val styles: DoubleArray, +) { + companion object { + /** Decodes `cells;styles;text`, where both number lists are comma separated. */ + fun decode(encoded: String): TerminalRowSource { + val cellsEnd = encoded.indexOf(';') + val stylesEnd = encoded.indexOf(';', cellsEnd + 1) + val cells = parseNumbers(encoded, 0, cellsEnd) + val styles = parseNumbers(encoded, cellsEnd + 1, stylesEnd) + return TerminalRowSource( + encoded.substring(stylesEnd + 1), + IntArray(cells.size) { cells[it].toInt() }, + DoubleArray(styles.size) { styles[it].toDouble() }, + ) + } + + private fun parseNumbers(encoded: String, start: Int, end: Int): LongArray { + if (end <= start) return LongArray(0) + var count = 1 + for (index in start until end) if (encoded[index] == ',') count += 1 + val numbers = LongArray(count) + var slot = 0 + for (index in start until end) { + val char = encoded[index] + if (char == ',') slot += 1 else numbers[slot] = numbers[slot] * 10 + (char - '0') + } + return numbers + } + } +} + +internal class TerminalSelection(record: TerminalSelectionRecord) { + private val startRow = record.startRow + private val startCol = record.startCol + private val endRow = record.endRow + private val endCol = record.endCol + + fun intersectsRow(row: Int): Boolean = row in startRow..endRow + + /** A cell is selected when any column it covers is inside the inclusive range. */ + fun contains(row: Int, col: Int, span: Int): Boolean { + if (!intersectsRow(row)) return false + val firstCol = if (row == startRow) startCol else 0 + val lastCol = if (row == endRow) endCol else Int.MAX_VALUE + return col + span - 1 >= firstCol && col <= lastCol + } +} diff --git a/packages/app/modules/paseo-terminal/android/src/main/java/sh/paseo/terminal/TerminalRowLayout.kt b/packages/app/modules/paseo-terminal/android/src/main/java/sh/paseo/terminal/TerminalRowLayout.kt new file mode 100644 index 0000000000..d2ee2355bc --- /dev/null +++ b/packages/app/modules/paseo-terminal/android/src/main/java/sh/paseo/terminal/TerminalRowLayout.kt @@ -0,0 +1,306 @@ +package sh.paseo.terminal + +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.RenderNode +import android.graphics.text.MeasuredText +import android.icu.text.BreakIterator + +/** Shaped glyphs may exceed their slot by this many pixels before they are scaled down. */ +private const val OVERFLOW_TOLERANCE_PX = 1f +/** Stands in for an open edge when a group is clipped on one side only. */ +private const val CLIP_UNBOUNDED_PX = 100_000f + +/** + * One row shaped once and placed on the terminal grid. + * + * Each run of cells with the same font face is shaped as a whole, so contextual forms, ligatures, + * marks and fallback fonts see their neighbours. The shaper reports a cluster's advance on its + * first UTF-16 unit and zero on the units it absorbed. A cell whose first unit has zero advance + * therefore belongs to the cluster of the cell before it: a ligature, conjunct, emoji sequence or + * reordered mark that spans columns. A cell that does not start a grapheme cluster also stays with + * the cell before it: a half-form conjunct such as स्त keeps both consonants' advances, and drawing + * either half alone would show an explicit virama. Each resulting group is drawn from the shaped + * run (in context) at its first cell's column, centred in the columns it owns, and scaled down only when + * its glyphs are wider than those columns. Colors never split groups or reshape the row: a group + * whose cells differ in color or selection is drawn once per color, clipped to those cells' columns. + */ +internal class TerminalRowLayout( + val source: TerminalRowSource, + private val font: TerminalFont, + private val customGlyphs: TerminalCustomGlyphs, +) { + private val chars: CharArray = source.text.toCharArray() + private val cellCount = source.cells.size / 2 + private val cellCol = IntArray(cellCount) + private val cellSpan = IntArray(cellCount) + private val cellStart = IntArray(cellCount) + private val cellEnd = IntArray(cellCount) + private val cellForeground = IntArray(cellCount) + private val cellBackground = IntArray(cellCount) + private val cellFlags = IntArray(cellCount) + private val cellGlyph = arrayOfNulls(cellCount) + private val groups = ArrayList() + private val measured: MeasuredText? + val text: String get() = source.text + + private var node: RenderNode? = null + + private class Group( + val firstCell: Int, + val lastCell: Int, + val col: Int, + val start: Int, + val end: Int, + val contextStart: Int, + val contextEnd: Int, + val offsetPx: Float, + val scale: Float, + val blank: Boolean, + ) + + init { + readCells() + measured = measure() + if (measured != null) buildGroups(measured) + } + + private fun readCells() { + var offset = 0 + var col = 0 + var styleIndex = 0 + var styleEndCol = 0 + for (cell in 0 until cellCount) { + val length = source.cells[cell * 2] + val span = maxOf(1, source.cells[cell * 2 + 1]) + while (col >= styleEndCol && styleIndex + 3 < source.styles.size) { + styleEndCol += source.styles[styleIndex].toInt() + styleIndex += 4 + } + val style = styleIndex - 4 + if (style >= 0) { + cellForeground[cell] = argb(source.styles[style + 1]) + cellBackground[cell] = argb(source.styles[style + 2]) + cellFlags[cell] = source.styles[style + 3].toInt() + } + cellCol[cell] = col + cellSpan[cell] = span + cellStart[cell] = offset + cellEnd[cell] = minOf(chars.size, offset + length) + cellGlyph[cell] = customGlyphs[source.text.substring(cellStart[cell], cellEnd[cell])] + offset = cellEnd[cell] + col += span + } + } + + /** Same face, not custom glyph: cells shaped together. */ + private fun shapesWith(cell: Int, other: Int): Boolean = + cellGlyph[cell] == null && cellGlyph[other] == null && + TerminalFont.face(cellFlags[cell]) == TerminalFont.face(cellFlags[other]) + + private fun measure(): MeasuredText? { + if (chars.isEmpty()) return null + val builder = MeasuredText.Builder(chars) + var cell = 0 + while (cell < cellCount) { + var last = cell + while (last + 1 < cellCount && shapesWith(cell, last + 1)) last += 1 + val length = cellEnd[last] - cellStart[cell] + if (length > 0) { + if (cellGlyph[cell] != null) { + builder.appendReplacementRun(font.paint(cellFlags[cell]), length, 0f) + } else { + builder.appendStyleRun(font.paint(cellFlags[cell]), length, false) + } + } + cell = last + 1 + } + val trailing = chars.size - (if (cellCount > 0) cellEnd[cellCount - 1] else 0) + if (trailing > 0) builder.appendReplacementRun(font.paint(0), trailing, 0f) + return builder.build() + } + + private fun buildGroups(measured: MeasuredText) { + // Keep platform graphemes together even when Minikin reports separate advances/cursors. + // Rules follow the OS ICU version: Android 10 splits Indic conjuncts such as स्ते. + val graphemes = BreakIterator.getCharacterInstance() + graphemes.setText(source.text) + var cell = 0 + while (cell < cellCount) { + if (cellGlyph[cell] != null) { + cell += 1 + continue + } + var last = cell + while (last + 1 < cellCount && shapesWith(cell, last + 1)) last += 1 + val contextStart = cellStart[cell] + val contextEnd = cellEnd[last] + var first = cell + for (next in cell + 1..last) { + val startsCluster = + cellEnd[next] > cellStart[next] && + measured.getCharWidthAt(cellStart[next]) != 0f && + graphemes.isBoundary(cellStart[next]) + if (startsCluster) { + addGroup(measured, first, next - 1, contextStart, contextEnd) + first = next + } + } + addGroup(measured, first, last, contextStart, contextEnd) + cell = last + 1 + } + } + + private fun addGroup(measured: MeasuredText, first: Int, last: Int, contextStart: Int, contextEnd: Int) { + val start = cellStart[first] + val end = cellEnd[last] + if (end <= start) return + var columns = 0 + for (cell in first..last) columns += cellSpan[cell] + val slot = (columns * font.cellWidthPx).toFloat() + val advance = measured.getWidth(start, end) + val scale = if (advance > slot + OVERFLOW_TOLERANCE_PX) slot / advance else 1f + val offset = if (scale < 1f) 0f else (slot - advance) / 2f + var blank = true + for (index in start until end) { + if (chars[index] != ' ') { + blank = false + break + } + } + groups.add(Group(first, last, cellCol[first], start, end, contextStart, contextEnd, offset, scale, blank)) + } + + /** Draws the row with its own colors, replaying a recorded display list when possible. */ + fun draw(canvas: Canvas, width: Int, paints: TerminalPaints) { + if (!canvas.isHardwareAccelerated) { + drawContent(canvas, -1, null, paints) + return + } + // Marks stacked above or below a letter ink outside the row. The node must not clip them: the + // view redraws every row on each frame, so ink outside the row is never left stale. + val recorded = node ?: RenderNode("terminal-row").also { + it.setClipToBounds(false) + node = it + } + if (!recorded.hasDisplayList() || recorded.width != width) { + recorded.setPosition(0, 0, width, font.cellHeightPx) + val recording = recorded.beginRecording() + try { + drawContent(recording, -1, null, paints) + } finally { + recorded.endRecording() + } + } + canvas.drawRenderNode(recorded) + } + + /** Draws the row with selected cells in selection colors. Selection never reshapes the row. */ + fun drawSelected(canvas: Canvas, row: Int, selection: TerminalSelection, paints: TerminalPaints) { + drawContent(canvas, row, selection, paints) + } + + fun release() { + node?.discardDisplayList() + node = null + } + + private fun selected(row: Int, selection: TerminalSelection?, cell: Int): Boolean = + selection?.contains(row, cellCol[cell], cellSpan[cell]) == true + + private fun drawContent(canvas: Canvas, row: Int, selection: TerminalSelection?, paints: TerminalPaints) { + val cellWidth = font.cellWidthPx.toFloat() + val cellHeight = font.cellHeightPx.toFloat() + val fill = paints.fill + for (cell in 0 until cellCount) { + val background = + if (selected(row, selection, cell)) paints.selectionBackground else cellBackground[cell] + if (background ushr 24 == 0) continue + fill.color = background + canvas.drawRect( + cellCol[cell] * cellWidth, + 0f, + (cellCol[cell] + cellSpan[cell]) * cellWidth, + cellHeight, + fill, + ) + } + + val baseline = font.baselinePx.toFloat() + val text = measured + if (text != null) { + for (group in groups) { + if (group.blank) continue + val paint = font.paint(cellFlags[group.firstCell]) + var first = group.firstCell + while (first <= group.lastCell) { + val color = foreground(row, selection, first, paints) + var last = first + while (last < group.lastCell && foreground(row, selection, last + 1, paints) == color) last += 1 + // Only edges between differently colored cells clip; the group's outer edges may overhang. + val clipped = first > group.firstCell || last < group.lastCell + canvas.save() + if (clipped) { + val left = if (first > group.firstCell) cellCol[first] * cellWidth else -CLIP_UNBOUNDED_PX + val right = + if (last < group.lastCell) (cellCol[last] + cellSpan[last]) * cellWidth else CLIP_UNBOUNDED_PX + canvas.clipRect(left, -CLIP_UNBOUNDED_PX, right, CLIP_UNBOUNDED_PX) + } + paint.color = color + drawGroupText(canvas, text, group, paint, cellWidth, cellHeight, baseline) + canvas.restore() + first = last + 1 + } + } + } + + for (cell in 0 until cellCount) { + val glyph = cellGlyph[cell] + val flags = cellFlags[cell] + val foreground = foreground(row, selection, cell, paints) + if (glyph != null) { + fill.color = foreground + glyph.draw(canvas, cellCol[cell] * cellWidth, cellSpan[cell] * cellWidth, cellHeight, font.density, fill) + } + if (flags and (TerminalStyleFlags.UNDERLINE or TerminalStyleFlags.STRIKETHROUGH) == 0) continue + fill.color = foreground + val left = cellCol[cell] * cellWidth + val right = left + cellSpan[cell] * cellWidth + if (flags and TerminalStyleFlags.UNDERLINE != 0) { + val top = baseline + font.underlineOffsetPx + canvas.drawRect(left, top, right, top + font.underlineThicknessPx, fill) + } + if (flags and TerminalStyleFlags.STRIKETHROUGH != 0) { + val top = baseline + font.strikeOffsetPx + canvas.drawRect(left, top, right, top + font.strikeThicknessPx, fill) + } + } + } + + private fun drawGroupText( + canvas: Canvas, + text: MeasuredText, + group: Group, + paint: Paint, + cellWidth: Float, + cellHeight: Float, + baseline: Float, + ) { + val x = group.col * cellWidth + group.offsetPx + if (group.scale < 1f) canvas.scale(group.scale, group.scale, x, cellHeight / 2f) + canvas.drawTextRun(text, group.start, group.end, group.contextStart, group.contextEnd, x, baseline, false, paint) + } + + private fun foreground(row: Int, selection: TerminalSelection?, cell: Int, paints: TerminalPaints): Int = + if (selected(row, selection, cell)) paints.selectionForeground else cellForeground[cell] + + /** The row's text as accessibility reads it. */ + fun describe(): String = source.text.trimEnd() +} + +/** Paint state shared by every row of one view. */ +internal class TerminalPaints { + val fill = Paint(Paint.ANTI_ALIAS_FLAG) + var selectionBackground: Int = 0xFFFFFF00.toInt() + var selectionForeground: Int = 0xFF000000.toInt() +} diff --git a/packages/app/modules/paseo-terminal/contract.ts b/packages/app/modules/paseo-terminal/contract.ts new file mode 100644 index 0000000000..ee4fe45ab0 --- /dev/null +++ b/packages/app/modules/paseo-terminal/contract.ts @@ -0,0 +1,60 @@ +/** + * Native terminal renderer. The caller owns terminal state (xterm), input and gestures; this view + * shapes and paints one frame of terminal cells on the authoritative cell grid and reports the + * cell size its font produces. + * + * Colors are unsigned 32-bit ARGB numbers (`0xAARRGGBB`). Alpha 0 means "not painted". + */ +export interface PaseoTerminalFrame { + /** Columns the frame paints. Cells beyond it are clipped. */ + columns: number; + rows: PaseoTerminalRow[]; + cursor: PaseoTerminalCursor | null; + selection: PaseoTerminalSelection | null; +} + +/** + * One terminal row. Cells start at column 0 and are consecutive. `text` is every cell's text in + * order, so cell `i` owns the next `cells[2i]` UTF-16 code units of `text` and covers the next + * `cells[2i + 1]` columns. Style spans are consecutive from column 0 and cover all columns. + */ +export interface PaseoTerminalRow { + text: string; + /** Flattened `[utf16Length, columnSpan]` per cell. */ + cells: number[]; + /** Flattened `[columnCount, foreground, background, flags]` per style span. */ + styles: number[]; +} + +export interface PaseoTerminalCursor { + row: number; + col: number; +} + +/** Inclusive cell range in frame rows. A cell is selected when any of its columns is. */ +export interface PaseoTerminalSelection { + startRow: number; + startCol: number; + endRow: number; + endCol: number; +} + +export interface PaseoTerminalTheme { + background: number; + cursor: number; + selectionBackground: number; + selectionForeground: number; +} + +export interface PaseoTerminalCellMetrics { + cellWidth: number; + cellHeight: number; +} + +export const PASEO_TERMINAL_STYLE_FLAGS = { + bold: 1, + italic: 2, + underline: 4, + strikethrough: 8, + dim: 16, +} as const; diff --git a/packages/app/src/terminal/native-renderer/terminal-custom-glyph.test.ts b/packages/app/modules/paseo-terminal/custom-glyphs.test.ts similarity index 81% rename from packages/app/src/terminal/native-renderer/terminal-custom-glyph.test.ts rename to packages/app/modules/paseo-terminal/custom-glyphs.test.ts index 95de6c8c54..f3892110c0 100644 --- a/packages/app/src/terminal/native-renderer/terminal-custom-glyph.test.ts +++ b/packages/app/modules/paseo-terminal/custom-glyphs.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { resolveTerminalCustomGlyph } from "./terminal-custom-glyph"; +import { encodeTerminalCustomGlyphs, resolveTerminalCustomGlyph } from "./custom-glyphs"; describe("native terminal custom glyphs", () => { it("fills the complete cell for the full block used by the Claude mascot", () => { @@ -85,4 +85,22 @@ describe("native terminal custom glyphs", () => { expect(resolveTerminalCustomGlyph(char), `missing custom glyph ${char}`).not.toBeNull(); } }); + + it("encodes every glyph for the native renderer in cell fractions", () => { + const encoded = new Map(encodeTerminalCustomGlyphs().map((glyph) => [glyph.char, glyph])); + + expect(encoded.get("▛")).toEqual({ + char: "▛", + rects: [0, 0, 0.5, 0.5, 0.5, 0, 0.5, 0.5, 0, 0.5, 0.5, 0.5], + path: [], + strokeWidth: 0, + }); + expect(encoded.get("╭")).toEqual({ + char: "╭", + rects: [], + path: [0, 0.5, 1, 1, 0.5, 0.75, 2, 0.5, 0.5, 0.75, 0.5, 1, 1, 0.5], + strokeWidth: 1, + }); + expect(encoded.get("┣")?.strokeWidth).toBe(3); + }); }); diff --git a/packages/app/src/terminal/native-renderer/terminal-custom-glyph.ts b/packages/app/modules/paseo-terminal/custom-glyphs.ts similarity index 66% rename from packages/app/src/terminal/native-renderer/terminal-custom-glyph.ts rename to packages/app/modules/paseo-terminal/custom-glyphs.ts index f63de40e24..fd55193f74 100644 --- a/packages/app/src/terminal/native-renderer/terminal-custom-glyph.ts +++ b/packages/app/modules/paseo-terminal/custom-glyphs.ts @@ -82,12 +82,60 @@ const BLOCK_GLYPHS: Readonly> = { "▟": rects(upperRight(), lowerLeft(), lowerRight()), }; +/** + * One custom glyph as the native view consumes it. Coordinates are fractions of one cell. + * `rects` is flattened `[x, y, width, height]`. `path` is flattened commands: + * `0 x y` moves, `1 x y` draws a line, `2 cx cy x y` draws a quadratic curve. Paths are + * stroked with `strokeWidth` points regardless of cell size. + */ +export interface PaseoTerminalCustomGlyph { + char: string; + rects: number[]; + path: number[]; + strokeWidth: number; +} + +const PATH_COMMAND_CODES: Readonly> = { + M: { code: 0, coordinates: 2 }, + L: { code: 1, coordinates: 2 }, + Q: { code: 2, coordinates: 4 }, +}; + export function resolveTerminalCustomGlyph(char: string): TerminalCustomGlyph | null { return LIGHT_BOX_GLYPHS[char] ?? HEAVY_BOX_GLYPHS[char] ?? BLOCK_GLYPHS[char] ?? null; } -export function isTerminalCustomGlyph(char: string): boolean { - return resolveTerminalCustomGlyph(char) !== null; +export function encodeTerminalCustomGlyphs(): PaseoTerminalCustomGlyph[] { + const table = { ...LIGHT_BOX_GLYPHS, ...HEAVY_BOX_GLYPHS, ...BLOCK_GLYPHS }; + return Object.entries(table).map(([char, glyph]) => + glyph.kind === "rects" + ? { + char, + rects: glyph.rects.flatMap((value) => [value.x, value.y, value.width, value.height]), + path: [], + strokeWidth: 0, + } + : { char, rects: [], path: encodePath(glyph.path), strokeWidth: glyph.strokeWidth }, + ); +} + +function encodePath(pathData: string): number[] { + const tokens = pathData.match(/[MLQ]|-?\d*\.?\d+/g) ?? []; + const encoded: number[] = []; + let index = 0; + while (index < tokens.length) { + const command = PATH_COMMAND_CODES[tokens[index]]; + if (!command) { + throw new Error(`Unsupported terminal glyph path command in ${pathData}`); + } + const coordinates = tokens.slice(index + 1, index + 1 + command.coordinates).map(Number); + if (coordinates.length !== command.coordinates || coordinates.some(Number.isNaN)) { + throw new Error(`Malformed terminal glyph path ${pathData}`); + } + encoded.push(command.code, ...coordinates); + index += 1 + command.coordinates; + } + return encoded; } function path(pathData: string, strokeWidth = 1): TerminalPathGlyph { diff --git a/packages/app/modules/paseo-terminal/expo-module.config.json b/packages/app/modules/paseo-terminal/expo-module.config.json new file mode 100644 index 0000000000..6291f992c1 --- /dev/null +++ b/packages/app/modules/paseo-terminal/expo-module.config.json @@ -0,0 +1,9 @@ +{ + "platforms": ["android", "ios"], + "android": { + "modules": ["sh.paseo.terminal.PaseoTerminalModule"] + }, + "ios": { + "modules": ["PaseoTerminalModule"] + } +} diff --git a/packages/app/modules/paseo-terminal/frame-encoding.test.ts b/packages/app/modules/paseo-terminal/frame-encoding.test.ts new file mode 100644 index 0000000000..a40d4302c2 --- /dev/null +++ b/packages/app/modules/paseo-terminal/frame-encoding.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "vitest"; + +import { encodeTerminalFrame } from "./frame-encoding"; + +describe("encodeTerminalFrame", () => { + test("puts cells and styles before text, so text may contain separators", () => { + const native = encodeTerminalFrame({ + columns: 10, + rows: [ + { + text: "안a;b,", + cells: [1, 2, 1, 1, 1, 1, 1, 1, 1, 1], + styles: [3, 0xffe5e5e5, 0, 0, 3, 0xffff0000, 0, 1], + }, + { text: "", cells: [], styles: [] }, + ], + cursor: { row: 1, col: 0 }, + selection: { startRow: 0, startCol: 0, endRow: 0, endCol: 1 }, + }); + + expect(native).toEqual({ + columns: 10, + rows: ["1,2,1,1,1,1,1,1,1,1;3,4293256677,0,0,3,4294901760,0,1;안a;b,", ";;"], + cursor: { row: 1, col: 0 }, + selection: { startRow: 0, startCol: 0, endRow: 0, endCol: 1 }, + }); + }); + + test("leaves out a missing cursor and selection instead of sending null", () => { + const native = encodeTerminalFrame({ columns: 1, rows: [], cursor: null, selection: null }); + + expect(Object.keys(native)).toEqual(["columns", "rows"]); + }); +}); diff --git a/packages/app/modules/paseo-terminal/frame-encoding.ts b/packages/app/modules/paseo-terminal/frame-encoding.ts new file mode 100644 index 0000000000..fc4d113bd2 --- /dev/null +++ b/packages/app/modules/paseo-terminal/frame-encoding.ts @@ -0,0 +1,27 @@ +import type { PaseoTerminalCursor, PaseoTerminalFrame, PaseoTerminalSelection } from "./contract"; + +/** A frame as it crosses to native. */ +export interface NativePaseoTerminalFrame { + columns: number; + rows: string[]; + cursor?: PaseoTerminalCursor; + selection?: PaseoTerminalSelection; +} + +/** + * Rows cross as `cells;styles;text`, with the numbers comma separated. One string per row keeps + * prop conversion to a single copy instead of one call per number, and the string is also the + * native shaping cache key, so an unchanged row costs one lookup. + * + * A missing cursor or selection is left out rather than sent as `null`: Expo on iOS fails to + * convert `null` into an optional record field and then drops the whole frame. + */ +export function encodeTerminalFrame(frame: PaseoTerminalFrame): NativePaseoTerminalFrame { + const native: NativePaseoTerminalFrame = { + columns: frame.columns, + rows: frame.rows.map((row) => `${row.cells.join(",")};${row.styles.join(",")};${row.text}`), + }; + if (frame.cursor) native.cursor = frame.cursor; + if (frame.selection) native.selection = frame.selection; + return native; +} diff --git a/packages/app/modules/paseo-terminal/index.tsx b/packages/app/modules/paseo-terminal/index.tsx new file mode 100644 index 0000000000..321cf7c30c --- /dev/null +++ b/packages/app/modules/paseo-terminal/index.tsx @@ -0,0 +1,47 @@ +import { requireNativeView } from "expo"; +import { useCallback, useMemo } from "react"; +import type { StyleProp, ViewStyle } from "react-native"; + +import type { PaseoTerminalCellMetrics, PaseoTerminalFrame, PaseoTerminalTheme } from "./contract"; +import { encodeTerminalCustomGlyphs, type PaseoTerminalCustomGlyph } from "./custom-glyphs"; +import { encodeTerminalFrame, type NativePaseoTerminalFrame } from "./frame-encoding"; + +export * from "./contract"; + +export interface PaseoTerminalViewProps { + frame: PaseoTerminalFrame; + theme: PaseoTerminalTheme; + fontFamily: string; + /** Font size in points after the system font scale is applied. */ + fontSize: number; + onCellMetrics?: (metrics: PaseoTerminalCellMetrics) => void; + style?: StyleProp; +} + +interface NativePaseoTerminalViewProps extends Omit< + PaseoTerminalViewProps, + "frame" | "onCellMetrics" +> { + frame: NativePaseoTerminalFrame; + customGlyphs: PaseoTerminalCustomGlyph[]; + onCellMetrics?: (event: { nativeEvent: PaseoTerminalCellMetrics }) => void; +} + +const NativePaseoTerminalView = requireNativeView("PaseoTerminal"); +const CUSTOM_GLYPHS = encodeTerminalCustomGlyphs(); + +export function PaseoTerminalView({ frame, onCellMetrics, ...props }: PaseoTerminalViewProps) { + const nativeFrame = useMemo(() => encodeTerminalFrame(frame), [frame]); + const handleCellMetrics = useCallback( + (event: { nativeEvent: PaseoTerminalCellMetrics }) => onCellMetrics?.(event.nativeEvent), + [onCellMetrics], + ); + return ( + + ); +} diff --git a/packages/app/modules/paseo-terminal/ios/PaseoTerminal.podspec b/packages/app/modules/paseo-terminal/ios/PaseoTerminal.podspec new file mode 100644 index 0000000000..f15570465f --- /dev/null +++ b/packages/app/modules/paseo-terminal/ios/PaseoTerminal.podspec @@ -0,0 +1,22 @@ +Pod::Spec.new do |s| + s.name = 'PaseoTerminal' + s.version = '0.1.0' + s.summary = 'Native terminal renderer for Paseo' + s.description = 'Shapes and paints terminal frames on the terminal cell grid' + s.license = 'Apache-2.0' + s.author = 'Paseo' + s.homepage = 'https://paseo.sh' + s.platforms = { :ios => '15.1' } + s.swift_version = '5.4' + s.source = { :path => '.' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'SWIFT_COMPILATION_MODE' => 'wholemodule' + } + + s.source_files = "**/*.{h,m,swift}" +end diff --git a/packages/app/modules/paseo-terminal/ios/PaseoTerminalModule.swift b/packages/app/modules/paseo-terminal/ios/PaseoTerminalModule.swift new file mode 100644 index 0000000000..ec6d7edb0c --- /dev/null +++ b/packages/app/modules/paseo-terminal/ios/PaseoTerminalModule.swift @@ -0,0 +1,21 @@ +import ExpoModulesCore + +public class PaseoTerminalModule: Module { + public func definition() -> ModuleDefinition { + Name("PaseoTerminal") + + View(PaseoTerminalView.self) { + Events("onCellMetrics") + + Prop("frame") { (view: PaseoTerminalView, frame: TerminalFrameRecord) in view.setFrame(frame) } + Prop("theme") { (view: PaseoTerminalView, theme: TerminalThemeRecord) in view.setTheme(theme) } + Prop("fontFamily") { (view: PaseoTerminalView, family: String) in view.setFontFamily(family) } + Prop("fontSize") { (view: PaseoTerminalView, size: Double) in view.setFontSize(size) } + Prop("customGlyphs") { (view: PaseoTerminalView, glyphs: [TerminalCustomGlyphRecord]) in + view.setCustomGlyphs(glyphs) + } + + OnViewDidUpdateProps { (view: PaseoTerminalView) in view.commit() } + } + } +} diff --git a/packages/app/modules/paseo-terminal/ios/PaseoTerminalView.swift b/packages/app/modules/paseo-terminal/ios/PaseoTerminalView.swift new file mode 100644 index 0000000000..240cddddab --- /dev/null +++ b/packages/app/modules/paseo-terminal/ios/PaseoTerminalView.swift @@ -0,0 +1,179 @@ +import ExpoModulesCore +import UIKit + +private let minCachedRows = 256 +private let cursorAlpha: CGFloat = 0.45 + +/// Paints terminal frames on the cell grid. Row shaping is cached by row content, and a frame +/// repaints only the rows whose layout, selection or cursor changed. A row's glyphs may ink outside +/// the row, so a repaint covers the old and new row's ink and redraws every row whose ink it meets. +final class PaseoTerminalView: ExpoView { + let onCellMetrics = EventDispatcher() + + private var fontFamily = "Menlo" + private var fontSize: CGFloat = 12 + private var font: TerminalFont? + private var customGlyphs = TerminalCustomGlyphs.empty + private var frameRecord: TerminalFrameRecord? + private var frameChanged = false + + private var rows: [TerminalRowLayout] = [] + private var columns = 0 + private var cursor: (row: Int, col: Int)? + private var selection: TerminalSelection? + private var background = TerminalColor(Double(0xff00_0000)) + private var cursorColor = TerminalColor(Double(0xffff_ffff)) + private var rowPaint = TerminalRowPaint( + selectionBackground: TerminalColor(Double(0xffff_ff00)), + selectionForeground: TerminalColor(Double(0xff00_0000))) + private var rowCache: [String: TerminalRowLayout] = [:] + + required init(appContext: AppContext? = nil) { + super.init(appContext: appContext) + isOpaque = true + contentMode = .redraw + isAccessibilityElement = false + } + + func setFrame(_ record: TerminalFrameRecord) { + frameRecord = record + frameChanged = true + } + + func setTheme(_ record: TerminalThemeRecord) { + background = TerminalColor(record.background) + cursorColor = TerminalColor(record.cursor) + rowPaint = TerminalRowPaint( + selectionBackground: TerminalColor(record.selectionBackground), + selectionForeground: TerminalColor(record.selectionForeground)) + backgroundColor = UIColor(cgColor: background.cgColor()) + setNeedsDisplay() + } + + func setFontFamily(_ value: String) { fontFamily = value } + + func setFontSize(_ value: Double) { fontSize = CGFloat(value) } + + func setCustomGlyphs(_ records: [TerminalCustomGlyphRecord]) { + customGlyphs = TerminalCustomGlyphs(records) + rowCache.removeAll() + rows = [] + frameChanged = true + } + + /// Applies a batch of prop updates: font first, then the frame laid out with it. + func commit() { + let displayScale = traitCollection.displayScale > 0 ? traitCollection.displayScale : UIScreen.main.scale + if font?.sameAs(family: fontFamily, size: fontSize, displayScale: displayScale) != true { + let next = TerminalFont(family: fontFamily, size: fontSize, displayScale: displayScale) + font = next + rowCache.removeAll() + rows = [] + frameChanged = true + onCellMetrics(["cellWidth": next.cellWidth, "cellHeight": next.cellHeight]) + setNeedsDisplay() + } + if frameChanged { + frameChanged = false + layoutFrame() + } + } + + override func traitCollectionDidChange(_ previous: UITraitCollection?) { + super.traitCollectionDidChange(previous) + if previous?.displayScale != traitCollection.displayScale { commit() } + } + + override func layoutSubviews() { + super.layoutSubviews() + setNeedsDisplay() + } + + private func layoutFrame() { + guard let font, let record = frameRecord else { return } + let previousRows = rows + let previousSelection = selection + let previousCursor = cursor + let previousColumns = columns + + columns = record.columns + cursor = record.cursor.map { ($0.row, $0.col) } + selection = record.selection.map(TerminalSelection.init) + rows = record.rows.map { encoded in + if let cached = rowCache[encoded] { return cached } + let layout = TerminalRowLayout( + source: TerminalRowSource(encoded: encoded), font: font, customGlyphs: customGlyphs) + rowCache[encoded] = layout + return layout + } + trimRowCache(keeping: record.rows) + updateAccessibility() + + if previousColumns != columns || previousRows.count != rows.count { + setNeedsDisplay() + return + } + for index in rows.indices { + let layoutChanged = previousRows[index] !== rows[index] + let selectionChanged = previousSelection?.columns(onRow: index) != selection?.columns(onRow: index) + let cursorChanged = + (previousCursor?.row == index || cursor?.row == index) + && (previousCursor?.row != cursor?.row || previousCursor?.col != cursor?.col) + if layoutChanged || selectionChanged || cursorChanged { + let previous = paintedRect(previousRows[index], at: index, font: font) + setNeedsDisplay(previous.union(paintedRect(rows[index], at: index, font: font))) + } + } + } + + /// The area row `index` paints, including glyph ink outside the row. + private func paintedRect(_ row: TerminalRowLayout, at index: Int, font: TerminalFont) -> CGRect { + let top = CGFloat(index) * font.cellHeight + return CGRect(x: 0, y: top + row.inkTop, width: bounds.width, height: row.inkBottom - row.inkTop) + } + + private func trimRowCache(keeping currentRows: [String]) { + guard rowCache.count > max(minCachedRows, rows.count * 2) else { return } + let current = Set(currentRows) + rowCache = rowCache.filter { current.contains($0.key) } + } + + override func draw(_ rect: CGRect) { + guard let context = UIGraphicsGetCurrentContext() else { return } + context.setFillColor(background.cgColor()) + context.fill(rect) + guard let font else { return } + let gridWidth = CGFloat(columns) * font.cellWidth + context.saveGState() + context.clip(to: CGRect(x: 0, y: 0, width: min(bounds.width, gridWidth), height: bounds.height)) + for (index, row) in rows.enumerated() where paintedRect(row, at: index, font: font).intersects(rect) { + row.draw(in: context, row: index, top: CGFloat(index) * font.cellHeight, selection: selection, paint: rowPaint) + } + if let cursor { + context.setFillColor(cursorColor.cgColor(alphaScale: cursorAlpha)) + context.fill(CGRect( + x: CGFloat(cursor.col) * font.cellWidth, y: CGFloat(cursor.row) * font.cellHeight, + width: font.cellWidth, height: font.cellHeight)) + } + context.restoreGState() + } + + /// Exposes each non-blank row as a static text element, `terminal-row-`. + private func updateAccessibility() { + guard let font else { return } + var elements: [UIAccessibilityElement] = [] + for (index, row) in rows.enumerated() { + let label = row.accessibilityText + guard !label.isEmpty else { continue } + let element = UIAccessibilityElement(accessibilityContainer: self) + element.accessibilityIdentifier = "terminal-row-\(index)" + element.accessibilityLabel = label + element.accessibilityTraits = .staticText + element.accessibilityFrameInContainerSpace = CGRect( + x: 0, y: CGFloat(index) * font.cellHeight, + width: min(bounds.width, CGFloat(columns) * font.cellWidth), height: font.cellHeight) + elements.append(element) + } + accessibilityElements = elements + } +} diff --git a/packages/app/modules/paseo-terminal/ios/TerminalCustomGlyphs.swift b/packages/app/modules/paseo-terminal/ios/TerminalCustomGlyphs.swift new file mode 100644 index 0000000000..2442131916 --- /dev/null +++ b/packages/app/modules/paseo-terminal/ios/TerminalCustomGlyphs.swift @@ -0,0 +1,73 @@ +import CoreGraphics + +/// Box drawing and block elements painted as cell geometry instead of font glyphs, so frames and +/// blocks join across cells regardless of the font's own metrics. +final class TerminalCustomGlyph { + private let rects: [Double] + private let commands: [Double] + private let strokeWidth: CGFloat + + init(_ record: TerminalCustomGlyphRecord) { + rects = record.rects + commands = record.path + strokeWidth = CGFloat(record.strokeWidth) + } + + func draw(in context: CGContext, rect cell: CGRect, color: CGColor) { + context.saveGState() + context.setFillColor(color) + var index = 0 + while index + 3 < rects.count { + context.fill(CGRect( + x: cell.minX + CGFloat(rects[index]) * cell.width, + y: cell.minY + CGFloat(rects[index + 1]) * cell.height, + width: CGFloat(rects[index + 2]) * cell.width, + height: CGFloat(rects[index + 3]) * cell.height)) + index += 4 + } + if !commands.isEmpty { + let point = { (offset: Int) in + CGPoint( + x: cell.minX + CGFloat(self.commands[offset]) * cell.width, + y: cell.minY + CGFloat(self.commands[offset + 1]) * cell.height) + } + let path = CGMutablePath() + index = 0 + while index < commands.count { + switch Int(commands[index]) { + case 0: + path.move(to: point(index + 1)) + index += 3 + case 1: + path.addLine(to: point(index + 1)) + index += 3 + case 2: + path.addQuadCurve(to: point(index + 3), control: point(index + 1)) + index += 5 + default: + index = commands.count + } + } + context.setStrokeColor(color) + context.setLineWidth(strokeWidth) + context.setLineCap(.butt) + context.setLineJoin(.miter) + context.addPath(path) + context.strokePath() + } + context.restoreGState() + } +} + +final class TerminalCustomGlyphs { + static let empty = TerminalCustomGlyphs([]) + private let glyphs: [String: TerminalCustomGlyph] + + init(_ records: [TerminalCustomGlyphRecord]) { + var glyphs: [String: TerminalCustomGlyph] = [:] + for record in records { glyphs[record.char] = TerminalCustomGlyph(record) } + self.glyphs = glyphs + } + + subscript(text: String) -> TerminalCustomGlyph? { glyphs[text] } +} diff --git a/packages/app/modules/paseo-terminal/ios/TerminalFont.swift b/packages/app/modules/paseo-terminal/ios/TerminalFont.swift new file mode 100644 index 0000000000..d2f52a78da --- /dev/null +++ b/packages/app/modules/paseo-terminal/ios/TerminalFont.swift @@ -0,0 +1,67 @@ +import CoreText +import UIKit + +private let measureText = "mmmmmmmmmm" + +/// The terminal font at one size: a CTFont per face (regular, bold, italic, bold italic) and the +/// cell those faces are laid out on. Cells are whole device pixels so every column origin is +/// exact. CoreText's cascade list covers characters the family lacks. +final class TerminalFont { + let family: String + let size: CGFloat + let displayScale: CGFloat + private let faces: [CTFont] + + /// Cell size and baseline in points, each a whole number of device pixels. + let cellWidth: CGFloat + let cellHeight: CGFloat + let baseline: CGFloat + let underlineOffset: CGFloat + let underlineThickness: CGFloat + let strikeOffset: CGFloat + let strikeThickness: CGFloat + + init(family: String, size: CGFloat, displayScale: CGFloat) { + self.family = family + self.size = size + self.displayScale = displayScale + let regular = TerminalFont.makeFont(family: family, size: size, traits: []) + faces = [ + regular, + TerminalFont.makeFont(family: family, size: size, traits: .traitBold), + TerminalFont.makeFont(family: family, size: size, traits: .traitItalic), + TerminalFont.makeFont(family: family, size: size, traits: [.traitBold, .traitItalic]), + ] + + let pixel = { (value: CGFloat) in max(1, (value * displayScale).rounded()) / displayScale } + let line = CTLineCreateWithAttributedString( + NSAttributedString(string: measureText, attributes: [.font: regular])) + let width = CGFloat(CTLineGetTypographicBounds(line, nil, nil, nil)) + cellWidth = pixel(width / CGFloat(measureText.count)) + let ascent = CTFontGetAscent(regular) + let descent = CTFontGetDescent(regular) + let leading = CTFontGetLeading(regular) + cellHeight = pixel(ascent + descent + leading) + baseline = pixel(ascent) + underlineOffset = -CTFontGetUnderlinePosition(regular) + underlineThickness = max(1 / displayScale, CTFontGetUnderlineThickness(regular)) + strikeOffset = -CTFontGetXHeight(regular) / 2 + strikeThickness = underlineThickness + } + + func font(flags: Int) -> CTFont { faces[TerminalFont.face(flags: flags)] } + + func sameAs(family: String, size: CGFloat, displayScale: CGFloat) -> Bool { + self.family == family && self.size == size && self.displayScale == displayScale + } + + static func face(flags: Int) -> Int { + (flags & TerminalStyleFlags.bold != 0 ? 1 : 0) + (flags & TerminalStyleFlags.italic != 0 ? 2 : 0) + } + + private static func makeFont(family: String, size: CGFloat, traits: UIFontDescriptor.SymbolicTraits) -> CTFont { + let base = UIFontDescriptor(fontAttributes: [.family: family]) + let descriptor = traits.isEmpty ? base : (base.withSymbolicTraits(traits) ?? base) + return UIFont(descriptor: descriptor, size: size) as CTFont + } +} diff --git a/packages/app/modules/paseo-terminal/ios/TerminalFrame.swift b/packages/app/modules/paseo-terminal/ios/TerminalFrame.swift new file mode 100644 index 0000000000..84ba6e8b0c --- /dev/null +++ b/packages/app/modules/paseo-terminal/ios/TerminalFrame.swift @@ -0,0 +1,122 @@ +import CoreGraphics +import ExpoModulesCore + +struct TerminalCursorRecord: Record { + @Field var row: Int = 0 + @Field var col: Int = 0 +} + +struct TerminalSelectionRecord: Record { + @Field var startRow: Int = 0 + @Field var startCol: Int = 0 + @Field var endRow: Int = 0 + @Field var endCol: Int = 0 +} + +struct TerminalFrameRecord: Record { + @Field var columns: Int = 0 + /// Encoded rows, `cells;styles;text`; see `frame-encoding.ts`. + @Field var rows: [String] = [] + @Field var cursor: TerminalCursorRecord? = nil + @Field var selection: TerminalSelectionRecord? = nil +} + +struct TerminalThemeRecord: Record { + @Field var background: Double = 0 + @Field var cursor: Double = 0 + @Field var selectionBackground: Double = 0 + @Field var selectionForeground: Double = 0 +} + +struct TerminalCustomGlyphRecord: Record { + @Field var char: String = "" + @Field var rects: [Double] = [] + @Field var path: [Double] = [] + @Field var strokeWidth: Double = 0 +} + +enum TerminalStyleFlags { + static let bold = 1 + static let italic = 2 + static let underline = 4 + static let strikethrough = 8 +} + +/// JS sends colors as unsigned ARGB numbers. +struct TerminalColor: Equatable { + let argb: UInt32 + + init(_ value: Double) { + argb = UInt32(truncatingIfNeeded: Int64(value)) + } + + var isPainted: Bool { argb >> 24 != 0 } + + func cgColor(alphaScale: CGFloat = 1) -> CGColor { + let channel = { (shift: UInt32) in CGFloat((argb >> shift) & 0xff) / 255 } + return CGColor(srgbRed: channel(16), green: channel(8), blue: channel(0), alpha: channel(24) * alphaScale) + } +} + +/// One row as the caller described it: cell text, column spans and style spans. +struct TerminalRowSource { + let text: String + let cells: [Int] + let styles: [Double] + + /// Decodes `cells;styles;text`, where both number lists are comma separated. + init(encoded: String) { + let utf8 = encoded.utf8 + let separator = UInt8(ascii: ";") + let cellsEnd = utf8.firstIndex(of: separator) ?? utf8.endIndex + let stylesStart = cellsEnd == utf8.endIndex ? cellsEnd : utf8.index(after: cellsEnd) + let stylesEnd = utf8[stylesStart...].firstIndex(of: separator) ?? utf8.endIndex + let textStart = stylesEnd == utf8.endIndex ? stylesEnd : utf8.index(after: stylesEnd) + cells = TerminalRowSource.parseNumbers(utf8[utf8.startIndex.. [Int64] { + guard !bytes.isEmpty else { return [] } + var numbers: [Int64] = [0] + for byte in bytes { + if byte == UInt8(ascii: ",") { + numbers.append(0) + } else { + numbers[numbers.count - 1] = numbers[numbers.count - 1] * 10 + Int64(byte - UInt8(ascii: "0")) + } + } + return numbers + } +} + +struct TerminalSelection: Equatable { + let startRow: Int + let startCol: Int + let endRow: Int + let endCol: Int + + init(_ record: TerminalSelectionRecord) { + startRow = record.startRow + startCol = record.startCol + endRow = record.endRow + endCol = record.endCol + } + + func intersects(row: Int) -> Bool { row >= startRow && row <= endRow } + + /// A cell is selected when any column it covers is inside the inclusive range. + func contains(row: Int, col: Int, span: Int) -> Bool { + guard intersects(row: row) else { return false } + let firstCol = row == startRow ? startCol : 0 + let lastCol = row == endRow ? endCol : Int.max + return col + span - 1 >= firstCol && col <= lastCol + } + + /// The part of the selection on one row, so a row only repaints when its own part changes. + func columns(onRow row: Int) -> ClosedRange? { + guard intersects(row: row) else { return nil } + return (row == startRow ? startCol : 0)...(row == endRow ? endCol : Int.max) + } +} diff --git a/packages/app/modules/paseo-terminal/ios/TerminalRowLayout.swift b/packages/app/modules/paseo-terminal/ios/TerminalRowLayout.swift new file mode 100644 index 0000000000..80050c9040 --- /dev/null +++ b/packages/app/modules/paseo-terminal/ios/TerminalRowLayout.swift @@ -0,0 +1,335 @@ +import CoreText +import UIKit + +/// Shaped glyphs may exceed their slot by this many points before they are scaled down. +private let overflowTolerance: CGFloat = 0.5 +/// Stands in for an open edge when a group is clipped on one side only. +private let clipUnbounded: CGFloat = 100_000 + +/// Lays text out left to right in logical order. The override fixes every character's direction; +/// the base direction also covers trailing spaces, which bidi rule L1 otherwise moves to the +/// direction detected from the first strong character, ahead of an Arabic or Hebrew row. +private let leftToRightAttributes: [NSAttributedString.Key: Any] = { + var base = CTWritingDirection.leftToRight + let paragraph = withUnsafeBytes(of: &base) { bytes in + CTParagraphStyleCreate( + [CTParagraphStyleSetting( + spec: .baseWritingDirection, valueSize: MemoryLayout.size, value: bytes.baseAddress!)], + 1) + } + let overrideLeftToRight = CTWritingDirection.leftToRight.rawValue | 2 // kCTWritingDirectionOverride + return [ + NSAttributedString.Key(kCTWritingDirectionAttributeName as String): [NSNumber(value: overrideLeftToRight)], + NSAttributedString.Key(kCTParagraphStyleAttributeName as String): paragraph, + ] +}() + +/// Colors a row is painted with besides its own styles. +struct TerminalRowPaint { + let selectionBackground: TerminalColor + let selectionForeground: TerminalColor +} + +/// One row shaped once and placed on the terminal grid. +/// +/// Each run of cells with the same font face is shaped as a whole line, in logical order and left +/// to right like the terminal grid, so contextual forms, ligatures, marks and fallback fonts see +/// their neighbours. CoreText reports which UTF-16 offset produced each glyph. Cells whose units +/// produced no advancing glyph, and cells a reordered glyph crosses, join the cluster of the cell +/// before them: a ligature, conjunct, emoji sequence or reordered mark that spans columns. Each +/// group is drawn at its first cell's column with the glyph offsets CoreText produced, centred in +/// the columns it owns and scaled down only when wider than them. Colors never split groups. +final class TerminalRowLayout { + let source: TerminalRowSource + private let font: TerminalFont + private let units: [UInt16] + private let cellCount: Int + private var cellCol: [Int] + private var cellSpan: [Int] + private var cellStart: [Int] + private var cellEnd: [Int] + private var cellForeground: [TerminalColor] + private var cellBackground: [TerminalColor] + private var cellFlags: [Int] + private var cellGlyph: [TerminalCustomGlyph?] + private var groups: [Group] = [] + /// The row's painted extent, relative to its top edge. Marks stacked above or below a + /// letter and some fallback glyphs ink outside the row; the view repaints that area with it. + private(set) var inkTop: CGFloat = 0 + private(set) var inkBottom: CGFloat + + private struct GlyphRun { + let font: CTFont + var glyphs: [CGGlyph] + /// Relative to the group's pen origin, y up from the baseline. + var positions: [CGPoint] + } + + private struct Group { + let firstCell: Int + let lastCell: Int + let col: Int + let offset: CGFloat + let scale: CGFloat + let runs: [GlyphRun] + } + + private struct ShapedGlyph { + let font: CTFont + let glyph: CGGlyph + let position: CGPoint + let advance: CGFloat + let cell: Int + } + + init(source: TerminalRowSource, font: TerminalFont, customGlyphs: TerminalCustomGlyphs) { + self.source = source + self.font = font + units = Array(source.text.utf16) + cellCount = source.cells.count / 2 + cellCol = Array(repeating: 0, count: cellCount) + cellSpan = Array(repeating: 1, count: cellCount) + cellStart = Array(repeating: 0, count: cellCount) + cellEnd = Array(repeating: 0, count: cellCount) + cellForeground = Array(repeating: TerminalColor(0), count: cellCount) + cellBackground = Array(repeating: TerminalColor(0), count: cellCount) + cellFlags = Array(repeating: 0, count: cellCount) + cellGlyph = Array(repeating: nil, count: cellCount) + inkBottom = font.cellHeight + readCells(customGlyphs: customGlyphs) + shapeRuns() + } + + var accessibilityText: String { + var text = source.text + while text.last == " " { text.removeLast() } + return text + } + + private func readCells(customGlyphs: TerminalCustomGlyphs) { + var offset = 0 + var col = 0 + var styleIndex = 0 + var styleEndCol = 0 + for cell in 0..= styleEndCol && styleIndex + 3 < source.styles.count { + styleEndCol += Int(source.styles[styleIndex]) + styleIndex += 4 + } + let style = styleIndex - 4 + if style >= 0 { + cellForeground[cell] = TerminalColor(source.styles[style + 1]) + cellBackground[cell] = TerminalColor(source.styles[style + 2]) + cellFlags[cell] = Int(source.styles[style + 3]) + } + cellCol[cell] = col + cellSpan[cell] = span + cellStart[cell] = offset + cellEnd[cell] = min(units.count, offset + length) + cellGlyph[cell] = customGlyphs[text(from: cellStart[cell], to: cellEnd[cell])] + offset = cellEnd[cell] + col += span + } + } + + private func text(from start: Int, to end: Int) -> String { + String(utf16CodeUnits: Array(units[start.. Bool { + cellGlyph[cell] == nil && cellGlyph[other] == nil + && TerminalFont.face(flags: cellFlags[cell]) == TerminalFont.face(flags: cellFlags[other]) + } + + private func shapeRuns() { + var cell = 0 + while cell < cellCount { + if cellGlyph[cell] != nil { + cell += 1 + continue + } + var last = cell + while last + 1 < cellCount && shapesWith(cell, last + 1) { last += 1 } + shapeRun(first: cell, last: last) + cell = last + 1 + } + } + + private func shapeRun(first: Int, last: Int) { + let start = cellStart[first] + let end = cellEnd[last] + guard end > start else { return } + var cellOfUnit = Array(repeating: first, count: end - start) + for cell in first...last where cellEnd[cell] > cellStart[cell] { + for unit in cellStart[cell].. 0 else { continue } + let runAttributes = CTRunGetAttributes(run) as NSDictionary + let runFont = runAttributes[kCTFontAttributeName as String].map { $0 as! CTFont } + ?? font.font(flags: cellFlags[first]) + var glyphs = Array(repeating: CGGlyph(0), count: count) + var positions = Array(repeating: CGPoint.zero, count: count) + var advances = Array(repeating: CGSize.zero, count: count) + var indices = Array(repeating: CFIndex(0), count: count) + let all = CFRange(location: 0, length: 0) + CTRunGetGlyphs(run, all, &glyphs) + CTRunGetPositions(run, all, &positions) + CTRunGetAdvances(run, all, &advances) + CTRunGetStringIndices(run, all, &indices) + for index in 0.. first { + for cell in (first + 1)...last where advance[cell - first] == 0 { + startsCluster[cell - first] = false + } + } + + var groupFirst = first + for cell in first...last { + let closesGroup = cell == last || startsCluster[cell + 1 - first] + guard closesGroup else { continue } + addGroup(first: groupFirst, last: cell, glyphs: shaped.filter { $0.cell >= groupFirst && $0.cell <= cell }) + groupFirst = cell + 1 + } + } + + private func addGroup(first: Int, last: Int, glyphs: [ShapedGlyph]) { + let isBlank = (cellStart[first].. slot + overflowTolerance ? slot / advance : 1 + let offset = scale < 1 ? 0 : (slot - advance) / 2 + var runs: [GlyphRun] = [] + for glyph in glyphs { + let position = CGPoint(x: glyph.position.x - origin, y: glyph.position.y) + if let lastRun = runs.last, CFEqual(lastRun.font, glyph.font) { + runs[runs.count - 1].glyphs.append(glyph.glyph) + runs[runs.count - 1].positions.append(position) + } else { + runs.append(GlyphRun(font: glyph.font, glyphs: [glyph.glyph], positions: [position])) + } + } + groups.append(Group( + firstCell: first, lastCell: last, col: cellCol[first], offset: offset, scale: scale, runs: runs)) + extendInk(runs: runs, scale: scale) + } + + /// Grows the row's ink extent to cover the glyphs as `draw` places them, padded by a device pixel + /// for antialiasing. + private func extendInk(runs: [GlyphRun], scale: CGFloat) { + let centerY = font.cellHeight / 2 + let baselineInTextSpace = font.cellHeight / 2 - font.baseline + let pixel = 1 / font.displayScale + for run in runs { + var bounds = Array(repeating: CGRect.zero, count: run.glyphs.count) + CTFontGetBoundingRectsForGlyphs(run.font, .horizontal, run.glyphs, &bounds, run.glyphs.count) + for (glyph, position) in zip(bounds, run.positions) where !glyph.isEmpty { + let top = centerY - (position.y + baselineInTextSpace + glyph.maxY) * scale - pixel + let bottom = centerY - (position.y + baselineInTextSpace + glyph.minY) * scale + pixel + inkTop = min(inkTop, (top * font.displayScale).rounded(.down) / font.displayScale) + inkBottom = max(inkBottom, (bottom * font.displayScale).rounded(.up) / font.displayScale) + } + } + } + + private func isSelected(_ cell: Int, row: Int, selection: TerminalSelection?) -> Bool { + selection?.contains(row: row, col: cellCol[cell], span: cellSpan[cell]) ?? false + } + + private func foreground(_ cell: Int, row: Int, selection: TerminalSelection?, paint: TerminalRowPaint) -> TerminalColor { + isSelected(cell, row: row, selection: selection) ? paint.selectionForeground : cellForeground[cell] + } + + /// Draws the row with its top edge at `top`, selected cells in selection colors. + func draw(in context: CGContext, row: Int, top: CGFloat, selection: TerminalSelection?, paint: TerminalRowPaint) { + let cellWidth = font.cellWidth + let cellHeight = font.cellHeight + for cell in 0.. group.firstCell || last < group.lastCell { + let left = first > group.firstCell ? CGFloat(cellCol[first]) * cellWidth : -clipUnbounded + let right = last < group.lastCell ? CGFloat(cellCol[last] + cellSpan[last]) * cellWidth : clipUnbounded + context.clip(to: CGRect(x: left, y: -clipUnbounded, width: right - left, height: 2 * clipUnbounded)) + } + context.setFillColor(color.cgColor()) + context.translateBy(x: CGFloat(group.col) * cellWidth + group.offset, y: centerY) + context.scaleBy(x: group.scale, y: -group.scale) + context.textMatrix = .identity + for run in group.runs { + let positions = run.positions.map { CGPoint(x: $0.x, y: $0.y + baselineInTextSpace) } + CTFontDrawGlyphs(run.font, run.glyphs, positions, run.glyphs.count, context) + } + context.restoreGState() + first = last + 1 + } + } + + let baseline = top + font.baseline + for cell in 0.. { hasNotifiedMeasuredSizeRef.current = false; resetTerminal(mountInputRef.current.initialSnapshot); - callbacksRef.current.onRendererReadyChange?.({ streamKey, isReady: true }); + emitMeasuredSize({ source: "measure" }); return () => { callbacksRef.current.onRendererReadyChange?.({ streamKey, isReady: false }); clearSelectionLongPressTimeout(); @@ -604,7 +613,7 @@ function NativeTerminalEmulator({ terminalRef.current?.dispose(); terminalRef.current = null; }; - }, [clearSelectionLongPressTimeout, resetTerminal, streamKey]); + }, [clearSelectionLongPressTimeout, emitMeasuredSize, resetTerminal, streamKey]); useEffect(() => { if (focusRequestToken <= 0) return; diff --git a/packages/app/src/terminal/native-renderer/colors.ts b/packages/app/src/terminal/native-renderer/colors.ts index dfebe4eb4d..03452cd30b 100644 --- a/packages/app/src/terminal/native-renderer/colors.ts +++ b/packages/app/src/terminal/native-renderer/colors.ts @@ -1,6 +1,6 @@ import type { ITheme } from "@xterm/xterm"; -import type { TextStyle } from "react-native"; import type { TerminalCell } from "@getpaseo/protocol/messages"; +import { PASEO_TERMINAL_STYLE_FLAGS } from "../../../modules/paseo-terminal/contract"; import { darkTheme } from "@/styles/theme"; import { toXtermTheme } from "@/utils/to-xterm-theme"; @@ -29,77 +29,115 @@ const DEFAULT_COLOR_MODE = 0; const ANSI_COLOR_MODE = 1; const INDEXED_COLOR_MODE = 2; const RGB_COLOR_MODE = 3; -const FLAG_BOLD = 1 << 0; -const FLAG_ITALIC = 1 << 1; -const FLAG_UNDERLINE = 1 << 2; -const FLAG_DIM = 1 << 3; -const FLAG_INVERSE = 1 << 4; -const FLAG_STRIKETHROUGH = 1 << 5; +const OPAQUE_BLACK = 0xff000000; +const DIM_ALPHA = 0.65; -interface ResolvedCellStyle { - key: string; - style: TextStyle; - foregroundColor: string; -} - -interface ResolvedCellColors { - foreground: string; - background: string; - paintsBackground: boolean; +/** Colors are unsigned ARGB. A background of 0 is the terminal background. */ +export interface ResolvedCellStyle { + foreground: number; + background: number; + flags: number; } export interface TerminalCellStyleResolver { - readonly themeKey: string; - readonly backgroundColor: string; - readonly cursorColor: string; + readonly backgroundColor: number; + readonly cursorColor: number; resolve(cell: TerminalCell): ResolvedCellStyle; } export const DEFAULT_TERMINAL_THEME = toXtermTheme(darkTheme.colors.terminal); export const NATIVE_TERMINAL_SELECTION_COLORS = { - background: "#ffff00", - foreground: "#000000", + background: 0xffffff00, + foreground: 0xff000000, } as const; -function build256Palette(theme: ITheme): string[] { - const palette = Array.from({ length: 256 }, () => ""); +function build256Palette(theme: ITheme): number[] { + const palette = Array.from({ length: 256 }, () => OPAQUE_BLACK); for (let index = 0; index < ANSI_THEME_KEYS.length; index += 1) { const key = ANSI_THEME_KEYS[index]; - palette[index] = theme[key] ?? DEFAULT_TERMINAL_THEME[key] ?? "#000000"; + palette[index] = parseTerminalColor(theme[key] ?? DEFAULT_TERMINAL_THEME[key], OPAQUE_BLACK); } for (let red = 0; red < CUBE_LEVELS.length; red += 1) { for (let green = 0; green < CUBE_LEVELS.length; green += 1) { for (let blue = 0; blue < CUBE_LEVELS.length; blue += 1) { const index = 16 + red * 36 + green * 6 + blue; - palette[index] = `rgb(${CUBE_LEVELS[red]}, ${CUBE_LEVELS[green]}, ${CUBE_LEVELS[blue]})`; + palette[index] = rgbColor( + (CUBE_LEVELS[red] << 16) | (CUBE_LEVELS[green] << 8) | CUBE_LEVELS[blue], + ); } } } for (let index = 0; index < 24; index += 1) { const value = 8 + index * 10; - palette[232 + index] = `rgb(${value}, ${value}, ${value})`; + palette[232 + index] = rgbColor((value << 16) | (value << 8) | value); } return palette; } -function rgbColor(value: number): string { - const red = (value >> 16) & 0xff; - const green = (value >> 8) & 0xff; - const blue = value & 0xff; - return `rgb(${red}, ${green}, ${blue})`; +function rgbColor(value: number): number { + return (OPAQUE_BLACK | (value & 0xffffff)) >>> 0; +} + +function argb(input: { alpha: number; red: number; green: number; blue: number }): number { + const channel = (value: number) => Math.max(0, Math.min(255, Math.round(value))); + return ( + ((channel(input.alpha) << 24) | + (channel(input.red) << 16) | + (channel(input.green) << 8) | + channel(input.blue)) >>> + 0 + ); +} + +/** Parses the CSS color forms terminal themes use: `#rgb`, `#rrggbb`, `#rrggbbaa`, `rgb()`, `rgba()`. */ +export function parseTerminalColor(value: string | undefined, fallback: number): number { + if (!value) return fallback; + const color = value.trim().toLowerCase(); + const hex = /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/.exec(color)?.[1]; + if (hex) { + const digits = hex.length === 3 ? [...hex].map((digit) => digit + digit).join("") : hex; + const channel = (offset: number) => Number.parseInt(digits.slice(offset, offset + 2), 16); + return argb({ + alpha: digits.length === 8 ? channel(6) : 255, + red: channel(0), + green: channel(2), + blue: channel(4), + }); + } + const functional = /^rgba?\(([^)]*)\)$/.exec(color)?.[1]; + if (functional) { + const parts = functional + .split(/[\s,/]+/) + .filter(Boolean) + .map(Number); + if (parts.length >= 3 && parts.every((part) => Number.isFinite(part))) { + return argb({ + alpha: parts.length >= 4 ? parts[3] * 255 : 255, + red: parts[0], + green: parts[1], + blue: parts[2], + }); + } + } + return fallback; +} + +function withDimAlpha(color: number): number { + const alpha = (color >>> 24) & 0xff; + return (((Math.round(alpha * DIM_ALPHA) & 0xff) << 24) | (color & 0xffffff)) >>> 0; } function resolveColor(input: { value: number | undefined; mode: number | undefined; - fallback: string; - palette: string[]; -}): string { + fallback: number; + palette: number[]; +}): number { const mode = input.mode ?? DEFAULT_COLOR_MODE; const value = input.value ?? 0; @@ -114,131 +152,50 @@ function resolveColor(input: { function cellFlags(cell: TerminalCell): number { let flags = 0; - if (cell.bold) flags |= FLAG_BOLD; - if (cell.italic) flags |= FLAG_ITALIC; - if (cell.underline) flags |= FLAG_UNDERLINE; - if (cell.dim) flags |= FLAG_DIM; - if (cell.inverse) flags |= FLAG_INVERSE; - if (cell.strikethrough) flags |= FLAG_STRIKETHROUGH; + if (cell.bold) flags |= PASEO_TERMINAL_STYLE_FLAGS.bold; + if (cell.italic) flags |= PASEO_TERMINAL_STYLE_FLAGS.italic; + if (cell.underline) flags |= PASEO_TERMINAL_STYLE_FLAGS.underline; + if (cell.strikethrough) flags |= PASEO_TERMINAL_STYLE_FLAGS.strikethrough; + if (cell.dim) flags |= PASEO_TERMINAL_STYLE_FLAGS.dim; return flags; } -function resolveCellColors(input: { - cell: TerminalCell; - palette: string[]; - foregroundColor: string; - backgroundColor: string; -}): ResolvedCellColors { - const foreground = resolveColor({ - value: input.cell.fg, - mode: input.cell.fgMode, - fallback: input.foregroundColor, - palette: input.palette, - }); - const background = resolveColor({ - value: input.cell.bg, - mode: input.cell.bgMode, - fallback: input.backgroundColor, - palette: input.palette, - }); - - const resolvedForeground = input.cell.inverse ? background : foreground; - const resolvedBackground = input.cell.inverse ? foreground : background; - return { - foreground: resolvedForeground, - background: resolvedBackground, - paintsBackground: resolvedBackground !== input.backgroundColor, - }; -} - -function textDecorationLine(flags: number): TextStyle["textDecorationLine"] | undefined { - const underline = (flags & FLAG_UNDERLINE) !== 0; - const strikethrough = (flags & FLAG_STRIKETHROUGH) !== 0; - if (underline && strikethrough) return "underline line-through"; - if (underline) return "underline"; - if (strikethrough) return "line-through"; - return undefined; -} - -function createTextStyle(input: { - foreground: string; - background: string; - paintsBackground: boolean; - flags: number; -}): TextStyle { - const style: TextStyle = { - color: input.foreground, - }; - - if (input.paintsBackground) { - style.backgroundColor = input.background; - } - if ((input.flags & FLAG_BOLD) !== 0) { - style.fontWeight = "700"; - } - if ((input.flags & FLAG_ITALIC) !== 0) { - style.fontStyle = "italic"; - } - if ((input.flags & FLAG_DIM) !== 0) { - style.opacity = 0.65; - } - - const decorationLine = textDecorationLine(input.flags); - if (decorationLine) { - style.textDecorationLine = decorationLine; - style.textDecorationColor = input.foreground; - } - - return style; -} - -function buildStyleKey(input: { - foreground: string; - background: string; - paintsBackground: boolean; - flags: number; -}): string { - const background = input.paintsBackground ? input.background : "default"; - return `${input.foreground}|${background}|${input.flags}`; -} - -function buildThemeKey(theme: ITheme): string { - return JSON.stringify(theme); -} - export function createTerminalCellStyleResolver(theme: ITheme): TerminalCellStyleResolver { const palette = build256Palette(theme); - const foregroundColor = theme.foreground ?? DEFAULT_TERMINAL_THEME.foreground ?? "#fafafa"; - const backgroundColor = theme.background ?? DEFAULT_TERMINAL_THEME.background ?? "#181B1A"; - const cursorColor = theme.cursor ?? foregroundColor; - const styleCache = new Map(); + const foregroundColor = parseTerminalColor( + theme.foreground ?? DEFAULT_TERMINAL_THEME.foreground, + 0xfffafafa, + ); + const backgroundColor = parseTerminalColor( + theme.background ?? DEFAULT_TERMINAL_THEME.background, + 0xff181b1a, + ); + const cursorColor = parseTerminalColor(theme.cursor, foregroundColor); return { - themeKey: buildThemeKey(theme), backgroundColor, cursorColor, resolve(cell: TerminalCell): ResolvedCellStyle { + const foreground = resolveColor({ + value: cell.fg, + mode: cell.fgMode, + fallback: foregroundColor, + palette, + }); + const background = resolveColor({ + value: cell.bg, + mode: cell.bgMode, + fallback: backgroundColor, + palette, + }); + const resolvedForeground = cell.inverse ? background : foreground; + const resolvedBackground = cell.inverse ? foreground : background; const flags = cellFlags(cell); - const colors = resolveCellColors({ cell, palette, foregroundColor, backgroundColor }); - const key = buildStyleKey({ - foreground: colors.foreground, - background: colors.background, - paintsBackground: colors.paintsBackground, + return { + foreground: cell.dim ? withDimAlpha(resolvedForeground) : resolvedForeground, + background: resolvedBackground === backgroundColor ? 0 : resolvedBackground, flags, - }); - const cachedStyle = styleCache.get(key); - if (cachedStyle) { - return { key, style: cachedStyle, foregroundColor: colors.foreground }; - } - - const style = createTextStyle({ - foreground: colors.foreground, - background: colors.background, - paintsBackground: colors.paintsBackground, - flags, - }); - styleCache.set(key, style); - return { key, style, foregroundColor: colors.foreground }; + }; }, }; } diff --git a/packages/app/src/terminal/native-renderer/terminal-frame.test.ts b/packages/app/src/terminal/native-renderer/terminal-frame.test.ts new file mode 100644 index 0000000000..129a7d9082 --- /dev/null +++ b/packages/app/src/terminal/native-renderer/terminal-frame.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, test } from "vitest"; + +import graphemeRows from "../../../modules/paseo-terminal/android/src/androidTest/assets/grapheme-rows.json"; +import { PASEO_TERMINAL_STYLE_FLAGS } from "../../../modules/paseo-terminal/contract"; +import { encodeTerminalFrame } from "../../../modules/paseo-terminal/frame-encoding"; +import { + createTerminalCellStyleResolver, + DEFAULT_TERMINAL_THEME, + parseTerminalColor, +} from "./colors"; +import { createNativeHeadlessTerminal } from "./headless-terminal-state"; +import { buildTerminalFrame } from "./terminal-frame"; + +const resolver = createTerminalCellStyleResolver(DEFAULT_TERMINAL_THEME); +const RED = parseTerminalColor(DEFAULT_TERMINAL_THEME.red, 0); +const FOREGROUND = parseTerminalColor(DEFAULT_TERMINAL_THEME.foreground, 0); + +async function frameFor(output: string, options: { cols?: number; rows?: number } = {}) { + const terminal = createNativeHeadlessTerminal({ + cols: options.cols ?? 20, + rows: options.rows ?? 4, + }); + await terminal.write(output); + const state = terminal.getViewportState({ firstRow: 0, rowCount: options.rows ?? 4 }); + terminal.dispose(); + return { + state, + frame: buildTerminalFrame({ state, columns: state.cols, resolver, selection: null }), + }; +} + +function cellLayout(row: { text: string; cells: number[] }) { + const layout: Array<{ text: string; col: number; span: number }> = []; + let offset = 0; + let col = 0; + for (let index = 0; index < row.cells.length; index += 2) { + const [length, span] = [row.cells[index], row.cells[index + 1]]; + layout.push({ text: row.text.slice(offset, offset + length), col, span }); + offset += length; + col += span; + } + return layout; +} + +/** Indexes of the cells that start a grapheme cluster. */ +function clusterStartCells(row: { text: string; cells: number[] }, segmenter: Intl.Segmenter) { + const boundaries = new Set(Array.from(segmenter.segment(row.text), (segment) => segment.index)); + const starts: number[] = []; + let offset = 0; + for (let cell = 0; cell < row.cells.length / 2; cell += 1) { + if (boundaries.has(offset)) starts.push(cell); + offset += row.cells[cell * 2]; + } + return starts; +} + +describe("native terminal frame", () => { + test("keeps each Hangul syllable on xterm's two-column allocation", async () => { + const { frame } = await frameFor("> 안녕하세요"); + + expect(cellLayout(frame.rows[0]).slice(0, 7)).toEqual([ + { text: ">", col: 0, span: 1 }, + { text: " ", col: 1, span: 1 }, + { text: "안", col: 2, span: 2 }, + { text: "녕", col: 4, span: 2 }, + { text: "하", col: 6, span: 2 }, + { text: "세", col: 8, span: 2 }, + { text: "요", col: 10, span: 2 }, + ]); + expect(frame.cursor).toEqual({ row: 0, col: 12 }); + }); + + test("covers every column once, including trailing blanks", async () => { + const corpus = ["안녕하세요NEXT", "日本語 中文", "áé é x̸", "क्ष कि ❤️ 👩‍💻 🇰🇷"]; + const { frame } = await frameFor(corpus.join("\r\n"), { rows: 4, cols: 24 }); + + for (const row of frame.rows) { + const spans = row.cells.filter((_, index) => index % 2 === 1); + const styleColumns = row.styles.filter((_, index) => index % 4 === 0); + expect(spans.reduce((total, span) => total + span, 0)).toBe(24); + expect(styleColumns.reduce((total, span) => total + span, 0)).toBe(24); + expect(row.cells.filter((_, index) => index % 2 === 0).reduce((a, b) => a + b, 0)).toBe( + row.text.length, + ); + } + }); + + 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([ + { 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 }, + ]); + }); + + test("splits style spans at SGR changes without moving cells", async () => { + const { frame } = await frameFor("안녕\x1b[31mNEXT\x1b[0m\x1b[1;4m!\x1b[0m"); + const row = frame.rows[0]; + + expect( + cellLayout(row) + .slice(0, 7) + .map((cell) => cell.col), + ).toEqual([0, 2, 4, 5, 6, 7, 8]); + expect(row.styles.slice(0, 12)).toEqual([ + 4, + FOREGROUND, + 0, + 0, + 4, + RED, + 0, + 0, + 1, + FOREGROUND, + 0, + PASEO_TERMINAL_STYLE_FLAGS.bold | PASEO_TERMINAL_STYLE_FLAGS.underline, + ]); + }); + + test("resolves inverse and dim into paint colors", async () => { + const { frame } = await frameFor("\x1b[7mI\x1b[0m\x1b[2mD\x1b[0m"); + const background = parseTerminalColor(DEFAULT_TERMINAL_THEME.background, 0); + + expect(frame.rows[0].styles.slice(0, 8)).toEqual([ + 1, + background, + FOREGROUND, + 0, + 1, + (((Math.round(255 * 0.65) & 0xff) << 24) | (FOREGROUND & 0xffffff)) >>> 0, + 0, + PASEO_TERMINAL_STYLE_FLAGS.dim, + ]); + }); + + test("rows follow content and style but not cursor or selection", async () => { + const plain = await frameFor("same\r\nrow"); + const moved = await frameFor("same\r\nrow\x1b[1;1H"); + const recolored = await frameFor("\x1b[31msame\x1b[0m\r\nrow"); + + expect(moved.frame.rows[0]).toEqual(plain.frame.rows[0]); + expect(moved.frame.cursor).not.toEqual(plain.frame.cursor); + expect(recolored.frame.rows[0]).not.toEqual(plain.frame.rows[0]); + + const selected = buildTerminalFrame({ + state: plain.state, + columns: plain.state.cols, + resolver, + selection: { + start: { row: 0, col: 1 }, + end: { row: 1, col: 0 }, + coordinateEpoch: 0, + }, + }); + expect(selected.rows).toEqual(plain.frame.rows); + expect(selected.selection).toEqual({ startRow: 0, startCol: 1, endRow: 1, endCol: 0 }); + }); + + test("clips the frame to the visible columns", async () => { + const { state } = await frameFor("0123456789abcdefghij"); + const frame = buildTerminalFrame({ state, columns: 8, resolver, selection: null }); + + expect(frame.columns).toBe(8); + expect(frame.rows[0].text).toBe("01234567"); + }); + + // The Android instrumented test draws these rows and compares them with each cluster drawn on its + // own. This keeps its input equal to what xterm produces, and its clusters equal to UAX #29 + // grapheme clusters. `contrastStarts` is a wrong clustering the comparison must reject. + test("Android cluster fixture matches xterm cells and grapheme clusters", async () => { + const segmenter = new Intl.Segmenter("und", { granularity: "grapheme" }); + const actual = await Promise.all( + graphemeRows.map(async ({ output, contrastStarts }) => { + const { frame } = await frameFor(output, { cols: 8, rows: 1 }); + const row = frame.rows[0]; + const [encoded] = encodeTerminalFrame({ ...frame, rows: [row] }).rows; + return { + output, + row: encoded, + clusterStarts: clusterStartCells(row, segmenter), + contrastStarts, + }; + }), + ); + + expect(actual).toEqual(graphemeRows); + }); +}); diff --git a/packages/app/src/terminal/native-renderer/terminal-frame.ts b/packages/app/src/terminal/native-renderer/terminal-frame.ts new file mode 100644 index 0000000000..584e09a274 --- /dev/null +++ b/packages/app/src/terminal/native-renderer/terminal-frame.ts @@ -0,0 +1,84 @@ +import type { + PaseoTerminalFrame, + PaseoTerminalRow, + PaseoTerminalSelection, +} from "../../../modules/paseo-terminal/contract"; +import type { TerminalCellStyleResolver } from "./colors"; +import type { NativeTerminalCell, TerminalViewportState } from "./headless-terminal-state"; +import type { TerminalSelectionRange } from "./terminal-selection"; + +/** + * Converts the xterm viewport into the native renderer's frame. Every cell keeps its own text and + * xterm's column span; continuation cells of wide characters (width 0) belong to the cell before + * them. Nothing here measures or joins text: the renderer shapes rows and places the result on + * these columns. + */ +export function buildTerminalFrame(input: { + state: TerminalViewportState; + columns: number; + resolver: TerminalCellStyleResolver; + selection: TerminalSelectionRange | null; +}): PaseoTerminalFrame { + const grid = input.state.grid as NativeTerminalCell[][]; + return { + columns: input.columns, + rows: grid.map((cells) => + buildRow({ cells, columns: input.columns, resolver: input.resolver }), + ), + cursor: input.state.cursor.hidden + ? null + : { row: input.state.cursor.row, col: input.state.cursor.col }, + selection: input.selection + ? toFrameSelection({ range: input.selection, firstRow: input.state.firstRow }) + : null, + }; +} + +function buildRow(input: { + cells: NativeTerminalCell[]; + columns: number; + resolver: TerminalCellStyleResolver; +}): PaseoTerminalRow { + let text = ""; + const cells: number[] = []; + const styles: number[] = []; + const lastColumn = Math.min(input.columns, input.cells.length); + + for (let col = 0; col < lastColumn; col += 1) { + const cell = input.cells[col]; + const span = cell.width ?? 1; + if (span <= 0) { + continue; + } + const cellText = cell.char || " "; + text += cellText; + cells.push(cellText.length, span); + + const style = input.resolver.resolve(cell); + const previous = styles.length - 4; + if ( + previous >= 0 && + styles[previous + 1] === style.foreground && + styles[previous + 2] === style.background && + styles[previous + 3] === style.flags + ) { + styles[previous] += span; + } else { + styles.push(span, style.foreground, style.background, style.flags); + } + } + + return { text, cells, styles }; +} + +function toFrameSelection(input: { + range: TerminalSelectionRange; + firstRow: number; +}): PaseoTerminalSelection { + return { + startRow: input.range.start.row - input.firstRow, + startCol: input.range.start.col, + endRow: input.range.end.row - input.firstRow, + endCol: input.range.end.col, + }; +} diff --git a/packages/app/src/terminal/native-renderer/terminal-grid-metrics.test.ts b/packages/app/src/terminal/native-renderer/terminal-grid-metrics.test.ts index 0c4d7bc25f..c0fef99104 100644 --- a/packages/app/src/terminal/native-renderer/terminal-grid-metrics.test.ts +++ b/packages/app/src/terminal/native-renderer/terminal-grid-metrics.test.ts @@ -1,68 +1,8 @@ import { describe, expect, it } from "vitest"; -import { - resolveTerminalCustomGlyphCellTransform, - resolveTerminalGridMetricsMeasurement, - resolveMeasuredTerminalCellMetrics, - resolveTerminalCursorOffset, -} from "./terminal-grid-metrics"; +import { resolveTerminalGridMetricsMeasurement } from "./terminal-grid-metrics"; describe("native terminal grid metrics", () => { - it("places each normalized custom glyph in run viewport coordinates", () => { - expect( - resolveTerminalCustomGlyphCellTransform({ - cellOffset: 57, - cellWidth: 7.4, - cellHeight: 16.2, - }), - ).toBe("matrix(7.4 0 0 16.2 421.8 0)"); - }); - - it("preserves fractional measured cell width for horizontal layout", () => { - expect( - resolveMeasuredTerminalCellMetrics({ - measuredTextWidth: 72.246, - measuredTextHeight: 16.2, - measureTextLength: 10, - roundToNearestPixel: (value) => value, - }), - ).toEqual({ - cellWidth: 7.2246, - cellHeight: 16.2, - }); - }); - - it.each([1, 2, 2.75, 3, 3.5])( - "aligns cells to the physical pixel grid at density %s", - (density) => { - const metrics = resolveMeasuredTerminalCellMetrics({ - measuredTextWidth: 72.246, - measuredTextHeight: 16.2, - measureTextLength: 10, - roundToNearestPixel: (value) => Math.round(value * density) / density, - }); - - expect(metrics.cellWidth * density).toBeCloseTo(Math.round(metrics.cellWidth * density), 10); - expect(metrics.cellHeight * density).toBeCloseTo( - Math.round(metrics.cellHeight * density), - 10, - ); - }, - ); - - it("positions the cursor with the measured fractional cell width", () => { - expect( - resolveTerminalCursorOffset({ - cursorCol: 80, - cursorRow: 3, - metrics: { cellWidth: 7.2246, cellHeight: 16 }, - }), - ).toEqual({ - x: 577.968, - y: 48, - }); - }); - it("emits measured metrics once until the measurement changes", () => { const measured = { cellWidth: 7.2, cellHeight: 16 }; diff --git a/packages/app/src/terminal/native-renderer/terminal-grid-metrics.ts b/packages/app/src/terminal/native-renderer/terminal-grid-metrics.ts index cb14111bf2..806c149c60 100644 --- a/packages/app/src/terminal/native-renderer/terminal-grid-metrics.ts +++ b/packages/app/src/terminal/native-renderer/terminal-grid-metrics.ts @@ -3,47 +3,6 @@ export interface TerminalGridCellMetrics { cellHeight: number; } -export interface TerminalGridCellMetricsInput { - measuredTextWidth: number; - measuredTextHeight: number; - measureTextLength: number; - roundToNearestPixel: (value: number) => number; -} - -export interface TerminalCursorOffsetInput { - cursorCol: number; - cursorRow: number; - metrics: TerminalGridCellMetrics; -} - -export interface TerminalCursorOffset { - x: number; - y: number; -} - -export interface TerminalCustomGlyphCellTransformInput { - cellOffset: number; - cellWidth: number; - cellHeight: number; -} - -export function resolveTerminalCustomGlyphCellTransform( - input: TerminalCustomGlyphCellTransformInput, -): string { - const translateX = input.cellOffset * input.cellWidth; - return `matrix(${input.cellWidth} 0 0 ${input.cellHeight} ${translateX} 0)`; -} - -export function resolveMeasuredTerminalCellMetrics( - input: TerminalGridCellMetricsInput, -): TerminalGridCellMetrics { - const textLength = Math.max(1, input.measureTextLength); - return { - cellWidth: snapCellMetric(input.measuredTextWidth / textLength, input.roundToNearestPixel), - cellHeight: snapCellMetric(input.measuredTextHeight, input.roundToNearestPixel), - }; -} - export function resolveTerminalGridMetricsMeasurement( previous: TerminalGridCellMetrics | null, next: TerminalGridCellMetrics, @@ -53,16 +12,3 @@ export function resolveTerminalGridMetricsMeasurement( } return next; } - -export function resolveTerminalCursorOffset( - input: TerminalCursorOffsetInput, -): TerminalCursorOffset { - return { - x: input.cursorCol * input.metrics.cellWidth, - y: input.cursorRow * input.metrics.cellHeight, - }; -} - -function snapCellMetric(value: number, roundToNearestPixel: (value: number) => number): number { - return Math.max(1, roundToNearestPixel(value)); -} diff --git a/packages/app/src/terminal/native-renderer/terminal-grid-view.native.tsx b/packages/app/src/terminal/native-renderer/terminal-grid-view.native.tsx index 04f82c1011..d88daf0679 100644 --- a/packages/app/src/terminal/native-renderer/terminal-grid-view.native.tsx +++ b/packages/app/src/terminal/native-renderer/terminal-grid-view.native.tsx @@ -1,71 +1,34 @@ -import { memo, useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { - PixelRatio, - Text, + useWindowDimensions, View, type LayoutChangeEvent, type StyleProp, - type TextStyle, type ViewStyle, } from "react-native"; import { StyleSheet } from "react-native-unistyles"; -import Svg, { G, Path } from "react-native-svg"; import type { ITheme } from "@xterm/xterm"; +import { + PaseoTerminalView, + type PaseoTerminalCellMetrics, + type PaseoTerminalTheme, +} from "../../../modules/paseo-terminal"; import { createTerminalCellStyleResolver, DEFAULT_TERMINAL_THEME, NATIVE_TERMINAL_SELECTION_COLORS, } from "./colors"; -import type { TerminalGlyphRect } from "./terminal-custom-glyph"; import { resolveNativeTerminalFontFamily } from "./font.native"; import type { TerminalViewportState } from "./headless-terminal-state"; import { - resolveMeasuredTerminalCellMetrics, - resolveTerminalCustomGlyphCellTransform, resolveTerminalGridMetricsMeasurement, - resolveTerminalCursorOffset, type TerminalGridCellMetrics, } from "./terminal-grid-metrics"; -import { - buildRows, - type TerminalCustomGlyphCell, - type TerminalCustomGlyphRun, - type TerminalRowModel, - type TerminalRun, -} from "./terminal-row-model"; +import { buildTerminalFrame } from "./terminal-frame"; import type { TerminalSelectionRange } from "./terminal-selection"; -const MEASURE_TEXT = "mmmmmmmmmm"; const DEFAULT_FONT_SIZE = 12; -const INITIAL_CELL_WIDTH_RATIO = 0.62; -const INITIAL_CELL_HEIGHT_RATIO = 1.35; - -interface CellMetrics { - cellWidth: number; - cellHeight: number; -} - -interface TerminalGridViewport { - width: number; - height: number; -} - -interface TerminalGridRowProps { - row: TerminalRowModel; - cellWidth: number; - cellHeight: number; - fontFamily?: string; - fontSize: number; - styleEpoch: string; -} - -interface TerminalGridRunProps { - run: TerminalRun; - cellWidth: number; - cellHeight: number; - textStyle: StyleProp; -} export interface TerminalGridViewProps { state: TerminalViewportState; @@ -77,221 +40,20 @@ export interface TerminalGridViewProps { onCellMetricsChange?: (metrics: TerminalGridCellMetrics) => void; } -function estimateCellMetrics(fontSize: number): CellMetrics { - return { - cellWidth: snapPixel(fontSize * INITIAL_CELL_WIDTH_RATIO), - cellHeight: snapPixel(fontSize * INITIAL_CELL_HEIGHT_RATIO), - }; -} - -function snapPixel(value: number): number { - return Math.max(1, PixelRatio.roundToNearestPixel(value)); -} - function resolveVisibleCols(input: { - viewportWidth: number; - cellWidth: number; + viewportWidth: number | null; + cellWidth: number | null; gridCols: number; }): number { + if (input.viewportWidth === null || input.cellWidth === null) { + return input.gridCols; + } if (input.viewportWidth <= 0 || input.cellWidth <= 0 || input.gridCols <= 0) { return 0; } return Math.min(input.gridCols, Math.max(1, Math.floor(input.viewportWidth / input.cellWidth))); } -function TerminalGridRun({ run, cellWidth, cellHeight, textStyle }: TerminalGridRunProps) { - const runStyle = useMemo>( - () => [ - styles.run, - { - backgroundColor: run.style.backgroundColor, - height: cellHeight, - width: run.cellCount * cellWidth, - }, - ], - [cellHeight, cellWidth, run.cellCount, run.style.backgroundColor], - ); - const runTextStyle = useMemo>( - () => [textStyle, run.style], - [run.style, textStyle], - ); - - return ( - - {run.renderKind === "custom-glyph" ? ( - - ) : ( - - {run.text} - - )} - - ); -} - -interface TerminalGridCustomGlyphRunProps { - run: TerminalCustomGlyphRun; - cellWidth: number; - cellHeight: number; -} - -interface TerminalGridCustomGlyphRectProps { - cell: TerminalCustomGlyphCell; - rect: TerminalGlyphRect; - cellWidth: number; - cellHeight: number; - foregroundColor: string; -} - -function TerminalGridCustomGlyphRect({ - cell, - rect, - cellWidth, - cellHeight, - foregroundColor, -}: TerminalGridCustomGlyphRectProps) { - const style = useMemo( - () => ({ - position: "absolute", - backgroundColor: foregroundColor, - left: (cell.offset + rect.x) * cellWidth, - top: rect.y * cellHeight, - width: rect.width * cellWidth, - height: rect.height * cellHeight, - }), - [cell.offset, cellHeight, cellWidth, foregroundColor, rect], - ); - - return ; -} - -function TerminalGridCustomGlyphRun({ - run, - cellWidth, - cellHeight, -}: TerminalGridCustomGlyphRunProps) { - const opacity = typeof run.style.opacity === "number" ? run.style.opacity : 1; - - return ( - - {run.glyphs.map((cell) => - cell.glyph.kind === "rects" - ? cell.glyph.rects.map((rect) => ( - - )) - : null, - )} - - - {run.glyphs.map((cell) => - cell.glyph.kind === "path" ? ( - - ) : null, - )} - - - - ); -} - -const MemoTerminalGridRun = memo(TerminalGridRun, (previous, next) => { - return ( - previous.run === next.run && - previous.cellWidth === next.cellWidth && - previous.cellHeight === next.cellHeight && - previous.textStyle === next.textStyle - ); -}); - -function TerminalGridRow({ - row, - cellWidth, - cellHeight, - fontFamily, - fontSize, -}: TerminalGridRowProps) { - const rowStyle = useMemo>( - () => [styles.row, { height: cellHeight }], - [cellHeight], - ); - const textStyle = useMemo>( - () => [ - styles.rowText, - { - height: cellHeight, - lineHeight: cellHeight, - fontFamily, - fontSize, - }, - ], - [cellHeight, fontFamily, fontSize], - ); - const accessibilityLabel = useMemo( - () => - row.runs - .map((run) => run.text) - .join("") - .trimEnd(), - [row.runs], - ); - - return ( - 0} - style={rowStyle} - testID={`terminal-row-${row.index}`} - > - {row.runs.map((run) => ( - - ))} - - ); -} - -const MemoTerminalGridRow = memo(TerminalGridRow, (previous, next) => { - return ( - previous.row.hash === next.row.hash && - previous.cellWidth === next.cellWidth && - previous.cellHeight === next.cellHeight && - previous.fontFamily === next.fontFamily && - previous.fontSize === next.fontSize && - previous.styleEpoch === next.styleEpoch - ); -}); - export function TerminalGridView({ state, xtermTheme = DEFAULT_TERMINAL_THEME, @@ -301,129 +63,61 @@ export function TerminalGridView({ selection = null, onCellMetricsChange, }: TerminalGridViewProps) { - const [metrics, setMetrics] = useState(() => estimateCellMetrics(fontSize)); + const { fontScale } = useWindowDimensions(); + const [cellWidth, setCellWidth] = useState(null); + const [viewportWidth, setViewportWidth] = useState(null); const measuredMetricsRef = useRef(null); - const [viewport, setViewport] = useState(null); const resolvedFontFamily = useMemo( () => resolveNativeTerminalFontFamily(fontFamily), [fontFamily], ); const resolver = useMemo(() => createTerminalCellStyleResolver(xtermTheme), [xtermTheme]); - const visibleCols = useMemo(() => { - const viewportWidth = viewport?.width ?? state.cols * metrics.cellWidth; - return resolveVisibleCols({ - viewportWidth, - cellWidth: metrics.cellWidth, - gridCols: state.cols, - }); - }, [metrics.cellWidth, state.cols, viewport?.width]); - const projectedGrid = useMemo( - () => state.grid.map((row) => row.slice(0, visibleCols)), - [state.grid, visibleCols], + const theme = useMemo( + () => ({ + background: resolver.backgroundColor, + cursor: resolver.cursorColor, + selectionBackground: NATIVE_TERMINAL_SELECTION_COLORS.background, + selectionForeground: NATIVE_TERMINAL_SELECTION_COLORS.foreground, + }), + [resolver], ); - const rows = useMemo( - () => - buildRows({ - grid: projectedGrid, - resolver, - selection: selection - ? { - range: selection, - firstRow: state.firstRow, - backgroundColor: NATIVE_TERMINAL_SELECTION_COLORS.background, - foregroundColor: NATIVE_TERMINAL_SELECTION_COLORS.foreground, - } - : undefined, - }), - [projectedGrid, resolver, selection, state.firstRow], + const columns = resolveVisibleCols({ viewportWidth, cellWidth, gridCols: state.cols }); + const frame = useMemo( + () => buildTerminalFrame({ state, columns, resolver, selection }), + [columns, resolver, selection, state], ); - const containerStyle = useMemo>( - () => [styles.root, { backgroundColor: resolver.backgroundColor }, style], - [resolver.backgroundColor, style], - ); - const gridStyle = useMemo>( - () => [ - styles.grid, - { - width: visibleCols * metrics.cellWidth, - height: state.grid.length * metrics.cellHeight, - }, - ], - [metrics.cellHeight, metrics.cellWidth, state.grid.length, visibleCols], - ); - const measureStyle = useMemo>( - () => [styles.measureText, { fontFamily: resolvedFontFamily, fontSize }], - [resolvedFontFamily, fontSize], - ); - const cursorStyle = useMemo>(() => { - const cursorOffset = resolveTerminalCursorOffset({ - cursorCol: state.cursor.col, - cursorRow: state.cursor.row, - metrics, - }); - return [ - styles.cursor, - { - backgroundColor: resolver.cursorColor, - width: metrics.cellWidth, - height: metrics.cellHeight, - transform: [{ translateX: cursorOffset.x }, { translateY: cursorOffset.y }], - }, - ]; - }, [metrics, state.cursor.col, state.cursor.row, resolver.cursorColor]); - - const handleMeasure = useCallback( - (event: LayoutChangeEvent) => { - const nextMetrics = resolveMeasuredTerminalCellMetrics({ - measuredTextWidth: event.nativeEvent.layout.width, - measuredTextHeight: event.nativeEvent.layout.height, - measureTextLength: MEASURE_TEXT.length, - roundToNearestPixel: (value) => PixelRatio.roundToNearestPixel(value), - }); + const handleCellMetrics = useCallback( + (metrics: PaseoTerminalCellMetrics) => { const changedMetrics = resolveTerminalGridMetricsMeasurement( measuredMetricsRef.current, - nextMetrics, + metrics, ); if (!changedMetrics) { return; } measuredMetricsRef.current = changedMetrics; - setMetrics(changedMetrics); + setCellWidth(changedMetrics.cellWidth); onCellMetricsChange?.(changedMetrics); }, [onCellMetricsChange], ); const handleContainerLayout = useCallback((event: LayoutChangeEvent) => { - const { width, height } = event.nativeEvent.layout; - setViewport((current) => { - if (current?.width === width && current.height === height) { - return current; - } - return { width, height }; - }); + const { width } = event.nativeEvent.layout; + setViewportWidth((current) => (current === width ? current : width)); }, []); return ( - - - - {MEASURE_TEXT} - - {rows.map((row) => ( - - ))} - {!state.cursor.hidden && } - + + ); } @@ -432,38 +126,7 @@ const styles = StyleSheet.create({ root: { overflow: "hidden", }, - grid: { - position: "relative", - }, - row: { - flexDirection: "row", - }, - run: { - overflow: "hidden", - }, - customGlyphRun: { + surface: { flex: 1, - position: "relative", - }, - customGlyphSvg: { - left: 0, - position: "absolute", - top: 0, - }, - rowText: { - includeFontPadding: false, - margin: 0, - padding: 0, - }, - measureText: { - includeFontPadding: false, - opacity: 0, - position: "absolute", - }, - cursor: { - left: 0, - opacity: 0.45, - position: "absolute", - top: 0, }, }); diff --git a/packages/app/src/terminal/native-renderer/terminal-row-model.test.ts b/packages/app/src/terminal/native-renderer/terminal-row-model.test.ts deleted file mode 100644 index b19922b7a9..0000000000 --- a/packages/app/src/terminal/native-renderer/terminal-row-model.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import type { TerminalCell } from "@getpaseo/protocol/messages"; -import { describe, expect, test } from "vitest"; - -import { createTerminalCellStyleResolver, DEFAULT_TERMINAL_THEME } from "./colors"; -import { buildRows } from "./terminal-row-model"; - -function cell( - char: string, - overrides: Partial & { width?: number } = {}, -): TerminalCell & { width?: number } { - return { char, ...overrides }; -} - -describe("terminal row model", () => { - test("preserves terminal cell width for styled runs and trailing spaces", () => { - const resolver = createTerminalCellStyleResolver(DEFAULT_TERMINAL_THEME); - - const rows = buildRows({ - grid: [ - [cell("n", { bg: 2, bgMode: 1 }), cell("v", { bg: 2, bgMode: 1 }), cell(" "), cell(" ")], - ], - resolver, - }); - - expect(rows[0].runs.map((run) => ({ text: run.text, cellCount: run.cellCount }))).toEqual([ - { text: "nv", cellCount: 2 }, - { text: " ", cellCount: 2 }, - ]); - }); - - test("uses xterm's authoritative width instead of guessing from the code point", () => { - const resolver = createTerminalCellStyleResolver(DEFAULT_TERMINAL_THEME); - - const rows = buildRows({ - grid: [[cell("A", { width: 2 }), cell(" ", { width: 0 }), cell("界", { width: 1 })]], - resolver, - }); - - expect(rows[0].runs.map((run) => ({ text: run.text, cellCount: run.cellCount }))).toEqual([ - { text: "A界", cellCount: 3 }, - ]); - }); - - test("redraws selected cells with the terminal selection colors", () => { - const resolver = createTerminalCellStyleResolver(DEFAULT_TERMINAL_THEME); - const grid = [[cell("a", { fg: 1, fgMode: 1 }), cell("b", { dim: true }), cell("c")]]; - - const rows = buildRows({ - grid, - resolver, - selection: { - range: { - start: { row: 7, col: 1 }, - end: { row: 7, col: 2 }, - coordinateEpoch: 1, - }, - firstRow: 7, - backgroundColor: "#ffff00", - foregroundColor: "#000000", - }, - }); - - expect( - rows[0].runs.map((run) => ({ - text: run.text, - color: run.style.color, - backgroundColor: run.style.backgroundColor, - opacity: run.style.opacity, - })), - ).toEqual([ - { - text: "a", - color: DEFAULT_TERMINAL_THEME.red, - backgroundColor: undefined, - opacity: undefined, - }, - { text: "b", color: "#000000", backgroundColor: "#ffff00", opacity: 1 }, - { text: "c", color: "#000000", backgroundColor: "#ffff00", opacity: 1 }, - ]); - expect(rows[0].hash).not.toBe(buildRows({ grid, resolver })[0].hash); - }); - - test("splits Claude box drawing from font text at exact terminal columns", () => { - const resolver = createTerminalCellStyleResolver(DEFAULT_TERMINAL_THEME); - const frame = "╭─ Claude Code ───╮"; - - const [row] = buildRows({ - grid: [frame.split("").map((char) => cell(char, { fg: 1, fgMode: 1 }))], - resolver, - }); - - expect( - row.runs.map((run) => ({ - text: run.text, - cellCount: run.cellCount, - renderKind: run.renderKind, - })), - ).toEqual([ - { text: "╭─", cellCount: 2, renderKind: "custom-glyph" }, - { text: " Claude Code ", cellCount: 13, renderKind: "text" }, - { text: "───╮", cellCount: 4, renderKind: "custom-glyph" }, - ]); - }); - - test("keeps Claude mascot glyphs custom while preserving copied row text", () => { - const resolver = createTerminalCellStyleResolver(DEFAULT_TERMINAL_THEME); - const mascot = "▐▛███▜▌ Claude Code"; - - const [row] = buildRows({ - grid: [mascot.split("").map((char) => cell(char, { fg: 1, fgMode: 1 }))], - resolver, - }); - - expect(row.runs.map((run) => [run.text, run.renderKind])).toEqual([ - ["▐▛███▜▌", "custom-glyph"], - [" Claude Code", "text"], - ]); - expect(row.runs.map((run) => run.text).join("")).toBe(mascot); - }); -}); diff --git a/packages/app/src/terminal/native-renderer/terminal-row-model.ts b/packages/app/src/terminal/native-renderer/terminal-row-model.ts deleted file mode 100644 index a65f39fd44..0000000000 --- a/packages/app/src/terminal/native-renderer/terminal-row-model.ts +++ /dev/null @@ -1,253 +0,0 @@ -import type { TextStyle } from "react-native"; -import type { TerminalCell } from "@getpaseo/protocol/messages"; - -import type { TerminalCellStyleResolver } from "./colors"; -import { resolveTerminalCustomGlyph, type TerminalCustomGlyph } from "./terminal-custom-glyph"; -import type { TerminalSelectionRange } from "./terminal-selection"; - -const WIDE_CHAR_RANGES: ReadonlyArray = [ - [0x1100, 0x115f], - [0x2329, 0x232a], - [0x2e80, 0xa4cf], - [0xac00, 0xd7a3], - [0xf900, 0xfaff], - [0xfe10, 0xfe19], - [0xfe30, 0xfe6f], - [0xff00, 0xff60], - [0xffe0, 0xffe6], - [0x1f300, 0x1faff], -]; - -interface TerminalRunBase { - key: string; - text: string; - cellCount: number; - styleKey: string; - style: TextStyle; - foregroundColor: string; -} - -export interface TerminalTextRun extends TerminalRunBase { - renderKind: "text"; -} - -export interface TerminalCustomGlyphRun extends TerminalRunBase { - renderKind: "custom-glyph"; - glyphs: TerminalCustomGlyphCell[]; -} - -export type TerminalRun = TerminalCustomGlyphRun | TerminalTextRun; - -export interface TerminalCustomGlyphCell { - key: string; - offset: number; - glyph: TerminalCustomGlyph; -} - -export interface TerminalRowModel { - index: number; - hash: string; - runs: TerminalRun[]; -} - -type TerminalRenderableCell = TerminalCell & { width?: number }; - -interface TerminalRowSelectionStyle { - range: TerminalSelectionRange; - firstRow: number; - backgroundColor: string; - foregroundColor: string; -} - -function hashStringPart(hash: number, value: string): number { - let nextHash = hash; - for (let index = 0; index < value.length; index += 1) { - nextHash = Math.imul(nextHash ^ value.charCodeAt(index), 16777619); - } - return nextHash; -} - -function finishHash(hash: number): string { - return (hash >>> 0).toString(36); -} - -function terminalCharWidth(char: string): number { - const codePoint = char.codePointAt(0); - if (codePoint === undefined) return 1; - const isWide = WIDE_CHAR_RANGES.some(([start, end]) => codePoint >= start && codePoint <= end); - return isWide ? 2 : 1; -} - -function shouldSkipSpacerCell(cells: TerminalRenderableCell[], col: number, char: string): boolean { - if (terminalCharWidth(char) < 2) { - return false; - } - return cells[col + 1]?.char === " "; -} - -function terminalCellCount(cells: TerminalRenderableCell[], col: number, char: string): number { - const authoritativeWidth = cells[col]?.width; - if (authoritativeWidth !== undefined) { - return Math.max(1, authoritativeWidth); - } - if (shouldSkipSpacerCell(cells, col, char)) { - return 2; - } - return 1; -} - -function appendRun(input: { - runs: TerminalRun[]; - text: string; - cellCount: number; - styleKey: string; - style: TextStyle; - foregroundColor: string; - customGlyph: TerminalCustomGlyph | null; - col: number; -}): void { - const renderKind = input.customGlyph ? "custom-glyph" : "text"; - const previousRun = input.runs[input.runs.length - 1]; - if ( - previousRun && - previousRun.styleKey === input.styleKey && - previousRun.renderKind === renderKind - ) { - const offset = previousRun.cellCount; - previousRun.text += input.text; - previousRun.cellCount += input.cellCount; - if (previousRun.renderKind === "custom-glyph" && input.customGlyph) { - previousRun.glyphs.push({ - key: `${input.col}:${input.text}`, - offset, - glyph: input.customGlyph, - }); - } - return; - } - - const baseRun: TerminalRunBase = { - key: `${input.col}:${input.styleKey}`, - text: input.text, - cellCount: input.cellCount, - styleKey: input.styleKey, - style: input.style, - foregroundColor: input.foregroundColor, - }; - if (input.customGlyph) { - input.runs.push({ - ...baseRun, - renderKind: "custom-glyph", - glyphs: [{ key: `${input.col}:${input.text}`, offset: 0, glyph: input.customGlyph }], - }); - return; - } - input.runs.push({ ...baseRun, renderKind: "text" }); -} - -function cellIntersectsSelection(input: { - selection: TerminalRowSelectionStyle; - row: number; - col: number; - cellCount: number; -}): boolean { - const absoluteRow = input.selection.firstRow + input.row; - const { start, end } = input.selection.range; - if (absoluteRow < start.row || absoluteRow > end.row) { - return false; - } - - const selectedStartCol = absoluteRow === start.row ? start.col : 0; - const selectedEndCol = absoluteRow === end.row ? end.col : Number.POSITIVE_INFINITY; - const cellEndCol = input.col + input.cellCount - 1; - return cellEndCol >= selectedStartCol && input.col <= selectedEndCol; -} - -function selectedCellStyle(input: { - style: TextStyle; - foregroundColor: string; - backgroundColor: string; -}): TextStyle { - return { - ...input.style, - backgroundColor: input.backgroundColor, - color: input.foregroundColor, - opacity: 1, - ...(input.style.textDecorationLine - ? { textDecorationColor: input.foregroundColor } - : undefined), - }; -} - -function buildRowModel(input: { - cells: TerminalRenderableCell[]; - index: number; - resolver: TerminalCellStyleResolver; - selection?: TerminalRowSelectionStyle; -}): TerminalRowModel { - const runs: TerminalRun[] = []; - let hash = 2166136261; - - for (let col = 0; col < input.cells.length; col += 1) { - const cell = input.cells[col]; - const text = cell.char || " "; - const resolvedStyle = input.resolver.resolve(cell); - const cellCount = terminalCellCount(input.cells, col, text); - const customGlyph = resolveTerminalCustomGlyph(text); - const selection = input.selection; - let styleKey = resolvedStyle.key; - let style = resolvedStyle.style; - let foregroundColor = resolvedStyle.foregroundColor; - if ( - selection && - cellIntersectsSelection({ - selection, - row: input.index, - col, - cellCount, - }) - ) { - styleKey = `${resolvedStyle.key}|selection:${selection.foregroundColor}:${selection.backgroundColor}`; - style = selectedCellStyle({ - style: resolvedStyle.style, - foregroundColor: selection.foregroundColor, - backgroundColor: selection.backgroundColor, - }); - foregroundColor = selection.foregroundColor; - } - appendRun({ - runs, - text, - cellCount, - styleKey, - style, - foregroundColor, - customGlyph, - col, - }); - hash = hashStringPart(hash, text); - hash = hashStringPart(hash, String(cellCount)); - hash = hashStringPart(hash, styleKey); - hash = hashStringPart(hash, customGlyph ? "custom-glyph" : "text"); - - if (cellCount > 1) { - col += 1; - } - } - - return { - index: input.index, - hash: finishHash(hash), - runs, - }; -} - -export function buildRows(input: { - grid: TerminalRenderableCell[][]; - resolver: TerminalCellStyleResolver; - selection?: TerminalRowSelectionStyle; -}): TerminalRowModel[] { - return input.grid.map((cells, index) => - buildRowModel({ cells, index, resolver: input.resolver, selection: input.selection }), - ); -} diff --git a/packages/app/vitest.config.ts b/packages/app/vitest.config.ts index b139e266c5..ba4c0fc979 100644 --- a/packages/app/vitest.config.ts +++ b/packages/app/vitest.config.ts @@ -82,6 +82,7 @@ export default defineConfig({ environment: "node", include: [ "src/**/*.{test,spec}.{ts,tsx}", + "modules/**/*.{test,spec}.{ts,tsx}", "native-release-version.test.ts", "app.config.test.ts", "vitest-environment.test.ts", From 7b03f0ad21d9d8d701ecd5867cb8f1ac52d406f7 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 31 Oct 2026 10:05:16 +0700 Subject: [PATCH 35/95] fix(ci): migrate to the latest macOS runners (#8335) * fix(ci): replace retiring macOS 14 runners Use the supported macOS 15 ARM64 image before the November 2 retirement. Keep the Intel release runner and existing test/build/release commands unchanged. * fix(ci): use the latest macOS runner images --- .github/workflows/ci.yml | 4 ++-- .github/workflows/desktop-release.yml | 4 ++-- .github/workflows/nix.yml | 2 +- scripts/ci-workflow.test.mjs | 32 ++++++++++++++++++++++++++- 4 files changed, 36 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7cb267c2dd..285ff8e072 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -281,10 +281,10 @@ jobs: # otherwise has no server-test coverage. Scoped to the observer/watcher suites to # bound runtime. server-tests-macos: - name: server-tests (macos-14, file observation) + name: server-tests (macos-latest, file observation) needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false') }} - runs-on: macos-14 + runs-on: macos-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index d81c036e62..26be12291b 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -100,10 +100,10 @@ jobs: fail-fast: false matrix: include: - - runner: macos-14 + - runner: macos-latest electron_arch: arm64 node_heap_mb: 4096 - - runner: macos-15-intel + - runner: macos-26-intel electron_arch: x64 node_heap_mb: 8192 permissions: diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index f37a8b0cdf..b653564793 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -101,7 +101,7 @@ jobs: run: nix build .#desktop -o result-desktop build-desktop-darwin: - runs-on: macos-14 + runs-on: macos-latest steps: - uses: actions/checkout@v4 diff --git a/scripts/ci-workflow.test.mjs b/scripts/ci-workflow.test.mjs index 37508cad7d..73a9003312 100644 --- a/scripts/ci-workflow.test.mjs +++ b/scripts/ci-workflow.test.mjs @@ -24,7 +24,10 @@ const gatedCiJobs = new Map([ "server-integration-windows", { name: "server-integration (windows-latest)", contract: "server" }, ], - ["server-tests-macos", { name: "server-tests (macos-14, file observation)", contract: "server" }], + [ + "server-tests-macos", + { name: "server-tests (macos-latest, file observation)", contract: "server" }, + ], ["desktop-tests-ubuntu", { name: "desktop-tests (ubuntu-latest)", contract: "desktop" }], ["desktop-tests-windows", { name: "desktop-tests (windows-latest)", contract: "desktop" }], ["app-tests", { name: "app-tests", contract: "app" }], @@ -649,3 +652,30 @@ process.exit(code); }); } }); + +// GitHub retires macOS 14 on 2026-11-02; brownouts already reject these jobs. +test("workflows avoid retired macOS 14 runners", () => { + for (const path of filesUnder(".github/workflows", (candidate) => /\.ya?ml$/.test(candidate))) { + assert.doesNotMatch( + readFileSync(new URL(path, repoRoot), "utf8"), + /macos-14(?:-large|-xlarge)?\b/, + path, + ); + } +}); + +test("macOS jobs use latest runners while preserving release architectures", () => { + for (const [workflow, jobId] of [ + [ciWorkflowPath, "server-tests-macos"], + [nixWorkflowPath, "build-desktop-darwin"], + ]) { + assert.match( + jobBlocks(readFileSync(workflow, "utf8")).get(jobId).join("\n"), + /^ runs-on: macos-latest$/m, + ); + } + const release = readFileSync(new URL(".github/workflows/desktop-release.yml", repoRoot), "utf8"); + assert.match(release, /runner: macos-latest\n\s+electron_arch: arm64/); + // 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/); +}); From 9c926ed126358247421e62cf6c7399df042fb97d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 31 Oct 2026 10:51:45 +0700 Subject: [PATCH 36/95] fix(terminal): preserve 10,000 rows with direct ANSI replay (#8282) Restore configured terminal history on reconnect, retaining 10,000 rows plus the screen. Serialize the worker-owned xterm buffer directly to ANSI and forward negotiated bounded replay chunks while preserving ANSI-era clients and per-viewer live ordering. Preserve captured geometry through replay, bound pressure recovery to 500 rows, and keep the omitted visible-history default at 200. Remove the full cell-grid copy and obsolete JSON rendering. All 21 CI checks pass. Real browser, native Debug emulator/simulator and Linux Electron coverage and platform limits are documented in the PR. The Android Debug invalid native delegate dispatch also reproduces on upstream main; its cause and any change in frequency remain unproven. Credit Manav Dhindsa (DrakenKor), #6139 and #6148, and Lennart Schoch, #6743, for prior ANSI replay and retention work informing this change. Closes #6137. Closes #6147. --- docs/protocol-compatibility.md | 14 + docs/terminal-performance.md | 16 +- docs/terminal-worker.md | 16 +- package-lock.json | 8 + .../app/e2e/browser/daemon-lifecycle.spec.ts | 57 +- .../terminal-retained-tab-stream.spec.ts | 66 + .../e2e/support/helpers/daemon-lifecycle.ts | 41 +- .../support/helpers/isolated-host-daemon.ts | 53 +- .../app/e2e/support/helpers/terminal-perf.ts | 98 ++ packages/app/package.json | 1 + .../components/terminal-emulator-contract.ts | 5 +- .../terminal-emulator-native-grid.native.tsx | 191 +-- .../app/src/components/terminal-emulator.tsx | 48 +- packages/app/src/components/terminal-pane.tsx | 48 +- packages/app/src/i18n/resources/ar.ts | 1 + packages/app/src/i18n/resources/en.ts | 1 + packages/app/src/i18n/resources/es.ts | 1 + packages/app/src/i18n/resources/fr.ts | 1 + packages/app/src/i18n/resources/ja.ts | 1 + packages/app/src/i18n/resources/ko.ts | 1 + packages/app/src/i18n/resources/pt-BR.ts | 1 + packages/app/src/i18n/resources/ru.ts | 1 + packages/app/src/i18n/resources/zh-CN.ts | 1 + .../screens/workspace/workspace-screen.tsx | 16 - ...se-workspace-terminal-session-retention.ts | 18 - .../headless-terminal-output-drain.test.ts | 326 ++++- .../headless-terminal-output-drain.ts | 121 +- .../headless-terminal-state.ts | 46 - .../native-renderer/terminal-frame.test.ts | 47 + .../terminal-scrollback.test.ts | 19 +- .../terminal-emulator-runtime.browser.test.ts | 120 +- .../runtime/terminal-emulator-runtime.test.ts | 139 +- .../runtime/terminal-emulator-runtime.ts | 69 +- .../runtime/terminal-find.browser.test.ts | 1 - .../terminal-glyph-rendering.browser.test.ts | 1 - .../terminal-resize-repro.browser.test.ts | 65 +- .../runtime/terminal-restore-options.test.ts | 27 +- .../runtime/terminal-restore-options.ts | 10 +- .../runtime/terminal-snapshot.test.ts | 8 +- .../src/terminal/runtime/terminal-snapshot.ts | 1 - .../terminal-stream-controller.test.ts | 116 +- .../runtime/terminal-stream-controller.ts | 22 +- .../workspace-terminal-session.test.ts | 75 -- .../runtime/workspace-terminal-session.ts | 79 -- .../utils/terminal-renderer-readiness.test.ts | 22 - .../src/utils/terminal-renderer-readiness.ts | 7 - .../46-daemon-terminal-worker-upgrade.test.ts | 144 ++- packages/client/src/connection/index.ts | 1 + packages/client/src/daemon-client.test.ts | 252 +++- packages/client/src/daemon-client.ts | 5 +- packages/client/src/index.ts | 1 + .../client/src/terminal-stream-router.test.ts | 56 +- packages/client/src/terminal-stream-router.ts | 55 +- .../protocol/src/binary-frames/demux.test.ts | 15 + packages/protocol/src/binary-frames/demux.ts | 3 + .../protocol/src/binary-frames/terminal.ts | 12 + packages/protocol/src/client-capabilities.ts | 1 + .../protocol/src/terminal-snapshot.test.ts | 79 -- packages/protocol/src/terminal-snapshot.ts | 224 ---- packages/server/package.json | 1 + .../src/server/agent/mcp-server.test.ts | 1 - .../terminal-byte-headless-parity.e2e.test.ts | 74 +- .../server/daemon-e2e/terminal.e2e.test.ts | 364 ++++-- .../server/owned-subscriptions.e2e.test.ts | 166 +-- .../src/server/script-health-monitor.test.ts | 8 +- packages/server/src/server/session.ts | 4 +- .../reconciliation-responsiveness.ts | 17 + .../workspace-reconciliation-service.test.ts | 78 +- .../src/server/worktree-bootstrap.test.ts | 18 +- .../server/src/server/worktree-bootstrap.ts | 17 +- .../src/server/worktree-session.test.ts | 8 +- .../server/src/terminal/terminal-capture.ts | 29 +- .../src/terminal/terminal-manager.test.ts | 1 - .../server/src/terminal/terminal-manager.ts | 37 +- .../server/src/terminal/terminal-replay.ts | 135 ++ .../src/terminal/terminal-restore.test.ts | 91 +- .../server/src/terminal/terminal-restore.ts | 112 +- .../src/terminal/terminal-serializer.ts | 42 + .../terminal-session-controller.test.ts | 420 +++--- .../terminal/terminal-session-controller.ts | 174 ++- .../terminal/terminal-worker-client.test.ts | 43 + .../src/terminal/terminal-worker-frames.ts | 12 + .../src/terminal/terminal-worker-pool.ts | 6 +- .../src/terminal/terminal-worker-process.ts | 112 +- .../terminal/terminal-worker-protocol.test.ts | 18 +- .../src/terminal/terminal-worker-protocol.ts | 33 +- .../src/terminal/terminal.posix.test.ts | 141 +- packages/server/src/terminal/terminal.test.ts | 390 +++++- packages/server/src/terminal/terminal.ts | 284 +--- .../src/terminal/test-utils/terminal-state.ts | 51 + .../terminal/worker-terminal-manager.test.ts | 1145 +++++++++++++++-- .../src/terminal/worker-terminal-manager.ts | 112 +- 92 files changed, 4380 insertions(+), 2638 deletions(-) delete mode 100644 packages/app/src/terminal/hooks/use-workspace-terminal-session-retention.ts delete mode 100644 packages/app/src/terminal/runtime/terminal-snapshot.ts delete mode 100644 packages/app/src/terminal/runtime/workspace-terminal-session.test.ts delete mode 100644 packages/app/src/terminal/runtime/workspace-terminal-session.ts delete mode 100644 packages/protocol/src/terminal-snapshot.test.ts delete mode 100644 packages/protocol/src/terminal-snapshot.ts create mode 100644 packages/server/src/terminal/terminal-replay.ts create mode 100644 packages/server/src/terminal/terminal-serializer.ts create mode 100644 packages/server/src/terminal/test-utils/terminal-state.ts diff --git a/docs/protocol-compatibility.md b/docs/protocol-compatibility.md index 74650255a5..0fa4fed63d 100644 --- a/docs/protocol-compatibility.md +++ b/docs/protocol-compatibility.md @@ -52,6 +52,20 @@ On capable daemons, connecting creates no timeline or event demand. Client subsc release it on unsubscribe, and restore it after reconnect. Raw message observers inspect traffic without requesting streams. Application caches and which agents are visible remain caller-owned. +## Terminal ANSI replay + +Terminal replay intentionally drops JSON-era apps, an exception to the general cross-version policy: +use app v0.1.81 or newer. Earlier apps omit `restore` and reject ANSI Restore (`0x05`), so they show +only subsequent live output, without the existing screen or history. + +`terminal_chunked_replay` is SDK-owned delivery behavior. The daemon sends new binary opcodes only to a source +advertising the capability. The SDK keeps the established subscribe request against older +ANSI hosts and normalizes their single Restore frame into begin/data/end. `observeTerminal` defaults +to an explicit full snapshot: omitting restore on an ANSI-era host selects its discarded JSON path. +Explicit caller restore modes and history counts pass through unchanged. JSON snapshot +wire declarations remain accepted, but JSON snapshot frames have no rendering runtime. +Terminals require `terminal-restore-modes`; older hosts show an update message without attaching. + ## Binary chunking `binary_chunking_v1` in hello and optional `server_info.features.binaryChunkingV1` diff --git a/docs/terminal-performance.md b/docs/terminal-performance.md index 3bb7c4d71c..c757d004ec 100644 --- a/docs/terminal-performance.md +++ b/docs/terminal-performance.md @@ -6,9 +6,9 @@ How terminal output stays low-latency, what the invariants are, and how to measu ``` pty (node-pty, forked worker process) - → headless xterm parse (worker, snapshot fidelity) - → TerminalOutputCoalescer (worker, ≤1 IPC message per 5ms per terminal) - → process.send IPC → daemon main process + → headless xterm parse (worker, ANSI replay fidelity) + → TerminalOutputCoalescer (worker, ≤1 socket frame per 5ms per terminal) + → framed local socket → daemon main process → TerminalOutputCoalescer (per client stream, terminal-session-controller.ts) → binary ws frame (2-byte header + raw bytes) → client decode (daemon-client.ts) → stream router → emulator runtime @@ -19,11 +19,15 @@ Terminal frames share the daemon main event loop with all agent traffic. The `ev ## Invariants (the easy-to-break ones) +- **Replay is incremental across the daemon and SDK.** Capable clients request their configured history, bounded by [worker retention](terminal-worker.md#process-model). Older apps retain their own 200-row request. Explicit SDK history demand is unchanged against older ANSI hosts, which apply their existing 500-row ceiling. Begin resets once, chunks keep a streaming UTF-8 decoder/parser, and each renderer suppresses generated replies while parsing replay. Never concatenate capable replay chunks to feed the renderer. +- **Replay serialization reads xterm directly.** The [worker](terminal-worker.md#process-model) uses its existing buffer; text capture translates only the requested rows, and readiness scans row strings. Do not rebuild a full cell-object grid. Serialization still allocates a whole ANSI string and UTF-8 bytes before bounded delivery; chunking does not bound those allocations. - **Coalescers are leading+trailing throttles.** The first chunk after an idle window flushes immediately (synchronously); only sustained bursts wait for the trailing timer. Reverting to trailing-only adds a full window (~5ms) to every keystroke echo. -- **Output coalescing happens in the worker, before IPC.** One `process.send` per pty chunk was a main-loop flood under build output. Non-output messages (snapshot/snapshotReady/titleChange/exit) must flush the coalescer first so ordering is preserved. +- **Output coalescing happens in the worker, before IPC.** One socket frame per pty chunk was a main-loop flood under build output. Non-output messages (snapshotReady/titleChange/exit) must flush the coalescer first so ordering is preserved. +- **Replay delivery is isolated per viewer.** Live worker output never waits for app delivery. Each daemon stream owns its bounded live tail and revision deduplication; a slow or failed replay cannot pause another viewer of the same terminal. +- **Replay geometry belongs to the parser queue.** Native measurements and web fits must leave the captured dimensions intact through every chunk's commit. Apply the latest measured size after replay, before live bytes; clear, replacement and disposal must release that geometry ownership. A layout effect must not resize the renderer directly during replay. - **Coalesced output carries the LAST chunk's revision.** Snapshot replay dedup (`replayTerminalOutputAfterSnapshot`) skips buffered output with `revision <= replayRevision`; a merged batch with a lower revision would be wrongly skipped (lost output). -- **The input-mode tracker runs once per process boundary, not per hop.** The worker owns the authoritative tracker; the daemon caches the replay preamble from `getTerminalState` responses and `snapshotReady` messages. Do not reintroduce a per-chunk `feed()` on the daemon main loop. -- **Snapshot catch-up is backpressure-gated.** A stream falls back to a full snapshot only when `outputBytesSinceSnapshot > MAX_TERMINAL_OUTPUT_FRAME_BYTES` (256KB) **and** the client transport reports `bufferedAmount > MAX_CLIENT_BUFFERED_BYTES` (4MB). A client that keeps draining streams continuously, no matter how much output is produced. Before this gate existed, every 256KB of build output dropped a frame and forced a full JSON cell-grid snapshot (~200k objects across IPC) — the historical source of spiky lag and GC hitches. +- **The input-mode tracker runs once per process boundary, not per hop.** The worker commits its authoritative tracker with xterm parsing before publishing the corresponding revision; the daemon caches current modes from ordered worker events and adoption. A replay response carries capture-time modes for its viewer; it must not overwrite the shared cache after newer live output. Apply that viewer's preamble before its buffered live bytes. Do not reintroduce a per-chunk `feed()` on the daemon main loop. +- **Snapshot catch-up is backpressure-gated.** After more than 256 KiB of output, a stream catches up only when its transport reports more than 4 MiB queued, or exposes no backpressure reading. A draining client streams continuously. Pressure recovery sends at most 500 recent history rows plus the screen, preserving smaller explicit demands including zero; even a live-only subscription needs this resynchronization. It does not change the subscription's configured demand: later attach/reconnect restores still request the full configured history. The recent window avoids repeatedly serializing all retained history for a slow viewer. - **Plugin daemon sessions report IPC queue bytes.** Their virtual socket increments `bufferedAmount` before `process.send` and decrements it only in the send callback. Text and binary frames share that ordered queue, so the normal snapshot catch-up and physical high-water gates remain valid for server-side plugin SDK traffic. - **Client output writes are not serialized per frame.** The emulator runtime drains contiguous plain writes straight into xterm (which buffers internally). Only barrier ops (`clear`, `snapshot`, `suppressInput` writes) wait — behind a zero-length sentinel write — so resets can't interleave with in-flight output. - **Find preserves the inspected xterm viewport, including at the live bottom.** Keep its output anchor inside the existing batched write path. Serializing output to preserve a search result would add terminal latency. diff --git a/docs/terminal-worker.md b/docs/terminal-worker.md index 991fc5d644..be9321fb4f 100644 --- a/docs/terminal-worker.md +++ b/docs/terminal-worker.md @@ -15,6 +15,8 @@ supervisor (paseo.pid) ──► daemon worker ──► launcher ──► term - The worker exits on its own when it has no controller and no terminals, on `shutdown`, on SIGTERM (killing its terminals), and when its record disappears (a deleted `$PASEO_HOME`: nobody can ever reach it again). - Its stderr goes to `$PASEO_HOME/terminal-worker.log`. No pipe to the daemon survives the daemon. +The worker retains a fixed 10,000 history rows plus the screen in its existing xterm. Replay uses `@xterm/addon-serialize` 0.14 against headless xterm 6; no second cell grid or lifetime byte log is retained. The serializer boundary owns cursor position, visibility and explicit program-selected presentation; the addon alone does not preserve all three. An unset program style leaves each client’s configured cursor defaults intact. Standard modes come from the serializer; the existing input-mode tracker retains protocol modes such as Kitty and Win32 input. Commit that tracker with parsed output so replay bytes and metadata describe the same cut. + Which triggers keep terminals and which close them is owned by the stop reason (`keepTerminals`) that flows from the request through the supervisor to `stop()`. The kill side always ends in the reaper (`terminal-worker-reaper.ts`), the only path that signals terminal processes outside a connected daemon. ## Record, socket and secret @@ -40,10 +42,22 @@ The worker writes `$PASEO_HOME/terminal-worker/.json` (owner-only) with its Never more than two workers run. A new worker launches only when none is current, none is incompatible and fewer than two run. The count comes from the records, so an unreachable or unidentified worker counts, and so does one a cleanup could not confirm gone. When two run, the daemon starts with no current worker, and the first terminal created after one of them exits launches it. -**The frozen envelope never changes across versions:** the frame format (4-byte length + `v8.serialize`), `hello`/`helloAck`, the `listTerminals` info fields and `shutdown`. That is what lets any daemon close any worker: a kill stop sends `shutdown` to a newer worker it cannot otherwise drive. When you bump the version, keep a previous-version adapter so the draining worker can still be driven, and drop the adapter for the version before that. +**The frozen envelope never changes across versions:** the frame format (4-byte length + `v8.serialize`), `hello`/`helloAck`, the `listTerminals` info fields and `shutdown`. That is what lets any daemon close any worker: a kill stop sends `shutdown` to a newer worker it cannot otherwise drive. Protocol 5 hard cuts unreleased protocols 1 through 4, including the acknowledgement-based replay contract: those workers are obsolete and close. For future versions, keep a previous-version adapter so the draining worker can still be driven, and drop the adapter for the version before that. Tests stand up other versions with `PASEO_TERMINAL_WORKER_PROTOCOL_OVERRIDE`, read by the worker. It is test-only. +## Display replay + +The worker keeps the headless xterm model and generates ANSI. A replay request emits begin metadata (dimensions, retained history, revision and input modes), at most 64 KiB per data chunk, and end. Live output keeps flowing independently during replay. Capture is synchronous with PTY callbacks; flush the output coalescer before the cut so a later batch never includes bytes already in replay. Retained-history metadata counts the worker history before the request limit. + +ConPTY renders a console rather than forwarding every program escape sequence unchanged. A program write callback does not establish that its hidden cursor move or input modes reached the worker. Test exact ANSI fidelity at the serializer boundary; PTY integration compares the initial replay plus committed observed output at the capture cut, including startup modes. + +The worker paces replay writes on its local socket; the daemon synchronously queues each event into the application's bounded transport. App network delivery does not extend the worker's request deadline. There is no per-chunk acknowledgement or second replay delivery queue in the daemon. The physical application transport owns its 64 MiB high-water close, so a backed-up app cannot hold worker capture open. Each daemon viewer still holds at most 4 MiB of live output during capture, then drops revisions already present in the worker replay. Capture failure and tail overflow affect only that viewer; PTYs and healthy viewers keep running. + +Live traffic that fills the shared worker socket beyond 4 MiB disconnects the stalled controller. Reconnection adopts the surviving sessions and requests fresh replay for existing streams. Readiness does not require a revision: adoption announces readiness without one, while the replay capture supplies the revision used for deduplication. + +The SDK advertises `terminal_chunked_replay` only because it decodes begin/data/end. Capable clients receive these events directly; the daemon collects one Restore frame only for older ANSI clients. The SDK normalizes an older host's single Restore into the same lifecycle. Capture stays a worker request; bootstrap readiness uses `hasOutput`, not a transported grid. + ## Running from its own copy A worker outlives the installation that started it. An npm or desktop update replaces that installation's files, and the next terminal the old worker creates would load node-pty's `spawn-helper` and the zsh integration from paths that no longer exist. So a built daemon does not launch the worker from its install: diff --git a/package-lock.json b/package-lock.json index 80355dbb21..30ceb1cb4c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14740,6 +14740,12 @@ "node": ">=10.0.0" } }, + "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/headless": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.0.0.tgz", @@ -37703,6 +37709,7 @@ "@types/ws": "^8.18.1", "@vitest/browser": "^4.1.7", "@vitest/browser-playwright": "^4.1.7", + "@xterm/addon-serialize": "0.14.0", "@xterm/headless": "^6.1.0-beta.213", "babel-preset-expo": "~57.0.16", "dotenv": "^17.2.3", @@ -40682,6 +40689,7 @@ "@modelcontextprotocol/sdk": "1.29.0", "@opencode-ai/sdk": "1.14.46", "@opencode/client": "2.0.10", + "@xterm/addon-serialize": "0.14.0", "@xterm/headless": "^6.0.0", "ai": "5.0.78", "ajv": "^8.20.0", diff --git a/packages/app/e2e/browser/daemon-lifecycle.spec.ts b/packages/app/e2e/browser/daemon-lifecycle.spec.ts index 2e65778868..23f6f5b2ff 100644 --- a/packages/app/e2e/browser/daemon-lifecycle.spec.ts +++ b/packages/app/e2e/browser/daemon-lifecycle.spec.ts @@ -1,8 +1,9 @@ import { randomUUID } from "node:crypto"; -import { mkdtemp, writeFile } from "node:fs/promises"; +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { existsSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { expect, test } from "../support/fixtures"; +import { expect, test, daemonTest as cleanupTest } from "../support/fixtures"; import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon"; import { openDaemonOverview, @@ -13,6 +14,10 @@ import { readWorkerPid, openLongLivedTerminal, listTerminalIds, + suspendDaemonProcess, + resumeDaemonProcess, + daemonProcessCanWrite, + readTerminalProcessIds, } from "../support/helpers/daemon-lifecycle"; test("settings restart waits for a replacement worker while retaining the supervisor", async ({ @@ -119,3 +124,51 @@ async function createHomeWithSlowProvider(versionDelayMs: number): Promise { + cleanupTest.skip(process.platform === "win32", "POSIX stopped-process failure injection"); + const daemon = await startIsolatedHostDaemon(randomUUID()); + const workerPid = await readWorkerPid(daemon); + try { + suspendDaemonProcess(workerPid); + await daemon.close(); + resumeDaemonProcess(workerPid); + await expect.poll(() => daemonProcessCanWrite(workerPid)).toBe(false); + expect(existsSync(daemon.paseoHome)).toBe(false); + } finally { + resumeDaemonProcess(workerPid); + await daemon.close(); + await rm(daemon.paseoHome, { recursive: true, force: true }); + } + }, +); + +cleanupTest( + "isolated cleanup force-stops a frozen supervisor and its detached terminal writers", + async () => { + cleanupTest.skip(process.platform === "win32", "POSIX stopped-process failure injection"); + const daemon = await startIsolatedHostDaemon(randomUUID()); + const supervisorPid = daemon.getPid()!; + const workerPid = await readWorkerPid(daemon); + try { + await openLongLivedTerminal(daemon); + const terminalProcesses = await readTerminalProcessIds(daemon); + expect(terminalProcesses.length).toBeGreaterThanOrEqual(2); + suspendDaemonProcess(supervisorPid); + await daemon.close(); + const writerStates = await Promise.all( + [supervisorPid, workerPid, ...terminalProcesses].map(daemonProcessCanWrite), + ); + expect(writerStates).toEqual(Array(terminalProcesses.length + 2).fill(false)); + expect(existsSync(daemon.paseoHome)).toBe(false); + } finally { + resumeDaemonProcess(supervisorPid); + await daemon.close(); + } + }, +); diff --git a/packages/app/e2e/browser/terminal-retained-tab-stream.spec.ts b/packages/app/e2e/browser/terminal-retained-tab-stream.spec.ts index c3481110f7..d0dbe9be23 100644 --- a/packages/app/e2e/browser/terminal-retained-tab-stream.spec.ts +++ b/packages/app/e2e/browser/terminal-retained-tab-stream.spec.ts @@ -1,5 +1,10 @@ import type { Page } from "@playwright/test"; import { expect, test } from "../support/fixtures"; +import { + expectTerminalCursor, + getTerminalBufferText, + waitForTerminalContent, +} from "../support/helpers/terminal-perf"; import { TerminalE2EHarness } from "../support/helpers/terminal-dsl"; interface AttachOverlayProbeWindow extends Window { @@ -59,4 +64,65 @@ test.describe("retained terminal tab streams", () => { expect(await terminalAttachOverlayWasSeen(page)).toBe(false); }); + + for (const { count, retainedMarker } of [ + { count: 3000, retainedMarker: "history-0001界🙂" }, + { count: 12050, retainedMarker: "history-3000界🙂" }, + ]) { + test(`reattach restores retained styled Unicode history from ${count} lines`, async ({ + page, + }) => { + await page.addInitScript(() => { + const key = "@paseo:app-settings"; + const settings = JSON.parse(localStorage.getItem(key) ?? "{}"); + localStorage.setItem(key, JSON.stringify({ ...settings, terminalScrollbackLines: 10000 })); + }); + const terminal = await harness.createTerminal({ + name: `history-${count}`, + command: "/bin/sh", + args: [ + "-lc", + `i=1; while [ $i -le ${count} ]; do printf '\\033[31mhistory-%04d界🙂 payload-abcdefghijklmnopqrstuvwxyz0123456789\\033[0m\\n' $i; i=$((i+1)); done; sleep 120`, + ], + }); + const last = `history-${String(count).padStart(4, "0")}界🙂`; + await harness.openTerminal(page, { terminalId: terminal.id }); + await waitForTerminalContent(page, (text) => text.includes(last), 10000); + expect(await getTerminalBufferText(page)).toContain(retainedMarker); + await page.reload(); + await waitForTerminalContent(page, (text) => text.includes(last), 10000); + const restored = await getTerminalBufferText(page); + expect(restored).toContain(retainedMarker); + expect(restored.match(/history-\d{4,5}/g)?.length).toBeGreaterThanOrEqual( + Math.min(count, 10000), + ); + if (count > 10000) expect(restored).not.toContain("history-0001界🙂"); + }); + } + for (const input of [ + { name: "client blinking bar", presentation: "", shape: "bar" as const, blinking: true }, + { + name: "program blinking underline", + presentation: "\x1b[3 q", + shape: "underline" as const, + blinking: true, + }, + ]) { + test(`reattach preserves the ${input.name} cursor`, async ({ page }) => { + const terminal = await harness.createTerminal({ + name: "cursor-presentation", + command: process.execPath, + args: [ + "-e", + `process.stdout.write(${JSON.stringify(input.presentation + "CURSOR-READY\r\n")});setInterval(()=>{},1000)`, + ], + }); + await harness.openTerminal(page, { terminalId: terminal.id }); + await waitForTerminalContent(page, (text) => text.includes("CURSOR-READY"), 10000); + await expectTerminalCursor(page, input); + await page.reload(); + await waitForTerminalContent(page, (text) => text.includes("CURSOR-READY"), 10000); + await expectTerminalCursor(page, input); + }); + } }); diff --git a/packages/app/e2e/support/helpers/daemon-lifecycle.ts b/packages/app/e2e/support/helpers/daemon-lifecycle.ts index d72cf063b1..c38c0f5f78 100644 --- a/packages/app/e2e/support/helpers/daemon-lifecycle.ts +++ b/packages/app/e2e/support/helpers/daemon-lifecycle.ts @@ -1,5 +1,5 @@ import { execFile, spawn } from "node:child_process"; -import { writeFile } from "node:fs/promises"; +import { writeFile, readFile, readdir } from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; import { expect, type Page } from "@playwright/test"; @@ -154,3 +154,42 @@ export async function plantUnclosableTerminalProgram( throw error; } } + +/** Fault injection and liveness checks for the isolated daemon cleanup journey. */ +export function suspendDaemonProcess(pid: number): void { + process.kill(pid, "SIGSTOP"); +} + +export function resumeDaemonProcess(pid: number): void { + try { + process.kill(pid, "SIGCONT"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; + } +} + +export async function daemonProcessCanWrite(pid: number): Promise { + try { + const state = await psField(pid, "stat"); + return state.length > 0 && !state.startsWith("Z"); + } catch (error) { + if ((error as { code?: unknown }).code === 1) return false; + throw error; + } +} + +export async function readTerminalProcessIds(daemon: IsolatedHostDaemon): Promise { + const directory = path.join(daemon.paseoHome, "terminal-worker"); + const records = await Promise.all( + (await readdir(directory)) + .filter((name) => name.endsWith(".json")) + .map(async (name) => { + const record = JSON.parse(await readFile(path.join(directory, name), "utf8")) as { + pid: number; + terminals: Array<{ pid: number }>; + }; + return [record.pid].concat(record.terminals.map((terminal) => terminal.pid)); + }), + ); + return records.flat(); +} diff --git a/packages/app/e2e/support/helpers/isolated-host-daemon.ts b/packages/app/e2e/support/helpers/isolated-host-daemon.ts index 01fe3a70c1..c8dd3eaa44 100644 --- a/packages/app/e2e/support/helpers/isolated-host-daemon.ts +++ b/packages/app/e2e/support/helpers/isolated-host-daemon.ts @@ -1,4 +1,11 @@ -import { spawn, execFileSync, type ChildProcess, type SpawnOptions } from "node:child_process"; +import { promisify } from "node:util"; +import { + spawn, + execFile, + execFileSync, + type ChildProcess, + type SpawnOptions, +} from "node:child_process"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import net from "node:net"; import { tmpdir } from "node:os"; @@ -6,6 +13,8 @@ import path from "node:path"; import { withDisabledE2ESpeechEnv } from "./speech-env"; import { killProcessTree, spawnTsx } from "./spawn-node"; +const execFileAsync = promisify(execFile); + export interface IsolatedHostDaemon { serverId: string; port: number; @@ -152,6 +161,24 @@ export async function startIsolatedHostDaemon( const serverDir = publishedPackageRoot ? path.join(publishedPackageRoot, "node_modules", "@getpaseo", "server") : path.resolve(__dirname, "../../../../server"); + let child: ChildProcess | null = null; + const stopDaemon = async (): Promise => { + // The CLI owns graceful shutdown, forced cleanup and detached terminal + // reaping. Keep its ESM graph out of Playwright's transforming loader. + await execFileAsync( + process.execPath, + [ + path.resolve(__dirname, "../../../../cli/dist/index.js"), + "daemon", + "stop", + "--home", + paseoHome, + "--force", + ], + { timeout: 30_000 }, + ); + await killProcessTree(child); + }; const spawnDaemon = async (): Promise => { const spawnOptions: SpawnOptions = { cwd: serverDir, @@ -169,21 +196,21 @@ export async function startIsolatedHostDaemon( stdio: ["ignore", "ignore", "pipe"], detached: false, }; - const child = publishedPackageRoot + const spawned = publishedPackageRoot ? spawn(process.execPath, ["dist/scripts/supervisor-entrypoint.js"], spawnOptions) : spawnTsx("scripts/supervisor-entrypoint.ts", ["--dev"], spawnOptions); + child = spawned; let stderr = ""; - child.stderr?.on("data", (chunk: Buffer) => { + spawned.stderr?.on("data", (chunk: Buffer) => { stderr += chunk.toString("utf8"); stderr = stderr.split("\n").slice(-40).join("\n"); }); try { - await waitForServer(port, child); - return child; + await waitForServer(port, spawned); + return spawned; } catch (error) { - await killProcessTree(child); throw new Error( `${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`, { cause: error }, @@ -191,10 +218,10 @@ export async function startIsolatedHostDaemon( } }; - let child: ChildProcess; try { - child = await spawnDaemon(); + await spawnDaemon(); } catch (error) { + await stopDaemon(); if (!options.preserveHome) { await rm(paseoHome, { recursive: true, force: true }); } @@ -209,22 +236,22 @@ export async function startIsolatedHostDaemon( serverId, port, paseoHome, - getPid: () => child.pid, + getPid: () => child?.pid, restart: async () => { if (closed) throw new Error(`Cannot restart closed isolated daemon ${serverId}`); - await killProcessTree(child); - child = await spawnDaemon(); + await stopDaemon(); + await spawnDaemon(); }, close: async () => { if (closed) return; - closed = true; - await killProcessTree(child); + await stopDaemon(); if (!options.preserveHome) { await rm(paseoHome, { recursive: true, force: true }); } if (publishedPackageRoot) { await rm(publishedPackageRoot, { recursive: true, force: true }); } + closed = true; }, }; } diff --git a/packages/app/e2e/support/helpers/terminal-perf.ts b/packages/app/e2e/support/helpers/terminal-perf.ts index 5c00bed485..67a114b30f 100644 --- a/packages/app/e2e/support/helpers/terminal-perf.ts +++ b/packages/app/e2e/support/helpers/terminal-perf.ts @@ -1,4 +1,5 @@ import { expect, type Page } from "@playwright/test"; +import type { Terminal } from "@xterm/xterm"; import { buildHostWorkspaceRoute } from "../../../src/utils/host-routes"; import { getServerId } from "./server-id"; @@ -206,3 +207,100 @@ export function computePercentile(samples: number[], p: number): number { export function round2(value: number): number { return Math.round(value * 100) / 100; } + +/** Observe actual cursor pixels and blink phases through xterm's public render event. */ +export async function expectTerminalCursor( + page: Page, + expected: { shape: "bar" | "underline"; blinking: boolean }, +): Promise { + const observed = await page.evaluate(async () => { + const terminal = (window as Window & { __paseoTerminal?: Terminal }).__paseoTerminal; + if (!terminal) throw new Error("Terminal did not mount"); + const source = Array.from(document.querySelectorAll(".xterm-screen canvas")).find( + (canvas): canvas is HTMLCanvasElement => + canvas instanceof HTMLCanvasElement && canvas.getContext("webgl2") !== null, + ); + if (!source) throw new Error("Cursor verification requires the actual WebGL renderer"); + const copy = document.createElement("canvas"); + const context = copy.getContext("2d"); + if (!context) throw new Error("Cursor pixel capture is unavailable"); + terminal.focus(); + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ); + if (!terminal.element?.contains(document.activeElement)) + throw new Error("Terminal did not focus"); + const started = performance.now(); + const frames: Array<{ time: number; width: number; height: number }> = []; + const rendered = terminal.onRender(() => { + copy.width = source.width; + copy.height = source.height; + // WebGL's default drawing buffer must be copied before this render returns. + context.drawImage(source, 0, 0); + const cellWidth = source.width / terminal.cols; + const cellHeight = source.height / terminal.rows; + const x = Math.round(terminal.buffer.active.cursorX * cellWidth); + const y = Math.round(terminal.buffer.active.cursorY * cellHeight); + const width = Math.ceil(cellWidth); + const height = Math.ceil(cellHeight); + const background = context.getImageData(x + width + 2, y, 1, 1).data; + const pixels = context.getImageData(x, y, width, height).data; + let minX = width; + let maxX = -1; + let minY = height; + let maxY = -1; + for (let row = 0; row < height; row++) { + for (let col = 0; col < width; col++) { + const offset = (row * width + col) * 4; + if ( + ![0, 1, 2, 3].some( + (channel) => Math.abs(pixels[offset + channel]! - background[channel]!) > 20, + ) + ) + continue; + minX = Math.min(minX, col); + maxX = Math.max(maxX, col); + minY = Math.min(minY, row); + maxY = Math.max(maxY, row); + } + } + frames.push({ + time: performance.now() - started, + width: maxX < 0 ? 0 : (maxX - minX + 1) / cellWidth, + height: maxY < 0 ? 0 : (maxY - minY + 1) / cellHeight, + }); + }); + try { + const refresh = setInterval(() => terminal.refresh(0, terminal.rows - 1), 40); + try { + await new Promise((resolve) => setTimeout(resolve, 1800)); + } finally { + clearInterval(refresh); + } + } finally { + rendered.dispose(); + } + return { + frames, + focused: terminal.element?.contains(document.activeElement), + samples: frames.length, + width: Math.max(...frames.map((frame) => frame.width)), + height: Math.max(...frames.map((frame) => frame.height)), + blinking: + frames.filter( + (frame, index) => index > 0 && (frame.width === 0) !== (frames[index - 1]!.width === 0), + ).length >= 2, + }; + }); + expect(observed.samples).toBeGreaterThan(5); + expect(observed.blinking, JSON.stringify(observed)).toBe(expected.blinking); + if (expected.shape === "bar") { + expect(observed.width).toBeGreaterThan(0); + expect(observed.width).toBeLessThan(0.4); + expect(observed.height).toBeGreaterThan(0.6); + } else { + expect(observed.width).toBeGreaterThan(0.6); + expect(observed.height).toBeGreaterThan(0); + expect(observed.height).toBeLessThan(0.4); + } +} diff --git a/packages/app/package.json b/packages/app/package.json index 42294ae6a1..4fcd5d4737 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -154,6 +154,7 @@ "@types/ws": "^8.18.1", "@vitest/browser": "^4.1.7", "@vitest/browser-playwright": "^4.1.7", + "@xterm/addon-serialize": "0.14.0", "@xterm/headless": "^6.1.0-beta.213", "babel-preset-expo": "~57.0.16", "dotenv": "^17.2.3", diff --git a/packages/app/src/components/terminal-emulator-contract.ts b/packages/app/src/components/terminal-emulator-contract.ts index c4c7080b83..12638161a1 100644 --- a/packages/app/src/components/terminal-emulator-contract.ts +++ b/packages/app/src/components/terminal-emulator-contract.ts @@ -4,7 +4,6 @@ import type { } from "../terminal/runtime/terminal-emulator-runtime"; import type { Ref } from "react"; import type { ITheme } from "@xterm/xterm"; -import type { TerminalState } from "@getpaseo/protocol/messages"; import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode"; import type { TerminalOutputData } from "../terminal/runtime/terminal-emulator-runtime"; import type { @@ -19,8 +18,7 @@ import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-rea export interface TerminalEmulatorHandle { find?: TerminalFindHandle; writeOutput: (data: TerminalOutputData) => void; - restoreOutput: (data: TerminalOutputData) => void; - renderSnapshot: (state: TerminalState | null) => void; + replay: (event: import("@getpaseo/client").TerminalReplayEvent) => void; paste: (text: string) => void; copySelection: (clipboard: TerminalClipboardWriter) => Promise; clear: () => void; @@ -44,7 +42,6 @@ export interface TerminalEmulatorProps { swipeGesturesEnabled?: boolean; onSwipeLeft?: () => void; onSwipeRight?: () => void; - initialSnapshot?: TerminalState | null; onFindRequest?: () => void; onFindResult?: (result: TerminalFindResult) => void; onInput?: (data: string) => Promise | void; 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 b85380b715..57286f2a95 100644 --- a/packages/app/src/components/terminal-emulator-native-grid.native.tsx +++ b/packages/app/src/components/terminal-emulator-native-grid.native.tsx @@ -13,7 +13,6 @@ import { type ViewStyle, } from "react-native"; import type { ITheme } from "@xterm/xterm"; -import type { TerminalState } from "@getpaseo/protocol/messages"; import { TerminalInputModeTracker, terminalInputModeStatesEqual, @@ -120,43 +119,8 @@ function resolvePanResponderPoint( }; } -function initialNativeTerminalSize(snapshot: TerminalState | null): TerminalMeasuredSize { - return { - rows: snapshot?.rows ?? MIN_NATIVE_TERMINAL_ROWS, - cols: snapshot?.cols ?? MIN_NATIVE_TERMINAL_COLS, - }; -} - -function screenStateFromViewport(viewport: TerminalViewportState): TerminalScreenState { - const visibleRows = viewport.grid.length; - const currentViewport = { - firstRow: viewport.firstRow, - lastRow: Math.min(viewport.newestRow, viewport.firstRow + Math.max(1, visibleRows) - 1), - }; - return { - viewport, - scroll: { - mode: "following", - firstRow: viewport.firstRow, - visibleRows, - oldestRow: viewport.oldestRow, - newestRow: viewport.newestRow, - currentViewport, - bottomViewport: currentViewport, - }, - }; -} - -function viewportStateFromSnapshot(state: TerminalState): TerminalViewportState { - return { - rows: state.grid.length, - cols: state.cols, - firstRow: 0, - oldestRow: 0, - newestRow: Math.max(0, state.grid.length - 1), - grid: state.grid, - cursor: state.cursor, - }; +function initialNativeTerminalSize(): TerminalMeasuredSize { + return { rows: MIN_NATIVE_TERMINAL_ROWS, cols: MIN_NATIVE_TERMINAL_COLS }; } function NativeTerminalEmulator({ @@ -169,7 +133,6 @@ function NativeTerminalEmulator({ fontSize, keyboardInset = 0, isKeyboardVisible = false, - initialSnapshot = null, onInput, onTerminalKey, onResize, @@ -181,20 +144,18 @@ function NativeTerminalEmulator({ resizeRequestToken = 0, }: TerminalEmulatorProps) { const terminalRef = useRef(null); - const mountInputRef = useRef({ streamKey, initialSnapshot }); + const mountInputRef = useRef({ streamKey }); if ( resolveNativeTerminalMountAction({ mountedStreamKey: mountInputRef.current.streamKey, nextStreamKey: streamKey, }) === "mount" ) { - mountInputRef.current = { streamKey, initialSnapshot }; + mountInputRef.current = { streamKey }; } const inputRef = useRef(null); - const terminalSizeRef = useRef(initialNativeTerminalSize(initialSnapshot)); - const resizePolicyRef = useRef( - createTerminalResizePolicy(initialNativeTerminalSize(initialSnapshot)), - ); + const terminalSizeRef = useRef(initialNativeTerminalSize()); + const resizePolicyRef = useRef(createTerminalResizePolicy(initialNativeTerminalSize())); const activeResizeClaimTokenRef = useRef(0); const layoutRef = useRef(null); const metricsRef = useRef(null); @@ -220,11 +181,7 @@ function NativeTerminalEmulator({ ...inputModeTrackerRef.current.getState(), applicationCursorKeys: false, }); - const [terminalScreen, setTerminalScreen] = useState(() => { - return initialSnapshot - ? screenStateFromViewport(viewportStateFromSnapshot(initialSnapshot)) - : null; - }); + const [terminalScreen, setTerminalScreen] = useState(null); const [selectionRange, setSelectionRange] = useState(null); const selectionRangeRef = useRef(null); const hasNotifiedSelectionRef = useRef(false); @@ -361,10 +318,14 @@ function NativeTerminalEmulator({ (terminal: NativeHeadlessTerminal): NativeTerminalOutputDrain => createNativeTerminalOutputDrain({ write: async (chunk) => { + inputModeTrackerRef.current.feed(chunk); await terminal.write(chunk); emitInputModeChange(); }, - reset: () => { + restoreSize: () => terminal.resize(terminalSizeRef.current), + reset: (size) => { + inputModeTrackerRef.current.reset(); + if (size) terminal.resize(size); terminal.reset(); emitInputModeChange(); }, @@ -419,8 +380,7 @@ function NativeTerminalEmulator({ hasNotifiedMeasuredSizeRef.current = true; terminalSizeRef.current = policyResult.measuredSize; if (policyResult.measuredSizeChanged) { - terminalRef.current?.resize(policyResult.measuredSize); - schedulePaint(); + outputDrainRef.current?.resize(); } callbacksRef.current.onResize?.({ rows: policyResult.measuredSize.rows, @@ -437,68 +397,51 @@ function NativeTerminalEmulator({ }); } }, - [schedulePaint], + [], ); - const resetTerminal = useCallback( - (snapshot: TerminalState | null) => { - outputDrainRef.current?.dispose(); - outputDrainRef.current = null; - terminalRef.current?.dispose(); - screenModelRef.current = null; - latestResolvedScreenRef.current = null; - clearSelection(); - const measuredSize = resolveMeasuredNativeTerminalSize({ - layout: layoutRef.current, - metrics: metricsRef.current, - }); - const size = - measuredSize ?? - (snapshot ? { rows: snapshot.rows, cols: snapshot.cols } : terminalSizeRef.current); - terminalSizeRef.current = size; - resizePolicyRef.current = createTerminalResizePolicy(size); - outputDecoderRef.current.decode(); - const terminal = createNativeHeadlessTerminal({ - rows: size.rows, - cols: size.cols, - scrollbackLines, - }); - terminalRef.current = terminal; - screenModelRef.current = createNativeTerminalScreenModel({ terminal }); - const outputDrain = createOutputDrain(terminal); - outputDrainRef.current = outputDrain; - const screen = snapshot - ? screenStateFromViewport(viewportStateFromSnapshot(snapshot)) - : resolveTerminalScreen(terminal); - commitTerminalScreen(screen); - resetInputModeTracker(); - if (snapshot) { - outputDrain.restoreSnapshot(snapshot); - } - }, - [ - commitTerminalScreen, - createOutputDrain, - resetInputModeTracker, - resolveTerminalScreen, + const resetTerminal = useCallback(() => { + outputDrainRef.current?.dispose(); + outputDrainRef.current = null; + terminalRef.current?.dispose(); + screenModelRef.current = null; + latestResolvedScreenRef.current = null; + clearSelection(); + const measuredSize = resolveMeasuredNativeTerminalSize({ + layout: layoutRef.current, + metrics: metricsRef.current, + }); + const size = measuredSize ?? terminalSizeRef.current; + terminalSizeRef.current = size; + resizePolicyRef.current = createTerminalResizePolicy(size); + outputDecoderRef.current.decode(); + const terminal = createNativeHeadlessTerminal({ + rows: size.rows, + cols: size.cols, scrollbackLines, - clearSelection, - ], - ); + }); + terminalRef.current = terminal; + screenModelRef.current = createNativeTerminalScreenModel({ terminal }); + const outputDrain = createOutputDrain(terminal); + outputDrainRef.current = outputDrain; + const screen = resolveTerminalScreen(terminal); + commitTerminalScreen(screen); + resetInputModeTracker(); + }, [ + commitTerminalScreen, + createOutputDrain, + resetInputModeTracker, + resolveTerminalScreen, + scrollbackLines, + clearSelection, + ]); - const enqueueOutputText = useCallback( - (text: string) => { - if (text.length === 0) { - return; - } - const inputModeUpdate = inputModeTrackerRef.current.feed(text); - if (inputModeUpdate.changed) { - emitInputModeChange(); - } - outputDrainRef.current?.enqueueText(text); - }, - [emitInputModeChange], - ); + const enqueueOutputText = useCallback((text: string) => { + if (text.length === 0) { + return; + } + outputDrainRef.current?.enqueueText(text); + }, []); const claimActiveTerminalSize = useCallback(() => { activeResizeClaimTokenRef.current += 1; @@ -521,27 +464,7 @@ function NativeTerminalEmulator({ const text = outputDecoderRef.current.decode(data, { stream: true }); enqueueOutputText(text); }, - restoreOutput: (data: TerminalOutputData) => { - outputDecoderRef.current.decode(); - resetInputModeTracker(); - const text = outputDecoderRef.current.decode(data, { stream: false }); - outputDrainRef.current?.restoreText(text); - }, - renderSnapshot: (state: TerminalState | null) => { - outputDecoderRef.current.decode(); - resetInputModeTracker(); - if (!state) { - resetTerminal(null); - return; - } - clearSelection(); - const outputDrain = outputDrainRef.current; - if (!outputDrain) { - resetTerminal(state); - return; - } - outputDrain.restoreSnapshot(state); - }, + replay: (event) => outputDrainRef.current?.replay(event), paste: (text: string) => { if (text.length === 0) { return; @@ -572,7 +495,6 @@ function NativeTerminalEmulator({ screenModelRef.current?.reset(); clearSelection(); resetInputModeTracker(); - terminalRef.current?.reset(); schedulePaint(); }, claimSize: claimActiveTerminalSize, @@ -591,14 +513,13 @@ function NativeTerminalEmulator({ claimActiveTerminalSizeForAction, clearSelection, resetInputModeTracker, - resetTerminal, schedulePaint, ], ); useEffect(() => { hasNotifiedMeasuredSizeRef.current = false; - resetTerminal(mountInputRef.current.initialSnapshot); + resetTerminal(); emitMeasuredSize({ source: "measure" }); return () => { callbacksRef.current.onRendererReadyChange?.({ streamKey, isReady: false }); diff --git a/packages/app/src/components/terminal-emulator.tsx b/packages/app/src/components/terminal-emulator.tsx index 3d272fb75e..fa6ac9faba 100644 --- a/packages/app/src/components/terminal-emulator.tsx +++ b/packages/app/src/components/terminal-emulator.tsx @@ -19,7 +19,6 @@ import type { DOMProps } from "expo/dom"; import { useDOMImperativeHandle, type DOMImperativeFactory } from "expo/dom"; import "@xterm/xterm/css/xterm.css"; import type { ITheme } from "@xterm/xterm"; -import type { TerminalState } from "@getpaseo/protocol/messages"; import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode"; import type { PendingTerminalModifiers } from "../utils/terminal-keys"; import { @@ -40,8 +39,7 @@ import { focusWithRetries } from "../utils/web-focus"; export interface TerminalEmulatorHandle { find?: TerminalFindHandle; writeOutput: (data: TerminalOutputData) => void; - restoreOutput: (data: TerminalOutputData) => void; - renderSnapshot: (state: TerminalState | null) => void; + replay: (event: import("@getpaseo/client").TerminalReplayEvent) => void; paste: (text: string) => void; copySelection: (clipboard: TerminalClipboardWriter) => Promise; clear: () => void; @@ -108,7 +106,6 @@ interface TerminalEmulatorProps { swipeGesturesEnabled?: boolean; onSwipeLeft?: () => void; onSwipeRight?: () => void; - initialSnapshot?: TerminalState | null; onFindRequest?: () => void; onFindResult?: (result: TerminalFindResult) => void; onInput?: (data: string) => Promise | void; @@ -147,16 +144,6 @@ declare global { interface Window {} } -function isTerminalState(value: unknown): value is TerminalState { - return ( - typeof value === "object" && - value !== null && - "rows" in value && - "cols" in value && - "grid" in value - ); -} - export default function TerminalEmulator({ ref, streamKey, @@ -172,7 +159,6 @@ export default function TerminalEmulator({ swipeGesturesEnabled = false, onSwipeLeft, onSwipeRight, - initialSnapshot = null, onFindRequest, onFindResult, onInput, @@ -228,8 +214,6 @@ export default function TerminalEmulator({ onOpenLocalFileLink, onFilePaste, }; - const initialSnapshotRef = useRef(initialSnapshot); - initialSnapshotRef.current = initialSnapshot; const pendingModifiersRef = useRef(pendingModifiers); pendingModifiersRef.current = pendingModifiers; @@ -253,17 +237,17 @@ export default function TerminalEmulator({ const data = args[0]; if (data instanceof Uint8Array) runtimeRef.current?.write({ data }); }, - restoreOutput: (...args) => { - const data = args[0]; - if (data instanceof Uint8Array) runtimeRef.current?.restoreOutput({ data }); - }, - renderSnapshot: (...args) => { - const state = args[0]; - if (state === null) { - runtimeRef.current?.renderSnapshot({ state: null }); - } else if (isTerminalState(state)) { - runtimeRef.current?.renderSnapshot({ state }); - } + replay: (event) => { + if (!event || typeof event !== "object" || Array.isArray(event)) return; + if (event.type === "end") runtimeRef.current?.replay({ type: "end" }); + if (event.type === "data" && event.data instanceof Uint8Array) + runtimeRef.current?.replay({ type: "data", data: event.data }); + if (event.type === "begin") + runtimeRef.current?.replay({ + type: "begin", + rows: typeof event.rows === "number" ? event.rows : undefined, + cols: typeof event.cols === "number" ? event.cols : undefined, + }); }, paste: (...args) => { const text = args[0]; @@ -299,12 +283,7 @@ export default function TerminalEmulator({ writeOutput: (data: TerminalOutputData) => { runtimeRef.current?.write({ data }); }, - restoreOutput: (data: TerminalOutputData) => { - runtimeRef.current?.restoreOutput({ data }); - }, - renderSnapshot: (state: TerminalState | null) => { - runtimeRef.current?.renderSnapshot({ state }); - }, + replay: (event) => runtimeRef.current?.replay(event), paste: (text: string) => { pasteText(text); }, @@ -471,7 +450,6 @@ export default function TerminalEmulator({ runtime.mount({ root, host, - initialSnapshot: initialSnapshotRef.current, scrollback: scrollbackLinesRef.current, theme: mountedThemeRef.current, fontFamily: fontFamilyRef.current, diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index 6c1561bc5b..d60556e0fa 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -15,7 +15,6 @@ import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated" import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { Keyboard as KeyboardIcon, KeyboardOff as KeyboardOffIcon } from "lucide-react-native"; import type { TerminalKeyInput } from "@getpaseo/protocol/terminal-key-input"; -import type { TerminalState } from "@getpaseo/protocol/messages"; import { DEFAULT_TERMINAL_INPUT_MODE_STATE, type TerminalInputModeState, @@ -60,7 +59,6 @@ import { getDesktopHost } from "@/desktop/host"; import { routeTerminalDrop } from "@/terminal/drop/terminal-file-drop"; import { prepareDroppedPathsForTerminal } from "@/terminal/drop/terminal-path"; import { toErrorMessage } from "@/utils/error-messages"; -import { getWorkspaceTerminalSession } from "@/terminal/runtime/workspace-terminal-session"; import { EMPTY_FOCUS_CLAIM_STATE, canRequestFocusClaim, @@ -88,7 +86,6 @@ import { isNative } from "@/constants/platform"; import { applyTerminalRendererReadyChange, resolveTerminalStreamTarget, - shouldReplayTerminalSnapshotForRenderer, shouldShowTerminalLoadingOverlay, type TerminalRendererReadyChange, } from "@/utils/terminal-renderer-readiness"; @@ -316,10 +313,6 @@ export function TerminalPane({ const measuredTerminalSizeRef = useRef<{ rows: number; cols: number } | null>(null); const lastSentTerminalSizeRef = useRef<{ rows: number; cols: number } | null>(null); const streamControllerRef = useRef(null); - const workspaceTerminalSession = useMemo( - () => getWorkspaceTerminalSession({ scopeKey }), - [scopeKey], - ); const [isAttaching, setIsAttaching] = useState(false); const [streamError, setStreamError] = useState(null); const [rendererReadyStreamKey, setRendererReadyStreamKey] = useState(null); @@ -383,7 +376,6 @@ export function TerminalPane({ const keyboardRefitTimeoutsRef = useRef>>([]); const lastAutoFocusKeyRef = useRef(null); const paneFocusResizeClaimRef = useRef(EMPTY_FOCUS_CLAIM_STATE); - const initialSnapshot = workspaceTerminalSession.snapshots.get({ terminalId }); useEffect(() => { terminalIdRef.current = terminalId; @@ -441,16 +433,8 @@ export function TerminalPane({ (change: TerminalRendererReadyChange) => { if (!change.isReady && change.streamKey === terminalStreamKey) findRef.current?.reset(); setRendererReadyStreamKey((current) => applyTerminalRendererReadyChange(current, change)); - if (!shouldReplayTerminalSnapshotForRenderer({ change, terminalStreamKey })) { - return; - } - - const snapshot = workspaceTerminalSession.snapshots.get({ terminalId }); - if (snapshot) { - emulatorRef.current?.renderSnapshot(snapshot); - } }, - [terminalId, terminalStreamKey, workspaceTerminalSession.snapshots], + [terminalStreamKey], ); useEffect(() => { @@ -564,7 +548,6 @@ export function TerminalPane({ ); const handleStreamExit = useStableEvent((exitedTerminalId: string) => { - workspaceTerminalSession.snapshots.clear({ terminalId: exitedTerminalId }); if (terminalIdRef.current === exitedTerminalId) emulatorRef.current?.clear(); setModifiers({ ...EMPTY_MODIFIERS }); }); @@ -604,29 +587,10 @@ export function TerminalPane({ }, ); - const handleStreamRestore = useStableEvent( - ({ terminalId: restoreTerminalId, data }: { terminalId: string; data: Uint8Array }) => { - workspaceTerminalSession.snapshots.clear({ terminalId: restoreTerminalId }); - if (terminalIdRef.current !== restoreTerminalId) { - return; - } - emulatorRef.current?.restoreOutput(data); - }, - ); - - const handleStreamSnapshot = useStableEvent( - ({ terminalId: snapshotTerminalId, state }: { terminalId: string; state: TerminalState }) => { - workspaceTerminalSession.snapshots.set({ terminalId: snapshotTerminalId, state }); - if (terminalIdRef.current !== snapshotTerminalId) { - return; - } - emulatorRef.current?.renderSnapshot(state); - }, - ); - const getStreamRestoreOptions = useStableEvent(() => resolveTerminalRestoreOptions({ supportsTerminalRestoreModes, + scrollbackLines: settings.terminalScrollbackLines, canClaimSize: canRequestFocusClaim({ isWorkspaceFocused: terminalPresentedRef.current, isPaneFocused, @@ -653,8 +617,9 @@ export function TerminalPane({ client, getPreferredSize: getPreferredStreamSize, onOutput: handleStreamOutput, - onRestore: handleStreamRestore, - onSnapshot: handleStreamSnapshot, + onReplay: ({ terminalId: id, replay }) => { + if (terminalIdRef.current === id) emulatorRef.current?.replay(replay); + }, onExit: handleStreamExit, getRestoreOptions: getStreamRestoreOptions, onStatusChange: handleStreamControllerStatus, @@ -674,8 +639,6 @@ export function TerminalPane({ getStreamRestoreOptions, handleStreamControllerStatus, handleStreamOutput, - handleStreamRestore, - handleStreamSnapshot, handleStreamExit, isConnected, ]); @@ -1216,7 +1179,6 @@ export function TerminalPane({ keyboardInset={keyboardInset} isKeyboardVisible={isKeyboardVisible} swipeGesturesEnabled={swipeGesturesEnabled} - initialSnapshot={initialSnapshot} onRendererReadyChange={handleRendererReadyChange} onSwipeRight={handleSwipeRight} onSwipeLeft={handleSwipeLeft} diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 7c9e13cce1..8d7c2a2950 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -617,6 +617,7 @@ export const ar: TranslationResources = { terminal: { hostDisconnected: "Host غير متصل", updateHost: "حدّث المضيف لاستخدام عارض الطرفية الأصلي.", + restoreRequiresUpdate: "حدّث المضيف لعرض الطرفيات.", unableToSubscribe: "غير قادر على الاشتراك في المحطة", fileUploaded: "تم رفع الملف", pasteFileFailed: "تعذر لصق الملف: {{reason}}", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 7022dc9a3d..1f61a83f12 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -614,6 +614,7 @@ export const en = { terminal: { hostDisconnected: "Host is not connected", updateHost: "Update the host to use the native terminal renderer.", + restoreRequiresUpdate: "Update the host to view terminals.", unableToSubscribe: "Unable to subscribe to terminal", fileUploaded: "File uploaded", pasteFileFailed: "Failed to paste file: {{reason}}", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 1c2c07ade0..7e5a1d41e6 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -623,6 +623,7 @@ export const es: TranslationResources = { terminal: { hostDisconnected: "Hostno está conectado", updateHost: "Actualiza el host para usar el renderizador de terminal nativo.", + restoreRequiresUpdate: "Actualiza el host para ver las terminales.", unableToSubscribe: "No se puede suscribir al terminal", fileUploaded: "Archivo subido", pasteFileFailed: "No se pudo pegar el archivo: {{reason}}", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index bf1c3b6f5e..551f4a4158 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -623,6 +623,7 @@ export const fr: TranslationResources = { terminal: { hostDisconnected: "L’hôte n’est pas connecté", updateHost: "Mettez à jour l’hôte pour utiliser le rendu natif du terminal.", + restoreRequiresUpdate: "Mettez à jour l’hôte pour afficher les terminaux.", unableToSubscribe: "Impossible de s’abonner au terminal", fileUploaded: "Fichier envoyé", pasteFileFailed: "Impossible de coller le fichier : {{reason}}", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 5147134716..4b1b8b6f64 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -623,6 +623,7 @@ export const ja: TranslationResources = { terminal: { hostDisconnected: "ホストが接続されていません", updateHost: "ネイティブターミナルを使用するにはホストを更新してください。", + restoreRequiresUpdate: "ターミナルを表示するにはホストを更新してください。", unableToSubscribe: "ターミナルに接続できません", fileUploaded: "ファイルをアップロードしました", pasteFileFailed: "ファイルを貼り付けられませんでした: {{reason}}", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 632bd1eeed..b9e33b4eba 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -618,6 +618,7 @@ export const ko: TranslationResources = { terminal: { hostDisconnected: "호스트가 연결되어 있지 않습니다", updateHost: "기본 터미널 렌더러를 사용하도록 호스트를 업데이트합니다.", + restoreRequiresUpdate: "터미널을 보려면 호스트를 업데이트하세요.", unableToSubscribe: "터미널을 구독할 수 없습니다", fileUploaded: "파일을 업로드했습니다", pasteFileFailed: "파일을 붙여넣지 못했습니다: {{reason}}", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index a80ded2f45..2fa6c30c8e 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -622,6 +622,7 @@ export const ptBR: TranslationResources = { terminal: { hostDisconnected: "Host não está conectado", updateHost: "Atualize o host para usar o renderizador de terminal nativo.", + restoreRequiresUpdate: "Atualize o host para visualizar os terminais.", unableToSubscribe: "Não foi possível assinar o terminal", fileUploaded: "Arquivo enviado", pasteFileFailed: "Falha ao colar o arquivo: {{reason}}", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index d4a23a4770..9a6eb5b0e2 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -624,6 +624,7 @@ export const ru: TranslationResources = { terminal: { hostDisconnected: "Хост не подключён", updateHost: "Обновите хост, чтобы использовать нативный рендерер терминала.", + restoreRequiresUpdate: "Обновите хост, чтобы просматривать терминалы.", unableToSubscribe: "Не удалось подключиться к терминалу", fileUploaded: "Файл загружен", pasteFileFailed: "Не удалось вставить файл: {{reason}}", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 2ce5f7bb9e..553d8212b4 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -617,6 +617,7 @@ export const zhCN: TranslationResources = { terminal: { hostDisconnected: "Host 未连接", updateHost: "请更新主机以使用原生终端渲染器。", + restoreRequiresUpdate: "更新主机以查看终端。", unableToSubscribe: "无法订阅 Terminal", fileUploaded: "文件已上传", pasteFileFailed: "无法粘贴文件:{{reason}}", diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 0b1cfe07ab..dd806e1fa1 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -127,7 +127,6 @@ import { useWorkspaceSetupStore, } from "@/stores/workspace-setup-store"; import { useWorkspace } from "@/stores/session-store-hooks"; -import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention"; import type { CheckoutStatusPayload } from "@/git/use-status-query"; import { confirmDialog } from "@/utils/confirm-dialog"; import { useArchiveAgent } from "@/hooks/use-archive-agent"; @@ -881,13 +880,6 @@ function shouldShowWorkspaceScreenHeader(input: { return !input.isFocusModeEnabled || input.isMobile; } -function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string): string | null { - if (!serverId || !workspaceId) { - return null; - } - return `${serverId}:${workspaceId}`; -} - /** * A pane the user acted inside owns the tab: it opens there, and an existing tab * moves there. No pane means the open has no opinion beyond the focused pane. @@ -1066,14 +1058,6 @@ function WorkspaceScreenContent({ const { handleRetryHost, handleManageHost, handleDismissMissingWorkspace } = useWorkspaceRouteActions(normalizedServerId); - const workspaceTerminalScopeKey = useMemo( - () => buildWorkspaceTerminalScopeKey(normalizedServerId, normalizedWorkspaceId), - [normalizedServerId, normalizedWorkspaceId], - ); - useWorkspaceTerminalSessionRetention({ - scopeKey: workspaceTerminalScopeKey, - }); - const client = useHostRuntimeClient(normalizedServerId); const isConnected = useHostRuntimeIsConnected(normalizedServerId); const supportsProvidersSnapshot = useSessionStore( diff --git a/packages/app/src/terminal/hooks/use-workspace-terminal-session-retention.ts b/packages/app/src/terminal/hooks/use-workspace-terminal-session-retention.ts deleted file mode 100644 index 506a5baab3..0000000000 --- a/packages/app/src/terminal/hooks/use-workspace-terminal-session-retention.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { useEffect } from "react"; -import { - releaseWorkspaceTerminalSession, - retainWorkspaceTerminalSession, -} from "@/terminal/runtime/workspace-terminal-session"; - -export function useWorkspaceTerminalSessionRetention(input: { scopeKey: string | null }): void { - useEffect(() => { - if (!input.scopeKey) { - return; - } - - retainWorkspaceTerminalSession({ scopeKey: input.scopeKey }); - return () => { - releaseWorkspaceTerminalSession({ scopeKey: input.scopeKey! }); - }; - }, [input.scopeKey]); -} diff --git a/packages/app/src/terminal/native-renderer/headless-terminal-output-drain.test.ts b/packages/app/src/terminal/native-renderer/headless-terminal-output-drain.test.ts index fb94588a01..4d238b98c6 100644 --- a/packages/app/src/terminal/native-renderer/headless-terminal-output-drain.test.ts +++ b/packages/app/src/terminal/native-renderer/headless-terminal-output-drain.test.ts @@ -1,8 +1,8 @@ +import { createTerminalResizePolicy, updateTerminalResizePolicy } from "./terminal-resize-policy"; import { describe, expect, test } from "vitest"; import { createNativeHeadlessTerminal, - type TerminalCellRow, type TerminalViewportState, } from "./headless-terminal-state"; import { createNativeTerminalBenchmarkPayload } from "./native-terminal-benchmark-payload"; @@ -11,7 +11,8 @@ import { NATIVE_TERMINAL_PARSE_CHUNK_CHARS, } from "./headless-terminal-output-drain"; import { createNativeTerminalScreenModel, type TerminalScreenState } from "./terminal-screen-model"; -import { renderTerminalSnapshotToAnsi } from "../runtime/terminal-snapshot"; +import { Terminal as ReplaySource } from "@xterm/headless"; +import { SerializeAddon } from "@xterm/addon-serialize"; const ROWS = 12; const COLS = 80; @@ -41,33 +42,6 @@ function createFrameRecorder() { }; } -function lineCells(line: string, cols: number): TerminalCellRow { - const cells: TerminalCellRow = []; - for (let index = 0; index < cols; index += 1) { - cells.push({ char: line[index] ?? " ", width: 1 }); - } - return cells; -} - -function terminalStateFromLines(input: { - lines: string[]; - rows: number; - cols: number; - cursor: TerminalViewportState["cursor"]; -}) { - const grid: TerminalCellRow[] = []; - for (let row = 0; row < input.rows; row += 1) { - grid.push(lineCells(input.lines[row] ?? "", input.cols)); - } - return { - rows: input.rows, - cols: input.cols, - grid, - scrollback: [], - cursor: input.cursor, - }; -} - async function runScheduledWork(input: { callbacks: Array<() => void>; flush: Promise; @@ -80,11 +54,13 @@ async function runScheduledWork(input: { } async function benchmarkSnapshot(lineCount: number) { - const terminal = createNativeHeadlessTerminal({ rows: ROWS, cols: COLS, scrollbackLines: 100 }); - await terminal.write( - createNativeTerminalBenchmarkPayload({ startLine: 0, lineCount, cols: COLS }), - ); - return terminal.getState(); + return { + rows: ROWS, + cols: COLS, + data: new TextEncoder().encode( + createNativeTerminalBenchmarkPayload({ startLine: 0, lineCount, cols: COLS }), + ), + }; } function createScreenRestorer( @@ -94,6 +70,7 @@ function createScreenRestorer( const frameRecorder = createFrameRecorder(); let screen: TerminalScreenState | null = null; const drain = createNativeTerminalOutputDrain({ + restoreSize: () => {}, write: (chunk) => terminal.write(chunk), reset: () => terminal.reset(), getViewportState: () => { @@ -107,7 +84,12 @@ function createScreenRestorer( return { async restore(snapshot: Awaited>) { - drain.restoreSnapshot(snapshot); + drain.replay({ type: "begin", rows: snapshot.rows, cols: snapshot.cols }); + drain.replay({ + type: "data", + data: snapshot.data, + }); + drain.replay({ type: "end" }); await drain.flush(); frameRecorder.frames[0]?.(); return screen; @@ -122,6 +104,7 @@ describe("native terminal output drain", () => { const frameRecorder = createFrameRecorder(); const payload = "x".repeat(NATIVE_TERMINAL_PARSE_CHUNK_CHARS * 2); const drain = createNativeTerminalOutputDrain({ + restoreSize: () => {}, write: async (chunk) => { writes.push(chunk); }, @@ -170,6 +153,7 @@ describe("native terminal output drain", () => { const paintedStates: TerminalViewportState[] = []; const frameRecorder = createFrameRecorder(); const drain = createNativeTerminalOutputDrain({ + restoreSize: () => {}, write: (chunk) => terminal.write(chunk), reset: () => terminal.reset(), getViewportState: () => terminal.getViewportState(), @@ -202,6 +186,7 @@ describe("native terminal output drain", () => { const scheduledWork: Array<() => void> = []; const frameRecorder = createFrameRecorder(); const drain = createNativeTerminalOutputDrain({ + restoreSize: () => {}, write: (chunk) => terminal.write(chunk), reset: () => terminal.reset(), getViewportState: () => terminal.getViewportState(), @@ -210,21 +195,22 @@ describe("native terminal output drain", () => { scheduleFrame: frameRecorder.scheduleFrame, cancelFrame: frameRecorder.cancelFrame, }); - const restoredSnapshot = terminalStateFromLines({ - rows: 4, - cols: 32, - lines: ["restored-one", "restored-two"], - cursor: { row: 1, col: "restored-two".length }, - }); + const restoredCursor = { row: 1, col: "restored-two".length }; + const replay = new TextEncoder().encode("restored-one\r\nrestored-two"); await terminal.write("old-one\r\nold-two\r\nold-three\r\n"); drain.enqueueText("stale-queued\r\n"); - drain.restoreText(renderTerminalSnapshotToAnsi(restoredSnapshot)); + drain.replay({ type: "begin" }); + drain.replay({ + type: "data", + data: replay, + }); + drain.replay({ type: "end" }); await runScheduledWork({ callbacks: scheduledWork, flush: drain.flush() }); const state = terminal.getViewportState(); expect(state.grid.map(rowText)).toEqual(["restored-one", "restored-two", "", ""]); - expect(state.cursor).toEqual(restoredSnapshot.cursor); + expect(state.cursor).toEqual(restoredCursor); }); test("snapshot restore preserves a scrolled viewport anchor while replacing terminal state", async () => { @@ -242,7 +228,7 @@ describe("native terminal output drain", () => { paintedMode: screen?.scroll.mode, paintedTopRow: rowText(screen?.viewport.grid[0] ?? []), beforeTopRow, - restoredBottomRows: terminal.getState().grid.slice(-2).map(rowText), + restoredBottomRows: terminal.getViewportState().grid.slice(-2).map(rowText), }).toEqual({ paintedMode: "scrolled", paintedTopRow: beforeTopRow, @@ -268,3 +254,255 @@ describe("native terminal output drain", () => { }); }); }); + +test("replays split Unicode and ANSI with one reset then live output", async () => { + const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 40, scrollbackLines: 1000 }); + let resets = 0; + const drain = createNativeTerminalOutputDrain({ + restoreSize: () => {}, + write: (text) => terminal.write(text), + reset: (size) => { + resets++; + if (size) terminal.resize(size); + terminal.reset(); + }, + getViewportState: () => terminal.getViewportState(), + onPaint: () => {}, + }); + try { + drain.replay({ type: "begin", rows: 4, cols: 40, retainedHistoryLines: 400 }); + const bytes = new TextEncoder().encode("\x1b[31m界🙂 replay\x1b[0m\r\n"); + for (let i = 0; i < bytes.length; i++) + drain.replay({ type: "data", data: bytes.subarray(i, i + 1) }); + drain.replay({ type: "end" }); + drain.enqueueText("live"); + await drain.flush(); + expect(resets).toBe(1); + expect( + terminal + .getBufferWindow({ startRow: 0, rowCount: 4 }) + .rows.map((row) => + row + .filter((cell) => cell.width !== 0) + .map((cell) => cell.char) + .join(""), + ) + .join("\n"), + ).toContain("界🙂 replay"); + expect( + terminal + .getBufferWindow({ startRow: 0, rowCount: 4 }) + .rows.map((row) => + row + .filter((cell) => cell.width !== 0) + .map((cell) => cell.char) + .join(""), + ) + .join("\n"), + ).toContain("live"); + } finally { + drain.dispose(); + terminal.dispose(); + } +}); + +test("an empty replacement replay resets after an in-flight write finishes", async () => { + let finish!: () => void; + let started!: () => void; + const writing = new Promise((resolve) => { + started = resolve; + }); + const blocked = new Promise((resolve) => { + finish = resolve; + }); + let resets = 0; + const drain = createNativeTerminalOutputDrain({ + restoreSize: () => {}, + write: async () => { + started(); + await blocked; + }, + reset: () => { + resets++; + }, + getViewportState: () => null, + onPaint: () => {}, + }); + try { + drain.enqueueText("old terminal"); + await writing; + drain.replay({ type: "begin" }); + drain.replay({ type: "end" }); + finish(); + await drain.flush(); + expect(resets).toBe(1); + expect(drain.getQueuedChars()).toBe(0); + } finally { + finish(); + drain.dispose(); + } +}); + +test("replay geometry returns to measured layout before live writes and unchanged-layout focus", async () => { + const measured = { rows: 24, cols: 40 }; + const terminal = createNativeHeadlessTerminal({ ...measured, scrollbackLines: 1000 }); + const policy = createTerminalResizePolicy(measured); + const sizes: number[] = []; + const drain = createNativeTerminalOutputDrain({ + write: async (text) => { + sizes.push(terminal.getViewportState().cols); + await terminal.write(text); + }, + reset: (size) => { + if (size) terminal.resize(size); + terminal.reset(); + }, + restoreSize: () => terminal.resize(measured), + getViewportState: () => terminal.getViewportState(), + onPaint: () => {}, + }); + try { + drain.replay({ type: "begin", rows: 30, cols: 120 }); + drain.replay({ type: "data", data: new TextEncoder().encode("x".repeat(100) + "\r\n") }); + drain.replay({ type: "end" }); + drain.enqueueText("LIVE-" + "y".repeat(60)); + await drain.flush(); + expect(sizes).toEqual([120, 40]); + const focus = updateTerminalResizePolicy(policy, { + source: "claim", + size: measured, + claimToken: 1, + }); + expect(focus.measuredSizeChanged).toBe(false); + expect(focus.resizeClaim).toMatchObject(measured); + expect(terminal.getViewportState()).toMatchObject(measured); + expect(terminal.getBufferBounds().cols).toBe(40); + const lines = terminal + .getViewportState() + .grid.map((row) => + row + .map((cell) => cell.char) + .join("") + .trimEnd(), + ) + .join("\n"); + expect(lines).toContain("LIVE-" + "y".repeat(35) + "\n" + "y".repeat(25)); + } finally { + drain.dispose(); + terminal.dispose(); + } +}); + +test.each(["clear", "replacement", "dispose"] as const)( + "cancels queued replay geometry on %s", + async (action) => { + const local = { rows: 24, cols: 40 }; + const terminal = createNativeHeadlessTerminal({ ...local, scrollbackLines: 1000 }); + let finish!: () => void; + const block = new Promise((resolve) => { + finish = resolve; + }); + let started!: () => void; + const writing = new Promise((resolve) => { + started = resolve; + }); + let first = true; + let restores = 0; + const drain = createNativeTerminalOutputDrain({ + restoreSize: () => { + restores++; + terminal.resize(local); + }, + reset: (size) => { + if (size) terminal.resize(size); + terminal.reset(); + }, + write: async (text) => { + await terminal.write(text); + if (first) { + first = false; + started(); + await block; + } + }, + getViewportState: () => terminal.getViewportState(), + onPaint: () => {}, + }); + try { + drain.replay({ type: "begin", rows: 30, cols: 120 }); + drain.replay({ type: "data", data: new TextEncoder().encode("old") }); + await writing; + local.cols = 60; + drain.resize(); + expect(terminal.getViewportState().cols).toBe(120); + drain.replay({ type: "end" }); + if (action === "clear") drain.clear(); + else if (action === "dispose") { + drain.dispose(); + drain.clear(); + } else { + drain.replay({ type: "begin", rows: 20, cols: 80 }); + drain.replay({ type: "data", data: new TextEncoder().encode("new") }); + drain.replay({ type: "end" }); + } + finish(); + await drain.flush(); + expect(restores).toBe(action === "dispose" ? 0 : 1); + if (action !== "dispose") expect(terminal.getViewportState()).toMatchObject(local); + } finally { + finish(); + drain.dispose(); + terminal.dispose(); + } + }, +); + +test("holds captured geometry across mid-replay measurement changes", async () => { + const source = new ReplaySource({ rows: 4, cols: 8, scrollback: 100, allowProposedApi: true }); + const serializer = new SerializeAddon(); + source.loadAddon(serializer); + await new Promise((resolve) => + source.write("ABCDEFGH\r\n12345678\r\nabcdefgh\r\nxyz\x1b[2;8H", resolve), + ); + const ansi = new TextEncoder().encode(serializer.serialize()); + const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 8, scrollbackLines: 100 }); + const expected = createNativeHeadlessTerminal({ rows: 4, cols: 8, scrollbackLines: 100 }); + await expected.write(new TextDecoder().decode(ansi)); + expected.resize({ rows: 4, cols: 4 }); + let measured = { rows: 4, cols: 8 }; + const drain = createNativeTerminalOutputDrain({ + write: (chunk) => terminal.write(chunk), + reset: (size) => { + if (size) terminal.resize(size); + terminal.reset(); + }, + restoreSize: () => terminal.resize(measured), + getViewportState: () => terminal.getViewportState(), + onPaint: () => {}, + }); + try { + drain.replay({ type: "begin", rows: 4, cols: 8 }); + await drain.flush(); + measured = { rows: 4, cols: 4 }; + drain.resize(); + drain.replay({ type: "data", data: ansi }); + drain.replay({ type: "end" }); + await drain.flush(); + expect(terminal.getViewportState()).toEqual(expected.getViewportState()); + const actualBounds = terminal.getBufferBounds(); + const expectedBounds = expected.getBufferBounds(); + expect( + terminal.getBufferWindow({ + startRow: actualBounds.oldestRow, + rowCount: actualBounds.newestRow - actualBounds.oldestRow + 1, + }), + ).toEqual( + expected.getBufferWindow({ + startRow: expectedBounds.oldestRow, + rowCount: expectedBounds.newestRow - expectedBounds.oldestRow + 1, + }), + ); + } finally { + drain.dispose(); + } +}); diff --git a/packages/app/src/terminal/native-renderer/headless-terminal-output-drain.ts b/packages/app/src/terminal/native-renderer/headless-terminal-output-drain.ts index d853165371..00b9865be9 100644 --- a/packages/app/src/terminal/native-renderer/headless-terminal-output-drain.ts +++ b/packages/app/src/terminal/native-renderer/headless-terminal-output-drain.ts @@ -1,5 +1,3 @@ -import type { TerminalState } from "@getpaseo/protocol/messages"; -import { renderTerminalSnapshotToAnsi } from "../runtime/terminal-snapshot"; import type { TerminalViewportState } from "./headless-terminal-state"; import { nativeTerminalPerformanceNow } from "./terminal-performance"; @@ -14,9 +12,9 @@ export interface NativeTerminalParseSample { } export interface NativeTerminalOutputDrain { + replay(event: import("@getpaseo/client").TerminalReplayEvent): void; enqueueText(text: string): void; - restoreText(text: string): void; - restoreSnapshot(snapshot: TerminalState): void; + resize(): void; clear(): void; flush(): Promise; dispose(): void; @@ -25,7 +23,8 @@ export interface NativeTerminalOutputDrain { export interface NativeTerminalOutputDrainOptions { write: (chunk: string) => Promise; - reset: () => void; + restoreSize: () => void; + reset: (size?: { rows: number; cols: number }) => void; getViewportState: () => TerminalViewportState | null; onPaint: (state: TerminalViewportState) => void; onParse?: (sample: NativeTerminalParseSample) => void; @@ -68,16 +67,22 @@ export function createNativeTerminalOutputDrain( const chunkChars = options.chunkChars ?? NATIVE_TERMINAL_PARSE_CHUNK_CHARS; const chunksPerTurn = options.chunksPerTurn ?? NATIVE_TERMINAL_PARSE_CHUNKS_PER_TURN; const idleWaiters: Array<() => void> = []; - let pendingText = ""; + type Operation = + | { type: "write"; text: string } + | { type: "reset"; size?: { rows: number; cols: number } } + | { type: "restore-size" }; + let operations: Operation[] = []; + let queuedChars = 0; let drainScheduled = false; let draining = false; let disposed = false; + let replaying = false; let paintFrame: number | null = null; let generation = 0; - let resetBeforeNextWrite = false; + const replayDecoder = new TextDecoder(); function resolveIdleIfReady(): void { - if (drainScheduled || draining || pendingText.length > 0) { + if (drainScheduled || draining || operations.length > 0) { return; } while (idleWaiters.length > 0) { @@ -119,15 +124,6 @@ export function createNativeTerminalOutputDrain( }); } - function replacePendingText(text: string): void { - generation += 1; - pendingText = text; - resetBeforeNextWrite = true; - cancelPendingPaint(); - scheduleDrain(); - resolveIdleIfReady(); - } - async function drainPending(): Promise { if (disposed || draining) { return; @@ -138,16 +134,23 @@ export function createNativeTerminalOutputDrain( let parsedChunks = 0; try { - while (pendingText.length > 0 && parsedChunks < chunksPerTurn) { - if (disposed || activeGeneration !== generation) { - return; + while (operations.length > 0 && parsedChunks < chunksPerTurn) { + if (disposed || activeGeneration !== generation) return; + const operation = operations[0]!; + if (operation.type !== "write") { + operations.shift(); + if (operation.type === "reset") options.reset(operation.size); + else { + options.restoreSize(); + replaying = false; + } + requestPaint(); + continue; } - if (resetBeforeNextWrite) { - options.reset(); - resetBeforeNextWrite = false; - } - const chunk = pendingText.slice(0, chunkChars); - pendingText = pendingText.slice(chunk.length); + const chunk = operation.text.slice(0, chunkChars); + operation.text = operation.text.slice(chunk.length); + queuedChars -= chunk.length; + if (!operation.text) operations.shift(); const parseStart = nativeTerminalPerformanceNow(); await options.write(chunk); if (disposed || activeGeneration !== generation) { @@ -156,14 +159,14 @@ export function createNativeTerminalOutputDrain( options.onParse?.({ durationMs: nativeTerminalPerformanceNow() - parseStart, parsedChars: chunk.length, - queuedChars: pendingText.length, + queuedChars, }); requestPaint(); parsedChunks += 1; } } finally { draining = false; - if (!disposed && pendingText.length > 0) { + if (!disposed && operations.length > 0) { scheduleDrain(); } resolveIdleIfReady(); @@ -171,38 +174,56 @@ export function createNativeTerminalOutputDrain( } return { - enqueueText(text: string): void { - if (disposed || text.length === 0) { - return; + replay(event): void { + if (disposed) return; + if (event.type === "begin") { + replaying = true; + generation++; + replayDecoder.decode(); + operations = [ + { + type: "reset", + size: event.rows && event.cols ? { rows: event.rows, cols: event.cols } : undefined, + }, + ]; + queuedChars = 0; + cancelPendingPaint(); + } else if (event.type === "data") { + this.enqueueText(replayDecoder.decode(event.data, { stream: true })); + } else { + this.enqueueText(replayDecoder.decode()); + operations.push({ type: "restore-size" }); } - pendingText += text; + scheduleDrain(); + }, + enqueueText(text: string): void { + if (disposed || !text) return; + const last = operations.at(-1); + if (last?.type === "write") last.text += text; + else operations.push({ type: "write", text }); + queuedChars += text.length; scheduleDrain(); }, - restoreText(text: string): void { - if (disposed) { - return; - } - replacePendingText(text); - }, - - restoreSnapshot(snapshot: TerminalState): void { - if (disposed) { - return; - } - replacePendingText(renderTerminalSnapshotToAnsi(snapshot)); + resize(): void { + if (disposed || replaying) return; + operations.push({ type: "restore-size" }); + scheduleDrain(); }, clear(): void { + if (disposed) return; generation += 1; - pendingText = ""; - resetBeforeNextWrite = false; + operations = [{ type: "restore-size" }, { type: "reset" }]; + queuedChars = 0; + replayDecoder.decode(); cancelPendingPaint(); + scheduleDrain(); resolveIdleIfReady(); }, flush(): Promise { - if (!drainScheduled && !draining && pendingText.length === 0) { + if (!drainScheduled && !draining && operations.length === 0) { return Promise.resolve(); } scheduleDrain(); @@ -214,8 +235,8 @@ export function createNativeTerminalOutputDrain( dispose(): void { disposed = true; generation += 1; - pendingText = ""; - resetBeforeNextWrite = false; + operations = []; + queuedChars = 0; cancelPendingPaint(); while (idleWaiters.length > 0) { idleWaiters.shift()?.(); @@ -223,7 +244,7 @@ export function createNativeTerminalOutputDrain( }, getQueuedChars(): number { - return pendingText.length; + return queuedChars; }, }; } 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 c25399c9b2..5fd87f7315 100644 --- a/packages/app/src/terminal/native-renderer/headless-terminal-state.ts +++ b/packages/app/src/terminal/native-renderer/headless-terminal-state.ts @@ -21,7 +21,6 @@ export interface NativeHeadlessTerminalOptions { export interface NativeHeadlessTerminal { write(data: NativeTerminalWriteData): Promise; - getState(): TerminalState; getViewportState(input?: TerminalViewportStateInput): TerminalViewportState; getInputModeState(): NativeHeadlessTerminalInputModeState; getBufferBounds(): TerminalBufferBounds; @@ -165,35 +164,6 @@ function extractRow( return rowCells; } -function extractGrid(terminal: HeadlessTerminalInstance): TerminalCellRow[] { - const grid: TerminalCellRow[] = []; - const baseY = terminal.buffer.active.baseY; - const reusableCell = terminal.buffer.active.getNullCell(); - for (let row = 0; row < terminal.rows; row += 1) { - grid.push(extractRow(terminal, baseY + row, reusableCell)); - } - return grid; -} - -function extractScrollback( - terminal: HeadlessTerminalInstance, - options: { scrollbackLines?: number }, -): TerminalCellRow[] { - const scrollback: TerminalCellRow[] = []; - const scrollbackLines = terminal.buffer.active.baseY; - const startRow = - typeof options.scrollbackLines === "number" - ? Math.max(0, scrollbackLines - options.scrollbackLines) - : 0; - const reusableCell = terminal.buffer.active.getNullCell(); - - for (let row = startRow; row < scrollbackLines; row += 1) { - scrollback.push(extractRow(terminal, row, reusableCell)); - } - - return scrollback; -} - function extractBufferWindow( terminal: HeadlessTerminalInstance, oldestRow: number, @@ -290,19 +260,6 @@ function extractInputModeState( }; } -function extractState( - terminal: HeadlessTerminalInstance, - options: { scrollbackLines?: number }, -): TerminalState { - return { - rows: terminal.rows, - cols: terminal.cols, - grid: extractGrid(terminal), - scrollback: extractScrollback(terminal, options), - cursor: extractCursorState(terminal), - }; -} - function extractViewportState( terminal: HeadlessTerminalInstance, coordinateEpoch: number, @@ -380,9 +337,6 @@ export function createNativeHeadlessTerminal( }); }); }, - getState(): TerminalState { - return extractState(terminal, { scrollbackLines: options.scrollbackLines }); - }, getViewportState(input?: TerminalViewportStateInput): TerminalViewportState { return extractViewportState( terminal, 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 129a7d9082..ea8b7a0cf3 100644 --- a/packages/app/src/terminal/native-renderer/terminal-frame.test.ts +++ b/packages/app/src/terminal/native-renderer/terminal-frame.test.ts @@ -10,6 +10,8 @@ import { } from "./colors"; import { createNativeHeadlessTerminal } from "./headless-terminal-state"; import { buildTerminalFrame } from "./terminal-frame"; +import { createNativeTerminalOutputDrain } from "./headless-terminal-output-drain"; +import { resolveMeasuredNativeTerminalSize } from "./terminal-size-measurement"; const resolver = createTerminalCellStyleResolver(DEFAULT_TERMINAL_THEME); const RED = parseTerminalColor(DEFAULT_TERMINAL_THEME.red, 0); @@ -197,3 +199,48 @@ describe("native terminal frame", () => { expect(actual).toEqual(graphemeRows); }); }); + +test("builds native frames after replay commits at captured geometry and the latest measurement precedes live text", async () => { + let measured = resolveMeasuredNativeTerminalSize({ + layout: { width: 400, height: 240 }, + metrics: { cellWidth: 10, cellHeight: 10 }, + })!; + const terminal = createNativeHeadlessTerminal({ ...measured, scrollbackLines: 10000 }); + const parsedColumns: number[] = []; + const drain = createNativeTerminalOutputDrain({ + write: async (text) => { + parsedColumns.push(terminal.getViewportState().cols); + await terminal.write(text); + }, + reset: (size) => { + if (size) terminal.resize(size); + terminal.reset(); + }, + restoreSize: () => terminal.resize(measured), + getViewportState: () => terminal.getViewportState(), + onPaint: () => {}, + }); + try { + drain.replay({ type: "begin", rows: 30, cols: 120 }); + drain.replay({ type: "data", data: new TextEncoder().encode("x".repeat(100) + "\r\n") }); + measured = resolveMeasuredNativeTerminalSize({ + layout: { width: 500, height: 100 }, + metrics: { cellWidth: 10, cellHeight: 10 }, + })!; + drain.resize(); + drain.replay({ type: "end" }); + drain.enqueueText("\x1b[H\x1b[2K\x1b[31mLIVE-界🙂\x1b[0m"); + await drain.flush(); + expect(parsedColumns).toEqual([120, 50]); + const state = terminal.getViewportState(); + const frame = buildTerminalFrame({ state, columns: state.cols, resolver, selection: null }); + 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(encodeTerminalFrame(frame).rows[0]).toContain("LIVE-界🙂"); + } finally { + drain.dispose(); + terminal.dispose(); + } +}); diff --git a/packages/app/src/terminal/native-renderer/terminal-scrollback.test.ts b/packages/app/src/terminal/native-renderer/terminal-scrollback.test.ts index 3464bcd973..6bacdc0472 100644 --- a/packages/app/src/terminal/native-renderer/terminal-scrollback.test.ts +++ b/packages/app/src/terminal/native-renderer/terminal-scrollback.test.ts @@ -54,16 +54,19 @@ describe("native terminal scrollback retention", () => { scrollbackLines: SCROLLBACK, lineCount: SCROLLBACK + ROWS + 10, }); - const state = terminal.getState(); const bounds = terminal.getBufferBounds(); - expect(state.scrollback.length).toBeGreaterThan(0); - expect(state.scrollback.length).toBeLessThanOrEqual(SCROLLBACK); + const historyRows = bounds.bottomViewport.firstRow - bounds.oldestRow; + expect(historyRows).toBeGreaterThan(0); + expect(historyRows).toBeLessThanOrEqual(SCROLLBACK); expect(bounds.newestRow).toBeGreaterThan(ROWS); expect(bounds.oldestRow).toBeGreaterThan(0); - expect(bounds.newestRow - bounds.oldestRow + 1).toBe( - state.scrollback.length + state.grid.length, - ); + const retained = terminal.getBufferWindow({ + startRow: bounds.oldestRow, + rowCount: SCROLLBACK + ROWS + 1, + }); + expect(retained.rows).toHaveLength(SCROLLBACK + ROWS); + expect(rowText(retained.rows[0]!)).toBe(expectedBenchmarkLine(bounds.oldestRow)); }); test("observable scrollback count is bounded by configured lines", async () => { @@ -73,9 +76,9 @@ describe("native terminal scrollback retention", () => { scrollbackLines: SCROLLBACK, lineCount: SCROLLBACK * 3, }); - const state = terminal.getState(); - expect(state.scrollback.length).toBe(SCROLLBACK); + const bounds = terminal.getBufferBounds(); + expect(bounds.bottomViewport.firstRow - bounds.oldestRow).toBe(SCROLLBACK); }); test("scroll up moves viewport into retained history", async () => { 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 5dcefb8ebf..7f54361265 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 @@ -166,7 +166,6 @@ function createTerminalHost(input: CreateTerminalHostInput): MountedTerminal { runtime.mount({ root, host, - initialSnapshot: null, scrollback: input.scrollback ?? 10_000, theme: { background: "#0b0b0b", @@ -750,30 +749,11 @@ describe("terminal emulator runtime in a real browser", () => { const reset = vi.fn(originalReset); terminal.reset = reset; - mounted.runtime.renderSnapshot({ - state: { - rows: terminal.rows, - cols: terminal.cols, - scrollback: [], - grid: [ - [ - { char: "p" }, - { char: "r" }, - { char: "o" }, - { char: "m" }, - { char: "p" }, - { char: "t" }, - ], - ], - cursor: { - row: 0, - col: 6, - }, - }, - }); - await nextFrame(); - + mounted.runtime.replay({ type: "begin", rows: terminal.rows, cols: terminal.cols }); + mounted.runtime.replay({ type: "data", data: terminalOutput("prompt") }); + mounted.runtime.replay({ type: "end" }); expect(reset).not.toHaveBeenCalled(); + await nextFrame(); }); it("opens OSC 8 hyperlinks the same way as plain-text URLs", async () => { @@ -795,3 +775,95 @@ describe("terminal emulator runtime in a real browser", () => { expect(confirm).not.toHaveBeenCalled(); }); }); + +describe("incremental replay lifecycle", () => { + it.each(["clear", "remount"] as const)( + "recovers live input after interrupted replay and %s", + async (cleanup) => { + const mounted = createTerminalHost({ width: 720, height: 360 }); + mounted.runtime.replay({ type: "begin", rows: 30, cols: 120 }); + mounted.root.style.width = "360px"; + mounted.runtime.resize({ shouldClaim: false }); + mounted.runtime.replay({ type: "data", data: terminalOutput("interrupted") }); + if (cleanup === "clear") + await new Promise((resolve) => mounted.runtime.clear({ onCommitted: resolve })); + else { + mounted.runtime.unmount(); + mounted.runtime.mount({ + root: mounted.root, + host: mounted.host, + scrollback: 1000, + theme: {}, + }); + } + await new Promise((resolve) => + mounted.runtime.write({ data: terminalOutput("live"), onCommitted: resolve }), + ); + getBrowserTerminal().input("user-input", true); + expect(mounted.inputs).toContain("user-input"); + expect(getBrowserTerminal().cols).toBeLessThan(60); + }, + ); + + it("suppresses generated replies until queued replay chunks commit after end", async () => { + const mounted = createTerminalHost({ width: 720, height: 360 }); + mounted.runtime.replay({ type: "begin" }); + mounted.runtime.replay({ type: "data", data: terminalOutput("\x1b]4;1;?\x07") }); + mounted.runtime.replay({ type: "data", data: terminalOutput("\x1b]4;1;?\x07") }); + mounted.runtime.replay({ type: "end" }); + await new Promise((resolve) => + mounted.runtime.write({ data: terminalOutput("committed"), onCommitted: resolve }), + ); + expect(mounted.inputs).toEqual([]); + await new Promise((resolve) => + mounted.runtime.write({ data: terminalOutput("\x1b]4;1;?\x07"), onCommitted: resolve }), + ); + expect(mounted.inputs).toHaveLength(1); + expect(mounted.inputs[0]?.startsWith("\x1b]4;1;rgb:")).toBe(true); + }); +}); + +it("restores fitted web geometry after replay before live output", async () => { + const mounted = createTerminalHost({ width: 720, height: 360 }); + await settleMountRefits(); + const local = latestSize(mounted.sizes); + mounted.runtime.replay({ type: "begin", rows: 30, cols: 120 }); + mounted.runtime.replay({ type: "data", data: terminalOutput("x".repeat(100)) }); + mounted.runtime.replay({ type: "end" }); + await new Promise((resolve) => + mounted.runtime.write({ data: terminalOutput("LIVE"), onCommitted: resolve }), + ); + expect(getBrowserTerminal().cols).toBe(latestSize(mounted.sizes).cols); + expect(getBrowserTerminal().cols).toBeLessThan(120); + expect(getBrowserTerminal().rows).toBe(local.rows); + mounted.runtime.resize({ forceClaim: true, shouldClaim: true }); + expect(getBrowserTerminal().cols).toBe(latestSize(mounted.sizes).cols); +}); + +it("defers layout fit until all replay chunks commit", async () => { + const mounted = createTerminalHost({ width: 720, height: 360 }); + await settleMountRefits(); + mounted.runtime.replay({ type: "begin", rows: 30, cols: 120 }); + await new Promise((resolve) => + mounted.runtime.write({ + data: terminalOutput("first"), + suppressInput: true, + onCommitted: resolve, + }), + ); + expect(getBrowserTerminal().cols).toBe(120); + mounted.root.style.width = "360px"; + mounted.runtime.resize({ shouldClaim: true, forceClaim: true, forceRefresh: true }); + await nextFrame(); + expect(getBrowserTerminal().cols).toBe(120); + mounted.runtime.replay({ type: "data", data: terminalOutput("\x1b[2;110Hsecond") }); + mounted.runtime.replay({ type: "end" }); + await new Promise((resolve) => + mounted.runtime.write({ data: terminalOutput("LIVE"), onCommitted: resolve }), + ); + expect(getBrowserTerminal().cols).toBe(latestSize(mounted.sizes).cols); + expect(getBrowserTerminal().cols).toBeLessThan(60); + expect(latestSize(mounted.sizes)).toMatchObject({ shouldClaim: true, forceClaim: true }); + mounted.runtime.resize({ shouldClaim: true, forceClaim: true }); + expect(getBrowserTerminal().cols).toBe(latestSize(mounted.sizes).cols); +}); 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 a11cedbfe2..a6939aaecb 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts @@ -302,25 +302,14 @@ describe("terminal-emulator-runtime", () => { expect(committed).toEqual(["first", "second", "clear", "third"]); }); - it("falls back to timeout commit for a barrier op when the gate sentinel never fires", () => { + it("unblocks queued replay writes if a begin barrier never commits", () => { vi.useFakeTimers(); - const { runtime, terminal } = createRuntimeWithTerminal(); - const onCommitted = vi.fn(); - - // restoreOutput is a barrier; if xterm never commits the gate sentinel, the barrier still - // applies (and commits) after the 5s safety timeout instead of stalling forever. - runtime.restoreOutput({ - data: terminalOutput("stuck"), - onCommitted, - }); - - expect(onCommitted).not.toHaveBeenCalled(); - expect(terminal.resetCalls).toBe(0); - vi.advanceTimersByTime(5_000); - // The barrier now runs (write submitted) but its own commit still waits on xterm; advance - // again to fire the barrier's own write timeout. - vi.advanceTimersByTime(5_000); - expect(onCommitted).toHaveBeenCalledTimes(1); + const { runtime, writeTexts } = createRuntimeWithTerminal(); + runtime.replay({ type: "begin" }); + runtime.replay({ type: "data", data: terminalOutput("stuck") }); + expect(writeTexts).toEqual(["\x1bc"]); + vi.advanceTimersByTime(5000); + expect(writeTexts).toEqual(["\x1bc", "stuck"]); }); it("reports input mode changes from terminal output and resets them on snapshots", () => { @@ -343,18 +332,7 @@ describe("terminal-emulator-runtime", () => { }); runtime.write({ data: terminalOutput("\x1b[>7u") }); - runtime.renderSnapshot({ - state: { - rows: 2, - cols: 8, - scrollback: [], - grid: [[{ char: "$" }, { char: " " }]], - cursor: { - row: 0, - col: 2, - }, - }, - }); + runtime.replay({ type: "begin", rows: 2, cols: 8 }); // The plain write reports kitty flags synchronously during drain; the snapshot resets // them only after its barrier gate (the sentinel write callback) resolves. expect(inputModeChanges).toEqual([ @@ -410,40 +388,18 @@ describe("terminal-emulator-runtime", () => { expect(committed).toEqual(["second", "first"]); }); - it("applies a snapshot after the writes ahead of it, never interleaving the reset", () => { + it("keeps replay reset and data after preceding writes, without interleaving", () => { const { runtime, terminal, writeTexts, writeCallbacks } = createRuntimeWithTerminal(); - const committed: string[] = []; - - runtime.write({ - data: terminalOutput("before-a"), - onCommitted: () => { - committed.push("before-a"); - }, - }); - runtime.write({ - data: terminalOutput("before-b"), - onCommitted: () => { - committed.push("before-b"); - }, - }); - runtime.restoreOutput({ - data: terminalOutput("snap"), - onCommitted: () => { - committed.push("snap"); - }, - }); - - // Plain writes are submitted up front; the snapshot's reset+write has not run yet. + runtime.write({ data: terminalOutput("before-a") }); + runtime.write({ data: terminalOutput("before-b") }); + runtime.replay({ type: "begin" }); + runtime.replay({ type: "data", data: terminalOutput("snap") }); + runtime.replay({ type: "end" }); expect(writeTexts).toEqual(["before-a", "before-b"]); - - // writeCallbacks: [0]=before-a, [1]=before-b, [2]=barrier gate sentinel. - // The snapshot is gated behind the prior writes; resolving the gate runs it after them. writeCallbacks[2]?.(); - expect(writeTexts).toEqual(["before-a", "before-b", "csnap"]); - - // Snapshot commit fires only after its own write callback, strictly after the writes. + expect(writeTexts).toEqual(["before-a", "before-b", "\x1bc"]); writeCallbacks[3]?.(); - expect(committed).toEqual(["snap"]); + expect(writeTexts).toEqual(["before-a", "before-b", "\x1bc", "snap"]); expect(terminal.resetCalls).toBe(0); }); @@ -456,7 +412,7 @@ describe("terminal-emulator-runtime", () => { // No plain writes precede this barrier (mount), so there is nothing to gate: it starts // at once with no sentinel, flipping suppressInput synchronously. - runtime.restoreOutput({ data: terminalOutput("snapshot") }); + runtime.replay({ type: "begin" }); expect(readSuppressInput()).toBe(true); // writeCallbacks[0] is the barrier's own snapshot write; committing it restores input. @@ -471,7 +427,7 @@ describe("terminal-emulator-runtime", () => { // A plain write is now ungated, so the following barrier must wait on the sentinel. runtime.write({ data: terminalOutput("output") }); - runtime.restoreOutput({ data: terminalOutput("snapshot") }); + runtime.replay({ type: "begin" }); // The plain write commits first. Only the following sentinel opens the barrier. expect(readSuppressInput()).toBe(false); @@ -505,57 +461,40 @@ describe("terminal-emulator-runtime", () => { expect(onCommittedB).toHaveBeenCalledTimes(1); }); - it("clears ungated writes on unmount so remount barriers apply immediately", () => { + it("clears ungated writes on unmount so remount replay begins immediately", () => { const { runtime } = createRuntimeWithTerminal(); - runtime.write({ data: terminalOutput("before unmount") }); runtime.unmount(); - const remounted = attachStubTerminal(runtime); - runtime.restoreOutput({ data: terminalOutput("restored screen") }); - - expect(remounted.writeTexts).toEqual(["\u001bcrestored screen"]); - expect(remounted.writeCallbacks).toHaveLength(1); + runtime.replay({ type: "begin" }); + runtime.replay({ type: "data", data: terminalOutput("restored screen") }); + expect(remounted.writeTexts).toEqual(["\x1bc"]); + remounted.writeCallbacks[0]?.(); + expect(remounted.writeTexts).toEqual(["\x1bc", "restored screen"]); }); - it("replays snapshots through a single write without first painting a reset terminal", () => { + it("parses replay chunks serially without synchronously resetting the terminal", () => { const { runtime, terminal, writeTexts, writeCallbacks } = createRuntimeWithTerminal(); - - runtime.renderSnapshot({ - state: { - rows: 2, - cols: 8, - scrollback: [], - grid: [ - [{ char: "h" }, { char: "i" }], - [{ char: "$" }, { char: " " }], - ], - cursor: { - row: 1, - col: 2, - }, - }, - }); - - // The snapshot is a barrier; its write applies after the gate sentinel (writeCallbacks[0]). - writeCallbacks[0]?.(); - + runtime.replay({ type: "begin", rows: 2, cols: 8 }); + runtime.replay({ type: "data", data: terminalOutput("hi") }); + runtime.replay({ type: "data", data: terminalOutput("\r\n$") }); + runtime.replay({ type: "end" }); expect(terminal.resetCalls).toBe(0); - expect(writeTexts).toHaveLength(1); - expect(writeTexts[0]?.startsWith("\u001bc")).toBe(true); - expect(writeTexts[0]).toContain("hi"); + expect(writeTexts).toEqual(["\x1bc"]); + writeCallbacks[0]?.(); + expect(writeTexts).toEqual(["\x1bc", "hi"]); + writeCallbacks[1]?.(); + expect(writeTexts).toEqual(["\x1bc", "hi", "\r\n$"]); }); - it("restores server-rendered ANSI snapshots through the snapshot write path", () => { + it("restores worker-rendered ANSI through the replay lifecycle", () => { const { runtime, terminal, writeTexts, writeCallbacks } = createRuntimeWithTerminal(); - - runtime.restoreOutput({ data: terminalOutput("restored screen") }); - - // restoreOutput is a barrier (suppressInput); it applies after the gate sentinel. + runtime.replay({ type: "begin" }); + runtime.replay({ type: "data", data: terminalOutput("restored screen") }); + runtime.replay({ type: "end" }); writeCallbacks[0]?.(); - expect(terminal.resetCalls).toBe(0); - expect(writeTexts).toEqual(["\u001bcrestored screen"]); + expect(writeTexts).toEqual(["\x1bc", "restored screen"]); }); it("forces a refit when resize is requested", () => { diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts index 0a55cd7126..a7460f7f80 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts @@ -7,7 +7,6 @@ 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 { TerminalState } from "@getpaseo/protocol/messages"; import { type TerminalInputModeState, TerminalInputModeTracker, @@ -24,7 +23,6 @@ import { resolveMacTerminalEditingShortcut, shouldInterceptDomTerminalKey, } from "@/utils/terminal-keys"; -import { renderTerminalSnapshotToAnsi } from "./terminal-snapshot"; import { createTerminalLocalFileLinkProvider, type TerminalLocalFileLinkSource, @@ -55,7 +53,6 @@ const FIND_HIGHLIGHT_LIMIT = 20_000; export interface TerminalEmulatorRuntimeMountInput { root: HTMLDivElement; host: HTMLDivElement; - initialSnapshot: TerminalState | null; scrollback: number; theme: ITheme; fontFamily?: string; @@ -134,7 +131,7 @@ interface TerminalEmulatorRuntimeDisposables { } interface TerminalOutputOperation { - type: "write" | "clear" | "snapshot"; + type: "write" | "clear" | "snapshot" | "fit"; data: TerminalOutputData; rows?: number; cols?: number; @@ -280,6 +277,7 @@ export class TerminalEmulatorRuntime { }, }; + private replayResize: TerminalResizeRequest | null = null; private fitAndEmitResize: ((input?: TerminalResizeRequest) => void) | null = null; private lastSize: { rows: number; cols: number } | null = null; private cleanup: (() => void) | null = null; @@ -652,6 +650,7 @@ export class TerminalEmulatorRuntime { window.__paseoTerminal = terminal; const fitAndEmitResize = (resizeInput?: TerminalResizeRequest): void => { + if (this.deferReplayResize(resizeInput)) return; const forceRefresh = resizeInput?.forceRefresh ?? false; const shouldClaim = resizeInput?.shouldClaim ?? true; const forceClaim = resizeInput?.forceClaim ?? false; @@ -777,10 +776,6 @@ export class TerminalEmulatorRuntime { fitAndEmitResize({ forceRefresh: true, shouldClaim: false }); }, 0); - if (input.initialSnapshot) { - this.renderSnapshot({ state: input.initialSnapshot }); - } - this.processOutputQueue(); const disposables: TerminalEmulatorRuntimeDisposables = { @@ -898,20 +893,19 @@ export class TerminalEmulatorRuntime { onFilePaste?.(file); } - renderSnapshot(input: { state: TerminalState | null; onCommitted?: () => void }): void { - if (!input.state) { - this.clear(input); - return; + replay(event: import("@getpaseo/client").TerminalReplayEvent): void { + if (event.type === "begin") { + this.replayResize ??= { shouldClaim: false }; + this.restoreOutput({ data: EMPTY_TERMINAL_OUTPUT, rows: event.rows, cols: event.cols }); + } else if (event.type === "data") { + this.write({ data: event.data, suppressInput: true }); + } else { + this.outputOperations.push({ type: "fit", data: EMPTY_TERMINAL_OUTPUT }); + this.processOutputQueue(); } - this.restoreOutput({ - data: encodeTerminalOutput(renderTerminalSnapshotToAnsi(input.state)), - rows: input.state.rows, - cols: input.state.cols, - ...(input.onCommitted ? { onCommitted: input.onCommitted } : {}), - }); } - restoreOutput(input: { + private restoreOutput(input: { data: TerminalOutputData; rows?: number; cols?: number; @@ -928,6 +922,16 @@ export class TerminalEmulatorRuntime { this.processOutputQueue(); } + private deferReplayResize(input?: TerminalResizeRequest): boolean { + if (!this.replayResize) return false; + this.replayResize = { + forceRefresh: this.replayResize.forceRefresh || input?.forceRefresh, + shouldClaim: this.replayResize.shouldClaim || (input?.shouldClaim ?? true), + forceClaim: this.replayResize.forceClaim || input?.forceClaim, + }; + return true; + } + resize(input?: TerminalResizeRequest): void { this.fitAndEmitResize?.(input); } @@ -1047,6 +1051,7 @@ export class TerminalEmulatorRuntime { commit(); } this.hasUngatedWrites = false; + this.replayResize = null; this.cleanup?.(); this.cleanup = null; @@ -1176,16 +1181,40 @@ export class TerminalEmulatorRuntime { this.processOutputQueue(); }; + if (operation.type === "fit") { + // xterm resize flushes its write buffer synchronously. Leave the parser's + // commit callback before fitting, or that flush consumes the next write. + queueMicrotask(() => { + if (this.inFlightOutputOperation !== operation || this.terminal !== terminal) return; + const resize = this.replayResize ?? { shouldClaim: false }; + this.replayResize = null; + this.fitAndEmitResize?.(resize); + finalizeOperation(operation); + }); + return; + } + if (operation.type === "clear") { this.inputModeDecoder.decode(); this.inputModeTracker.reset(); this.emitInputModeChange(); terminal.reset(); - finalizeOperation(operation); + if (!this.replayResize) { + finalizeOperation(operation); + return; + } + queueMicrotask(() => { + if (this.inFlightOutputOperation !== operation || this.terminal !== terminal) return; + const resize = this.replayResize ?? { shouldClaim: false }; + this.replayResize = null; + this.fitAndEmitResize?.(resize); + finalizeOperation(operation); + }); return; } if (operation.type === "snapshot") { + this.replayResize ??= { shouldClaim: false }; this.inputModeDecoder.decode(); this.inputModeTracker.reset(); this.emitInputModeChange(); diff --git a/packages/app/src/terminal/runtime/terminal-find.browser.test.ts b/packages/app/src/terminal/runtime/terminal-find.browser.test.ts index 8336925338..84b941a340 100644 --- a/packages/app/src/terminal/runtime/terminal-find.browser.test.ts +++ b/packages/app/src/terminal/runtime/terminal-find.browser.test.ts @@ -31,7 +31,6 @@ function mount(options?: { isMac: boolean }) { runtime.mount({ root, host, - initialSnapshot: null, scrollback: 1000, theme: { background: "#ffffff", foreground: "#111111" }, }); diff --git a/packages/app/src/terminal/runtime/terminal-glyph-rendering.browser.test.ts b/packages/app/src/terminal/runtime/terminal-glyph-rendering.browser.test.ts index 35629f01f8..62a65c6878 100644 --- a/packages/app/src/terminal/runtime/terminal-glyph-rendering.browser.test.ts +++ b/packages/app/src/terminal/runtime/terminal-glyph-rendering.browser.test.ts @@ -38,7 +38,6 @@ function mountTerminal(left: number): TerminalView { runtime.mount({ root, host, - initialSnapshot: null, scrollback: 1000, fontFamily: "monospace", fontSize: 16, diff --git a/packages/app/src/terminal/runtime/terminal-resize-repro.browser.test.ts b/packages/app/src/terminal/runtime/terminal-resize-repro.browser.test.ts index 35b3492b71..d72a935f49 100644 --- a/packages/app/src/terminal/runtime/terminal-resize-repro.browser.test.ts +++ b/packages/app/src/terminal/runtime/terminal-resize-repro.browser.test.ts @@ -1,15 +1,14 @@ +import { Terminal as ReplaySource } from "@xterm/xterm"; +import { SerializeAddon } from "@xterm/addon-serialize"; import { page } from "@vitest/browser/context"; import { afterEach, describe, expect, it } from "vitest"; -import type { TerminalState } from "@getpaseo/protocol/messages"; import { encodeTerminalOutput, TerminalEmulatorRuntime } from "./terminal-emulator-runtime"; // Regression: "streaming pino log, resized the Paseo terminal, old logs stayed // narrow and new logs drew on top of the old ones." // -// A heavy stream overflows MAX_TERMINAL_OUTPUT_FRAME_BYTES, so the server sends a -// SNAPSHOT mid-stream. The client restores it via renderTerminalSnapshotToAnsi. -// When the snapshot carries per-row soft-wrap flags, restored long lines must -// reflow on resize just like live output — not freeze at the snapshot width. +// Backpressure triggers an ANSI replay serialized directly from xterm. Replayed +// soft-wrapped lines must reflow on resize like live output. interface Mounted { host: HTMLDivElement; @@ -57,7 +56,6 @@ function mount(width: number, height: number): Mounted { runtime.mount({ root, host, - initialSnapshot: null, scrollback: 10_000, theme: { background: "#0b0b0b", foreground: "#e6e6e6", cursor: "#e6e6e6" }, }); @@ -90,9 +88,18 @@ function someRowContains(term: InspectableTerminal, text: string): boolean { return dumpRows(term).some((r) => r.text.includes(text)); } -function renderSnapshotCommitted(runtime: TerminalEmulatorRuntime, state: TerminalState) { +function replayCommitted( + runtime: TerminalEmulatorRuntime, + state: { rows: number; cols: number; data: Uint8Array }, +) { return new Promise((resolve) => { - runtime.renderSnapshot({ state, onCommitted: resolve }); + runtime.replay({ type: "begin", rows: state.rows, cols: state.cols }); + runtime.replay({ + type: "data", + data: state.data, + }); + runtime.replay({ type: "end" }); + runtime.write({ data: new Uint8Array([0]), onCommitted: resolve }); }); } @@ -102,31 +109,18 @@ function writeCommitted(runtime: TerminalEmulatorRuntime, data: Uint8Array) { }); } -// Build a server-style snapshot of one long pino line that the SERVER soft-wrapped -// at `cols`, represented as multiple grid rows (exactly what the daemon stores in -// its headless xterm and ships in a snapshot). -function buildWrappedSnapshot(cols: number): TerminalState { - const longLine = `[server] [15:30:00.123] TRACE: provider.claude.raw_event {"module":"agent","seq":1,"sessionId":"ed8972f4-d3be-45d0-992c-631f8f1ed04e","turnId":"foreground-turn-1","payload":"${"A".repeat(40)}"}`; - const grid: TerminalState["grid"] = []; - for (let i = 0; i < longLine.length; i += cols) { - const chunk = longLine.slice(i, i + cols); - grid.push([...chunk].map((char) => ({ char }))); +// Serialize one long pino line from a real server-width xterm buffer. +// The replay must retain its soft-wrap boundaries for later client reflow. +async function buildWrappedSnapshot(cols: number) { + const source = new ReplaySource({ rows: 24, cols, allowProposedApi: true }); + const serializer = new SerializeAddon(); + source.loadAddon(serializer); + try { + await new Promise((resolve) => source.write(pinoLine(0), resolve)); + return { rows: 24, cols, data: new TextEncoder().encode(serializer.serialize()) }; + } finally { + source.dispose(); } - // The fixed daemon ships per-row soft-wrap flags: every row of the logical line - // continues onto the next except the last. - const gridWrapped = grid.map((_, index) => index < grid.length - 1); - // Cursor sits just below the restored content, exactly where the daemon's - // headless terminal leaves it after a line break, so streamed output appends - // cleanly below instead of overwriting the restored rows. - return { - rows: 24, - cols, - scrollback: [], - scrollbackWrapped: [], - grid, - gridWrapped, - cursor: { row: grid.length, col: 0 }, - }; } function pinoLine(seq: number): string { @@ -141,15 +135,14 @@ afterEach(() => { }); describe("terminal resize reflow repro (Paseo terminal)", () => { - it("snapshot-restored rows stay frozen at the snapshot width after the terminal grows", async () => { + it("replayed soft-wrapped rows reflow after the terminal grows", async () => { await page.viewport(1600, 700); const m = mount(560, 360); // ~70 cols await waitFor(() => m.terminal.cols > 40); const narrowCols = m.terminal.cols; - // 1) Mid-stream snapshot arrives (server overflowed 256KB). It carries the - // long line wrapped at the server width as separate grid rows. - await renderSnapshotCommitted(m.runtime, buildWrappedSnapshot(narrowCols)); + // 1) A mid-stream ANSI replay arrives, preserving wraps at the captured width. + await replayCommitted(m.runtime, await buildWrappedSnapshot(narrowCols)); await waitFor(() => someRowContains(m.terminal, "[15:30:00.123]")); // 2) Normal post-snapshot streaming resumes (autowrap on -> soft-wrapped). diff --git a/packages/app/src/terminal/runtime/terminal-restore-options.test.ts b/packages/app/src/terminal/runtime/terminal-restore-options.test.ts index 14f2ce4d8f..7e5294b347 100644 --- a/packages/app/src/terminal/runtime/terminal-restore-options.test.ts +++ b/packages/app/src/terminal/runtime/terminal-restore-options.test.ts @@ -4,25 +4,27 @@ import { resolveTerminalRestoreOptions } from "./terminal-restore-options"; describe("terminal restore options", () => { it("omits restore options for daemons without terminal restore modes", () => { - expect( + expect(() => resolveTerminalRestoreOptions({ + scrollbackLines: 10000, supportsTerminalRestoreModes: false, canClaimSize: true, size: { rows: 24, cols: 80 }, }), - ).toBeUndefined(); + ).toThrow("Update the host to view terminals."); }); - it("requests visible snapshot restore with bounded scrollback for capable daemons", () => { + it("requests visible snapshot restore with configured scrollback for capable daemons", () => { expect( resolveTerminalRestoreOptions({ + scrollbackLines: 10000, supportsTerminalRestoreModes: true, canClaimSize: true, size: { rows: 24, cols: 80 }, }), ).toEqual({ mode: "visible-snapshot", - scrollbackLines: 200, + scrollbackLines: 10000, size: { rows: 24, cols: 80 }, }); }); @@ -30,26 +32,39 @@ describe("terminal restore options", () => { it("omits size until the terminal has been measured", () => { expect( resolveTerminalRestoreOptions({ + scrollbackLines: 10000, supportsTerminalRestoreModes: true, canClaimSize: true, size: null, }), ).toEqual({ mode: "visible-snapshot", - scrollbackLines: 200, + scrollbackLines: 10000, }); }); it("does not let a background attach resize the PTY", () => { expect( resolveTerminalRestoreOptions({ + scrollbackLines: 10000, supportsTerminalRestoreModes: true, canClaimSize: false, size: { rows: 24, cols: 80 }, }), ).toEqual({ mode: "visible-snapshot", - scrollbackLines: 200, + scrollbackLines: 10000, }); }); }); + +it.each([0, 5, 400, 10000])("requests exactly %s configured history rows", (scrollbackLines) => { + expect( + resolveTerminalRestoreOptions({ + supportsTerminalRestoreModes: true, + scrollbackLines, + canClaimSize: false, + size: null, + }), + ).toEqual({ mode: "visible-snapshot", scrollbackLines }); +}); diff --git a/packages/app/src/terminal/runtime/terminal-restore-options.ts b/packages/app/src/terminal/runtime/terminal-restore-options.ts index d1e304cec7..3fcd894eac 100644 --- a/packages/app/src/terminal/runtime/terminal-restore-options.ts +++ b/packages/app/src/terminal/runtime/terminal-restore-options.ts @@ -1,23 +1,23 @@ +import { i18n } from "@/i18n/i18next"; import type { SubscribeTerminalRequest } from "@getpaseo/protocol/messages"; -export const TERMINAL_VISIBLE_RESTORE_SCROLLBACK_LINES = 200; - export interface ResolveTerminalRestoreOptionsInput { supportsTerminalRestoreModes: boolean; + scrollbackLines: number; canClaimSize: boolean; size: { rows: number; cols: number } | null; } export function resolveTerminalRestoreOptions( input: ResolveTerminalRestoreOptionsInput, -): SubscribeTerminalRequest["restore"] | undefined { +): NonNullable { if (!input.supportsTerminalRestoreModes) { - return undefined; + throw new Error(i18n.t("workspace.terminal.restoreRequiresUpdate")); } return { mode: "visible-snapshot", - scrollbackLines: TERMINAL_VISIBLE_RESTORE_SCROLLBACK_LINES, + scrollbackLines: input.scrollbackLines, ...(input.canClaimSize && input.size ? { size: input.size } : {}), }; } diff --git a/packages/app/src/terminal/runtime/terminal-snapshot.test.ts b/packages/app/src/terminal/runtime/terminal-snapshot.test.ts index c5fdbbd9fd..6232d9a8c0 100644 --- a/packages/app/src/terminal/runtime/terminal-snapshot.test.ts +++ b/packages/app/src/terminal/runtime/terminal-snapshot.test.ts @@ -2,7 +2,7 @@ import { Terminal as HeadlessTerminal } from "@xterm/headless"; import { Terminal as ClientTerminal } from "@xterm/xterm"; import { describe, expect, it } from "vitest"; -import { renderTerminalSnapshotToAnsi } from "./terminal-snapshot"; +import { SerializeAddon } from "@xterm/addon-serialize"; interface SnapshotCell { char: string; @@ -122,7 +122,7 @@ function extractRow(terminal: ClientTerminal | HeadlessTerminal, row: number): S } describe("terminal-snapshot", () => { - it("replays extracted terminal state into a client xterm with matching grid, scrollback, and cursor presentation", async () => { + it("replays upstream serialized ANSI into a client xterm with matching grid, scrollback, and cursor presentation", async () => { const source = new HeadlessTerminal({ rows: 4, cols: 12, @@ -138,6 +138,8 @@ describe("terminal-snapshot", () => { await writeToTerminal(source, "\u001b[2 q"); await writeToTerminal(source, "\u001b[?25l"); + const serializer = new SerializeAddon(); + source.loadAddon(serializer); const snapshot = extractState(source); const client = new ClientTerminal({ @@ -147,7 +149,7 @@ describe("terminal-snapshot", () => { scrollback: 100, }); - await writeToTerminal(client, renderTerminalSnapshotToAnsi(snapshot)); + await writeToTerminal(client, serializer.serialize() + "\x1b[2 q\x1b[?25l"); expect(extractState(client)).toEqual(snapshot); }); diff --git a/packages/app/src/terminal/runtime/terminal-snapshot.ts b/packages/app/src/terminal/runtime/terminal-snapshot.ts deleted file mode 100644 index 4b7a8e0b9b..0000000000 --- a/packages/app/src/terminal/runtime/terminal-snapshot.ts +++ /dev/null @@ -1 +0,0 @@ -export { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot"; diff --git a/packages/app/src/terminal/runtime/terminal-stream-controller.test.ts b/packages/app/src/terminal/runtime/terminal-stream-controller.test.ts index 388484d79d..ecd49c6fd5 100644 --- a/packages/app/src/terminal/runtime/terminal-stream-controller.test.ts +++ b/packages/app/src/terminal/runtime/terminal-stream-controller.test.ts @@ -1,3 +1,4 @@ +import { resolveTerminalRestoreOptions } from "./terminal-restore-options"; import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; import { describe, expect, it } from "vitest"; @@ -7,34 +8,16 @@ import { type TerminalStreamControllerStatus, } from "./terminal-stream-controller"; -interface TerminalSnapshot { - rows: number; - cols: number; - grid: Array>; - scrollback: Array>; - cursor: { row: number; col: number }; -} - -function terminalCellText(cell: { char: string }): string { - return cell.char; -} - -function terminalRowText(row: Array<{ char: string }>): string { - return row.map(terminalCellText).join(""); -} - -function terminalSnapshotText(state: TerminalSnapshot): string { - return state.grid.map(terminalRowText).join("\n"); -} - function terminalOutput(text: string): Uint8Array { return new TextEncoder().encode(text); } -type TerminalStreamEvent = - | { terminalId: string; type: "output"; data: Uint8Array } - | { terminalId: string; type: "snapshot"; state: TerminalSnapshot } - | { terminalId: string; type: "restore"; data: Uint8Array }; +type StreamEvent = import("@getpaseo/client").TerminalStreamEvent; +type TerminalStreamEvent = StreamEvent extends infer Event + ? Event extends StreamEvent + ? Omit + : never + : never; class FakeTerminalStreamClient implements TerminalStreamControllerClient { private readonly updates = new Set<(message: SessionOutboundMessage) => void>(); @@ -115,32 +98,23 @@ class FakeTerminalStreamClient implements TerminalStreamControllerClient { function createHarness(input?: { client?: FakeTerminalStreamClient }) { const client = input?.client ?? new FakeTerminalStreamClient(); const outputs: Array<{ terminalId: string; data: Uint8Array }> = []; - const restores: Array<{ terminalId: string; data: Uint8Array }> = []; - const snapshots: Array<{ terminalId: string; text: string }> = []; + const replays: import("@getpaseo/client").TerminalReplayEvent[] = []; const statuses: TerminalStreamControllerStatus[] = []; const exits: string[] = []; const controller = new TerminalStreamController({ client, + onReplay: ({ replay }) => replays.push(replay), onExit: (terminalId) => exits.push(terminalId), getPreferredSize: () => ({ rows: 24, cols: 80 }), onOutput: (output) => { outputs.push(output); }, - onRestore: (restore) => { - restores.push(restore); - }, - onSnapshot: ({ terminalId, state }) => { - snapshots.push({ - terminalId, - text: terminalSnapshotText(state), - }); - }, onStatusChange: (status) => { statuses.push(status); }, }); - return { client, controller, outputs, restores, snapshots, statuses, exits }; + return { client, controller, outputs, replays, statuses, exits }; } async function flushAsyncWork(): Promise { @@ -149,24 +123,20 @@ async function flushAsyncWork(): Promise { } describe("terminal-stream-controller", () => { - it("subscribes, resizes, and forwards snapshot/output events", async () => { + it("subscribes, resizes, and forwards replay/output events", async () => { const harness = createHarness(); harness.client.nextSubscribeResults.push({ terminalId: "term-1", error: null }); harness.controller.setTerminal({ terminalId: "term-1" }); await flushAsyncWork(); - harness.client.emit({ - terminalId: "term-1", - type: "snapshot", - state: { - rows: 1, - cols: 5, - grid: [[{ char: "h" }, { char: "e" }, { char: "l" }, { char: "l" }, { char: "o" }]], - scrollback: [], - cursor: { row: 0, col: 5 }, - }, - }); + const replay: import("@getpaseo/client").TerminalReplayEvent[] = [ + { type: "begin", rows: 24, cols: 80, retainedHistoryLines: 400 }, + { type: "data", data: terminalOutput("hello") }, + { type: "end" }, + ]; + for (const event of replay) + harness.client.emit({ terminalId: "term-1", type: "replay", replay: event }); const outputData = terminalOutput(" world"); harness.client.emit({ terminalId: "term-1", @@ -178,7 +148,7 @@ describe("terminal-stream-controller", () => { expect(harness.client.resizeCalls).toEqual([ { terminalId: "term-1", rows: 24, cols: 80, intent: "claim" }, ]); - expect(harness.snapshots).toEqual([{ terminalId: "term-1", text: "hello" }]); + expect(harness.replays).toEqual(replay); expect(harness.outputs[0]?.data).toBe(outputData); expect(harness.outputs).toEqual([{ terminalId: "term-1", data: terminalOutput(" world") }]); expect(harness.statuses.at(-1)).toEqual({ @@ -236,23 +206,19 @@ describe("terminal-stream-controller", () => { size: { rows: 24, cols: 80 }, }), onOutput: (output) => harness.outputs.push(output), - onRestore: (restore) => harness.restores.push(restore), - onSnapshot: ({ terminalId, state }) => { - harness.snapshots.push({ - terminalId, - text: terminalSnapshotText(state), - }); - }, + onReplay: ({ replay }) => harness.replays.push(replay), }); controller.setTerminal({ terminalId: "term-1" }); await flushAsyncWork(); const restoreData = terminalOutput("restored"); - client.emit({ - terminalId: "term-1", - type: "restore", - data: restoreData, - }); + const replay: import("@getpaseo/client").TerminalReplayEvent[] = [ + { type: "begin" }, + { type: "data", data: restoreData }, + { type: "end" }, + ]; + for (const event of replay) + client.emit({ terminalId: "term-1", type: "replay", replay: event }); controller.dispose(); expect(client.subscribeCalls).toEqual([ @@ -270,8 +236,7 @@ describe("terminal-stream-controller", () => { expect(client.resizeCalls).toEqual([ { terminalId: "term-1", rows: 24, cols: 80, intent: "claim" }, ]); - expect(harness.restores[0]?.data).toBe(restoreData); - expect(harness.restores).toEqual([{ terminalId: "term-1", data: terminalOutput("restored") }]); + expect(harness.replays).toEqual(replay); expect(harness.outputs).toEqual([]); }); @@ -319,3 +284,28 @@ it("reports observation failure without declaring the PTY exited, then allows re expect(h.statuses.at(-1)?.error).toBe(null); h.controller.dispose(); }); + +it("does not resize or attach on a JSON-era host and reports the required update", () => { + const client = new FakeTerminalStreamClient(); + const statuses: TerminalStreamControllerStatus[] = []; + const controller = new TerminalStreamController({ + client, + getPreferredSize: () => ({ rows: 24, cols: 40 }), + onOutput: () => {}, + getRestoreOptions: () => + resolveTerminalRestoreOptions({ + supportsTerminalRestoreModes: false, + scrollbackLines: 10000, + canClaimSize: true, + size: { rows: 24, cols: 40 }, + }), + onStatusChange: (status) => statuses.push(status), + }); + controller.setTerminal({ terminalId: "old-host-terminal" }); + expect(client.subscribeCalls).toEqual([]); + expect(client.resizeCalls).toEqual([]); + expect(statuses.at(-1)).toMatchObject({ + isAttaching: false, + error: "Update the host to view terminals.", + }); +}); diff --git a/packages/app/src/terminal/runtime/terminal-stream-controller.ts b/packages/app/src/terminal/runtime/terminal-stream-controller.ts index 90d54be4d4..b4b166cadf 100644 --- a/packages/app/src/terminal/runtime/terminal-stream-controller.ts +++ b/packages/app/src/terminal/runtime/terminal-stream-controller.ts @@ -1,5 +1,5 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import type { SubscribeTerminalRequest, TerminalState } from "@getpaseo/protocol/messages"; +import type { SubscribeTerminalRequest } from "@getpaseo/protocol/messages"; import type { TerminalOutputData } from "./terminal-emulator-runtime"; import { i18n } from "@/i18n/i18next"; @@ -23,9 +23,11 @@ export interface TerminalStreamControllerOptions { client: TerminalStreamControllerClient; getPreferredSize: () => TerminalStreamControllerSize | null; onOutput: (input: { terminalId: string; data: TerminalOutputData }) => void; - onSnapshot: (input: { terminalId: string; state: TerminalState }) => void; onExit?: (terminalId: string) => void; - onRestore?: (input: { terminalId: string; data: TerminalOutputData }) => void; + onReplay?: (input: { + terminalId: string; + replay: import("@getpaseo/client").TerminalReplayEvent; + }) => void; getRestoreOptions?: () => SubscribeTerminalRequest["restore"] | undefined; onStatusChange?: (status: TerminalStreamControllerStatus) => void; } @@ -43,17 +45,11 @@ export class TerminalStreamController { if (this.disposed || event.terminalId !== this.terminalId) { return; } - if (event.type === "snapshot") { - this.options.onSnapshot({ terminalId: event.terminalId, state: event.state }); + if (event.type === "replay") { + this.options.onReplay?.({ terminalId: event.terminalId, replay: event.replay }); return; } - if (event.type === "restore") { - if (event.data.length > 0) { - this.options.onRestore?.({ terminalId: event.terminalId, data: event.data }); - } - return; - } - if (event.data.length > 0) { + if (event.type === "output" && event.data.length > 0) { this.options.onOutput({ terminalId: event.terminalId, data: event.data }); } }; @@ -70,10 +66,10 @@ export class TerminalStreamController { this.options.onStatusChange?.({ terminalId: null, isAttaching: false, error: null }); return; } - const restore = this.options.getRestoreOptions?.(); this.options.onStatusChange?.({ terminalId: nextTerminalId, isAttaching: true, error: null }); let subscription: ReturnType; try { + const restore = this.options.getRestoreOptions?.(); const preferredSize = restore?.size ?? this.options.getPreferredSize(); if (preferredSize) this.options.client.sendTerminalInput(nextTerminalId, { diff --git a/packages/app/src/terminal/runtime/workspace-terminal-session.test.ts b/packages/app/src/terminal/runtime/workspace-terminal-session.test.ts deleted file mode 100644 index 97063539d6..0000000000 --- a/packages/app/src/terminal/runtime/workspace-terminal-session.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - getWorkspaceTerminalSession, - releaseWorkspaceTerminalSession, - retainWorkspaceTerminalSession, -} from "./workspace-terminal-session"; - -describe("workspace-terminal-session", () => { - it("returns the same workspace session instance for the same scope", () => { - const first = getWorkspaceTerminalSession({ - scopeKey: "workspace-a", - }); - const second = getWorkspaceTerminalSession({ - scopeKey: "workspace-a", - }); - - expect(second).toBe(first); - }); - - it("preserves snapshots across repeated lookups", () => { - const first = getWorkspaceTerminalSession({ - scopeKey: "workspace-snapshots", - }); - first.snapshots.set({ - terminalId: "term-1", - state: { - rows: 1, - cols: 1, - grid: [[{ char: "A" }]], - scrollback: [], - cursor: { row: 0, col: 0 }, - }, - }); - - const second = getWorkspaceTerminalSession({ - scopeKey: "workspace-snapshots", - }); - - expect(second.snapshots.get({ terminalId: "term-1" })).toEqual({ - rows: 1, - cols: 1, - grid: [[{ char: "A" }]], - scrollback: [], - cursor: { row: 0, col: 0 }, - }); - }); - - it("evicts workspace terminal session state when the retain count returns to zero", () => { - const scopeKey = "workspace-release"; - const first = getWorkspaceTerminalSession({ - scopeKey, - }); - first.snapshots.set({ - terminalId: "term-1", - state: { - rows: 1, - cols: 1, - grid: [[{ char: "A" }]], - scrollback: [], - cursor: { row: 0, col: 0 }, - }, - }); - - retainWorkspaceTerminalSession({ scopeKey }); - releaseWorkspaceTerminalSession({ scopeKey }); - - const second = getWorkspaceTerminalSession({ - scopeKey, - }); - - expect(second).not.toBe(first); - expect(second.snapshots.get({ terminalId: "term-1" })).toBeNull(); - }); -}); diff --git a/packages/app/src/terminal/runtime/workspace-terminal-session.ts b/packages/app/src/terminal/runtime/workspace-terminal-session.ts deleted file mode 100644 index 277f956b97..0000000000 --- a/packages/app/src/terminal/runtime/workspace-terminal-session.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { TerminalState } from "@getpaseo/protocol/messages"; - -export interface WorkspaceTerminalSnapshots { - get: (input: { terminalId: string }) => TerminalState | null; - set: (input: { terminalId: string; state: TerminalState }) => void; - clear: (input: { terminalId: string }) => void; - prune: (input: { terminalIds: string[] }) => void; -} - -export interface WorkspaceTerminalSession { - scopeKey: string; - snapshots: WorkspaceTerminalSnapshots; -} - -interface WorkspaceTerminalSessionRecord { - snapshotByTerminalId: Map; - session: WorkspaceTerminalSession; -} - -const sessionsByScopeKey = new Map(); -const refCountByScopeKey = new Map(); - -function createSnapshots(input: { - snapshotByTerminalId: Map; -}): WorkspaceTerminalSnapshots { - return { - get: ({ terminalId }) => input.snapshotByTerminalId.get(terminalId) ?? null, - set: ({ terminalId, state }) => { - input.snapshotByTerminalId.set(terminalId, state); - }, - clear: ({ terminalId }) => { - input.snapshotByTerminalId.delete(terminalId); - }, - prune: ({ terminalIds }) => { - const terminalIdSet = new Set(terminalIds); - for (const terminalId of Array.from(input.snapshotByTerminalId.keys())) { - if (!terminalIdSet.has(terminalId)) { - input.snapshotByTerminalId.delete(terminalId); - } - } - }, - }; -} - -export function getWorkspaceTerminalSession(input: { scopeKey: string }): WorkspaceTerminalSession { - const existing = sessionsByScopeKey.get(input.scopeKey); - if (existing) { - return existing.session; - } - - const snapshotByTerminalId = new Map(); - const session: WorkspaceTerminalSession = { - scopeKey: input.scopeKey, - snapshots: createSnapshots({ - snapshotByTerminalId, - }), - }; - - sessionsByScopeKey.set(input.scopeKey, { - snapshotByTerminalId, - session, - }); - return session; -} - -export function retainWorkspaceTerminalSession(input: { scopeKey: string }): void { - const current = refCountByScopeKey.get(input.scopeKey) ?? 0; - refCountByScopeKey.set(input.scopeKey, current + 1); -} - -export function releaseWorkspaceTerminalSession(input: { scopeKey: string }): void { - const current = refCountByScopeKey.get(input.scopeKey) ?? 0; - if (current > 1) { - refCountByScopeKey.set(input.scopeKey, current - 1); - return; - } - refCountByScopeKey.delete(input.scopeKey); - sessionsByScopeKey.delete(input.scopeKey); -} diff --git a/packages/app/src/utils/terminal-renderer-readiness.test.ts b/packages/app/src/utils/terminal-renderer-readiness.test.ts index 45866f8df6..d7e5f71833 100644 --- a/packages/app/src/utils/terminal-renderer-readiness.test.ts +++ b/packages/app/src/utils/terminal-renderer-readiness.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "vitest"; import { applyTerminalRendererReadyChange, resolveTerminalStreamTarget, - shouldReplayTerminalSnapshotForRenderer, shouldShowTerminalLoadingOverlay, } from "./terminal-renderer-readiness"; @@ -74,27 +73,6 @@ describe("terminal-renderer-readiness", () => { expect(current).toBeNull(); }); - it("replays snapshots only for ready events from the current renderer", () => { - expect( - shouldReplayTerminalSnapshotForRenderer({ - change: { streamKey: "scope:terminal-1", isReady: true }, - terminalStreamKey: "scope:terminal-1", - }), - ).toBe(true); - expect( - shouldReplayTerminalSnapshotForRenderer({ - change: { streamKey: "scope:terminal-1", isReady: false }, - terminalStreamKey: "scope:terminal-1", - }), - ).toBe(false); - expect( - shouldReplayTerminalSnapshotForRenderer({ - change: { streamKey: "scope:terminal-1", isReady: true }, - terminalStreamKey: "scope:terminal-2", - }), - ).toBe(false); - }); - it("does not attach the stream before the matching renderer is ready", () => { expect( resolveTerminalStreamTarget({ diff --git a/packages/app/src/utils/terminal-renderer-readiness.ts b/packages/app/src/utils/terminal-renderer-readiness.ts index 7d4f855aba..197a8811f9 100644 --- a/packages/app/src/utils/terminal-renderer-readiness.ts +++ b/packages/app/src/utils/terminal-renderer-readiness.ts @@ -14,13 +14,6 @@ export function applyTerminalRendererReadyChange( return currentReadyStreamKey === change.streamKey ? null : currentReadyStreamKey; } -export function shouldReplayTerminalSnapshotForRenderer(input: { - change: TerminalRendererReadyChange; - terminalStreamKey: string; -}): boolean { - return input.change.isReady && input.change.streamKey === input.terminalStreamKey; -} - export function resolveTerminalStreamTarget(input: { terminalId: string; terminalStreamKey: string; diff --git a/packages/cli/tests/46-daemon-terminal-worker-upgrade.test.ts b/packages/cli/tests/46-daemon-terminal-worker-upgrade.test.ts index ff8ef6bc6f..9090456406 100644 --- a/packages/cli/tests/46-daemon-terminal-worker-upgrade.test.ts +++ b/packages/cli/tests/46-daemon-terminal-worker-upgrade.test.ts @@ -10,11 +10,10 @@ * `stop --keep-terminals` and moving A's node-pty, worker bundle and shell integration away, * a daemon from B adopts the same worker and opens a new, working terminal in it with B's * environment and CLI, while the first terminal keeps A's. - * 2. Worker protocol versions (PASEO_TERMINAL_WORKER_PROTOCOL_OVERRIDE): a worker one version - * old drains (keeps its terminal, new ones open in a second worker, it exits after its last - * terminal and its copy is deleted); one two versions old is shut down; one from a newer - * Paseo is left running, reported as terminal_worker_incompatible, and closed by - * `daemon stop`. + * 2. Worker protocol versions (PASEO_TERMINAL_WORKER_PROTOCOL_OVERRIDE): protocols 0–4 + * are obsolete unreleased wires, so their programs/workers/copies are closed and a current + * worker serves new terminals. A truly newer protocol is left running, reported as + * terminal_worker_incompatible, and closed by `daemon stop`. * * Every process it starts is stopped in `finally`. */ @@ -35,7 +34,7 @@ import { import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { getAvailablePort } from "./helpers/network.ts"; +import { createServer } from "node:net"; if (process.platform === "win32") { console.log("Skipping terminal worker upgrade CLI test on Windows (POSIX shell terminals)."); @@ -50,7 +49,7 @@ const paseoHome = join(root, "home"); const cliHome = join(root, "os-home"); const workDir = join(root, "work"); for (const dir of [paseoHome, cliHome, workDir]) mkdirSync(dir, { recursive: true }); -const port = await getAvailablePort(); +const currentProtocolVersion = 5; const zsh = ["/bin/zsh", "/usr/bin/zsh"].find((candidate) => existsSync(candidate)) ?? null; interface Installation { @@ -336,7 +335,18 @@ async function terminalIds(installation: Installation): Promise { } async function startDaemon(installation: Installation, extraEnv: Record = {}) { - await paseo(installation, ["daemon", "start"], extraEnv); + const result = await paseo(installation, ["daemon", "start", "--json"], extraEnv); + lastListen = (JSON.parse(result.stdout) as { listen: string }).listen; + assert.match(lastListen, /^127\.0\.0\.1:[1-9]\d*$/, "start publishes the actual bound port"); + if (competingListener.listening) { + const competitor = competingListener.address(); + assert(competitor && typeof competitor !== "string"); + assert.notEqual( + Number(lastListen.split(":").at(-1)), + competitor.port, + "each daemon binds its own endpoint while the released port belongs to a peer", + ); + } } async function stopDaemon(installation: Installation, keepTerminals = false) { @@ -371,10 +381,14 @@ const a = createInstallation("A", "0.98.0"); const b = createInstallation("B", "0.99.0"); let restoreA: (() => void) | null = null; let passed = false; +let lastListen = ""; +const competingListener = createServer(); try { for (const [field, value] of [ - ["daemon.listen", `127.0.0.1:${port}`], + // Let the listening daemon reserve its port atomically. Clients discover the + // actual endpoint from this home's PID record after every start. + ["daemon.listen", "127.0.0.1:0"], ["daemon.relay.enabled", "false"], ["features.dictation.enabled", "false"], ["features.voiceMode.enabled", "false"], @@ -387,6 +401,7 @@ try { const t1 = await openTerminal(a, "t1"); const [firstWorker] = workerRecords(); assert(firstWorker, "one worker runs"); + assert.equal(firstWorker.protocolVersion, currentProtocolVersion, "the current wire is adopted"); assert.match(firstWorker.runtime ?? "", /^0\.98\.0-[0-9a-f]{12}$/); const copyA = join(paseoHome, "terminal-worker", firstWorker.runtime!); assert( @@ -432,54 +447,62 @@ try { restoreA = null; console.log("✓ closed and cleaned up\n"); - console.log("Test 4: a worker one protocol version old drains"); - const old = await leaveWorkerWithProtocol(a, 0, "t3"); - await startDaemon(b); - const workers = workerRecords(); - assert.equal(workers.length, 2, "the old worker drains beside a new one"); - const current = workers.find((record) => record.pid !== old.workerPid)!; - assert.equal(current.protocolVersion, 1); - assert.match(current.runtime ?? "", /^0\.99\.0-/); - assert((await terminalIds(b)).includes(old.terminal.id), "the old worker's terminal is listed"); - await type(b, old.terminal.id, "echo still-served"); - await waitForScreen(b, old.terminal.id, "\nstill-served"); - const t4 = await openTerminal(b, "t4"); - assert.deepEqual( - workerRecords() - .find((record) => record.pid === current.pid)! - .terminals.map((terminal) => terminal.id), - [t4.id], - "new terminals open in the current worker", - ); - await paseo(b, ["terminal", "kill", old.terminal.id]); - await waitFor( - () => !isAlive(old.workerPid), - () => `the drained worker ${old.workerPid} did not exit`, - ); - await waitFor( - () => !runtimeCopies().includes(old.runtime), - () => `the drained worker's copy ${old.runtime} was not deleted: ${runtimeCopies().join(", ")}`, - ); - assert.deepEqual(runtimeCopies(), [current.runtime], "the current worker's copy stays"); - await type(b, t4.id, "echo after-drain"); - await waitForScreen(b, t4.id, "\nafter-drain"); - await stopDaemon(b); - console.log("✓ drained, exited, copy deleted\n"); + // A released endpoint belongs to nobody. Reproduce another process taking it + // between starts; worker adoption is keyed by the home, not by a stable TCP port. + const releasedPort = Number(lastListen.split(":").at(-1)); + await new Promise((resolve, reject) => { + competingListener.once("error", reject); + competingListener.listen(releasedPort, "127.0.0.1", resolve); + }); + console.log(`Competing listener owns released port ${releasedPort}`); - console.log("Test 5: a worker two protocol versions old is shut down"); - const obsolete = await leaveWorkerWithProtocol(a, -1, "t5"); - await startDaemon(b); - await waitFor( - () => !isAlive(obsolete.workerPid) && !isAlive(obsolete.terminal.pid), - () => "the obsolete worker or its terminal survived", - ); - assert(!(await terminalIds(b)).includes(obsolete.terminal.id), "its terminal is gone"); - assert.equal(workerRecords().length, 1, "one current worker runs"); - await stopDaemon(b); - console.log("✓ shut down\n"); + // There is no supported previous wire at protocol 5. Future previous-version classification + // is covered by terminal-worker-protocol.test.ts; do not simulate an unreleased drain adapter. + for (const protocolVersion of [0, 1, 2, 3, 4]) { + console.log(`Test 4: obsolete protocol ${protocolVersion} is closed before serving terminals`); + const obsolete = await leaveWorkerWithProtocol( + a, + protocolVersion, + `obsolete-${protocolVersion}`, + ); + await startDaemon(b); + await waitFor( + () => !isAlive(obsolete.workerPid) && !isAlive(obsolete.terminal.pid), + () => "the obsolete worker or its terminal survived", + ); + assert(!(await terminalIds(b)).includes(obsolete.terminal.id), "its terminal is gone"); + const [current] = workerRecords(); + assert.equal(workerRecords().length, 1, "one current worker runs"); + assert(current); + assert.equal(current.protocolVersion, currentProtocolVersion); + assert.match(current.runtime ?? "", /^0\.99\.0-/); + await waitFor( + () => !runtimeCopies().includes(obsolete.runtime), + () => `the obsolete worker's copy ${obsolete.runtime} was not deleted`, + ); + assert.deepEqual(runtimeCopies(), [current.runtime], "the current copy stays"); + const replacement = await openTerminal(b, `replacement-${protocolVersion}`); + assert.deepEqual( + workerRecords() + .find((record) => record.pid === current.pid)! + .terminals.map((terminal) => terminal.id), + [replacement.id], + "new terminals open in the current worker", + ); + await type(b, replacement.id, `echo after-protocol-cut-${protocolVersion}`); + await waitForScreen(b, replacement.id, `\nafter-protocol-cut-${protocolVersion}`); + await stopDaemon(b); + await waitFor( + () => !isAlive(replacement.pid) && !isAlive(current.pid), + () => "the replacement worker or program outlived daemon stop", + ); + assert.deepEqual(workerRecords(), [], "no worker record is left"); + assert.deepEqual(runtimeCopies(), [], "no copy is left"); + console.log("✓ obsolete closed, current usable, copies cleaned up\n"); + } - console.log("Test 6: a worker from a newer Paseo is left running and reported"); - const newer = await leaveWorkerWithProtocol(a, 2, "t6"); + console.log("Test 5: a worker from a newer Paseo is left running and reported"); + const newer = await leaveWorkerWithProtocol(a, currentProtocolVersion + 1, "newer"); await startDaemon(b); const listed = await run(b, ["terminal", "ls", "--all", "--json"]); assert.notEqual(listed.exitCode, 0, "terminal ls fails"); @@ -514,6 +537,17 @@ try { } } } + if (competingListener.listening) { + await new Promise((resolve, reject) => + competingListener.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }), + ); + } restoreA?.(); for (const installation of [b, a]) { await run(installation, ["daemon", "stop", "--home", paseoHome, "--force"]); diff --git a/packages/client/src/connection/index.ts b/packages/client/src/connection/index.ts index 1ad4b34dee..62753bca5c 100644 --- a/packages/client/src/connection/index.ts +++ b/packages/client/src/connection/index.ts @@ -125,6 +125,7 @@ export class ConnectionSubscriptions extends OwnedSubscriptions { // Protocol support belongs to the installed client. Only browser hosting needs // a resource supplied by the caller. Keep this exhaustive as the protocol evolves. export const DEFAULT_CLIENT_CAPABILITIES = { + [CLIENT_CAPS.terminalChunkedReplay]: true, [CLIENT_CAPS.binaryChunkingV1]: true, [CLIENT_CAPS.helloRejection]: true, [CLIENT_CAPS.ownedSubscriptions]: true, diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 6ef72a0e1a..4fa7d5723c 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -13,6 +13,7 @@ import { type PluginClientBundleStore, } from "./daemon-client"; import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities"; +import { ChunkedTransport } from "@getpaseo/protocol/chunked-transport"; import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas"; import { decodeFileTransferFrame, @@ -1102,6 +1103,7 @@ test("advertises client capabilities in hello", async () => { projected_subagent_timeline: true, reasoning_merge_enum: true, terminal_reflowable_snapshot: true, + terminal_chunked_replay: true, timeline_notifications: true, plugin_timeline_items: true, workspace_setup_blocked: true, @@ -6388,66 +6390,80 @@ test("emits output events for the active terminal stream", async () => { unsubscribe(); }); -test("emits snapshot events for the subscribed terminal stream", async () => { - const logger = createMockLogger(); - const mock = createMockTransport(); +test.each([5, 500, 10000])( + "preserves explicit %s history demand on an old ANSI host and normalizes restore", + async (history) => { + const logger = createMockLogger(); + const mock = createMockTransport(); - const client = new DaemonClient({ - url: "ws://test", - clientId: "clsk_unit_test", - logger, - reconnect: { enabled: false }, - transportFactory: () => mock.transport, - }); - clients.push(client); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); - const connectPromise = client.connect(); - mock.triggerOpen(); - await connectPromise; + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; - const snapshots: unknown[] = []; - client.onTerminalStreamEvent((event) => { - if (event.type !== "snapshot") { - return; - } - snapshots.push(event.state); - }); + const events: unknown[] = []; + const replayEvents: unknown[] = []; + client.onTerminalStreamEvent((event) => { + if (event.type === "replay") replayEvents.push(event.replay); + events.push(event); + }); - const subscribePromise = client.subscribeTerminal("term-1", { requestId: "sub-2" }); - await vi.waitFor(() => - expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscribe_terminal_request"), - ); - mock.triggerMessage( - wrapSessionMessage({ - type: "subscribe_terminal_response", - payload: { - subscriptionId: "server-" + String(parseSentFrame(mock.sent.at(-1)).requestId), - terminalId: "term-1", + const subscribePromise = client.subscribeTerminal("term-1", { + requestId: "sub-2", + restore: { mode: "visible-snapshot", scrollbackLines: history }, + }); + await vi.waitFor(() => + expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscribe_terminal_request"), + ); + expect(parseSentFrame(mock.sent.at(-1)).restore).toEqual({ + mode: "visible-snapshot", + scrollbackLines: history, + }); + mock.triggerMessage( + wrapSessionMessage({ + type: "subscribe_terminal_response", + payload: { + subscriptionId: "server-" + String(parseSentFrame(mock.sent.at(-1)).requestId), + terminalId: "term-1", + slot: 12, + error: null, + requestId: "sub-2", + }, + }), + ); + await subscribePromise; + + const state = { + rows: 1, + cols: 5, + grid: [[{ char: "h" }, { char: "e" }, { char: "l" }, { char: "l" }, { char: "o" }]], + scrollback: [], + cursor: { row: 0, col: 5 }, + }; + mock.triggerMessage( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Snapshot, slot: 12, - error: null, - requestId: "sub-2", - }, - }), - ); - await subscribePromise; + payload: encodeTerminalSnapshotPayload(state), + }), + ); - const state = { - rows: 1, - cols: 5, - grid: [[{ char: "h" }, { char: "e" }, { char: "l" }, { char: "l" }, { char: "o" }]], - scrollback: [], - cursor: { row: 0, col: 5 }, - }; - mock.triggerMessage( - encodeTerminalStreamFrame({ - opcode: TerminalStreamOpcode.Snapshot, - slot: 12, - payload: encodeTerminalSnapshotPayload(state), - }), - ); - - expect(snapshots).toEqual([state]); -}); + expect(events).toEqual([]); + const data = new TextEncoder().encode("\x1b[31mold ANSI host界🙂"); + mock.triggerMessage( + encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.Restore, slot: 12, payload: data }), + ); + expect(replayEvents).toEqual([{ type: "begin" }, { type: "data", data }, { type: "end" }]); + }, +); test("sends explicit terminal input and resize commands", async () => { const logger = createMockLogger(); @@ -6843,7 +6859,6 @@ test.each([false, true])( }); const command = chunked ? "x".repeat(50 * 1024 * 1024) : "pwd"; - const { ChunkedTransport } = await import("@getpaseo/protocol/chunked-transport"); const sender = new ChunkedTransport({ frameBytes: 24 * 1024 * 1024, send: (frame) => mock.triggerMessage(frame), @@ -8484,6 +8499,133 @@ test("closes before admission when a protected-capable daemon is reached in lega expect(JSON.parse(daemonReceived[0] as string)).toMatchObject({ type: "hello" }); }); +test.each([ + { chunked: false, transportChunked: false }, + { chunked: true, transportChunked: false }, + { chunked: false, transportChunked: true }, + { chunked: true, transportChunked: true }, +])( + "default terminal observation requests ANSI replay (replay=$chunked, transport=$transportChunked)", + async ({ chunked, transportChunked }) => { + const transport = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger: createMockLogger(), + reconnect: { enabled: false }, + transportFactory: () => transport.transport, + }); + clients.push(client); + const connecting = client.connect(); + transport.triggerOpen({ + preserveSent: true, + features: { + ownedSubscriptions: true, + "terminal-restore-modes": true, + binaryChunkingV1: transportChunked, + }, + }); + await connecting; + const hello = JSON.parse(transport.sent[0] as string); + expect(hello.capabilities[CLIENT_CAPS.terminalChunkedReplay]).toBe(true); + expect(hello.capabilities[CLIENT_CAPS.binaryChunkingV1]).toBe(true); + const wire: Uint8Array[] = []; + const sender = new ChunkedTransport({ + frameBytes: 17, + send: (frame) => { + if (typeof frame === "string") throw new Error("Expected binary terminal frame"); + wire.push(frame); + transport.triggerMessage(frame); + }, + deliver: () => {}, + }); + sender.enabled = transportChunked; + const events: import("./terminal-stream-router").TerminalStreamEvent[] = []; + const observation = client.observeTerminal("default-terminal", (event) => events.push(event)); + await vi.waitFor(() => + expect(parseSentFrame(transport.sent.at(-1)).type).toBe("subscribe_terminal_request"), + ); + const request = parseSentFrame(transport.sent.at(-1)); + expect(request.restore).toEqual({ mode: "full-snapshot" }); + transport.triggerMessage( + wrapSessionMessage({ + type: "subscribe_terminal_response", + payload: { + terminalId: "default-terminal", + requestId: request.requestId, + subscriptionId: "default-subscription", + slot: 2, + error: null, + }, + }), + ); + await observation.ready; + const data = new TextEncoder().encode("\x1b[31mretained 界🙂\x1b[0m"); + const metadata = { rows: 24, cols: 80, retainedHistoryLines: 1000 }; + if (chunked) { + await sender.send( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.ReplayBegin, + slot: 2, + payload: JSON.stringify(metadata), + }), + ); + await sender.send( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.ReplayData, + slot: 2, + payload: data, + }), + ); + await sender.send( + encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.ReplayEnd, slot: 2 }), + ); + } else { + // An ANSI-era host selects its existing Restore path for this explicit request. + await sender.send( + encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.Restore, slot: 2, payload: data }), + ); + } + if (transportChunked) { + expect(wire.length).toBeGreaterThan(chunked ? 3 : 1); + expect(wire.some((frame) => frame[0] === 0x31)).toBe(true); + expect(wire.every((frame) => frame.length <= 17)).toBe(true); + } else { + expect(wire.map((frame) => frame[0])).toEqual(chunked ? [6, 7, 8] : [5]); + } + expect(events).toEqual([ + { + terminalId: "default-terminal", + subscriptionId: "default-subscription", + type: "replay", + replay: chunked ? { type: "begin", ...metadata } : { type: "begin" }, + }, + { + terminalId: "default-terminal", + subscriptionId: "default-subscription", + type: "replay", + replay: { type: "data", data }, + }, + { + terminalId: "default-terminal", + subscriptionId: "default-subscription", + type: "replay", + replay: { type: "end" }, + }, + ]); + const live = new TextEncoder().encode("live after replay 界🙂"); + await sender.send( + encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.Output, slot: 2, payload: live }), + ); + expect(events.at(-1)).toEqual({ + terminalId: "default-terminal", + subscriptionId: "default-subscription", + type: "output", + data: live, + }); + }, +); + test("uses the frame budget selected by an asynchronously opened transport", async () => { const mock = createMockTransport(); mock.transport.maxFrameBytes = 1024; diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 9152159f1c..3aa91f84c7 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -6460,7 +6460,8 @@ export class DaemonClient { } /** - * Snapshot/restore and output arrive in receive. Subscribe to the returned handle + * Defaults to a full ANSI replay. Replay begin/data/end and live output arrive in receive. + * Subscribe to the returned handle * for terminal_stream_exit: payload.error means observation failure, not PTY exit. * Either outcome detaches this slot and releases the handle automatically. */ @@ -6479,7 +6480,7 @@ export class DaemonClient { { type: "subscribe_terminal_request", terminalId, - ...(options?.restore ? { restore: options.restore } : {}), + restore: options?.restore ?? { mode: "full-snapshot" }, }, options, ); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 1437068f95..3655b0023a 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,3 +1,4 @@ +export type { TerminalReplayEvent, TerminalStreamEvent } from "./terminal-stream-router.js"; import type { AgentAddress } from "@getpaseo/protocol/addresses"; import { parentAddressLabels } from "@getpaseo/protocol/agent-labels"; export type { AgentAddress } from "@getpaseo/protocol/addresses"; diff --git a/packages/client/src/terminal-stream-router.test.ts b/packages/client/src/terminal-stream-router.test.ts index 7b9e598410..b8ed727117 100644 --- a/packages/client/src/terminal-stream-router.test.ts +++ b/packages/client/src/terminal-stream-router.test.ts @@ -4,7 +4,7 @@ import { describe, expect, test } from "vitest"; import { TerminalStreamRouter, type TerminalStreamEvent } from "./terminal-stream-router.js"; describe("terminal-stream-router", () => { - test("routes restore frames as restore events", () => { + test("normalizes old ANSI restore into the replay lifecycle", () => { const router = new TerminalStreamRouter(); const events: TerminalStreamEvent[] = []; const payload = new TextEncoder().encode("restored screen"); @@ -16,14 +16,14 @@ describe("terminal-stream-router", () => { payload, }); - expect(events).toEqual([ - { + expect(events).toEqual( + ["begin", "data", "end"].map((type) => ({ terminalId: "term-1", subscriptionId: "subscription-1", - type: "restore", - data: payload, - }, - ]); + type: "replay", + replay: type === "data" ? { type, data: payload } : { type }, + })), + ); }); }); @@ -61,3 +61,45 @@ test("legacy listeners share a terminal slot without releasing each other", () = router.handleFrame(frame); expect(received).toEqual(["first", "second", "second"]); }); + +test("streams chunks without joining and ignores released slots during replay", () => { + const router = new TerminalStreamRouter(); + const events: TerminalStreamEvent[] = []; + const release = router.attach("old", "terminal", 4, (event) => events.push(event)); + const payload = new TextEncoder().encode( + JSON.stringify({ rows: 24, cols: 80, retainedHistoryLines: 1000 }), + ); + router.handleFrame({ opcode: TerminalStreamOpcode.ReplayBegin, slot: 4, payload }); + const data = new TextEncoder().encode("\x1b[31m界🙂"); + router.handleFrame({ + opcode: TerminalStreamOpcode.ReplayData, + slot: 4, + payload: data.subarray(0, 7), + }); + release(); + router.attach("new", "terminal", 5, (event) => events.push(event)); + router.handleFrame({ + opcode: TerminalStreamOpcode.ReplayData, + slot: 4, + payload: data.subarray(7), + }); + router.handleFrame({ + opcode: TerminalStreamOpcode.ReplayEnd, + slot: 4, + payload: new Uint8Array(), + }); + expect(events).toHaveLength(2); + expect(events[1]).toMatchObject({ + subscriptionId: "old", + type: "replay", + replay: { type: "data", data: data.subarray(0, 7) }, + }); + router.handleFrame({ opcode: TerminalStreamOpcode.ReplayBegin, slot: 5, payload }); + router.handleFrame({ opcode: TerminalStreamOpcode.ReplayData, slot: 5, payload: data }); + router.handleFrame({ + opcode: TerminalStreamOpcode.ReplayEnd, + slot: 5, + payload: new Uint8Array(), + }); + expect(events.slice(2).map((event) => event.subscriptionId)).toEqual(["new", "new", "new"]); +}); diff --git a/packages/client/src/terminal-stream-router.ts b/packages/client/src/terminal-stream-router.ts index a2c9575450..bca9fb3ea1 100644 --- a/packages/client/src/terminal-stream-router.ts +++ b/packages/client/src/terminal-stream-router.ts @@ -1,14 +1,17 @@ import { - decodeTerminalSnapshotPayload, TerminalStreamOpcode, + TerminalReplayMetadataSchema, type TerminalStreamFrame, } from "@getpaseo/protocol/binary-frames/index"; -import type { TerminalState } from "@getpaseo/protocol/messages"; + +export type TerminalReplayEvent = + | { type: "begin"; rows?: number; cols?: number; retainedHistoryLines?: number } + | { type: "data"; data: Uint8Array } + | { type: "end" }; export type TerminalStreamEvent = { terminalId: string; subscriptionId: string } & ( | { type: "output"; data: Uint8Array } - | { type: "snapshot"; state: TerminalState } - | { type: "restore"; data: Uint8Array } + | { type: "replay"; replay: TerminalReplayEvent } ); export class TerminalStreamRouter { @@ -61,20 +64,42 @@ export class TerminalStreamRouter { let event: TerminalStreamEvent; if (frame.opcode === TerminalStreamOpcode.Output) event = { ...identity, type: "output", data: frame.payload }; - else if (frame.opcode === TerminalStreamOpcode.Restore) - event = { ...identity, type: "restore", data: frame.payload }; - else if (frame.opcode === TerminalStreamOpcode.Snapshot) { - const state = decodeTerminalSnapshotPayload(frame.payload); - if (!state) return; - event = { ...identity, type: "snapshot", state }; - } else return; - registration.receive(event); - for (const listener of this.listeners) { + else if (frame.opcode === TerminalStreamOpcode.Restore) { + // COMPAT(terminalSingleRestore): added after v0.14.0; remove when ANSI host floor supports chunked replay after 2027-04-30. + for (const replay of [ + { type: "begin" }, + { type: "data", data: frame.payload }, + { type: "end" }, + ] as TerminalReplayEvent[]) + this.deliver(registration.receive, { ...identity, type: "replay", replay }); + continue; + } else if (frame.opcode === TerminalStreamOpcode.ReplayBegin) { + let raw: unknown; try { - listener(event); + raw = JSON.parse(new TextDecoder().decode(frame.payload)); } catch { - /* Passive diagnostics cannot interrupt delivery. */ + return; } + const parsed = TerminalReplayMetadataSchema.safeParse(raw); + if (!parsed.success) return; + const metadata = parsed.data; + event = { ...identity, type: "replay", replay: { type: "begin", ...metadata } }; + } else if (frame.opcode === TerminalStreamOpcode.ReplayData) + event = { ...identity, type: "replay", replay: { type: "data", data: frame.payload } }; + else if (frame.opcode === TerminalStreamOpcode.ReplayEnd) + event = { ...identity, type: "replay", replay: { type: "end" } }; + else return; + this.deliver(registration.receive, event); + } + } + + private deliver(receive: (event: TerminalStreamEvent) => void, event: TerminalStreamEvent): void { + receive(event); + for (const listener of this.listeners) { + try { + listener(event); + } catch { + /* Passive diagnostics cannot interrupt delivery. */ } } } diff --git a/packages/protocol/src/binary-frames/demux.test.ts b/packages/protocol/src/binary-frames/demux.test.ts index c56e417528..a350cfaf03 100644 --- a/packages/protocol/src/binary-frames/demux.test.ts +++ b/packages/protocol/src/binary-frames/demux.test.ts @@ -89,3 +89,18 @@ describe("host route binary framing", () => { ).toThrow("Invalid host route ID"); }); }); + +it.each([ + TerminalStreamOpcode.ReplayBegin, + TerminalStreamOpcode.ReplayData, + TerminalStreamOpcode.ReplayEnd, +])("decodes replay opcode %s directly and inside a host route", (opcode) => { + const payload = new TextEncoder().encode("界 replay"); + const bytes = encodeTerminalStreamFrame({ opcode, slot: 12, payload }); + const expected = { kind: "terminal", frame: { opcode, slot: 12, payload } }; + expect(decodeBinaryFrame(bytes)).toEqual(expected); + const routed = decodeBinaryFrame(encodeHostRouteFrame("host", bytes)); + expect(routed?.kind).toBe("host_route"); + if (routed?.kind !== "host_route") throw new Error("Missing host route"); + expect(decodeBinaryFrame(routed.frame.payload)).toEqual(expected); +}); diff --git a/packages/protocol/src/binary-frames/demux.ts b/packages/protocol/src/binary-frames/demux.ts index a8503b898f..c207026362 100644 --- a/packages/protocol/src/binary-frames/demux.ts +++ b/packages/protocol/src/binary-frames/demux.ts @@ -25,6 +25,9 @@ export function decodeBinaryFrame(bytes: Uint8Array): BinaryFrame | null { case TerminalStreamOpcode.Input: case TerminalStreamOpcode.Resize: case TerminalStreamOpcode.Snapshot: + case TerminalStreamOpcode.ReplayBegin: + case TerminalStreamOpcode.ReplayData: + case TerminalStreamOpcode.ReplayEnd: case TerminalStreamOpcode.Restore: { const frame = decodeTerminalStreamFrame(bytes); return frame ? { kind: "terminal", frame } : null; diff --git a/packages/protocol/src/binary-frames/terminal.ts b/packages/protocol/src/binary-frames/terminal.ts index 3936502c89..b74ea7a8de 100644 --- a/packages/protocol/src/binary-frames/terminal.ts +++ b/packages/protocol/src/binary-frames/terminal.ts @@ -1,6 +1,12 @@ import { z } from "zod"; import { TerminalStateSchema } from "../messages.js"; +export const TerminalReplayMetadataSchema = z.object({ + rows: z.number().int().positive(), + cols: z.number().int().positive(), + retainedHistoryLines: z.number().int().nonnegative(), +}); + export const TerminalStreamResizeSchema = z.object({ rows: z.number().int().positive(), cols: z.number().int().positive(), @@ -13,6 +19,9 @@ export const TerminalStreamOpcode = { Resize: 0x03, Snapshot: 0x04, Restore: 0x05, + ReplayBegin: 0x06, + ReplayData: 0x07, + ReplayEnd: 0x08, } as const; export type TerminalStreamOpcode = (typeof TerminalStreamOpcode)[keyof typeof TerminalStreamOpcode]; @@ -58,6 +67,9 @@ function isTerminalStreamOpcode(value: number): value is TerminalStreamOpcode { value === TerminalStreamOpcode.Input || value === TerminalStreamOpcode.Resize || value === TerminalStreamOpcode.Snapshot || + value === TerminalStreamOpcode.ReplayBegin || + value === TerminalStreamOpcode.ReplayData || + value === TerminalStreamOpcode.ReplayEnd || value === TerminalStreamOpcode.Restore ); } diff --git a/packages/protocol/src/client-capabilities.ts b/packages/protocol/src/client-capabilities.ts index 28ec8456cb..3ae02671e7 100644 --- a/packages/protocol/src/client-capabilities.ts +++ b/packages/protocol/src/client-capabilities.ts @@ -1,4 +1,5 @@ export const CLIENT_CAPS = { + terminalChunkedReplay: "terminal_chunked_replay", binaryChunkingV1: "binary_chunking_v1", helloRejection: "hello_rejection", // COMPAT(ownedSubscriptions): added in v0.8.0, remove legacy ownership after 2027-03-09. diff --git a/packages/protocol/src/terminal-snapshot.test.ts b/packages/protocol/src/terminal-snapshot.test.ts deleted file mode 100644 index 96d82a9205..0000000000 --- a/packages/protocol/src/terminal-snapshot.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { renderTerminalSnapshotToAnsi } from "./terminal-snapshot"; -import type { TerminalState } from "./messages"; - -function cells(text: string): TerminalState["grid"][number] { - return [...text].map((char) => ({ char })); -} - -describe("renderTerminalSnapshotToAnsi", () => { - it("renders soft-wrapped rows as one contiguous logical line when wrap flags are present", () => { - // The server soft-wrapped one logical line "ABCDEFGHIJKLMNOP" at 10 cols into - // two grid rows. gridWrapped[0] = true marks row 0 as continuing into row 1. - const state: TerminalState = { - rows: 2, - cols: 10, - scrollback: [], - scrollbackWrapped: [], - grid: [cells("ABCDEFGHIJ"), cells("KLMNOP")], - gridWrapped: [true, false], - cursor: { row: 1, col: 6 }, - }; - - const ansi = renderTerminalSnapshotToAnsi(state); - - // The rows must arrive unbroken so xterm re-wraps them itself (and can later - // reflow them) — no hard newline injected between "...IJ" and "KL...". - expect(ansi).toContain("ABCDEFGHIJKLMNOP"); - // Auto-wrap must stay enabled; disabling it (ESC[?7l) is what makes xterm mark - // the rows non-wrapped and refuse to reflow them on resize. - expect(ansi).not.toContain("[?7l"); - }); - - it("falls back to verbatim per-row replay when wrap flags are absent (old daemon)", () => { - // No gridWrapped/scrollbackWrapped: the client cannot tell soft-wraps from hard - // newlines, so it must keep today's exact behaviour rather than guess. - const state: TerminalState = { - rows: 2, - cols: 10, - scrollback: [], - grid: [cells("ABCDEFGHIJ"), cells("KLMNOP")], - cursor: { row: 1, col: 6 }, - }; - - const ansi = renderTerminalSnapshotToAnsi(state); - - expect(ansi).toContain("[?7l"); - expect(ansi).toContain("ABCDEFGHIJ\r\nKLMNOP"); - }); -}); - -describe("renderTerminalSnapshotToAnsi wide chars", () => { - it("skips wide-char continuation cells so the glyph keeps its two columns", () => { - // The daemon captures "통" as the glyph followed by an empty continuation - // cell. Emitting anything for that cell pushes the rest of the row right. - const state: TerminalState = { - rows: 1, - cols: 10, - scrollback: [], - grid: [[{ char: "통" }, { char: "" }, { char: "합" }, { char: "" }, { char: "!" }]], - cursor: { row: 0, col: 5 }, - }; - - expect(renderTerminalSnapshotToAnsi(state)).toContain("통합!"); - }); - - it("replays snapshots from an old daemon verbatim, including its continuation spaces", () => { - // Old daemons already collapsed the continuation to " ". The serializer - // cannot tell that apart from a real space, so it must not guess. - const state: TerminalState = { - rows: 1, - cols: 10, - scrollback: [], - grid: [cells("통 합 !")], - cursor: { row: 0, col: 6 }, - }; - - expect(renderTerminalSnapshotToAnsi(state)).toContain("통 합 !"); - }); -}); diff --git a/packages/protocol/src/terminal-snapshot.ts b/packages/protocol/src/terminal-snapshot.ts deleted file mode 100644 index 1860067573..0000000000 --- a/packages/protocol/src/terminal-snapshot.ts +++ /dev/null @@ -1,224 +0,0 @@ -import type { TerminalCell, TerminalState } from "./messages.js"; - -interface TerminalStyle { - fg: number | undefined; - bg: number | undefined; - fgMode: number | undefined; - bgMode: number | undefined; - bold: boolean; - italic: boolean; - underline: boolean; - dim: boolean; - inverse: boolean; - strikethrough: boolean; -} - -const DEFAULT_STYLE: TerminalStyle = { - fg: undefined, - bg: undefined, - fgMode: undefined, - bgMode: undefined, - bold: false, - italic: false, - underline: false, - dim: false, - inverse: false, - strikethrough: false, -}; - -export function renderTerminalSnapshotToAnsi(state: TerminalState): string { - const rows = [...state.scrollback, ...state.grid]; - const wrapFlags = [...(state.scrollbackWrapped ?? []), ...(state.gridWrapped ?? [])]; - // Soft-wrapped lines can only be re-wrapped on resize when we know which rows - // were continuations. With that per-row flag we replay each logical line as one - // unbroken run (autowrap on) so xterm marks the continuations wrapped and reflows - // them. Without it (old daemon) we keep the verbatim per-row replay: autowrap off - // plus a hard newline per row. - const hasWrapInfo = wrapFlags.length === rows.length; - const lines: string[] = hasWrapInfo ? [] : ["\u001b[?7l"]; - - for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) { - const row = rows[rowIndex] ?? []; - const continuesToNextRow = hasWrapInfo && wrapFlags[rowIndex] === true; - // A continuation row must fill the full width so the next row first cell - // triggers xterm auto-wrap, which is what marks the row wrapped/reflowable. - lines.push(renderTerminalRow(row, continuesToNextRow ? state.cols : undefined)); - if (rowIndex < rows.length - 1 && !continuesToNextRow) { - lines.push("\r\n"); - } - } - - lines.push("\u001b[0m"); - const cursorPresentationAnsi = renderCursorPresentationToAnsi(state.cursor); - if (cursorPresentationAnsi) { - lines.push(cursorPresentationAnsi); - } - lines.push(`\u001b[${state.cursor.row + 1};${state.cursor.col + 1}H`); - lines.push(state.cursor.hidden ? "\u001b[?25l" : "\u001b[?25h"); - if (!hasWrapInfo) { - lines.push("\u001b[?7h"); - } - return lines.join(""); -} - -function renderCursorPresentationToAnsi(cursor: TerminalState["cursor"]): string | null { - if (!cursor.style) { - return null; - } - - const cursorStyleCode = (() => { - if (cursor.style === "block") { - return cursor.blink === false ? 2 : 1; - } - if (cursor.style === "underline") { - return cursor.blink === false ? 4 : 3; - } - return cursor.blink === false ? 6 : 5; - })(); - - return `\u001b[${cursorStyleCode} q`; -} - -function renderTerminalRow(row: TerminalCell[], padToCols?: number): string { - const output: string[] = []; - const contentLength = getTerminalRowLength(row); - const length = padToCols !== undefined ? Math.max(contentLength, padToCols) : contentLength; - let previousStyle = DEFAULT_STYLE; - - for (let index = 0; index < length; index += 1) { - const cell = row[index] ?? { char: " " }; - // An empty char marks a wide-char continuation cell. The glyph in the - // previous cell already advances the cursor two columns, so emitting - // anything here would shift the rest of the row one column right. - if (cell.char === "") { - continue; - } - const nextStyle = getTerminalStyle(cell); - if (!terminalStylesEqual(previousStyle, nextStyle)) { - output.push(styleToAnsi(nextStyle)); - previousStyle = nextStyle; - } - output.push(cell.char || " "); - } - - if (!terminalStylesEqual(previousStyle, DEFAULT_STYLE)) { - output.push("\u001b[0m"); - } - - return output.join(""); -} - -function getTerminalRowLength(row: TerminalCell[]): number { - for (let index = row.length - 1; index >= 0; index -= 1) { - const cell = row[index]; - if (!cell) { - continue; - } - if (cell.char !== " ") { - return index + 1; - } - if ( - cell.fg !== undefined || - cell.bg !== undefined || - cell.fgMode !== undefined || - cell.bgMode !== undefined || - cell.bold || - cell.italic || - cell.underline || - cell.dim || - cell.inverse || - cell.strikethrough - ) { - return index + 1; - } - } - return 0; -} - -function getTerminalStyle(cell: TerminalCell): TerminalStyle { - return { - fg: cell.fg, - bg: cell.bg, - fgMode: cell.fgMode, - bgMode: cell.bgMode, - bold: Boolean(cell.bold), - italic: Boolean(cell.italic), - underline: Boolean(cell.underline), - dim: Boolean(cell.dim), - inverse: Boolean(cell.inverse), - strikethrough: Boolean(cell.strikethrough), - }; -} - -function terminalStylesEqual(left: TerminalStyle, right: TerminalStyle): boolean { - return ( - left.fg === right.fg && - left.bg === right.bg && - left.fgMode === right.fgMode && - left.bgMode === right.bgMode && - left.bold === right.bold && - left.italic === right.italic && - left.underline === right.underline && - left.dim === right.dim && - left.inverse === right.inverse && - left.strikethrough === right.strikethrough - ); -} - -function styleToAnsi(style: TerminalStyle): string { - const codes = ["0"]; - - if (style.bold) { - codes.push("1"); - } - if (style.dim) { - codes.push("2"); - } - if (style.italic) { - codes.push("3"); - } - if (style.underline) { - codes.push("4"); - } - if (style.inverse) { - codes.push("7"); - } - if (style.strikethrough) { - codes.push("9"); - } - - if (style.fg !== undefined && style.fgMode !== undefined) { - codes.push(...colorToSgr(style.fgMode, style.fg, false)); - } - - if (style.bg !== undefined && style.bgMode !== undefined) { - codes.push(...colorToSgr(style.bgMode, style.bg, true)); - } - - return `\u001b[${codes.join(";")}m`; -} - -function colorToSgr(mode: number, value: number, background: boolean): string[] { - if (mode === 1) { - if (value >= 8) { - return [String((background ? 100 : 90) + (value - 8))]; - } - return [String((background ? 40 : 30) + value)]; - } - - if (mode === 2) { - return [background ? "48" : "38", "5", String(value)]; - } - - if (mode === 3) { - return [ - background ? "48" : "38", - "2", - String((value >> 16) & 0xff), - String((value >> 8) & 0xff), - String(value & 0xff), - ]; - } - - return []; -} diff --git a/packages/server/package.json b/packages/server/package.json index a31ce8d02a..0fcbacaf24 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -116,6 +116,7 @@ "@modelcontextprotocol/sdk": "1.29.0", "@opencode-ai/sdk": "1.14.46", "@opencode/client": "2.0.10", + "@xterm/addon-serialize": "0.14.0", "@xterm/headless": "^6.0.0", "ai": "5.0.78", "ajv": "^8.20.0", diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 86ee383f73..ecf2d912a5 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -1493,7 +1493,6 @@ describe("terminal MCP tools", () => { id: "term-1", name: "daemon", cwd: process.cwd(), - getState: vi.fn().mockReturnValue({ scrollback: [], grid: [[]] }), }), captureTerminal, }); diff --git a/packages/server/src/server/daemon-e2e/terminal-byte-headless-parity.e2e.test.ts b/packages/server/src/server/daemon-e2e/terminal-byte-headless-parity.e2e.test.ts index 2b789788ad..ed00f6cd9a 100644 --- a/packages/server/src/server/daemon-e2e/terminal-byte-headless-parity.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/terminal-byte-headless-parity.e2e.test.ts @@ -7,7 +7,6 @@ import { Terminal as HeadlessTerminal, type IBufferCell, type IBufferLine } from import { expect, test } from "vitest"; import type { TerminalCell, TerminalState } from "@getpaseo/protocol/messages"; -import { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot"; import type { TerminalStreamEvent } from "@getpaseo/client/internal/daemon-client"; import { DaemonClient } from "../test-utils/daemon-client.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; @@ -46,7 +45,7 @@ test("byte-stream headless terminal matches daemon state after high-output attac terminalId: terminal.id, headless: liveHeadless, }); - const initialSnapshot = waitForTerminalStreamEvent(client, terminal.id, "snapshot"); + const initialSnapshot = waitForReplayEnd(client, terminal.id); const liveSubscription = await subscribeTerminal(client, terminal.id); await initialSnapshot; @@ -60,13 +59,13 @@ test("byte-stream headless terminal matches daemon state after high-output attac text: BYTE_DONE_MARKER, rows: BYTE_TEST_SIZE.rows, }); - const daemonSnapshot = await readTerminalSnapshot({ + const daemonSnapshot = await readReplayViewport({ port: daemon.port, terminalId: terminal.id, }); await liveHeadless.flush(); - expect(liveHeadless.visibleLines()).toEqual(visibleTerminalLines(daemonSnapshot)); + expect(liveHeadless.visibleLines()).toEqual(daemonSnapshot.lines); expect(liveHeadless.cursor()).toEqual(daemonSnapshot.cursor); expect(liveHeadless.visibleLines()).toEqual(capturedViewport.lines); @@ -79,7 +78,7 @@ test("byte-stream headless terminal matches daemon state after high-output attac terminalId: terminal.id, headless: restoreHeadless, }); - const restoreFrame = waitForTerminalStreamEvent(client, terminal.id, "restore"); + const restoreFrame = waitForReplayEnd(client, terminal.id); await subscribeTerminal(client, terminal.id, { restore: { mode: "visible-snapshot", scrollbackLines: 200, size: BYTE_TEST_SIZE }, @@ -116,12 +115,10 @@ class ClientHeadlessTerminal { } applyEvent(event: TerminalStreamEvent): void { - if (event.type === "snapshot") { - this.enqueueText(renderTerminalSnapshotToAnsi(event.state), { resetDecoder: true }); - return; - } - if (event.type === "restore") { - this.enqueueText(this.decoder.decode(event.data, { stream: false }), { resetDecoder: true }); + if (event.type === "replay") { + if (event.replay.type === "begin") this.enqueueText("\x1bc", { resetDecoder: true }); + if (event.replay.type === "data") + this.enqueueText(this.decoder.decode(event.replay.data, { stream: true })); return; } this.enqueueText(this.decoder.decode(event.data, { stream: true })); @@ -187,35 +184,32 @@ async function subscribeTerminal( return response.subscription; } -async function readTerminalSnapshot(input: { - port: number; - terminalId: string; -}): Promise { - const client = new DaemonClient({ - url: `ws://127.0.0.1:${input.port}/ws`, - appVersion: "0.1.96", - clientType: "mobile", - }); +async function readReplayViewport(input: { port: number; terminalId: string }): Promise<{ + lines: string[]; + cursor: TerminalState["cursor"]; +}> { + const client = new DaemonClient({ url: `ws://127.0.0.1:${input.port}/ws`, clientType: "mobile" }); + const headless = new ClientHeadlessTerminal(BYTE_TEST_SIZE); + const stop = pipeTerminalStreamToHeadless({ client, terminalId: input.terminalId, headless }); try { await client.connect(); - const snapshot = waitForTerminalStreamEvent(client, input.terminalId, "snapshot"); + const completed = waitForReplayEnd(client, input.terminalId); await subscribeTerminal(client, input.terminalId); - const event = await snapshot; - return event.state; + await completed; + await headless.flush(); + return { lines: headless.visibleLines(), cursor: headless.cursor() }; } finally { + stop(); await client.close(); } } -function waitForTerminalStreamEvent( - client: DaemonClient, - terminalId: string, - type: TType, -): Promise> { - return client.waitForTerminalStreamEvent( - (event) => event.terminalId === terminalId && event.type === type, - 10_000, - ) as Promise>; +async function waitForReplayEnd(client: DaemonClient, terminalId: string): Promise { + await client.waitForTerminalStreamEvent( + (event) => + event.terminalId === terminalId && event.type === "replay" && event.replay.type === "end", + 10000, + ); } async function createPackageLockTerminalCwd(): Promise { @@ -287,24 +281,16 @@ async function waitForCondition( throw new Error(`Timed out after ${timeoutMs}ms waiting for terminal byte parity`); } -function visibleTerminalLines(state: TerminalState): string[] { - return state.grid.map(terminalRowText); -} - -function terminalRowText(row: TerminalState["grid"][number]): string { - return row - .map((cell) => cell.char) - .join("") - .trimEnd(); -} - function bufferLineText(line: IBufferLine | undefined, reusableCell: IBufferCell): string { const cells: TerminalCell[] = []; for (let col = 0; col < BYTE_TEST_SIZE.cols; col += 1) { const cell = line?.getCell(col, reusableCell); cells.push({ char: cell?.getChars() || " " }); } - return terminalRowText(cells); + return cells + .map((cell) => cell.char) + .join("") + .trimEnd(); } function packageLockStreamScript(): string { diff --git a/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts b/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts index 3c131ecab3..36425ffeec 100644 --- a/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts @@ -1,3 +1,4 @@ +import { Terminal as HeadlessTerminal } from "@xterm/headless"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { mkdtempSync, rmSync } from "fs"; import { tmpdir } from "os"; @@ -10,7 +11,6 @@ import { type WSOutboundMessage, } from "@getpaseo/protocol/messages"; import { - decodeTerminalSnapshotPayload, decodeTerminalStreamFrame, encodeTerminalStreamFrame, TerminalStreamOpcode, @@ -80,6 +80,24 @@ async function waitForCondition( throw new Error(`Timed out after ${timeoutMs}ms waiting for condition`); } +function readHeadlessState(terminal: HeadlessTerminal): TerminalState { + const buffer = terminal.buffer.active; + const lines: TerminalState["grid"] = []; + for (let row = 0; row < buffer.length; row++) { + const cells: TerminalState["grid"][number] = []; + for (let col = 0; col < terminal.cols; col++) + cells.push({ char: buffer.getLine(row)?.getCell(col)?.getChars() ?? "" }); + lines.push(cells); + } + return { + rows: terminal.rows, + cols: terminal.cols, + grid: lines.slice(buffer.baseY), + scrollback: lines.slice(0, buffer.baseY), + cursor: { row: buffer.cursorY, col: buffer.cursorX }, + }; +} + async function waitForTerminalSnapshot( client: DaemonClient, terminalId: string, @@ -92,16 +110,34 @@ async function waitForTerminalSnapshot( reject(new Error(`Timeout waiting for terminal snapshot (${timeout}ms)`)); }, timeout); + let terminal: HeadlessTerminal | null = null; + let writes = Promise.resolve(); const unsubscribe = client.onTerminalStreamEvent((event) => { - if (event.terminalId !== terminalId || event.type !== "snapshot") { - return; + if (event.terminalId !== terminalId || event.type !== "replay") return; + const replay = event.replay; + if (replay.type === "begin") { + terminal?.dispose(); + terminal = new HeadlessTerminal({ + rows: replay.rows ?? 24, + cols: replay.cols ?? 80, + scrollback: 1000, + allowProposedApi: true, + }); + } else if (replay.type === "data") { + const active = terminal!; + writes = writes.then(() => new Promise((done) => active.write(replay.data, done))); + } else { + void writes.then(() => { + const active = terminal!; + const state = readHeadlessState(active); + if (!predicate(state)) return null; + clearTimeout(timeoutHandle); + unsubscribe(); + active.dispose(); + resolve(state); + return null; + }); } - if (!predicate(event.state)) { - return; - } - clearTimeout(timeoutHandle); - unsubscribe(); - resolve(event.state); }); }); } @@ -160,7 +196,7 @@ function toWsBuffer(raw: WebSocket.RawData): Buffer | null { return null; } -async function connectRawWebSocket(port: number): Promise { +async function connectRawWebSocket(port: number, chunkedReplay = false): Promise { const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`); await new Promise((resolve, reject) => { ws.once("open", () => resolve()); @@ -180,6 +216,7 @@ async function connectRawWebSocket(port: number): Promise { clientId: `terminal-raw-${Date.now()}-${Math.random().toString(36).slice(2)}`, clientType: "cli", protocolVersion: 1, + capabilities: { terminal_chunked_replay: chunkedReplay }, }), ); @@ -386,17 +423,24 @@ async function collectRawBinaryFrames( }); } +async function decodeRestoreState( + frame: TerminalStreamFrame, + terminalId: string, +): Promise { + const size = ctx.daemon.daemon.terminalManager!.getTerminal(terminalId)!.getSize(); + const terminal = new HeadlessTerminal({ ...size, scrollback: 1000, allowProposedApi: true }); + try { + await new Promise((resolve) => terminal.write(frame.payload, resolve)); + return readHeadlessState(terminal); + } finally { + terminal.dispose(); + } +} + function getFrameText(frame: TerminalStreamFrame): string { if (frame.opcode === TerminalStreamOpcode.Output) { return new TextDecoder().decode(frame.payload); } - if (frame.opcode === TerminalStreamOpcode.Snapshot) { - const state = decodeTerminalSnapshotPayload(frame.payload); - if (!state) { - return ""; - } - return extractStateText(state); - } if (frame.opcode === TerminalStreamOpcode.Restore) { return new TextDecoder().decode(frame.payload); } @@ -446,7 +490,10 @@ function startTerminalOutputCapture(ws: WebSocket, slot: number): TerminalOutput if (!frame || frame.slot !== slot) { return; } - if (frame.opcode === TerminalStreamOpcode.Snapshot) { + if ( + frame.opcode === TerminalStreamOpcode.Restore || + frame.opcode === TerminalStreamOpcode.ReplayEnd + ) { snapshotCount += 1; return; } @@ -634,7 +681,7 @@ async function measureRepeatCadenceForTerminal(input: { const slot = await subscribeRawTerminal(ws, terminalId, `sub-repeat-cadence-${input.name}`); await waitForRawBinaryFrame( ws, - (frame) => frame.slot === slot && frame.opcode === TerminalStreamOpcode.Snapshot, + (frame) => frame.slot === slot && frame.opcode === TerminalStreamOpcode.Restore, 10000, ); await new Promise((resolve) => setTimeout(resolve, 1000)); @@ -817,13 +864,7 @@ test("live terminal restore skips the initial snapshot", async () => { expect(outputText).toContain("after-live"); expect(outputText).not.toContain("before-live"); - expect( - outputFrames.some( - (frame) => - frame.opcode === TerminalStreamOpcode.Snapshot || - frame.opcode === TerminalStreamOpcode.Restore, - ), - ).toBe(false); + expect(outputFrames.some((frame) => frame.opcode === TerminalStreamOpcode.Restore)).toBe(false); } finally { await closeWebSocket(ws); } @@ -901,15 +942,12 @@ test("propagates debounced terminal titles through list responses and snapshots" } expect(listedTitle).toBe("Build Output"); - const snapshotPromise = waitForTerminalSnapshot( - ctx.client, - terminalId, - (state) => state.title === "Build Output", + const attached = await ctx.client.subscribeTerminal(terminalId); + expect(attached.error).toBeNull(); + const afterAttach = await ctx.client.listTerminals(cwd); + expect(afterAttach.terminals.find((terminal) => terminal.id === terminalId)?.title).toBe( + "Build Output", ); - await ctx.client.subscribeTerminal(terminalId); - const snapshot = await snapshotPromise; - - expect(snapshot.title).toBe("Build Output"); rmSync(cwd, { recursive: true, force: true }); }, 30000); @@ -973,13 +1011,10 @@ test("subscribe response is sent before the initial snapshot frame", async () => return; } const frame = decodeTerminalStreamFrame(new Uint8Array(buffer)); - if (frame?.opcode !== TerminalStreamOpcode.Snapshot) { - return; - } - const state = decodeTerminalSnapshotPayload(frame.payload); - if (!state || !extractStateText(state).includes("hello-ordering")) { + if (frame?.opcode !== TerminalStreamOpcode.Restore) { return; } + if (!getFrameText(frame).includes("hello-ordering")) return; events.push("snapshot"); maybeResolve(); }; @@ -1113,7 +1148,7 @@ test("reconnected terminal streams replay active input modes after the snapshot" const firstSlot = await subscribeRawTerminal(firstWs, terminalId, "sub-input-mode-first"); await waitForRawBinaryFrame( firstWs, - (frame) => frame.slot === firstSlot && frame.opcode === TerminalStreamOpcode.Snapshot, + (frame) => frame.slot === firstSlot && frame.opcode === TerminalStreamOpcode.Restore, ); sendRawTerminalInput(firstWs, firstSlot, "e"); await waitForRawBinaryFrame( @@ -1140,7 +1175,7 @@ test("reconnected terminal streams replay active input modes after the snapshot" const secondSlot = await subscribeRawTerminal(secondWs, terminalId, "sub-input-mode-second"); await waitForRawBinaryFrame( secondWs, - (frame) => frame.slot === secondSlot && frame.opcode === TerminalStreamOpcode.Snapshot, + (frame) => frame.slot === secondSlot && frame.opcode === TerminalStreamOpcode.Restore, ); const replay = await waitForRawBinaryFrame( secondWs, @@ -1172,7 +1207,7 @@ test("reconnected terminal streams do not replay input modes before they are ena const slot = await subscribeRawTerminal(ws, terminalId, "sub-input-mode-inactive"); await waitForRawBinaryFrame( ws, - (frame) => frame.slot === slot && frame.opcode === TerminalStreamOpcode.Snapshot, + (frame) => frame.slot === slot && frame.opcode === TerminalStreamOpcode.Restore, ); await waitForNoRawBinaryFrame(ws, 300); } finally { @@ -1188,7 +1223,7 @@ test("fast output to a slow websocket client falls back to a snapshot", async () const ws = await connectRawWebSocket(ctx.daemon.port); await subscribeRawTerminal(ws, terminalId, "sub-raw"); - await waitForRawBinaryFrame(ws, (frame) => frame.opcode === TerminalStreamOpcode.Snapshot, 10000); + await waitForRawBinaryFrame(ws, (frame) => frame.opcode === TerminalStreamOpcode.Restore, 10000); const rawSocket = (ws as WebSocket & { _socket?: { pause: () => void; resume: () => void } }) ._socket; @@ -1203,12 +1238,12 @@ test("fast output to a slow websocket client falls back to a snapshot", async () await new Promise((resolve) => setTimeout(resolve, 750)); const catchUpFramePromise = waitForRawBinaryFrame( ws, - (frame) => frame.opcode === TerminalStreamOpcode.Snapshot, + (frame) => frame.opcode === TerminalStreamOpcode.Restore, 15000, ); rawSocket!.resume(); const catchUpFrame = await catchUpFramePromise; - expect(catchUpFrame.opcode).toBe(TerminalStreamOpcode.Snapshot); + expect(catchUpFrame.opcode).toBe(TerminalStreamOpcode.Restore); await closeWebSocket(ws); rmSync(cwd, { recursive: true, force: true }); @@ -1260,7 +1295,7 @@ test("resize updates server dimensions without sending a live snapshot", async ( await new Promise((resolve) => setTimeout(resolve, 300)); - const state = ctx.daemon.daemon.terminalManager?.getTerminal(terminalId)?.getState(); + const state = ctx.daemon.daemon.terminalManager?.getTerminal(terminalId)?.getSize(); expect(state?.rows).toBe(10); expect(state?.cols).toBe(40); @@ -1277,7 +1312,7 @@ test("the latest resize from any attached client owns the PTY size", async () => ); const readSize = () => { - const state = ctx.daemon.daemon.terminalManager?.getTerminal(terminalId)?.getState(); + const state = ctx.daemon.daemon.terminalManager?.getTerminal(terminalId)?.getSize(); return state ? { rows: state.rows, cols: state.cols } : null; }; const expectSize = async (size: { rows: number; cols: number }) => { @@ -1403,10 +1438,10 @@ test("websocket terminate then new connection gets snapshot with all prior outpu await subscribeRawTerminal(firstSocket, terminalId, "sub-drop-a"); const initialSnapshot = await waitForRawBinaryFrame( firstSocket, - (frame) => frame.opcode === TerminalStreamOpcode.Snapshot, + (frame) => frame.opcode === TerminalStreamOpcode.Restore, 10000, ); - expect(initialSnapshot.opcode).toBe(TerminalStreamOpcode.Snapshot); + expect(initialSnapshot.opcode).toBe(TerminalStreamOpcode.Restore); ctx.client.sendTerminalInput(terminalId, { type: "input", @@ -1442,9 +1477,9 @@ test("websocket terminate then new connection gets snapshot with all prior outpu try { await subscribeRawTerminal(secondSocket, terminalId, "sub-drop-b"); const reconnectFrame = await waitForRawBinaryFrame(secondSocket, () => true, 10000); - expect(reconnectFrame.opcode).toBe(TerminalStreamOpcode.Snapshot); + expect(reconnectFrame.opcode).toBe(TerminalStreamOpcode.Restore); - const state = decodeTerminalSnapshotPayload(reconnectFrame.payload); + const state = await decodeRestoreState(reconnectFrame, terminalId); expect(state).not.toBeNull(); expect(extractStateText(state!)).toContain("before-drop"); expect(extractStateText(state!)).toContain("while-dead"); @@ -1513,11 +1548,11 @@ test("snapshot fidelity through websocket decode preserves dimensions and visibl await subscribeRawTerminal(ws, terminalId, "sub-fidelity"); const snapshotFrame = await waitForRawBinaryFrame( ws, - (frame) => frame.opcode === TerminalStreamOpcode.Snapshot, + (frame) => frame.opcode === TerminalStreamOpcode.Restore, 10000, ); - const state = decodeTerminalSnapshotPayload(snapshotFrame.payload); + const state = await decodeRestoreState(snapshotFrame, terminalId); expect(state).not.toBeNull(); expect(state).toMatchObject({ rows: 24, @@ -1545,7 +1580,7 @@ test("terminal exit prevents resubscribe and sends no frames after exit", async await subscribeRawTerminal(ws, terminalId, "sub-exit"); await waitForRawBinaryFrame( ws, - (frame) => frame.opcode === TerminalStreamOpcode.Snapshot, + (frame) => frame.opcode === TerminalStreamOpcode.Restore, 10000, ); @@ -1561,8 +1596,7 @@ test("terminal exit prevents resubscribe and sends no frames after exit", async const exitMessage = await exitMessagePromise; expect(exitMessage.message.type).toBe("terminal_stream_exit"); - const subscribeResult = await ctx.client.subscribeTerminal(terminalId); - expect(subscribeResult.error).toBe("Terminal not found"); + await expect(ctx.client.subscribeTerminal(terminalId)).rejects.toThrow("Terminal not found"); await waitForNoRawBinaryFrame(ws, 750); } finally { await closeWebSocket(ws); @@ -1580,7 +1614,7 @@ test("empty input frame does not crash the server", async () => { const slot = await subscribeRawTerminal(ws, terminalId, "sub-empty-input"); await waitForRawBinaryFrame( ws, - (frame) => frame.opcode === TerminalStreamOpcode.Snapshot, + (frame) => frame.opcode === TerminalStreamOpcode.Restore, 10000, ); @@ -1619,7 +1653,7 @@ test("1MB output burst keeps frames decodable and terminal usable afterward", as await subscribeRawTerminal(ws, terminalId, "sub-large-output"); await waitForRawBinaryFrame( ws, - (frame) => frame.opcode === TerminalStreamOpcode.Snapshot, + (frame) => frame.opcode === TerminalStreamOpcode.Restore, 10000, ); @@ -1636,7 +1670,7 @@ test("1MB output burst keeps frames decodable and terminal usable afterward", as .reduce((sum, frame) => sum + frame.payload.byteLength, 0); return ( outputBytes >= 1024 * 1024 || - collectedFrames.some((frame) => frame.opcode === TerminalStreamOpcode.Snapshot) + collectedFrames.some((frame) => frame.opcode === TerminalStreamOpcode.Restore) ); }, 20000, @@ -1646,7 +1680,7 @@ test("1MB output burst keeps frames decodable and terminal usable afterward", as for (const frame of frames) { expect( frame.opcode === TerminalStreamOpcode.Output || - frame.opcode === TerminalStreamOpcode.Snapshot, + frame.opcode === TerminalStreamOpcode.Restore, ).toBe(true); } @@ -1837,18 +1871,149 @@ describe("list terminals across directories", () => { }); }); -test("a paused terminal consumer catches up through a snapshot instead of retaining every output frame", async () => { - const { existsSync } = await import("node:fs"); - const cwd = tmpCwd(); - tempDirs.push(cwd); - const marker = path.join(cwd, "output-complete"); - const created = await createTerminalInWorkspace(ctx.client, { - cwd, - options: { - command: process.execPath, - args: [ - "-e", - ` +test.each([3000, 12050])( + "chunked SDK replay retains requested history for %s printed lines", + async (count) => { + const cwd = tmpCwd(); + tempDirs.push(cwd); + const created = await createTerminalInWorkspace(ctx.client, { + cwd, + options: { + command: "/bin/sh", + args: [ + "-lc", + `i=1; while [ $i -le ${count} ]; do printf '\\033[31mreplay-%04d界🙂 payload-abcdefghijklmnopqrstuvwxyz0123456789\\033[0m\\n' $i; i=$((i+1)); done; sleep 30`, + ], + }, + }); + const id = created.terminal!.id; + await waitForCondition( + async () => + (await ctx.client.captureTerminal(id)).lines + .join("\n") + .includes(`replay-${String(count).padStart(4, "0")}`), + 10000, + ); + const events: import("@getpaseo/client").TerminalStreamEvent[] = []; + let end!: () => void; + const completed = new Promise((resolve) => { + end = resolve; + }); + const subscription = ctx.client.observeTerminal( + id, + (event) => { + events.push(event); + if (event.type === "replay" && event.replay.type === "end") end(); + }, + { restore: { mode: "visible-snapshot", scrollbackLines: 10000 } }, + ); + try { + await subscription.ready; + await completed; + const replay = events.flatMap((event) => (event.type === "replay" ? [event.replay] : [])); + expect(replay[0]).toMatchObject({ + type: "begin", + retainedHistoryLines: count === 12050 ? 10000 : expect.any(Number), + }); + expect(replay.at(-1)).toEqual({ type: "end" }); + const chunks = replay.flatMap((event) => (event.type === "data" ? [event.data] : [])); + expect(chunks.every((chunk) => chunk.length <= 65536)).toBe(true); + const text = Buffer.concat(chunks).toString("utf8"); + const begin = replay[0]; + if (begin?.type !== "begin" || begin.rows === undefined) + throw new Error("Missing replay dimensions"); + expect(text.match(/replay-\d{4,5}/g)).toHaveLength(Math.min(count, 10000 + begin.rows - 1)); + expect(text).toContain(`replay-${String(count).padStart(4, "0")}界🙂`); + if (count === 3000) expect(text).toContain("replay-0001界🙂"); + else { + expect(text).not.toContain("replay-0001"); + expect(chunks.length).toBeGreaterThan(1); + } + } finally { + await subscription.release(); + } + }, + 30000, +); + +test.each([false, true])( + "default SDK observation replays a terminal without waiting for output (quietScreen=%s)", + async (quietScreen) => { + const cwd = tmpCwd(); + tempDirs.push(cwd); + const created = await createTerminalInWorkspace(ctx.client, { + cwd, + options: { + command: process.execPath, + args: [ + "-e", + `${quietScreen ? 'process.stdout.write("QUIET-SCREEN\\r\\n");' : ""} process.stdin.on("data", data => process.stdout.write("LIVE:" + data)); setInterval(() => {}, 1000);`, + ], + }, + }); + const id = created.terminal!.id; + if (quietScreen) + await waitForCondition( + async () => (await ctx.client.captureTerminal(id)).lines.join("").includes("QUIET-SCREEN"), + 10000, + ); + const events: import("@getpaseo/client").TerminalStreamEvent[] = []; + const observation = ctx.client.observeTerminal(id, (event) => events.push(event)); + try { + await observation.ready; + await waitForCondition( + () => events.some((event) => event.type === "replay" && event.replay.type === "end"), + 10000, + ); + const replay = events.flatMap((event) => (event.type === "replay" ? [event.replay] : [])); + const begin = replay[0]; + if (begin?.type !== "begin" || !begin.rows || !begin.cols) + throw new Error("Missing replay begin geometry"); + expect(replay.at(-1)).toEqual({ type: "end" }); + const terminal = new HeadlessTerminal({ + rows: begin.rows, + cols: begin.cols, + allowProposedApi: true, + }); + try { + for (const event of replay) + if (event.type === "data") + await new Promise((resolve) => terminal.write(event.data, resolve)); + const text = extractStateText(readHeadlessState(terminal)); + expect(text).toBe(quietScreen ? "QUIET-SCREEN" : ""); + } finally { + terminal.dispose(); + } + ctx.client.sendTerminalInput(id, { type: "input", data: "next\r" }); + await waitForCondition( + () => + events.some( + (event) => + event.type === "output" && new TextDecoder().decode(event.data).includes("LIVE:next"), + ), + 10000, + ); + } finally { + await observation.release(); + } + }, + 30000, +); + +test.each([false, true])( + "a paused terminal consumer catches up through ANSI replay instead of retaining every output frame (chunked=%s)", + async (chunked) => { + const { existsSync } = await import("node:fs"); + const cwd = tmpCwd(); + tempDirs.push(cwd); + const marker = path.join(cwd, "output-complete"); + const created = await createTerminalInWorkspace(ctx.client, { + cwd, + options: { + command: process.execPath, + args: [ + "-e", + ` const fs = require('node:fs'); process.stdin.setRawMode?.(true); process.stdin.resume(); @@ -1862,32 +2027,37 @@ test("a paused terminal consumer catches up through a snapshot instead of retain write(); }); `, - ], - }, - }); - const ws = await connectRawWebSocket(ctx.daemon.port); - try { - const slot = await subscribeRawTerminal(ws, created.terminal!.id, "slow-terminal"); - await waitForRawBinaryFrame( - ws, - (frame) => frame.slot === slot && frame.opcode === TerminalStreamOpcode.Snapshot, - ); - const capture = startTerminalOutputCapture(ws, slot); + ], + }, + }); + const ws = await connectRawWebSocket(ctx.daemon.port, chunked); try { - ws.pause(); - sendRawTerminalInput(ws, slot, "go\n"); - await waitForCondition(() => existsSync(marker), 20000); - ws.resume(); - await waitForCondition(() => capture.snapshotCount > 0, 10000); - expect(ws.readyState).toBe(WebSocket.OPEN); - expect(capture.frames.reduce((total, frame) => total + frame.payloadBytes, 0)).toBeLessThan( - 32 * 1024 * 1024, + const slot = await subscribeRawTerminal(ws, created.terminal!.id, "slow-terminal"); + await waitForRawBinaryFrame( + ws, + (frame) => + frame.slot === slot && + frame.opcode === + (chunked ? TerminalStreamOpcode.ReplayEnd : TerminalStreamOpcode.Restore), ); + const capture = startTerminalOutputCapture(ws, slot); + try { + ws.pause(); + sendRawTerminalInput(ws, slot, "go\n"); + await waitForCondition(() => existsSync(marker), 20000); + ws.resume(); + await waitForCondition(() => capture.snapshotCount > 0, 10000); + expect(ws.readyState).toBe(WebSocket.OPEN); + expect(capture.frames.reduce((total, frame) => total + frame.payloadBytes, 0)).toBeLessThan( + 32 * 1024 * 1024, + ); + } finally { + ws.resume(); + capture.stop(); + } } finally { - ws.resume(); - capture.stop(); + await closeWebSocket(ws); } - } finally { - await closeWebSocket(ws); - } -}, 40000); + }, + 40000, +); diff --git a/packages/server/src/server/owned-subscriptions.e2e.test.ts b/packages/server/src/server/owned-subscriptions.e2e.test.ts index 2a3128ea9f..fc73e44db4 100644 --- a/packages/server/src/server/owned-subscriptions.e2e.test.ts +++ b/packages/server/src/server/owned-subscriptions.e2e.test.ts @@ -822,86 +822,100 @@ test("same-workspace terminal directory observers do not share a teardown slot", } }); -test("terminal output has separate server IDs and binary slots, including in a shared session", async () => { - const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); - const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); - const peers: SubscriptionPeer[] = []; - try { - await admin.connect(); - const workspace = ( - await admin.createWorkspace({ source: { kind: "directory", path: daemon.staticDir } }) - ).workspace!; - const result = await admin.createTerminal(daemon.staticDir, "Output ownership", undefined, { - workspaceId: workspace.id, - command: process.execPath, - args: ["-e", "process.stdin.on('data', data => process.stdout.write(data))"], +test.each([false, true])( + "terminal output has separate server IDs and binary slots, including in a shared session (chunked=%s)", + async (chunked) => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const admin = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.8.0", }); - expect(result.error).toBe(null); - const idle = await SubscriptionPeer.connect(daemon.port, "terminal-output-session"); - peers.push(idle); - const peer = await SubscriptionPeer.connect(daemon.port, "terminal-output-session"); - peers.push(peer); - for (const requestId of ["output-a", "output-b"]) { - await peer.request({ - type: "subscribe_terminal_request", - requestId, - terminalId: result.terminal!.id, + const peers: SubscriptionPeer[] = []; + try { + await admin.connect(); + const workspace = ( + await admin.createWorkspace({ source: { kind: "directory", path: daemon.staticDir } }) + ).workspace!; + const result = await admin.createTerminal(daemon.staticDir, "Output ownership", undefined, { + workspaceId: workspace.id, + command: process.execPath, + args: ["-e", "process.stdin.on('data', data => process.stdout.write(data))"], }); - } - const replies = peer.frames.flatMap((frame) => - frame.type === "session" && - frame.message.type === "subscribe_terminal_response" && - frame.message.payload.error === null - ? [frame.message.payload] - : [], - ); - expect(replies).toHaveLength(2); - const ids = replies.map( - (reply) => (reply as typeof reply & { subscriptionId: string }).subscriptionId, - ); - expect(ids.every((id) => typeof id === "string")).toBe(true); - expect(ids[0]).not.toBe(ids[1]); - expect(replies[0].slot).not.toBe(replies[1].slot); - await expect - .poll(() => - peer.binaryFrames - .map(decodeTerminalStreamFrame) - .filter((frame) => frame?.opcode === TerminalStreamOpcode.Snapshot) - .map((frame) => frame!.slot), - ) - .toEqual(expect.arrayContaining(replies.map((reply) => reply.slot))); - expect(idle.binaryFrames).toEqual([]); - await peer.request({ - type: "subscription.release.request", - requestId: "release-output-a", - subscriptionId: ids[0], - }); - peer.binaryFrames.length = 0; - admin.sendTerminalInput(result.terminal!.id, { type: "input", data: "remaining-observer\n" }); - await expect - .poll( - () => + expect(result.error).toBe(null); + const idle = await SubscriptionPeer.connect(daemon.port, "terminal-output-session", { + terminal_chunked_replay: chunked, + }); + peers.push(idle); + const peer = await SubscriptionPeer.connect(daemon.port, "terminal-output-session", { + terminal_chunked_replay: chunked, + }); + peers.push(peer); + for (const requestId of ["output-a", "output-b"]) { + await peer.request({ + type: "subscribe_terminal_request", + requestId, + terminalId: result.terminal!.id, + }); + } + const replies = peer.frames.flatMap((frame) => + frame.type === "session" && + frame.message.type === "subscribe_terminal_response" && + frame.message.payload.error === null + ? [frame.message.payload] + : [], + ); + expect(replies).toHaveLength(2); + const ids = replies.map( + (reply) => (reply as typeof reply & { subscriptionId: string }).subscriptionId, + ); + expect(ids.every((id) => typeof id === "string")).toBe(true); + expect(ids[0]).not.toBe(ids[1]); + expect(replies[0].slot).not.toBe(replies[1].slot); + await expect + .poll(() => peer.binaryFrames .map(decodeTerminalStreamFrame) - .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) - .map((frame) => Buffer.from(frame!.payload).toString()) - .join(""), - { timeout: 5000 }, - ) - .toContain("remaining-observer"); - expect( - peer.binaryFrames - .map(decodeTerminalStreamFrame) - .every((frame) => frame?.slot === replies[1].slot), - ).toBe(true); - expect(idle.binaryFrames).toEqual([]); - await admin.killTerminal(result.terminal!.id); - } finally { - for (const peer of peers) peer.close(); - await admin.close(); - await daemon.close(); - } -}); + .filter( + (frame) => + frame?.opcode === + (chunked ? TerminalStreamOpcode.ReplayEnd : TerminalStreamOpcode.Restore), + ) + .map((frame) => frame!.slot), + ) + .toEqual(expect.arrayContaining(replies.map((reply) => reply.slot))); + expect(idle.binaryFrames).toEqual([]); + await peer.request({ + type: "subscription.release.request", + requestId: "release-output-a", + subscriptionId: ids[0], + }); + peer.binaryFrames.length = 0; + admin.sendTerminalInput(result.terminal!.id, { type: "input", data: "remaining-observer\n" }); + await expect + .poll( + () => + peer.binaryFrames + .map(decodeTerminalStreamFrame) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + .map((frame) => Buffer.from(frame!.payload).toString()) + .join(""), + { timeout: 5000 }, + ) + .toContain("remaining-observer"); + expect( + peer.binaryFrames + .map(decodeTerminalStreamFrame) + .every((frame) => frame?.slot === replies[1].slot), + ).toBe(true); + expect(idle.binaryFrames).toEqual([]); + await admin.killTerminal(result.terminal!.id); + } finally { + for (const peer of peers) peer.close(); + await admin.close(); + await daemon.close(); + } + }, +); test("a modern connection and pure reads retain no client observation producers", async () => { const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); diff --git a/packages/server/src/server/script-health-monitor.test.ts b/packages/server/src/server/script-health-monitor.test.ts index 96af0133ca..c5e4904a9d 100644 --- a/packages/server/src/server/script-health-monitor.test.ts +++ b/packages/server/src/server/script-health-monitor.test.ts @@ -68,13 +68,7 @@ function createStubTerminalManager( subscribe: () => () => {}, onExit: () => () => {}, onCommandFinished: () => () => {}, - getState: () => ({ - rows: 1, - cols: 1, - grid: [[{ char: "$" }]], - scrollback: [], - cursor: { row: 0, col: 0 }, - }), + hasOutput: () => true, kill: () => {}, onTitleChange: () => () => {}, getSize: () => ({ rows: 1, cols: 1 }), diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 09681de8ba..42c676b50b 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1130,11 +1130,11 @@ export class Session { terminalManager, emit: (msg) => this.emit(msg), hasBinaryChannel: () => this.onBinaryMessage !== null, + supportsChunkedReplay: (source) => + this.supportsForSource(CLIENT_CAPS.terminalChunkedReplay, source), isPathWithinRoot: (rootPath, candidatePath) => this.isPathWithinRoot(rootPath, candidatePath), sessionLogger: this.sessionLogger, listTerminalWorkspaceRefs: () => this.listActiveWorkspaceRefs(), - clientSupportsWrapReflow: (source) => - this.supportsForSource(CLIENT_CAPS.terminalReflowableSnapshot, source), getClientBufferedAmount: (source) => this.getTransportBufferedAmount(source), }); this.agentDirectory = new AgentDirectory({ diff --git a/packages/server/src/server/test-fixtures/reconciliation-responsiveness.ts b/packages/server/src/server/test-fixtures/reconciliation-responsiveness.ts index 7df5414d2d..dd73ce38a8 100644 --- a/packages/server/src/server/test-fixtures/reconciliation-responsiveness.ts +++ b/packages/server/src/server/test-fixtures/reconciliation-responsiveness.ts @@ -1,3 +1,6 @@ +import assert from "node:assert/strict"; +import { createHook, executionAsyncResource } from "node:async_hooks"; +import { MessagePort } from "node:worker_threads"; import path from "node:path"; import { stat } from "node:fs/promises"; import { setImmediate as nextTurn } from "node:timers/promises"; @@ -57,19 +60,33 @@ const service = new WorkspaceReconciliationService({ // Initialize async filesystem support using the bundle, leaving every project // and workspace path untouched until the first reconciliation. await stat(process.argv[1]); +// Collect fixture allocations before the measured run; allocations made by +// reconciliation itself remain part of its budget. +assert.ok(global.gc, "Launch the probe with --expose-gc"); +global.gc(); await nextTurn(); +let workerMessages = 0; +const activity = createHook({ + before() { + if (executionAsyncResource() instanceof MessagePort) workerMessages++; + }, +}).enable(); try { const measured = await measureMainThreadWork("workspace reconciliation main-thread work", () => service.runOnce(), ); + activity.disable(); process.stdout.write( JSON.stringify({ blockingMs: measured.blockingMs, filesystemRequests: measured.filesystemRequests, + yields: measured.yields, + workerMessages, changesApplied: measured.result.changesApplied.length, allArchived: [...workspaces.values()].every((workspace) => workspace.archivedAt !== null), }), ); } finally { + activity.disable(); service.dispose(); } diff --git a/packages/server/src/server/workspace-reconciliation-service.test.ts b/packages/server/src/server/workspace-reconciliation-service.test.ts index 7ef187d16e..b818804912 100644 --- a/packages/server/src/server/workspace-reconciliation-service.test.ts +++ b/packages/server/src/server/workspace-reconciliation-service.test.ts @@ -1,15 +1,15 @@ import { execFile, execFileSync } from "node:child_process"; -import { promisify } from "node:util"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; import { build } from "esbuild"; import { createTestRegistries, createTestLogger, deferred, } from "./test-fixtures/reconciliation-registries.js"; -import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages"; import type pino from "pino"; import { afterAll, beforeAll, afterEach, describe, expect, test } from "vitest"; @@ -535,30 +535,54 @@ describe("WorkspaceReconciliationService", () => { test("keeps the event loop responsive while inspecting 20000 real workspace paths", async () => { const rootPath = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-many-"))); tempDirs.push(rootPath); - // Unrelated runner work must not consume the reconciliation budget. - const unrelatedWork = new Promise((resolve) => { - setImmediate(() => { - const cpu = Reflect.get(process, "threadCpuUsage") as typeof process.cpuUsage; - const start = cpu(); - while (cpu(start).user + cpu(start).system < 80_000) {} - resolve(); - }); - }); - const { stdout, stderr } = await promisify(execFile)(process.execPath, [probePath, rootPath], { - timeout: 25_000, - }); - process.stderr.write(stderr); - const { changesApplied, blockingMs, filesystemRequests, allArchived } = JSON.parse(stdout) as { - changesApplied: number; - blockingMs: number; - filesystemRequests: number; - allArchived: boolean; + // This CPU belongs to the runner, not to reconciliation. Keep it present + // throughout the read so a later loop turn cannot contaminate the budget. + let running = true; + const unrelatedWork = () => { + const threadCpuUsage: (previous?: NodeJS.CpuUsage) => NodeJS.CpuUsage = Reflect.get( + process, + "threadCpuUsage", + ); + const start = threadCpuUsage(); + let elapsed: NodeJS.CpuUsage; + do { + elapsed = threadCpuUsage(start); + } while (elapsed.user + elapsed.system < 60_000); + if (running) unrelated = setImmediate(unrelatedWork); }; - await unrelatedWork; - expect(blockingMs).toBeLessThan(20); - expect(filesystemRequests).toBe(20001); - expect(changesApplied).toBe(20000); - expect(allArchived).toBe(true); + let unrelated = setImmediate(unrelatedWork); + try { + const { stdout, stderr } = await promisify(execFile)( + process.execPath, + ["--expose-gc", probePath, rootPath], + { timeout: 25_000 }, + ); + process.stderr.write(stderr); + const { + changesApplied, + blockingMs, + filesystemRequests, + yields, + allArchived, + workerMessages, + } = JSON.parse(stdout) as { + changesApplied: number; + blockingMs: number; + filesystemRequests: number; + yields: number; + allArchived: boolean; + workerMessages: number; + }; + expect(blockingMs).toBeLessThan(20); + expect(filesystemRequests).toBe(20001); + expect(changesApplied).toBe(20000); + expect(allArchived).toBe(true); + expect(yields).toBeGreaterThan(0); + expect(workerMessages).toBe(0); + } finally { + running = false; + clearImmediate(unrelated); + } }, 30000); test("preserves workspace archival that lands during boot reconciliation", async () => { diff --git a/packages/server/src/server/worktree-bootstrap.test.ts b/packages/server/src/server/worktree-bootstrap.test.ts index 96da6e648b..ed43e02388 100644 --- a/packages/server/src/server/worktree-bootstrap.test.ts +++ b/packages/server/src/server/worktree-bootstrap.test.ts @@ -209,13 +209,8 @@ describe("runWorktreeAutoTerminals", () => { getSize: () => ({ rows: 0, cols: 0 }), getTitle: () => undefined, getExitInfo: () => null, - getState: () => ({ - rows: 0, - cols: 0, - grid: [], - scrollback: [], - cursor: { row: 0, col: 0 }, - }), + hasOutput: () => false, + kill: () => {}, killAndWait: async () => {}, }; @@ -312,13 +307,8 @@ describe("runWorktreeAutoTerminals", () => { } }; }, - getState: () => ({ - rows: 1, - cols: 1, - grid: [[{ char: "$" }]], - scrollback: [], - cursor: { row: 0, col: 0 }, - }), + hasOutput: () => true, + kill: () => {}, onTitleChange: () => () => {}, onActivityChange: () => () => {}, diff --git a/packages/server/src/server/worktree-bootstrap.ts b/packages/server/src/server/worktree-bootstrap.ts index d656738bd4..1e1ba58f8e 100644 --- a/packages/server/src/server/worktree-bootstrap.ts +++ b/packages/server/src/server/worktree-bootstrap.ts @@ -269,9 +269,9 @@ export function buildWorktreeSetupDetail(input: { } async function waitForTerminalBootstrapReadiness( - terminal: Pick, + terminal: Pick, ): Promise { - if (terminalHasOutput(terminal.getState())) { + if (terminal.hasOutput()) { return; } @@ -304,7 +304,7 @@ async function waitForTerminalBootstrapReadiness( finish(); }); - if (terminalHasOutput(terminal.getState())) { + if (terminal.hasOutput()) { finish(); return; } @@ -313,17 +313,6 @@ async function waitForTerminalBootstrapReadiness( }); } -function terminalHasOutput(state: ReturnType): boolean { - for (const row of [...state.scrollback, ...state.grid]) { - for (const cell of row) { - if (cell.char.trim().length > 0) { - return true; - } - } - } - return false; -} - export async function runWorktreeAutoTerminals(options: { workspaceId: string; worktree: WorktreeConfig; diff --git a/packages/server/src/server/worktree-session.test.ts b/packages/server/src/server/worktree-session.test.ts index d462a2605a..7117aa2501 100644 --- a/packages/server/src/server/worktree-session.test.ts +++ b/packages/server/src/server/worktree-session.test.ts @@ -212,13 +212,7 @@ function createTerminalManagerStub(options?: { id: `terminal-${terminals.length + 1}`, name: input.name ?? "Terminal", cwd: input.cwd, - getState: () => ({ - rows: 1, - cols: 1, - scrollback: [[{ char: "$" }]], - grid: [], - cursor: { row: 0, col: 0 }, - }), + hasOutput: () => true, subscribe: () => () => {}, onExit: () => () => {}, onCommandFinished: () => () => {}, diff --git a/packages/server/src/terminal/terminal-capture.ts b/packages/server/src/terminal/terminal-capture.ts index e22d3b6f69..4f8a0f4625 100644 --- a/packages/server/src/terminal/terminal-capture.ts +++ b/packages/server/src/terminal/terminal-capture.ts @@ -1,6 +1,5 @@ import stripAnsi from "strip-ansi"; -import type { TerminalCell } from "@getpaseo/protocol/messages"; -import type { TerminalSession } from "./terminal.js"; +import type { IBuffer } from "@xterm/headless"; export interface CaptureTerminalLinesOptions { start?: number; @@ -13,14 +12,6 @@ export interface CaptureTerminalLinesResult { totalLines: number; } -function cellsToPlainText(cells: TerminalCell[], options: { stripAnsi: boolean }): string { - const text = cells - .map((cell) => cell.char) - .join("") - .trimEnd(); - return options.stripAnsi ? stripAnsi(text) : text; -} - function resolveCaptureLineIndex( lineNumber: number | undefined, totalLines: number, @@ -46,14 +37,10 @@ function resolveCaptureLineIndex( } export function captureTerminalLines( - terminal: TerminalSession, + buffer: IBuffer, options: CaptureTerminalLinesOptions = {}, ): CaptureTerminalLinesResult { - const state = terminal.getState(); - const allLines = [...state.scrollback, ...state.grid].map((cells) => - cellsToPlainText(cells, { stripAnsi: options.stripAnsi ?? true }), - ); - const totalLines = allLines.length; + const totalLines = buffer.length; const startIndex = resolveCaptureLineIndex(options.start, totalLines, "start"); const endIndex = resolveCaptureLineIndex(options.end, totalLines, "end"); @@ -64,8 +51,10 @@ export function captureTerminalLines( }; } - return { - lines: allLines.slice(startIndex, endIndex + 1), - totalLines, - }; + const lines: string[] = []; + for (let row = startIndex; row <= endIndex; row++) { + const text = buffer.getLine(row)!.translateToString(true).trimEnd(); + lines.push(options.stripAnsi === false ? text : stripAnsi(text)); + } + return { lines, totalLines }; } diff --git a/packages/server/src/terminal/terminal-manager.test.ts b/packages/server/src/terminal/terminal-manager.test.ts index 47905d1424..83ba035c37 100644 --- a/packages/server/src/terminal/terminal-manager.test.ts +++ b/packages/server/src/terminal/terminal-manager.test.ts @@ -457,7 +457,6 @@ it("setTerminalTitle returns false for unknown terminal ids without changing exi expect(manager.setTerminalTitle("unknown-id", "x")).toBe(false); expect(session.getTitle()).toBe("Existing title"); - expect(session.getState().title).toBe("Existing title"); expect(snapshots).toEqual([]); unsubscribe(); diff --git a/packages/server/src/terminal/terminal-manager.ts b/packages/server/src/terminal/terminal-manager.ts index 86b4cdb662..f8ba1851fa 100644 --- a/packages/server/src/terminal/terminal-manager.ts +++ b/packages/server/src/terminal/terminal-manager.ts @@ -1,12 +1,16 @@ +import { + replayTerminal, + type TerminalReplaySink, + type TerminalReplayMetadata, + type TerminalReplayOptions, +} from "./terminal-replay.js"; import { createTerminal, type LocalTerminalSession, type TerminalActivityTransition, type TerminalSession, - type TerminalStateSnapshot, - type TerminalStateSnapshotOptions, } from "./terminal.js"; -import { captureTerminalLines, type CaptureTerminalLinesResult } from "./terminal-capture.js"; +import { type CaptureTerminalLinesResult } from "./terminal-capture.js"; import { randomBytes, randomUUID } from "node:crypto"; import { resolve, sep } from "node:path"; import { assertAbsolutePath, isSameOrDescendantPath } from "../server/path-utils.js"; @@ -76,10 +80,11 @@ export interface TerminalManager { registerCwdEnv(options: { cwd: string; env: Record }): void; validateTerminalActivityToken(terminalId: string, token: string): "valid" | "unknown" | "invalid"; getTerminal(id: string): TerminalSession | undefined; - getTerminalState( + replayTerminal( id: string, - options?: TerminalStateSnapshotOptions, - ): Promise; + options: TerminalReplayOptions | undefined, + sink: TerminalReplaySink, + ): Promise; setTerminalTitle(id: string, title: string): boolean; setTerminalActivity(id: string, state: TerminalActivityState): Promise; clearTerminalAttention(id: string): Promise; @@ -107,6 +112,10 @@ export interface TerminalManagerOptions { /** The in-process manager the terminal worker runs: it owns each PTY, so it knows its pid. */ export interface LocalTerminalManager extends TerminalManager { + createTerminal( + options: Parameters[0], + ): Promise; + getTerminal(id: string): LocalTerminalSession | undefined; getTerminalProcessId(id: string): number | null; getTerminalActivityToken(id: string): string | null; } @@ -337,7 +346,7 @@ export function createTerminalManager( activityUrl?: string | null; baseEnv?: Record; paseoCliPath?: string | null; - }): Promise { + }): Promise { assertAbsolutePath(options.cwd); const terminals = terminalsByCwd.get(options.cwd) ?? []; @@ -357,7 +366,7 @@ export function createTerminalManager( ...(terminalActivityUrl ? { PASEO_TERMINAL_ACTIVITY_URL: terminalActivityUrl } : {}), }; terminalActivityTokenById.set(terminalId, activityToken); - let session: TerminalSession; + let session: LocalTerminalSession; try { session = registerSession( await createTerminal({ @@ -404,7 +413,7 @@ export function createTerminalManager( return expected === token ? "valid" : "invalid"; }, - getTerminal(id: string): TerminalSession | undefined { + getTerminal(id: string): LocalTerminalSession | undefined { return terminalsById.get(id); }, @@ -416,11 +425,9 @@ export function createTerminalManager( return terminalActivityTokenById.get(id) ?? null; }, - async getTerminalState( - id: string, - options?: TerminalStateSnapshotOptions, - ): Promise { - return terminalsById.get(id)?.getStateSnapshot(options) ?? null; + async replayTerminal(id, options, sink) { + const session = terminalsById.get(id); + return session ? replayTerminal(session, options, sink) : null; }, setTerminalTitle(id: string, title: string): boolean { @@ -482,7 +489,7 @@ export function createTerminalManager( totalLines: 0, }; } - return captureTerminalLines(session, options); + return session.captureLines(options); }, listDirectories(): string[] { diff --git a/packages/server/src/terminal/terminal-replay.ts b/packages/server/src/terminal/terminal-replay.ts new file mode 100644 index 0000000000..73336a88f1 --- /dev/null +++ b/packages/server/src/terminal/terminal-replay.ts @@ -0,0 +1,135 @@ +import type { FramedSocket } from "./terminal-worker-frames.js"; +import type { + TerminalWorkerRequest, + TerminalWorkerToParentMessage, +} from "./terminal-worker-protocol.js"; +import type { LocalTerminalSession } from "./terminal.js"; + +export interface TerminalReplayOptions { + scrollbackLines?: number; +} + +export interface TerminalReplayMetadata { + rows: number; + cols: number; + retainedHistoryLines: number; + revision: number; + replayPreamble: string; +} +export type TerminalReplayEvent = + | { type: "begin"; metadata: TerminalReplayMetadata } + | { type: "data"; data: Uint8Array } + | { type: "end" }; +export type TerminalReplaySink = (event: TerminalReplayEvent) => void; +export const TERMINAL_REPLAY_CHUNK_BYTES = 64 * 1024; + +export function* createTerminalReplay( + session: LocalTerminalSession, + options: TerminalReplayOptions | undefined, +): Generator { + const { metadata, data } = session.serializeReplay(options); + yield { type: "begin", metadata }; + for (let offset = 0; offset < data.length; offset += TERMINAL_REPLAY_CHUNK_BYTES) { + yield { type: "data", data: data.subarray(offset, offset + TERMINAL_REPLAY_CHUNK_BYTES) }; + } + yield { type: "end" }; + return metadata; +} + +export async function replayTerminal( + session: LocalTerminalSession, + options: TerminalReplayOptions | undefined, + sink: TerminalReplaySink, +): Promise { + const replay = createTerminalReplay(session, options); + let next = replay.next(); + while (!next.done) { + sink(next.value); + next = replay.next(); + } + return next.value; +} + +interface TerminalReplayPorts { + getSession(id: string): LocalTerminalSession | undefined; + flushOutput(id: string): void; +} + +/** Worker-owned ANSI capture and paced delivery; live observations never wait for a replay. */ +export function createTerminalReplayController(ports: TerminalReplayPorts) { + let controller: FramedSocket | null = null; + + function sendToController(message: TerminalWorkerToParentMessage): void { + // A stalled physical controller is different from a stalled viewer: all its + // observations must reconnect to the surviving worker after this socket closes. + if (controller && controller.socket.writableLength > 4 * 1024 * 1024) { + controller.socket.destroy(); + return; + } + controller?.send(message); + } + + async function deliverReplay( + connection: FramedSocket, + message: Extract, + ): Promise { + ports.flushOutput(message.terminalId); + const session = ports.getSession(message.terminalId); + if (!session) { + await connection.sendAndWait({ + type: "response", + requestId: message.requestId, + ok: true, + result: null, + }); + return; + } + // Flush and capture share one synchronous turn. Each daemon viewer buffers + // its own live tail and discards revisions already included in this cut. + const replay = createTerminalReplay(session, message.options); + let next = replay.next(); + while (!next.done) { + if (controller !== connection) throw new Error("Terminal replay controller disconnected"); + await connection.sendAndWait({ + type: "terminalReplay", + requestId: message.requestId, + event: next.value, + }); + next = replay.next(); + } + await connection.sendAndWait({ + type: "response", + requestId: message.requestId, + ok: true, + result: next.value, + }); + } + + return { + setConnection(connection: FramedSocket | null): void { + if (controller === connection) return; + const previous = controller; + controller = connection; + previous?.socket.destroy(); + }, + send: sendToController, + async replay( + request: Extract, + ): Promise { + const connection = controller; + if (!connection) return; + try { + await deliverReplay(connection, request); + } catch (error) { + await connection + .sendAndWait({ + type: "response", + requestId: request.requestId, + ok: false, + error: error instanceof Error ? error.message : String(error), + }) + .catch(() => {}); + } + }, + }; +} diff --git a/packages/server/src/terminal/terminal-restore.test.ts b/packages/server/src/terminal/terminal-restore.test.ts index 7255cba1a5..ad008e1bd7 100644 --- a/packages/server/src/terminal/terminal-restore.test.ts +++ b/packages/server/src/terminal/terminal-restore.test.ts @@ -4,56 +4,45 @@ import { TerminalStreamOpcode, decodeTerminalStreamFrame, } from "@getpaseo/protocol/binary-frames/index"; -import type { TerminalCell, TerminalState } from "@getpaseo/protocol/messages"; import { + createTerminalRestoreDelivery, encodeTerminalRestoreFrame, resolveRestoreAfterOutputOverflow, resolveTerminalRestoreSnapshotOptions, - resolveTerminalSubscriptionSnapshotMode, } from "./terminal-restore.js"; -function terminalRow(text: string, cols = 80): TerminalCell[] { - return Array.from({ length: cols }, (_, index) => ({ - char: text[index] ?? " ", - })); -} - -function terminalState(text: string): TerminalState { - return { - rows: 1, - cols: 80, - grid: [terminalRow(text)], - scrollback: [], - cursor: { row: 0, col: text.length }, - }; -} - describe("terminal restore policy", () => { - test("uses ready snapshots only for restore-aware subscriptions", () => { - expect(resolveTerminalSubscriptionSnapshotMode(undefined)).toBe("state"); - expect(resolveTerminalSubscriptionSnapshotMode({ mode: "live" })).toBe("ready"); - }); - test("resolves bounded restore snapshot options", () => { - expect(resolveTerminalRestoreSnapshotOptions({ mode: "live" })).toBeNull(); - expect(resolveTerminalRestoreSnapshotOptions({ mode: "full-snapshot" })).toBeUndefined(); - expect(resolveTerminalRestoreSnapshotOptions({ mode: "visible-snapshot" })).toEqual({ + expect( + resolveTerminalRestoreSnapshotOptions({ restore: { mode: "live" }, chunked: false }), + ).toBeNull(); + expect( + resolveTerminalRestoreSnapshotOptions({ restore: { mode: "full-snapshot" }, chunked: false }), + ).toBeUndefined(); + expect( + resolveTerminalRestoreSnapshotOptions({ + restore: { mode: "visible-snapshot" }, + chunked: false, + }), + ).toEqual({ scrollbackLines: 200, }); expect( resolveTerminalRestoreSnapshotOptions({ - mode: "visible-snapshot", - scrollbackLines: 999, + restore: { mode: "visible-snapshot", scrollbackLines: 999 }, + chunked: false, }), ).toEqual({ scrollbackLines: 500 }); }); - test("promotes live restore to visible restore after output overflow", () => { + test("bounds pressure recovery for every restore mode", () => { expect(resolveRestoreAfterOutputOverflow({ mode: "live" })).toEqual({ mode: "visible-snapshot", + scrollbackLines: 500, }); expect(resolveRestoreAfterOutputOverflow({ mode: "full-snapshot" })).toEqual({ - mode: "full-snapshot", + mode: "visible-snapshot", + scrollbackLines: 500, }); }); @@ -61,10 +50,7 @@ describe("terminal restore policy", () => { const frame = decodeTerminalStreamFrame( encodeTerminalRestoreFrame({ slot: 4, - snapshot: { - state: terminalState("restored"), - revision: 1, - }, + data: new TextEncoder().encode("restored"), }), ); @@ -73,3 +59,40 @@ describe("terminal restore policy", () => { expect(new TextDecoder().decode(frame?.payload ?? new Uint8Array())).toContain("restored"); }); }); + +test("projects chunked replay begin onto the declared wire metadata", () => { + const frames: Uint8Array[] = []; + const deliver = createTerminalRestoreDelivery({ + slot: 7, + chunked: true, + send: (frame) => { + frames.push(frame); + }, + }); + deliver({ + type: "begin", + metadata: { + rows: 30, + cols: 120, + retainedHistoryLines: 842, + revision: 5123, + replayPreamble: "\x1b[?2004h", + }, + }); + const data = new TextEncoder().encode("\x1b[31m界 replay\x1b[0m"); + deliver({ type: "data", data }); + deliver({ type: "end" }); + const decoded = frames.map((frame) => decodeTerminalStreamFrame(frame)!); + expect(decoded.map((frame) => ({ slot: frame.slot, opcode: frame.opcode }))).toEqual([ + { slot: 7, opcode: TerminalStreamOpcode.ReplayBegin }, + { slot: 7, opcode: TerminalStreamOpcode.ReplayData }, + { slot: 7, opcode: TerminalStreamOpcode.ReplayEnd }, + ]); + expect(JSON.parse(new TextDecoder().decode(decoded[0]!.payload))).toEqual({ + rows: 30, + cols: 120, + retainedHistoryLines: 842, + }); + expect(decoded[1]!.payload).toEqual(data); + expect(decoded[2]!.payload.byteLength).toBe(0); +}); diff --git a/packages/server/src/terminal/terminal-restore.ts b/packages/server/src/terminal/terminal-restore.ts index ffc1bd9faa..c0ee7b1105 100644 --- a/packages/server/src/terminal/terminal-restore.ts +++ b/packages/server/src/terminal/terminal-restore.ts @@ -1,11 +1,11 @@ +import type { z } from "zod"; import type { SubscribeTerminalRequest } from "@getpaseo/protocol/messages"; import { TerminalStreamOpcode, - encodeTerminalSnapshotPayload, + type TerminalReplayMetadataSchema, encodeTerminalStreamFrame, } from "@getpaseo/protocol/binary-frames/index"; -import { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot"; -import type { TerminalStateSnapshot, TerminalStateSnapshotOptions } from "./terminal.js"; +import type { TerminalReplaySink, TerminalReplayOptions } from "./terminal-replay.js"; export const MAX_TERMINAL_OUTPUT_FRAME_BYTES = 256 * 1024; @@ -21,62 +21,84 @@ const MAX_VISIBLE_RESTORE_SCROLLBACK_LINES = 500; export type TerminalRestoreOptions = NonNullable; -export type TerminalSubscriptionSnapshotMode = "state" | "ready"; - -export function resolveTerminalSubscriptionSnapshotMode( - restore: TerminalRestoreOptions | undefined, -): TerminalSubscriptionSnapshotMode { - return restore ? "ready" : "state"; -} - export function resolveRestoreAfterOutputOverflow( restore: TerminalRestoreOptions | undefined, -): TerminalRestoreOptions | undefined { - if (restore?.mode === "live") { - return { mode: "visible-snapshot" }; - } - return restore; +): TerminalRestoreOptions { + return { + mode: "visible-snapshot", + scrollbackLines: Math.min( + restore?.scrollbackLines ?? MAX_VISIBLE_RESTORE_SCROLLBACK_LINES, + MAX_VISIBLE_RESTORE_SCROLLBACK_LINES, + ), + }; } -export function resolveTerminalRestoreSnapshotOptions( - restore: TerminalRestoreOptions, -): TerminalStateSnapshotOptions | null | undefined { +export function resolveTerminalRestoreSnapshotOptions({ + restore, + chunked, +}: { + restore: TerminalRestoreOptions; + chunked: boolean; +}): TerminalReplayOptions | null | undefined { if (restore.mode === "live") { return null; } - if (restore.mode === "visible-snapshot") { - return { - scrollbackLines: resolveVisibleRestoreScrollbackLines(restore.scrollbackLines), - }; + if (restore.mode === "full-snapshot") { + return chunked ? { scrollbackLines: restore.scrollbackLines } : undefined; } - return undefined; + const scrollbackLines = restore.scrollbackLines ?? DEFAULT_VISIBLE_RESTORE_SCROLLBACK_LINES; + return { + scrollbackLines: chunked + ? scrollbackLines + : Math.min(scrollbackLines, MAX_VISIBLE_RESTORE_SCROLLBACK_LINES), + }; } -export function encodeLegacyTerminalSnapshotFrame(input: { - slot: number; - snapshot: TerminalStateSnapshot; -}): Uint8Array { - return encodeTerminalStreamFrame({ - opcode: TerminalStreamOpcode.Snapshot, - slot: input.slot, - payload: encodeTerminalSnapshotPayload(input.snapshot.state), - }); -} - -export function encodeTerminalRestoreFrame(input: { - slot: number; - snapshot: TerminalStateSnapshot; -}): Uint8Array { +export function encodeTerminalRestoreFrame(input: { slot: number; data: Uint8Array }): Uint8Array { return encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.Restore, slot: input.slot, - payload: renderTerminalSnapshotToAnsi(input.snapshot.state), + payload: input.data, }); } -function resolveVisibleRestoreScrollbackLines(value: number | undefined): number { - if (typeof value !== "number") { - return DEFAULT_VISIBLE_RESTORE_SCROLLBACK_LINES; - } - return Math.min(Math.max(0, value), MAX_VISIBLE_RESTORE_SCROLLBACK_LINES); +/** The daemon's only old-app adapter. Capable delivery never assembles the replay. */ +export function createTerminalRestoreDelivery(input: { + slot: number; + chunked: boolean; + send(frame: Uint8Array): void; +}): TerminalReplaySink { + // COMPAT(terminalSingleRestore): added after v0.14.0; remove after 2027-04-30 when client floor supports chunked replay. + const legacyChunks: Uint8Array[] = []; + return (event) => { + if (!input.chunked) { + if (event.type === "data") legacyChunks.push(event.data); + if (event.type === "end") + input.send( + encodeTerminalRestoreFrame({ + slot: input.slot, + data: Buffer.concat(legacyChunks), + }), + ); + return; + } + const opcodes = { + begin: TerminalStreamOpcode.ReplayBegin, + data: TerminalStreamOpcode.ReplayData, + end: TerminalStreamOpcode.ReplayEnd, + }; + let payload: string | Uint8Array | undefined; + if (event.type === "begin") { + const begin = { + rows: event.metadata.rows, + cols: event.metadata.cols, + retainedHistoryLines: event.metadata.retainedHistoryLines, + } satisfies z.infer; + payload = JSON.stringify(begin); + } + if (event.type === "data") payload = event.data; + input.send( + encodeTerminalStreamFrame({ slot: input.slot, opcode: opcodes[event.type], payload }), + ); + }; } diff --git a/packages/server/src/terminal/terminal-serializer.ts b/packages/server/src/terminal/terminal-serializer.ts new file mode 100644 index 0000000000..5f79064948 --- /dev/null +++ b/packages/server/src/terminal/terminal-serializer.ts @@ -0,0 +1,42 @@ +import type { Terminal as TerminalType } from "@xterm/headless"; +import { SerializeAddon } from "@xterm/addon-serialize"; +import type { TerminalReplayOptions } from "./terminal-replay.js"; + +/** Serialize the owned model; the PTY is a transport, not an ANSI parser oracle. */ +export function createTerminalSerializer( + terminal: TerminalType, +): (options?: TerminalReplayOptions) => Uint8Array { + const addon = new SerializeAddon(); + terminal.loadAddon(addon); + return (options) => { + const data = addon.serialize({ scrollback: options?.scrollbackLines }); + const buffer = terminal.buffer.active; + // Addon 0.14 applies a horizontal offset after vertical movement has already + // clamped a pending-wrap column, losing a column. Restore a valid position + // absolutely; an x==cols cursor must keep its pending wrap for the next byte. + const position = + buffer.cursorX < terminal.cols ? `\x1b[${buffer.cursorY + 1};${buffer.cursorX + 1}H` : ""; + return Buffer.from(data + position + serializeCursorPresentation(terminal), "utf8"); + }; +} + +// Stable addon 0.14 serializes buffer/modes but omits DECSCUSR and cursor visibility. +// xterm 6 keeps the program-selected presentation in coreService, not public options. +function serializeCursorPresentation(terminal: TerminalType): string { + const core = ( + terminal as unknown as { + _core: { + coreService: { + isCursorHidden: boolean; + decPrivateModes: { cursorStyle?: "block" | "underline" | "bar"; cursorBlink?: boolean }; + }; + }; + } + )._core.coreService; + const visibility = `\x1b[?25${core.isCursorHidden ? "l" : "h"}`; + const cursorStyle = core.decPrivateModes.cursorStyle; + // Unset presentation belongs to each renderer's configured defaults. + if (cursorStyle === undefined) return visibility; + const style = { block: 2, underline: 4, bar: 6 }[cursorStyle]; + return `${visibility}\x1b[${style - (core.decPrivateModes.cursorBlink ? 1 : 0)} q`; +} diff --git a/packages/server/src/terminal/terminal-session-controller.test.ts b/packages/server/src/terminal/terminal-session-controller.test.ts index 27f69cb44d..ccb152aa23 100644 --- a/packages/server/src/terminal/terminal-session-controller.test.ts +++ b/packages/server/src/terminal/terminal-session-controller.test.ts @@ -1,3 +1,6 @@ +import type { TerminalReplayOptions } from "./terminal-replay.js"; +import type { TerminalRestoreOptions } from "./terminal-restore.js"; +import type { TerminalReplayMetadata } from "./terminal-replay.js"; import { describe, expect, test, vi } from "vitest"; import pino from "pino"; @@ -7,8 +10,7 @@ import { decodeTerminalStreamFrame, type TerminalStreamFrame, } from "@getpaseo/protocol/binary-frames/index"; -import type { TerminalCell, TerminalState } from "@getpaseo/protocol/messages"; -import type { ServerMessage, TerminalSession, TerminalStateSnapshot } from "./terminal.js"; +import type { ServerMessage, TerminalSession } from "./terminal.js"; import { TerminalSessionController, type TerminalSessionControllerOptions, @@ -19,8 +21,33 @@ import { isSameOrDescendantPath } from "../server/path-utils.js"; import { PluginSessionSocket } from "../server/plugins/session-socket.js"; import { TerminalWorkerIncompatibleError } from "./terminal-worker-pool.js"; +interface ReplayFixture { + data: Uint8Array; + metadata: TerminalReplayMetadata; +} +function terminalReplay(text: string, revision: number, replayPreamble = ""): ReplayFixture { + return { + data: Buffer.from(text), + metadata: { rows: 1, cols: 80, retainedHistoryLines: 0, revision, replayPreamble }, + }; +} +function replayFixture( + read: () => Promise, +): TerminalManager["replayTerminal"] { + return async (_id, _options, sink) => { + const replay = await read(); + if (!replay) return null; + await sink({ type: "begin", metadata: replay.metadata }); + await sink({ type: "data", data: replay.data }); + await sink({ type: "end" }); + return replay.metadata; + }; +} + function createController( - options: TerminalSessionControllerOptions & { emitBinary(frame: Uint8Array): void }, + options: TerminalSessionControllerOptions & { + emitBinary(frame: Uint8Array): void | Promise; + }, ) { const source = {}; const ownership = new SessionDelivery( @@ -59,22 +86,6 @@ function deferred(): { return { promise, resolve, reject }; } -function terminalRow(text: string, cols = 80): TerminalCell[] { - return Array.from({ length: cols }, (_, index) => ({ - char: text[index] ?? " ", - })); -} - -function terminalState(text: string): TerminalState { - return { - rows: 1, - cols: 80, - grid: [terminalRow(text)], - scrollback: [], - cursor: { row: 0, col: text.length }, - }; -} - function createLogger(): pino.Logger { return { warn: vi.fn(), @@ -85,7 +96,7 @@ function createLogger(): pino.Logger { describe("terminal-session-controller restore", () => { test("delivers output produced while restore is in flight after the restore frame", async () => { let terminalListener: ((message: ServerMessage) => void) | null = null; - const snapshot = deferred(); + const snapshot = deferred(); const binaryFrames: TerminalStreamFrame[] = []; const outboundMessages: SessionOutboundMessage[] = []; const terminal: TerminalSession = { @@ -104,8 +115,7 @@ describe("terminal-session-controller restore", () => { onTitleChange: () => vi.fn(), onActivityChange: () => vi.fn(), getSize: () => ({ rows: 1, cols: 80 }), - getState: () => terminalState("restore-before"), - getStateSnapshot: () => ({ state: terminalState("restore-before"), revision: 1 }), + hasOutput: () => true, getReplayPreamble: () => "\x1b[?1h\x1b[?2004h", getTitle: () => undefined, getActivity: () => null, @@ -121,7 +131,7 @@ describe("terminal-session-controller restore", () => { registerCwdEnv: vi.fn(), validateTerminalActivityToken: vi.fn(() => "unknown"), getTerminal: vi.fn(() => terminal), - getTerminalState: vi.fn(() => snapshot.promise), + replayTerminal: replayFixture(() => snapshot.promise), setTerminalTitle: vi.fn(), setTerminalActivity: vi.fn(), killTerminal: vi.fn(), @@ -157,10 +167,10 @@ describe("terminal-session-controller restore", () => { }, }); await Promise.resolve(); - expect(terminalManager.getTerminalState).toHaveBeenCalledTimes(1); + expect(binaryFrames).toEqual([]); terminalListener?.({ type: "output", data: "restore-after\n", revision: 2 }); - snapshot.resolve({ state: terminalState("restore-before"), revision: 1 }); + snapshot.resolve(terminalReplay("restore-before", 1, "\x1b[?1h\x1b[?2004h")); await snapshot.promise; await new Promise((resolve) => setTimeout(resolve, 20)); @@ -202,8 +212,7 @@ function listSession(input: { onTitleChange: () => vi.fn(), onActivityChange: () => vi.fn(), getSize: () => ({ rows: 1, cols: 80 }), - getState: () => terminalState(""), - getStateSnapshot: () => ({ state: terminalState(""), revision: 0 }), + hasOutput: () => true, getReplayPreamble: () => "", getTitle: () => undefined, getActivity: () => null, @@ -236,7 +245,7 @@ describe("terminal-session-controller legacy terminal creation", () => { registerCwdEnv: vi.fn(), validateTerminalActivityToken: vi.fn(() => "unknown"), getTerminal: vi.fn(), - getTerminalState: vi.fn(), + replayTerminal: async () => null, setTerminalTitle: vi.fn(), setTerminalActivity: vi.fn(), clearTerminalAttention: vi.fn(), @@ -311,7 +320,7 @@ describe("terminal-session-controller legacy terminal creation", () => { registerCwdEnv: vi.fn(), validateTerminalActivityToken: vi.fn(() => "unknown"), getTerminal: vi.fn(), - getTerminalState: vi.fn(), + replayTerminal: async () => null, setTerminalTitle: vi.fn(), setTerminalActivity: vi.fn(), clearTerminalAttention: vi.fn(), @@ -362,7 +371,7 @@ describe("terminal-session-controller with terminals held by a newer Paseo", () registerCwdEnv: vi.fn(), validateTerminalActivityToken: vi.fn(() => "unknown"), getTerminal: vi.fn(), - getTerminalState: vi.fn(), + replayTerminal: async () => null, setTerminalTitle: vi.fn(), setTerminalActivity: vi.fn(), clearTerminalAttention: vi.fn(), @@ -428,99 +437,6 @@ async function waitForCoalescerFlush(): Promise { await new Promise((resolve) => setTimeout(resolve, 30)); } -describe("terminal-session-controller wrap-flag gating", () => { - function setup(clientSupportsWrapReflow?: () => boolean): { - controller: ReturnType; - getTerminalState: ReturnType; - } { - const terminal: TerminalSession = { - id: "term-1", - name: "Terminal", - cwd: "/tmp", - workspaceId: "ws-test", - send: vi.fn(), - subscribe: (listener) => { - queueMicrotask(() => listener({ type: "snapshotReady", revision: 1 })); - return vi.fn(); - }, - onExit: () => vi.fn(), - onCommandFinished: () => vi.fn(), - onTitleChange: () => vi.fn(), - onActivityChange: () => vi.fn(), - getSize: () => ({ rows: 1, cols: 80 }), - getState: () => terminalState("hello"), - getStateSnapshot: () => ({ state: terminalState("hello"), revision: 1 }), - getReplayPreamble: () => "", - getTitle: () => undefined, - getActivity: () => null, - setActivity: vi.fn(), - setTitle: vi.fn(), - getExitInfo: () => null, - kill: vi.fn(), - killAndWait: vi.fn(), - }; - const getTerminalState = vi.fn(() => - Promise.resolve({ state: terminalState("hello"), revision: 1 }), - ); - const terminalManager = { - getTerminals: vi.fn(), - createTerminal: vi.fn(), - registerCwdEnv: vi.fn(), - validateTerminalActivityToken: vi.fn(() => "unknown"), - getTerminal: vi.fn(() => terminal), - getTerminalState, - setTerminalTitle: vi.fn(), - setTerminalActivity: vi.fn(), - killTerminal: vi.fn(), - killTerminalAndWait: vi.fn(), - captureTerminal: vi.fn(), - listDirectories: vi.fn(() => []), - killAll: vi.fn(), - subscribeTerminalsChanged: vi.fn(() => vi.fn()), - subscribeTerminalActivity: vi.fn(() => vi.fn()), - subscribeTerminalWorkspaceContributionChanged: vi.fn(() => vi.fn()), - } as unknown as TerminalManager; - const controller = createController({ - terminalManager, - emit: vi.fn(), - emitBinary: vi.fn(), - hasBinaryChannel: () => true, - isPathWithinRoot: () => false, - sessionLogger: createLogger(), - ...(clientSupportsWrapReflow ? { clientSupportsWrapReflow } : {}), - }); - return { controller, getTerminalState }; - } - - async function subscribe(controller: ReturnType): Promise { - await controller.dispatch({ - type: "subscribe_terminal_request", - terminalId: "term-1", - requestId: "req-1", - restore: { mode: "visible-snapshot", scrollbackLines: 200 }, - }); - await flushMicrotasks(); - } - - test("requests wrap flags when the client supports reflowable snapshots", async () => { - const { controller, getTerminalState } = setup(() => true); - await subscribe(controller); - expect(getTerminalState).toHaveBeenCalledWith( - "term-1", - expect.objectContaining({ includeWrapFlags: true }), - ); - }); - - test("omits wrap flags when the client does not advertise support", async () => { - const { controller, getTerminalState } = setup(); - await subscribe(controller); - expect(getTerminalState).toHaveBeenCalledWith( - "term-1", - expect.objectContaining({ includeWrapFlags: false }), - ); - }); -}); - describe("terminal-session-controller subdirectory aggregation", () => { test("delivers a subdirectory change to a root subscriber as an aggregated, root-keyed snapshot", async () => { const rootCwd = "/work/repo"; @@ -541,7 +457,7 @@ describe("terminal-session-controller subdirectory aggregation", () => { registerCwdEnv: vi.fn(), validateTerminalActivityToken: vi.fn(() => "unknown"), getTerminal: vi.fn(), - getTerminalState: vi.fn(), + replayTerminal: async () => null, setTerminalTitle: vi.fn(), setTerminalActivity: vi.fn(), killTerminal: vi.fn(), @@ -609,7 +525,7 @@ describe("terminal-session-controller subdirectory aggregation", () => { registerCwdEnv: vi.fn(), validateTerminalActivityToken: vi.fn(() => "unknown"), getTerminal: vi.fn(), - getTerminalState: vi.fn(), + replayTerminal: async () => null, setTerminalTitle: vi.fn(), setTerminalActivity: vi.fn(), killTerminal: vi.fn(), @@ -701,7 +617,7 @@ describe("terminal-session-controller workspace-scoped subscriptions", () => { registerCwdEnv: vi.fn(), validateTerminalActivityToken: vi.fn(() => "unknown"), getTerminal: vi.fn(), - getTerminalState: vi.fn(), + replayTerminal: async () => null, setTerminalTitle: vi.fn(), setTerminalActivity: vi.fn(), killTerminal: vi.fn(), @@ -764,9 +680,17 @@ describe("terminal-session-controller workspace-scoped subscriptions", () => { }); describe("terminal-session-controller backpressure snapshot fallback", () => { - async function setup(getClientBufferedAmount: () => number | null): Promise<{ + async function setup( + getClientBufferedAmount: () => number | null, + chunked = false, + pace?: () => Promise, + restore?: TerminalRestoreOptions, + ): Promise<{ + messages: SessionOutboundMessage[]; pushOutput: (data: string) => void; frames: TerminalStreamFrame[]; + replayOptions: Array; + ready: () => void; }> { let terminalListener: ((message: ServerMessage) => void) | null = null; const terminal: TerminalSession = { @@ -777,19 +701,15 @@ describe("terminal-session-controller backpressure snapshot fallback", () => { send: vi.fn(), subscribe: (listener) => { terminalListener = listener; - // Legacy stream: a snapshot arrives on subscribe (one Snapshot frame), - // after which output streams through the coalescer as Output frames. - queueMicrotask(() => - listener({ type: "snapshot", state: terminalState("live"), revision: 1 }), - ); + // Readiness starts ANSI replay; later bytes use Output frames. + queueMicrotask(() => listener({ type: "snapshotReady", revision: 1 })); return vi.fn(); }, onExit: () => vi.fn(), onCommandFinished: () => vi.fn(), onTitleChange: () => vi.fn(), getSize: () => ({ rows: 1, cols: 80 }), - getState: () => terminalState("live"), - getStateSnapshot: () => ({ state: terminalState("live"), revision: 1 }), + hasOutput: () => true, getReplayPreamble: () => "", getTitle: () => undefined, setTitle: vi.fn(), @@ -797,14 +717,16 @@ describe("terminal-session-controller backpressure snapshot fallback", () => { kill: vi.fn(), killAndWait: vi.fn(), }; - const terminalManager = { + const replayOptions: Array = []; + const terminalManager: TerminalManager = { getTerminals: vi.fn(), createTerminal: vi.fn(), registerCwdEnv: vi.fn(), getTerminal: vi.fn(() => terminal), - getTerminalState: vi.fn(() => - Promise.resolve({ state: terminalState("live"), revision: 1 }), - ), + replayTerminal: (id, options, sink) => { + replayOptions.push(options); + return replayFixture(async () => terminalReplay("live", 1))(id, options, sink); + }, setTerminalTitle: vi.fn(), killTerminal: vi.fn(), killTerminalAndWait: vi.fn(), @@ -812,16 +734,26 @@ describe("terminal-session-controller backpressure snapshot fallback", () => { listDirectories: vi.fn(() => []), killAll: vi.fn(), subscribeTerminalsChanged: vi.fn(() => vi.fn()), - } as unknown as TerminalManager; + validateTerminalActivityToken: () => "unknown", + setTerminalActivity: async () => true, + clearTerminalAttention: async () => false, + subscribeTerminalActivity: () => () => {}, + subscribeTerminalWorkspaceContributionChanged: () => () => {}, + }; const frames: TerminalStreamFrame[] = []; + const messages: SessionOutboundMessage[] = []; const controller = createController({ terminalManager, - emit: vi.fn(), + emit: (message) => { + messages.push(message); + }, + supportsChunkedReplay: () => chunked, emitBinary: (bytes) => { const frame = decodeTerminalStreamFrame(bytes); if (frame) { frames.push(frame); + if (frame.opcode === TerminalStreamOpcode.ReplayBegin) return pace?.(); } }, hasBinaryChannel: () => true, @@ -834,6 +766,7 @@ describe("terminal-session-controller backpressure snapshot fallback", () => { type: "subscribe_terminal_request", terminalId: "term-1", requestId: "req-1", + restore, }); await waitForCoalescerFlush(); // Drop the initial subscribe snapshot frame so each test only sees frames @@ -841,11 +774,83 @@ describe("terminal-session-controller backpressure snapshot fallback", () => { frames.length = 0; return { + replayOptions, + ready: () => terminalListener?.({ type: "snapshotReady" }), pushOutput: (data) => terminalListener?.({ type: "output", data, revision: 2 }), frames, + messages, }; } + test.each([false, true])( + "preserves visible history defaults and explicit requests across delivery (chunked=%s)", + async (chunked) => { + const replayOpcodes = chunked + ? [ + TerminalStreamOpcode.ReplayBegin, + TerminalStreamOpcode.ReplayData, + TerminalStreamOpcode.ReplayEnd, + ] + : [TerminalStreamOpcode.Restore]; + for (const [restore, expected] of [ + [{ mode: "visible-snapshot" }, [{ scrollbackLines: 200 }]], + [{ mode: "visible-snapshot", scrollbackLines: 0 }, [{ scrollbackLines: 0 }]], + [{ mode: "visible-snapshot", scrollbackLines: 5 }, [{ scrollbackLines: 5 }]], + [{ mode: "visible-snapshot", scrollbackLines: 500 }, [{ scrollbackLines: 500 }]], + [ + { mode: "visible-snapshot", scrollbackLines: 10000 }, + [{ scrollbackLines: chunked ? 10000 : 500 }], + ], + [{ mode: "full-snapshot" }, [chunked ? { scrollbackLines: undefined } : undefined]], + [{ mode: "live" }, []], + ] as const) { + const fixture = await setup(() => 0, chunked, undefined, restore); + expect(fixture.replayOptions).toEqual(expected); + fixture.ready(); + await waitForCoalescerFlush(); + expect(fixture.replayOptions).toEqual([...expected, ...expected]); + expect(fixture.frames.map((frame) => frame.opcode)).toEqual( + restore.mode === "live" ? [] : replayOpcodes, + ); + fixture.pushOutput("after-restore"); + await waitForCoalescerFlush(); + expect(fixture.frames.at(-1)?.opcode).toBe(TerminalStreamOpcode.Output); + expect(new TextDecoder().decode(fixture.frames.at(-1)!.payload)).toBe("after-restore"); + } + }, + ); + + test.each([false, true])( + "bounds recovery without changing attach demand (chunked=%s)", + async (chunked) => { + for (const [restore, history] of [ + [{ mode: "visible-snapshot" }, 500], + [{ mode: "visible-snapshot", scrollbackLines: 10000 }, 500], + [{ mode: "full-snapshot" }, 500], + [{ mode: "visible-snapshot", scrollbackLines: 5 }, 5], + [{ mode: "visible-snapshot", scrollbackLines: 0 }, 0], + [{ mode: "full-snapshot", scrollbackLines: 0 }, 0], + [{ mode: "live", scrollbackLines: 0 }, 0], + [{ mode: "live" }, 500], + [undefined, 500], + ] as const) { + const fixture = await setup(() => 8 * 1024 * 1024, chunked, undefined, restore); + const initial = fixture.replayOptions.slice(); + fixture.pushOutput("pressure".repeat(40000)); + await waitForCoalescerFlush(); + expect(fixture.replayOptions.at(-1)).toEqual({ scrollbackLines: history }); + expect(fixture.replayOptions).toHaveLength(initial.length + 1); + fixture.ready(); // Worker adoption must use the original demand, even on the same stream. + await waitForCoalescerFlush(); + expect(fixture.replayOptions.slice(initial.length + 1)).toEqual(initial); + fixture.frames.length = 0; + fixture.pushOutput("after-catch-up"); + await waitForCoalescerFlush(); + expect(fixture.frames.map((frame) => frame.opcode)).toEqual([TerminalStreamOpcode.Output]); + } + }, + ); + test("streams all output without a snapshot when the client keeps up", async () => { const { pushOutput, frames } = await setup(() => 0); @@ -853,7 +858,7 @@ describe("terminal-session-controller backpressure snapshot fallback", () => { pushOutput(chunk); await waitForCoalescerFlush(); - expect(frames.some((frame) => frame.opcode === TerminalStreamOpcode.Snapshot)).toBe(false); + expect(frames.some((frame) => frame.opcode === TerminalStreamOpcode.Restore)).toBe(false); const outputFrames = frames.filter((frame) => frame.opcode === TerminalStreamOpcode.Output); expect(outputFrames.length).toBeGreaterThan(0); const receivedBytes = outputFrames.reduce( @@ -869,7 +874,7 @@ describe("terminal-session-controller backpressure snapshot fallback", () => { pushOutput("y".repeat(300 * 1024)); await waitForCoalescerFlush(); - expect(frames.some((frame) => frame.opcode === TerminalStreamOpcode.Snapshot)).toBe(true); + expect(frames.some((frame) => frame.opcode === TerminalStreamOpcode.Restore)).toBe(true); // After the snapshot the byte counter is reset, so a small follow-up chunk // streams as Output rather than tripping the fallback again. @@ -877,7 +882,7 @@ describe("terminal-session-controller backpressure snapshot fallback", () => { pushOutput("z".repeat(1024)); await waitForCoalescerFlush(); - expect(frames.some((frame) => frame.opcode === TerminalStreamOpcode.Snapshot)).toBe(false); + expect(frames.some((frame) => frame.opcode === TerminalStreamOpcode.Restore)).toBe(false); expect(frames.some((frame) => frame.opcode === TerminalStreamOpcode.Output)).toBe(true); }); @@ -893,7 +898,7 @@ describe("terminal-session-controller backpressure snapshot fallback", () => { pushOutput("p".repeat(300 * 1024)); await waitForCoalescerFlush(); - expect(frames.some((frame) => frame.opcode === TerminalStreamOpcode.Snapshot)).toBe(true); + expect(frames.some((frame) => frame.opcode === TerminalStreamOpcode.Restore)).toBe(true); }); test("falls back to a snapshot at the byte threshold when no backpressure signal exists", async () => { @@ -905,11 +910,41 @@ describe("terminal-session-controller backpressure snapshot fallback", () => { pushOutput("r".repeat(300 * 1024)); await waitForCoalescerFlush(); - expect(frames.some((frame) => frame.opcode === TerminalStreamOpcode.Snapshot)).toBe(true); + expect(frames.some((frame) => frame.opcode === TerminalStreamOpcode.Restore)).toBe(true); + }); + test("queues capable catch-up without waiting for the direct socket to drain", async () => { + let buffered = 0; + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const { pushOutput, frames, messages } = await setup( + () => buffered, + true, + () => (buffered ? gate : Promise.resolve()), + ); + buffered = 8 * 1024 * 1024; + pushOutput("y".repeat(300 * 1024)); + await waitForCoalescerFlush(); + expect(messages.filter((message) => message.type === "terminal_stream_exit")).toEqual([]); + expect(frames.map((frame) => frame.opcode)).toEqual([ + TerminalStreamOpcode.ReplayBegin, + TerminalStreamOpcode.ReplayData, + TerminalStreamOpcode.ReplayEnd, + ]); + buffered = 0; + release(); + await waitForCoalescerFlush(); + expect(frames.map((frame) => frame.opcode)).toContain(TerminalStreamOpcode.ReplayEnd); + frames.length = 0; + buffered = 8 * 1024 * 1024; // Small output must stay live after the replay resets its byte counter. + pushOutput("live-after-recovery"); + await waitForCoalescerFlush(); + expect(frames.map((frame) => frame.opcode)).toEqual([TerminalStreamOpcode.Output]); }); }); -function exitFixture(readSnapshot: () => Promise, backedUp = false) { +function exitFixture(readReplay: () => Promise, backedUp = false) { const outputs = new Set<(message: ServerMessage) => void>(); const exits = new Set[0]>(); const terminal: TerminalSession = { @@ -934,8 +969,7 @@ function exitFixture(readSnapshot: () => Promise, onTitleChange: () => () => {}, onActivityChange: () => () => {}, getSize: () => ({ rows: 1, cols: 80 }), - getState: () => terminalState("snapshot"), - getStateSnapshot: () => ({ state: terminalState("snapshot"), revision: 0 }), + hasOutput: () => true, getReplayPreamble: () => "", getTitle: () => undefined, getActivity: () => null, @@ -948,7 +982,7 @@ function exitFixture(readSnapshot: () => Promise, }; const manager: TerminalManager = { getTerminal: () => terminal, - getTerminalState: readSnapshot, + replayTerminal: replayFixture(readReplay), getTerminals: async () => [terminal], createTerminal: async () => terminal, registerCwdEnv() {}, @@ -1026,7 +1060,7 @@ describe("terminal output before natural exit", () => { "flushes each observer's final bytes before exit (modern=%s)", async (modern) => { vi.useFakeTimers(); - const f = exitFixture(async () => ({ state: terminalState("snapshot"), revision: 0 })); + const f = exitFixture(async () => terminalReplay("snapshot", 0)); try { const source = {}; const owners = [ @@ -1048,7 +1082,7 @@ describe("terminal output before natural exit", () => { row.message.payload.subscriptionId === owner.id)), ); expect(ordered.map((row) => row.binary?.opcode ?? row.message?.type)).toEqual([ - TerminalStreamOpcode.Snapshot, + TerminalStreamOpcode.Restore, TerminalStreamOpcode.Output, TerminalStreamOpcode.Output, "terminal_stream_exit", @@ -1068,7 +1102,7 @@ describe("terminal output before natural exit", () => { test("waits for bootstrap snapshot and flushes buffered bytes without starting backpressure work", async () => { vi.useFakeTimers(); - const snapshot = deferred(); + const snapshot = deferred(); let reads = 0; const f = exitFixture(() => { reads++; @@ -1081,10 +1115,10 @@ describe("terminal output before natural exit", () => { f.exit(); await vi.advanceTimersByTimeAsync(0); expect(f.frames.map((row) => row.message?.type)).toEqual(["subscribe_terminal_response"]); - snapshot.resolve({ state: terminalState("snapshot"), revision: 0 }); + snapshot.resolve(terminalReplay("snapshot", 0)); await vi.advanceTimersByTimeAsync(0); expect(f.frames.slice(1).map((row) => row.binary?.opcode ?? row.message?.type)).toEqual([ - TerminalStreamOpcode.Snapshot, + TerminalStreamOpcode.Restore, TerminalStreamOpcode.Output, "terminal_stream_exit", ]); @@ -1104,11 +1138,10 @@ describe("terminal output before natural exit", () => { "finishes an in-flight backpressure snapshot (available=%s)", async (available) => { vi.useFakeTimers(); - const snapshot = deferred(); + const snapshot = deferred(); let reads = 0; const f = exitFixture( - async () => - ++reads === 1 ? { state: terminalState("initial"), revision: 0 } : snapshot.promise, + async () => (++reads === 1 ? terminalReplay("initial", 0) : snapshot.promise), true, ); try { @@ -1122,10 +1155,7 @@ describe("terminal output before natural exit", () => { await vi.advanceTimersByTimeAsync(0); expect(f.frames.some((row) => row.message?.type === "terminal_stream_exit")).toBe(false); if (available === "rejected") snapshot.reject(new Error("Snapshot failed during exit")); - else - snapshot.resolve( - available ? { state: terminalState("pressure-snapshot"), revision: 1 } : null, - ); + else snapshot.resolve(available ? terminalReplay("pressure-snapshot", 1) : null); await vi.advanceTimersByTimeAsync(0); const output = f.frames .filter((row) => row.binary?.opcode === TerminalStreamOpcode.Output) @@ -1145,7 +1175,7 @@ describe("terminal output before natural exit", () => { test("release during exit's pending snapshot suppresses late delivery", async () => { vi.useFakeTimers(); - const snapshot = deferred(); + const snapshot = deferred(); const f = exitFixture(() => snapshot.promise); const source = {}; try { @@ -1158,7 +1188,7 @@ describe("terminal output before natural exit", () => { subscriptionId: owner.id!, }; const release = f.delivery.request(source, request, () => f.delivery.release(owner.id!)); - snapshot.resolve({ state: terminalState("snapshot"), revision: 0 }); + snapshot.resolve(terminalReplay("snapshot", 0)); await release; await vi.advanceTimersByTimeAsync(0); expect(f.frames.map((row) => row.message?.type)).toEqual(["subscribe_terminal_response"]); @@ -1173,7 +1203,7 @@ describe("terminal output before natural exit", () => { test("explicit release discards only that observer's trailing output", async () => { vi.useFakeTimers(); - const f = exitFixture(async () => ({ state: terminalState("snapshot"), revision: 0 })); + const f = exitFixture(async () => terminalReplay("snapshot", 0)); const source = {}; try { const a = await f.subscribe(source, true, "a"); @@ -1221,8 +1251,8 @@ describe("terminal snapshot failure", () => { let reads = 0; const f = exitFixture(async () => { if (++reads === (phase === "backpressure" ? 2 : 1)) - throw new Error("Terminal worker request timed out: getTerminalState"); - return { state: terminalState("healthy"), revision: 0 }; + throw new Error("Terminal worker request timed out: replayTerminal"); + return terminalReplay("healthy", 0); }, phase === "backpressure"); try { const source = {}; @@ -1250,7 +1280,7 @@ describe("terminal snapshot failure", () => { payload: { terminalId: "exit-terminal", subscriptionId: failed.id, - error: "Terminal worker request timed out: getTerminalState", + error: "Terminal worker request timed out: replayTerminal", }, }, ]); @@ -1281,7 +1311,7 @@ describe("terminal snapshot failure", () => { ); test("release during pending rejection suppresses the failure and completes cleanup", async () => { - const snapshot = deferred(); + const snapshot = deferred(); const f = exitFixture(() => snapshot.promise); const source = {}; try { @@ -1293,7 +1323,7 @@ describe("terminal snapshot failure", () => { subscriptionId: owner.id!, }; const release = f.delivery.request(source, request, () => f.delivery.release(owner.id!)); - snapshot.reject(new Error("Terminal worker request timed out: getTerminalState")); + snapshot.reject(new Error("Terminal worker request timed out: replayTerminal")); await release; expect(f.frames.map((row) => row.message?.type)).toEqual(["subscribe_terminal_response"]); expect(f.delivery.registrationCount).toBe(0); @@ -1313,7 +1343,7 @@ test.each([false, true])( let reads = 0; const f = exitFixture(async () => { if (++reads === (backpressure ? 2 : 1)) throw new Error("Snapshot unavailable"); - return { state: terminalState("recovered"), revision: 0 }; + return terminalReplay("recovered", 0); }, backpressure); try { await f.subscribe({}, false, "legacy-recovery"); @@ -1327,7 +1357,7 @@ test.each([false, true])( for (const listener of f.outputs) listener({ type: "snapshotReady", revision: 2 }); await vi.advanceTimersByTimeAsync(5); expect(reads).toBe(backpressure ? 3 : 2); - expect(f.frames.some((row) => row.binary?.opcode === TerminalStreamOpcode.Snapshot)).toBe( + expect(f.frames.some((row) => row.binary?.opcode === TerminalStreamOpcode.Restore)).toBe( true, ); f.exit(); @@ -1347,3 +1377,51 @@ test.each([false, true])( } }, ); + +test("bounds a capturing viewer tail without ending a healthy viewer", async () => { + const pending = deferred(); + let reads = 0; + const f = exitFixture(() => + ++reads === 2 ? pending.promise : Promise.resolve(terminalReplay("retained", 0)), + ); + const desktop = {}, + phone = {}; + try { + await f.subscribe(desktop, true, "desktop"); + await waitForCoalescerFlush(); + await f.subscribe(phone, true, "phone"); + f.output("x".repeat(4 * 1024 * 1024 + 1), 1); + await waitForCoalescerFlush(); + expect( + f.frames.filter( + (row) => row.source === phone && row.message?.type === "terminal_stream_exit", + ), + ).toHaveLength(1); + expect( + f.frames.filter( + (row) => row.source === desktop && row.message?.type === "terminal_stream_exit", + ), + ).toHaveLength(0); + expect( + f.frames.some( + (row) => row.source === desktop && row.binary?.opcode === TerminalStreamOpcode.Output, + ), + ).toBe(true); + pending.resolve(terminalReplay("phone", 1)); + await waitForCoalescerFlush(); + f.output("AFTER-OVERFLOW", 2); + await waitForCoalescerFlush(); + expect( + f.frames.some( + (row) => + row.source === desktop && + row.binary?.opcode === TerminalStreamOpcode.Output && + Buffer.from(row.binary.payload).toString().includes("AFTER-OVERFLOW"), + ), + ).toBe(true); + expect(f.delivery.registrationCount).toBe(1); + } finally { + pending.resolve(null); + await f.delivery.close(); + } +}); diff --git a/packages/server/src/terminal/terminal-session-controller.ts b/packages/server/src/terminal/terminal-session-controller.ts index e41186fdb1..653524e54a 100644 --- a/packages/server/src/terminal/terminal-session-controller.ts +++ b/packages/server/src/terminal/terminal-session-controller.ts @@ -28,13 +28,12 @@ import { TerminalOutputCoalescer } from "./terminal-output-coalescer.js"; import { MAX_CLIENT_BUFFERED_BYTES, MAX_TERMINAL_OUTPUT_FRAME_BYTES, - encodeLegacyTerminalSnapshotFrame, - encodeTerminalRestoreFrame, + createTerminalRestoreDelivery, resolveRestoreAfterOutputOverflow, resolveTerminalRestoreSnapshotOptions, - resolveTerminalSubscriptionSnapshotMode, type TerminalRestoreOptions, } from "./terminal-restore.js"; +import type { TerminalReplayMetadata } from "./terminal-replay.js"; import type { TerminalSession } from "./terminal.js"; import type { TerminalManager, TerminalsChangedEvent } from "./terminal-manager.js"; import { applyTerminalSize } from "./terminal-size-ownership.js"; @@ -68,31 +67,28 @@ interface ActiveTerminalStream { needsSnapshot: boolean; snapshotInFlight: boolean; retrySnapshotErrors: boolean; - readyRevision?: number; + // Readiness is independent of revision: reconnect adoption has no revision. + snapshotReady: boolean; restore?: TerminalRestoreOptions; bufferedOutputs: BufferedTerminalOutput[]; + bufferedOutputBytes: number; outputBytesSinceSnapshot: number; outputCoalescer: TerminalOutputCoalescer; } -interface SnapshotSendResult { - shouldContinue: boolean; - replayRevision?: number; -} +type SnapshotSendResult = + | { shouldContinue: false } + | { shouldContinue: true; replay: TerminalReplayMetadata | null }; export interface TerminalSessionControllerOptions { terminalManager: TerminalManager | null; emit: (msg: SessionOutboundMessage) => void; hasBinaryChannel: () => boolean; + supportsChunkedReplay?: (source: object) => boolean; isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean; sessionLogger: pino.Logger; listTerminalWorkspaceRefs?: () => Promise; listTerminalWorkspaceRoots?: () => Promise; - // Whether the connected client can reflow restored snapshots. When true the - // daemon attaches per-row soft-wrap flags to snapshots; otherwise it omits them - // so old (strict-schema) clients still parse the snapshot. - clientSupportsWrapReflow?: (source: object) => boolean; - // Current max bytes queued on the client's transport(s) but not yet sent. // Drives the snapshot catch-up fallback: a keeping-up client reports ~0 and // keeps streaming; a backed-up client trips the snapshot path. Defaults to a // constant 0 (no backpressure signal) so callers without a transport always @@ -145,7 +141,6 @@ export class TerminalSessionController { private readonly sessionLogger: pino.Logger; private readonly listTerminalWorkspaceRefs: () => Promise; private readonly listTerminalWorkspaceRoots: () => Promise; - private readonly clientSupportsWrapReflow: (source: object) => boolean; private readonly getClientBufferedAmount: (source: object) => number | null; private readonly subscribedDirectories = new Map(); @@ -153,7 +148,7 @@ export class TerminalSessionController { private readonly activeStreams = new Map(); private nextSlot = 0; - constructor(options: TerminalSessionControllerOptions) { + constructor(private readonly options: TerminalSessionControllerOptions) { this.terminalManager = options.terminalManager; this.emit = options.emit; this.hasBinaryChannel = options.hasBinaryChannel; @@ -163,7 +158,6 @@ export class TerminalSessionController { this.listTerminalWorkspaceRoots = options.listTerminalWorkspaceRoots ?? (async () => (await this.listTerminalWorkspaceRefs()).map((workspace) => workspace.cwd)); - this.clientSupportsWrapReflow = options.clientSupportsWrapReflow ?? (() => false); this.getClientBufferedAmount = options.getClientBufferedAmount ?? (() => 0); } @@ -904,9 +898,10 @@ export class TerminalSessionController { snapshotInFlight: false, retrySnapshotErrors: options.retrySnapshotErrors, exiting: false, - readyRevision: undefined, + snapshotReady: false, restore: options?.restore, bufferedOutputs: [], + bufferedOutputBytes: 0, outputBytesSinceSnapshot: 0, outputCoalescer: new TerminalOutputCoalescer({ timers: { setTimeout, clearTimeout }, @@ -934,7 +929,6 @@ export class TerminalSessionController { // The snapshot replaces this batch only after it succeeds. If the // terminal disappears during the read, completion still owns these bytes. activeStream.snapshotOutput = payload; - activeStream.restore = resolveRestoreAfterOutputOverflow(activeStream.restore); activeStream.needsSnapshot = true; void this.trySendSnapshot(activeStream); return; @@ -952,35 +946,45 @@ export class TerminalSessionController { this.activeStreams.set(slot, activeStream); - const unsubscribeOutput = terminal.subscribe( - (message) => { - if (this.activeStreams.get(slot) !== activeStream) { - return; - } - if (message.type === "snapshot" || message.type === "snapshotReady") { - activeStream.readyRevision = message.revision; - activeStream.outputCoalescer.flush(); - activeStream.needsSnapshot = true; - void this.trySendSnapshot(activeStream); - return; - } - if (message.type === "titleChange") { - return; - } - if (message.data.length === 0) { - return; - } - if (activeStream.needsSnapshot || activeStream.snapshotInFlight) { - activeStream.bufferedOutputs.push({ - data: message.data, - revision: message.revision, + const unsubscribeOutput = terminal.subscribe((message) => { + if (this.activeStreams.get(slot) !== activeStream) { + return; + } + if (message.type === "snapshotReady") { + activeStream.snapshotReady = true; + activeStream.outputCoalescer.flush(); + activeStream.needsSnapshot = true; + void this.trySendSnapshot(activeStream); + return; + } + if (message.type === "titleChange") { + return; + } + if (message.data.length === 0) { + return; + } + if (activeStream.needsSnapshot || activeStream.snapshotInFlight) { + activeStream.bufferedOutputBytes += Buffer.byteLength(message.data); + if (activeStream.bufferedOutputBytes > MAX_CLIENT_BUFFERED_BYTES) { + activeStream.owner.emit({ + type: "terminal_stream_exit", + payload: { + terminalId: activeStream.terminalId, + error: "Terminal replay live tail exceeded the buffer; attach again", + }, }); + this.detachRegistration(activeStream); + void activeStream.owner.release(); return; } - activeStream.outputCoalescer.handle(message.data); - }, - { initialSnapshot: resolveTerminalSubscriptionSnapshotMode(options?.restore) }, - ); + activeStream.bufferedOutputs.push({ + data: message.data, + revision: message.revision, + }); + return; + } + activeStream.outputCoalescer.handle(message.data); + }); const unsubscribeExit = terminal.onExit(() => this.detachStream(terminal.id, { emitExit: true }), ); @@ -1017,22 +1021,27 @@ export class TerminalSessionController { this.detachStream(activeStream.terminalId, { emitExit: true }); return; } - if (activeStream.restore && activeStream.readyRevision === undefined) { + if (activeStream.restore && !activeStream.snapshotReady) { return; } activeStream.outputCoalescer.flush(); activeStream.snapshotInFlight = true; try { - const restore = activeStream.restore; + // A pressure catch-up replaces the skipped batch with a recent window. + // Keep the subscription demand intact for later adoption/full restores. + const restore = + activeStream.snapshotOutput !== undefined + ? resolveRestoreAfterOutputOverflow(activeStream.restore) + : activeStream.restore; const snapshotResult = restore ? await this.emitRestoreSnapshot(activeStream, terminalManager, restore) - : await this.emitLegacySnapshot(activeStream, terminalManager); + : await this.emitRestoreSnapshot(activeStream, terminalManager, { mode: "full-snapshot" }); if (!snapshotResult.shouldContinue) { return; } activeStream.snapshotOutput = undefined; - this.replayTerminalOutputAfterSnapshot(activeStream, terminal, snapshotResult.replayRevision); + this.replayTerminalOutputAfterSnapshot(activeStream, terminal, snapshotResult.replay); activeStream.needsSnapshot = false; activeStream.outputBytesSinceSnapshot = 0; } catch (error) { @@ -1070,47 +1079,31 @@ export class TerminalSessionController { } } - private async emitLegacySnapshot( - activeStream: ActiveTerminalStream, - terminalManager: TerminalManager, - ): Promise { - const snapshot = await terminalManager.getTerminalState(activeStream.terminalId, { - includeWrapFlags: this.clientSupportsWrapReflow(activeStream.owner.source), - }); - if (this.activeStreams.get(activeStream.slot) !== activeStream) { - return { shouldContinue: false }; - } - if (!snapshot) { - this.detachStream(activeStream.terminalId, { emitExit: true }); - return { shouldContinue: false }; - } - - activeStream.owner.emitBinary( - encodeLegacyTerminalSnapshotFrame({ - slot: activeStream.slot, - snapshot, - }), - ); - // The snapshot frame went out-of-band; keep the replay that follows on the - // coalescer's trailing path so it doesn't flush back-to-back with it. - activeStream.outputCoalescer.markFlushed(); - return { shouldContinue: true, replayRevision: snapshot.revision }; - } - private async emitRestoreSnapshot( activeStream: ActiveTerminalStream, terminalManager: TerminalManager, restore: TerminalRestoreOptions, ): Promise { - const snapshotOptions = resolveTerminalRestoreSnapshotOptions(restore); + const chunked = this.options.supportsChunkedReplay?.(activeStream.owner.source) === true; + const snapshotOptions = resolveTerminalRestoreSnapshotOptions({ restore, chunked }); if (snapshotOptions === null) { - return { shouldContinue: true }; + return { shouldContinue: true, replay: null }; } - const snapshot = await terminalManager.getTerminalState(activeStream.terminalId, { - ...snapshotOptions, - includeWrapFlags: this.clientSupportsWrapReflow(activeStream.owner.source), + const deliver = createTerminalRestoreDelivery({ + slot: activeStream.slot, + chunked, + // Network delivery belongs to the bounded application transport, not the worker request. + send: (frame) => activeStream.owner.emitBinary(frame), }); + const snapshot = await terminalManager.replayTerminal( + activeStream.terminalId, + snapshotOptions, + (event) => { + if (this.activeStreams.get(activeStream.slot) !== activeStream) return; + deliver(event); + }, + ); if (this.activeStreams.get(activeStream.slot) !== activeStream) { return { shouldContinue: false }; } @@ -1119,38 +1112,31 @@ export class TerminalSessionController { return { shouldContinue: false }; } - activeStream.owner.emitBinary( - encodeTerminalRestoreFrame({ - slot: activeStream.slot, - snapshot, - }), - ); // The restore frame went out-of-band; keep the replay that follows on the // coalescer's trailing path so it doesn't flush back-to-back with it. activeStream.outputCoalescer.markFlushed(); - return { shouldContinue: true, replayRevision: snapshot.revision }; + return { shouldContinue: true, replay: snapshot }; } private replayTerminalOutputAfterSnapshot( activeStream: ActiveTerminalStream, terminal: TerminalSession, - replayRevision: number | undefined, + replay: TerminalReplayMetadata | null, ): void { - const replayPreamble = terminal.getReplayPreamble(); + // A captured viewer must start from its cut, then apply the buffered mode changes. + // Live-only subscriptions have no capture and start from the current shared modes. + const replayPreamble = replay === null ? terminal.getReplayPreamble() : replay.replayPreamble; if (replayPreamble.length > 0) { activeStream.outputCoalescer.handle(replayPreamble); } + activeStream.bufferedOutputBytes = 0; const bufferedOutputs = activeStream.bufferedOutputs.splice( 0, activeStream.bufferedOutputs.length, ); for (const output of bufferedOutputs) { - if ( - replayRevision !== undefined && - output.revision !== undefined && - output.revision <= replayRevision - ) { + if (replay !== null && output.revision !== undefined && output.revision <= replay.revision) { continue; } activeStream.outputCoalescer.handle(output.data); diff --git a/packages/server/src/terminal/terminal-worker-client.test.ts b/packages/server/src/terminal/terminal-worker-client.test.ts index a363ab500d..01a4dd91d3 100644 --- a/packages/server/src/terminal/terminal-worker-client.test.ts +++ b/packages/server/src/terminal/terminal-worker-client.test.ts @@ -72,3 +72,46 @@ describe("terminal worker authentication", () => { await waitUntil(() => !existsSync(recordPath), 10_000); }); }); + +it("bounds a stalled framed writer to one chunk and resumes in order", async () => { + const { createServer, createConnection } = await import("node:net"); + const { attachFramedSocket } = await import("./terminal-worker-frames.js"); + const server = createServer(); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing test address"); + const accepted = new Promise((resolve) => + server.once("connection", resolve), + ); + const reader = createConnection(address.port, "127.0.0.1"); + reader.pause(); + const writer = await accepted; + const framed = attachFramedSocket(writer, () => {}); + let completed = 0; + let peakQueuedBytes = 0; + const received: number[] = []; + attachFramedSocket(reader, (value) => received.push((value as { index: number }).index)); + const sending = (async () => { + for (let index = 0; index < 512; index++) { + const pending = framed.sendAndWait({ index, data: Buffer.alloc(64 * 1024) }); + peakQueuedBytes = Math.max(peakQueuedBytes, writer.writableLength); + await pending; + completed++; + } + })(); + try { + await waitUntil(() => writer.writableLength > 0, 10000); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(completed).toBeLessThan(512); + expect(peakQueuedBytes).toBeLessThan(65 * 1024); + reader.resume(); + await sending; + await waitUntil(() => received.length === 512, 10000); + expect(received).toEqual(Array.from({ length: 512 }, (_, index) => index)); + expect(writer.destroyed).toBe(false); + } finally { + reader.destroy(); + writer.destroy(); + await new Promise((resolve) => server.close(() => resolve())); + } +}); diff --git a/packages/server/src/terminal/terminal-worker-frames.ts b/packages/server/src/terminal/terminal-worker-frames.ts index f0b990e239..897e91826f 100644 --- a/packages/server/src/terminal/terminal-worker-frames.ts +++ b/packages/server/src/terminal/terminal-worker-frames.ts @@ -50,6 +50,7 @@ export class FrameDecoder { export interface FramedSocket { readonly socket: Socket; send(message: unknown, callback?: (error: Error | null) => void): boolean; + sendAndWait(message: unknown): Promise; close(): void; } @@ -91,6 +92,17 @@ export function attachFramedSocket( } return socket.write(frame, (error) => callback?.(error ?? null)); }, + sendAndWait(message) { + return new Promise((resolve, reject) => { + this.send(message, (error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + }, close() { socket.end(() => socket.destroy()); }, diff --git a/packages/server/src/terminal/terminal-worker-pool.ts b/packages/server/src/terminal/terminal-worker-pool.ts index f15ad5b46d..517d34aa33 100644 --- a/packages/server/src/terminal/terminal-worker-pool.ts +++ b/packages/server/src/terminal/terminal-worker-pool.ts @@ -5,7 +5,7 @@ import type { TerminalsChangedListener, TerminalWorkspaceContributionChangedListener, } from "./terminal-manager.js"; -import type { TerminalSession, TerminalStateSnapshot } from "./terminal.js"; +import type { TerminalSession } from "./terminal.js"; import type { WorkerCreateTerminalOptions } from "./terminal-worker-protocol.js"; import type { DaemonTerminalManager } from "./worker-terminal-manager.js"; @@ -217,8 +217,8 @@ export function createTerminalWorkerPool( return undefined; }, - async getTerminalState(id, stateOptions): Promise { - return routed(id).getTerminalState(id, stateOptions); + async replayTerminal(id, replayOptions, sink) { + return routed(id).replayTerminal(id, replayOptions, sink); }, setTerminalTitle(id, title) { diff --git a/packages/server/src/terminal/terminal-worker-process.ts b/packages/server/src/terminal/terminal-worker-process.ts index 6c13ba04d5..b3e8864f9d 100644 --- a/packages/server/src/terminal/terminal-worker-process.ts +++ b/packages/server/src/terminal/terminal-worker-process.ts @@ -1,3 +1,4 @@ +import { createTerminalReplayController } from "./terminal-replay.js"; import { spawn } from "node:child_process"; import { randomBytes, timingSafeEqual } from "node:crypto"; import { chmodSync, closeSync, existsSync, openSync, rmSync, writeSync } from "node:fs"; @@ -6,13 +7,8 @@ import { fileURLToPath } from "node:url"; import { currentBootId } from "../server/pid-lock.js"; import { PRIVATE_FILE_MODE } from "../server/private-files.js"; import { createTerminalManager } from "./terminal-manager.js"; -import { captureTerminalLines } from "./terminal-capture.js"; import { TerminalOutputCoalescer } from "./terminal-output-coalescer.js"; -import type { - TerminalExitInfo, - TerminalSession, - TerminalStateSnapshotOptions, -} from "./terminal.js"; +import type { TerminalExitInfo, LocalTerminalSession } from "./terminal.js"; import { attachFramedSocket, type FramedSocket } from "./terminal-worker-frames.js"; import { createTerminalWorkerProcessInspector } from "./terminal-worker-processes.js"; import { @@ -30,7 +26,6 @@ import { type TerminalWorkerHelloAck, type TerminalWorkerListResult, type TerminalWorkerRequest, - type TerminalWorkerStateResult, type TerminalWorkerToParentMessage, type WorkerTerminalInfo, } from "./terminal-worker-protocol.js"; @@ -177,7 +172,7 @@ function runWorker(argv: string[]): void { const protocolVersion = reportedProtocolVersion(); const manager = createTerminalManager(); const inspector = createTerminalWorkerProcessInspector(); - const sessionsById = new Map(); + const sessionsById = new Map(); const unsubscribeByTerminalId = new Map void>>(); const outputCoalescerByTerminalId = new Map(); // A terminal is published (terminalCreated) only once its program is recorded. An exit that @@ -211,10 +206,12 @@ function runWorker(argv: string[]): void { reportInFlightTerminalCreateFailure(error); }); + const replayController = createTerminalReplayController({ + getSession: (id) => sessionsById.get(id), + flushOutput: (id) => outputCoalescerByTerminalId.get(id)?.flush(), + }); function sendToController(message: TerminalWorkerToParentMessage): void { - // With no controller the event is dropped; the headless terminal keeps the screen, and the - // next daemon reads it through listTerminals. - controller?.send(message); + replayController.send(message); } function persistRecord(): void { @@ -259,17 +256,11 @@ function runWorker(argv: string[]): void { } } - function buildTerminalStateResult( - session: TerminalSession | undefined, - options?: TerminalStateSnapshotOptions, - ): TerminalWorkerStateResult { - if (!session) { - return null; - } - return { ...session.getStateSnapshot(options), replayPreamble: session.getReplayPreamble() }; + function terminalMetadata(session: LocalTerminalSession) { + return { ...session.getSize(), hasOutput: session.hasOutput(), title: session.getTitle() }; } - function toTerminalInfo(session: TerminalSession): WorkerTerminalInfo { + function toTerminalInfo(session: LocalTerminalSession): WorkerTerminalInfo { return { id: session.id, name: session.name, @@ -316,7 +307,7 @@ function runWorker(argv: string[]): void { } } - function watchTerminal(session: TerminalSession): void { + function watchTerminal(session: LocalTerminalSession): void { clearTerminalSubscriptions(session.id); // Coalesce pty output chunks into a single frame per ~5ms window so a burst of small @@ -338,28 +329,22 @@ function runWorker(argv: string[]): void { }); outputCoalescerByTerminalId.set(session.id, outputCoalescer); - const unsubscribeMessage = session.subscribe( - (message) => { - if (message.type === "output") { - pendingOutputRevision = message.revision; - outputCoalescer.handle(message.data); - return; - } - // Non-output messages (snapshot/snapshotReady/titleChange) must not jump - // ahead of buffered output: flush the coalescer first, then forward. - outputCoalescer.flush(); - sendToController({ - type: "terminalMessage", - terminalId: session.id, - message, - replayPreamble: session.getReplayPreamble(), - }); - }, - // Creation already sends an authoritative state in terminalCreated. A second - // asynchronous state snapshot can arrive after the create response and - // overwrite a resize issued immediately by the caller. - { initialSnapshot: "ready" }, - ); + const unsubscribeMessage = session.subscribe((message) => { + if (message.type === "output") { + pendingOutputRevision = message.revision; + outputCoalescer.handle(message.data); + return; + } + // Non-output messages (snapshotReady/titleChange) must not jump + // ahead of buffered output: flush the coalescer first, then forward. + outputCoalescer.flush(); + sendToController({ + type: "terminalMessage", + terminalId: session.id, + message, + replayPreamble: session.getReplayPreamble(), + }); + }); const unsubscribeExit = session.onExit((info) => { outputCoalescer.flush(); clearTerminalSubscriptions(session.id); @@ -413,7 +398,7 @@ function runWorker(argv: string[]): void { // A terminal enters the record only with its program's birth time, so cleanup can always // tell the program from a later process that reuses its pid. A program that already exited // needs no entry; one whose birth time cannot be read is killed and the create fails. - async function recordTerminalProgram(session: TerminalSession): Promise { + async function recordTerminalProgram(session: LocalTerminalSession): Promise { if (session.getExitInfo()) return; // The manager drops a session as soon as its program exits, so only a live one has a pid. const pid = manager.getTerminalProcessId(session.id); @@ -476,11 +461,19 @@ function runWorker(argv: string[]): void { await session.killAndWait().catch(() => undefined); throw error; } - const initialSnapshot = session.getStateSnapshot(); + const state = terminalMetadata(session); sendToController({ type: "terminalCreated", terminal: toTerminalInfo(session), - state: initialSnapshot.state, + state, + }); + // Startup output/readiness may have arrived while recording the program, + // before the daemon knew this terminal. Publish its current modes after creation. + sendToController({ + type: "terminalMessage", + terminalId: session.id, + message: { type: "snapshotReady" }, + replayPreamble: session.getReplayPreamble(), }); sendToController({ type: "response", @@ -488,7 +481,7 @@ function runWorker(argv: string[]): void { ok: true, result: { terminal: toTerminalInfo(session), - state: initialSnapshot.state, + state, }, }); const heldExit = exitsHeldDuringCreation.get(session.id); @@ -513,7 +506,7 @@ function runWorker(argv: string[]): void { outputCoalescerByTerminalId.get(session.id)?.flush(); return { terminal: toTerminalInfo(session), - state: session.getStateSnapshot().state, + state: terminalMetadata(session), activityToken: manager.getTerminalActivityToken(session.id), replayPreamble: session.getReplayPreamble(), }; @@ -559,28 +552,15 @@ function runWorker(argv: string[]): void { return; } - case "getTerminalState": { - // Flush buffered output before snapshotting: the headless state already includes it, - // so if the coalescer emitted it afterward (in a batch carrying a revision past the - // snapshot's) the controller's revision dedup wouldn't drop it and the client would - // see the bytes twice. Flushing first sends them with a revision <= the snapshot's. - outputCoalescerByTerminalId.get(message.terminalId)?.flush(); - sendToController({ - type: "response", - requestId: message.requestId, - ok: true, - result: buildTerminalStateResult( - manager.getTerminal(message.terminalId), - message.options, - ), - }); + case "replayTerminal": { + await replayController.replay(message); return; } case "captureTerminal": { const session = manager.getTerminal(message.terminalId); const result = session - ? captureTerminalLines(session, { + ? session.captureLines({ start: message.start, end: message.end, stripAnsi: message.stripAnsi, @@ -659,10 +639,9 @@ function runWorker(argv: string[]): void { } authenticated = true; // One controller at a time: a new authenticated connection replaces the old one. - const previous = controller; controller = framed; + replayController.setConnection(framed); everControlled = true; - previous?.socket.destroy(); const ack: TerminalWorkerHelloAck = { type: "helloAck", pid: process.pid, @@ -688,6 +667,7 @@ function runWorker(argv: string[]): void { clearTimeout(helloTimer); if (controller === framed) { controller = null; + replayController.setConnection(null); exitIfIdle(); } }); diff --git a/packages/server/src/terminal/terminal-worker-protocol.test.ts b/packages/server/src/terminal/terminal-worker-protocol.test.ts index c3810bf9cf..6ea9a823db 100644 --- a/packages/server/src/terminal/terminal-worker-protocol.test.ts +++ b/packages/server/src/terminal/terminal-worker-protocol.test.ts @@ -2,18 +2,24 @@ import { describe, expect, it } from "vitest"; import { classifyTerminalWorkerProtocol } from "./terminal-worker-protocol.js"; describe("classifyTerminalWorkerProtocol", () => { + it("rejects the unreleased cell protocol without a draining adapter", () => { + expect(classifyTerminalWorkerProtocol(1, 2)).toBe("obsolete"); + expect(classifyTerminalWorkerProtocol(2, 3)).toBe("obsolete"); + expect(classifyTerminalWorkerProtocol(3, 4)).toBe("obsolete"); + expect(classifyTerminalWorkerProtocol(4, 5)).toBe("obsolete"); + }); it.each([ - [5, "current"], - [4, "previous"], - [3, "obsolete"], + [6, "current"], + [5, "previous"], + [4, "obsolete"], [-1, "obsolete"], - [6, "newer"], + [7, "newer"], // A version this daemon cannot read comes from a Paseo it does not know. ["6", "newer"], [4.5, "newer"], [Number.NaN, "newer"], [undefined, "newer"], - ])("a worker reporting %s to a daemon at 5 is %s", (reported, fit) => { - expect(classifyTerminalWorkerProtocol(reported, 5)).toBe(fit); + ])("a worker reporting %s to a daemon at 6 is %s", (reported, fit) => { + expect(classifyTerminalWorkerProtocol(reported, 6)).toBe(fit); }); }); diff --git a/packages/server/src/terminal/terminal-worker-protocol.ts b/packages/server/src/terminal/terminal-worker-protocol.ts index 86fc4a7772..e781543697 100644 --- a/packages/server/src/terminal/terminal-worker-protocol.ts +++ b/packages/server/src/terminal/terminal-worker-protocol.ts @@ -1,11 +1,11 @@ -import type { - TerminalExitInfo, - ServerMessage, - ClientMessage, - TerminalStateSnapshot, - TerminalStateSnapshotOptions, -} from "./terminal.js"; -import type { TerminalState } from "@getpaseo/protocol/messages"; +import type { TerminalExitInfo, ServerMessage, ClientMessage } from "./terminal.js"; +import type { TerminalReplayEvent, TerminalReplayOptions } from "./terminal-replay.js"; +export interface WorkerTerminalState { + rows: number; + cols: number; + hasOutput: boolean; + title?: string; +} import type { TerminalActivity, TerminalActivityState } from "@getpaseo/protocol/terminal-activity"; import type { CaptureTerminalLinesResult } from "./terminal-capture.js"; @@ -43,7 +43,7 @@ export interface WorkerCreateTerminalOptions { // `shutdown`. A daemon drives a worker of its own version, drains one a version older (no new // terminals; it exits after its last terminal), shuts down anything older, and leaves a newer // worker running untouched. See docs/terminal-worker.md. -export const TERMINAL_WORKER_PROTOCOL_VERSION = 1; +export const TERMINAL_WORKER_PROTOCOL_VERSION = 5; // Test-only: the protocol version a worker reports, so tests can stand up older and newer // workers from one build. @@ -59,7 +59,8 @@ export function classifyTerminalWorkerProtocol( // A version this daemon cannot read comes from a Paseo it does not know: treat it as newer. if (typeof reported !== "number" || !Number.isSafeInteger(reported)) return "newer"; if (reported === current) return "current"; - if (reported === current - 1) return "previous"; + // Protocols 1 through 4 were unreleased; they have no draining adapter. + if (reported === current - 1 && reported >= 5) return "previous"; return reported < current ? "obsolete" : "newer"; } @@ -77,7 +78,7 @@ export interface TerminalWorkerHelloAck { export interface WorkerListedTerminal { terminal: WorkerTerminalInfo; - state: TerminalState; + state: WorkerTerminalState; activityToken: string | null; replayPreamble: string; } @@ -122,10 +123,10 @@ export type TerminalWorkerRequest = options?: WorkerKillAndWaitOptions; } | { - type: "getTerminalState"; + type: "replayTerminal"; requestId: string; terminalId: string; - options?: TerminalStateSnapshotOptions; + options?: TerminalReplayOptions; } | { type: "captureTerminal"; @@ -166,10 +167,11 @@ export type TerminalWorkerResponse = }; export type TerminalWorkerEvent = + | { type: "terminalReplay"; requestId: string; event: TerminalReplayEvent } | { type: "terminalCreated"; terminal: WorkerTerminalInfo; - state: TerminalState; + state: WorkerTerminalState; } | { type: "terminalMessage"; @@ -206,6 +208,3 @@ export type TerminalWorkerToParentMessage = TerminalWorkerResponse | TerminalWor export type TerminalWorkerListResult = WorkerListedTerminal[]; export type TerminalWorkerCaptureResult = CaptureTerminalLinesResult; -// The worker fills TerminalStateSnapshot.replayPreamble on getTerminalState so -// the parent can cache the input-mode preamble instead of re-deriving it. -export type TerminalWorkerStateResult = TerminalStateSnapshot | null; diff --git a/packages/server/src/terminal/terminal.posix.test.ts b/packages/server/src/terminal/terminal.posix.test.ts index 4452fb6c1f..474507f771 100644 --- a/packages/server/src/terminal/terminal.posix.test.ts +++ b/packages/server/src/terminal/terminal.posix.test.ts @@ -1,3 +1,5 @@ +import { readTerminalState } from "./test-utils/terminal-state.js"; +import type { TerminalState } from "@getpaseo/protocol/messages"; // POSIX-only: node-pty + POSIX shell assertions /* eslint-disable max-nested-callbacks */ import { describe, it, expect, afterEach } from "vitest"; @@ -6,7 +8,7 @@ import { buildTerminalEnvironment, createTerminal, resolveZshShellIntegrationDir, - type TerminalSession, + type LocalTerminalSession as TerminalSession, } from "./terminal.js"; import { chmodSync, @@ -28,7 +30,7 @@ if (isPlatform("win32") && !process.env.ComSpec && !process.env.COMSPEC) { process.env.ComSpec = "C:\\Windows\\System32\\cmd.exe"; } -type TerminalRow = ReturnType["grid"][number]; +type TerminalRow = TerminalState["grid"][number]; function rowToText(row: TerminalRow): string { return row @@ -38,12 +40,12 @@ function rowToText(row: TerminalRow): string { } // Extract text from a single row -function getRowText(state: ReturnType, rowIndex: number): string { +function getRowText(state: TerminalState, rowIndex: number): string { return rowToText(state.grid[rowIndex]); } // Extract all visible lines as array (trimmed, empty lines included) -function getLines(state: ReturnType): string[] { +function getLines(state: TerminalState): string[] { return state.grid.map(rowToText); } @@ -55,7 +57,7 @@ async function waitForLines( ): Promise { const start = Date.now(); while (Date.now() - start < timeoutMs) { - const lines = getLines(session.getState()); + const lines = getLines(await readTerminalState(session)); let matches = true; for (let i = 0; i < expectedLines.length; i++) { if (lines[i] !== expectedLines[i]) { @@ -68,7 +70,7 @@ async function waitForLines( } await new Promise((resolve) => setTimeout(resolve, 50)); } - const actual = getLines(session.getState()).slice(0, expectedLines.length); + const actual = getLines(await readTerminalState(session)).slice(0, expectedLines.length); throw new Error( `Timeout waiting for expected lines.\nExpected:\n${JSON.stringify(expectedLines, null, 2)}\nActual:\n${JSON.stringify(actual, null, 2)}`, ); @@ -76,12 +78,12 @@ async function waitForLines( async function waitForState( session: TerminalSession, - predicate: (state: ReturnType) => boolean, + predicate: (state: TerminalState) => boolean, timeoutMs = 5000, -): Promise> { +): Promise { const start = Date.now(); while (Date.now() - start < timeoutMs) { - const state = session.getState(); + const state = await readTerminalState(session); if (predicate(state)) { return state; } @@ -232,19 +234,19 @@ function isOsc11OkLine(line: string): boolean { return line.startsWith("OSC11_OK:"); } -function hasDaOkLine(state: ReturnType): boolean { +function hasDaOkLine(state: TerminalState): boolean { return getLines(state).some(isDaOkLine); } -function hasDsrOkLine(state: ReturnType): boolean { +function hasDsrOkLine(state: TerminalState): boolean { return getLines(state).some(isDsrOkLine); } -function hasOsc11OkLine(state: ReturnType): boolean { +function hasOsc11OkLine(state: TerminalState): boolean { return getLines(state).some(isOsc11OkLine); } -function lastNonEmptyLineIsPrompt(state: ReturnType): boolean { +function lastNonEmptyLineIsPrompt(state: TerminalState): boolean { const last = getLines(state) .toReversed() @@ -322,7 +324,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { // Line 2: "$" await waitForLines(session, ["$ echo hello", "hello", "$"]); - const state = session.getState(); + const state = await readTerminalState(session); expect(getRowText(state, 0)).toBe("$ echo hello"); expect(getRowText(state, 1)).toBe("hello"); expect(getRowText(state, 2)).toBe("$"); @@ -346,7 +348,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { session.send({ type: "input", data: "echo second\r" }); await waitForLines(session, ["$ echo first", "first", "$ echo second", "second", "$"]); - const state = session.getState(); + const state = await readTerminalState(session); expect(getRowText(state, 0)).toBe("$ echo first"); expect(getRowText(state, 1)).toBe("first"); expect(getRowText(state, 2)).toBe("$ echo second"); @@ -370,7 +372,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { await waitForLines(session, ["$ pwd", "/tmp", "$"]); - const state = session.getState(); + const state = await readTerminalState(session); expect(getRowText(state, 0)).toBe("$ pwd"); expect(getRowText(state, 1)).toBe("/tmp"); expect(getRowText(state, 2)).toBe("$"); @@ -431,7 +433,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { expect(seenTitles).toContain("npm run dev"); expect(session.getTitle()).toBe("npm run dev"); - expect(session.getState().title).toBe("npm run dev"); + expect(session.getTitle()).toBe("npm run dev"); unsubscribeTitle(); }); @@ -457,7 +459,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { expect(seenTitles).toContain("Build Log"); expect(session.getTitle()).toBe("Build Log"); - expect(session.getState().title).toBe("Build Log"); + expect(session.getTitle()).toBe("Build Log"); unsubscribeTitle(); }); @@ -478,7 +480,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { await new Promise((resolve) => setTimeout(resolve, 150)); expect(session.getTitle()).toBe("typecheck"); - expect(session.getState().title).toBe("typecheck"); + expect(session.getTitle()).toBe("typecheck"); }); it("emits command completion from VS Code OSC 633 without visible output", async () => { @@ -507,7 +509,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { await waitForState(session, () => commandCompletions.length === 1); expect(commandCompletions).toEqual([7]); - expect(getLines(session.getState()).join("\n")).not.toContain("633;D;7"); + expect(getLines(await readTerminalState(session)).join("\n")).not.toContain("633;D;7"); unsubscribeCommandFinished(); }); @@ -541,7 +543,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { await waitForState(session, () => commandCompletions.length === 1); expect(commandCompletions).toEqual([3]); - expect(getLines(session.getState()).join("\n")).not.toContain("633;D;garbage"); + expect(getLines(await readTerminalState(session)).join("\n")).not.toContain("633;D;garbage"); unsubscribeCommandFinished(); }); @@ -696,7 +698,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { await waitForState(session, () => commandCompletions.includes(1)); expect(commandCompletions).toEqual([1]); - expect(getLines(session.getState()).join("\n")).not.toContain("633;D;1"); + expect(getLines(await readTerminalState(session)).join("\n")).not.toContain("633;D;1"); unsubscribeCommandFinished(); }); @@ -720,7 +722,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { await waitForLines(session, ["$ printf '\\033[31mRED\\033[0m'", "RED$"]); - const state = session.getState(); + const state = await readTerminalState(session); const outputRow = state.grid[1]; expect(outputRow[0].char).toBe("R"); @@ -750,7 +752,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { await waitForLines(session, ["$ printf '\\033[38;5;208mORG\\033[0m'", "ORG$"]); - const state = session.getState(); + const state = await readTerminalState(session); const outputRow = state.grid[1]; // Check O cell @@ -776,7 +778,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { await waitForLines(session, ["$ printf '\\033[38;2;255;128;64mRGB\\033[0m'", "RGB$"]); - const state = session.getState(); + const state = await readTerminalState(session); const outputRow = state.grid[1]; // Check R cell @@ -805,7 +807,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { await waitForLines(session, ["$ printf '\\033[41mBG\\033[0m'", "BG$"]); - const state = session.getState(); + const state = await readTerminalState(session); const outputRow = state.grid[1]; expect(outputRow[0].char).toBe("B"); @@ -815,7 +817,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { }); describe("subscribe", () => { - it("receives a snapshot on initial subscription", async () => { + it("receives readiness on initial subscription", async () => { const session = trackSession( await createTerminal({ workspaceId: "ws-test", @@ -831,7 +833,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { await new Promise((resolve) => setTimeout(resolve, 100)); expect(messages.length).toBeGreaterThan(0); - expect(messages[0].type).toBe("snapshot"); + expect(messages[0].type).toBe("snapshotReady"); unsubscribe(); }); @@ -861,12 +863,14 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { await waitForLines(session, ["$ echo test", "test", "$"]); expect(messages.some((message) => message.type === "output")).toBe(true); - expect(messages.some((message) => message.type === "snapshot")).toBe(false); + expect( + messages.every((message) => message.type === "output" || message.type === "titleChange"), + ).toBe(true); unsubscribe(); }); - it("does not emit snapshot messages for resize-only updates", async () => { + it("keeps resize updates in the live observation lifecycle", async () => { const session = trackSession( await createTerminal({ workspaceId: "ws-test", @@ -889,13 +893,15 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { session.send({ type: "resize", rows: 30, cols: 100 }); await new Promise((resolve) => setTimeout(resolve, 200)); - expect(messages.some((message) => message.type === "snapshot")).toBe(false); + expect( + messages.every((message) => message.type === "output" || message.type === "titleChange"), + ).toBe(true); expect(session.getSize()).toEqual({ rows: 30, cols: 100 }); unsubscribe(); }); - it("emits output only after getState reflects the new data", async () => { + it("emits output only after text capture reflects the new data", async () => { const session = trackSession( await createTerminal({ workspaceId: "ws-test", @@ -912,7 +918,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { return; } unsubscribe(); - const stateText = getLines(session.getState()).join("\n"); + const stateText = session.captureLines().lines.join("\n"); resolve(stateText.includes("state-after-output")); }); }); @@ -965,7 +971,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { session.send({ type: "input", data: `${process.execPath} ${helperPath}\r` }); await waitForState(session, hasDaOkLine); - const ack = getLines(session.getState()).find(isDaOkLine) ?? ""; + const ack = getLines(await readTerminalState(session)).find(isDaOkLine) ?? ""; expect(ack).toMatch(/^DA_OK:\[\?[\d;]+c$/); }); @@ -1003,7 +1009,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { session.send({ type: "input", data: `${process.execPath} ${helperPath} cursor\r` }); await waitForState(session, hasDsrOkLine); - const ack = getLines(session.getState()).find(isDsrOkLine) ?? ""; + const ack = getLines(await readTerminalState(session)).find(isDsrOkLine) ?? ""; expect(ack).toMatch(/^DSR_OK:\d+:\d+$/); }); @@ -1023,7 +1029,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { session.send({ type: "input", data: `${process.execPath} ${helperPath} private-cursor\r` }); await waitForState(session, hasDsrOkLine); - const ack = getLines(session.getState()).find(isDsrOkLine) ?? ""; + const ack = getLines(await readTerminalState(session)).find(isDsrOkLine) ?? ""; expect(ack).toMatch(/^DSR_OK:\d+:\d+$/); }); @@ -1043,7 +1049,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { session.send({ type: "input", data: `${process.execPath} ${helperPath} status\r` }); await waitForState(session, hasDsrOkLine); - const ack = getLines(session.getState()).find(isDsrOkLine) ?? ""; + const ack = getLines(await readTerminalState(session)).find(isDsrOkLine) ?? ""; expect(ack).toBe("DSR_OK:status"); }); @@ -1063,7 +1069,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { session.send({ type: "input", data: `${process.execPath} ${helperPath}\r` }); await waitForState(session, hasOsc11OkLine); - const ack = getLines(session.getState()).find(isOsc11OkLine) ?? ""; + const ack = getLines(await readTerminalState(session)).find(isOsc11OkLine) ?? ""; expect(ack).toBe("OSC11_OK:ESC]11;rgb:0b0b/0b0b/0b0bESC\\"); }); }); @@ -1097,7 +1103,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { unsubscribe(); }); - it("sends the current snapshot to a new subscriber instead of replaying raw output", async () => { + it("announces readiness for a fresh replay instead of replaying raw live output", async () => { const session = trackSession( await createTerminal({ workspaceId: "ws-test", @@ -1121,26 +1127,24 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { "$", ]); - let snapshotText = ""; - const unsubscribe = session.subscribe((message) => { - if (message.type !== "snapshot") { - return; - } - snapshotText = [...message.state.scrollback, ...message.state.grid] - .map(rowToText) - .join("\n"); + let ready!: () => void; + const readiness = new Promise((resolve) => { + ready = resolve; }); - - await new Promise((resolve) => setTimeout(resolve, 100)); - - expect(snapshotText).toContain("before-detach"); - expect(snapshotText).toContain("after-detach"); + const unsubscribe = session.subscribe((message) => { + if (message.type === "snapshotReady") ready(); + }); + await readiness; + const state = await readTerminalState(session); + const text = [...state.scrollback, ...state.grid].map(rowToText).join("\n"); + expect(text).toContain("before-detach"); + expect(text).toContain("after-detach"); unsubscribe(); }); }); - describe("getState", () => { - it("returns current terminal state with grid", async () => { + describe("ANSI replay observation", () => { + it("reconstructs current terminal state from ANSI", async () => { const session = trackSession( await createTerminal({ workspaceId: "ws-test", @@ -1152,7 +1156,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { }), ); - const state = session.getState(); + const state = await readTerminalState(session); expect(state.rows).toBe(24); expect(state.cols).toBe(80); @@ -1179,19 +1183,16 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { await waitForLines(session, ["$"]); session.send({ type: "input", data: "printf '\\033[2 q\\033[?25l'\r" }); - const state = await waitForState( - session, - (current) => - current.cursor.style === "block" && - current.cursor.blink === false && - current.cursor.hidden === true, + const deadline = Date.now() + 5000; + while ( + !new TextDecoder().decode(session.serializeReplay().data).includes("\x1b[?25l\x1b[2 q") + ) { + if (Date.now() > deadline) throw new Error("cursor presentation did not reach replay"); + await new Promise((resolve) => setTimeout(resolve, 20)); + } + expect(new TextDecoder().decode(session.serializeReplay().data)).toContain( + "\x1b[?25l\x1b[2 q", ); - - expect(state.cursor).toMatchObject({ - style: "block", - blink: false, - hidden: true, - }); }); it("grid cells have char and color attributes", async () => { @@ -1206,7 +1207,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { await waitForLines(session, ["$"]); - const state = session.getState(); + const state = await readTerminalState(session); // First cell should be "$" expect(state.grid[0][0].char).toBe("$"); expect(state.grid[0][0]).toHaveProperty("fg"); @@ -1238,7 +1239,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { // The visible area will show something like: 17, 18, 19, 20, $ await waitForLines(session, ["17", "18", "19", "20", "$"]); - const state = session.getState(); + const state = await readTerminalState(session); // Scrollback should contain the earlier output expect(state.scrollback.length).toBeGreaterThan(0); @@ -1269,7 +1270,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { session.kill(); // Should not throw when trying to get state after kill - const state = session.getState(); + const state = await readTerminalState(session); expect(state).toBeDefined(); }); diff --git a/packages/server/src/terminal/terminal.test.ts b/packages/server/src/terminal/terminal.test.ts index 751fb1b33a..69e6468673 100644 --- a/packages/server/src/terminal/terminal.test.ts +++ b/packages/server/src/terminal/terminal.test.ts @@ -1,3 +1,7 @@ +import { TerminalInputModeTracker } from "@getpaseo/protocol/terminal-input-mode"; +import { createTerminalSerializer } from "./terminal-serializer.js"; +import { readTerminalState } from "./test-utils/terminal-state.js"; +import type { TerminalState } from "@getpaseo/protocol/messages"; import { describe, it, expect, afterEach, vi } from "vitest"; import { isPlatform } from "../test-utils/platform.js"; import { @@ -10,7 +14,7 @@ import { humanizeProcessTitle, normalizeProcessTitle, resolveZshShellIntegrationDir, - type TerminalSession, + type LocalTerminalSession as TerminalSession, } from "./terminal.js"; import { chmodSync, @@ -26,7 +30,6 @@ import { import { spawnSync } from "node:child_process"; import * as pty from "node-pty"; import xterm from "@xterm/headless"; -import { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot"; import { delimiter, join } from "node:path"; import { tmpdir } from "node:os"; import { setImmediate as waitForImmediate, setTimeout as delay } from "node:timers/promises"; @@ -34,7 +37,7 @@ import { stripVTControlCharacters } from "node:util"; const hasZsh = existsSync("/bin/zsh"); -type TerminalRow = ReturnType["grid"][number]; +type TerminalRow = TerminalState["grid"][number]; function rowToText(row: TerminalRow): string { return row @@ -44,12 +47,12 @@ function rowToText(row: TerminalRow): string { } // Extract text from a single row -function getRowText(state: ReturnType, rowIndex: number): string { +function getRowText(state: TerminalState, rowIndex: number): string { return rowToText(state.grid[rowIndex]); } // Extract all visible lines as array (trimmed, empty lines included) -function getLines(state: ReturnType): string[] { +function getLines(state: TerminalState): string[] { return state.grid.map(rowToText); } @@ -65,7 +68,7 @@ async function waitForLines( ): Promise { const start = Date.now(); while (Date.now() - start < timeoutMs) { - const lines = getLines(session.getState()); + const lines = getLines(await readTerminalState(session)); let matches = true; for (let i = 0; i < expectedLines.length; i++) { if (lines[i] !== expectedLines[i]) { @@ -78,7 +81,7 @@ async function waitForLines( } await new Promise((resolve) => setTimeout(resolve, 50)); } - const actual = getLines(session.getState()).slice(0, expectedLines.length); + const actual = getLines(await readTerminalState(session)).slice(0, expectedLines.length); throw new Error( `Timeout waiting for expected lines.\nExpected:\n${JSON.stringify(expectedLines, null, 2)}\nActual:\n${JSON.stringify(actual, null, 2)}`, ); @@ -86,12 +89,12 @@ async function waitForLines( async function waitForState( session: TerminalSession, - predicate: (state: ReturnType) => boolean, + predicate: (state: TerminalState) => boolean, timeoutMs = 5000, -): Promise> { +): Promise { const start = Date.now(); while (Date.now() - start < timeoutMs) { - const state = session.getState(); + const state = await readTerminalState(session); if (predicate(state)) { return state; } @@ -473,7 +476,7 @@ describe("createTerminal", () => { }), ); - const state = session.getState(); + const state = await readTerminalState(session); expect(state.rows).toBe(24); expect(state.cols).toBe(80); }); @@ -493,12 +496,12 @@ describe("createTerminal", () => { }), ); - const state = session.getState(); + const state = await readTerminalState(session); expect(state.rows).toBe(40); expect(state.cols).toBe(120); }); - it("reports per-row soft-wrap flags only when wrap flags are requested", async () => { + it("preserves soft-wrap boundaries through public replay", async () => { const session = trackSession( await createTerminal({ workspaceId: "ws-test", @@ -513,21 +516,15 @@ describe("createTerminal", () => { const start = Date.now(); while (Date.now() - start < 5000) { - if (rowToText(session.getState().grid[2]).startsWith("A")) { + if (rowToText((await readTerminalState(session)).grid[2]).startsWith("A")) { break; } await new Promise((resolve) => setTimeout(resolve, 50)); } // Rows 0 and 1 continue onto the next row; row 2 is the end of the logical line. - const withFlags = session.getState({ includeWrapFlags: true }); + const withFlags = await readTerminalState(session); expect(withFlags.gridWrapped?.slice(0, 3)).toEqual([true, true, false]); - - // Back-compat gate: without the capability the daemon must not attach the new - // fields, so an old strict-schema client still parses the snapshot. - const withoutFlags = session.getState(); - expect(withoutFlags.gridWrapped).toBeUndefined(); - expect(withoutFlags.scrollbackWrapped).toBeUndefined(); }); it("restores CJK text without injected spaces when a snapshot round-trips through the client", async () => { @@ -555,9 +552,7 @@ describe("createTerminal", () => { // row back the way a selection copy would. const restored = new xterm.Terminal({ cols: 80, rows: 10, allowProposedApi: true }); try { - await new Promise((resolve) => - restored.write(renderTerminalSnapshotToAnsi(state), resolve), - ); + await new Promise((resolve) => restored.write(session.serializeReplay().data, resolve)); expect(restored.buffer.active.getLine(0)?.translateToString(true)).toBe(text); } finally { restored.dispose(); @@ -615,11 +610,8 @@ describe("createTerminal", () => { // Poll the parsed buffer until the final line lands, so the exit summary // (which reads the headless buffer) can't race the async xterm parse. - const finalLineParsed = (): boolean => { - const state = session.getState(); - const rowText = (row: { char: string }[]): string => row.map((cell) => cell.char).join(""); - return [...state.scrollback, ...state.grid].some((row) => rowText(row).includes("line-2999")); - }; + const finalLineParsed = (): boolean => + session.captureLines({ start: -12 }).lines.some((line) => line.includes("line-2999")); const deadline = Date.now() + 10000; while (!finalLineParsed()) { if (Date.now() > deadline) { @@ -678,7 +670,7 @@ describe("createTerminal", () => { ); await waitForLines(session, ["paseo-launch-ok"]); - expect(getLines(session.getState())[0]).toBe("paseo-launch-ok"); + expect(getLines(await readTerminalState(session))[0]).toBe("paseo-launch-ok"); }, ); @@ -721,7 +713,7 @@ describe("createTerminal", () => { ); await waitForLines(session, ["paseo-launch-ok"]); - expect(getLines(session.getState())[0]).toBe("paseo-launch-ok"); + expect(getLines(await readTerminalState(session))[0]).toBe("paseo-launch-ok"); }, ); @@ -743,7 +735,7 @@ describe("createTerminal", () => { ); await waitForLines(session, ["paseo-launch-ok"]); - expect(getLines(session.getState())[0]).toBe("paseo-launch-ok"); + expect(getLines(await readTerminalState(session))[0]).toBe("paseo-launch-ok"); }); it("leaves the command check to the terminal itself on Windows", async () => { @@ -807,7 +799,7 @@ describe.skipIf(isPlatform("win32"))("send input", () => { // Line 2: "$" await waitForLines(session, ["$ echo hello", "hello", "$"]); - const state = session.getState(); + const state = await readTerminalState(session); expect(getRowText(state, 0)).toBe("$ echo hello"); expect(getRowText(state, 1)).toBe("hello"); expect(getRowText(state, 2)).toBe("$"); @@ -829,7 +821,7 @@ describe.skipIf(isPlatform("win32"))("send input", () => { await waitForLines(session, ["$ pwd", "/tmp", "$"]); - const state = session.getState(); + const state = await readTerminalState(session); expect(getRowText(state, 0)).toBe("$ pwd"); expect(getRowText(state, 1)).toBe("/tmp"); expect(getRowText(state, 2)).toBe("$"); @@ -890,7 +882,6 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => { expect(seenTitles).toContain("npm run dev"); expect(session.getTitle()).toBe("npm run dev"); - expect(session.getState().title).toBe("npm run dev"); unsubscribeTitle(); }); @@ -916,7 +907,6 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => { expect(seenTitles).toContain("Build Log"); expect(session.getTitle()).toBe("Build Log"); - expect(session.getState().title).toBe("Build Log"); unsubscribeTitle(); }); @@ -937,7 +927,6 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => { await new Promise((resolve) => setTimeout(resolve, 150)); expect(session.getTitle()).toBe("typecheck"); - expect(session.getState().title).toBe("typecheck"); }); it("emits command completion from VS Code OSC 633 without visible output", async () => { @@ -966,7 +955,7 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => { await waitForState(session, () => commandCompletions.length === 1); expect(commandCompletions).toEqual([7]); - expect(getLines(session.getState()).join("\n")).not.toContain("633;D;7"); + expect(getLines(await readTerminalState(session)).join("\n")).not.toContain("633;D;7"); unsubscribeCommandFinished(); }); @@ -1000,7 +989,7 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => { await waitForState(session, () => commandCompletions.length === 1); expect(commandCompletions).toEqual([3]); - expect(getLines(session.getState()).join("\n")).not.toContain("633;D;garbage"); + expect(getLines(await readTerminalState(session)).join("\n")).not.toContain("633;D;garbage"); unsubscribeCommandFinished(); }); @@ -1155,7 +1144,7 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => { await waitForState(session, () => commandCompletions.includes(1)); expect(commandCompletions).toEqual([1]); - expect(getLines(session.getState()).join("\n")).not.toContain("633;D;1"); + expect(getLines(await readTerminalState(session)).join("\n")).not.toContain("633;D;1"); unsubscribeCommandFinished(); }); @@ -1189,7 +1178,6 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => { expect(seenTitles).toEqual(["Build Log", "User terminal"]); expect(session.getTitle()).toBe("User terminal"); - expect(session.getState().title).toBe("User terminal"); unsubscribeTitle(); }); @@ -1219,7 +1207,6 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => { expect(seenTitles).toEqual(["Build Log", "User terminal"]); expect(session.getTitle()).toBe("User terminal"); - expect(session.getState().title).toBe("User terminal"); unsubscribeTitle(); }); @@ -1248,7 +1235,6 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => { expect(seenTitles).toEqual(["Build Log", "User terminal"]); expect(session.getTitle()).toBe("User terminal"); - expect(session.getState().title).toBe("User terminal"); unsubscribeTitle(); }); @@ -1272,7 +1258,7 @@ describe.skipIf(isPlatform("win32"))("colors", () => { await waitForLines(session, ["$ printf '\\033[31mRED\\033[0m'", "RED$"]); - const state = session.getState(); + const state = await readTerminalState(session); const outputRow = state.grid[1]; expect(outputRow[0].char).toBe("R"); @@ -1302,7 +1288,7 @@ describe.skipIf(isPlatform("win32"))("colors", () => { await waitForLines(session, ["$ printf '\\033[38;2;255;128;64mRGB\\033[0m'", "RGB$"]); - const state = session.getState(); + const state = await readTerminalState(session); const outputRow = state.grid[1]; // Check R cell @@ -1331,7 +1317,7 @@ describe.skipIf(isPlatform("win32"))("colors", () => { await waitForLines(session, ["$ printf '\\033[41mBG\\033[0m'", "BG$"]); - const state = session.getState(); + const state = await readTerminalState(session); const outputRow = state.grid[1]; expect(outputRow[0].char).toBe("B"); @@ -1355,7 +1341,7 @@ describe("resize", () => { await new Promise((resolve) => setTimeout(resolve, 100)); - const state = session.getState(); + const state = await readTerminalState(session); expect(state.rows).toBe(40); expect(state.cols).toBe(120); }); @@ -1373,7 +1359,7 @@ describe("resize", () => { session.send({ type: "resize", rows: 10, cols: 40 }); await new Promise((resolve) => setTimeout(resolve, 100)); - const state = session.getState(); + const state = await readTerminalState(session); expect(state.grid.length).toBe(10); expect(state.grid[0].length).toBe(40); }); @@ -1552,7 +1538,7 @@ it.runIf(isPlatform("linux", "darwin"))( env: { PATH: "bin" }, }); await waitForLines(session, ["paseo-launch-ok"]); - expect(getLines(session.getState())[0]).toBe("paseo-launch-ok"); + expect(getLines(await readTerminalState(session))[0]).toBe("paseo-launch-ok"); expect(session.cwd).toBe(realpathSync(projectDir)); expect(session.getExitInfo()).toBeNull(); } finally { @@ -1574,3 +1560,309 @@ it.runIf(isPlatform("linux", "darwin"))( ).rejects.toThrow(" node : command not found"); }, ); + +it.each([false, true])( + "serializes styled Unicode, cursor presentation and modes directly (alternate=%s)", + async (alternate) => { + // Exercise the serializer's input model directly. ConPTY renders program + // output: it consumes input modes and need not publish a hidden cursor move. + const terminal = new xterm.Terminal({ + rows: 4, + cols: 16, + scrollback: 10000, + allowProposedApi: true, + }); + const serialize = createTerminalSerializer(terminal); + const inputModes = new TerminalInputModeTracker(); + const content = + Array.from({ length: 8 }, (_, i) => "normal-" + i + "\r\n").join("") + + (alternate ? "\x1b[?1049h\x1b[H" : "") + + "\x1b[38;2;255;128;64;4m界é🙂\x1b[0m\r\nVIEW-READY\x1b[?1h\x1b[?2004h\x1b[>1u\x1b[?9001h\x1b[?7l\x1b[?1000h\x1b[5 q\x1b[?25l"; + const output = content + "\x1b[2;7H"; + await new Promise((resolve) => terminal.write(output, resolve)); + inputModes.feed(output); + const data = serialize(); + const ansi = new TextDecoder().decode(data); + expect(ansi).toContain("\x1b[?25l\x1b[5 q"); + expect(ansi).not.toContain("NaN"); + expect(terminal.buffer.normal.baseY).toBeGreaterThan(0); + const receiver = new xterm.Terminal({ + rows: 4, + cols: 16, + scrollback: 10000, + allowProposedApi: true, + }); + try { + await new Promise((resolve) => receiver.write(data, resolve)); + await new Promise((resolve) => receiver.write(inputModes.getPreamble(), resolve)); + expect(receiver.buffer.active.type).toBe(alternate ? "alternate" : "normal"); + const lines = Array.from({ length: receiver.buffer.active.length }, (_, i) => + receiver.buffer.active.getLine(i)!.translateToString(true), + ); + expect(lines.join("\n")).toContain("界é🙂"); + expect(lines.join("\n")).toContain("VIEW-READY"); + const coloredIndex = lines.findIndex((line) => line.includes("界")); + const colored = receiver.buffer.active.getLine(coloredIndex)!.getCell(0)!; + expect(colored.getChars()).toBe("界"); + expect(colored.getFgColor()).toBe(0xff8040); + expect(colored.isUnderline()).not.toBe(0); + expect(receiver.buffer.normal.getLine(0)!.translateToString(true)).toBe("normal-0"); + expect({ row: receiver.buffer.active.cursorY, col: receiver.buffer.active.cursorX }).toEqual({ + row: 1, + col: 6, + }); + expect(receiver.modes).toMatchObject({ + applicationCursorKeysMode: true, + bracketedPasteMode: true, + wraparoundMode: false, + mouseTrackingMode: "vt200", + }); + const modes = new TerminalInputModeTracker(); + modes.feed(ansi + inputModes.getPreamble()); + expect(modes.getState()).toMatchObject({ + kittyKeyboardFlags: 1, + win32InputMode: true, + applicationCursorKeys: true, + bracketedPaste: true, + }); + } finally { + receiver.dispose(); + terminal.dispose(); + } + }, +); + +it.each([ + { name: "unset", presentation: "", style: undefined, visible: true }, + { + name: "reset to client default", + presentation: "\x1b[5 q\x1b[0 q", + style: undefined, + visible: true, + }, + { name: "unset hidden", presentation: "\x1b[?25l", style: undefined, visible: false }, + ...[1, 2, 3, 4, 5, 6].map((style) => ({ + name: `explicit style ${style}`, + presentation: `\x1b[${style} q\x1b[?25l`, + style, + visible: false, + })), +])("replays cursor presentation without replacing client defaults ($name)", async (input) => { + const session = trackSession( + await createTerminal({ + cwd: realpathSync(tmpdir()), + workspaceId: "cursor-presentation", + command: process.execPath, + args: [ + "-e", + `process.stdout.write(${JSON.stringify(input.presentation + "CURSOR-READY\r\n")});setInterval(()=>{},1000)`, + ], + }), + ); + await waitForLines(session, ["CURSOR-READY"]); + const { data } = session.serializeReplay(); + const ansi = new TextDecoder().decode(data); + const receiver = new xterm.Terminal({ + cursorStyle: "bar", + cursorBlink: true, + allowProposedApi: true, + }); + const styles: number[] = []; + receiver.parser.registerCsiHandler({ intermediates: " ", final: "q" }, (params) => { + styles.push(Number(params[0])); + return false; + }); + try { + await new Promise((resolve) => receiver.write(data, resolve)); + expect(styles).toEqual(input.style === undefined ? [] : [input.style]); + expect(ansi).toContain(`\x1b[?25${input.visible ? "h" : "l"}`); + } finally { + receiver.dispose(); + } +}); + +it("captures input modes at the same committed cut as serialized bytes", async () => { + const session = trackSession( + await createTerminal({ + cwd: realpathSync(tmpdir()), + workspaceId: "mode-cut", + rows: 4, + cols: 80, + command: process.execPath, + args: [ + "-e", + 'process.stdin.on("data",()=>process.stdout.write("x".repeat(1024*1024)+"\x1b[?1hMODE-CUT"));setInterval(()=>{},1000)', + ], + }), + ); + const mismatches: string[] = []; + let completed!: () => void; + const output = new Promise((resolve) => { + completed = resolve; + }); + const unsubscribe = session.subscribe((message) => { + if (message.type !== "output") return; + if (session.getReplayPreamble().includes("\x1b[?1h")) { + const cut = session.serializeReplay({ scrollbackLines: 0 }); + if (!new TextDecoder().decode(cut.data).includes("\x1b[?1h")) + mismatches.push(cut.metadata.replayPreamble); + } + if (message.data.includes("MODE-CUT")) completed(); + }); + try { + session.send({ type: "input", data: "go\r" }); + await Promise.race([ + output, + delay(5000).then(() => { + throw new Error("mode cut did not complete"); + }), + ]); + expect(mismatches).toEqual([]); + } finally { + unsubscribe(); + } +}); + +it.each( + [false, true].flatMap((alternate) => [ + { + alternate, + pendingWrap: false, + position: "\x1b[3;16H", + cursor: { row: 2, col: 15 }, + after: { row: 2, col: 16 }, + }, + { + alternate, + pendingWrap: true, + position: "", + cursor: { row: 0, col: 16 }, + after: { row: 1, col: 1 }, + }, + ]), +)( + "replays a full-width row without losing cursor state ($alternate, pendingWrap=$pendingWrap)", + async (input) => { + const source = new xterm.Terminal({ + rows: 4, + cols: 16, + scrollback: 10000, + allowProposedApi: true, + }); + const serialize = createTerminalSerializer(source); + const receiver = new xterm.Terminal({ + rows: 4, + cols: 16, + scrollback: 10000, + allowProposedApi: true, + }); + try { + await new Promise((resolve) => + source.write( + (input.alternate ? "\x1b[?1049h" : "") + "x".repeat(16) + input.position, + resolve, + ), + ); + expect({ row: source.buffer.active.cursorY, col: source.buffer.active.cursorX }).toEqual( + input.cursor, + ); + await new Promise((resolve) => receiver.write(serialize(), resolve)); + expect({ row: receiver.buffer.active.cursorY, col: receiver.buffer.active.cursorX }).toEqual( + input.cursor, + ); + await new Promise((resolve) => receiver.write("Z", resolve)); + expect({ row: receiver.buffer.active.cursorY, col: receiver.buffer.active.cursorX }).toEqual( + input.after, + ); + } finally { + receiver.dispose(); + source.dispose(); + } + }, +); + +it.each([false, true])( + "replays the observed PTY projection including startup modes (alternate=%s)", + async (alternate) => { + const gate = mkdtempSync(join(tmpdir(), "terminal-projection-")); + temporaryDirs.push(gate); + const release = join(gate, "output"); + const content = + Array.from({ length: 8 }, (_, i) => `normal-${i}\r\n`).join("") + + (alternate ? "\x1b[?1049h\x1b[H" : "") + + "\x1b[38;2;255;128;64;4m界é🙂\x1b[0m\r\nVIEW-READY\x1b[?1h\x1b[?2004h\x1b[>1u\x1b[?9001h\x1b[?7l\x1b[?1000h\x1b[5 q\x1b[?25l\x1b[2;7H"; + const session = trackSession( + await createTerminal({ + cwd: realpathSync(tmpdir()), + workspaceId: "projection", + rows: 4, + cols: 16, + command: process.execPath, + args: [ + "-e", + `const fs=require("node:fs");let sent=false; + function emit(){if(!sent&&fs.existsSync(${JSON.stringify(release)})){sent=true;watcher.close();process.stdout.write(${JSON.stringify(content)});}} + const watcher=fs.watch(${JSON.stringify(gate)},emit);emit();setInterval(()=>{},1000);`, + ], + }), + ); + const bootstrap = session.serializeReplay(); + const output: string[] = []; + const unsubscribe = session.subscribe((message) => { + if (message.type === "output") output.push(message.data); + }); + const reference = new xterm.Terminal({ + rows: 4, + cols: 16, + scrollback: 10000, + allowProposedApi: true, + }); + const receiver = new xterm.Terminal({ + rows: 4, + cols: 16, + scrollback: 10000, + allowProposedApi: true, + }); + try { + await new Promise((resolve) => reference.write(bootstrap.data, resolve)); + await new Promise((resolve) => + reference.write(bootstrap.metadata.replayPreamble, resolve), + ); + writeFileSync(release, "release"); + const deadline = Date.now() + 5000; + while (!session.captureLines().lines.some((line) => line.includes("VIEW-READY"))) { + if (Date.now() > deadline) throw new Error("PTY did not render fixture output"); + await delay(20); + } + // Bootstrap and these committed output events cover precisely this source cut. + // Program bytes are not the oracle: ConPTY consumes modes and renders cells. + const replay = session.serializeReplay(); + const committed = output.join(""); + await new Promise((resolve) => reference.write(committed, resolve)); + await new Promise((resolve) => receiver.write(replay.data, resolve)); + await new Promise((resolve) => receiver.write(replay.metadata.replayPreamble, resolve)); + const text = (terminal: typeof receiver) => + Array.from({ length: terminal.buffer.active.length }, (_, i) => + terminal.buffer.active.getLine(i)!.translateToString(true).trimEnd(), + ); + expect(text(receiver)).toEqual(text(reference)); + expect(text(receiver).join("\n")).toContain("界é🙂"); + expect(text(receiver).join("\n")).toContain("VIEW-READY"); + expect(receiver.buffer.active.type).toBe(alternate ? "alternate" : "normal"); + expect({ row: receiver.buffer.active.cursorY, col: receiver.buffer.active.cursorX }).toEqual({ + row: reference.buffer.active.cursorY, + col: reference.buffer.active.cursorX, + }); + expect(receiver.modes).toEqual(reference.modes); + const expectedModes = new TerminalInputModeTracker(); + expectedModes.feed(bootstrap.metadata.replayPreamble + committed); + const actualModes = new TerminalInputModeTracker(); + actualModes.feed(new TextDecoder().decode(replay.data) + replay.metadata.replayPreamble); + expect(actualModes.getState()).toEqual(expectedModes.getState()); + } finally { + unsubscribe(); + receiver.dispose(); + reference.dispose(); + } + }, +); diff --git a/packages/server/src/terminal/terminal.ts b/packages/server/src/terminal/terminal.ts index b8cb7ee8cd..3294c11b93 100644 --- a/packages/server/src/terminal/terminal.ts +++ b/packages/server/src/terminal/terminal.ts @@ -25,7 +25,13 @@ import { findExecutable, findExecutableWithoutProbing, } from "../executable-resolution/executable-resolution.js"; -import type { TerminalCell, TerminalState } from "@getpaseo/protocol/messages"; +import { createTerminalSerializer } from "./terminal-serializer.js"; +import { + captureTerminalLines, + type CaptureTerminalLinesOptions, + type CaptureTerminalLinesResult, +} from "./terminal-capture.js"; +import type { TerminalReplayOptions, TerminalReplayMetadata } from "./terminal-replay.js"; 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"; @@ -66,26 +72,6 @@ export interface TerminalCommandFinishedInfo { exitCode: number | null; } -export interface TerminalStateSnapshot { - state: TerminalState; - revision: number; - // Input-mode replay preamble at snapshot time. Populated by the terminal - // worker so the daemon main loop doesn't have to re-derive it from output. - replayPreamble?: string; -} - -export interface TerminalStateSnapshotOptions { - scrollbackLines?: number; - // Include per-row soft-wrap flags (gridWrapped/scrollbackWrapped) so the client - // can reflow restored content on resize. Gated on a client capability, so old - // clients never receive the extra fields. - includeWrapFlags?: boolean; -} - -export interface TerminalSubscribeOptions { - initialSnapshot?: "state" | "ready"; -} - export type ClientMessage = | { type: "input"; data: string } | { type: "resize"; rows: number; cols: number } @@ -93,7 +79,6 @@ export type ClientMessage = export type ServerMessage = | { type: "output"; data: string; revision?: number } - | { type: "snapshot"; state: TerminalState; revision?: number } | { type: "snapshotReady"; revision?: number; replayPreamble?: string } | { type: "titleChange"; title?: string }; @@ -108,14 +93,13 @@ export interface TerminalSession { cwd: string; workspaceId: string; send(msg: ClientMessage): void; - subscribe(listener: (msg: ServerMessage) => void, options?: TerminalSubscribeOptions): () => void; + subscribe(listener: (msg: ServerMessage) => void): () => void; onExit(listener: (info: TerminalExitInfo) => void): () => void; onCommandFinished(listener: (info: TerminalCommandFinishedInfo) => void): () => void; onTitleChange(listener: (title?: string) => void): () => void; onActivityChange(listener: (transition: TerminalActivityTransition) => void): () => void; getSize(): { rows: number; cols: number }; - getState(): TerminalState; - getStateSnapshot(options?: TerminalStateSnapshotOptions): TerminalStateSnapshot; + hasOutput(): boolean; getReplayPreamble(): string; getTitle(): string | undefined; getActivity(): TerminalActivity | null; @@ -130,6 +114,11 @@ export interface TerminalSession { /** A session that owns its PTY in this process, so it knows the program's pid. */ export interface LocalTerminalSession extends TerminalSession { readonly pid: number; + serializeReplay(options?: TerminalReplayOptions): { + metadata: TerminalReplayMetadata; + data: Uint8Array; + }; + captureLines(options?: CaptureTerminalLinesOptions): CaptureTerminalLinesResult; } function parseCommandFinishedOsc(data: string): TerminalCommandFinishedInfo | null { @@ -545,179 +534,6 @@ function prependPathEntry(currentPath: string, entry: string): string { return [entry, ...entries].join(delimiter); } -function extractCell(terminal: TerminalType, row: number, col: number): TerminalCell { - const buffer = terminal.buffer.active; - const line = buffer.getLine(row); - if (!line) { - return { char: " ", fg: undefined, bg: undefined }; - } - - const cell = line.getCell(col); - if (!cell) { - return { char: " ", fg: undefined, bg: undefined }; - } - - // Color modes from xterm.js: 0=DEFAULT, 1=16 colors (ANSI), 2=256 colors, 3=RGB - // getFgColorMode() returns packed value with mode in upper byte (e.g. 0x01000000 for mode 1) - const fgModeRaw = cell.getFgColorMode(); - const bgModeRaw = cell.getBgColorMode(); - const fgMode = fgModeRaw >> 24; - const bgMode = bgModeRaw >> 24; - - // Only return color if not default (mode 0) - const fg = fgMode !== 0 ? cell.getFgColor() : undefined; - const bg = bgMode !== 0 ? cell.getBgColor() : undefined; - - return { - // Wide chars (CJK) span two cells; xterm returns "" for the zero-width - // continuation. Collapsing it to " " injects a real space on restore. - char: cell.getWidth() === 0 ? "" : cell.getChars() || " ", - fg, - bg, - fgMode: fgMode !== 0 ? fgMode : undefined, - bgMode: bgMode !== 0 ? bgMode : undefined, - bold: cell.isBold() !== 0, - italic: cell.isItalic() !== 0, - underline: cell.isUnderline() !== 0, - dim: cell.isDim() !== 0, - inverse: cell.isInverse() !== 0, - strikethrough: cell.isStrikethrough() !== 0, - }; -} - -function extractGrid(terminal: TerminalType): TerminalCell[][] { - const grid: TerminalCell[][] = []; - const buffer = terminal.buffer.active; - // Visible viewport starts at baseY - const baseY = buffer.baseY; - - for (let row = 0; row < terminal.rows; row++) { - const rowCells: TerminalCell[] = []; - for (let col = 0; col < terminal.cols; col++) { - rowCells.push(extractCell(terminal, baseY + row, col)); - } - grid.push(rowCells); - } - - return grid; -} - -function extractScrollback( - terminal: TerminalType, - options?: { scrollbackLines?: number }, -): TerminalCell[][] { - const scrollback: TerminalCell[][] = []; - const buffer = terminal.buffer.active; - // baseY is the first row of the visible viewport (0-indexed) - // Lines 0 to baseY-1 are in scrollback, lines baseY onwards are visible - const scrollbackLines = buffer.baseY; - const startRow = - typeof options?.scrollbackLines === "number" - ? Math.max(0, scrollbackLines - options.scrollbackLines) - : 0; - - for (let row = startRow; row < scrollbackLines; row++) { - const rowCells: TerminalCell[] = []; - const line = buffer.getLine(row); - for (let col = 0; col < terminal.cols; col++) { - if (line) { - const cell = line.getCell(col); - if (cell) { - const fgModeRaw = cell.getFgColorMode(); - const bgModeRaw = cell.getBgColorMode(); - const fgMode = fgModeRaw >> 24; - const bgMode = bgModeRaw >> 24; - const fg = fgMode !== 0 ? cell.getFgColor() : undefined; - const bg = bgMode !== 0 ? cell.getBgColor() : undefined; - rowCells.push({ - // Wide chars (CJK) span two cells; xterm returns "" for the zero-width - // continuation. Collapsing it to " " injects a real space on restore. - char: cell.getWidth() === 0 ? "" : cell.getChars() || " ", - fg, - bg, - fgMode: fgMode !== 0 ? fgMode : undefined, - bgMode: bgMode !== 0 ? bgMode : undefined, - bold: cell.isBold() !== 0, - italic: cell.isItalic() !== 0, - underline: cell.isUnderline() !== 0, - dim: cell.isDim() !== 0, - inverse: cell.isInverse() !== 0, - strikethrough: cell.isStrikethrough() !== 0, - }); - } else { - rowCells.push({ char: " ", fg: undefined, bg: undefined }); - } - } else { - rowCells.push({ char: " ", fg: undefined, bg: undefined }); - } - } - scrollback.push(rowCells); - } - - return scrollback; -} - -// xterm marks a line `isWrapped` when it is a continuation of the PREVIOUS line. -// The snapshot carries the inverse, tmux-style flag — "this row continues onto the -// next row" — so the client can rejoin and reflow logical lines. So row y's flag is -// whether line y+1 is a wrapped continuation. -function lineContinuesToNext(terminal: TerminalType, absoluteRow: number): boolean { - return terminal.buffer.active.getLine(absoluteRow + 1)?.isWrapped === true; -} - -function extractGridWrapped(terminal: TerminalType): boolean[] { - const baseY = terminal.buffer.active.baseY; - const wrapped: boolean[] = []; - for (let row = 0; row < terminal.rows; row++) { - wrapped.push(lineContinuesToNext(terminal, baseY + row)); - } - return wrapped; -} - -function extractScrollbackWrapped( - terminal: TerminalType, - options?: { scrollbackLines?: number }, -): boolean[] { - const scrollbackLines = terminal.buffer.active.baseY; - const startRow = - typeof options?.scrollbackLines === "number" - ? Math.max(0, scrollbackLines - options.scrollbackLines) - : 0; - const wrapped: boolean[] = []; - for (let row = startRow; row < scrollbackLines; row++) { - wrapped.push(lineContinuesToNext(terminal, row)); - } - return wrapped; -} - -function extractCursorState(terminal: TerminalType): TerminalState["cursor"] { - const coreService = (terminal as unknown as { _core?: { coreService?: Record } }) - ._core?.coreService as - | { - decPrivateModes?: { cursorStyle?: unknown; cursorBlink?: unknown }; - isCursorHidden?: unknown; - } - | undefined; - const cursorStyle = coreService?.decPrivateModes?.cursorStyle; - const normalizedCursorStyle = - cursorStyle === "block" || cursorStyle === "underline" || cursorStyle === "bar" - ? cursorStyle - : undefined; - const cursorBlink = - typeof coreService?.decPrivateModes?.cursorBlink === "boolean" - ? coreService.decPrivateModes.cursorBlink - : undefined; - const hidden = Boolean(coreService?.isCursorHidden); - - return { - row: terminal.buffer.active.cursorY, - col: terminal.buffer.active.cursorX, - ...(hidden ? { hidden: true } : {}), - ...(normalizedCursorStyle ? { style: normalizedCursorStyle } : {}), - ...(typeof cursorBlink === "boolean" ? { blink: cursorBlink } : {}), - }; -} - function normalizeProcessToken(token: string): string { if (token.length === 0) { return token; @@ -984,10 +800,12 @@ export async function createTerminal( const terminal = new Terminal({ rows, cols, - scrollback: 1000, + scrollback: 10000, allowProposedApi: true, }); + const serialize = createTerminalSerializer(terminal); + ensureNodePtySpawnHelperExecutableForCurrentPlatform(); // Create PTY @@ -1224,6 +1042,10 @@ export async function createTerminal( if (disposed || killed) { return; } + // Commit protocol modes with the parsed bytes, so a replay cut cannot + // combine an older xterm buffer with modes from queued future output. + const inputModeUpdate = inputModeTracker.feed(data); + for (const response of inputModeUpdate.responses) ptyProcess.write(response); stateRevision += 1; for (const listener of listeners) { listener({ type: "output", data, revision: stateRevision }); @@ -1234,10 +1056,6 @@ export async function createTerminal( // Pipe PTY output to terminal emulator ptyProcess.onData((data) => { if (killed) return; - const inputModeUpdate = inputModeTracker.feed(data); - for (const response of inputModeUpdate.responses) { - ptyProcess.write(response); - } recentOutputChunks.push(data); recentOutputLength += data.length; // Drop whole leading chunks while the rest still covers the char limit, so @@ -1295,32 +1113,15 @@ export async function createTerminal( } } - function getState(snapshotOptions?: TerminalStateSnapshotOptions): TerminalState { - return { + function serializeReplay(replayOptions?: TerminalReplayOptions) { + const metadata = { rows: terminal.rows, cols: terminal.cols, - grid: extractGrid(terminal), - scrollback: extractScrollback(terminal, { - scrollbackLines: snapshotOptions?.scrollbackLines, - }), - cursor: extractCursorState(terminal), - ...(title ? { title } : {}), - ...(snapshotOptions?.includeWrapFlags - ? { - gridWrapped: extractGridWrapped(terminal), - scrollbackWrapped: extractScrollbackWrapped(terminal, { - scrollbackLines: snapshotOptions?.scrollbackLines, - }), - } - : {}), - }; - } - - function getStateSnapshot(snapshotOptions?: TerminalStateSnapshotOptions): TerminalStateSnapshot { - return { - state: getState(snapshotOptions), + retainedHistoryLines: terminal.buffer.normal.baseY, revision: stateRevision, + replayPreamble: getReplayPreamble(), }; + return { metadata, data: serialize(replayOptions) }; } function getSize(): { rows: number; cols: number } { @@ -1386,14 +1187,10 @@ export async function createTerminal( } } - function subscribe( - listener: (msg: ServerMessage) => void, - subscribeOptions?: TerminalSubscribeOptions, - ): () => void { + function subscribe(listener: (msg: ServerMessage) => void): () => void { let active = true; let snapshotDelivered = false; const queuedMessages: ServerMessage[] = []; - const initialSnapshot = subscribeOptions?.initialSnapshot ?? "state"; const subscriptionListener = (msg: ServerMessage): void => { if (!active) { return; @@ -1410,17 +1207,11 @@ export async function createTerminal( terminal.write("", () => { if (!disposed && active && listeners.has(subscriptionListener)) { snapshotDelivered = true; - if (initialSnapshot === "ready") { - // Carry the input-mode preamble so the snapshot-less "ready" path - // (live restore) can replay it without a separate state fetch. - listener({ - type: "snapshotReady", - revision: stateRevision, - replayPreamble: getReplayPreamble(), - }); - } else { - listener({ type: "snapshot", ...getStateSnapshot() }); - } + listener({ + type: "snapshotReady", + revision: stateRevision, + replayPreamble: getReplayPreamble(), + }); for (const message of queuedMessages.splice(0)) { listener(message); } @@ -1604,8 +1395,15 @@ export async function createTerminal( onTitleChange, onActivityChange, getSize, - getState, - getStateSnapshot, + serializeReplay, + captureLines: (captureOptions) => captureTerminalLines(terminal.buffer.active, captureOptions), + hasOutput: () => { + const buffer = terminal.buffer.active; + for (let row = 0; row < buffer.length; row++) { + if (buffer.getLine(row)?.translateToString(true).trim()) return true; + } + return false; + }, getReplayPreamble, getTitle, getActivity, diff --git a/packages/server/src/terminal/test-utils/terminal-state.ts b/packages/server/src/terminal/test-utils/terminal-state.ts new file mode 100644 index 0000000000..0fd4777b4c --- /dev/null +++ b/packages/server/src/terminal/test-utils/terminal-state.ts @@ -0,0 +1,51 @@ +import xterm from "@xterm/headless"; +import type { TerminalState } from "@getpaseo/protocol/messages"; +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({ + rows: metadata.rows, + cols: metadata.cols, + scrollback: 10000, + allowProposedApi: true, + }); + try { + await new Promise((resolve) => terminal.write(data, resolve)); + const buffer = terminal.buffer.active; + const rows = Array.from({ length: buffer.length }, (_, row) => + Array.from({ length: terminal.cols }, (_cell, col) => { + const cell = buffer.getLine(row)!.getCell(col)!; + const fgMode = cell.getFgColorMode() >> 24; + const bgMode = cell.getBgColorMode() >> 24; + return { + char: cell.getWidth() === 0 ? "" : cell.getChars() || " ", + fg: fgMode ? cell.getFgColor() : undefined, + bg: bgMode ? cell.getBgColor() : undefined, + fgMode: fgMode || undefined, + bgMode: bgMode || undefined, + bold: !!cell.isBold(), + italic: !!cell.isItalic(), + underline: !!cell.isUnderline(), + dim: !!cell.isDim(), + inverse: !!cell.isInverse(), + strikethrough: !!cell.isStrikethrough(), + }; + }), + ); + const wrapped = rows.map((_, i) => buffer.getLine(i + 1)?.isWrapped === true); + return { + rows: terminal.rows, + cols: terminal.cols, + grid: rows.slice(buffer.baseY), + scrollback: rows.slice(0, buffer.baseY), + gridWrapped: wrapped.slice(buffer.baseY), + scrollbackWrapped: wrapped.slice(0, buffer.baseY), + cursor: { row: buffer.cursorY, col: buffer.cursorX }, + ...(session.getTitle() ? { title: session.getTitle() } : {}), + }; + } finally { + terminal.dispose(); + } +} diff --git a/packages/server/src/terminal/worker-terminal-manager.test.ts b/packages/server/src/terminal/worker-terminal-manager.test.ts index d953d6cb69..ad066327a2 100644 --- a/packages/server/src/terminal/worker-terminal-manager.test.ts +++ b/packages/server/src/terminal/worker-terminal-manager.test.ts @@ -1,3 +1,17 @@ +import { TerminalInputModeTracker } from "@getpaseo/protocol/terminal-input-mode"; +import { encodeTerminalKeyInput } from "@getpaseo/protocol/terminal-key-input"; +import type { SubscribeTerminalRequest } from "@getpaseo/protocol/messages"; +import { TerminalSessionController } from "./terminal-session-controller.js"; +import { SessionDelivery } from "../server/session/owned-subscriptions/index.js"; +import { + decodeTerminalStreamFrame, + TerminalStreamOpcode, + type TerminalStreamFrame, +} from "@getpaseo/protocol/binary-frames/index"; +import type { SessionOutboundMessage } from "../server/messages.js"; +import { createConnection } from "node:net"; +import { attachFramedSocket } from "./terminal-worker-frames.js"; +import xterm from "@xterm/headless"; import { afterEach, expect, it } from "vitest"; import { isPlatform } from "../test-utils/platform.js"; import { EventEmitter } from "node:events"; @@ -31,6 +45,7 @@ import type { TerminalState } from "@getpaseo/protocol/messages"; import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity"; import type { TerminalWorkerRequest, + WorkerTerminalState, TerminalWorkerToParentMessage, } from "./terminal-worker-protocol.js"; import { createTerminalWorkerProcessInspector } from "./terminal-worker-processes.js"; @@ -77,8 +92,35 @@ async function waitForCondition( throw new Error(`Timed out after ${timeoutMs}ms waiting for condition`); } -function getVisibleText(session: TerminalSession): string { - return getVisibleTextFromState(session.getState()); +async function readReplay(manager: DaemonTerminalManager, id: string) { + const chunks: Uint8Array[] = []; + const metadata = await manager.replayTerminal(id, undefined, (event) => { + if (event.type === "data") chunks.push(event.data); + }); + if (!metadata) return null; + const terminal = new xterm.Terminal({ + rows: metadata.rows, + cols: metadata.cols, + scrollback: 1000, + allowProposedApi: true, + }); + await new Promise((resolve) => terminal.write(Buffer.concat(chunks), resolve)); + const buffer = terminal.buffer.active; + const rows = Array.from({ length: buffer.length }, (_, i) => { + const line = buffer.getLine(i)!; + return Array.from({ length: metadata.cols }, (_cell, col) => ({ + char: line.getCell(col)?.getChars() || " ", + })); + }); + const state: TerminalState = { + rows: metadata.rows, + cols: metadata.cols, + scrollback: rows.slice(0, buffer.baseY), + grid: rows.slice(buffer.baseY), + cursor: { row: buffer.cursorY, col: buffer.cursorX }, + }; + terminal.dispose(); + return { state, revision: metadata.revision }; } function rowToText(row: TerminalRow): string { @@ -107,15 +149,8 @@ function readRenderedTokenIndexesFromState(state: TerminalState): number[] { return indexes; } -function createTerminalState(): TerminalState { - const blankCell = { char: " " }; - return { - rows: 1, - cols: 1, - grid: [[blankCell]], - scrollback: [], - cursor: { row: 0, col: 0 }, - }; +function createTerminalState(): WorkerTerminalState { + return { rows: 1, cols: 1, hasOutput: false }; } class FakeTerminalWorker extends EventEmitter { @@ -144,7 +179,11 @@ const temporaryDirs: string[] = []; // A real detached worker in a throwaway home; afterEach closes its terminals and the worker. async function startRealWorkerManager( - options: { getTerminalActivityUrl?: () => string | null; paseoHome?: string } = {}, + options: { + getTerminalActivityUrl?: () => string | null; + requestTimeoutMs?: number; + paseoHome?: string; + } = {}, ): Promise { const { paseoHome = createTemporaryHome(), ...rest } = options; return startWorkerTerminalManager({ @@ -277,7 +316,7 @@ it("reconnects a live worker without replacing terminal sessions or processes", await waitForCondition(() => !competitor.connected, 10000); await waitForCondition(async () => { try { - return (await manager!.getTerminalState(terminal.id)) !== null; + return (await readReplay(manager!, terminal.id)) !== null; } catch { return false; } @@ -343,26 +382,36 @@ it.each(["current", "draining"] as const)( "keeps the peer usable when the %s worker dies", async (target) => { const paseoHome = createTemporaryHome(); - const old = await leaveWorkerWithTerminal(paseoHome, 0); + const seeds = [ + await launchDetachedWorkerWithTerminal(paseoHome), + await launchDetachedWorkerWithTerminal(paseoHome), + ]; + // Exercise an existing seed adopted as current; launch age does not imply draining ownership. + const [firstAdopted] = await workerRecords(paseoHome); + const seed = seeds.find((candidate) => candidate.workerPid === firstAdopted!.pid)!; manager = await startRealWorkerManager({ paseoHome }); const added = await manager.createTerminal({ workspaceId: "ws-peer", - cwd: old.cwd, + cwd: seed.cwd, ...nodeTerminalCommand("setInterval(() => {}, 1000)"), }); - const current = (await workerRecords(paseoHome)).find( - (record) => record.pid !== old.workerPid, + const records = await workerRecords(paseoHome); + const current = records.find((record) => + record.terminals.some((terminal) => terminal.id === added.id), )!; - const deadId = target === "current" ? added.id : old.terminalId; - const healthyId = target === "current" ? old.terminalId : added.id; + const draining = records.find((record) => record.pid !== current.pid)!; + const drainingId = draining.terminals[0]!.id; + const deadId = target === "current" ? added.id : drainingId; + const healthyId = target === "current" ? drainingId : added.id; const healthy = manager.getTerminal(healthyId); - process.kill(target === "current" ? current.pid : old.workerPid, "SIGKILL"); + expect(healthy).toBeDefined(); + process.kill(target === "current" ? current.pid : draining.pid, "SIGKILL"); await waitForCondition(() => !manager!.getTerminal(deadId), 10000); expect(manager.getTerminal(healthyId)).toBe(healthy); - expect(await manager.getTerminalState(healthyId)).not.toBeNull(); + expect(await readReplay(manager, healthyId)).not.toBeNull(); await manager.createTerminal({ workspaceId: "ws-peer", - cwd: old.cwd, + cwd: seed.cwd, ...nodeTerminalCommand("setInterval(() => {}, 1000)"), }); expect((await workerRecords(paseoHome)).length).toBeLessThanOrEqual(2); @@ -387,31 +436,26 @@ it("creates a terminal through the worker and streams output", async () => { }), ); const messages: string[] = []; - let snapshots = 0; + let readinessEvents = 0; const unsubscribe = session.subscribe((message) => { if (message.type === "output") { messages.push(message.data); } - if (message.type === "snapshot") { - snapshots += 1; + if (message.type === "snapshotReady") { + readinessEvents += 1; } }); await new Promise((resolve) => setTimeout(resolve, 100)); - const snapshotsBeforeOutput = snapshots; + expect(readinessEvents).toBe(1); session.send({ type: "input", data: "hello\r" }); - await waitForCondition( - () => - messages.join("").includes("worker-output:hello") || - getVisibleText(session).includes("worker-output:hello"), - 10000, - ); + await waitForCondition(() => messages.join("").includes("worker-output:hello"), 10000); await new Promise((resolve) => setTimeout(resolve, 100)); unsubscribe(); - expect(messages.join("") + getVisibleText(session)).toContain("worker-output:hello"); - expect(snapshots).toBe(snapshotsBeforeOutput); + expect(messages.join("")).toContain("worker-output:hello"); + expect(readinessEvents).toBe(1); }); it("delivers rapid small writes complete and in order through worker coalescing", async () => { @@ -447,19 +491,19 @@ it("delivers rapid small writes complete and in order through worker coalescing" session.subscribe((message) => { if (message.type === "output") { events.push({ type: "output", data: message.data }); - } else if (message.type === "snapshot" || message.type === "snapshotReady") { + } else if (message.type === "snapshotReady") { events.push({ type: message.type }); } }); - // Let the snapshot subscription settle, then trigger the burst. + // Let readiness settle, then trigger the burst. await new Promise((resolve) => setTimeout(resolve, 100)); writeFileSync(burstGatePath, "go"); const expected = Array.from({ length: 500 }, (_, index) => index); let received: number[] = []; await waitForCondition(async () => { - const snapshot = await manager!.getTerminalState(session.id); + const snapshot = await readReplay(manager!, session.id); received = snapshot ? readRenderedTokenIndexesFromState(snapshot.state) : []; return expected.every((value, index) => received[index] === value); }, 10000); @@ -468,14 +512,13 @@ it("delivers rapid small writes complete and in order through worker coalescing" await waitForCondition(() => events.some((event) => event.type === "output"), 10000); - // No snapshot may land after output it should have preceded: every snapshot - // event must come before the first output event. + // Readiness precedes the ordered live stream. const firstOutputIndex = events.findIndex((event) => event.type === "output"); expect(firstOutputIndex).toBeGreaterThanOrEqual(0); - const snapshotAfterOutput = events + const readinessAfterOutput = events .slice(firstOutputIndex + 1) - .find((event) => event.type === "snapshot" || event.type === "snapshotReady"); - expect(snapshotAfterOutput).toBeUndefined(); + .find((event) => event.type === "snapshotReady"); + expect(readinessAfterOutput).toBeUndefined(); }); it("pulls fresh terminal state from the worker authority", async () => { @@ -495,7 +538,7 @@ it("pulls fresh terminal state from the worker authority", async () => { let visibleText = ""; await waitForCondition(async () => { - const snapshot = await manager!.getTerminalState(session.id); + const snapshot = await readReplay(manager!, session.id); visibleText = snapshot ? getVisibleTextFromState(snapshot.state) : ""; return visibleText.includes("worker-state-ready"); }, 10000); @@ -508,7 +551,7 @@ it("pulls fresh terminal state from the worker authority", async () => { // preamble-caching contract is verified on Linux/macOS; the daemon's input-mode // handling runs identically on every platform once the escape is observed. it.skipIf(isPlatform("win32"))( - "caches the input-mode replay preamble from the worker after getTerminalState", + "caches input modes from worker output independently of replay capture", async () => { const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-preamble-")); temporaryDirs.push(cwd); @@ -526,11 +569,10 @@ it.skipIf(isPlatform("win32"))( }), ); - await waitForCondition(async () => { - const snapshot = await manager!.getTerminalState(session.id); - return snapshot !== null && session.getReplayPreamble() === "\x1b[=1;1u"; - }, 10000); - + await waitForCondition(() => session.getReplayPreamble() === "\x1b[=1;1u", 10000); + // Initial shared modes must be ready without a replay response repairing the cache. + expect(session.getReplayPreamble()).toBe("\x1b[=1;1u"); + expect(await readReplay(manager!, session.id)).not.toBeNull(); expect(session.getReplayPreamble()).toBe("\x1b[=1;1u"); }, ); @@ -552,7 +594,7 @@ it("refreshes cached terminal title after worker title changes", async () => { await waitForCondition(() => session.getTitle() === "Build Output", 10000); - expect(session.getState().title).toBe("Build Output"); + expect(session.getTitle()).toBe("Build Output"); }); it("refreshes cached terminal size after worker resize", async () => { @@ -562,12 +604,12 @@ it("refreshes cached terminal size after worker resize", async () => { const session = trackTerminal(await manager.createTerminal({ cwd, workspaceId: "ws-test" })); session.send({ type: "resize", rows: 10, cols: 40 }); - const snapshot = await manager.getTerminalState(session.id); + const snapshot = await readReplay(manager, session.id); expect(snapshot?.state.rows).toBe(10); expect(snapshot?.state.cols).toBe(40); - expect(session.getState().rows).toBe(10); - expect(session.getState().cols).toBe(40); + expect(session.getSize().rows).toBe(10); + expect(session.getSize().cols).toBe(40); }); it("captures terminal output from the worker authority", async () => { @@ -817,6 +859,7 @@ it("never adopts a live worker's terminals after a kill cleanup it could not fin // the shim; the PATH is restored as soon as it is up. async function startWorkerWithSlowProcessTable( delaySeconds: number, + options: { paseoHome?: string; requestTimeoutMs?: number } = {}, ): Promise { const realPs = (process.env.PATH ?? "") .split(delimiter) @@ -830,7 +873,7 @@ async function startWorkerWithSlowProcessTable( const originalPath = process.env.PATH; process.env.PATH = `${shimDir}${delimiter}${originalPath ?? ""}`; try { - return await startRealWorkerManager(); + return await startRealWorkerManager(options); } finally { process.env.PATH = originalPath; } @@ -854,7 +897,7 @@ async function expectCompletedShortCommand( expect(session.getExitInfo()?.exitCode).toBe(0); expect(terminalManager.getTerminal(session.id)).toBeUndefined(); expect(await terminalManager.getTerminals(cwd)).toEqual([]); - expect(await terminalManager.getTerminalState(session.id)).toBeNull(); + expect(await readReplay(terminalManager, session.id)).toBeNull(); } it.skipIf(isPlatform("win32"))( @@ -1531,63 +1574,34 @@ async function leaveWorkerWithTerminal( }; } -it("drains a worker one protocol older: it keeps its terminal, new ones open in a second worker, and it exits after its last", async () => { - const paseoHome = createTemporaryHome(); - const old = await leaveWorkerWithTerminal(paseoHome, 0); +it.each([1, 4])( + "hard cuts unreleased worker protocol %s, closing its terminals", + async (version) => { + const paseoHome = createTemporaryHome(); + const old = await leaveWorkerWithTerminal(paseoHome, version); - manager = await startRealWorkerManager({ paseoHome }); + manager = await startRealWorkerManager({ paseoHome }); - expect(manager.getTerminal(old.terminalId)).toBeDefined(); - const workers = await workerRecords(paseoHome); - expect(workers).toHaveLength(2); - const current = workers.find((record) => record.pid !== old.workerPid)!; - expect(current.protocolVersion).toBe(1); - const added = await manager.createTerminal({ - workspaceId: "ws-protocol", - cwd: old.cwd, - ...nodeTerminalCommand("setInterval(() => {}, 1000);"), - }); - trackTerminal(added); - const currentTerminals = ( - await readTerminalWorkerRecord(join(terminalWorkerDirectory(paseoHome), `${current.pid}.json`)) - ).terminals.map((entry) => entry.id); - expect(currentTerminals).toEqual([added.id]); - expect((await manager.getTerminals(old.cwd)).map((session) => session.id).sort()).toEqual( - [old.terminalId, added.id].sort(), - ); - - await manager.killTerminalAndWait(old.terminalId); - - await waitForCondition(() => !isProcessAlive(old.workerPid), 10000); - expect((await workerRecords(paseoHome)).map((record) => record.pid)).toEqual([current.pid]); - expect(manager.getTerminal(added.id)).toBeDefined(); -}); - -it("shuts down a worker two protocol versions old, closing its terminals", async () => { - const paseoHome = createTemporaryHome(); - const old = await leaveWorkerWithTerminal(paseoHome, -1); - - manager = await startRealWorkerManager({ paseoHome }); - - await waitForCondition( - () => !isProcessAlive(old.workerPid) && !isProcessAlive(old.programPid), - 10000, - ); - expect(manager.getTerminal(old.terminalId)).toBeUndefined(); - const workers = await workerRecords(paseoHome); - expect(workers).toHaveLength(1); - expect(workers[0]!.protocolVersion).toBe(1); -}); + await waitForCondition( + () => !isProcessAlive(old.workerPid) && !isProcessAlive(old.programPid), + 10000, + ); + expect(manager.getTerminal(old.terminalId)).toBeUndefined(); + const workers = await workerRecords(paseoHome); + expect(workers).toHaveLength(1); + expect(workers[0]!.protocolVersion).toBe(5); + }, +); it("leaves a worker from a newer Paseo running, reports it incompatible, and closes it on a kill stop", async () => { const paseoHome = createTemporaryHome(); - const newer = await leaveWorkerWithTerminal(paseoHome, 2); + const newer = await leaveWorkerWithTerminal(paseoHome, 6); manager = await startRealWorkerManager({ paseoHome }); const unavailable = manager.getUnavailableError?.(); expect(unavailable).toBeInstanceOf(TerminalWorkerIncompatibleError); - expect(unavailable?.message).toContain("worker protocol 2"); + expect(unavailable?.message).toContain("worker protocol 6"); await expect(manager.getTerminals(newer.cwd)).rejects.toBeInstanceOf( TerminalWorkerIncompatibleError, ); @@ -1694,45 +1708,892 @@ it.skipIf(isPlatform("win32"))( }, ); -it("opens new terminals in a new worker once one of two draining workers exits", async () => { - const paseoHome = createTemporaryHome(); - const first = await launchDetachedWorkerWithTerminal(paseoHome, 0); - const second = await launchDetachedWorkerWithTerminal(paseoHome, 0); - - manager = await startRealWorkerManager({ paseoHome }); - - expect(await liveWorkerPids(paseoHome)).toEqual([first.workerPid, second.workerPid].sort()); - await expect( - manager.createTerminal({ workspaceId: "ws-detached", cwd: first.cwd }), - ).rejects.toBeInstanceOf(NoTerminalWorkerAvailableError); - - await manager.killTerminalAndWait(first.terminalId); - await waitForCondition(() => !isProcessAlive(first.workerPid), 10000); - - // Concurrent creates start one worker between them. - const created = await Promise.all( - [0, 1].map(() => - manager!.createTerminal({ - workspaceId: "ws-detached", - cwd: first.cwd, - ...nodeTerminalCommand("setInterval(() => {}, 1000);"), - }), - ), +it("replays bounded ANSI chunks independently of live output and reconstructs the revision cut", async () => { + const cwd = createTemporaryHome(); + manager = await startRealWorkerManager(); + const session = trackTerminal( + await manager.createTerminal({ + cwd, + workspaceId: "ws-replay", + rows: 8, + cols: 120, + ...nodeTerminalCommand(` + for (let i = 0; i < 800; i++) process.stdout.write("\\x1b[31m" + String(i).padStart(4, "0") + ":" + "x".repeat(100) + "\\x1b[0m\\r\\n"); + require("node:readline").createInterface({ input: process.stdin }).on("line", line => process.stdout.write(line === "before" ? "PRE-CUT\\r\\n" : "LIVE-AFTER-REPLAY\\r\\n")); + setInterval(() => {}, 1000); + `), + }), ); - created.forEach(trackTerminal); - const workers = await workerRecords(paseoHome); - expect(workers.map((record) => record.pid).sort()).toEqual(await liveWorkerPids(paseoHome)); - expect(workers).toHaveLength(2); - const started = workers.find((record) => record.pid !== second.workerPid)!; - expect(started.protocolVersion).toBe(1); - expect( - ( - await readTerminalWorkerRecord( - join(terminalWorkerDirectory(paseoHome), `${started.pid}.json`), - ) - ).terminals - .map((entry) => entry.id) - .sort(), - ).toEqual(created.map((session) => session.id).sort()); - expect(manager.getTerminal(second.terminalId)).toBeDefined(); + await waitForCondition( + async () => + (await manager!.captureTerminal(session.id)).lines.some((line) => line.startsWith("0799:")), + 10000, + ); + const events: string[] = []; + const replayEvents: string[] = []; + const liveOutputs: Array<{ data: string; revision?: number }> = []; + const chunks: Uint8Array[] = []; + let live = ""; + const unsubscribe = session.subscribe((event) => { + if (event.type === "output") { + events.push("live"); + liveOutputs.push(event); + live += event.data; + } + }); + session.send({ type: "input", data: "before\r" }); + await waitForCondition(() => live.includes("PRE-CUT"), 10000); + const expected = await manager.captureTerminal(session.id); + const metadata = await manager.replayTerminal(session.id, undefined, (event) => { + events.push(event.type); + replayEvents.push(event.type); + if (event.type === "begin") session.send({ type: "input", data: "go\r" }); + if (event.type === "data") chunks.push(event.data); + }); + await waitForCondition(() => live.includes("LIVE-AFTER-REPLAY"), 10000); + unsubscribe(); + // Existing live observers are independent of replay. Pre-cut output may also be + // flushed by capture before begin; only the replay events have begin/data/end order. + expect(events[0]).toBe("live"); + expect(replayEvents[0]).toBe("begin"); + expect(replayEvents.at(-1)).toBe("end"); + expect(replayEvents.slice(1, -1)).toEqual(chunks.map(() => "data")); + const beforeCut = liveOutputs.filter((output) => output.data.includes("PRE-CUT")); + const afterCut = liveOutputs.filter((output) => output.data.includes("LIVE-AFTER-REPLAY")); + expect(beforeCut).toHaveLength(1); + expect(afterCut).toHaveLength(1); + expect(beforeCut[0]!.revision).toBeDefined(); + expect(beforeCut[0]!.revision).toBeLessThanOrEqual(metadata!.revision); + expect(afterCut[0]!.revision).toBeGreaterThan(metadata!.revision); + expect(live.match(/LIVE-AFTER-REPLAY/g)).toHaveLength(1); + expect(chunks.length).toBeGreaterThan(1); + expect(chunks.every((chunk) => chunk.byteLength <= 64 * 1024)).toBe(true); + expect(metadata?.rows).toBe(8); + expect(metadata?.cols).toBe(120); + const terminal = new xterm.Terminal({ + rows: 8, + cols: 120, + scrollback: 1000, + allowProposedApi: true, + }); + try { + await new Promise((resolve) => terminal.write(Buffer.concat(chunks), resolve)); + const buffer = terminal.buffer.active; + expect( + Array.from({ length: buffer.length }, (_, i) => buffer.getLine(i)!.translateToString(true)), + ).toEqual(expected.lines); + expect(expected.lines.filter((line) => line.includes("PRE-CUT"))).toHaveLength(1); + expect(expected.lines.some((line) => line.includes("LIVE-AFTER-REPLAY"))).toBe(false); + expect(buffer.getLine(0)!.getCell(0)!.getFgColor()).toBe(1); + } finally { + terminal.dispose(); + } }); + +it("completes a replay larger than the socket budget while a sibling stays usable", async () => { + const paseoHome = createTemporaryHome(); + const overflowProgress = join(paseoHome, "overflow-progress"); + manager = await startRealWorkerManager({ paseoHome }); + const terminal = trackTerminal( + await manager.createTerminal({ + cwd: paseoHome, + workspaceId: "ws-wide", + rows: 4, + cols: 1024, + ...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"); + 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); + `), + }), + ); + const siblingProgress = join(paseoHome, "sibling-progress"); + const sibling = trackTerminal( + await manager.createTerminal({ + cwd: paseoHome, + workspaceId: "ws-wide", + ...nodeTerminalCommand(` + let count = 0; + process.stdin.on("data", () => { + count++; + process.stdout.write((count > 1 ? "z".repeat(1024 * 1024) : "") + "SIBLING-ALIVE\\r\\n", () => require("node:fs").writeFileSync(${JSON.stringify(siblingProgress)}, String(count))); + }); + setInterval(() => {}, 1000); + `), + }), + ); + await waitForCondition( + async () => + (await manager!.captureTerminal(terminal.id)).lines.some((line) => + line.includes("READY-WIDE"), + ), + 20000, + ); + const seen: string[] = []; + const unsubscribe = sibling.subscribe((event) => { + if (event.type === "output") seen.push(event.data); + }); + let bytes = 0; + const metadata = await manager.replayTerminal(terminal.id, undefined, (event) => { + if (event.type === "data") bytes += event.data.byteLength; + if (event.type === "begin") { + sibling.send({ type: "input", data: "go\r" }); + } + }); + expect(bytes).toBeGreaterThan(4 * 1024 * 1024); + expect(metadata?.cols).toBe(1024); + await waitForCondition(() => seen.join("").includes("SIBLING-ALIVE"), 10000); + unsubscribe(); + expect(manager.getTerminal(sibling.id)).toBe(sibling); + const truncated = await manager.replayTerminal(terminal.id, { scrollbackLines: 200 }, () => {}); + expect(metadata!.retainedHistoryLines).toBeGreaterThan(200); + expect(truncated!.retainedHistoryLines).toBe(metadata!.retainedHistoryLines); + const concurrent = await Promise.all( + [0, 1].map(() => manager!.replayTerminal(terminal.id, { scrollbackLines: 200 }, () => {})), + ); + expect(concurrent.map((replay) => replay!.retainedHistoryLines)).toEqual([ + metadata!.retainedHistoryLines, + metadata!.retainedHistoryLines, + ]); + await manager.detach(); + const [record] = await workerRecords(paseoHome); + const socket = createConnection(record!.socketPath); + let began = false; + let ended = false; + let completed = false; + let resumedBytes = 0; + let cancelledReplayBegan = false; + let existingLiveOutput = ""; + const replayOrder: string[] = []; + const framed = attachFramedSocket(socket, (value) => { + const message = value as TerminalWorkerToParentMessage | { type: "helloAck" }; + if ( + message.type === "terminalMessage" && + message.terminalId === terminal.id && + message.message.type === "output" + ) { + existingLiveOutput += message.message.data; + replayOrder.push("live"); + } + if (message.type === "helloAck") { + framed.send({ type: "replayTerminal", terminalId: terminal.id, requestId: "stalled-replay" }); + } else if (message.type === "terminalReplay") { + if (message.requestId === "cancelled-replay") { + if (message.event.type === "begin") { + cancelledReplayBegan = true; + socket.pause(); + } + return; + } + if (message.event.type === "begin") { + began = true; + socket.pause(); + } + if (message.event.type === "data") resumedBytes += message.event.data.byteLength; + if (message.event.type === "end") { + ended = true; + replayOrder.push("end"); + } + } else if (message.type === "response" && message.requestId === "stalled-replay") + completed = message.ok; + }); + try { + framed.send({ type: "hello", secret: record!.secret, protocolVersion: 5 }); + await waitForCondition(() => began, 10000); + framed.send({ + type: "send", + terminalId: terminal.id, + requestId: "same-terminal-input", + message: { type: "input", data: "small\r" }, + }); + framed.send({ + type: "send", + terminalId: sibling.id, + requestId: "sibling-input", + message: { type: "input", data: "again\r" }, + }); + await waitForCondition( + () => existsSync(siblingProgress) && readFileSync(siblingProgress, "utf8") === "2", + 10000, + ); + await new Promise((resolve) => setTimeout(resolve, 150)); + expect(completed).toBe(false); + expect(socket.destroyed).toBe(false); + socket.resume(); + await waitForCondition(() => completed, 10000); + expect(ended).toBe(true); + expect(resumedBytes).toBe(bytes); + await waitForCondition(() => existingLiveOutput.includes("SAME-TERMINAL-LIVE"), 10000); + expect(existingLiveOutput.match(/SAME-TERMINAL-LIVE/g)).toHaveLength(1); + expect(replayOrder.indexOf("live")).toBeLessThan(replayOrder.indexOf("end")); + framed.send({ type: "replayTerminal", terminalId: terminal.id, requestId: "cancelled-replay" }); + await waitForCondition(() => cancelledReplayBegan, 10000); + // Replace a controller while its large replay is socket-paced. The original + // request must never leak frames or completion onto the replacement connection. + manager = await startRealWorkerManager({ paseoHome }); + socket.resume(); + await waitForCondition(() => socket.destroyed, 10000); + expect((await readReplay(manager, terminal.id))?.state.cols).toBe(1024); + } finally { + socket.destroy(); + } + expect( + (await manager!.captureTerminal(terminal.id)).lines.some((line) => line.includes("READY-WIDE")), + ).toBe(true); + const exitingReplay = await manager.replayTerminal( + terminal.id, + { scrollbackLines: 200 }, + (event) => { + if (event.type === "begin") manager!.killTerminal(terminal.id); + }, + ); + expect(exitingReplay!.cols).toBe(1024); + await waitForCondition(() => manager!.getTerminal(terminal.id) === undefined, 10000); + expect(manager.getTerminal(sibling.id)?.id).toBe(sibling.id); +}, 60000); + +function terminalViewers( + terminalManager: DaemonTerminalManager, + terminalId: string, + chunked = true, + getClientBufferedAmount?: (source: object) => number | null, +) { + const frames: Array<{ source: object; frame: TerminalStreamFrame }> = []; + const messages: Array<{ source: object; message: SessionOutboundMessage }> = []; + let blockedSource: object | null = null; + let started = false; + let finish!: (error?: Error) => void; + const block = new Promise((resolve, reject) => { + finish = (error) => (error ? reject(error) : resolve()); + }); + const delivery = new SessionDelivery( + (source, message) => { + messages.push({ source, message }); + }, + async (source, bytes) => { + const frame = decodeTerminalStreamFrame(bytes)!; + frames.push({ source, frame }); + if ( + source === blockedSource && + frame.opcode === (chunked ? TerminalStreamOpcode.ReplayBegin : TerminalStreamOpcode.Restore) + ) { + started = true; + await block; + } + }, + ); + const controller = new TerminalSessionController({ + terminalManager, + emit: (message) => { + delivery.reply(message); + }, + hasBinaryChannel: () => true, + supportsChunkedReplay: () => chunked, + getClientBufferedAmount, + isPathWithinRoot: () => true, + sessionLogger: pino({ level: "silent" }), + }); + return { + frames, + messages, + delivery, + controller, + block(source: object) { + blockedSource = source; + }, + started: () => started, + finish, + async attach( + source: object, + requestId: string, + restore: NonNullable = { + mode: "visible-snapshot", + scrollbackLines: 1000, + }, + ) { + delivery.attach(source, true); + const request = { + type: "subscribe_terminal_request" as const, + terminalId, + requestId, + restore, + }; + await delivery.request(source, request, () => controller.dispatch(request, delivery)); + }, + text(source: object) { + return frames + .filter((row) => row.source === source && row.frame.opcode === TerminalStreamOpcode.Output) + .map((row) => Buffer.from(row.frame.payload).toString("utf8")) + .join(""); + }, + ended(source: object) { + return messages.filter( + (row) => row.source === source && row.message.type === "terminal_stream_exit", + ); + }, + replayed(source: object) { + return frames.some( + (row) => row.source === source && row.frame.opcode === TerminalStreamOpcode.ReplayEnd, + ); + }, + }; +} + +it("isolates a failed app transport while another viewer of the same terminal stays live", async () => { + manager = await startRealWorkerManager(); + const session = trackTerminal( + await manager.createTerminal({ + cwd: createTemporaryHome(), + workspaceId: "two-viewers", + ...nodeTerminalCommand( + 'process.stdout.write("READY\\r\\n"); process.stdin.on("data", data => process.stdout.write("ECHO:" + data)); setInterval(() => {}, 1000);', + ), + }), + ); + await waitForCondition( + async () => + (await manager!.captureTerminal(session.id)).lines.some((line) => line.includes("READY")), + 10000, + ); + const viewers = terminalViewers(manager, session.id); + const desktop = {}, + phone = {}, + fresh = {}; + try { + await viewers.attach(desktop, "desktop"); + await waitForCondition(() => viewers.replayed(desktop), 10000); + await expect( + manager.replayTerminal(session.id, undefined, () => { + throw new Error("Consumer cancelled replay"); + }), + ).rejects.toThrow("Consumer cancelled replay"); + viewers.block(phone); + await viewers.attach(phone, "phone"); + await waitForCondition(viewers.started, 10000); + session.send({ type: "input", data: "BEFORE-FAILURE\r" }); + await waitForCondition(() => viewers.text(desktop).includes("ECHO:BEFORE-FAILURE"), 1500); + expect(viewers.ended(desktop)).toHaveLength(0); + expect(viewers.ended(phone)).toHaveLength(0); + viewers.finish(new Error("Phone socket closed")); + await viewers.delivery.detach(phone); + expect(viewers.ended(desktop)).toHaveLength(0); + session.send({ type: "input", data: "AFTER-FAILURE\r" }); + await waitForCondition(() => viewers.text(desktop).includes("ECHO:AFTER-FAILURE"), 10000); + expect(viewers.ended(desktop)).toHaveLength(0); + await viewers.attach(fresh, "fresh"); + await waitForCondition(() => viewers.replayed(fresh), 10000); + expect(manager.getTerminal(session.id)).toBe(session); + } finally { + viewers.finish(); + await viewers.delivery.close(); + } +}, 20000); + +it("keeps concurrent viewer replays and live output ordered behind slow app delivery", async () => { + manager = await startRealWorkerManager(); + const session = trackTerminal( + await manager.createTerminal({ + cwd: createTemporaryHome(), + workspaceId: "concurrent-delivery", + ...nodeTerminalCommand( + 'process.stdout.write("READY\\r\\n"); process.stdin.on("data", data => process.stdout.write("ECHO:" + data)); setInterval(() => {}, 1000);', + ), + }), + ); + await waitForCondition( + async () => (await manager!.captureTerminal(session.id)).lines.join("").includes("READY"), + 10000, + ); + const viewers = terminalViewers(manager, session.id); + const desktop = {}, + phone = {}, + concurrent = {}; + try { + await viewers.attach(desktop, "desktop"); + await waitForCondition(() => viewers.replayed(desktop), 10000); + viewers.block(phone); + await viewers.attach(phone, "phone"); + await waitForCondition(viewers.started, 10000); + await viewers.attach(concurrent, "concurrent"); + await waitForCondition(() => viewers.replayed(concurrent) && viewers.replayed(phone), 10000); + session.send({ type: "input", data: "DURING-DELIVERY\r" }); + await waitForCondition( + () => + viewers.text(phone).includes("ECHO:DURING-DELIVERY") && + viewers.text(desktop).includes("ECHO:DURING-DELIVERY"), + 3000, + ); + expect(viewers.text(phone).match(/ECHO:DURING-DELIVERY/g)).toHaveLength(1); + const order = viewers.frames + .filter((row) => row.source === phone) + .map((row) => row.frame.opcode); + expect(order.indexOf(TerminalStreamOpcode.Output)).toBeGreaterThan( + order.indexOf(TerminalStreamOpcode.ReplayEnd), + ); + expect(viewers.ended(phone)).toHaveLength(0); + expect(viewers.ended(desktop)).toHaveLength(0); + } finally { + viewers.finish(); + await viewers.delivery.close(); + } +}, 20000); + +it.each([true, false])( + "queues replay independently of app delivery past the worker deadline (chunked=%s)", + async (chunked) => { + const paseoHome = createTemporaryHome(); + // Slow program identification belongs to setup, not the replay request deadline. + // The POSIX shim deterministically exceeds 800 ms; Windows keeps its real OS path. + manager = isPlatform("win32") + ? await startRealWorkerManager({ paseoHome }) + : await startWorkerWithSlowProcessTable(1, { paseoHome }); + const created = await manager.createTerminal({ + cwd: paseoHome, + workspaceId: "slow-delivery", + ...nodeTerminalCommand( + 'process.stdout.write("READY\\r\\n"); process.stdin.on("data", data => process.stdout.write("ECHO:" + data)); setInterval(() => {}, 1000);', + ), + }); + await waitForCondition( + async () => (await manager!.captureTerminal(created.id)).lines.join("").includes("READY"), + 10000, + ); + await manager.detach(); + manager = await startRealWorkerManager({ paseoHome, requestTimeoutMs: 800 }); + const session = manager.getTerminal(created.id); + if (!session) throw new Error("Replay fixture did not adopt its prepared terminal"); + trackTerminal(session); + const viewers = terminalViewers(manager, session.id, chunked); + const phone = {}; + viewers.block(phone); + try { + await viewers.attach(phone, "slow-phone"); + await waitForCondition(viewers.started, 10000); + await new Promise((resolve) => setTimeout(resolve, 1100)); + expect(viewers.ended(phone)).toHaveLength(0); + expect((await manager.captureTerminal(session.id)).lines.join("")).toContain("READY"); + session.send({ type: "input", data: "AFTER-DEADLINE\r" }); + await waitForCondition(() => viewers.text(phone).includes("ECHO:AFTER-DEADLINE"), 3000); + } finally { + viewers.finish(); + await viewers.delivery.close(); + } + }, + 20000, +); + +it.each(["replacement", "socket-overflow"] as const)( + "recovers an attached restore viewer after worker %s", + async (loss) => { + const paseoHome = createTemporaryHome(); + const drained = join(paseoHome, "burst-drained"); + manager = await startRealWorkerManager({ paseoHome }); + const session = trackTerminal( + await manager.createTerminal({ + cwd: paseoHome, + workspaceId: "attached-reconnect", + ...nodeTerminalCommand( + `process.stdout.write("RETAINED\\r\\n"); process.stdin.on("data", data => { + if (data.toString().includes("BURST")) process.stdout.write("x".repeat(12 * 1024 * 1024) + "\\r\\nRETAINED-AFTER-LOSS\\r\\n", () => require("node:fs").writeFileSync(${JSON.stringify(drained)}, "done")); + else process.stdout.write("ECHO:" + data); + }); setInterval(() => {}, 1000);`, + ), + }), + ); + await waitForCondition( + async () => (await manager!.captureTerminal(session.id)).lines.join("").includes("RETAINED"), + 10000, + ); + const viewers = terminalViewers(manager, session.id); + const desktop = {}; + try { + await viewers.attach(desktop, "desktop"); + await waitForCondition(() => viewers.replayed(desktop), 10000); + viewers.frames.length = 0; + const [record] = await workerRecords(paseoHome); + if (loss === "replacement") { + const competitor = await connectTerminalWorker({ + socketPath: record!.socketPath, + secret: record!.secret, + }); + await waitForCondition(() => !competitor.connected, 10000); + } else { + session.send({ type: "input", data: "BURST\r" }); + // Stall only the daemon event loop. The detached worker and PTY keep running, + // filling the bounded worker socket and forcing its disconnect/re-adoption path. + const stall = new Int32Array(new SharedArrayBuffer(4)); + const deadline = Date.now() + 10000; + while (!existsSync(drained) && Date.now() < deadline) Atomics.wait(stall, 0, 0, 25); + expect(existsSync(drained)).toBe(true); + } + await waitForCondition(() => viewers.replayed(desktop), 10000); + const replay = viewers.frames + .filter((row) => row.frame.opcode === TerminalStreamOpcode.ReplayData) + .map((row) => Buffer.from(row.frame.payload).toString("utf8")) + .join(""); + expect(replay).toContain("RETAINED"); + if (loss === "socket-overflow") expect(replay).toContain("RETAINED-AFTER-LOSS"); + expect((await workerRecords(paseoHome)).map((worker) => worker.pid)).toEqual([record!.pid]); + session.send({ type: "input", data: "RECOVERED\r" }); + await waitForCondition(() => viewers.text(desktop).includes("ECHO:RECOVERED"), 3000); + expect(viewers.ended(desktop)).toHaveLength(0); + expect(manager.getTerminal(session.id)).toBe(session); + } finally { + await viewers.delivery.close(); + } + }, + 30000, +); + +it.each([true, false])( + "keeps live modes after a replay captured enabled=%s while restoring its own modes first", + async (enabled) => { + const on = "\x1b[?1h\x1b[?2004h"; + const off = "\x1b[?1l\x1b[?2004l"; + const captured = enabled ? on : ""; + const latest = enabled ? "" : on; + const live = (enabled ? off : on) + "MODE-LIVE"; + const worker = new FakeTerminalWorker(); + manager = createWorkerTerminalManager({ worker, requestTimeoutMs: 1000 }); + worker.emitWorkerMessage({ + type: "terminalCreated", + terminal: { + id: "mode-terminal", + name: "Terminal", + cwd: "/tmp", + workspaceId: "modes", + activity: null, + }, + state: createTerminalState(), + }); + worker.emitWorkerMessage({ + type: "terminalMessage", + terminalId: "mode-terminal", + replayPreamble: captured, + message: { type: "snapshotReady", revision: 1 }, + }); + const session = manager.getTerminal("mode-terminal")!; + const viewers = terminalViewers(manager, session.id); + const restoring = {}, + following = {}; + try { + await viewers.attach(restoring, "restoring"); + await waitForCondition( + () => worker.sentMessages.some((message) => message.type === "replayTerminal"), + 1000, + ); + const request = worker.sentMessages.find((message) => message.type === "replayTerminal")!; + const metadata = { + rows: 30, + cols: 120, + retainedHistoryLines: 10, + revision: 1, + replayPreamble: captured, + }; + worker.emitWorkerMessage({ + type: "terminalReplay", + requestId: request.requestId, + event: { type: "begin", metadata }, + }); + worker.emitWorkerMessage({ + type: "terminalReplay", + requestId: request.requestId, + event: { type: "data", data: Buffer.from("CAPTURE-A") }, + }); + worker.emitWorkerMessage({ + type: "terminalMessage", + terminalId: session.id, + replayPreamble: latest, + message: { type: "output", data: live, revision: 2 }, + }); + expect(session.getReplayPreamble()).toBe(latest); + worker.emitWorkerMessage({ + type: "terminalReplay", + requestId: request.requestId, + event: { type: "data", data: Buffer.from("CAPTURE-B") }, + }); + worker.emitWorkerMessage({ + type: "terminalReplay", + requestId: request.requestId, + event: { type: "end" }, + }); + worker.emitWorkerMessage({ + type: "response", + requestId: request.requestId, + ok: true, + result: metadata, + }); + await waitForCondition(() => viewers.text(restoring).includes("MODE-LIVE"), 1000); + expect(session.getReplayPreamble()).toBe(latest); + expect(viewers.text(restoring)).toBe(captured + live); + const sequence = viewers.frames + .filter((row) => row.source === restoring) + .map((row) => row.frame.opcode); + expect(sequence.indexOf(TerminalStreamOpcode.Output)).toBeGreaterThan( + sequence.indexOf(TerminalStreamOpcode.ReplayEnd), + ); + const modes = new TerminalInputModeTracker(); + modes.feed(session.getReplayPreamble()); + expect(encodeTerminalKeyInput({ key: "ArrowUp" }, { inputMode: modes.getState() })).toBe( + enabled ? "\x1b[A" : "\x1bOA", + ); + expect(modes.getState().bracketedPaste).toBe(!enabled); + await viewers.attach(following, "live-following", { mode: "live" }); + await waitForCondition(() => latest === "" || viewers.text(following).length > 0, 1000); + expect(viewers.text(following)).toBe(latest); + expect( + worker.sentMessages.filter((message) => message.type === "replayTerminal"), + ).toHaveLength(1); + } finally { + await viewers.delivery.close(); + } + }, +); + +it.each([3000, 12050])( + "retains ten thousand history rows and restores exact demands after %s printed rows", + async (count) => { + manager = await startRealWorkerManager(); + const gate = createTemporaryHome(); + const releaseNewline = join(gate, "newline"); + const session = trackTerminal( + await manager.createTerminal({ + cwd: createTemporaryHome(), + workspaceId: "ten-thousand", + rows: 4, + cols: 80, + ...nodeTerminalCommand( + `const fs=require("node:fs"); + const watcher=fs.watch(${JSON.stringify(gate)},()=>{ + if(fs.existsSync(${JSON.stringify(releaseNewline)})){ + watcher.close();process.stdout.write("\\r\\nROWS-COMPLETE"); + } + }); + for(let i=0;i<${count};i++) process.stdout.write("\\x1b[31mrow-"+String(i).padStart(5,"0")+" 界é🙂\\x1b[0m"+(i===${count}-1?"":"\\r\\n")); + setInterval(()=>{},1000)`, + ), + }), + ); + await waitForCondition( + async () => + (await manager!.captureTerminal(session.id, { start: -5 })).lines + .join("") + .includes("row-" + String(count - 1).padStart(5, "0")), + 15000, + ); + // A visible final label can precede its trailing CRLF. Hold that transition + // deliberately, then observe content on the following row before capture. + writeFileSync(releaseNewline, "release"); + await waitForCondition( + async () => + (await manager!.captureTerminal(session.id, { start: -1 })).lines[0] === "ROWS-COMPLETE", + 15000, + ); + for (const history of [0, 200, 1000, 10000]) { + const chunks: Uint8Array[] = []; + const metadata = await manager.replayTerminal( + session.id, + { scrollbackLines: history }, + (event) => { + if (event.type === "data") chunks.push(event.data); + }, + ); + expect(metadata).toMatchObject({ + rows: 4, + cols: 80, + retainedHistoryLines: Math.min(10000, count - 3), + }); + expect(chunks.every((chunk) => chunk.byteLength <= 64 * 1024)).toBe(true); + const restored = new xterm.Terminal({ + rows: 4, + cols: 80, + scrollback: 10000, + allowProposedApi: true, + }); + try { + await new Promise((resolve) => restored.write(Buffer.concat(chunks), resolve)); + const buffer = restored.buffer.active; + const labels = Array.from({ length: buffer.length }, (_, i) => + buffer.getLine(i)!.translateToString(true), + ).filter((line) => line.includes("row-")); + expect({ row: buffer.cursorY, col: buffer.cursorX }).toEqual({ row: 3, col: 13 }); + expect(buffer.getLine(buffer.length - 1)!.translateToString(true)).toBe("ROWS-COMPLETE"); + expect(labels).toHaveLength(Math.min(count, history + 3)); + expect(labels[0]).toContain("row-" + String(count - labels.length).padStart(5, "0")); + expect(labels.at(-1)).toContain("row-" + String(count - 1).padStart(5, "0") + " 界é🙂"); + } finally { + restored.dispose(); + } + } + }, + 30000, +); + +it.each([false, true])( + "shrinks real ten-thousand-row pressure recovery and preserves reattach (chunked=%s)", + async (chunked) => { + manager = await startRealWorkerManager(); + const session = trackTerminal( + await manager.createTerminal({ + cwd: createTemporaryHome(), + workspaceId: "pressure-history", + rows: 4, + cols: 120, + ...nodeTerminalCommand(` + process.stdin.setRawMode(true); + let row = 0; + function print(count) { + for(let i=0;i { + if(data.toString().includes("BURST")) { print(4000); process.stdout.write("BURST-DONE\\r\\n"); } + else process.stdout.write("ECHO:" + data.toString() + "\\r\\n"); + }); + setInterval(()=>{},1000); + `), + }), + ); + await waitForCondition( + async () => + (await manager!.captureTerminal(session.id, { start: -5 })).lines + .join("") + .includes("READY"), + 15000, + ); + const desktop = {}, + phone = {}, + fresh = {}; + let pressure = false; + const viewers = terminalViewers(manager, session.id, chunked, (source) => + source === phone && pressure ? 8 * 1024 * 1024 : 0, + ); + const restore = { mode: "full-snapshot" as const, scrollbackLines: 10000 }; + function payloads(source: object): Buffer[] { + const result: Buffer[] = []; + let chunks: Uint8Array[] = []; + for (const { source: owner, frame } of viewers.frames) { + if (owner !== source) continue; + if (frame.opcode === TerminalStreamOpcode.Restore) result.push(Buffer.from(frame.payload)); + if (frame.opcode === TerminalStreamOpcode.ReplayBegin) chunks = []; + if (frame.opcode === TerminalStreamOpcode.ReplayData) chunks.push(frame.payload); + if (frame.opcode === TerminalStreamOpcode.ReplayEnd) result.push(Buffer.concat(chunks)); + } + return result; + } + async function parse(payload: Uint8Array) { + const receiver = new xterm.Terminal({ + rows: 4, + cols: 120, + scrollback: 10000, + allowProposedApi: true, + }); + try { + await new Promise((resolve) => receiver.write(payload, resolve)); + const buffer = receiver.buffer.active; + return { + history: buffer.baseY, + text: Array.from({ length: buffer.length }, (_, i) => + buffer.getLine(i)!.translateToString(true), + ).join("\n"), + }; + } finally { + receiver.dispose(); + } + } + try { + await viewers.attach(desktop, "healthy", restore); + await viewers.attach(phone, "pressured", restore); + await waitForCondition( + () => payloads(phone).length === 1 && payloads(desktop).length === 1, + 15000, + ); + const initial = payloads(phone)[0]!; + expect((await parse(initial)).history).toBe(10000); + pressure = true; + session.send({ type: "input", data: "BURST" }); + await waitForCondition(() => payloads(phone).length >= 2, 15000); + pressure = false; + // The final burst marker may belong to the replay cut or to its live tail. + // Read the same replay/output exchange a renderer sees rather than assuming + // worker parsing and daemon request scheduling cut the burst at one point. + async function currentView(source: object) { + const frames = viewers.frames + .filter((row) => row.source === source) + .map((row) => row.frame); + const start = frames.findLastIndex( + (frame) => + frame.opcode === TerminalStreamOpcode.Restore || + frame.opcode === TerminalStreamOpcode.ReplayBegin, + ); + return parse( + Buffer.concat( + frames + .slice(start) + .filter( + (frame) => + frame.opcode === TerminalStreamOpcode.Restore || + frame.opcode === TerminalStreamOpcode.ReplayData || + frame.opcode === TerminalStreamOpcode.Output, + ) + .map((frame) => frame.payload), + ), + ); + } + await waitForCondition( + async () => + (await currentView(phone)).text.includes("BURST-DONE") && + (await currentView(desktop)).text.includes("BURST-DONE"), + 15000, + ); + const recovery = payloads(phone)[1]!; + const recovered = await parse(recovery); + expect(recovered.history).toBe(500); + expect(recovered.text).toContain("界é🙂"); + for (const source of [phone, desktop]) { + const indexes = [...(await currentView(source)).text.matchAll(/row-(\d+)/g)].map((match) => + Number(match[1]), + ); + expect(indexes.at(-1)).toBe(16049); + expect(indexes).toEqual(Array.from({ length: indexes.length }, (_, i) => indexes[0]! + i)); + } + expect(recovery.byteLength).toBeLessThan(initial.byteLength / 10); + expect(recovery.byteLength).toBeLessThan(256 * 1024); + expect(payloads(desktop)).toHaveLength(1); // The healthy peer keeps its full replay and live stream. + session.send({ type: "input", data: "AFTER-CATCH-UP" }); + await waitForCondition( + () => + viewers.text(phone).includes("ECHO:AFTER-CATCH-UP") && + viewers.text(desktop).includes("ECHO:AFTER-CATCH-UP"), + 10000, + ); + expect(viewers.text(phone).match(/ECHO:AFTER-CATCH-UP/g)).toHaveLength(1); + expect(viewers.text(desktop).match(/ECHO:AFTER-CATCH-UP/g)).toHaveLength(1); + await viewers.delivery.detach(phone); + await viewers.attach(fresh, "reattach", restore); + await waitForCondition(() => payloads(fresh).length === 1, 15000); + const reattached = await parse(payloads(fresh)[0]!); + expect(reattached.history).toBe(10000); + expect(reattached.text).toContain("row-16049"); + expect(reattached.text).toContain("ECHO:AFTER-CATCH-UP"); + expect(viewers.ended(desktop)).toHaveLength(0); + expect(viewers.ended(phone)).toHaveLength(0); + process.stdout.write( + JSON.stringify({ + chunked, + initialBytes: initial.byteLength, + recoveryBytes: recovery.byteLength, + reattachBytes: payloads(fresh)[0]!.byteLength, + retainedHistory: reattached.history, + recoveryHistory: recovered.history, + }) + "\n", + ); + } finally { + await viewers.delivery.close(); + } + }, + 45000, +); diff --git a/packages/server/src/terminal/worker-terminal-manager.ts b/packages/server/src/terminal/worker-terminal-manager.ts index c9a6d7186e..9a82826cec 100644 --- a/packages/server/src/terminal/worker-terminal-manager.ts +++ b/packages/server/src/terminal/worker-terminal-manager.ts @@ -1,3 +1,4 @@ +import type { TerminalReplaySink, TerminalReplayMetadata } from "./terminal-replay.js"; import { randomBytes, randomUUID } from "node:crypto"; import { access } from "node:fs/promises"; import { setTimeout as delay } from "node:timers/promises"; @@ -5,7 +6,7 @@ import type { Logger } from "pino"; import { createExternalProcessEnv } from "../server/paseo-env.js"; import { assertAbsolutePath, isSameOrDescendantPath } from "../server/path-utils.js"; import { writtenDuringThisBoot } from "../server/pid-lock.js"; -import type { TerminalState } from "@getpaseo/protocol/messages"; +import type { WorkerTerminalState } from "./terminal-worker-protocol.js"; import type { TerminalActivity, TerminalActivityState } from "@getpaseo/protocol/terminal-activity"; import { deriveTerminalActivityStatusBucket } from "@getpaseo/protocol/terminal-activity"; import type { @@ -15,7 +16,6 @@ import type { TerminalCommandFinishedInfo, TerminalExitInfo, TerminalSession, - TerminalStateSnapshot, } from "./terminal.js"; import { resolvePaseoCliExecutablePath } from "./paseo-cli-path.js"; import type { CaptureTerminalLinesResult } from "./terminal-capture.js"; @@ -59,7 +59,6 @@ import type { TerminalWorkerResponse, TerminalWorkerToParentMessage, WorkerCreateTerminalOptions, - TerminalWorkerStateResult, WorkerTerminalInfo, } from "./terminal-worker-protocol.js"; @@ -93,7 +92,7 @@ interface PendingRequest { interface WorkerTerminalRecord { info: RequiredWorkerTerminalInfo; - state: TerminalState; + state: WorkerTerminalState; activity: TerminalActivity | null; // Cached input-mode preamble from the worker (the authoritative tracker lives // in the worker process). Updated with streamed output and snapshot responses. @@ -172,6 +171,7 @@ export function createWorkerTerminalManager( const requestTimeoutMs = managerOptions.requestTimeoutMs ?? REQUEST_TIMEOUT_MS; const resolveCreateEnvironment = managerOptions.resolveCreateEnvironment ?? resolveDaemonTerminalCreateEnvironment; + const replaySinks = new Map(); const pendingRequests = new Map(); const inFlight = new Set>(); const recordsById = new Map(); @@ -244,7 +244,7 @@ export function createWorkerTerminalManager( function registerRecord(input: { info: RequiredWorkerTerminalInfo; - state: TerminalState; + state: WorkerTerminalState; }): TerminalSession { const existing = recordsById.get(input.info.id); if (existing) { @@ -290,18 +290,11 @@ export function createWorkerTerminalManager( } sendBestEffortRequest({ type: "send", terminalId: record.info.id, message }); }, - subscribe( - listener: (msg: ServerMessage) => void, - options?: { initialSnapshot?: "state" | "ready" }, - ): () => void { + subscribe(listener: (msg: ServerMessage) => void): () => void { record.messageListeners.add(listener); - if (options?.initialSnapshot === "ready") { - queueMicrotask(() => { - if (record.messageListeners.has(listener)) { - listener({ type: "snapshotReady", revision: 0 }); - } - }); - } + queueMicrotask(() => { + if (record.messageListeners.has(listener)) listener({ type: "snapshotReady" }); + }); return () => { record.messageListeners.delete(listener); }; @@ -362,19 +355,8 @@ export function createWorkerTerminalManager( cols: record.state.cols, }; }, - getState(): TerminalState { - return record.state; - }, - getStateSnapshot(options?: { scrollbackLines?: number }): TerminalStateSnapshot { - const scrollbackLines = options?.scrollbackLines; - const scrollback = - typeof scrollbackLines === "number" - ? record.state.scrollback.slice(-scrollbackLines) - : record.state.scrollback; - return { - state: { ...record.state, scrollback }, - revision: 0, - }; + hasOutput(): boolean { + return record.state.hasOutput; }, getReplayPreamble(): string { return record.replayPreamble; @@ -442,9 +424,7 @@ export function createWorkerTerminalManager( if (!record) { return; } - if (message.message.type === "snapshot") { - record.state = message.message.state; - } + if (message.message.type === "output") record.state.hasOutput = true; record.replayPreamble = message.replayPreamble; for (const listener of Array.from(record.messageListeners)) { listener(message.message); @@ -610,7 +590,23 @@ export function createWorkerTerminalManager( } function attachWorker(): void { - worker.on("message", (message: TerminalWorkerToParentMessage) => { + const connection = worker; + connection.on("message", (message: TerminalWorkerToParentMessage) => { + if (message.type === "terminalReplay") { + // Consumption queues into the application's bounded transport synchronously. + // Never wait for app network delivery or accumulate a second replay queue here. + if (worker !== connection || !pendingRequests.has(message.requestId)) return; + try { + replaySinks.get(message.requestId)?.(message.event); + } catch (error) { + const pending = pendingRequests.get(message.requestId)!; + clearTimeout(pending.timeout); + pendingRequests.delete(message.requestId); + replaySinks.delete(message.requestId); + pending.reject(error instanceof Error ? error : new Error(String(error))); + } + return; + } if (isResponse(message)) { const pending = pendingRequests.get(message.requestId); if (!pending) { @@ -628,14 +624,15 @@ export function createWorkerTerminalManager( handleWorkerEvent(message); }); - worker.on("close", () => { + connection.on("close", () => { + if (worker !== connection) return; workerDisconnected = true; rejectPendingRequests(new Error("Terminal worker connection closed")); const reconnect = managerOptions.reconnect; if (!detached && reconnect) { reconnecting = (async () => { - const connection = await reconnect(); - if (!connection) { + const recovered = await reconnect(); + if (!recovered) { for (const terminalId of Array.from(recordsById.keys())) { handleTerminalExitEvent({ type: "terminalExit", @@ -646,10 +643,10 @@ export function createWorkerTerminalManager( return; } if (detached) { - connection.disconnect(); + recovered.disconnect(); return; } - worker = connection; + worker = recovered; workerDisconnected = false; attachWorker(); await adoptTerminals(); @@ -672,12 +669,18 @@ export function createWorkerTerminalManager( } attachWorker(); - function sendRequest(input: TerminalWorkerRequestInput): Promise { - if (reconnecting && workerDisconnected) return reconnecting.then(() => sendRequest(input)); + function sendRequest( + input: TerminalWorkerRequestInput, + sink?: TerminalReplaySink, + ): Promise { + if (reconnecting && workerDisconnected) + return reconnecting.then(() => sendRequest(input, sink)); if (workerDisconnected || !worker.connected) { return Promise.reject(new Error("Terminal worker is not running")); } + const connection = worker; const requestId = randomUUID(); + if (sink) replaySinks.set(requestId, sink); const message = { ...input, requestId } as TerminalWorkerRequest; const request = new Promise((resolve, reject) => { const timeout = setTimeout(() => { @@ -685,7 +688,7 @@ export function createWorkerTerminalManager( reject(new Error(`Terminal worker request timed out: ${input.type}`)); }, requestTimeoutMs); pendingRequests.set(requestId, { resolve, reject, timeout }); - worker.send(message, (error) => { + connection.send(message, (error) => { if (!error) { return; } @@ -700,7 +703,10 @@ export function createWorkerTerminalManager( () => undefined, ); inFlight.add(settled); - void settled.finally(() => inFlight.delete(settled)); + void settled.finally(() => { + inFlight.delete(settled); + replaySinks.delete(requestId); + }); return request; } @@ -867,22 +873,12 @@ export function createWorkerTerminalManager( return recordsById.get(id)?.session; }, - async getTerminalState( - id: string, - options?: { scrollbackLines?: number }, - ): Promise { - const snapshot = (await sendRequest({ - type: "getTerminalState", - terminalId: id, - ...(options ? { options } : {}), - })) as TerminalWorkerStateResult; - if (snapshot && snapshot.replayPreamble !== undefined) { - const record = recordsById.get(id); - if (record) { - record.replayPreamble = snapshot.replayPreamble; - } - } - return snapshot; + async replayTerminal(id, options, sink): Promise { + const metadata = (await sendRequest( + { type: "replayTerminal", terminalId: id, options }, + sink, + )) as TerminalReplayMetadata | null; + return metadata; }, setTerminalTitle(id: string, title: string): boolean { From 5ea639d9b25147560d1f384fbf6c7f4538b961c2 Mon Sep 17 00:00:00 2001 From: "paseo-ai[bot]" <266920839+paseo-ai[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 04:01:29 +0000 Subject: [PATCH 37/95] 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 35593d9f96..c7a63567f9 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-8NOrrCAxraQ74lP3Vy59MRhloBfzStTc4rW+ltc42Fg= +sha256-zFjdXeRpqrjEZk44ZCvEOTnVVGX0ASfDPktYNiWZpsw= From 9b3f1bfffdce88fb00f24e0b12a5a476fdac2944 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:02:14 +0000 Subject: [PATCH 38/95] Start custom Codex providers with reserved names (#6954) * fix(codex): start custom providers with reserved names Codex rejects custom provider keys such as ollama before a turn starts. Namespace the generated keys without changing user-facing provider ids. Keep older sessions discoverable and select the new key explicitly when resuming them, so the startup fix does not strand existing conversations. Closes #6874 Co-authored-by: Egg3901 * fix(codex): keep native sessions out of custom provider imports * fix(providers): align custom provider example with generated config Correct the documented internal provider key and simplify import filtering while preserving native and legacy session selection. * fix(codex): exclude native GCP sessions from custom imports * test(codex): seed sessions through the portable launcher --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> Co-authored-by: Egg3901 --- docs/custom-providers.md | 6 +- .../providers/codex-app-server-agent.test.ts | 69 ++++++++- .../agent/providers/codex-app-server-agent.ts | 34 ++++- ...x-custom-provider-import.local.e2e.test.ts | 143 ++++++++++++------ 4 files changed, 195 insertions(+), 57 deletions(-) diff --git a/docs/custom-providers.md b/docs/custom-providers.md index a6c6cefbdc..615389fe60 100644 --- a/docs/custom-providers.md +++ b/docs/custom-providers.md @@ -232,7 +232,7 @@ For pay-as-you-go, use `ANTHROPIC_API_KEY` with a standard Model Studio key (`sk Codex talks to OpenAI's Responses API by default. Custom providers that extend `"codex"` can point Codex at any OpenAI-compatible endpoint (OpenRouter, LiteLLM, vLLM, llama.cpp server, an internal gateway, etc.) by setting `OPENAI_BASE_URL` and `OPENAI_API_KEY` in the provider `env`. -Paseo passes those variables through to the Codex app-server process **and** maps them into Codex's thread config under `model_provider` / `model_providers`, because Codex reads provider routing from config rather than from `OPENAI_BASE_URL` alone. +Paseo passes those variables through to the Codex app-server process **and** maps them into Codex's thread config under `model_provider` / `model_providers`, because Codex reads provider routing from config rather than from `OPENAI_BASE_URL` alone. The generated Codex provider key is `paseo:`, so a Paseo provider such as `ollama` does not override a Codex built-in. Existing custom sessions created with a non-built-in provider key remain available in Import session. Native Codex conversations using a built-in key stay separate from a custom provider of the same name. ### Setup @@ -260,9 +260,9 @@ Paseo passes those variables through to the Codex app-server process **and** map Under the hood, for each custom Codex provider Paseo injects this into Codex's config: ```toml -model_provider = "my-codex" +model_provider = "paseo:my-codex" -[model_providers.my-codex] +[model_providers."paseo:my-codex"] name = "My Codex" base_url = "https://custom-relay.example.com/v1" wire_api = "responses" 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 61a86ccb8f..62e6bb0404 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 @@ -2623,9 +2623,9 @@ describe("Codex app-server provider", () => { OPENAI_BASE_URL: "https://custom-relay.example.com", }); expect(capturedThreadStartConfig(capturedRequests)).toEqual({ - model_provider: "codex-iisb", + model_provider: "paseo:codex-iisb", model_providers: { - "codex-iisb": { + "paseo:codex-iisb": { name: "Custom Codex", base_url: "https://custom-relay.example.com/v1", env_key: "OPENAI_API_KEY", @@ -2643,15 +2643,30 @@ describe("Codex app-server provider", () => { ); expect(capturedThreadStartConfig(capturedRequests)).toEqual({ - model_provider: "codex-custom", + model_provider: "paseo:codex-custom", model_providers: { - "codex-custom": expect.objectContaining({ + "paseo:codex-custom": expect.objectContaining({ base_url: "https://custom-relay.example.com/v1", }), }, }); }); + test("namespaces provider ids that collide with Codex built-ins", async () => { + // `ollama` is a reserved Codex provider id: without namespacing, Codex rejects + // the generated config outright and the agent never starts. + const capturedRequests = await runCustomCodexProviderTurn("ollama", "https://ollama.com"); + + expect(capturedThreadStartConfig(capturedRequests)).toEqual({ + model_provider: "paseo:ollama", + model_providers: { + "paseo:ollama": expect.objectContaining({ + base_url: "https://ollama.com/v1", + }), + }, + }); + }); + test("resumeSession does not replace a persisted Codex thread when app-server resume fails", async () => { const threadRequests: string[] = []; const appServer = createFakeCodexAppServer({ @@ -7348,9 +7363,21 @@ describe("Codex importable sessions", () => { const threadsByModelProvider = [ { id: "stock-thread", cwd: "/workspace/project-a", modelProvider: "openai", updatedAt: 2 }, { id: "custom-thread", cwd: "/workspace/project-a", modelProvider: "my-codex", updatedAt: 1 }, + { + id: "new-custom-thread", + cwd: "/workspace/project-a", + modelProvider: "paseo:my-codex", + updatedAt: 3, + }, + { + id: "other-custom-thread", + cwd: "/workspace/project-a", + modelProvider: "paseo-my-codex", + updatedAt: 4, + }, ]; - test("a custom Codex provider lists the threads it created", async () => { + test("a custom Codex provider lists its old and namespaced threads without other providers", async () => { const appServer = createFakeCodexAppServer({ "thread/list": providerFilteringThreadListHandler(threadsByModelProvider), }); @@ -7363,10 +7390,40 @@ describe("Codex importable sessions", () => { const sessions = await provider.listImportableSessions({ cwd: "/workspace/project-a" }); - expect(sessions.map((session) => session.providerHandleId)).toEqual(["custom-thread"]); + expect(sessions.map((session) => session.providerHandleId)).toEqual([ + "custom-thread", + "new-custom-thread", + ]); appServer.assertNoErrors(); }); + test.each(["openai", "ollama", "lmstudio", "amazon-bedrock", "amazon-bedrock-runtime", "gcp"])( + "a custom provider named %s excludes native built-in sessions", + async (providerId) => { + const appServer = createFakeCodexAppServer({ + "thread/list": providerFilteringThreadListHandler([ + { id: "native-thread", cwd: "/workspace/project-a", modelProvider: providerId }, + { + id: "custom-thread", + cwd: "/workspace/project-a", + modelProvider: `paseo:${providerId}`, + }, + ]), + }); + const provider = createProviderWithFakeAppServer(appServer, { + runtimeSettings: { + env: { OPENAI_BASE_URL: "https://llm.example.test/v1", OPENAI_API_KEY: "test-key" }, + }, + customProvider: { id: providerId, label: "Custom Codex", extends: "codex" }, + }); + + const sessions = await provider.listImportableSessions({ cwd: "/workspace/project-a" }); + + expect(sessions.map((session) => session.providerHandleId)).toEqual(["custom-thread"]); + appServer.assertNoErrors(); + }, + ); + test("stock Codex keeps listing only its own threads", async () => { const appServer = createFakeCodexAppServer({ "thread/list": providerFilteringThreadListHandler(threadsByModelProvider), 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 75370f0b5b..ea4e6378d9 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 @@ -151,6 +151,16 @@ function isCodexAlreadyUnarchivedError(error: unknown, threadId: string): boolea const TURN_START_TIMEOUT_MS = 90 * 1000; const INTERRUPT_TIMEOUT_MS = 2_000; 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. +const CODEX_BUILTIN_MODEL_PROVIDER_IDS = new Set([ + "openai", + "ollama", + "lmstudio", + "amazon-bedrock", + "amazon-bedrock-runtime", + "gcp", +]); // Codex treats most app-server client names as the model-request originator. // This reserved Codex name is non-originating, so requests keep Codex's default // CLI identity instead of showing up as Paseo in provider usage logs. @@ -3368,10 +3378,14 @@ function buildCodexCustomProviderConfig( providerConfig.env_key = "OPENAI_API_KEY"; providerConfig.requires_openai_auth = false; } + // Namespace Paseo ids so they cannot override Codex's built-ins. A colon is + // forbidden in Paseo ids, keeping these keys distinct from every legacy id + // when both generations are listed together. + const providerKey = `paseo:${customProvider.id}`; return { - model_provider: customProvider.id, + model_provider: providerKey, model_providers: { - [customProvider.id]: providerConfig, + [providerKey]: providerConfig, }, }; } @@ -4059,6 +4073,11 @@ export class CodexAppServerAgentSession implements AgentSession { if (!this.client || !this.currentThreadId) return; // Paseo reads history only when it replays it; the resume response needs no turns. const params: Record = { threadId: this.currentThreadId, excludeTurns: true }; + if (this.deps.customCodexConfig) { + // Resume otherwise prefers the provider stored on the thread, including + // the unnamespaced key emitted by earlier Paseo versions. + params.modelProvider = this.deps.customCodexConfig.model_provider; + } const developerInstructions = composeSystemPromptParts( this.config.systemPrompt, this.config.daemonAppendSystemPrompt, @@ -7373,12 +7392,19 @@ export class CodexAppServerAgentClient implements AgentClient { const listLimit = options?.cwd ? Math.max(scanLimit, 50) : scanLimit; // Codex records each thread under the model provider that ran it and, // unless told otherwise, lists only the provider its own config selects. - // A custom provider runs under its own id, so ask for exactly that one. + // Include legacy custom ids, but keep Codex built-in sessions separate. const customProviderConfig = this.customProviderConfig(); + let modelProviders: string[] | undefined; + if (customProviderConfig && this.deps.customProvider) { + modelProviders = [customProviderConfig.model_provider]; + if (!CODEX_BUILTIN_MODEL_PROVIDER_IDS.has(this.deps.customProvider.id)) { + modelProviders.push(this.deps.customProvider.id); + } + } const allThreads = await readCodexThreadWindow(client, this.logger, { limit: listLimit, cwd: options?.cwd, - ...(customProviderConfig ? { modelProviders: [customProviderConfig.model_provider] } : {}), + ...(modelProviders ? { modelProviders } : {}), }); const threads = filterCodexThreadsByCwd(allThreads, options?.cwd); return threads.slice(0, limit).map((thread) => { diff --git a/packages/server/src/server/agent/providers/codex-custom-provider-import.local.e2e.test.ts b/packages/server/src/server/agent/providers/codex-custom-provider-import.local.e2e.test.ts index ebd8de5ab5..f546dc69e7 100644 --- a/packages/server/src/server/agent/providers/codex-custom-provider-import.local.e2e.test.ts +++ b/packages/server/src/server/agent/providers/codex-custom-provider-import.local.e2e.test.ts @@ -7,7 +7,8 @@ import path from "node:path"; import { afterEach, describe, expect, test } from "vitest"; import { createTestLogger } from "../../../test-utils/test-logger.js"; -import { CodexAppServerAgentClient } from "./codex-app-server-agent.js"; +import { spawnProcess } from "../../../utils/spawn.js"; +import { CodexAppServerAgentClient, CodexAppServerAgentSession } from "./codex-app-server-agent.js"; // Runs the real `codex` binary against a local Responses endpoint standing in // for a custom provider's OPENAI_BASE_URL, so no login is needed. @@ -88,52 +89,106 @@ describe("Codex custom provider import list", () => { for (const cleanup of cleanups.splice(0).toReversed()) await cleanup(); }); - test("lists a custom provider's session under that provider and imports it there", async () => { - const codexHome = mkdtempSync(path.join(os.tmpdir(), "codex-custom-import-home-")); - const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-custom-import-cwd-")); - cleanups.push(() => rmSync(codexHome, { recursive: true, force: true })); - cleanups.push(() => rmSync(cwd, { recursive: true, force: true })); - const { baseUrl, server, requestCount } = await startResponsesEndpoint(); - cleanups.push(() => closeServer(server)); + test.each([ + { providerId: "my-codex", seedLegacy: false, seedBuiltin: false }, + { providerId: "ollama", seedLegacy: false, seedBuiltin: false }, + { providerId: "my-codex", seedLegacy: true, seedBuiltin: false }, + { providerId: "ollama", seedLegacy: false, seedBuiltin: true }, + ])( + "lists and imports sessions for $providerId (legacy: $seedLegacy, native: $seedBuiltin)", + async ({ providerId, seedLegacy, seedBuiltin }) => { + const codexHome = mkdtempSync(path.join(os.tmpdir(), "codex-custom-import-home-")); + const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-custom-import-cwd-")); + cleanups.push(() => rmSync(codexHome, { recursive: true, force: true })); + cleanups.push(() => rmSync(cwd, { recursive: true, force: true })); + const { baseUrl, server, requestCount } = await startResponsesEndpoint(); + cleanups.push(() => closeServer(server)); - const logger = createTestLogger(); - const customCodex = new CodexAppServerAgentClient( - logger, - { env: { CODEX_HOME: codexHome, OPENAI_BASE_URL: baseUrl, OPENAI_API_KEY: "test-key" } }, - { customProvider: { id: "my-codex", label: "My Codex", extends: "codex" } }, - ); - const stockCodex = new CodexAppServerAgentClient(logger, { env: { CODEX_HOME: codexHome } }); + const logger = createTestLogger(); + if (seedLegacy || seedBuiltin) { + // Seed an old custom session or a native built-in session with real Codex. + const environment = { + ...process.env, + CODEX_HOME: codexHome, + CODEX_OSS_BASE_URL: baseUrl, + OPENAI_API_KEY: "test-key", + }; + const legacy = new CodexAppServerAgentSession( + { provider: "codex", cwd, model: "mock-model" }, + null, + logger, + async () => + spawnProcess("codex", ["app-server"], { cwd, env: environment, stdio: "pipe" }), + { + environment, + customCodexConfig: { + model_provider: providerId, + model_providers: seedBuiltin + ? {} + : { + [providerId]: { + name: "My Codex", + base_url: baseUrl, + wire_api: "responses", + env_key: "OPENAI_API_KEY", + requires_openai_auth: false, + }, + }, + }, + }, + ); + try { + await legacy.run("hello from the legacy provider"); + } finally { + await legacy.close(); + } + } + const customCodex = new CodexAppServerAgentClient( + logger, + { env: { CODEX_HOME: codexHome, OPENAI_BASE_URL: baseUrl, OPENAI_API_KEY: "test-key" } }, + { customProvider: { id: providerId, label: "My Codex", extends: "codex" } }, + ); + const stockCodex = new CodexAppServerAgentClient(logger, { env: { CODEX_HOME: codexHome } }); - const session = await customCodex.createSession({ - provider: "codex", - cwd, - model: "mock-model", - }); - try { - await session.run("hello from the custom provider"); - } finally { - await session.close(); - } + const session = await customCodex.createSession({ + provider: "codex", + cwd, + model: "mock-model", + }); + try { + await session.run("hello from the custom provider"); + } finally { + await session.close(); + } - const customSessions = await customCodex.listImportableSessions({ cwd }); - const stockSessions = await stockCodex.listImportableSessions({ cwd }); + const customSessions = await customCodex.listImportableSessions({ cwd }); + const stockSessions = await stockCodex.listImportableSessions({ cwd }); - expect(customSessions.map((entry) => entry.firstPromptPreview)).toEqual([ - "hello from the custom provider", - ]); - expect(stockSessions).toEqual([]); + expect(customSessions.map((entry) => entry.firstPromptPreview).sort()).toEqual( + seedLegacy + ? ["hello from the custom provider", "hello from the legacy provider"] + : ["hello from the custom provider"], + ); + expect(stockSessions).toEqual([]); - const config = { provider: "codex", cwd, model: "mock-model" }; - const imported = await customCodex.importSession( - { providerHandleId: customSessions[0].providerHandleId, cwd }, - { config, storedConfig: config }, - ); - const requestsBeforeImportedTurn = requestCount(); - try { - await imported.session.run("hello again after import"); - } finally { - await imported.session.close(); - } - expect(requestCount()).toBeGreaterThan(requestsBeforeImportedTurn); - }, 120_000); + const config = { provider: "codex", cwd, model: "mock-model" }; + const sessionToImport = seedLegacy + ? customSessions.find( + (entry) => entry.firstPromptPreview === "hello from the legacy provider", + )! + : customSessions[0]; + const imported = await customCodex.importSession( + { providerHandleId: sessionToImport.providerHandleId, cwd }, + { config, storedConfig: config }, + ); + const requestsBeforeImportedTurn = requestCount(); + try { + await imported.session.run("hello again after import"); + } finally { + await imported.session.close(); + } + expect(requestCount()).toBeGreaterThan(requestsBeforeImportedTurn); + }, + 120_000, + ); }); From 3b51b8ab2e2a394a7ae8c22645870cfe0ea44467 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 06:31:08 +0000 Subject: [PATCH 39/95] Recover terminal worker records after Windows reader contention (#8155) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(terminal): let cleanup fixture exit on request * test(terminal): capture the Windows record rewrite failure * test(terminal): repeat Windows cleanup exit diagnostics * fix(server): reopening a Codex agent no longer reads and holds its full history (#8132) * fix(server): read Codex history only when Paseo replays it Reopening a Codex agent whose timeline Paseo still held read the full thread on connect and kept it on the session until the next replay, which never came, so every reopened agent held a second copy of its transcript. thread/resume also returned every turn, and only the sandbox policy was used from it. Resume now sends excludeTurns (older Codex ignores the unknown field) and the history read moves into streamHistory, whose caller owns the result. A resumed session still needs the earlier turns for rewind targets and the sub-agent routes that keep an earlier child's interactions on its original card, so the first turn, goal or rewind indexes the thread and drops the timeline it read. * test(server): run reopened Codex agent tests without the Codex CLI installed * test(terminal): isolate record reader contention on exit * feat(usage): show when each limit resets, and make usage rows easier to read (#8162) The reset countdown floored to one unit, so a weekly window read "resets 2d" from 2d 23h down to 2d 0h and looked stuck for a day; the session window had the same problem with hours. Countdowns now use two units ("2d 23h", "4h 59m"), and every row also shows the clock time it resets ("7:20 AM", "Sat 8:00 AM", "Oct 31, 3:00 PM"), so users can plan work around a reset without hovering. Rows now put the window name and percent on one line, the bar under it, and the reset on its own full-width line, so the exact time fits at phone width in longer languages. The pinned-row background is gone; the pin glyph, drawn in the foreground colour when pinned, is the only pinned signal. Bars are 6px. * Make Claude session entrypoint relabeling optional (#8164) * fix(claude): make session entrypoint relabeling opt-in Keep inherited SDK and parent identities cleared. Honor the explicit session-open entrypoint contribution so users can opt into the label through a plugin instead of every session receiving it unconditionally. * test(hub): use standard budget for restart and worktree recovery The Windows CI scenario timed out at its custom 20-second limit while the other Hub cases took 4–10 seconds each. This scenario starts two daemons and creates, archives and restores a worktree. Use the existing 30-second suite budget without changing any assertions. * Deliver a steer message that arrives while the previous turn is still starting (#6192) * Deliver a steer message that arrives while the previous turn is still starting A steer send that reached an agent before its previous turn had a turn id found no turn to steer and replaced the starting turn, so the earlier message never reached the provider. Steering now waits for the starting turn to be accepted (or fail) and steers the turn it became. Fixes #6186 * Wake a waiting steer only once the starting turn has an id or its run has ended A failed start used to wake the waiting steer before its run was cleared, leaving the later send to replace a run that had already failed. * Clear a failed start's run even when reporting the failure throws A steer waiting on that start is released only when the run is cleared. * Test that a failed start keeps a waiting steer until its run is cleared * fix(server): steer Codex turns while native identification is pending Wait for the accepted foreground turn to receive its native ID before steering it. Returning unavailable in this window replaced the turn even after the manager had waited for startup. --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> * fix(terminal): recover worker records after reader contention Retain the latest record until a transient sharing failure clears. Deliver exits while publication is pending, await publication before confirming creation, and cancel retries before retirement. --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> Co-authored-by: Mohamed Boudra --- .../src/terminal/terminal-worker-process.ts | 16 ++- .../terminal-worker-record-publisher.test.ts | 70 +++++++++++ .../terminal-worker-record-publisher.ts | 62 ++++++++++ .../terminal/worker-terminal-manager.test.ts | 116 +++++++++++------- 4 files changed, 218 insertions(+), 46 deletions(-) create mode 100644 packages/server/src/terminal/terminal-worker-record-publisher.test.ts create mode 100644 packages/server/src/terminal/terminal-worker-record-publisher.ts diff --git a/packages/server/src/terminal/terminal-worker-process.ts b/packages/server/src/terminal/terminal-worker-process.ts index b3e8864f9d..855c39da59 100644 --- a/packages/server/src/terminal/terminal-worker-process.ts +++ b/packages/server/src/terminal/terminal-worker-process.ts @@ -18,6 +18,7 @@ import { writeTerminalWorkerRecord, type TerminalWorkerRecord, } from "./terminal-worker-record.js"; +import { createTerminalWorkerRecordPublisher } from "./terminal-worker-record-publisher.js"; import { resolveTerminalWorkerSocketPath } from "./terminal-worker-socket-path.js"; import { TERMINAL_WORKER_PROTOCOL_OVERRIDE_ENV, @@ -214,13 +215,17 @@ function runWorker(argv: string[]): void { replayController.send(message); } - function persistRecord(): void { + const recordPublisher = createTerminalWorkerRecordPublisher((state) => { + writeTerminalWorkerRecord(args.paseoHome, state); + }); + + async function persistRecord(): Promise { if (!record || exiting) return; record = { ...record, terminals: record.terminals.filter((entry) => sessionsById.has(entry.id)), }; - writeTerminalWorkerRecord(args.paseoHome, record); + await recordPublisher.publish(record); } function removeOwnFiles(): void { @@ -233,6 +238,7 @@ function runWorker(argv: string[]): void { function exitWorker(code: number): void { if (exiting) return; exiting = true; + recordPublisher.stop(); removeOwnFiles(); server?.close(); process.exit(code); @@ -349,7 +355,9 @@ function runWorker(argv: string[]): void { outputCoalescer.flush(); clearTerminalSubscriptions(session.id); sessionsById.delete(session.id); - persistRecord(); + void persistRecord().catch((error) => { + if (!exiting) console.error("Cannot publish terminal exit:", error); + }); if (exitsHeldDuringCreation.has(session.id)) { exitsHeldDuringCreation.set(session.id, info); return; @@ -424,7 +432,7 @@ function runWorker(argv: string[]): void { { id: session.id, pid, startedAt: inspection.snapshot.startedAt }, ], }; - persistRecord(); + await persistRecord(); } function enqueueCreateTerminalRequest(message: TerminalCreateRequest): Promise { diff --git a/packages/server/src/terminal/terminal-worker-record-publisher.test.ts b/packages/server/src/terminal/terminal-worker-record-publisher.test.ts new file mode 100644 index 0000000000..dba50b9efe --- /dev/null +++ b/packages/server/src/terminal/terminal-worker-record-publisher.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { setTimeout as delay } from "node:timers/promises"; +import { createTerminalWorkerRecordPublisher } from "./terminal-worker-record-publisher.js"; +import type { TerminalWorkerRecord } from "./terminal-worker-record.js"; + +const record: TerminalWorkerRecord = { + recordId: "worker", + pid: 123, + bootId: null, + createdAt: "now", + identity: { startedAt: "now", commandLine: "worker" }, + protocolVersion: 1, + paseoVersion: "test", + socketPath: "socket", + secret: "secret", + terminals: [{ id: "terminal", pid: 124, startedAt: "now" }], +}; + +describe("live worker record publication", () => { + it.each(["EPERM", "EBUSY", "EACCES"])( + "publishes the latest state after %s contention clears", + async (code) => { + let locked = true; + const written: TerminalWorkerRecord[] = []; + const publisher = createTerminalWorkerRecordPublisher((state) => { + if (locked) throw Object.assign(new Error("reader holds record"), { code }); + written.push(state); + }); + try { + const creation = publisher.publish(record); + const exited = { ...record, terminals: [] }; + const exit = publisher.publish(exited); + expect(written).toEqual([]); + locked = false; + await Promise.all([creation, exit]); + expect(written).toEqual([exited]); + } finally { + publisher.stop(); + } + }, + ); + + it("does not recreate a retired record when a reader releases it", async () => { + let locked = true; + const written: TerminalWorkerRecord[] = []; + const publisher = createTerminalWorkerRecordPublisher((state) => { + if (locked) + throw Object.assign(new Error("reader holds record"), { + code: "EPERM", + }); + written.push(state); + }); + const pending = publisher.publish(record); + const rejected = expect(pending).rejects.toThrow("stopped"); + publisher.stop(); + locked = false; + await rejected; + await delay(50); + expect(written).toEqual([]); + await expect(publisher.publish(record)).rejects.toThrow("stopped"); + }); + + it("reports permanent storage failures without retrying", async () => { + const publisher = createTerminalWorkerRecordPublisher(() => { + throw Object.assign(new Error("disk full"), { code: "ENOSPC" }); + }); + await expect(publisher.publish(record)).rejects.toThrow("disk full"); + publisher.stop(); + }); +}); diff --git a/packages/server/src/terminal/terminal-worker-record-publisher.ts b/packages/server/src/terminal/terminal-worker-record-publisher.ts new file mode 100644 index 0000000000..415c785102 --- /dev/null +++ b/packages/server/src/terminal/terminal-worker-record-publisher.ts @@ -0,0 +1,62 @@ +import type { TerminalWorkerRecord } from "./terminal-worker-record.js"; + +const TRANSIENT_ERRORS = new Set(["EPERM", "EBUSY", "EACCES"]); + +/** Serializes live worker publication, retaining the latest state across reader contention. + * Creation awaits publication; exit notifications need not wait for a reader to close. + * Stop before retiring the record so a pending retry cannot recreate it. + */ +export function createTerminalWorkerRecordPublisher(write: (record: TerminalWorkerRecord) => void) { + let stopped = false; + let latest: TerminalWorkerRecord; + let timer: ReturnType | undefined; + let pending: + | { + promise: Promise; + resolve: () => void; + reject: (error: unknown) => void; + } + | undefined; + + function attempt(): void { + timer = undefined; + const publication = pending; + if (!publication) return; + try { + write(latest); + publication.resolve(); + pending = undefined; + } catch (error) { + if (TRANSIENT_ERRORS.has((error as NodeJS.ErrnoException).code ?? "")) { + timer = setTimeout(attempt, 25); + } else { + publication.reject(error); + pending = undefined; + } + } + } + + return { + publish(record: TerminalWorkerRecord): Promise { + if (stopped) return Promise.reject(new Error("Worker record publisher stopped")); + latest = record; + if (pending) return pending.promise; + let resolve!: () => void; + let reject!: (error: unknown) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + pending = { promise, resolve, reject }; + attempt(); + return promise; + }, + stop(): void { + stopped = true; + clearTimeout(timer); + timer = undefined; + pending?.reject(new Error("Worker record publisher stopped")); + pending = undefined; + }, + }; +} diff --git a/packages/server/src/terminal/worker-terminal-manager.test.ts b/packages/server/src/terminal/worker-terminal-manager.test.ts index ad066327a2..ffc9b18a45 100644 --- a/packages/server/src/terminal/worker-terminal-manager.test.ts +++ b/packages/server/src/terminal/worker-terminal-manager.test.ts @@ -16,7 +16,9 @@ import { afterEach, expect, it } from "vitest"; import { isPlatform } from "../test-utils/platform.js"; import { EventEmitter } from "node:events"; import { + closeSync, existsSync, + openSync, mkdirSync, mkdtempSync, readdirSync, @@ -58,6 +60,7 @@ import { listTerminalWorkerRecords, readTerminalWorkerRecord, terminalWorkerDirectory, + terminalWorkerRecordPath, type TerminalWorkerRecord, } from "./terminal-worker-record.js"; import { TERMINAL_WORKER_PROTOCOL_OVERRIDE_ENV } from "./terminal-worker-protocol.js"; @@ -807,52 +810,81 @@ async function recordedTerminalPids(paseoHome: string): Promise { - const paseoHome = createTemporaryHome(); - const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-unfinished-kill-")); - temporaryDirs.push(cwd); - const first = await startRealWorkerManager({ paseoHome }); - manager = first; - const ending = await first.createTerminal({ - workspaceId: "ws-unfinished-kill", - cwd, - ...nodeTerminalCommand("setInterval(() => {}, 1000);"), - }); - const remaining = await first.createTerminal({ - workspaceId: "ws-unfinished-kill", - cwd, - ...nodeTerminalCommand("setInterval(() => {}, 1000);"), - }); - const pids = await recordedTerminalPids(paseoHome); - const endingPid = pids.get(ending.id)!; - const remainingPid = pids.get(remaining.id)!; +it.each([false, true])( + "never adopts a live worker's terminals after a kill cleanup it could not finish, even once the worker rewrites its record (held reader: %s)", + async (holdReader) => { + const paseoHome = createTemporaryHome(); + const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-unfinished-kill-")); + temporaryDirs.push(cwd); + const first = await startRealWorkerManager({ paseoHome }); + manager = first; + const exitRequestPath = join(cwd, "exit-requested"); + const exitObservedPath = join(cwd, "exit-observed"); + const ending = await first.createTerminal({ + workspaceId: "ws-unfinished-kill", + cwd, + ...nodeTerminalCommand(` + const { existsSync, writeFileSync } = require("node:fs"); + setInterval(() => { + if (existsSync(${JSON.stringify(exitRequestPath)})) { + writeFileSync(${JSON.stringify(exitObservedPath)}, ""); + process.exit(0); + } + }, 25); + `), + }); + const remaining = await first.createTerminal({ + workspaceId: "ws-unfinished-kill", + cwd, + ...nodeTerminalCommand("setInterval(() => {}, 1000);"), + }); + const pids = await recordedTerminalPids(paseoHome); + const endingPid = pids.get(ending.id)!; + const remainingPid = pids.get(remaining.id)!; + const [record] = await workerRecords(paseoHome); - // A kill cleanup whose process table read fails signals nothing and leaves the worker live. - const inspector = createTerminalWorkerProcessInspector(); - await expect( - reapTerminalWorkers({ - paseoHome, - inspector: { ...inspector, listProcesses: () => Promise.reject(new Error("ps timed out")) }, - }), - ).rejects.toBeInstanceOf(TerminalCleanupNotConfirmedError); - await first.detach(); - expect((await readUnconfirmedTerminalCleanup(paseoHome)).length).toBeGreaterThan(0); + // A kill cleanup whose process table read fails signals nothing and leaves the worker live. + const inspector = createTerminalWorkerProcessInspector(); + await expect( + reapTerminalWorkers({ + paseoHome, + inspector: { + ...inspector, + listProcesses: () => Promise.reject(new Error("ps timed out")), + }, + }), + ).rejects.toBeInstanceOf(TerminalCleanupNotConfirmedError); + expect((await readUnconfirmedTerminalCleanup(paseoHome)).length).toBeGreaterThan(0); - // One terminal ends on its own: the live worker rewrites its record from memory. - process.kill(endingPid, "SIGKILL"); - await waitForCondition( - async () => !(await recordedTerminalPids(paseoHome)).has(ending.id), - 10000, - ); - expect((await readUnconfirmedTerminalCleanup(paseoHome)).length).toBeGreaterThan(0); + // Keep an ordinary reader open until the worker delivers the terminal exit. + // Windows cannot replace the record while this handle is open. The exit must + // still reach the controller, and publication must recover after release. + const recordFd = holdReader + ? openSync(terminalWorkerRecordPath(paseoHome, record!.pid), "r") + : null; + try { + writeFileSync(exitRequestPath, ""); + await waitForCondition(() => first.getTerminal(ending.id) === undefined, 10000); + expect(existsSync(exitObservedPath)).toBe(true); + expect(isProcessAlive(endingPid)).toBe(false); + } finally { + if (recordFd !== null) closeSync(recordFd); + } + await waitForCondition( + async () => !(await recordedTerminalPids(paseoHome)).has(ending.id), + 10000, + ); + expect((await readUnconfirmedTerminalCleanup(paseoHome)).length).toBeGreaterThan(0); - manager = await startRealWorkerManager({ paseoHome }); + await first.detach(); + manager = await startRealWorkerManager({ paseoHome }); - expect(manager.getTerminal(remaining.id)).toBeUndefined(); - expect(await manager.getTerminals(cwd)).toEqual([]); - await waitForCondition(() => !isProcessAlive(remainingPid), 10000); - expect(await readUnconfirmedTerminalCleanup(paseoHome)).toEqual([]); -}); + expect(manager.getTerminal(remaining.id)).toBeUndefined(); + expect(await manager.getTerminals(cwd)).toEqual([]); + await waitForCondition(() => !isProcessAlive(remainingPid), 10000); + expect(await readUnconfirmedTerminalCleanup(paseoHome)).toEqual([]); + }, +); // The worker's own `ps`, delayed: a program that exits while the worker reads its birth time // lands its exit in the middle of creation. Only the worker launched while this PATH is set sees From 9c5b47421a8c1a1b307612b59becf76f85ce800d Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 06:49:47 +0000 Subject: [PATCH 40/95] test(server): exclude filesystem startup from config responsiveness (#8296) * test(server): diagnose recurring config responsiveness failure * test(server): compare profiled and plain config reads on Windows * test(server): correlate config profiles with long callbacks * test(server): probe config timing under CPU contention * test(server): exclude filesystem pool startup from config budget * test(terminal): finish output before comparing replay captures A marker can arrive before its trailing newline. Comparing captures taken on either side of that output compares different terminal states. Split that output deliberately and wait for its parsed completion before capturing the expected replay. --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../terminal/worker-terminal-manager.test.ts | 18 +++++++++++++++++- .../test-fixtures/config-responsiveness.ts | 6 ++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/server/src/terminal/worker-terminal-manager.test.ts b/packages/server/src/terminal/worker-terminal-manager.test.ts index ffc9b18a45..7d91ec180e 100644 --- a/packages/server/src/terminal/worker-terminal-manager.test.ts +++ b/packages/server/src/terminal/worker-terminal-manager.test.ts @@ -1742,6 +1742,7 @@ it.skipIf(isPlatform("win32"))( it("replays bounded ANSI chunks independently of live output and reconstructs the revision cut", async () => { const cwd = createTemporaryHome(); + const finishBefore = join(cwd, "finish-before"); manager = await startRealWorkerManager(); const session = trackTerminal( await manager.createTerminal({ @@ -1751,7 +1752,15 @@ it("replays bounded ANSI chunks independently of live output and reconstructs th cols: 120, ...nodeTerminalCommand(` for (let i = 0; i < 800; i++) process.stdout.write("\\x1b[31m" + String(i).padStart(4, "0") + ":" + "x".repeat(100) + "\\x1b[0m\\r\\n"); - require("node:readline").createInterface({ input: process.stdin }).on("line", line => process.stdout.write(line === "before" ? "PRE-CUT\\r\\n" : "LIVE-AFTER-REPLAY\\r\\n")); + require("node:readline").createInterface({ input: process.stdin }).on("line", line => { + if (line !== "before") { process.stdout.write("LIVE-AFTER-REPLAY\\r\\n"); return; } + process.stdout.write("PRE-CUT"); + const finish = setInterval(() => { + if (!require("node:fs").existsSync(${JSON.stringify(finishBefore)})) return; + clearInterval(finish); + process.stdout.write("\\r\\n"); + }, 5); + }); setInterval(() => {}, 1000); `), }), @@ -1775,6 +1784,13 @@ it("replays bounded ANSI chunks independently of live output and reconstructs th }); session.send({ type: "input", data: "before\r" }); await waitForCondition(() => live.includes("PRE-CUT"), 10000); + // A PTY may deliver the marker and its trailing newline separately. Finish + // that output before replay, so the two captures must describe the same cut. + writeFileSync(finishBefore, ""); + await waitForCondition(async () => { + const { lines } = await manager!.captureTerminal(session.id); + return lines.at(-2) === "PRE-CUT" && lines.at(-1) === ""; + }, 10000); const expected = await manager.captureTerminal(session.id); const metadata = await manager.replayTerminal(session.id, undefined, (event) => { events.push(event.type); diff --git a/packages/server/src/utils/test-fixtures/config-responsiveness.ts b/packages/server/src/utils/test-fixtures/config-responsiveness.ts index 9e151c805a..787ad88674 100644 --- a/packages/server/src/utils/test-fixtures/config-responsiveness.ts +++ b/packages/server/src/utils/test-fixtures/config-responsiveness.ts @@ -1,4 +1,5 @@ import { createHook, executionAsyncResource } from "node:async_hooks"; +import { stat } from "node:fs/promises"; import { MessagePort } from "node:worker_threads"; import assert from "node:assert/strict"; import { setImmediate as nextTurn } from "node:timers/promises"; @@ -10,6 +11,11 @@ import { readPaseoConfigForEdit } from "../paseo-config-file.js"; // Read the real file once, without warming the config snapshot or schema. const directory = process.argv[2]; assert.ok(directory, "Config directory is required"); +// The daemon has already used asynchronous filesystem APIs before reading a +// project config. Start libuv's filesystem worker pool outside the measurement: +// its first submission synchronously creates workers in a fresh Node process. +// Stat the probe itself so the config, snapshot and schemas remain cold. +await stat(process.argv[1]); await nextTurn(); let workerMessages = 0; const activity = createHook({ From 254166e7c13b87f1bcf48105716a9021c48f643a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 31 Oct 2026 14:23:10 +0700 Subject: [PATCH 41/95] feat(terminal): add native selection handles (#8348) * feat(terminal): add native selection handles * fix(terminal): preserve selection at row boundaries * fix(terminal): preserve selection boundary when handles cross --- .../paseo/terminal/TerminalRowLayoutTest.kt | 35 ++ .../sh/paseo/terminal/PaseoTerminalView.kt | 1 - .../java/sh/paseo/terminal/TerminalFrame.kt | 1 - .../sh/paseo/terminal/TerminalRowLayout.kt | 38 +- .../app/modules/paseo-terminal/contract.ts | 1 - .../ios/PaseoTerminalView.swift | 6 +- .../paseo-terminal/ios/TerminalFrame.swift | 1 - .../ios/TerminalRowLayout.swift | 29 +- .../terminal-emulator-native-grid.native.tsx | 379 +++++----------- .../src/terminal/native-renderer/colors.ts | 3 +- .../terminal-grid-view.native.tsx | 1 - .../terminal-selection-controller.test.ts | 411 ++++++++++++++++++ .../terminal-selection-controller.ts | 208 +++++++++ .../terminal-selection-gesture.test.ts | 354 --------------- .../terminal-selection-gesture.ts | 108 ----- .../terminal-selection.test.ts | 48 ++ .../native-renderer/terminal-selection.ts | 27 +- 17 files changed, 865 insertions(+), 786 deletions(-) create mode 100644 packages/app/src/terminal/native-renderer/terminal-selection-controller.test.ts create mode 100644 packages/app/src/terminal/native-renderer/terminal-selection-controller.ts delete mode 100644 packages/app/src/terminal/native-renderer/terminal-selection-gesture.test.ts delete mode 100644 packages/app/src/terminal/native-renderer/terminal-selection-gesture.ts 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 e9b8ab1edf..8e889bccf2 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 @@ -10,6 +10,7 @@ import androidx.test.platform.app.InstrumentationRegistry import org.json.JSONArray import org.json.JSONObject import org.junit.Assert.assertTrue +import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith @@ -55,6 +56,40 @@ class TerminalRowLayoutTest { } } + @Test + fun selectionCompositesOverCellAndTerminalBackgroundsWithoutChangingForeground() { + val red = 0xffff0000.toInt() + val green = 0xff00aa00.toInt() + val overlay = 0x472684ff + val source = TerminalRowSource.decode("1,1;1,4294901760,4278233600,0;M") + val layout = TerminalRowLayout(source, font, TerminalCustomGlyphs.EMPTY) + paints.selectionBackground = overlay + for (terminalBackground in listOf(0xffffffff.toInt(), 0xff000000.toInt())) { + val actual = raster { canvas -> + canvas.drawColor(terminalBackground) + layout.drawSelected(canvas, 0, TerminalSelection(TerminalSelectionRecord()), paints) + } + val expected = raster { canvas -> + canvas.drawColor(terminalBackground) + canvas.drawColor(green) + canvas.drawColor(overlay) + } + assertEquals(expected.getPixel(0, 0), actual.getPixel(0, 0)) + assertTrue("selected glyph retains its red foreground", (0 until font.cellHeightPx).any { y -> + (0 until font.cellWidthPx).any { x -> actual.getPixel(x, y) == red } + }) + } + val defaultBackground = TerminalRowLayout(TerminalRowSource.decode("1,1;1,4294901760,0,0;M"), font, TerminalCustomGlyphs.EMPTY) + for (terminalBackground in listOf(0xffffffff.toInt(), 0xff000000.toInt())) { + val actual = raster { canvas -> + canvas.drawColor(terminalBackground) + defaultBackground.drawSelected(canvas, 0, TerminalSelection(TerminalSelectionRecord()), paints) + } + val expected = raster { canvas -> canvas.drawColor(terminalBackground); canvas.drawColor(overlay) } + assertEquals(expected.getPixel(0, 0), actual.getPixel(0, 0)) + } + } + private fun starts(row: JSONObject, key: String): List = row.getJSONArray(key).let { starts -> List(starts.length()) { starts.getInt(it) } } diff --git a/packages/app/modules/paseo-terminal/android/src/main/java/sh/paseo/terminal/PaseoTerminalView.kt b/packages/app/modules/paseo-terminal/android/src/main/java/sh/paseo/terminal/PaseoTerminalView.kt index 3fcf012e03..03f76cf91b 100644 --- a/packages/app/modules/paseo-terminal/android/src/main/java/sh/paseo/terminal/PaseoTerminalView.kt +++ b/packages/app/modules/paseo-terminal/android/src/main/java/sh/paseo/terminal/PaseoTerminalView.kt @@ -63,7 +63,6 @@ class PaseoTerminalView(context: Context, appContext: AppContext) : ExpoView(con backgroundColor = argb(value.background) cursorColor = argb(value.cursor) paints.selectionBackground = argb(value.selectionBackground) - paints.selectionForeground = argb(value.selectionForeground) invalidate() } diff --git a/packages/app/modules/paseo-terminal/android/src/main/java/sh/paseo/terminal/TerminalFrame.kt b/packages/app/modules/paseo-terminal/android/src/main/java/sh/paseo/terminal/TerminalFrame.kt index c27be7cc7a..a33eca76e6 100644 --- a/packages/app/modules/paseo-terminal/android/src/main/java/sh/paseo/terminal/TerminalFrame.kt +++ b/packages/app/modules/paseo-terminal/android/src/main/java/sh/paseo/terminal/TerminalFrame.kt @@ -27,7 +27,6 @@ class TerminalThemeRecord : Record { @Field val background: Double = 0.0 @Field val cursor: Double = 0.0 @Field val selectionBackground: Double = 0.0 - @Field val selectionForeground: Double = 0.0 } class TerminalCustomGlyphRecord : Record { diff --git a/packages/app/modules/paseo-terminal/android/src/main/java/sh/paseo/terminal/TerminalRowLayout.kt b/packages/app/modules/paseo-terminal/android/src/main/java/sh/paseo/terminal/TerminalRowLayout.kt index d2ee2355bc..6680f15ed9 100644 --- a/packages/app/modules/paseo-terminal/android/src/main/java/sh/paseo/terminal/TerminalRowLayout.kt +++ b/packages/app/modules/paseo-terminal/android/src/main/java/sh/paseo/terminal/TerminalRowLayout.kt @@ -23,7 +23,7 @@ private const val CLIP_UNBOUNDED_PX = 100_000f * either half alone would show an explicit virama. Each resulting group is drawn from the shaped * run (in context) at its first cell's column, centred in the columns it owns, and scaled down only when * its glyphs are wider than those columns. Colors never split groups or reshape the row: a group - * whose cells differ in color or selection is drawn once per color, clipped to those cells' columns. + * whose cells differ in color is drawn once per color, clipped to those cells' columns. */ internal class TerminalRowLayout( val source: TerminalRowSource, @@ -195,7 +195,7 @@ internal class TerminalRowLayout( canvas.drawRenderNode(recorded) } - /** Draws the row with selected cells in selection colors. Selection never reshapes the row. */ + /** Draws backgrounds, the selection overlay, then text with its original colors. */ fun drawSelected(canvas: Canvas, row: Int, selection: TerminalSelection, paints: TerminalPaints) { drawContent(canvas, row, selection, paints) } @@ -213,17 +213,17 @@ internal class TerminalRowLayout( val cellHeight = font.cellHeightPx.toFloat() val fill = paints.fill for (cell in 0 until cellCount) { - val background = - if (selected(row, selection, cell)) paints.selectionBackground else cellBackground[cell] - if (background ushr 24 == 0) continue - fill.color = background - canvas.drawRect( - cellCol[cell] * cellWidth, - 0f, - (cellCol[cell] + cellSpan[cell]) * cellWidth, - cellHeight, - fill, - ) + val left = cellCol[cell] * cellWidth + val right = (cellCol[cell] + cellSpan[cell]) * cellWidth + val background = cellBackground[cell] + if (background ushr 24 != 0) { + fill.color = background + canvas.drawRect(left, 0f, right, cellHeight, fill) + } + if (selected(row, selection, cell)) { + fill.color = paints.selectionBackground + canvas.drawRect(left, 0f, right, cellHeight, fill) + } } val baseline = font.baselinePx.toFloat() @@ -234,9 +234,9 @@ internal class TerminalRowLayout( val paint = font.paint(cellFlags[group.firstCell]) var first = group.firstCell while (first <= group.lastCell) { - val color = foreground(row, selection, first, paints) + val color = cellForeground[first] var last = first - while (last < group.lastCell && foreground(row, selection, last + 1, paints) == color) last += 1 + while (last < group.lastCell && cellForeground[last + 1] == color) last += 1 // Only edges between differently colored cells clip; the group's outer edges may overhang. val clipped = first > group.firstCell || last < group.lastCell canvas.save() @@ -257,7 +257,7 @@ internal class TerminalRowLayout( for (cell in 0 until cellCount) { val glyph = cellGlyph[cell] val flags = cellFlags[cell] - val foreground = foreground(row, selection, cell, paints) + val foreground = cellForeground[cell] if (glyph != null) { fill.color = foreground glyph.draw(canvas, cellCol[cell] * cellWidth, cellSpan[cell] * cellWidth, cellHeight, font.density, fill) @@ -291,9 +291,6 @@ internal class TerminalRowLayout( canvas.drawTextRun(text, group.start, group.end, group.contextStart, group.contextEnd, x, baseline, false, paint) } - private fun foreground(row: Int, selection: TerminalSelection?, cell: Int, paints: TerminalPaints): Int = - if (selected(row, selection, cell)) paints.selectionForeground else cellForeground[cell] - /** The row's text as accessibility reads it. */ fun describe(): String = source.text.trimEnd() } @@ -301,6 +298,5 @@ internal class TerminalRowLayout( /** Paint state shared by every row of one view. */ internal class TerminalPaints { val fill = Paint(Paint.ANTI_ALIAS_FLAG) - var selectionBackground: Int = 0xFFFFFF00.toInt() - var selectionForeground: Int = 0xFF000000.toInt() + var selectionBackground: Int = 0x472684FF } diff --git a/packages/app/modules/paseo-terminal/contract.ts b/packages/app/modules/paseo-terminal/contract.ts index ee4fe45ab0..6fd6c0906b 100644 --- a/packages/app/modules/paseo-terminal/contract.ts +++ b/packages/app/modules/paseo-terminal/contract.ts @@ -43,7 +43,6 @@ export interface PaseoTerminalTheme { background: number; cursor: number; selectionBackground: number; - selectionForeground: number; } export interface PaseoTerminalCellMetrics { diff --git a/packages/app/modules/paseo-terminal/ios/PaseoTerminalView.swift b/packages/app/modules/paseo-terminal/ios/PaseoTerminalView.swift index 240cddddab..9739202cad 100644 --- a/packages/app/modules/paseo-terminal/ios/PaseoTerminalView.swift +++ b/packages/app/modules/paseo-terminal/ios/PaseoTerminalView.swift @@ -24,8 +24,7 @@ final class PaseoTerminalView: ExpoView { private var background = TerminalColor(Double(0xff00_0000)) private var cursorColor = TerminalColor(Double(0xffff_ffff)) private var rowPaint = TerminalRowPaint( - selectionBackground: TerminalColor(Double(0xffff_ff00)), - selectionForeground: TerminalColor(Double(0xff00_0000))) + selectionBackground: TerminalColor(Double(0x4726_84ff))) private var rowCache: [String: TerminalRowLayout] = [:] required init(appContext: AppContext? = nil) { @@ -44,8 +43,7 @@ final class PaseoTerminalView: ExpoView { background = TerminalColor(record.background) cursorColor = TerminalColor(record.cursor) rowPaint = TerminalRowPaint( - selectionBackground: TerminalColor(record.selectionBackground), - selectionForeground: TerminalColor(record.selectionForeground)) + selectionBackground: TerminalColor(record.selectionBackground)) backgroundColor = UIColor(cgColor: background.cgColor()) setNeedsDisplay() } diff --git a/packages/app/modules/paseo-terminal/ios/TerminalFrame.swift b/packages/app/modules/paseo-terminal/ios/TerminalFrame.swift index 84ba6e8b0c..2cba97fd61 100644 --- a/packages/app/modules/paseo-terminal/ios/TerminalFrame.swift +++ b/packages/app/modules/paseo-terminal/ios/TerminalFrame.swift @@ -25,7 +25,6 @@ struct TerminalThemeRecord: Record { @Field var background: Double = 0 @Field var cursor: Double = 0 @Field var selectionBackground: Double = 0 - @Field var selectionForeground: Double = 0 } struct TerminalCustomGlyphRecord: Record { diff --git a/packages/app/modules/paseo-terminal/ios/TerminalRowLayout.swift b/packages/app/modules/paseo-terminal/ios/TerminalRowLayout.swift index 80050c9040..83d731f1e3 100644 --- a/packages/app/modules/paseo-terminal/ios/TerminalRowLayout.swift +++ b/packages/app/modules/paseo-terminal/ios/TerminalRowLayout.swift @@ -27,7 +27,6 @@ private let leftToRightAttributes: [NSAttributedString.Key: Any] = { /// Colors a row is painted with besides its own styles. struct TerminalRowPaint { let selectionBackground: TerminalColor - let selectionForeground: TerminalColor } /// One row shaped once and placed on the terminal grid. @@ -267,31 +266,33 @@ final class TerminalRowLayout { selection?.contains(row: row, col: cellCol[cell], span: cellSpan[cell]) ?? false } - private func foreground(_ cell: Int, row: Int, selection: TerminalSelection?, paint: TerminalRowPaint) -> TerminalColor { - isSelected(cell, row: row, selection: selection) ? paint.selectionForeground : cellForeground[cell] - } - - /// Draws the row with its top edge at `top`, selected cells in selection colors. + /// Draws cell backgrounds, the selection overlay, then text with its original colors. func draw(in context: CGContext, row: Int, top: CGFloat, selection: TerminalSelection?, paint: TerminalRowPaint) { let cellWidth = font.cellWidth let cellHeight = font.cellHeight for cell in 0.. Math.abs(gesture.dx) ? eventDx : gesture.dx; - const dy = Math.abs(eventDy) > Math.abs(gesture.dy) ? eventDy : gesture.dy; - return { - x: press.startX + dx, - y: press.startY + dy, - }; -} - function initialNativeTerminalSize(): TerminalMeasuredSize { return { rows: MIN_NATIVE_TERMINAL_ROWS, cols: MIN_NATIVE_TERMINAL_COLS }; } @@ -163,19 +127,14 @@ function NativeTerminalEmulator({ const outputDrainRef = useRef(null); const screenModelRef = useRef(null); const selectionModelRef = useRef(createTerminalSelectionModel()); - const selectionLongPressTimeoutRef = useRef | null>(null); - const selectionPressRef = useRef(null); + const selectionControllerRef = useRef | null>(null); + const gestureStartRef = useRef({ x: 0, y: 0 }); + const [cellMetrics, setCellMetrics] = useState(null); const latestResolvedScreenRef = useRef(null); const paintFrameRef = useRef(null); const outputDecoderRef = useRef(new TextDecoder()); - const panStateRef = useRef({ - lastDy: 0, - rowRemainder: 0, - didScroll: false, - didNavigate: false, - movedBeyondTapTolerance: false, - intent: null, - }); const inputModeTrackerRef = useRef(new TerminalInputModeTracker()); const lastInputModeStateRef = useRef({ ...inputModeTrackerRef.current.getState(), @@ -245,6 +204,7 @@ function NativeTerminalEmulator({ }, []); const clearSelection = useCallback(() => { + selectionControllerRef.current?.cancel(); const snapshot = selectionModelRef.current.clear(); commitSelectionRange(snapshot.range); }, [commitSelectionRange]); @@ -255,20 +215,14 @@ function NativeTerminalEmulator({ const syncSelectionWithTerminal = useCallback( (terminal: NativeHeadlessTerminal) => { + const previous = selectionModelRef.current.getSnapshot().range; const snapshot = selectionModelRef.current.sync({ bounds: terminal.getBufferBounds() }); + if (previous && !snapshot.range) selectionControllerRef.current?.cancel(); commitSelectionRange(snapshot.range); }, [commitSelectionRange], ); - const clearSelectionLongPressTimeout = useCallback(() => { - if (selectionLongPressTimeoutRef.current === null) { - return; - } - clearTimeout(selectionLongPressTimeoutRef.current); - selectionLongPressTimeoutRef.current = null; - }, []); - const resolveVisibleRows = useCallback((terminal: NativeHeadlessTerminal): number => { return ( resolveMeasuredNativeTerminalSize({ @@ -523,8 +477,7 @@ function NativeTerminalEmulator({ emitMeasuredSize({ source: "measure" }); return () => { callbacksRef.current.onRendererReadyChange?.({ streamKey, isReady: false }); - clearSelectionLongPressTimeout(); - selectionPressRef.current = null; + selectionControllerRef.current?.cancel(); if (paintFrameRef.current !== null) { cancelAnimationFrame(paintFrameRef.current); paintFrameRef.current = null; @@ -534,7 +487,7 @@ function NativeTerminalEmulator({ terminalRef.current?.dispose(); terminalRef.current = null; }; - }, [clearSelectionLongPressTimeout, emitMeasuredSize, resetTerminal, streamKey]); + }, [emitMeasuredSize, resetTerminal, streamKey]); useEffect(() => { if (focusRequestToken <= 0) return; @@ -591,6 +544,7 @@ function NativeTerminalEmulator({ const handleCellMetricsChange = useCallback( (metrics: TerminalGridCellMetrics) => { metricsRef.current = metrics; + setCellMetrics(metrics); emitMeasuredSize({ source: "measure" }); }, [emitMeasuredSize], @@ -663,14 +617,6 @@ function NativeTerminalEmulator({ if (!coordinate) { return; } - const existingPress = selectionPressRef.current; - selectionPressRef.current = { - startX: point.x, - startY: point.y, - startedAt: existingPress?.startedAt ?? Date.now(), - startedWithSelection: existingPress?.startedWithSelection ?? false, - status: "selecting", - }; const bounds = terminal.getBufferBounds(); const wordSelection = resolveTerminalWordSelection({ terminal, coordinate }); let snapshot = selectionModelRef.current.begin({ @@ -685,222 +631,61 @@ function NativeTerminalEmulator({ [commitSelectionRange, resolveSelectionCoordinate], ); - const updateSelectionAtPoint = useCallback( - (point: { x: number; y: number }) => { - const terminal = terminalRef.current; - if (!terminal || selectionPressRef.current?.status !== "selecting") { - return; - } - const coordinate = resolveSelectionCoordinate(point); - if (!coordinate) { - return; - } - const snapshot = selectionModelRef.current.update({ - coordinate, - bounds: terminal.getBufferBounds(), - }); - commitSelectionRange(snapshot.range); - }, - [commitSelectionRange, resolveSelectionCoordinate], - ); - - const handlePanResponderScroll = useCallback( - (currentDy: number) => { - const cellHeight = metricsRef.current?.cellHeight; - if (!cellHeight || cellHeight <= 0) { - return; - } - const panState = panStateRef.current; - const update = advanceTerminalScrollGesture({ - state: panState, - currentDy, - cellHeight, - }); - panState.lastDy = update.state.lastDy; - panState.rowRemainder = update.state.rowRemainder; - panState.didScroll = update.state.didScroll; - const { rowDelta } = update; - if (rowDelta === 0) { - return; - } - if (rowDelta > 0) { - applyScroll("up", rowDelta); - return; - } - applyScroll("down", Math.abs(rowDelta)); - }, - [applyScroll], + const selectionController = useMemo( + () => + createTerminalSelectionController({ + getGeometry: () => { + const viewport = resolveSelectionViewport(); + const metrics = metricsRef.current; + return viewport && metrics + ? { range: selectionRangeRef.current, viewport, metrics } + : null; + }, + onBeginHandle: (endpoint, cols) => + selectionModelRef.current.beginEndpointDrag(endpoint, cols), + onMoveHandle: (coordinate) => { + const terminal = terminalRef.current; + if (!terminal) return; + commitSelectionRange( + selectionModelRef.current.update({ coordinate, bounds: terminal.getBufferBounds() }) + .range, + ); + }, + onLongPress: beginSelectionAtPoint, + onTap: focusTerminalFromTap, + onScroll: applyScroll, + schedule: (callback, delay) => setTimeout(callback, delay) as unknown as number, + cancel: (timer) => clearTimeout(timer), + }), + [ + applyScroll, + beginSelectionAtPoint, + commitSelectionRange, + focusTerminalFromTap, + resolveSelectionViewport, + ], ); + selectionControllerRef.current = selectionController; const panResponder = useMemo( () => PanResponder.create({ onStartShouldSetPanResponder: () => true, onStartShouldSetPanResponderCapture: () => true, - onMoveShouldSetPanResponder: (_event, gesture) => { - const intent = classifyTerminalGestureIntent({ - status: selectionRangeRef.current ? "selecting" : "pressing", - dx: gesture.dx, - dy: gesture.dy, - vx: gesture.vx, - vy: gesture.vy, - }); - return intent === "scroll" || intent === "select"; - }, - onMoveShouldSetPanResponderCapture: (_event, gesture) => { - const intent = classifyTerminalGestureIntent({ - status: selectionRangeRef.current ? "selecting" : "pressing", - dx: gesture.dx, - dy: gesture.dy, - vx: gesture.vx, - vy: gesture.vy, - }); - return intent === "scroll" || intent === "select"; - }, onPanResponderGrant: (event) => { - panStateRef.current = { - lastDy: 0, - rowRemainder: 0, - didScroll: false, - didNavigate: false, - movedBeyondTapTolerance: false, - intent: null, - }; - const point = { - x: event.nativeEvent.locationX, - y: event.nativeEvent.locationY, - }; - const pressStatus = selectionRangeRef.current ? "selecting" : "pressing"; - selectionPressRef.current = { - startX: point.x, - startY: point.y, - startedAt: Date.now(), - startedWithSelection: pressStatus === "selecting", - status: pressStatus, - }; - clearSelectionLongPressTimeout(); - if (pressStatus === "selecting") { - return; - } - selectionLongPressTimeoutRef.current = setTimeout(() => { - selectionLongPressTimeoutRef.current = null; - const press = selectionPressRef.current; - if (press?.status !== "pressing") { - return; - } - beginSelectionAtPoint({ x: press.startX, y: press.startY }); - }, TERMINAL_GESTURE_LONG_PRESS_MS); - }, - onPanResponderMove: (event, gesture) => { - const selectionPress = selectionPressRef.current; - const panState = panStateRef.current; - if (Math.hypot(gesture.dx, gesture.dy) > TERMINAL_GESTURE_TAP_TOLERANCE_PX) { - panState.movedBeyondTapTolerance = true; - } - if (selectionPress?.status === "selecting") { - if (panState.movedBeyondTapTolerance) { - updateSelectionAtPoint(resolvePanResponderPoint(selectionPress, event, gesture)); - } - return; - } - - if (panState.intent === "scroll") { - handlePanResponderScroll(gesture.dy); - return; - } - if (panState.didNavigate) { - return; - } - - if (selectionPress?.status === "pressing") { - const point = resolvePanResponderPoint(selectionPress, event, gesture); - const dx = point.x - selectionPress.startX; - const dy = point.y - selectionPress.startY; - const intent = classifyTerminalGestureIntent({ - status: "pressing", - dx, - dy, - vx: gesture.vx, - vy: gesture.vy, - elapsedMs: Date.now() - selectionPress.startedAt, - }); - if (intent === "tap" || intent === "pending") { - return; - } - - clearSelectionLongPressTimeout(); - panState.intent = intent; - - if (intent === "scroll") { - selectionPressRef.current = null; - handlePanResponderScroll(gesture.dy); - return; - } - - if (intent === "select") { - beginSelectionAtPoint({ x: selectionPress.startX, y: selectionPress.startY }); - updateSelectionAtPoint(point); - return; - } - } - }, - onPanResponderRelease: (event, gesture) => { - clearSelectionLongPressTimeout(); - const selectionPress = selectionPressRef.current; - if ( - selectionPress?.status === "selecting" && - panStateRef.current.movedBeyondTapTolerance - ) { - updateSelectionAtPoint(resolvePanResponderPoint(selectionPress, event, gesture)); - } - selectionPressRef.current = null; - const { didScroll, didNavigate, movedBeyondTapTolerance } = panStateRef.current; - panStateRef.current = { - lastDy: 0, - rowRemainder: 0, - didScroll: false, - didNavigate: false, - movedBeyondTapTolerance: false, - intent: null, - }; - const releaseAction = resolveTerminalGestureReleaseAction({ - status: selectionPress?.status ?? "idle", - startedWithSelection: selectionPress?.startedWithSelection, - didScroll, - didNavigate, - movedBeyondTapTolerance, - pressDurationMs: selectionPress ? Date.now() - selectionPress.startedAt : 0, - longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, - scrollMode: terminalScreenRef.current?.scroll.mode ?? "following", - }); - if (releaseAction === "select" && selectionPress) { - beginSelectionAtPoint({ x: selectionPress.startX, y: selectionPress.startY }); - return; - } - if (releaseAction === "focus" || releaseAction === "clear") { - focusTerminalFromTap(); - } - }, - onPanResponderTerminate: () => { - clearSelectionLongPressTimeout(); - selectionPressRef.current = null; - panStateRef.current = { - lastDy: 0, - rowRemainder: 0, - didScroll: false, - didNavigate: false, - movedBeyondTapTolerance: false, - intent: null, - }; + const point = { x: event.nativeEvent.locationX, y: event.nativeEvent.locationY }; + gestureStartRef.current = point; + selectionController.press(point); }, + onPanResponderMove: (_event, gesture) => + selectionController.move({ + x: gestureStartRef.current.x + gesture.dx, + y: gestureStartRef.current.y + gesture.dy, + }), + onPanResponderRelease: () => selectionController.release(), + onPanResponderTerminate: () => selectionController.cancel(), }), - [ - beginSelectionAtPoint, - clearSelectionLongPressTimeout, - focusTerminalFromTap, - handlePanResponderScroll, - updateSelectionAtPoint, - ], + [selectionController], ); const backgroundColor = xtermTheme.background ?? "#0b0b0b"; @@ -911,6 +696,11 @@ function NativeTerminalEmulator({ const terminalState = terminalScreen?.viewport ?? null; const isScrolled = terminalScreen?.scroll.mode === "scrolled"; + const viewport = resolveSelectionViewport(); + const handles = + viewport && cellMetrics + ? terminalSelectionHandles({ range: selectionRange, viewport, metrics: cellMetrics }) + : []; const terminalGrid = terminalState ? ( {terminalGrid} + + {handles.map((handle) => ( + + + + + + + ))} + { + it("preserves the grab offset while moving a handle", () => { + const range = { start: { row: 1, col: 2 }, end: { row: 1, col: 5 }, coordinateEpoch: 0 }; + const moves: Array<{ row: number; col: number }> = []; + const controller = createTerminalSelectionController({ + getGeometry: () => ({ + range, + viewport: { firstRow: 0, rows: 10, cols: 10 }, + metrics: { cellWidth: 10, cellHeight: 10 }, + }), + onBeginHandle: () => {}, + onMoveHandle: (coordinate) => moves.push(coordinate), + onLongPress: () => {}, + onTap: () => {}, + onScroll: () => {}, + schedule: () => 1, + cancel: () => {}, + }); + controller.press({ x: 23, y: 15 }); + controller.move({ x: 63, y: 15 }); + controller.release(); + expect(moves).toEqual([{ row: 1, col: 5 }]); + }); +}); + +it("selects on hold, preserves a handle tap, and cancels an active drag", () => { + const actions: string[] = []; + let timer: (() => void) | null = null; + const controller = createTerminalSelectionController({ + getGeometry: () => ({ + range: { start: { row: 2, col: 2 }, end: { row: 2, col: 5 }, coordinateEpoch: 0 }, + viewport: { firstRow: 0, rows: 10, cols: 10 }, + metrics: { cellWidth: 10, cellHeight: 10 }, + }), + onBeginHandle: (endpoint) => actions.push(endpoint), + onMoveHandle: () => actions.push("move"), + onLongPress: () => actions.push("long"), + onTap: () => actions.push("tap"), + onScroll: () => actions.push("scroll"), + schedule: (callback) => { + timer = callback; + return 1; + }, + cancel: () => { + timer = null; + }, + }); + controller.press({ x: 90, y: 80 }); + (timer as (() => void) | null)?.(); + controller.release(); + controller.press({ x: 20, y: 25 }); + controller.release(); + controller.press({ x: 20, y: 25 }); + controller.cancel(); + controller.move({ x: 50, y: 25 }); + expect(actions).toEqual(["long", "start", "start"]); +}); + +it("scrolls selection into history while holding a handle at the edge", () => { + let firstRow = 10; + let timer: (() => void) | null = null; + const coordinates: Array<{ row: number; col: number }> = []; + const controller = createTerminalSelectionController({ + getGeometry: () => ({ + range: { start: { row: 12, col: 2 }, end: { row: 13, col: 5 }, coordinateEpoch: 0 }, + viewport: { firstRow, rows: 10, cols: 10 }, + metrics: { cellWidth: 10, cellHeight: 10 }, + }), + onBeginHandle: () => {}, + onMoveHandle: (point) => coordinates.push(point), + onLongPress: () => {}, + onTap: () => {}, + onScroll: (direction) => { + if (direction === "up") firstRow--; + }, + schedule: (callback) => { + timer = callback; + return 1; + }, + cancel: () => { + timer = null; + }, + }); + controller.press({ x: 20, y: 25 }); + controller.move({ x: 20, y: 1 }); + (timer as (() => void) | null)?.(); + (timer as (() => void) | null)?.(); + controller.release(); + expect(coordinates).toEqual([ + { row: 10, col: 2 }, + { row: 9, col: 2 }, + { row: 8, col: 2 }, + ]); + expect(timer).toBeNull(); +}); + +it("scrolls outside handles without clearing selection and clears on an outside tap", () => { + const actions: string[] = []; + const controller = createTerminalSelectionController({ + getGeometry: () => ({ + range: { start: { row: 2, col: 2 }, end: { row: 2, col: 5 }, coordinateEpoch: 0 }, + viewport: { firstRow: 0, rows: 10, cols: 10 }, + metrics: { cellWidth: 10, cellHeight: 10 }, + }), + onBeginHandle: () => actions.push("handle"), + onMoveHandle: () => actions.push("move"), + onLongPress: () => actions.push("long"), + onTap: () => actions.push("tap"), + onScroll: (direction, rows) => actions.push(`${direction}:${rows}`), + schedule: () => 1, + cancel: () => {}, + }); + controller.press({ x: 95, y: 70 }); + controller.move({ x: 95, y: 90 }); + controller.release(); + controller.press({ x: 95, y: 70 }); + controller.release(); + expect(actions).toEqual(["up:2", "tap"]); +}); + +it("extends the selection after a long press and drag", () => { + let timer: (() => void) | null = null; + const moves: Array<{ row: number; col: number }> = []; + const controller = createTerminalSelectionController({ + getGeometry: () => ({ + range: null, + viewport: { firstRow: 0, rows: 10, cols: 10 }, + metrics: { cellWidth: 10, cellHeight: 10 }, + }), + onBeginHandle: () => {}, + onMoveHandle: (coordinate) => moves.push(coordinate), + onLongPress: () => {}, + onTap: () => {}, + onScroll: () => {}, + schedule: (callback) => { + timer = callback; + return 1; + }, + cancel: () => { + timer = null; + }, + }); + controller.press({ x: 25, y: 25 }); + (timer as (() => void) | null)?.(); + controller.move({ x: 65, y: 35 }); + controller.release(); + expect(moves).toEqual([{ row: 3, col: 6 }]); +}); + +it.each([ + { + endpoint: "start" as const, + startX: 20, + crossedX: 80, + crossedText: "gh", + crossedStart: 6, + crossedEnd: 7, + backX: 10, + backText: "bcdef", + backStart: 1, + backEnd: 5, + }, + { + endpoint: "end" as const, + startX: 60, + crossedX: 10, + crossedText: "b", + crossedStart: 1, + crossedEnd: 1, + backX: 80, + backText: "cdefgh", + backStart: 2, + backEnd: 7, + }, +])("tracks the $endpoint handle through crossing and back", async (scenario) => { + const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 10 }); + try { + await terminal.write("abcdefghij"); + const bounds = terminal.getBufferBounds(); + const model = createTerminalSelectionModel(); + model.begin({ coordinate: { row: 0, col: 2 }, bounds }); + model.update({ coordinate: { row: 0, col: 5 }, bounds }); + const viewport = { firstRow: 0, rows: 4, cols: 10 }; + const metrics = { cellWidth: 10, cellHeight: 10 }; + const geometry = () => ({ range: model.getSnapshot().range, viewport, metrics }); + const controller = createTerminalSelectionController({ + getGeometry: geometry, + onBeginHandle: (endpoint, cols) => model.beginEndpointDrag(endpoint, cols), + onMoveHandle: (coordinate) => { + model.update({ coordinate, bounds }); + }, + onLongPress: () => {}, + onTap: () => {}, + onScroll: () => {}, + schedule: () => 1, + cancel: () => {}, + }); + const check = (start: number, end: number, text: string) => { + const range = model.getSnapshot().range; + expect(range).toEqual({ + start: { row: 0, col: start }, + end: { row: 0, col: end }, + coordinateEpoch: bounds.coordinateEpoch, + }); + expect(terminalSelectionHandles(geometry())).toMatchObject([ + { endpoint: "start", x: start * 10, y: 5 }, + { endpoint: "end", x: (end + 1) * 10, y: 5 }, + ]); + expect(resolveTerminalSelectionRects({ selection: range, viewport, metrics })).toEqual([ + { + key: `0:${start}:${end}`, + x: start * 10, + y: 0, + width: (end - start + 1) * 10, + height: 10, + }, + ]); + expect(extractTerminalSelectedText({ terminal, selection: range })).toBe(text); + }; + controller.press({ x: scenario.startX, y: 5 }); + controller.move({ x: scenario.crossedX, y: 5 }); + check(scenario.crossedStart, scenario.crossedEnd, scenario.crossedText); + controller.move({ x: scenario.backX, y: 5 }); + check(scenario.backStart, scenario.backEnd, scenario.backText); + controller.release(); + } finally { + terminal.dispose(); + } +}); + +it.each([ + { + endpoint: "end" as const, + initialRow: 0, + targetRow: 1, + targetX: 0, + expectedRow: 0, + expectedCol: 9, + text: "cdefghij", + }, + { + endpoint: "start" as const, + initialRow: 1, + targetRow: 0, + targetX: 100, + expectedRow: 1, + expectedCol: 0, + text: "KLMNOP", + }, +])("preserves a $endpoint boundary at a row edge", async (scenario) => { + const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 10 }); + try { + await terminal.write("abcdefghij\r\nKLMNOPQRST"); + const bounds = terminal.getBufferBounds(); + const model = createTerminalSelectionModel(); + model.begin({ coordinate: { row: scenario.initialRow, col: 2 }, bounds }); + model.update({ coordinate: { row: scenario.initialRow, col: 5 }, bounds }); + const viewport = { firstRow: 0, rows: 4, cols: 10 }; + const metrics = { cellWidth: 10, cellHeight: 10 }; + const geometry = () => ({ range: model.getSnapshot().range, viewport, metrics }); + const controller = createTerminalSelectionController({ + getGeometry: geometry, + onBeginHandle: (endpoint, cols) => model.beginEndpointDrag(endpoint, cols), + onMoveHandle: (coordinate) => { + model.update({ coordinate, bounds }); + }, + onLongPress: () => {}, + onTap: () => {}, + onScroll: () => {}, + schedule: () => 1, + cancel: () => {}, + }); + controller.press({ x: scenario.endpoint === "end" ? 60 : 20, y: scenario.initialRow * 10 + 5 }); + controller.move({ x: scenario.targetX, y: scenario.targetRow * 10 + 5 }); + const range = model.getSnapshot().range; + expect(range?.[scenario.endpoint]).toEqual({ + row: scenario.expectedRow, + col: scenario.expectedCol, + }); + expect(extractTerminalSelectedText({ terminal, selection: range })).toBe(scenario.text); + expect( + terminalSelectionHandles(geometry()).find((handle) => handle.endpoint === scenario.endpoint), + ).toMatchObject({ + x: scenario.endpoint === "end" ? 100 : 0, + y: scenario.expectedRow * 10 + 5, + }); + controller.release(); + } finally { + terminal.dispose(); + } +}); + +it("cancels a drag when buffer coordinates are invalidated", () => { + const model = createTerminalSelectionModel(); + const bounds = { oldestRow: 0, newestRow: 9, coordinateEpoch: 0 }; + model.begin({ coordinate: { row: 2, col: 2 }, bounds }); + model.update({ coordinate: { row: 2, col: 5 }, bounds }); + const viewport = { firstRow: 0, rows: 10, cols: 10 }; + const metrics = { cellWidth: 10, cellHeight: 10 }; + const controller = createTerminalSelectionController({ + getGeometry: () => ({ range: model.getSnapshot().range, viewport, metrics }), + onBeginHandle: (endpoint, cols) => model.beginEndpointDrag(endpoint, cols), + onMoveHandle: (coordinate) => { + model.update({ coordinate, bounds }); + }, + onLongPress: () => {}, + onTap: () => {}, + onScroll: () => {}, + schedule: () => 1, + cancel: () => {}, + }); + controller.press({ x: 20, y: 25 }); + const previous = model.getSnapshot().range; + const snapshot = model.sync({ bounds: { ...bounds, coordinateEpoch: 1 } }); + if (previous && !snapshot.range) controller.cancel(); + controller.move({ x: 80, y: 25 }); + controller.release(); + expect(model.getSnapshot().range).toBeNull(); +}); + +it("preserves a newly selected word through small movement at the viewport edge", () => { + const model = createTerminalSelectionModel(); + const bounds = { oldestRow: 0, newestRow: 9, coordinateEpoch: 0 }; + let timer: (() => void) | null = null; + const controller = createTerminalSelectionController({ + getGeometry: () => ({ + range: model.getSnapshot().range, + viewport: { firstRow: 0, rows: 10, cols: 20 }, + metrics: { cellWidth: 10, cellHeight: 10 }, + }), + onBeginHandle: (endpoint, cols) => model.beginEndpointDrag(endpoint, cols), + onMoveHandle: (coordinate) => { + model.update({ coordinate, bounds }); + }, + onLongPress: () => { + model.begin({ coordinate: { row: 0, col: 2 }, bounds }); + model.update({ coordinate: { row: 0, col: 6 }, bounds }); + }, + onTap: () => {}, + onScroll: () => {}, + schedule: (callback) => { + timer = callback; + return 1; + }, + cancel: () => { + timer = null; + }, + }); + controller.press({ x: 45, y: 5 }); + const fire = timer as (() => void) | null; + timer = null; + fire?.(); + controller.move({ x: 44, y: 4 }); + controller.release(); + expect(model.getSnapshot().range).toEqual({ + start: { row: 0, col: 2 }, + end: { row: 0, col: 6 }, + coordinateEpoch: 0, + }); + expect(timer).toBeNull(); +}); + +it("places mirrored teardrop grips below the selected row", () => { + const handles = terminalSelectionHandles({ + range: { start: { row: 2, col: 2 }, end: { row: 2, col: 5 }, coordinateEpoch: 0 }, + viewport: { firstRow: 0, rows: 10, cols: 10 }, + metrics: { cellWidth: 10, cellHeight: 10 }, + }); + expect(handles).toMatchObject([ + { endpoint: "start", x: 20, y: 25, gripX: 11, gripY: 39, gripDirection: "left" }, + { endpoint: "end", x: 60, y: 25, gripX: 69, gripY: 39, gripDirection: "right" }, + ]); +}); + +it("keeps edge grips reachable and preserves the offset from the cell when dragging a grip", () => { + const range = { start: { row: 0, col: 0 }, end: { row: 9, col: 9 }, coordinateEpoch: 0 }; + const viewport = { firstRow: 0, rows: 10, cols: 10 }; + const metrics = { cellWidth: 10, cellHeight: 10 }; + const handles = terminalSelectionHandles({ range, viewport, metrics }); + expect(handles.map(({ gripX, gripY }) => ({ gripX, gripY }))).toEqual([ + { gripX: 9, gripY: 19 }, + { gripX: 91, gripY: 81 }, + ]); + const moves: Array<{ row: number; col: number }> = []; + const controller = createTerminalSelectionController({ + getGeometry: () => ({ range, viewport, metrics }), + onBeginHandle: () => {}, + onMoveHandle: (coordinate) => moves.push(coordinate), + onLongPress: () => {}, + onTap: () => {}, + onScroll: () => {}, + schedule: () => 1, + cancel: () => {}, + }); + controller.press({ x: 9, y: 19 }); + controller.move({ x: 19, y: 29 }); + controller.release(); + expect(moves).toEqual([{ row: 1, col: 1 }]); +}); diff --git a/packages/app/src/terminal/native-renderer/terminal-selection-controller.ts b/packages/app/src/terminal/native-renderer/terminal-selection-controller.ts new file mode 100644 index 0000000000..721fd501d7 --- /dev/null +++ b/packages/app/src/terminal/native-renderer/terminal-selection-controller.ts @@ -0,0 +1,208 @@ +import type { TerminalGridCellMetrics } from "./terminal-grid-metrics"; +import type { + TerminalBufferCoordinate, + TerminalSelectionPoint, + TerminalSelectionRange, + TerminalSelectionViewport, +} from "./terminal-selection"; +export const TERMINAL_SELECTION_GRIP_SIZE = 18; +const TERMINAL_GESTURE_LONG_PRESS_MS = 450; +const TERMINAL_GESTURE_TAP_TOLERANCE_PX = 8; + +type Endpoint = "start" | "end"; +interface Geometry { + range: TerminalSelectionRange | null; + viewport: TerminalSelectionViewport; + metrics: TerminalGridCellMetrics; +} +interface Ports { + getGeometry(): Geometry | null; + onBeginHandle(endpoint: Endpoint, cols: number): void; + onMoveHandle(coordinate: TerminalBufferCoordinate): void; + onLongPress(point: TerminalSelectionPoint): void; + onTap(): void; + onScroll(direction: "up" | "down", rows: number): void; + schedule(callback: () => void, delay: number): number; + cancel(timer: number): void; +} + +export function terminalSelectionHandles(geometry: Geometry): Array<{ + endpoint: Endpoint; + x: number; + y: number; + gripX: number; + gripY: number; + gripDirection: "left" | "right"; + gripAbove: boolean; + stemTop: number; + stemHeight: number; +}> { + const { range, viewport, metrics } = geometry; + if (!range) return []; + return (["start", "end"] as const).flatMap((endpoint) => { + const cell = range[endpoint]; + if (cell.row < viewport.firstRow || cell.row >= viewport.firstRow + viewport.rows) return []; + const x = (cell.col + (endpoint === "end" ? 1 : 0)) * metrics.cellWidth; + const stemTop = (cell.row - viewport.firstRow) * metrics.cellHeight; + const size = TERMINAL_SELECTION_GRIP_SIZE; + const width = viewport.cols * metrics.cellWidth; + const height = viewport.rows * metrics.cellHeight; + const wantsLeft = endpoint === "start"; + const gripDirection = + (wantsLeft && x >= size) || (!wantsLeft && x > width - size) ? "left" : "right"; + const gripAbove = stemTop + metrics.cellHeight + size > height; + const gripTop = gripAbove ? Math.max(0, stemTop - size) : stemTop + metrics.cellHeight; + return [ + { + endpoint, + x, + y: stemTop + metrics.cellHeight / 2, + gripX: x + (gripDirection === "left" ? -size / 2 : size / 2), + gripY: gripTop + size / 2, + gripDirection, + gripAbove, + stemTop, + stemHeight: metrics.cellHeight, + }, + ]; + }); +} + +export function createTerminalSelectionController(ports: Ports) { + let press: { + start: TerminalSelectionPoint; + point: TerminalSelectionPoint; + endpoint: Endpoint | null; + offset: TerminalSelectionPoint; + fixed: TerminalBufferCoordinate | null; + moved: boolean; + long: boolean; + scrolling: boolean; + remainder: number; + } | null = null; + let timer: number | null = null; + function stopTimer() { + if (timer !== null) ports.cancel(timer); + timer = null; + } + function updateHandle() { + const geometry = ports.getGeometry(); + if (!press || (!press.endpoint && !press.long) || !geometry) return; + const { viewport, metrics } = geometry; + const x = press.point.x - press.offset.x; + const y = press.point.y - press.offset.y; + let row = + viewport.firstRow + + Math.max(0, Math.min(viewport.rows - 1, Math.floor(y / metrics.cellHeight))); + let col = Math.floor(x / metrics.cellWidth); + if (press.endpoint && press.fixed) { + const boundary = Math.round(x / metrics.cellWidth); + const isEnd = + row > press.fixed.row || (row === press.fixed.row && boundary > press.fixed.col); + col = boundary - (isEnd ? 1 : 0); + if (col < 0 && isEnd) { + row -= 1; + col = viewport.cols - 1; + } else if (col >= viewport.cols && !isEnd) { + row += 1; + col = 0; + } + } + col = Math.max(0, Math.min(viewport.cols - 1, col)); + ports.onMoveHandle({ row, col }); + } + function edgeTick() { + timer = null; + const geometry = ports.getGeometry(); + if (!press || (!press.endpoint && !press.long) || !geometry) return; + const y = press.point.y - press.offset.y; + const height = geometry.viewport.rows * geometry.metrics.cellHeight; + let direction: "up" | "down" | null = null; + if (y < geometry.metrics.cellHeight) direction = "up"; + else if (y > height - geometry.metrics.cellHeight) direction = "down"; + if (!direction) return; + ports.onScroll(direction, 1); + if (!press) return; + updateHandle(); + if (press) timer = ports.schedule(edgeTick, 100); + } + return { + press(point: TerminalSelectionPoint) { + stopTimer(); + const geometry = ports.getGeometry(); + const handles = geometry ? terminalSelectionHandles(geometry) : []; + const handle = handles + .map((item) => ({ + endpoint: item.endpoint, + x: item.x, + y: item.y, + distance: Math.hypot(point.x - item.gripX, point.y - item.gripY), + })) + .filter((item) => item.distance <= 28) + .sort((a, b) => a.distance - b.distance)[0]; + press = { + start: point, + point, + endpoint: handle?.endpoint ?? null, + offset: handle ? { x: point.x - handle.x, y: point.y - handle.y } : { x: 0, y: 0 }, + fixed: + handle && geometry?.range + ? geometry.range[handle.endpoint === "start" ? "end" : "start"] + : null, + moved: false, + long: false, + scrolling: false, + remainder: 0, + }; + if (handle && geometry) { + ports.onBeginHandle(handle.endpoint, geometry.viewport.cols); + return; + } + timer = ports.schedule(() => { + timer = null; + if (press && !press.moved) { + press.long = true; + ports.onLongPress(press.start); + } + }, TERMINAL_GESTURE_LONG_PRESS_MS); + }, + move(point: TerminalSelectionPoint) { + if (!press) return; + const previous = press.point; + press.point = point; + const dx = point.x - press.start.x, + dy = point.y - press.start.y; + if (Math.hypot(dx, dy) > TERMINAL_GESTURE_TAP_TOLERANCE_PX) { + press.moved = true; + if (!press.endpoint) stopTimer(); + } + if (press.endpoint || press.long) { + if (press.endpoint || press.moved) { + updateHandle(); + stopTimer(); + if (press) timer = ports.schedule(edgeTick, 100); + } + } else if (!press.long) { + if (!press.scrolling && Math.abs(dy) > 12 && Math.abs(dy) > Math.abs(dx)) { + press.scrolling = true; + press.remainder = dy; + } else if (press.scrolling) press.remainder += point.y - previous.y; + const height = ports.getGeometry()?.metrics.cellHeight ?? 0; + if (press.scrolling && height > 0) { + const rows = Math.trunc(press.remainder / height); + press.remainder -= rows * height; + if (rows) ports.onScroll(rows > 0 ? "up" : "down", Math.abs(rows)); + } + } + }, + release() { + stopTimer(); + if (press && !press.endpoint && !press.long && !press.moved) ports.onTap(); + press = null; + }, + cancel() { + stopTimer(); + press = null; + }, + }; +} diff --git a/packages/app/src/terminal/native-renderer/terminal-selection-gesture.test.ts b/packages/app/src/terminal/native-renderer/terminal-selection-gesture.test.ts deleted file mode 100644 index aed6857d5c..0000000000 --- a/packages/app/src/terminal/native-renderer/terminal-selection-gesture.test.ts +++ /dev/null @@ -1,354 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - TERMINAL_GESTURE_LONG_PRESS_MS, - TERMINAL_GESTURE_TAP_TOLERANCE_PX, - TERMINAL_GESTURE_VERTICAL_SCROLL_THRESHOLD_PX, - advanceTerminalScrollGesture, - classifyTerminalGestureIntent, - resolveTerminalGestureReleaseAction, -} from "./terminal-selection-gesture"; - -describe("native terminal gesture policy", () => { - describe("advanceTerminalScrollGesture", () => { - it("keeps consuming a slow drag after scroll intent arms below one row", () => { - let state = { lastDy: 0, rowRemainder: 0, didScroll: false }; - const rowDeltas: number[] = []; - - for (const currentDy of [13, 15, 18, 22, 28]) { - const update = advanceTerminalScrollGesture({ - state, - currentDy, - cellHeight: 14, - }); - state = update.state; - rowDeltas.push(update.rowDelta); - } - - expect({ rowDeltas, state }).toEqual({ - rowDeltas: [0, 1, 0, 0, 1], - state: { lastDy: 28, rowRemainder: 0, didScroll: true }, - }); - }); - }); - - describe("classifyTerminalGestureIntent", () => { - it("treats a motionless press as a tap", () => { - expect( - classifyTerminalGestureIntent({ - status: "pressing", - dx: 0, - dy: 0, - vx: 0, - vy: 0, - }), - ).toEqual("tap"); - }); - - it("treats a tiny jitter within tap tolerance as a tap", () => { - expect( - classifyTerminalGestureIntent({ - status: "pressing", - dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX - 4, - dy: TERMINAL_GESTURE_TAP_TOLERANCE_PX - 4, - vx: 0, - vy: 0, - }), - ).toEqual("tap"); - }); - - it("leaves horizontal movement available to terminal selection", () => { - expect( - classifyTerminalGestureIntent({ - status: "pressing", - dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX + 20, - dy: 4, - vx: 0, - vy: 0, - }), - ).toEqual("pending"); - }); - - it("does not infer selection from slow horizontal drag dwell", () => { - expect( - classifyTerminalGestureIntent({ - status: "pressing", - dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX + 40, - dy: 3, - vx: 0, - vy: 0, - elapsedMs: TERMINAL_GESTURE_LONG_PRESS_MS + 50, - }), - ).toEqual("pending"); - }); - - it("keeps tiny horizontal jitter inside tap tolerance as a tap", () => { - expect( - classifyTerminalGestureIntent({ - status: "pressing", - dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX - 1, - dy: 1, - vx: 2, - vy: 0, - elapsedMs: 20, - }), - ).toEqual("tap"); - }); - - it("keeps horizontal movement below navigation distance pending", () => { - expect( - classifyTerminalGestureIntent({ - status: "pressing", - dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX + 10, - dy: 2, - vx: 0, - vy: 0, - }), - ).toEqual("pending"); - }); - - it("treats a clearly vertical drag as scroll", () => { - expect( - classifyTerminalGestureIntent({ - status: "pressing", - dx: 4, - dy: TERMINAL_GESTURE_VERTICAL_SCROLL_THRESHOLD_PX + 5, - vx: 0, - vy: 0.2, - }), - ).toEqual("scroll"); - }); - - it("does not navigate on a fast rightward swipe", () => { - expect( - classifyTerminalGestureIntent({ - status: "pressing", - dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX + 40, - dy: 4, - vx: 2, - vy: 0, - }), - ).toEqual("pending"); - }); - - it("does not navigate on a slow rightward swipe", () => { - expect( - classifyTerminalGestureIntent({ - status: "pressing", - dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX + 40, - dy: 4, - vx: 0, - vy: 0, - }), - ).toEqual("pending"); - }); - - it("does not navigate on a fast leftward swipe", () => { - expect( - classifyTerminalGestureIntent({ - status: "pressing", - dx: -(TERMINAL_GESTURE_TAP_TOLERANCE_PX + 40), - dy: -4, - vx: -2, - vy: 0, - }), - ).toEqual("pending"); - }); - - it("does not navigate while movement stays inside tap tolerance", () => { - expect( - classifyTerminalGestureIntent({ - status: "pressing", - dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX - 1, - dy: 2, - vx: 2, - vy: 0, - }), - ).toEqual("tap"); - }); - - it("keeps selection intent once selection is active", () => { - expect( - classifyTerminalGestureIntent({ - status: "selecting", - dx: 100, - dy: 50, - vx: 2, - vy: 1, - }), - ).toEqual("select"); - }); - }); - - describe("resolveTerminalGestureReleaseAction", () => { - it("focuses the terminal after a normal tap", () => { - expect( - resolveTerminalGestureReleaseAction({ - status: "pressing", - didScroll: false, - didNavigate: false, - movedBeyondTapTolerance: false, - pressDurationMs: 80, - longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, - scrollMode: "following", - }), - ).toEqual("focus"); - }); - - it("focuses the terminal after a normal tap while scrolled", () => { - expect( - resolveTerminalGestureReleaseAction({ - status: "pressing", - didScroll: false, - didNavigate: false, - movedBeyondTapTolerance: false, - pressDurationMs: 80, - longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, - scrollMode: "scrolled", - }), - ).toEqual("focus"); - }); - - it("starts selection after a long press", () => { - expect( - resolveTerminalGestureReleaseAction({ - status: "pressing", - didScroll: false, - didNavigate: false, - movedBeyondTapTolerance: false, - pressDurationMs: TERMINAL_GESTURE_LONG_PRESS_MS + 50, - longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, - scrollMode: "following", - }), - ).toEqual("select"); - }); - - it("starts selection after a 500ms hold", () => { - expect( - resolveTerminalGestureReleaseAction({ - status: "pressing", - didScroll: false, - didNavigate: false, - movedBeyondTapTolerance: false, - pressDurationMs: 500, - longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, - scrollMode: "following", - }), - ).toEqual("select"); - }); - - it("keeps a 400ms press as a tap", () => { - expect( - resolveTerminalGestureReleaseAction({ - status: "pressing", - didScroll: false, - didNavigate: false, - movedBeyondTapTolerance: false, - pressDurationMs: 400, - longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, - scrollMode: "following", - }), - ).toEqual("focus"); - }); - - it("does nothing after a scroll gesture", () => { - expect( - resolveTerminalGestureReleaseAction({ - status: "pressing", - didScroll: true, - didNavigate: false, - movedBeyondTapTolerance: true, - pressDurationMs: 80, - longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, - scrollMode: "scrolled", - }), - ).toEqual("none"); - }); - - it("does nothing after a navigation swipe", () => { - expect( - resolveTerminalGestureReleaseAction({ - status: "pressing", - didScroll: false, - didNavigate: true, - movedBeyondTapTolerance: true, - pressDurationMs: 80, - longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, - scrollMode: "following", - }), - ).toEqual("none"); - }); - - it("does nothing while actively selecting", () => { - expect( - resolveTerminalGestureReleaseAction({ - status: "selecting", - didScroll: false, - didNavigate: false, - movedBeyondTapTolerance: true, - pressDurationMs: 80, - longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, - scrollMode: "following", - }), - ).toEqual("none"); - }); - - it("clears an existing selection after a short tap", () => { - expect( - resolveTerminalGestureReleaseAction({ - status: "selecting", - startedWithSelection: true, - didScroll: false, - didNavigate: false, - movedBeyondTapTolerance: false, - pressDurationMs: 80, - longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, - scrollMode: "following", - }), - ).toEqual("clear"); - }); - - it("preserves a word selection created by the current long press", () => { - expect( - resolveTerminalGestureReleaseAction({ - status: "selecting", - startedWithSelection: false, - didScroll: false, - didNavigate: false, - movedBeyondTapTolerance: false, - pressDurationMs: TERMINAL_GESTURE_LONG_PRESS_MS + 50, - longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, - scrollMode: "following", - }), - ).toEqual("none"); - }); - - it("does not focus after an undecided fast drag", () => { - expect( - resolveTerminalGestureReleaseAction({ - status: "pressing", - didScroll: false, - didNavigate: false, - movedBeyondTapTolerance: true, - pressDurationMs: 80, - longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, - scrollMode: "following", - }), - ).toEqual("none"); - }); - - it("does nothing from idle", () => { - expect( - resolveTerminalGestureReleaseAction({ - status: "idle", - didScroll: false, - didNavigate: false, - movedBeyondTapTolerance: false, - pressDurationMs: 80, - longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, - scrollMode: "following", - }), - ).toEqual("none"); - }); - }); -}); diff --git a/packages/app/src/terminal/native-renderer/terminal-selection-gesture.ts b/packages/app/src/terminal/native-renderer/terminal-selection-gesture.ts deleted file mode 100644 index 07b5267e87..0000000000 --- a/packages/app/src/terminal/native-renderer/terminal-selection-gesture.ts +++ /dev/null @@ -1,108 +0,0 @@ -export type TerminalGestureStatus = "idle" | "pressing" | "selecting"; -export type TerminalGestureIntent = "tap" | "pending" | "select" | "scroll"; -export type TerminalGestureScrollMode = "following" | "scrolled"; - -export interface TerminalGestureMoveInput { - status: TerminalGestureStatus; - dx: number; - dy: number; - vx: number; - vy: number; - elapsedMs?: number; -} - -export interface TerminalGestureReleaseInput { - status: TerminalGestureStatus; - startedWithSelection?: boolean; - didScroll: boolean; - didNavigate: boolean; - movedBeyondTapTolerance: boolean; - pressDurationMs: number; - longPressMs: number; - scrollMode: TerminalGestureScrollMode; -} - -export interface TerminalScrollGestureState { - lastDy: number; - rowRemainder: number; - didScroll: boolean; -} - -export interface TerminalScrollGestureUpdate { - state: TerminalScrollGestureState; - rowDelta: number; -} - -export type TerminalGestureAction = "none" | "focus" | "select" | "clear"; - -export const TERMINAL_GESTURE_TAP_TOLERANCE_PX = 8; -export const TERMINAL_GESTURE_LONG_PRESS_MS = 450; -export const TERMINAL_GESTURE_VERTICAL_SCROLL_THRESHOLD_PX = 12; - -export function classifyTerminalGestureIntent( - input: TerminalGestureMoveInput, -): TerminalGestureIntent { - if (input.status === "selecting") { - return "select"; - } - - const absDx = Math.abs(input.dx); - const absDy = Math.abs(input.dy); - - if (absDy > TERMINAL_GESTURE_VERTICAL_SCROLL_THRESHOLD_PX && absDy > absDx) { - return "scroll"; - } - - if (Math.hypot(input.dx, input.dy) > TERMINAL_GESTURE_TAP_TOLERANCE_PX) { - return "pending"; - } - - return "tap"; -} - -export function advanceTerminalScrollGesture(input: { - state: TerminalScrollGestureState; - currentDy: number; - cellHeight: number; -}): TerminalScrollGestureUpdate { - const deltaY = input.currentDy - input.state.lastDy; - const accumulatedRows = input.state.rowRemainder + deltaY; - const rowDelta = Math.trunc(accumulatedRows / input.cellHeight); - return { - state: { - lastDy: input.currentDy, - rowRemainder: accumulatedRows - rowDelta * input.cellHeight, - didScroll: input.state.didScroll || rowDelta !== 0, - }, - rowDelta, - }; -} - -export function resolveTerminalGestureReleaseAction( - input: TerminalGestureReleaseInput, -): TerminalGestureAction { - if (input.didScroll || input.didNavigate) { - return "none"; - } - - if (input.status === "selecting") { - if (!input.startedWithSelection) { - return "none"; - } - return input.movedBeyondTapTolerance ? "none" : "clear"; - } - - if (input.status !== "pressing") { - return "none"; - } - - if (input.pressDurationMs >= input.longPressMs) { - return "select"; - } - - if (input.movedBeyondTapTolerance) { - return "none"; - } - - return "focus"; -} diff --git a/packages/app/src/terminal/native-renderer/terminal-selection.test.ts b/packages/app/src/terminal/native-renderer/terminal-selection.test.ts index c188671ffe..6141bf1efd 100644 --- a/packages/app/src/terminal/native-renderer/terminal-selection.test.ts +++ b/packages/app/src/terminal/native-renderer/terminal-selection.test.ts @@ -483,3 +483,51 @@ describe("native terminal selection", () => { expect(copied).toEqual([]); }); }); + +describe("endpoint dragging", () => { + it.each([ + { + endpoint: "start" as const, + fixed: { row: 2, col: 9 }, + target: { row: 3, col: 2 }, + expected: { row: 3, col: 0 }, + }, + { + endpoint: "end" as const, + fixed: { row: 2, col: 0 }, + target: { row: 1, col: 7 }, + expected: { row: 1, col: 9 }, + }, + ])( + "preserves the fixed boundary across a row edge for $endpoint", + ({ endpoint, fixed, target, expected }) => { + const selection = createTerminalSelectionModel(); + const bounds = { oldestRow: 0, newestRow: 10, coordinateEpoch: 1 }; + selection.begin({ coordinate: fixed, bounds }); + selection.update({ coordinate: fixed, bounds }); + selection.beginEndpointDrag(endpoint, 10); + selection.update({ coordinate: target, bounds }); + expect(selection.getSnapshot().range?.[endpoint === "start" ? "start" : "end"]).toEqual( + expected, + ); + }, + ); + + it.each(["start", "end"] as const)( + "keeps the opposite endpoint fixed as %s crosses it", + (endpoint) => { + const selection = createTerminalSelectionModel(); + const bounds = { oldestRow: 0, newestRow: 10, coordinateEpoch: 1 }; + selection.begin({ coordinate: { row: 2, col: 2 }, bounds }); + selection.update({ coordinate: { row: 3, col: 4 }, bounds }); + selection.beginEndpointDrag(endpoint, 10); + const coordinate = endpoint === "start" ? { row: 4, col: 5 } : { row: 1, col: 1 }; + selection.update({ coordinate, bounds }); + expect(selection.getSnapshot().range).toEqual( + endpoint === "start" + ? { start: { row: 3, col: 5 }, end: coordinate, coordinateEpoch: 1 } + : { start: coordinate, end: { row: 2, col: 1 }, coordinateEpoch: 1 }, + ); + }, + ); +}); diff --git a/packages/app/src/terminal/native-renderer/terminal-selection.ts b/packages/app/src/terminal/native-renderer/terminal-selection.ts index 450a3813ac..0f7391e669 100644 --- a/packages/app/src/terminal/native-renderer/terminal-selection.ts +++ b/packages/app/src/terminal/native-renderer/terminal-selection.ts @@ -25,6 +25,7 @@ export interface TerminalSelectionBounds { export interface TerminalSelectionModel { begin(input: TerminalSelectionUpdateInput): TerminalSelectionSnapshot; update(input: TerminalSelectionUpdateInput): TerminalSelectionSnapshot; + beginEndpointDrag(endpoint: "start" | "end", cols: number): void; clear(): TerminalSelectionSnapshot; sync(input: TerminalSelectionSyncInput): TerminalSelectionSnapshot; getSnapshot(): TerminalSelectionSnapshot; @@ -252,6 +253,7 @@ export function resolveTerminalSelectionRects( export function createTerminalSelectionModel(): TerminalSelectionModel { let state: TerminalSelectionState = { anchor: null, range: null }; + let drag: { endpoint: "start" | "end"; cols: number } | null = null; function snapshot(): TerminalSelectionSnapshot { return { range: state.range }; @@ -259,18 +261,31 @@ export function createTerminalSelectionModel(): TerminalSelectionModel { function setSelection(input: TerminalSelectionUpdateInput): TerminalSelectionSnapshot { const coordinate = clampCoordinate(input.coordinate, input.bounds); - const anchor = state.anchor ?? coordinate; + const fixed = state.anchor ?? coordinate; + let anchor = fixed; + if (drag?.endpoint === "start" && compareCoordinates(coordinate, fixed) > 0) { + anchor = + fixed.col + 1 < drag.cols + ? { row: fixed.row, col: fixed.col + 1 } + : { row: fixed.row + 1, col: 0 }; + } else if (drag?.endpoint === "end" && compareCoordinates(coordinate, fixed) < 0) { + anchor = + fixed.col > 0 + ? { row: fixed.row, col: fixed.col - 1 } + : { row: fixed.row - 1, col: drag.cols - 1 }; + } const range = normalizeTerminalSelection({ anchor, focus: coordinate, coordinateEpoch: input.bounds.coordinateEpoch, }); - state = { anchor, range }; + state = { anchor: fixed, range }; return snapshot(); } return { begin(input: TerminalSelectionUpdateInput): TerminalSelectionSnapshot { + drag = null; const coordinate = clampCoordinate(input.coordinate, input.bounds); state = { anchor: coordinate, @@ -285,7 +300,14 @@ export function createTerminalSelectionModel(): TerminalSelectionModel { update(input: TerminalSelectionUpdateInput): TerminalSelectionSnapshot { return setSelection(input); }, + beginEndpointDrag(endpoint, cols) { + if (state.range) { + drag = { endpoint, cols }; + state = { ...state, anchor: state.range[endpoint === "start" ? "end" : "start"] }; + } + }, clear(): TerminalSelectionSnapshot { + drag = null; state = { anchor: null, range: null }; return snapshot(); }, @@ -293,6 +315,7 @@ export function createTerminalSelectionModel(): TerminalSelectionModel { if (!state.range || rangeIsSafe(state.range, input.bounds)) { return snapshot(); } + drag = null; state = { anchor: null, range: null }; return snapshot(); }, From fd65985e9d588109898fcf84298b6ecd1815462a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 31 Oct 2026 14:45:13 +0700 Subject: [PATCH 42/95] fix(android): resize header fades after rotation (#8349) Clear Android SVG descendant geometry caches when the canvas resizes. Cover growth and shrinkage with a native pixel-rendering regression. --- docs/android.md | 12 +++ patches/react-native-svg+15.15.4.patch | 127 +++++++++++++++++++++++++ scripts/postinstall-patches.mjs | 2 + 3 files changed, 141 insertions(+) diff --git a/docs/android.md b/docs/android.md index 6188589eb8..17bafd6b24 100644 --- a/docs/android.md +++ b/docs/android.md @@ -61,6 +61,18 @@ Gradle auto-fetches the platform/build-tools it needs once licenses are accepted ## Local build + install +The `react-native-svg@15.15.4` patch prevents header fades retaining their old painted size after +rotation. Rebuild native Android after changing it; Metro reloads cannot update this code. Keep the +resize hunks until upstream invalidates descendant geometry on canvas resize. Its instrumentation +regression renders real gradient pixels across growing and shrinking layouts, including a nested +group. From `packages/app/android`, run it on your selected emulator: + +```bash +ANDROID_SERIAL=emulator-5562 ./gradlew :react-native-svg:connectedDebugAndroidTest \ + -Pandroid.testInstrumentationRunnerArguments.class=com.horcrux.svg.SvgResizeTest \ + --no-daemon --max-workers=2 -Dorg.gradle.parallel=false +``` + The locked `expo-modules-core@57.0.23` needs the patch installed by `npm install`. It backports [Expo's Activity-result launcher fix](https://github.com/expo/expo/pull/49634): changing diff --git a/patches/react-native-svg+15.15.4.patch b/patches/react-native-svg+15.15.4.patch index b6b4a85a90..90a30090d7 100644 --- a/patches/react-native-svg+15.15.4.patch +++ b/patches/react-native-svg+15.15.4.patch @@ -59,3 +59,130 @@ index 0c854d6..7dc2828 100644 } return props.transform as TransformsStyle['transform']; } +diff --git a/node_modules/react-native-svg/android/build.gradle b/node_modules/react-native-svg/android/build.gradle +--- a/node_modules/react-native-svg/android/build.gradle ++++ b/node_modules/react-native-svg/android/build.gradle +@@ -103,6 +103,7 @@ + } + + defaultConfig { ++ testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' + minSdkVersion safeExtGet('minSdkVersion', 16) + //noinspection OldTargetApi + targetSdkVersion safeExtGet('targetSdkVersion', 28) +@@ -136,6 +137,8 @@ + } + + dependencies { ++ androidTestImplementation 'androidx.test:runner:1.6.2' ++ androidTestImplementation 'androidx.test.ext:junit:1.2.1' + implementation 'com.facebook.react:react-native:+' + implementation("com.facebook.fresco:fresco:${FRESCO_VERSION}") { + exclude group: 'com.facebook.soloader' +diff --git a/node_modules/react-native-svg/android/src/main/java/com/horcrux/svg/SvgView.java b/node_modules/react-native-svg/android/src/main/java/com/horcrux/svg/SvgView.java +--- a/node_modules/react-native-svg/android/src/main/java/com/horcrux/svg/SvgView.java ++++ b/node_modules/react-native-svg/android/src/main/java/com/horcrux/svg/SvgView.java +@@ -159,6 +159,8 @@ + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); ++ // Percentage lengths and paths depend on the canvas size, not just SVG props. ++ clearChildCache(); + this.invalidate(); + } + +diff --git a/node_modules/react-native-svg/android/src/androidTest/java/com/horcrux/svg/SvgResizeTest.java b/node_modules/react-native-svg/android/src/androidTest/java/com/horcrux/svg/SvgResizeTest.java +new file mode 100644 +--- /dev/null ++++ b/node_modules/react-native-svg/android/src/androidTest/java/com/horcrux/svg/SvgResizeTest.java +@@ -0,0 +1,90 @@ ++package com.horcrux.svg; ++ ++import static org.junit.Assert.assertEquals; ++import static org.junit.Assert.assertTrue; ++ ++import android.graphics.Bitmap; ++import android.graphics.Canvas; ++import android.graphics.Color; ++import android.view.View; ++import androidx.test.ext.junit.runners.AndroidJUnit4; ++import androidx.test.platform.app.InstrumentationRegistry; ++import com.facebook.react.bridge.BridgeReactContext; ++import com.facebook.react.bridge.DynamicFromObject; ++import com.facebook.react.bridge.JavaOnlyArray; ++import com.facebook.react.bridge.JavaOnlyMap; ++import com.facebook.react.soloader.OpenSourceMergedSoMapping; ++import com.facebook.react.uimanager.DisplayMetricsHolder; ++import com.facebook.soloader.SoLoader; ++import org.junit.Test; ++import org.junit.runner.RunWith; ++ ++@RunWith(AndroidJUnit4.class) ++public class SvgResizeTest { ++ @Test ++ public void percentageFadeFollowsCanvasGrowthAndShrinkage() { ++ InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { ++ android.content.Context app = InstrumentationRegistry.getInstrumentation().getTargetContext(); ++ DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(app); ++ try { ++ SoLoader.init(app, OpenSourceMergedSoMapping.INSTANCE); ++ } catch (java.io.IOException exception) { ++ throw new RuntimeException(exception); ++ } ++ BridgeReactContext context = new DrawingContext(app); ++ SvgView svg = new SvgView(context); ++ LinearGradientView gradient = new LinearGradientView(context); ++ gradient.setName("fade"); ++ gradient.setX1(new DynamicFromObject("0%")); ++ gradient.setY1(new DynamicFromObject("0%")); ++ gradient.setX2(new DynamicFromObject("0%")); ++ gradient.setY2(new DynamicFromObject("100%")); ++ gradient.setGradientUnits(0); ++ gradient.setGradient(JavaOnlyArray.of(0.0, Color.RED, 1.0, Color.TRANSPARENT)); ++ svg.addView(gradient); ++ // Exercise recursive invalidation as well as the direct child case. ++ GroupView group = new GroupView(context); ++ RectView rectangle = new RectView(context); ++ rectangle.setX(new DynamicFromObject(0.0)); ++ rectangle.setY(new DynamicFromObject(0.0)); ++ rectangle.setWidth(new DynamicFromObject("100%")); ++ rectangle.setHeight(new DynamicFromObject("100%")); ++ rectangle.setFill(JavaOnlyMap.of("type", 1, "brushRef", "fade")); ++ group.addView(rectangle); ++ svg.addView(group); ++ ++ assertFade(svg, 80, 60); ++ assertFade(svg, 160, 120); ++ assertFade(svg, 60, 40); ++ assertFade(svg, 160, 120); ++ }); ++ } ++ ++ // Pixel rendering needs a React context but does not send layout events to JavaScript. ++ private static final class DrawingContext extends BridgeReactContext ++ implements com.facebook.react.uimanager.events.EventDispatcherProvider { ++ DrawingContext(android.content.Context context) { super(context); } ++ ++ @Override ++ public com.facebook.react.uimanager.events.EventDispatcher getEventDispatcher() { return null; } ++ } ++ ++ private static void assertFade(SvgView svg, int width, int height) { ++ svg.measure(View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY), ++ View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY)); ++ svg.layout(0, 0, width, height); ++ Bitmap image = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); ++ svg.draw(new Canvas(image)); ++ try { ++ int middle = Color.alpha(image.getPixel(width / 2, height / 2)); ++ int right = Color.alpha(image.getPixel(width - 2, height / 2)); ++ assertTrue("Gradient midpoint must follow canvas height at " + width + "x" + height ++ + "; alpha=" + middle, middle >= 120 && middle <= 135); ++ assertEquals("Fade must reach the right edge at " + width + "x" + height, middle, right); ++ assertTrue("Fade must end at the new bottom edge", ++ Color.alpha(image.getPixel(width / 2, height - 2)) < 20); ++ } finally { ++ image.recycle(); ++ } ++ } ++} diff --git a/scripts/postinstall-patches.mjs b/scripts/postinstall-patches.mjs index 312bb90724..fbf87241c7 100644 --- a/scripts/postinstall-patches.mjs +++ b/scripts/postinstall-patches.mjs @@ -59,6 +59,8 @@ const patchedPackages = [ nodeModulesPath: "node_modules/@gorhom/bottom-sheet", patchPrefix: "@gorhom+bottom-sheet+", }, + // Android resize: retain until upstream clears descendant geometry when the SVG canvas resizes. + // The native pixel regression is included in the patch; preserve the separate transform hunks. { nodeModulesPath: "packages/app/node_modules/react-native-svg", patchPrefix: "react-native-svg+", From a7d9bb34fce2633ecb847786d5bc03ce212f741c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 31 Oct 2026 17:48:42 +0700 Subject: [PATCH 43/95] 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 44/95] 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 45/95] 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 46/95] 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 47/95] 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 48/95] 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 49/95] 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 50/95] 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 51/95] 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 52/95] 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 53/95] 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 54/95] 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 55/95] 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 56/95] 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 57/95] 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 58/95] 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 59/95] 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 60/95] 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 61/95] 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 62/95] 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 63/95] 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 64/95] 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 65/95] 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 66/95] 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 67/95] 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 68/95] 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 69/95] 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 70/95] 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 71/95] 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 72/95] 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 73/95] 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 74/95] 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 75/95] 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 76/95] 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 77/95] 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 78/95] 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 79/95] 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 80/95] 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 81/95] 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 82/95] 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 83/95] 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 84/95] 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 85/95] 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 86/95] 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 87/95] 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 88/95] 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" ? (