From d3e3a83a0dabdcb4809e3eb4602db8e9b557f947 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 7 Apr 2026 17:15:18 +0700 Subject: [PATCH 01/18] Render speak tool calls inline as spoken messages Instead of showing speak tool calls as expandable tool call badges, render them inline with a mic icon header matching assistant message styling. --- .../app/src/components/agent-stream-view.tsx | 16 +++++ packages/app/src/components/message.tsx | 60 +++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index f99667d54..0dccd9e61 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -26,6 +26,7 @@ import { Check, ChevronDown, X } from "lucide-react-native"; import { usePanelStore } from "@/stores/panel-store"; import { AssistantMessage, + SpeakMessage, UserMessage, ActivityLog, ToolCall, @@ -359,6 +360,21 @@ const AgentStreamViewComponent = forwardRef + ); + } + return ( ({ + container: { + paddingHorizontal: theme.spacing[2], + paddingVertical: theme.spacing[3], + }, + containerSpacing: { + marginBottom: theme.spacing[4], + }, + header: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + marginBottom: theme.spacing[2], + }, + headerLabel: { + fontFamily: Fonts.sans, + fontSize: 12, + fontWeight: "500", + color: theme.colors.foregroundMuted, + }, + text: { + fontFamily: Fonts.sans, + fontSize: theme.fontSize.base, + lineHeight: 22, + color: theme.colors.foreground, + }, +})); + +export const SpeakMessage = memo(function SpeakMessage({ + message, + timestamp, + disableOuterSpacing, +}: SpeakMessageProps) { + const { theme } = useUnistyles(); + const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing); + + return ( + + + + Spoke + + {message} + + ); +}); + interface ActivityLogProps { type: "system" | "info" | "success" | "error" | "artifact"; message: string; From 82466aaa9fc595c9bae3fe13212fe8109747cc61 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 7 Apr 2026 18:41:46 +0700 Subject: [PATCH 02/18] Reset Silero VAD between voice turns to prevent LSTM drift The Silero VAD session ran for the entire voice mode lifetime without resetting its LSTM hidden states. Over time the internal state drifted, causing phantom speech detection on silence and getting permanently stuck in the "speaking" phase. Add reset() to TurnDetectionSession and call it after each completed utterance to clear the LSTM hidden states between turns. --- .../local/sherpa/silero-vad-session.ts | 163 +++++++++++++++--- .../server/speech/turn-detection-provider.ts | 1 + .../voice/voice-turn-controller.test.ts | 1 + .../src/server/voice/voice-turn-controller.ts | 4 +- 4 files changed, 141 insertions(+), 28 deletions(-) diff --git a/packages/server/src/server/speech/providers/local/sherpa/silero-vad-session.ts b/packages/server/src/server/speech/providers/local/sherpa/silero-vad-session.ts index c212d7204..87fe92b1f 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/silero-vad-session.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/silero-vad-session.ts @@ -8,10 +8,17 @@ import { loadSherpaOnnxNode } from "./sherpa-onnx-node-loader.js"; const DEFAULT_SAMPLE_RATE = 16000; const DEFAULT_BUFFER_SIZE_SECONDS = 60; const DEFAULT_SILERO_THRESHOLD = 0.5; -const DEFAULT_MIN_SILENCE_DURATION = 1.2; -const DEFAULT_MIN_SPEECH_DURATION = 0.2; const DEFAULT_WINDOW_SIZE = 512; +// Silero internal durations — kept low so isDetected() tracks actual sound. +// Our own state machine handles speech confirmation and end-of-speech detection. +const SILERO_MIN_SILENCE_DURATION = 0.2; +const SILERO_MIN_SPEECH_DURATION = 0.1; + +// Our boundary detection thresholds (in milliseconds). +const DEFAULT_CONFIRM_MS = 800; +const DEFAULT_SILENCE_MS = 1000; + type SherpaVadHandle = { acceptWaveform(samples: Float32Array): void; isDetected(): boolean; @@ -42,20 +49,30 @@ export interface SherpaSileroVadSessionConfig { modelPath?: string; sampleRate?: number; threshold?: number; - minSilenceDuration?: number; - minSpeechDuration?: number; windowSize?: number; bufferSizeInSeconds?: number; + confirmMs?: number; + silenceMs?: number; } +type VadPhase = + | { state: "idle" } + | { state: "confirming"; startedAt: number } + | { state: "speaking" } + | { state: "ending"; startedAt: number }; + export class SherpaSileroVadSession extends EventEmitter implements TurnDetectionSession { public readonly requiredSampleRate: number; private readonly vad: SherpaVadHandle; private readonly inputBuffer: SherpaCircularBufferHandle; private readonly windowSize: number; + private readonly msPerWindow: number; + private readonly confirmMs: number; + private readonly silenceMs: number; private connected = false; - private inSpeech = false; + private phase: VadPhase = { state: "idle" }; + private windowTimestamp = 0; private readonly logger; constructor(params: { @@ -67,16 +84,35 @@ export class SherpaSileroVadSession extends EventEmitter implements TurnDetectio const config = params.config ?? {}; this.requiredSampleRate = config.sampleRate ?? DEFAULT_SAMPLE_RATE; this.windowSize = config.windowSize ?? DEFAULT_WINDOW_SIZE; + this.msPerWindow = (this.windowSize / this.requiredSampleRate) * 1000; + this.confirmMs = config.confirmMs ?? DEFAULT_CONFIRM_MS; + this.silenceMs = config.silenceMs ?? DEFAULT_SILENCE_MS; + + const threshold = config.threshold ?? DEFAULT_SILERO_THRESHOLD; + + this.logger.debug( + { + threshold, + sileroMinSilenceDuration: SILERO_MIN_SILENCE_DURATION, + sileroMinSpeechDuration: SILERO_MIN_SPEECH_DURATION, + confirmMs: this.confirmMs, + silenceMs: this.silenceMs, + windowSize: this.windowSize, + msPerWindow: this.msPerWindow, + sampleRate: this.requiredSampleRate, + }, + "[VAD] Initializing Silero VAD session", + ); const sherpa = loadSherpaOnnxNode() as unknown as SherpaVadModule; this.vad = new sherpa.Vad( { sileroVad: { model: config.modelPath ?? resolveBundledSileroVadModelPath(), - threshold: config.threshold ?? DEFAULT_SILERO_THRESHOLD, - minSilenceDuration: config.minSilenceDuration ?? DEFAULT_MIN_SILENCE_DURATION, - minSpeechDuration: config.minSpeechDuration ?? DEFAULT_MIN_SPEECH_DURATION, - windowSize: config.windowSize ?? DEFAULT_WINDOW_SIZE, + threshold, + minSilenceDuration: SILERO_MIN_SILENCE_DURATION, + minSpeechDuration: SILERO_MIN_SPEECH_DURATION, + windowSize: this.windowSize, }, sampleRate: this.requiredSampleRate, numThreads: 1, @@ -106,11 +142,14 @@ export class SherpaSileroVadSession extends EventEmitter implements TurnDetectio try { const samples = pcm16leToFloat32(pcm16le, 1); this.inputBuffer.push(samples); + let windowsProcessed = 0; while (this.inputBuffer.size() > this.windowSize) { const window = this.inputBuffer.get(this.inputBuffer.head(), this.windowSize, false); this.inputBuffer.pop(this.windowSize); this.vad.acceptWaveform(window); - this.syncDetectionState(); + this.windowTimestamp += this.msPerWindow; + windowsProcessed++; + this.stepStateMachine(); } } catch (error) { this.emit("error", error instanceof Error ? error : new Error(String(error))); @@ -123,41 +162,111 @@ export class SherpaSileroVadSession extends EventEmitter implements TurnDetectio } try { + this.logger.debug( + { phase: this.phase.state }, + "[VAD] Flushing remaining audio", + ); this.vad.flush(); - this.syncDetectionState(); - if (this.inSpeech) { - this.inSpeech = false; + this.stepStateMachine(); + if (this.phase.state === "speaking" || this.phase.state === "ending") { + this.logger.debug("[VAD] Forcing speech_stopped after flush"); + this.phase = { state: "idle" }; this.emit("speech_stopped"); + } else if (this.phase.state === "confirming") { + this.logger.debug("[VAD] Discarding unconfirmed speech on flush"); + this.phase = { state: "idle" }; } } catch (error) { this.emit("error", error instanceof Error ? error : new Error(String(error))); } } - close(): void { + reset(): void { try { this.vad.reset(); this.inputBuffer.reset(); } catch { // ignore native cleanup failures } finally { - this.connected = false; - this.inSpeech = false; + this.phase = { state: "idle" }; } } - private syncDetectionState(): void { - const detected = this.vad.isDetected(); - if (detected && !this.inSpeech) { - this.inSpeech = true; - this.emit("speech_started"); - return; - } + close(): void { + this.reset(); + this.connected = false; + this.windowTimestamp = 0; + } - if (!detected && this.inSpeech && !this.vad.isEmpty()) { - this.logger.debug("Silero VAD marked end of speech"); - this.inSpeech = false; - this.emit("speech_stopped"); + private stepStateMachine(): void { + const detected = this.vad.isDetected(); + const now = this.windowTimestamp; + + switch (this.phase.state) { + case "idle": { + if (detected) { + this.logger.debug( + { now }, + "[VAD] idle → confirming (detection started)", + ); + this.phase = { state: "confirming", startedAt: now }; + } + break; + } + + case "confirming": { + if (!detected) { + const elapsed = now - this.phase.startedAt; + this.logger.debug( + { elapsed, confirmMs: this.confirmMs }, + "[VAD] confirming → idle (detection dropped before confirmation)", + ); + this.phase = { state: "idle" }; + break; + } + const elapsed = now - this.phase.startedAt; + if (elapsed >= this.confirmMs) { + this.logger.debug( + { elapsed, confirmMs: this.confirmMs }, + "[VAD] confirming → speaking (speech confirmed)", + ); + this.phase = { state: "speaking" }; + this.emit("speech_started"); + } + break; + } + + case "speaking": { + if (!detected) { + this.logger.debug( + { now }, + "[VAD] speaking → ending (silence started)", + ); + this.phase = { state: "ending", startedAt: now }; + } + break; + } + + case "ending": { + if (detected) { + this.logger.debug( + { elapsed: now - this.phase.startedAt }, + "[VAD] ending → speaking (speech resumed)", + ); + this.phase = { state: "speaking" }; + break; + } + const elapsed = now - this.phase.startedAt; + if (elapsed >= this.silenceMs) { + this.logger.debug( + { elapsed, silenceMs: this.silenceMs }, + "[VAD] ending → idle (speech stopped)", + ); + this.phase = { state: "idle" }; + this.emit("speech_stopped"); + } + break; + } } } } diff --git a/packages/server/src/server/speech/turn-detection-provider.ts b/packages/server/src/server/speech/turn-detection-provider.ts index 46fb6c386..d25501833 100644 --- a/packages/server/src/server/speech/turn-detection-provider.ts +++ b/packages/server/src/server/speech/turn-detection-provider.ts @@ -10,6 +10,7 @@ export type TurnDetectionSession = { connect(): Promise; appendPcm16(pcm16le: Buffer): void; flush(): void; + reset(): void; close(): void; on(event: "speech_started", handler: () => void): unknown; diff --git a/packages/server/src/server/voice/voice-turn-controller.test.ts b/packages/server/src/server/voice/voice-turn-controller.test.ts index 1a5446eb1..92901539b 100644 --- a/packages/server/src/server/voice/voice-turn-controller.test.ts +++ b/packages/server/src/server/voice/voice-turn-controller.test.ts @@ -19,6 +19,7 @@ class FakeTurnDetectionSession extends EventEmitter implements TurnDetectionSess } flush(): void {} + reset(): void {} close(): void {} } diff --git a/packages/server/src/server/voice/voice-turn-controller.ts b/packages/server/src/server/voice/voice-turn-controller.ts index b1eeac40e..3c2f0b76d 100644 --- a/packages/server/src/server/voice/voice-turn-controller.ts +++ b/packages/server/src/server/voice/voice-turn-controller.ts @@ -9,7 +9,7 @@ import { FixedDurationPcmRingBuffer } from "./fixed-duration-pcm-ring-buffer.js" const PCM_CHANNELS = 1; const PCM_BITS_PER_SAMPLE = 16; -const DEFAULT_PREFIX_DURATION_MS = 400; +const DEFAULT_PREFIX_DURATION_MS = 1000; type VoiceInputState = | { status: "idle" } @@ -134,6 +134,8 @@ export function createVoiceTurnController(params: { utteranceChunks = []; state = { status: "listening", rollingPrefixBytes: prefixBuffer.byteLength }; + detector.reset(); + await params.callbacks.onSpeechStopped(); params.logger.info( From cbc2ce06e9c351e7e31be9c7372ef0376f296e7c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 7 Apr 2026 18:56:47 +0700 Subject: [PATCH 03/18] Fix WorkingIndicator remounting on every stream update on native Passing an arrow function to FlatList's ListHeaderComponent caused React to treat each re-render as a new component type, unmounting and remounting the entire header (including the bouncing dots animation). Pass the element directly instead so React can reconcile properly. --- packages/app/src/components/stream-strategy-native.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/components/stream-strategy-native.tsx b/packages/app/src/components/stream-strategy-native.tsx index ac75da5b2..97664cd39 100644 --- a/packages/app/src/components/stream-strategy-native.tsx +++ b/packages/app/src/components/stream-strategy-native.tsx @@ -313,7 +313,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat keyExtractor={(item) => item.id} testID="agent-chat-scroll" nativeID="agent-chat-scroll-native-virtualized" - ListHeaderComponent={liveHeaderContent ? () => liveHeaderContent : undefined} + ListHeaderComponent={liveHeaderContent ?? undefined} contentContainerStyle={baseListContentContainerStyle} style={listStyle} onLayout={handleListLayout} From 33a1557aede1263127c1559ace29ca03c7b41a9f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 7 Apr 2026 18:56:51 +0700 Subject: [PATCH 04/18] Show provider error details in settings screen --- packages/app/src/screens/settings-screen.tsx | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 796c21bf2..6227b429c 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -477,12 +477,25 @@ function ProvidersSection({ routeServerId }: ProvidersSectionProps) { const entry = entries?.find((e) => e.provider === def.id); const status = entry?.status ?? "unavailable"; const ProviderIcon = getProviderIcon(def.id); + const providerError = + status === "error" && typeof entry?.error === "string" && entry.error.trim().length > 0 + ? entry.error.trim() + : null; return ( - - - {def.label} + + + + {def.label} + + {providerError ? ( + + {providerError} + + ) : null} Date: Tue, 7 Apr 2026 18:56:53 +0700 Subject: [PATCH 05/18] Skip provider refresh when one is already in-flight --- .../agent/provider-snapshot-manager.test.ts | 45 +++++++++++++++++++ .../server/agent/provider-snapshot-manager.ts | 18 ++++++-- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts index 4ea80bf3b..e47495505 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -251,6 +251,51 @@ describe("ProviderSnapshotManager", () => { manager.destroy(); }); + test("refresh during an in-flight refresh is a no-op", async () => { + const fetchModels = deferred(); + const fetchModes = deferred(); + const { registry, handles } = createRegistry([ + createMockProvider({ + provider: "codex", + fetchModels: async () => fetchModels.promise, + fetchModes: async () => fetchModes.promise, + }), + ]); + const manager = new ProviderSnapshotManager(registry, createTestLogger()); + const changes: ProviderSnapshotEntry[][] = []; + manager.on("change", (entries) => changes.push(entries)); + + manager.refresh("/tmp/project"); + + expect(manager.getSnapshot("/tmp/project")).toEqual([ + { provider: "codex", status: "loading" }, + ]); + + manager.refresh("/tmp/project"); + manager.refresh("/tmp/project"); + manager.refresh("/tmp/project"); + + expect(changes).toHaveLength(1); + expect(handles.codex?.isAvailable).toHaveBeenCalledTimes(1); + + fetchModels.resolve([createModel("codex", "gpt-5.2")]); + fetchModes.resolve([createMode("auto")]); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "codex")).toMatchObject({ + provider: "codex", + status: "ready", + models: [createModel("codex", "gpt-5.2")], + modes: [createMode("auto")], + }); + }); + + expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(1); + expect(handles.codex?.fetchModes).toHaveBeenCalledTimes(1); + + manager.destroy(); + }); + test("multiple getSnapshot calls for same cwd do not trigger multiple warmUps", async () => { const codexModels = deferred(); const { registry, handles } = createRegistry([ diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index dded5a2b4..78b56ce70 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -32,8 +32,7 @@ export class ProviderSnapshotManager { const cwdKey = normalizeCwdKey(cwd); const entries = this.snapshots.get(cwdKey); if (!entries) { - const loadingEntries = this.createLoadingEntries(); - this.snapshots.set(cwdKey, loadingEntries); + const loadingEntries = this.resetSnapshotToLoading(cwdKey); void this.warmUp(cwd); return entriesToArray(loadingEntries); } @@ -42,7 +41,11 @@ export class ProviderSnapshotManager { refresh(cwd?: string): void { const cwdKey = normalizeCwdKey(cwd); - this.snapshots.set(cwdKey, this.createLoadingEntries()); + if (this.warmUps.has(cwdKey)) { + return; + } + this.resetSnapshotToLoading(cwdKey); + this.emitChange(cwdKey); void this.warmUp(cwd); } @@ -170,6 +173,15 @@ export class ProviderSnapshotManager { return created; } + private resetSnapshotToLoading(cwdKey: string): Map { + const snapshot = this.getOrCreateSnapshot(cwdKey); + snapshot.clear(); + for (const [provider, entry] of this.createLoadingEntries()) { + snapshot.set(provider, entry); + } + return snapshot; + } + private getProviderIds(): AgentProvider[] { return AGENT_PROVIDER_IDS.filter((provider) => this.providerRegistry[provider]); } From 76c6253ae08447af8a473161db434a213d52a8f8 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 7 Apr 2026 18:56:58 +0700 Subject: [PATCH 06/18] Persist context window usage across turns from last task_progress Stop resetting lastContextWindowUsedTokens between turns. The result.usage fallback contains accumulated session totals which grow incorrectly across turns. Once a task_progress arrives, its value is the accurate context fill level and should be retained until the next task_progress supersedes it. --- .../src/server/agent/providers/claude-agent.test.ts | 13 ++++++++----- .../src/server/agent/providers/claude-agent.ts | 12 ++++++------ 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/packages/server/src/server/agent/providers/claude-agent.test.ts b/packages/server/src/server/agent/providers/claude-agent.test.ts index 68732b9e9..b6863a3f7 100644 --- a/packages/server/src/server/agent/providers/claude-agent.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.test.ts @@ -358,7 +358,7 @@ describe("ClaudeAgentSession context window usage", () => { }); } - test("convertUsage includes contextWindowMaxTokens and derives used tokens from result usage", async () => { + test("convertUsage includes contextWindowMaxTokens and derives used tokens from result usage as initial fallback", async () => { const session = await createSessionForTest(); const usage = session.convertUsage( @@ -388,7 +388,7 @@ describe("ClaudeAgentSession context window usage", () => { }); }); - test("contextWindowUsedTokens is derived from result usage when task_progress is missing", async () => { + test("contextWindowUsedTokens falls back to result usage when no task_progress was received", async () => { const session = await createSessionForTest(); const usage = session.convertUsage({ @@ -508,7 +508,7 @@ describe("ClaudeAgentSession context window usage", () => { }); }); - test("contextWindowUsedTokens resets between turns and falls back to result usage", async () => { + test("contextWindowUsedTokens persists across turns from last task_progress", async () => { const queryFactory = createQueryFactoryForTurns([ [ { @@ -600,20 +600,23 @@ describe("ClaudeAgentSession context window usage", () => { contextWindowMaxTokens: 200_000, contextWindowUsedTokens: 999, }); + // Turn 2 has no task_progress, so contextWindowUsedTokens retains the + // last known value from turn 1 rather than deriving from accumulated + // result.usage (which would be incorrect — those are session-level totals). expect(secondTurn.usage).toEqual({ inputTokens: 11, cachedInputTokens: 6, outputTokens: 8, totalCostUsd: 0.1, contextWindowMaxTokens: 200_000, - contextWindowUsedTokens: 28, + contextWindowUsedTokens: 999, }); } finally { await session.close(); } }); - test("convertUsage derives used tokens even when modelUsage is missing", async () => { + test("convertUsage derives used tokens from result usage as fallback when task_progress is missing", async () => { const session = await createSessionForTest(); const usage = session.convertUsage({ diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index 364a8af77..e780586e2 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -1460,7 +1460,6 @@ class ClaudeAgentSession implements AgentSession { const sdkMessage = this.toSdkUserMessage(prompt); this.lastForegroundPromptText = this.extractPromptText(prompt); - this.lastContextWindowUsedTokens = undefined; const turnId = this.createTurnId("foreground"); this.activeForegroundTurnId = turnId; this.foregroundHasVisibleActivity = false; @@ -2304,7 +2303,6 @@ class ClaudeAgentSession implements AgentSession { this.notifySubscribers(event); this.activeForegroundTurnId = null; this.lastForegroundPromptText = null; - this.lastContextWindowUsedTokens = undefined; this.cancelCurrentTurn = null; this.syncTurnState("foreground turn terminal"); } @@ -2320,12 +2318,10 @@ class ClaudeAgentSession implements AgentSession { if (this.activeForegroundTurnId) { this.activeForegroundTurnId = null; this.lastForegroundPromptText = null; - this.lastContextWindowUsedTokens = undefined; this.cancelCurrentTurn = null; this.syncTurnState("foreground turn terminal"); } else if (this.autonomousTurn) { this.autonomousTurn = null; - this.lastContextWindowUsedTokens = undefined; this.syncTurnState("autonomous turn terminal"); } } @@ -2338,7 +2334,6 @@ class ClaudeAgentSession implements AgentSession { this.autonomousTurn = { id: this.createTurnId("autonomous"), }; - this.lastContextWindowUsedTokens = undefined; this.notifySubscribers({ type: "turn_started", provider: "claude" }); this.syncTurnState("autonomous turn started"); } @@ -2349,7 +2344,6 @@ class ClaudeAgentSession implements AgentSession { } this.notifySubscribers({ type: "turn_completed", provider: "claude" }); this.autonomousTurn = null; - this.lastContextWindowUsedTokens = undefined; this.syncTurnState("autonomous turn completed"); } @@ -2920,8 +2914,14 @@ class ClaudeAgentSession implements AgentSession { usage.contextWindowMaxTokens = contextWindowMaxTokens; } if (typeof this.lastContextWindowUsedTokens === "number") { + // task_progress.total_tokens is the accurate context window fill level. + // Prefer it over result.usage which contains accumulated session totals. usage.contextWindowUsedTokens = this.lastContextWindowUsedTokens; } else if (message.usage) { + // Fallback: derive from result.usage when no task_progress has been + // received yet. These values are accumulated across all API calls, but + // for the first turn they equal the per-call values so the estimate is + // reasonable. Once a task_progress arrives it takes over permanently. const usageWithCacheCreation = message.usage as typeof message.usage & { cache_creation_input_tokens?: number; }; From 0defbc1dc3921497912bd8532c6195992a6b3e47 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 7 Apr 2026 18:57:03 +0700 Subject: [PATCH 07/18] Normalize Codex paseo_voice.speak MCP calls and wrap spoken input Broaden the Codex speak tool name matcher to recognize paseo_voice.speak MCP calls alongside the existing paseo.speak convention. Wrap voice transcription input in tags so agents can distinguish spoken from typed messages. --- .../providers/codex/tool-call-mapper.test.ts | 20 +++++++++++++++++++ .../agent/providers/codex/tool-call-mapper.ts | 3 ++- packages/server/src/server/session.ts | 16 +++++++++++---- packages/server/src/server/voice-config.ts | 4 ++++ 4 files changed, 38 insertions(+), 5 deletions(-) diff --git a/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts index 65c4144d0..065934f9b 100644 --- a/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts +++ b/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts @@ -539,6 +539,26 @@ describe("codex tool-call mapper", () => { }); }); + it("normalizes codex paseo_voice.speak mcp calls and extracts spoken text", () => { + const item = mapCodexToolCallFromThreadItem({ + type: "mcpToolCall", + id: "codex-speak-thread-2", + status: "completed", + server: "paseo_voice", + tool: "speak", + arguments: { text: "Voice response from Codex via paseo_voice." }, + result: { ok: true }, + }); + + expect(item).toBeTruthy(); + expect(item?.name).toBe("speak"); + expect(item?.detail).toEqual({ + type: "unknown", + input: "Voice response from Codex via paseo_voice.", + output: null, + }); + }); + it("normalizes codex paseo speak rollout names and extracts spoken text", () => { const item = expectMapped( mapCodexRolloutToolCall({ diff --git a/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts b/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts index a5fd872f2..8b0657c05 100644 --- a/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts +++ b/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import type { ToolCallTimelineItem } from "../../agent-sdk-types.js"; import { extractCodexShellOutput, truncateDiffText } from "../tool-call-mapper-utils.js"; import { deriveCodexToolDetail, normalizeCodexFilePath } from "./tool-call-detail-parser.js"; +import { isSpeakToolName } from "../../tool-name-normalization.js"; type CodexMapperOptions = { cwd?: string | null }; @@ -67,7 +68,7 @@ const CodexEditToolNameSchema = z.union([ z.literal("apply_diff"), ]); const CodexSearchToolNameSchema = z.union([z.literal("search"), z.literal("web_search")]); -const CodexSpeakToolNameSchema = z.literal("paseo.speak"); +const CodexSpeakToolNameSchema = z.string().min(1).refine((name) => isSpeakToolName(name.trim())); const CodexToolKindSchema = z.enum([ "shell", diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 813cf0e4d..b60151273 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -133,6 +133,7 @@ import { buildVoiceAgentMcpServerConfig, buildVoiceModeSystemPrompt, stripVoiceModeSystemPrompt, + wrapSpokenInput, } from "./voice-config.js"; import { isVoicePermissionAllowed } from "./voice-permission-policy.js"; import { @@ -2834,6 +2835,7 @@ export class Session { messageId?: string, images?: Array<{ data: string; mimeType: string }>, runOptions?: AgentRunOptions, + options?: { spokenInput?: boolean }, ): Promise { this.sessionLogger.info( { agentId, textPreview: text.substring(0, 50), imageCount: images?.length ?? 0 }, @@ -2861,7 +2863,8 @@ export class Session { ); } - const prompt = this.buildAgentPrompt(text, images); + const promptText = options?.spokenInput ? wrapSpokenInput(text) : text; + const prompt = this.buildAgentPrompt(promptText, images); this.startAgentStream(agentId, prompt, runOptions); } @@ -7019,9 +7022,14 @@ export class Session { return; } - // Route voice utterances through the same send path as regular text input: - // interrupt-if-running, record message, then start a new stream. - await this.handleSendAgentMessage(agentId, result.text); + await this.handleSendAgentMessage( + agentId, + result.text, + undefined, + undefined, + undefined, + { spokenInput: true }, + ); await this.flushPendingAudioSegments("transcription complete"); } diff --git a/packages/server/src/server/voice-config.ts b/packages/server/src/server/voice-config.ts index 482e8197f..77b3a698a 100644 --- a/packages/server/src/server/voice-config.ts +++ b/packages/server/src/server/voice-config.ts @@ -53,6 +53,10 @@ export function buildVoiceModeSystemPrompt(existing: string | undefined, enabled .join("\n\n"); } +export function wrapSpokenInput(text: string): string { + return `\n${text}\n`; +} + export function buildVoiceAgentMcpServerConfig(params: { command: string; baseArgs: string[]; From 1279f1d556ff03b9704a6440270f58f54280d9fc Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 7 Apr 2026 20:00:46 +0700 Subject: [PATCH 08/18] Fix split diff horizontal scroll and pin line number gutters Split diff now renders two independent scrollable columns (left/right) instead of one scroll wrapping both sides. Line number gutters are pinned outside the scroll area in both unified and split views. --- packages/app/src/components/git-diff-pane.tsx | 351 +++++++++++++----- 1 file changed, 253 insertions(+), 98 deletions(-) diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index e790af7f2..ea9e86320 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -56,7 +56,7 @@ import { import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; import { Fonts } from "@/constants/theme"; import { shouldAnchorHeaderBeforeCollapse } from "@/utils/git-diff-scroll"; -import { buildSplitDiffRows, type SplitDiffDisplayLine } from "@/utils/diff-layout"; +import { buildSplitDiffRows, type SplitDiffDisplayLine, type SplitDiffRow } from "@/utils/diff-layout"; import { DropdownMenu, DropdownMenuContent, @@ -138,6 +138,96 @@ interface DiffFileSectionProps { testID?: string; } +function lineTypeBackground(type: DiffLine["type"] | undefined | null) { + if (!type) return styles.emptySplitCell; + if (type === "add") return styles.addLineContainer; + if (type === "remove") return styles.removeLineContainer; + if (type === "header") return styles.headerLineContainer; + return styles.contextLineContainer; +} + +function DiffGutterCell({ + lineNumber, + type, + gutterWidth, +}: { + lineNumber: number | null; + type: DiffLine["type"] | undefined | null; + gutterWidth: number; +}) { + return ( + + + {lineNumber != null ? String(lineNumber) : ""} + + + ); +} + +function DiffTextLine({ + line, + wrapLines, +}: { + line: DiffLine; + wrapLines: boolean; +}) { + return ( + + {line.tokens && line.type !== "header" ? ( + + ) : ( + + {line.content || " "} + + )} + + ); +} + +function SplitTextLine({ + line, + wrapLines, +}: { + line: SplitDiffDisplayLine | null; + wrapLines: boolean; +}) { + return ( + + {line?.tokens ? ( + + ) : ( + + {line?.content ?? ""} + + )} + + ); +} + function DiffLineView({ line, lineNumber, @@ -153,10 +243,7 @@ function DiffLineView({ @@ -190,26 +277,20 @@ function DiffLineView({ ); } -function SplitDiffCell({ +function SplitDiffLine({ line, gutterWidth, wrapLines, - showDivider = false, }: { line: SplitDiffDisplayLine | null; gutterWidth: number; wrapLines: boolean; - showDivider?: boolean; }) { return ( @@ -229,7 +310,7 @@ function SplitDiffCell({ + + {rows.map((row, i) => { + if (row.kind === "header") { + return ( + + {row.content} + + ); + } + return ( + + ); + })} + + + ); + } + + return ( + + + {rows.map((row, i) => { + if (row.kind === "header") { + return ; + } + const line = side === "left" ? row.left : row.right; + return ; + })} + + + 0 && { minWidth: scrollWidth }]}> + {rows.map((row, i) => { + if (row.kind === "header") { + return ( + + {row.content} + + ); + } + return ; + })} + + + + ); +} + const DiffFileHeader = memo(function DiffFileHeader({ file, isExpanded, @@ -368,85 +524,70 @@ function DiffFileBody({ } const gutterWidth = lineNumberGutterWidth(maxLineNo); - const linesContent = - layout === "split" - ? buildSplitDiffRows(file).map((row, rowIndex) => { - if (row.kind === "header") { - return ( - - {row.content} - - ); - } - - return ( - - - - - ); - }) - : file.hunks.map((hunk, hunkIndex) => { - let oldLineNo = hunk.oldStart; - let newLineNo = hunk.newStart; - return hunk.lines.map((line, lineIndex) => { - let lineNumber: number | null = null; - if (line.type === "remove") { - lineNumber = oldLineNo; - oldLineNo++; - } else if (line.type === "add") { - lineNumber = newLineNo; - newLineNo++; - } else if (line.type === "context") { - lineNumber = newLineNo; - oldLineNo++; - newLineNo++; - } - return ( - - ); - }); - }); - - const availableWidth = bodyWidth > 0 ? bodyWidth : scrollViewWidth; - const contentContainer = ( - 0 && - (layout === "split" - ? { width: availableWidth, minWidth: availableWidth, maxWidth: availableWidth } - : { minWidth: availableWidth }), - ]} - > - {linesContent} - - ); - - if (wrapLines) { - return {contentContainer}; + if (layout === "split") { + const rows = buildSplitDiffRows(file); + return ( + + + + + ); } + const computedLines: { line: DiffLine; lineNumber: number | null; key: string }[] = []; + for (const [hunkIndex, hunk] of file.hunks.entries()) { + let oldLineNo = hunk.oldStart; + let newLineNo = hunk.newStart; + for (const [lineIndex, line] of hunk.lines.entries()) { + let lineNumber: number | null = null; + if (line.type === "remove") { + lineNumber = oldLineNo; + oldLineNo++; + } else if (line.type === "add") { + lineNumber = newLineNo; + newLineNo++; + } else if (line.type === "context") { + lineNumber = newLineNo; + oldLineNo++; + newLineNo++; + } + computedLines.push({ line, lineNumber, key: `${hunkIndex}-${lineIndex}` }); + } + } + + if (wrapLines) { + return ( + + + {computedLines.map(({ line, lineNumber, key }) => ( + + ))} + + + ); + } + + const availableWidth = bodyWidth > 0 ? bodyWidth : scrollViewWidth; return ( - - {contentContainer} - + + + {computedLines.map(({ line, lineNumber, key }) => ( + + ))} + + + 0 && { minWidth: availableWidth }]}> + {computedLines.map(({ line, key }) => ( + + ))} + + + ); })()} @@ -1612,21 +1753,35 @@ const styles = StyleSheet.create((theme) => ({ borderTopColor: theme.colors.border, backgroundColor: theme.colors.surface1, }, + diffContentRow: { + flexDirection: "row", + alignItems: "stretch", + }, diffContentInner: { flexDirection: "column", }, linesContainer: { backgroundColor: theme.colors.surface1, }, - splitLinesContainer: { + gutterColumn: { backgroundColor: theme.colors.surface1, - minWidth: 760, + }, + gutterCell: { + borderRightWidth: theme.borderWidth[1], + borderRightColor: theme.colors.border, + justifyContent: "flex-start", + }, + textLineContainer: { + flexDirection: "row", + alignItems: "stretch", + paddingLeft: theme.spacing[2], }, splitRow: { flexDirection: "row", alignItems: "stretch", - width: "100%", - minWidth: "100%", + }, + splitColumnScroll: { + flex: 1, }, splitHeaderRow: { backgroundColor: theme.colors.surface2, @@ -1635,11 +1790,11 @@ const styles = StyleSheet.create((theme) => ({ splitCell: { flex: 1, flexBasis: 0, - minWidth: 0, + backgroundColor: theme.colors.surface2, + }, + splitCellRow: { flexDirection: "row", alignItems: "stretch", - overflow: "hidden", - backgroundColor: theme.colors.surface2, }, emptySplitCell: { backgroundColor: theme.colors.surfaceDiffEmpty, From 8088c39fd69243e9ca115018696cdd818b54080f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 7 Apr 2026 20:02:48 +0700 Subject: [PATCH 09/18] Fix OpenCode usage regression and update workspace tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-add usage stamping on turn_completed events for OpenCode agents — the extractAndResetUsage callsite was removed during context window meter work, causing token counts and cost to be lost after each turn. Update workspace tests to account for the new async reconciliation that fires after workspace update fanout. --- .../src/server/agent/providers/opencode-agent.ts | 11 +++++++++++ .../src/server/session.workspace-git-watch.test.ts | 6 ++++-- packages/server/src/server/session.workspaces.test.ts | 10 ++++++++-- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 7965c96f1..6f004d11f 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -2041,6 +2041,17 @@ class OpenCodeAgentSession implements AgentSession { if (translatedEvent.type === "permission_requested") { this.pendingPermissions.set(translatedEvent.request.id, translatedEvent.request); } + if (translatedEvent.type === "turn_completed") { + if (hasNormalizedOpenCodeUsage(this.accumulatedUsage)) { + translatedEvent.usage = this.accumulatedUsage; + } + const contextWindowMaxTokens = + this.resolveSelectedModelContextWindowMaxTokens(); + this.accumulatedUsage = + contextWindowMaxTokens !== undefined + ? { contextWindowMaxTokens } + : {}; + } } return translated; diff --git a/packages/server/src/server/session.workspace-git-watch.test.ts b/packages/server/src/server/session.workspace-git-watch.test.ts index 38c3a0290..db704bcd0 100644 --- a/packages/server/src/server/session.workspace-git-watch.test.ts +++ b/packages/server/src/server/session.workspace-git-watch.test.ts @@ -255,7 +255,8 @@ describe("workspace git watch targets", () => { diffStat: { additions: 1, deletions: 0 }, }; - sessionAny.listWorkspaceDescriptorsSnapshot = async () => [descriptor]; + sessionAny.buildWorkspaceDescriptorMap = async () => + new Map([[descriptor.id, descriptor]]); await sessionAny.ensureWorkspaceRegistered("/tmp/repo"); sessionAny.primeWorkspaceGitWatchFingerprints([descriptor]); @@ -477,7 +478,8 @@ describe("workspace git watch targets", () => { diffStat: { additions: 1, deletions: 0 }, }; - sessionAny.listWorkspaceDescriptorsSnapshot = async () => [descriptor]; + sessionAny.buildWorkspaceDescriptorMap = async () => + new Map([[descriptor.id, descriptor]]); await sessionAny.ensureWorkspaceRegistered("/tmp/repo"); sessionAny.primeWorkspaceGitWatchFingerprints([descriptor]); diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 879ed0370..c054ca006 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -1127,15 +1127,21 @@ describe("workspace aggregation", () => { }; await session.emitWorkspaceUpdateForCwd("/tmp/repo/worktree"); + await new Promise((resolve) => setTimeout(resolve, 0)); const workspaceUpdates = emitted.filter( (message) => message.type === "workspace_update", ) as any[]; - expect(workspaceUpdates).toHaveLength(2); - expect(workspaceUpdates.map((entry) => entry.payload.kind)).toEqual(["upsert", "upsert"]); + expect(workspaceUpdates).toHaveLength(3); + expect(workspaceUpdates.map((entry) => entry.payload.kind)).toEqual([ + "upsert", + "upsert", + "upsert", + ]); expect(workspaceUpdates.map((entry) => entry.payload.workspace.id).sort()).toEqual([ "/tmp/repo", "/tmp/repo/worktree", + "/tmp/repo/worktree", ]); }); From 9c8b0c3acadc3a31a1f08433cb7ddea0ca1620f8 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 7 Apr 2026 20:03:22 +0700 Subject: [PATCH 10/18] docs(changelog): add 0.1.50 release notes --- CHANGELOG.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22241d6dd..167692ce9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ # Changelog +## 0.1.50 - 2026-04-07 + +### Added +- Context window meter — live token usage indicator for Claude Code, Codex, and OpenCode shows how much of the context window has been consumed, with color thresholds at 70% and 90%. +- Open in editor — open the current workspace directory in Cursor, VS Code, Zed, or the system file manager directly from the toolbar. Remembers your preferred editor. +- Side-by-side diff layout — toggle between unified and split-column diff views in the Changes pane, with a whitespace visibility toggle. +- Spoken messages — voice-mode speak tool calls now render inline in the conversation as labeled spoken messages instead of raw tool call blocks. +- Plan approval actions — plan permission cards now show provider-defined action buttons (e.g. "Implement", "Deny") instead of hardcoded accept/reject. +- Background git fetch — the daemon periodically fetches from origin so the Changes pane shows accurate ahead/behind counts without manual refreshes. + +### Improved +- File explorer and diff pane expanded/collapsed state persists across tab switches and rehydration. +- Workspace list and updates are served instantly on connect; reconciliation happens in the background, eliminating the initial loading delay. +- Provider list in Settings now includes a Refresh button and shows inline error details. +- Workspace tabs close optimistically — the tab disappears immediately while the daemon archives the agent in the background. +- Reload agent action moved away from the close button to prevent accidental taps. + +### Fixed +- WorkingIndicator no longer remounts on every stream update on native. +- Silero VAD state is now reset between voice turns, preventing LSTM drift that could cause false speech detections in long sessions. +- OpenCode context window meter updates correctly after the first turn. +- Garbled overlapping text in plan card markdown. +- Worktree branch tracking now prefers `origin/{branch}` over the local branch ref, fixing stale diff baselines. +- Session ID reset on query restart prevents an overwrite crash when restarting an agent quickly. +- Copilot ACP permission prompts are now bypassed in autopilot mode. +- Direct connection and pairing modal content now displays correctly on tablets. +- `wait_for_finish` errors from agents are now surfaced to the caller instead of silently swallowed. +- Workspace diff stats preserved across rehydration instead of resetting to zero. +- Diff toolbar toggle buttons polished for consistent sizing and alignment. + ## 0.1.49 - 2026-04-07 ### Fixed From e7cf9ee69da4698495a3bcf69156c065dc40d85a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 7 Apr 2026 20:04:13 +0700 Subject: [PATCH 11/18] chore(release): cut 0.1.50 --- 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 9767a1a42..8e69550f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.1.49", + "version": "0.1.50", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.1.49", + "version": "0.1.50", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "workspaces": [ @@ -34906,16 +34906,16 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.1.49", + "version": "0.1.50", "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.49", - "@getpaseo/highlight": "0.1.49", - "@getpaseo/server": "0.1.49", + "@getpaseo/expo-two-way-audio": "0.1.50", + "@getpaseo/highlight": "0.1.50", + "@getpaseo/server": "0.1.50", "@gorhom/bottom-sheet": "^5.2.6", "@gorhom/portal": "^1.0.14", "@react-native-async-storage/async-storage": "2.2.0", @@ -35032,11 +35032,11 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.1.49", + "version": "0.1.50", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/relay": "0.1.49", - "@getpaseo/server": "0.1.49", + "@getpaseo/relay": "0.1.50", + "@getpaseo/server": "0.1.50", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", @@ -35077,11 +35077,11 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.1.49", + "version": "0.1.50", "license": "AGPL-3.0-or-later", "dependencies": { - "@getpaseo/cli": "0.1.49", - "@getpaseo/server": "0.1.49", + "@getpaseo/cli": "0.1.50", + "@getpaseo/server": "0.1.50", "electron-log": "^5.4.3", "electron-updater": "^6.6.2", "ws": "^8.14.2" @@ -35115,7 +35115,7 @@ }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", - "version": "0.1.49", + "version": "0.1.50", "license": "MIT", "devDependencies": { "@biomejs/biome": "1.9.4", @@ -35316,7 +35316,7 @@ }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.1.49", + "version": "0.1.50", "dependencies": { "@lezer/common": "^1.5.0", "@lezer/cpp": "^1.1.5", @@ -35342,7 +35342,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.1.49", + "version": "0.1.50", "dependencies": { "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", @@ -35358,14 +35358,14 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.1.49", + "version": "0.1.50", "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.49", - "@getpaseo/relay": "0.1.49", + "@getpaseo/highlight": "0.1.50", + "@getpaseo/relay": "0.1.50", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.2.6", @@ -35764,7 +35764,7 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.1.49", + "version": "0.1.50", "dependencies": { "@cloudflare/vite-plugin": "^1.20.3", "@cloudflare/workers-types": "^4.20260114.0", diff --git a/package.json b/package.json index bc33dd151..26f091731 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.1.49", + "version": "0.1.50", "private": true, "workspaces": [ "packages/expo-two-way-audio", diff --git a/packages/app/package.json b/packages/app/package.json index 3f4c48f1d..8689b9f57 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,7 +1,7 @@ { "name": "@getpaseo/app", "main": "index.ts", - "version": "0.1.49", + "version": "0.1.50", "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.49", - "@getpaseo/highlight": "0.1.49", - "@getpaseo/server": "0.1.49", + "@getpaseo/expo-two-way-audio": "0.1.50", + "@getpaseo/highlight": "0.1.50", + "@getpaseo/server": "0.1.50", "@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 3a39a8dc1..32deaaff8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.1.49", + "version": "0.1.50", "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.49", - "@getpaseo/server": "0.1.49", + "@getpaseo/relay": "0.1.50", + "@getpaseo/server": "0.1.50", "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 60c61e130..75319fd8f 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.1.49", + "version": "0.1.50", "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.49", - "@getpaseo/server": "0.1.49", + "@getpaseo/cli": "0.1.50", + "@getpaseo/server": "0.1.50", "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 e7e396595..cd80f4c60 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.49", + "version": "0.1.50", "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 34613ccbb..9b2dc37d7 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.1.49", + "version": "0.1.50", "type": "module", "publishConfig": { "access": "public" diff --git a/packages/relay/package.json b/packages/relay/package.json index b5f035fd4..71afa8dbc 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.1.49", + "version": "0.1.50", "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 10e5088d0..4a40a95f5 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.1.49", + "version": "0.1.50", "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.49", - "@getpaseo/relay": "0.1.49", + "@getpaseo/highlight": "0.1.50", + "@getpaseo/relay": "0.1.50", "@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 72a3e2419..e0deb6766 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.1.49", + "version": "0.1.50", "private": true, "type": "module", "scripts": { From 43f01600ce30c892f463c86456630ac75d907511 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 7 Apr 2026 13:05:48 +0000 Subject: [PATCH 12/18] 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 b1c364dc7..f931cb1cf 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-TEsFzJRgVubRnjAy7OO6Xkn6HY7CRO3LVxsVdbw3IH4="; + npmDepsHash = "sha256-QFe3aBiYlt6rhYmMmBzwjph/gDHe90kZYrlin3qIpRo="; # 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 b4d6a5d6b81b134c12e66e3c6623e1444baf787a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 7 Apr 2026 20:21:53 +0700 Subject: [PATCH 13/18] docs(changelog): rewrite release notes for end users --- CHANGELOG.md | 50 ++++++++++++++++++++++++-------------------------- 1 file changed, 24 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 167692ce9..ee328b620 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,40 +3,38 @@ ## 0.1.50 - 2026-04-07 ### Added -- Context window meter — live token usage indicator for Claude Code, Codex, and OpenCode shows how much of the context window has been consumed, with color thresholds at 70% and 90%. -- Open in editor — open the current workspace directory in Cursor, VS Code, Zed, or the system file manager directly from the toolbar. Remembers your preferred editor. -- Side-by-side diff layout — toggle between unified and split-column diff views in the Changes pane, with a whitespace visibility toggle. -- Spoken messages — voice-mode speak tool calls now render inline in the conversation as labeled spoken messages instead of raw tool call blocks. -- Plan approval actions — plan permission cards now show provider-defined action buttons (e.g. "Implement", "Deny") instead of hardcoded accept/reject. -- Background git fetch — the daemon periodically fetches from origin so the Changes pane shows accurate ahead/behind counts without manual refreshes. +- Context window meter — see how much of the context window your agent has used, with color thresholds at 70% and 90%. Works with Claude Code, Codex, and OpenCode. +- Open in editor — jump from any workspace straight into Cursor, VS Code, Zed, or your file manager. Paseo remembers your choice. +- Side-by-side diffs — toggle between unified and split-column diff views, with a whitespace visibility option. +- Spoken messages — when using voice mode, agent speech now appears as regular messages in the conversation instead of raw tool output. +- Plan actions — plan cards now show the actions your agent supports (e.g. "Implement", "Deny") instead of generic accept/reject buttons. +- Background git fetch — ahead/behind counts in the Changes pane stay up to date automatically. ### Improved -- File explorer and diff pane expanded/collapsed state persists across tab switches and rehydration. -- Workspace list and updates are served instantly on connect; reconciliation happens in the background, eliminating the initial loading delay. -- Provider list in Settings now includes a Refresh button and shows inline error details. -- Workspace tabs close optimistically — the tab disappears immediately while the daemon archives the agent in the background. -- Reload agent action moved away from the close button to prevent accidental taps. +- Workspaces load instantly on connect instead of waiting for a full sync. +- File explorer and diff pane remember which folders are expanded when you switch tabs. +- Closing a workspace tab is now instant. +- Settings shows a Refresh button for providers and displays error details inline. +- Reload agent moved away from the close button to prevent accidental taps. ### Fixed -- WorkingIndicator no longer remounts on every stream update on native. -- Silero VAD state is now reset between voice turns, preventing LSTM drift that could cause false speech detections in long sessions. -- OpenCode context window meter updates correctly after the first turn. -- Garbled overlapping text in plan card markdown. -- Worktree branch tracking now prefers `origin/{branch}` over the local branch ref, fixing stale diff baselines. -- Session ID reset on query restart prevents an overwrite crash when restarting an agent quickly. -- Copilot ACP permission prompts are now bypassed in autopilot mode. -- Direct connection and pairing modal content now displays correctly on tablets. -- `wait_for_finish` errors from agents are now surfaced to the caller instead of silently swallowed. -- Workspace diff stats preserved across rehydration instead of resetting to zero. -- Diff toolbar toggle buttons polished for consistent sizing and alignment. +- Voice mode no longer drifts into false speech detection during long sessions. +- OpenCode context window meter now updates after the first turn. +- Garbled overlapping text on plan cards. +- Changes pane could show stale diffs when working with git worktrees. +- Restarting an agent quickly could crash the session. +- Copilot no longer pauses for permission prompts in autopilot mode. +- Connection and pairing dialogs now display correctly on tablets. +- Orchestration errors from agents are now surfaced instead of silently lost. +- Diff stats no longer reset to zero when reconnecting. +- Diff toolbar buttons have consistent sizing. ## 0.1.49 - 2026-04-07 ### Fixed -- Models and providers now load reliably on first app connect instead of requiring a second status refresh. -- Model picker on running agents now only shows models from the agent's own provider, not every provider on the server. -- Model data is now prefetched consistently regardless of which screen you open first. -- Draft and running-agent flows now share the same provider data path, eliminating stale model lists from legacy fallbacks. +- Models and providers now load reliably on first connect instead of requiring a manual refresh. +- Model picker only shows models from the agent's own provider, not every provider on the server. +- Model lists stay consistent regardless of which screen you open first. ## 0.1.48 - 2026-04-05 From c742f1708007e5077e03887b33bda5379fb5e2f1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 7 Apr 2026 20:24:02 +0700 Subject: [PATCH 14/18] docs(changelog): remove internal-only fixes from 0.1.50 notes --- CHANGELOG.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee328b620..06a4959f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,6 @@ ### Fixed - Voice mode no longer drifts into false speech detection during long sessions. -- OpenCode context window meter now updates after the first turn. - Garbled overlapping text on plan cards. - Changes pane could show stale diffs when working with git worktrees. - Restarting an agent quickly could crash the session. @@ -27,7 +26,6 @@ - Connection and pairing dialogs now display correctly on tablets. - Orchestration errors from agents are now surfaced instead of silently lost. - Diff stats no longer reset to zero when reconnecting. -- Diff toolbar buttons have consistent sizing. ## 0.1.49 - 2026-04-07 From 5ac7b3f7c7a9b3be0200adaafac1200cb92c8bce Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 7 Apr 2026 20:26:01 +0700 Subject: [PATCH 15/18] docs(release): add changelog voice guidelines --- docs/RELEASE.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/RELEASE.md b/docs/RELEASE.md index e8a46db4b..b20aaec68 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -129,6 +129,16 @@ 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. +## Changelog voice + +The changelog is shown on the Paseo homepage. Write it for **end users**, not developers. + +- **Frame everything from the user's perspective.** Describe what changed in the app, not what changed in the code. Users care that "workspaces load instantly" — not that a component no longer remounts. +- **Never mention component names, internal modules, or implementation details.** No `WorkingIndicator`, no `accumulatedUsage`, no `reconcileAndEmitWorkspaceUpdates`. +- **Collapse internal iterations.** If a feature was added and then fixed within the same release, just list the feature as working. Users never saw the broken version. +- **Only list changes relative to the previous stable release.** The diff is `v(previous)..HEAD`. If something was introduced and fixed between those two tags, it never shipped — don't mention the fix. +- **Cut low-signal entries.** "Toolbar buttons have consistent sizing" is too granular. Combine small polish items or drop them. + ## 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. From 5c93fbc9557622b92de22be17038d11a1a5d11e6 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 8 Apr 2026 08:59:02 +0700 Subject: [PATCH 16/18] feat: emit usage_updated events for real-time token usage updates --- .../server/src/server/agent/agent-manager.ts | 4 + .../src/server/agent/agent-sdk-types.ts | 1 + .../agent/providers/claude-agent.test.ts | 248 ++++++++++++++++++ .../server/agent/providers/claude-agent.ts | 129 ++++++++- .../providers/codex-app-server-agent.test.ts | 46 ++++ .../agent/providers/codex-app-server-agent.ts | 7 + .../server/agent/providers/opencode-agent.ts | 15 +- .../opencode/event-translator.test.ts | 55 ++++ 8 files changed, 498 insertions(+), 7 deletions(-) diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index f95f29205..cd84027ec 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -2124,6 +2124,10 @@ export class AgentManager { void this.refreshRuntimeInfo(agent); } break; + case "usage_updated": + agent.lastUsage = event.usage; + this.emitState(agent); + break; case "timeline": // Skip provider-replayed user_message items during history hydration. if (options?.fromHistory && event.item.type === "user_message") { diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 795e375a0..cc0850cc1 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -301,6 +301,7 @@ export type AgentStreamEvent = | { type: "thread_started"; sessionId: string; provider: AgentProvider } | { type: "turn_started"; provider: AgentProvider; turnId?: string } | { type: "turn_completed"; provider: AgentProvider; usage?: AgentUsage; turnId?: string } + | { type: "usage_updated"; provider: AgentProvider; usage: AgentUsage; turnId?: string } | { type: "turn_failed"; provider: AgentProvider; diff --git a/packages/server/src/server/agent/providers/claude-agent.test.ts b/packages/server/src/server/agent/providers/claude-agent.test.ts index b6863a3f7..8da63128c 100644 --- a/packages/server/src/server/agent/providers/claude-agent.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.test.ts @@ -468,6 +468,124 @@ describe("ClaudeAgentSession context window usage", () => { }); }); + test("task_progress emits a usage_updated event", async () => { + const session = await createSessionForTest(); + + const events = session.translateMessageToEvents({ + type: "system", + subtype: "task_progress", + task_id: "task-1", + description: "Processing", + usage: { + total_tokens: 999, + tool_uses: 1, + duration_ms: 50, + }, + uuid: "task-progress-1", + session_id: "session-1", + }); + + expect(events).toContainEqual({ + type: "usage_updated", + provider: "claude", + usage: { + contextWindowUsedTokens: 999, + }, + }); + }); + + test("task_notification emits a usage_updated event", async () => { + const session = await createSessionForTest(); + + const events = session.translateMessageToEvents({ + type: "system", + subtype: "task_notification", + uuid: "task-note-1", + task_id: "task-1", + status: "running", + summary: "Background task still running", + usage: { + total_tokens: 777, + tool_uses: 1, + duration_ms: 50, + }, + session_id: "session-1", + } as any); + + expect(events).toContainEqual({ + type: "usage_updated", + provider: "claude", + usage: { + contextWindowUsedTokens: 777, + }, + }); + }); + + test("message_start stream events emit usage_updated with per-request usage", async () => { + const session = await createSessionForTest(); + + const events = session.translateMessageToEvents({ + type: "stream_event", + event: { + type: "message_start", + message: { + usage: { + input_tokens: 100, + cache_creation_input_tokens: 20, + cache_read_input_tokens: 30, + }, + }, + }, + session_id: "session-1", + } as any); + + expect(events).toContainEqual({ + type: "usage_updated", + provider: "claude", + usage: { + contextWindowUsedTokens: 150, + }, + }); + }); + + test("message_delta stream events update per-request usage", async () => { + const session = await createSessionForTest(); + + session.translateMessageToEvents({ + type: "stream_event", + event: { + type: "message_start", + message: { + usage: { + input_tokens: 100, + cache_creation_input_tokens: 20, + cache_read_input_tokens: 30, + }, + }, + }, + session_id: "session-1", + } as any); + + const events = session.translateMessageToEvents({ + type: "stream_event", + event: { + type: "message_delta", + usage: { + output_tokens: 25, + }, + }, + session_id: "session-1", + } as any); + + expect(events).toContainEqual({ + type: "usage_updated", + provider: "claude", + usage: { + contextWindowUsedTokens: 175, + }, + }); + }); + test("task_progress usage takes priority over derived result usage", async () => { const session = await createSessionForTest(); @@ -638,4 +756,134 @@ describe("ClaudeAgentSession context window usage", () => { contextWindowUsedTokens: 22, }); }); + + test("convertUsage uses per-request stream usage when no task_progress is available", async () => { + const session = await createSessionForTest(); + + session.translateMessageToEvents({ + type: "stream_event", + event: { + type: "message_start", + message: { + usage: { + input_tokens: 100, + cache_creation_input_tokens: 20, + cache_read_input_tokens: 30, + }, + }, + }, + session_id: "session-1", + } as any); + session.translateMessageToEvents({ + type: "stream_event", + event: { + type: "message_delta", + usage: { + output_tokens: 25, + }, + }, + session_id: "session-1", + } as any); + + const usage = session.convertUsage({ + type: "result", + subtype: "success", + usage: { + input_tokens: 10, + cache_read_input_tokens: 5, + output_tokens: 7, + }, + total_cost_usd: 0.12, + }); + + expect(usage).toEqual({ + inputTokens: 10, + cachedInputTokens: 5, + outputTokens: 7, + totalCostUsd: 0.12, + contextWindowUsedTokens: 175, + }); + }); + + test("per-request stream usage is not cumulative across API calls in a turn", async () => { + const session = await createSessionForTest(); + + session.translateMessageToEvents({ + type: "stream_event", + event: { + type: "message_start", + message: { + usage: { + input_tokens: 100, + cache_creation_input_tokens: 20, + cache_read_input_tokens: 30, + }, + }, + }, + session_id: "session-1", + } as any); + session.translateMessageToEvents({ + type: "stream_event", + event: { + type: "message_delta", + usage: { + output_tokens: 25, + }, + }, + session_id: "session-1", + } as any); + + const secondStartEvents = session.translateMessageToEvents({ + type: "stream_event", + event: { + type: "message_start", + message: { + usage: { + input_tokens: 40, + cache_creation_input_tokens: 5, + cache_read_input_tokens: 10, + }, + }, + }, + session_id: "session-1", + } as any); + + expect(secondStartEvents).toContainEqual({ + type: "usage_updated", + provider: "claude", + usage: { + contextWindowUsedTokens: 55, + }, + }); + + session.translateMessageToEvents({ + type: "stream_event", + event: { + type: "message_delta", + usage: { + output_tokens: 7, + }, + }, + session_id: "session-1", + } as any); + + const usage = session.convertUsage({ + type: "result", + subtype: "success", + usage: { + input_tokens: 10, + cache_read_input_tokens: 5, + output_tokens: 7, + }, + total_cost_usd: 0.12, + }); + + expect(usage).toEqual({ + inputTokens: 10, + cachedInputTokens: 5, + outputTokens: 7, + totalCostUsd: 0.12, + contextWindowUsedTokens: 62, + }); + }); }); diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index e780586e2..f936e9124 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -1225,16 +1225,67 @@ function extractContextWindowSize(modelUsage: unknown): number | undefined { return maxContextWindow; } -function readContextWindowUsedTokensFromTaskProgress( - message: SDKTaskProgressMessage, +function readUsageTotalTokens( + usage: unknown, ): number | undefined { - const totalTokens = message.usage?.total_tokens; + if (!usage || typeof usage !== "object") { + return undefined; + } + const totalTokens = (usage as { total_tokens?: unknown }).total_tokens; if (typeof totalTokens !== "number" || !Number.isFinite(totalTokens) || totalTokens < 0) { return undefined; } return totalTokens; } +function readContextWindowUsedTokensFromTaskProgress( + message: SDKTaskProgressMessage, +): number | undefined { + return readUsageTotalTokens(message.usage); +} + +function readUsageFromTaskNotification(message: { usage?: unknown }): number | undefined { + return readUsageTotalTokens(message.usage); +} + +function readStreamRequestInputTokens(event: Record): number | undefined { + const messageUsage = (event.message as { usage?: unknown } | undefined)?.usage; + if (!messageUsage || typeof messageUsage !== "object") { + return undefined; + } + const usage = messageUsage as { + input_tokens?: unknown; + cache_creation_input_tokens?: unknown; + cache_read_input_tokens?: unknown; + }; + const inputTokens = + typeof usage.input_tokens === "number" && Number.isFinite(usage.input_tokens) + ? usage.input_tokens + : undefined; + const cacheCreationInputTokens = + typeof usage.cache_creation_input_tokens === "number" && + Number.isFinite(usage.cache_creation_input_tokens) + ? usage.cache_creation_input_tokens + : 0; + const cacheReadInputTokens = + typeof usage.cache_read_input_tokens === "number" && + Number.isFinite(usage.cache_read_input_tokens) + ? usage.cache_read_input_tokens + : 0; + if (typeof inputTokens !== "number" || inputTokens < 0) { + return undefined; + } + return inputTokens + cacheCreationInputTokens + cacheReadInputTokens; +} + +function readStreamRequestOutputTokens(event: Record): number | undefined { + const outputTokens = (event.usage as { output_tokens?: unknown } | undefined)?.output_tokens; + if (typeof outputTokens !== "number" || !Number.isFinite(outputTokens) || outputTokens < 0) { + return undefined; + } + return outputTokens; +} + class ClaudeAgentSession implements AgentSession { readonly provider: "claude" = "claude"; readonly capabilities = CLAUDE_CAPABILITIES; @@ -1278,6 +1329,9 @@ class ClaudeAgentSession implements AgentSession { private lastForegroundPromptText: string | null = null; private foregroundHasVisibleActivity = false; private lastContextWindowUsedTokens: number | undefined; + private lastContextWindowMaxTokens: number | undefined; + private lastStreamRequestInputTokens: number | undefined; + private lastStreamRequestOutputTokens: number | undefined; private userMessageIds: string[] = []; private recentStderr = ""; private closed = false; @@ -2672,10 +2726,18 @@ class ClaudeAgentSession implements AgentSession { provider: "claude", }); } + const usage = readUsageFromTaskNotification(message); + if (typeof usage === "number") { + this.lastContextWindowUsedTokens = usage; + events.push(this.createUsageUpdatedEvent(usage)); + } } else if (message.subtype === "task_progress") { this.lastContextWindowUsedTokens = readContextWindowUsedTokensFromTaskProgress(message) ?? this.lastContextWindowUsedTokens; + if (typeof this.lastContextWindowUsedTokens === "number") { + events.push(this.createUsageUpdatedEvent(this.lastContextWindowUsedTokens)); + } } break; case "user": { @@ -2743,6 +2805,10 @@ class ClaudeAgentSession implements AgentSession { break; } case "stream_event": { + const usageUpdatedEvent = this.trackStreamEventUsage(message.event); + if (usageUpdatedEvent) { + events.push(usageUpdatedEvent); + } const timelineItems = this.mapPartialEvent(message.event, { suppressAssistantText: options?.suppressAssistantText ?? false, suppressReasoning: options?.suppressReasoning ?? false, @@ -2911,12 +2977,21 @@ class ClaudeAgentSession implements AgentSession { modelUsage ?? message.modelUsage, ); if (contextWindowMaxTokens !== undefined) { + this.lastContextWindowMaxTokens = contextWindowMaxTokens; usage.contextWindowMaxTokens = contextWindowMaxTokens; + } else if (this.lastContextWindowMaxTokens !== undefined) { + usage.contextWindowMaxTokens = this.lastContextWindowMaxTokens; } if (typeof this.lastContextWindowUsedTokens === "number") { // task_progress.total_tokens is the accurate context window fill level. // Prefer it over result.usage which contains accumulated session totals. usage.contextWindowUsedTokens = this.lastContextWindowUsedTokens; + } else if ( + typeof this.lastStreamRequestInputTokens === "number" && + typeof this.lastStreamRequestOutputTokens === "number" + ) { + usage.contextWindowUsedTokens = + this.lastStreamRequestInputTokens + this.lastStreamRequestOutputTokens; } else if (message.usage) { // Fallback: derive from result.usage when no task_progress has been // received yet. These values are accumulated across all API calls, but @@ -2937,6 +3012,54 @@ class ClaudeAgentSession implements AgentSession { return usage; } + private createUsageUpdatedEvent(contextWindowUsedTokens: number): AgentStreamEvent { + const usage: AgentUsage = { + contextWindowUsedTokens, + }; + if (this.lastContextWindowMaxTokens !== undefined) { + usage.contextWindowMaxTokens = this.lastContextWindowMaxTokens; + } + return { + type: "usage_updated", + provider: "claude", + usage, + }; + } + + private trackStreamEventUsage(event: unknown): AgentStreamEvent | null { + if (!event || typeof event !== "object") { + return null; + } + const streamEvent = event as Record; + const eventType = readTrimmedString(streamEvent.type); + if (eventType === "message_start") { + const inputTokens = readStreamRequestInputTokens(streamEvent); + if (typeof inputTokens !== "number") { + return null; + } + this.lastStreamRequestInputTokens = inputTokens; + this.lastStreamRequestOutputTokens = 0; + } else if (eventType === "message_delta") { + const outputTokens = readStreamRequestOutputTokens(streamEvent); + if (typeof outputTokens !== "number") { + return null; + } + this.lastStreamRequestOutputTokens = outputTokens; + } else { + return null; + } + + if ( + typeof this.lastStreamRequestInputTokens !== "number" || + typeof this.lastStreamRequestOutputTokens !== "number" + ) { + return null; + } + return this.createUsageUpdatedEvent( + this.lastStreamRequestInputTokens + this.lastStreamRequestOutputTokens, + ); + } + private handlePermissionRequest: CanUseTool = async ( toolName, input, 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 813a201c1..0cd41a189 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 @@ -577,6 +577,52 @@ describe("Codex app-server provider", () => { }); }); + test("emits usage_updated on token usage updates and keeps usage on turn completion", () => { + const session = createSession(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + (session as any).handleNotification("thread/tokenUsage/updated", { + tokenUsage: { + model_context_window: 200000, + last: { + total_tokens: 50000, + inputTokens: 30000, + cachedInputTokens: 5000, + outputTokens: 15000, + }, + }, + }); + (session as any).handleNotification("turn/completed", { + turn: { status: "completed", error: null }, + }); + + expect(events).toContainEqual({ + type: "usage_updated", + provider: "codex", + turnId: "test-turn", + usage: { + inputTokens: 30000, + cachedInputTokens: 5000, + outputTokens: 15000, + contextWindowMaxTokens: 200000, + contextWindowUsedTokens: 50000, + }, + }); + expect(events.at(-1)).toEqual({ + type: "turn_completed", + provider: "codex", + turnId: "test-turn", + usage: { + inputTokens: 30000, + cachedInputTokens: 5000, + outputTokens: 15000, + contextWindowMaxTokens: 200000, + contextWindowUsedTokens: 50000, + }, + }); + }); + test("approving a synthetic Codex plan permission disables plan and fast mode and starts implementation", async () => { const session = createSession({ featureValues: { plan_mode: true, fast_mode: true }, 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 141a05d96..35baca8d2 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 @@ -3506,6 +3506,13 @@ class CodexAppServerAgentSession implements AgentSession { if (parsed.kind === "token_usage_updated") { this.latestUsage = toAgentUsage(parsed.tokenUsage); + if (this.latestUsage) { + this.notifySubscribers({ + type: "usage_updated", + provider: CODEX_PROVIDER, + usage: this.latestUsage, + }); + } return; } diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 6f004d11f..fcb93de7d 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -450,16 +450,16 @@ function mergeOpenCodeStepFinishUsage( const cost = readPositiveFiniteNumber(part.cost); if (inputTokens !== undefined) { - usage.inputTokens = (usage.inputTokens ?? 0) + inputTokens; + usage.inputTokens = inputTokens; } if (cacheReadTokens !== undefined) { - usage.cachedInputTokens = (usage.cachedInputTokens ?? 0) + cacheReadTokens; + usage.cachedInputTokens = cacheReadTokens; } if (outputTokens !== undefined) { - usage.outputTokens = (usage.outputTokens ?? 0) + outputTokens; + usage.outputTokens = outputTokens; } if (totalTokens > 0) { - usage.contextWindowUsedTokens = (usage.contextWindowUsedTokens ?? 0) + totalTokens; + usage.contextWindowUsedTokens = totalTokens; } if (cost !== undefined) { usage.totalCostUsd = (usage.totalCostUsd ?? 0) + cost; @@ -1144,6 +1144,13 @@ export function translateOpenCodeEvent( } } else if (partType === "step-finish") { mergeOpenCodeStepFinishUsage(state.accumulatedUsage, part); + if (hasNormalizedOpenCodeUsage(state.accumulatedUsage)) { + events.push({ + type: "usage_updated", + provider: "opencode", + usage: { ...state.accumulatedUsage }, + }); + } } break; } diff --git a/packages/server/src/server/agent/providers/opencode/event-translator.test.ts b/packages/server/src/server/agent/providers/opencode/event-translator.test.ts index b68a4f009..72986fe28 100644 --- a/packages/server/src/server/agent/providers/opencode/event-translator.test.ts +++ b/packages/server/src/server/agent/providers/opencode/event-translator.test.ts @@ -255,6 +255,61 @@ describe("translateOpenCodeEvent", () => { ]); }); + it("emits usage_updated after step-finish parts", () => { + const state = createState(); + state.accumulatedUsage.contextWindowMaxTokens = 400_000; + + const events = translateOpenCodeEvent( + { + type: "message.part.updated", + properties: { + part: { + id: "step-finish-1", + sessionID: "session-1", + messageID: "message-usage-1", + type: "step-finish", + reason: "stop", + cost: 0.25, + tokens: { + total: 999_999, + input: 30_000, + output: 12_000, + reasoning: 10_000, + cache: { + read: 2_000, + write: 1_000, + }, + }, + }, + }, + }, + state, + ); + + expect(events).toEqual([ + { + type: "usage_updated", + provider: "opencode", + usage: { + contextWindowMaxTokens: 400_000, + contextWindowUsedTokens: 55_000, + cachedInputTokens: 2_000, + inputTokens: 30_000, + outputTokens: 12_000, + totalCostUsd: 0.25, + }, + }, + ]); + expect(state.accumulatedUsage).toEqual({ + contextWindowMaxTokens: 400_000, + contextWindowUsedTokens: 55_000, + cachedInputTokens: 2_000, + inputTokens: 30_000, + outputTokens: 12_000, + totalCostUsd: 0.25, + }); + }); + it("emits reasoning from message.part.delta events", () => { const state = createState(); From bf7d3c27750b52a6342c4a22dab0100ae4dacf65 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 8 Apr 2026 11:05:42 +0700 Subject: [PATCH 17/18] copy: update hero headline to focus on orchestration value prop --- packages/website/src/routes/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/website/src/routes/index.tsx b/packages/website/src/routes/index.tsx index 73572272f..9d62a8b8c 100644 --- a/packages/website/src/routes/index.tsx +++ b/packages/website/src/routes/index.tsx @@ -15,7 +15,7 @@ export const Route = createFileRoute("/")({ function Home() { return ( The development environment
built for coding agents} + title={<>Orchestrate coding agents
from your desk and your phone} subtitle="Run any coding agent from your phone, desktop, or terminal. Self-hosted, multi-provider, open source." /> ); From ac5e6df6c938f1db1bf2be500592491ea147d9fd Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 8 Apr 2026 11:18:03 +0700 Subject: [PATCH 18/18] feat(website): add Plausible analytics to all pages --- packages/website/src/routes/__root.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/website/src/routes/__root.tsx b/packages/website/src/routes/__root.tsx index e887ac222..234ed8842 100644 --- a/packages/website/src/routes/__root.tsx +++ b/packages/website/src/routes/__root.tsx @@ -53,6 +53,12 @@ function RootDocument({ children }: Readonly<{ children: ReactNode }>) { +