From bb9ef76017a488a592e7d7076970bb744b7fc05e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 3 Apr 2026 11:15:02 +0700 Subject: [PATCH 01/73] Fix OpenCode interrupt tool-call terminal state parity --- .../server/agent/providers/opencode-agent.ts | 134 +++++- .../opencode-send-interrupt.real.e2e.test.ts | 424 ++++++++++++++++++ 2 files changed, 538 insertions(+), 20 deletions(-) create mode 100644 packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index e6d35cf5f..9f6a26749 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -29,6 +29,7 @@ import type { ListPersistedAgentsOptions, McpServerConfig, PersistedAgentDescriptor, + ToolCallTimelineItem, } from "../agent-sdk-types.js"; import { applyProviderEnv, @@ -201,6 +202,11 @@ function stringifyUnknownError(error: unknown): string { } } +function normalizeTurnFailureError(error: unknown): string { + const normalized = stringifyUnknownError(error).trim(); + return normalized.length > 0 ? normalized : "Unknown error"; +} + function isAlreadyPresentMcpError(error: unknown): boolean { const normalized = stringifyUnknownError(error).toLowerCase(); return MCP_ALREADY_PRESENT_ERROR_TOKENS.some((token) => normalized.includes(token)); @@ -955,11 +961,10 @@ export function translateOpenCodeEvent( if (sessionId === state.sessionId) { state.streamedPartKeys.clear(); state.partTypes.clear(); - const error = props.error as string | undefined; events.push({ type: "turn_failed", provider: "opencode", - error: error ?? "Unknown error", + error: normalizeTurnFailureError(props.error), }); } break; @@ -995,6 +1000,7 @@ class OpenCodeAgentSession implements AgentSession { private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); private nextTurnOrdinal = 0; private activeForegroundTurnId: string | null = null; + private readonly runningToolCalls = new Map(); constructor( config: OpenCodeAgentConfig, @@ -1112,11 +1118,19 @@ class OpenCodeAgentSession implements AgentSession { } async interrupt(): Promise { - this.abortController?.abort(); + const turnId = this.activeForegroundTurnId; + const turnAbortController = this.abortController; + turnAbortController?.abort(); await this.client.session.abort({ sessionID: this.sessionId, directory: this.config.cwd, }); + if (turnId) { + this.finishForegroundTurn( + { type: "turn_canceled", provider: "opencode", reason: "interrupted" }, + turnId, + ); + } } async startTurn( @@ -1127,7 +1141,9 @@ class OpenCodeAgentSession implements AgentSession { throw new Error("A foreground turn is already active"); } - this.abortController = new AbortController(); + this.runningToolCalls.clear(); + const turnAbortController = new AbortController(); + this.abortController = turnAbortController; await this.ensureMcpServersConfigured(); const parts = this.buildPromptParts(prompt); @@ -1170,7 +1186,7 @@ class OpenCodeAgentSession implements AgentSession { } if (promptResponse.error) { - const errorMsg = JSON.stringify(promptResponse.error); + const errorMsg = normalizeTurnFailureError(promptResponse.error); this.notifySubscribers({ type: "turn_failed", provider: "opencode", @@ -1181,7 +1197,7 @@ class OpenCodeAgentSession implements AgentSession { const turnId = this.createTurnId(); this.activeForegroundTurnId = turnId; - void this.consumeEventStream(); + void this.consumeEventStream(turnId, turnAbortController); return { turnId }; } @@ -1193,40 +1209,118 @@ class OpenCodeAgentSession implements AgentSession { }; } - private async consumeEventStream(): Promise { + private async consumeEventStream( + turnId: string, + turnAbortController: AbortController, + ): Promise { const eventsResult = await this.client.event.subscribe({ directory: this.config.cwd, }); try { for await (const event of eventsResult.stream) { - if (this.abortController?.signal.aborted) { + if (turnAbortController.signal.aborted || this.activeForegroundTurnId !== turnId) { break; } const translated = this.translateEvent(event); for (const e of translated) { - this.notifySubscribers(e); - if (e.type === "turn_completed" || e.type === "turn_failed") { - this.activeForegroundTurnId = null; + if (this.activeForegroundTurnId !== turnId) { return; } + if (e.type === "timeline" && e.item.type === "tool_call") { + this.trackToolCall(e.item); + } + if (e.type === "turn_completed" || e.type === "turn_failed" || e.type === "turn_canceled") { + if (e.type === "turn_failed") { + this.finishForegroundTurn( + { + type: "turn_failed", + provider: "opencode", + error: normalizeTurnFailureError(e.error), + }, + turnId, + ); + } else { + this.finishForegroundTurn(e, turnId); + } + return; + } + this.notifySubscribers(e, turnId); } } } catch (error) { - if (!this.abortController?.signal.aborted) { - this.notifySubscribers({ - type: "turn_failed", - provider: "opencode", - error: error instanceof Error ? error.message : "Stream error", - }); - this.activeForegroundTurnId = null; + if (!turnAbortController.signal.aborted && this.activeForegroundTurnId === turnId) { + this.finishForegroundTurn( + { + type: "turn_failed", + provider: "opencode", + error: normalizeTurnFailureError(error), + }, + turnId, + ); + } + } finally { + if (turnAbortController.signal.aborted) { + this.finishForegroundTurn( + { + type: "turn_canceled", + provider: "opencode", + reason: "interrupted", + }, + turnId, + ); + } + if (this.abortController === turnAbortController && this.activeForegroundTurnId !== turnId) { + this.abortController = null; } } } - private notifySubscribers(event: AgentStreamEvent): void { - const turnId = this.activeForegroundTurnId; + private finishForegroundTurn( + event: Extract, + turnId: string, + ): void { + if (this.activeForegroundTurnId !== turnId) { + return; + } + if (event.type === "turn_canceled" || event.type === "turn_failed") { + this.synthesizeInterruptedToolCalls(turnId); + } else { + this.runningToolCalls.clear(); + } + this.activeForegroundTurnId = null; + this.notifySubscribers(event, turnId); + } + + private trackToolCall(item: ToolCallTimelineItem): void { + if (item.status === "running") { + this.runningToolCalls.set(item.callId, item); + return; + } + this.runningToolCalls.delete(item.callId); + } + + private synthesizeInterruptedToolCalls(turnId: string): void { + for (const item of this.runningToolCalls.values()) { + this.notifySubscribers( + { + type: "timeline", + provider: "opencode", + item: { + ...item, + status: "failed", + error: { message: "Tool execution aborted" }, + }, + }, + turnId, + ); + } + this.runningToolCalls.clear(); + } + + private notifySubscribers(event: AgentStreamEvent, turnIdOverride?: string): void { + const turnId = turnIdOverride ?? this.activeForegroundTurnId; const tagged = turnId ? { ...event, turnId } : event; for (const callback of this.subscribers) { try { diff --git a/packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts new file mode 100644 index 000000000..4d4ceb1fa --- /dev/null +++ b/packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts @@ -0,0 +1,424 @@ +import { describe, 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 { OpenCodeAgentClient } from "../agent/providers/opencode-agent.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { DaemonClient, type WaitForFinishResult } from "../test-utils/daemon-client.js"; +import { createMessageCollector } from "../test-utils/message-collector.js"; +import { isProviderAvailable } from "./agent-configs.js"; +import type { AgentPermissionRequest } from "../agent/agent-sdk-types.js"; +import type { SessionOutboundMessage } from "../messages.js"; + +const SYSTEM_ERROR_SNIPPET = "A foreground turn is already active"; + +function tmpCwd(): string { + return mkdtempSync(path.join(tmpdir(), "daemon-real-opencode-send-interrupt-")); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function pickOpenCodeModel( + models: Array<{ id: string }>, + preferences: string[] = [ + "minimax-m2.5-free", + "kimi-k2.5-free", + "glm-5-free", + "free", + "mini", + "gpt-5-nano", + ], +): string { + const preferred = models.find((model) => + preferences.some((fragment) => model.id.includes(fragment)), + ); + return preferred?.id ?? models[0]!.id; +} + +function hasRunningBashToolCall(messages: SessionOutboundMessage[], agentId: string): boolean { + return messages.some( + (message) => + message.type === "agent_stream" && + message.payload.agentId === agentId && + message.payload.event.type === "timeline" && + message.payload.event.item.type === "tool_call" && + message.payload.event.item.status === "running" && + ["bash", "shell"].includes(message.payload.event.item.name.toLowerCase()), + ); +} + +function getAssistantTexts(messages: SessionOutboundMessage[], agentId: string): string[] { + return messages + .filter( + (message) => + message.type === "agent_stream" && + message.payload.agentId === agentId && + message.payload.event.type === "timeline" && + message.payload.event.item.type === "assistant_message", + ) + .map((message) => message.payload.event.item.text); +} + +function findSystemErrorText(texts: string[]): string | null { + return texts.find((text) => text.includes("[System Error]")) ?? null; +} + +function getTimelineAssistantTexts( + timeline: Awaited>, +): string[] { + return timeline.entries + .filter((entry) => entry.item.type === "assistant_message") + .map((entry) => entry.item.text); +} + +function findSleepToolCall( + timeline: Awaited>, +): { status: "running" | "completed" | "failed" | "canceled"; callId: string } | null { + for (let idx = timeline.entries.length - 1; idx >= 0; idx -= 1) { + const entry = timeline.entries[idx]; + if (entry?.item.type !== "tool_call") { + continue; + } + if (entry.item.detail.type !== "shell") { + continue; + } + if (!entry.item.detail.command.includes("sleep 60")) { + continue; + } + return { + status: entry.item.status, + callId: entry.item.callId, + }; + } + return null; +} + +async function allowPermission( + client: DaemonClient, + agentId: string, + permission: AgentPermissionRequest, +): Promise { + if (permission.kind === "question") { + throw new Error( + `Unexpected question permission while waiting for tool call: ${permission.id} ${permission.title}`, + ); + } + await client.respondToPermission(agentId, permission.id, { + behavior: "allow", + message: "Approved by integration test", + }); +} + +async function approvePendingPermissions( + client: DaemonClient, + agentId: string, + handledPermissionIds: Set, +): Promise { + const snapshot = await client.fetchAgent(agentId).catch(() => null); + const pending = snapshot?.agent.pendingPermissions ?? []; + for (const permission of pending) { + if (handledPermissionIds.has(permission.id)) { + continue; + } + handledPermissionIds.add(permission.id); + await allowPermission(client, agentId, permission); + } +} + +async function waitForRunningBashToolCall( + client: DaemonClient, + collector: ReturnType, + agentId: string, + timeoutMs = 120_000, +): Promise { + const deadline = Date.now() + timeoutMs; + const handledPermissionIds = new Set(); + + while (Date.now() < deadline) { + await approvePendingPermissions(client, agentId, handledPermissionIds); + + const streamSystemError = findSystemErrorText(getAssistantTexts(collector.messages, agentId)); + if (streamSystemError) { + throw new Error(`OpenCode failed before tool call started: ${streamSystemError}`); + } + + if (hasRunningBashToolCall(collector.messages, agentId)) { + return; + } + + const timeline = await client.fetchAgentTimeline(agentId, { limit: 120 }).catch(() => null); + const timelineSystemError = timeline + ? findSystemErrorText(getTimelineAssistantTexts(timeline).slice(-8)) + : null; + if (timelineSystemError) { + throw new Error(`OpenCode failed before tool call started: ${timelineSystemError}`); + } + if ( + timeline?.entries.some( + (entry) => + entry.item.type === "tool_call" && + entry.item.status === "running" && + ["bash", "shell"].includes(entry.item.name.toLowerCase()), + ) + ) { + return; + } + + await sleep(500); + } + + const timeline = await client.fetchAgentTimeline(agentId, { limit: 120 }).catch(() => null); + const recentToolCalls = + timeline?.entries + .filter((entry) => entry.item.type === "tool_call") + .slice(-10) + .map((entry) => ({ + name: entry.item.name, + status: entry.item.status, + callId: entry.item.callId, + })) ?? []; + const recentAssistantTexts = timeline ? getTimelineAssistantTexts(timeline).slice(-6) : []; + throw new Error( + `Timed out waiting for running bash/shell tool call. recentToolCalls=${JSON.stringify(recentToolCalls)} recentAssistantTexts=${JSON.stringify(recentAssistantTexts)}`, + ); +} + +async function waitForSleepToolCallTerminal( + client: DaemonClient, + agentId: string, + timeoutMs = 30_000, +): Promise<{ status: "completed" | "failed" | "canceled"; callId: string }> { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const timeline = await client.fetchAgentTimeline(agentId, { limit: 200 }); + const sleepToolCall = findSleepToolCall(timeline); + if (sleepToolCall && sleepToolCall.status !== "running") { + return { + status: sleepToolCall.status, + callId: sleepToolCall.callId, + }; + } + await sleep(300); + } + + const timeline = await client.fetchAgentTimeline(agentId, { limit: 200 }).catch(() => null); + const recentToolCalls = + timeline?.entries + .filter((entry) => entry.item.type === "tool_call") + .slice(-10) + .map((entry) => ({ + callId: entry.item.callId, + name: entry.item.name, + status: entry.item.status, + })) ?? []; + throw new Error( + `Timed out waiting for interrupted sleep tool call to become terminal. recentToolCalls=${JSON.stringify(recentToolCalls)}`, + ); +} + +async function waitForIdleResolvingPermissions( + client: DaemonClient, + agentId: string, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + const handledPermissionIds = new Set(); + + while (Date.now() < deadline) { + const remaining = Math.max(1, deadline - Date.now()); + const result = await client.waitForFinish(agentId, Math.min(remaining, 45_000)); + if (result.status !== "permission") { + return result; + } + + const pendingPermissions = result.final?.pendingPermissions ?? []; + if (pendingPermissions.length === 0) { + throw new Error("waitForFinish reported permission but no pending permissions were present"); + } + + let resolvedAny = false; + for (const permission of pendingPermissions) { + if (handledPermissionIds.has(permission.id)) { + continue; + } + handledPermissionIds.add(permission.id); + await allowPermission(client, agentId, permission); + resolvedAny = true; + } + + if (!resolvedAny) { + throw new Error( + "Permission wait loop made no progress; all permissions were already handled", + ); + } + } + + return { + status: "timeout", + final: null, + error: `Timed out waiting for idle after ${timeoutMs}ms`, + lastMessage: null, + }; +} + +async function createHarness(): Promise<{ + client: DaemonClient; + daemon: Awaited>; +}> { + const logger = pino({ level: "silent" }); + const daemon = await createTestPaseoDaemon({ + agentClients: { opencode: new OpenCodeAgentClient(logger) }, + logger, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + await client.connect(); + await client.fetchAgents({ subscribe: { subscriptionId: "opencode-send-interrupt-real" } }); + return { client, daemon }; +} + +describe("daemon E2E (real opencode) - send while working and interrupt", () => { + test.runIf(isProviderAvailable("opencode"))( + "send_message while sleep tool call is running starts a clean replacement turn", + async () => { + const cwd = tmpCwd(); + const { client, daemon } = await createHarness(); + const collector = createMessageCollector(client); + const followUpToken = "OPENCODE_SEND_WHILE_WORKING_OK"; + + try { + const modelList = await client.listProviderModels("opencode"); + expect(modelList.models.length).toBeGreaterThan(0); + + const agent = await client.createAgent({ + provider: "opencode", + cwd, + title: "OpenCode send while working", + model: pickOpenCodeModel(modelList.models), + modeId: "default", + }); + + await client.sendMessage( + agent.id, + [ + "Use the Bash tool.", + "Run exactly: sleep 60", + "Do not run it in the background.", + "Do not do anything after starting the command.", + ].join(" "), + ); + + await client.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "running", + 90_000, + ); + await waitForRunningBashToolCall(client, collector, agent.id); + + collector.clear(); + await client.sendMessage(agent.id, `Reply with exactly: ${followUpToken}`); + + const finish = await waitForIdleResolvingPermissions(client, agent.id, 240_000); + expect(finish.status).toBe("idle"); + + const postSendAssistantTexts = getAssistantTexts(collector.messages, agent.id); + expect( + postSendAssistantTexts.some((text) => text.includes("[System Error]")), + ).toBe(false); + expect( + postSendAssistantTexts.some((text) => text.includes(SYSTEM_ERROR_SNIPPET)), + ).toBe(false); + + const timeline = await client.fetchAgentTimeline(agent.id, { limit: 160 }); + const assistantTexts = getTimelineAssistantTexts(timeline); + expect(assistantTexts.some((text) => text.includes(followUpToken))).toBe(true); + expect(assistantTexts.some((text) => text.includes("[System Error]"))).toBe(false); + expect(assistantTexts.some((text) => text.includes(SYSTEM_ERROR_SNIPPET))).toBe(false); + } finally { + collector.unsubscribe(); + await client.close().catch(() => undefined); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, + 360_000, + ); + + test.runIf(isProviderAvailable("opencode"))( + "explicit interrupt during sleep tool call still allows the next turn to complete", + async () => { + const cwd = tmpCwd(); + const { client, daemon } = await createHarness(); + const collector = createMessageCollector(client); + const followUpToken = "OPENCODE_INTERRUPT_FOLLOWUP_OK"; + + try { + const modelList = await client.listProviderModels("opencode"); + expect(modelList.models.length).toBeGreaterThan(0); + + const agent = await client.createAgent({ + provider: "opencode", + cwd, + title: "OpenCode explicit interrupt", + model: pickOpenCodeModel(modelList.models), + modeId: "default", + }); + + await client.sendMessage( + agent.id, + [ + "Use the Bash tool.", + "Run exactly: sleep 60", + "Do not run it in the background.", + "Do not do anything after starting the command.", + ].join(" "), + ); + + await client.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "running", + 90_000, + ); + await waitForRunningBashToolCall(client, collector, agent.id); + + await client.cancelAgent(agent.id); + await client.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "idle" || snapshot.status === "error", + 90_000, + ); + const interruptedToolCall = await waitForSleepToolCallTerminal(client, agent.id, 45_000); + expect(interruptedToolCall.status).toBe("failed"); + + collector.clear(); + await client.sendMessage(agent.id, `Reply with exactly: ${followUpToken}`); + + const finish = await waitForIdleResolvingPermissions(client, agent.id, 240_000); + expect(finish.status).toBe("idle"); + + const postInterruptAssistantTexts = getAssistantTexts(collector.messages, agent.id); + expect( + postInterruptAssistantTexts.some((text) => text.includes("[System Error]")), + ).toBe(false); + expect( + postInterruptAssistantTexts.some((text) => text.includes(SYSTEM_ERROR_SNIPPET)), + ).toBe(false); + + const timeline = await client.fetchAgentTimeline(agent.id, { limit: 200 }); + const assistantTexts = getTimelineAssistantTexts(timeline); + expect(assistantTexts.some((text) => text.includes(followUpToken))).toBe(true); + expect(assistantTexts.some((text) => text.includes("[System Error]"))).toBe(false); + expect(assistantTexts.some((text) => text.includes(SYSTEM_ERROR_SNIPPET))).toBe(false); + } finally { + collector.unsubscribe(); + await client.close().catch(() => undefined); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, + 360_000, + ); +}); From 7ff5933b08174c8631fb93740fadb4b9b95ee107 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 3 Apr 2026 11:16:57 +0700 Subject: [PATCH 02/73] chore: update og-image.png --- packages/website/public/og-image.png | Bin 22592 -> 176454 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/packages/website/public/og-image.png b/packages/website/public/og-image.png index f9b8c171d555a7a5b4d3b1aef46d4232f0e4b03e..acd25d018451e8da8371e8f23fd8d555a4c47223 100644 GIT binary patch literal 176454 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 RmJ2w>2(l^sQ!7iu|`P8p8ARlS5JVRs3+T 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�aFfy@40uiwqT8bD1 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

=pYh-jf3?CtmZ3Yeyz8p8J5Px zQmYQ^NdrLhEh32Rd;+2JBfeqBjtWWCCXQKl*=Q}v{+RLF$E?*HQujsIVr%7O(}Jv;)=X5X zrgci~e5zHMm;5Q!MSoGL(J7Am+bct}x=-cVkAs5WP}oMoI_CSE)et*6A_D_B40Jzr z@v>J?o^UTS$7tuwSrgT?9-oi_V2=3YX1u<)=fV;F zMD8e_UsYkfhzTTlYJY?FRQupPx$4wTXMOqe`c!ZA%o89}Yw@7xmjJ+XnA8;-9> zFe}KH7}CSk<6If8Qde9~McUI?6r|UCqk`?6Klt*TH;ntJ#_2G(@Kc%F#nhmSSG+Vi z%kV5Wo0&xDpvB2_|9CiD)JDf(eYHAonASvQ&ySp(cFfV!NWK%grzpkZuKMnSyZI6| zrelCYy*1-s1;Yn5E4|+i$ES2=6(l}xVvfu+rsNG7CV%fINb{2P-crU49xXJ8l(eKN zC?4T(@^|&1LhZG4;NK+?cJiQ)J!nwL*uy_lh{dPrnv&z+X$B+NU`|btTG4@B0JiCEbbjsQU*Dxy|)sf6*TLsMr0S#0WsC*fwSuOHc59T;ICrx4S@^#SbMH8YulP z(P7bYt^%>HJsH)Z_V)aMoz@*^ixV?PBX-DWUPo5_d#b!wnJom47008S4E6=dOg%Ac zLJ!an-M+Wy5Jro@m}?f2r?5}SZ;042^U+XJ(G&7bOdOU#T9mR-MleHia(1X?!nF}^ z3L*%Rh)%^Fhe}kIPKEFV1xV8cfKDbxh67lmCnuEMv1#SNx1(b5IA**hrE1|4di5q5 z#7nG*v<0FNxrvzg*iNq#{3bM7w8a0+Y2jCQer*YKASgFoJk{G+QMsPf({%J?L|_J7 znP)K;5C4lSqF!?DN12afr1O*t2y8HpLG=!FLe5Z)2D$>#SnW__waQ$3?42LBGj?)C zeA}_AkEJKw?)m)uCra-e18k$%KuUixW0Gt*0JJ`~R%rQ!Wewc)gjiKP3J~M?w=AI` z%UF8jtCu0nY9tKn$ZBL5Q$>Y1$Zo#mlU?&c1`&%r9dYB%T3E7IvV6{|qKjWo3If!Y zgrqMPuDW+Oa|4~EwZHA@?#3ylMXWKAiFQFhG}kH+*D^KAZ6dx%A1jH~JSM@a*BFYW zii&QQ|0xK2`#WAX8kwxZyxL7u6OVw!?*5AQ0t8O)ARV9_e0D~pWymv1#>Iw*erJV( ziGX)40!YrpAEHoH_%~BOAzV7Lr+HX&70Fc<+A4hRnSoAVuEca0l2`uN+wM|L35!ua zZ}%18Bu@R^AYfThQX8|ge@fVW_ip~-F3ZPtnLIc9%cq|eFB^cy1G+DzmOSe`T%I^= zrr$*RrAi&{AWFV-oA-Zsh>C!qy7=12mQS}2ZePEhKzSxNG}U}f{C&&~(SGaX@;f<* zhXnk9B{?1o=x18XWgS(vPFR1f6bgoaKD-C8E&awg5sGUwCJw7_6NMU8Gi;rSMSbd| zFji}0YXHEV$(?6jnx%%}brV1VFZ^MOe4mlK`WOFT(VmIGct+c+J|# zP%-wUi-feiQx4Jn6GDau<18fNV;yq&me7#`BzD`ooz=AoG5%!@|U)*HfJsCd}p-#wYxz|hzKKe?U zkmr~^tMDZ`j2Y4gpbyRAJH&nhX7zrvLF+d-;yjCGytWyB{{2U~n6Up8j@pz*7J(dc zU>L2t{20YA`b0^<3(vZOfP{W}qZ>We$0LP;IrZ%6s9Vnc6oX)R`c9IR96t?OC{c(9 zRA^1w9@?Gw_+1|ze13io_tRrB%86_|34(VjqEF0d`XJL5ZP8u4f-M*oMF5gxh)>S1~$w-O~mYtEN z9<+;Qi`G+u0EaYHh&@w3MpfGCe#qQ5T8`Ng$&k!HH6wZ3e%whu0@EVMfHrIQMn<1^m)Mrc`fo30KS7H#&q zQ8p?t+x#;IED$NSNKzp-E^e+dJv3RIXAdv@mLMV)`GSg4-%+6eFPUsM2OH$!xLO=m z48YZLw?xZ+Is#S`F zSZ4fhD8MSJ4mN=j;rDJkyK;#Hs823eZ)%S*z_L|`?+oQdS+(h{uR)9O5v(Kk&##s=H zkYS2y9|HF1|B@JFKKUWSpdy`3LK~~g+s6XX!R57u@hQ%!EkZPYj4KFmdBdYlq|R zbLYM;I^5lKV8etd7K_s%f=qepuhnWnPfEr)Q)ei6Y;iduSo#CfPS=X@GsOzfud%hC z;r=tvuyBRsIG;zuGo~dZF|G>%$}r-5w^T{6a(+Y&*PZuA-V6QWMET=^Poc^BMz>&R zd|bx}J_4dJ(42?Dk91*_)!M}RIBa81qV@g-2kjvPJT9fn*sB?aS#AIM%~dKwr|~sG z?Stn>wB`pVEG%q238J3CR?%J~7@)s^=ZRZB^ACf_O58I>!zbLC`Wv^{<|av|P`5~2 zwnEc)Y|!=22Z2+0E)SSuF1laKuG7@4NRwQgoG=3;nqU&d{vV#fTlnt`kY)UwKK$Ia z57ihca9aeamOCtmsF^ez^R4Lp$wpHyejY*B)u4@W zo3A%qrO&5CP)P?LvLIKNI87b!OW`FKzqWk{5(X`e4)|RV?v5A|bRq)tC2K6-*d(-h z8TQybiM6?133D?~!PB5zKtm?}!hUm=3J8YzZ3SsLU`XW~=YQj4l;9Mg3q1HFX#C~= zy-g-@hTzY0uH@vr^t?>Ajp!_bn_BWnxuKbFGv76V*YWOI*G039`Yni9SMvEI>eQuA z+d*{48=c++G=wwq2a4DaZJcO5Ap@%XV0JyAmYqfwxgdN*L$j z0GJ#7dVZ4RE9v-0uE7dE6>6C;y=Oz;sU*BH(5PAz5NIurzR2y9FLL3hjB-Q`^rZ9Q zdgO_Pye9+ZZXwW4lNreU>MfVKe}@b;hIG1-CgocehzCJi4NB#h+#i&cl|>>{XMeE} zVuEvIqQxHjT4@(sr#4#wHwfZ#Um4JC)9KgWA{3XrL~ut859MiPqF5lWTPUdp53Wj8 zY`QxxGNFS94PbPz;r^lYe7fE!-x0!8y%>hO?X8>XgpC6%qy2vNIsbQj_&Jc!dC+N)6|<>!$0Tzv15n4q_8IA$P5s( zIS)EeGiZlp(Z!wkYt~6o``%~$eHsl0)^Zd1s^7Skf{J1CzFq%NVCXzZHX*O)WTi+5 zwASx1VT3ta$ui{pnmjnY5Z2N7&)TT0qv&io2+*RpM8GQHUlG?ifxzSeEH@?;JPKC4 zv*``7FL`BKDL^?h!+Hd$AvaYT*Dxmw-yUE4W&h~>dufh9Z%r)^4IsefqqBB7pS(=Di((S4q9bk_x_4c1*;m>Cw1h|L4YE12ooCg3pYzFVw%&HXn%|M zcLU6wEJ5gFt~65lbl+=EciEw9zp1c8(9ces;Uq=c9S?Z8T~_Dh}JsWF4hP2iY(X zhnK#TxMT-&F-p3gePMS`Iq_uzK%wdeD|Ris8|I|ac&W$=B~yo16GiEdfoUtN+EO;_ zy|Iz_ZpbmGjo#(|&64LZ&9hb)qQJ8zWm2CK}0y zg#=WRK1~NTX@s5M|Jpjo+q+!2uiFBB=|No8Y$@=w;Ds2uZ~u6D7MD^Tkn5d4U)h!~ zGRyW*1Ha2pPjOTkHT>)FIjvs#&h^Rp-Y=kT_e)ySznM+_&BIc$2-#u$a`ZQ^cS-iw zJKu$o0R0|-e${YD`|o8L<2Yb?bGxF>%3Tlra(2%pd0%-09awMfi3Sq=(!;;Fa{)|9 zJOV@BPS4UCE4k;Cd(#vDO8>*~tz?e_hZ!hpvl85mzh}6nzJ#yKQLIsY8mkC4^uC|Q zG>9l~jJ%3SI3WU>25aZ2Q*l2N01vLw!!)#lj#~-7(7r$+KzD$*OOIZwW7P4aP)&ii z984xOtwJqd&}VuUZ@?Y@o}cqm4uH|4wXCzVp`A2!a08R*@@Bf8%+gq zLW8bKuA;j!roTZ7hG4a-U(uTzzp71NyQn@b->Wk8RpM{rym=euS^L~SIo&=h4Is~^ z->ampFnEJKvaLI`*gYW3S53yX;)5*4tDkBK6UtcF;M9gBy#(k^tK{4Tqq7*m#ifBU z!?v$d?0+!NvZ^-JTxbTAtjY1!F*38O^N0EE9~|NBfAQA-F&VlMVX%wgDzm}2KD|gkkp8x;){*f3 z@i$req)cL(lxP`n4)xAYZH8lQ_~dU3fY#9CPiH(J`@-7yN*?T_oM^hF5%VY7J3OED zdXP+wZF&snjXn(!M1AQZy1b%6L=nS~QnF^`7_ICj;uM&#m3rnoxarr^l96}uukdkr zV^gpwkzpYPRPvArisoYQzwFY}RH?5Bw{LX^?EPwYZpy!q=(7E>+3gzqcXMLQ0UgoE z(VXBYzw1}rnFzn<;baQI_Q2ms@%T%d8m!+2Z|D2N`AoGm?3R}oI@ia>&eI8v8fM@A z)o~&mw6>6a^;$#P2Jv8_m(YbFav2-@)+Ob3&~^2E-(B}w3(C8X+1J{SPFyfOtLZ3; zW0AeDQ2K?P-rp^zl1i){QMdD4^L7b_7lozHv*z|!P}NLHw(Pex8Suz?8i{z0VGIWmsN0JDYe z>Nvs;MY-|UPs+;#(WH_4ZW}t3{NakBao*o7vzs(r%ieb~lsDA`qMTG6pRyTIyLJ%O zPs_l2)Tl4T_ZmO`JDELUO%Spfc^`PQ{L?-*?nV4FDT^=_jUTI^kGf70+d=_vV5t?^I>9j83Xh#83xJ9edT} zG%O4r5CeN6udxx#)%JK&^-}0#2Q|f~6UPv>EZFIJxY=qK+TD7bU!$fa`&zw3Z`3YO zEPnKBv56YlxFT76Wt~HVe1FcwPU;r4{(YWYoP|BgP;LMAr`ofYH)SthzjKK zd78DoR*KE38XOh|xAr4QIUB3ksHw@mV@MsaM)A`sn_J&kHqXk9N)5qNihhwSHn#JUF7 zA@!7X#?R&pLLN^s0&1?>JSlghZW%srT@}d4J`&x2!~C%~Oh3YE@nnnV0o;+J_tlX_ zUXZpXIbVZ?ycKZ`5ae%N*rohL^}RPi_?Yx)Fq;~!275VjJA=4&f-Yd!v>gYIj*lxw z_9_7;zFu%L(awERjE46AyjDbYFg;e*C=w#G&s$z4$0wQ{eAgy}Hm zZTzKAD=^4@O(p|T@8N?3ze3tQ|Hf<(C_L2jbdiC&U2tIOGVSV+x7Ja_nS@BaDnOEI z92#<}zQVu$XwUSeEdrFPLoEscDuI+Ya#dXsgeN<|yypse2rz0UJgc1uc>NPO4A3XN zFg(;TjOqp#y9wr|p(RnGzS1%h-GTvv98heRK|UT4ME0;`1X?`21AhrL?A_t4*0*$Y zbXwW(rz248{shj@(KVLyGbptMajI}{bVnt3!P7QK#VgcM_RwU6;xUHiK;|7~`1`!} zH+?Q2^M~g>t+f|Bh|UoO8R#_7=30#zb&rS#2%dBQ+78>nISr4~U(S>)1!3XhLy3yxcV5jxn20t%zZ+&K==b9k<2XT2FrvFwV{zkQ?)p0n zV9vc0A`BTA^Mxc3zxs1}ME|ygmq&DTG!TT3_jdyGPpl6D6ekqK$1V2N8<$qPZzF7W zH6J$14i60B(5jLB?NdgAno+kfgTx*hsMedOlJJWV?aW0HQ2!5AZy6TV7q^YhkV6mM zFhhfMHv=e*q=HC?fFRu+Lw9#0-5r9ol$401ba#iung8=X@43#|ALio2W?1aK;72i$}uIlRmekgLn|CUI9QH)LT;i8;3i80?5RIWG9+fb}V#1W=lgNrhKv z7s#Qw7yIAiLtMZ(|1LKefXgD8U3_4zvN_2TOoVCkG64+!h9Y5Cc1Pqsr_A(!Vc` zCcwvsFd*I#9l{5i7V%*vs=X7yYT=S50snFH-(WkjA~<<`{)Yr77{g65$53LS*MCt+wp0#!>{Bv;6S zx*laM5D!-K;OGF({tBew=jTe?i$#z$cyxG5FRzbi41O0IEWE#r9bHEB=lgLJsE4%a z(o(D24UvxaN4^n7O3Et#+SCC;5Yuj|OK9>6+SIYF?*P)x&i+QeM@7BT^fP#Q~I&=PZQ|-~|fk&B&k55mS?{F7O3>KAM#9G*=&8$i?Hl z@+UZ=?$l|5B#x%){Bb{Os2A~M{Rf6+yB-f;;2|OgZMg32@kzwRrm|yATol4+KZ_5&PBlm={x1vmja7 zXGCJ#-0weJ?3{>W^S!$W!Z;eK-0Vz*EzjeKnjb+8?su2_8F^ZN6c>ag&fQ*7T!XWd z-AcE?$B#CdUp5bZOCijE%g7}}MDDXzpV6#@goW?U*84XS6L0@|<6%cMOqXdIsO#2w zt@j~_oJhgJe*czMNr*`~YT5g}Q9_;(j^LXYo`6|I7BhAzgE4VHB#F44ogMCX&qsux zPw(gRDqo`i8H6(KT$4xN^y`ce#Kg|hAAEu8pCDavsE)!;^`Fga( z79D~IH+GntO?^yX2WoFc_g=SelI@pV$D10#_d1E{@lMD;?v|JheCYsHGWuFd~>8OKjNW}qI+Kab3}zpXFwtWN_bAe zne2iT>l|G;DeOYB56_$ zh@vvbJp0z&*6!y+|MeR+t&HL2{CNNB@0e=&uf$6;RvFDtIGdCou7hU@%^E8XS!+%rWmyNw{qyjSj zwj@~)1^+8C=^HRX*H-fMbKrA#pdZTo_tuV%3L!E?EGmM1`>aft)cx}OuZLSzn;{-s zilHa+PmMwU@MuNFf0hdp^!X&NyL0Z2n4JW0@nCci&I+?WOrj{18mH{pt ze*`1*67!sK*ESqY!%^#9rm? zp*6&VqA%Kzl+> zvgG^BbN5r8dR5#P*7~kbTc;Wm(lc zCfxPfRU5l(Y~tq!UH!+iBD~$5U7GH5hfi*3$ByTY2npEt=j+-@cJ?)1=`;_|FULCH zABY_sUxEAHzOIFe+&4~2sVS&DZj5o2StrzWoWd0TD;xq4Et_Ct<0!ZPe1&z^%3v`& z7-)$k6F=$oxloVO8V3si?qhiUFKxSBsr%`tiiBxbtZi+e|OV+I%$lUZDJzIDBwv@q>#P z04fd}AOdUVM`6!2H=#h9-tP+&(~6K(x)Gk~VDpF=)+`v-kku6dls$hI4c&LvEna+7 z3tokNNOlI@Tf20T`e`qV@Hy`8;u73XJ^X;tt13a$j?D)m6&7ppoy7Lo;v%%$v&`nVde8d1gK(n0;KdEDH>EoM%!;QOlplOPOGC6aF& z_JjP_4>E$6hIYI@GHoV|&$(niA_BnJ4C}<(NInrURos0oKEw|6=P!Y@;%q04J53l% zQrVh?t4_AoKW2-e-*osySgDckRG2Iqw(a8GRT4|;8eKl@t+mZO(PRzw|4|KdAa5@r zW5s9V!3d~>t$y-){bgb=XTP?1BPWinz0MC=h|Yrvyf~j*{5I2*kBj&tr%jMgB~q6v zEd5CvvZcJGswFg2>216a_yB;{U4#ls7Szx0aU7%vda}6R=G?7wP2sMNqrm4~BS_?R zY8S%32MdJtez}y=_CklAVaC|ybeYMP=7`?Ls=fYJ%Y>hifsHHAqN9mhV5z`gr@l=X ztvF==G%H=m4Kz7?q(+%#$XA+A_oht_AmSQz}C=Wcw-UvP!R(JimzHfw0a zCj6&>iSp$Ts>@?PSbJg8;7FcoVb;ZCp&o@FaCcQ;5(eCHfTAsULJjwLjj9w^8p5FuO4bboUx6a_; zAf$)(Ws5o7QQ1#gk|r=Gj0j927^t3S3jovgsdE9u{h zN7S1G2h#IqgG5rCY8_q}e$fXcI3L?*a79d*GnHCSa2dgDIn5*cPz8X{8*rv+PUWu< zA|H^6vDr7?_%^2bKSp%eY07y{8#W{5o_S{;#a2o=62{#*O^s0_RXuak%xVY~NRRk{ zNQ^9?^yopF>S+U?ikKj;mpHOs8SGN}%BuK?cPl*gmg9|O^5&!Fn#I`BG14Xx7~hY= zw&Nz`pKvQ$KLHly{k&n}g+k?lT;wOa%VBpHHaEC{{Gecyed=`gU7epTb!w5C2UTv$ zl6dBdaI|4aCvC2sxUa6xE#*d6ozj4;4^WOuYD4HA+0(tK}F}r3T z=lS;AJQvv<91pt?Yw^AOK7RV)LynN<%&nxw>=yXsz1s!@3WR~*3Ku>%lfNy1QH|I$ zpQ6K=!p`P7ao63wK?P4vpa=zOnYJL|v!uub)`!JZa$CVhR+Y5;*F9v!QyJLV6p=9}a7xF2a~Hw0+jf1nX`Du(zxK-FjTYwjPWDUYqHF(WW~%j~ zmI0W|GGXemii!T@;$~stUmo?OL&6R;(KENlv9ZX~1Le6yDFC3m8c7 zIpp;Teye@QqW6FT>ZU*5pg6~8sKwPQU6fl)Ub-`U5Kp$kp@gb{VW5<(in@zF^@>q3p`85v6@1G=lAAN z^_h92!(B)kYwEkJi@F+09_|inC49LnP!SaGSIKWJwzqe>SU8J+9cqMAt^T?N>!ee) z2r`8ZnM2T88OX0bEG$rQ{^4Jnw_&Dh*2vynQP@A(Mp&GfuN%*{Asj^*|MyuTAc}A+ zE^3)6h`VLj83Ry8IZ(Z&6lTRrcyeFB+ld6dqSKTM?w45+mjF9Z-w9m1mf^m}NN@VG z71$q^I$1NElMTQ_NVBDcUBDbxLa5f{FPattdgN4wiG1>0uEs^8hL$#`9gk9wu? zM|*u48EF$JC+iwN2S%UA0l~N;j>EMBdS+pi7yZGCs=e3MfaAc&Ra}nn?}%WZ#I}3v zl?B__Qsm!s(`?si7kN%mxj!=8Tr(XsxMOK8Xrva88M2(U<`hE0>h63|W&Q`MLW1+7 zA)l&z@IyIENHHU&XSTs;4X#xd!-yQIgP6n>dOy^{LJX=m$nqQ1S#jKx-VEb{~ zwN-=NS?7Lt=b~wll!Jp6 z2!c;d8pYW&+0zq{6(+|1+NbvX=2}|dZ2FfOBC0RFuBFEA=*rrx6Tcspg)0MPsw%5W z7Gpg_&i6N7ySnpmRII@k&=jO`&7Gp;~q@|{$B&QApz{%oMb7On^>HDz-$9}JL6*Ust zoSYo1P7cG&e9e%16!Wr=NXg?fn5@DVD4yy+aDhGTyyA>im)#5-Cj$`#Azg z@XRlw#?Q1G6Yw@zsRH0QO z8dercOO|8nXa8oESBC*{7U7&JN2jG+l>y~uVicC_&m^}}dH?2R^p}<#UIwsW1j?^W zF7e=d3nqYwpI0Cid|1F3h58)RYRSbRgG81P978=k_*o~#$B%3km9|!H+^Yci&U~HI z4f{7s~cxIn)PoE0ma1_)3t*)}4Gz z`<8xKT3N9`6~lfYq~=Hj!q8B( zW~Rb|`uc_r%(4Ea!^6Yj_pWXl)!7EU&L&X9Z!YI+>#_)|Yq#60;;O;YL-a7bT$lVe zY9&;{7w%(37Zsp)7G6cKoL)~}5`JCfT=F9wSKM1V55uLUa&mL27c1XvsALE-lgEuI z+d`AHq($s(|GNMMNsXG@2Q#yaL1a?hm7i^r*>-;=5z-juA^;M`JTWkF>fv#y@Qk8h zUKw>^&bGSCT_LAl5K@?6u&ILpn%)afvb>H@^=>nu0czUfmJpGQIUrZ7d zBt?@tCE@2BYuV$Nq1j>cAT*(NJ| zv382di$?e_giLas&Uu}LY5iE+<3+PgJI#{L`Bf%m@byn1KsV4m)`TOj=GA}9#)_$p_ z(L2`lA*0HQOwcbVwsyG|X!%)#TYv##r6>*nd(E*Hp@7tMIkQfg53vaJPQfP~VQ0g+ zqHxA-3osd)TSngpYe_O#cr8KzHcTmGx0i$yex5`9o(2gML{BHMnM8;<1~~i4f_FBa z!FSgQ5&nq+Ps)O|)wOwiax^hFM-eB_OAnBuG6ld<5izAeVQS)w@_klBDv9{HGwKjM zq`JEv*p*I&H#nTRxH#LSEnfDuIi!QSyn1&cZx@5AEQL>Z{IYVlTs6^Hq^p-(Oo%yE z(a~B#5e>Yvz1#0Mz$^DdF6*Nn0Iu-vtL=F4Pg0axQ${8TwpQgd2m&Vz2Z%?wzXl-S zar$Z^n8J2~q}DSI{^I#okUQmBN{w;Ro0NURDJqO5^(qYjQ3jjc1m$mja+^Z~Pno~z zQH83UB3^gky1Ho%eI}Cpm794D{@9AG{s|}Kxc^g7O-q(WV=|gzf)eDCGm;EG8l~yz z=Eu&86N0{c`65Hb7yuhU?WWlV8sa%?UHC3n2K(ssAw?g+MB*;}9GTJ-zh`}ciPKOf zY;Q;QlBFKh1a*cVT>oFRGW{$4f!0jgM6Gp`*j?D*NKR;(CQ2Hb6#6xAi!NA`fzDEl z!l0IwqWn9}2BCk!3t_}m`INE#@yon!1B76BB?q}=i3Y{Ih2mAO>UvLNo6x!9f4g5J-JGK?3>*9n9Dt~$u#^piK= zP7BWj{Cg8379L27VC;)?6J|5DC5Ke)R2?}uoH|Knmp;qqib}#&Ss9ACGGT76>NXz6 zrA#2&>#wtSnUFkf_uVf^;ahAB{*goz?3}duUj?^Xx&EGh@GVs2gxR}os%z_EAhiMn z&J_3dq90W-lhA5?s;(YO!oOK6xcunz4OE%-+sXA-XYfkPee@2PgSfhp4rimyNn$IH zqxtSfX8O<*=vkgZy0i*iigIgS+43hhK>;Zy40l@J7EotjrwLY@Ki*6_h=}Vxf-gPLpu=@#B z?yCgF*1x#y*q9hYiNJ09ey44;6PEN(trgV!Q6Cmu-7PRWKDx`4-bEOpOK+ z;t=9C80s=W^ldeNs?)v;3+!Q~B_-?uQSSNTMwBP+Mdq;grZJ}3vcSD>P|?~s@qdS- z6b87XtsNH|59&G`pBY5Eky;$xA$+C+OJCn_$jvNERbG1rf;Qh_Hu-b zTxV6~4!UkaDVG@IA4w|AaemQXkr*o3!TDe4b)fJ63%#N|Q<~1ZyNVReuY?(v54Vdt zTUD)>h{C>4-GdC87y1JY^%7t_+U^!LPaNu0p%z1bRGa^vR(1@3a(tNxTshSM>f$2^ zip*qjfWDgz1MMN$ zBjYeNH3bdQipJFNhQpN8`UWbtvZ5-g>3{Db{)T9f)mZH9?qYUqU^SQrReq1g;IjKe#)RLobSqaI5rTv*(Bb*qYSe&+& z?Cz$dq#WKoIy#WyYT+{M^n055sysF}c8`h*P$*8{AmG|21p^?qg1`c4HDG})A*f#f z|BTt>tsSb&a;}lChs7r5b3d+Z(;9>d6U4DBi=g1h4Ei;HKB9p?e(qOA_Oa=6@47uJ z;0z&J0u_*X&mGFg@sGa7I+?Kc@is=_5{SKLp)V0o=JC5ZJv(-0X6uKVqoG$yxT?%5t*oXpf3J{>g}K^<9}=3UFxuC(%C3AyQHG)ynM^ z*sB6DYMzz_E|2XCG??CVxOAJ@$jj^J{>`eiNsP#;9|4nB0AwH=tDbT>f3)f`-(NXl z)E6C}ow%?1$e<$1<%1*?%}OlXVzwF!o&28D>a z*z&;q6U#3g(UKvZT&bAxvVQx55Nx$k)?-8Cb85L-{}JLyARI~lYm^a-lWJ>MRbM}z zZTZ|TbcHBwo>9!{m`@Ci&{4+F0KrSsVdx#@L9>@=d)UE+Svi63KY4?0F%c(bi@J=L zcwxA+np@U> zJgl0JKi*nx85WeUl13I<`tgoE9}CyF-pAylP6+z?O*I7m_e+u%mbYXt>tJ^A5eg%s zXVD?>t>GzF7S15e8O;Q-Fi|x`@ThS7a-x=8l732Kga!ggrtk*IQVkLV{})J3H|OLz z$cE0zv-b>h%a24#mPoM?i3g800Jv~BI~O*zBQxL%!g@Pn2%kv3c89_KCCiUu**!H4 z#0);BIy9*{r8d1Z--o_oIFR0P3zQJNr*Q)WwFSh^vxIl~oSmPM-~&T@7q3qu-_@%F zm8hz^W^{ZNq0ar;XNPNx#HB8#1T;F>9z|P+H9!>H*(z6^8Uz;2W?d*syQ$T*4N(64 zoICn1~N{Dwa6)#vd%fY5{Y>q4xILTjFPpfVGY2vr46)J{8O8Kx}i ztTHk8`$fBJDW#{bqizYF=K0BASBekHsPknJco}aS2c=?aU#k;t9dbCSkL9^xt9ZGp zseEd${7!K;gdZO0Igg^?3Po;wzVbR;{_+dRH;l`1`TbMsSoGRadah1@!57)%5>L=6e%J!Uk-xfw zGL5)vth%waJ_%g!ZH)3M0Or;9`hxk}YnH|qJULpQR3=ez>{!Ksm_43!D4=R| z8Op?xswR3YK{h57`HLFQ)IMok9LD7+U;)D1$Ae*BDma&%nQa z&*3j|pmx0ko_eH&y=`&aP_nn=sEvV7$%a6s(;m`WcH?4qkL60Ou(P!n%TsoTk70!@ zZBZ{v`R1X#e0&@w3~U@-LgTfOtf2`dI6okxdj@%Y4mc*Y>3%SfjVcs7i#HNsJq=RV zq*VdoAQI%Fy+Z2LK&17VK>=VS06?a#b8o36*WeX$ag%NX8bl-gQ4MASvjv0E-1^$&!`Oibc8%(Ix4IluG1Z7S_&&_tZyOWI&!^nFuB!_E(R$ zO_~$RDGkeMeq)VMm%p!@k5_WayI*4_Z+j<-I4pD_*{~l zxxJcr&MoHpWMSXx?mj0tXQ3#M8JM`*VcVoxiT1B*fBuJMX+N{S{U5g#DM_o32~<_K z1~<}AG(V>pts1mhTU%Z|$$_A-i8gY2I5KNda|<{Xp>=Bkx5&x;YLc-My&3zrv^&|l z>79Bw>%Sm|l`ofS2TbdfN;_<5lMU*W1)b37BmAOdLD0Rw*Ig%m{_gJXe;Sy#ZzP>g zj{PI+0xLWJ`Bju?O7L-%s=2F8{3-Pw+ArGm>4WmqE%z@)1)r)A5s?z75?x~1I=o_( zsO~OL0QqDE+S52i(X$Ee3^YWyx4%wuP$;9(+Lwq@#m5*?qz#ps2#gm>d8hpj7h&W| zN+P`4wcG9eFAPnnYHlUi7Z<4u`Kg94J0owiad6^KrE0|n@ zdDH@+v?cE6sngDoM+FUa|A&fcJg1Q|50HpHpx~c8LUC%;UC|Oc*>EY~>34ti?PfFZ z<)E(bzR*|E^Mk%=Ul-o8ke~=~*o4PHh=Z@Fy;?xGmXeD4=IWeaC>U>y?bTa$EW&u7 zE!m*-KQYxD7hSJ)wYv$2e-O~NI{;bUph+q#zyOJW(u1O)0d%oOrv@qPkZfd=PpN^a z7MT!Uri!oC*YBqTh7cz57-p^}%BX1QBU?f{xHX}}dn`zL5Cmc4_Uin-h|BHG5jD+J ztW$?wy~n=?0*BiR6$K>*bQQI3fbwm>X@AR*H-M+FGM-^0fpQyjr&ne@Awh#@T9eKc zoTRcWA(3qG_G@~Ir7phwV+@%CS*DYc-7>0VMvK(6DvyL%R&IuLKrQ#ut=k}9ghv}y zuuE@bs_d25x3r^-X(TAFW+(>m$Y2?Nyn0Qdh%TG1fRle#iY0!uu%|fLT$@4UA1azc z%d<4_j^wq+x7hG%hAnJ3~Ko>4@Bjy@;0Rf!WID$C!B5g%?Cp({SzHi zIyIOIPD8et_@#~${?W&i>}kq5yQ_*QjBFG+B6}uz=E5B7MI7>3_;2P z#wf<9vwo50UlIGfz93^+$@7~o4u<+$qghtO$3;w>EeP;{Ft^?ej6kC9>JyW0_$}N- zPCr)pN;`}@L;b@6T@GP-v3@z1FS6p)Wj`qmzEIoM=VNJeItQI;+(FjtsgQk%Z{Msy zH7w*TX?C#Z;b^}p0#a6rQgR*j@Hyu<1y&^m<@k_dH+Xw&6G%>a*vI3ZM-;6Q`lCdT zC1gCs7|+OqUl1DGk{=bB9Zeca?8?9k^rFy=@QCF}UnAY~#|9}5!oV09zvxOHM5i&D zi{88z$KCI6At(XG0ikN8H@JLZJk-+!H2T1_gVD6&O<>Ay)%wKYg=*eYK_w)o6 z8!RA$2;Ob$_;ep4Xmk8M@7&zM$6u?#@I^ z#G#)5%6=()&tJZy`lGlJOwYnO`l526H1kGiJB$7a=@iq4Ky}pdMqB)QEJC*iZ}ZOn ztGLWXR7#W;9fL?bN(q}%_5=yQhJoO}_%waaklD&j15u3CKgt$wZ^3>8%5s6PZ0#U1 z4%TnlZ#J*f^@Z`yLF--Cw$?k7PPV4mfWu$maS|vheelQ0a{D$l6*UvWY~87c5(@`& z6m^Ukkl9H~cl&;?JWXz9&X>-jE@_E1G?GS8Fq)AFqRLm00TqauVGPLY$T+Cc7 zvOT4qDA}7+3avQ#S=akJ1jGh9rsiY?Z(}BLqXRF&&M#-+gyC&cn^EzMeU^^gjuhC!L&BcIdX^7Pj=xr#0kOcQ zkVH#2L(ccCA#`XV8|&s^ViHn~y@6Sxr*Ad&y{3)0*f@3zU((k+R?3TrVi0Z-^b)NQ z+AM+VQP{gg3}!0Rn#IICWv6p3IQQj}6#dNX zZDJB)eBy$m9>wf-j&H`jsJv#=s&vs59f?Lgn31RhH0yRSARIrXyP9BRgrquubmNdR zTs0A?Zw20NH@Cy>U%RT?ng#;RPl_$94-dTioYgNDL&Lt$v|OmO_z(8l=vLWvc=tPY8Mf|TF$!&5y63SYAxny?!~zL z3o4XBg@fb>yqMfI#O~yG_QKAQek9^?j8$(m5Il&in5}#+kXCfU23fPePXHUU2L5h& z6WYZ7D^tY-N`st{t8e&Dag|;OUP?EcfTk&8ZcaW0lXqus-6LaIx5s)j9os}*$O|=H zU8N?YVSuWU1%N~7*VoRb4jw4*gqmXm7I;2GC*JL(49j!Vn4t;4>6{VMpanPE+y%~C1`iJrHtKboBs$onp->G85uOTG~hE__)q=#=6QPXSc+F- z`0=!}=Jvu%#~S_fCqmch1O8W{{k#spToco5r9{(xj(28A7o+~^|0wRRJ$)`LvSkru zxvBxkXuZ9BwIo%KBh|U+e0`M3uEgf${33KSy=&C+B+Mb+>$-JN_3+9$@AL4NP6K5* z0}qhY)hYO3GVp9=CV%Wy%?F|^Co+Korva=>`3-QT^j3#m)0^aDiOH=X9pB0Ca>hzq z#V=fCdJ%rFdNzH|eR%^2ID9lB(DDi0Ku!*3_2<zNnCAvMgD&b*GSq*lAq{0? z!pY=#595A!w$yV@pn$?&jh9?T@yJ(7JDR`s;Q%AzOLVl~ndJ7xAi>k45l7JGNP)(- z(pgi~JP}+lh5VM9*ugLq6cEn^cMErwVu*rnOdD@qR(qd;D4rYHO`$Lq3h3O&&CNYR z;9HlCr5y4rsuzF`$m}==1RoAw=<+|09|o!1ej-}q2WaeNV`t|)`JS_jIMCrc`&*fl zWpTY)3b$=Dq8bwQ3;j2u6ae;YbL#EK*&{F_3vwl_pValxkV`5ta!;eP;M%%-sk97~?k$HH&Sm0CZDg^UNpyX*8wc*nd zKo}(PJ(LjVH3dEmZ~D+_{XATK4Y1()uYb>Xzj&@nAi-mP{h}KLg1U2}fJ!cQt|yQ9 zAZO>hcu5A~XZ-DR2)$KrZs}GmXJr9;$O0(zu;8H7%v`4FslU^w!M8Rm!E3aWn0?XG zJ!DiCR{3sp$gSA?qH<=+N#dO5XS~vUd_u|rjsuJKP#-;gAe5r00fZe~;8xS$RJF+n z8{8)0pNJ1T>$P=c|J4--Tpub40nMO8+%(U_`iju%f0Qz%pX%xxtYfgk6nx;5zxVVB zMpaP8fOkf+8qpRtV-;bDAe4y{yIk%IY;!vhgx}rm1_yg;N~IT)6^*;!KklHIzQ7}z zSiDhyZu$4?e{l-$Hcl_~ow5NY8Q)K}6nO@pB`tw6vhgp{iYMxNNb3*jXUsN{weLu} z^^HtGH$u!q`s_b_J6Z;asE1Z!;pvt(%3nf;gpIh~raAKpUf^)erKG*ts|xw>o%(Q* z>t}WNlvvcCg+)mo!F3frW*PpXK$JSOS(}o2kspR6+ zZRRaP=Yq?+BHQ$j$AeE{TJ|cjMs#bFB36t}mFJ z`%=)f!aKzVQ4Kx#N4czu?J(R;?SF6WtR-ReCYZC_+v6YtnJXT5m)s-9_vKb&MwrG@ z7SuM*k-Z6I3okY=^VRKeGIqi~@79j{R|tO{Aj@i&IzPBMYh*2OG*^Bj>3d&m{5erl zR8rW-c`c&rTqiOyN->2pPi(10)gtVWCABwtQf+G*u7gd>Pd|pstwSW8lB1pe z@;~_Oqf+Tk?n8zf!)n8u@71=2w--Zm-BcGnG1^Oxn2 zMV|MU3o&O^x1k?8>8U6D{iVFS-djCan5*1KE2Y`6>C$B4TJqb?^M(+d@4I#XH1elK zKKHOTWBqF8h&db}SEwmo!%%;zCa&c^#xc{KBvS*7@7%VnGc7?>DLd)80mPg(x0i!_T65pZ8pe z0-?(G$;1{{_k@999poLcSji)qJya>SH{)EKRK(^h@W^`hI0Qr$gGbY;Ku~Ytqlrf}78n;9L&? zTtz4z<&G#w6d&~AdXyOLdGD@G32W~nI}qLKb%k1iRWQlAvF|5!1rV&PI-#OBM%iNYg0W_u+DrESN zOkax~ludaVEGEBbD2~(<1H~^H$6rT~fZlMEX2n>ea=a~4F?$;eKTll8G`mvFDF66cu@w3?gU<0i`j#kh23_ zjaj~kYZ{YxUseq~*F_9Y1eZ7cme=65+#(aed6eJ@E)VduGXX-jFoSij$!Vpe+Q&HF zZUcvxF};!&$oa=lS31r4SC+H_)MI}TPGZ8LC!}ojS30jSL2)I_s+5}D<~5Y6YS}_1 z)w|?^SPe5sPu)vP1e?eA3xb3urntpJg!fT94=2U*vw%Dnt~g!V$M|cV0V({xa26(6-HwP4~p` zOybC*^c89}i}^z;rIY&J@ymK+^AMAjNx`LY{bhh~&pQJ*!aszr8pdQkNNnKetFPZ- z0Xn-yT{+u3Yi0SY=<@a`r({0R(k>E~-mh5JqkB`y9 zv$fIn?8%+7^A5EgP{;YW^Na@lq3+{6Pe`PqkpHbOx_uybkcI)PV>d+oeHJx@7Al7hPcBIzy5V>W;&C4=Lx%A0GE;aqzD zfV*{E4h8n`D;$r*H6hP)ky%dW36}d))o(NJXG<@y$jUOvt8%{Xaoew!zW=V5x70D^ z$o`Uvq`1@6=j%TLAekg5 z3)NR1>bhzF##*n;h~QV3c_mP=CKlWSloL3$PUFH|u=;%+x+Q!I{tJYaNnwFI%=_G% z&H3+A{|E1)egj-Tz65V%nr)C5A<~yR*>!dLDc-VUysZ~2-Tfry-H~CL^2P7*m6?hI zs$c&@uj6hZBmj&KIsXue5KM!gcb{(8KMgEz1<;^^L|9_|?T!E_{m#Qu65-*;Z>XNF zFwBxny8Q!`gwbVG-s!zqtr~k*x(O|o!@}_wmKW*l2~oc~;2LLR7@9_!&?3Wyfpzj9 zi1)GK4XPj=M>8~{jGi4|%4CJsu*z*nHfBF>wCkoTa?$}f> z)Y+tzp1kw)coT*QKP;hgw>74>9iHej9TH$O9^w&`Vuppw%hVjvoS3N~PX>9~2L3`S zXk=hfSC0|)_zLqy2bX+%1$mvZ(O||Y2ZWYlg2)SkNR~5=^j&b^!)l#p9tk@`-#%IO9 z@E|<@#8LK`!4$4(8bdPr6^b9%unbmo|4n+IOd}P9SIoX8E=#_m>p-dbx8F{lsO8v$ zr&vZ^KJX{3uR!vFK~3X>7o&M$C}UC-GvAf3?paG})Q!X8Pr=H_6223y zHRJvS`XBa|SNFb4uf=p3sq%jm2H*i@%v6|wKJV44{cuRhPapwxbMqd~_gDmt{LR>q zY`X>$ZArhk?PD31$dN}eSlE!iJd-!jGew9 z%!Az@DC1+3h;o2@4-@O<4giy;E+tlyI!xh8$7)?B7}U_Yl@ z1JC#XCT=Puej>tbQasq$7=|BqkCVq+WSPu^rO_6FEN~yV6uerb<*$SyX9pi8`rcvp_ z{nu4A@{;<|SKyaEic-53wGV-@;Ys(b_7rrQZKe_?AE7AQIPO~Ed3n8(ha6KQ4mV^5 ztncUL2&4DK<)RUTJM@Gfo>zGfbOjDRkFezIxcRstjc&xdcir9ni;S8a1Y-8NqA)}h z`%0nL)`-aQKA!W%j4o!asXx^V6RE=SWbt-k!)!`-N^cPmqONPYenU|Naxlg#^obvJ z{TKbqCd|+hsB)BJ0Zd@AH9FFQod~WGl@Z$&zKO+0vGe0^qQIv$x<@x8aB3PocMB;W z(G-3o@nfPQ*S`{ySSEbI7C}bBtzJVS{*AQ%hpD%Wiz@2gKxt;kp*x1|PU#^9q$Q*z zrAu0x89+d~Q$j=(q`Ra$B&3n&k-J(YNx`k-*l}znPfx?E z>+mfGUSY$jGj58zs^rl|w06;tw9Qqm3yK6GFtN%|=RX?myZh%}qiQxeOvt?M%-ZHz zNEQ(A=@S)19}7~_hyjcBXr*R?^J@Uic2X0()aCuI1vmWCxhe3(0+)&?Xevk|T{N zz%Giv#`7w|-~PCI$fM4hxNc)<c|B6kBGnG@`NR? z_G&lP_t+8;NJvo168GEZbu8)_j9Pg@I^IW8<`3eekwyGU*UR@hnQZu?^#tvCl==Hd z1Zd&6zEa85i*%IKYir&dkiL8Wnf?eF1LL&AlOr5Bnc6?B3pw)>m)NVT8y)4qmxcpd z=MO=Vk*9TiszwqVnC{i`D6rQbvx>g7haTlsgz|~ng5&q zBi=TX?WSKx-UI=`&JzmJ+QO$x0xEUwQY38#OX29CsV*Yshr_-GUsJ5XwK*$Uv8m=| zBM}sv*J29jAoSm2S=cSIG%l&dDPi6K^J~k^`BnVC^%^5ate@*DLg{!~Chi_pW8Jye zt4s%0^P`~Q#Br_F&b75vqV(9~*!B=q(j6w*s6Tjz*0);;_MuyKzVuVw$ykKvYyjop zkIn$w$87N`B@V+Z{UEgQDOcz?kXcbv+Cl<-q&VFDPC+op@yuizOA2xyU!PR0j~&p{ zCPOD-B2{G(!VSyIa?v5D8*P2H@4#$C~Wm=wI$$P%w?&Z*Je&y{% znzzZSeb0Rbd5Y|o2N9vb0o0J@^I?xTWCbi#8i$Vsq7xfdvpI$Mi}6xCevrKBu>KQy zXe%jmJaPiDWUtPBiK9=7A(%c67bQ)~DAuBmY-DkhQ$y0TB1mT(hzQtomRxlHIImv+P7p=|d`dnn^uH_buw8R|um*?w4QhdhPW(YFox9G`; zx2LhiBq!@tsPd$VVx>heUP->WpCGQVGb}kLeRzKb2|f}_btB6FQ9MMV2SUPM$;eJ~ zr-LDyYO_i&!aycY0v^m+H;jLUx5?i0Q66I&3Z5StW+hN}42))4Vo3F%8u~8wI*>qK z(xXtlY+SLYNM{`?_jWQB%MF{EDE}@AUr}XShv1S@ry~R@I@w1&&v&3imxQG}CG30J zYj3gKVy=C*V>0vLXDjE#zn%C9*_)WT2-c%8r?SxEKa;Wxu2YF=U%%JJ2%c)WD!LK)CJNK(( zUlEwC_lP!f z_wZtWU_#RLt+}enn-E@g5X6jqT!w3O80(7F)~NB=(r6_>pF`0N>qUekY1FI|qUuYf zBy5MvOwM*2o43R0wT{Q(3tEoT2xbI;XOoScq`=+|Bt^5Fec#YGM~~K(NJxpjU4CU| zyx$t-7+QodhQxPp;F7B*X7>f-a}cA}()K9qj0mlHGSD&Q$aYUN)8a z+vt3bfq(AGN)X4V!&R(WW=hd%Dk^~zs*#3?F|1m~Cb~h9Z+bay89R`maB$)fcZhep z6`54HcMgD&=KRg_&X-*LJ1)!09V1WA8!OaYrs>50V@5OTjOl<$(AAt3IXd4{+dY}~Npj?&n z^q`S{_M>>l0e5dV^L_2kn?1tw6m5C8A-hTliVd_F;@}#-kI?d>fh7u*0e@joCQ5pC zAqE@9Y`m`8xar%t|C)+t=Zs_1aLh#mqcO>H3OU& z7SBN?*<%IAdc*cpeizG1o8rN9^Ls2IFi>zvA$R7yZH+DsUQJVHw34K(io1;Q#kRDmW}7gSHvZ!fj|b z2Y0MMDZ3TCVE#3#D|7y~pdHTI#Q)ghZkqi9)^p zxE?n9cz*ubOs@PcYv_cErqQ}|^k}?$yXkQ>9lv2&=Ea=uYtMPD#cSm;la8vIn%}(n zE3-62_i++gL4{JWkRil`SVQT^1E_$elvhFys&^$jBU0M_1czDpBT}@n*3KhgDj+DF zy(q3328D=NSN&o z2fFw(CaI0@V;^SL^nWc;if~YM=1~h>j2Qjn?Sd;_Rb?t@uo|GY_?GpNY1=JTJNy+b zRPPxFiqK-DnwACaI91BZbR5Dz#SHae6T@I2C~?>xs8a_DqSiuPwSV4YSq z$X&ufR5(29u7Ad{3b<_a6hOFXB*Xe3Fd_j01Gbuv*GiT%i81ZsR;gF!8Sh_;rVnNQ z=Ja2Mt`lIpXFE{Fi`h}Br^0|Hh?(S^PD}mi42^NlN)B0xs7S>%y@Y3>JB>!GYwhaM zz3YR4x076&y;m z5Mn{0a0pgG&E%A?C1&Z;@>?(C?^hT-B`q^PD$v8Si~NNtq`0F}=bxvqf4YAXuHfc0 zo6%m2gsijwior1clP*3{W8ZeY{%f{q!SM$Vw#XzdMe(nVPN?xv+xgy1z39?yC*zU4 zlwJz?FVLDQI#eBxNBtxRAyTRVl9IKe(u*eIPypc*!uZTVUL~KaiTp`RSpPE#9|a8o za|kS}*a-h<4z;1|@1<-^-s2D~=(HS2o{IxDEkK(BtDqX!fdwur2TC=d__U*F1Nz^n zPOOa%CR>I?%D~4V`!!?`SW7do$O^93etE{D2tf6!hDt*bd;gsRl~xcG>P`=*$JFpm zfS_D@0kgL6tmOzz(NPLbf<%KXnAnR*YpQCbU2O_0=tcdk(!&`P*x*dk{LHL28G_s* zjyKj>HAy;2x03`gs0j?Dt3)))dGQaPK+yGt02VK+OM+t#+R`XB$&gn3#`hQ<#Q?u_ z6bue@FQ)IW^-2EM_MwFhriP7}*y8DN3=Mp(q+e1ywdPS${DsGo8dv3&rl2u*j2J&R zqttIAXYu(V8n_qNvEUz!-GrJ6Vb`&yTR#Wov^GYoB=!YYG;=zZ4$x_KV&5-%*~frl zy42-0kYDM$MV*B25=MT#Ji;2xQ?4+tm(L z5-DYaV~s-=E|FjvgeH+%Jn&l!Z~?R0^YFh%)K}s@Ij@6`*bn5dUJj2mZp2BDLP>hR zd?7OnNQ6bEf}8J8I)ZI1VPZwOfn)OkzAq-D?^1Lr!JdEWOb7y7dTs7EliG59n0UO= z@pR{S7;tp?2Pj1^cA*P6{nhzbR)MhtAm73bRT3&3ot<>XmwIqJrMaEE5wldb;}jH@ zy6i;7SMV%n>iLAlBNgeC~YO$qSY)7p5Auseb!=UqxINqLeOVf4Jst zuYH=!jdO!T^2=AOAsadV3*X4hJwDof|LQclPZ#{azgYk<&3RgPgD5A9ifcFjmrG9dKhw9`@b*Z zPaImWitA36XIg!V1g%BkKGFbR6FEw)YLaNK^kiBxqlni>ug4h>`qBcnz%V*bF2Y*|+UTO)4Ek$$@1)`|k!Y>Z^> z{kNloN_1;!jnPbkzNU$vhxy*EdE^IOQ4rd>6K@p1vyC2J6VZ6m7!~<01iG8^c#rdv zg`fD!YGKg++Z=H|Dnd&`_x)6U@Dq478Gf4W+8zp7zp83@mVrJ1Y^Bz#g3jQG;bYm#b8r8IH1Y5m!)>};QAx6G`t)VNem@c$&Ffj ztE=F8dQ?OeG&kQQ{(GwuT>t5l^uqw-@3Bjgtwz?gh~7PypUPQG-ggf{{5OCgGr;r0 zRuRU&_PP~4hk*rYl%}wA>8d>|`5{78!2?TqT4%7|R%Gt|{h`NbjuC@EfRs9> zn-1@V{7lf@9%~b19rYFvvpn@a{ena=(52p^9e~$ZxEo=wvI=@3mZM?P>r|yepf_US z;eb^V{rxZJ&CY5tzvv!g7dy!8BY%~QSPFc!aGk+~>;s?5E7K!DtCh1NhF48`r})|G zgezpDd8R{ZBp~w}q@Ublq}Emfa42IlDXeU5BlyC*M@C-iZhH?MOqv8s@^~bDdd3lD{s-buu_nDVz_8ImDG*@m*13iZp+f810BJ__4tX}agwMrt z=(0CNe>>ly!w7(6`ZITk+f113jp&{apeC8`jK79e*Ris+9~7MZuRl^!T8idwruP&= zmWl|0j9NTDJ(6$Ld3hDT9W?E5|4&^|xP16qGd0KWt|2meuc^uaegeIj8OQzf$Cn6f8BPE!e{+*MV9C$trG|+TjskXU~JF+IH zq+PGbj=Zls_>&L;$f(K*gh13D0fkouA$>bJE}*uTjMq#dP@smd!N*Wk;04ELm+QP` zh02jqJ_r0O{x<#=_l?jdaaBG6;R2WbI645*ihRoSaBy_6F}cH=^Odd9eYHJM2$E#< z{i~ymqdY1eofJxYeSIB`jQaz?YspbIoGL5EOCKK_7iZSwfuN`sgB0EZk~z=0KC5!U zjLa`s?#6N@@ad$jvyh_=^Ns6WdKp-&N*e(|H!EGDh|a0S;rZUJ@`jZ{_^WKue_}XO zMe)xNro_a4R+g2Zhd+~(q2e(LIOW<=BPQNQ3&ISWORau_y4ab$Goz^~zt06|um3|> z!R<#c>2WatXwQyIT2qpdlIT1ET&NhBm{sB5M)m~1@|w4gPzwvD140*EE_O~!bst0_ zbv~=o$79LznbCi7$Jal7)&{hFeaU$Ta$9_2)S?*~82!-pqCO?a>#1M#Y%*E*GUCy` z0JeAjQ$gFvJO7a5(ZSI#h7$;UonWkIK#`XW1;$d|V@oCKtgfQYRM7-GC~sJeC<4lx z{ifJOI_UE+mRo%2G9)@bdwO~w%DBmfppBu_dDUGXuRiY|{M@Ck+yVBUM9{+x(9T}C z{2%t|$}4d6@(}OiVzm+H8hNOVG;y;h&~+ zsAHbv{nn1<#nwTB64~Eh9i_;Azb%8}(|zTmRmj~~UsnNxu>(&}k^edTIX0!7IRA%Q zP*6pHtB{T8L4)Xg2bf=VZdht-8n9zPtciIeV}of-3U64Ep}5wzI{(%ZEnbvYef#K& zb63#VC^;pnwR?NGE?x~E?qmgKwyuDD2YN*`9FFpBw6@6hlxeXgM$s!8P^@)ZxwwF z6ePYE+eJ4$XgV2vt@N=Hl=Oo6ELxO|8bleU7a}z=*VEloMXiN74yQ`3|HdRpQd3n~ z^dfdpV6QS+I+Qi7=d)}Sa8OJz{G& zi*jDPaa^so%BSFoROBG)`<@;7z(^0#=u7RS8D14A8VCVX3BfR*#h>B~)qe7FtKGB|K`b*I9e-e* zq8_Qd_09b-YD{GR+q=^afJ0>%BfO=sOh8hA zu!w?_lnA0?xU&g5(Pn9{o>R)`Fw`EvX0f#;|8arQXJetASh?YRbmTjPh#@O?c2b~P zmXwU6m5H#}+1N3DZHibH5tCF;J;%mo^Ue+LJ|kjf^LuLt@Gz!b6!pg?>Vo0t$mkoN zlwBJeo4mcf!XPoIYxPHqb$=Wp;Sq1C<;BGPn(#E@$mjdxXks*@#>WPsU8nPN^I>ko zdTIO?g;tgT(e5WT)-Loue+Pd`!DR6Wek}T5rjqF?J;gno20zIuDJ(B7yYB98T^4Fz zY7xn@QJ=~Nen#Ic{r+>Y-3k-`4mc?*I}%;^{I)_NF(zzq!v{B%Bxk8cdl9BNDE~EIZKur{Qy@EJtN~0kT`yvCn&-=c`3mc1)&{ zRbR;amg=&h!TFDxzr+c&6TvS(h9wMAP?9Pg7!G%8LlUfLtY`LLo2{(S1>8XQxKgG? zr-|4(d6kP_gW$F9Co63ilA$d9%ssA4wP41)ubr%t$6dYHwPTZmi7W@Z`(5CvsVPZB zbf`tWcRdb2L4pL7KePn~eE|FtS(dY`@OGSwxjAxqd=+{339i%pjFcWx)LoR?%D-Kw zXP#MLExCvhqdtJRX;8EBL^6;O-|plGT0%(&xMtrI z>kBBTN^(i&xWD5LQDyM4iYw|34gNv@R~Qo#^zSB~Baxy@&dQ>suD))FuBD4Al!s|u zPaX++)u8fgosO2GfR0VP;*oFSUNpijiKAiw(i0V9t%vw-Gq6I(Hu3m=%EHii5Im7F+q z5TG}nK_zLoQ;{p`vhtaYqg$PmoAXQmz!eiD%$U#-`AaRf3@EWHy>D}xDF*L?PYMY# zJ3R~HeI@jQq4=NH?PXNF%#&fyEo|dU1}0pK;zA?Y2$V1;VRPZfu9x;5f_ci8jKEny ze~tCqHl)}CV(Xqv8dnsHLPSMx9V$OUu|gJ+l*#x|`?5g#Tz2lyALSSC@dx@(3_i*Z zoyN*GcKOdxvgSq|9c&TFi!$%uk#iG(Fh!|zo!hC26L6lK2WXT@aPU>H&*Iiy)Xrw} zq94m-IJ}TDkYL=8ua~zzuu_g49s6C7gNBm}RijGFU+=j1^oE{A)Rgxw3V9jKZfovk zW+u#;HLJ-RybtuewcfZrj(&8E^+aD5CUsew^PgFi7+UJ9Xz<|Sc0t{%KUh*a&^S5@ z{9Y5l%aB)vurZ%mQrPhGxt=)e!O-Fp;AO_Q4T!4NKX8$G7hR(yZ56kA%#u`siP&4gqW-GJe zUYs)`-TsqfWQII>OGC;ySL91QDNF?*?UJI}1Zg;M9V8HQ`XnMK!;B5S?gM&$aFT62 zOqB`r=s6m$LB3`k{pm>wnO}8jhCnP=7sY=VH`xkveJ%PK`Qe?dH|hO#rFP{rwEe$60}4H!_iHJFA3edqbQk3RmD#c zfd*fnrZ|ly!74C3UTzR&6~*N0IcMn|TcJHLV&G?J4+`QVVG(2bSxQy(%mlGK8|E6} z_q!#|B3TgduE}bCaZctGLiP1jRVw&NQdqgCD1wiv1}_sdf$m$3A{SGAE>I-{aWqDP zLO3(fCO*Y|LUs+|_oGLf>FM|TGl1{mv{;XunHa*Prfm(Ox3G8aU5p;YD}<1GP>>+& zVKkF7`d9nQUydBcvJt_QNQ&|JtEw2jrq+?HMBwnB{E?)8xKb9|NlnCrJ&2&Ehc(91 z^i*5z;FX~j@<}lPz}gAfvgPHe_m@@jKLKsFe^zmpPn|PJ7CxE{Di+r6U7J)PV?FWd z#24zjc&2f$X-#76QCgTO*zMz~g|*4I+2Mr?Yza$*P8g$wl0`~{37Ui9`h5rE#Fq1F zP>AX~>@`$#Z6jpJxsuzUQvbEgO~`zlWQ;9aNWvb6_b!;e_=}hWR@BxWJx`5BN<_{* z6U4#6o*(@2FGNo2OL_Sz-!XmNxyny-m@ZSI>vgfy`=i^=i$;Bxg9Kn z$)h$Xn)0TyOz?cZ}FQMwK>0 zQ6Z9|H*S?S1cNG{923NEqo57YeE~$TN>;y)m2oJj=veugh0!w;Ri6Kb%vjNB)RX_R z#T(Q3OaqF3rL^8T0m(KlMb$JKGomN|Y-RNp>8X%fqL} zKgl2Vdh{}f(rbJi5dEeYQ9x(qj@w&Q(3vE4Hu+Z)a_2v;OP!Z^SdV`=pXck|iTVu; zR=+r|mhbKk5P`CTU6mLP+1FDPnIS_bH({K`Z7+Q_To&7%3Fv#&_?Wp#^sGE98Rt9D zFxUB`j5N_m$UM3oHU46;l+4LT#juldh`6`}(H?(x>JI<4jSPiHv|?bcLu2_rosF8+ z0W9&=EnZXGugHP&AgFX{#&Ax2G$)HSGd`Tn-muf})(@#~@uW4pYj$o9RF4d=q>wJm z==EJHK>sKO2a=IkEgchadZK%=(5P?r$P<|n0B_t;P`qm_*PYYUmk%zwi+s!jW zF$ALd=FKaSXU=$h##2Rud#sDM31Y6^y5aW3JA1dF5?FRs)$TI;kr6cF8r;S zIAs|&Ut@6(J1$3edQ`hh*?3qL}300wS( zBDa*xoM#->Gp7a(cK@()kN&k@@O$~&H+H<#UW-)MH?2z)b%>CJcl<4qKj;ujXJn^%gWRO*vQVB3~(!yYdRMZ;Nw29KpC)^N*POOT?4YaQ`)PA~r z!iIyru#qiPWCapqZx)Jo1sEXdNL?9~l8MUW)4q=PM>)kMD`nwwPcI|3s|#@Z8*#8z ztrlrH==?%zt7D~+;iUBkJ(xSl1tWLCvQecfQhp4YgRfl9E&(b9%F2qGfBV)Ewgr{RmKs8H@ZmAfhwvl}bO;YJx_Md6E@6n)ij60;RYxa#QCU0* zEcKli1+(Ga_GtDW~JX&&B~JXvaGIdphl^XzCOKoIk=nv)Cw#CEXC70lV*WS<290 zPOly!zo^wptNUrl^}rF#Iy$=A2E{F6NLKCw$Zw2|5!n=6k;}1)G0U!w?|_;%qN~em zktxtO@ivTOkCP=iD>wIQOi43B8N_CRI*wk za*18E+8kVHJoS+T$O#in;6~lT$w9hKkg%|@s1;hx;wNt7PG~j;E)MR?!J#qOC-_Ug zU8vHbtA$d{d@P{zJ*$gL*_##h!26!5TytADV{C5Sx+t_(WvSOIExyu-;7e|<)ix<9 zJq4n>fdRFglxBV7ARHy0h^0T;?e?zvkGrMy462I-y{rkT36jihjVM9Gw+xVHA_PHB zZ>M^Z-4ZEbvwp*U_bDEgNe3L2|Mn}>!LKwb2ghCGXp)bia&l~iI6}2Jp%XKD@;AS# zt>rZoOeqJ-kf{``HY~r;UA;ADQ+}~#gJd~)E$Jug*qkoRXhlTi0D8OOk7ZTUq(Kcu z&BCHbE=EK$6;gw+xOKa*520u%MwFk4I$`=m)56o4+%5bAW~3zN@`b5Pd)%= z#Bqx(iL@usn2wJeiPVpwy}GiQ_V2W^r3qAQ0Li?wMTAKCa!Cn>yIc1O9D9OdYznPQ z=zQ5dJx@W%l@O0CZ=w8PDr*F|&i*3vgv6rbiBm8}oxX<6(%5hZbrxWarDjH%khbO$J zrmEj$dX{u@%&gFND$K|_f0Uim;yd`eiTeEc=F))9Y-e-XJP<@bJ#C6yfDA906=~E; z5|?Vn&{UJp%*-~Xcgdgi9((_IGL8z9jf?-g`7)G`ik-%$<&^97)QpM!XIu!w((9er zkAS0e`-R9R#}sw0Bx;H!Jz>-E(Ox6Y;w#;BI;DZLlgru`N$4=!IU@xSzl`5KP;g4@ zNJ+VZWWC;FD`~@btwp@Mg;`6%{cF3~nI7q=9_DZ&j!xbLw>=lnr=1c7_h;O;bL12`tMk?AI z9v7p97cd`fXeP%(nF+)3n)031tAL&wvBHRCwU-t}^yzQzIOTMO27g~j4;4o!k#2`c zN-fEoo40XES3`1YZi5fo5I%Z;R-T{l8C45j()T2PY#HfU27Vt~T0Vp^Bf~(ya{ub* zz$VQWey>P7|GYOnXgIug5uu5g8ic$^z-!8X*QP(avU6CogM)wY+h8TXt-*kvXQ8FJ zC{36Xpy1;pHT@y3Q1&r38h}K5BDXI9cU;ErcK#}U_w=|PQZIw`vXjz@q&kJqGVt|y zu~kKCLMT5M*Ea5}6u;Sa2DGz10FxC5ok3fk=kxby_Dpu}mh478ia0vt8WMNIJDC(PY)uhICy zP)I)HX%n9BhF6mQ5=00KOORuQAGJD*3|w6?fx_SkzRDAnunGtRlnMIq89uT82!gUg z(IV97)PL3~#laKkp+GZw70^d~+&A)3aIG$J_<^`12%ypc7jS_KdhZEg%-T@2Bt*go zJg8g(gb+;*!URVyl?@ha1$sR1B|VKfP8S{1o>}T?70dE4D^To zKb^RtFG$j^fJ9dS(1`ZMS2kQo;5Dqd&(ml0s2l@tSs~SMH+Co*@K`WZ4uU2JQwcEC zf;DP1e#21)eNh3%*QOKW3n@$kHUM{<#4sm=9T_kedHny|PfS!1N%FVEcAw_*P&rUH7)ZR0)N#RX!wf$JzLPhC zz(R|{8(i5mBKt#wT<@JsJlpfWv{%M#K;-3qzd*iQy_$qJw^`(_Urqh>lH5>%C*$pFlpJycIb z8ndVm;W3b$&vsi+?uQR$nv zQsCl*{K@3)^Wjev7i<&txn7QdoR8E}MK}EYwbo~a5x}_ATYkLXl-Zu`Jy!7nla5O% zrl}i{i0zd4JA1s64TZksPnL_3=OO3HOT3S#EoLU_5)O32fM~ zIzJ<}_9%TijXQSy($ogfpm00Cv#9BvH*KtI^0KHsn=^TqV#?o5+rIews^rB_yX*r6 zMQaUj6O0Tx)7DMv{x2(8eiR-|927PpY}?eZObCn+w(ZNw1h15<%zzAW@?b&LK*4ts zp{u03Sn;`rYAyda;MMr4054q!A?!fx{-Wj!_})W!R0zPai0OD*>%Uq9_4JK$BHa;_ zrv27yF8^jtb=*}wfynzce|51sE3iRgThEZI7Q0y6wqa>#cRp6(n7Fn#(Hhzu4yhUC z#(rb_#!W`(b9yuBo%7bzpnGpbK(f__IgGKPF2BLs1FnofwEn=(wKI~D%wD55Xo^DG9scdkzJ# zdRXCDzZdAi1&52D`JmR!MInrE6_7>vk`=2RE)*~e5H#q00z^Pv*wVXXk?DmBBb=}H zbRy+^NxC?Utz1&%iMk6F1=Y&k=fgcZ_6I5J=*)3F47uN4GleE`zAYFw&L}q!0_U(Sor4k#!v2;8W zTWM%Mth6_(YW17+%g|%xQBb9Z!*unWEXkK}V0H)V(Fae+lWZroKJLALo1P3{1FA}h zk&C6;JEQ-Y7}kEWNH~)M{OfzduBU}Zl?T3e>UttXT`4z&-kdURO;y%ZW*Fv}8k`(( z77O22~6evH#e zi^V|i&0cuZr}*|g+4TGEFQOKKG_&_>JU#dty-IK9upyskh~q4gn%kcezlbk? z1An<#Q)9ij&ZwM>AoA{&-qEfx zN=s6bMD+0>X{Z29QCZ9$i*Qg@e*_P|r!Bi<`piJELuT|6#R``Je0s>wWfV&;_ zQ%%p7QT}`_Nb+mb%4Ru!w)}pE6c_*w4t4-Wv_-nPv%LehKRI;s_A(li{I6dC1>B#0 zjRAL6B}F&{kdDKxXay#y!n2A^zA-{;?-H(c_61V0g|#L?2opy=!KE#aHd~Hfk&@*N zP+)YEoox9KZV(yfb@kI5=puw_u(rP8{c!{w(bv<<z<6F$Qe7~!1xz>{I! z`YRaB*ic_~ctm0LP^&74WJL)i+9=wEN|EO$5&Rq(Z0$oG?AKefVZ}@{`RnHA!|8yE z&Q18`a!GpWI~xI>)e=ZM^C2_hg43&uD=ry0CoLeAF*$xM4v#Ut0J$hoqkNt zA39wl(7E=(wPo4vs6?BZDyn-c*iTfFOIU=1Osuh~=>9txD{e<(4lJWRm2%(arV?mp zCoqQ5v%DLn?(EKs`a7A!y@z2(j=C`F=jZ2NC)ujtg2q=io{cTB9IAeYXy7#}ZRF~= z;nYwe0szj)` zUEq^8VPDBenQ%g69?s%}O*pRy8#=J|K}8vQ-Q!v znm_*-PGtv32n`6Yw~BdFRCwX2UYJLwt7A)UEG@a;7xnsW0y+8nk8`shs+d}cZf+3& z`AD4DZu`q0QY!&N^UK+e1GC#-8=j;k>O{DNwr2C5SG)4&1=7#bbj2dfF0kS<8Y+nn z>4M<$w+8u)i4b6q_JIa|h5&On6piaa1S|TZsw570-A+*J^_>jq+by5qT=C0JF@L9% zd1YKCiwQK~iWp3bN5WUf*H1#?RaYB(aTVu{$;#^ah>D^*M5`!^OAR;$*Qbk_Bvq~AJk-#Wq1fIF9#>k$zlk#^rR27d9Eim$j? zq$|FC>Fr;|_W{zVg_f3Oj*ddUZvf;$?y@PryLHoKDFO{RIZa~^FA%WZ{k@himxuLN*!YyXjB_xKHiGW1JGrGNZ4Y zE4{q;zAr~2ue4iS_GZ6w=2FhqaB-F}H|2|sNxKrc`Mg)*=qAGy6m@Cq@9KXG|EvG{ z*I&dp85eu0hl=sWomLlYmUA9bbUs zT%RamyE}9rulp-+G6F`xr(8f3z(&za+2zH+TtqkyO-T;GgMtfEgbM{&;X^FvvL>)7 zVK{I`k?ugc?nc&GwAh0;w|u{*h) zMq0~j)*5woL_-50h%$OIovIU0%2le_(!Esy>2 z1DlMui`!=EvkHhplIEy>4DaKA*S^-^t{Qau{e#lu8x^!Sc1miu_MJ{Ams17l*;IPc~32kC{QQ%Y!o0Wh`M=r zEUMOVz{it`*p}O;k8UI(C&yO&!A(8YAdqv|ij!{q@cA8ik>j`iGI>XA<-kWqF)-BZ zayrdbcVpP!@%sAD@#@U`qPhTw>J7q9rhoF+XWS2{g)L>ajNMZOxk@O>ZK(Njpm+B! zaz1Bmr(P-`19u>hOn8sChT$?;8g?O~0t0O%*;Annl`z0Su}s)&)nicjr9B#MC(?=3 zJ+$a(_cC&|SCm@JV{aaqlFOK%79cx1i6Ab47k9V+c38G;cNqk^IC*zKYrq|N4#c#_ zUmY}_E74z4UDfd2)ee9I_2KO1Nd)J)a`OrCw|gCsHmjfvb8@_my*^p>xxNU2bTPOE zu8m|?M!5+k=+H`gWH`M_L%4M-^_I-0{f-9s1qGlq%cdgU^*M?E3n4!=nJ;`>-`%_D zy8nbnQSlqQAUqqBQmb0((PttmW~`hyYqvB^ z=s(tl-CO`cM;B%~A9-=MFH=I-CyiY0d%z; zAD$%iB^G8^^xx>$2YLtyN?m*I~Dzd=&UU>d-)q6PWr`sT0PehY` zK5vRhn++eQ`(f0e&j$B(0}e46zQ28=RO%p9`7rm#&2PHCShy*vV`HO<<%87_l!w=A zi-s|lP7bggh`@i-CYPo-f!XqcAl?CCOb+Mw2MTP93MA*MA1;836le@Dg3iv3#XNq zd-TGbBI3+hYMpTATs2CXLU&gT5|g<>+A~)Tcf zf$P<JN=MK>{MXs@L`& z0bp620ey0o9%2l%zR%7Fo3Y}8$z_!h2|%J7`Vxrd1C%A;3GS1AAU=0~76EM4kDB~7 zTL;FJEWIuqW5*ZgxEAq-;&T=nKnzj)^?Kw_T|+sRG6VF%@S#Ki=>vNOfECz1*eO`lH_zhF96~0ZJB*530k% zm!ELl(HHv^l#~GmYs1BbO8#$qdlMpal%!aNF~n7+#pF6<b-%%8EiW$Ttx3_IlHFI`~sR6r#FdLk##@gs>Z0`RbcNGZ8jvTC^dC zehnmk*np9IRL4uIRV{&L{cg)P(>09reO#V8hUp(ZY)=ZSRyHZ9=!IJC*LMW}Z(4C6 zF1_=q@c-fKEu*4pzqnxuVMvEgk)cDn8;0&~>25?yQe-GWIwc2`?k)k5mPY9=>246- zgWmt=eLg+2mS0@JoH^&3>)QKQyR*DLgq0-xrDXa*AB#V`$eYfOwV`V9V7E-=3nw)V z;XYjG7g}8CkwJeU9UriM{sS?Og5Tx9Tr>Mv^G;SnT(40l62Jqok9W8;Uj4e82X;aZ zdDWf@W;&WwFqV{i#7G}|O^8HaKtF>n2;mV;T|qfAPQg}?1JRiCG+H#cI)3b40@vSMnp5y!9)XAs??i0#3=2rRaZ)(!tcKy zoWDF@;wKKnT77rBm)N{9qB_c&mmQuMH2`!l0%-86y8(LO4aou$%+(L7@RUFZc+l5q z5MEMR2+cnjHjMx}veQwUAPtn!qmy+LKbmy*C-8TSH|f8CSNXj&Sb{>cz90zkFU}{s zbnrpbjiiW(#weVHC-s|JXj-^LnSgBYwxTiuUqa%Oj~q$x4rDRq(xLORs3XeqludUh zCm};a{^r0fcrwYeu(5V>Q@h5i?PlUo&BGZRo+QVT1#UXmT3I3@4l$HBo3dmHsk3^k z`kw44`J8oC9UM1~G|hybh{~EQwXboUqHIitx_S_w*|RJfC#rq^1gfhmFb!qMS$`4#)i8JpiJVDKe#rtKfCHh$z%t{-rBP928k2XzGP3OY!{-5z(U8P{qkXXvw(k z`~0MR<4T|RX@AzIw{ivs=GSA#zvpJzHuEPXPd_Qr^EG7jgpsl+EGr!<^NJalQdI;h z&NXe9mjIaSYxTEtvmPb#3-$$0rrl7IpmS9xPI*I^))57Lq@eI?leFI=VAy-pd!n}D_4Mg0KLzNA4`Gu z)r?;hV|Z4ashTiwxN{}TfE3>}fB!x{%Flm-JX_y%?r6S8*A^^NPFb;Am=VN5k$v^) z&=i%AtXOPbtyKVMB1L)IzqsRC@HS)}A7OJh;E|_$N55AceAm)cGe72zfx3@qiL)7HXqTSP_P#Htm zsf4kjks(q<*M?U29e2zAE=#e|14bnBNdTz!WaASU`jXmG)rxj1apNp^O!F%r15khQ zPX)#-ya!v;>OSKke|>)Dz_Nq@ks@Hg)SQC8@PgO@$Csm}{}{6AX&#}L7Dy6`i@Niy z#?YJg)r`;)iSNJ7@Ba1gxG(*0{8 zSOeJT{%tMA;GGuY8{RPMEVc&a*PPEbS{zS22TuF=O;8jQWk6gBC8Qr3v^KHCLFx@@ zS$f+qh~IdhrL_lYx%nO2PN22FE6gp$evaX<4uQEl9X0}}`Fp5ZqB|6HdA6Xh08-~1 zaOQxNvJyjs^_LFX=0IoYTn1H}YFZ$tj0|fZXB1=^8#?VK->1t1uO5IVrtW9@3%_@7 zHC7c(eaIn$@@2Aa#KgquV9gFM_i4Vfx8maN#s<_;_+)cA3*l_(V&QR!eCZ_(2d4{; zI+3Jt;D9p)kjH0!PXG|w5Q&8|{Ta@60YibN!c~*x5hi+E;IaM?Zg(Cx)-y!xv~oSk zE#s9#eFD=R7sd@zsAaI;IT0~F89`jcd!;QCGizq07^kk)T_W|^u_zGE?KfoPo>V=< zN9fOnOlQSd=*47r=+t2s*R`%BqSo}U5+OjDfJ+XXt+AFnD z$*E%U0OB%a<|fP>72qp_h*TjaHJuV2+g#<5S7S(MpoWwihW@w;}RJPm9Rt-IopP3#B(XP@W* zX6*IEC}-f`z57X5?};${|2%F;9)qJ^#plw}e0VwhOHu#C4W*inHvA39Dq-vARb>|g zw+_i8e^Qk`@a~f4@uYzD#erwOUL*l+M0~{V zQiHek`x$`U?%R?r`#GupgNF|~v zfTrXbNWSpE#xYhi?}*h(%w8?mJC+xFrp&g#0}& zz@vVkgS~?tWumX3gua~4S@(`Le9R*tllCI{W#s^x8W;HQnjLd<^O*#p z8Zt|0qgjZ$fA%ruJ1q1mFd_g_xkKq-6_enBG17nQ1)4lvsjhyJ6a!qkCBRy)@+GMw zk0Jn=Ck|A5JLZ#KJ(%g?B}{=F0>eTSOwIa>*2c;M?!CwNir^D(i2}&Z?hHxWT23?o zaKN+-UR97W3s$6qorqyT%Kja_v5dCoIa~F)G_X(BpOSGV?`#bxf4$*$Wj%85bL~Fo zLpPIAUu?^x-)|-F9(Ej_asZv$gDz!mr*L!)9Eg^QG_|(LNe9cf=2Exjh%r#N^md+p zwqCaJeAAwsc0oq}vub?AnPi+Zg?k**H!{AUPMUJ0sKEFl%L8;`g5dJaOTOHmWq--c z)ZwO0jQLCR&}^?rRqMBb@7sa$pc&RY2jiruowh%uP~6TW2@l#3FbEF{2a~oMa}M}M zz5x;NGBFi1uyxBiOqoZW{VTP)e7do6t)-z``p;9{8Q3=D5M~W@TF*i>ViW@TAu$zS zGhmhQ8fq#=nJAw$yNxRW722?t+4Pqbd*wwA`Bvsdc`n0}Q=`ziK?NeLEMWe0NPXNF z>~d2~oA_iSjBg|Y9){0k*Hf0IZqIHGCez*w38~)F7gQmLM#yWgow~vogZVx_FFkuI z!zPwl;j&{{W|kYl8{OT@vZ)waZ;cdoXm7aFiHT62%CDCuXb7;Y@FA4T=go{qo*VjG zwuU<^A6!+GY*)K%oCZyI9D%%}u}#xW$C~rb$k;*Qi=4QxPeq}H6<2DKhMFQl@T`u$ zzG3o!NQ8N=KP(>ZS18^#@J92>@{ZA~4dGAuDu{j(n+?WQuaRUAnq+0n+1sA>Y#FxW zTem)0#0x$)noh4IIhyq*uQ=X``JwS*3Ht>k!-OBrm|!XKYyVb%@4=Y*pD-6+DOr}` zJS#_s>RCk@6K5vsh72AU9iRk&snI#9eSoRuf_F-vzJ$EW2RmzN>+dADuA=lkAsv_e zSM_{QAd?44j}dMGU>^sh7QG>QA#>Y^%rbigk;fn!tW(Erjlc0hAbVlq#!{s-uS*cwbo&;&(w2BsGKq!${AA>w?)(*Sb@f|WlfH!h zLbBZaL-EbCg@Z+}AvVS&GFIBImt+Oes5Y&4==Y4kW9`(R+4|M-CoT9vDr@!=53=W z$62AKr>Nf(Oqde=&~l(2hlgQOdmyOES&4^ZK61aCSCAFOBK!s&4L1|{_14d%2ZvPQ zSplz^(P46pslYAlHn+LKx0*H*ua~rb42P6oJ6s?Hc#}lUc&(fX3fihskH)cg`lw810H! z%2wmqtq+y7hq2MEc5x-ItpXn>x+zIe;$;D}4E8I5lcijhnW z17t4+tgo2Mi=gR}>NyUtW0@U#-_CYiauX$d&mw;CRwXlfvv_^C4%TJ<94`4f2rY}Z zw`P@}OI(2@yi5Vbvtz0G(&X6)3#sTm*b6RlJ{JQ=AR-k#9vxp`tjlK+ZXlMR+JVNI ze9Id&+RqW;6{Uz}{TvBI1roQ8i7=r*qtoX7PL?Stsb>I+tw(EJ7?opi>9gIEu(%v* zU$q(9L`rXNkbtoR(8tBqcEQ`{7QwP&lIjlXOP1Tqn35?j?@~xa&QqoBH@>$VOT@Ca zQgGu!r@tO*&O^08F7D`Pj*G7azvPR+OO5#@jc-$JlrTeA40WUSf^vYonwC*TWdF_b ziv-;^=A9YrmhXMpn7jf6&i(h`{Bl1N!AhinNIRmXYf4LEto4r;1z9>VhZV$`b8h#{ ziT)SQn?JH4UcQNpiehD8Bb$#flao>=f9iN&zwrA+xN3;X2^pP z!p+Xj*joJA zjEyO7Mw5uPm})ThEmz?4TibV6)q-@e8n64HZ19iT+2JZp)y`@RyaCdjHz1avqymh9 z8`=WSBkWvgcocR7?b$ON+Hy(IZo_k{Q{9-6In*m&mFy_Cl9)v;#tej-CTS`6y(Sn( zh*7#^%?Skd71cSX5ntEzGqA5z#nfXQ5fDIs`dvJ44cUln3~`M?gMX}`Cw=lM>>Yjs zWuYHiN_}^DUCtXs+MIrEmNaGmEw@cCOzaMVX*LDVW7#lXRE94#?dj294(y3Kc{<;K z0{>Lu0S~`~mdvv7jtbCfJr*czE7NEZ0MD{Qh>sg@82chs!7-VL=J>ly{QPWr4eneT2F10dSVKU2mp;gG1z& z0SjNi<>JCEfSEV>qE@3q?#^z`=~c7ummdDS5Y~}4+8^Ke5%WUq{-%3Mj+3jJ^Elqb z|NPfQQNI`I>u*^&zOp{L1J<7#US*t$Ymck7#pO<%E5P$pw0>6Rw`X0sM4m_K-jIV`1}n3$};!X$952aCw70X19f2;)fh zs@^NW#lWYz5KM?q=y`d@&C3Tj)YtBYfjQAqz7srjJbA;c*xvEZ#>6k^VE%3qun&M% zs@8fdOxm3-Y-}veF*7x_bf73#Gkb;O&p^bZgTI9$2Ac@j;r=c zgFG8NGBMWE4H$eqIRGD5d$JM7V@J2zI}J>A5^e6u%8wQ}(9>?ru%OpCt;zqr`LtZ= zd6S+YS*pQybv>=g$Z(IE_m8E1;qEzXud3dtgv_e+uMt5Zyrky8aSoivYYsL0&|h%X}fzMyb@$5;<}>7lT>YJa@)uPY^H7WDhU9kJk(5X-G<{ z{a7)mn@kwz}1al($z~IJff?7zF_1 zKnV+_>)A4Hb0Yzidzdj%;!_iIzL$G@(^pr2P@iFw3xj5bgeYNi4KBS4+Ao3T^slDj zipAbj<>I9G_Uh(tTQzWJcQ-vX^$PIchlPc`#@P_#;^IOIMrt^m@2T*4IyqS#hz*C= zjVb70c4|YpPPukk;vCExlDveP>O-(6XZv9vt{N@+bN84N3}XutvVRmWS|y^bkfK2g z8H7`_aDg$>P`mdMj49ZNb<(E66=*A}Vgk@c&#y<*5e+X%e({X<4l|Q!Cpzuz(|}e? z?oU!WmQ53Fp(9FzL#)QlddBL}Nxt+dS?{v2VlchjJ)ysI0>PchlRBS04Q5-13>yGu z#jQQ?&+Sq%>CTLR03d%Y8#+(tbA1kN^q8wZSfrSyqx;q_kFBC2htYnuaCqd?j^Ek8 z&4{`Cf_#gNSWZD+)b;2IAvp^2Q!8tm2=tZiNTM}7Ts=yQ0! zFD>e2^5Fa}vzV49GdA8dWu3pF-B zCd(*{vw+hQk$h4`N#Q*?5Wa_wCiDI0Af{JBe8RS=&-+-ovXwW`C7VIz&{h;c@SOqe z51d^ylmHHk34+Y&hW_o)Zw}p3)bB9dk47O3Be+UV_HH+W zoe`S!zc)8Ksi=6Fn5ofPefCLlTYGqE1cPB~AjiO&IWbJ(f?L}X6h%$0i zq!3K`loi_IySZ7Z2L3oUrtV1oA@CtIGz{Q_z0RgNUWFaP z9XEICN*Dw~%w&3`h*AE#@2c_2ncM3GXw%w8(oW+ zW5IV+b-BfEK0Y^_WrkN9rNj^=mClS@=oVlQWM08p0hToc2@T?f1^LE_{*21 ze!roGcRtX@F*&9F!n=~-Z83vk0oWB=K-Kc(EUgC5*hdldhyHCh?)V9Uufvm9AEQ3X z^mc{6pk`ycekpZ*j_!yXnjxo)KWh#s#3(_l!QjrXr>CX`4EGD1nu~t1~I;d+|~M z*ds)~ifpZJ8PUNCG4}U%guU*C*N1wBbs9t2fjpt(1p3Y**W=42{|2Yq`XNK!2aU%= zF>Xx#NQMq(J2x};=U6omCi$p%*W&(p-2KHYuNqe&NYD3|B-na$b2kyztK#>P31*Jh z>Gg_J0b0mp8B!zB&)cUALU2^ec;Gd`+zV`32_c(|ZOips}rkZEI*BBME`D9;cwN;QO`A$}v7* zh242VZ9^a4nJ`nqPI93jnxv?Btbuo6Hxad)ua-}|G)ewy=(3dCEunprfSl{^q8!A4 z)Gr5Ibl129KCmQDtf6yu$i}=_Q8RfN-+nnGqWSn(!^T#Sm)Ck@EVl1gzK7u@;Bs)X zGk~h#u1u;fY#HOocCGH$xGI;wxJGTJfMT!w)z{AJ`4Q3EC^UFT(ng(XMHQMnC@83k zqyD#kzbXyrCBY^LPF^E%>;R&5W|D+5gxvRcjZ5Uv@j|#a=wN^_*n2f5^HCBAsM|=0 zp!nfbL&4h-Rfe%Ok3m{umyw|VtIt??nNE1bHhsvgB>>p(SnHUZ3$0LB^QJ48GF zlV5B~L)Eb~j^Ds{Z!VA?&6(vYzncpgMG<>lR0Qh z)Ib%W_Sr+W!Ggo$)p0w2Sq3YCg7hHBY$0m5fbO&6n&W@&i0bbEj94N8;`zCh0Hz{# zJlq?d{B=zLP2IHg<)@_^9j~2P!h;e!{OCQW|01AA<;}~Cuy2XF8%^oLjh){G`XT$R zj1~sQ$4?Aou`3ChQ4CV24OS z<^Jav048eOoR^4$;7*4Mfc8C2U?!OQd(bzDfnuh`fVM5xH!146c#?^sS^Wx1Zk4Q; zZe~#8qZ)A5V7SB4>Gbm#aCpApbFxs!BPkib)Q;5@D9EVvr{@=MeQY+k z{cS&kBB-e)YP<@MlNDBcC`bW((;Z#)(+BR>VOsz3mVID1^b;Uc=S4G^>bIXRH2gdJ z9{Cuia)H{OH}xctGLzpwym{Y3-_4zT*- zcU5@kz;8Yh>N#gn22seXk&7gF@b*8+!i=QZcONX4p=V<#RfEqCRe{3-_^$6t0l*HW z9us6<4h?SkSD@p4uW!gf=)-*ejXS(^hQeFr&AjqohDr+K&aTGrSl#da^>RMwQad=4ZeZDUmzWyLwv!UQRR#Ek8+ z00@E?)Cl;wu-pJnsQY!7D*9px79gN#`V9vD$A(1_gaC{EKTMlGBq}0?xun$A))_iC zI$h}E+A%X!S5q(-??U?#?CkPYD{)@Hz?YM)jl_t6$HU3VACH>ZLQ>J)!vnL7#VAo* zOP@^d(@Y+g`zUYS+@hPhq$Zh>CUsC!8j|SqsgP*<9GB3UBmAX#oW<1aQ6I-WW)Y(` zNl8^h^a6HqWux!Whw$?9e!4C>LuTFDptf?ffRkEx?O%(6JhX+DXDpLo1Mn-zdi4nt zgFoO-Q?}wI^{ZA-1uP0H^gD_FnK)ok=#c0pJ`OG`Xkl5&#O#=}lS6Pep#}!Lqcfs+ zLPYFZ)*Wen?czL8RLlP}t5;24-DTE+&{u-n7KKYkEuAdq7D3}cd&LO>lLlf*q&LFT zCU2MTF2uw^n$!cmWGbrMtd>&R4fqF@8!Z-0EG!GhHxt*#EJCvtAAde)Q``*(?o*Ri zf(Y;28&s&Rr76eMn~E~q->&(TG96KOwPfFoWv)-prYb1)j~_U6!GgyXq2WPU zoQ#!?oXp1ZqYqeXf$u$c`Tk8x{0D__T{o9&QO^=VU+xZSY=i~(TWzD~dDr)-qrARB zy=p^o8PhkNpFN|}_S+&OE2RvX`TRu^oQ89wT?~h%!5$G ziIy5%{wD!~f6bHhbjBvtli7NK&+rvYN~WKgdPQmkg5(N&mauvu2EpU2Pm+6Td<1(= z{j*C-9N_QqXN*)FB~@Ngi#p`*(!pE}e@MT(5y6jkb`lK3bge#jgZ9yZ%*<^$ ztcBRGh@r%r>DJbc`zyrgkkUTlmn!=*L>5OPiE`UTqjeam;|nqO?Qo zq9^Xvb|@ivU!V2CU*3#X53jN0RL0tUDCv3N#OSh>)D-1_!veQa5>M2S9qZv0S=LwX zKYV<*l+obOo6osJz^7z2E#6n~bCvLz1nH1LK~)Sm&}sEnGDTD`uLUT=PX95HN+guO z&0p4m-j`!lSHc1G5!h|y4uKQrRb2uJ8Gd_F)JZOwi3J^E+N%QC%|6Wvi%15?i0L;Z z0Q5!-dIku%c5TT%ksgoLaeEzk z#eCPak2D-67p@1B;Dw=l3wiW8LsS7z@JY$;G;k0q_P7CjbU+e#!09Iu0n`(;GKU85 zn53f@V`Hb`7i86m2aEd7j!rlFyN%CH=`z3o;ds{K;&H@rY^B<(m#i#|ENtl+pP;`` zKd7XEcSyGyyxdC@Qt&d_83o?{E;j-~*8n9ixsiotLR{6@kYWAw^xR5A=v28;i~F^k zxLWKMHIU#2=-(%Vyp}cB`2b2G_m!au-&YGk9+Brvkukdm+iBC)O{SV=j(+4lvfWZ^ ztUZPHg?V%H~i~` zeV1K6CQj%|>d zkdpo>?*96F*sM=8ps013Y1Wo~sJj&NI+&lIC%0F0o^MS{%Opri9pz(R-dwrAI<{bT zkg(C6YWs8h{yP+aPTTx%JzL32aX$&RySzv)UOSFVVKqD2nkFV^6Xsl<+&64PxW`MU z2Qq(492}ybi$zxeT}~Nb2|#x&1)z0Rwv3)MISiYToXNjU{(H(T)0;~yiAVR==lC%K zFq8xfrJD{mpakls5CR4OG{}Q+Fzt_ON7>nV0R{m8<_TIrLy?MdrchfmE41!Ui&blY zh?ky8#CfT0e|ZnaG3D~Am-9)~-R;uDq8R@>=DryU)_W7c5GHSD=P!wLD^-{ioG{U_ z6cEYU=)R4cSW(i_(pH7=!VAE7E)r`>FkqwI3m=t3a&Q%~*)d=Ix%i)5O zkiTjUGSipt1|oJ}6LE>-OqCkmZXHwK<++{DY6E{9(*h_5HR0p$eBeXU1jFS{Rl;t3E=9-$bn`eRVMz8?lA`0Ra+ zMMiMUuVBf66g?1PAOF|!1=Q$O<7&|GhrTI&C0EBz~5Wl8yrpApa-t1thN>a?vog z%@8#ovd0R62wEQ+`y9%^X24l|77BCCD<05jz~^Oh13nFAToPmyBj$safgjlDRo8=3 z7%Zfzv5D$E;FMU(0XIf6PfMeygqNAPKIh0B*tO9z{Y{J{Rf_rEpfdY8rCz} z*l^aY^ON&2KF)Oz1Vu_(){xct33WlvyH$-0$Dn83>8ATH-pdNV4}Z2x%<;sDJSm6< zye}Ds-Nc%lp6iL99sZ5fz+jJuDlf0h$w@8#1FI;$nD(z<(}s}isE9}&n`6p^y;vamT*~!1w%5{~9?7=#_*xfDpjJfE4lx#F7^%ivUYo z8&);Y&-53@7lP68pvrVGhpv>Ua?kZi=GZ>qmh=CG;HQ%wv%2R-QaoPkxcX^O6?=Tf z@04pFoNvq*k3k-cIhfN(6Nvhbk|HoYGs!nDhJj_0!tf#m#J#v9qNw2uZH-@A{;r^K zfGwdg;BwDeY`+sdX|u5z&f7}eq!|UFhvH`&|s{9#&*b>&Mdzzqbsd!WN7%Jpr4G) zAh)E%BK&j%kkkLaT8!g_j~t_X-yOX_xEz37232iEcrOTsi^PQQO24?GBmMlzvv)>_Qm&|BS?)?l?bUvUqwd z+OnlJasSZ;nFaWrdjGMDeQr3VE%>`KwN+v|-=HG#^V!}(^Do1y3XVV_7&wNHNJXX2 zC{Gu`K1nCWCA)>7K(#0D$+O5=Zv_l6qF$sq{H=!ww6xHn)Ydgb%l>EV9PyCTeLl1$qwsLjX?y{lZ z=jTcnK)W`lvMj3ghMT^;h!RdUrhmn1TV0zglMiv;A|0cE zm)66w`zK>b;og4BGl)`T>8t4*k(by|y{tvCU!GrBT~7U$kZ63tyr@Ab1)6%k$3U)1 z?~cl>UQ-Un-qpdJ0c&m?u8zT{$*>|dg%1ZaPGZXi$;rc(yR?2dwbXZ9Hi9nZ0U(>& zK{rY19;_-JIiez7&-vj!_*8cDanv`Foyx32lT2L%mg?BZe#(& z2HL;f6>95jr>*F#1|TP znFbEPgd`3b$^2&uf~gs4BHi|~kMeMEbwj?B8hp|?;IuDviT-0cUC%78Z4~U(+sV#Z z+3{X{aTQ35^BRhJDhH~{0vkE@npT zh$(5X)C$X#yaH4ebTHF8bU=bDC<{`S#)EbR1LSF)5{N(#+SpZv_ZV{oILU#?r_3)k zCDY7QxfX8)1=n>;R!n^&96)@3z5m5Q1Vbp@^e_cLSrPX?ED;E6`h|kFBKrd{N8PzR zihPU0!K4v1Fu)hp$_jfi2K@2=7TFbo#3*T}70|w-!Poz~fA)ejXkY`mXz*~bvv(S> zsWU?E_JMGb$7};_Wh??7ZM%0^K!#=wuSBWO%d%3!DR3Ju^zA+$fK?lV`q3W5a6}Sx z5Un~;q&-P^g1}52DBYkHb7*5$!vz3Y0Vlg5^zR1)?-+cVrjb_Qu6+B|Ru5%{J$dA*zWfhueGfa545NGuy?WoUqyRa|?qD8m1=9_Z^#fl5J>MXrFghyc<1OwgAN; zcT!J|O;GCM9OoK9@(@8hbV)ekwoie{aNA#KzzXuf%O5qUva$AaOGQI@72FQ>w-4%# z1KG}P7)qXb?s*~}$@380_yp_C-{Ng!5yzw9uE>VPZaTlT^4{f$n*@sOyUh#F2Y-jk zKM9KC7_;0Z+loKRUU~nuv1;sp{{KE<5jno41TojakrOOQsg!v_n*o(Jygt6)YG% z&%2{vhZMl2QzphgzQjSV&atpNbj5$pcZTdV&#wPWZ0&r%S+A37KQeCGR8@qA+hJ&< z>ZR?3C6moGvfSq?skwGt&T6XKg8Wi6L#DfMeTS0V*xXL~t(p95O@|*nlld*I$rzEK z7sM1A`^c069MsG;Ur+Bww?KiCozR(yv0o9#SASqsXfS$hYwMKJ z`T_%196P4@WJSOTl2>3dO5CZdlI41Oc7$~_mDrG70Y4VlA2bav3;wfRgW`#eE!fmK z*!gPaGcr0KFPDgoeeQsrorN;Qguua4ZqtQYHcj7PYsA9J!NT5|E=WxQc;_ah8kl+%Flhc)3F>_~H z2Qksk3-ZdFn_mchamO4{ZRLv;can%g9P~vXGs>-`Y{JEJ0ZNMY5)Cs$z4NfGNysDG8B3CVvlrf?O2TTR2H8n@1`y{JB1&wJ^ z*8af-;fQ;Chp|)ghqKjnC0Z(Ptp85Z*0HtOjCAz%o%_*iV{w{$F*Xgms*m=xswrsT zW1#sQ?;160pgr6c@SYVnNx584*W{rL30irvIh2m*i5Un%Tsi8?6+{P&Ctz7 zUMit9PC6lg@dlpt3zO06jhZYy8IKX|0$&)>hMRP2-22E6=V zk(!e?Sz|7ivY}+=ljV=~M?&f<Bi<_$%tR^m1bz^!PUg|KrTC&Ap3wLSg?c=G#-DUF0n?b&`O{quL^R>1s#(?;$@kMmicHf`lWrK)A0 z8y_I|IXx|NV}14H8|n)sKyNfMTv}0qN9%TdVl`||Sn=^AmNF&G)Mc?*e@QKH_rj@d z=7q4g>*`MjO7ir|U10W+pa4%@b3G7{FzjDeS~fg5WIR9bXy>Gju#zVmGs(wWI0B6B zitu4!W%=rM&gA8_wyDhS?dk07;g97VuoxEaTXXYy#V+oG5i5d3q2%UD*v>*11Bp3L zGv9_;%<#O^2t^;h#+>(i36Ig`fk;{tOssb>t@er{2 z#M~6onMS_%wVT1%1+VgsMYY-r)eeqy(EBrRqT;)hJA&or*7{+Gp(H$ zxD-9xl?)tJ?{ae{4ohgj_scv#bh8lQWvk$Tn0IQ35adQ?@FyUX))j=4FhJQ25k{v( zrV*#UagzQ*JDYa6*wSl)_lB!bX&P|K{<*m8d^+wKjXjze6t@D5(^4KBoJPWC6z`kb z0wO)+F79s+*ZR;X0SMd1l~(|*;|U1TGc+{Rv7ECSk9+ljZE1I^;tf7tjL9uHQ4Vz9 z*9Q_B$>0HDJ!58M+21*^PKd~A60E1_8_;eH87gBT^KD_)EuR(=oaEtNU*9m>h;VDP zePxb#{hf-cYg+m!#(S!C&(Zt_0Dhi|a2lLkWD4Eky=QvL!%;=tlg~iIC-#ie?>5t` z)U);5MiPNF1(GJ(>lMNrWykTesHlORo!>s>M_H1khS#W0sX@v2o-!BtAr(`ZoE%{B zaN##dOkx7r-06x2r$tXwiDeB_ulMGo8>+@yRwTUM|A`35$KdehRG5fEr>3!iVUX**@WNYK?q?i> z!M#$eja@E;N^!EN?@1}7uTf-d?2Pwze+x$b?slQQ9T{OeJ99}K3W7^y-G5^u$03lO zPm$Y2zUofeI3J4KH6*&0s_C8nX=(4AymT_f>g)}QzY~>LP(Xwru3x2K@kB71fz~0* z36(iWCLxUCCNJONP&1)0AIRQ$eb2>=i~^rVpEwPo6E0mQ(bIs7$)Q%5klBZ7JX}ya z*F_E@Ol~wMUoyO_8jp?F68{|pmRaG3OmI~fYAwd9=$ki6Hy)HD^+#N8_4qbMx|aSm z5wX=CLamhT#`0(^A`CyoE;&6#mYgvR8EpL3M_Vc&-^NMBELp}9YJLE z3sKFbVCFhgKW(u!*Go4i32`1GL4PGrQJbMD8V-A_tj zSCEj!vN)CX(fyPc*zM}aYx11ji1uWMfB9yI5zMt$`nsCAx5aa-?U(4oo%(dtr7T1dBO5crb7o1I-aQadEL%LILJc*xG# zDFm}FC|3B0DTxMx{hAd?>`1yA=(oC%Mwp)hiF;%yPr+!RteT;pl z;XAi4o^8stMvVx~c|XTk#-Q1JD;UJyK$#s&%n&jxB97?s1lA}rh}DsL+k|l{{xqXQ zLmjh2&)CSSwkJy*w7Ej9j%dRn)ipo9+Bu$^Qo)x{&oR*6T(0sIwv1KtRB1>lA?Ph= z`30AX{;M41Wf*_9|EyJOEHjxy>+cMxAE$5QK}X-w@)3JJ;RJ4FSW;vWS+G)QuS}CZ z0xSndKtQm)QJe>O7P@?sZ!c{VZhBA--shWoB7{`%$9<>@^z(@q%EbwlmF4B-OH0MJ zwzzj!$ibEt+Uu$$%znP>d(kQERa;prRoURValIs2Mwd@lL}A&@GT|H*1TShrhUjr) zpnXgG`JpN2>i(h?k1wErSmm*Q$|8!9F#B4YLomvP_yZ){ddXH$}?cWuCa{D#40DlCl zC1zTMH=@?j*$%Fn_9^-QBkC)F;)u33aRwhGxDCPGT?co!;O_1OXK;535+rB>2@pJ3 zfZ!fng1ZKnu>Ib@yFXPFR81AtboagYoX<{ln|vsCoJU*_q@Ql@?WcvI+#eBrQ0jR9 z$DY(bg~W55=5dsqFA`Tg&XOH9nS)FObU_N15|X{~HA+<|)SbSk?__Ok?DQGR5c*3= zTiiC(6jbKIdp~^|8It4W=fZA{mj)~wmRyN_r8Ic=%y7(-qhA~d5gcOSxBSg`BkTvS zM$lcuMJ(~}OE%PLn1q%jI8$?fXEy1Cr7p+ACv^-D_3GG6zC&4zTlt3V>#cMlXjSLd zOf3Ji5iNT!Sq`N<1slYb)59N|VAnzh-txwc!r%yx3W$0*DN&7uLz3u$Fw*eSU<$nP z{27GbKM*1&d2q6aZj7d#AX`8{y%V*02U-_CP1TGjg-}rv@-Gi95}V?myC}bB`3Swq z*301wpW0dy;Pa2}m4(JiBiZ3Cz8wOWS_!f&>`FDpXtJv?gq5JWO34_<9!{X4Yr#Xp zV54jW(iP-AQ_;OuhzJBg&Bz;`3bJ-oXr-r8@r@XHc9&MAR?i0k^76g{dO)7{01+6` zS=7RvAcJ(!N{aXPJZB^arf{q>x19cR_@FGpy`y}>x&rJ{&ghNZWd%7JwbN|~!T8;t?DT_gb(uQ9c?PH;bN2u?5 z?j1|UN;8IO@K|5}{uz~6Lo(cR;yf5h0}tE8M`KVcTzzdz%{9-pB8Y8h`(E7^6Q@p+ zTvgnqA+0_8B5)q*2yCD)h$UNC5me07I%u|$E8#R1i@@{@6oRJ``U}jeFpG+PJKTil zK9s<))XQS?Lze#lL{E^GZW#E;yiVvNHXpwLk4!cUEHI>t zMC8$(z@lu4<577MUUo!!%w+d8V&n0)pl`e;3=Bv3aR7%=B`LKi&%@)r@=gq=l(;H2 zLF7U10QN_^UN^>j|3Bnjc;=MstZZ5p^njYMwAy2)X+N=q+*X;$LFr@73H(m~Z+L#W z1CQ<|hHm4q@j0o`Ax5;i(MiSvpPNCS-8z<f`;U4q8ugs# ze?Xya)IO_HsZDVHkuT^bCb0{SS_Zsk_`$1a_g`|gYki^Ei?IwuFH6vObH(1(@*VnH z&2Dm_M{=?WLW3EIm5#C_A%2w?F_QBobc%`PZN}S{;A6<@{f7X`^Zsxk&xoOC<_Z|VF@r%-xq{R>!rpw2N z03y=Qalm5c_^9?g&||$tH4VC;Z5d-4Yy9;I*NGjYFa3;xnmz>PZ zj+iGN>L6JhFsV2)R4Q`AnL1O_{vVCTRFM4C$3RR74~9^6xCT(>#r%SnGel-0^$i`^ zj~mL7%qUWS@@=kKw3quHP6OMnb8+o~EK3gQtmVFA?T3l{n(7ey1?GMLhe_Sj{e^Qt zuy8kE#I01(C`W)IaC=aH8vt>cyfX~%2*YP@YUmn?>gfzlhR}uu*tjHdlMFEQ3F=CM zeru5WNs00ZLFis2LF=4VDM&2=79O~JOZcF?{p?bZa#7xgSsWN~iM16DBE~l@02}u( zV@etY&_=IO9h`nkHTp2}w`yaiKea!)IF!C1LQ)kuPyY;Y{{D%!(~FxX;j(+XJJJe-Wb$i%4e?ACES^ZO`(bz8X=w3aY22qYIxJUEmPp zO_WZjD(}jW^q*0=E9Z8M0aHImi@@+e<7PK(}E6xu~&{N?aUt$G)80s)?F9S#l`!-0+|NfCuNK1cPcV zSJBktE`5|XL2^ExKDfEC;{kt(KN`l#%2w(nad}J=%Nsw!BtzzSSZRzGT~!EZBY4^{ zzA+-GfXKPEbif3yu@dx-L-a%O8T5NQ)(}EGa?7ve+EqO6YO?}XpWEz7zG{$LYWL7` zMi0j+qp_kfm#JWTG?q%%$U991)=|2arakm35PwL_{S+*Se5aKe_d598Q@WI<^_h}d zr=~yF&{mCz$R?T5kq*Hg*K2rcnUf(0KUVqaXOlH(HFy{uo6DyI{Fa$XZ`IsXN*p^6B5SBO@F%{>4zu{^~pZjH@|b0?RC`SK8&n( zxeb(2a@}6kQ?dQFxJ^QEMnUdVPMc~Nyv`zcbuf~m#(}OnCVX+2-RyhW_9p-R*cX?P z4%_k1pRtW?HB@R6a$d!GP-W=!yeF3gh1FN@om)h)U5jvsg=n!^jTja#kBb-ET2WQ|eZPex!2h*|^K)*GHn3Q<(7}|F|E)yO;usJjsLFFy5{fg3c~o+Rd*23ypEFoD z`MLAHHIiT0SRW}B5clL~TVLo+R#o=mVQ?CC*Jr>na^nAS;!?U+n53FGuBzI3;@oFK zj#zMV%KAHtyzQBmtGnIp<;!&gVYWt5*Wc~iB{H$mJB<7o!6Oo4!VE)42P@^}#W7Y^ zu9dgc*N3VPNCqn+a-WF$*x1G4bxeC`LxG#~R{QU%anX9Aym@Y8PN;_Wh!8$L*N>&6 z_o2l~rPZZwpU7DJGGW@KUM(m} z*c_)YUKVXRIU~~TcX_)_(0$?W`OaYFgty+q+Oepyvi?Uc>W6H8U(T;dWf%_fIBrDe za7WJ0j*gbDYFYf^&+_Kp4N+gdKy%qLaugSZItHw>TOX0q32!R<3Y;8WCo0Tjf;3dn z1r#6?rXl~*(AGs(kI$%}7&%QT0VoG>?}^-A?+ei16A8@NW@mqNhPv8}?$8~zJsxnJ zczfUB-RQ_*lxOp@b=?>-4}bS(e75PXbpxM_$Zoo9d?ij>8tetaNfsBy%ErqTmiq?> zd&d)2yss{n{_RMJ2Wy#{7Ik&~BH^x4VYa%%1Qm86LH|(#WB|oWJ*}np&foab8M36+ zmi56GB9 z4h!f>AO2AM(=DT=%bs+@G~k*6UTq=tS%UHF`-d(`nL6ERF&2At>S_vuIxG0-LOUN%uu+zV!Vu3_cBvr1nqiFs8_4|jO z0lyCnJ)Ahkv$YbB=ijAk!w%F~3O}^|Vux5lM@H&QEOK5+L}`>$;-k4N(iuf0pN9MxZwn@PK_Lo|=8YF^f!BU;4&-F=T_ z5lnUVWm`Kodf$4xZuW*6Gj6i=Vvhr2I^O79Xwo|M_!L^1o@(c@LYx#GE_64@!Rma< zF8;9JGbp0n5}K>E+Vl3~^pPR?QCTRe`Mv<=1(Fa8D^tw);I{-|fU;^vj?A|VuY?4r z55IQ7MGe9wa3#wS9r`SfmqEA^B%V_npj)$c=Pv{x!=PioMN2pf!~M)pg<|uwNyY!_i?W`2h;4J=pmeN_olf*$ zK7VTWgS1sagv*HgdaaB@U)_*Z5y^jCABHw8UJ~O9wBFv=0}*@WXeuV}pId6yG3K$= zoVCPRc>{Nc@4u%Yg9mN09J9?9d3B})KW2t*j+<$A!9f|+sqp$x$U7d>IPsVWuMSN^ zw$JKci@ho;{6b$srT0kQxWGX^xrAO(F<%+NjB|KHu$cre zhaJyjX_qz#S{ls}OpSzUiBs44^y{b#*|m3dIdJpKMy}CPzfBGrb?4*HSKsZ*SH$8B ze21W28Fo}mugX%rqVUZ>9`44%Uw+X`Dqy^ik(pS1;mR_8|4ek2Sr~`RH+6q{^f;^bnQ4)Jo!Q%n%+SYcLg1Y-369L-yP>^B z>Z_g;Oe<1M-~e3+*#(_N9+mpzEF=}oJv6~EsZ#uy(XSC9U=RVk2?kCGn@VSUZ`Qx0 zmbD-Rk7rOWtl{$s1v2;%CdVS#XGkRRT$%MlT1LtAbx_1wnnurSce*%B8H~9k?4!E` zjH6-~kwDf+iykLm>+Fw107Y8O>g=uvcR1$YfSG$sKb|aNtC!9s4=;z)iP+tZGrZ2v zt!~|NyaMwaxx2ra!9ga97?G^7s1wJTx6dwQfoXw1Uf$zDGg`!n-cq8um;~LEb&gz3 z91dQ7xxUh4Rv(5L12a6Rp4Zj{auLv``ym9*h7t{GSu!+``gmA%6tZtI4OcctM}^bF z=?4C3T$%#fx20WR< z0zg(|&`lyl6eR|1YhEKquW5bhs^@2}BsuRPwO}D zE2ymX=)cbwQz)6dojXKq#!!0TB5J0#5A+mWA%EtrTJa~=*lIcz6Me6}T%dtkI1 zFeZS1vI7U6_(WHJjttAoI+bO68*uYu9Dl{%tltsaty?1GMVnsGBFya9GaI#?`(I5@ z%|dk0^w?VQpYt?0qDt3Lsr%jj@kvnQ91;{$7=aJ1gy6$i9CzT&TNSn~vZa2y4_!}h z&DD`In^oXp)%m-Kw)wkWmI^_8Jk-2zmOl1g*lBaYv1v>F>R8E~cM368+wE<-YrM^;3Vo zw!lHjmnk&53w3{&2v3jA#5=Ez*5m&hu{Bl`%9B!phs_n2nI&I%yT1#$OX;7T3G83p zYVb58XLF@us8qt|A!0Y_2*zwotjR9a;7|#D_nxBfoB>~nTE~_|w_e(RkA=GFz0gh% zZx~WYthZW7QCrq0diIFe#ps|>9-I27Uz*#Gmf(K-U^!Zw!KRJf~O*iqLm48++8RLDM_!Wm_Qw1>W|Adv#@__Yq`SUL^5@lxh4e4fOW2Yp;hY3@WBH zw@=Xn+@k72$VQVaj1dKW2UdSDo#iNT#}ynfV`f>YBxhKQaU)BEqUqzKcjNoL;+N4k zPXbV237D;gXI)a5;sl5f?zg#!gKHc}8;MTehSlhYa2dGw<1;EbyjloalH|*oJ({iE zOOSgcJQ{U=O3+x`Ocz&C=9ml8q!onG#R!_LM$NDm(D~NDUJt4AhQ`rtD<+c9)JK1D zuf;Wf|6_cL9{?s8iYq`KZc#C_sf2^tsZ$kwyfSD*ibQi@S1XAUL-+{^Iz^xo4@^%l zy3~@EOKKvj_yv*4A5{~5C5EEG94QLmQsNP##%>TxJ_uXS9XLt0@+3~ZgNNFZ1P4ee zEHeBZ&FJ}cOmPr{hI|i^OuJbM6OfBf5Jd;ms8R!oQ#-~s{nSpqn>6vfbZ~hpQgZnm z@@gr{Dt|a8XeEbKY(8^_T#Y%@Xd3G=OQ|800Ci<%rg82+)Z`G5A9ql z6|%Qe#}}6CYI}3NL~opQ$jdk()p4NjvV4fj)LZZ)Xdpt@zoVEWy5xQObN@j|DV>+l z&|s(w9<$VRx$QioRqLsm@G$;*Qc(<&H=}Bu*3`7u60|f0vhAA!1J_pl)Z3U#aod%s zAYCOTRR~RHthN#gfmy)m&cG>~p7S;yYsPpLB6WsT`k|rWXZnSuSu8~*0B!iMuz?N` zf*ct!+hl9LNZQ}eK?rym0o0+KyJ!QKR3-xD24OR<5*reD2q^WN0jr`1Gjzjr2!8kIArXh2nUNJIZCowj8 zD_(<9vl?&b|L=O4>5qDfe9;%E`I7#FYVR>O|Ee*+{RIxBlt877Mv3~*32T|HiOY?S zCwZ#ModVCZWV>BedXFpD0{e7WUn?8C6MoyT_yj7DB(aKE# zTfrb%%d;n-ehvrNR67_xBSiK8J}9KV^D2NQ39OQqbMZ!mu&9%>TI(P{Fn!0!{o&j+ zVx$jhuu1r|!loYmZAr4y|L@)U@9ny1Oe~s1fS%eg$aFHMW0w@AY9h+Yt3(srta67^ zx@m+C9SHqen5c%zrI7un75T3;N>P!z{EF!?Z&PzxUZ*QFR71noReUxkq!KDO(15?E zE-k0Na>6}Y^-(HVl92-HHUJ?|Q9z(j1RqY%ess*9=0N}@A%knA<^1L=78*HUJd~CG z|9|qiz_|kD;PBYT+t)IK56x(0#bnj^4h>e+JU`2HNsC=pGZnUm{RZXUfQnQG81c%n z(R#dj`X#uE@yDkw_nkI}-rpQFjE3dM=+W%hVumLHEJFf&0i${6fwR9KYTwDmST|*= z9SytL9ru6V6FHYhsXQ7>EuOiUAK9q!dip-9U)|;}%2T3M{x(v3)#N!-nu%**;P4CW zEr)>mv}A)BHLEm>+f0D`eB>lYfsnz~hy0fJnICu@1dM70adPba_6gkl zytMIgM2^9BznmpFGAI^?rblRg{`xuDo6L*u=Y#3lV26`1{j0U(xvh+UdiMA5vX-uR z45j{?xhT|tm6cWlBTae{7uK;L3TMR_A(4tF|3$<6Mtpmf8{ZUfpVA^>&g#~twz3RZ zb6sNSrTHO;?Ot|yA-U*`rE;(sbe!$;;`Lp!LNa^9cMlqh6CyIgR4`SMi?ij?)=qGj z=O*+HQ81ovPH~5BVIXF|v*q9U@Zr(U>E;m?QIT}StJ5ZYTwGef-d^^t?*u~o*!Y*{ z&OSxh{lG5W!iW6)wB~QV3bA9gLq}(Irna-w>uh3mW20gkO`R!r_8;9;l)TeoYBzp7 zYm`_|lG$I~N{J`Rf&#aNc_NJ~(9iuv{Z z?yrGJsq}f4wS1fKx8V-lKla6c4oS}sM=DYxUFy19BC`gBw!P9^)rL)7xy~l6HQvl{ z`{dC*87_4<*d_D3k**w>1ZUWg85y1z#mmyu@94HYqNLd#zJBfh9J^y|q2GWYen4ET zJ$&>4uc^3-i=L>omzq^5?JegaX^r53WGdVmFle2f`|gZ4t>tc(3${l(s+5SG{Zhnd;iDAto}W+PFUE!4cf7)Y zk6o9CiUyxPR&k1O#E2F29p$L8PZWG_YQQVb?U~qrliR<=(t-QVB~tKu6+I>KqZsh+ zr@prcaCm*N5Y4`uTLm286Q>73Q(1h!z4r+9v;A0-Y&(NakD@+(qF{5R{I5<@p<9R@ z(pB5Sz`&t@a(H;Y!T@exd6z^#;JjCn?i!ETW|seylia={YbY3U@> zsJ>lUDJ#yhZZa}lf`@&xFsz#pFE)FT$Xt3SjAhZBsiwQ*T{)0xO8)q@(bMA7xe4m~ zh2so~@68RPvGoLV`UUe>6C)BxStjJqU+Z08uI`D*J$Bvf{bm+A4usVu_Gh=8YZw~6CqD$OkWq9J&pVp?<0#9=e;U85Y_H6ZC^?o4vhh!ylDZ z^!a;S+r#sVSvH`jHx0lm)RdWM69^FxIn5X;dia=2z>m-Ucf8>9Z(!4hirf(Qy|9Zw z!ZlQ*m!-;~@^aC8sGp?7B<)L4L0{fbKlcuM)dLj~a4>+7BH)3jG0O~rRI%ly5#|v? z3p~^~Z&@E*&v!L&(4flPRs&9H>$g&V@JhJsr6JTXBZEvlY0S>+tIG=a`9WH&KR$Q2 zkEke)wu5~448&CVdHbCC>HvCP1PCt4xXYK%{Lh_|v7BB6)St1j zf^-a1_fEOe2@t=&L49a=EjGbgzis~b5rkE!kW6>k+AwAI6Zz3b`M2wDL~g(x8O>a_ zR!}@g@P6@~c8HM-(P>^P9-Ll;Qa7=7C^d3Gk>~1dKI>F zR`~{JA@%O>6VWSv4hpCdzZSl)!>habyAKT+Hh|eUVtZlpnYQyhv3Nqi7{|V>SQVq; zXy}Sd5}#1TaJ7oxhYR*6fv9DIzdI?WA=9v(sli#Ok1I(jy+vb3N13JWABje#PvSM1 zcUAd^FBW)Zc>bsAw#1ui@^H={!0W=L)9NtM4wPeQJn97*elBdEUOkU!ye@y0#5-A4 z)i74l{CR()erh7h4vhZ0gj-mDQdWH})py^?k$t&+rD(f?RpeaQG2gk5=RX-uWN<D$)Pz$sx)u!P0~9PR(MU1z%E|=PDyr(+A%}S56O$g1fL{7VI?93J^0fzSdLtgkU!VlmM=X(x0lVwrXDp+o?$s z+v)OCOSD9hb2s0-=grKbo%_Vzh5SkXI3no1lb$m7^5-w7UtCVm@Yjd~p^^?d?#t#s z=>t47j)y+NS=={X!Vo|SGw);qJ8uHL722bs_{1p<6d3o$9ycPEGOj|8Sn~q%UG&YclP239l<@N(8?xI zfnvo%G>Y^ox6JSf@4t$wF@@&sHEh7A4SVlvtS39y0|ADl=niC^fI_JIPo;BAc`Y?qZgfOHsxH zvAKNi;m=Ki_wRs*NMQaHVmWrgP*#?qBqc3I1T6?~6!GwF9?YF!{zU#@WMsj7+xk8r zOa(*jWS^3YrgBSHopF zf~dM^JUx8|4ZIkByk*zX)J2mIS0qjcj5xU=?(~%fLQZf)ny4p&UkQI=MY@JqNHrRS zrKa)hz{bt_-$g&06VKDM%I1q%E2yYxg{97e<$!#FTK)!45#u!=JHXr20$x(Vr9^O5 znH1mA#n}}duw~@uL8Ki5NRm{b4B%20K%Og-*;5!la*ciM_2??eI2c7U5wEU9d%{o0 z_NBAI(jy=*TeIQ6tqOoDF3nF*oa^;Lb`pXETx>i8g&BFR)u~x=Zr>YZ-^DcN?~aw% zExNNk5VxpjD8X~XW?Z{AYJAE69+S2-6 zfavw$l(KW?b=~v411X4J_6?XVz-e!vhr+L3{RK7#NIphv%uzw#md_?$eM@gp1}P*# zDo04rXk_jIj6rHb@(+Xb^yi;n8oxMa^M0$CJ#8s=e#y!DC!Z)m?$p&T^vtD3EopCQ z8xnl=%lL8YLLQbFf(0^p7Vo%{s8|1IU3ZK{ohqoyEc83X&gBo+-*HhysIun2oyIHt zw^urn3c-}@uUVHFHX@z+smrjKB|Ty3_OdO6r}K#yU_yJ!iuKS|(89}b^9Ooc+sE*) z<)t;XZuUgf*VG^N;rovNGqU^D!)ob!M526F)Wp2r6F@V{1H>B-i^y_CJ-~r;f!t4y zyp+lY~2Zx8UB;O@LL|Az2OTZ@W(dP;6Md6h*%L%qj+myC$xB&TOj4nfF1M08JA}HRsF*H46X`W^ zvk6eP{6WfvJhF#Tp+Nvs7KW3uQco+{2jYEiWDrw#+xIBWxo>fC@-o9Q=a_rq=L5-`vC7fqje_5PytU;e)E*@DYqdzp}KL+d8mge=D|# z$){6RR2)v#?7ABpx`5^s!Yy(Ki=DD{mx_ZlR5-mjNw`j9%GT=NG0Q?ku*jY=wfiONc_$>MwD*gSrN5{#=e?9IBBohK2^q@qA&Kw$5--%wltSG4z?oMKgK zKDfYtVrhD|f3DzK6BO&?>At~Z1XVg38BmrgDM9uBJSP0NGa;5Y(FNN$sHHqul!h-w zZ2mS`t*C1x2x?21tyUCbsohOn9ibyK{^hgCp6kQ?Q-Y!mZ`g${GUz)w^roCU9qmR0 zh61g5K_{Vc;a&}8tbX`=1eah02ZO$kPDFs|Apqz4eFI%Mcq63+yC3iB^6u@KrihWD zO;m0@@;|0pR#a$H_}7MtXlDc-;vW?f)&%ywZm`w^Q-alqsp4F({5WFJ1PBEP5d_SFEtCndu-Gtvy;x*)0Kh{lr%~B( zEz*n;xcv_J5~XSq2Ov1dQqB;?8c#~;9pE)npj&|SS7Z1#hlX26i1{;dh)t}?GD$3t z9}e{4G*%iPgz!jwPrDW{)=EAzmEx*aqZF5KMhm|#Ep9uXlUjkV@}AY!Xy7?L<1EK(O)qb;B+ z9L)LFmw=G8kT_OMGR-yjZw3o$mBuEH?zZe)uq+?gm%Hd%>5P{@8wCO{*zD)=+sQUHcjNNkv z0VzlvxsMXqy$)SW+N^Dyeme6KVE^k4r+q|ubO3Wja{}%lq%W!;!n`cIifaYjXldzy zBia>7KNVHRNE6@N!XX^b^Xlq|jO_FV0piQJe|vp{m6D8!iG?tB@(gJ3l$6+2IOu%b zsd;A*phkcs)CO}i4>3AXiF(fJ%@Gs%_b4}APC1fA zln>R}(-=vhU-MQF*3A#_?`Rkp(#^H|wa&B)6vA~>JXl{M5qc#YF;G2yH1d?<=>#~{ zvhb@Be-rZOYbkQ~_{eB+WuvT@1%j!Us_>#TGNH!qWeqV%LIa+psf`8<8srh0B-aX9 zm)E?grZYv4;i*?`xJUHN(ix6=(PRXJ+pb$@mRnYrD8s`Q{}phc0b2J*q6+;OV66=4 zVSomfuj>#%lM2*x@UVGtyR`=)YtDQeO@S6O*6%7cSU* zJwPcU0>%x-R`nRb)3b~UCZ&GSD+&2|q2S=|MxAN(zd$kdvzmfZGDHU|r(R2+HBG@C z_-^KpE2J!10hNU2JQ9}v?qni{V~W3Nifyei5gHj7cCpi@U;ZDZ+kZ`+!1cq|k>%u) zQ3@+5Hy>i&AOGR4f-5Ofw3%2A=8FagTjdm{ z{y(5QoRfv2qpUV#DJ5?=fut>Wv8}OEgn`4aSn|NZX`CAn*8C5sJU~yo_WpvAk@v*E zM^Y&|(NP^x>fn(>uPKnAYXd(bV zr2jvv53TsBq@s3odOkWdf%o2EGI~JSP{U50+Pk5FI5uT@OqFhpfW)(w@i^u5d(Pk4 zqo0v-8J6o`hRzdGg6bQl`up!^+tcT(rY4V+xPwH8o&!t{cRR1o7B3V8_O~QALcQlB z6aFRp)?AsauF5gkk-b|^DF&DCG~hq}0*Bm%YXEQ2e+=ID1sNlYh6Vim!is$%e(%8y z7ZT4guYdLqrSd6^F{yz?hR)9F@I+^hTa4cy4~Z zju}{0|3i==^@V-xsaek7n!$q)kx8wvDL?12=Ixm_h3~7G>hWVi*ZQ$0pKb})*C%Oq z``TFByRv?(sQT@H(Ta^}Q>L99HeKOkJ?)L_L8BDy8J=X|%FCtw|D;F%Q=Osspv3m~B7tc(!AJ0Yh@YkH&D|T=heetG6#R1ralEA~L z!~^g?fV!{|7DDzf)_!+0II&I>oLr3xL2U5z)Hq^i^DM~7XDRq@!Z02ood-JnB0E_|B`__s@X_dNiEazv;jeUL?T$!<~fChlT10_a8%Hua|$u-|8x$;IcBc2`J{ zLCRily8i*QStMrYZ7b@#FWU>tBGExkj!r_tK+6w~i1-m4oXkFvN{jf5F9DEI{||pD zEl0u|OLuPx6oVk1U@^8al`OIOy^6l1Aa=u*CF~@1f$u&FG675Jl2yRdFAHv8XvkWKfdaV3@twdlN$VRybzW2znY7KBZ?HGfX*$g4U}^W zxUN56s)u;)9_f-p*CYkS)@B!o$VsnafB`dS$9EyGF&mZTm5QzZ_tJdkfa8|_ecO}Z zqC(yMtunjFw_+dA+28RjErmQm>L>W8En2Oyk5Xe~7=8lTi-N7%AZLSu%{D zddR#~8)9rsBo#yKYlPeM5{4aI=Vc%zpXFg8B&sXH9<^p?0Mnleo{P2R7&8GT)Ah|A z;QOZ|+lD_OCs#mm+&Tl)aZx`7Fw6{5IpkkPDD(iM+k-5!X!T36rK9U0 z+f;KC=LdN<9w!WKkw)o})%o=94OB(N0Mw@f-hU$y(eE+R{&L;YKn1J837J8RA?0li z735ttaul3PibV&fYc!FmIKpi@Xop?YrcN)+LCWTV3PlwU6y83#R{_vMCG*L??KjIy zpfod6BNtaE02}CP{UNKLLdTxxq!zlF!87Z9uo=@H{rKV?2(UJXQk2R9fzF-?{(_5# zB!-4JNmv|h6F^eD1up7IIkztL*-;*X0VW#gdpAb6Wi?f*31kr0pMj{!e@_{ zU%^*#a<H_cBOkHB^oVVm{Fo(DF8bq#?f$;~&P#E6YC(UBO%p#=d=4Om zASA4d$w-KbDti-m9$8-gO!K0aUvR%%&ON^vsel76H35zBhaVY{S7Z|Jn;La>^ncGS#PP0ewD!X& zsEt8r7@vq@ooKNEIF*QiC_K~K?-mJa3mY97SDYN_A1fh;hxua@3{}{w*@{LeGl~k<6+obi?eAUFCbU6wzkVVD^Rn`sdEJM z`B7g<$f#P{eyFxg`rGA;w3{KzM!8a+J)uI$-=OcXp2>Q=xk^AUv$Sf>t|A+K@7Y*t zGf}`7s~lVTrSm07$M0zSTz&$ev`JyltpT(E#@(oxu*vx~?)>QJ447JkQ6eiNyZ46B zJ>?0tFSd#?UP(cTfLJA#2rLVM%N^RLB9=^`tE`B9+-~EV4!X^$q^%B{TARhU-R|Z4 z-V6upONB6e!CIfazyH~XQxg5;!6)25k&^M|;8kMUQW#!jm{fL|-qn?XRPtl0J zEt(?g7<)R>ybt3;BRgQbIE=WNT-N_MiG}|J#M_o5h*B5jU)6bT|3G|M-5Z>z4`qUh zu3`k_O1<-U?G)DTX^YStVhz7#L zQ}Zvt)oQ^iH%;@{t8)ArWO+5G#14#KL`*1cj^?y;kBwwOsEbtT2i z%fDqR;vxin+Ta-C+DWexz4#LO!Zv#uZvuT>Nri2@{axnY?jC++?&9L29GAz$wLiQ} zTn&SyE|E&SO)-JJenUo)nio(EDyzn5I9UYZa>uWFM**E-(_cP<2HD3o=u1#l^v}p& znKW?f#n)P;Q8DoJ5Nf9@^0ZSlplU*_)lVLW0NmQawrY-!1bD{9 z0nF5EN_t~MTS(oqkFJ*Q{^#_KqTc$8F?``ddHFPBpd|Cr`U;ihCaz;TXBB%+W2y9+xhxyMQ;vDOIz(*HfcA;H$o z-nEqe+CCuaYqAuwDeYOry|iopz;>^0-6C86BZBE%;sD;P3vc}n; z$4p=ZPEj*GtxpvGzA6cy3MT5`!1qdB|EZ2cfUREL@5s1@h>rt|$b@udPenE%rJm9| z(wm()brtnX{u$C>#1~PJYc~DvQ4{~<-KYQ$M~s@p`digsWTRLOXy88cvFSIIB#PVS z#L4Qa8eFQR))Heq2(H(M5H4nwn}g%OZ!!L$qg^PLe1Fg|?g)rP2xu@TBSPT0c&u@9 z5|sJkA5aB3=cIf~$0Ne~y-%d{9}RbN*5~RK z)|O_+eZp(AzVlI%Q0+`#e!Hqc2Y#bd+Fm_e$}8yXyc||c1|qI!_6P=k0#`4nk^x6b@!~&L-X#GOeUVK5Vd#(0! zUFk&7QQJ$>sj4{B$G04qvX>;dl0*B+DR?>CZ--k*o(9U`Wd_Ph#vSMs5-Zr5*`|@8 zl!h+S6u;m0BB-r0Z72lseiRA8wm(1Gxn%qD089w*F{>)CUa&KXm@TH)Q05fhUso zhql$rKzv8Jfy`DcE&3~EtVovizZnW>soa<1FVgmv2vDv>pz&IltRm#CqQbsWQ3<@r zor!!~T0$cgX@)`WBmM&P@bo2Xir({khcacAbYRL1TJVhQc4gpWg`gEcegqSQe5q_i zXq+x9r9uJE#NkTR&PIJYPp*|_h^FNKLHMZaZE*t!F(H3+-12^GMF1UA#uYz6@E?Qn zbCF2n%=sUauY;E&87_R7M^8@92p#sOcask~XEv>Py1#yPvrmn;_%nq|3xDqcF_sTB zzusvTx&4`ULQ75P7A`6hreBsxMS`DIlofuEb>`1UVu=r}cv4$1G(bOBHdc8HR-W8+Led$8{!RMTFuEwMKhJrH}5Yb_^%nQ?X|v{80!n za1=-e0I7GAGvV{T=2ev>bg%%%Cv8;hOC+>G{0U-CM20Epflnpk*NJg76-=!hTR4|x z^p%wgvwZs%f>C4(NYM5n8sltjf3a3ZQMnYeCVmdMf0&CsvzzUmqD`Hi>=eeDEW%rz z{xnJURG*m@Oy@L=>sq?07G&c#wii(0!fMSgFO|wq`||Q76;x%yB{LZ{5}$XxKgO0` zBopdoAAHmfwU~1kK>#tqy0@@)n5NEuHNhAAdmWLIArbWI1J=^5m+qh!=9nT272-K~ zRLlD5=+3{L&Z1JBAAEj9Z77GE^N_$nEkIt7tFID5e0fA+mQuJD9Eil7ZcTWninAWE zRc;o4em;mgv5^{9x^294<^6ub){^+Q(b%AG>8!CN)Su$QPbEiGYTw(vz6~ z6RMNTzf(5yRs;@wMGE&EJ4r9VvM#rdv^%X~cWGgb69quU0!qLlih@h=kRSOKa}E~g z>vY&M1^Y3Ws=>p8Fab}ZybGZKYhHSeTsWb4WNmn89nGqy9^4?;-U2x@hdB51$e=z8m1C7I zCh01hul`NR-ind)Rg#CdB=JgddT3+K!Hw{pj$4ezuTC=EC_kr+iUw>P*sd&vvE>L+^F(xjOY_ck zR*B!cnqOa5zXVA~Fv%1UlVlhePX&3LpgGi?9c(x7Uf=ExPcEvcjBLt1L#3TXPXB`Zw+Ra@%zwR=zH+F_zn%tT0(#4woS@W%W&p9 z|6^|8V@$M?vxxA6EED!73*06X;8by10*-7_3J4g04)9gugRm*JYp8%uJ7$j(27sg} zQ`GoL;t8Z;Kk)eimB>Jo29)amEq~Ld&cQ`ITo&nQn~ z=1`eyz|5bE74lJCh13yGvMLdRgnRhS!BT6cF)ay5z>+cg789$%&nHrb03m>f_2t&o zR2Ow{ayUlGO4?xZSn32A(({$#jA>#5>JL&*7tu7Vbbj&wk}fwwK7 zX_%J&3wh;6aggLrKm&XS*;@{o0`|BYopmskEH(;afiwnkhRE8Uc!V0NaQ zay(Z7IvBlEYBlX^9Fp1;X=(Iz*kMotuPa_mCKv2lHNeq&u`9?hz?FXsM@$&)O)NQX$oLaAC*73*&vZ4!bN3v@X^2?r(dWCf5lA{Nb?( zO^iOaynl)$6=FWX?sA-`>Vc|{iBxKi&?<(1=f@jKgenj zkBXW21Yh}oiaP7CCf~6AZ{$V}5Tt|+B`4C7gE?C1kW^AaT6#1L7>H6z3sO>o5)uN^ z3Me663Imi-a&-P4-{bqfzx};qe?0enU-z@?JkQUyfwV$NnL-X0%97Q=7EFqL zKhPd2ZNmQLm%hv!8!H&AN(<(?WG+--(>(8lG58z_nz`EB)dPtF*$viLwFmU3UAwqOL%=b$ot65;X%{HatQR z9bFsQfz#s!d}3Z@=_QK(#KVt@r>SQ-=HnYJtrz}>puDx*S#9G&o9|v6cHH<{0j6-W z5e@I`7S;^*kCXwmaeN=E zB8L4Q((S~VO*(|B{Fc8O+Ou)CH*qOH<@bGJbTfAHGP>ZQ)mdeBTZxsEfODLi2(SGf zGXo$9NCQ%aIxTKho(dQ5y5E_v*riu_-kzUtjy(vXF3W_$B`G04046l;V_Dq6JJf@~ z&J!GfYDgi&U@}%s1qvv&M6>;Fe{AEaPoje)~;_$hWZQY}P7Gu5L;P$D{`4;*UfB6)Ot-c6leSt}QmMgt~m2}qALB{9g z8ygP-j9G7KtWl{hKqma*c`G{)x(ta)FhIr^2g~{SaIZ;HNA_R~+B7EF+6&hNxJ@XJ z2tS?Oz^s<;y72D61wSV+Hh!;XpOlab5K6|o{uXNy$g92i>m^bL68>tLzkHkuR*$rJ z?FL+P43A!@HEF!Cgg&>16&gcSuHCMQu@j!(Oz{~n5jx!@fk?x zmUi|#_qP$zb&T?HX3@(wH^fFfynLrSM^gByVewv0mU#XkR`@d09o{C+mtT5EC@@FQ z?#6#jc676gY7>HY4=#@0K{QUwMI_k<+5;Bnv@IrPm;v7{Ubm@QCe&A9;=`SqxB#*Q z8EyD7+fMCMpe%^w8CD?`{ewZqi81B7wb6LF-#-Cd?#OSSPZk>O>r}9j`wHTrXC!@2 zlCRJjqj7!KPUue$0HT##sx`fTN4A}V^Wdq-Eq|N9xY*Mc74_5M3`uzO0{v67j-&5{ zZQ$kAoo#~gsWR)2)NgO@wW#lE>GXA}#fp}6fo#wLN8@YU&-^U!Y6{aN>NMUyzuqGC z8j=8pcnn<@v^{Dq3}tzez5c05YNr~6(b=QgrG(_mi~#-5i+2l#R>a31qf}_q7xPAz z!&tk6k~<8^{w$%YxjAp|y2`zkeXEg(&a)u4y?@f+aEbnEuIj}P4fkve$?mqCZ(Tfd z2={z+^x(0<{V_1?XYS=ABUf=&wMS#<#smKgTZO7bMG6Q(b8y}5;SL=g2qOJODRP?X z$)D&{C@eDi{VGsv=B!RaoQ&RrFwbq{`FK}S&~wyijavLbNBux&3k9QTR@a8b4K=x2 zv>)7lTAg$a9Oo@jlHjZf%^a8Y`L?VX!I_8}0LjS?Nv4CV$N{aV{GsXAh;4WbLJkBg zM=7@!()vmuBn85^*H|&>hE^QPy>hsNf7G@B8bl$`1-)|Z@KqeaHe&5gn=`SWBjpW4aiuxp>XjF)E$?*y58ga*$P}>m%6k^gorGg1JF^3FE9#@>W8{}{C#6f1rB8e&M!H<2>09;gkk>zky(?ZM2Ze*_nn@oVysbsWAJ31r=l}AO+j+CzBasQ zR5LN>cAk~_!go^K-u})#lZpG&%W-(hyC6}{=F3cGFf@`eP?{u)r016a8|%V5@iH0~ zD&s|=!lfmiTtkbhLEY+zh6R_Me4U{)|6B88nT*0sc_U+6STE^ z-YPA2K)2G>q3;>Gzy4WYUA)ns{}Qe>z=jR%Z=tYf%JsIp$5c7ceVd@q(Be8}C<$*< z+|Ct1|@~0%-hv3Hht*)n3|c**$ z0b3}2K}X<_s%z!?_iaAU7ZbmoT!UNqU^*LfGEyayiVJh>v`F_%?kh4+X^c$CadiqW z?P+Rv2l|9TxFVoZE*N;}_kW34rj8fUx_x|~jgyPqVxHBbk;`l-uo0aAewDq~WBr>==kVFSf zhnD%_O`T0=@VJW2<1Hs2>yy{Mc0egdM$N`crux=XHeCn^9n69?c3V2s(>~%o72>eyv_seq_4jq_U!NLM+7zz)hM@30N5?_~%hW1S{V;N0XV#jCdVyc6zKMDL5+B zz!<-i7>b4oEl;Ssp6%>CYBq96&&@)|zdmv}#~ZLsFfP8mEs8Zp5lhpBauf&|d~b06 z0*`0km$4%}x9%M6{if<-z$Wk0IsALANPONiR_|_=vc_aAR}A_l%9efTT+0okn^l#V zS|Jf4gH`=jU%+3xJC2zET7FuF71%7cpyi$Bwcqb&o*>H$iHe_h%sz~;CSt-aJL%10K zVt&~~-_(W_T%zF}*3|OBP$9vvWp)Wf?q5;a7@uqLTkucYBdWY?(= zvk(Nj*J{PIA%&8b$Bp2S?B6Q%nrBZG-KcnSVKk|&)*ljj9u`D#(%QVU#o3Z$ogW<` z4es20!<+mpNk1HBwOLzn>8%Stvp!WqW`lrl!uoxCL3ee|0s5V5bZlc-Ik>;F1jLm ztgWV&+53BOWpKOm@YaPq9WVci9e<64bn{6^e;%mwJ28F!;VH*PQri8S;jk`4kd6Nm z&ysMNsaGhISN=CPWm#*)2cA|Y^%+VggNi@3O21&*5fAz<2J*8&20S_sAji z+~Pw2vLUfjG;YHTfiXXu`x)8ik|CTSR5+UJIOsK9Fiz!}Eh80N!DT-_;upOR5VlwR z-v~AU7MmfLH4QY6)8^pj_?=PhSsP6Fs$=aojh)(13NtlQpeW?A6+bM{J0kYbRCohp zZM%$a(RzLghUe4C9a;oUiPk=^+3h|VulV&$N~HMilQez6X+YK~LFoy|-ds4E{e;^J zGQ5u+hZFGt%l#U=BvTjU)kBF61Q`U%#+=&Uj=TSk{k?l!HK5J@r_&B0nnY2fr3;mU zJP_iePE(x;lXQpvVUL>)-S*wH*lDgieT)#}Kd^W)oZKX0j2g#C%?z(Q97i;lh%1C2 zKx0oI%<8qOC;ElMz_?9$JII3%;d(eg008><|7Cu+XN3jVU>9g0dkaDoGD5~VOC2yh zHxzmyUA2_<7!)4AN1D%%#`{rXnwdcPYQ3vSaeJfx6Mj2A@`{#=#HnC2Im4*31meHg zZp5&dLA>RTNClC!i05<-B z9zij<~qQ7{xsN={?7v9X%tTn3p@Q#7O zCf6o^!b4+uTs=1Zm5dC~EHSz#&Hb2rrp-~nM`K8j7H5@h@*5coV^@)on^v_NhcJ#bQO6~WV zVYwGnkXJANIU%g106G1*-+9Ty>3_N||NUJ1Piaobz(D1}H#^(M4v*b;EPKpN&E=<> zv$8GLWrP5DSq?kIS63(s1Ym#jSIqW)Py!F3Tnv{*hd`II0n~iuQHFsry7NOMnP@yR5}*d0_IF5%Y8L~ z%|MAR1s29E;{wMh{`fUOY5K>aKVjq1rmHh4di^@70p|RYt~Yk(b-?!c%_It#d0HOTQ{Z6ds#4N5wa3LPg`Az~u;m0sxGn){=*7)94=U0{{;MjC zkfBW06_#cC#AkpMT|50VP!TXvY@9TUrqIQ4-K{Bl$T& zG4CP3`HZeF{4gf_)&ur)pL!w}nsu2YB=9fsZFg3~{R;<|KQg0U4b@JrjcdDiFRvq! zv$0>pzT%Nq>ml;IZyI@;D@w4uV@kQ=O*!d7!y2Q-X(NIopK@&)9K&96K6t@p(f`|a z%Yu`f+EH$EYDOl_q0W1C^UUKxPOC8*=$<<+*yfbYo^GC{Qe>TZGvaUv1J>rD~cduF6X zln`Jd=f%Zg8#hR3L_#7rcrQ7ZW<^W;;>+a`cloQf)AKDLOJW6|t@EujF*ZSF111Hf z^EF-GUEtW?`WLIvgab~SinnD|Z>y+i>0RUjQUZfwTcfp`3-Fh|MfTiV+R-W~$RvDt z7IjpMykye#a9Oq!NG?Q4RX`a6Zvf7=y5B$&G?z6KNYH@l)3j^D%9nHuj5>ha=G2T_ zQ_~R%F1bUH+x5bcDuNYCyYE9g$9|0a_5xmIn)015Jjsyj=J_#Z@_g$3O=g(6#jJMS zk2%icqrQW%XV3vXFv0ZehkJH;Hm)Ol<+I5tb`anStOp)eT?61h(SvfUt|Ow&1y(CZ z-0`J3#nf~m^gQRU0c5H@IgC-}?pP@j%P#YHT|lezRp28~=c^BEhx9m8N8t^(ao-P$ z;LwxsZSJrkWG{DK4ls+vn%-(SI8IkvmpnG6Ci6wk-;Z`33>Sorm8WecdPXw0#eR;D zk^)ey%(NFTla+*Sf1VkvUpfoKxu{>PHcrVOZjx~EJ}Oqe-gOoPL-($AUB3>v@?3!- z4^mRnhkN_x(=)8BYz9VN&d>rnfHz-gR|uFQvZh_H{RO^C8c8y}1`u*z*kZ7!CpDRA z$%9A3R}a&YgP6P>t!z_}ri_f@;xFg%GY z^couf6e5=S}2m^CMBHoa_ zed6q@_N{O2K;8xnW!rfPve9zl8*{X=p=C;8=_t0;hryGJjhOw##08=Umh1L5FA$6d nBM76&X8HX?@QV literal 22592 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 LNkvXXu0mjfIH6&- From a64292f2b09fa41cb2895a2c78368322ed07c63d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 3 Apr 2026 14:38:07 +0700 Subject: [PATCH 03/73] fix: use cross-env for cross-platform NODE_ENV in server scripts --- package-lock.json | 26 ++++++++++++++++++++++++++ packages/server/package.json | 7 ++++--- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 568fa0644..a25eeb94f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3519,6 +3519,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@epic-web/invariant": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", + "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", + "dev": true, + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -15434,6 +15441,24 @@ "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", + "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@epic-web/invariant": "^1.0.0", + "cross-spawn": "^7.0.6" + }, + "bin": { + "cross-env": "dist/bin/cross-env.js", + "cross-env-shell": "dist/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/cross-fetch": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz", @@ -35468,6 +35493,7 @@ "@types/uuid": "^9.0.7", "@types/ws": "^8.5.8", "@vitest/ui": "^3.2.4", + "cross-env": "^10.1.0", "playwright": "^1.56.1", "tsx": "^4.6.0", "typescript": "^5.2.2", diff --git a/packages/server/package.json b/packages/server/package.json index e6dbf781c..37de5c142 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -32,13 +32,13 @@ } }, "scripts": { - "dev": "NODE_ENV=development tsx scripts/dev-runner.ts", - "dev:tsx": "NODE_ENV=development tsx watch --ignore '**/*.timestamp-*' src/server/index.ts", + "dev": "cross-env NODE_ENV=development tsx scripts/dev-runner.ts", + "dev:tsx": "cross-env NODE_ENV=development tsx watch --ignore '**/*.timestamp-*' src/server/index.ts", "build": "node -e \"require('node:fs').rmSync('dist',{ recursive: true, force: true })\" && npm run build:lib && npm run build:scripts", "build:lib": "tsc -p tsconfig.server.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/server/server/speech/providers/local/sherpa/assets',{recursive:true}); fs.copyFileSync('src/server/speech/providers/local/sherpa/assets/silero_vad.onnx','dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx');\"", "build:scripts": "tsc -p tsconfig.scripts.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/scripts',{recursive:true}); fs.copyFileSync('scripts/mcp-stdio-socket-bridge-cli.mjs','dist/scripts/mcp-stdio-socket-bridge-cli.mjs');\"", "prepack": "npm run build", - "start": "NODE_ENV=production node dist/server/server/index.js", + "start": "cross-env NODE_ENV=production node dist/server/server/index.js", "typecheck": "tsc -p tsconfig.server.typecheck.json --noEmit", "generate:config-schema": "tsx scripts/generate-config-schema.ts", "speech:models": "tsx scripts/list-speech-models.ts", @@ -103,6 +103,7 @@ "@types/uuid": "^9.0.7", "@types/ws": "^8.5.8", "@vitest/ui": "^3.2.4", + "cross-env": "^10.1.0", "playwright": "^1.56.1", "tsx": "^4.6.0", "typescript": "^5.2.2", From df617a4c8f27324fe22ce4f33c0234dff38a5047 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 3 Apr 2026 07:39:11 +0000 Subject: [PATCH 04/73] fix: update lockfile signatures and Nix hash --- nix/package.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/package.nix b/nix/package.nix index 52c101bcf..f8d923e83 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -42,7 +42,7 @@ buildNpmPackage rec { # To update: run `nix build` with lib.fakeHash, copy the `got:` hash. # CI auto-updates this when package-lock.json changes (see .github/workflows/). - npmDepsHash = "sha256-daWF5ntco0CDCG7es8BYijBs7LE3427AsajlWLP7THo="; + npmDepsHash = "sha256-v8ArSIil8F9dalo+Z+IKcYvGVVgDlLMsSJemI7HT14Q="; # Prevent onnxruntime-node's install script from running during automatic # npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox). From a2b1498c3f57a25bc3c0c44b4d26cdac96f90787 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 3 Apr 2026 16:46:17 +0700 Subject: [PATCH 05/73] fix(app): broaden keyboard focus scope resolution to check multiple candidates Check target, parentElement, and document.activeElement as focus candidates instead of relying solely on the direct event target. Fixes scope misdetection when the keyboard event target is a text node or non-element. --- packages/app/src/keyboard/focus-scope.test.ts | 80 +++++++++++++++++++ packages/app/src/keyboard/focus-scope.ts | 64 ++++++++++++--- 2 files changed, 132 insertions(+), 12 deletions(-) create mode 100644 packages/app/src/keyboard/focus-scope.test.ts diff --git a/packages/app/src/keyboard/focus-scope.test.ts b/packages/app/src/keyboard/focus-scope.test.ts new file mode 100644 index 000000000..5456493c0 --- /dev/null +++ b/packages/app/src/keyboard/focus-scope.test.ts @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { resolveKeyboardFocusScope } from "./focus-scope"; + +class FakeNode { + parentElement: FakeElement | null = null; +} + +class FakeElement extends FakeNode { + tagName: string; + isContentEditable = false; + private selectors: Set; + + constructor(input?: { tagName?: string; selectors?: string[]; isContentEditable?: boolean }) { + super(); + this.tagName = (input?.tagName ?? "div").toUpperCase(); + this.selectors = new Set(input?.selectors ?? []); + if (input?.isContentEditable) { + this.isContentEditable = true; + } + } + + closest(selector: string): FakeElement | null { + if (this.selectors.has(selector)) { + return this; + } + return this.parentElement?.closest(selector) ?? null; + } +} + +describe("resolveKeyboardFocusScope", () => { + const globalRef = globalThis as { + Element?: unknown; + Node?: unknown; + document?: { activeElement?: unknown }; + }; + const originalElement = globalRef.Element; + const originalNode = globalRef.Node; + const originalDocument = globalRef.document; + + beforeEach(() => { + globalRef.Element = FakeElement; + globalRef.Node = FakeNode; + globalRef.document = { activeElement: null }; + }); + + afterEach(() => { + globalRef.Element = originalElement; + globalRef.Node = originalNode; + globalRef.document = originalDocument; + }); + + it("resolves terminal scope from the direct keyboard event target", () => { + const target = new FakeElement({ selectors: [".xterm"] }); + const scope = resolveKeyboardFocusScope({ + target: target as unknown as EventTarget, + commandCenterOpen: false, + }); + expect(scope).toBe("terminal"); + }); + + it("falls back to activeElement when target is not an Element", () => { + const activeElement = new FakeElement({ selectors: [".xterm"] }); + globalRef.document = { activeElement }; + const scope = resolveKeyboardFocusScope({ + target: null, + commandCenterOpen: false, + }); + expect(scope).toBe("terminal"); + }); + + it("detects editable scope from activeElement fallback", () => { + const activeElement = new FakeElement({ tagName: "input" }); + globalRef.document = { activeElement }; + const scope = resolveKeyboardFocusScope({ + target: null, + commandCenterOpen: false, + }); + expect(scope).toBe("editable"); + }); +}); diff --git a/packages/app/src/keyboard/focus-scope.ts b/packages/app/src/keyboard/focus-scope.ts index 457db7e3b..340a8cd41 100644 --- a/packages/app/src/keyboard/focus-scope.ts +++ b/packages/app/src/keyboard/focus-scope.ts @@ -1,37 +1,77 @@ import type { KeyboardFocusScope } from "@/keyboard/actions"; +function isElement(value: unknown): value is Element { + return typeof Element !== "undefined" && value instanceof Element; +} + +function getFocusCandidateElements(target: EventTarget | null): Element[] { + const candidates: Element[] = []; + const pushUnique = (element: Element | null) => { + if (!element || candidates.includes(element)) { + return; + } + candidates.push(element); + }; + + if (isElement(target)) { + pushUnique(target); + } + + if (typeof Node !== "undefined" && target instanceof Node) { + pushUnique(isElement(target.parentElement) ? target.parentElement : null); + } + + if (typeof document !== "undefined" && isElement(document.activeElement)) { + pushUnique(document.activeElement); + } + + return candidates; +} + export function resolveKeyboardFocusScope(input: { target: EventTarget | null; commandCenterOpen: boolean; }): KeyboardFocusScope { const { target, commandCenterOpen } = input; - if (!(target instanceof Element)) { + const candidates = getFocusCandidateElements(target); + if (candidates.length === 0) { return commandCenterOpen ? "command-center" : "other"; } - if (target.closest("[data-testid='terminal-surface']") || target.closest(".xterm")) { + if ( + candidates.some((element) => + Boolean(element.closest("[data-testid='terminal-surface']") || element.closest(".xterm")), + ) + ) { return "terminal"; } if ( commandCenterOpen && - (target.closest("[data-testid='command-center-panel']") || - target.closest("[data-testid='command-center-input']")) + candidates.some((element) => + Boolean( + element.closest("[data-testid='command-center-panel']") || + element.closest("[data-testid='command-center-input']"), + ), + ) ) { return "command-center"; } - if (target.closest("[data-testid='message-input-root']")) { + if (candidates.some((element) => Boolean(element.closest("[data-testid='message-input-root']")))) { return "message-input"; } - const editable = target as HTMLElement; - if (editable.isContentEditable) { - return commandCenterOpen ? "command-center" : "editable"; - } - - const tag = target.tagName.toLowerCase(); - if (tag === "input" || tag === "textarea" || tag === "select") { + if ( + candidates.some((element) => { + const editable = element as HTMLElement; + if (editable.isContentEditable) { + return true; + } + const tag = element.tagName.toLowerCase(); + return tag === "input" || tag === "textarea" || tag === "select"; + }) + ) { return commandCenterOpen ? "command-center" : "editable"; } From 55c4e58aa3eb406038a0e87b9e23ee82ba6abbd7 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 3 Apr 2026 16:46:20 +0700 Subject: [PATCH 06/73] fix(desktop): disable npmRebuild in electron-builder --- packages/desktop/electron-builder.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/desktop/electron-builder.yml b/packages/desktop/electron-builder.yml index 31a686a02..aeca00417 100644 --- a/packages/desktop/electron-builder.yml +++ b/packages/desktop/electron-builder.yml @@ -1,3 +1,4 @@ +npmRebuild: false appId: sh.paseo.desktop productName: Paseo executableName: Paseo From 44da0c67b2112c99344ed3bbf7e9e4b495b1f937 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 3 Apr 2026 16:47:59 +0700 Subject: [PATCH 07/73] fix(server): disable claude-acp and copilot providers from registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These providers cause old mobile clients (<=0.1.40) to fail parsing the list_available_providers_response because their AgentProviderSchema enum rejects unknown provider IDs, dropping the entire message and leaving the model picker empty. The provider implementations remain in the codebase — only the manifest entries and factory registrations are removed until the updated mobile app (0.1.43) is live in the App Store. --- .../src/server/agent/provider-manifest.ts | 19 ------------------- .../src/server/agent/provider-registry.ts | 12 ------------ 2 files changed, 31 deletions(-) diff --git a/packages/server/src/server/agent/provider-manifest.ts b/packages/server/src/server/agent/provider-manifest.ts index fa318a593..65e20fbc6 100644 --- a/packages/server/src/server/agent/provider-manifest.ts +++ b/packages/server/src/server/agent/provider-manifest.ts @@ -137,18 +137,6 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [ defaultModel: "haiku", }, }, - { - id: "claude-acp", - label: "Claude ACP", - description: "Claude Code via Agent Client Protocol with streaming, permissions, and session resume", - defaultModeId: "default", - modes: CLAUDE_MODES, - voice: { - enabled: true, - defaultModeId: "default", - defaultModel: "haiku", - }, - }, { id: "codex", label: "Codex", @@ -161,13 +149,6 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [ defaultModel: "gpt-5.1-codex-mini", }, }, - { - id: "copilot", - label: "Copilot", - description: "GitHub Copilot via Agent Client Protocol with dynamic modes and session support", - defaultModeId: "https://agentclientprotocol.com/protocol/session-modes#agent", - modes: COPILOT_MODES, - }, { id: "opencode", label: "OpenCode", diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 9929a1b42..34d646eff 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -8,9 +8,7 @@ import type { AgentProviderRuntimeSettingsMap } from "./provider-launch-config.j import type { Logger } from "pino"; import { ClaudeAgentClient } from "./providers/claude-agent.js"; -import { ClaudeACPAgentClient } from "./providers/claude-acp-agent.js"; import { CodexAppServerAgentClient } from "./providers/codex-app-server-agent.js"; -import { CopilotACPAgentClient } from "./providers/copilot-acp-agent.js"; import { OpenCodeAgentClient, OpenCodeServerManager } from "./providers/opencode-agent.js"; import { @@ -43,17 +41,7 @@ const PROVIDER_CLIENT_FACTORIES: Record = { logger, runtimeSettings: runtimeSettings?.claude, }), - "claude-acp": (logger, runtimeSettings) => - new ClaudeACPAgentClient({ - logger, - runtimeSettings: runtimeSettings?.["claude-acp"], - }), codex: (logger, runtimeSettings) => new CodexAppServerAgentClient(logger, runtimeSettings?.codex), - copilot: (logger, runtimeSettings) => - new CopilotACPAgentClient({ - logger, - runtimeSettings: runtimeSettings?.copilot, - }), opencode: (logger, runtimeSettings) => new OpenCodeAgentClient(logger, runtimeSettings?.opencode), }; From a96f2d765230715a7d9e3402c617c144ac01fb0b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 3 Apr 2026 16:51:33 +0700 Subject: [PATCH 08/73] fix(desktop): stop daemon before auto-update restart The daemon is a detached process that survives Electron restarts, so after an auto-update the old daemon version would keep running. Now we stop it before quitAndInstall so the new app instance starts a fresh daemon with the updated binary. --- packages/desktop/src/daemon/daemon-manager.ts | 4 +++- packages/desktop/src/features/auto-updater.ts | 4 +++- .../src/server/agent/provider-manifest.ts | 24 ------------------- 3 files changed, 6 insertions(+), 26 deletions(-) diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index 1bd61bf85..4b8cb18dd 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -548,7 +548,9 @@ export function createDaemonCommandHandlers(): Record { const currentVersion = await resolveCurrentUpdateVersion(); - return downloadAndInstallUpdate(currentVersion); + return downloadAndInstallUpdate(currentVersion, async () => { + await stopDaemon(); + }); }, get_local_daemon_version: () => getLocalDaemonVersion(), }; diff --git a/packages/desktop/src/features/auto-updater.ts b/packages/desktop/src/features/auto-updater.ts index bf4810c33..d1c1dca8f 100644 --- a/packages/desktop/src/features/auto-updater.ts +++ b/packages/desktop/src/features/auto-updater.ts @@ -98,6 +98,7 @@ export async function checkForAppUpdate(currentVersion: string): Promise Promise, ): Promise { if (!app.isPackaged) { return { @@ -131,8 +132,9 @@ export async function downloadAndInstallUpdate( await autoUpdater.downloadUpdate(); // quitAndInstall restarts the app with the new version. // Use a short delay to allow the renderer to receive the response. - setTimeout(() => { + setTimeout(async () => { try { + if (onBeforeQuit) await onBeforeQuit(); autoUpdater.quitAndInstall(/* isSilent */ false, /* isForceRunAfter */ true); } catch (error) { console.error("[auto-updater] quitAndInstall failed:", error); diff --git a/packages/server/src/server/agent/provider-manifest.ts b/packages/server/src/server/agent/provider-manifest.ts index 65e20fbc6..687a90bb8 100644 --- a/packages/server/src/server/agent/provider-manifest.ts +++ b/packages/server/src/server/agent/provider-manifest.ts @@ -83,30 +83,6 @@ const CODEX_MODES: AgentProviderModeDefinition[] = [ }, ]; -const COPILOT_MODES: AgentProviderModeDefinition[] = [ - { - id: "https://agentclientprotocol.com/protocol/session-modes#agent", - label: "Agent", - description: "Default agent mode for conversational interactions", - icon: "ShieldAlert", - colorTier: "moderate", - }, - { - id: "https://agentclientprotocol.com/protocol/session-modes#plan", - label: "Plan", - description: "Plan mode for creating and executing multi-step plans", - icon: "ShieldCheck", - colorTier: "planning", - }, - { - id: "https://agentclientprotocol.com/protocol/session-modes#autopilot", - label: "Autopilot", - description: "Autonomous mode that runs until task completion without user interaction", - icon: "ShieldOff", - colorTier: "dangerous", - }, -]; - const OPENCODE_MODES: AgentProviderModeDefinition[] = [ { id: "build", From 9ea181a0725bfe2eab246117d94f017f7a20ff74 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 3 Apr 2026 16:55:05 +0700 Subject: [PATCH 09/73] docs: add 0.1.44 changelog entry --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 996e547ce..a7bf081c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.1.44 - 2026-04-03 + +### Fixed +- Desktop app now stops the daemon cleanly before auto-update restarts. +- Disabled claude-acp and copilot providers from the agent registry. +- Keyboard focus scope resolution now checks multiple candidates for broader compatibility. +- OpenCode interrupt now reaches correct terminal state parity with tool-call flows. +- Shell injection, symlink escape, and pairing endpoint security hardening. + ## 0.1.43 - 2026-04-02 ### Added From fd894dc3d76ffa9bc307f6195288a35f74f3daa3 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 3 Apr 2026 16:55:14 +0700 Subject: [PATCH 10/73] chore(release): cut 0.1.44 --- package-lock.json | 38 ++++++++++++------------ package.json | 2 +- packages/app/package.json | 8 ++--- packages/cli/package.json | 6 ++-- packages/desktop/package.json | 6 ++-- packages/expo-two-way-audio/package.json | 2 +- packages/highlight/package.json | 2 +- packages/relay/package.json | 2 +- packages/server/package.json | 6 ++-- packages/website/package.json | 2 +- 10 files changed, 37 insertions(+), 37 deletions(-) diff --git a/package-lock.json b/package-lock.json index a25eeb94f..ea50e3cb7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.1.43", + "version": "0.1.44", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.1.43", + "version": "0.1.44", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "workspaces": [ @@ -34996,16 +34996,16 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.1.43", + "version": "0.1.44", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@expo/vector-icons": "^15.0.2", "@floating-ui/react-native": "^0.10.7", - "@getpaseo/expo-two-way-audio": "0.1.43", - "@getpaseo/highlight": "0.1.43", - "@getpaseo/server": "0.1.43", + "@getpaseo/expo-two-way-audio": "0.1.44", + "@getpaseo/highlight": "0.1.44", + "@getpaseo/server": "0.1.44", "@gorhom/bottom-sheet": "^5.2.6", "@gorhom/portal": "^1.0.14", "@react-native-async-storage/async-storage": "2.2.0", @@ -35122,11 +35122,11 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.1.43", + "version": "0.1.44", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/relay": "0.1.43", - "@getpaseo/server": "0.1.43", + "@getpaseo/relay": "0.1.44", + "@getpaseo/server": "0.1.44", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", @@ -35167,11 +35167,11 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.1.43", + "version": "0.1.44", "license": "AGPL-3.0-or-later", "dependencies": { - "@getpaseo/cli": "0.1.43", - "@getpaseo/server": "0.1.43", + "@getpaseo/cli": "0.1.44", + "@getpaseo/server": "0.1.44", "electron-log": "^5.4.3", "electron-updater": "^6.6.2", "ws": "^8.14.2" @@ -35205,7 +35205,7 @@ }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", - "version": "0.1.43", + "version": "0.1.44", "license": "MIT", "devDependencies": { "@biomejs/biome": "1.9.4", @@ -35406,7 +35406,7 @@ }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.1.43", + "version": "0.1.44", "dependencies": { "@lezer/common": "^1.5.0", "@lezer/cpp": "^1.1.5", @@ -35432,7 +35432,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.1.43", + "version": "0.1.44", "dependencies": { "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", @@ -35448,14 +35448,14 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.1.43", + "version": "0.1.44", "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", "@ai-sdk/openai": "2.0.52", "@anthropic-ai/claude-agent-sdk": "^0.2.11", "@deepgram/sdk": "^3.4.0", - "@getpaseo/highlight": "0.1.43", - "@getpaseo/relay": "0.1.43", + "@getpaseo/highlight": "0.1.44", + "@getpaseo/relay": "0.1.44", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.2.6", @@ -35854,7 +35854,7 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.1.43", + "version": "0.1.44", "dependencies": { "@cloudflare/vite-plugin": "^1.20.3", "@cloudflare/workers-types": "^4.20260114.0", diff --git a/package.json b/package.json index 8fed2d668..fe2b6679f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.1.43", + "version": "0.1.44", "private": true, "workspaces": [ "packages/expo-two-way-audio", diff --git a/packages/app/package.json b/packages/app/package.json index c1f5c3c69..13aa945d5 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,7 +1,7 @@ { "name": "@getpaseo/app", "main": "index.ts", - "version": "0.1.43", + "version": "0.1.44", "private": true, "scripts": { "start": "expo start", @@ -31,9 +31,9 @@ "@dnd-kit/utilities": "^3.2.2", "@expo/vector-icons": "^15.0.2", "@floating-ui/react-native": "^0.10.7", - "@getpaseo/expo-two-way-audio": "0.1.43", - "@getpaseo/highlight": "0.1.43", - "@getpaseo/server": "0.1.43", + "@getpaseo/expo-two-way-audio": "0.1.44", + "@getpaseo/highlight": "0.1.44", + "@getpaseo/server": "0.1.44", "@gorhom/bottom-sheet": "^5.2.6", "@gorhom/portal": "^1.0.14", "@react-native-async-storage/async-storage": "2.2.0", diff --git a/packages/cli/package.json b/packages/cli/package.json index 01665066b..c7a978af5 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.1.43", + "version": "0.1.44", "description": "Paseo CLI - control your AI coding agents from the command line", "type": "module", "files": [ @@ -24,8 +24,8 @@ }, "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/relay": "0.1.43", - "@getpaseo/server": "0.1.43", + "@getpaseo/relay": "0.1.44", + "@getpaseo/server": "0.1.44", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index bdb19ca9a..92d300f65 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.1.43", + "version": "0.1.44", "private": true, "description": "Paseo desktop app (Electron wrapper)", "main": "dist/main.js", @@ -12,8 +12,8 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { - "@getpaseo/cli": "0.1.43", - "@getpaseo/server": "0.1.43", + "@getpaseo/cli": "0.1.44", + "@getpaseo/server": "0.1.44", "electron-log": "^5.4.3", "electron-updater": "^6.6.2", "ws": "^8.14.2" diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index d0fa2ea03..2f65df5a1 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.1.43", + "version": "0.1.44", "description": "Native module for two way audio streaming", "main": "build/index.js", "types": "build/index.d.ts", diff --git a/packages/highlight/package.json b/packages/highlight/package.json index 71284a11a..313b46724 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.1.43", + "version": "0.1.44", "type": "module", "publishConfig": { "access": "public" diff --git a/packages/relay/package.json b/packages/relay/package.json index 2eb7feca6..93aa1c480 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.1.43", + "version": "0.1.44", "description": "Paseo relay for bridging daemon and client connections", "type": "module", "publishConfig": { diff --git a/packages/server/package.json b/packages/server/package.json index 37de5c142..cf4386ac1 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.1.43", + "version": "0.1.44", "description": "Paseo backend server", "type": "module", "publishConfig": { @@ -64,8 +64,8 @@ "@ai-sdk/openai": "2.0.52", "@anthropic-ai/claude-agent-sdk": "^0.2.11", "@deepgram/sdk": "^3.4.0", - "@getpaseo/highlight": "0.1.43", - "@getpaseo/relay": "0.1.43", + "@getpaseo/highlight": "0.1.44", + "@getpaseo/relay": "0.1.44", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.2.6", diff --git a/packages/website/package.json b/packages/website/package.json index 8f57d1fd3..137220f27 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.1.43", + "version": "0.1.44", "private": true, "type": "module", "scripts": { From 763cfa933377ccd795059694e7610eb8a633dfa0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 3 Apr 2026 09:56:55 +0000 Subject: [PATCH 11/73] fix: update lockfile signatures and Nix hash --- nix/package.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/package.nix b/nix/package.nix index f8d923e83..5b4bc0b0a 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -42,7 +42,7 @@ buildNpmPackage rec { # To update: run `nix build` with lib.fakeHash, copy the `got:` hash. # CI auto-updates this when package-lock.json changes (see .github/workflows/). - npmDepsHash = "sha256-v8ArSIil8F9dalo+Z+IKcYvGVVgDlLMsSJemI7HT14Q="; + npmDepsHash = "sha256-pS+g/Ty7+OqnC4SvVF8hsFPrUNa9+PQ7+eCaukmoRAU="; # Prevent onnxruntime-node's install script from running during automatic # npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox). From f924d33076ba926d342ab8a28118caad61d9597f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 3 Apr 2026 17:55:06 +0700 Subject: [PATCH 12/73] Fix bulk close archiving for stored agents --- packages/server/src/server/session.ts | 48 +++++- .../src/server/session.workspaces.test.ts | 162 ++++++++++++++++++ 2 files changed, 206 insertions(+), 4 deletions(-) diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index ebb36fe52..2e308b0a6 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -2003,17 +2003,53 @@ export class Session { }); } + private async archiveStoredAgentForClose( + agentId: string, + ): Promise<{ agentId: string; archivedAt: string }> { + const existing = await this.agentStorage.get(agentId); + if (!existing) { + throw new Error(`Agent not found: ${agentId}`); + } + + if (existing.archivedAt) { + return { + agentId, + archivedAt: existing.archivedAt, + }; + } + + const archivedAt = new Date().toISOString(); + const normalizedStatus = + existing.lastStatus === "running" || existing.lastStatus === "initializing" + ? "idle" + : existing.lastStatus; + + await this.agentStorage.upsert({ + ...existing, + archivedAt, + updatedAt: archivedAt, + lastStatus: normalizedStatus, + requiresAttention: false, + attentionReason: null, + attentionTimestamp: null, + }); + + return { agentId, archivedAt }; + } + private async archiveAgentForClose( agentId: string, ): Promise<{ agentId: string; archivedAt: string }> { this.sessionLogger.info({ agentId }, `Archiving agent ${agentId}`); - if (this.agentManager.getAgent(agentId)) { + const liveAgent = this.agentManager.getAgent(agentId); + if (liveAgent) { await this.interruptAgentIfRunning(agentId); await this.agentManager.clearAgentAttention(agentId).catch(() => undefined); + await this.agentManager.archiveAgent(agentId); + } else { + await this.archiveStoredAgentForClose(agentId); } - - const { archivedAt } = await this.agentManager.archiveAgent(agentId); const archivedRecord = await this.agentStorage.get(agentId); if (!archivedRecord) { throw new Error(`Agent not found in storage after archive: ${agentId}`); @@ -2043,7 +2079,11 @@ export class Session { await this.emitWorkspaceUpdateForCwd(payload.cwd); } - return { agentId, archivedAt }; + if (!archivedRecord.archivedAt) { + throw new Error(`Agent missing archivedAt after archive: ${agentId}`); + } + + return { agentId, archivedAt: archivedRecord.archivedAt }; } private async handleCloseItemsRequest(msg: CloseItemsRequest): Promise { diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 4f85436ca..04f3b739d 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -193,6 +193,9 @@ describe("workspace aggregation", () => { agentStorage: { list: async () => [archivedRecord], get: async (agentId: string) => (agentId === archivedRecord.id ? archivedRecord : null), + upsert: async (record: typeof archivedRecord) => { + Object.assign(archivedRecord, record); + }, } as any, projectRegistry: { initialize: async () => {}, @@ -428,6 +431,165 @@ describe("workspace aggregation", () => { }); }); + test("close_items_request archives stored agents that are not currently loaded", async () => { + const emitted: Array<{ type: string; payload: any }> = []; + const sessionLogger = { + child: () => sessionLogger, + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + const liveArchivedAt = "2026-04-01T00:00:00.000Z"; + const storedAgentId = "agent-stored"; + const liveRecord = { + ...makeAgent({ + id: "agent-live", + cwd: "/tmp/repo", + status: "idle", + updatedAt: "2026-03-01T12:00:00.000Z", + }), + archivedAt: null as string | null, + }; + const storedRecord = { + ...makeAgent({ + id: storedAgentId, + cwd: "/tmp/repo", + status: "idle", + updatedAt: "2026-03-01T12:05:00.000Z", + }), + archivedAt: null as string | null, + }; + const upsertStoredRecord = vi.fn(async (record: typeof storedRecord) => { + if (record.id === storedAgentId) { + storedRecord.archivedAt = record.archivedAt; + storedRecord.updatedAt = record.updatedAt; + storedRecord.status = record.status; + storedRecord.requiresAttention = record.requiresAttention; + storedRecord.attentionReason = record.attentionReason; + storedRecord.attentionTimestamp = record.attentionTimestamp; + } + }); + + const session = new Session({ + clientId: "test-client", + onMessage: (message) => emitted.push(message as any), + logger: sessionLogger as any, + downloadTokenStore: {} as any, + pushTokenStore: {} as any, + paseoHome: "/tmp/paseo-test", + agentManager: { + subscribe: () => () => {}, + listAgents: () => [], + getAgent: (agentId: string) => (agentId === "agent-live" ? { id: agentId } : null), + archiveAgent: async (agentId: string) => { + if (agentId !== "agent-live") { + throw new Error(`Unexpected live archive: ${agentId}`); + } + liveRecord.archivedAt = liveArchivedAt; + liveRecord.updatedAt = liveArchivedAt; + return { archivedAt: liveArchivedAt }; + }, + clearAgentAttention: async () => {}, + notifyAgentState: () => {}, + } as any, + agentStorage: { + list: async () => [], + get: async (agentId: string) => { + if (agentId === "agent-live") { + return liveRecord; + } + if (agentId === storedAgentId) { + return storedRecord; + } + return null; + }, + upsert: upsertStoredRecord, + } as any, + projectRegistry: { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => [], + get: async () => null, + upsert: async () => {}, + archive: async () => {}, + remove: async () => {}, + } as any, + workspaceRegistry: { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => [], + get: async () => null, + upsert: async () => {}, + archive: async () => {}, + remove: async () => {}, + } as any, + checkoutDiffManager: { + subscribe: async () => ({ + initial: { cwd: "/tmp", files: [], error: null }, + unsubscribe: () => {}, + }), + scheduleRefreshForCwd: () => {}, + getMetrics: () => ({ + checkoutDiffTargetCount: 0, + checkoutDiffSubscriptionCount: 0, + checkoutDiffWatcherCount: 0, + checkoutDiffFallbackRefreshTargetCount: 0, + }), + dispose: () => {}, + } as any, + createAgentMcpTransport: async () => { + throw new Error("not used"); + }, + stt: null, + tts: null, + terminalManager: { + killTerminal: vi.fn(), + subscribeTerminalsChanged: () => () => {}, + } as any, + }) as any; + + session.agentUpdatesSubscription = { + subscriptionId: "sub-agents", + filter: { includeArchived: true }, + isBootstrapping: false, + pendingUpdatesByAgentId: new Map(), + }; + session.buildProjectPlacement = async (cwd: string) => ({ + projectKey: cwd, + projectName: "repo", + checkout: { + cwd, + isGit: false, + currentBranch: null, + remoteUrl: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + }); + session.interruptAgentIfRunning = vi.fn(); + + await session.handleMessage({ + type: "close_items_request", + agentIds: ["agent-live", storedAgentId], + terminalIds: [], + requestId: "req-close-stored", + }); + + expect(upsertStoredRecord).toHaveBeenCalledTimes(1); + expect(storedRecord.archivedAt).toEqual(expect.any(String)); + expect(emitted.find((message) => message.type === "close_items_response")?.payload).toEqual({ + agents: [ + { agentId: "agent-live", archivedAt: liveArchivedAt }, + { agentId: storedAgentId, archivedAt: storedRecord.archivedAt }, + ], + terminals: [], + requestId: "req-close-stored", + }); + expect(sessionLogger.warn).not.toHaveBeenCalled(); + }); + test("close_items_request continues after an archive failure", async () => { const emitted: Array<{ type: string; payload: any }> = []; const sessionLogger = { From 4ca04fc6619d058122ee8ba169d42efedbaf40ca Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 3 Apr 2026 18:13:54 +0700 Subject: [PATCH 13/73] feat(desktop): add daemon status dialog to built-in daemon settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a "Full status" button in the desktop daemon section that runs `paseo daemon status` via Electron IPC and displays the raw output in a modal. Also fixes Commander.js argv parsing when the CLI is invoked via Electron's ELECTRON_RUN_AS_NODE — Commander auto-detects the Electron environment and skips only 1 argv element instead of 2, causing "unknown command" errors. Fixed by explicitly passing { from: "node" }. --- .../components/desktop-updates-section.tsx | 71 +++++++++++++++++++ .../app/src/desktop/daemon/desktop-daemon.ts | 8 +++ packages/cli/src/index.ts | 2 +- packages/desktop/src/daemon/daemon-manager.ts | 6 ++ packages/desktop/src/daemon/runtime-paths.ts | 20 ++++++ 5 files changed, 106 insertions(+), 1 deletion(-) diff --git a/packages/app/src/desktop/components/desktop-updates-section.tsx b/packages/app/src/desktop/components/desktop-updates-section.tsx index 86b385077..01fef73e9 100644 --- a/packages/app/src/desktop/components/desktop-updates-section.tsx +++ b/packages/app/src/desktop/components/desktop-updates-section.tsx @@ -14,6 +14,7 @@ import { Copy, FileText, Smartphone, + Activity, } from "lucide-react-native"; import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; import { Button } from "@/components/ui/button"; @@ -22,6 +23,7 @@ import { confirmDialog } from "@/utils/confirm-dialog"; import { openExternalUrl } from "@/utils/open-external-url"; import { getLocalDaemonVersion, isVersionMismatch } from "@/desktop/updates/desktop-updates"; import { + getCliDaemonStatus, getCliSymlinkInstructions, getDesktopDaemonLogs, getDesktopDaemonPairing, @@ -62,6 +64,9 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD const [cliSymlinkInstructions, setCliSymlinkInstructions] = useState(null); const [pairingStatusMessage, setPairingStatusMessage] = useState(null); + const [cliStatusOutput, setCliStatusOutput] = useState(null); + const [isCliStatusModalOpen, setIsCliStatusModalOpen] = useState(false); + const [isLoadingCliStatus, setIsLoadingCliStatus] = useState(false); const loadDaemonData = useCallback(() => { if (!showSection) { @@ -315,6 +320,33 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD }); }, [pairingOffer?.url]); + const handleOpenCliStatus = useCallback(async () => { + setIsLoadingCliStatus(true); + try { + setCliStatusOutput(await getCliDaemonStatus()); + setIsCliStatusModalOpen(true); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setCliStatusOutput(`Failed to fetch daemon status: ${message}`); + setIsCliStatusModalOpen(true); + } finally { + setIsLoadingCliStatus(false); + } + }, []); + + const handleCopyCliStatus = useCallback(() => { + if (!cliStatusOutput) { + return; + } + void Clipboard.setStringAsync(cliStatusOutput) + .then(() => { + Alert.alert("Copied", "Status copied to clipboard."); + }) + .catch((error) => { + console.error("[Settings] Failed to copy daemon status", error); + }); + }, [cliStatusOutput]); + if (!showSection) { return null; } @@ -458,6 +490,23 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD Pair device + + + Full status + + Runs `paseo daemon status` and shows the output. + + + + {daemonVersionMismatch ? ( @@ -524,6 +573,28 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD + + setIsCliStatusModalOpen(false)} + title="Daemon status" + testID="daemon-cli-status-dialog" + snapPoints={["60%", "85%"]} + > + + + {cliStatusOutput ?? ""} + + + + + + + ); } diff --git a/packages/app/src/desktop/daemon/desktop-daemon.ts b/packages/app/src/desktop/daemon/desktop-daemon.ts index 5dbca01d8..a88beca23 100644 --- a/packages/app/src/desktop/daemon/desktop-daemon.ts +++ b/packages/app/src/desktop/daemon/desktop-daemon.ts @@ -162,6 +162,14 @@ export async function getCliSymlinkInstructions(): Promise { + const raw = await invokeDesktopCommand("cli_daemon_status"); + if (typeof raw !== "string") { + throw new Error("Unexpected CLI daemon status response."); + } + return raw; +} + export type LocalTransportEventUnlisten = () => void; export type LocalTransportEventHandler = (payload: LocalTransportEventPayload) => void; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index e138e1435..c50882984 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -4,4 +4,4 @@ const program = createCli(); if (process.argv.length <= 2) { process.argv.push("onboard"); } -program.parse(); +program.parse(process.argv, { from: "node" }); diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index 4b8cb18dd..056ab5f4a 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -21,6 +21,7 @@ import { createNodeEntrypointInvocation, resolveDaemonRunnerEntrypoint, runCliJsonCommand, + runCliTextCommand, } from "./runtime-paths.js"; const DAEMON_LOG_FILENAME = "daemon.log"; @@ -398,6 +399,10 @@ function getDaemonLogs(): DesktopDaemonLogs { }; } +function getCliDaemonStatus(): string { + return runCliTextCommand(["daemon", "status"]); +} + async function getDaemonPairing(): Promise { const status = resolveStatus(); if (status.status !== "running") { @@ -521,6 +526,7 @@ export function createDaemonCommandHandlers(): Record getDaemonLogs(), desktop_daemon_pairing: () => getDaemonPairing(), cli_symlink_instructions: () => getCliSymlinkInstructions(), + cli_daemon_status: () => getCliDaemonStatus(), write_attachment_base64: (args) => writeAttachmentBase64(args ?? {}), copy_attachment_file: (args) => copyAttachmentFileToManagedStorage(args ?? {}), read_file_base64: (args) => readManagedFileBase64(args ?? {}), diff --git a/packages/desktop/src/daemon/runtime-paths.ts b/packages/desktop/src/daemon/runtime-paths.ts index e630bca06..e56f47bc2 100644 --- a/packages/desktop/src/daemon/runtime-paths.ts +++ b/packages/desktop/src/daemon/runtime-paths.ts @@ -246,6 +246,26 @@ export function runCliPassthroughCommand(args: string[]): number { return result.signal ? 1 : 0; } +export function runCliTextCommand(args: string[]): string { + const invocation = createCliInvocation(args); + const result = spawnSync(invocation.command, invocation.args, { + env: invocation.env, + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + }); + + if (result.error) { + throw result.error; + } + + if (result.status !== 0) { + const stderr = typeof result.stderr === "string" ? result.stderr.trim() : ""; + throw new Error(stderr.length > 0 ? stderr : `CLI command failed with exit code ${result.status}`); + } + + return typeof result.stdout === "string" ? result.stdout.trimEnd() : ""; +} + export function runCliJsonCommand(args: string[]): unknown { const invocation = createCliInvocation(args); const result = spawnSync(invocation.command, invocation.args, { From cacbbf4053f9a91f17cb2d1b75d9caf2cd8cf33b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 3 Apr 2026 18:49:28 +0700 Subject: [PATCH 14/73] fix(app): remove input event listener from scrollbar hook that raced with RNW controlled input The scrollbar hook's raw DOM `input` listener on the textarea called setState before React Native Web's delegated handler could process the same event, causing a re-render with the stale controlled value and swallowing the edit. Scroll and ResizeObserver already cover all scrollbar metric updates. --- packages/app/src/components/use-web-scrollbar.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/app/src/components/use-web-scrollbar.tsx b/packages/app/src/components/use-web-scrollbar.tsx index 5b4f95948..2e19032bf 100644 --- a/packages/app/src/components/use-web-scrollbar.tsx +++ b/packages/app/src/components/use-web-scrollbar.tsx @@ -77,7 +77,6 @@ export function useWebElementScrollbar( } element.addEventListener("scroll", update, { passive: true }); - element.addEventListener("input", update, { passive: true }); const resizeObserver = new ResizeObserver(update); resizeObserver.observe(element); @@ -90,7 +89,6 @@ export function useWebElementScrollbar( return () => { element.removeEventListener("scroll", update); - element.removeEventListener("input", update); resizeObserver.disconnect(); element.removeAttribute("data-hide-scrollbar"); (element.style as any).scrollbarWidth = ""; From 1e64dd550983596d8db518ec1364ff30d89ae35a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 3 Apr 2026 18:58:10 +0700 Subject: [PATCH 15/73] fix(app): add Ctrl+C/V clipboard support for Windows/Linux terminal On non-Mac platforms, Ctrl+C now copies selected text to clipboard (falling through to SIGINT when nothing is selected) and Ctrl+V pastes from clipboard into the terminal. --- .../runtime/terminal-emulator-runtime.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts index 8dae88359..f4a6df21c 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts @@ -286,6 +286,25 @@ export class TerminalEmulatorRuntime { } if (!isMac && event.ctrlKey && !event.shiftKey && !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 navigator.clipboard.writeText(terminal.getSelection()); + return false; + } + + // Ctrl+V: paste from clipboard into terminal + if (key === "v") { + event.preventDefault(); + void navigator.clipboard.readText().then((text) => { + if (text) { + terminal.paste(text); + } + }); + return false; + } + return true; } From 23ffec50723613e64cad8e588302c41018f2d5b7 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 3 Apr 2026 19:02:18 +0700 Subject: [PATCH 16/73] fix(app): prevent tab pruning from closing pinned archived agents The tab sync effect pruned archived agent tabs without checking if they were pinned, so clicking an archived agent from the sessions page would open and immediately close the tab. --- packages/app/src/screens/workspace/workspace-screen.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 3e5722964..e9b402145 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -983,6 +983,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) if ( canPruneAgentTabs && tab.target.kind === "agent" && + !pinnedAgentIds.has(tab.target.agentId) && shouldPruneWorkspaceAgentTab({ agentId: tab.target.agentId, agentsHydrated: hasHydratedAgents, From 29037abd541acf0fca33d3aa306977c89b5d8511 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 3 Apr 2026 19:43:56 +0700 Subject: [PATCH 17/73] feat(desktop): auto-restart desktop-managed daemon on version mismatch When the desktop app starts and finds a running daemon with a different version, it now restarts it automatically. Only applies to daemons the desktop app itself started, identified by a PASEO_DESKTOP_MANAGED flag written into the PID lock file. Also rewrites resolveStatus() to use the CLI daemon status command instead of manually parsing the PID file, and surfaces daemonVersion in the CLI status output from the WebSocket hello message. --- .../components/desktop-updates-section.tsx | 8 +- .../app/src/desktop/daemon/desktop-daemon.ts | 4 + .../cli/src/commands/daemon/local-daemon.ts | 2 + packages/cli/src/commands/daemon/status.ts | 7 + packages/desktop/src/daemon/daemon-manager.ts | 186 ++++++------------ packages/server/src/server/pid-lock.ts | 2 + 6 files changed, 81 insertions(+), 128 deletions(-) diff --git a/packages/app/src/desktop/components/desktop-updates-section.tsx b/packages/app/src/desktop/components/desktop-updates-section.tsx index 01fef73e9..c0678edcd 100644 --- a/packages/app/src/desktop/components/desktop-updates-section.tsx +++ b/packages/app/src/desktop/components/desktop-updates-section.tsx @@ -21,7 +21,7 @@ import { Button } from "@/components/ui/button"; import { useAppSettings } from "@/hooks/use-settings"; import { confirmDialog } from "@/utils/confirm-dialog"; import { openExternalUrl } from "@/utils/open-external-url"; -import { getLocalDaemonVersion, isVersionMismatch } from "@/desktop/updates/desktop-updates"; +import { isVersionMismatch } from "@/desktop/updates/desktop-updates"; import { getCliDaemonStatus, getCliSymlinkInstructions, @@ -72,11 +72,11 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD if (!showSection) { return Promise.resolve(); } - return Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs(), getLocalDaemonVersion()]) - .then(([status, logs, version]) => { + return Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()]) + .then(([status, logs]) => { setDaemonStatus(status); setDaemonLogs(logs); - setDaemonVersion(version.version); + setDaemonVersion(status.version); setStatusError(null); }) .catch((error) => { diff --git a/packages/app/src/desktop/daemon/desktop-daemon.ts b/packages/app/src/desktop/daemon/desktop-daemon.ts index a88beca23..5fcc61a59 100644 --- a/packages/app/src/desktop/daemon/desktop-daemon.ts +++ b/packages/app/src/desktop/daemon/desktop-daemon.ts @@ -10,6 +10,8 @@ export type DesktopDaemonStatus = { hostname: string | null; pid: number | null; home: string; + version: string | null; + desktopManaged: boolean; error: string | null; }; @@ -86,6 +88,8 @@ function parseDesktopDaemonStatus(raw: unknown): DesktopDaemonStatus { hostname: toStringOrNull(raw.hostname), pid: toNumberOrNull(raw.pid), home: toStringOrNull(raw.home) ?? "", + version: toStringOrNull(raw.version), + desktopManaged: raw.desktopManaged === true, error: toStringOrNull(raw.error), }; } diff --git a/packages/cli/src/commands/daemon/local-daemon.ts b/packages/cli/src/commands/daemon/local-daemon.ts index c2e39e513..957ca64ce 100644 --- a/packages/cli/src/commands/daemon/local-daemon.ts +++ b/packages/cli/src/commands/daemon/local-daemon.ts @@ -21,6 +21,7 @@ export interface LocalDaemonPidInfo { hostname?: string; uid?: number; listen?: string; + desktopManaged?: boolean; } export interface LocalDaemonState { @@ -173,6 +174,7 @@ function readPidFile(pidPath: string): LocalDaemonPidInfo | null { : typeof parsed.sockPath === "string" ? parsed.sockPath : undefined, + desktopManaged: parsed.desktopManaged === true ? true : undefined, }; } catch { return null; diff --git a/packages/cli/src/commands/daemon/status.ts b/packages/cli/src/commands/daemon/status.ts index d37194100..1029b53d3 100644 --- a/packages/cli/src/commands/daemon/status.ts +++ b/packages/cli/src/commands/daemon/status.ts @@ -34,6 +34,8 @@ interface DaemonStatus { daemonNode: string; cliNode: string; cliVersion: string; + daemonVersion: string | null; + desktopManaged: boolean; providers: ProviderBinaryStatus[]; note?: string; } @@ -129,6 +131,7 @@ function toStatusRows(status: DaemonStatus): StatusRow[] { { key: "Daemon Node", value: status.daemonNode }, { key: "CLI Node", value: status.cliNode }, { key: "CLI", value: status.cliVersion }, + { key: "Daemon Version", value: status.daemonVersion ?? "-" }, ]; if (status.runningAgents !== null && status.idleAgents !== null) { @@ -229,6 +232,7 @@ export async function runStatusCommand( let connectedDaemon: DaemonStatus["connectedDaemon"] = "not_probed"; let runningAgents: number | null = null; let idleAgents: number | null = null; + let daemonVersion: string | null = null; let note: string | undefined; if (!state.running && state.stalePidFile && state.pidInfo) { @@ -240,6 +244,7 @@ export async function runStatusCommand( const client = await tryConnectToDaemon({ host, timeout: 1500 }); if (client) { connectedDaemon = "reachable"; + daemonVersion = client.getLastServerInfoMessage()?.version ?? null; try { const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } }); const agents = agentsPayload.entries.map((entry) => entry.agent); @@ -308,6 +313,8 @@ export async function runStatusCommand( daemonNode, cliNode, cliVersion, + daemonVersion, + desktopManaged: state.pidInfo?.desktopManaged === true, providers, note, }; diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index 056ab5f4a..98e48f4ea 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -1,9 +1,9 @@ import { spawn, type ChildProcess } from "node:child_process"; -import { existsSync, readFileSync } from "node:fs"; +import { readFileSync } from "node:fs"; import path from "node:path"; import { app, ipcMain } from "electron"; import log from "electron-log/main"; -import { resolvePaseoHome, getOrCreateServerId } from "@getpaseo/server"; +import { resolvePaseoHome } from "@getpaseo/server"; import { copyAttachmentFileToManagedStorage, deleteManagedAttachmentFile, @@ -25,7 +25,6 @@ import { } from "./runtime-paths.js"; const DAEMON_LOG_FILENAME = "daemon.log"; -const DAEMON_PID_FILENAME = "paseo.pid"; const PID_POLL_INTERVAL_MS = 100; const STARTUP_POLL_INTERVAL_MS = 200; const STARTUP_POLL_MAX_ATTEMPTS = 150; @@ -42,6 +41,8 @@ type DesktopDaemonStatus = { hostname: string | null; pid: number | null; home: string; + version: string | null; + desktopManaged: boolean; error: string | null; }; @@ -72,10 +73,6 @@ function getPaseoHome(): string { return resolvePaseoHome(process.env); } -function pidFilePath(): string { - return path.join(getPaseoHome(), DAEMON_PID_FILENAME); -} - function logFilePath(): string { return path.join(getPaseoHome(), DAEMON_LOG_FILENAME); } @@ -165,41 +162,6 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -function resolveTcpHostFromListen(listen: string): string | null { - const normalized = listen.trim(); - if (!normalized) { - return null; - } - - if ( - normalized.startsWith("/") || - normalized.startsWith("unix://") || - normalized.startsWith("pipe://") || - normalized.startsWith("\\\\.\\pipe\\") || - /^[A-Za-z]:[/\\]/.test(normalized) - ) { - return null; - } - - if (/^\d+$/.test(normalized)) { - return `127.0.0.1:${normalized}`; - } - - if (normalized.includes(":")) { - return normalized; - } - - return null; -} - -function buildDaemonHttpBaseUrl(listen: string): string | null { - const endpoint = resolveTcpHostFromListen(listen); - if (!endpoint) { - return null; - } - return new URL(`http://${endpoint}`).toString().replace(/\/$/, ""); -} - function resolveDesktopAppVersion(): string { if (app.isPackaged) { return app.getVersion(); @@ -226,55 +188,64 @@ function resolveDesktopAppVersion(): string { function resolveStatus(): DesktopDaemonStatus { const home = getPaseoHome(); - const pidPath = pidFilePath(); - - let pid: number | null = null; - let hostname: string | null = null; - let listen: string | null = null; try { - if (existsSync(pidPath)) { - const parsed = JSON.parse(readFileSync(pidPath, "utf-8")) as Record; - const pidValue = parsed.pid; - if (typeof pidValue === "number" && Number.isInteger(pidValue) && pidValue > 0) { - pid = pidValue; - hostname = typeof parsed.hostname === "string" ? parsed.hostname : null; - const pidListen = - typeof parsed.listen === "string" - ? parsed.listen - : typeof parsed.sockPath === "string" - ? (parsed.sockPath as string) - : null; - listen = pidListen; - } - } - } catch { - // PID file missing or malformed — treat as stopped. + const payload = runCliJsonCommand(["daemon", "status", "--json"]) as Record; + const localDaemon = typeof payload.localDaemon === "string" ? payload.localDaemon : "stopped"; + const running = localDaemon === "running"; + + return { + serverId: typeof payload.serverId === "string" ? payload.serverId : "", + status: running ? "running" : "stopped", + listen: typeof payload.listen === "string" ? payload.listen : null, + hostname: running && typeof payload.hostname === "string" ? payload.hostname : null, + pid: running && typeof payload.pid === "number" ? payload.pid : null, + home, + version: typeof payload.daemonVersion === "string" ? payload.daemonVersion : null, + desktopManaged: payload.desktopManaged === true, + error: null, + }; + } catch (error) { + return { + serverId: "", + status: "stopped", + listen: null, + hostname: null, + pid: null, + home, + version: null, + desktopManaged: false, + error: error instanceof Error ? error.message : String(error), + }; } +} - const running = pid !== null && isProcessRunning(pid); - - let serverId = ""; - try { - serverId = getOrCreateServerId(home); - } catch { - // Ignore — server-id may not exist yet. - } - - return { - serverId, - status: running ? "running" : "stopped", - listen, - hostname: running ? hostname : null, - pid: running ? pid : null, - home, - error: null, - }; +function normalizeVersion(version: string | null): string | null { + const trimmed = version?.trim(); + if (!trimmed) return null; + return trimmed.replace(/^v/i, ""); } async function startDaemon(): Promise { const current = resolveStatus(); - if (current.status === "running") return current; + if (current.status === "running") { + const appVersion = normalizeVersion(resolveDesktopAppVersion()); + const daemonVersion = normalizeVersion(current.version); + if ( + current.desktopManaged && + appVersion && + daemonVersion && + appVersion !== daemonVersion + ) { + logDesktopDaemonLifecycle("daemon version mismatch, restarting", { + appVersion, + daemonVersion, + }); + await stopDaemon(); + } else { + return current; + } + } const daemonRunner = resolveDaemonRunnerEntrypoint(); const invocation = createNodeEntrypointInvocation({ @@ -297,7 +268,7 @@ async function startDaemon(): Promise { invocation.args, { detached: true, - env: invocation.env, + env: { ...invocation.env, PASEO_DESKTOP_MANAGED: "1" }, stdio: ["ignore", "ignore", "ignore"], }, ); @@ -433,51 +404,18 @@ async function getDaemonPairing(): Promise { } } -async function getLocalDaemonVersion(): Promise<{ - version: string | null; - error: string | null; -}> { +function getLocalDaemonVersion(): { version: string | null; error: string | null } { const status = resolveStatus(); if (status.status !== "running") { - return { - version: null, - error: "Daemon is not running.", - }; - } - - if (!status.listen) { - return { version: null, error: "Daemon listen target is unavailable." }; - } - - const baseUrl = buildDaemonHttpBaseUrl(status.listen); - if (!baseUrl) { - return { version: null, error: `Daemon listen target is not a TCP endpoint: ${status.listen}` }; - } - - try { - const response = await fetch(`${baseUrl}/api/status`); - if (!response.ok) { - return { version: null, error: `Daemon status request failed with ${response.status}` }; - } - const payload = (await response.json()) as Record; - const version = typeof payload.version === "string" ? payload.version.trim() : null; - return { - version: version && version.length > 0 ? version : null, - error: version ? null : "Running daemon did not report a version.", - }; - } catch (error) { - return { - version: null, - error: error instanceof Error ? error.message : String(error), - }; + return { version: null, error: "Daemon is not running." }; } + return { + version: status.version, + error: status.version ? null : "Running daemon did not report a version.", + }; } -async function resolveCurrentUpdateVersion(): Promise { - const daemonVersion = await getLocalDaemonVersion(); - if (daemonVersion.version) { - return daemonVersion.version; - } +function resolveCurrentUpdateVersion(): string { return resolveDesktopAppVersion(); } diff --git a/packages/server/src/server/pid-lock.ts b/packages/server/src/server/pid-lock.ts index 43563a9da..286ee1218 100644 --- a/packages/server/src/server/pid-lock.ts +++ b/packages/server/src/server/pid-lock.ts @@ -9,6 +9,7 @@ export interface PidLockInfo { hostname: string; uid: number; listen: string | null; + desktopManaged?: boolean; } export class PidLockError extends Error { @@ -86,6 +87,7 @@ export async function acquirePidLock( hostname: hostname(), uid: process.getuid?.() ?? 0, listen, + ...(process.env.PASEO_DESKTOP_MANAGED === "1" ? { desktopManaged: true } : {}), }; let fd; From 9476b3fc1d32b2aeec30f2e85399ba8839d7d025 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 3 Apr 2026 19:59:54 +0700 Subject: [PATCH 18/73] chore(release): cut 0.1.45-rc.1 --- package-lock.json | 38 ++++++++++++------------ package.json | 2 +- packages/app/package.json | 8 ++--- packages/cli/package.json | 6 ++-- packages/desktop/package.json | 6 ++-- packages/expo-two-way-audio/package.json | 2 +- packages/highlight/package.json | 2 +- packages/relay/package.json | 2 +- packages/server/package.json | 6 ++-- packages/website/package.json | 2 +- 10 files changed, 37 insertions(+), 37 deletions(-) diff --git a/package-lock.json b/package-lock.json index ea50e3cb7..8cda69529 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.1.44", + "version": "0.1.45-rc.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.1.44", + "version": "0.1.45-rc.1", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "workspaces": [ @@ -34996,16 +34996,16 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.1.44", + "version": "0.1.45-rc.1", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@expo/vector-icons": "^15.0.2", "@floating-ui/react-native": "^0.10.7", - "@getpaseo/expo-two-way-audio": "0.1.44", - "@getpaseo/highlight": "0.1.44", - "@getpaseo/server": "0.1.44", + "@getpaseo/expo-two-way-audio": "0.1.45-rc.1", + "@getpaseo/highlight": "0.1.45-rc.1", + "@getpaseo/server": "0.1.45-rc.1", "@gorhom/bottom-sheet": "^5.2.6", "@gorhom/portal": "^1.0.14", "@react-native-async-storage/async-storage": "2.2.0", @@ -35122,11 +35122,11 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.1.44", + "version": "0.1.45-rc.1", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/relay": "0.1.44", - "@getpaseo/server": "0.1.44", + "@getpaseo/relay": "0.1.45-rc.1", + "@getpaseo/server": "0.1.45-rc.1", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", @@ -35167,11 +35167,11 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.1.44", + "version": "0.1.45-rc.1", "license": "AGPL-3.0-or-later", "dependencies": { - "@getpaseo/cli": "0.1.44", - "@getpaseo/server": "0.1.44", + "@getpaseo/cli": "0.1.45-rc.1", + "@getpaseo/server": "0.1.45-rc.1", "electron-log": "^5.4.3", "electron-updater": "^6.6.2", "ws": "^8.14.2" @@ -35205,7 +35205,7 @@ }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", - "version": "0.1.44", + "version": "0.1.45-rc.1", "license": "MIT", "devDependencies": { "@biomejs/biome": "1.9.4", @@ -35406,7 +35406,7 @@ }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.1.44", + "version": "0.1.45-rc.1", "dependencies": { "@lezer/common": "^1.5.0", "@lezer/cpp": "^1.1.5", @@ -35432,7 +35432,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.1.44", + "version": "0.1.45-rc.1", "dependencies": { "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", @@ -35448,14 +35448,14 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.1.44", + "version": "0.1.45-rc.1", "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", "@ai-sdk/openai": "2.0.52", "@anthropic-ai/claude-agent-sdk": "^0.2.11", "@deepgram/sdk": "^3.4.0", - "@getpaseo/highlight": "0.1.44", - "@getpaseo/relay": "0.1.44", + "@getpaseo/highlight": "0.1.45-rc.1", + "@getpaseo/relay": "0.1.45-rc.1", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.2.6", @@ -35854,7 +35854,7 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.1.44", + "version": "0.1.45-rc.1", "dependencies": { "@cloudflare/vite-plugin": "^1.20.3", "@cloudflare/workers-types": "^4.20260114.0", diff --git a/package.json b/package.json index fe2b6679f..bb5c27fa4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.1.44", + "version": "0.1.45-rc.1", "private": true, "workspaces": [ "packages/expo-two-way-audio", diff --git a/packages/app/package.json b/packages/app/package.json index 13aa945d5..6bf6147fa 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,7 +1,7 @@ { "name": "@getpaseo/app", "main": "index.ts", - "version": "0.1.44", + "version": "0.1.45-rc.1", "private": true, "scripts": { "start": "expo start", @@ -31,9 +31,9 @@ "@dnd-kit/utilities": "^3.2.2", "@expo/vector-icons": "^15.0.2", "@floating-ui/react-native": "^0.10.7", - "@getpaseo/expo-two-way-audio": "0.1.44", - "@getpaseo/highlight": "0.1.44", - "@getpaseo/server": "0.1.44", + "@getpaseo/expo-two-way-audio": "0.1.45-rc.1", + "@getpaseo/highlight": "0.1.45-rc.1", + "@getpaseo/server": "0.1.45-rc.1", "@gorhom/bottom-sheet": "^5.2.6", "@gorhom/portal": "^1.0.14", "@react-native-async-storage/async-storage": "2.2.0", diff --git a/packages/cli/package.json b/packages/cli/package.json index c7a978af5..aeecfdd12 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.1.44", + "version": "0.1.45-rc.1", "description": "Paseo CLI - control your AI coding agents from the command line", "type": "module", "files": [ @@ -24,8 +24,8 @@ }, "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/relay": "0.1.44", - "@getpaseo/server": "0.1.44", + "@getpaseo/relay": "0.1.45-rc.1", + "@getpaseo/server": "0.1.45-rc.1", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 92d300f65..e3227fdc9 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.1.44", + "version": "0.1.45-rc.1", "private": true, "description": "Paseo desktop app (Electron wrapper)", "main": "dist/main.js", @@ -12,8 +12,8 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { - "@getpaseo/cli": "0.1.44", - "@getpaseo/server": "0.1.44", + "@getpaseo/cli": "0.1.45-rc.1", + "@getpaseo/server": "0.1.45-rc.1", "electron-log": "^5.4.3", "electron-updater": "^6.6.2", "ws": "^8.14.2" diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index 2f65df5a1..0004d735c 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.1.44", + "version": "0.1.45-rc.1", "description": "Native module for two way audio streaming", "main": "build/index.js", "types": "build/index.d.ts", diff --git a/packages/highlight/package.json b/packages/highlight/package.json index 313b46724..5e3a57c88 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.1.44", + "version": "0.1.45-rc.1", "type": "module", "publishConfig": { "access": "public" diff --git a/packages/relay/package.json b/packages/relay/package.json index 93aa1c480..a7b3773e8 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.1.44", + "version": "0.1.45-rc.1", "description": "Paseo relay for bridging daemon and client connections", "type": "module", "publishConfig": { diff --git a/packages/server/package.json b/packages/server/package.json index cf4386ac1..2129c59c0 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.1.44", + "version": "0.1.45-rc.1", "description": "Paseo backend server", "type": "module", "publishConfig": { @@ -64,8 +64,8 @@ "@ai-sdk/openai": "2.0.52", "@anthropic-ai/claude-agent-sdk": "^0.2.11", "@deepgram/sdk": "^3.4.0", - "@getpaseo/highlight": "0.1.44", - "@getpaseo/relay": "0.1.44", + "@getpaseo/highlight": "0.1.45-rc.1", + "@getpaseo/relay": "0.1.45-rc.1", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.2.6", diff --git a/packages/website/package.json b/packages/website/package.json index 137220f27..8c8c6ba2e 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.1.44", + "version": "0.1.45-rc.1", "private": true, "type": "module", "scripts": { From 49402b854fb40edb8f0dca93b33a31c377582951 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 3 Apr 2026 13:00:59 +0000 Subject: [PATCH 19/73] fix: update lockfile signatures and Nix hash --- nix/package.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/package.nix b/nix/package.nix index 5b4bc0b0a..4b027ad3a 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -42,7 +42,7 @@ buildNpmPackage rec { # To update: run `nix build` with lib.fakeHash, copy the `got:` hash. # CI auto-updates this when package-lock.json changes (see .github/workflows/). - npmDepsHash = "sha256-pS+g/Ty7+OqnC4SvVF8hsFPrUNa9+PQ7+eCaukmoRAU="; + npmDepsHash = "sha256-h27d75Y6ZR7Mq8n/iju7h3BdRkgfbUI4yyVl2WFRYik="; # Prevent onnxruntime-node's install script from running during automatic # npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox). From 1ae2f9280e2e3fba9c4b73f1469e9cabab30d395 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 3 Apr 2026 20:41:12 +0700 Subject: [PATCH 20/73] fix(website): fetch release version at runtime with asset validation Move release version resolution from build-time (vite.config.ts) to runtime (server function on Cloudflare Workers). The server function fetches GitHub releases, filters out prereleases/drafts, and only returns a version that has all required assets (Mac DMG, Linux AppImage, Windows exe). Uses Cloudflare's cf.cacheEverything with a 60s TTL to avoid hitting GitHub on every request. This fixes a race condition where the website would deploy and show download links for a new release before the desktop build assets were actually uploaded, resulting in 404s for users. --- .../website/src/components/landing-page.tsx | 6 +- packages/website/src/downloads.tsx | 80 ++++++++++--------- packages/website/src/release.ts | 69 ++++++++++++++++ packages/website/src/routes/__root.tsx | 24 +++++- packages/website/src/routes/download.tsx | 28 +++---- packages/website/vite.config.ts | 21 +---- 6 files changed, 152 insertions(+), 76 deletions(-) create mode 100644 packages/website/src/release.ts diff --git a/packages/website/src/components/landing-page.tsx b/packages/website/src/components/landing-page.tsx index 6d2b21ffe..967601ea7 100644 --- a/packages/website/src/components/landing-page.tsx +++ b/packages/website/src/components/landing-page.tsx @@ -6,13 +6,14 @@ import { appStoreUrl, playStoreUrl, webAppUrl, - downloadOptions, + getDownloadOptions, useDetectedPlatform, AppleIcon, AndroidIcon, TerminalIcon, GlobeIcon, } from "~/downloads"; +import { useRelease } from "~/routes/__root"; import { Mic } from "lucide-react"; import { HeroMockup } from "~/components/hero-mockup"; import { ClaudeIcon } from "~/components/mockup"; @@ -772,8 +773,9 @@ function GetStarted() { } function DownloadButton() { + const { version } = useRelease(); const detectedPlatform = useDetectedPlatform(); - const primary = downloadOptions.find((o) => o.platform === detectedPlatform)!; + const primary = getDownloadOptions(version).find((o) => o.platform === detectedPlatform)!; const PrimaryIcon = primary.icon; return ( diff --git a/packages/website/src/downloads.tsx b/packages/website/src/downloads.tsx index 379cb8590..aae15a88d 100644 --- a/packages/website/src/downloads.tsx +++ b/packages/website/src/downloads.tsx @@ -1,16 +1,21 @@ import * as React from "react"; -import websitePackage from "../package.json"; -declare const __DESKTOP_VERSION__: string | undefined; +export function releaseBase(version: string) { + return `https://github.com/getpaseo/paseo/releases/download/v${version}`; +} -export const desktopVersion = - typeof __DESKTOP_VERSION__ !== "undefined" ? __DESKTOP_VERSION__ : websitePackage.version; -export const releaseBase = `https://github.com/getpaseo/paseo/releases/download/v${desktopVersion}`; -export const macAppleSiliconDownloadUrl = `${releaseBase}/Paseo-${desktopVersion}-arm64.dmg`; -export const macIntelDownloadUrl = `${releaseBase}/Paseo-${desktopVersion}-x64.dmg`; -export const linuxAppImageDownloadUrl = `${releaseBase}/Paseo-${desktopVersion}-x86_64.AppImage`; -export const linuxDebDownloadUrl = `${releaseBase}/Paseo-${desktopVersion}-amd64.deb`; -export const linuxRpmDownloadUrl = `${releaseBase}/Paseo-${desktopVersion}-x86_64.rpm`; +export function downloadUrls(version: string) { + const base = releaseBase(version); + return { + macAppleSilicon: `${base}/Paseo-${version}-arm64.dmg`, + macIntel: `${base}/Paseo-${version}-x64.dmg`, + linuxAppImage: `${base}/Paseo-${version}-x86_64.AppImage`, + linuxDeb: `${base}/Paseo-${version}-amd64.deb`, + linuxRpm: `${base}/Paseo-${version}-x86_64.rpm`, + windowsExe: `${base}/Paseo-Setup-${version}.exe`, + androidApk: `${base}/paseo-v${version}-android.apk`, + }; +} export const appStoreUrl = "https://apps.apple.com/app/paseo-pocket-engineer/id6758887924"; export const playStoreUrl = "https://play.google.com/store/apps/details?id=sh.paseo"; @@ -25,32 +30,35 @@ export interface DownloadOption { icon: (props: React.SVGProps) => React.ReactElement; } -export const downloadOptions: DownloadOption[] = [ - { - platform: "mac-silicon", - label: "Mac", - href: macAppleSiliconDownloadUrl, - icon: AppleIcon, - }, - { - platform: "mac-intel", - label: "Mac Intel", - href: macIntelDownloadUrl, - icon: AppleIcon, - }, - { - platform: "windows", - label: "Windows", - href: `${releaseBase}/Paseo-Setup-${desktopVersion}.exe`, - icon: WindowsIcon, - }, - { - platform: "linux", - label: "Linux", - href: linuxAppImageDownloadUrl, - icon: LinuxIcon, - }, -]; +export function getDownloadOptions(version: string): DownloadOption[] { + const urls = downloadUrls(version); + return [ + { + platform: "mac-silicon", + label: "Mac", + href: urls.macAppleSilicon, + icon: AppleIcon, + }, + { + platform: "mac-intel", + label: "Mac Intel", + href: urls.macIntel, + icon: AppleIcon, + }, + { + platform: "windows", + label: "Windows", + href: urls.windowsExe, + icon: WindowsIcon, + }, + { + platform: "linux", + label: "Linux", + href: urls.linuxAppImage, + icon: LinuxIcon, + }, + ]; +} export function useDetectedPlatform(): Platform { const [platform, setPlatform] = React.useState("mac-silicon"); diff --git a/packages/website/src/release.ts b/packages/website/src/release.ts new file mode 100644 index 000000000..ac925cd5c --- /dev/null +++ b/packages/website/src/release.ts @@ -0,0 +1,69 @@ +import { createServerFn } from "@tanstack/react-start"; +import websitePackage from "../package.json"; + +interface GitHubAsset { + name: string; +} + +interface GitHubRelease { + tag_name: string; + assets: GitHubAsset[]; + prerelease: boolean; + draft: boolean; +} + +const REQUIRED_ASSET_PATTERNS = [ + /Paseo-.*-arm64\.dmg$/, // Mac Apple Silicon + /Paseo-.*-x86_64\.AppImage$/, // Linux AppImage + /Paseo-Setup-.*\.exe$/, // Windows +]; + +function hasRequiredAssets(release: GitHubRelease): boolean { + return REQUIRED_ASSET_PATTERNS.every((pattern) => + release.assets.some((asset) => pattern.test(asset.name)), + ); +} + +function versionFromTag(tag: string): string { + return tag.replace(/^v/, ""); +} + +const GITHUB_RELEASES_URL = + "https://api.github.com/repos/getpaseo/paseo/releases?per_page=10"; + +async function fetchLatestReadyRelease(): Promise { + const fallback = websitePackage.version.replace(/-.*$/, ""); + + try { + const res = await fetch(GITHUB_RELEASES_URL, { + headers: { + Accept: "application/vnd.github+json", + "User-Agent": "paseo-website", + }, + // Cloudflare Workers: cache the upstream response with stale-while-revalidate. + // Fresh for 60s, serve stale for up to 300s while revalidating in background. + // In non-Workers environments this is ignored. + cf: { + cacheEverything: true, + cacheTtl: 60, + cacheKey: "github-releases-latest", + }, + } as RequestInit); + if (!res.ok) return fallback; + + const releases = (await res.json()) as GitHubRelease[]; + const ready = releases.find( + (r) => !r.prerelease && !r.draft && hasRequiredAssets(r), + ); + return ready ? versionFromTag(ready.tag_name) : fallback; + } catch { + return fallback; + } +} + +export const getLatestRelease = createServerFn({ method: "GET" }).handler( + async () => { + const version = await fetchLatestReadyRelease(); + return { version }; + }, +); diff --git a/packages/website/src/routes/__root.tsx b/packages/website/src/routes/__root.tsx index fe7db8cb2..e887ac222 100644 --- a/packages/website/src/routes/__root.tsx +++ b/packages/website/src/routes/__root.tsx @@ -1,7 +1,22 @@ import type { ReactNode } from "react"; +import { createContext, useContext } from "react"; import { Outlet, createRootRoute, HeadContent, Scripts } from "@tanstack/react-router"; +import { getLatestRelease } from "~/release"; + +interface ReleaseContext { + version: string; +} + +const ReleaseCtx = createContext({ version: "" }); + +export function useRelease(): ReleaseContext { + return useContext(ReleaseCtx); +} export const Route = createRootRoute({ + loader: async () => { + return getLatestRelease(); + }, head: () => ({ meta: [ { charSet: "utf-8" }, @@ -23,10 +38,13 @@ export const Route = createRootRoute({ }); function RootComponent() { + const release = Route.useLoaderData(); return ( - - - + + + + + ); } diff --git a/packages/website/src/routes/download.tsx b/packages/website/src/routes/download.tsx index aebd3fff9..e28acbed2 100644 --- a/packages/website/src/routes/download.tsx +++ b/packages/website/src/routes/download.tsx @@ -2,13 +2,7 @@ import { Link, createFileRoute } from "@tanstack/react-router"; import { CommandDialog } from "~/components/command-dialog"; import { pageMeta } from "~/meta"; import { - desktopVersion, - linuxAppImageDownloadUrl, - linuxDebDownloadUrl, - linuxRpmDownloadUrl, - releaseBase, - macAppleSiliconDownloadUrl, - macIntelDownloadUrl, + downloadUrls, appStoreUrl, playStoreUrl, webAppUrl, @@ -19,6 +13,7 @@ import { TerminalIcon, GlobeIcon, } from "~/downloads"; +import { useRelease } from "~/routes/__root"; import "~/styles.css"; export const Route = createFileRoute("/download")({ @@ -32,6 +27,9 @@ export const Route = createFileRoute("/download")({ }); function Download() { + const { version } = useRelease(); + const urls = downloadUrls(version); + return (

@@ -93,7 +91,7 @@ function Download() {

Download

- v{desktopVersion} + v{version}

{/* Desktop */} @@ -111,8 +109,8 @@ function Download() { macOS
- - + + @@ -133,7 +131,7 @@ function Download() {
@@ -147,11 +145,11 @@ function Download() {
- - + +
@@ -178,7 +176,7 @@ function Download() { external /> diff --git a/packages/website/vite.config.ts b/packages/website/vite.config.ts index 3ff066e67..648e5e533 100644 --- a/packages/website/vite.config.ts +++ b/packages/website/vite.config.ts @@ -28,27 +28,8 @@ const sitemapPages = [ path: routePath, })); -async function fetchLatestReleaseVersion(): Promise { - try { - const res = await fetch( - "https://api.github.com/repos/getpaseo/paseo/releases/latest", - { headers: { Accept: "application/vnd.github+json" }, signal: AbortSignal.timeout(5000) }, - ); - if (!res.ok) return undefined; - const data = (await res.json()) as { tag_name: string }; - return data.tag_name.replace(/^v/, ""); - } catch { - return undefined; - } -} - -export default defineConfig(async (): Promise => { - const latestVersion = await fetchLatestReleaseVersion(); - +export default defineConfig((): UserConfig => { return { - define: { - ...(latestVersion && { __DESKTOP_VERSION__: JSON.stringify(latestVersion) }), - }, server: { port: 8082, fs: { From 176942bbf1a319b9841d20934228e6dd87c1e607 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 3 Apr 2026 21:33:05 +0700 Subject: [PATCH 21/73] fix(sidebar): persist projects/workspaces and add remove project menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop auto-archiving workspaces when all their agents are archived — only archive when the directory no longer exists on disk. This prevents projects from disappearing when users archive their last agent. Add a kebab dropdown menu to all project rows (desktop) with a "Remove project" action that archives all workspaces in the project. --- .../src/components/sidebar-workspace-list.tsx | 318 +++++++----------- packages/server/src/server/session.ts | 4 - .../server/workspace-registry-model.test.ts | 27 +- .../src/server/workspace-registry-model.ts | 23 -- 4 files changed, 132 insertions(+), 240 deletions(-) diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 9333fbf46..f2bb37431 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -38,6 +38,7 @@ import { Monitor, MoreVertical, Plus, + Trash2, } from "lucide-react-native"; import { NestableScrollContainer } from "react-native-draggable-flatlist"; import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list"; @@ -53,13 +54,7 @@ import { } from "@/hooks/use-sidebar-workspaces-list"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuTrigger, - useContextMenu, -} from "@/components/ui/context-menu"; +import { ContextMenuTrigger, useContextMenu } from "@/components/ui/context-menu"; import { DropdownMenu, DropdownMenuTrigger, @@ -145,6 +140,8 @@ interface ProjectHeaderRowProps { isDragging: boolean; isArchiving?: boolean; menuController: ReturnType | null; + onRemoveProject?: () => void; + removeProjectStatus?: "idle" | "pending"; dragHandleProps?: DraggableListDragHandleProps; } @@ -704,8 +701,11 @@ function ProjectHeaderRow({ isDragging, isArchiving = false, menuController, + onRemoveProject, + removeProjectStatus = "idle", dragHandleProps, }: ProjectHeaderRowProps) { + const { theme } = useUnistyles(); const [isHovered, setIsHovered] = useState(false); const isMobileBreakpoint = isCompactFormFactor(); const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); @@ -792,16 +792,55 @@ function ProjectHeaderRow({ - {canCreateWorktree ? ( - createWorktreeMutation.mutate()} - visible={isHovered || isMobileBreakpoint} - loading={createWorktreeMutation.isPending} - showShortcutHint={isProjectActive} - testID={`sidebar-project-new-worktree-${project.projectKey}`} - /> - ) : null} + + {canCreateWorktree ? ( + createWorktreeMutation.mutate()} + visible={isHovered || isMobileBreakpoint} + loading={createWorktreeMutation.isPending} + showShortcutHint={isProjectActive} + testID={`sidebar-project-new-worktree-${project.projectKey}`} + /> + ) : null} + {onRemoveProject ? ( + + + [ + styles.projectKebabButton, + hovered && styles.projectKebabButtonHovered, + ]} + accessibilityRole="button" + accessibilityLabel="Project actions" + testID={`sidebar-project-kebab-${project.projectKey}`} + > + {({ hovered }) => ( + + )} + + + } + status={removeProjectStatus} + pendingLabel="Removing..." + onSelect={onRemoveProject} + > + Remove project + + + + + ) : null} + {showShortcutBadge && shortcutNumber !== null ? ( {shortcutNumber} @@ -1209,160 +1248,6 @@ function WorkspaceRowWithMenu({ ); } -function NonGitProjectRowWithMenuContent({ - project, - displayName, - iconDataUri, - workspace, - selected, - onPress, - shortcutNumber, - showShortcutBadge, - drag, - isDragging, - dragHandleProps, -}: { - project: SidebarProjectEntry; - displayName: string; - iconDataUri: string | null; - workspace: SidebarWorkspaceEntry; - selected: boolean; - onPress: () => void; - shortcutNumber: number | null; - showShortcutBadge: boolean; - drag: () => void; - isDragging: boolean; - dragHandleProps?: DraggableListDragHandleProps; -}) { - const toast = useToast(); - const contextMenu = useContextMenu(); - const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection(); - const sessionWorkspaces = useSessionStore( - (state) => state.sessions[workspace.serverId]?.workspaces ?? EMPTY_WORKSPACES, - ); - const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false); - const redirectAfterArchive = useCallback(() => { - if ( - activeWorkspaceSelection?.serverId !== workspace.serverId || - activeWorkspaceSelection.workspaceId !== workspace.workspaceId - ) { - return; - } - - router.replace( - buildWorkspaceArchiveRedirectRoute({ - serverId: workspace.serverId, - archivedWorkspaceId: workspace.workspaceId, - workspaces: sessionWorkspaces.values(), - }) as any, - ); - }, [activeWorkspaceSelection, sessionWorkspaces, workspace.serverId, workspace.workspaceId]); - - const handleArchiveWorkspace = useCallback(() => { - if (isArchivingWorkspace) { - return; - } - - void (async () => { - const confirmed = await confirmDialog({ - title: "Hide workspace?", - message: `Hide "${workspace.name}" from the sidebar?\n\nFiles on disk will not be changed.`, - confirmLabel: "Hide", - cancelLabel: "Cancel", - destructive: true, - }); - if (!confirmed) { - return; - } - - const client = getHostRuntimeStore().getClient(workspace.serverId); - if (!client) { - toast.error("Host is not connected"); - return; - } - - setIsArchivingWorkspace(true); - try { - const payload = await client.archiveWorkspace(workspace.workspaceId); - if (payload.error) { - throw new Error(payload.error); - } - redirectAfterArchive(); - } catch (error) { - toast.error(error instanceof Error ? error.message : "Failed to hide workspace"); - } finally { - setIsArchivingWorkspace(false); - } - })(); - }, [ - isArchivingWorkspace, - redirectAfterArchive, - toast, - workspace.name, - workspace.serverId, - workspace.workspaceId, - ]); - - return ( - <> - - - - Hide from sidebar - - - - ); -} - -function NonGitProjectRowWithMenu(props: { - project: SidebarProjectEntry; - displayName: string; - iconDataUri: string | null; - workspace: SidebarWorkspaceEntry; - selected: boolean; - onPress: () => void; - shortcutNumber: number | null; - showShortcutBadge: boolean; - drag: () => void; - isDragging: boolean; - dragHandleProps?: DraggableListDragHandleProps; -}) { - return ( - - - - ); -} - function FlattenedProjectRow({ project, displayName, @@ -1378,6 +1263,8 @@ function FlattenedProjectRow({ isDragging, dragHandleProps, isProjectActive = false, + onRemoveProject, + removeProjectStatus, }: { project: SidebarProjectEntry; displayName: string; @@ -1393,25 +1280,9 @@ function FlattenedProjectRow({ isDragging: boolean; dragHandleProps?: DraggableListDragHandleProps; isProjectActive?: boolean; + onRemoveProject?: () => void; + removeProjectStatus?: "idle" | "pending"; }) { - if (project.projectKind === "non_git") { - return ( - - ); - } - return ( ); @@ -1601,6 +1474,48 @@ function ProjectBlock({ [onWorkspaceReorder, project.projectKey], ); + const toast = useToast(); + const [isRemovingProject, setIsRemovingProject] = useState(false); + + const handleRemoveProject = useCallback(() => { + if (isRemovingProject || !serverId) { + return; + } + + void (async () => { + const confirmed = await confirmDialog({ + title: "Remove project?", + message: `Remove "${displayName}" from the sidebar?\n\nFiles on disk will not be changed.`, + confirmLabel: "Remove", + cancelLabel: "Cancel", + destructive: true, + }); + if (!confirmed) { + return; + } + + const client = getHostRuntimeStore().getClient(serverId); + if (!client) { + toast.error("Host is not connected"); + return; + } + + setIsRemovingProject(true); + try { + for (const ws of project.workspaces) { + const payload = await client.archiveWorkspace(ws.workspaceId); + if (payload.error) { + throw new Error(payload.error); + } + } + } catch (error) { + toast.error(error instanceof Error ? error.message : "Failed to remove project"); + } finally { + setIsRemovingProject(false); + } + })(); + }, [isRemovingProject, serverId, displayName, toast, project.workspaces]); + return ( {rowModel.kind === "workspace_link" ? ( @@ -1625,6 +1540,8 @@ function ProjectBlock({ isDragging={isDragging} dragHandleProps={dragHandleProps} isProjectActive={isProjectActive} + onRemoveProject={handleRemoveProject} + removeProjectStatus={isRemovingProject ? "pending" : "idle"} /> ) : ( <> @@ -1643,7 +1560,10 @@ function ProjectBlock({ onWorktreeCreated={onWorktreeCreated} drag={drag} isDragging={isDragging} + isArchiving={isRemovingProject} menuController={null} + onRemoveProject={handleRemoveProject} + removeProjectStatus={isRemovingProject ? "pending" : "idle"} dragHandleProps={dragHandleProps} /> @@ -2144,6 +2064,26 @@ const styles = StyleSheet.create((theme) => ({ projectIconActionButtonHidden: { opacity: 0, }, + projectTrailingActions: { + flexDirection: "row", + alignItems: "center", + gap: 2, + flexShrink: 0, + }, + projectKebabButton: { + width: 24, + height: 24, + borderRadius: theme.borderRadius.md, + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + }, + projectKebabButtonHidden: { + opacity: 0, + }, + projectKebabButtonHovered: { + backgroundColor: theme.colors.surface2, + }, projectTrailingControlSlot: { width: 24, height: 24, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 2e308b0a6..9161bd975 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1363,10 +1363,6 @@ export class Session { ); const staleWorkspaceIds = await detectStaleWorkspaces({ activeWorkspaces, - agentRecords: (await this.agentStorage.list()).map((agent) => ({ - cwd: agent.cwd, - archivedAt: agent.archivedAt ?? null, - })), checkDirectoryExists: async (cwd) => { try { await stat(cwd); diff --git a/packages/server/src/server/workspace-registry-model.test.ts b/packages/server/src/server/workspace-registry-model.test.ts index 4f4f186d3..29610578b 100644 --- a/packages/server/src/server/workspace-registry-model.test.ts +++ b/packages/server/src/server/workspace-registry-model.test.ts @@ -24,7 +24,6 @@ describe("detectStaleWorkspaces", () => { createWorkspaceRecord("/tmp/existing"), createWorkspaceRecord("/tmp/missing"), ], - agentRecords: [], checkDirectoryExists, }); @@ -32,41 +31,21 @@ describe("detectStaleWorkspaces", () => { expect(checkDirectoryExists.mock.calls).toEqual([["/tmp/existing"], ["/tmp/missing"]]); }); - test("returns workspace ids when all matching agents are archived", async () => { + test("keeps workspaces whose directories exist even when all agents are archived", async () => { const staleWorkspaceIds = await detectStaleWorkspaces({ activeWorkspaces: [createWorkspaceRecord("/tmp/repo"), createWorkspaceRecord("/tmp/other")], - agentRecords: [ - { - cwd: "/tmp/repo", - archivedAt: "2026-03-02T00:00:00.000Z", - }, - { - cwd: "/tmp/other", - archivedAt: null, - }, - ], checkDirectoryExists: async () => true, }); - expect(Array.from(staleWorkspaceIds)).toEqual(["/tmp/repo"]); + expect(Array.from(staleWorkspaceIds)).toEqual([]); }); - test("keeps workspaces with no agents or at least one active agent", async () => { + test("keeps workspaces with no agents when directory exists", async () => { const staleWorkspaceIds = await detectStaleWorkspaces({ activeWorkspaces: [ createWorkspaceRecord("/tmp/active"), createWorkspaceRecord("/tmp/no-agents"), ], - agentRecords: [ - { - cwd: "/tmp/active", - archivedAt: "2026-03-02T00:00:00.000Z", - }, - { - cwd: "/tmp/active/../active", - archivedAt: null, - }, - ], checkDirectoryExists: async () => true, }); diff --git a/packages/server/src/server/workspace-registry-model.ts b/packages/server/src/server/workspace-registry-model.ts index 6af4f7bbc..9d1590be0 100644 --- a/packages/server/src/server/workspace-registry-model.ts +++ b/packages/server/src/server/workspace-registry-model.ts @@ -6,14 +6,8 @@ import type { PersistedWorkspaceRecord } from "./workspace-registry.js"; export type PersistedProjectKind = "git" | "non_git"; export type PersistedWorkspaceKind = "local_checkout" | "worktree" | "directory"; -export type StaleWorkspaceAgentRecord = { - cwd: string; - archivedAt: string | null; -}; - export type DetectStaleWorkspacesInput = { activeWorkspaces: PersistedWorkspaceRecord[]; - agentRecords: StaleWorkspaceAgentRecord[]; checkDirectoryExists: (cwd: string) => Promise; }; @@ -143,28 +137,11 @@ export async function detectStaleWorkspaces( input: DetectStaleWorkspacesInput, ): Promise> { const staleWorkspaceIds = new Set(); - const cwdsWithActiveAgents = new Set(); - const cwdsWithAnyAgent = new Set(); - - for (const agent of input.agentRecords) { - const normalizedCwd = normalizeWorkspaceId(agent.cwd); - cwdsWithAnyAgent.add(normalizedCwd); - if (!agent.archivedAt) { - cwdsWithActiveAgents.add(normalizedCwd); - } - } for (const workspace of input.activeWorkspaces) { const dirExists = await input.checkDirectoryExists(workspace.cwd); if (!dirExists) { staleWorkspaceIds.add(workspace.workspaceId); - continue; - } - - const hasAgents = cwdsWithAnyAgent.has(workspace.workspaceId); - const hasActiveAgents = cwdsWithActiveAgents.has(workspace.workspaceId); - if (hasAgents && !hasActiveAgents) { - staleWorkspaceIds.add(workspace.workspaceId); } } From 255c2665ccd88c3437ce1c210f9695050ef482ee Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 3 Apr 2026 22:15:44 +0700 Subject: [PATCH 22/73] feat(opencode): support custom agents and slash commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the hardcoded mode whitelist so user-defined agents (from opencode.json or global config) appear in the mode picker. Add a listProviderModes API (client → session → provider) so the draft agent form fetches dynamic modes instead of relying on the static manifest. Fix slash command dispatch to accept optional arguments. Closes #185 --- .../app/src/hooks/use-agent-form-state.ts | 27 +++++- packages/server/src/client/daemon-client.ts | 18 ++++ .../src/server/agent/agent-sdk-types.ts | 5 ++ .../src/server/agent/provider-registry.ts | 7 ++ .../opencode-agent-commands.e2e.test.ts | 52 ++++++++++++ .../opencode-agent-commands.real.e2e.test.ts | 47 +++++++++++ .../agent/providers/opencode-agent.test.ts | 36 ++++++++ .../server/agent/providers/opencode-agent.ts | 41 ++++++++- .../opencode-custom-agents.real.e2e.test.ts | 83 +++++++++++++++++++ packages/server/src/server/session.ts | 39 +++++++++ .../server/test-utils/fake-agent-client.ts | 2 +- packages/server/src/shared/messages.ts | 26 ++++++ 12 files changed, 377 insertions(+), 6 deletions(-) create mode 100644 packages/server/src/server/daemon-e2e/opencode-custom-agents.real.e2e.test.ts diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index bec3f0392..badebe2b6 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -444,6 +444,31 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg const availableModels = providerModelsQuery.data ?? null; + const providerModesQuery = useQuery({ + queryKey: ["providerModes", formState.serverId, formState.provider, debouncedCwd], + enabled: Boolean( + isVisible && + isTargetDaemonReady && + formState.serverId && + client && + isConnected && + providerDefinitionMap.has(formState.provider), + ), + staleTime: 5 * 60 * 1000, + queryFn: async () => { + if (!client) { + throw new Error("Host is not connected"); + } + const payload = await client.listProviderModes(formState.provider, { + cwd: debouncedCwd, + }); + if (payload.error) { + throw new Error(payload.error); + } + return payload.modes ?? []; + }, + }); + const allProviderModelQueries = useQueries({ queries: providerDefinitions.map((def) => ({ queryKey: ["providerModels", formState.serverId, def.id], @@ -722,7 +747,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg ]); const agentDefinition = providerDefinitionMap.get(formState.provider); - const modeOptions = agentDefinition?.modes ?? []; + const modeOptions = providerModesQuery.data ?? agentDefinition?.modes ?? []; const effectiveModel = resolveEffectiveModel(availableModels, formState.model); const resolvedModelId = effectiveModel?.id ?? formState.model; const availableThinkingOptions = effectiveModel?.thinkingOptions ?? []; diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 8f1c1178e..4d6f0e2a2 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -38,6 +38,7 @@ import type { ArchiveWorkspaceResponseMessage, ListCommandsResponse, ListProviderModelsResponseMessage, + ListProviderModesResponseMessage, ListAvailableProvidersResponse, ListTerminalsResponse, CreateTerminalResponse, @@ -216,6 +217,7 @@ type CreatePaseoWorktreePayload = Extract< type FileExplorerPayload = FileExplorerResponse["payload"]; type FileDownloadTokenPayload = FileDownloadTokenResponse["payload"]; type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"]; +type ListProviderModesPayload = ListProviderModesResponseMessage["payload"]; type ListAvailableProvidersPayload = ListAvailableProvidersResponse["payload"]; type ListCommandsPayload = ListCommandsResponse["payload"]; type ListCommandsDraftConfig = Pick< @@ -2474,6 +2476,22 @@ export class DaemonClient { }); } + async listProviderModes( + provider: AgentProvider, + options?: { cwd?: string; requestId?: string }, + ): Promise { + return this.sendCorrelatedSessionRequest({ + requestId: options?.requestId, + message: { + type: "list_provider_modes_request", + provider, + cwd: options?.cwd, + }, + responseType: "list_provider_modes_response", + timeout: 45000, + }); + } + async listAvailableProviders(options?: { requestId?: string; }): Promise { diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index a4440aa81..8fad606fa 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -414,6 +414,10 @@ export interface ListModelsOptions { cwd?: string; } +export interface ListModesOptions { + cwd?: string; +} + export interface AgentClient { readonly provider: AgentProvider; readonly capabilities: AgentCapabilityFlags; @@ -427,6 +431,7 @@ export interface AgentClient { launchContext?: AgentLaunchContext, ): Promise; listModels(options?: ListModelsOptions): Promise; + listModes?(options?: ListModesOptions): Promise; listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise; /** * Check if this provider is available (CLI binary is installed). diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 34d646eff..10c534d74 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -1,8 +1,10 @@ import type { AgentClient, + AgentMode, AgentModelDefinition, AgentProvider, ListModelsOptions, + ListModesOptions, } from "./agent-sdk-types.js"; import type { AgentProviderRuntimeSettingsMap } from "./provider-launch-config.js"; import type { Logger } from "pino"; @@ -24,6 +26,7 @@ export { AGENT_PROVIDER_DEFINITIONS, getAgentProviderDefinition }; export interface ProviderDefinition extends AgentProviderDefinition { createClient: (logger: Logger) => AgentClient; fetchModels: (options?: ListModelsOptions) => Promise; + fetchModes: (options?: ListModesOptions) => Promise; } type BuildProviderRegistryOptions = { @@ -69,6 +72,10 @@ export function buildProviderRegistry( ...definition, createClient: (providerLogger: Logger) => createClient(providerLogger, runtimeSettings), fetchModels: (listOptions?: ListModelsOptions) => modelClient.listModels(listOptions), + fetchModes: (listOptions?: ListModesOptions) => + modelClient.listModes + ? modelClient.listModes(listOptions) + : Promise.resolve(definition.modes), } satisfies ProviderDefinition, ]; }), diff --git a/packages/server/src/server/agent/providers/opencode-agent-commands.e2e.test.ts b/packages/server/src/server/agent/providers/opencode-agent-commands.e2e.test.ts index 28431cc28..13ef380d0 100644 --- a/packages/server/src/server/agent/providers/opencode-agent-commands.e2e.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent-commands.e2e.test.ts @@ -37,6 +37,58 @@ describe("opencode agent commands E2E", () => { } }, 60_000); + test("sendMessage executes a slash command without arguments", async () => { + const agent = await ctx.client.createAgent({ + ...getFullAccessConfig("opencode"), + cwd: "/tmp", + title: "OpenCode Slash Command No Args", + }); + + expect(agent.id).toBeTruthy(); + expect(agent.provider).toBe("opencode"); + + await ctx.client.sendMessage(agent.id, "/help"); + const state = await ctx.client.waitForFinish(agent.id, 30_000); + + expect(state.status).toBe("idle"); + expect(state.lastMessage).toContain("PASEO_SKILL_OK"); + }, 30_000); + + test("sendMessage executes a slash command with arguments", async () => { + const agent = await ctx.client.createAgent({ + ...getFullAccessConfig("opencode"), + cwd: "/tmp", + title: "OpenCode Slash Command With Args", + }); + + expect(agent.id).toBeTruthy(); + expect(agent.provider).toBe("opencode"); + + await ctx.client.sendMessage(agent.id, "/help some-topic"); + const state = await ctx.client.waitForFinish(agent.id, 30_000); + + expect(state.status).toBe("idle"); + expect(state.lastMessage).toContain("PASEO_SKILL_OK"); + }, 30_000); + + test("sendMessage keeps unknown slash input as plain prompt text", async () => { + const agent = await ctx.client.createAgent({ + ...getFullAccessConfig("opencode"), + cwd: "/tmp", + title: "OpenCode Slash Fallback", + }); + + const token = `RAW_PROMPT_TOKEN_${Date.now()}`; + await ctx.client.sendMessage( + agent.id, + `/not-a-real-command respond with exactly: ${token}`, + ); + const state = await ctx.client.waitForFinish(agent.id, 30_000); + + expect(state.status).toBe("idle"); + expect(state.lastMessage).toContain(token); + }, 30_000); + test("returns error for non-existent agent", async () => { const result = await ctx.client.listCommands("non-existent-agent-id"); diff --git a/packages/server/src/server/agent/providers/opencode-agent-commands.real.e2e.test.ts b/packages/server/src/server/agent/providers/opencode-agent-commands.real.e2e.test.ts index 9fbf2566b..dcc2b97ea 100644 --- a/packages/server/src/server/agent/providers/opencode-agent-commands.real.e2e.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent-commands.real.e2e.test.ts @@ -35,4 +35,51 @@ describe("opencode agent commands contract (real)", () => { await session.close(); } }, 60_000); + + test("executes a slash command without arguments", async () => { + expect(isCommandAvailable("opencode")).toBe(true); + + const client = new OpenCodeAgentClient(pino({ level: "silent" })); + const session = await client.createSession({ + provider: "opencode", + cwd: process.cwd(), + modeId: "default", + }); + + try { + const commands = await session.listCommands!(); + expect(commands.length).toBeGreaterThan(0); + + // Pick the first available command and send it without arguments. + const command = commands[0]!; + const events: Array<{ type: string }> = []; + session.subscribe((event) => events.push(event)); + + const { turnId } = await session.startTurn(`/${command.name}`); + expect(turnId).toBeTruthy(); + + // Wait for the turn to complete or fail. + await new Promise((resolve) => { + const unsub = session.subscribe((event) => { + if ( + event.type === "turn_completed" || + event.type === "turn_failed" || + event.type === "turn_canceled" + ) { + unsub(); + resolve(); + } + }); + }); + + // The turn should not have failed with an "invalid_type" error on arguments. + const failEvent = events.find((e) => e.type === "turn_failed"); + if (failEvent && "error" in failEvent) { + expect(String(failEvent.error)).not.toContain("invalid_type"); + expect(String(failEvent.error)).not.toContain("expected string, received undefined"); + } + } finally { + await session.close(); + } + }, 60_000); }); diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index d17a29424..b894292e5 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -219,6 +219,42 @@ const hasOpenCode = isBinaryInstalled("opencode"); rmSync(cwd, { recursive: true, force: true }); }, 60_000); + test("custom agents defined in opencode.json appear in available modes", async () => { + const cwd = tmpCwd(); + writeFileSync( + path.join(cwd, "opencode.json"), + JSON.stringify({ + agent: { + "paseo-test-custom": { + description: "Custom agent defined for Paseo integration test", + mode: "primary", + }, + }, + }), + ); + + const client = new OpenCodeAgentClient(logger); + const session = await client.createSession(buildConfig(cwd)); + + const modes = await session.getAvailableModes(); + + expect(modes.some((mode) => mode.id === "build")).toBe(true); + expect(modes.some((mode) => mode.id === "plan")).toBe(true); + + const custom = modes.find((mode) => mode.id === "paseo-test-custom"); + expect(custom).toBeDefined(); + expect(custom!.label).toBe("Paseo-test-custom"); + expect(custom!.description).toBe("Custom agent defined for Paseo integration test"); + + // System agents should not appear as selectable modes + expect(modes.some((mode) => mode.id === "compaction")).toBe(false); + expect(modes.some((mode) => mode.id === "summary")).toBe(false); + expect(modes.some((mode) => mode.id === "title")).toBe(false); + + await session.close(); + rmSync(cwd, { recursive: true, force: true }); + }, 60_000); + test("plan mode blocks edits while build mode can write files", async () => { const cwd = tmpCwd(); const planFile = path.join(cwd, "plan-mode-output.txt"); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 9f6a26749..b9c6f75d8 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -26,6 +26,7 @@ import type { AgentTimelineItem, AgentUsage, ListModelsOptions, + ListModesOptions, ListPersistedAgentsOptions, McpServerConfig, PersistedAgentDescriptor, @@ -63,8 +64,6 @@ const DEFAULT_MODES: AgentMode[] = [ }, ]; -const OPENCODE_MODE_IDS = new Set(DEFAULT_MODES.map((mode) => mode.id)); - type OpenCodeAgentConfig = AgentSessionConfig & { provider: "opencode" }; type OpenCodeMessageRole = "user" | "assistant"; @@ -563,6 +562,41 @@ export class OpenCodeAgentClient implements AgentClient { return models; } + async listModes(options?: ListModesOptions): Promise { + const { url } = await this.serverManager.ensureRunning(); + const directory = options?.cwd ?? process.cwd(); + const client = createOpencodeClient({ baseUrl: url, directory }); + + const timeoutPromise = new Promise((_, reject) => { + setTimeout( + () => reject(new Error("OpenCode app.agents timed out after 10s")), + 10_000, + ); + }); + + const response = await Promise.race([ + client.app.agents({ directory }), + timeoutPromise, + ]); + + if (response.error || !response.data) { + return DEFAULT_MODES; + } + + const discovered = response.data + .filter((agent) => agent.mode === "primary" && agent.hidden !== true) + .map((agent) => ({ + id: agent.name, + label: agent.name.charAt(0).toUpperCase() + agent.name.slice(1), + description: + typeof agent.description === "string" && agent.description.trim().length > 0 + ? agent.description.trim() + : DEFAULT_MODES.find((mode) => mode.id === agent.name)?.description, + })); + + return discovered.length > 0 ? sortOpenCodeModes(discovered) : DEFAULT_MODES; + } + async listPersistedAgents( _options?: ListPersistedAgentsOptions, ): Promise { @@ -1160,7 +1194,7 @@ class OpenCodeAgentSession implements AgentSession { sessionID: this.sessionId, directory: this.config.cwd, command: slashCommand.commandName, - arguments: slashCommand.args, + arguments: slashCommand.args ?? "", ...(this.config.model ? { model: this.config.model } : {}), ...(effectiveMode ? { agent: effectiveMode } : {}), ...(effectiveVariant ? { variant: effectiveVariant } : {}), @@ -1432,7 +1466,6 @@ class OpenCodeAgentSession implements AgentSession { ? [] : response.data .filter((agent) => agent.mode === "primary" && agent.hidden !== true) - .filter((agent) => OPENCODE_MODE_IDS.has(agent.name)) .map((agent) => ({ id: agent.name, label: agent.name.charAt(0).toUpperCase() + agent.name.slice(1), diff --git a/packages/server/src/server/daemon-e2e/opencode-custom-agents.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-custom-agents.real.e2e.test.ts new file mode 100644 index 000000000..190d8e189 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/opencode-custom-agents.real.e2e.test.ts @@ -0,0 +1,83 @@ +import { describe, test, expect } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import pino from "pino"; + +import { OpenCodeAgentClient } from "../agent/providers/opencode-agent.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { isProviderAvailable } from "./agent-configs.js"; + +function tmpCwd(): string { + return mkdtempSync(path.join(tmpdir(), "daemon-real-opencode-custom-")); +} + +async function createHarness(): Promise<{ + client: DaemonClient; + daemon: Awaited>; +}> { + const logger = pino({ level: "silent" }); + const daemon = await createTestPaseoDaemon({ + agentClients: { opencode: new OpenCodeAgentClient(logger) }, + logger, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + await client.connect(); + await client.fetchAgents({ subscribe: { subscriptionId: "opencode-custom-agents" } }); + return { client, daemon }; +} + +describe("daemon E2E (real opencode) - custom agent discovery", () => { + test.runIf(isProviderAvailable("opencode"))( + "custom agents defined in opencode.json appear in availableModes", + async () => { + const cwd = tmpCwd(); + writeFileSync( + path.join(cwd, "opencode.json"), + JSON.stringify({ + agent: { + "paseo-e2e-custom": { + description: "Custom agent for Paseo daemon E2E test", + mode: "primary", + }, + }, + }), + ); + + const { client, daemon } = await createHarness(); + + try { + const agent = await client.createAgent({ + provider: "opencode", + cwd, + title: "OpenCode custom agent discovery", + }); + + // Wait for modes to be populated (they arrive after session init) + const snapshot = await client.waitForAgentUpsert( + agent.id, + (s) => s.availableModes.length > 0, + 30_000, + ); + + expect(snapshot.availableModes.some((m) => m.id === "build")).toBe(true); + expect(snapshot.availableModes.some((m) => m.id === "plan")).toBe(true); + + const custom = snapshot.availableModes.find((m) => m.id === "paseo-e2e-custom"); + expect(custom).toBeDefined(); + expect(custom!.description).toBe("Custom agent for Paseo daemon E2E test"); + + // System agents should not leak through + expect(snapshot.availableModes.some((m) => m.id === "compaction")).toBe(false); + expect(snapshot.availableModes.some((m) => m.id === "summary")).toBe(false); + expect(snapshot.availableModes.some((m) => m.id === "title")).toBe(false); + } finally { + await client.close().catch(() => undefined); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, + 60_000, + ); +}); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 9161bd975..741ac6e6a 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1661,6 +1661,10 @@ export class Session { await this.handleListProviderModelsRequest(msg); break; + case "list_provider_modes_request": + await this.handleListProviderModesRequest(msg); + break; + case "list_available_providers_request": await this.handleListAvailableProvidersRequest(msg); break; @@ -3042,6 +3046,41 @@ export class Session { } } + private async handleListProviderModesRequest( + msg: Extract, + ): Promise { + const fetchedAt = new Date().toISOString(); + try { + const modes = await this.providerRegistry[msg.provider].fetchModes({ + cwd: msg.cwd ? expandTilde(msg.cwd) : undefined, + }); + this.emit({ + type: "list_provider_modes_response", + payload: { + provider: msg.provider, + modes, + error: null, + fetchedAt, + requestId: msg.requestId, + }, + }); + } catch (error) { + this.sessionLogger.error( + { err: error, provider: msg.provider }, + `Failed to list modes for ${msg.provider}`, + ); + this.emit({ + type: "list_provider_modes_response", + payload: { + provider: msg.provider, + error: (error as Error)?.message ?? String(error), + fetchedAt, + requestId: msg.requestId, + }, + }); + } + } + private async handleListAvailableProvidersRequest( msg: Extract, ): Promise { diff --git a/packages/server/src/server/test-utils/fake-agent-client.ts b/packages/server/src/server/test-utils/fake-agent-client.ts index b873de66d..a2b53368e 100644 --- a/packages/server/src/server/test-utils/fake-agent-client.ts +++ b/packages/server/src/server/test-utils/fake-agent-client.ts @@ -243,7 +243,7 @@ class FakeAgentSession implements AgentSession { private async resolveSlashCommandInput( prompt: AgentPromptInput, ): Promise<{ commandName: string; args?: string } | null> { - if (this.providerName !== "codex" || typeof prompt !== "string") { + if ((this.providerName !== "codex" && this.providerName !== "opencode") || typeof prompt !== "string") { return null; } const parsed = this.parseSlashCommandInput(prompt); diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 55ffa15af..5b9771058 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -728,6 +728,13 @@ export const ListProviderModelsRequestMessageSchema = z.object({ requestId: z.string(), }); +export const ListProviderModesRequestMessageSchema = z.object({ + type: z.literal("list_provider_modes_request"), + provider: AgentProviderSchema, + cwd: z.string().optional(), + requestId: z.string(), +}); + export const ListAvailableProvidersRequestMessageSchema = z.object({ type: z.literal("list_available_providers_request"), requestId: z.string(), @@ -1208,6 +1215,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ DictationStreamCancelMessageSchema, CreateAgentRequestMessageSchema, ListProviderModelsRequestMessageSchema, + ListProviderModesRequestMessageSchema, ListAvailableProvidersRequestMessageSchema, ResumeAgentRequestMessageSchema, RefreshAgentRequestMessageSchema, @@ -2112,6 +2120,17 @@ export const ListProviderModelsResponseMessageSchema = z.object({ }), }); +export const ListProviderModesResponseMessageSchema = z.object({ + type: z.literal("list_provider_modes_response"), + payload: z.object({ + provider: AgentProviderSchema, + modes: z.array(AgentModeSchema).optional(), + error: z.string().nullable().optional(), + fetchedAt: z.string(), + requestId: z.string(), + }), +}); + const ProviderAvailabilitySchema = z.object({ provider: AgentProviderSchema, available: z.boolean(), @@ -2317,6 +2336,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ProjectIconResponseSchema, FileDownloadTokenResponseSchema, ListProviderModelsResponseMessageSchema, + ListProviderModesResponseMessageSchema, ListAvailableProvidersResponseSchema, ListCommandsResponseSchema, ListTerminalsResponseSchema, @@ -2386,6 +2406,9 @@ export type AgentDeletedMessage = z.infer; export type ListProviderModelsResponseMessage = z.infer< typeof ListProviderModelsResponseMessageSchema >; +export type ListProviderModesResponseMessage = z.infer< + typeof ListProviderModesResponseMessageSchema +>; export type ListAvailableProvidersResponse = z.infer; export type ChatCreateResponse = z.infer; export type ChatListResponse = z.infer; @@ -2425,6 +2448,9 @@ export type CreateAgentRequestMessage = z.infer; +export type ListProviderModesRequestMessage = z.infer< + typeof ListProviderModesRequestMessageSchema +>; export type ListAvailableProvidersRequestMessage = z.infer< typeof ListAvailableProvidersRequestMessageSchema >; From 71ce95de9031402381bbb520a8677be92714ed07 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 3 Apr 2026 23:20:56 +0700 Subject: [PATCH 23/73] fix(desktop): race existing connections against bootstrap for faster startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Electron startup blocked on a ~2.6s synchronous CLI status check before showing the app. Now the host runtime controllers (which probe persisted connections) race against the bootstrap path — if the daemon is already running, the UI comes online in milliseconds. Also converts the desktop CLI spawn calls from spawnSync to async spawn so the Electron main thread is no longer blocked during daemon status checks. --- packages/app/src/app/_layout.tsx | 63 +++++++++++----- packages/app/src/runtime/host-runtime.ts | 35 +++++++++ packages/desktop/src/daemon/daemon-manager.ts | 29 ++++---- packages/desktop/src/daemon/runtime-paths.ts | 72 +++++++++++-------- 4 files changed, 140 insertions(+), 59 deletions(-) diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index d75ad1550..47b989309 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -230,6 +230,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) { useEffect(() => { let cancelled = false; + let cancelAnyOnline: (() => void) | null = null; const shouldManageDesktop = shouldUseDesktopDaemon(); const store = getHostRuntimeStore(); @@ -240,28 +241,53 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) { if (isDesktopManaged) { setPhase("starting-daemon"); setError(null); - const bootstrapResult = await store.bootstrapDesktop(); - if (!bootstrapResult.ok) { - if (!cancelled) { - setPhase("error"); - setError(bootstrapResult.error); + + let raceSettled = false; + + const anyOnline = store.waitForAnyConnectionOnline(); + cancelAnyOnline = anyOnline.cancel; + + const bootstrapPromise = (async (): Promise< + { type: "online" } | { type: "error"; error: string } + > => { + try { + const bootstrapResult = await store.bootstrapDesktop(); + if (!bootstrapResult.ok) { + return { type: "error", error: bootstrapResult.error }; + } + if (!cancelled && !raceSettled) { + setPhase("connecting"); + } + await store.addConnectionFromListenAndWaitForOnline({ + listenAddress: bootstrapResult.listenAddress, + serverId: bootstrapResult.serverId, + hostname: bootstrapResult.hostname, + }); + return { type: "online" }; + } catch (err) { + return { + type: "error", + error: err instanceof Error ? err.message : String(err), + }; } - return; - } + })(); - if (cancelled) { - return; - } + const result = await Promise.race([ + anyOnline.promise.then((): { type: "online" } => ({ type: "online" })), + bootstrapPromise, + ]); + + raceSettled = true; + anyOnline.cancel(); - setPhase("connecting"); - await store.addConnectionFromListenAndWaitForOnline({ - listenAddress: bootstrapResult.listenAddress, - serverId: bootstrapResult.serverId, - hostname: bootstrapResult.hostname, - }); if (!cancelled) { - setPhase("online"); - setError(null); + if (result.type === "online") { + setPhase("online"); + setError(null); + } else { + setPhase("error"); + setError(result.error); + } } } else { void store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon }); @@ -288,6 +314,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) { return () => { cancelled = true; + cancelAnyOnline?.(); }; }, [retryToken]); diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 74c3eb08c..f5405857c 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -1681,6 +1681,41 @@ export class HostRuntimeStore { }; } + waitForAnyConnectionOnline(): { promise: Promise; cancel: () => void } { + let unsubscribe: (() => void) | null = null; + + const isAnyOnline = (): boolean => { + for (const host of this.hosts) { + const snapshot = this.getSnapshot(host.serverId); + if (snapshot?.connectionStatus === "online") return true; + } + return false; + }; + + const promise = new Promise((resolve) => { + if (isAnyOnline()) { + resolve(); + return; + } + + unsubscribe = this.subscribeAll(() => { + if (isAnyOnline()) { + unsubscribe?.(); + unsubscribe = null; + resolve(); + } + }); + }); + + return { + promise, + cancel: () => { + unsubscribe?.(); + unsubscribe = null; + }, + }; + } + ensureConnectedAll(): void { for (const controller of this.controllers.values()) { controller.ensureConnected(); diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index 98e48f4ea..d1b1091a4 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -186,11 +186,14 @@ function resolveDesktopAppVersion(): string { // Daemon lifecycle // --------------------------------------------------------------------------- -function resolveStatus(): DesktopDaemonStatus { +async function resolveStatus(): Promise { const home = getPaseoHome(); try { - const payload = runCliJsonCommand(["daemon", "status", "--json"]) as Record; + const payload = (await runCliJsonCommand(["daemon", "status", "--json"])) as Record< + string, + unknown + >; const localDaemon = typeof payload.localDaemon === "string" ? payload.localDaemon : "stopped"; const running = localDaemon === "running"; @@ -227,7 +230,7 @@ function normalizeVersion(version: string | null): string | null { } async function startDaemon(): Promise { - const current = resolveStatus(); + const current = await resolveStatus(); if (current.status === "running") { const appVersion = normalizeVersion(resolveDesktopAppVersion()); const daemonVersion = normalizeVersion(current.version); @@ -320,7 +323,7 @@ async function startDaemon(): Promise { // Poll for PID file with server ID for (let attempt = 0; attempt < STARTUP_POLL_MAX_ATTEMPTS; attempt++) { - const status = resolveStatus(); + const status = await resolveStatus(); if (attempt === 0 || attempt === STARTUP_POLL_MAX_ATTEMPTS - 1 || attempt % 10 === 9) { logDesktopDaemonLifecycle("polling daemon status after detached start", { attempt: attempt + 1, @@ -334,11 +337,11 @@ async function startDaemon(): Promise { await sleep(STARTUP_POLL_INTERVAL_MS); } - return resolveStatus(); + return await resolveStatus(); } async function stopDaemon(): Promise { - const status = resolveStatus(); + const status = await resolveStatus(); if (status.status !== "running" || !status.pid) return status; const pid = status.pid; @@ -354,7 +357,7 @@ async function stopDaemon(): Promise { throw new Error(`Timed out waiting for daemon PID ${pid} to stop`); } - return resolveStatus(); + return await resolveStatus(); } async function restartDaemon(): Promise { @@ -370,12 +373,12 @@ function getDaemonLogs(): DesktopDaemonLogs { }; } -function getCliDaemonStatus(): string { - return runCliTextCommand(["daemon", "status"]); +async function getCliDaemonStatus(): Promise { + return await runCliTextCommand(["daemon", "status"]); } async function getDaemonPairing(): Promise { - const status = resolveStatus(); + const status = await resolveStatus(); if (status.status !== "running") { return { relayEnabled: false, @@ -385,7 +388,7 @@ async function getDaemonPairing(): Promise { } try { - const payload = runCliJsonCommand(["daemon", "pair", "--json"]); + const payload = await runCliJsonCommand(["daemon", "pair", "--json"]); if (!isRecord(payload)) { throw new Error("Daemon pairing response was not an object."); } @@ -404,8 +407,8 @@ async function getDaemonPairing(): Promise { } } -function getLocalDaemonVersion(): { version: string | null; error: string | null } { - const status = resolveStatus(); +async function getLocalDaemonVersion(): Promise<{ version: string | null; error: string | null }> { + const status = await resolveStatus(); if (status.status !== "running") { return { version: null, error: "Daemon is not running." }; } diff --git a/packages/desktop/src/daemon/runtime-paths.ts b/packages/desktop/src/daemon/runtime-paths.ts index e56f47bc2..d464ddc22 100644 --- a/packages/desktop/src/daemon/runtime-paths.ts +++ b/packages/desktop/src/daemon/runtime-paths.ts @@ -1,5 +1,5 @@ import { existsSync, readFileSync } from "node:fs"; -import { spawnSync } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import { createRequire } from "node:module"; import path from "node:path"; import { app } from "electron"; @@ -246,44 +246,60 @@ export function runCliPassthroughCommand(args: string[]): number { return result.signal ? 1 : 0; } -export function runCliTextCommand(args: string[]): string { - const invocation = createCliInvocation(args); - const result = spawnSync(invocation.command, invocation.args, { - env: invocation.env, - encoding: "utf-8", - stdio: ["ignore", "pipe", "pipe"], +function spawnAsync( + command: string, + args: string[], + options: { env: NodeJS.ProcessEnv }, +): Promise<{ stdout: string; stderr: string; exitCode: number | null }> { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + env: options.env, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + + child.stdout.on("data", (data: Buffer) => { + stdout += data.toString(); + }); + child.stderr.on("data", (data: Buffer) => { + stderr += data.toString(); + }); + + child.on("error", reject); + child.on("close", (exitCode) => { + resolve({ stdout, stderr, exitCode }); + }); }); - - if (result.error) { - throw result.error; - } - - if (result.status !== 0) { - const stderr = typeof result.stderr === "string" ? result.stderr.trim() : ""; - throw new Error(stderr.length > 0 ? stderr : `CLI command failed with exit code ${result.status}`); - } - - return typeof result.stdout === "string" ? result.stdout.trimEnd() : ""; } -export function runCliJsonCommand(args: string[]): unknown { +export async function runCliTextCommand(args: string[]): Promise { const invocation = createCliInvocation(args); - const result = spawnSync(invocation.command, invocation.args, { + const result = await spawnAsync(invocation.command, invocation.args, { env: invocation.env, - encoding: "utf-8", - stdio: ["ignore", "pipe", "pipe"], }); - if (result.error) { - throw result.error; + if (result.exitCode !== 0) { + const stderr = result.stderr.trim(); + throw new Error(stderr.length > 0 ? stderr : `CLI command failed with exit code ${result.exitCode}`); } - if (result.status !== 0) { - const stderr = typeof result.stderr === "string" ? result.stderr.trim() : ""; - throw new Error(stderr.length > 0 ? stderr : `CLI command failed with exit code ${result.status}`); + return result.stdout.trimEnd(); +} + +export async function runCliJsonCommand(args: string[]): Promise { + const invocation = createCliInvocation(args); + const result = await spawnAsync(invocation.command, invocation.args, { + env: invocation.env, + }); + + if (result.exitCode !== 0) { + const stderr = result.stderr.trim(); + throw new Error(stderr.length > 0 ? stderr : `CLI command failed with exit code ${result.exitCode}`); } - const stdout = typeof result.stdout === "string" ? result.stdout.trim() : ""; + const stdout = result.stdout.trim(); if (stdout.length === 0) { throw new Error("CLI command did not produce JSON output."); } From 60bbfdde25b2578fecef5f1a97018674f65296c7 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 3 Apr 2026 23:47:37 +0700 Subject: [PATCH 24/73] fix(opencode): prevent event stream starvation for slash commands --- .../server/agent/providers/opencode-agent.ts | 57 +++++++++++-------- 1 file changed, 33 insertions(+), 24 deletions(-) diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index b9c6f75d8..a6ea4ab5a 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -532,18 +532,19 @@ export class OpenCodeAgentClient implements AgentClient { for (const [modelId, model] of Object.entries(provider.models)) { const rawVariants = model.variants ? Object.keys(model.variants) : []; - const thinkingOptions = [ - { id: "default", label: "Model default", isDefault: true }, - ...rawVariants.map((id) => ({ id, label: id })), - ]; + const thinkingOptions = rawVariants.map((id, index) => ({ + id, + label: id, + isDefault: index === 0, + })); models.push({ provider: "opencode", id: `${provider.id}/${modelId}`, label: model.name, description: `${provider.name} - ${model.family ?? ""}`.trim(), - thinkingOptions: thinkingOptions.length > 1 ? thinkingOptions : undefined, - defaultThinkingOptionId: "default", + thinkingOptions: thinkingOptions.length > 0 ? thinkingOptions : undefined, + defaultThinkingOptionId: thinkingOptions[0]?.id, metadata: { providerId: provider.id, providerName: provider.name, @@ -1183,14 +1184,18 @@ class OpenCodeAgentSession implements AgentSession { const parts = this.buildPromptParts(prompt); const model = this.parseModel(this.config.model); const thinkingOptionId = this.config.thinkingOptionId; - const effectiveVariant = - thinkingOptionId && thinkingOptionId !== "default" ? thinkingOptionId : undefined; + const effectiveVariant = thinkingOptionId ?? undefined; const effectiveMode = normalizeOpenCodeModeId(this.currentMode); - let promptResponse; + const turnId = this.createTurnId(); + this.activeForegroundTurnId = turnId; + void this.consumeEventStream(turnId, turnAbortController); + const slashCommand = await this.resolveSlashCommandInvocation(prompt); if (slashCommand) { - promptResponse = await this.client.session.command({ + // command() blocks until completion, but events stream via SSE in the + // background — fire without awaiting so the event stream isn't starved. + void this.client.session.command({ sessionID: this.sessionId, directory: this.config.cwd, command: slashCommand.commandName, @@ -1198,9 +1203,17 @@ class OpenCodeAgentSession implements AgentSession { ...(this.config.model ? { model: this.config.model } : {}), ...(effectiveMode ? { agent: effectiveMode } : {}), ...(effectiveVariant ? { variant: effectiveVariant } : {}), + }).then((response) => { + if (response.error) { + const errorMsg = normalizeTurnFailureError(response.error); + this.finishForegroundTurn( + { type: "turn_failed", provider: "opencode", error: errorMsg }, + turnId, + ); + } }); } else { - promptResponse = await this.client.session.promptAsync({ + const promptResponse = await this.client.session.promptAsync({ sessionID: this.sessionId, directory: this.config.cwd, parts, @@ -1217,22 +1230,18 @@ class OpenCodeAgentSession implements AgentSession { ...(effectiveMode ? { agent: effectiveMode } : {}), ...(effectiveVariant ? { variant: effectiveVariant } : {}), }); - } - if (promptResponse.error) { - const errorMsg = normalizeTurnFailureError(promptResponse.error); - this.notifySubscribers({ - type: "turn_failed", - provider: "opencode", - error: errorMsg, - }); - throw new Error(errorMsg); + if (promptResponse.error) { + const errorMsg = normalizeTurnFailureError(promptResponse.error); + this.notifySubscribers({ + type: "turn_failed", + provider: "opencode", + error: errorMsg, + }); + throw new Error(errorMsg); + } } - const turnId = this.createTurnId(); - this.activeForegroundTurnId = turnId; - void this.consumeEventStream(turnId, turnAbortController); - return { turnId }; } From 325ab500b332243b14373c8940581100e441ff72 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 3 Apr 2026 23:49:49 +0700 Subject: [PATCH 25/73] chore(release): cut 0.1.45-rc.2 --- package-lock.json | 38 ++++++++++++------------ package.json | 2 +- packages/app/package.json | 8 ++--- packages/cli/package.json | 6 ++-- packages/desktop/package.json | 6 ++-- packages/expo-two-way-audio/package.json | 2 +- packages/highlight/package.json | 2 +- packages/relay/package.json | 2 +- packages/server/package.json | 6 ++-- packages/website/package.json | 2 +- 10 files changed, 37 insertions(+), 37 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8cda69529..b07de6976 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.1.45-rc.1", + "version": "0.1.45-rc.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.1.45-rc.1", + "version": "0.1.45-rc.2", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "workspaces": [ @@ -34996,16 +34996,16 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.1.45-rc.1", + "version": "0.1.45-rc.2", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@expo/vector-icons": "^15.0.2", "@floating-ui/react-native": "^0.10.7", - "@getpaseo/expo-two-way-audio": "0.1.45-rc.1", - "@getpaseo/highlight": "0.1.45-rc.1", - "@getpaseo/server": "0.1.45-rc.1", + "@getpaseo/expo-two-way-audio": "0.1.45-rc.2", + "@getpaseo/highlight": "0.1.45-rc.2", + "@getpaseo/server": "0.1.45-rc.2", "@gorhom/bottom-sheet": "^5.2.6", "@gorhom/portal": "^1.0.14", "@react-native-async-storage/async-storage": "2.2.0", @@ -35122,11 +35122,11 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.1.45-rc.1", + "version": "0.1.45-rc.2", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/relay": "0.1.45-rc.1", - "@getpaseo/server": "0.1.45-rc.1", + "@getpaseo/relay": "0.1.45-rc.2", + "@getpaseo/server": "0.1.45-rc.2", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", @@ -35167,11 +35167,11 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.1.45-rc.1", + "version": "0.1.45-rc.2", "license": "AGPL-3.0-or-later", "dependencies": { - "@getpaseo/cli": "0.1.45-rc.1", - "@getpaseo/server": "0.1.45-rc.1", + "@getpaseo/cli": "0.1.45-rc.2", + "@getpaseo/server": "0.1.45-rc.2", "electron-log": "^5.4.3", "electron-updater": "^6.6.2", "ws": "^8.14.2" @@ -35205,7 +35205,7 @@ }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", - "version": "0.1.45-rc.1", + "version": "0.1.45-rc.2", "license": "MIT", "devDependencies": { "@biomejs/biome": "1.9.4", @@ -35406,7 +35406,7 @@ }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.1.45-rc.1", + "version": "0.1.45-rc.2", "dependencies": { "@lezer/common": "^1.5.0", "@lezer/cpp": "^1.1.5", @@ -35432,7 +35432,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.1.45-rc.1", + "version": "0.1.45-rc.2", "dependencies": { "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", @@ -35448,14 +35448,14 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.1.45-rc.1", + "version": "0.1.45-rc.2", "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", "@ai-sdk/openai": "2.0.52", "@anthropic-ai/claude-agent-sdk": "^0.2.11", "@deepgram/sdk": "^3.4.0", - "@getpaseo/highlight": "0.1.45-rc.1", - "@getpaseo/relay": "0.1.45-rc.1", + "@getpaseo/highlight": "0.1.45-rc.2", + "@getpaseo/relay": "0.1.45-rc.2", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.2.6", @@ -35854,7 +35854,7 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.1.45-rc.1", + "version": "0.1.45-rc.2", "dependencies": { "@cloudflare/vite-plugin": "^1.20.3", "@cloudflare/workers-types": "^4.20260114.0", diff --git a/package.json b/package.json index bb5c27fa4..c932443a4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.1.45-rc.1", + "version": "0.1.45-rc.2", "private": true, "workspaces": [ "packages/expo-two-way-audio", diff --git a/packages/app/package.json b/packages/app/package.json index 6bf6147fa..1442fbc57 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,7 +1,7 @@ { "name": "@getpaseo/app", "main": "index.ts", - "version": "0.1.45-rc.1", + "version": "0.1.45-rc.2", "private": true, "scripts": { "start": "expo start", @@ -31,9 +31,9 @@ "@dnd-kit/utilities": "^3.2.2", "@expo/vector-icons": "^15.0.2", "@floating-ui/react-native": "^0.10.7", - "@getpaseo/expo-two-way-audio": "0.1.45-rc.1", - "@getpaseo/highlight": "0.1.45-rc.1", - "@getpaseo/server": "0.1.45-rc.1", + "@getpaseo/expo-two-way-audio": "0.1.45-rc.2", + "@getpaseo/highlight": "0.1.45-rc.2", + "@getpaseo/server": "0.1.45-rc.2", "@gorhom/bottom-sheet": "^5.2.6", "@gorhom/portal": "^1.0.14", "@react-native-async-storage/async-storage": "2.2.0", diff --git a/packages/cli/package.json b/packages/cli/package.json index aeecfdd12..e4bce809d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.1.45-rc.1", + "version": "0.1.45-rc.2", "description": "Paseo CLI - control your AI coding agents from the command line", "type": "module", "files": [ @@ -24,8 +24,8 @@ }, "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/relay": "0.1.45-rc.1", - "@getpaseo/server": "0.1.45-rc.1", + "@getpaseo/relay": "0.1.45-rc.2", + "@getpaseo/server": "0.1.45-rc.2", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index e3227fdc9..3fb597e22 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.1.45-rc.1", + "version": "0.1.45-rc.2", "private": true, "description": "Paseo desktop app (Electron wrapper)", "main": "dist/main.js", @@ -12,8 +12,8 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { - "@getpaseo/cli": "0.1.45-rc.1", - "@getpaseo/server": "0.1.45-rc.1", + "@getpaseo/cli": "0.1.45-rc.2", + "@getpaseo/server": "0.1.45-rc.2", "electron-log": "^5.4.3", "electron-updater": "^6.6.2", "ws": "^8.14.2" diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index 0004d735c..3e49cd3b6 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.1.45-rc.1", + "version": "0.1.45-rc.2", "description": "Native module for two way audio streaming", "main": "build/index.js", "types": "build/index.d.ts", diff --git a/packages/highlight/package.json b/packages/highlight/package.json index 5e3a57c88..13f4ca59b 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.1.45-rc.1", + "version": "0.1.45-rc.2", "type": "module", "publishConfig": { "access": "public" diff --git a/packages/relay/package.json b/packages/relay/package.json index a7b3773e8..240516db2 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.1.45-rc.1", + "version": "0.1.45-rc.2", "description": "Paseo relay for bridging daemon and client connections", "type": "module", "publishConfig": { diff --git a/packages/server/package.json b/packages/server/package.json index 2129c59c0..1ecfc3244 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.1.45-rc.1", + "version": "0.1.45-rc.2", "description": "Paseo backend server", "type": "module", "publishConfig": { @@ -64,8 +64,8 @@ "@ai-sdk/openai": "2.0.52", "@anthropic-ai/claude-agent-sdk": "^0.2.11", "@deepgram/sdk": "^3.4.0", - "@getpaseo/highlight": "0.1.45-rc.1", - "@getpaseo/relay": "0.1.45-rc.1", + "@getpaseo/highlight": "0.1.45-rc.2", + "@getpaseo/relay": "0.1.45-rc.2", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.2.6", diff --git a/packages/website/package.json b/packages/website/package.json index 8c8c6ba2e..8f4068911 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.1.45-rc.1", + "version": "0.1.45-rc.2", "private": true, "type": "module", "scripts": { From 9a6a8ce4979f18f996baeb69e1bdfb017ab5a773 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 3 Apr 2026 16:50:58 +0000 Subject: [PATCH 26/73] fix: update lockfile signatures and Nix hash --- nix/package.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/package.nix b/nix/package.nix index 4b027ad3a..ddf5dc6bf 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -42,7 +42,7 @@ buildNpmPackage rec { # To update: run `nix build` with lib.fakeHash, copy the `got:` hash. # CI auto-updates this when package-lock.json changes (see .github/workflows/). - npmDepsHash = "sha256-h27d75Y6ZR7Mq8n/iju7h3BdRkgfbUI4yyVl2WFRYik="; + npmDepsHash = "sha256-rbw+tXNKO7lNRNG6cdlaLyLLaEEdMzDD/Ob7/8noeKw="; # Prevent onnxruntime-node's install script from running during automatic # npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox). From 178b4cd618a5844216e889b270e3eba45262e93d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 4 Apr 2026 00:14:30 +0700 Subject: [PATCH 27/73] fix(app): reorder settings sections for better grouping --- packages/app/src/screens/settings-screen.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index c2e8235f6..5dc28a0b6 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -85,17 +85,20 @@ function getSettingsSections(context: { isDesktopApp: boolean }): SettingsSectio { id: "hosts", label: "Hosts", icon: Server }, { id: "appearance", label: "Appearance", icon: Palette }, { id: "shortcuts", label: "Shortcuts", icon: Keyboard }, - { id: "diagnostics", label: "Diagnostics", icon: Stethoscope }, - { id: "about", label: "About", icon: Info }, ]; if (context.isDesktopApp) { sections.push( - { id: "permissions", label: "Permissions", icon: Shield }, { id: "daemon", label: "Daemon", icon: Settings }, + { id: "permissions", label: "Permissions", icon: Shield }, ); } + sections.push( + { id: "diagnostics", label: "Diagnostics", icon: Stethoscope }, + { id: "about", label: "About", icon: Info }, + ); + return sections; } From 100502ae1eababb3a597895cd0fc758bfacde320 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 4 Apr 2026 08:40:29 +0700 Subject: [PATCH 28/73] feat(app): add setup hint and paseo.sh link on mobile welcome screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New app store users land on the welcome screen with no context. Show a brief explanation that the desktop app or server is needed, plus a link to paseo.sh — only on mobile (iOS/Android), hidden on web/desktop. --- .../app/src/components/welcome-screen.tsx | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/welcome-screen.tsx b/packages/app/src/components/welcome-screen.tsx index 19f7a5310..bbdf38f7e 100644 --- a/packages/app/src/components/welcome-screen.tsx +++ b/packages/app/src/components/welcome-screen.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; import { Pressable, Text, View, Platform, ScrollView } from "react-native"; import { useRouter } from "expo-router"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { QrCode, Link2, ClipboardPaste } from "lucide-react-native"; +import { QrCode, Link2, ClipboardPaste, ExternalLink } from "lucide-react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import type { HostProfile } from "@/types/host-connection"; import { @@ -20,6 +20,7 @@ import { resolveAppVersion } from "@/utils/app-version"; import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates"; import { buildHostRootRoute } from "@/utils/host-routes"; import { PaseoLogo } from "@/components/icons/paseo-logo"; +import { openExternalUrl } from "@/utils/open-external-url"; type WelcomeAction = { key: "scan-qr" | "direct-connection" | "paste-pairing-link"; @@ -118,6 +119,25 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.destructive, fontSize: theme.fontSize.sm, }, + setupHint: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + textAlign: "center", + marginBottom: theme.spacing[6], + lineHeight: theme.fontSize.sm * 1.5, + }, + setupLink: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 6, + marginBottom: theme.spacing[6], + }, + setupLinkText: { + color: theme.colors.accent, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.medium, + }, versionLabel: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, @@ -326,6 +346,21 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { {showHostList ? "Connecting to your hosts…" : "Connect to your host to start"} + {!showHostList && Platform.OS !== "web" && ( + <> + + You need the Paseo desktop app or server running on your computer first. + + openExternalUrl("https://paseo.sh")} + > + Get started at paseo.sh + + + + )} + {actions.map((action) => { const Icon = action.icon; From 7b4b42068c74fe55ca7fb11d217313e7c1b9b3e0 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 4 Apr 2026 09:41:54 +0700 Subject: [PATCH 29/73] docs(release): add pre-release sanity check and clarify changelog scope Add a Codex 5.4 review step before cutting releases to catch breaking changes and backward-compatibility issues (mobile apps lag behind desktop/daemon updates). Clarify that the changelog always covers the delta from the previous stable release, not from the last RC. --- docs/RELEASE.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docs/RELEASE.md b/docs/RELEASE.md index a7d07292d..b327db20f 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -122,8 +122,34 @@ No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to - **Only Claude should write changelog entries.** - If you are Codex and a stable release needs a changelog entry, launch a Claude agent with Paseo to draft it, then review and commit the result. +## Pre-release sanity check + +Before cutting any release (RC or stable), run a Codex review of the diff as a last line of defence against shipping bugs. + +Load the `paseo` skill and launch a **Codex 5.4** agent with a prompt like: + +> Review the diff between the latest release tag and HEAD. Focus on: +> +> 1. **Breaking changes** — especially in the WebSocket protocol, agent lifecycle, and any server↔client contract. +> 2. **Backward compatibility** — mobile apps lag behind desktop/daemon updates by days. Users will update desktop and daemon immediately but keep running the old app. Flag anything that requires both sides to update in lockstep. +> 3. **Regressions** — anything that looks like it could break existing functionality. +> +> Diff: `git diff ..HEAD` + +The agent's job is a deep sanity check, not a full code review. If it flags anything, investigate before proceeding. + +## Changelog scope + +The changelog always covers **stable-to-HEAD**: + +- **RC release**: the diff and release notes cover `latest stable tag → HEAD`. RC release notes are auto-generated and not added to `CHANGELOG.md`. +- **Stable release**: the diff and changelog entry cover `latest stable tag → HEAD`. Any intermediate RCs are skipped — the changelog captures the full delta from the previous stable release, not just what changed since the last RC. + +In other words, RCs are checkpoints along the way; the changelog only records the final jump from one stable version to the next. + ## Completion checklist +- [ ] Run the pre-release sanity check (see above) and address any findings - [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors) - [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format - [ ] `npm run release:patch` or `npm run release:promote` completes successfully From dcffe46f90be1eab561dce67513075f52604457d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 4 Apr 2026 12:22:54 +0700 Subject: [PATCH 30/73] fix(server): increase agent creation timeout to 60s Under contention, agent creation can be slow enough to exceed the previous 15s timeout even though the agent is successfully created. --- packages/server/src/client/daemon-client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 4d6f0e2a2..886eaa841 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -1402,7 +1402,7 @@ export class DaemonClient { const status = await this.sendRequest({ requestId, message, - timeout: 15000, + timeout: 60000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "status") { From 4896cfe970770d3136126b273e0ee3497c01ff5a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 4 Apr 2026 12:43:54 +0700 Subject: [PATCH 31/73] fix(app): fix sidebar crash when switching iOS theme Unistyles' StyleSheet.create((theme) => ...) wraps styled components in and patches native view properties via C++. When these dynamic styles are applied to Reanimated's Animated.View, a theme change causes both systems to fight over the same native node, crashing with "Unable to find node on an unmounted component." Fix: use plain React Native StyleSheet for static positioning on Animated.View, and pass theme-dependent values (backgroundColor) as inline styles from useUnistyles() instead of Unistyles dynamic sheets. Also: - Add gestureAnimatingRef to prevent double withTiming during gesture opens - Remove all debug instrumentation added during investigation - Add Maestro flow and bash script for automated theme-toggle verification - Add docs/MOBILE_TESTING.md covering Maestro patterns, self-verification loops, the Unistyles+Reanimated pitfall, and simulator commands --- docs/MOBILE_TESTING.md | 206 ++++++++++++++++++ packages/app/maestro/.gitignore | 2 + packages/app/maestro/sidebar-theme-repro.yaml | 31 +++ packages/app/maestro/test-sidebar-theme.sh | 62 ++++++ packages/app/src/app/_layout.tsx | 23 +- .../app/src/components/explorer-sidebar.tsx | 90 ++++---- packages/app/src/components/left-sidebar.tsx | 55 +++-- .../explorer-sidebar-animation-context.tsx | 11 + .../contexts/sidebar-animation-context.tsx | 16 ++ .../src/hooks/use-explorer-open-gesture.ts | 25 ++- 10 files changed, 453 insertions(+), 68 deletions(-) create mode 100644 docs/MOBILE_TESTING.md create mode 100644 packages/app/maestro/.gitignore create mode 100644 packages/app/maestro/sidebar-theme-repro.yaml create mode 100755 packages/app/maestro/test-sidebar-theme.sh diff --git a/docs/MOBILE_TESTING.md b/docs/MOBILE_TESTING.md new file mode 100644 index 000000000..12f543ad7 --- /dev/null +++ b/docs/MOBILE_TESTING.md @@ -0,0 +1,206 @@ +# Mobile Testing + +## Maestro + +Maestro flows live in `packages/app/maestro/`. Reusable sub-flows live in `packages/app/maestro/flows/`. + +Run a flow: + +```bash +maestro test packages/app/maestro/my-flow.yaml +``` + +### Screenshots + +`takeScreenshot` writes to the **current working directory** — there's no way to configure the output path in the YAML. To keep screenshots out of the checkout, `cd` into a temp directory and use an absolute path for the flow: + +```bash +FLOW="$(pwd)/packages/app/maestro/my-flow.yaml" +mkdir -p /tmp/maestro-out +cd /tmp/maestro-out && maestro test "$FLOW" +``` + +`packages/app/maestro/.gitignore` excludes `*.png` as a safety net. + +### Element targeting + +Use `testID` or `nativeID` on components, then target with `id:` in flows. Prefer this over text matching — text breaks on copy changes. + +```tsx +// Component + +``` + +```yaml +# Flow +- tapOn: + id: "sidebar-sessions" +- assertVisible: + id: "sidebar-sessions" +``` + +### Conditional steps + +Use `runFlow:when:visible` for steps that should only execute when a specific element is on screen: + +```yaml +- runFlow: + when: + visible: + id: "sidebar-sessions" + commands: + - swipe: + direction: LEFT + duration: 300 +``` + +This is how `flows/dev-client.yaml` handles Expo dev client screens that only appear in dev builds. + +### Don't use launchApp against a running dev app + +`launchApp` kills and restarts the app, disrupting Expo dev client state and host connections. For flows that test against an already-running dev app, **omit launchApp entirely** — just interact with whatever is on screen. + +Use `launchApp` only in flows that need a clean start (e.g., onboarding tests). + +### Swipe gestures + +Use `start`/`end` with percentage coordinates for precise control: + +```yaml +# Edge swipe from left to open sidebar +- swipe: + start: "5%,50%" + end: "80%,50%" + duration: 300 +``` + +`direction: RIGHT` is simpler but less precise — use it for generic swipes, use coordinates when the start position matters (edge gestures, avoiding specific UI regions). + +### Assertions + +`assertVisible` checks **actual screen visibility**, not just view tree presence. An element that exists in the tree but is off-screen (e.g., `translateX: -400`) will correctly fail `assertVisible`. This makes it reliable for catching animation bugs where state says "open" but the view is visually hidden. + +For async elements, use `extendedWaitUntil`: + +```yaml +- extendedWaitUntil: + visible: ".*Online.*" + timeout: 90000 +``` + +### Dev client handling + +Two reusable flows handle Expo dev client screens after launch: + +- `flows/launch.yaml` — handles dev launcher, dismisses dev menu, asserts "Welcome to Paseo" +- `flows/dev-client.yaml` — same but without asserting a particular app route + +## Self-verification loops + +Maestro can only interact with the app UI — it can't toggle iOS appearance, change locale, or simulate network conditions. For bugs that depend on system-level state, wrap Maestro in a bash script that handles the system changes between Maestro runs. + +This pattern also lets agents self-verify fixes without manual user testing. + +### Pattern + +1. Run baseline Maestro flow (confirm feature works) +2. Make system-level change via `xcrun simctl` (toggle appearance, etc.) +3. Re-run Maestro flow (confirm feature still works) +4. Repeat N iterations to catch intermittent failures + +Scripts run `maestro test` from inside a temp directory so screenshots don't dirty the checkout. + +See `packages/app/maestro/test-sidebar-theme.sh` for the canonical example: + +```bash +bash packages/app/maestro/test-sidebar-theme.sh 6 1 +# Args: iterations=6, wait_seconds=1 between toggle and test +``` + +Key elements of the script pattern: + +```bash +set -euo pipefail +ITERATIONS="${1:-3}" + +for i in $(seq 1 "$ITERATIONS"); do + # Toggle system state + xcrun simctl ui booted appearance light + + # Wait for change to propagate + sleep 1 + + # Run Maestro flow and capture result + if maestro test "$FLOW" 2>&1 | tee "$ITER_DIR/test.log"; then + echo "PASS" + else + echo "FAIL" + xcrun simctl io booted screenshot "$ITER_DIR/failure-state.png" + fi +done +``` + +## Unistyles + Reanimated + +### The crash + +Applying Unistyles theme-reactive styles (`StyleSheet.create((theme) => ...)`) directly to `Animated.View` causes **"Unable to find node on an unmounted component"** on theme change. + +Unistyles wraps styled components in `` and patches native view properties via C++. Reanimated also manages the same native node for animated transforms. When the theme changes, both systems try to update the node simultaneously and the view crashes. + +### The fix + +Use plain React Native `StyleSheet.create` for static positioning on `Animated.View`. Pass theme-dependent values as inline styles from `useUnistyles()`: + +```tsx +// BAD: Unistyles dynamic style on Animated.View +const styles = StyleSheet.create((theme) => ({ + sidebar: { + position: "absolute", + top: 0, + left: 0, + bottom: 0, + backgroundColor: theme.colors.surfaceSidebar, // theme-reactive + overflow: "hidden", + }, +})); + + +``` + +```tsx +// GOOD: static stylesheet + inline theme values +import { StyleSheet as RNStyleSheet } from "react-native"; + +const staticStyles = RNStyleSheet.create({ + sidebar: { + position: "absolute", + top: 0, + left: 0, + bottom: 0, + overflow: "hidden", + }, +}); + +const { theme } = useUnistyles(); + + +``` + +Regular `View` components can safely use Unistyles dynamic styles — the conflict is specific to `Animated.View`. + +## iOS Simulator + +```bash +# Screenshot +xcrun simctl io booted screenshot /tmp/screenshot.png + +# Dark/light mode +xcrun simctl ui booted appearance # check current +xcrun simctl ui booted appearance dark # set dark +xcrun simctl ui booted appearance light # set light +``` + +Expo dev server logs are in the tmux pane running `npm run dev`. Daemon logs are at `$PASEO_HOME/daemon.log` (see [DEVELOPMENT.md](DEVELOPMENT.md)). diff --git a/packages/app/maestro/.gitignore b/packages/app/maestro/.gitignore new file mode 100644 index 000000000..c7a365a58 --- /dev/null +++ b/packages/app/maestro/.gitignore @@ -0,0 +1,2 @@ +# Maestro takeScreenshot artifacts +*.png diff --git a/packages/app/maestro/sidebar-theme-repro.yaml b/packages/app/maestro/sidebar-theme-repro.yaml new file mode 100644 index 000000000..3c9dff9e9 --- /dev/null +++ b/packages/app/maestro/sidebar-theme-repro.yaml @@ -0,0 +1,31 @@ +appId: sh.paseo +--- +# Ensure sidebar is closed: if sidebar-sessions is visible, close it via swipe left +- runFlow: + when: + visible: + id: "sidebar-sessions" + commands: + - swipe: + direction: LEFT + duration: 300 + +# Small pause for close animation +- takeScreenshot: 00-sidebar-closed + +# Open sidebar via swipe right gesture (the actual path that triggers the bug) +- swipe: + start: "5%,50%" + end: "80%,50%" + duration: 300 + +# Verify sidebar opened +- assertVisible: + id: "sidebar-sessions" +- takeScreenshot: 01-sidebar-opened + +# Close sidebar via swipe left +- swipe: + direction: LEFT + duration: 300 +- takeScreenshot: 02-sidebar-closed-again diff --git a/packages/app/maestro/test-sidebar-theme.sh b/packages/app/maestro/test-sidebar-theme.sh new file mode 100755 index 000000000..6a89f133b --- /dev/null +++ b/packages/app/maestro/test-sidebar-theme.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Verification loop for sidebar theme bug. +# +# Maestro can't toggle iOS appearance, so this script bridges the gap: +# toggle appearance via xcrun simctl, then run Maestro to verify the sidebar +# still works. Runs N iterations to catch intermittent failures. +# +# Usage: +# bash packages/app/maestro/test-sidebar-theme.sh [iterations] [wait_seconds] +# bash packages/app/maestro/test-sidebar-theme.sh 6 1 +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +FLOW="$REPO_ROOT/packages/app/maestro/sidebar-theme-repro.yaml" +OUT_DIR="/tmp/sidebar-theme-test-$(date +%s)" +ITERATIONS="${1:-3}" +WAIT_SECS="${2:-1}" +mkdir -p "$OUT_DIR" + +echo "=== Sidebar Theme Bug Verification ===" +echo "Output dir: $OUT_DIR" +echo "Iterations: $ITERATIONS, wait after toggle: ${WAIT_SECS}s" + +FAILURES=0 + +for i in $(seq 1 "$ITERATIONS"); do + echo "" + echo "========== Iteration $i / $ITERATIONS ==========" + + CURRENT=$(xcrun simctl ui booted appearance 2>&1 | tr -d '[:space:]') + echo "Current appearance: $CURRENT" + + if [ "$CURRENT" = "dark" ]; then + xcrun simctl ui booted appearance light + echo "Switched to light mode" + else + xcrun simctl ui booted appearance dark + echo "Switched to dark mode" + fi + + echo "Waiting ${WAIT_SECS}s..." + sleep "$WAIT_SECS" + + ITER_DIR="$OUT_DIR/iter-$i" + mkdir -p "$ITER_DIR" + + # Run maestro from the output dir so takeScreenshot artifacts land there + if (cd "$ITER_DIR" && maestro test "$FLOW") 2>&1 | tee "$ITER_DIR/test.log"; then + echo " -> PASS (iteration $i)" + else + echo " -> FAIL (iteration $i) — bug reproduced!" + FAILURES=$((FAILURES + 1)) + xcrun simctl io booted screenshot "$ITER_DIR/failure-state.png" 2>/dev/null || true + fi +done + +# Restore to dark mode +xcrun simctl ui booted appearance dark + +echo "" +echo "=== Summary: $FAILURES failures out of $ITERATIONS iterations ===" +echo "Output: $OUT_DIR" diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 47b989309..b517c1072 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -420,14 +420,28 @@ function MobileGestureWrapper({ const mobileView = usePanelStore((state) => state.mobileView); const openAgentList = usePanelStore((state) => state.openAgentList); const horizontalScroll = useHorizontalScrollOptional(); - const { translateX, backdropOpacity, windowWidth, animateToOpen, animateToClose, isGesturing } = - useSidebarAnimation(); + const { + translateX, + backdropOpacity, + windowWidth, + animateToOpen, + animateToClose, + isGesturing, + gestureAnimatingRef, + openGestureRef, + } = useSidebarAnimation(); const touchStartX = useSharedValue(0); const openGestureEnabled = chromeEnabled && mobileView === "agent"; + const handleGestureOpen = useCallback(() => { + gestureAnimatingRef.current = true; + openAgentList(); + }, [openAgentList, gestureAnimatingRef]); + const openGesture = useMemo( () => Gesture.Pan() + .withRef(openGestureRef) .enabled(openGestureEnabled) .manualActivation(true) .failOffsetY([-10, 10]) @@ -470,7 +484,7 @@ function MobileGestureWrapper({ const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500; if (shouldOpen) { animateToOpen(); - runOnJS(openAgentList)(); + runOnJS(handleGestureOpen)(); } else { animateToClose(); } @@ -485,8 +499,9 @@ function MobileGestureWrapper({ backdropOpacity, animateToOpen, animateToClose, - openAgentList, + handleGestureOpen, isGesturing, + openGestureRef, horizontalScroll?.isAnyScrolledRight, touchStartX, ], diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index 5d22549c5..1ab3eb94c 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -1,5 +1,12 @@ import { useCallback, useEffect, useMemo, useRef } from "react"; -import { View, Text, Pressable, Platform, useWindowDimensions } from "react-native"; +import { + View, + Text, + Pressable, + Platform, + useWindowDimensions, + StyleSheet as RNStyleSheet, +} from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useIsFocused } from "@react-navigation/native"; import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-native-reanimated"; @@ -81,6 +88,7 @@ export function ExplorerSidebar({ animateToOpen, animateToClose, isGesturing, + gestureAnimatingRef, closeGestureRef, } = useExplorerSidebarAnimation(); @@ -101,6 +109,11 @@ export function ExplorerSidebar({ [closeToAgent, desktopFileExplorerOpen, isOpen, mobileView], ); + const handleCloseFromGesture = useCallback(() => { + gestureAnimatingRef.current = true; + closeToAgent(); + }, [closeToAgent, gestureAnimatingRef]); + const enableSidebarCloseGesture = isMobile && isOpen; const handleTabPress = useCallback( @@ -175,7 +188,7 @@ export function ExplorerSidebar({ }); if (shouldClose) { animateToClose(); - runOnJS(handleClose)("swipe-close-gesture"); + runOnJS(handleCloseFromGesture)(); } else { animateToOpen(); } @@ -190,7 +203,7 @@ export function ExplorerSidebar({ backdropOpacity, animateToOpen, animateToClose, - handleClose, + handleCloseFromGesture, isGesturing, closeGestureRef, closeTouchStartX, @@ -251,18 +264,13 @@ export function ExplorerSidebar({ return ( {/* Backdrop */} - - handleClose("backdrop-press")} - /> - + - {/* Resize handle - absolutely positioned over left border */} - - - + + + {/* Resize handle - absolutely positioned over left border */} + + + - handleClose("desktop-close-button")} - serverId={serverId} - workspaceId={workspaceId} - workspaceRoot={workspaceRoot} - isGit={isGit} - isMobile={false} - onOpenFile={onOpenFile} - /> + handleClose("desktop-close-button")} + serverId={serverId} + workspaceId={workspaceId} + workspaceRoot={workspaceRoot} + isGit={isGit} + isMobile={false} + onOpenFile={onOpenFile} + /> + ); } @@ -400,24 +410,28 @@ function SidebarContent({ ); } -const styles = StyleSheet.create((theme) => ({ +// Static styles for Animated.Views — must NOT use Unistyles dynamic theme to +// avoid the "Unable to find node on an unmounted component" crash when Unistyles +// tries to patch the native node that Reanimated also manages. +const explorerStaticStyles = RNStyleSheet.create({ backdrop: { - ...StyleSheet.absoluteFillObject, + ...RNStyleSheet.absoluteFillObject, backgroundColor: "rgba(0, 0, 0, 0.5)", }, - backdropPressable: { - flex: 1, - }, mobileSidebar: { - position: "absolute", + position: "absolute" as const, top: 0, right: 0, bottom: 0, - backgroundColor: theme.colors.surfaceSidebar, - overflow: "hidden", + overflow: "hidden" as const, }, desktopSidebar: { - position: "relative", + position: "relative" as const, + }, +}); + +const styles = StyleSheet.create((theme) => ({ + desktopSidebarBorder: { borderLeftWidth: 1, borderLeftColor: theme.colors.border, backgroundColor: theme.colors.surfaceSidebar, diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index ca7377e44..ab5b40371 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -10,7 +10,14 @@ import { type RefObject, type SetStateAction, } from "react"; -import { View, Pressable, Text, Platform, useWindowDimensions } from "react-native"; +import { + View, + Pressable, + Text, + Platform, + useWindowDimensions, + StyleSheet as RNStyleSheet, +} from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import Animated, { useAnimatedStyle, @@ -370,14 +377,16 @@ function MobileSidebar({ animateToOpen, animateToClose, isGesturing, + gestureAnimatingRef, closeGestureRef, } = useSidebarAnimation(); const closeTouchStartX = useSharedValue(0); const closeTouchStartY = useSharedValue(0); - const handleClose = useCallback(() => { + const handleCloseFromGesture = useCallback(() => { + gestureAnimatingRef.current = true; closeToAgent(); - }, [closeToAgent]); + }, [closeToAgent, gestureAnimatingRef]); const handleViewMore = useCallback(() => { if (!activeServerId) { @@ -452,7 +461,7 @@ function MobileSidebar({ const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500; if (shouldClose) { animateToClose(); - runOnJS(handleClose)(); + runOnJS(handleCloseFromGesture)(); } else { animateToOpen(); } @@ -471,7 +480,7 @@ function MobileSidebar({ backdropOpacity, animateToClose, animateToOpen, - handleClose, + handleCloseFromGesture, ], ); @@ -498,13 +507,11 @@ function MobileSidebar({ return ( - - - + @@ -526,7 +533,7 @@ function MobileSidebar({ projects={projects} isRefreshing={isManualRefresh && isRevalidating} onRefresh={handleRefresh} - onWorkspacePress={closeToAgent} + onWorkspacePress={() => closeToAgent()} onAddProject={handleOpenProject} parentGestureRef={closeGestureRef} /> @@ -688,7 +695,8 @@ function DesktopSidebar({ } return ( - + + {padding.top > 0 ? : null} @@ -796,32 +804,37 @@ function DesktopSidebar({ style={[styles.resizeHandle, Platform.OS === "web" && ({ cursor: "col-resize" } as any)]} /> + ); } -const styles = StyleSheet.create((theme) => ({ +// Static styles for Animated.Views — must NOT use Unistyles dynamic theme to +// avoid the "Unable to find node on an unmounted component" crash when Unistyles +// tries to patch the native node that Reanimated also manages. +const staticStyles = RNStyleSheet.create({ backdrop: { - ...StyleSheet.absoluteFillObject, + ...RNStyleSheet.absoluteFillObject, backgroundColor: "rgba(0, 0, 0, 0.5)", }, - backdropPressable: { - flex: 1, - }, mobileSidebar: { - position: "absolute", + position: "absolute" as const, top: 0, left: 0, bottom: 0, - backgroundColor: theme.colors.surfaceSidebar, - overflow: "hidden", + overflow: "hidden" as const, }, + desktopSidebar: { + position: "relative" as const, + }, +}); + +const styles = StyleSheet.create((theme) => ({ sidebarContent: { flex: 1, minHeight: 0, }, - desktopSidebar: { - position: "relative", + desktopSidebarBorder: { borderRightWidth: 1, borderRightColor: theme.colors.border, backgroundColor: theme.colors.surfaceSidebar, diff --git a/packages/app/src/contexts/explorer-sidebar-animation-context.tsx b/packages/app/src/contexts/explorer-sidebar-animation-context.tsx index c6e1c3715..80d5c191d 100644 --- a/packages/app/src/contexts/explorer-sidebar-animation-context.tsx +++ b/packages/app/src/contexts/explorer-sidebar-animation-context.tsx @@ -18,6 +18,8 @@ interface ExplorerSidebarAnimationContextValue { animateToOpen: () => void; animateToClose: () => void; isGesturing: SharedValue; + gestureAnimatingRef: React.MutableRefObject; + openGestureRef: React.MutableRefObject; closeGestureRef: React.MutableRefObject; } @@ -39,6 +41,8 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React const translateX = useSharedValue(initialTargets.translateX); const backdropOpacity = useSharedValue(initialTargets.backdropOpacity); const isGesturing = useSharedValue(false); + const gestureAnimatingRef = useRef(false); + const openGestureRef = useRef(undefined); const closeGestureRef = useRef(undefined); // Track previous isOpen to detect changes @@ -61,6 +65,11 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React return; } + if (gestureAnimatingRef.current) { + gestureAnimatingRef.current = false; + return; + } + // Don't animate if we're in the middle of a gesture - the gesture handler will handle it if (isGesturing.value) { return; @@ -123,6 +132,8 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React animateToOpen, animateToClose, isGesturing, + gestureAnimatingRef, + openGestureRef, closeGestureRef, }} > diff --git a/packages/app/src/contexts/sidebar-animation-context.tsx b/packages/app/src/contexts/sidebar-animation-context.tsx index 4e10c0361..f315eee6c 100644 --- a/packages/app/src/contexts/sidebar-animation-context.tsx +++ b/packages/app/src/contexts/sidebar-animation-context.tsx @@ -27,6 +27,8 @@ interface SidebarAnimationContextValue { animateToOpen: () => void; animateToClose: () => void; isGesturing: SharedValue; + gestureAnimatingRef: React.MutableRefObject; + openGestureRef: React.MutableRefObject; closeGestureRef: React.MutableRefObject; } @@ -46,6 +48,8 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode }) const translateX = useSharedValue(initialTargets.translateX); const backdropOpacity = useSharedValue(initialTargets.backdropOpacity); const isGesturing = useSharedValue(false); + const gestureAnimatingRef = useRef(false); + const openGestureRef = useRef(undefined); const closeGestureRef = useRef(undefined); // Track previous isOpen to detect changes @@ -68,6 +72,14 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode }) return; } + // Gesture onEnd already started the animation on the UI thread — skip to avoid + // a second competing withTiming that can desync translateX and backdropOpacity + // after a provider remount (e.g. theme change). + if (gestureAnimatingRef.current) { + gestureAnimatingRef.current = false; + return; + } + // Don't animate if we're in the middle of a gesture - the gesture handler will handle it if (isGesturing.value) { return; @@ -123,6 +135,8 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode }) animateToOpen, animateToClose, isGesturing, + gestureAnimatingRef, + openGestureRef, closeGestureRef, }), [ @@ -132,6 +146,8 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode }) animateToOpen, animateToClose, isGesturing, + gestureAnimatingRef, + openGestureRef, closeGestureRef, ], ); diff --git a/packages/app/src/hooks/use-explorer-open-gesture.ts b/packages/app/src/hooks/use-explorer-open-gesture.ts index ad0a031cf..052a3d06b 100644 --- a/packages/app/src/hooks/use-explorer-open-gesture.ts +++ b/packages/app/src/hooks/use-explorer-open-gesture.ts @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; import { Gesture } from "react-native-gesture-handler"; import { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-native-reanimated"; import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context"; @@ -9,14 +9,28 @@ interface UseExplorerOpenGestureParams { } export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestureParams) { - const { translateX, backdropOpacity, windowWidth, animateToOpen, animateToClose, isGesturing } = - useExplorerSidebarAnimation(); + const { + translateX, + backdropOpacity, + windowWidth, + animateToOpen, + animateToClose, + isGesturing, + gestureAnimatingRef, + openGestureRef, + } = useExplorerSidebarAnimation(); const touchStartX = useSharedValue(0); const touchStartY = useSharedValue(0); + const handleGestureOpen = useCallback(() => { + gestureAnimatingRef.current = true; + onOpen(); + }, [onOpen, gestureAnimatingRef]); + return useMemo( () => Gesture.Pan() + .withRef(openGestureRef) .enabled(enabled) .manualActivation(true) .onTouchesDown((event) => { @@ -78,7 +92,7 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu const shouldOpen = shouldOpenByPosition || shouldOpenByVelocity; if (shouldOpen) { animateToOpen(); - runOnJS(onOpen)(); + runOnJS(handleGestureOpen)(); } else { animateToClose(); } @@ -94,7 +108,8 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu animateToOpen, animateToClose, isGesturing, - onOpen, + openGestureRef, + handleGestureOpen, touchStartX, touchStartY, ], From 1594e416025c7af158880cc6532fab631549b049 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 4 Apr 2026 12:52:10 +0700 Subject: [PATCH 32/73] fix(app): vertically center open-project screen content Add paddingBottom to the content area equal to the header height so the visual center accounts for the header above, keeping TitlebarDragRegion scoped to the content area for Electron window dragging. --- packages/app/src/screens/open-project-screen.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/app/src/screens/open-project-screen.tsx b/packages/app/src/screens/open-project-screen.tsx index 073301177..65e64902a 100644 --- a/packages/app/src/screens/open-project-screen.tsx +++ b/packages/app/src/screens/open-project-screen.tsx @@ -8,7 +8,7 @@ import { MenuHeader } from "@/components/headers/menu-header"; import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; import { usePanelStore } from "@/stores/panel-store"; import { useSessionStore } from "@/stores/session-store"; -import { isCompactFormFactor } from "@/constants/layout"; +import { isCompactFormFactor, HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE, HEADER_TOP_PADDING_MOBILE } from "@/constants/layout"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; export function OpenProjectScreen({ serverId }: { serverId: string }) { @@ -59,11 +59,15 @@ const styles = StyleSheet.create((theme) => ({ }, content: { position: "relative", - flexGrow: 1, + flex: 1, justifyContent: "center", alignItems: "center", gap: 0, padding: theme.spacing[6], + paddingBottom: { + xs: HEADER_INNER_HEIGHT_MOBILE + HEADER_TOP_PADDING_MOBILE + theme.spacing[6], + md: HEADER_INNER_HEIGHT + theme.spacing[6], + }, }, logo: { marginBottom: theme.spacing[8], From e73c332e4f0828f69026752f9447ce767eed2f6f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 4 Apr 2026 13:20:46 +0700 Subject: [PATCH 33/73] feat(desktop): add Integrations settings to install CLI and orchestration skills Add a new Integrations section in desktop settings that lets users install the Paseo CLI and orchestration skills directly from the app. CLI install symlinks the bundled shim to ~/.local/bin and updates the user's shell rc file if needed. Skills install copies SKILL.md files to ~/.agents/skills/, symlinks into ~/.claude/skills/ for Claude Code, and copies to ~/.codex/skills/ for Codex. Both use a single status check code path for install verification and on-load detection. Also adds a /docs/skills page documenting all six orchestration skills, extracts shared settings styles (section headers, rows) used across the daemon, integrations, and shortcuts sections, and reorders the settings sidebar to group integrations and daemon together. --- .../components/desktop-updates-section.tsx | 193 +++--------- .../components/integrations-section.tsx | 189 ++++++++++++ .../app/src/desktop/daemon/desktop-daemon.ts | 60 ++-- packages/app/src/screens/settings-screen.tsx | 60 ++-- .../settings/keyboard-shortcuts-section.tsx | 9 +- packages/app/src/styles/settings.ts | 45 +++ packages/desktop/electron-builder.yml | 2 + packages/desktop/src/daemon/daemon-manager.ts | 49 +--- .../src/integrations/integrations-manager.ts | 275 ++++++++++++++++++ packages/website/src/routeTree.gen.ts | 21 ++ packages/website/src/routes/docs.tsx | 1 + packages/website/src/routes/docs/skills.tsx | 162 +++++++++++ packages/website/vite.config.ts | 1 + 13 files changed, 811 insertions(+), 256 deletions(-) create mode 100644 packages/app/src/desktop/components/integrations-section.tsx create mode 100644 packages/desktop/src/integrations/integrations-manager.ts create mode 100644 packages/website/src/routes/docs/skills.tsx diff --git a/packages/app/src/desktop/components/desktop-updates-section.tsx b/packages/app/src/desktop/components/desktop-updates-section.tsx index c0678edcd..df0756eff 100644 --- a/packages/app/src/desktop/components/desktop-updates-section.tsx +++ b/packages/app/src/desktop/components/desktop-updates-section.tsx @@ -10,7 +10,6 @@ import { Play, Pause, RotateCw, - Terminal, Copy, FileText, Smartphone, @@ -24,7 +23,6 @@ import { openExternalUrl } from "@/utils/open-external-url"; import { isVersionMismatch } from "@/desktop/updates/desktop-updates"; import { getCliDaemonStatus, - getCliSymlinkInstructions, getDesktopDaemonLogs, getDesktopDaemonPairing, getDesktopDaemonStatus, @@ -32,7 +30,6 @@ import { shouldUseDesktopDaemon, startDesktopDaemon, stopDesktopDaemon, - type CliSymlinkInstructions, type DesktopDaemonLogs, type DesktopDaemonStatus, type DesktopPairingOffer, @@ -52,17 +49,12 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD const [statusError, setStatusError] = useState(null); const [isRestartingDaemon, setIsRestartingDaemon] = useState(false); const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false); - const [isLoadingCliSymlinkInstructions, setIsLoadingCliSymlinkInstructions] = useState(false); const [statusMessage, setStatusMessage] = useState(null); - const [cliStatusMessage, setCliStatusMessage] = useState(null); const [daemonLogs, setDaemonLogs] = useState(null); const [isLogsModalOpen, setIsLogsModalOpen] = useState(false); const [isPairingModalOpen, setIsPairingModalOpen] = useState(false); - const [isCliSymlinkModalOpen, setIsCliSymlinkModalOpen] = useState(false); const [isLoadingPairing, setIsLoadingPairing] = useState(false); const [pairingOffer, setPairingOffer] = useState(null); - const [cliSymlinkInstructions, setCliSymlinkInstructions] = - useState(null); const [pairingStatusMessage, setPairingStatusMessage] = useState(null); const [cliStatusOutput, setCliStatusOutput] = useState(null); const [isCliStatusModalOpen, setIsCliStatusModalOpen] = useState(false); @@ -223,40 +215,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD updateSettings, ]); - const handleOpenCliSymlinkInstructions = useCallback(() => { - if (!showSection || isLoadingCliSymlinkInstructions) { - return; - } - setIsLoadingCliSymlinkInstructions(true); - setCliStatusMessage(null); - void getCliSymlinkInstructions() - .then((instructions) => { - setCliSymlinkInstructions(instructions); - setIsCliSymlinkModalOpen(true); - }) - .catch((error) => { - const message = error instanceof Error ? error.message : String(error); - setCliStatusMessage(`Unable to load CLI symlink instructions: ${message}`); - }) - .finally(() => { - setIsLoadingCliSymlinkInstructions(false); - }); - }, [isLoadingCliSymlinkInstructions, showSection]); - - const handleCopyCliSymlinkCommands = useCallback(() => { - if (!cliSymlinkInstructions?.commands) { - return; - } - void Clipboard.setStringAsync(cliSymlinkInstructions.commands) - .then(() => { - Alert.alert("Copied", "CLI symlink commands copied."); - }) - .catch((error) => { - console.error("[Settings] Failed to copy CLI symlink commands", error); - Alert.alert("Error", "Unable to copy CLI symlink commands."); - }); - }, [cliSymlinkInstructions?.commands]); - const handleCopyLogPath = useCallback(() => { const logPath = daemonLogs?.logPath; if (!logPath) { @@ -353,14 +311,14 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD return ( - - Built-in daemon + + Built-in daemon - - - Status - Only the built-in desktop daemon is shown here. + + + Status + Only the built-in desktop daemon is shown here. {daemonStatusStateText} @@ -380,10 +338,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD {showLifecycleControls ? ( <> - - - Daemon management - + + + Daemon management + {isDaemonManagementPaused ? "Paused. The built-in daemon stays stopped until you start it again." : "Enabled. Paseo can manage the built-in daemon from the desktop app."} @@ -411,10 +369,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD : "Pause"} - - - {daemonActionLabel} - {daemonActionMessage} + + + {daemonActionLabel} + {daemonActionMessage} {statusMessage ? {statusMessage} : null} - - - - Log file - {daemonLogs?.logPath ?? "Log path unavailable."} + + + Log file + {daemonLogs?.logPath ?? "Log path unavailable."} {daemonLogs?.logPath ? ( @@ -476,10 +418,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD - - - Pair device - Connect your phone to this computer. + + + Pair device + Connect your phone to this computer. - - - - - setIsPairingModalOpen(false)} @@ -567,7 +482,7 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD snapPoints={["70%", "92%"]} > - {daemonLogs?.logPath ?? "Log path unavailable."} + {daemonLogs?.logPath ?? "Log path unavailable."} {daemonLogs?.contents.length ? daemonLogs.contents : "(log file is empty)"} @@ -652,7 +567,7 @@ function PairingOfferDialogContent(input: { return ( - Loading pairing offer… + Loading pairing offer… ); } @@ -660,7 +575,7 @@ function PairingOfferDialogContent(input: { if (statusMessage) { return ( - {statusMessage} + {statusMessage} ); } @@ -668,21 +583,21 @@ function PairingOfferDialogContent(input: { if (!pairingOffer?.url) { return ( - Pairing offer unavailable. + Pairing offer unavailable. ); } return ( - + Scan this QR code in Paseo, or copy the pairing link below. {qrDataUrl ? ( ) : qrError ? ( - QR unavailable: {qrError} + QR unavailable: {qrError} ) : ( )} @@ -703,37 +618,6 @@ function PairingOfferDialogContent(input: { } const styles = StyleSheet.create((theme) => ({ - sectionHeader: { - alignItems: "center", - flexDirection: "row", - justifyContent: "space-between", - marginBottom: theme.spacing[3], - marginLeft: theme.spacing[1], - }, - sectionLink: { - alignItems: "center", - flexDirection: "row", - gap: theme.spacing[1], - }, - sectionLinkText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, - }, - row: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - paddingVertical: theme.spacing[4], - paddingHorizontal: theme.spacing[4], - }, - rowBorder: { - borderTopWidth: 1, - borderTopColor: theme.colors.border, - }, - rowContent: { - flex: 1, - marginRight: theme.spacing[3], - }, actionGroup: { flexDirection: "row", gap: theme.spacing[2], @@ -744,10 +628,6 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "flex-end", gap: 2, }, - rowTitle: { - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - }, valueText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, @@ -757,11 +637,6 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, }, - hintText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, - marginTop: 2, - }, statusText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, diff --git a/packages/app/src/desktop/components/integrations-section.tsx b/packages/app/src/desktop/components/integrations-section.tsx new file mode 100644 index 000000000..cfc1b8510 --- /dev/null +++ b/packages/app/src/desktop/components/integrations-section.tsx @@ -0,0 +1,189 @@ +import { useCallback, useState } from "react"; +import { Text, View } from "react-native"; +import { useFocusEffect } from "@react-navigation/native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { ArrowUpRight, Terminal, Blocks, Check } from "lucide-react-native"; +import { settingsStyles } from "@/styles/settings"; +import { Button } from "@/components/ui/button"; +import { openExternalUrl } from "@/utils/open-external-url"; +import { + shouldUseDesktopDaemon, + getCliInstallStatus, + installCli, + getSkillsInstallStatus, + installSkills, + type InstallStatus, +} from "@/desktop/daemon/desktop-daemon"; + +const CLI_DOCS_URL = "https://paseo.sh/docs/cli"; +const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills"; + +export function IntegrationsSection() { + const { theme } = useUnistyles(); + const showSection = shouldUseDesktopDaemon(); + + const [cliStatus, setCliStatus] = useState(null); + const [skillsStatus, setSkillsStatus] = useState(null); + const [isInstallingCli, setIsInstallingCli] = useState(false); + const [isInstallingSkills, setIsInstallingSkills] = useState(false); + + const loadStatus = useCallback(() => { + if (!showSection) return; + void getCliInstallStatus() + .then(setCliStatus) + .catch((error) => { + console.error("[Integrations] Failed to load CLI status", error); + }); + void getSkillsInstallStatus() + .then(setSkillsStatus) + .catch((error) => { + console.error("[Integrations] Failed to load skills status", error); + }); + }, [showSection]); + + useFocusEffect( + useCallback(() => { + if (!showSection) return undefined; + loadStatus(); + return undefined; + }, [loadStatus, showSection]), + ); + + const handleInstallCli = useCallback(() => { + if (isInstallingCli) return; + setIsInstallingCli(true); + void installCli() + .then(setCliStatus) + .catch((error) => { + console.error("[Integrations] Failed to install CLI", error); + }) + .finally(() => { + setIsInstallingCli(false); + }); + }, [isInstallingCli]); + + const handleInstallSkills = useCallback(() => { + if (isInstallingSkills) return; + setIsInstallingSkills(true); + void installSkills() + .then(setSkillsStatus) + .catch((error) => { + console.error("[Integrations] Failed to install skills", error); + }) + .finally(() => { + setIsInstallingSkills(false); + }); + }, [isInstallingSkills]); + + if (!showSection) { + return null; + } + + return ( + + + Integrations + + + + + + + + + + + Command line + + + Control and script agents from your terminal. + + + {cliStatus?.installed ? ( + + + Installed + + ) : ( + + )} + + + + + + Orchestration skills + + + Teach your agents to orchestrate through the CLI. + + + {skillsStatus?.installed ? ( + + + Installed + + ) : ( + + )} + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + headerLinks: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[0], + }, + rowTitleRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + installedLabel: { + flexDirection: "row", + alignItems: "center", + gap: 4, + }, + mutedText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, +})); diff --git a/packages/app/src/desktop/daemon/desktop-daemon.ts b/packages/app/src/desktop/daemon/desktop-daemon.ts index 5fcc61a59..9204dc7b3 100644 --- a/packages/app/src/desktop/daemon/desktop-daemon.ts +++ b/packages/app/src/desktop/daemon/desktop-daemon.ts @@ -26,12 +26,6 @@ export type DesktopPairingOffer = { qr: string | null; }; -export type CliSymlinkInstructions = { - title: string; - detail: string; - commands: string; -}; - export type LocalTransportTarget = { transportType: "socket" | "pipe"; transportPath: string; @@ -115,17 +109,6 @@ function parseDesktopPairingOffer(raw: unknown): DesktopPairingOffer { }; } -function parseCliSymlinkInstructionsInternal(raw: unknown): CliSymlinkInstructions | null { - if (!isRecord(raw)) { - return null; - } - return { - title: toStringOrNull(raw.title) ?? "", - detail: toStringOrNull(raw.detail) ?? "", - commands: toStringOrNull(raw.commands) ?? "", - }; -} - export function shouldUseDesktopDaemon(): boolean { return isElectronRuntime(); } @@ -154,18 +137,6 @@ export async function getDesktopDaemonPairing(): Promise { return parseDesktopPairingOffer(await invokeDesktopCommand("desktop_daemon_pairing")); } -export function parseCliSymlinkInstructions(raw: unknown): CliSymlinkInstructions { - const instructions = parseCliSymlinkInstructionsInternal(raw); - if (!instructions) { - throw new Error("Unexpected CLI symlink instructions response."); - } - return instructions; -} - -export async function getCliSymlinkInstructions(): Promise { - return parseCliSymlinkInstructions(await invokeDesktopCommand("cli_symlink_instructions")); -} - export async function getCliDaemonStatus(): Promise { const raw = await invokeDesktopCommand("cli_daemon_status"); if (typeof raw !== "string") { @@ -224,3 +195,34 @@ export async function sendLocalTransportMessage(input: { export async function closeLocalTransportSession(sessionId: string): Promise { await invokeDesktopCommand("close_local_daemon_transport", { sessionId }); } + +// --------------------------------------------------------------------------- +// Integrations +// --------------------------------------------------------------------------- + +export interface InstallStatus { + installed: boolean; +} + +function parseInstallStatus(raw: unknown): InstallStatus { + if (!isRecord(raw)) { + throw new Error("Unexpected install status response."); + } + return { installed: raw.installed === true }; +} + +export async function getCliInstallStatus(): Promise { + return parseInstallStatus(await invokeDesktopCommand("get_cli_install_status")); +} + +export async function installCli(): Promise { + return parseInstallStatus(await invokeDesktopCommand("install_cli")); +} + +export async function getSkillsInstallStatus(): Promise { + return parseInstallStatus(await invokeDesktopCommand("get_skills_install_status")); +} + +export async function installSkills(): Promise { + return parseInstallStatus(await invokeDesktopCommand("install_skills")); +} diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 5dc28a0b6..39e3611fd 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -20,6 +20,7 @@ import { Stethoscope, Info, Shield, + Puzzle, } from "lucide-react-native"; import { useAppSettings, type AppSettings } from "@/hooks/use-settings"; import type { HostProfile, HostConnection } from "@/types/host-connection"; @@ -50,6 +51,7 @@ import { } from "@/components/ui/dropdown-menu"; import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet"; import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section"; +import { IntegrationsSection } from "@/desktop/components/integrations-section"; import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section"; import { isElectronRuntime } from "@/desktop/host"; import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater"; @@ -69,6 +71,7 @@ type SettingsSectionId = | "hosts" | "appearance" | "shortcuts" + | "integrations" | "diagnostics" | "about" | "permissions" @@ -85,12 +88,13 @@ function getSettingsSections(context: { isDesktopApp: boolean }): SettingsSectio { id: "hosts", label: "Hosts", icon: Server }, { id: "appearance", label: "Appearance", icon: Palette }, { id: "shortcuts", label: "Shortcuts", icon: Keyboard }, + { id: "permissions", label: "Permissions", icon: Shield }, ]; if (context.isDesktopApp) { sections.push( + { id: "integrations", label: "Integrations", icon: Puzzle }, { id: "daemon", label: "Daemon", icon: Settings }, - { id: "permissions", label: "Permissions", icon: Shield }, ); } @@ -523,6 +527,8 @@ function SettingsSectionContent({ return ; case "about": return ; + case "integrations": + return isDesktopApp ? : null; case "permissions": return isDesktopApp ? : null; case "daemon": @@ -573,31 +579,35 @@ function SettingsDesktopLayout({ sections, sectionContentProps }: SettingsLayout {sections.map((section) => { const isSelected = section.id === selectedSectionId; const IconComponent = section.icon; + const showSeparator = + section.id === "integrations" || section.id === "diagnostics"; return ( - setSelectedSectionId(section.id)} - accessibilityRole="button" - accessibilityState={{ selected: isSelected }} - > - - + {showSeparator ? : null} + setSelectedSectionId(section.id)} + accessibilityRole="button" + accessibilityState={{ selected: isSelected }} > - {section.label} - - + + + {section.label} + + + ); })} @@ -1896,6 +1906,12 @@ const desktopStyles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, fontWeight: theme.fontWeight.normal, }, + sidebarSeparator: { + borderBottomWidth: 1, + borderBottomColor: theme.colors.border, + marginVertical: theme.spacing[2], + marginHorizontal: theme.spacing[3], + }, contentPane: { flex: 1, }, diff --git a/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx b/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx index 7f64c7e55..09b5b07ac 100644 --- a/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx +++ b/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx @@ -192,8 +192,8 @@ export function KeyboardShortcutsSection() { return ( - - Shortcuts + + Shortcuts {hasOverrides && ( ) : null} From 5c1e869bc58f24115277640fb59ff573bd1b56e1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 4 Apr 2026 23:45:28 +0700 Subject: [PATCH 65/73] fix: copy sherpa TTS samples to avoid external buffer crash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same fix as the Silero VAD — sherpa-onnx returns Float32Arrays backed by native memory which Node.js rejects as "External buffers are not allowed". Copy into JS-managed memory before passing to audio pipeline. --- .../src/server/speech/providers/local/sherpa/sherpa-tts.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.ts b/packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.ts index 2388fef0d..89627aee4 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.ts @@ -95,12 +95,15 @@ export class SherpaOnnxTTS implements TextToSpeechProvider { } const audio = this.tts.generate({ text: trimmed, sid: this.speakerId, speed: this.speed }); - const samples: Float32Array | null = + const rawSamples: Float32Array | null = audio && audio.samples instanceof Float32Array ? audio.samples : audio && Array.isArray(audio.samples) ? Float32Array.from(audio.samples as number[]) : null; + // Copy to avoid "External buffers are not allowed" when sherpa-onnx + // returns a Float32Array backed by native memory. + const samples = rawSamples ? Float32Array.from(rawSamples) : null; const sampleRate: number = audio && typeof audio.sampleRate === "number" && From 5db9942125d4997006425e1cfcdcea2ca2940286 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 5 Apr 2026 00:53:46 +0700 Subject: [PATCH 66/73] fix(desktop): restore daemon QR pairing output --- packages/desktop/src/daemon/runtime-paths.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/desktop/src/daemon/runtime-paths.ts b/packages/desktop/src/daemon/runtime-paths.ts index b20c84f81..88615f942 100644 --- a/packages/desktop/src/daemon/runtime-paths.ts +++ b/packages/desktop/src/daemon/runtime-paths.ts @@ -223,7 +223,7 @@ function createCliInvocation(args: string[]): NodeEntrypointInvocation { const cli = resolveCliEntrypoint(); return createNodeEntrypointInvocation({ entrypoint: cli, - argvMode: "bare", + argvMode: "node-script", args, baseEnv: process.env, }); @@ -304,8 +304,16 @@ export async function runCliJsonCommand(args: string[]): Promise { throw new Error("CLI command did not produce JSON output."); } + // The stdout may contain non-JSON preamble (e.g. Node deprecation warnings). + // Extract the first valid JSON object or array from the output. + const jsonStart = stdout.search(/[{[]/); + if (jsonStart < 0) { + throw new Error("CLI command output contained no JSON."); + } + const jsonText = stdout.slice(jsonStart); + try { - return JSON.parse(stdout) as unknown; + return JSON.parse(jsonText) as unknown; } catch (error) { throw new Error( `CLI command returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`, From bedf7926169e6cada056f31ace6680bb883ba723 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 5 Apr 2026 00:53:50 +0700 Subject: [PATCH 67/73] fix(voice): harden Electron speak TTS path --- packages/server/src/server/bootstrap.ts | 1 + .../server/session.voice-mcp-config.test.ts | 2 + .../local/sherpa/sherpa-parakeet-stt.test.ts | 86 +++++++++++++++++++ .../local/sherpa/sherpa-parakeet-stt.ts | 14 +-- .../providers/local/sherpa/sherpa-tts.test.ts | 55 ++++++++++++ .../providers/local/sherpa/sherpa-tts.ts | 9 +- 6 files changed, 161 insertions(+), 6 deletions(-) create mode 100644 packages/server/src/server/speech/providers/local/sherpa/sherpa-parakeet-stt.test.ts create mode 100644 packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.test.ts diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 095a22e07..e0d4f3890 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -590,6 +590,7 @@ export async function createPaseoDaemon( command: voiceMcpBridgeCommand.command, baseArgs: [...voiceMcpBridgeCommand.baseArgs], env: { + ELECTRON_RUN_AS_NODE: "1", PASEO_HOME: config.paseoHome, }, }, diff --git a/packages/server/src/server/session.voice-mcp-config.test.ts b/packages/server/src/server/session.voice-mcp-config.test.ts index 9b5b7e1bf..98b1d4102 100644 --- a/packages/server/src/server/session.voice-mcp-config.test.ts +++ b/packages/server/src/server/session.voice-mcp-config.test.ts @@ -13,6 +13,7 @@ describe("voice MCP stdio config", () => { baseArgs: ["/tmp/mcp-stdio-socket-bridge-cli.mjs"], socketPath: "/tmp/paseo-voice.sock", env: { + ELECTRON_RUN_AS_NODE: "1", PASEO_HOME: "/tmp/paseo-home", }, }); @@ -25,6 +26,7 @@ describe("voice MCP stdio config", () => { "/tmp/paseo-voice.sock", ]); expect(config.env).toEqual({ + ELECTRON_RUN_AS_NODE: "1", PASEO_HOME: "/tmp/paseo-home", }); }); diff --git a/packages/server/src/server/speech/providers/local/sherpa/sherpa-parakeet-stt.test.ts b/packages/server/src/server/speech/providers/local/sherpa/sherpa-parakeet-stt.test.ts new file mode 100644 index 000000000..582239206 --- /dev/null +++ b/packages/server/src/server/speech/providers/local/sherpa/sherpa-parakeet-stt.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import pino from "pino"; + +import { SherpaOnnxParakeetSTT } from "./sherpa-parakeet-stt.js"; +import type { TranscriptionResult } from "../../../speech-provider.js"; + +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +class TestSherpaOnnxParakeetStt extends SherpaOnnxParakeetSTT { + public readonly calls: Array<{ audio: Buffer; format: string }> = []; + public readonly pending: Array>> = []; + + constructor() { + super({ engine: { sampleRate: 16000 } as any }, pino({ level: "silent" })); + } + + override async transcribeAudio(audioBuffer: Buffer, format: string): Promise { + this.calls.push({ audio: Buffer.from(audioBuffer), format }); + const deferred = createDeferred(); + this.pending.push(deferred); + return deferred.promise; + } +} + +describe("SherpaOnnxParakeetSTT session", () => { + it("snapshots segment ids and buffers before async transcription starts", async () => { + const provider = new TestSherpaOnnxParakeetStt(); + const session = provider.createSession({ + logger: pino({ level: "silent" }), + language: "en", + }); + + const committed: Array<{ segmentId: string; previousSegmentId: string | null }> = []; + const transcripts: Array<{ segmentId: string; transcript: string; isFinal: boolean }> = []; + + session.on("committed", (payload) => { + committed.push(payload); + }); + session.on("transcript", (payload) => { + transcripts.push(payload); + }); + + await session.connect(); + + session.appendPcm16(Buffer.from([1, 2, 3, 4])); + session.commit(); + session.appendPcm16(Buffer.from([5, 6, 7, 8])); + session.commit(); + + expect(committed).toHaveLength(2); + expect(committed[1]?.segmentId).not.toBe(committed[0]?.segmentId); + expect(committed[0]?.previousSegmentId).toBeNull(); + expect(committed[1]?.previousSegmentId).toBe(committed[0]?.segmentId); + + expect(provider.calls).toEqual([ + { audio: Buffer.from([1, 2, 3, 4]), format: "audio/pcm;rate=16000" }, + { audio: Buffer.from([5, 6, 7, 8]), format: "audio/pcm;rate=16000" }, + ]); + + provider.pending[0]?.resolve({ text: "first", duration: 1 }); + provider.pending[1]?.resolve({ text: "second", duration: 1 }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(transcripts).toHaveLength(2); + expect(transcripts).toEqual([ + expect.objectContaining({ + segmentId: committed[0]!.segmentId, + transcript: "first", + isFinal: true, + }), + expect.objectContaining({ + segmentId: committed[1]!.segmentId, + transcript: "second", + isFinal: true, + }), + ]); + }); +}); diff --git a/packages/server/src/server/speech/providers/local/sherpa/sherpa-parakeet-stt.ts b/packages/server/src/server/speech/providers/local/sherpa/sherpa-parakeet-stt.ts index 73552d14e..bba9f70c8 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/sherpa-parakeet-stt.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/sherpa-parakeet-stt.ts @@ -66,11 +66,18 @@ export class SherpaOnnxParakeetSTT implements SpeechToTextProvider { const committedId = segmentId; const prev = previousSegmentId; + const committedPcm16 = pcm16; + previousSegmentId = committedId; + segmentId = uuidv4(); + pcm16 = Buffer.alloc(0); (emitter as any).emit("committed", { segmentId: committedId, previousSegmentId: prev }); void (async () => { try { - const rt = await this.transcribeAudio(pcm16, `audio/pcm;rate=${requiredSampleRate}`); + const rt = await this.transcribeAudio( + committedPcm16, + `audio/pcm;rate=${requiredSampleRate}`, + ); (emitter as any).emit("transcript", { segmentId: committedId, transcript: rt.text, @@ -83,10 +90,7 @@ export class SherpaOnnxParakeetSTT implements SpeechToTextProvider { } catch (err) { (emitter as any).emit("error", err); } finally { - previousSegmentId = committedId; - segmentId = uuidv4(); - pcm16 = Buffer.alloc(0); - logger.debug({ bytes: pcm16.length }, "Parakeet session reset"); + logger.debug({ bytes: committedPcm16.length }, "Parakeet session reset"); } })(); }, diff --git a/packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.test.ts b/packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.test.ts new file mode 100644 index 000000000..c16929ec9 --- /dev/null +++ b/packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import pino from "pino"; + +const generate = vi.fn(); +const free = vi.fn(); + +vi.mock("node:fs", () => ({ + existsSync: vi.fn(() => true), +})); + +vi.mock("./sherpa-onnx-node-loader.js", () => ({ + loadSherpaOnnxNode: () => ({ + OfflineTts: class { + public readonly sampleRate = 24000; + + constructor(_config: unknown) {} + + generate = generate; + free = free; + }, + }), +})); + +describe("SherpaOnnxTTS", () => { + beforeEach(() => { + generate.mockReset(); + free.mockReset(); + }); + + it("disables external buffers when calling sherpa generate", async () => { + generate.mockReturnValue({ + samples: Float32Array.from([0, 0.5, -0.5, 0.25]), + sampleRate: 24000, + }); + + const { SherpaOnnxTTS } = await import("./sherpa-tts.js"); + const tts = new SherpaOnnxTTS( + { + preset: "kokoro-en-v0_19", + modelDir: "/tmp/fake-model", + }, + pino({ level: "silent" }), + ); + + const result = await tts.synthesizeSpeech("hello"); + + expect(generate).toHaveBeenCalledWith({ + text: "hello", + sid: 0, + speed: 1, + enableExternalBuffer: false, + }); + expect(result.format).toBe("pcm;rate=24000"); + }); +}); diff --git a/packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.ts b/packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.ts index 89627aee4..4a4c71a26 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.ts @@ -94,7 +94,14 @@ export class SherpaOnnxTTS implements TextToSpeechProvider { throw new Error("Cannot synthesize empty text"); } - const audio = this.tts.generate({ text: trimmed, sid: this.speakerId, speed: this.speed }); + const audio = this.tts.generate({ + text: trimmed, + sid: this.speakerId, + speed: this.speed, + // Electron rejects native external-backed typed arrays. Request a copied buffer + // from sherpa itself instead of trying to clone after generate() returns. + enableExternalBuffer: false, + }); const rawSamples: Float32Array | null = audio && audio.samples instanceof Float32Array ? audio.samples From e44e495481bc80bfd3adbb964e0ac0e447a59885 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 5 Apr 2026 00:57:03 +0700 Subject: [PATCH 68/73] Update CHANGELOG.md for 0.1.47 stable release --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index de4f90e18..e4a97f1ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.1.47 - 2026-04-05 + +### Fixed +- Voice TTS in Electron — sherpa now requests copied buffers and the voice MCP bridge sets `ELECTRON_RUN_AS_NODE`, preventing "external buffers not allowed" crashes. +- QR pairing in desktop — CLI JSON output parsing now tolerates Node deprecation warnings in stdout. +- STT segment race condition — segment ID and audio buffer are snapshotted before the async transcription call, so rapid commits no longer interleave. +- Per-host "Add connection" button removed — it blocked multi-host setups by scoping new connections to a single server. + ## 0.1.46 - 2026-04-04 ### Fixed From 2d9ca6983aea878f43ab0c0ecd5bb32b1234a8f0 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 5 Apr 2026 00:58:22 +0700 Subject: [PATCH 69/73] chore(release): cut 0.1.47 --- package-lock.json | 38 ++++++++++++------------ package.json | 2 +- packages/app/package.json | 8 ++--- packages/cli/package.json | 6 ++-- packages/desktop/package.json | 6 ++-- packages/expo-two-way-audio/package.json | 2 +- packages/highlight/package.json | 2 +- packages/relay/package.json | 2 +- packages/server/package.json | 6 ++-- packages/website/package.json | 2 +- 10 files changed, 37 insertions(+), 37 deletions(-) diff --git a/package-lock.json b/package-lock.json index a31f8e026..a17b83ab5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.1.46", + "version": "0.1.47", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.1.46", + "version": "0.1.47", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "workspaces": [ @@ -35030,16 +35030,16 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.1.46", + "version": "0.1.47", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@expo/vector-icons": "^15.0.2", "@floating-ui/react-native": "^0.10.7", - "@getpaseo/expo-two-way-audio": "0.1.46", - "@getpaseo/highlight": "0.1.46", - "@getpaseo/server": "0.1.46", + "@getpaseo/expo-two-way-audio": "0.1.47", + "@getpaseo/highlight": "0.1.47", + "@getpaseo/server": "0.1.47", "@gorhom/bottom-sheet": "^5.2.6", "@gorhom/portal": "^1.0.14", "@react-native-async-storage/async-storage": "2.2.0", @@ -35156,11 +35156,11 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.1.46", + "version": "0.1.47", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/relay": "0.1.46", - "@getpaseo/server": "0.1.46", + "@getpaseo/relay": "0.1.47", + "@getpaseo/server": "0.1.47", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", @@ -35201,11 +35201,11 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.1.46", + "version": "0.1.47", "license": "AGPL-3.0-or-later", "dependencies": { - "@getpaseo/cli": "0.1.46", - "@getpaseo/server": "0.1.46", + "@getpaseo/cli": "0.1.47", + "@getpaseo/server": "0.1.47", "electron-log": "^5.4.3", "electron-updater": "^6.6.2", "ws": "^8.14.2" @@ -35239,7 +35239,7 @@ }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", - "version": "0.1.46", + "version": "0.1.47", "license": "MIT", "devDependencies": { "@biomejs/biome": "1.9.4", @@ -35440,7 +35440,7 @@ }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.1.46", + "version": "0.1.47", "dependencies": { "@lezer/common": "^1.5.0", "@lezer/cpp": "^1.1.5", @@ -35466,7 +35466,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.1.46", + "version": "0.1.47", "dependencies": { "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", @@ -35482,14 +35482,14 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.1.46", + "version": "0.1.47", "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", "@ai-sdk/openai": "2.0.52", "@anthropic-ai/claude-agent-sdk": "^0.2.11", "@deepgram/sdk": "^3.4.0", - "@getpaseo/highlight": "0.1.46", - "@getpaseo/relay": "0.1.46", + "@getpaseo/highlight": "0.1.47", + "@getpaseo/relay": "0.1.47", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.2.6", @@ -35889,7 +35889,7 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.1.46", + "version": "0.1.47", "dependencies": { "@cloudflare/vite-plugin": "^1.20.3", "@cloudflare/workers-types": "^4.20260114.0", diff --git a/package.json b/package.json index f8115e8d8..c3b9f4162 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.1.46", + "version": "0.1.47", "private": true, "workspaces": [ "packages/expo-two-way-audio", diff --git a/packages/app/package.json b/packages/app/package.json index ccf3f9a2c..bbf1ee2ca 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,7 +1,7 @@ { "name": "@getpaseo/app", "main": "index.ts", - "version": "0.1.46", + "version": "0.1.47", "private": true, "scripts": { "start": "expo start", @@ -31,9 +31,9 @@ "@dnd-kit/utilities": "^3.2.2", "@expo/vector-icons": "^15.0.2", "@floating-ui/react-native": "^0.10.7", - "@getpaseo/expo-two-way-audio": "0.1.46", - "@getpaseo/highlight": "0.1.46", - "@getpaseo/server": "0.1.46", + "@getpaseo/expo-two-way-audio": "0.1.47", + "@getpaseo/highlight": "0.1.47", + "@getpaseo/server": "0.1.47", "@gorhom/bottom-sheet": "^5.2.6", "@gorhom/portal": "^1.0.14", "@react-native-async-storage/async-storage": "2.2.0", diff --git a/packages/cli/package.json b/packages/cli/package.json index f5f098386..a0008a23b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.1.46", + "version": "0.1.47", "description": "Paseo CLI - control your AI coding agents from the command line", "type": "module", "files": [ @@ -24,8 +24,8 @@ }, "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/relay": "0.1.46", - "@getpaseo/server": "0.1.46", + "@getpaseo/relay": "0.1.47", + "@getpaseo/server": "0.1.47", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index a7030ab8b..651eeca73 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.1.46", + "version": "0.1.47", "private": true, "description": "Paseo desktop app (Electron wrapper)", "main": "dist/main.js", @@ -12,8 +12,8 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { - "@getpaseo/cli": "0.1.46", - "@getpaseo/server": "0.1.46", + "@getpaseo/cli": "0.1.47", + "@getpaseo/server": "0.1.47", "electron-log": "^5.4.3", "electron-updater": "^6.6.2", "ws": "^8.14.2" diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index a3abab54f..3319eb6c2 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.1.46", + "version": "0.1.47", "description": "Native module for two way audio streaming", "main": "build/index.js", "types": "build/index.d.ts", diff --git a/packages/highlight/package.json b/packages/highlight/package.json index 15fe5da89..970f94e6d 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.1.46", + "version": "0.1.47", "type": "module", "publishConfig": { "access": "public" diff --git a/packages/relay/package.json b/packages/relay/package.json index 36c91e86c..a91b06fd6 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.1.46", + "version": "0.1.47", "description": "Paseo relay for bridging daemon and client connections", "type": "module", "publishConfig": { diff --git a/packages/server/package.json b/packages/server/package.json index 29521f971..adb0968a6 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.1.46", + "version": "0.1.47", "description": "Paseo backend server", "type": "module", "publishConfig": { @@ -64,8 +64,8 @@ "@ai-sdk/openai": "2.0.52", "@anthropic-ai/claude-agent-sdk": "^0.2.11", "@deepgram/sdk": "^3.4.0", - "@getpaseo/highlight": "0.1.46", - "@getpaseo/relay": "0.1.46", + "@getpaseo/highlight": "0.1.47", + "@getpaseo/relay": "0.1.47", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.2.6", diff --git a/packages/website/package.json b/packages/website/package.json index 724b32878..ba65a9033 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.1.46", + "version": "0.1.47", "private": true, "type": "module", "scripts": { From 7a2d9760817e6c25aed69314b0a2e5f6b4e7be6d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 4 Apr 2026 17:59:55 +0000 Subject: [PATCH 70/73] fix: update lockfile signatures and Nix hash --- nix/package.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/package.nix b/nix/package.nix index 08291f027..eec96c76f 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -42,7 +42,7 @@ buildNpmPackage rec { # To update: run `nix build` with lib.fakeHash, copy the `got:` hash. # CI auto-updates this when package-lock.json changes (see .github/workflows/). - npmDepsHash = "sha256-8OTtv6FJq62fT5E6Ai4M/z21flDpIMtnpnkMGCHyJ9E="; + npmDepsHash = "sha256-ZLwOacIYQ6rUT2sYs3vNvOh7wTkwQ92cpOJDm29GLWs="; # Prevent onnxruntime-node's install script from running during automatic # npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox). From c32bc847bfcfca1f71f4772ad95bed827d72def9 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 5 Apr 2026 09:17:55 +0700 Subject: [PATCH 71/73] feat(app): restore reload action for agent tabs --- .../app/src/components/split-container.tsx | 8 +++++ .../app/src/components/ui/context-menu.tsx | 22 +++++++++++- .../app/src/components/ui/dropdown-menu.tsx | 22 +++++++++++- .../workspace/workspace-desktop-tabs-row.tsx | 11 ++++++ .../screens/workspace/workspace-screen.tsx | 30 ++++++++++++++++ .../workspace/workspace-tab-menu.test.ts | 34 +++++++++++++++++++ .../screens/workspace/workspace-tab-menu.ts | 24 ++++++++++++- 7 files changed, 148 insertions(+), 3 deletions(-) diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index ce42f2d76..e799cdc5d 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -84,6 +84,7 @@ interface SplitContainerProps { onCloseTab: (tabId: string) => Promise | void; onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; + onReloadAgent: (agentId: string) => Promise | void; onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise | void; onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise | void; onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise | void; @@ -265,6 +266,7 @@ export function SplitContainer({ onCloseTab, onCopyResumeCommand, onCopyAgentId, + onReloadAgent, onCloseTabsToLeft, onCloseTabsToRight, onCloseOtherTabs, @@ -534,6 +536,7 @@ export function SplitContainer({ onCloseTab={onCloseTab} onCopyResumeCommand={onCopyResumeCommand} onCopyAgentId={onCopyAgentId} + onReloadAgent={onReloadAgent} onCloseTabsToLeft={onCloseTabsToLeft} onCloseTabsToRight={onCloseTabsToRight} onCloseOtherTabs={onCloseOtherTabs} @@ -656,6 +659,7 @@ function SplitNodeView({ onCloseTab, onCopyResumeCommand, onCopyAgentId, + onReloadAgent, onCloseTabsToLeft, onCloseTabsToRight, onCloseOtherTabs, @@ -691,6 +695,7 @@ function SplitNodeView({ onCloseTab={onCloseTab} onCopyResumeCommand={onCopyResumeCommand} onCopyAgentId={onCopyAgentId} + onReloadAgent={onReloadAgent} onCloseTabsToLeft={onCloseTabsToLeft} onCloseTabsToRight={onCloseTabsToRight} onCloseOtherTabs={onCloseOtherTabs} @@ -741,6 +746,7 @@ function SplitNodeView({ onCloseTab={onCloseTab} onCopyResumeCommand={onCopyResumeCommand} onCopyAgentId={onCopyAgentId} + onReloadAgent={onReloadAgent} onCloseTabsToLeft={onCloseTabsToLeft} onCloseTabsToRight={onCloseTabsToRight} onCloseOtherTabs={onCloseOtherTabs} @@ -790,6 +796,7 @@ function SplitPaneView({ onCloseTab, onCopyResumeCommand, onCopyAgentId, + onReloadAgent, onCloseTabsToLeft, onCloseTabsToRight, onCloseOtherTabs, @@ -901,6 +908,7 @@ function SplitPaneView({ onCloseTab={onCloseTab} onCopyResumeCommand={onCopyResumeCommand} onCopyAgentId={onCopyAgentId} + onReloadAgent={onReloadAgent} onCloseTabsToLeft={(tabId) => onCloseTabsToLeft(tabId, paneTabs)} onCloseTabsToRight={(tabId) => onCloseTabsToRight(tabId, paneTabs)} onCloseOtherTabs={(tabId) => onCloseOtherTabs(tabId, paneTabs)} diff --git a/packages/app/src/components/ui/context-menu.tsx b/packages/app/src/components/ui/context-menu.tsx index c539af754..db3196e03 100644 --- a/packages/app/src/components/ui/context-menu.tsx +++ b/packages/app/src/components/ui/context-menu.tsx @@ -30,6 +30,7 @@ import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { Check, CheckCircle } from "lucide-react-native"; import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; // Keep parity with dropdown-menu action statuses. export type ActionStatus = "idle" | "pending" | "success"; @@ -595,6 +596,7 @@ export function ContextMenuItem({ successLabel, closeOnSelect = true, testID, + tooltip, }: PropsWithChildren<{ description?: string; onSelect?: () => void; @@ -612,6 +614,7 @@ export function ContextMenuItem({ successLabel?: string; closeOnSelect?: boolean; testID?: string; + tooltip?: string; }>): ReactElement { const { theme } = useUnistyles(); const { setOpen } = useContextMenuContext("ContextMenuItem"); @@ -642,7 +645,7 @@ export function ContextMenuItem({ ) : null); - return ( + const content = ( {trailingContent} : null} ); + + if (!tooltip) { + return content; + } + + return ( + + {content} + + {tooltip} + + + ); } const styles = StyleSheet.create((theme) => ({ @@ -762,6 +778,10 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.xs, color: theme.colors.foregroundMuted, }, + tooltipText: { + fontSize: theme.fontSize.sm, + color: theme.colors.foreground, + }, item: { flexDirection: "row", alignItems: "center", diff --git a/packages/app/src/components/ui/dropdown-menu.tsx b/packages/app/src/components/ui/dropdown-menu.tsx index 32280504a..2441b14d4 100644 --- a/packages/app/src/components/ui/dropdown-menu.tsx +++ b/packages/app/src/components/ui/dropdown-menu.tsx @@ -27,6 +27,7 @@ import { import Animated, { Keyframe, runOnJS } from "react-native-reanimated"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { Check, CheckCircle } from "lucide-react-native"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; // Action status for menu items with loading/success feedback export type ActionStatus = "idle" | "pending" | "success"; @@ -471,6 +472,7 @@ export function DropdownMenuItem({ successLabel, closeOnSelect = true, testID, + tooltip, }: PropsWithChildren<{ description?: string; onSelect?: () => void; @@ -491,6 +493,7 @@ export function DropdownMenuItem({ successLabel?: string; closeOnSelect?: boolean; testID?: string; + tooltip?: string; }>): ReactElement { const { theme } = useUnistyles(); const { setOpen } = useDropdownMenuContext("DropdownMenuItem"); @@ -524,7 +527,7 @@ export function DropdownMenuItem({ ) : null); - return ( + const content = ( {trailingContent} : null} ); + + if (!tooltip) { + return content; + } + + return ( + + {content} + + {tooltip} + + + ); } const styles = StyleSheet.create((theme) => ({ @@ -630,6 +646,10 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.xs, color: theme.colors.foregroundMuted, }, + tooltipText: { + fontSize: theme.fontSize.sm, + color: theme.colors.foreground, + }, item: { flexDirection: "row", alignItems: "center", diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index 595be3647..110f01970 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -14,6 +14,7 @@ import { ArrowRightToLine, Columns2, Copy, + RotateCw, Rows2, SquarePen, SquareTerminal, @@ -71,6 +72,7 @@ type WorkspaceDesktopTabsRowProps = { onCloseTab: (tabId: string) => Promise | void; onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; + onReloadAgent: (agentId: string) => Promise | void; onCloseTabsToLeft: (tabId: string) => Promise | void; onCloseTabsToRight: (tabId: string) => Promise | void; onCloseOtherTabs: (tabId: string) => Promise | void; @@ -291,11 +293,14 @@ function TabChip({ disabled={entry.disabled} destructive={entry.destructive} onSelect={entry.onSelect} + tooltip={entry.tooltip} leading={(() => { const iconColor = theme.colors.foregroundMuted; switch (entry.icon) { case "copy": return ; + case "rotate-cw": + return ; case "arrow-left-to-line": return ; case "arrow-right-to-line": @@ -335,6 +340,7 @@ export function WorkspaceDesktopTabsRow({ onCloseTab, onCopyResumeCommand, onCopyAgentId, + onReloadAgent, onCloseTabsToLeft, onCloseTabsToRight, onCloseOtherTabs, @@ -457,6 +463,7 @@ export function WorkspaceDesktopTabsRow({ normalizedWorkspaceId={normalizedWorkspaceId} onCopyResumeCommand={onCopyResumeCommand} onCopyAgentId={onCopyAgentId} + onReloadAgent={onReloadAgent} onCloseTabsToLeft={onCloseTabsToLeft} onCloseTabsToRight={onCloseTabsToRight} onCloseOtherTabs={onCloseOtherTabs} @@ -585,6 +592,7 @@ function ResolvedDesktopTabChip({ normalizedWorkspaceId, onCopyResumeCommand, onCopyAgentId, + onReloadAgent, onCloseTabsToLeft, onCloseTabsToRight, onCloseOtherTabs, @@ -608,6 +616,7 @@ function ResolvedDesktopTabChip({ normalizedWorkspaceId: string; onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; + onReloadAgent: (agentId: string) => Promise | void; onCloseTabsToLeft: (tabId: string) => Promise | void; onCloseTabsToRight: (tabId: string) => Promise | void; onCloseOtherTabs: (tabId: string) => Promise | void; @@ -630,6 +639,7 @@ function ResolvedDesktopTabChip({ tabCount, onCopyResumeCommand, onCopyAgentId, + onReloadAgent, onCloseTab, onCloseTabsToLeft, onCloseTabsToRight, @@ -644,6 +654,7 @@ function ResolvedDesktopTabChip({ onCloseTabsToRight, onCopyAgentId, onCopyResumeCommand, + onReloadAgent, tabCount, ], ); diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index e9b402145..ff241f11a 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -21,6 +21,7 @@ import { Ellipsis, EllipsisVertical, PanelRight, + RotateCw, SquarePen, SquareTerminal, X, @@ -182,6 +183,7 @@ type MobileWorkspaceTabSwitcherProps = { onSelectSwitcherTab: (key: string) => void; onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; + onReloadAgent: (agentId: string) => Promise | void; onCloseTab: (tabId: string) => Promise | void; onCloseTabsAbove: (tabId: string) => Promise | void; onCloseTabsBelow: (tabId: string) => Promise | void; @@ -269,6 +271,7 @@ function MobileWorkspaceTabOption({ onPress, onCopyResumeCommand, onCopyAgentId, + onReloadAgent, onCloseTab, onCloseTabsAbove, onCloseTabsBelow, @@ -284,6 +287,7 @@ function MobileWorkspaceTabOption({ onPress: () => void; onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; + onReloadAgent: (agentId: string) => Promise | void; onCloseTab: (tabId: string) => Promise | void; onCloseTabsAbove: (tabId: string) => Promise | void; onCloseTabsBelow: (tabId: string) => Promise | void; @@ -299,6 +303,7 @@ function MobileWorkspaceTabOption({ menuTestIDBase, onCopyResumeCommand, onCopyAgentId, + onReloadAgent, onCloseTab, onCloseTabsBefore: onCloseTabsAbove, onCloseTabsAfter: onCloseTabsBelow, @@ -342,11 +347,14 @@ function MobileWorkspaceTabOption({ disabled={entry.disabled} destructive={entry.destructive} onSelect={entry.onSelect} + tooltip={entry.tooltip} leading={(() => { const iconColor = theme.colors.foregroundMuted; switch (entry.icon) { case "copy": return ; + case "rotate-cw": + return ; case "arrow-left-to-line": return ; case "arrow-right-to-line": @@ -389,6 +397,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({ onSelectSwitcherTab, onCopyResumeCommand, onCopyAgentId, + onReloadAgent, onCloseTab, onCloseTabsAbove, onCloseTabsBelow, @@ -460,6 +469,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({ onPress={onPress} onCopyResumeCommand={onCopyResumeCommand} onCopyAgentId={onCopyAgentId} + onReloadAgent={onReloadAgent} onCloseTab={onCloseTab} onCloseTabsAbove={onCloseTabsAbove} onCloseTabsBelow={onCloseTabsBelow} @@ -1366,6 +1376,23 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) [normalizedServerId, toast], ); + const handleReloadAgent = useCallback( + async (agentId: string) => { + if (!client || !isConnected) { + toast.error("Host is not connected"); + return; + } + + try { + await client.refreshAgent(agentId); + toast.show("Reloaded agent", { variant: "success" }); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Failed to reload agent"); + } + }, + [client, isConnected, toast], + ); + const handleCopyWorkspacePath = useCallback(async () => { if (!isAbsolutePath(normalizedWorkspaceId)) { toast.error("Workspace path not available"); @@ -2211,6 +2238,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) onSelectSwitcherTab={handleSelectSwitcherTab} onCopyResumeCommand={handleCopyResumeCommand} onCopyAgentId={handleCopyAgentId} + onReloadAgent={handleReloadAgent} onCloseTab={handleCloseTabById} onCloseTabsAbove={handleCloseTabsToLeft} onCloseTabsBelow={handleCloseTabsToRight} @@ -2231,6 +2259,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) onCloseTab={handleCloseTabById} onCopyResumeCommand={handleCopyResumeCommand} onCopyAgentId={handleCopyAgentId} + onReloadAgent={handleReloadAgent} onCloseTabsToLeft={handleCloseTabsToLeft} onCloseTabsToRight={handleCloseTabsToRight} onCloseOtherTabs={handleCloseOtherTabs} @@ -2267,6 +2296,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) onCloseTab={handleCloseTabById} onCopyResumeCommand={handleCopyResumeCommand} onCopyAgentId={handleCopyAgentId} + onReloadAgent={handleReloadAgent} onCloseTabsToLeft={handleCloseTabsToLeftInPane} onCloseTabsToRight={handleCloseTabsToRightInPane} onCloseOtherTabs={handleCloseOtherTabsInPane} diff --git a/packages/app/src/screens/workspace/workspace-tab-menu.test.ts b/packages/app/src/screens/workspace/workspace-tab-menu.test.ts index 67c58efb8..ba8395234 100644 --- a/packages/app/src/screens/workspace/workspace-tab-menu.test.ts +++ b/packages/app/src/screens/workspace/workspace-tab-menu.test.ts @@ -15,6 +15,7 @@ describe("buildWorkspaceTabMenuEntries", () => { it("uses desktop tab ordering labels for desktop menus", () => { const onCopyResumeCommand = vi.fn(); const onCopyAgentId = vi.fn(); + const onReloadAgent = vi.fn(); const onCloseTab = vi.fn(); const onCloseTabsBefore = vi.fn(); const onCloseTabsAfter = vi.fn(); @@ -28,6 +29,7 @@ describe("buildWorkspaceTabMenuEntries", () => { menuTestIDBase: "workspace-tab-context-agent_123", onCopyResumeCommand, onCopyAgentId, + onReloadAgent, onCloseTab, onCloseTabsBefore, onCloseTabsAfter, @@ -37,6 +39,7 @@ describe("buildWorkspaceTabMenuEntries", () => { expect(entries.filter((entry) => entry.kind === "item").map((entry) => entry.label)).toEqual([ "Copy resume command", "Copy agent id", + "Reload agent", "Close to the left", "Close to the right", "Close other tabs", @@ -53,6 +56,7 @@ describe("buildWorkspaceTabMenuEntries", () => { menuTestIDBase: "workspace-tab-menu-agent_123", onCopyResumeCommand: vi.fn(), onCopyAgentId: vi.fn(), + onReloadAgent: vi.fn(), onCloseTab: vi.fn(), onCloseTabsBefore: vi.fn(), onCloseTabsAfter: vi.fn(), @@ -62,6 +66,7 @@ describe("buildWorkspaceTabMenuEntries", () => { expect(entries.filter((entry) => entry.kind === "item").map((entry) => entry.label)).toEqual([ "Copy resume command", "Copy agent id", + "Reload agent", "Close tabs above", "Close tabs below", "Close other tabs", @@ -83,6 +88,7 @@ describe("buildWorkspaceTabMenuEntries", () => { menuTestIDBase: "workspace-tab-menu-draft_123", onCopyResumeCommand: vi.fn(), onCopyAgentId: vi.fn(), + onReloadAgent: vi.fn(), onCloseTab: vi.fn(), onCloseTabsBefore: vi.fn(), onCloseTabsAfter: vi.fn(), @@ -92,6 +98,34 @@ describe("buildWorkspaceTabMenuEntries", () => { expect(entries.some((entry) => entry.kind === "item" && entry.label === "Copy agent id")).toBe( false, ); + expect(entries.some((entry) => entry.kind === "item" && entry.label === "Reload agent")).toBe( + false, + ); expect(entries.some((entry) => entry.kind === "separator")).toBe(false); }); + + it("adds reload tooltip copy for agent tabs", () => { + const entries = buildWorkspaceTabMenuEntries({ + surface: "desktop", + tab: createAgentTab(), + index: 0, + tabCount: 1, + menuTestIDBase: "workspace-tab-context-agent_123", + onCopyResumeCommand: vi.fn(), + onCopyAgentId: vi.fn(), + onReloadAgent: vi.fn(), + onCloseTab: vi.fn(), + onCloseTabsBefore: vi.fn(), + onCloseTabsAfter: vi.fn(), + onCloseOtherTabs: vi.fn(), + }); + + expect(entries).toContainEqual( + expect.objectContaining({ + kind: "item", + key: "reload-agent", + tooltip: "Reload agent to update skills, MCPs or login status.", + }), + ); + }); }); diff --git a/packages/app/src/screens/workspace/workspace-tab-menu.ts b/packages/app/src/screens/workspace/workspace-tab-menu.ts index fea7255f4..00741d215 100644 --- a/packages/app/src/screens/workspace/workspace-tab-menu.ts +++ b/packages/app/src/screens/workspace/workspace-tab-menu.ts @@ -8,8 +8,15 @@ export type WorkspaceTabMenuEntry = kind: "item"; key: string; label: string; - icon?: "copy" | "arrow-left-to-line" | "arrow-right-to-line" | "copy-x" | "x"; + icon?: + | "copy" + | "rotate-cw" + | "arrow-left-to-line" + | "arrow-right-to-line" + | "copy-x" + | "x"; hint?: string; + tooltip?: string; disabled?: boolean; destructive?: boolean; testID: string; @@ -28,6 +35,7 @@ interface BuildWorkspaceTabMenuEntriesInput { menuTestIDBase: string; onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; + onReloadAgent: (agentId: string) => Promise | void; onCloseTab: (tabId: string) => Promise | void; onCloseTabsBefore: (tabId: string) => Promise | void; onCloseTabsAfter: (tabId: string) => Promise | void; @@ -40,6 +48,7 @@ interface BuildWorkspaceDesktopTabActionsInput { tabCount: number; onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; + onReloadAgent: (agentId: string) => Promise | void; onCloseTab: (tabId: string) => Promise | void; onCloseTabsToLeft: (tabId: string) => Promise | void; onCloseTabsToRight: (tabId: string) => Promise | void; @@ -92,6 +101,7 @@ export function buildWorkspaceTabMenuEntries( menuTestIDBase, onCopyResumeCommand, onCopyAgentId, + onReloadAgent, onCloseTab, onCloseTabsBefore, onCloseTabsAfter, @@ -125,6 +135,17 @@ export function buildWorkspaceTabMenuEntries( void onCopyAgentId(agentId); }, }); + entries.push({ + kind: "item", + key: "reload-agent", + label: "Reload agent", + icon: "rotate-cw", + tooltip: "Reload agent to update skills, MCPs or login status.", + testID: `${menuTestIDBase}-reload-agent`, + onSelect: () => { + void onReloadAgent(agentId); + }, + }); entries.push({ kind: "separator", key: "copy-separator", @@ -192,6 +213,7 @@ export function buildWorkspaceDesktopTabActions( menuTestIDBase: contextMenuTestId, onCopyResumeCommand: input.onCopyResumeCommand, onCopyAgentId: input.onCopyAgentId, + onReloadAgent: input.onReloadAgent, onCloseTab: input.onCloseTab, onCloseTabsBefore: input.onCloseTabsToLeft, onCloseTabsAfter: input.onCloseTabsToRight, From 702e2e7db93517d40e02429211b2de9b2686a195 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 5 Apr 2026 09:18:46 +0700 Subject: [PATCH 72/73] Handle Codex request_user_input app-server questions Fixes #195 --- .../codex-app-server-agent.e2e.test.ts | 358 +++++++++++++++++- .../providers/codex-app-server-agent.test.ts | 209 +++++++++- .../agent/providers/codex-app-server-agent.ts | 305 +++++++++++++-- 3 files changed, 834 insertions(+), 38 deletions(-) diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.e2e.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.e2e.test.ts index 8094fe95e..89d1b0016 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.e2e.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.e2e.test.ts @@ -2,11 +2,14 @@ import { describe, expect, test } from "vitest"; import { execFileSync } from "node:child_process"; import os from "node:os"; import path from "node:path"; -import { mkdtempSync } from "node:fs"; +import { createServer } from "node:http"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { once } from "node:events"; import { CodexAppServerAgentClient } from "./codex-app-server-agent.js"; import { createTestLogger } from "../../../test-utils/test-logger.js"; import { agentConfigs } from "../../daemon-e2e/agent-configs.js"; +import type { AgentStreamEvent } from "../agent-sdk-types.js"; const CODEX_TEST_MODEL = agentConfigs.codex.model; const CODEX_TEST_THINKING_OPTION_ID = agentConfigs.codex.thinkingOptionId; @@ -20,6 +23,192 @@ function isCodexInstalled(): boolean { } } +function sse(events: unknown[]): string { + return events + .map((event) => { + const type = + typeof event === "object" && + event !== null && + "type" in event && + typeof (event as { type?: unknown }).type === "string" + ? (event as { type: string }).type + : "message"; + return `event: ${type}\ndata: ${JSON.stringify(event)}\n\n`; + }) + .join(""); +} + +function responseCreated(id: string): Record { + return { + type: "response.created", + response: { id }, + }; +} + +function responseCompleted(id: string): Record { + return { + type: "response.completed", + response: { + id, + usage: { + input_tokens: 0, + input_tokens_details: null, + output_tokens: 0, + output_tokens_details: null, + total_tokens: 0, + }, + }, + }; +} + +function functionCallEvent(callId: string, name: string, argumentsJson: string): Record { + return { + type: "response.output_item.done", + item: { + type: "function_call", + call_id: callId, + name, + arguments: argumentsJson, + }, + }; +} + +function assistantMessageEvent(id: string, text: string): Record { + return { + type: "response.output_item.done", + item: { + type: "message", + role: "assistant", + id, + content: [{ type: "output_text", text }], + }, + }; +} + +function requestUserInputSse(callId: string): string { + return sse([ + responseCreated("resp-1"), + functionCallEvent( + callId, + "request_user_input", + JSON.stringify({ + questions: [ + { + id: "confirm_path", + header: "Confirm", + question: "Proceed with the plan?", + options: [ + { + label: "Yes (Recommended)", + description: "Continue the current plan.", + }, + { + label: "No", + description: "Stop and revisit the approach.", + }, + ], + }, + ], + }), + ), + responseCompleted("resp-1"), + ]); +} + +function assistantMessageSse(text: string): string { + return sse([responseCreated("resp-2"), assistantMessageEvent("msg-1", text), responseCompleted("resp-2")]); +} + +async function startMockResponsesServer(sequence: string[]): Promise<{ + url: string; + close: () => Promise; + requestBodies: string[]; +}> { + const requestBodies: string[] = []; + let index = 0; + const server = createServer((req, res) => { + const chunks: Buffer[] = []; + req.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + req.on("end", () => { + requestBodies.push(Buffer.concat(chunks).toString("utf8")); + if (req.method !== "POST" || req.url !== "/v1/responses") { + res.statusCode = 404; + res.end("not found"); + return; + } + const body = sequence[index] ?? assistantMessageSse("done"); + index += 1; + res.statusCode = 200; + res.setHeader("content-type", "text/event-stream"); + res.setHeader("cache-control", "no-cache"); + res.end(body); + }); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Expected TCP address for mock responses server"); + } + return { + url: `http://127.0.0.1:${address.port}`, + requestBodies, + close: () => + new Promise((resolve, reject) => { + server.close((error) => { + if (error) reject(error); + else resolve(); + }); + }), + }; +} + +function writeMockCodexConfig(codexHome: string, serverUrl: string): void { + writeFileSync( + path.join(codexHome, "config.toml"), + ` +model = "mock-model" +approval_policy = "untrusted" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "${serverUrl}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +`, + ); +} + +function waitForEvent(params: { + session: { + subscribe(callback: (event: AgentStreamEvent) => void): () => void; + }; + predicate: (event: AgentStreamEvent) => event is TEvent; + timeoutMs: number; + label: string; +}): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + unsubscribe(); + reject(new Error(`Timed out waiting for ${params.label}`)); + }, params.timeoutMs); + + const unsubscribe = params.session.subscribe((event) => { + if (!params.predicate(event)) { + return; + } + clearTimeout(timeout); + unsubscribe(); + resolve(event); + }); + }); +} + describe("Codex app-server provider (e2e)", () => { test.runIf(isCodexInstalled())( "lists models and runs a simple prompt", @@ -43,4 +232,171 @@ describe("Codex app-server provider (e2e)", () => { }, 30000, ); + + test.runIf(isCodexInstalled())( + "surfaces request_user_input from the app-server as question permissions and timeline tool calls", + async () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-app-server-question-cwd-")); + const codexHome = mkdtempSync(path.join(os.tmpdir(), "codex-app-server-question-home-")); + const mockServer = await startMockResponsesServer([ + requestUserInputSse("call1"), + assistantMessageSse("done"), + ]); + + try { + writeMockCodexConfig(codexHome, mockServer.url); + + const client = new CodexAppServerAgentClient(createTestLogger()); + const session = await client.createSession( + { + provider: "codex", + cwd, + modeId: "auto", + model: "mock-model", + thinkingOptionId: "medium", + }, + { + env: { + CODEX_HOME: codexHome, + }, + }, + ); + + try { + await session.setFeature?.("plan_mode", true); + + const events: AgentStreamEvent[] = []; + session.subscribe((event) => { + events.push(event); + }); + + const questionRequested = waitForEvent({ + session, + timeoutMs: 15_000, + label: "question permission request", + predicate: ( + event, + ): event is Extract< + AgentStreamEvent, + { type: "permission_requested" } + > => + event.type === "permission_requested" && + event.request.provider === "codex" && + event.request.kind === "question" && + event.request.name === "request_user_input", + }); + + const turnFinished = waitForEvent({ + session, + timeoutMs: 15_000, + label: "turn completion", + predicate: ( + event, + ): event is Extract< + AgentStreamEvent, + { type: "turn_completed" | "turn_failed" | "turn_canceled" } + > => + event.type === "turn_completed" || + event.type === "turn_failed" || + event.type === "turn_canceled", + }); + + await session.startTurn("ask something"); + + const permissionEvent = await questionRequested; + expect(permissionEvent.request.input).toEqual({ + questions: [ + { + id: "confirm_path", + header: "Confirm", + question: "Proceed with the plan?", + isOther: true, + options: [ + { + label: "Yes (Recommended)", + description: "Continue the current plan.", + }, + { + label: "No", + description: "Stop and revisit the approach.", + }, + ], + }, + ], + }); + + const runningQuestionCall = events.find( + (event) => + event.type === "timeline" && + event.provider === "codex" && + event.item.type === "tool_call" && + event.item.name === "request_user_input" && + event.item.status === "running", + ); + expect(runningQuestionCall).toBeDefined(); + + await session.respondToPermission(permissionEvent.request.id, { + behavior: "allow", + updatedInput: { + answers: { + Confirm: "Yes (Recommended)", + }, + }, + }); + + const terminalEvent = await turnFinished; + expect(terminalEvent.type).toBe("turn_completed"); + + const completedQuestionCall = events.find( + (event) => + event.type === "timeline" && + event.provider === "codex" && + event.item.type === "tool_call" && + event.item.name === "request_user_input" && + event.item.status === "completed", + ); + expect(completedQuestionCall).toBeDefined(); + if ( + !completedQuestionCall || + completedQuestionCall.type !== "timeline" || + completedQuestionCall.item.type !== "tool_call" + ) { + throw new Error("Expected completed request_user_input tool call"); + } + expect(completedQuestionCall.item.metadata).toMatchObject({ + answers: { + confirm_path: ["Yes (Recommended)"], + }, + }); + expect( + mockServer.requestBodies.some( + (body) => + body.includes('"type":"function_call_output"') && + body.includes('"call_id":"call1"'), + ), + ).toBe(true); + + const finalAssistantMessage = [...events] + .reverse() + .find((event) => event.type === "timeline" && event.item.type === "assistant_message"); + expect(finalAssistantMessage).toBeDefined(); + if ( + !finalAssistantMessage || + finalAssistantMessage.type !== "timeline" || + finalAssistantMessage.item.type !== "assistant_message" + ) { + throw new Error("Expected final assistant message"); + } + expect(finalAssistantMessage.item.text.trim()).toBe("done"); + } finally { + await session.close(); + } + } finally { + await mockServer.close(); + rmSync(cwd, { recursive: true, force: true }); + rmSync(codexHome, { recursive: true, force: true }); + } + }, + 30_000, + ); }); 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 671ca4517..1faf6e213 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 @@ -1,7 +1,7 @@ import { describe, expect, test } from "vitest"; import { existsSync, rmSync } from "node:fs"; -import type { AgentLaunchContext } from "../agent-sdk-types.js"; +import type { AgentLaunchContext, AgentSession, AgentSessionConfig, AgentStreamEvent } from "../agent-sdk-types.js"; import { __codexAppServerInternals, codexAppServerTurnInputFromPrompt, @@ -10,6 +10,32 @@ import { createTestLogger } from "../../../test-utils/test-logger.js"; const ONE_BY_ONE_PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X1r0AAAAASUVORK5CYII="; +const CODEX_PROVIDER = "codex"; + +function createConfig(overrides: Partial = {}): AgentSessionConfig { + return { + provider: CODEX_PROVIDER, + cwd: "/tmp/codex-question-test", + modeId: "auto", + model: "gpt-5.4", + ...overrides, + }; +} + +function createSession(configOverrides: Partial = {}) { + const session = new __codexAppServerInternals.CodexAppServerAgentSession( + createConfig(configOverrides), + null, + createTestLogger(), + () => { + throw new Error("Test session cannot spawn Codex app-server"); + }, + ) as unknown as AgentSession & { [key: string]: unknown }; + session.connected = true; + session.currentThreadId = "test-thread"; + session.activeForegroundTurnId = "test-turn"; + return session; +} describe("Codex app-server provider", () => { const logger = createTestLogger(); @@ -138,4 +164,185 @@ describe("Codex app-server provider", () => { expect(env.PASEO_AGENT_ID).toBe(launchContext.env?.PASEO_AGENT_ID); expect(env.PASEO_TEST_FLAG).toBe(launchContext.env?.PASEO_TEST_FLAG); }); + + test("projects request_user_input into a question permission and running timeline tool call", () => { + const session = createSession(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + void (session as any).handleToolApprovalRequest({ + itemId: "call-question-1", + threadId: "thread-1", + turnId: "turn-1", + questions: [ + { + id: "favorite_drink", + header: "Drink", + question: "Which drink do you want?", + options: [ + { label: "Coffee", description: "Default" }, + { label: "Tea" }, + ], + }, + ], + }); + + expect(events).toEqual([ + { + type: "timeline", + provider: "codex", + turnId: "test-turn", + item: { + type: "tool_call", + callId: "call-question-1", + name: "request_user_input", + status: "running", + error: null, + detail: { + type: "plain_text", + text: "Drink: Which drink do you want?\nOptions: Coffee, Tea", + icon: "brain", + }, + metadata: { + questions: [ + { + id: "favorite_drink", + header: "Drink", + question: "Which drink do you want?", + options: [ + { label: "Coffee", description: "Default" }, + { label: "Tea" }, + ], + }, + ], + }, + }, + }, + { + type: "permission_requested", + provider: "codex", + turnId: "test-turn", + request: { + id: "permission-call-question-1", + provider: "codex", + name: "request_user_input", + kind: "question", + title: "Question", + detail: { + type: "plain_text", + text: "Drink: Which drink do you want?\nOptions: Coffee, Tea", + icon: "brain", + }, + input: { + questions: [ + { + id: "favorite_drink", + header: "Drink", + question: "Which drink do you want?", + options: [ + { label: "Coffee", description: "Default" }, + { label: "Tea" }, + ], + }, + ], + }, + metadata: { + itemId: "call-question-1", + threadId: "thread-1", + turnId: "turn-1", + questions: [ + { + id: "favorite_drink", + header: "Drink", + question: "Which drink do you want?", + options: [ + { label: "Coffee", description: "Default" }, + { label: "Tea" }, + ], + }, + ], + }, + }, + }, + ]); + }); + + test("maps question responses from headers back to question ids and completes the tool call", async () => { + const session = createSession(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + const pendingResponse = (session as any).handleToolApprovalRequest({ + itemId: "call-question-2", + threadId: "thread-1", + turnId: "turn-1", + questions: [ + { + id: "favorite_drink", + header: "Drink", + question: "Which drink do you want?", + options: [{ label: "Coffee" }, { label: "Tea" }], + }, + ], + }); + + await session.respondToPermission("permission-call-question-2", { + behavior: "allow", + updatedInput: { + answers: { + Drink: "Tea", + }, + }, + }); + + await expect(pendingResponse).resolves.toEqual({ + answers: { + favorite_drink: { answers: ["Tea"] }, + }, + }); + expect(events.at(-2)).toEqual({ + type: "permission_resolved", + provider: "codex", + turnId: "test-turn", + requestId: "permission-call-question-2", + resolution: { + behavior: "allow", + updatedInput: { + answers: { + Drink: "Tea", + }, + }, + }, + }); + expect(events.at(-1)).toEqual({ + type: "timeline", + provider: "codex", + turnId: "test-turn", + item: { + type: "tool_call", + callId: "call-question-2", + name: "request_user_input", + status: "completed", + error: null, + detail: { + type: "plain_text", + text: "Drink: Which drink do you want?\nOptions: Coffee, Tea\n\nAnswers:\n\nfavorite_drink: Tea", + icon: "brain", + }, + metadata: { + questions: [ + { + id: "favorite_drink", + header: "Drink", + question: "Which drink do you want?", + options: [{ label: "Coffee" }, { label: "Tea" }], + }, + ], + answers: { + favorite_drink: ["Tea"], + }, + }, + }, + }); + }); }); 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 cf88f12f8..bf8d3e086 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 @@ -573,6 +573,17 @@ class CodexAppServerClient { this.child.stdin.write(`${JSON.stringify(payload)}\n`); } + private writeJsonRpcResponse(response: JsonRpcResponse): void { + if (this.disposed || this.child.stdin.destroyed || !this.child.stdin.writable) { + return; + } + try { + this.child.stdin.write(`${JSON.stringify(response)}\n`); + } catch (error) { + this.logger.debug({ error }, "Failed to write Codex app-server JSON-RPC response"); + } + } + async dispose(): Promise { if (this.disposed) return; this.disposed = true; @@ -617,14 +628,12 @@ class CodexAppServerClient { const handler = this.requestHandlers.get(request.method); try { const result = handler ? await handler(request.params) : {}; - const response: JsonRpcResponse = { id: request.id, result }; - this.child.stdin.write(`${JSON.stringify(response)}\n`); + this.writeJsonRpcResponse({ id: request.id, result }); } catch (error) { - const response: JsonRpcResponse = { + this.writeJsonRpcResponse({ id: request.id, error: { message: error instanceof Error ? error.message : String(error) }, - }; - this.child.stdin.write(`${JSON.stringify(response)}\n`); + }); } return; } @@ -723,6 +732,187 @@ function mapCodexPlanToToolCall(params: { callId: string; text: string }): ToolC }; } +type CodexQuestionOption = { + label: string; + description?: string; +}; + +type CodexQuestionPrompt = { + id: string; + header: string; + question: string; + options: CodexQuestionOption[]; + multiSelect?: boolean; + isOther?: boolean; + isSecret?: boolean; +}; + +function normalizeCodexQuestionPrompts(raw: unknown): CodexQuestionPrompt[] { + if (!Array.isArray(raw)) { + return []; + } + const questions: CodexQuestionPrompt[] = []; + for (const item of raw) { + if (!item || typeof item !== "object") { + continue; + } + const record = item as Record; + const id = nonEmptyString(record.id); + const header = nonEmptyString(record.header); + const question = nonEmptyString(record.question); + if (!id || !header || !question) { + continue; + } + const options = Array.isArray(record.options) + ? record.options.flatMap((option): CodexQuestionOption[] => { + if (!option || typeof option !== "object") { + return []; + } + const optionRecord = option as Record; + const label = nonEmptyString(optionRecord.label); + if (!label) { + return []; + } + return [ + { + label, + ...(typeof optionRecord.description === "string" && + optionRecord.description.trim().length > 0 + ? { description: optionRecord.description } + : {}), + }, + ]; + }) + : []; + questions.push({ + id, + header, + question, + options, + ...(record.multiSelect === true ? { multiSelect: true } : {}), + ...(record.isOther === true ? { isOther: true } : {}), + ...(record.isSecret === true ? { isSecret: true } : {}), + }); + } + return questions; +} + +function formatCodexQuestionPrompts(questions: CodexQuestionPrompt[]): string { + return questions + .map((question) => { + const lines = [`${question.header}: ${question.question}`]; + if (question.options.length > 0) { + lines.push(`Options: ${question.options.map((option) => option.label).join(", ")}`); + } + return lines.join("\n"); + }) + .join("\n\n") + .trim(); +} + +function mapCodexQuestionRequestToToolCall(params: { + callId: string; + questions: CodexQuestionPrompt[]; + status: ToolCallTimelineItem["status"]; + answers?: Record; + error?: unknown; +}): ToolCallTimelineItem { + const formattedQuestions = formatCodexQuestionPrompts(params.questions); + const formattedAnswers = + params.answers && Object.keys(params.answers).length > 0 + ? Object.entries(params.answers) + .map(([id, values]) => `${id}: ${values.join(", ")}`) + .join("\n") + : null; + const detailText = + params.status === "completed" && formattedAnswers + ? [formattedQuestions, "Answers:", formattedAnswers].filter(Boolean).join("\n\n") + : formattedQuestions; + + const base = { + type: "tool_call" as const, + callId: params.callId, + name: "request_user_input", + detail: { + type: "plain_text" as const, + text: detailText, + icon: "brain" as const, + }, + metadata: { + questions: params.questions, + ...(params.answers ? { answers: params.answers } : {}), + }, + }; + + if (params.status === "failed") { + return { + ...base, + status: "failed", + error: params.error ?? { message: "Question dismissed" }, + }; + } + if (params.status === "canceled") { + return { + ...base, + status: "canceled", + error: null, + }; + } + if (params.status === "running") { + return { + ...base, + status: "running", + error: null, + }; + } + return { + ...base, + status: "completed", + error: null, + }; +} + +function mapCodexQuestionResponseByHeader(params: { + questions: CodexQuestionPrompt[]; + response: AgentPermissionResponse; +}): Record | null { + if (params.response.behavior !== "allow") { + return null; + } + const answersRecord = + params.response.updatedInput && typeof params.response.updatedInput === "object" + ? ((params.response.updatedInput as Record).answers as + | Record + | undefined) + : undefined; + if (!answersRecord || typeof answersRecord !== "object") { + return null; + } + + const answers: Record = {}; + for (const question of params.questions) { + const rawAnswer = answersRecord[question.header]; + if (typeof rawAnswer !== "string") { + continue; + } + const normalizedAnswer = rawAnswer.trim(); + if (!normalizedAnswer) { + continue; + } + const values = question.multiSelect + ? normalizedAnswer + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + : [normalizedAnswer]; + if (values.length > 0) { + answers[question.id] = { answers: values }; + } + } + + return Object.keys(answers).length > 0 ? answers : null; +} + type CodexPatchFileChange = { path: string; kind?: string; @@ -2072,8 +2262,8 @@ class CodexAppServerAgentSession implements AgentSession { string, { resolve: (value: unknown) => void; - kind: "command" | "file" | "tool"; - questions?: Array<{ id: string; options?: Array<{ label?: string; value?: string }> }>; + kind: "command" | "file" | "question"; + questions?: CodexQuestionPrompt[]; } >(); private resolvedPermissionRequests = new Set(); @@ -2284,6 +2474,10 @@ class CodexAppServerAgentSession implements AgentSession { this.client.setRequestHandler("item/fileChange/requestApproval", (params) => this.handleFileChangeApprovalRequest(params), ); + this.client.setRequestHandler("item/tool/requestUserInput", (params) => + this.handleToolApprovalRequest(params), + ); + // Keep the legacy method name for older Codex builds. this.client.setRequestHandler("tool/requestUserInput", (params) => this.handleToolApprovalRequest(params), ); @@ -2745,26 +2939,53 @@ class CodexAppServerAgentSession implements AgentSession { return; } - // tool/requestUserInput - const answers: Record = {}; const questions = pending.questions ?? []; - const decision = - response.behavior === "allow" ? "accept" : response.interrupt ? "cancel" : "decline"; - for (const question of questions) { - let picked = decision; - const options = question.options ?? []; - if (options.length > 0) { - const byLabel = options.find((opt) => (opt.label ?? "").toLowerCase().includes(decision)); - const byValue = options.find((opt) => (opt.value ?? "").toLowerCase().includes(decision)); - const option = byLabel ?? byValue ?? options[0]!; - picked = option.value ?? option.label ?? decision; - } - answers[question.id] = { answers: [picked] }; + const itemId = + typeof pendingRequest?.metadata?.itemId === "string" ? pendingRequest.metadata.itemId : requestId; + if (response.behavior === "allow") { + const mappedAnswers = mapCodexQuestionResponseByHeader({ + questions, + response, + }); + const answers = + mappedAnswers ?? + Object.fromEntries( + questions + .map((question) => { + const fallback = question.options[0]?.label?.trim(); + return fallback + ? [question.id, { answers: [fallback] }] + : null; + }) + .filter((entry): entry is [string, { answers: string[] }] => entry !== null), + ); + this.emitEvent({ + type: "timeline", + provider: CODEX_PROVIDER, + item: mapCodexQuestionRequestToToolCall({ + callId: itemId, + questions, + status: "completed", + answers: Object.fromEntries( + Object.entries(answers).map(([id, value]) => [id, value.answers]), + ), + }), + }); + pending.resolve({ answers }); + return; } - if (questions.length === 0) { - answers["default"] = { answers: [decision] }; - } - pending.resolve({ answers }); + + this.emitEvent({ + type: "timeline", + provider: CODEX_PROVIDER, + item: mapCodexQuestionRequestToToolCall({ + callId: itemId, + questions, + status: response.interrupt ? "canceled" : "failed", + error: { message: response.message ?? "Question dismissed" }, + }), + }); + pending.resolve({ answers: {} }); } describePersistence(): { @@ -3463,34 +3684,43 @@ class CodexAppServerAgentSession implements AgentSession { private handleToolApprovalRequest(params: unknown): Promise { const parsed = params as { itemId: string; threadId: string; turnId: string; questions: any[] }; const requestId = `permission-${parsed.itemId}`; + const questions = normalizeCodexQuestionPrompts(parsed.questions); const request: AgentPermissionRequest = { id: requestId, provider: CODEX_PROVIDER, - name: "CodexTool", - kind: "tool", - title: "Tool action requires approval", + name: "request_user_input", + kind: "question", + title: "Question", description: undefined, detail: { - type: "unknown", - input: { - questions: Array.isArray(parsed.questions) ? parsed.questions : [], - }, - output: null, + type: "plain_text", + text: formatCodexQuestionPrompts(questions), + icon: "brain", }, + input: { questions }, metadata: { itemId: parsed.itemId, threadId: parsed.threadId, turnId: parsed.turnId, - questions: parsed.questions, + questions, }, }; this.pendingPermissions.set(requestId, request); + this.emitEvent({ + type: "timeline", + provider: CODEX_PROVIDER, + item: mapCodexQuestionRequestToToolCall({ + callId: parsed.itemId, + questions, + status: "running", + }), + }); this.emitEvent({ type: "permission_requested", provider: CODEX_PROVIDER, request }); return new Promise((resolve) => { this.pendingPermissionHandlers.set(requestId, { resolve, - kind: "tool", - questions: Array.isArray(parsed.questions) ? parsed.questions : [], + kind: "question", + questions, }); }); } @@ -3723,8 +3953,11 @@ export const __codexAppServerInternals = { buildCodexAppServerEnv, codexModelSupportsFastMode, CodexAppServerAgentSession, + formatCodexQuestionPrompts, + mapCodexQuestionRequestToToolCall, mapCodexPatchNotificationToToolCall, planStepsToMarkdown, mapCodexPlanToToolCall, + normalizeCodexQuestionPrompts, threadItemToTimeline, }; From a264058a3057185f1a94daf3c72038fc1fce9833 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 5 Apr 2026 11:10:49 +0700 Subject: [PATCH 73/73] Remove noisy agent toasts and debug logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the "Refreshing" and "Failed to refresh agent" toasts from the agent panel — they fire frequently but are meaningless since sync recovers transparently. The "Reconnecting..." toast for host disconnect is preserved. Strip debug console.log calls across the app: render tracking in message/stream views, dependency change tracking in workspace screen, terminal tab slot logging, and verbose audio engine/voice runtime bridge stats logging. Legitimate console.error/warn in catch blocks are kept. --- .../app/src/components/agent-stream-view.tsx | 38 ---- packages/app/src/components/message.tsx | 10 -- .../app/src/components/split-container.tsx | 12 -- .../use-agent-screen-state-machine.test.ts | 117 +------------ .../hooks/use-agent-screen-state-machine.ts | 47 +---- .../use-delayed-history-refresh-toast.tsx | 70 -------- packages/app/src/panels/agent-panel.tsx | 22 --- .../screens/workspace/workspace-screen.tsx | 61 ------- packages/app/src/voice/audio-engine.native.ts | 118 +------------ packages/app/src/voice/voice-runtime.ts | 165 +----------------- 10 files changed, 15 insertions(+), 645 deletions(-) delete mode 100644 packages/app/src/hooks/use-delayed-history-refresh-toast.tsx diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 00c1790ba..fd17d4223 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -264,44 +264,6 @@ const AgentStreamViewComponent = forwardRef>({}); - useEffect(() => { - const prev = debugStreamPrevRef.current; - const curr: Record = { - // handleInlinePathPress deps (line 196-205) - "hip.agent.cwd": agent.cwd, - "hip.openFileExplorer": openFileExplorer, - "hip.requestDirectoryListing": requestDirectoryListing, - "hip.resolvedServerId": resolvedServerId, - "hip.router": router, - "hip.setExplorerTabForCheckout": setExplorerTabForCheckout, - "hip.onOpenWorkspaceFile": onOpenWorkspaceFile, - "hip.workspaceId": workspaceId, - // top-level deps - handleInlinePathPress, - "agent.status": agent.status, - streamRenderStrategy, - getGapBetween, - streamItems, - "streamItems.length": streamItems.length, - streamHead, - baseRenderModel, - }; - const changed: string[] = []; - for (const key of Object.keys(curr)) { - if (!Object.is(prev[key], curr[key])) { - changed.push(key); - } - } - if (changed.length > 0 && Object.keys(prev).length > 0) { - console.log("[AgentStreamView] deps changed:", changed.join(", ")); - } - debugStreamPrevRef.current = curr; - }); - const renderStreamItemContent = useCallback( ( item: StreamItem, diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 7c65e5e55..3bc5eacb3 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -717,13 +717,6 @@ export const AssistantMessage = memo(function AssistantMessage({ workspaceRoot, disableOuterSpacing, }: AssistantMessageProps) { - // DEBUG: log when AssistantMessage actually renders (inside memo boundary) - console.log("[AssistantMessage] render", { - messageLength: message?.length, - timestamp, - hasOnInlinePathPress: !!onInlinePathPress, - }); - const { theme, rt } = useUnistyles(); const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing); @@ -1765,9 +1758,6 @@ export const ToolCall = memo(function ToolCall({ onInlineDetailsHoverChange, onInlineDetailsExpandedChange, }: ToolCallProps) { - // DEBUG: log when ToolCall actually renders (inside memo boundary) - console.log("[ToolCall] render", { toolName, status }); - const { openToolCall } = useToolCallSheet(); const [isExpanded, setIsExpanded] = useState(false); diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index e799cdc5d..8b521358f 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -177,18 +177,6 @@ const MountedTabSlot = memo(function MountedTabSlot({ paneId, buildPaneContentModel, }: MountedTabSlotProps) { - useEffect(() => { - if (tabDescriptor.target.kind !== "terminal") { - return; - } - console.log("[terminal-tab-slot]", { - paneId, - tabId: tabDescriptor.tabId, - terminalId: tabDescriptor.target.terminalId, - isVisible, - isPaneFocused, - }); - }, [isPaneFocused, isVisible, paneId, tabDescriptor]); const content = useMemo( () => 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 137985743..59a9d165e 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 @@ -9,7 +9,6 @@ import { type ReadyState = Extract; type CatchingUpSyncState = Extract; -type SyncErrorSyncState = Extract; function createAgent(id: string): Agent { const now = new Date("2026-02-19T00:00:00.000Z"); @@ -74,7 +73,6 @@ function createBaseMemory( return { hasRenderedReady: false, lastReadyAgent: null, - activeToastLatch: "none", hadInitialSyncFailure: false, ...overrides, }; @@ -96,12 +94,8 @@ function expectCatchingUpSync(state: ReadyState): CatchingUpSyncState { return state.sync; } -function expectSyncErrorSync(state: ReadyState): SyncErrorSyncState { +function expectSyncErrorSync(state: ReadyState): void { expect(state.sync.status).toBe("sync_error"); - if (state.sync.status !== "sync_error") { - throw new Error("expected sync_error sync state"); - } - return state.sync; } describe("deriveAgentScreenViewState", () => { @@ -165,10 +159,9 @@ describe("deriveAgentScreenViewState", () => { const sync = expectCatchingUpSync(ready); expect(sync.ui).toBe("overlay"); - expect(sync.shouldEmitHistoryRefreshToast).toBe(false); }); - it("uses toast catching-up state for already-hydrated agents", () => { + it("uses silent catching-up state for already-hydrated agents", () => { const memory = createBaseMemory({ hasRenderedReady: true, lastReadyAgent: createAgent("agent-1"), @@ -183,8 +176,7 @@ describe("deriveAgentScreenViewState", () => { const ready = expectReadyState(result.state); const sync = expectCatchingUpSync(ready); - expect(sync.ui).toBe("toast"); - expect(sync.shouldEmitHistoryRefreshToast).toBe(true); + expect(sync.ui).toBe("silent"); }); it("keeps sync errors non-blocking once the screen was ready", () => { @@ -200,9 +192,7 @@ describe("deriveAgentScreenViewState", () => { const result = deriveAgentScreenViewState({ input, memory }); const ready = expectReadyState(result.state); - const sync = expectSyncErrorSync(ready); - - expect(sync.shouldEmitSyncErrorToast).toBe(true); + expectSyncErrorSync(ready); }); it("remembers first-load sync failure and keeps catch-up overlay off after error clears", () => { @@ -221,9 +211,7 @@ describe("deriveAgentScreenViewState", () => { memory: initialMemory, }); const errorReady = expectReadyState(errorResult.state); - const errorSync = expectSyncErrorSync(errorReady); - - expect(errorSync.shouldEmitSyncErrorToast).toBe(true); + expectSyncErrorSync(errorReady); expect(errorResult.memory.hadInitialSyncFailure).toBe(true); const retryInput: AgentScreenMachineInput = { @@ -239,7 +227,6 @@ describe("deriveAgentScreenViewState", () => { const retrySync = expectCatchingUpSync(retryReady); expect(retrySync.ui).toBe("silent"); - expect(retrySync.shouldEmitHistoryRefreshToast).toBe(false); expect(retryResult.memory.hadInitialSyncFailure).toBe(true); }); @@ -255,35 +242,10 @@ describe("deriveAgentScreenViewState", () => { const result = deriveAgentScreenViewState({ input, memory }); const ready = expectReadyState(result.state); - const sync = expectSyncErrorSync(ready); + expectSyncErrorSync(ready); expect(ready.source).toBe("stale"); expect(ready.agent.id).toBe("agent-1"); - expect(sync.shouldEmitSyncErrorToast).toBe(true); - }); - - it("emits sync error toast only on transition into sync_error", () => { - const memory = createBaseMemory({ - hasRenderedReady: true, - lastReadyAgent: createAgent("agent-1"), - }); - const input: AgentScreenMachineInput = { - ...createBaseInput(), - missingAgentState: { kind: "error", message: "network timeout" }, - }; - - const first = deriveAgentScreenViewState({ input, memory }); - const firstReady = expectReadyState(first.state); - const firstSync = expectSyncErrorSync(firstReady); - expect(firstSync.shouldEmitSyncErrorToast).toBe(true); - - const second = deriveAgentScreenViewState({ - input, - memory: first.memory, - }); - const secondReady = expectReadyState(second.state); - const secondSync = expectSyncErrorSync(secondReady); - expect(secondSync.shouldEmitSyncErrorToast).toBe(false); }); it("returns blocking error before first paint when refresh fails", () => { @@ -484,71 +446,6 @@ describe("deriveAgentScreenViewState", () => { expect(ready.agent.status).toBe("closed"); }); - it("emits history refresh toast only on transition into toast catch-up state", () => { - const memory = createBaseMemory({ - hasRenderedReady: true, - lastReadyAgent: createAgent("agent-1"), - }); - const input: AgentScreenMachineInput = { - ...createBaseInput(), - needsAuthoritativeSync: true, - hasHydratedHistoryBefore: true, - }; - - const first = deriveAgentScreenViewState({ input, memory }); - const firstReady = expectReadyState(first.state); - const firstSync = expectCatchingUpSync(firstReady); - - expect(firstSync.ui).toBe("toast"); - expect(firstSync.shouldEmitHistoryRefreshToast).toBe(true); - - const second = deriveAgentScreenViewState({ - input, - memory: first.memory, - }); - const secondReady = expectReadyState(second.state); - const secondSync = expectCatchingUpSync(secondReady); - - expect(secondSync.ui).toBe("toast"); - expect(secondSync.shouldEmitHistoryRefreshToast).toBe(false); - }); - - it("re-arms history refresh toast after leaving and re-entering catch-up", () => { - const baseInput: AgentScreenMachineInput = { - ...createBaseInput(), - hasHydratedHistoryBefore: true, - }; - const initialMemory = createBaseMemory({ - hasRenderedReady: true, - lastReadyAgent: createAgent("agent-1"), - }); - - const firstCatchingUp = deriveAgentScreenViewState({ - input: { ...baseInput, needsAuthoritativeSync: true }, - memory: initialMemory, - }); - const firstCatchingUpReady = expectReadyState(firstCatchingUp.state); - const firstCatchingUpSync = expectCatchingUpSync(firstCatchingUpReady); - expect(firstCatchingUpSync.ui).toBe("toast"); - expect(firstCatchingUpSync.shouldEmitHistoryRefreshToast).toBe(true); - - const idle = deriveAgentScreenViewState({ - input: { ...baseInput, needsAuthoritativeSync: false }, - memory: firstCatchingUp.memory, - }); - const idleReady = expectReadyState(idle.state); - expect(idleReady.sync.status).toBe("idle"); - - const secondCatchingUp = deriveAgentScreenViewState({ - input: { ...baseInput, needsAuthoritativeSync: true }, - memory: idle.memory, - }); - const secondCatchingUpReady = expectReadyState(secondCatchingUp.state); - const secondCatchingUpSync = expectCatchingUpSync(secondCatchingUpReady); - expect(secondCatchingUpSync.ui).toBe("toast"); - expect(secondCatchingUpSync.shouldEmitHistoryRefreshToast).toBe(true); - }); - it("clears initial sync failure memory after history is hydrated", () => { const memory = createBaseMemory({ hasRenderedReady: true, @@ -565,7 +462,7 @@ describe("deriveAgentScreenViewState", () => { const ready = expectReadyState(result.state); const sync = expectCatchingUpSync(ready); - expect(sync.ui).toBe("toast"); + expect(sync.ui).toBe("silent"); expect(result.memory.hadInitialSyncFailure).toBe(false); }); }); 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 2c37b70a6..5bc29306e 100644 --- a/packages/app/src/hooks/use-agent-screen-state-machine.ts +++ b/packages/app/src/hooks/use-agent-screen-state-machine.ts @@ -39,12 +39,9 @@ function shouldBlockInitialAuthoritativeReadyState(input: AgentScreenMachineInpu ); } -export type AgentScreenToastLatch = "none" | "history_refresh" | "sync_error"; - export interface AgentScreenMachineMemory { hasRenderedReady: boolean; lastReadyAgent: AgentScreenAgent | null; - activeToastLatch: AgentScreenToastLatch; hadInitialSyncFailure: boolean; } @@ -54,17 +51,8 @@ export type AgentScreenReadySyncState = | { status: "catching_up"; ui: "overlay" | "silent"; - shouldEmitHistoryRefreshToast: false; } - | { - status: "catching_up"; - ui: "toast"; - shouldEmitHistoryRefreshToast: boolean; - } - | { - status: "sync_error"; - shouldEmitSyncErrorToast: boolean; - }; + | { status: "sync_error" }; export type AgentScreenViewState = | { @@ -98,7 +86,6 @@ export function deriveAgentScreenViewState({ const nextMemory: AgentScreenMachineMemory = { hasRenderedReady: memory.hasRenderedReady, lastReadyAgent: memory.lastReadyAgent, - activeToastLatch: memory.activeToastLatch, hadInitialSyncFailure: memory.hadInitialSyncFailure, }; @@ -180,45 +167,23 @@ export function deriveAgentScreenViewState({ let sync: AgentScreenReadySyncState; if (!input.isConnected) { - nextMemory.activeToastLatch = "none"; sync = { status: "reconnecting" }; } else if (input.missingAgentState.kind === "error") { - const shouldEmitSyncErrorToast = memory.activeToastLatch !== "sync_error"; - nextMemory.activeToastLatch = "sync_error"; - sync = { - status: "sync_error", - shouldEmitSyncErrorToast, - }; + sync = { status: "sync_error" }; } else if (input.needsAuthoritativeSync || input.isHistorySyncing) { - let ui: "overlay" | "toast" | "silent"; + let ui: "overlay" | "silent"; if (input.shouldUseOptimisticStream) { ui = "silent"; } else if (input.hasHydratedHistoryBefore) { - ui = "toast"; + ui = "silent"; } else if (nextMemory.hadInitialSyncFailure) { ui = "silent"; } else { ui = "overlay"; } - if (ui === "toast") { - const shouldEmitHistoryRefreshToast = memory.activeToastLatch !== "history_refresh"; - nextMemory.activeToastLatch = "history_refresh"; - sync = { - status: "catching_up", - ui, - shouldEmitHistoryRefreshToast, - }; - } else { - nextMemory.activeToastLatch = "none"; - sync = { - status: "catching_up", - ui, - shouldEmitHistoryRefreshToast: false, - }; - } + sync = { status: "catching_up", ui }; } else { - nextMemory.activeToastLatch = "none"; sync = { status: "idle" }; } @@ -245,7 +210,6 @@ export function useAgentScreenStateMachine({ const memoryRef = useRef({ hasRenderedReady: false, lastReadyAgent: null, - activeToastLatch: "none", hadInitialSyncFailure: false, }); @@ -254,7 +218,6 @@ export function useAgentScreenStateMachine({ memoryRef.current = { hasRenderedReady: false, lastReadyAgent: null, - activeToastLatch: "none", hadInitialSyncFailure: false, }; } diff --git a/packages/app/src/hooks/use-delayed-history-refresh-toast.tsx b/packages/app/src/hooks/use-delayed-history-refresh-toast.tsx deleted file mode 100644 index 3c1d1d772..000000000 --- a/packages/app/src/hooks/use-delayed-history-refresh-toast.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { useEffect, useRef, type ReactNode } from "react"; -import { ActivityIndicator } from "react-native"; -import type { ToastShowOptions } from "@/components/toast-host"; - -const HISTORY_REFRESH_TOAST_DELAY_MS = 1000; -const HISTORY_REFRESH_TOAST_DURATION_MS = 2200; - -interface UseDelayedHistoryRefreshToastParams { - isCatchingUp: boolean; - indicatorColor: string; - showToast: (content: ReactNode, options?: ToastShowOptions) => void; -} - -export function useDelayedHistoryRefreshToast({ - isCatchingUp, - indicatorColor, - showToast, -}: UseDelayedHistoryRefreshToastParams): void { - const timerRef = useRef | null>(null); - const wasCatchingUpRef = useRef(false); - const isCatchingUpRef = useRef(false); - const showToastRef = useRef(showToast); - const indicatorColorRef = useRef(indicatorColor); - - useEffect(() => { - showToastRef.current = showToast; - }, [showToast]); - - useEffect(() => { - indicatorColorRef.current = indicatorColor; - }, [indicatorColor]); - - useEffect(() => { - isCatchingUpRef.current = isCatchingUp; - - const enteredCatchUp = !wasCatchingUpRef.current && isCatchingUp; - const exitedCatchUp = wasCatchingUpRef.current && !isCatchingUp; - - if (enteredCatchUp) { - if (timerRef.current) { - clearTimeout(timerRef.current); - } - timerRef.current = setTimeout(() => { - timerRef.current = null; - if (!isCatchingUpRef.current) { - return; - } - showToastRef.current("Refreshing", { - icon: , - durationMs: HISTORY_REFRESH_TOAST_DURATION_MS, - testID: "agent-history-refresh-toast", - }); - }, HISTORY_REFRESH_TOAST_DELAY_MS); - } else if (exitedCatchUp && timerRef.current) { - clearTimeout(timerRef.current); - timerRef.current = null; - } - - wasCatchingUpRef.current = isCatchingUp; - }, [isCatchingUp]); - - useEffect(() => { - return () => { - if (timerRef.current) { - clearTimeout(timerRef.current); - timerRef.current = null; - } - }; - }, []); -} diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 96910ffa7..debf2ff29 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -21,7 +21,6 @@ import { type AgentScreenMissingState, } from "@/hooks/use-agent-screen-state-machine"; import { useArchiveAgent } from "@/hooks/use-archive-agent"; -import { useDelayedHistoryRefreshToast } from "@/hooks/use-delayed-history-refresh-toast"; import { useAgentInputDraft } from "@/hooks/use-agent-input-draft"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useStableEvent } from "@/hooks/use-stable-event"; @@ -704,27 +703,6 @@ function AgentPanelBody({ shouldUseOptimisticStream, ]); - const isHistoryRefreshCatchingUp = - viewState.tag === "ready" && - viewState.sync.status === "catching_up" && - viewState.sync.ui === "toast"; - const shouldEmitSyncErrorToast = - viewState.tag === "ready" && - viewState.sync.status === "sync_error" && - viewState.sync.shouldEmitSyncErrorToast; - - useDelayedHistoryRefreshToast({ - isCatchingUp: isHistoryRefreshCatchingUp, - indicatorColor: theme.colors.primary, - showToast: panelToast.api.show, - }); - - useEffect(() => { - if (!shouldEmitSyncErrorToast) { - return; - } - panelToast.api.error("Failed to refresh agent. Retrying in background."); - }, [panelToast.api, shouldEmitSyncErrorToast]); if (viewState.tag === "not_found") { return ( diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index ff241f11a..d4555c4ab 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -1308,29 +1308,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) [allTabDescriptorsById, handleCloseAgentTab, handleCloseDraftOrFileTab, handleCloseTerminalTab], ); - const prevCloseTabDeps = useRef({ - allTabDescriptorsById, - handleCloseAgentTab, - handleCloseDraftOrFileTab, - handleCloseTerminalTab, - }); - useEffect(() => { - const prev = prevCloseTabDeps.current; - const changed: string[] = []; - if (prev.allTabDescriptorsById !== allTabDescriptorsById) changed.push("allTabDescriptorsById"); - if (prev.handleCloseAgentTab !== handleCloseAgentTab) changed.push("handleCloseAgentTab"); - if (prev.handleCloseDraftOrFileTab !== handleCloseDraftOrFileTab) - changed.push("handleCloseDraftOrFileTab"); - if (prev.handleCloseTerminalTab !== handleCloseTerminalTab) - changed.push("handleCloseTerminalTab"); - if (changed.length > 0) console.log("[handleCloseTabById] deps changed:", changed.join(", ")); - prevCloseTabDeps.current = { - allTabDescriptorsById, - handleCloseAgentTab, - handleCloseDraftOrFileTab, - handleCloseTerminalTab, - }; - }); const handleCopyAgentId = useCallback( async (agentId: string) => { if (!agentId) return; @@ -1795,44 +1772,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) retargetWorkspaceTab, ], ); - const prevBuildDeps = useRef({ - handleCloseTabById, - handleOpenFileFromChat, - focusWorkspacePane, - navigateToTabId, - normalizedServerId, - normalizedWorkspaceId, - openWorkspaceTab, - persistenceKey, - retargetWorkspaceTab, - }); - useEffect(() => { - const prev = prevBuildDeps.current; - const changed: string[] = []; - if (prev.handleCloseTabById !== handleCloseTabById) changed.push("handleCloseTabById"); - if (prev.handleOpenFileFromChat !== handleOpenFileFromChat) - changed.push("handleOpenFileFromChat"); - if (prev.focusWorkspacePane !== focusWorkspacePane) changed.push("focusWorkspacePane"); - if (prev.navigateToTabId !== navigateToTabId) changed.push("navigateToTabId"); - if (prev.normalizedServerId !== normalizedServerId) changed.push("normalizedServerId"); - if (prev.normalizedWorkspaceId !== normalizedWorkspaceId) changed.push("normalizedWorkspaceId"); - if (prev.openWorkspaceTab !== openWorkspaceTab) changed.push("openWorkspaceTab"); - if (prev.persistenceKey !== persistenceKey) changed.push("persistenceKey"); - if (prev.retargetWorkspaceTab !== retargetWorkspaceTab) changed.push("retargetWorkspaceTab"); - if (changed.length > 0) - console.log("[buildPaneContentModel] deps changed:", changed.join(", ")); - prevBuildDeps.current = { - handleCloseTabById, - handleOpenFileFromChat, - focusWorkspacePane, - navigateToTabId, - normalizedServerId, - normalizedWorkspaceId, - openWorkspaceTab, - persistenceKey, - retargetWorkspaceTab, - }; - }); const focusedPaneId = focusedPaneTabState.pane?.id ?? null; const focusedPaneTabIds = useMemo(() => tabs.map((tab) => tab.tabId), [tabs]); const focusedPaneTabDescriptorMap = useStableTabDescriptorMap(tabs); diff --git a/packages/app/src/voice/audio-engine.native.ts b/packages/app/src/voice/audio-engine.native.ts index b88344953..f58056ade 100644 --- a/packages/app/src/voice/audio-engine.native.ts +++ b/packages/app/src/voice/audio-engine.native.ts @@ -14,20 +14,6 @@ interface AudioEngineTraceOptions { traceLabel?: string; } -let nextAudioEngineInstanceId = 1; - -interface BridgeStats { - windowStartedAtMs: number; - captureEvents: number; - captureBytes: number; - volumeEvents: number; - volumeMax: number; - playbackEvents: number; - playbackInputBytes: number; - playbackResampledBytes: number; - playbackDurationMs: number; -} - function parsePcmSampleRate(mimeType: string): number | null { const match = /rate=(\d+)/i.exec(mimeType); if (!match) { @@ -85,48 +71,6 @@ export function createAudioEngine( _options?: AudioEngineTraceOptions, ): AudioEngine { const native = require("@getpaseo/expo-two-way-audio"); - const instanceId = nextAudioEngineInstanceId++; - const bridgeStats: BridgeStats = { - windowStartedAtMs: Date.now(), - captureEvents: 0, - captureBytes: 0, - volumeEvents: 0, - volumeMax: 0, - playbackEvents: 0, - playbackInputBytes: 0, - playbackResampledBytes: 0, - playbackDurationMs: 0, - }; - - const toHexPreview = (bytes: Uint8Array, count = 12): string => - Array.from(bytes.slice(0, count)) - .map((value) => value.toString(16).padStart(2, "0")) - .join(" "); - - const maybeFlushBridgeStats = (reason: string): void => { - const now = Date.now(); - const elapsedMs = now - bridgeStats.windowStartedAtMs; - if (elapsedMs < 1000) { - return; - } - console.log( - `[AudioEngine.native#${instanceId}][bridge] ${reason} ` + - `capture=${bridgeStats.captureEvents}ev/${bridgeStats.captureBytes}B ` + - `volume=${bridgeStats.volumeEvents}ev max=${bridgeStats.volumeMax.toFixed(3)} ` + - `play=${bridgeStats.playbackEvents}ev/${bridgeStats.playbackInputBytes}B->${bridgeStats.playbackResampledBytes}B ` + - `playMs=${bridgeStats.playbackDurationMs.toFixed(1)} ` + - `windowMs=${elapsedMs}`, - ); - bridgeStats.windowStartedAtMs = now; - bridgeStats.captureEvents = 0; - bridgeStats.captureBytes = 0; - bridgeStats.volumeEvents = 0; - bridgeStats.volumeMax = 0; - bridgeStats.playbackEvents = 0; - bridgeStats.playbackInputBytes = 0; - bridgeStats.playbackResampledBytes = 0; - bridgeStats.playbackDurationMs = 0; - }; const refs: { initialized: boolean; @@ -140,8 +84,6 @@ export function createAudioEngine( reject: (error: Error) => void; settled: boolean; } | null; - sawFirstMicChunk: boolean; - sawFirstVolumeEvent: boolean; destroyed: boolean; } = { initialized: false, @@ -151,8 +93,6 @@ export function createAudioEngine( processingQueue: false, playbackTimeout: null, activePlayback: null, - sawFirstMicChunk: false, - sawFirstVolumeEvent: false, destroyed: false, }; @@ -163,15 +103,6 @@ export function createAudioEngine( return; } const pcm = event.data as Uint8Array; - if (!refs.sawFirstMicChunk) { - refs.sawFirstMicChunk = true; - console.log( - `[AudioEngine.native#${instanceId}] firstMicChunk bytes=${pcm.byteLength} head=${toHexPreview(pcm)}`, - ); - } - bridgeStats.captureEvents += 1; - bridgeStats.captureBytes += pcm.byteLength; - maybeFlushBridgeStats("capture"); callbacks.onCaptureData(pcm); }, ); @@ -182,26 +113,10 @@ export function createAudioEngine( return; } const level = refs.muted ? 0 : event.data; - bridgeStats.volumeEvents += 1; - bridgeStats.volumeMax = Math.max(bridgeStats.volumeMax, level); - if (!refs.sawFirstVolumeEvent) { - refs.sawFirstVolumeEvent = true; - console.log( - `[AudioEngine.native#${instanceId}] firstInputVolume level=${level.toFixed(3)} muted=${refs.muted}`, - ); - } - maybeFlushBridgeStats("volume"); callbacks.onVolumeLevel(level); }, ); - const outputVolumeSubscription = native.addExpoTwoWayAudioEventListener( - "onOutputVolumeLevelData", - (event: any) => { - console.log(`[AudioEngine.native#${instanceId}] outputVolume=${event.data}`); - }, - ); - async function ensureInitialized(): Promise { if (refs.initialized) { return; @@ -210,20 +125,13 @@ export function createAudioEngine( if (!success) { throw new Error("expo-two-way-audio: native initialize() returned false"); } - console.log(`[AudioEngine.native#${instanceId}] initialized successfully`); refs.initialized = true; } async function ensureMicrophonePermission(): Promise { let permission = await native.getMicrophonePermissionsAsync().catch(() => null); - console.log( - `[AudioEngine.native#${instanceId}] microphonePermission initial=${permission?.status ?? "unknown"} granted=${String(permission?.granted ?? false)}`, - ); if (!permission?.granted) { permission = await native.requestMicrophonePermissionsAsync().catch(() => null); - console.log( - `[AudioEngine.native#${instanceId}] microphonePermission requested=${permission?.status ?? "unknown"} granted=${String(permission?.granted ?? false)}`, - ); } if (!permission?.granted) { throw new Error( @@ -253,17 +161,6 @@ export function createAudioEngine( // Native AudioEngine expects 16kHz PCM16 const pcm16k = resamplePcm16(pcm, inputRate, 16000); const durationSec = pcm16k.length / 2 / 16000; - bridgeStats.playbackEvents += 1; - bridgeStats.playbackInputBytes += pcm.length; - bridgeStats.playbackResampledBytes += pcm16k.length; - bridgeStats.playbackDurationMs += durationSec * 1000; - - console.log( - `[AudioEngine.native#${instanceId}] playPCMData: inputRate=${inputRate} inputBytes=${pcm.length} ` + - `resampled=${pcm16k.length} durationSec=${durationSec.toFixed(3)} ` + - `pcmHead=${toHexPreview(pcm)} resampledHead=${toHexPreview(pcm16k)}`, - ); - maybeFlushBridgeStats("play"); native.resumePlayback(); native.playPCMData(pcm16k); @@ -334,26 +231,18 @@ export function createAudioEngine( } microphoneSubscription.remove(); volumeSubscription.remove(); - outputVolumeSubscription.remove(); }, async startCapture() { if (refs.captureActive) { - console.log(`[AudioEngine.native#${instanceId}] startCapture skipped: already active`); return; } try { - console.log(`[AudioEngine.native#${instanceId}] startCapture begin`); await ensureMicrophonePermission(); await ensureInitialized(); - refs.sawFirstMicChunk = false; - refs.sawFirstVolumeEvent = false; - const isRecording = native.toggleRecording(true); + native.toggleRecording(true); refs.captureActive = true; - console.log( - `[AudioEngine.native#${instanceId}] startCapture toggleRecording(true) => ${String(isRecording)}`, - ); } catch (error) { const wrapped = error instanceof Error ? error : new Error(String(error)); callbacks.onError?.(wrapped); @@ -363,10 +252,7 @@ export function createAudioEngine( async stopCapture() { if (refs.captureActive) { - const isRecording = native.toggleRecording(false); - console.log( - `[AudioEngine.native#${instanceId}] stopCapture toggleRecording(false) => ${String(isRecording)}`, - ); + native.toggleRecording(false); } refs.captureActive = false; refs.muted = false; diff --git a/packages/app/src/voice/voice-runtime.ts b/packages/app/src/voice/voice-runtime.ts index 5b26e9a99..dd56b4712 100644 --- a/packages/app/src/voice/voice-runtime.ts +++ b/packages/app/src/voice/voice-runtime.ts @@ -3,7 +3,6 @@ import type { AgentStreamEventPayload, SessionOutboundMessage } from "@server/sh import { resolveVoiceUnavailableMessage } from "@/utils/server-info-capabilities"; import type { DaemonServerInfo } from "@/stores/session-store"; import type { AudioEngine } from "@/voice/audio-engine-types"; -import { REALTIME_VOICE_VAD_CONFIG } from "@/voice/realtime-voice-config"; import { THINKING_TONE_NATIVE_PCM_BASE64, THINKING_TONE_NATIVE_PCM_DURATION_MS, @@ -82,8 +81,6 @@ interface RuntimeState { segmentDurationTimer: ReturnType | null; lastDisplayVolumePublishMs: number; serverSpeechStartedAt: number | null; - lastNoServerSpeechLogMs: number; - localAboveThresholdActive: boolean; } type AudioOutputPayload = Extract["payload"]; @@ -120,19 +117,6 @@ interface CueState { playing: boolean; } -interface RealtimeBridgeStats { - windowStartedAtMs: number; - captureEvents: number; - captureBytes: number; - uplinkEvents: number; - uplinkRawBytes: number; - uplinkBase64Chars: number; - outputEvents: number; - outputBytes: number; - outputGroups: number; - jsLagMaxMs: number; -} - const INITIAL_SNAPSHOT: VoiceRuntimeSnapshot = { phase: "disabled", isVoiceMode: false, @@ -210,8 +194,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime { segmentDurationTimer: null, lastDisplayVolumePublishMs: 0, serverSpeechStartedAt: null, - lastNoServerSpeechLogMs: 0, - localAboveThresholdActive: false, }; const playback: RuntimePlaybackState = { groups: new Map(), @@ -220,18 +202,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime { processing: false, generation: 0, }; - const bridgeStats: RealtimeBridgeStats = { - windowStartedAtMs: Date.now(), - captureEvents: 0, - captureBytes: 0, - uplinkEvents: 0, - uplinkRawBytes: 0, - uplinkBase64Chars: 0, - outputEvents: 0, - outputBytes: 0, - outputGroups: 0, - jsLagMaxMs: 0, - }; const cue: CueState = { active: false, token: 0, @@ -246,41 +216,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime { return cuePcm16.buffer.slice(cuePcm16.byteOffset, cuePcm16.byteOffset + cuePcm16.byteLength); }, }; - let lagProbeLastMs = Date.now(); - const lagProbe = setInterval(() => { - const now = Date.now(); - const lagMs = Math.max(0, now - lagProbeLastMs - 100); - lagProbeLastMs = now; - if (lagMs > bridgeStats.jsLagMaxMs) { - bridgeStats.jsLagMaxMs = lagMs; - } - }, 100); - - function flushBridgeStats(reason: string): void { - const now = Date.now(); - const elapsedMs = now - bridgeStats.windowStartedAtMs; - if (elapsedMs < 1000) { - return; - } - console.log( - `[VoiceRuntime#${instanceId}][bridge] ${reason} ` + - `capture=${bridgeStats.captureEvents}ev/${bridgeStats.captureBytes}B ` + - `uplink=${bridgeStats.uplinkEvents}ev/${bridgeStats.uplinkRawBytes}B/${bridgeStats.uplinkBase64Chars}c ` + - `output=${bridgeStats.outputEvents}ev/${bridgeStats.outputBytes}B groups=${bridgeStats.outputGroups} ` + - `jsLagMaxMs=${bridgeStats.jsLagMaxMs} windowMs=${elapsedMs}`, - ); - bridgeStats.windowStartedAtMs = now; - bridgeStats.captureEvents = 0; - bridgeStats.captureBytes = 0; - bridgeStats.uplinkEvents = 0; - bridgeStats.uplinkRawBytes = 0; - bridgeStats.uplinkBase64Chars = 0; - bridgeStats.outputEvents = 0; - bridgeStats.outputBytes = 0; - bridgeStats.outputGroups = 0; - bridgeStats.jsLagMaxMs = 0; - } - function emit(): void { for (const listener of listeners) { listener(); @@ -354,17 +289,11 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime { } function resetPlaybackState(): void { - const hadGroups = playback.groups.size; playback.generation += 1; playback.groups.clear(); playback.orderedGroupIds = []; playback.activeGroupId = null; playback.processing = false; - if (hadGroups > 0) { - console.log( - `[VoiceRuntime] resetPlaybackState: cleared ${hadGroups} groups, new gen=${playback.generation}`, - ); - } } function activateNextPlaybackGroup(): void { @@ -394,15 +323,9 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime { playback.processing = true; const generation = playback.generation; - console.log( - `[VoiceRuntime] processPlaybackQueue start gen=${generation} activeGroup=${playback.activeGroupId}`, - ); try { while (playback.activeGroupId) { if (generation !== playback.generation) { - console.log( - `[VoiceRuntime] processPlaybackQueue abort: generation changed ${generation} -> ${playback.generation}`, - ); return; } @@ -415,9 +338,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime { const nextChunk = group.chunks.get(group.nextChunkToPlay); if (!nextChunk) { if (group.finalChunkIndex !== null && group.nextChunkToPlay > group.finalChunkIndex) { - console.log( - `[VoiceRuntime] group=${group.groupId} complete, played=${group.started} chunks=${group.nextChunkToPlay}`, - ); playback.groups.delete(group.groupId); if (playback.orderedGroupIds[0] === group.groupId) { playback.orderedGroupIds.shift(); @@ -432,9 +352,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime { activateNextPlaybackGroup(); continue; } - console.log( - `[VoiceRuntime] group=${group.groupId} waiting for chunk=${group.nextChunkToPlay} (finalChunkIndex=${group.finalChunkIndex})`, - ); return; } @@ -442,39 +359,21 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime { if (group.shouldPlay && !group.started && group.isVoiceMode) { group.started = true; - console.log( - `[VoiceRuntime] group=${group.groupId} first play starting at chunk=${group.nextChunkToPlay}`, - ); api.onAssistantAudioStarted(serverId); } - const playStart = Date.now(); try { if (group.shouldPlay) { await deps.engine.play(nextChunk.source); - console.log( - `[VoiceRuntime] played chunk=${group.nextChunkToPlay} id=${nextChunk.id} took=${Date.now() - playStart}ms`, - ); - } else { - console.log( - `[VoiceRuntime] SKIPPED chunk=${group.nextChunkToPlay} id=${nextChunk.id} shouldPlay=false`, - ); } } catch (error) { if (generation !== playback.generation) { - console.log(`[VoiceRuntime] play error + generation changed, aborting`); return; } - console.error( - `[VoiceRuntime] play error chunk=${group.nextChunkToPlay} took=${Date.now() - playStart}ms:`, - error, - ); + console.error(`[VoiceRuntime] play error chunk=${group.nextChunkToPlay}:`, error); } if (generation !== playback.generation) { - console.log( - `[VoiceRuntime] post-play generation changed ${generation} -> ${playback.generation}`, - ); return; } @@ -491,9 +390,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime { if (generation === playback.generation) { playback.processing = false; } - console.log( - `[VoiceRuntime] processPlaybackQueue exit gen=${generation} currentGen=${playback.generation}`, - ); } } @@ -606,12 +502,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime { } const base64 = Buffer.from(chunk).toString("base64"); - bridgeStats.captureEvents += 1; - bridgeStats.captureBytes += chunk.byteLength; - bridgeStats.uplinkEvents += 1; - bridgeStats.uplinkRawBytes += chunk.byteLength; - bridgeStats.uplinkBase64Chars += base64.length; - flushBridgeStats("uplink"); void activeSession.adapter.sendVoiceAudioChunk(base64, PCM_MIME_TYPE).catch((error) => { console.error(`[VoiceRuntime#${instanceId}] Failed to send audio chunk:`, error); @@ -624,8 +514,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime { state.turnInProgress = false; state.serverSpeechDetected = false; state.lastDisplayVolumePublishMs = 0; - state.lastNoServerSpeechLogMs = 0; - state.localAboveThresholdActive = false; uploader.reset(); resetCaptureTelemetry(); patchSnapshot({ ...INITIAL_SNAPSHOT }); @@ -746,11 +634,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime { if (!state.snapshot.isVoiceMode || state.snapshot.isMuted) { return; } - if (bridgeStats.captureEvents === 0) { - console.log( - `[VoiceRuntime#${instanceId}] firstCapturePcm bytes=${chunk.byteLength} phase=${state.snapshot.phase} transportReady=${state.transportReady}`, - ); - } uploader.pushPcmChunk(chunk); }, @@ -767,30 +650,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime { return; } - const isActive = level > REALTIME_VOICE_VAD_CONFIG.volumeThreshold; - if (isActive && !state.localAboveThresholdActive) { - state.localAboveThresholdActive = true; - console.log( - `[VoiceRuntime#${instanceId}] localSpeechActive level=${level.toFixed(3)} threshold=${REALTIME_VOICE_VAD_CONFIG.volumeThreshold.toFixed(3)} phase=${state.snapshot.phase} transportReady=${state.transportReady}`, - ); - } - if (!isActive && state.localAboveThresholdActive) { - state.localAboveThresholdActive = false; - console.log( - `[VoiceRuntime#${instanceId}] localSpeechInactive phase=${state.snapshot.phase} serverSpeaking=${state.serverSpeechDetected}`, - ); - } - if ( - isActive && - !state.serverSpeechDetected && - nowMs - state.lastNoServerSpeechLogMs >= 1500 - ) { - state.lastNoServerSpeechLogMs = nowMs; - console.log( - `[VoiceRuntime#${instanceId}] localSpeechWithoutServerSpeech level=${level.toFixed(3)} phase=${state.snapshot.phase} turnInProgress=${state.turnInProgress} transportReady=${state.transportReady}`, - ); - } - patchTelemetry((prev) => ({ ...prev, isSpeaking: state.serverSpeechDetected, @@ -805,33 +664,15 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime { !state.snapshot.isVoiceMode || !payload.isVoiceMode ) { - console.log( - `[VoiceRuntime#${instanceId}] audio_output DROPPED: activeServer=${state.snapshot.activeServerId} serverId=${serverId} isVoiceMode=${state.snapshot.isVoiceMode} payloadVoice=${payload.isVoiceMode}`, - ); return; } const groupId = payload.groupId ?? payload.id; const chunkIndex = payload.chunkIndex ?? 0; const decoded = decodeAudioChunk(payload.audio); - bridgeStats.outputEvents += 1; - bridgeStats.outputBytes += decoded.byteLength; - bridgeStats.outputGroups += playback.groups.has(groupId) ? 0 : 1; - console.log( - `[VoiceRuntime#${instanceId}] audio_output groupId=${groupId} chunk=${chunkIndex} isLast=${payload.isLastChunk} ` + - `base64Chars=${payload.audio.length} decodedBytes=${decoded.byteLength} format=${payload.format} ` + - `head=${Array.from(decoded.slice(0, 12)) - .map((value) => value.toString(16).padStart(2, "0")) - .join(" ")}`, - ); - flushBridgeStats("audio_output"); let group = playback.groups.get(groupId); if (!group) { - const shouldPlay = api.shouldPlayVoiceAudio(serverId); - console.log( - `[VoiceRuntime] new group=${groupId} shouldPlay=${shouldPlay} phase=${state.snapshot.phase} isSpeaking=${state.telemetry.isSpeaking}`, - ); group = { groupId, isVoiceMode: payload.isVoiceMode, @@ -965,7 +806,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime { async destroy() { await this.stopVoice().catch(() => undefined); - clearInterval(lagProbe); await deps.engine.destroy(); listeners.clear(); telemetryListeners.clear(); @@ -1054,9 +894,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime { return; } - console.log( - `[VoiceRuntime#${instanceId}] onServerSpeechStateChanged isSpeaking=${isSpeaking} phase=${state.snapshot.phase} volume=${state.telemetry.volume}`, - ); state.serverSpeechDetected = isSpeaking; state.serverSpeechStartedAt = isSpeaking ? (state.serverSpeechStartedAt ?? Date.now()) : null; if (isSpeaking) {