From 06df029ce5237e6543ca237154f59607e03737b7 Mon Sep 17 00:00:00 2001 From: cafeai <116491182+cafeai@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:54:39 +0900 Subject: [PATCH 01/13] Fix live timeline scroll anchoring --- AGENTS.md | 1 + .../chat/MessagesTimeline.browser.tsx | 96 ++++++++++++++++++- .../src/components/chat/MessagesTimeline.tsx | 67 ++++++++++++- apps/web/src/rpc/wsTransport.test.ts | 25 +++-- 4 files changed, 170 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2702c49c..829698b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -381,6 +381,7 @@ If Claude behavior is unclear, check the official Claude Agent SDK docs, the ins - The renderer should display backend/provider truth and should not synthesize terminal, running, or active-turn state that can conflict with orchestration projections. - Scroll-follow behavior should be tolerant of small gaps from the bottom and must not jump to the top when steer messages, late messages, or terminal markers arrive. +- Timeline scrolling has two explicit modes. While following the tail, live row measurements must keep the final working/message row pinned even when a bounded tool/work-log preview rotates or changes height; coalesce any post-layout correction to one animation frame. Upward wheel/touch/keyboard/scrollbar review intent must synchronously cancel pending tail corrections. While detached for review, disable tail following and enable both data-change and size-change visible-content anchoring so provider updates do not move the text being read. Data-change anchoring must remain disabled in follow mode because it can fight submit-time bottom pinning. - Message timelines must handle late provider events after completion without duplicating terminal banners or losing streamed content. - Per-thread detail subscriptions must apply snapshots and events monotonically by orchestration sequence. Events at or below the detail snapshot sequence are stale for that subscription and must not regress focused-thread session or turn state. - Recoverable non-transport subscription failures must use capped exponential backoff instead of retrying a permanently invalid snapshot four times per second. Receiving an early snapshot chunk does not by itself prove recovery, because the same subscription may fail on a later chunk or event; reset the failure ramp only after a quiet failure window. Transport reconnect handling remains independent. diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.tsx index cabdd0a7..1b7779df 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.tsx @@ -47,6 +47,13 @@ vi.mock("@legendapp/list/react", async () => { onPointerCancel?: React.PointerEventHandler; onKeyDown?: React.KeyboardEventHandler; onScroll?: React.UIEventHandler; + onItemSizeChanged?: (info: { + size: number; + previous: number; + index: number; + itemKey: string; + itemData: { id: string }; + }) => void; maintainScrollAtEnd?: boolean; maintainScrollAtEndThreshold?: number; maintainVisibleContentPosition?: unknown; @@ -352,10 +359,11 @@ describe("MessagesTimeline", () => { } }); - it("does not let data-change anchoring fight submit-time bottom pinning", async () => { + it("uses data anchoring only while the user is reviewing older content", async () => { + const props = buildProps(); const screen = await render( , ); @@ -368,6 +376,90 @@ describe("MessagesTimeline", () => { data: false, size: true, }); + + await screen.rerender( + , + ); + + const reviewProps = legendListPropsSpy.mock.calls.at(-1)?.[0] as + | { maintainVisibleContentPosition?: unknown } + | undefined; + expect(reviewProps?.maintainVisibleContentPosition).toEqual({ + data: true, + size: true, + }); + } finally { + await screen.unmount(); + } + }); + + it("repins live row resizes at the tail and cancels that repin on review intent", async () => { + const frameCallbacks: FrameRequestCallback[] = []; + vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { + frameCallbacks.push(callback); + return frameCallbacks.length; + }); + vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => undefined); + + const props = buildProps(); + const entry = buildUserTimelineEntry("conversation tail before a live tool update"); + const screen = await render(); + + try { + frameCallbacks.length = 0; + scrollToEndSpy.mockClear(); + scrollToIndexSpy.mockClear(); + + const tailProps = legendListPropsSpy.mock.calls.at(-1)?.[0] as + | { + onItemSizeChanged?: (info: { + size: number; + previous: number; + index: number; + itemKey: string; + itemData: { id: string }; + }) => void; + } + | undefined; + tailProps?.onItemSizeChanged?.({ + size: 180, + previous: 90, + index: 0, + itemKey: entry.id, + itemData: entry, + }); + + expect(frameCallbacks).toHaveLength(1); + frameCallbacks.shift()?.(0); + expect(scrollToEndSpy).toHaveBeenCalledWith({ animated: false }); + expect(scrollToIndexSpy).toHaveBeenCalledWith({ + index: 0, + animated: false, + viewPosition: 1, + }); + + scrollToEndSpy.mockClear(); + scrollToIndexSpy.mockClear(); + tailProps?.onItemSizeChanged?.({ + size: 240, + previous: 180, + index: 0, + itemKey: entry.id, + itemData: entry, + }); + expect(frameCallbacks).toHaveLength(1); + + const list = document.querySelector("[data-testid='legend-list']"); + list?.dispatchEvent(new WheelEvent("wheel", { deltaY: -120, bubbles: true })); + frameCallbacks.shift()?.(0); + + expect(props.onUserScrollIntent).toHaveBeenCalled(); + expect(scrollToEndSpy).not.toHaveBeenCalled(); + expect(scrollToIndexSpy).not.toHaveBeenCalled(); } finally { await screen.unmount(); } diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index d64e158c..b4071500 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -142,10 +142,18 @@ const TIMELINE_SUBMIT_STICK_TO_END_SETTLE_TIMEOUTS_MS = [80, 180, 360, 720] as c const HISTORICAL_WORK_LOG_PREVIEW_LIMIT = 6; const HISTORICAL_WORK_LOG_PAGE_SIZE = 24; const HISTORICAL_WORK_LOG_SHOW_ALL_LIMIT = 1_000; -const TIMELINE_MAINTAIN_VISIBLE_CONTENT_POSITION = { +// Data-change anchoring can fight explicit tail following because a submitted +// message and its working row are data changes. Enable it only after the user +// has detached from the tail. Size anchoring remains active in both modes so +// rows above the viewport can settle without moving the visible conversation. +const TIMELINE_FOLLOW_VISIBLE_CONTENT_POSITION = { data: false, size: true, } as const; +const TIMELINE_REVIEW_VISIBLE_CONTENT_POSITION = { + data: true, + size: true, +} as const; // --------------------------------------------------------------------------- // Props (public API) @@ -254,6 +262,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const rows = useStableRows(rawRows); const stickToEndDeadlineMsRef = useRef(0); const submitStickScrollEventRepinFrameRef = useRef(null); + const tailFollowItemLayoutRepinFrameRef = useRef(null); + const autoFollowTailRef = useRef(autoFollowTail); + autoFollowTailRef.current = autoFollowTail; const forcedScrollGenerationRef = useRef(0); const touchStartYRef = useRef(null); const touchReviewIntentReportedRef = useRef(false); @@ -404,17 +415,51 @@ export const MessagesTimeline = memo(function MessagesTimeline({ }, [emitScrollDebugEvent, listRef, onDebugScrollEvent, rows.length], ); + const cancelTailFollowItemLayoutRepin = useCallback(() => { + if (tailFollowItemLayoutRepinFrameRef.current === null) { + return; + } + window.cancelAnimationFrame(tailFollowItemLayoutRepinFrameRef.current); + tailFollowItemLayoutRepinFrameRef.current = null; + }, []); + const scheduleTailFollowItemLayoutRepin = useCallback(() => { + if (!autoFollowTailRef.current || rows.length === 0) { + return; + } + if (tailFollowItemLayoutRepinFrameRef.current !== null) { + return; + } + + // LegendList checks its internal `isAtEnd` after recalculating item + // positions. A live tool/work-log row can grow enough during that same + // measurement to flip the flag before LegendList's own item-layout follow + // step runs. Reassert Cafe's explicit follow-mode decision after the + // measurement frame. Coalescing keeps this O(1) per paint even when many + // rows settle together after a large provider update. + tailFollowItemLayoutRepinFrameRef.current = window.requestAnimationFrame(() => { + tailFollowItemLayoutRepinFrameRef.current = null; + if (!autoFollowTailRef.current) { + return; + } + forceScrollToEnd(rows.length, "tail-follow-item-layout-repin"); + }); + }, [forceScrollToEnd, rows.length]); + const handleItemSizeChanged = useCallback(() => { + scheduleTailFollowItemLayoutRepin(); + }, [scheduleTailFollowItemLayoutRepin]); const cancelSubmitStickToEnd = useCallback(() => { // Invalidate every already-scheduled initial/submit hard-scroll callback. // Clearing only the deadline is insufficient because the animation-frame // loop historically did not consult it before issuing scrollToEnd. + autoFollowTailRef.current = false; + cancelTailFollowItemLayoutRepin(); forcedScrollGenerationRef.current += 1; stickToEndDeadlineMsRef.current = 0; if (submitStickScrollEventRepinFrameRef.current !== null) { window.cancelAnimationFrame(submitStickScrollEventRepinFrameRef.current); submitStickScrollEventRepinFrameRef.current = null; } - }, []); + }, [cancelTailFollowItemLayoutRepin]); const scheduleSubmitStickScrollEventRepin = useCallback(() => { if (submitStickScrollEventRepinFrameRef.current !== null) { return; @@ -436,10 +481,21 @@ export const MessagesTimeline = memo(function MessagesTimeline({ window.cancelAnimationFrame(scrollbarPointerReleaseFrameRef.current); scrollbarPointerReleaseFrameRef.current = null; } + if (tailFollowItemLayoutRepinFrameRef.current !== null) { + window.cancelAnimationFrame(tailFollowItemLayoutRepinFrameRef.current); + tailFollowItemLayoutRepinFrameRef.current = null; + } }, [], ); + useEffect(() => { + autoFollowTailRef.current = autoFollowTail; + if (!autoFollowTail) { + cancelTailFollowItemLayoutRepin(); + } + }, [autoFollowTail, cancelTailFollowItemLayoutRepin]); + const handleUserScrollIntent = useCallback( (event?: { readonly type?: string }) => { emitScrollDebugEvent("user-scroll-intent", { @@ -784,7 +840,12 @@ export const MessagesTimeline = memo(function MessagesTimeline({ initialScrollAtEnd maintainScrollAtEnd={autoFollowTail} maintainScrollAtEndThreshold={TIMELINE_MAINTAIN_SCROLL_AT_END_THRESHOLD} - maintainVisibleContentPosition={TIMELINE_MAINTAIN_VISIBLE_CONTENT_POSITION} + maintainVisibleContentPosition={ + autoFollowTail + ? TIMELINE_FOLLOW_VISIBLE_CONTENT_POSITION + : TIMELINE_REVIEW_VISIBLE_CONTENT_POSITION + } + onItemSizeChanged={handleItemSizeChanged} onScroll={handleScroll} onWheel={handleWheel} onTouchStart={handleTouchStart} diff --git a/apps/web/src/rpc/wsTransport.test.ts b/apps/web/src/rpc/wsTransport.test.ts index 559ab4ac..2c6594c0 100644 --- a/apps/web/src/rpc/wsTransport.test.ts +++ b/apps/web/src/rpc/wsTransport.test.ts @@ -104,20 +104,17 @@ async function waitFor(assertion: () => void): Promise { return; } catch (error) { lastError = error; - await new Promise((resolve) => { - const channel = new MessageChannel(); - channel.port1.addEventListener( - "message", - () => { - channel.port1.close(); - channel.port2.close(); - resolve(); - }, - { once: true }, - ); - channel.port1.start(); - channel.port2.postMessage(undefined); - }); + // Subscription retries intentionally pass through `setTimeout`, even + // when their configured delay is zero. Progress whichever timer system + // the current test uses so this poll observes retries consistently on + // every supported Node event-loop implementation. + if (vi.isFakeTimers()) { + await vi.advanceTimersByTimeAsync(0); + } else { + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + } } } throw lastError; From bf19b9c351b39ce1a1f5d5181b0c680e567cabdc Mon Sep 17 00:00:00 2001 From: cafeai <116491182+cafeai@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:54:11 +0900 Subject: [PATCH 02/13] Fix cross-platform CI runtime checks --- AGENTS.md | 2 + oxlint-plugin-cafecode/test/utils.ts | 12 +++- package.json | 2 +- scripts/build-desktop-artifact.ts | 2 +- scripts/ensure-desktop-runtime.test.ts | 63 +++++++++++++++++++ scripts/ensure-desktop-runtime.ts | 85 ++++++++++++++++++++++++++ scripts/ensure-electron.ts | 11 ---- scripts/release-smoke.ts | 17 +++--- 8 files changed, 171 insertions(+), 23 deletions(-) create mode 100644 scripts/ensure-desktop-runtime.test.ts create mode 100644 scripts/ensure-desktop-runtime.ts delete mode 100644 scripts/ensure-electron.ts diff --git a/AGENTS.md b/AGENTS.md index 829698b2..4c75fc62 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,7 @@ - Windows tests must not assume Developer Mode or administrator symlink privileges. If a test specifically validates symlink handling and Windows returns a symlink privilege `EPERM`, skip only that privilege-dependent assertion path while preserving the full symlink security test on macOS/Linux and on Windows machines where symlinks are available. - Windows file URL conversion must use `fileURLToPath` instead of `URL.pathname`, because raw pathname strings keep a leading slash before the drive letter. This is safe for macOS/Linux and prevents Windows-only false negatives when tests resolve repo-relative assets. - Windows test assertions that run through the host `Path.Path` service must build expected paths through that same path service instead of hard-coded POSIX separators. Keep macOS/Linux behavior unchanged by deriving expected strings from the platform path implementation already under test. +- Windows cannot directly spawn Yarn's `.cmd` command shims through the shell-free Effect child-process API. Custom Oxlint fixture tests must invoke `node_modules/oxlint/bin/oxlint` through `process.execPath`; keep `shell: false` so temporary fixture paths are passed as structured arguments, and preserve the same JavaScript entrypoint path on macOS/Linux. - Windows subprocess and heavy dynamic-import tests can need longer per-test timeouts under full parallel `turbo` load. Apply longer timeouts only under `process.platform === "win32"` and preserve existing macOS/Linux timeouts. - Windows process-table diagnostics must spawn `powershell.exe` directly with `shell: false`; do not route the CIM pipeline through `cmd.exe` or any shell wrapper because `cmd.exe` can misparse PowerShell pipeline syntax before PowerShell receives it. Keep macOS/Linux on the existing `ps -axo pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command=` path. Windows CPU/perf lookup should remain batched once per diagnostics read instead of querying performance counters once per process row. The longer diagnostics timeout exists for Windows CIM latency; if future work needs a tighter POSIX timeout, split the timeout by platform rather than regressing Windows reliability. - Windows `cafe-code killall` process discovery must also spawn `powershell.exe` directly with `shell: false` and consume `Get-CimInstance Win32_Process` JSON. Keep macOS/Linux on targeted `ps -axo pid=,ppid=,command=` discovery, and keep the command's current process plus ancestors protected so `cafe-code killall` does not terminate its own launcher before it finishes reporting results. @@ -132,6 +133,7 @@ Cafe Code has three important runtime layers: - The desktop backend manager treats both sustained repeated backend HTTP health failures and bootstrap readiness timeouts as terminal backend run conditions even if the child process remains alive after termination is requested. This prevents a split-brain state where the backend PID is still present but the HTTP listener is gone, leaving the renderer unable to reconnect and blocking the manager forever on process `exitCode`. The post-readiness watchdog is intentionally more patient than bootstrap readiness because large long-running workspaces can briefly push SQLite/projection work over a one-second health probe. Do not lower the watchdog to short one-second failure windows; brief missed probes should log and recover, while a sustained outage should still restart the backend. On replacement startup, stale desktop backend children are reaped before binding the new backend; provider daemon/supervisor processes are intentionally excluded so provider sessions can survive the recovery. - The desktop checks the detached provider daemon every five seconds through the capability-authenticated `/api/provider-daemon/liveness` route. That route is a database-free process-identity boundary and must never read provider adapters, SQLite, journals, command ledgers, projections, or supervisor state. The richer `/api/provider-daemon/health` route remains available for cached debug/settings diagnostics, but it must never drive destructive watchdog recovery: on very large long-running databases, its diagnostic reads can legitimately exceed the request timeout while the daemon and provider sessions remain alive. A dead daemon PID triggers recovery after the first failed liveness probe; otherwise Cafe requires three consecutive liveness failures so transient event-loop pressure does not destroy a live long-running provider session. Recovery stops the backend first, replaces the authenticated daemon, and then starts a backend with the replacement daemon's newly issued lease. The daemon lease is inherited bootstrap capability data and must never be mutated in a running backend or copied through process argv/environment. Preserve and test the stop-backend -> recover-daemon -> start-backend order. - A detached provider daemon must not keep stdout/stderr pipes owned by Electron. Those pipes close when the desktop process restarts and can kill the surviving daemon with `EPIPE` on a later log write. Provider daemon and supervisor stdio therefore stays ignored, while each child owns a role-specific durable trace (`provider-daemon.trace.ndjson` or `provider-supervisor.trace.ndjson`) plus the existing bounded provider event logs. Do not redirect both detached runtimes into the backend's rotating `server.trace.ndjson`, because independent processes cannot safely coordinate that file's rotation. +- Yarn source installs use `scripts/ensure-desktop-runtime.ts` as the trusted root postinstall boundary. It verifies the pinned Electron binary and, on POSIX, restores executable bits on the exact `node-pty` `spawn-helper` selected alongside `pty.node`; open the helper with `O_NOFOLLOW` and mutate permissions through the verified file descriptor so a symlink cannot redirect the chmod operation. Keep third-party lifecycle scripts denied by default except for the explicitly trusted native dependencies in root `dependenciesMeta`. - `cafe-code killall` and `cafe-code-server killall` are explicit user-requested process termination paths. They scan for Cafe Code launcher, desktop client, main server/backend, and provider daemon/supervisor processes, protect the current command process and its ancestors, terminate children before parents, and avoid printing raw process argv by default because argv can contain sensitive flags or paths. Important files: diff --git a/oxlint-plugin-cafecode/test/utils.ts b/oxlint-plugin-cafecode/test/utils.ts index cffc4c68..6e7a1ad4 100644 --- a/oxlint-plugin-cafecode/test/utils.ts +++ b/oxlint-plugin-cafecode/test/utils.ts @@ -91,7 +91,7 @@ export const createOxlintRuleHarness = (ruleName: string): RuleHarness => { const configPath = path.join(fixtureDir, ".oxlintrc.json"); const sourcePaths = sources.map((_, index) => path.join(fixtureDir, `fixture-${index + 1}.ts`)); const repoRoot = path.join(import.meta.dirname, "..", ".."); - const oxlintBin = path.join(repoRoot, "node_modules", ".bin", "oxlint"); + const oxlintEntrypoint = path.join(repoRoot, "node_modules", "oxlint", "bin", "oxlint"); const pluginPath = path.join(repoRoot, "oxlint-plugin-cafecode", "index.ts"); yield* fs.writeFileString( @@ -108,7 +108,15 @@ export const createOxlintRuleHarness = (ruleName: string): RuleHarness => { ); const output = yield* spawnAndCollectOutput( - ChildProcess.make(oxlintBin, ["--config", configPath, ...sourcePaths], { cwd: repoRoot }), + // Yarn's Windows `.bin` artifact is a `.cmd` shim and cannot be spawned + // directly with `shell: false`. Running Oxlint's JavaScript entrypoint + // through the pinned Node executable is portable and avoids introducing + // a shell boundary around fixture-controlled paths. + ChildProcess.make( + process.execPath, + [oxlintEntrypoint, "--config", configPath, ...sourcePaths], + { cwd: repoRoot }, + ), ); if (output.exitCode !== 0) { diff --git a/package.json b/package.json index 4d58ffce..44b0443d 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "type": "module", "scripts": { "prepare": "effect-language-service patch", - "postinstall": "node scripts/ensure-electron.ts", + "postinstall": "node scripts/ensure-desktop-runtime.ts", "dev": "node scripts/dev-runner.ts dev", "dev:server": "node scripts/dev-runner.ts dev:server", "dev:web": "node scripts/dev-runner.ts dev:web", diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index f00c4942..1db59e05 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -264,7 +264,7 @@ const STAGED_YARN_PROJECT_FILES = [ "packages/effect-codex-app-server/package.json", "packages/shared/package.json", "scripts/package.json", - "scripts/ensure-electron.ts", + "scripts/ensure-desktop-runtime.ts", "packaging/desktop-runtime/package.json", ] as const; diff --git a/scripts/ensure-desktop-runtime.test.ts b/scripts/ensure-desktop-runtime.test.ts new file mode 100644 index 00000000..c92e3e66 --- /dev/null +++ b/scripts/ensure-desktop-runtime.test.ts @@ -0,0 +1,63 @@ +import { + chmodSync, + lstatSync, + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { ensureNodePtySpawnHelperExecutable } from "./ensure-desktop-runtime.ts"; + +const temporaryDirectories: string[] = []; + +function makeNodePtyFixture(): { readonly packageRoot: string; readonly helperPath: string } { + const packageRoot = mkdtempSync(join(tmpdir(), "cafecode-node-pty-install-")); + temporaryDirectories.push(packageRoot); + const nativeDirectory = join(packageRoot, "prebuilds", "darwin-arm64"); + mkdirSync(nativeDirectory, { recursive: true }); + writeFileSync(join(nativeDirectory, "pty.node"), "native fixture"); + const helperPath = join(nativeDirectory, "spawn-helper"); + writeFileSync(helperPath, "helper fixture", { mode: 0o644 }); + chmodSync(helperPath, 0o644); + return { packageRoot, helperPath }; +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("ensureNodePtySpawnHelperExecutable", () => { + it("restores executable permissions on the selected POSIX helper", () => { + const fixture = makeNodePtyFixture(); + + expect(ensureNodePtySpawnHelperExecutable(fixture.packageRoot, "darwin", "arm64")).toBe( + fixture.helperPath, + ); + expect(lstatSync(fixture.helperPath).mode & 0o111).toBe(0o111); + }); + + it.skipIf(process.platform === "win32")("refuses to chmod a symlinked helper", () => { + const fixture = makeNodePtyFixture(); + const symlinkTarget = join(fixture.packageRoot, "untrusted-target"); + writeFileSync(symlinkTarget, "do not chmod", { mode: 0o600 }); + rmSync(fixture.helperPath); + symlinkSync(symlinkTarget, fixture.helperPath); + + expect(() => + ensureNodePtySpawnHelperExecutable(fixture.packageRoot, "darwin", "arm64"), + ).toThrow(/missing or is not a safe regular file/u); + expect(lstatSync(symlinkTarget).mode & 0o777).toBe(0o600); + }); + + it("does not require a POSIX helper on Windows", () => { + expect(ensureNodePtySpawnHelperExecutable("unused", "win32", "x64")).toBeNull(); + }); +}); diff --git a/scripts/ensure-desktop-runtime.ts b/scripts/ensure-desktop-runtime.ts new file mode 100644 index 00000000..d759ee5d --- /dev/null +++ b/scripts/ensure-desktop-runtime.ts @@ -0,0 +1,85 @@ +#!/usr/bin/env node + +import { closeSync, constants, existsSync, fchmodSync, fstatSync, openSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; + +// Resolve native runtime packages from the desktop workspace that declares +// them, rather than relying on the root node_modules hoisting layout. +const desktopRequire = createRequire(new URL("../apps/desktop/package.json", import.meta.url)); +const EXECUTABLE_PERMISSION_BITS = 0o111; + +export function ensureNodePtySpawnHelperExecutable( + nodePtyPackageRoot: string, + platform: NodeJS.Platform = process.platform, + arch: string = process.arch, +): string | null { + if (platform === "win32") { + return null; + } + + // node-pty searches build outputs before its packaged prebuild. Match that + // order so a source-built addon and its helper cannot drift apart. + const nativeDirectories = [ + join(nodePtyPackageRoot, "build", "Release"), + join(nodePtyPackageRoot, "build", "Debug"), + join(nodePtyPackageRoot, "prebuilds", `${platform}-${arch}`), + ]; + const nativeDirectory = nativeDirectories.find((directory) => + existsSync(join(directory, "pty.node")), + ); + if (!nativeDirectory) { + throw new Error(`node-pty has no native runtime for ${platform}-${arch}.`); + } + + const helperPath = join(nativeDirectory, "spawn-helper"); + let descriptor: number; + try { + // Yarn's node-modules linker currently extracts node-pty's POSIX helper + // without executable bits. Open the helper without following symlinks, + // then chmod the already-open file descriptor. This avoids turning a + // compromised node_modules symlink into an arbitrary chmod target. + descriptor = openSync(helperPath, constants.O_RDONLY | constants.O_NOFOLLOW); + } catch (error) { + throw new Error("node-pty spawn-helper is missing or is not a safe regular file.", { + cause: error, + }); + } + + try { + const initialStat = fstatSync(descriptor); + if (!initialStat.isFile()) { + throw new Error("node-pty spawn-helper is not a regular file."); + } + + const initialMode = initialStat.mode & 0o777; + if ((initialMode & EXECUTABLE_PERMISSION_BITS) !== EXECUTABLE_PERMISSION_BITS) { + fchmodSync(descriptor, initialMode | EXECUTABLE_PERMISSION_BITS); + } + + const finalMode = fstatSync(descriptor).mode & 0o777; + if ((finalMode & EXECUTABLE_PERMISSION_BITS) !== EXECUTABLE_PERMISSION_BITS) { + throw new Error("node-pty spawn-helper executable permissions could not be restored."); + } + } finally { + closeSync(descriptor); + } + + return helperPath; +} + +export function ensureInstalledDesktopRuntime(): void { + const electronExecutable = desktopRequire("electron") as unknown; + if (typeof electronExecutable !== "string" || !existsSync(electronExecutable)) { + throw new Error("The pinned Electron executable is unavailable after installation."); + } + + if (process.platform !== "win32") { + const nodePtyPackageRoot = dirname(desktopRequire.resolve("node-pty/package.json")); + ensureNodePtySpawnHelperExecutable(nodePtyPackageRoot); + } +} + +if (import.meta.main) { + ensureInstalledDesktopRuntime(); +} diff --git a/scripts/ensure-electron.ts b/scripts/ensure-electron.ts deleted file mode 100644 index aa3c71c3..00000000 --- a/scripts/ensure-electron.ts +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env node - -import { existsSync } from "node:fs"; -import { createRequire } from "node:module"; - -const require = createRequire(import.meta.url); -const electronExecutable = require("electron") as unknown; - -if (typeof electronExecutable !== "string" || !existsSync(electronExecutable)) { - throw new Error("The pinned Electron executable is unavailable after installation."); -} diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index d92b4006..393a8eb0 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -191,9 +191,15 @@ try { ); const expectedLockfile = readFileSync(resolve(tempRoot, "yarn.lock"), "utf8"); - rmSync(resolve(tempRoot, "yarn.lock"), { force: true }); - execFileSync("corepack", ["yarn", "install", "--no-immutable", "--mode=skip-build"], { + // A lockfile records concrete versions selected from open semver ranges. A + // from-scratch resolution is therefore expected to change whenever an + // upstream package publishes a matching release; comparing that result with + // a previously committed lockfile tests registry timing, not reproducibility. + // Immutable install is the release invariant Cafe actually needs: every + // copied manifest and the patched dependency graph must agree exactly with + // the committed lockfile after release package versions are rewritten. + execFileSync("corepack", ["yarn", "install", "--immutable", "--mode=skip-build"], { cwd: tempRoot, stdio: "inherit", shell: process.platform === "win32", @@ -201,7 +207,7 @@ try { const lockfile = readFileSync(resolve(tempRoot, "yarn.lock"), "utf8"); if (lockfile !== expectedLockfile) { - throw new Error("Expected the regenerated Yarn lockfile to match the committed lockfile."); + throw new Error("Expected immutable Yarn install to preserve the committed lockfile."); } assertContains( lockfile, @@ -213,11 +219,6 @@ try { '"@cafecode/desktop-runtime@workspace:packaging/desktop-runtime"', "Expected yarn.lock to contain the canonical desktop runtime workspace.", ); - execFileSync("corepack", ["yarn", "install", "--immutable", "--mode=skip-build"], { - cwd: tempRoot, - stdio: "inherit", - shell: process.platform === "win32", - }); const nightlyReleaseMetadata = execFileSync( process.execPath, From 388906631e379884afe176d99bacffd4277073ac Mon Sep 17 00:00:00 2001 From: cafeai <116491182+cafeai@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:04:19 +0900 Subject: [PATCH 03/13] Fix remaining cross-platform CI failures --- AGENTS.md | 2 +- package.json | 1 - packaging/desktop-runtime/package.json | 3 +++ scripts/ensure-desktop-runtime.test.ts | 17 ++++++++++------- scripts/ensure-desktop-runtime.ts | 13 ++++++++----- scripts/native-desktop-runtime-smoke.ts | 10 +++++++--- scripts/repository-audit.ts | 10 +++++++--- scripts/restart-cafe-code.test.ts | 3 ++- scripts/toolchain-policy.test.ts | 4 ++++ 9 files changed, 42 insertions(+), 21 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4c75fc62..ab348d49 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -133,7 +133,7 @@ Cafe Code has three important runtime layers: - The desktop backend manager treats both sustained repeated backend HTTP health failures and bootstrap readiness timeouts as terminal backend run conditions even if the child process remains alive after termination is requested. This prevents a split-brain state where the backend PID is still present but the HTTP listener is gone, leaving the renderer unable to reconnect and blocking the manager forever on process `exitCode`. The post-readiness watchdog is intentionally more patient than bootstrap readiness because large long-running workspaces can briefly push SQLite/projection work over a one-second health probe. Do not lower the watchdog to short one-second failure windows; brief missed probes should log and recover, while a sustained outage should still restart the backend. On replacement startup, stale desktop backend children are reaped before binding the new backend; provider daemon/supervisor processes are intentionally excluded so provider sessions can survive the recovery. - The desktop checks the detached provider daemon every five seconds through the capability-authenticated `/api/provider-daemon/liveness` route. That route is a database-free process-identity boundary and must never read provider adapters, SQLite, journals, command ledgers, projections, or supervisor state. The richer `/api/provider-daemon/health` route remains available for cached debug/settings diagnostics, but it must never drive destructive watchdog recovery: on very large long-running databases, its diagnostic reads can legitimately exceed the request timeout while the daemon and provider sessions remain alive. A dead daemon PID triggers recovery after the first failed liveness probe; otherwise Cafe requires three consecutive liveness failures so transient event-loop pressure does not destroy a live long-running provider session. Recovery stops the backend first, replaces the authenticated daemon, and then starts a backend with the replacement daemon's newly issued lease. The daemon lease is inherited bootstrap capability data and must never be mutated in a running backend or copied through process argv/environment. Preserve and test the stop-backend -> recover-daemon -> start-backend order. - A detached provider daemon must not keep stdout/stderr pipes owned by Electron. Those pipes close when the desktop process restarts and can kill the surviving daemon with `EPIPE` on a later log write. Provider daemon and supervisor stdio therefore stays ignored, while each child owns a role-specific durable trace (`provider-daemon.trace.ndjson` or `provider-supervisor.trace.ndjson`) plus the existing bounded provider event logs. Do not redirect both detached runtimes into the backend's rotating `server.trace.ndjson`, because independent processes cannot safely coordinate that file's rotation. -- Yarn source installs use `scripts/ensure-desktop-runtime.ts` as the trusted root postinstall boundary. It verifies the pinned Electron binary and, on POSIX, restores executable bits on the exact `node-pty` `spawn-helper` selected alongside `pty.node`; open the helper with `O_NOFOLLOW` and mutate permissions through the verified file descriptor so a symlink cannot redirect the chmod operation. Keep third-party lifecycle scripts denied by default except for the explicitly trusted native dependencies in root `dependenciesMeta`. +- Yarn source and staged artifact installs use the `@cafecode/desktop-runtime` workspace postinstall as the trusted `scripts/ensure-desktop-runtime.ts` boundary. Keep the verifier on this workspace instead of the root package: Yarn must finish the workspace's Electron and `node-pty` dependency builds before verification, especially when Linux compiles `node-pty` from source. The verifier checks the pinned Electron binary and, on POSIX, restores executable bits on the exact `node-pty` `spawn-helper` selected alongside `pty.node`; open the helper with `O_NOFOLLOW` and mutate permissions through the verified file descriptor so a symlink cannot redirect the chmod operation. Keep third-party lifecycle scripts denied by default except for the explicitly trusted native dependencies in root `dependenciesMeta`. - `cafe-code killall` and `cafe-code-server killall` are explicit user-requested process termination paths. They scan for Cafe Code launcher, desktop client, main server/backend, and provider daemon/supervisor processes, protect the current command process and its ancestors, terminate children before parents, and avoid printing raw process argv by default because argv can contain sensitive flags or paths. Important files: diff --git a/package.json b/package.json index 44b0443d..2ba550d4 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,6 @@ "type": "module", "scripts": { "prepare": "effect-language-service patch", - "postinstall": "node scripts/ensure-desktop-runtime.ts", "dev": "node scripts/dev-runner.ts dev", "dev:server": "node scripts/dev-runner.ts dev:server", "dev:web": "node scripts/dev-runner.ts dev:web", diff --git a/packaging/desktop-runtime/package.json b/packaging/desktop-runtime/package.json index 7b74761d..ce8db36e 100644 --- a/packaging/desktop-runtime/package.json +++ b/packaging/desktop-runtime/package.json @@ -2,6 +2,9 @@ "name": "@cafecode/desktop-runtime", "version": "0.0.51", "private": true, + "scripts": { + "postinstall": "node ../../scripts/ensure-desktop-runtime.ts" + }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "0.3.215", "@anthropic-ai/sdk": "^0.98.0", diff --git a/scripts/ensure-desktop-runtime.test.ts b/scripts/ensure-desktop-runtime.test.ts index c92e3e66..ce42234a 100644 --- a/scripts/ensure-desktop-runtime.test.ts +++ b/scripts/ensure-desktop-runtime.test.ts @@ -35,14 +35,17 @@ afterEach(() => { }); describe("ensureNodePtySpawnHelperExecutable", () => { - it("restores executable permissions on the selected POSIX helper", () => { - const fixture = makeNodePtyFixture(); + it.skipIf(process.platform === "win32")( + "restores executable permissions on the selected POSIX helper", + () => { + const fixture = makeNodePtyFixture(); - expect(ensureNodePtySpawnHelperExecutable(fixture.packageRoot, "darwin", "arm64")).toBe( - fixture.helperPath, - ); - expect(lstatSync(fixture.helperPath).mode & 0o111).toBe(0o111); - }); + expect(ensureNodePtySpawnHelperExecutable(fixture.packageRoot, "darwin", "arm64")).toBe( + fixture.helperPath, + ); + expect(lstatSync(fixture.helperPath).mode & 0o111).toBe(0o111); + }, + ); it.skipIf(process.platform === "win32")("refuses to chmod a symlinked helper", () => { const fixture = makeNodePtyFixture(); diff --git a/scripts/ensure-desktop-runtime.ts b/scripts/ensure-desktop-runtime.ts index d759ee5d..fffbbfd3 100644 --- a/scripts/ensure-desktop-runtime.ts +++ b/scripts/ensure-desktop-runtime.ts @@ -4,9 +4,12 @@ import { closeSync, constants, existsSync, fchmodSync, fstatSync, openSync } fro import { createRequire } from "node:module"; import { dirname, join } from "node:path"; -// Resolve native runtime packages from the desktop workspace that declares -// them, rather than relying on the root node_modules hoisting layout. -const desktopRequire = createRequire(new URL("../apps/desktop/package.json", import.meta.url)); +// Resolve native packages from the runtime workspace that owns this +// postinstall. Yarn builds that workspace only after its native dependencies, +// avoiding a race with node-pty's source build on platforms without prebuilds. +const desktopRuntimeRequire = createRequire( + new URL("../packaging/desktop-runtime/package.json", import.meta.url), +); const EXECUTABLE_PERMISSION_BITS = 0o111; export function ensureNodePtySpawnHelperExecutable( @@ -69,13 +72,13 @@ export function ensureNodePtySpawnHelperExecutable( } export function ensureInstalledDesktopRuntime(): void { - const electronExecutable = desktopRequire("electron") as unknown; + const electronExecutable = desktopRuntimeRequire("electron") as unknown; if (typeof electronExecutable !== "string" || !existsSync(electronExecutable)) { throw new Error("The pinned Electron executable is unavailable after installation."); } if (process.platform !== "win32") { - const nodePtyPackageRoot = dirname(desktopRequire.resolve("node-pty/package.json")); + const nodePtyPackageRoot = dirname(desktopRuntimeRequire.resolve("node-pty/package.json")); ensureNodePtySpawnHelperExecutable(nodePtyPackageRoot); } } diff --git a/scripts/native-desktop-runtime-smoke.ts b/scripts/native-desktop-runtime-smoke.ts index 18ad9a33..3ad7ec19 100644 --- a/scripts/native-desktop-runtime-smoke.ts +++ b/scripts/native-desktop-runtime-smoke.ts @@ -2,7 +2,7 @@ import { spawn, spawnSync } from "node:child_process"; import { createServer } from "node:net"; import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { dirname, join, resolve, win32 } from "node:path"; +import { join, posix, resolve, win32 } from "node:path"; const SELF_TEST_SWITCH = "--cafe-runtime-self-test"; const SELF_TEST_RESULT_ENV = "CAFE_CODE_RUNTIME_SELF_TEST_RESULT"; @@ -67,9 +67,13 @@ export function resolvePackagedResourcesPath( appPath: string, platform: NodeJS.Platform = process.platform, ): string { - if (platform === "darwin") return resolve(dirname(appPath), "../Resources"); + // This helper validates artifacts for a target platform, which may differ + // from the CI host. Use the target path implementation rather than the + // host-global path functions so Windows can validate macOS metadata and the + // converse remains true. + if (platform === "darwin") return posix.resolve(posix.dirname(appPath), "../Resources"); if (platform === "win32") return win32.join(win32.dirname(appPath), "resources"); - return join(dirname(appPath), "resources"); + return posix.join(posix.dirname(appPath), "resources"); } export function readDebugUrl(output: string): string | undefined { diff --git a/scripts/repository-audit.ts b/scripts/repository-audit.ts index 9004c959..b02fb8b0 100644 --- a/scripts/repository-audit.ts +++ b/scripts/repository-audit.ts @@ -57,7 +57,7 @@ const ignoredFilesystemDirectoryNames = new Set([ ]); function isIgnoredFilesystemPath(path: string): boolean { - const normalizedPath = path.split(sep).join("/"); + const normalizedPath = toRepositoryPath(path); return ( normalizedPath === ".yarn/install-state.gz" || normalizedPath === "packaging/aur/cafe-code/pkg" || @@ -67,6 +67,10 @@ function isIgnoredFilesystemPath(path: string): boolean { ); } +function toRepositoryPath(path: string): string { + return path.split(sep).join("/"); +} + export function listRepositoryFilesFromFilesystem(root: string): ReadonlyArray { const files: string[] = []; @@ -79,7 +83,7 @@ export function listRepositoryFilesFromFilesystem(root: string): ReadonlyArray { homeDir: "/home/me", cwd: "/repo", }), - ).toBe("/tmp/cafe-home/restart-logs"); + ).toBe(join("/tmp/cafe-home", "restart-logs")); }); }); diff --git a/scripts/toolchain-policy.test.ts b/scripts/toolchain-policy.test.ts index ceace4b6..4bd2b1fc 100644 --- a/scripts/toolchain-policy.test.ts +++ b/scripts/toolchain-policy.test.ts @@ -90,6 +90,10 @@ describe("repository toolchain policy", () => { expect(stagePackage.name).toBe("@cafecode/desktop-runtime"); expect(stagePackage.private).toBe(true); + expect(readStringMap(rootPackage.scripts)).not.toHaveProperty("postinstall"); + expect(readStringMap(stagePackage.scripts).postinstall).toBe( + "node ../../scripts/ensure-desktop-runtime.ts", + ); expect(stagePackage.dependencies).toEqual(expectedDependencies); expect(stagePackage.devDependencies).toEqual({ electron: desktopDependencies.electron, From c64c9313051376533ad60ba04f68dd47be5ab9d6 Mon Sep 17 00:00:00 2001 From: cafeai <116491182+cafeai@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:11:13 +0900 Subject: [PATCH 04/13] Fix Linux runtime install and Windows launcher tests --- AGENTS.md | 4 +- apps/desktop/scripts/start-electron.test.mjs | 43 ++++++++++++-------- scripts/ensure-desktop-runtime.test.ts | 6 ++- scripts/ensure-desktop-runtime.ts | 10 +++-- 4 files changed, 40 insertions(+), 23 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ab348d49..985d656f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,7 @@ - Windows provider health checks can receive localized CLI failure text in the active Windows code page instead of UTF-8. Preserve UTF-8 decoding on macOS/Linux, but on Windows fall back only when the byte stream is invalid UTF-8 so settings diagnostics do not show mojibake. - Windows tests must not assume Developer Mode or administrator symlink privileges. If a test specifically validates symlink handling and Windows returns a symlink privilege `EPERM`, skip only that privilege-dependent assertion path while preserving the full symlink security test on macOS/Linux and on Windows machines where symlinks are available. - Windows file URL conversion must use `fileURLToPath` instead of `URL.pathname`, because raw pathname strings keep a leading slash before the drive letter. This is safe for macOS/Linux and prevents Windows-only false negatives when tests resolve repo-relative assets. -- Windows test assertions that run through the host `Path.Path` service must build expected paths through that same path service instead of hard-coded POSIX separators. Keep macOS/Linux behavior unchanged by deriving expected strings from the platform path implementation already under test. +- Windows test assertions that run through the host `Path.Path` service or Node path API must build expected paths through that same path implementation instead of hard-coded POSIX separators. Cross-platform launcher tests may select Linux behavior while executing on Windows, but their filesystem fixtures still need host-native paths because that is what the production launcher receives. Keep macOS/Linux behavior unchanged by deriving expected strings from the host path implementation already under test. - Windows cannot directly spawn Yarn's `.cmd` command shims through the shell-free Effect child-process API. Custom Oxlint fixture tests must invoke `node_modules/oxlint/bin/oxlint` through `process.execPath`; keep `shell: false` so temporary fixture paths are passed as structured arguments, and preserve the same JavaScript entrypoint path on macOS/Linux. - Windows subprocess and heavy dynamic-import tests can need longer per-test timeouts under full parallel `turbo` load. Apply longer timeouts only under `process.platform === "win32"` and preserve existing macOS/Linux timeouts. - Windows process-table diagnostics must spawn `powershell.exe` directly with `shell: false`; do not route the CIM pipeline through `cmd.exe` or any shell wrapper because `cmd.exe` can misparse PowerShell pipeline syntax before PowerShell receives it. Keep macOS/Linux on the existing `ps -axo pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command=` path. Windows CPU/perf lookup should remain batched once per diagnostics read instead of querying performance counters once per process row. The longer diagnostics timeout exists for Windows CIM latency; if future work needs a tighter POSIX timeout, split the timeout by platform rather than regressing Windows reliability. @@ -133,7 +133,7 @@ Cafe Code has three important runtime layers: - The desktop backend manager treats both sustained repeated backend HTTP health failures and bootstrap readiness timeouts as terminal backend run conditions even if the child process remains alive after termination is requested. This prevents a split-brain state where the backend PID is still present but the HTTP listener is gone, leaving the renderer unable to reconnect and blocking the manager forever on process `exitCode`. The post-readiness watchdog is intentionally more patient than bootstrap readiness because large long-running workspaces can briefly push SQLite/projection work over a one-second health probe. Do not lower the watchdog to short one-second failure windows; brief missed probes should log and recover, while a sustained outage should still restart the backend. On replacement startup, stale desktop backend children are reaped before binding the new backend; provider daemon/supervisor processes are intentionally excluded so provider sessions can survive the recovery. - The desktop checks the detached provider daemon every five seconds through the capability-authenticated `/api/provider-daemon/liveness` route. That route is a database-free process-identity boundary and must never read provider adapters, SQLite, journals, command ledgers, projections, or supervisor state. The richer `/api/provider-daemon/health` route remains available for cached debug/settings diagnostics, but it must never drive destructive watchdog recovery: on very large long-running databases, its diagnostic reads can legitimately exceed the request timeout while the daemon and provider sessions remain alive. A dead daemon PID triggers recovery after the first failed liveness probe; otherwise Cafe requires three consecutive liveness failures so transient event-loop pressure does not destroy a live long-running provider session. Recovery stops the backend first, replaces the authenticated daemon, and then starts a backend with the replacement daemon's newly issued lease. The daemon lease is inherited bootstrap capability data and must never be mutated in a running backend or copied through process argv/environment. Preserve and test the stop-backend -> recover-daemon -> start-backend order. - A detached provider daemon must not keep stdout/stderr pipes owned by Electron. Those pipes close when the desktop process restarts and can kill the surviving daemon with `EPIPE` on a later log write. Provider daemon and supervisor stdio therefore stays ignored, while each child owns a role-specific durable trace (`provider-daemon.trace.ndjson` or `provider-supervisor.trace.ndjson`) plus the existing bounded provider event logs. Do not redirect both detached runtimes into the backend's rotating `server.trace.ndjson`, because independent processes cannot safely coordinate that file's rotation. -- Yarn source and staged artifact installs use the `@cafecode/desktop-runtime` workspace postinstall as the trusted `scripts/ensure-desktop-runtime.ts` boundary. Keep the verifier on this workspace instead of the root package: Yarn must finish the workspace's Electron and `node-pty` dependency builds before verification, especially when Linux compiles `node-pty` from source. The verifier checks the pinned Electron binary and, on POSIX, restores executable bits on the exact `node-pty` `spawn-helper` selected alongside `pty.node`; open the helper with `O_NOFOLLOW` and mutate permissions through the verified file descriptor so a symlink cannot redirect the chmod operation. Keep third-party lifecycle scripts denied by default except for the explicitly trusted native dependencies in root `dependenciesMeta`. +- Yarn source and staged artifact installs use the `@cafecode/desktop-runtime` workspace postinstall as the trusted `scripts/ensure-desktop-runtime.ts` boundary. Keep the verifier on this workspace instead of the root package: Yarn must finish the workspace's Electron and `node-pty` dependency builds before verification, especially when Linux compiles `node-pty` from source. The verifier checks the pinned Electron binary and, on macOS, restores executable bits on the exact `node-pty` `spawn-helper` selected alongside `pty.node`; node-pty compiles and consumes that helper only on macOS, while Linux validly builds only `pty.node` and uses `forkpty` directly. Open the macOS helper with `O_NOFOLLOW` and mutate permissions through the verified file descriptor so a symlink cannot redirect the chmod operation. Keep third-party lifecycle scripts denied by default except for the explicitly trusted native dependencies in root `dependenciesMeta`. - `cafe-code killall` and `cafe-code-server killall` are explicit user-requested process termination paths. They scan for Cafe Code launcher, desktop client, main server/backend, and provider daemon/supervisor processes, protect the current command process and its ancestors, terminate children before parents, and avoid printing raw process argv by default because argv can contain sensitive flags or paths. Important files: diff --git a/apps/desktop/scripts/start-electron.test.mjs b/apps/desktop/scripts/start-electron.test.mjs index 27b121ca..8e34446d 100644 --- a/apps/desktop/scripts/start-electron.test.mjs +++ b/apps/desktop/scripts/start-electron.test.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { join, resolve } from "node:path"; import { describe, it } from "vitest"; @@ -11,6 +12,14 @@ import { resolveLaunchPlan, } from "./start-electron.mjs"; +// These launcher tests execute on all CI hosts. Build fixture paths with the +// host path implementation because the production launcher receives paths +// from that same host, even when a test selects the Linux display branch. +const fixtureRepositoryRoot = resolve("/repo"); +const fixtureDesktopDir = join(fixtureRepositoryRoot, "apps", "desktop"); +const fixtureServerEntrypoint = join(fixtureRepositoryRoot, "apps", "server", "dist", "bin.mjs"); +const fixtureWorkspaceDir = resolve("/workspace/project"); + describe("start-electron launcher", () => { it("detects whether Linux has a graphical display available", () => { assert.equal(hasDisplayServer({}, "linux"), false); @@ -41,33 +50,33 @@ describe("start-electron launcher", () => { args: ["--cafe-debug", "--port", "3888"], environment: {}, platform: "linux", - runtimeDesktopDir: "/repo/apps/desktop", - cwd: "/repo/apps/desktop", + runtimeDesktopDir: fixtureDesktopDir, + cwd: fixtureDesktopDir, electronPath: () => "/electron", - serverEntrypoint: "/repo/apps/server/dist/bin.mjs", + serverEntrypoint: fixtureServerEntrypoint, }); assert.equal(plan.type, "headless-server"); assert.equal(plan.command, process.execPath); assert.deepEqual(plan.args, [ - "/repo/apps/server/dist/bin.mjs", + fixtureServerEntrypoint, "serve", "--mode", "desktop", "--port", "3888", ]); - assert.equal(plan.cwd, "/repo"); + assert.equal(plan.cwd, fixtureRepositoryRoot); }); it("preserves the original invocation directory for headless server cwd when available", () => { assert.equal( resolveHeadlessServerCwd({ - cwd: "/repo/apps/desktop", - environment: { INIT_CWD: "/workspace/project" }, - runtimeDesktopDir: "/repo/apps/desktop", + cwd: fixtureDesktopDir, + environment: { INIT_CWD: fixtureWorkspaceDir }, + runtimeDesktopDir: fixtureDesktopDir, }), - "/workspace/project", + fixtureWorkspaceDir, ); }); @@ -76,28 +85,28 @@ describe("start-electron launcher", () => { args: ["--user-arg"], environment: { DISPLAY: ":0" }, platform: "linux", - runtimeDesktopDir: "/repo/apps/desktop", - cwd: "/repo/apps/desktop", + runtimeDesktopDir: fixtureDesktopDir, + cwd: fixtureDesktopDir, electronPath: () => "/electron", - serverEntrypoint: "/repo/apps/server/dist/bin.mjs", + serverEntrypoint: fixtureServerEntrypoint, }); assert.equal(plan.type, "electron"); assert.equal(plan.command, "/electron"); assert.deepEqual(plan.args, ["dist-electron/main.cjs", "--user-arg"]); - assert.equal(plan.cwd, "/repo/apps/desktop"); + assert.equal(plan.cwd, fixtureDesktopDir); }); it("resolves the staged or source server entrypoint next to the desktop runtime", () => { assert.equal( resolveHeadlessServerEntrypoint( - "/repo/apps/desktop", - (path) => path === "/repo/apps/server/dist/bin.mjs", + fixtureDesktopDir, + (path) => path === fixtureServerEntrypoint, ), - "/repo/apps/server/dist/bin.mjs", + fixtureServerEntrypoint, ); assert.equal( - resolveHeadlessServerEntrypoint("/repo/apps/desktop", () => false), + resolveHeadlessServerEntrypoint(fixtureDesktopDir, () => false), undefined, ); }); diff --git a/scripts/ensure-desktop-runtime.test.ts b/scripts/ensure-desktop-runtime.test.ts index ce42234a..7be18858 100644 --- a/scripts/ensure-desktop-runtime.test.ts +++ b/scripts/ensure-desktop-runtime.test.ts @@ -36,7 +36,7 @@ afterEach(() => { describe("ensureNodePtySpawnHelperExecutable", () => { it.skipIf(process.platform === "win32")( - "restores executable permissions on the selected POSIX helper", + "restores executable permissions on the selected macOS helper", () => { const fixture = makeNodePtyFixture(); @@ -63,4 +63,8 @@ describe("ensureNodePtySpawnHelperExecutable", () => { it("does not require a POSIX helper on Windows", () => { expect(ensureNodePtySpawnHelperExecutable("unused", "win32", "x64")).toBeNull(); }); + + it("does not require the macOS-only helper on Linux", () => { + expect(ensureNodePtySpawnHelperExecutable("unused", "linux", "x64")).toBeNull(); + }); }); diff --git a/scripts/ensure-desktop-runtime.ts b/scripts/ensure-desktop-runtime.ts index fffbbfd3..3260ddde 100644 --- a/scripts/ensure-desktop-runtime.ts +++ b/scripts/ensure-desktop-runtime.ts @@ -17,7 +17,11 @@ export function ensureNodePtySpawnHelperExecutable( platform: NodeJS.Platform = process.platform, arch: string = process.arch, ): string | null { - if (platform === "win32") { + // node-pty's binding.gyp builds spawn-helper only for macOS, and its native + // fork implementation consumes the helper only inside the __APPLE__ branch. + // Linux uses forkpty directly, so requiring a helper there rejects a valid + // source build after node-gyp has successfully produced pty.node. + if (platform !== "darwin") { return null; } @@ -38,7 +42,7 @@ export function ensureNodePtySpawnHelperExecutable( const helperPath = join(nativeDirectory, "spawn-helper"); let descriptor: number; try { - // Yarn's node-modules linker currently extracts node-pty's POSIX helper + // Yarn's node-modules linker currently extracts node-pty's macOS helper // without executable bits. Open the helper without following symlinks, // then chmod the already-open file descriptor. This avoids turning a // compromised node_modules symlink into an arbitrary chmod target. @@ -77,7 +81,7 @@ export function ensureInstalledDesktopRuntime(): void { throw new Error("The pinned Electron executable is unavailable after installation."); } - if (process.platform !== "win32") { + if (process.platform === "darwin") { const nodePtyPackageRoot = dirname(desktopRuntimeRequire.resolve("node-pty/package.json")); ensureNodePtySpawnHelperExecutable(nodePtyPackageRoot); } From 70d1ce8ed43f43deaf24b95f9fbe6495a85ffb49 Mon Sep 17 00:00:00 2001 From: cafeai <116491182+cafeai@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:51:46 +0900 Subject: [PATCH 05/13] Fix Windows CI portability --- AGENTS.md | 2 + .../OrchestrationEngineHarness.integration.ts | 4 + .../src/auth/Layers/ServerSecretStore.test.ts | 8 +- apps/server/src/bootstrap.test.ts | 88 ++++++++++--------- apps/server/src/bootstrap.ts | 19 +++- apps/server/src/cli/config.test.ts | 7 ++ apps/server/src/git/GitManager.ts | 29 +++++- .../Layers/CheckpointReactor.test.ts | 1 + .../src/process/externalLauncher.test.ts | 66 +++++--------- apps/server/src/process/externalLauncher.ts | 25 ++++-- .../Layers/ProjectFaviconResolver.test.ts | 3 +- .../Layers/RepositoryIdentityResolver.test.ts | 17 ++-- .../src/provider/Layers/ClaudeAdapter.test.ts | 14 ++- .../src/provider/Layers/ClaudeAdapter.ts | 34 ++++++- .../ProviderInstanceRegistryLive.test.ts | 17 +++- .../provider/Layers/ProviderRegistry.test.ts | 14 ++- .../src/provider/providerMaintenance.test.ts | 28 +++--- apps/server/src/server.test.ts | 12 +-- .../SourceControlRepositoryService.test.ts | 4 +- .../ClaudeTextGeneration.test.ts | 25 +++++- .../CodexTextGeneration.test.ts | 46 +++++++++- .../vcs/testing/VcsDriverContractHarness.ts | 16 ++-- 22 files changed, 330 insertions(+), 149 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 985d656f..530eab14 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,7 @@ - Windows tests must not assume Developer Mode or administrator symlink privileges. If a test specifically validates symlink handling and Windows returns a symlink privilege `EPERM`, skip only that privilege-dependent assertion path while preserving the full symlink security test on macOS/Linux and on Windows machines where symlinks are available. - Windows file URL conversion must use `fileURLToPath` instead of `URL.pathname`, because raw pathname strings keep a leading slash before the drive letter. This is safe for macOS/Linux and prevents Windows-only false negatives when tests resolve repo-relative assets. - Windows test assertions that run through the host `Path.Path` service or Node path API must build expected paths through that same path implementation instead of hard-coded POSIX separators. Cross-platform launcher tests may select Linux behavior while executing on Windows, but their filesystem fixtures still need host-native paths because that is what the production launcher receives. Keep macOS/Linux behavior unchanged by deriving expected strings from the host path implementation already under test. +- Windows can report one directory through different casing, separators, long names, or 8.3 aliases. Git/worktree safety checks must compare stable filesystem identity when available instead of raw path spelling. Tests for simulated Linux/macOS command-selection policy must inject command availability rather than asking the Windows host filesystem to interpret a foreign `PATH`; real Windows provider-process smoke fixtures must use `.cmd` shims. Windows has no `/proc/self/fd` or `/dev/fd` duplication path, so a bootstrap descriptor handed to the direct stream becomes stream-owned and must not also be closed by the caller. POSIX FIFO timeout coverage stays POSIX-only. - Windows cannot directly spawn Yarn's `.cmd` command shims through the shell-free Effect child-process API. Custom Oxlint fixture tests must invoke `node_modules/oxlint/bin/oxlint` through `process.execPath`; keep `shell: false` so temporary fixture paths are passed as structured arguments, and preserve the same JavaScript entrypoint path on macOS/Linux. - Windows subprocess and heavy dynamic-import tests can need longer per-test timeouts under full parallel `turbo` load. Apply longer timeouts only under `process.platform === "win32"` and preserve existing macOS/Linux timeouts. - Windows process-table diagnostics must spawn `powershell.exe` directly with `shell: false`; do not route the CIM pipeline through `cmd.exe` or any shell wrapper because `cmd.exe` can misparse PowerShell pipeline syntax before PowerShell receives it. Keep macOS/Linux on the existing `ps -axo pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command=` path. Windows CPU/perf lookup should remain batched once per diagnostics read instead of querying performance counters once per process row. The longer diagnostics timeout exists for Windows CIM latency; if future work needs a tighter POSIX timeout, split the timeout by platform rather than regressing Windows reliability. @@ -323,6 +324,7 @@ Claude lifecycle facts to preserve: - Fresh Claude sessions should let upstream `query()` allocate the session id. The official SDK docs mark `sessionId` as optional/default auto-generated and recommend capturing the durable id from `system` init or result messages before passing it back through `resume`; Cafe Code should therefore only pass `resume` when it has a durable resume cursor from a real Claude transcript. Do not generate arbitrary `sessionId` values for new long-lived AsyncIterable sessions, because current Claude Code can reject the first queued turn with "No conversation found with session ID" before the transcript exists. - Do not pass Claude SDK `resumeSessionAt` for ordinary follow-up turns. Upstream SDK types document `resumeSessionAt` as a specific assistant-message checkpoint, not as the normal latest-leaf resume pointer; always applying it to Cafe follow-ups can make current Claude Code reject valid transcripts with "No message found with message.uuid". Only use `resumeSessionAt` for an explicit user-visible rewind/fork-to-message operation with dedicated recovery handling. - Claude `resume` loads a local transcript from the resolved Claude home project directory for the active cwd, not from Cafe's UI state alone. Before passing a durable Cafe cursor through SDK `resume`, verify that `~/.claude/projects//.jsonl` exists in the resolved Claude home or can be copied from another Claude project directory. If the transcript is missing, drop the stale cursor and start a fresh upstream Claude session rather than sending a doomed `--resume` that fails before the user's turn starts. +- Claude project directory names must match the installed Agent SDK's `projectKey` algorithm: resolve the cwd, replace every non-ASCII-alphanumeric character with `-`, and when the encoded name exceeds 200 characters append the same deterministic signed-32-bit hash suffix used upstream. Replacing only path separators is incorrect on POSIX paths containing punctuation and creates invalid colon-bearing directory components on Windows, which prevents durable resume discovery. - A zero-turn Claude `error_during_execution` result is a pre-run failure, not a durable conversation turn. Do not replace a healthy resume session id with the failed result's new session id, do not increment the durable resume turn count for that failed turn, and repair stale Cafe cursors from local Claude transcript files when a stored assistant checkpoint identifies the real session. Recoverable provider lifecycle failures should be recorded as work-log/debug diagnostics rather than promoted to blocking toasts or thread-level fatal banners. - Claude permissions flow through SDK permission callbacks such as `canUseTool`; respect the supplied abort signal, request id, tool-use id, suggested permission updates, and deny/allow semantics. - Claude Agent SDK sessions persist by default unless configured otherwise. Resume/session identifiers must be treated as durable provider state, not UI state. diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index fd14b1d1..de9e283a 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -92,6 +92,10 @@ const initializeGitWorkspace = Effect.fn(function* (cwd: string) { runGit(cwd, ["init", "--initial-branch=main"]); runGit(cwd, ["config", "user.email", "test@example.com"]); runGit(cwd, ["config", "user.name", "Test User"]); + // Checkpoint contents are byte-level test fixtures. Disable the Windows + // global autocrlf policy so checkout/revert does not rewrite committed LF + // bytes and turn a VCS correctness assertion into a runner preference test. + runGit(cwd, ["config", "core.autocrlf", "false"]); const fileSystem = yield* FileSystem.FileSystem; const { join } = yield* Path.Path; yield* fileSystem.writeFileString(join(cwd, "README.md"), "v1\n"); diff --git a/apps/server/src/auth/Layers/ServerSecretStore.test.ts b/apps/server/src/auth/Layers/ServerSecretStore.test.ts index 30b5a9d3..2b90e362 100644 --- a/apps/server/src/auth/Layers/ServerSecretStore.test.ts +++ b/apps/server/src/auth/Layers/ServerSecretStore.test.ts @@ -218,9 +218,11 @@ it.layer(NodeServices.layer)("ServerSecretStoreLive", (it) => { yield* secretStore.set("session-signing-key", Uint8Array.from([1, 2, 3])); - expect(chmodCalls.some((call) => call.mode === 0o700 && call.path.endsWith("/secrets"))).toBe( - true, - ); + expect( + chmodCalls.some( + (call) => call.mode === 0o700 && call.path.replaceAll("\\", "/").endsWith("/secrets"), + ), + ).toBe(true); expect(chmodCalls.filter((call) => call.mode === 0o600).length).toBeGreaterThanOrEqual(2); }).pipe(Effect.provide(NodeServices.layer)), ); diff --git a/apps/server/src/bootstrap.test.ts b/apps/server/src/bootstrap.test.ts index 151d386d..2f5c3795 100644 --- a/apps/server/src/bootstrap.test.ts +++ b/apps/server/src/bootstrap.test.ts @@ -81,10 +81,13 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { `${yield* encodeTestEnvelopeSchema({ mode: "desktop" })}\n`, ); - const fd = yield* Effect.acquireRelease( - Effect.sync(() => NFS.openSync(filePath, "r")), - (fd) => Effect.sync(() => NFS.closeSync(fd)), - ); + const fd = + process.platform === "win32" + ? NFS.openSync(filePath, "r") + : yield* Effect.acquireRelease( + Effect.sync(() => NFS.openSync(filePath, "r")), + (fd) => Effect.sync(() => NFS.closeSync(fd)), + ); const payload = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { timeoutMs: 100 }); assertSome(payload, { @@ -107,6 +110,9 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { // Pretend this ordinary temp file fd has the same fstat shape so the test // verifies that bootstrap does not attempt the blocking `/dev/fd/` // duplication path for socket descriptors. + // This path deliberately bypasses descriptor duplication, so the + // bootstrap stream becomes the sole descriptor owner just as it does + // for inherited child-process pipes in production. const fd = NFS.openSync(filePath, "r"); openSyncInterceptor.socketLikeFd = fd; openSyncInterceptor.openedPaths = []; @@ -136,10 +142,8 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { `${yield* encodeTestEnvelopeSchema({ mode: "desktop" })}\n`, ); - // Open without acquireRelease: the direct-stream fallback uses autoClose: true, - // so the stream owns the fd lifecycle and closes it asynchronously on end. - // Attempting to also close it synchronously in a finalizer races with the - // stream's async close and produces an uncaught EBADF. + // Open without acquireRelease: the direct-stream fallback uses + // autoClose, so the stream owns this descriptor after handoff. const fd = NFS.openSync(filePath, "r"); openSyncInterceptor.failPath = `/proc/self/fd/${fd}`; @@ -156,7 +160,9 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { it.effect("returns none when the fd is unavailable", () => Effect.gen(function* () { - const fd = NFS.openSync("/dev/null", "r"); + const fs = yield* FileSystem.FileSystem; + const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-unavailable-" }); + const fd = NFS.openSync(filePath, "r"); NFS.closeSync(fd); const payload = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { timeoutMs: 100 }); @@ -164,40 +170,42 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { }), ); - it.effect("returns none when the bootstrap read times out before any value arrives", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-bootstrap-" }); - const fifoPath = path.join(tempDir, "bootstrap.pipe"); - - yield* Effect.sync(() => execFileSync("mkfifo", [fifoPath])); - - const _writer = yield* Effect.acquireRelease( - Effect.sync(() => - spawn("sh", ["-c", 'exec 3>"$1"; sleep 60', "sh", fifoPath], { - stdio: ["ignore", "ignore", "ignore"], - }), - ), - (writer) => - Effect.sync(() => { - writer.kill("SIGKILL"); - }), - ); + it.effect.skipIf(process.platform === "win32")( + "returns none when the bootstrap read times out before any value arrives", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-bootstrap-" }); + const fifoPath = path.join(tempDir, "bootstrap.pipe"); + + yield* Effect.sync(() => execFileSync("mkfifo", [fifoPath])); + + const _writer = yield* Effect.acquireRelease( + Effect.sync(() => + spawn("sh", ["-c", 'exec 3>"$1"; sleep 60', "sh", fifoPath], { + stdio: ["ignore", "ignore", "ignore"], + }), + ), + (writer) => + Effect.sync(() => { + writer.kill("SIGKILL"); + }), + ); - const fd = yield* Effect.acquireRelease( - Effect.sync(() => NFS.openSync(fifoPath, "r")), - (fd) => Effect.sync(() => NFS.closeSync(fd)), - ); + const fd = yield* Effect.acquireRelease( + Effect.sync(() => NFS.openSync(fifoPath, "r")), + (fd) => Effect.sync(() => NFS.closeSync(fd)), + ); - const fiber = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { - timeoutMs: 100, - }).pipe(Effect.forkScoped); + const fiber = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { + timeoutMs: 100, + }).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - yield* TestClock.adjust(Duration.millis(100)); + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.millis(100)); - const payload = yield* Fiber.join(fiber); - assertNone(payload); - }).pipe(Effect.provide(TestClock.layer())), + const payload = yield* Fiber.join(fiber); + assertNone(payload); + }).pipe(Effect.provide(TestClock.layer())), ); }); diff --git a/apps/server/src/bootstrap.ts b/apps/server/src/bootstrap.ts index d23dcb3a..a99f417a 100644 --- a/apps/server/src/bootstrap.ts +++ b/apps/server/src/bootstrap.ts @@ -36,16 +36,31 @@ export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function input: stream, crlfDelay: Infinity, }); + let completed = false; const cleanup = () => { + // `Effect.callback` invokes this canceler after a successful resume as + // well as on interruption. Once a line/close/error has settled the + // callback, let the ReadStream finish its normal EOF auto-close. Calling + // destroy in parallel with that close can issue a second close/read on + // the descriptor and surface an uncaught EBADF. + if (completed) { + return; + } stream.removeListener("error", handleError); input.removeListener("line", handleLine); input.removeListener("close", handleClose); input.close(); - stream.destroy(); + // The bootstrap stream owns its descriptor after handoff. On + // interruption, destroy initiates that stream's auto-close; the + // completed guard above keeps this path from racing normal EOF teardown. + if (!stream.destroyed && !stream.closed) { + stream.destroy(); + } }; const handleError = (error: Error) => { + completed = true; if (isUnavailableBootstrapFdError(error)) { resume(Effect.succeedNone); return; @@ -61,6 +76,7 @@ export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function }; const handleLine = (line: string) => { + completed = true; const parsed = decodeJsonResult(schema)(line); if (Result.isSuccess(parsed)) { resume(Effect.succeedSome(parsed.success)); @@ -77,6 +93,7 @@ export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function }; const handleClose = () => { + completed = true; resume(Effect.succeedNone); }; diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index 511af782..629380cd 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -1,4 +1,5 @@ import NodeOS from "node:os"; +import { openSync } from "node:fs"; import { assert, expect, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; @@ -51,6 +52,12 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" }); const encoded = yield* encodeDesktopBootstrap(payload); yield* fs.writeFileString(filePath, `${encoded}\n`); + if (process.platform === "win32") { + // Windows cannot duplicate an inherited descriptor through + // `/proc/self/fd` or `/dev/fd`; readBootstrapEnvelope therefore owns + // and closes the direct descriptor after this explicit handoff. + return openSync(filePath, "r"); + } const { fd } = yield* fs.open(filePath, { flag: "r" }); return fd; }); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 9634c50a..b7f6915d 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -5,6 +5,7 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import { GitCommandError, GitPreparePullRequestThreadInput, @@ -365,7 +366,33 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const fileSystem = yield* FileSystem.FileSystem; const canonicalizeExistingPath = (value: string) => - fileSystem.realPath(value).pipe(Effect.catch(() => Effect.succeed(value))); + Effect.gen(function* () { + const canonicalPath = yield* fileSystem + .realPath(value) + .pipe(Effect.catch(() => Effect.succeed(value))); + + if (process.platform !== "win32") { + return canonicalPath; + } + + // A Windows directory can be reported through a short 8.3 alias, a + // long path, different separator styles, or different casing. String + // equality can therefore mistake the main checkout for a second + // worktree and either reuse the wrong branch or overwrite it. Prefer + // the filesystem identity Git is actually referring to. Fall back to a + // normalized path only when the entry disappears between Git's listing + // and this lookup. + const info = yield* fileSystem.stat(canonicalPath).pipe(Effect.option); + if (Option.isSome(info) && Option.isSome(info.value.ino) && info.value.ino.value !== 0) { + return `win32-file:${info.value.dev}:${info.value.ino.value}`; + } + + const normalizedPath = canonicalPath + .replace(/^\\\\\?\\/, "") + .replaceAll("\\", "/") + .toLowerCase(); + return `win32-path:${normalizedPath}`; + }); const normalizeStatusCacheKey = canonicalizeExistingPath; const nonRepositoryStatusDetails = { isRepo: false, diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 58418dc6..21d17980 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -253,6 +253,7 @@ function createGitRepository() { runGit(cwd, ["init", "--initial-branch=main"]); runGit(cwd, ["config", "user.email", "test@example.com"]); runGit(cwd, ["config", "user.name", "Test User"]); + runGit(cwd, ["config", "core.autocrlf", "false"]); fs.writeFileSync(path.join(cwd, "README.md"), "v1\n", "utf8"); runGit(cwd, ["add", "."]); runGit(cwd, ["commit", "-m", "Initial"]); diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 42cb4c50..cc10522f 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -12,6 +12,7 @@ import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { + type CommandAvailabilityProbe, isCommandAvailable, launchBrowser, launchEditorProcess, @@ -25,6 +26,11 @@ import { resolveTerminalProcessLaunch, } from "./externalLauncher.ts"; +function commandsAvailable(...commands: ReadonlyArray): CommandAvailabilityProbe { + const available = new Set(commands); + return (command) => available.has(command); +} + function encodeUtf16LeBase64(input: string): string { const bytes = new Uint8Array(input.length * 2); for (let index = 0; index < input.length; index += 1) { @@ -454,15 +460,12 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { it.effect("falls back to zeditor when zed is not installed", () => Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-launcher-test-" }); - yield* fs.writeFileString(path.join(dir, "zeditor"), "#!/bin/sh\nexit 0\n"); - yield* fs.chmod(path.join(dir, "zeditor"), 0o755); - - const result = yield* resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "zed" }, "linux", { - PATH: dir, - }); + const result = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "zed" }, + "linux", + { PATH: "" }, + commandsAvailable("zeditor"), + ); assert.deepEqual(result, { command: "zeditor", @@ -519,21 +522,11 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { it.effect("prefers Dolphin for the file-manager editor in KDE sessions", () => Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-file-manager-" }); - - yield* fs.writeFileString(path.join(dir, "dolphin"), "#!/bin/sh\nexit 0\n"); - yield* fs.writeFileString(path.join(dir, "gio"), "#!/bin/sh\nexit 0\n"); - yield* fs.writeFileString(path.join(dir, "xdg-open"), "#!/bin/sh\nexit 0\n"); - yield* fs.chmod(path.join(dir, "dolphin"), 0o755); - yield* fs.chmod(path.join(dir, "gio"), 0o755); - yield* fs.chmod(path.join(dir, "xdg-open"), 0o755); - const launch = yield* resolveEditorLaunch( { cwd: "/tmp/workspace", editor: "file-manager" }, "linux", - { PATH: dir, XDG_CURRENT_DESKTOP: "KDE" }, + { PATH: "", XDG_CURRENT_DESKTOP: "KDE" }, + commandsAvailable("dolphin", "gio", "xdg-open"), ); assert.deepEqual(launch, { @@ -545,19 +538,11 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { it.effect("uses gio open for the Linux file-manager editor when available outside KDE", () => Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-file-manager-" }); - - yield* fs.writeFileString(path.join(dir, "gio"), "#!/bin/sh\nexit 0\n"); - yield* fs.writeFileString(path.join(dir, "xdg-open"), "#!/bin/sh\nexit 0\n"); - yield* fs.chmod(path.join(dir, "gio"), 0o755); - yield* fs.chmod(path.join(dir, "xdg-open"), 0o755); - const launch = yield* resolveEditorLaunch( { cwd: "/tmp/workspace", editor: "file-manager" }, "linux", - { PATH: dir, XDG_CURRENT_DESKTOP: "GNOME" }, + { PATH: "", XDG_CURRENT_DESKTOP: "GNOME" }, + commandsAvailable("gio", "xdg-open"), ); assert.deepEqual(launch, { @@ -968,19 +953,12 @@ it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => { ); it.effect("includes zed when only the zeditor command is installed", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - - yield* fs.writeFileString(path.join(dir, "zeditor"), "#!/bin/sh\nexit 0\n"); - yield* fs.writeFileString(path.join(dir, "xdg-open"), "#!/bin/sh\nexit 0\n"); - yield* fs.chmod(path.join(dir, "zeditor"), 0o755); - yield* fs.chmod(path.join(dir, "xdg-open"), 0o755); - - const editors = resolveAvailableEditors("linux", { - PATH: dir, - }); + Effect.sync(() => { + const editors = resolveAvailableEditors( + "linux", + { PATH: "" }, + commandsAvailable("zeditor", "xdg-open"), + ); assert.deepEqual(editors, ["zed", "file-manager"]); }), ); diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index b595a0bf..7c097ff0 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -57,6 +57,11 @@ interface TargetPathAndPosition { readonly column: Option.Option; } +export type CommandAvailabilityProbe = ( + command: string, + options?: CommandAvailabilityOptions, +) => boolean; + const TARGET_WITH_POSITION_PATTERN = /^(.*?):(\d+)(?::(\d+))?$/; const POWERSHELL_ARGUMENTS_PREFIX = [ "-NoProfile", @@ -139,9 +144,10 @@ function resolveEditorArgs( function resolveAvailableCommand( commands: ReadonlyArray, options: CommandAvailabilityOptions = {}, + commandAvailable: CommandAvailabilityProbe = isCommandAvailable, ): Option.Option { for (const command of commands) { - if (isCommandAvailable(command, options)) { + if (commandAvailable(command, options)) { return Option.some(command); } } @@ -234,6 +240,7 @@ function fileManagerLaunchForPlatform( target: string, platform: NodeJS.Platform, env: NodeJS.ProcessEnv, + commandAvailable: CommandAvailabilityProbe = isCommandAvailable, ): EditorLaunch { switch (platform) { case "darwin": @@ -246,10 +253,10 @@ function fileManagerLaunchForPlatform( // (for example org.kde.dolphin.desktop). In some source-launch // environments that id is then treated as an executable name, so prefer // the concrete Dolphin binary when Cafe is running in a KDE session. - if (isKdeDesktop(env) && isCommandAvailable("dolphin", { platform, env })) { + if (isKdeDesktop(env) && commandAvailable("dolphin", { platform, env })) { return { command: "dolphin", args: [target] }; } - if (isCommandAvailable("gio", { platform, env })) { + if (commandAvailable("gio", { platform, env })) { return { command: "gio", args: ["open", target] }; } } @@ -288,19 +295,20 @@ export function resolveBrowserLaunch( export function resolveAvailableEditors( platform: NodeJS.Platform = process.platform, env: NodeJS.ProcessEnv = process.env, + commandAvailable: CommandAvailabilityProbe = isCommandAvailable, ): ReadonlyArray { const available: EditorId[] = []; for (const editor of EDITORS) { if (editor.commands === null) { - const { command } = fileManagerLaunchForPlatform("", platform, env); - if (isCommandAvailable(command, { platform, env })) { + const { command } = fileManagerLaunchForPlatform("", platform, env, commandAvailable); + if (commandAvailable(command, { platform, env })) { available.push(editor.id); } continue; } - const command = resolveAvailableCommand(editor.commands, { platform, env }); + const command = resolveAvailableCommand(editor.commands, { platform, env }, commandAvailable); if (Option.isSome(command)) { available.push(editor.id); } @@ -378,6 +386,7 @@ export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( input: LaunchEditorInput, platform: NodeJS.Platform = process.platform, env: NodeJS.ProcessEnv = process.env, + commandAvailable: CommandAvailabilityProbe = isCommandAvailable, ): Effect.fn.Return { yield* Effect.annotateCurrentSpan({ "externalLauncher.editor": input.editor, @@ -391,7 +400,7 @@ export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( if (editorDef.commands) { const command = Option.getOrElse( - resolveAvailableCommand(editorDef.commands, { platform, env }), + resolveAvailableCommand(editorDef.commands, { platform, env }, commandAvailable), () => editorDef.commands[0], ); return { @@ -404,7 +413,7 @@ export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( return yield* new ExternalLauncherError({ message: `Unsupported editor: ${input.editor}` }); } - return fileManagerLaunchForPlatform(input.cwd, platform, env); + return fileManagerLaunchForPlatform(input.cwd, platform, env, commandAvailable); }); export function resolveEditorProcessLaunch( diff --git a/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts b/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts index c983aca4..1eb3853c 100644 --- a/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts +++ b/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts @@ -52,6 +52,7 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { it.effect("resolves icon hrefs from project source files", () => Effect.gen(function* () { const resolver = yield* ProjectFaviconResolver; + const path = yield* Path.Path; const cwd = yield* makeTempDir; yield* writeTextFile(cwd, "index.html", ''); yield* writeTextFile(cwd, "public/brand/logo.svg", "brand"); @@ -59,7 +60,7 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { const resolved = yield* resolver.resolvePath(cwd); expect(resolved).not.toBeNull(); - expect(resolved).toContain("public/brand/logo.svg"); + expect(resolved).toBe(path.join(cwd, "public", "brand", "logo.svg")); }), ); diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts b/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts index 7862e67e..b113829f 100644 --- a/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts @@ -16,8 +16,6 @@ import { repositoryIdentityFromRemoteOutput, } from "./RepositoryIdentityResolver.ts"; -const normalizePathSeparators = (value: string) => value.replaceAll("\\", "/"); - const git = (cwd: string, args: ReadonlyArray) => Effect.gen(function* () { const processRunner = yield* ProcessRunner.ProcessRunner; @@ -53,15 +51,18 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { const resolver = yield* RepositoryIdentityResolver; const identity = yield* resolver.resolve(nestedWorkspace); - const resolvedIdentityRoot = - identity?.rootPath === undefined ? "" : yield* fileSystem.realPath(identity.rootPath); - const resolvedRepoRoot = yield* fileSystem.realPath(repoRoot); expect(identity).not.toBeNull(); expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); - expect(normalizePathSeparators(resolvedIdentityRoot)).toBe( - normalizePathSeparators(resolvedRepoRoot), - ); + const [identityInfo, repoInfo] = yield* Effect.all([ + fileSystem.stat(identity?.rootPath ?? ""), + fileSystem.stat(repoRoot), + ]); + // Windows can spell the same directory through both its short 8.3 name + // and its long name. Device/inode identity verifies the repository + // contract without conflating spelling with filesystem identity. + expect(identityInfo.dev).toBe(repoInfo.dev); + expect(identityInfo.ino).toEqual(repoInfo.ino); expect(identity?.displayName).toBe("t3tools/t3code"); expect(identity?.provider).toBe("github"); expect(identity?.owner).toBe("t3tools"); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 43075092..73b6482a 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -41,6 +41,8 @@ import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderAdapterValidationError } from "../Errors.ts"; import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; import { + claudeProjectDirectoryName, + encodeClaudeProjectDirectoryName, makeClaudeAdapter, resolveClaudeModelSessionOptions, type ClaudeAdapterLiveOptions, @@ -327,12 +329,22 @@ function promptMessageText(message: SDKUserMessage | undefined): string | undefi } function claudeProjectDirectoryForTest(homePath: string, cwd: string): string { - return path.join(homePath, ".claude", "projects", path.resolve(cwd).replaceAll(path.sep, "-")); + return path.join(homePath, ".claude", "projects", claudeProjectDirectoryName(path, cwd)); } const THREAD_ID = ThreadId.make("thread-claude-1"); const RESUME_THREAD_ID = ThreadId.make("thread-claude-resume"); +describe("Claude project directory encoding", () => { + it("matches upstream punctuation replacement and bounded long-path hashing", () => { + assert.equal( + encodeClaudeProjectDirectoryName(String.raw`C:\Users\mike\work.dir`), + "C--Users-mike-work-dir", + ); + assert.equal(encodeClaudeProjectDirectoryName("a".repeat(201)), `${"a".repeat(200)}-rkvsv5`); + }); +}); + describe("ClaudeAdapterLive", () => { it.effect("returns validation error for non-claude provider on startSession", () => { const harness = makeHarness(); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 87e77ee0..92e4462b 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -854,8 +854,38 @@ function isDurableClaudeResumeState( return Boolean(resumeState.resumeSessionAt) || (resumeState.turnCount ?? 0) > 0; } -function claudeProjectDirectoryName(path: Path.Path, cwd: string): string { - return path.resolve(cwd).replaceAll(path.sep, "-"); +const CLAUDE_PROJECT_DIRECTORY_PREFIX_LIMIT = 200; + +function claudeProjectDirectoryHash(value: string): string { + // Claude Code uses the conventional signed 32-bit JavaScript string hash + // here. Keep the bitwise truncation explicit so long-path transcript lookup + // remains byte-for-byte compatible with the upstream CLI on every host. + let hash = 0; + for (let index = 0; index < value.length; index += 1) { + hash = ((hash << 5) - hash + value.charCodeAt(index)) | 0; + } + return Math.abs(hash).toString(36); +} + +/** + * Encode an absolute cwd using Claude Code's project-directory algorithm. + * + * Agent SDK 0.3.215's `projectKey` implementation replaces every + * non-alphanumeric character, not only the host path separator, and bounds + * long names with a deterministic hash suffix. Besides matching upstream, + * replacing the Windows volume colon prevents an invalid `projects/C:...` + * child path from escaping the intended directory component. + */ +export function encodeClaudeProjectDirectoryName(resolvedCwd: string): string { + const encoded = resolvedCwd.replace(/[^a-zA-Z0-9]/g, "-"); + if (encoded.length <= CLAUDE_PROJECT_DIRECTORY_PREFIX_LIMIT) { + return encoded; + } + return `${encoded.slice(0, CLAUDE_PROJECT_DIRECTORY_PREFIX_LIMIT)}-${claudeProjectDirectoryHash(resolvedCwd)}`; +} + +export function claudeProjectDirectoryName(path: Pick, cwd: string): string { + return encodeClaudeProjectDirectoryName(path.resolve(cwd)); } function resolveClaudeConfigDirectory(path: Path.Path, env: NodeJS.ProcessEnv): string { diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index 5e6373aa..2ed9b622 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -34,6 +34,7 @@ import { } from "@cafecode/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ServerConfig } from "../../config.ts"; @@ -86,6 +87,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { it.live("boots two independent codex instances from a ProviderInstanceConfigMap", () => Effect.gen(function* () { + const path = yield* Path.Path; const personalId = ProviderInstanceId.make("codex_personal"); const workId = ProviderInstanceId.make("codex_work"); const codexDriverKind = ProviderDriverKind.make("codex"); @@ -143,14 +145,16 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { expect(personalSnapshot.driver).toBe(codexDriverKind); expect(personalSnapshot.enabled).toBe(false); expect(personalSnapshot.continuation?.groupKey).toBe( - "codex:home:/home/julius/.codex_personal", + `codex:home:${path.resolve("/home/julius/.codex_personal")}`, ); const workSnapshot = yield* work!.snapshot.getSnapshot; expect(workSnapshot.instanceId).toBe(workId); expect(workSnapshot.driver).toBe(codexDriverKind); expect(workSnapshot.enabled).toBe(false); - expect(workSnapshot.continuation?.groupKey).toBe("codex:home:/home/julius/.codex"); + expect(workSnapshot.continuation?.groupKey).toBe( + `codex:home:${path.resolve("/home/julius/.codex")}`, + ); // Nothing goes to the unavailable bucket — both drivers are registered. const unavailable = yield* registry.listUnavailable; @@ -213,6 +217,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { it.live("boots one instance of every shipped driver from a single config map", () => Effect.gen(function* () { + const path = yield* Path.Path; const codexId = ProviderInstanceId.make("codex_default"); const claudeId = ProviderInstanceId.make("claude_default"); @@ -283,13 +288,17 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { expect(codexSnapshot.instanceId).toBe(codexId); expect(codexSnapshot.driver).toBe(codexDriverKind); expect(codexSnapshot.enabled).toBe(false); - expect(codexSnapshot.continuation?.groupKey).toBe("codex:home:/home/julius/.codex"); + expect(codexSnapshot.continuation?.groupKey).toBe( + `codex:home:${path.resolve("/home/julius/.codex")}`, + ); const claudeSnapshot = yield* claude!.snapshot.getSnapshot; expect(claudeSnapshot.instanceId).toBe(claudeId); expect(claudeSnapshot.driver).toBe(claudeDriverKind); expect(claudeSnapshot.enabled).toBe(false); - expect(claudeSnapshot.continuation?.groupKey).toBe("claude:home:/home/julius/.claude-work"); + expect(claudeSnapshot.continuation?.groupKey).toBe( + `claude:home:${path.resolve("/home/julius/.claude-work")}`, + ); }).pipe(Effect.provide(testLayer)), ); }); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index bae26824..b07e7ae2 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1257,7 +1257,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T // but custom models are projected into error snapshots, so this // proves the aggregator no longer holds the initial snapshot. const refreshed = yield* Effect.gen(function* () { - for (let attempts = 0; attempts < 60; attempts += 1) { + for (let attempts = 0; attempts < 120; attempts += 1) { const providers = yield* registry.getProviders; const codex = providers.find((provider) => provider.instanceId === "codex"); if ( @@ -1268,6 +1268,15 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T } yield* TestClock.adjust("50 millis"); yield* Effect.yieldNow; + if (process.platform === "win32") { + // The probe intentionally uses the real process spawner to + // observe ENOENT. Advancing TestClock cannot advance libuv's + // Windows process callback, so give that callback a bounded + // slice of wall time under the fully parallel CI workload. + yield* Effect.promise( + () => new Promise((resolve) => setTimeout(resolve, 25)), + ); + } } return yield* registry.getProviders; }); @@ -1886,7 +1895,6 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T ); it.effect("runs Claude status probes with the configured Claude HOME", () => { - const claudeHome = "/tmp/t3code-claude-home"; const recorded = recordingMockSpawnerLayer((args) => { const joined = args.join(" "); if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; @@ -1900,6 +1908,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T }); return Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHome = path.resolve("/tmp/t3code-claude-home"); const status = yield* checkClaudeProviderStatus( { ...defaultClaudeSettings, diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts index 40a00a00..69c8952b 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -167,11 +167,13 @@ describe("providerMaintenance", () => { expect( packageToolUpdate.resolve({ - binaryPath: "package-tool", + // The resolver receives the already-resolved executable in + // production. Pass that host-native path directly so this Darwin + // policy test does not ask a Windows runner to interpret a + // Windows PATH with POSIX delimiter semantics. + binaryPath: packageToolPath, platform: "darwin", - env: { - PATH: vitePlusBinDir, - }, + env: { PATH: "" }, }), ).toEqual({ provider: driver("packageTool"), @@ -256,11 +258,9 @@ describe("providerMaintenance", () => { expect( scopedPackageToolUpdate.resolve({ - binaryPath: "scoped-package-tool", + binaryPath: scopedPackageToolPath, platform: "darwin", - env: { - PATH: pnpmHomeDir, - }, + env: { PATH: "" }, }), ).toEqual({ provider: driver("scopedPackageTool"), @@ -315,11 +315,9 @@ describe("providerMaintenance", () => { expect( nativePackageToolUpdate.resolve({ - binaryPath: "native-package-tool", + binaryPath: nativePackageToolPath, platform: "darwin", - env: { - PATH: nativeBinDir, - }, + env: { PATH: "" }, }), ).toEqual({ provider: driver("nativePackageTool"), @@ -350,11 +348,9 @@ describe("providerMaintenance", () => { expect( scopedPackageToolUpdate.resolve({ - binaryPath: "scoped-package-tool", + binaryPath: scopedPackageToolPath, platform: "darwin", - env: { - PATH: nativeBinDir, - }, + env: { PATH: "" }, }), ).toEqual({ provider: driver("scopedPackageTool"), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 1c48233b..43d3d4dd 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -3198,6 +3198,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { it.effect("routes websocket rpc subscribeServerConfig streams snapshot then update", () => Effect.gen(function* () { + const path = yield* Path.Path; const providers = [ { instanceId: ProviderInstanceId.make("codex"), @@ -3251,7 +3252,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.deepEqual(first.config.keybindings, []); assert.deepEqual(first.config.issues, []); assert.deepEqual(first.config.providers, providers); - assert.equal(first.config.observability.logsDirectoryPath.endsWith("/logs"), true); + assert.equal(path.basename(first.config.observability.logsDirectoryPath), "logs"); assert.equal(first.config.observability.localTracingEnabled, true); assert.equal(first.config.observability.otlpTracesUrl, "http://localhost:4318/v1/traces"); assert.equal(first.config.observability.otlpTracesEnabled, true); @@ -3459,13 +3460,15 @@ it.layer(NodeServices.layer)("server router seam", (it) => { it.effect("routes websocket rpc projects.searchEntries errors", () => Effect.gen(function* () { + const path = yield* Path.Path; + const missingWorkspace = path.resolve("/definitely/not/a/real/workspace/path"); yield* buildAppUnderTest(); const wsUrl = yield* getWsServerUrl("/ws"); const result = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => client[WS_METHODS.projectsSearchEntries]({ - cwd: "/definitely/not/a/real/workspace/path", + cwd: missingWorkspace, query: "needle", limit: 10, }), @@ -3474,10 +3477,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assertTrue(result._tag === "Failure"); assertTrue(result.failure._tag === "ProjectSearchEntriesError"); - assertInclude( - result.failure.message, - "Workspace root does not exist: /definitely/not/a/real/workspace/path", - ); + assertInclude(result.failure.message, `Workspace root does not exist: ${missingWorkspace}`); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 58a24de2..6c45931c 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -3,6 +3,7 @@ import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import { ChildProcessSpawner } from "effect/unstable/process"; import { type SourceControlProviderError } from "@cafecode/contracts"; @@ -106,10 +107,11 @@ it.effect("looks up repositories through the requested provider without search", it.effect("clones a looked-up repository into the requested destination", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const parent = yield* fs.makeTempDirectoryScoped({ prefix: "t3-source-control-clone-parent-", }); - const destinationPath = `${parent}/t3code`; + const destinationPath = path.join(parent, "t3code"); const cloneCalls: Array<{ cwd: string; args: ReadonlyArray }> = []; yield* Effect.gen(function* () { diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts index d53143e3..8022483e 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts @@ -27,9 +27,32 @@ function makeFakeClaudeBinary(dir: string, output: string) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const binDir = path.join(dir, "bin"); - const claudePath = path.join(binDir, "claude"); yield* fs.makeDirectory(binDir, { recursive: true }); + if (process.platform === "win32") { + // Windows cannot execute a shebang-only extensionless fixture. Exercise + // the same `.cmd` shim path used by npm-installed Claude Code while a + // Node helper handles stdin/stdout without depending on PowerShell. + const fixturePath = path.join(binDir, "claude-fixture.cjs"); + const claudePath = path.join(binDir, "claude.cmd"); + yield* fs.writeFileString( + fixturePath, + [ + '"use strict";', + "process.stdin.resume();", + `process.stdin.on("end", () => process.stdout.write(${JSON.stringify(output)}));`, + "", + ].join("\n"), + ); + yield* fs.writeFileString( + claudePath, + `@echo off\r\n"${process.execPath}" "%~dp0claude-fixture.cjs" %*\r\n`, + ); + return claudePath; + } + + const claudePath = path.join(binDir, "claude"); + yield* fs.writeFileString( claudePath, [ diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index 117ba6a2..672e654f 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -48,9 +48,53 @@ function makeFakeCodexBinary( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const binDir = path.join(dir, "bin"); - const codexPath = path.join(binDir, "codex"); yield* fs.makeDirectory(binDir, { recursive: true }); + if (process.platform === "win32") { + // npm exposes Codex through a `.cmd` shim on Windows. Build the real + // process smoke fixture in that shape, with a Node helper so argument, + // stdin, stderr, and output-file behavior is identical across shells. + const fixturePath = path.join(binDir, "codex-fixture.cjs"); + const codexPath = path.join(binDir, "codex.cmd"); + const fixtureConfig = Buffer.from(JSON.stringify(input), "utf8").toString("base64"); + yield* fs.writeFileString( + fixturePath, + [ + '"use strict";', + 'const fs = require("node:fs");', + `const config = JSON.parse(Buffer.from(${JSON.stringify(fixtureConfig)}, "base64").toString("utf8"));`, + "const args = process.argv.slice(2);", + 'const outputFlag = args.indexOf("--output-last-message");', + "const outputPath = outputFlag >= 0 ? args[outputFlag + 1] : undefined;", + 'const configValues = args.flatMap((value, index) => args[index - 1] === "--config" ? [value] : []);', + 'const reasoningEffort = configValues.find((value) => value.startsWith("model_reasoning_effort="));', + 'let stdin = "";', + 'process.stdin.setEncoding("utf8");', + 'process.stdin.on("data", (chunk) => { stdin += chunk; });', + 'process.stdin.on("end", () => {', + " const fail = (code, message) => { process.stderr.write(`${message}\\n`); process.exitCode = code; };", + ' if (config.requireImage && !args.includes("--image")) return fail(2, "missing --image input");', + ' if (config.requireFastServiceTier && !configValues.includes(\'service_tier="fast"\')) return fail(5, "missing fast service tier config");', + ' if (config.requireReasoningEffort !== undefined && reasoningEffort !== `model_reasoning_effort="${config.requireReasoningEffort}"`) return fail(6, `unexpected reasoning effort config: ${reasoningEffort ?? ""}`);', + " if (config.forbidReasoningEffort && reasoningEffort !== undefined) return fail(7, `reasoning effort config should be omitted: ${reasoningEffort}`);", + ' if (config.stdinMustContain !== undefined && !stdin.includes(config.stdinMustContain)) return fail(3, "stdin missing expected content");', + ' if (config.stdinMustNotContain !== undefined && stdin.includes(config.stdinMustNotContain)) return fail(4, "stdin contained forbidden content");', + " if (config.stderr !== undefined) process.stderr.write(`${config.stderr}\\n`);", + " if (outputPath) fs.writeFileSync(outputPath, config.output);", + " process.exitCode = config.exitCode ?? 0;", + "});", + "", + ].join("\n"), + ); + yield* fs.writeFileString( + codexPath, + `@echo off\r\n"${process.execPath}" "%~dp0codex-fixture.cjs" %*\r\n`, + ); + return codexPath; + } + + const codexPath = path.join(binDir, "codex"); + yield* fs.writeFileString( codexPath, [ diff --git a/apps/server/src/vcs/testing/VcsDriverContractHarness.ts b/apps/server/src/vcs/testing/VcsDriverContractHarness.ts index 6290cbb6..fcd02bb6 100644 --- a/apps/server/src/vcs/testing/VcsDriverContractHarness.ts +++ b/apps/server/src/vcs/testing/VcsDriverContractHarness.ts @@ -11,10 +11,6 @@ import * as Option from "effect/Option"; import type { VcsDriverKind } from "@cafecode/contracts"; import * as VcsDriver from "../VcsDriver.ts"; -function normalizePathForComparison(value: string): string { - return value.replaceAll("\\", "/"); -} - export interface VcsDriverFixture { readonly createRepo: (cwd: string) => Effect.Effect; readonly writeFile: ( @@ -65,17 +61,19 @@ export function runVcsDriverContractSuite(input: VcsDriverContractSuiteInp it.effect("detects repository identity inside a repository and nested directories", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); + const fileSystem = yield* FileSystem.FileSystem; const driver = yield* VcsDriver.VcsDriver; yield* input.fixture.createRepo(cwd); yield* input.fixture.writeFile(cwd, "src/index.ts", "export const value = 1;\n"); const identity = yield* driver.detectRepository(cwd); assert.equal(identity?.kind, input.kind); - assert.isTrue( - normalizePathForComparison(identity?.rootPath ?? "").endsWith( - normalizePathForComparison(cwd), - ), - ); + const [identityInfo, cwdInfo] = yield* Effect.all([ + fileSystem.stat(identity?.rootPath ?? ""), + fileSystem.stat(cwd), + ]); + assert.equal(identityInfo.dev, cwdInfo.dev); + assert.deepStrictEqual(identityInfo.ino, cwdInfo.ino); assert.equal(identity?.freshness.source, "live-local"); assert.isTrue(DateTime.isDateTime(identity?.freshness.observedAt)); assert.isTrue(Option.isNone(identity?.freshness.expiresAt ?? Option.none())); From 54830fed9c6a5b5059ea2c4ec00f950cf761b328 Mon Sep 17 00:00:00 2001 From: cafeai <116491182+cafeai@users.noreply.github.com> Date: Tue, 21 Jul 2026 19:08:49 +0900 Subject: [PATCH 06/13] Bound Windows CLI smoke helpers --- AGENTS.md | 2 +- .../ClaudeTextGeneration.test.ts | 14 +++++++++-- .../CodexTextGeneration.test.ts | 25 +++++++++++++++---- 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 530eab14..f9606614 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,7 @@ - Windows tests must not assume Developer Mode or administrator symlink privileges. If a test specifically validates symlink handling and Windows returns a symlink privilege `EPERM`, skip only that privilege-dependent assertion path while preserving the full symlink security test on macOS/Linux and on Windows machines where symlinks are available. - Windows file URL conversion must use `fileURLToPath` instead of `URL.pathname`, because raw pathname strings keep a leading slash before the drive letter. This is safe for macOS/Linux and prevents Windows-only false negatives when tests resolve repo-relative assets. - Windows test assertions that run through the host `Path.Path` service or Node path API must build expected paths through that same path implementation instead of hard-coded POSIX separators. Cross-platform launcher tests may select Linux behavior while executing on Windows, but their filesystem fixtures still need host-native paths because that is what the production launcher receives. Keep macOS/Linux behavior unchanged by deriving expected strings from the host path implementation already under test. -- Windows can report one directory through different casing, separators, long names, or 8.3 aliases. Git/worktree safety checks must compare stable filesystem identity when available instead of raw path spelling. Tests for simulated Linux/macOS command-selection policy must inject command availability rather than asking the Windows host filesystem to interpret a foreign `PATH`; real Windows provider-process smoke fixtures must use `.cmd` shims. Windows has no `/proc/self/fd` or `/dev/fd` duplication path, so a bootstrap descriptor handed to the direct stream becomes stream-owned and must not also be closed by the caller. POSIX FIFO timeout coverage stays POSIX-only. +- Windows can report one directory through different casing, separators, long names, or 8.3 aliases. Git/worktree safety checks must compare stable filesystem identity when available instead of raw path spelling. Tests for simulated Linux/macOS command-selection policy must inject command availability rather than asking the Windows host filesystem to interpret a foreign `PATH`; real Windows provider-process smoke fixtures must use `.cmd` shims. A helper launched through `cmd.exe` must not wait indefinitely for stdin EOF: verify prompt delivery from data, use a bounded deadline, and exit the helper explicitly so the shell cannot retain the pipeline. Windows has no `/proc/self/fd` or `/dev/fd` duplication path, so a bootstrap descriptor handed to the direct stream becomes stream-owned and must not also be closed by the caller. POSIX FIFO timeout coverage stays POSIX-only. - Windows cannot directly spawn Yarn's `.cmd` command shims through the shell-free Effect child-process API. Custom Oxlint fixture tests must invoke `node_modules/oxlint/bin/oxlint` through `process.execPath`; keep `shell: false` so temporary fixture paths are passed as structured arguments, and preserve the same JavaScript entrypoint path on macOS/Linux. - Windows subprocess and heavy dynamic-import tests can need longer per-test timeouts under full parallel `turbo` load. Apply longer timeouts only under `process.platform === "win32"` and preserve existing macOS/Linux timeouts. - Windows process-table diagnostics must spawn `powershell.exe` directly with `shell: false`; do not route the CIM pipeline through `cmd.exe` or any shell wrapper because `cmd.exe` can misparse PowerShell pipeline syntax before PowerShell receives it. Keep macOS/Linux on the existing `ps -axo pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command=` path. Windows CPU/perf lookup should remain batched once per diagnostics read instead of querying performance counters once per process row. The longer diagnostics timeout exists for Windows CIM latency; if future work needs a tighter POSIX timeout, split the timeout by platform rather than regressing Windows reliability. diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts index 8022483e..4d4b1b65 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts @@ -39,8 +39,18 @@ function makeFakeClaudeBinary(dir: string, output: string) { fixturePath, [ '"use strict";', - "process.stdin.resume();", - `process.stdin.on("end", () => process.stdout.write(${JSON.stringify(output)}));`, + "let settled = false;", + "const deadline = setTimeout(() => {", + ' process.stderr.write("timed out waiting for prompt stdin\\n", () => process.exit(2));', + "}, 2000);", + 'process.stdin.setEncoding("utf8");', + 'process.stdin.on("data", (chunk) => {', + " if (settled || chunk.length === 0) return;", + " settled = true;", + " clearTimeout(deadline);", + " process.stdin.pause();", + ` process.stdout.write(${JSON.stringify(output)}, () => process.exit(0));`, + "});", "", ].join("\n"), ); diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index 672e654f..3c539bc9 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -69,20 +69,35 @@ function makeFakeCodexBinary( 'const configValues = args.flatMap((value, index) => args[index - 1] === "--config" ? [value] : []);', 'const reasoningEffort = configValues.find((value) => value.startsWith("model_reasoning_effort="));', 'let stdin = "";', + "let settled = false;", + "let idleTimer;", + 'const deadline = setTimeout(() => finish(8, "timed out waiting for prompt stdin"), 2000);', 'process.stdin.setEncoding("utf8");', - 'process.stdin.on("data", (chunk) => { stdin += chunk; });', - 'process.stdin.on("end", () => {', - " const fail = (code, message) => { process.stderr.write(`${message}\\n`); process.exitCode = code; };", + "function finish(forcedCode, forcedMessage) {", + " if (settled) return;", + " settled = true;", + " clearTimeout(deadline);", + " if (idleTimer !== undefined) clearTimeout(idleTimer);", + " process.stdin.pause();", + " const fail = (code, message) => process.stderr.write(`${message}\\n`, () => process.exit(code));", + " if (forcedCode !== undefined) return fail(forcedCode, forcedMessage);", ' if (config.requireImage && !args.includes("--image")) return fail(2, "missing --image input");', ' if (config.requireFastServiceTier && !configValues.includes(\'service_tier="fast"\')) return fail(5, "missing fast service tier config");', ' if (config.requireReasoningEffort !== undefined && reasoningEffort !== `model_reasoning_effort="${config.requireReasoningEffort}"`) return fail(6, `unexpected reasoning effort config: ${reasoningEffort ?? ""}`);', " if (config.forbidReasoningEffort && reasoningEffort !== undefined) return fail(7, `reasoning effort config should be omitted: ${reasoningEffort}`);", ' if (config.stdinMustContain !== undefined && !stdin.includes(config.stdinMustContain)) return fail(3, "stdin missing expected content");', ' if (config.stdinMustNotContain !== undefined && stdin.includes(config.stdinMustNotContain)) return fail(4, "stdin contained forbidden content");', - " if (config.stderr !== undefined) process.stderr.write(`${config.stderr}\\n`);", " if (outputPath) fs.writeFileSync(outputPath, config.output);", - " process.exitCode = config.exitCode ?? 0;", + " const exitCode = config.exitCode ?? 0;", + " if (config.stderr !== undefined) return process.stderr.write(`${config.stderr}\\n`, () => process.exit(exitCode));", + " process.exit(exitCode);", + "}", + 'process.stdin.on("data", (chunk) => {', + " stdin += chunk;", + " if (idleTimer !== undefined) clearTimeout(idleTimer);", + " idleTimer = setTimeout(() => finish(), 50);", "});", + 'process.stdin.on("end", () => finish());', "", ].join("\n"), ); From c4b86f9738e6b9cec2559bccb506ce54d4f55235 Mon Sep 17 00:00:00 2001 From: cafeai <116491182+cafeai@users.noreply.github.com> Date: Tue, 21 Jul 2026 19:23:35 +0900 Subject: [PATCH 07/13] Fix Windows Git status cache paths --- AGENTS.md | 2 +- apps/server/src/cli/config.test.ts | 7 +++++-- apps/server/src/git/GitManager.ts | 24 +++++++++++++++--------- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f9606614..141f25c7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,7 @@ - Windows tests must not assume Developer Mode or administrator symlink privileges. If a test specifically validates symlink handling and Windows returns a symlink privilege `EPERM`, skip only that privilege-dependent assertion path while preserving the full symlink security test on macOS/Linux and on Windows machines where symlinks are available. - Windows file URL conversion must use `fileURLToPath` instead of `URL.pathname`, because raw pathname strings keep a leading slash before the drive letter. This is safe for macOS/Linux and prevents Windows-only false negatives when tests resolve repo-relative assets. - Windows test assertions that run through the host `Path.Path` service or Node path API must build expected paths through that same path implementation instead of hard-coded POSIX separators. Cross-platform launcher tests may select Linux behavior while executing on Windows, but their filesystem fixtures still need host-native paths because that is what the production launcher receives. Keep macOS/Linux behavior unchanged by deriving expected strings from the host path implementation already under test. -- Windows can report one directory through different casing, separators, long names, or 8.3 aliases. Git/worktree safety checks must compare stable filesystem identity when available instead of raw path spelling. Tests for simulated Linux/macOS command-selection policy must inject command availability rather than asking the Windows host filesystem to interpret a foreign `PATH`; real Windows provider-process smoke fixtures must use `.cmd` shims. A helper launched through `cmd.exe` must not wait indefinitely for stdin EOF: verify prompt delivery from data, use a bounded deadline, and exit the helper explicitly so the shell cannot retain the pipeline. Windows has no `/proc/self/fd` or `/dev/fd` duplication path, so a bootstrap descriptor handed to the direct stream becomes stream-owned and must not also be closed by the caller. POSIX FIFO timeout coverage stays POSIX-only. +- Windows can report one directory through different casing, separators, long names, or 8.3 aliases. Git/worktree safety checks must compare stable filesystem identity when available instead of raw path spelling, but opaque device/inode identities are comparison keys only and must never be passed to Git or Node as a working directory; status-cache loaders must retain a canonical usable path. Tests for simulated Linux/macOS command-selection policy must inject command availability rather than asking the Windows host filesystem to interpret a foreign `PATH`; real Windows provider-process smoke fixtures must use `.cmd` shims. A helper launched through `cmd.exe` must not wait indefinitely for stdin EOF: verify prompt delivery from data, use a bounded deadline, and exit the helper explicitly so the shell cannot retain the pipeline. Windows has no `/proc/self/fd` or `/dev/fd` duplication path, so a bootstrap descriptor handed to the direct stream becomes stream-owned and must not also be closed by the caller. POSIX FIFO timeout coverage stays POSIX-only. - Windows cannot directly spawn Yarn's `.cmd` command shims through the shell-free Effect child-process API. Custom Oxlint fixture tests must invoke `node_modules/oxlint/bin/oxlint` through `process.execPath`; keep `shell: false` so temporary fixture paths are passed as structured arguments, and preserve the same JavaScript entrypoint path on macOS/Linux. - Windows subprocess and heavy dynamic-import tests can need longer per-test timeouts under full parallel `turbo` load. Apply longer timeouts only under `process.platform === "win32"` and preserve existing macOS/Linux timeouts. - Windows process-table diagnostics must spawn `powershell.exe` directly with `shell: false`; do not route the CIM pipeline through `cmd.exe` or any shell wrapper because `cmd.exe` can misparse PowerShell pipeline syntax before PowerShell receives it. Keep macOS/Linux on the existing `ps -axo pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command=` path. Windows CPU/perf lookup should remain batched once per diagnostics read instead of querying performance counters once per process row. The longer diagnostics timeout exists for Windows CIM latency; if future work needs a tighter POSIX timeout, split the timeout by platform rather than regressing Windows reliability. diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index 629380cd..b25bb690 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -351,8 +351,11 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { it.effect("uses bootstrap envelope values as fallbacks when flags and env are absent", () => Effect.gen(function* () { - const { join } = yield* Path.Path; - const baseDir = "/tmp/t3-bootstrap-home"; + const { join, resolve } = yield* Path.Path; + // Bootstrap homes are resolved before they enter ServerConfig. Resolve + // the fixture through the host path service too, so `/tmp` acquires the + // current drive on Windows while remaining unchanged on POSIX. + const baseDir = resolve("/tmp/t3-bootstrap-home"); const fd = yield* openBootstrapFd( makeDesktopBootstrap({ port: 4888, diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index b7f6915d..d18edfb1 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -365,11 +365,12 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { ); const fileSystem = yield* FileSystem.FileSystem; - const canonicalizeExistingPath = (value: string) => + const resolveCanonicalExistingPath = (value: string) => + fileSystem.realPath(value).pipe(Effect.catch(() => Effect.succeed(value))); + + const resolveExistingPathIdentity = (value: string) => Effect.gen(function* () { - const canonicalPath = yield* fileSystem - .realPath(value) - .pipe(Effect.catch(() => Effect.succeed(value))); + const canonicalPath = yield* resolveCanonicalExistingPath(value); if (process.platform !== "win32") { return canonicalPath; @@ -393,7 +394,12 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { .toLowerCase(); return `win32-path:${normalizedPath}`; }); - const normalizeStatusCacheKey = canonicalizeExistingPath; + // Cache loaders receive their key as the cwd they query. Keep that key a + // valid filesystem path: the Windows identity above is intentionally only + // for equality checks and must never escape into a Git subprocess as cwd. + // realPath still coalesces ordinary aliases for cache purposes while + // preserving a path that Node and Git can open on every platform. + const normalizeStatusCacheKey = resolveCanonicalExistingPath; const nonRepositoryStatusDetails = { isRepo: false, hasOriginRemote: false, @@ -672,7 +678,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }; return yield* Effect.gen(function* () { const normalizedReference = normalizePullRequestReference(input.reference); - const rootWorktreePath = yield* canonicalizeExistingPath(input.cwd); + const rootWorktreePath = yield* resolveExistingPathIdentity(input.cwd); const pullRequestSummary = yield* (yield* sourceControlProvider(input.cwd)).getChangeRequest({ cwd: input.cwd, reference: normalizedReference, @@ -747,7 +753,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { continue; } - const worktreePath = yield* canonicalizeExistingPath(branch.worktreePath); + const worktreePath = yield* resolveExistingPathIdentity(branch.worktreePath); if (worktreePath !== rootWorktreePath) { return branch; } @@ -758,7 +764,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const existingBranchBeforeFetch = yield* findLocalHeadBranch(input.cwd); const existingBranchBeforeFetchPath = existingBranchBeforeFetch?.worktreePath - ? yield* canonicalizeExistingPath(existingBranchBeforeFetch.worktreePath) + ? yield* resolveExistingPathIdentity(existingBranchBeforeFetch.worktreePath) : null; if ( existingBranchBeforeFetch?.worktreePath && @@ -786,7 +792,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const existingBranchAfterFetch = yield* findLocalHeadBranch(input.cwd); const existingBranchAfterFetchPath = existingBranchAfterFetch?.worktreePath - ? yield* canonicalizeExistingPath(existingBranchAfterFetch.worktreePath) + ? yield* resolveExistingPathIdentity(existingBranchAfterFetch.worktreePath) : null; if ( existingBranchAfterFetch?.worktreePath && From 30a67505e4e129da5dc1337dfdf2807f585ef064 Mon Sep 17 00:00:00 2001 From: cafeai <116491182+cafeai@users.noreply.github.com> Date: Tue, 21 Jul 2026 20:25:03 +0900 Subject: [PATCH 08/13] Show usage breakdown and coalesce Codex refreshes --- AGENTS.md | 5 +- .../src/persistence/Services/UsageStats.ts | 14 +- .../src/provider/Drivers/CodexDriver.ts | 42 ++++- .../src/provider/Layers/CodexProvider.ts | 10 +- .../provider/Layers/ProviderRegistry.test.ts | 17 ++ .../src/provider/Layers/ProviderRegistry.ts | 20 ++ .../src/provider/Services/ProviderRegistry.ts | 10 + .../src/provider/Services/ServerProvider.ts | 6 + .../src/provider/builtInProviderCatalog.ts | 1 + .../makeManagedServerProvider.test.ts | 125 ++++++++++++- .../src/provider/makeManagedServerProvider.ts | 159 +++++++++++++++- .../providerMaintenanceRunner.test.ts | 1 + apps/server/src/server.test.ts | 75 ++++++++ .../Layers/UsageStatsService.test.ts | 13 +- .../usageStats/Layers/UsageStatsService.ts | 90 +++++++-- .../usageStats/Services/UsageStatsService.ts | 4 +- apps/server/src/ws.ts | 19 +- .../settings/UsageStatsPanel.browser.tsx | 137 ++++++++++++++ .../components/settings/UsageStatsPanel.tsx | 177 +++++++++++++++++- .../settings/usageStatsPresentation.test.ts | 60 ++++++ .../settings/usageStatsPresentation.ts | 109 +++++++++++ packages/contracts/src/usageStats.test.ts | 21 +++ packages/contracts/src/usageStats.ts | 37 +++- 23 files changed, 1090 insertions(+), 62 deletions(-) create mode 100644 apps/web/src/components/settings/UsageStatsPanel.browser.tsx create mode 100644 apps/web/src/components/settings/usageStatsPresentation.test.ts create mode 100644 apps/web/src/components/settings/usageStatsPresentation.ts create mode 100644 packages/contracts/src/usageStats.test.ts diff --git a/AGENTS.md b/AGENTS.md index 141f25c7..699819fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -282,7 +282,8 @@ Codex account usage behavior to preserve: - Upstream Codex app-server exposes `account/rateLimits/read` and emits `account/rateLimits/updated`; both are backed by `BackendClient::get_rate_limits_many()`, which reads ChatGPT-backed usage from `/backend-api/wham/usage` and Codex API-backed usage from `/api/codex/usage`. - Cafe Code provider snapshots may expose a redacted `accountRateLimits` summary for authenticated Codex ChatGPT accounts: primary/secondary used percentages, window durations, reset timestamps, plan metadata, and credit metadata only. Never expose access tokens, refresh tokens, account IDs, raw auth JSON, or raw usage payloads to the renderer. - The normal provider settings badge path intentionally avoids spawning hidden Codex app-server processes. When it needs rate-limit metadata before a session exists, it may perform the same authenticated ChatGPT usage request shape upstream uses, with the provider's effective shadow `CODEX_HOME` auth copy and bounded timeout. Active app-server sessions should still prefer upstream `account/rateLimits/read`/`updated` protocol data when available. -- Codex account-usage snapshots must stay fresh during long work sessions. Refresh Codex provider snapshots every five minutes, and after `thread.turn.start` or `thread.turn.steer` enqueue a non-blocking refresh for the selected Codex provider instance, throttled to at most once per minute per instance. These refreshes must not block prompt dispatch or expose raw usage/auth payloads. +- Codex account-usage snapshots must stay fresh during long work sessions. Run the full Codex provider status probe every five minutes and on an explicit user refresh, but coalesce every overlapping full refresh for one instance into a single flight so initial, periodic, and manual requests cannot queue repeated `codex --version` / `codex login status` subprocesses. After `thread.turn.start` or `thread.turn.steer`, enqueue a non-blocking usage-only refresh for the selected Codex provider instance, throttled to at most once per minute per instance; this path refreshes the effective shadow auth copy and performs only the bounded, redacted account-usage HTTP request. It must never fall back to a full binary status probe, block prompt dispatch, or expose raw usage/auth payloads. Coalesce overlapping usage-only requests too, serialize all snapshot mutations, and restart stale asynchronous snapshot enrichment before it can overwrite newer usage data. +- Renderer `subscribeServerConfig` connections are read boundaries. They must return the latest cached provider snapshots and subscribe to future changes without forcing provider refreshes; reconnect storms must never execute provider CLI health or login probes. Managed provider startup, periodic refresh, explicit refresh commands, and provider-instance rebuilds own probe scheduling. - Upstream Codex TUI's usage-limit reset flow treats reset availability as account-derived state from `rateLimitResetCredits`: it refreshes `account/rateLimits/read` before reset dialogs, after consuming a reset credit, and when cached zero availability may be stale. Upstream Codex `rust-v0.143.0` can include redacted reset-credit rows with ids, reset type, status, grant/expiry times, title, and description; Cafe may carry those rows through provider snapshots for UI accuracy, but must never expose auth tokens, account ids, or raw usage payloads. If a Codex provider refresh omits account usage after auth/account churn, clear the Codex usage snapshot rather than preserving stale percentages from a previous account; Claude's event-sourced usage-window preservation is a separate provider-specific path. Important local files: @@ -365,7 +366,7 @@ If Claude behavior is unclear, check the official Claude Agent SDK docs, the ins - Keep provider command ledgers idempotent. Mutating provider daemon RPCs must carry command IDs when replay or retry is possible. - Avoid long SQLite transactions around provider I/O, process startup, network calls, or stream consumption. - Do not load entire chat histories or event stores on every token/tool call. Use bounded queries, projections, cursors, and summary snapshots. -- Usage statistics keep headline daily totals in `usage_stats_days` and output-token attribution in `usage_stats_token_breakdown_days`, keyed by local day, canonical provider driver, and effective model. Attribution must never persist `providerInstanceId` or another configured account identifier: multiple accounts using the same driver intentionally aggregate together. Resolve a missing model at most once per turn from the live session, update it on `model.rerouted`, and use the bounded `unknown` bucket when no trustworthy model is available; never add provider-session queries to the per-token hot path. Aggregate and attribution deltas must commit in one SQLite transaction. Migration 61 begins attribution prospectively because older aggregate rows cannot be backfilled truthfully, and the existing Usage API/UI remains aggregate-only until an explicit display change is requested. +- Usage statistics keep headline daily totals in `usage_stats_days` and output-token attribution in `usage_stats_token_breakdown_days`, keyed by local day, canonical provider driver, and effective model. Attribution must never persist or display `providerInstanceId` or another configured account identifier: multiple accounts using the same driver intentionally aggregate together. Resolve a missing model at most once per turn from the live session, update it on `model.rerouted`, and use the bounded `unknown` bucket when no trustworthy model is available; never add provider-session queries to the per-token hot path. Aggregate and attribution deltas must commit in one SQLite transaction. Migration 61 begins attribution prospectively because older aggregate rows cannot be backfilled truthfully. Hydrate lifetime provider/model totals once at service startup, serve them from memory through `usageStats.get`, and keep the model-cardinality payload out of the 10 Hz `subscribeUsageStats` stream. The Usage page may refresh that in-memory detail at a low cadence while mounted and must show pre-migration aggregate tokens as earlier unattributed usage rather than assigning them speculatively. - Keep provider daemon event journals bounded in SQLite as well as in memory. The persistent `provider_daemon_events` table is a restart/replay buffer for daemon/supervisor handoff, not an analytics warehouse; retaining unbounded provider events causes post-prompt latency and stale projection pressure that the Codex CLI does not have. Large inherited journals must be pruned by non-blocking journal maintenance after IPC/TCP readiness, because one-time migration deletes can exceed the desktop provider-daemon readiness timeout and leave bootstrap without a socket. Restart replay from a detached supervisor must be idempotent on provider runtime `eventId`, not local cursor, because supervisor cursors and daemon cursors are separate ownership domains. - SQLite `database is locked` is a real lifecycle/performance signal. Add diagnostics for the writer, SQL operation, busy timeout, and affected command path instead of only increasing timeouts. - Migrations must be deterministic, backward-safe where possible, and covered by tests. Reconciliation migrations should explain the exact stale state they repair. diff --git a/apps/server/src/persistence/Services/UsageStats.ts b/apps/server/src/persistence/Services/UsageStats.ts index 0fd1792c..423429e4 100644 --- a/apps/server/src/persistence/Services/UsageStats.ts +++ b/apps/server/src/persistence/Services/UsageStats.ts @@ -13,8 +13,8 @@ import { NonNegativeInt, ProviderDriverKind, - TrimmedNonEmptyString, UsageStatsDayKey, + UsageStatsModel, } from "@cafecode/contracts"; import * as Schema from "effect/Schema"; import * as Context from "effect/Context"; @@ -30,14 +30,6 @@ export const UsageStatsDayRow = Schema.Struct({ }); export type UsageStatsDayRow = typeof UsageStatsDayRow.Type; -/** - * Provider model identifiers originate outside Cafe. Bound their persisted - * size so a hostile or malformed runtime event cannot create unbounded SQLite - * index keys. The service maps invalid/missing values to a short sentinel. - */ -export const UsageStatsModel = TrimmedNonEmptyString.check(Schema.isMaxLength(256)); -export type UsageStatsModel = typeof UsageStatsModel.Type; - export const UsageStatsTokenBreakdownDayRow = Schema.Struct({ day: UsageStatsDayKey, provider: ProviderDriverKind, @@ -60,8 +52,8 @@ export interface UsageStatsRepositoryShape { /** * List provider/model output-token attribution ascending by day and stable - * key order. This is deliberately not loaded by the aggregate usage service - * or exposed through the current Usage UI. + * key order. The usage service hydrates these rows once and aggregates them + * in memory; opening Settings must not query SQLite. */ readonly listTokenBreakdownDays: Effect.Effect< ReadonlyArray, diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 01508b0b..34c9e798 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -22,6 +22,7 @@ * @module provider/Drivers/CodexDriver */ import { CodexSettings, ProviderDriverKind, type ServerProvider } from "@cafecode/contracts"; +import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -35,7 +36,11 @@ import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneratio import { ServerConfig } from "../../config.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeCodexAdapter } from "../Layers/CodexAdapter.ts"; -import { checkCodexCliProviderStatus, makePendingCodexProvider } from "../Layers/CodexProvider.ts"; +import { + checkCodexCliProviderStatus, + makePendingCodexProvider, + readCodexAccountRateLimits, +} from "../Layers/CodexProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts"; @@ -55,10 +60,11 @@ import { const decodeCodexSettings = Schema.decodeSync(CodexSettings); const DRIVER_KIND = ProviderDriverKind.make("codex"); -// Keep account usage reasonably fresh without using the heavy app-server -// metadata path. The Codex status probe intentionally stays on the cheap -// `codex --version` / `codex login status` / redacted usage-request path, so -// this periodic refresh does not create hidden Codex app-server sessions. +// Periodically refresh installation/authentication truth without using the +// heavy app-server metadata path. Full refreshes are single-flight, while +// prompt-triggered usage updates below use only the redacted HTTP request, so +// neither path creates hidden Codex app-server sessions or repeated CLI probe +// queues. const PERIODIC_SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); const UPDATE_DEFINITION = { provider: DRIVER_KIND, @@ -253,6 +259,32 @@ export const CodexDriver: ProviderDriver = { initialSnapshot: (settings) => makePendingCodexProvider(settings).pipe(Effect.map(stampIdentity)), checkProvider, + // Prompt sends need fresh rate-limit metadata, not another pair of + // `codex --version` / `codex login status` subprocesses. Upstream + // Codex obtains this data from BackendClient's account usage request; + // use the same bounded, redacted HTTP path against the effective + // shadow home and leave full health/auth checks on the five-minute and + // explicit manual-refresh paths. + refreshAccountUsage: ({ settings, snapshot }) => { + if (snapshot.auth.status !== "authenticated" || snapshot.auth.type !== "chatgpt") { + return Effect.succeed(undefined); + } + return refreshCodexShadowHome.pipe( + Effect.catch((cause) => + Effect.logWarning("codex.home.authRefreshBeforeUsageFailed", { + instanceId, + detail: cause.message, + }), + ), + Effect.andThen(DateTime.now), + Effect.map(DateTime.formatIso), + Effect.flatMap((checkedAt) => + readCodexAccountRateLimits(settings, effectiveEnvironment, checkedAt), + ), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); + }, enrichSnapshot: ({ snapshot, publishSnapshot }) => enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe( Effect.provideService(HttpClient.HttpClient, httpClient), diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 7c6b0140..8743858b 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -654,7 +654,15 @@ async function fetchCodexAccountRateLimits(input: { } } -const readCodexAccountRateLimits = Effect.fn("readCodexAccountRateLimits")(function* ( +/** + * Read only the redacted ChatGPT account-usage snapshot used by Codex. + * + * This deliberately performs no Codex CLI or app-server process operation. + * Callers must continue to use the full provider-status path when they need + * installation, version, or authentication truth. Credentials stay inside + * this module and only the schema-bounded usage summary is returned. + */ +export const readCodexAccountRateLimits = Effect.fn("readCodexAccountRateLimits")(function* ( codexSettings: CodexSettings, environment: NodeJS.ProcessEnv, checkedAt: string, diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index b07e7ae2..da0b1784 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -899,6 +899,19 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T slashCommands: [], skills: [], } as const satisfies ServerProvider; + const usageRefreshedProvider = { + ...cachedProvider, + accountRateLimits: { + rateLimits: { + primary: { + usedPercent: 20, + windowDurationMins: 300, + resetsAt: 1_780_000_000, + }, + }, + checkedAt: "2026-04-29T10:01:00.000Z", + }, + } as const satisfies ServerProvider; const instance = { instanceId: codexInstanceId, driverKind: codexDriver, @@ -915,6 +928,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T }), getSnapshot: Effect.succeed(cachedProvider), refresh: Effect.die(new Error("simulated refresh failure")), + refreshAccountUsage: Effect.succeed(usageRefreshedProvider), streamChanges: Stream.empty, }, adapter: {} as ProviderInstance["adapter"], @@ -952,6 +966,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T assert.deepStrictEqual(yield* registry.refreshInstance(codexInstanceId), [ cachedProvider, ]); + assert.deepStrictEqual(yield* registry.refreshInstanceAccountUsage(codexInstanceId), [ + usageRefreshedProvider, + ]); }).pipe(Effect.provide(runtimeServices)); }), ); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index e76680e0..98d51c64 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -199,6 +199,7 @@ const buildSnapshotSource = (instance: ProviderInstance): ProviderSnapshotSource driverKind: instance.driverKind, getSnapshot: instance.snapshot.getSnapshot, refresh: instance.snapshot.refresh, + refreshAccountUsage: instance.snapshot.refreshAccountUsage, streamChanges: instance.snapshot.streamChanges, }); @@ -549,6 +550,23 @@ export const ProviderRegistryLive = Layer.effect( return yield* refreshOneSource(providerSource); }); + const refreshInstanceAccountUsage = Effect.fn("refreshInstanceAccountUsage")(function* ( + instanceId: ProviderInstanceId, + ) { + const sources = yield* getLiveSources; + const providerSource = sources.find((candidate) => candidate.instanceId === instanceId); + if (!providerSource?.refreshAccountUsage) { + return yield* Ref.get(providersRef); + } + return yield* providerSource.refreshAccountUsage.pipe( + Effect.flatMap((nextProvider) => + correlateSnapshotWithSource(providerSource, nextProvider).pipe( + Effect.flatMap(syncProvider), + ), + ), + ); + }); + const getProviderMaintenanceCapabilitiesForInstance = Effect.fn( "getProviderMaintenanceCapabilitiesForInstance", )(function* (instanceId: ProviderInstanceId, provider: ProviderDriverKind) { @@ -760,6 +778,8 @@ export const ProviderRegistryLive = Layer.effect( refresh(provider).pipe(Effect.catchCause(recoverRefreshFailure)), refreshInstance: (instanceId: ProviderInstanceId) => refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)), + refreshInstanceAccountUsage: (instanceId: ProviderInstanceId) => + refreshInstanceAccountUsage(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)), getProviderMaintenanceCapabilitiesForInstance, setProviderMaintenanceActionState, updateProviderAccountRateLimits, diff --git a/apps/server/src/provider/Services/ProviderRegistry.ts b/apps/server/src/provider/Services/ProviderRegistry.ts index 698b6c06..ec550c19 100644 --- a/apps/server/src/provider/Services/ProviderRegistry.ts +++ b/apps/server/src/provider/Services/ProviderRegistry.ts @@ -49,6 +49,16 @@ export interface ProviderRegistryShape { instanceId: ProviderInstanceId, ) => Effect.Effect>; + /** + * Refresh only account-usage metadata for one provider instance. This is a + * no-op for providers without a usage-only capability and intentionally + * never falls back to the provider's full binary health/authentication + * probe. + */ + readonly refreshInstanceAccountUsage: ( + instanceId: ProviderInstanceId, + ) => Effect.Effect>; + /** * Resolve the maintenance capabilities owned by one live provider instance. * Falls back to manual-only capabilities when the instance is not live. diff --git a/apps/server/src/provider/Services/ServerProvider.ts b/apps/server/src/provider/Services/ServerProvider.ts index aa404ef9..27b7bc8b 100644 --- a/apps/server/src/provider/Services/ServerProvider.ts +++ b/apps/server/src/provider/Services/ServerProvider.ts @@ -7,5 +7,11 @@ export interface ServerProviderShape { readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; readonly getSnapshot: Effect.Effect; readonly refresh: Effect.Effect; + /** + * Refresh account-scoped usage metadata without executing the provider's + * binary health/authentication probes. Providers that do not expose a + * bounded usage-only path omit this capability. + */ + readonly refreshAccountUsage?: Effect.Effect; readonly streamChanges: Stream.Stream; } diff --git a/apps/server/src/provider/builtInProviderCatalog.ts b/apps/server/src/provider/builtInProviderCatalog.ts index 5531ee5e..4373c4c0 100644 --- a/apps/server/src/provider/builtInProviderCatalog.ts +++ b/apps/server/src/provider/builtInProviderCatalog.ts @@ -13,5 +13,6 @@ export type ProviderSnapshotSource = { readonly driverKind: ProviderDriverKind; readonly getSnapshot: ServerProviderShape["getSnapshot"]; readonly refresh: ServerProviderShape["refresh"]; + readonly refreshAccountUsage: ServerProviderShape["refreshAccountUsage"]; readonly streamChanges: Stream.Stream; }; diff --git a/apps/server/src/provider/makeManagedServerProvider.test.ts b/apps/server/src/provider/makeManagedServerProvider.test.ts index dfd30e8c..9f88876d 100644 --- a/apps/server/src/provider/makeManagedServerProvider.test.ts +++ b/apps/server/src/provider/makeManagedServerProvider.test.ts @@ -88,6 +88,22 @@ const refreshedSnapshotSecond: ServerProvider = { message: "Refreshed provider availability again.", }; +const refreshedAccountRateLimits = { + rateLimits: { + primary: { + usedPercent: 35, + windowDurationMins: 300, + resetsAt: 1_780_000_000, + }, + secondary: { + usedPercent: 60, + windowDurationMins: 10_080, + resetsAt: 1_780_100_000, + }, + }, + checkedAt: "2026-04-10T00:00:05.000Z", +} as const; + const enrichedSnapshotSecond: ServerProvider = { ...refreshedSnapshotSecond, checkedAt: "2026-04-10T00:00:04.000Z", @@ -291,6 +307,7 @@ describe("makeManagedServerProvider", () => { Effect.scoped( Effect.gen(function* () { const checkCalls = yield* Ref.make(0); + const initialCheckStarted = yield* Deferred.make(); const releaseInitialCheck = yield* Deferred.make(); const provider = yield* makeManagedServerProvider({ maintenanceCapabilities, @@ -299,6 +316,7 @@ describe("makeManagedServerProvider", () => { haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, initialSnapshot: () => Effect.succeed(initialSnapshot), checkProvider: Ref.updateAndGet(checkCalls, (count) => count + 1).pipe( + Effect.tap(() => Deferred.succeed(initialCheckStarted, undefined).pipe(Effect.ignore)), Effect.flatMap((count) => count === 1 ? Deferred.await(releaseInitialCheck).pipe(Effect.as(refreshedSnapshot)) @@ -308,7 +326,7 @@ describe("makeManagedServerProvider", () => { refreshInterval: null, }); - yield* Effect.yieldNow; + yield* Deferred.await(initialCheckStarted); assert.strictEqual(yield* Ref.get(checkCalls), 1); const initialUpdateFiber = yield* Stream.take(provider.streamChanges, 1).pipe( @@ -330,4 +348,109 @@ describe("makeManagedServerProvider", () => { }).pipe(Effect.provide(TestClock.layer())), ), ); + + it.effect("coalesces overlapping full refreshes into one provider check", () => + Effect.scoped( + Effect.gen(function* () { + const checkCalls = yield* Ref.make(0); + const checkStarted = yield* Deferred.make(); + const releaseCheck = yield* Deferred.make(); + const provider = yield* makeManagedServerProvider({ + maintenanceCapabilities, + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, + initialSnapshot: () => Effect.succeed(initialSnapshot), + checkProvider: Ref.update(checkCalls, (count) => count + 1).pipe( + Effect.tap(() => Deferred.succeed(checkStarted, undefined).pipe(Effect.ignore)), + Effect.flatMap(() => Deferred.await(releaseCheck)), + Effect.as(refreshedSnapshot), + ), + refreshInterval: "1 hour", + }); + + yield* Deferred.await(checkStarted); + const refreshes = yield* Effect.all([provider.refresh, provider.refresh], { + concurrency: "unbounded", + }).pipe(Effect.forkChild); + yield* Effect.yieldNow; + + // The initial background probe owns the single flight. Both manual + // callers wait on it instead of queueing two more CLI checks behind it. + assert.strictEqual(yield* Ref.get(checkCalls), 1); + yield* Deferred.succeed(releaseCheck, undefined); + + assert.deepStrictEqual(yield* Fiber.join(refreshes), [ + refreshedSnapshot, + refreshedSnapshot, + ]); + assert.strictEqual(yield* Ref.get(checkCalls), 1); + }), + ), + ); + + it.effect("coalesces usage-only refreshes without rerunning the full provider check", () => + Effect.scoped( + Effect.gen(function* () { + const checkCalls = yield* Ref.make(0); + const releaseInitialCheck = yield* Deferred.make(); + const usageCalls = yield* Ref.make(0); + const usageStarted = yield* Deferred.make(); + const releaseUsage = yield* Deferred.make(); + const provider = yield* makeManagedServerProvider({ + maintenanceCapabilities, + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, + initialSnapshot: () => Effect.succeed(initialSnapshot), + checkProvider: Ref.update(checkCalls, (count) => count + 1).pipe( + Effect.flatMap(() => Deferred.await(releaseInitialCheck)), + Effect.as(refreshedSnapshot), + ), + refreshAccountUsage: () => + Ref.update(usageCalls, (count) => count + 1).pipe( + Effect.tap(() => Deferred.succeed(usageStarted, undefined).pipe(Effect.ignore)), + Effect.flatMap(() => Deferred.await(releaseUsage)), + Effect.as(refreshedAccountRateLimits), + ), + refreshInterval: "1 hour", + }); + + const initialUpdate = yield* Stream.take(provider.streamChanges, 1).pipe( + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.yieldNow; + yield* Deferred.succeed(releaseInitialCheck, undefined); + yield* Fiber.join(initialUpdate); + + const refreshAccountUsage = provider.refreshAccountUsage; + assert.isDefined(refreshAccountUsage); + if (!refreshAccountUsage) { + return; + } + + const usageRefreshes = yield* Effect.all([refreshAccountUsage, refreshAccountUsage], { + concurrency: "unbounded", + }).pipe(Effect.forkChild); + yield* Deferred.await(usageStarted); + + assert.strictEqual(yield* Ref.get(checkCalls), 1); + assert.strictEqual(yield* Ref.get(usageCalls), 1); + yield* Deferred.succeed(releaseUsage, undefined); + + const refreshed = yield* Fiber.join(usageRefreshes); + assert.deepStrictEqual(refreshed, [ + { ...refreshedSnapshot, accountRateLimits: refreshedAccountRateLimits }, + { ...refreshedSnapshot, accountRateLimits: refreshedAccountRateLimits }, + ]); + assert.strictEqual((yield* provider.getSnapshot).version, refreshedSnapshot.version); + assert.deepStrictEqual( + (yield* provider.getSnapshot).accountRateLimits, + refreshedAccountRateLimits, + ); + assert.strictEqual(yield* Ref.get(checkCalls), 1); + }), + ), + ); }); diff --git a/apps/server/src/provider/makeManagedServerProvider.ts b/apps/server/src/provider/makeManagedServerProvider.ts index 7f6d4213..f926d261 100644 --- a/apps/server/src/provider/makeManagedServerProvider.ts +++ b/apps/server/src/provider/makeManagedServerProvider.ts @@ -1,4 +1,5 @@ -import type { ServerProvider } from "@cafecode/contracts"; +import type { ServerProvider, ServerProviderAccountRateLimits } from "@cafecode/contracts"; +import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; @@ -17,6 +18,71 @@ interface ProviderSnapshotState { readonly enrichmentGeneration: number; } +interface SingleFlight { + readonly current: Effect.Effect | null>; + readonly run: (operation: Effect.Effect) => Effect.Effect; +} + +type SingleFlightAdmission = + | { readonly deferred: Deferred.Deferred; readonly leader: true } + | { readonly deferred: Deferred.Deferred; readonly leader: false }; + +/** + * Share one provider probe among every caller that arrives while that probe is + * running. A semaphore alone is insufficient here: it serializes duplicate + * work, which means an initial refresh, a periodic refresh, and a manual + * refresh can all execute back-to-back after one slow CLI invocation. + * + * The worker is forked into the managed provider's owning scope. Callers may + * therefore stop waiting without interrupting the shared probe for all other + * callers. The admission transition and worker fork are uninterruptible so an + * interrupt cannot leave an uncompleted Deferred installed in `inFlightRef`; + * the provider operation itself remains interruptible and is always converted + * to an Exit that completes every waiter. + */ +const makeSingleFlight = (scope: Scope.Scope): Effect.Effect> => + Effect.gen(function* () { + const inFlightRef = yield* Ref.make | null>(null); + + const run = (operation: Effect.Effect): Effect.Effect => + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const candidate = yield* Deferred.make(); + const admission = yield* Ref.modify< + Deferred.Deferred | null, + SingleFlightAdmission + >( + inFlightRef, + (current): readonly [SingleFlightAdmission, Deferred.Deferred] => { + if (current !== null) { + return [{ deferred: current, leader: false }, current]; + } + return [{ deferred: candidate, leader: true }, candidate]; + }, + ); + + if (!admission.leader) { + return yield* restore(Deferred.await(admission.deferred)); + } + + yield* Effect.exit(Effect.interruptible(operation)).pipe( + Effect.flatMap((exit) => Deferred.done(candidate, exit)), + Effect.ensuring( + Ref.update(inFlightRef, (current) => (current === candidate ? null : current)), + ), + Effect.forkIn(scope), + ); + + return yield* restore(Deferred.await(candidate)); + }), + ); + + return { + current: Ref.get(inFlightRef), + run, + }; + }); + export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(function* < Settings, >(input: { @@ -26,6 +92,10 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( readonly haveSettingsChanged: (previous: Settings, next: Settings) => boolean; readonly initialSnapshot: (settings: Settings) => Effect.Effect; readonly checkProvider: Effect.Effect; + readonly refreshAccountUsage?: (input: { + readonly settings: Settings; + readonly snapshot: ServerProvider; + }) => Effect.Effect; readonly enrichSnapshot?: (input: { readonly settings: Settings; readonly snapshot: ServerProvider; @@ -34,7 +104,10 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( }) => Effect.Effect; readonly refreshInterval?: Duration.Input | null; }): Effect.fn.Return { - const refreshSemaphore = yield* Semaphore.make(1); + // Full probes, settings changes, and usage-only updates all mutate the same + // snapshot. Keep those writes serialized even though duplicate calls of the + // same operation are coalesced independently below. + const snapshotMutationSemaphore = yield* Semaphore.make(1); const changesPubSub = yield* Effect.acquireRelease( PubSub.unbounded(), PubSub.shutdown, @@ -48,6 +121,12 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( const settingsRef = yield* Ref.make(initialSettings); const enrichmentFiberRef = yield* Ref.make | null>(null); const scope = yield* Effect.scope; + const fullRefreshSingleFlight = yield* makeSingleFlight( + scope, + ); + const accountUsageSingleFlight = yield* makeSingleFlight( + scope, + ); const publishEnrichedSnapshot = Effect.fn("publishEnrichedSnapshot")(function* ( generation: number, @@ -127,11 +206,68 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( return nextSnapshot; }); const applySnapshot = (nextSettings: Settings, options?: { readonly forceRefresh?: boolean }) => - refreshSemaphore.withPermits(1)(applySnapshotBase(nextSettings, options)); + snapshotMutationSemaphore.withPermits(1)(applySnapshotBase(nextSettings, options)); const refreshSnapshot = Effect.fn("refreshSnapshot")(function* () { - const nextSettings = yield* input.getSettings; - return yield* applySnapshot(nextSettings, { forceRefresh: true }); + return yield* fullRefreshSingleFlight.run( + input.getSettings.pipe( + Effect.flatMap((nextSettings) => applySnapshot(nextSettings, { forceRefresh: true })), + ), + ); + }); + + const applyAccountUsageBase = Effect.fn("applyAccountUsage")(function* () { + if (!input.refreshAccountUsage) { + return yield* Ref.get(snapshotStateRef).pipe(Effect.map((state) => state.snapshot)); + } + + const settings = yield* input.getSettings; + const currentState = yield* Ref.get(snapshotStateRef); + const accountRateLimits = yield* input.refreshAccountUsage({ + settings, + snapshot: currentState.snapshot, + }); + // A transient usage endpoint failure must not erase a known-good usage + // snapshot. Full provider health refreshes remain authoritative for + // clearing account-bound data after logout or account replacement. + if (accountRateLimits === undefined) { + return currentState.snapshot; + } + + const nextSnapshot: ServerProvider = { + ...currentState.snapshot, + accountRateLimits, + }; + if (Equal.equals(currentState.snapshot, nextSnapshot)) { + return currentState.snapshot; + } + + // Usage can land while asynchronous version enrichment is still working + // from an older base snapshot. Advance the generation and restart that + // enrichment so its eventual full-snapshot publish cannot overwrite the + // newer account usage. + const nextGeneration = input.enrichSnapshot + ? currentState.enrichmentGeneration + 1 + : currentState.enrichmentGeneration; + yield* Ref.set(snapshotStateRef, { + snapshot: nextSnapshot, + enrichmentGeneration: nextGeneration, + }); + yield* PubSub.publish(changesPubSub, nextSnapshot); + yield* restartSnapshotEnrichment(settings, nextSnapshot, nextGeneration); + return nextSnapshot; + }); + + const refreshAccountUsageSnapshot = Effect.fn("refreshAccountUsageSnapshot")(function* () { + // A full status refresh includes account usage. If one is already active, + // share its result instead of issuing a second authenticated HTTP request. + const activeFullRefresh = yield* fullRefreshSingleFlight.current; + if (activeFullRefresh !== null) { + return yield* Deferred.await(activeFullRefresh); + } + return yield* accountUsageSingleFlight.run( + snapshotMutationSemaphore.withPermits(1)(applyAccountUsageBase()), + ); }); yield* Stream.runForEach(input.streamSettings, (nextSettings) => @@ -147,10 +283,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( ).pipe(Effect.forkScoped); } - yield* applySnapshot(initialSettings, { forceRefresh: true }).pipe( - Effect.ignoreCause({ log: true }), - Effect.forkScoped, - ); + yield* refreshSnapshot().pipe(Effect.ignoreCause({ log: true }), Effect.forkScoped); return { maintenanceCapabilities: input.maintenanceCapabilities, @@ -160,6 +293,14 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( Effect.orDie, ), refresh: refreshSnapshot().pipe(Effect.tapError(Effect.logError), Effect.orDie), + ...(input.refreshAccountUsage + ? { + refreshAccountUsage: refreshAccountUsageSnapshot().pipe( + Effect.tapError(Effect.logError), + Effect.orDie, + ), + } + : {}), get streamChanges() { return Stream.fromPubSub(changesPubSub); }, diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 8e4077bc..072d3e5b 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -168,6 +168,7 @@ function makeRegistry( getProviders: Ref.get(providersRef), refresh: () => Ref.get(providersRef), refreshInstance: () => Ref.get(providersRef), + refreshInstanceAccountUsage: () => Ref.get(providersRef), getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => Effect.succeed(lifecycleFor(provider)), setProviderMaintenanceActionState, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 43d3d4dd..2e81572c 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -44,6 +44,7 @@ import * as Layer from "effect/Layer"; import * as ManagedRuntime from "effect/ManagedRuntime"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/unstable/process"; import { @@ -648,6 +649,7 @@ const buildAppUnderTest = (options?: { getProviders: Effect.succeed([]), refresh: () => Effect.succeed([]), refreshInstance: () => Effect.succeed([]), + refreshInstanceAccountUsage: () => Effect.succeed([]), getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => Effect.succeed( makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null }), @@ -718,6 +720,7 @@ const buildAppUnderTest = (options?: { collectionEnabled: true, asOfMs: 0, days: [], + tokenBreakdown: [], }), snapshot: Effect.succeed({ totals: { generatingMs: 0, outputTokens: 0, userMessages: 0 }, @@ -3199,6 +3202,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { it.effect("routes websocket rpc subscribeServerConfig streams snapshot then update", () => Effect.gen(function* () { const path = yield* Path.Path; + const providerRefreshCalls = yield* Ref.make(0); const providers = [ { instanceId: ProviderInstanceId.make("codex"), @@ -3234,6 +3238,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }, providerRegistry: { getProviders: Effect.succeed(providers), + refresh: () => + Ref.update(providerRefreshCalls, (count) => count + 1).pipe(Effect.as(providers)), }, }, }); @@ -3265,6 +3271,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { type: "keybindingsUpdated", payload: { keybindings: [], issues: [] }, }); + yield* Effect.yieldNow; + assert.equal(yield* Ref.get(providerRefreshCalls), 0); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -4086,6 +4094,73 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect( + "refreshes Codex account usage without a full provider probe after prompt dispatch", + () => + Effect.gen(function* () { + const usageRefresh = yield* Deferred.make(); + const fullRefreshCalls = yield* Ref.make(0); + const provider = { + instanceId: defaultModelSelection.instanceId, + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "0.145.0", + status: "ready" as const, + auth: { status: "authenticated" as const, type: "chatgpt" as const }, + checkedAt: "2026-01-01T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + }; + + yield* buildAppUnderTest({ + layers: { + providerRegistry: { + getProviders: Effect.succeed([provider]), + refreshInstance: () => + Ref.update(fullRefreshCalls, (count) => count + 1).pipe(Effect.as([provider])), + refreshInstanceAccountUsage: (instanceId) => + Deferred.succeed(usageRefresh, instanceId).pipe( + Effect.ignore, + Effect.as([provider]), + ), + }, + orchestrationEngine: { + dispatch: () => Effect.succeed({ sequence: 8 }), + readEvents: () => Stream.empty, + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const createdAt = "2026-01-01T00:00:00.000Z"; + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-prompt-usage-refresh"), + threadId: defaultThreadId, + message: { + messageId: MessageId.make("msg-prompt-usage-refresh"), + role: "user", + text: "hello", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + createdAt, + }), + ), + ); + + assert.equal(result.sequence, 8); + assert.equal(yield* Deferred.await(usageRefresh), defaultModelSelection.instanceId); + assert.equal(yield* Ref.get(fullRefreshCalls), 0); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("filters thread subscription events already covered by the snapshot", () => Effect.gen(function* () { const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/usageStats/Layers/UsageStatsService.test.ts b/apps/server/src/usageStats/Layers/UsageStatsService.test.ts index 8485a00a..a89c27c0 100644 --- a/apps/server/src/usageStats/Layers/UsageStatsService.test.ts +++ b/apps/server/src/usageStats/Layers/UsageStatsService.test.ts @@ -354,7 +354,7 @@ describe("UsageStatsService", () => { yield* harness.emitProvider(tokenUsageEvent(THREAD_1, "p5", { outputTokens: 150 }, CODEX)); yield* harness.service.flush; - assert.deepEqual(yield* harness.repository.listTokenBreakdownDays, [ + const expectedRows = [ { day: "1970-01-01", provider: CLAUDE, @@ -373,7 +373,16 @@ describe("UsageStatsService", () => { model: "gpt-5.6-codex-mini", outputTokens: 50, }, - ]); + ]; + assert.deepEqual(yield* harness.repository.listTokenBreakdownDays, expectedRows); + + const expectedLifetimeBreakdown = expectedRows.map(({ day: _day, ...row }) => row); + assert.deepEqual((yield* harness.service.get).tokenBreakdown, expectedLifetimeBreakdown); + + // A fresh process must reconstruct the same lifetime view from the + // daily ledger without a Settings-page SQL query. + const rebuiltService = yield* harness.rebuildService; + assert.deepEqual((yield* rebuiltService.get).tokenBreakdown, expectedLifetimeBreakdown); }), ), ); diff --git a/apps/server/src/usageStats/Layers/UsageStatsService.ts b/apps/server/src/usageStats/Layers/UsageStatsService.ts index cbe32250..839e3873 100644 --- a/apps/server/src/usageStats/Layers/UsageStatsService.ts +++ b/apps/server/src/usageStats/Layers/UsageStatsService.ts @@ -1,9 +1,10 @@ /** * UsageStatsServiceLive - In-memory usage accumulator with periodic SQLite flush. * - * Building the layer hydrates lifetime counters from `usage_stats_days`, forks - * consumers of the domain-event and provider-runtime streams, and forks a - * flush loop that accrues in-flight generating time and persists pending + * Building the layer hydrates lifetime counters from `usage_stats_days` and + * provider/model token attribution from `usage_stats_token_breakdown_days`, + * forks consumers of the domain-event and provider-runtime streams, and forks + * a flush loop that accrues in-flight generating time and persists pending * per-day deltas every few seconds. A finalizer performs one last * accrue-and-flush on shutdown, so a clean stop loses nothing and a hard kill * loses at most one flush interval. @@ -22,10 +23,12 @@ * and accrual cursors always advance, so toggling collection partitions time * and tokens cleanly instead of retroactively counting the disabled period. */ -import type { - OrchestrationEvent, - ProviderDriverKind, - ProviderRuntimeEvent, +import { + USAGE_STATS_MODEL_MAX_CHARS, + type OrchestrationEvent, + type ProviderDriverKind, + type ProviderRuntimeEvent, + type UsageStatsTokenBreakdownEntry, } from "@cafecode/contracts"; import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; @@ -43,7 +46,6 @@ import { UsageStatsService, type UsageStatsServiceShape } from "../Services/Usag const FLUSH_INTERVAL_MS = 5_000; const MODEL_RESOLUTION_TIMEOUT_MS = 1_000; -const MAX_USAGE_MODEL_CHARS = 256; const UNKNOWN_USAGE_MODEL = "unknown"; interface MutableDayTotals { @@ -76,6 +78,7 @@ interface ThreadTracking { } type PendingTokenBreakdowns = Map>>; +type TokenBreakdownTotals = Map>; /** * Best-effort output-token extraction from an opaque `turn.completed` usage @@ -96,7 +99,7 @@ function normalizeUsageModel(value: unknown): string | undefined { return undefined; } const normalized = value.trim(); - return normalized.length > 0 && normalized.length <= MAX_USAGE_MODEL_CHARS + return normalized.length > 0 && normalized.length <= USAGE_STATS_MODEL_MAX_CHARS ? normalized : undefined; } @@ -131,10 +134,60 @@ const makeUsageStatsService = Effect.gen(function* () { const days = new Map(); const pending = new Map(); const pendingTokenBreakdowns: PendingTokenBreakdowns = new Map(); + const tokenBreakdownTotals: TokenBreakdownTotals = new Map(); const threads = new Map(); const totals: MutableDayTotals = { generatingMs: 0, outputTokens: 0, userMessages: 0 }; + let tokenBreakdownSnapshot: ReadonlyArray = []; + let tokenBreakdownSnapshotDirty = true; let enabled = true; + const addTokenBreakdownTotal = ( + provider: ProviderDriverKind, + model: string, + outputTokens: number, + ): void => { + if (outputTokens <= 0) { + return; + } + let models = tokenBreakdownTotals.get(provider); + if (models === undefined) { + models = new Map(); + tokenBreakdownTotals.set(provider, models); + } + models.set(model, (models.get(model) ?? 0) + outputTokens); + tokenBreakdownSnapshotDirty = true; + }; + + /** + * Materialize the RPC rows only after attribution changes. Usage snapshots + * are read frequently, while model attribution changes only when a provider + * reports additional output tokens. + */ + const readTokenBreakdown = (): ReadonlyArray => { + if (!tokenBreakdownSnapshotDirty) { + return tokenBreakdownSnapshot; + } + tokenBreakdownSnapshot = Array.from(tokenBreakdownTotals.entries()) + .flatMap(([provider, models]) => + Array.from(models.entries(), ([model, outputTokens]) => ({ + provider, + model, + outputTokens, + })), + ) + .toSorted((left, right) => { + if (left.provider !== right.provider) { + return left.provider < right.provider ? -1 : 1; + } + if (left.outputTokens !== right.outputTokens) { + return right.outputTokens - left.outputTokens; + } + return left.model < right.model ? -1 : left.model > right.model ? 1 : 0; + }); + tokenBreakdownSnapshotDirty = false; + return tokenBreakdownSnapshot; + }; + yield* repository.listDays.pipe( Effect.map((rows) => { for (const row of rows) { @@ -155,6 +208,19 @@ const makeUsageStatsService = Effect.gen(function* () { ), ); + yield* repository.listTokenBreakdownDays.pipe( + Effect.map((rows) => { + for (const row of rows) { + addTokenBreakdownTotal(row.provider, row.model, row.outputTokens); + } + }), + // Aggregate usage remains useful if only the attribution ledger is + // damaged. Keep this failure isolated and let current-session rows accrue. + Effect.catch((error) => + Effect.logError("usage stats: failed to hydrate token breakdown", { error }), + ), + ); + enabled = yield* serverSettings.getSettings.pipe( Effect.map((settings) => settings.usageStatsEnabled), Effect.catch(() => Effect.succeed(true)), @@ -197,6 +263,9 @@ const makeUsageStatsService = Effect.gen(function* () { } addDelta(day, { outputTokens }); + const modelKey = model ?? UNKNOWN_USAGE_MODEL; + addTokenBreakdownTotal(provider, modelKey, outputTokens); + let providers = pendingTokenBreakdowns.get(day); if (providers === undefined) { providers = new Map(); @@ -207,7 +276,6 @@ const makeUsageStatsService = Effect.gen(function* () { models = new Map(); providers.set(provider, models); } - const modelKey = model ?? UNKNOWN_USAGE_MODEL; models.set(modelKey, (models.get(modelKey) ?? 0) + outputTokens); }; @@ -555,7 +623,7 @@ const makeUsageStatsService = Effect.gen(function* () { (left, right) => (left.day < right.day ? -1 : 1), ) : dayRows; - return { ...state, days: withLiveToday }; + return { ...state, days: withLiveToday, tokenBreakdown: readTokenBreakdown() }; }); yield* Effect.forkScoped( diff --git a/apps/server/src/usageStats/Services/UsageStatsService.ts b/apps/server/src/usageStats/Services/UsageStatsService.ts index 87aced8f..8707ddd5 100644 --- a/apps/server/src/usageStats/Services/UsageStatsService.ts +++ b/apps/server/src/usageStats/Services/UsageStatsService.ts @@ -13,8 +13,8 @@ import type * as Effect from "effect/Effect"; export interface UsageStatsServiceShape { /** - * Lifetime totals plus the full per-day history for the activity heatmap. - * Served from memory. + * Lifetime totals, full per-day history for the activity heatmap, and + * provider/model output-token attribution. Served entirely from memory. */ readonly get: Effect.Effect; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index b2067177..8ce840d7 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -565,8 +565,11 @@ const makeWsRpcLayer = ( const nowMs = DateTime.toEpochMillis(yield* DateTime.now); const shouldRefresh = yield* Ref.modify(codexPromptUsageRefreshAtRef, (previous) => { - const previousRefreshAt = previous.get(instanceId) ?? 0; - if (nowMs - previousRefreshAt < CODEX_PROMPT_USAGE_REFRESH_THROTTLE_MS) { + const previousRefreshAt = previous.get(instanceId); + if ( + previousRefreshAt !== undefined && + nowMs - previousRefreshAt < CODEX_PROMPT_USAGE_REFRESH_THROTTLE_MS + ) { return [false, previous] as const; } const next = new Map(previous); @@ -580,7 +583,7 @@ const makeWsRpcLayer = ( // Provider account usage is display metadata, never part of the // prompt-send critical path. Refresh it in the background and let the // normal provider snapshot stream update the renderer when it lands. - yield* providerRegistry.refreshInstance(instanceId).pipe( + yield* providerRegistry.refreshInstanceAccountUsage(instanceId).pipe( Effect.catchCause((cause) => Effect.logWarning("codex prompt usage refresh failed", { commandType: command.type, @@ -1318,10 +1321,12 @@ const makeWsRpcLayer = ( })), ); - yield* providerRegistry - .refresh() - .pipe(Effect.ignoreCause({ log: true }), Effect.forkScoped); - + // A renderer subscription is a read boundary, not a provider + // maintenance command. Managed snapshots already perform an + // initial probe and periodic refresh; forcing every provider + // here made reconnect storms repeatedly execute CLI version and + // login checks. The initial event below reads the latest cached + // snapshots, then `providerStatuses` carries future changes. const liveUpdates = Stream.merge( keybindingsUpdates, Stream.merge( diff --git a/apps/web/src/components/settings/UsageStatsPanel.browser.tsx b/apps/web/src/components/settings/UsageStatsPanel.browser.tsx new file mode 100644 index 00000000..8fa9e0d0 --- /dev/null +++ b/apps/web/src/components/settings/UsageStatsPanel.browser.tsx @@ -0,0 +1,137 @@ +import "../../index.css"; + +import { page } from "vitest/browser"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; + +import { UsageStatsPanel } from "./UsageStatsPanel"; + +const usageHarness = vi.hoisted(() => { + let detail: unknown; + let snapshot: unknown; + const updateSettings = vi.fn(); + const getUsageStats = vi.fn(async () => detail); + const subscribeUsageStats = vi.fn((nextListener: (event: unknown) => void) => { + nextListener(snapshot); + return () => undefined; + }); + + return { + updateSettings, + getUsageStats, + subscribeUsageStats, + reset(nextDetail: unknown, nextSnapshot: unknown) { + detail = nextDetail; + snapshot = nextSnapshot; + updateSettings.mockReset(); + getUsageStats.mockClear(); + subscribeUsageStats.mockClear(); + }, + }; +}); + +vi.mock("../../environments/runtime", () => ({ + getPrimaryEnvironmentConnection: () => ({ + client: { + server: { + getUsageStats: usageHarness.getUsageStats, + subscribeUsageStats: usageHarness.subscribeUsageStats, + }, + }, + }), +})); + +vi.mock("../../hooks/useSettings", () => ({ + useSettings: () => ({ usageStatsEnabled: true }), + useUpdateSettings: () => ({ updateSettings: usageHarness.updateSettings }), +})); + +const totals = { + generatingMs: 3_661_000, + outputTokens: 250_000, + userMessages: 42, +}; + +const snapshot = { + totals, + today: { + day: "2026-07-21", + generatingMs: 61_000, + outputTokens: 25_000, + userMessages: 4, + }, + activeSessionCount: 0, + collectionEnabled: true, + asOfMs: Date.now(), +}; + +describe("UsageStatsPanel", () => { + let mounted: + | (Awaited> & { + cleanup?: () => Promise; + unmount?: () => Promise; + }) + | null = null; + + beforeEach(() => { + usageHarness.reset( + { + ...snapshot, + days: [snapshot.today], + tokenBreakdown: [ + { provider: "codex", model: "gpt-5.6-codex", outputTokens: 100_000 }, + { provider: "codex", model: "gpt-5.6-codex-mini", outputTokens: 25_000 }, + { provider: "claudeAgent", model: "claude-opus-5", outputTokens: 75_000 }, + ], + }, + snapshot, + ); + }); + + afterEach(async () => { + const teardown = mounted?.cleanup ?? mounted?.unmount; + await teardown?.call(mounted).catch(() => {}); + mounted = null; + document.body.innerHTML = ""; + }); + + it("renders stored provider and model token attribution with earlier usage separated", async () => { + mounted = await render(); + + await expect.element(page.getByText("Tokens by provider and model")).toBeVisible(); + await expect.element(page.getByText("200,000 attributed")).toBeVisible(); + await expect.element(page.getByText("Codex", { exact: true })).toBeVisible(); + await expect.element(page.getByText("Claude", { exact: true })).toBeVisible(); + await expect.element(page.getByText("gpt-5.6-codex", { exact: true })).toBeVisible(); + await expect.element(page.getByText("gpt-5.6-codex-mini", { exact: true })).toBeVisible(); + await expect.element(page.getByText("claude-opus-5", { exact: true })).toBeVisible(); + await expect.element(page.getByText("Earlier usage")).toBeVisible(); + await expect + .element(page.getByText("Recorded before provider and model attribution")) + .toBeVisible(); + expect(usageHarness.getUsageStats).toHaveBeenCalledTimes(1); + expect(usageHarness.subscribeUsageStats).toHaveBeenCalledTimes(1); + }); + + it("renders a quiet empty state before attributed tokens exist", async () => { + usageHarness.reset( + { + ...snapshot, + totals: { ...totals, outputTokens: 0 }, + days: [], + tokenBreakdown: [], + }, + { ...snapshot, totals: { ...totals, outputTokens: 0 } }, + ); + + mounted = await render(); + + await expect + .element( + page.getByText( + "Provider and model attribution will appear after output tokens are recorded.", + ), + ) + .toBeVisible(); + }); +}); diff --git a/apps/web/src/components/settings/UsageStatsPanel.tsx b/apps/web/src/components/settings/UsageStatsPanel.tsx index aa97f884..cefab572 100644 --- a/apps/web/src/components/settings/UsageStatsPanel.tsx +++ b/apps/web/src/components/settings/UsageStatsPanel.tsx @@ -1,15 +1,23 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import type { UsageStatsGetResult, UsageStatsSnapshot } from "@cafecode/contracts"; import { getPrimaryEnvironmentConnection } from "~/environments/runtime"; import { useSettings, useUpdateSettings } from "../../hooks/useSettings"; import { ActivityHeatmap } from "../stats/ActivityHeatmap"; import { useCountUp } from "../stats/useCountUp"; +import { PROVIDER_ICON_BY_PROVIDER } from "../chat/providerIconUtils"; import { Skeleton } from "../ui/skeleton"; import { Switch } from "../ui/switch"; import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; +import { + buildUsageTokenBreakdownView, + formatUsageModelLabel, + formatUsagePercentage, + formatUsageProviderLabel, +} from "./usageStatsPresentation"; const integerFormat = new Intl.NumberFormat("en-US"); +const DETAIL_REFRESH_INTERVAL_MS = 5_000; const pad = (value: number) => String(value).padStart(2, "0"); @@ -84,6 +92,132 @@ function StatTile({ label, value }: { label: string; value: string }) { ); } +function TokenBreakdownSection({ + usage, + lifetimeOutputTokens, +}: { + usage: UsageStatsGetResult["tokenBreakdown"]; + lifetimeOutputTokens: number; +}) { + const breakdown = useMemo( + () => buildUsageTokenBreakdownView(usage, lifetimeOutputTokens), + [lifetimeOutputTokens, usage], + ); + const percentageTotal = Math.max(lifetimeOutputTokens, breakdown.attributedOutputTokens); + const hasRows = breakdown.providers.length > 0 || breakdown.unattributedOutputTokens > 0; + + return ( + 0 ? ( + + {integerFormat.format(breakdown.attributedOutputTokens)} attributed + + ) : null + } + > + {hasRows ? ( +
+ {breakdown.providers.map((providerUsage) => { + const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[providerUsage.provider]; + const providerPercentage = formatUsagePercentage( + providerUsage.outputTokens, + percentageTotal, + ); + const providerBarWidth = + percentageTotal > 0 + ? Math.min(100, (providerUsage.outputTokens / percentageTotal) * 100) + : 0; + + return ( +
+
+ + {ProviderIcon ? : null} + +
+
+
+ + {formatUsageProviderLabel(providerUsage.provider)} + + + {providerPercentage} + +
+ + {integerFormat.format(providerUsage.outputTokens)} + +
+
+
+
+
+
+ +
+ {providerUsage.models.map((modelUsage) => ( +
+ + {formatUsageModelLabel(modelUsage.model)} + +
+ + {formatUsagePercentage( + modelUsage.outputTokens, + providerUsage.outputTokens, + )} + + + {integerFormat.format(modelUsage.outputTokens)} + +
+
+ ))} +
+
+ ); + })} + + {breakdown.unattributedOutputTokens > 0 ? ( +
+
+
Earlier usage
+
+ Recorded before provider and model attribution +
+
+
+
+ {integerFormat.format(breakdown.unattributedOutputTokens)} +
+
+ {formatUsagePercentage(breakdown.unattributedOutputTokens, percentageTotal)} +
+
+
+ ) : null} +
+ ) : ( +

+ Provider and model attribution will appear after output tokens are recorded. +

+ )} + + ); +} + export function UsageStatsPanel() { const settings = useSettings(); const { updateSettings } = useUpdateSettings(); @@ -93,20 +227,39 @@ export function UsageStatsPanel() { useEffect(() => { let cancelled = false; + let detailRequestInFlight = false; + let detailLoaded = false; const connection = getPrimaryEnvironmentConnection(); - connection.client.server - .getUsageStats() - .then((result) => { + const loadDetail = async () => { + if (detailRequestInFlight) { + return; + } + detailRequestInFlight = true; + try { + const result = await connection.client.server.getUsageStats(); if (!cancelled) { + detailLoaded = true; + setLoadError(false); setInitial(result); - setSnapshot(result); + // The subscription owns the live clock once it arrives. A detail + // refresh should initialize, but never rewind, that high-rate state. + setSnapshot((current) => current ?? result); } - }) - .catch(() => { - if (!cancelled) { + } catch { + if (!cancelled && !detailLoaded) { setLoadError(true); } - }); + } finally { + detailRequestInFlight = false; + } + }; + + void loadDetail(); + // Detail responses are in-memory and model-cardinality bounded, but they + // stay off the 10 Hz snapshot stream. Refresh only while this page exists. + const detailRefreshId = window.setInterval(() => { + void loadDetail(); + }, DETAIL_REFRESH_INTERVAL_MS); const unsubscribe = connection.client.server.subscribeUsageStats((event) => { if (!cancelled) { setSnapshot(event); @@ -114,6 +267,7 @@ export function UsageStatsPanel() { }); return () => { cancelled = true; + window.clearInterval(detailRefreshId); unsubscribe(); }; }, []); @@ -172,6 +326,11 @@ export function UsageStatsPanel() { )} + +
{initial ? ( diff --git a/apps/web/src/components/settings/usageStatsPresentation.test.ts b/apps/web/src/components/settings/usageStatsPresentation.test.ts new file mode 100644 index 00000000..0d577a7e --- /dev/null +++ b/apps/web/src/components/settings/usageStatsPresentation.test.ts @@ -0,0 +1,60 @@ +import { ProviderDriverKind } from "@cafecode/contracts"; +import { describe, expect, it } from "vitest"; + +import { + buildUsageTokenBreakdownView, + formatUsageModelLabel, + formatUsagePercentage, + formatUsageProviderLabel, +} from "./usageStatsPresentation"; + +const CODEX = ProviderDriverKind.make("codex"); +const CLAUDE = ProviderDriverKind.make("claudeAgent"); + +describe("usageStatsPresentation", () => { + it("groups duplicate rows and sorts providers and models by generated tokens", () => { + expect( + buildUsageTokenBreakdownView( + [ + { provider: CODEX, model: "gpt-small", outputTokens: 20 }, + { provider: CLAUDE, model: "claude-opus", outputTokens: 75 }, + { provider: CODEX, model: "gpt-large", outputTokens: 40 }, + { provider: CODEX, model: "gpt-small", outputTokens: 10 }, + { provider: CLAUDE, model: "unused", outputTokens: 0 }, + ], + 200, + ), + ).toEqual({ + providers: [ + { + provider: CLAUDE, + outputTokens: 75, + models: [{ model: "claude-opus", outputTokens: 75 }], + }, + { + provider: CODEX, + outputTokens: 70, + models: [ + { model: "gpt-large", outputTokens: 40 }, + { model: "gpt-small", outputTokens: 30 }, + ], + }, + ], + attributedOutputTokens: 145, + unattributedOutputTokens: 55, + }); + }); + + it("formats known providers, unknown models, and compact percentages", () => { + expect(formatUsageProviderLabel(CODEX)).toBe("Codex"); + expect(formatUsageProviderLabel(CLAUDE)).toBe("Claude"); + expect(formatUsageProviderLabel(ProviderDriverKind.make("custom_driver"))).toBe( + "Custom Driver", + ); + expect(formatUsageModelLabel("unknown")).toBe("Unknown model"); + expect(formatUsageModelLabel("gpt-5.6-codex")).toBe("gpt-5.6-codex"); + expect(formatUsagePercentage(1, 2_000)).toBe("<0.1%"); + expect(formatUsagePercentage(5, 100)).toBe("5.0%"); + expect(formatUsagePercentage(1, 0)).toBe("0%"); + }); +}); diff --git a/apps/web/src/components/settings/usageStatsPresentation.ts b/apps/web/src/components/settings/usageStatsPresentation.ts new file mode 100644 index 00000000..21b87eff --- /dev/null +++ b/apps/web/src/components/settings/usageStatsPresentation.ts @@ -0,0 +1,109 @@ +import type { ProviderDriverKind, UsageStatsTokenBreakdownEntry } from "@cafecode/contracts"; + +export interface UsageModelBreakdownView { + readonly model: string; + readonly outputTokens: number; +} + +export interface UsageProviderBreakdownView { + readonly provider: ProviderDriverKind; + readonly outputTokens: number; + readonly models: ReadonlyArray; +} + +export interface UsageTokenBreakdownView { + readonly providers: ReadonlyArray; + readonly attributedOutputTokens: number; + readonly unattributedOutputTokens: number; +} + +const compareText = (left: string, right: string): number => + left < right ? -1 : left > right ? 1 : 0; + +/** + * Collapse defensive duplicate rows and prepare a deterministic dense view. + * The server normally returns one row per provider/model, but merging here + * keeps stale or mixed-version servers from rendering duplicated model lines. + */ +export function buildUsageTokenBreakdownView( + rows: ReadonlyArray, + lifetimeOutputTokens: number, +): UsageTokenBreakdownView { + const byProvider = new Map>(); + + for (const row of rows) { + if (row.outputTokens <= 0) { + continue; + } + let models = byProvider.get(row.provider); + if (models === undefined) { + models = new Map(); + byProvider.set(row.provider, models); + } + models.set(row.model, (models.get(row.model) ?? 0) + row.outputTokens); + } + + const providers = Array.from(byProvider.entries(), ([provider, models]) => { + const modelRows = Array.from(models.entries(), ([model, outputTokens]) => ({ + model, + outputTokens, + })).toSorted( + (left, right) => + right.outputTokens - left.outputTokens || compareText(left.model, right.model), + ); + return { + provider, + outputTokens: modelRows.reduce((sum, row) => sum + row.outputTokens, 0), + models: modelRows, + }; + }).toSorted( + (left, right) => + right.outputTokens - left.outputTokens || compareText(left.provider, right.provider), + ); + + const attributedOutputTokens = providers.reduce( + (sum, provider) => sum + provider.outputTokens, + 0, + ); + + return { + providers, + attributedOutputTokens, + // Migration 61 intentionally did not guess provider/model attribution for + // older aggregate rows. Surface that honest remainder instead of silently + // making the visible provider totals appear to equal lifetime usage. + unattributedOutputTokens: Math.max(0, lifetimeOutputTokens - attributedOutputTokens), + }; +} + +export function formatUsageProviderLabel(provider: ProviderDriverKind): string { + switch (provider) { + case "codex": + return "Codex"; + case "claudeAgent": + return "Claude"; + case "opencode": + return "OpenCode"; + default: + return provider + .replace(/([a-z])([A-Z])/g, "$1 $2") + .replace(/[_-]+/g, " ") + .trim() + .replace(/\b\w/g, (character) => character.toUpperCase()); + } +} + +export function formatUsageModelLabel(model: string): string { + return model === "unknown" ? "Unknown model" : model; +} + +export function formatUsagePercentage(part: number, whole: number): string { + if (part <= 0 || whole <= 0) { + return "0%"; + } + const percentage = Math.min(100, (part / whole) * 100); + if (percentage < 0.1) { + return "<0.1%"; + } + return percentage < 10 ? `${percentage.toFixed(1)}%` : `${Math.round(percentage)}%`; +} diff --git a/packages/contracts/src/usageStats.test.ts b/packages/contracts/src/usageStats.test.ts new file mode 100644 index 00000000..94e55822 --- /dev/null +++ b/packages/contracts/src/usageStats.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import * as Schema from "effect/Schema"; + +import { UsageStatsGetResult } from "./usageStats.ts"; + +const decodeUsageStatsGetResult = Schema.decodeUnknownSync(UsageStatsGetResult); + +describe("UsageStatsGetResult", () => { + it("decodes legacy aggregate-only responses with an empty token breakdown", () => { + const decoded = decodeUsageStatsGetResult({ + totals: { generatingMs: 10, outputTokens: 20, userMessages: 1 }, + today: { day: "2026-07-21", generatingMs: 10, outputTokens: 20, userMessages: 1 }, + activeSessionCount: 0, + collectionEnabled: true, + asOfMs: 100, + days: [], + }); + + expect(decoded.tokenBreakdown).toEqual([]); + }); +}); diff --git a/packages/contracts/src/usageStats.ts b/packages/contracts/src/usageStats.ts index 9ffc88bd..6512aefb 100644 --- a/packages/contracts/src/usageStats.ts +++ b/packages/contracts/src/usageStats.ts @@ -1,5 +1,9 @@ +import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import { NonNegativeInt } from "./baseSchemas.ts"; +import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { ProviderDriverKind } from "./providerInstance.ts"; + +export const USAGE_STATS_MODEL_MAX_CHARS = 256; /** Local-date key, `YYYY-MM-DD` in the server's timezone. */ export const UsageStatsDayKey = Schema.String.check(Schema.isPattern(/^\d{4}-\d{2}-\d{2}$/)); @@ -21,7 +25,25 @@ export const UsageStatsTotals = Schema.Struct({ export type UsageStatsTotals = typeof UsageStatsTotals.Type; /** - * Live totals pushed to subscribers roughly once per second. `totals` + * Effective provider model attached to output-token observations. Provider + * runtimes control this value, so the shared contract enforces the same bound + * as the SQLite composite key before data can cross an RPC boundary. + */ +export const UsageStatsModel = TrimmedNonEmptyString.check( + Schema.isMaxLength(USAGE_STATS_MODEL_MAX_CHARS), +); +export type UsageStatsModel = typeof UsageStatsModel.Type; + +/** Lifetime output-token attribution, intentionally aggregated across accounts. */ +export const UsageStatsTokenBreakdownEntry = Schema.Struct({ + provider: ProviderDriverKind, + model: UsageStatsModel, + outputTokens: NonNegativeInt, +}); +export type UsageStatsTokenBreakdownEntry = typeof UsageStatsTokenBreakdownEntry.Type; + +/** + * Live totals pushed to subscribers at a high cadence. `totals` * includes time accrued by in-flight turns up to `asOfMs`; clients * extrapolate between pushes as `activeSessionCount` ms per elapsed ms * (three concurrently generating sessions advance the clock 3x). @@ -39,5 +61,16 @@ export const UsageStatsGetResult = Schema.Struct({ ...UsageStatsSnapshot.fields, /** Every recorded day, ascending; days with no activity have no entry. */ days: Schema.Array(UsageStatsDay), + /** + * Lifetime provider/model token totals, sorted by provider then descending + * token count. Kept off the high-frequency snapshot stream so historical + * model cardinality cannot inflate the live counter hot path. + */ + tokenBreakdown: Schema.Array(UsageStatsTokenBreakdownEntry).pipe( + // Saved remote environments can run an older Cafe server during a + // staggered upgrade. Treat the absent additive field as an empty ledger + // instead of making the entire Usage page fail schema decoding. + Schema.withDecodingDefault(Effect.succeed([])), + ), }); export type UsageStatsGetResult = typeof UsageStatsGetResult.Type; From 3b2538b203f1abc85c1829261b7277a352c6fe50 Mon Sep 17 00:00:00 2001 From: cafeai <116491182+cafeai@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:19:16 +0900 Subject: [PATCH 09/13] fix(claude): queue follow-ups without interrupting turns --- AGENTS.md | 10 +- apps/server/package.json | 2 +- .../src/provider/Layers/ClaudeAdapter.test.ts | 289 +++++++++++++++++- .../src/provider/Layers/ClaudeAdapter.ts | 247 ++++++++++++--- .../provider/Layers/ProviderService.test.ts | 41 ++- .../src/provider/Layers/ProviderService.ts | 7 +- packaging/desktop-runtime/package.json | 2 +- scripts/package.json | 2 +- yarn.lock | 78 ++--- 9 files changed, 584 insertions(+), 94 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 699819fb..657e9579 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -306,7 +306,7 @@ Codex home rules: ## Claude Integration -Cafe Code's Claude adapter uses `@anthropic-ai/claude-agent-sdk`, currently pinned to v0.3.215 to match Claude Code v2.1.215. +Cafe Code's Claude adapter uses `@anthropic-ai/claude-agent-sdk`, currently pinned to v0.3.216 to match Claude Code v2.1.216. Claude lifecycle facts to preserve: @@ -314,12 +314,14 @@ Claude lifecycle facts to preserve: - `query()` can take an `AsyncIterable` prompt, which Cafe Code uses as a prompt queue for long-lived sessions. - Agent SDK 0.3.212 requires a host wrapping keyboard input to stamp `SDKUserMessage.origin = { kind: "human" }` explicitly. Cafe must apply that provenance only to actual user-submitted prompt/steer messages; never infer or overwrite `peer`, `channel`, or `task-notification` origins on provider-emitted messages. Missing origin is intentionally unattributed and fails closed at upstream human-trust gates. - Agent SDK 0.3.211 fixes streamed user-message replay ordering when partial messages are enabled and includes complete CLI stderr in process-exit errors. It also adds optional assistant timestamps and `resumed_from_incomplete_thinking`; assistant timestamps use the originating host clock and are display-only, so Cafe must retain provider receive order as lifecycle truth and must not sort or terminalize events by that timestamp. The resumed-thinking flag is SDK normalization metadata, not a new assistant item or turn boundary. The new rate-limit prefix exports remain alpha classification helpers rather than a durable protocol, and `BashToolOutput.timedOutAfterMs` is tool-result metadata rather than thread context-window usage. -- Official Claude Agent SDK streaming input mode is the supported interactive path for queued messages: the docs describe dynamic message queueing, real-time interruption, and natural multi-turn conversations for `AsyncIterable` prompts, while the installed SDK types document `streamInput()` as the multi-turn input pipe. Cafe therefore advertises Claude live steer support by validating Cafe's expected active turn id locally and queueing the follow-up into the active SDK prompt stream. Claude does not expose a Codex-style expected-turn RPC, so never claim upstream has acknowledged a specific turn beyond Cafe's local active-turn binding. +- Official Claude Agent SDK streaming input mode is the supported interactive path for queued messages: the Claude Code changelog documents Enter as queueing additional messages while Claude works, and the SDK's `AsyncIterable`/`streamInput()` path writes those messages into the live command queue without calling `interrupt()`. Cafe therefore advertises Claude live steer support by validating Cafe's expected active turn id locally and queueing the follow-up into the active SDK prompt stream. Claude does not expose a Codex-style expected-turn RPC, so never claim upstream has acknowledged a specific turn beyond Cafe's local active-turn binding. An explicit Cafe stop remains the only path that calls `Query.interrupt()`. - The returned `Query` supports operations such as `interrupt()`, `setModel()`, `setPermissionMode()`, and `setMaxThinkingTokens(maxThinkingTokens, thinkingDisplay?)`. -- Claude Code 2.1.205+ advertises `interrupt_receipt_v1`; 2.1.206+ also advertises `msg_lifecycle_v1` and emits top-level `command_lifecycle` frames with `queued`, `started`, `completed`, `cancelled`, or `discarded` state. Agent SDK 0.3.212 parses the interrupt receipt but still omits `command_lifecycle` and the implemented `cancelAsyncMessage()` method from its public TypeScript `Query` surface. Cafe must UUID-stamp every streamed user input, decode these runtime frames through a narrow forward-compatible guard, keep normal lifecycle frames out of unhandled-message warnings, and use terminal lifecycle states to retire local delivery tracking. On interrupt, cancel only Cafe-owned UUIDs returned in `still_queued` or still locally known as submitted/queued; upstream receipts can include internal cron/auto-resume UUIDs, which Cafe must ignore. If cancellation cannot be confirmed, retire the Claude process before accepting more input so durable Cafe replay cannot race an unconfirmed provider copy. Never log prompt content in these diagnostics. +- Claude Code 2.1.205+ advertises `interrupt_receipt_v1`; 2.1.206+ also advertises `msg_lifecycle_v1` and emits top-level `command_lifecycle` frames with `queued`, `started`, `completed`, `cancelled`, or `discarded` state. Agent SDK 0.3.216 parses the interrupt receipt but still omits `command_lifecycle` and the implemented `cancelAsyncMessage()` method from its public TypeScript `Query` surface. Cafe must UUID-stamp every streamed user input, decode these runtime frames through a narrow forward-compatible guard, keep normal lifecycle frames out of unhandled-message warnings, and use terminal lifecycle states to retire local delivery tracking. On interrupt, cancel only Cafe-owned UUIDs returned in `still_queued` or still locally known as submitted/queued; upstream receipts can include internal cron/auto-resume UUIDs, which Cafe must ignore. If cancellation cannot be confirmed, retire the Claude process before accepting more input so durable Cafe replay cannot race an unconfirmed provider copy. Never log prompt content in these diagnostics. +- Claude streaming input can emit a `result` for one response segment and then immediately promote another queued Cafe message on the same process. This also happens after recoverable execution-error results: log the segment failure as a work-log warning, but do not terminalize the canonical Cafe turn while accepted input remains queued. SDK 0.3.216 adds `user_message_uuid` to successful results for exact correlation. A result must retire only its correlated Cafe input (falling back to the oldest lifecycle-started input for older runtimes and error results), and must not emit Cafe `turn.completed` while another Cafe-owned input remains queued or started. Authentication failures remain terminal because the session must be retired before any stale credentials can be reused. Finalize and reset only response-segment-scoped tool/text block state so the next Claude response may reuse stream block indexes while preserving the same canonical Cafe turn id. Claude may coalesce multiple queued inputs into one model turn, so a deferred result becomes terminal when the remaining tracked UUIDs reach completed lifecycle states; if a queued input is promoted after that result, discard the older deferred boundary and wait for the promoted segment's own result. This mirrors Cafe's Codex steer UX without pretending Claude exposes Codex's expected-turn RPC. +- Provider-service `sendTurn` must consult live adapter session state for every adapter advertising `liveSteer: "supported"`, not only for Codex. If Claude still owns a running turn, route the input through `steerTurn` so it enters Claude's non-interrupting queue. Inside the Claude adapter, only provider-initiated synthetic background turns may be auto-closed before a new user turn; a direct second `sendTurn` against a real active user turn must fail closed without mutating that turn, because the service is responsible for routing it through the queue. - Claude Code 2.1.208 / Agent SDK 0.3.208 introduced no new `SDKMessage` or system-subtype discriminants, but upstream fixed truncated headless `stream-json` output and missing terminal result messages, bounded several long-session SDK/CLI caches, cached permission-rule and MCP tool-pool assembly, and corrected transient long-context metadata after CLI auto-update. These fixes directly benefit Cafe's long-lived `AsyncIterable` sessions, so keep the SDK wrapper and system Claude Code binary on matching current patch releases. The SDK control wire shape now permits omitted `max_thinking_tokens` to reset a session override, while the public `Query.setMaxThinkingTokens()` method still requires `number | null`; do not synthesize an omitted control request through local casts. Claude Code also honors `CLAUDE_CODE_PROCESS_WRAPPER` for its own child/self-spawns, and Cafe's Claude environment must continue preserving explicitly supplied process/provider environment variables. - Claude Code 2.1.212 / Agent SDK 0.3.212 add no new public `SDKMessage` or system-subtype discriminants. The SDK adds settings metadata for `feedbackDrafts` and generated schemas for the built-in `SendFeedback` and `ProposeSkills` tools; Cafe's tool pipeline must continue handling unfamiliar built-ins as generic dynamic tool calls instead of rejecting or silently dropping them. Upstream also makes headless/SDK `set_model` control requests effective on the next model round-trip even when issued mid-turn and fixes streaming control requests being marked complete before their handlers finish. Cafe should continue awaiting `Query.setModel()` and must not locally infer model-switch completion before that promise resolves. The CLI patch additionally fixes plan-mode file-changing Bash commands bypassing the SDK `canUseTool` callback, prevents `.claude/worktrees` symlink traversal outside the repository, preserves `continue:false` hook halts, and kills running Bash process trees on SIGTERM; keep Cafe's SDK and system CLI matched so these security and lifecycle fixes apply consistently. -- Claude Code 2.1.214-2.1.215 / Agent SDK 0.3.215 add no new top-level `SDKMessage` or system-subtype discriminants, but 2.1.214 includes permission-analysis fixes and fixes truncated `stream-json` output for slow SDK consumers, so Cafe must keep the wrapper and system CLI on the matching patch. The SDK's `canUseTool` callback now exposes `matchedAskRule` when an explicit `permissions.ask` rule requires human confirmation; that specific user policy must override Cafe's broad full-access auto-allow path, and Cafe must not persist the rule's potentially sensitive `ruleContent` in request diagnostics. `tool_progress` now carries periodic `heartbeat`, optional `subagent_type`, and structured `subagent_retry`: treat every progress frame as active-turn liveness, do not project plain heartbeats into durable work-log activities, and persist at most one bounded `task.progress` row per concrete retry attempt. `SDKAssistantMessage.aborted` marks partial text cut off by interruption; preserve the partial output but let the result/interrupt lifecycle event remain terminal truth rather than inferring normal completion from the assistant snapshot. The new `EndConversation` built-in remains a generic dynamic tool call, `SessionStart` source `fork` is hook metadata, and the added Google Cloud account-provider value is account metadata rather than a new Cafe provider driver. +- Claude Code 2.1.214-2.1.216 / Agent SDK 0.3.216 add no new top-level `SDKMessage` or system-subtype discriminants. Version 2.1.214 includes permission-analysis fixes and fixes truncated `stream-json` output for slow SDK consumers; 0.3.216 adds result correlation/latency fields and tool-result metadata, so Cafe must keep the wrapper and system CLI on the matching patch. The SDK's `canUseTool` callback now exposes `matchedAskRule` when an explicit `permissions.ask` rule requires human confirmation; that specific user policy must override Cafe's broad full-access auto-allow path, and Cafe must not persist the rule's potentially sensitive `ruleContent` in request diagnostics. `tool_progress` now carries periodic `heartbeat`, optional `subagent_type`, and structured `subagent_retry`: treat every progress frame as active-turn liveness, do not project plain heartbeats into durable work-log activities, and persist at most one bounded `task.progress` row per concrete retry attempt. `SDKAssistantMessage.aborted` marks partial text cut off by interruption; preserve the partial output but let the result/interrupt lifecycle event remain terminal truth rather than inferring normal completion from the assistant snapshot. The new `EndConversation` built-in remains a generic dynamic tool call, `SessionStart` source `fork` is hook metadata, and the added Google Cloud account-provider value is account metadata rather than a new Cafe provider driver. - Start Claude sessions with `includePartialMessages: true` and `agentProgressSummaries: true`. `includePartialMessages` keeps assistant streaming granular, and upstream documents `agentProgressSummaries` as the supported way to receive periodic AI-written summaries for long-running foreground/background subagents. Do not replace this with system-prompt nagging; provider progress should come from SDK/runtime events and render through the work log. - Permission mode has two distinct paths in upstream Claude Agent SDK: initial `query()` options and `Query.setPermissionMode()` for an already-active streaming session. Cafe Code must bind the first turn's interaction mode into `query()` when starting a Claude session and must not send a redundant pre-prompt `setPermissionMode()` for default/full-access sends, because current Claude Code can reject that control request before it has a transcript message/conversation to attach it to. - Fresh Claude sessions should let upstream `query()` allocate the session id. The official SDK docs mark `sessionId` as optional/default auto-generated and recommend capturing the durable id from `system` init or result messages before passing it back through `resume`; Cafe Code should therefore only pass `resume` when it has a durable resume cursor from a real Claude transcript. Do not generate arbitrary `sessionId` values for new long-lived AsyncIterable sessions, because current Claude Code can reject the first queued turn with "No conversation found with session ID" before the transcript exists. diff --git a/apps/server/package.json b/apps/server/package.json index 9cdc8eb6..a7ff7dd9 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -46,7 +46,7 @@ "test:e2e:provider-daemon": "vitest run --config vitest.e2e.config.ts" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "0.3.215", + "@anthropic-ai/claude-agent-sdk": "0.3.216", "@anthropic-ai/sdk": "^0.98.0", "@effect/platform-node": "catalog:", "@effect/platform-node-shared": "catalog:", diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 73b6482a..f2bf12be 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -792,6 +792,231 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("keeps one Cafe turn active across a queued Claude follow-up", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + const turn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "finish the original task", + attachments: [], + }); + yield* adapter.steerTurn({ + threadId: session.threadId, + expectedTurnId: turn.turnId, + input: "incorporate this follow-up", + attachments: [], + }); + + const messages = yield* Effect.promise(() => + readPromptMessages(harness.getLastCreateQueryInput(), 2), + ); + const firstMessageUuid = messages[0]?.uuid; + const followUpMessageUuid = messages[1]?.uuid; + assert.isString(firstMessageUuid); + assert.isString(followUpMessageUuid); + if (firstMessageUuid === undefined || followUpMessageUuid === undefined) { + throw new Error("Expected UUID-stamped Claude prompts."); + } + + harness.query.emit({ + type: "system", + subtype: "init", + capabilities: ["msg_lifecycle_v1"], + session_id: "claude-session-follow-up", + uuid: "init-follow-up", + } as unknown as SDKMessage); + harness.query.emit({ + type: "command_lifecycle", + command_uuid: firstMessageUuid, + state: "started", + session_id: "claude-session-follow-up", + uuid: "lifecycle-first-started", + } as unknown as SDKMessage); + harness.query.emit({ + type: "command_lifecycle", + command_uuid: followUpMessageUuid, + state: "queued", + session_id: "claude-session-follow-up", + uuid: "lifecycle-follow-up-queued", + } as unknown as SDKMessage); + harness.query.emit({ + type: "assistant", + session_id: "claude-session-follow-up", + uuid: "assistant-first-segment", + parent_tool_use_id: null, + message: { + id: "assistant-message-first-segment", + content: [{ type: "text", text: "Original segment complete." }], + }, + } as unknown as SDKMessage); + harness.query.emit({ + type: "result", + subtype: "error_during_execution", + is_error: true, + errors: ["transient first-segment failure"], + session_id: "claude-session-follow-up", + uuid: "result-first-segment", + } as unknown as SDKMessage); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + + const activeSessions = yield* adapter.listSessions(); + assert.equal(activeSessions[0]?.status, "running"); + assert.equal(activeSessions[0]?.activeTurnId, turn.turnId); + assert.equal((yield* adapter.readThread(session.threadId)).turns.length, 0); + + // The real CLI emits the result before retiring the completed command, + // then immediately promotes the queued UUID. These lifecycle frames must + // not create a second synthetic Cafe turn or close the original turn. + harness.query.emit({ + type: "command_lifecycle", + command_uuid: firstMessageUuid, + state: "completed", + session_id: "claude-session-follow-up", + uuid: "lifecycle-first-completed", + } as unknown as SDKMessage); + harness.query.emit({ + type: "command_lifecycle", + command_uuid: followUpMessageUuid, + state: "started", + session_id: "claude-session-follow-up", + uuid: "lifecycle-follow-up-started", + } as unknown as SDKMessage); + harness.query.emit({ + type: "assistant", + session_id: "claude-session-follow-up", + uuid: "assistant-follow-up-segment", + parent_tool_use_id: null, + message: { + id: "assistant-message-follow-up-segment", + content: [{ type: "text", text: "Follow-up incorporated." }], + }, + } as unknown as SDKMessage); + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "claude-session-follow-up", + uuid: "result-follow-up-segment", + user_message_uuid: followUpMessageUuid, + } as unknown as SDKMessage); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + + const completedSessions = yield* adapter.listSessions(); + assert.equal(completedSessions[0]?.status, "ready"); + assert.equal(completedSessions[0]?.activeTurnId, undefined); + const snapshot = yield* adapter.readThread(session.threadId); + assert.equal(snapshot.turns.length, 1); + assert.equal(snapshot.turns[0]?.id, turn.turnId); + assert.equal(snapshot.turns[0]?.items.length, 2); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("completes one Cafe turn for a coalesced Claude follow-up batch", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + const turn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "first batch message", + attachments: [], + }); + yield* adapter.steerTurn({ + threadId: session.threadId, + expectedTurnId: turn.turnId, + input: "coalesced follow-up", + attachments: [], + }); + + const messages = yield* Effect.promise(() => + readPromptMessages(harness.getLastCreateQueryInput(), 2), + ); + const firstMessageUuid = messages[0]?.uuid; + const followUpMessageUuid = messages[1]?.uuid; + if (firstMessageUuid === undefined || followUpMessageUuid === undefined) { + throw new Error("Expected UUID-stamped Claude prompts."); + } + + // Claude can dequeue several queued UUIDs into one model turn. Both + // lifecycle entries become started before the shared assistant/result + // segment, and the non-representative UUID completes afterward. + for (const [commandUuid, uuid] of [ + [firstMessageUuid, "lifecycle-coalesced-first-started"], + [followUpMessageUuid, "lifecycle-coalesced-follow-up-started"], + ] as const) { + harness.query.emit({ + type: "command_lifecycle", + command_uuid: commandUuid, + state: "started", + session_id: "claude-session-coalesced-follow-up", + uuid, + } as unknown as SDKMessage); + } + harness.query.emit({ + type: "assistant", + session_id: "claude-session-coalesced-follow-up", + uuid: "assistant-coalesced-follow-up", + parent_tool_use_id: null, + message: { + id: "assistant-message-coalesced-follow-up", + content: [{ type: "text", text: "Both messages handled together." }], + }, + } as unknown as SDKMessage); + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "claude-session-coalesced-follow-up", + uuid: "result-coalesced-follow-up", + user_message_uuid: firstMessageUuid, + } as unknown as SDKMessage); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + + assert.equal((yield* adapter.listSessions())[0]?.status, "running"); + + harness.query.emit({ + type: "command_lifecycle", + command_uuid: followUpMessageUuid, + state: "completed", + session_id: "claude-session-coalesced-follow-up", + uuid: "lifecycle-coalesced-follow-up-completed", + } as unknown as SDKMessage); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + + const completedSessions = yield* adapter.listSessions(); + assert.equal(completedSessions[0]?.status, "ready"); + assert.equal(completedSessions[0]?.activeTurnId, undefined); + const snapshot = yield* adapter.readThread(session.threadId); + assert.equal(snapshot.turns.length, 1); + assert.equal(snapshot.turns[0]?.id, turn.turnId); + assert.equal(snapshot.turns[0]?.items.length, 1); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect( "tracks Claude command lifecycle and cancels only Cafe-owned interrupt survivors", () => { @@ -894,6 +1119,44 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("does not close an active user turn when sendTurn is called directly", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + const activeTurn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "keep this turn running", + attachments: [], + }); + const secondSendExit = yield* Effect.exit( + adapter.sendTurn({ + threadId: session.threadId, + input: "this must use the queued follow-up path", + attachments: [], + }), + ); + + assert.equal(secondSendExit._tag, "Failure"); + if (secondSendExit._tag === "Failure") { + assert.include(String(secondSendExit.cause), "turn/steer"); + } + + const activeSessions = yield* adapter.listSessions(); + assert.equal(activeSessions[0]?.status, "running"); + assert.equal(activeSessions[0]?.activeTurnId, activeTurn.turnId); + assert.equal((yield* adapter.readThread(session.threadId)).turns.length, 0); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("maps Claude stream/runtime messages to canonical provider runtime events", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -4529,12 +4792,23 @@ describe("ClaudeAdapterLive", () => { runtimeMode: "full-access", }); - yield* adapter.sendTurn({ + const firstTurn = yield* adapter.sendTurn({ threadId: session.threadId, input: "hello", modelSelection, attachments: [], }); + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "claude-session-same-model", + uuid: "result-same-model-first-turn", + user_message_uuid: firstTurn.turnId, + } as unknown as SDKMessage); + yield* Effect.yieldNow; + yield* Effect.yieldNow; yield* adapter.sendTurn({ threadId: session.threadId, input: "hello again", @@ -4561,7 +4835,7 @@ describe("ClaudeAdapterLive", () => { runtimeMode: "full-access", }); - yield* adapter.sendTurn({ + const firstTurn = yield* adapter.sendTurn({ threadId: session.threadId, input: "hello", modelSelection: createModelSelection( @@ -4571,6 +4845,17 @@ describe("ClaudeAdapterLive", () => { ), attachments: [], }); + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "claude-session-model-change", + uuid: "result-model-change-first-turn", + user_message_uuid: firstTurn.turnId, + } as unknown as SDKMessage); + yield* Effect.yieldNow; + yield* Effect.yieldNow; yield* adapter.sendTurn({ threadId: session.threadId, input: "hello again", diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 92e4462b..2dd67380 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -193,6 +193,13 @@ interface ClaudeResumeState { interface ClaudeTurnState { readonly turnId: TurnId; readonly startedAt: string; + /** + * User turns may only be extended through `steerTurn`; a second `sendTurn` + * must never silently terminalize them. Synthetic turns are the exception: + * they represent provider-initiated background output that arrived between + * user prompts and may be closed before the next explicit user turn starts. + */ + readonly origin: "user" | "synthetic"; readonly items: Array; readonly assistantTextBlocks: Map; readonly assistantTextBlockOrder: Array; @@ -213,6 +220,12 @@ interface ClaudeTurnState { nextSyntheticAssistantBlockIndex: number; } +interface ClaudeDeferredTurnResult { + readonly status: ProviderRuntimeTurnStatus; + readonly result: SDKResultMessage; + readonly errorMessage?: string; +} + interface AssistantTextBlockState { readonly itemId: string; readonly blockIndex: number; @@ -272,6 +285,7 @@ interface ClaudeSessionContext { readonly promptLifecycleByUuid: Map; readonly capabilities: Set; turnState: ClaudeTurnState | undefined; + deferredTurnResult: ClaudeDeferredTurnResult | undefined; lastKnownContextWindow: number | undefined; lastKnownTokenUsage: ThreadTokenUsageSnapshot | undefined; lastAssistantUuid: string | undefined; @@ -283,7 +297,7 @@ interface ClaudeSessionContext { interface ClaudeQueryRuntime extends AsyncIterable { readonly interrupt: () => Promise; - // The 0.3.209 runtime implements this control request, but its public Query + // The 0.3.216 runtime implements this control request, but its public Query // interface has not exposed the method yet. Keep it optional for older SDKs. readonly cancelAsyncMessage?: (messageUuid: string) => Promise; readonly setModel: (model?: string) => Promise; @@ -383,6 +397,43 @@ function isTerminalClaudeCommandLifecycleState(state: ClaudeCommandLifecycleStat return state === "completed" || state === "cancelled" || state === "discarded"; } +function claudeResultUserMessageUuid(result: SDKResultMessage): string | undefined { + return trimmedStringValue((result as Record).user_message_uuid); +} + +/** + * Retire the Cafe-owned input represented by a Claude result. + * + * Agent SDK 0.3.216 correlates successful results with `user_message_uuid`. + * Older CLIs and error results can omit that field, so the compatibility path + * retires the oldest input Claude has acknowledged as started. A final fallback + * handles pre-2.1.206 runtimes that do not emit command lifecycle frames at all. + * Map insertion order is the provider input order and is never reconstructed + * from prompt text, keeping this boundary content-blind and deterministic. + */ +function consumeClaudeResultPrompt( + context: ClaudeSessionContext, + result: SDKResultMessage, +): string | undefined { + const correlatedUuid = claudeResultUserMessageUuid(result); + if (correlatedUuid !== undefined) { + context.promptLifecycleByUuid.delete(correlatedUuid); + return correlatedUuid; + } + + const started = Array.from(context.promptLifecycleByUuid).find( + ([, state]) => state === "started", + ); + const fallback = started ?? context.promptLifecycleByUuid.entries().next().value; + if (fallback === undefined) { + return undefined; + } + + const [messageUuid] = fallback; + context.promptLifecycleByUuid.delete(messageUuid); + return messageUuid; +} + function claudeTaskTerminalStatus(value: unknown): "completed" | "failed" | "stopped" | undefined { const status = trimmedStringValue(value)?.toLowerCase(); if (status === "completed" || status === "failed" || status === "stopped") { @@ -1356,10 +1407,12 @@ const CLAUDE_EXECUTION_DIAGNOSTIC_PREFIX = "[ede_diagnostic]"; function makeClaudeTurnState(input: { readonly turnId: TurnId; readonly startedAt: string; + readonly origin: "user" | "synthetic"; }): ClaudeTurnState { return { turnId: input.turnId, startedAt: input.startedAt, + origin: input.origin, items: [], assistantTextBlocks: new Map(), assistantTextBlockOrder: [], @@ -2408,11 +2461,74 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); }); + const finalizeTurnSegment = Effect.fn("finalizeTurnSegment")(function* ( + context: ClaudeSessionContext, + status: ProviderRuntimeTurnStatus, + result?: SDKResultMessage, + ) { + const turnState = context.turnState; + if (!turnState) { + return; + } + + for (const [index, tool] of context.inFlightTools.entries()) { + const toolStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "item.completed", + eventId: toolStamp.eventId, + provider: PROVIDER, + createdAt: toolStamp.createdAt, + threadId: context.session.threadId, + turnId: turnState.turnId, + itemId: asRuntimeItemId(tool.itemId), + payload: { + itemType: tool.itemType, + status: status === "completed" ? "completed" : "failed", + title: tool.title, + ...(tool.detail ? { detail: tool.detail } : {}), + data: { + toolName: tool.toolName, + input: tool.input, + }, + }, + providerRefs: nativeProviderRefs(context, { + providerItemId: tool.itemId, + }), + raw: { + source: "claude.sdk.message", + method: "claude/result", + payload: result ?? { status }, + }, + }); + context.inFlightTools.delete(index); + } + // Clear any remaining stale entries (e.g. from interrupted content blocks). + context.inFlightTools.clear(); + + for (const block of turnState.assistantTextBlockOrder) { + yield* completeAssistantTextBlock(context, block, { + force: true, + rawMethod: "claude/result", + rawPayload: result ?? { status }, + }); + } + + // A streaming-input query emits one result per dequeued user-message batch. + // A Cafe steer can therefore produce another assistant response with block + // index zero while the same Cafe turn remains active. Retain accumulated + // turn items, diagnostics, and the canonical turn id, but reset all state + // whose identifiers are scoped to one Claude response segment. + turnState.assistantTextBlocks.clear(); + turnState.assistantTextBlockOrder.splice(0); + turnState.nextSyntheticAssistantBlockIndex = -1; + }); + const completeTurn = Effect.fn("completeTurn")(function* ( context: ClaudeSessionContext, status: ProviderRuntimeTurnStatus, errorMessage?: string, result?: SDKResultMessage, + options?: { readonly segmentAlreadyFinalized?: boolean }, ) { const resultContextWindow = maxClaudeContextWindowFromModelUsage(result?.modelUsage); const effectiveContextWindow = @@ -2479,46 +2595,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( return; } - for (const [index, tool] of context.inFlightTools.entries()) { - const toolStamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "item.completed", - eventId: toolStamp.eventId, - provider: PROVIDER, - createdAt: toolStamp.createdAt, - threadId: context.session.threadId, - turnId: turnState.turnId, - itemId: asRuntimeItemId(tool.itemId), - payload: { - itemType: tool.itemType, - status: status === "completed" ? "completed" : "failed", - title: tool.title, - ...(tool.detail ? { detail: tool.detail } : {}), - data: { - toolName: tool.toolName, - input: tool.input, - }, - }, - providerRefs: nativeProviderRefs(context, { - providerItemId: tool.itemId, - }), - raw: { - source: "claude.sdk.message", - method: "claude/result", - payload: result ?? { status }, - }, - }); - context.inFlightTools.delete(index); - } - // Clear any remaining stale entries (e.g. from interrupted content blocks) - context.inFlightTools.clear(); - - for (const block of turnState.assistantTextBlockOrder) { - yield* completeAssistantTextBlock(context, block, { - force: true, - rawMethod: "claude/result", - rawPayload: result ?? { status }, - }); + if (options?.segmentAlreadyFinalized !== true) { + yield* finalizeTurnSegment(context, status, result); } const zeroTurnExecutionFailure = @@ -2561,6 +2639,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); const updatedAt = yield* nowIso; + context.deferredTurnResult = undefined; context.turnState = undefined; context.session = { ...context.session, @@ -2956,6 +3035,33 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( context.promptLifecycleByUuid.set(message.command_uuid, message.state); } + if (message.state === "started" && context.deferredTurnResult !== undefined) { + // A queued message promoted after the preceding result owns a new Claude + // response segment and will emit its own result. Retire the older deferred + // boundary so a later lifecycle-completed frame cannot close Cafe's turn + // before this newly started segment reports its terminal result. For a + // coalesced batch, every member is already `started` before the shared + // result arrives, so this branch does not discard the batch result. + context.deferredTurnResult = undefined; + } + + if ( + message.state === "completed" && + context.promptLifecycleByUuid.size === 0 && + context.deferredTurnResult !== undefined + ) { + // Claude can coalesce several queued user messages into one model turn. + // In that case a single result represents the batch while lifecycle + // frames retire every UUID. Complete only after the final tracked UUID + // reaches terminal state; the response segment was already finalized + // when its result arrived, so doing so again would duplicate item events. + const deferred = context.deferredTurnResult; + context.deferredTurnResult = undefined; + yield* completeTurn(context, deferred.status, deferred.errorMessage, deferred.result, { + segmentAlreadyFinalized: true, + }); + } + if (message.state === "discarded") { yield* Effect.logWarning("claude.commandLifecycle.discarded", { threadId: context.session.threadId, @@ -3007,6 +3113,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( context.turnState = makeClaudeTurnState({ turnId, startedAt, + origin: "synthetic", }); context.session = { ...context.session, @@ -3100,6 +3207,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( status !== "completed" ? (resultPrimaryError(message) ?? resultErrors[0] ?? "Claude turn failed.") : undefined; + const completedPromptUuid = consumeClaudeResultPrompt(context, message); + const hasQueuedCafeInput = context.promptLifecycleByUuid.size > 0; if (status === "failed") { if (isZeroTurnClaudeExecutionFailure(message)) { @@ -3135,6 +3244,19 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( payload: message, }, ); + } else if (hasQueuedCafeInput) { + // A result is scoped to one dequeued command/batch, not to the lifetime + // of the streaming query. Claude continues draining its input queue + // after recoverable execution errors, so keep this diagnostic in the + // work log and let the already-accepted follow-up continue. + yield* emitRuntimeWarning( + context, + "Claude response segment failed; an already-queued follow-up remains active.", + { + error: sanitizeDiagnosticLine(errorMessage ?? "Claude turn failed."), + pendingPromptCount: context.promptLifecycleByUuid.size, + }, + ); } else { yield* emitRuntimeError(context, errorMessage ?? "Claude turn failed."); } @@ -3147,6 +3269,32 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* notifyAuthStatusChanged(false); } + if (!authFailure && hasQueuedCafeInput) { + // Claude's stream-json queue emits a result for the response segment that + // just finished, then promotes the next queued SDKUserMessage without + // ending the process. This remains true for recoverable execution-error + // results. Preserve the same user-facing lifecycle Cafe uses for a Codex + // steer: one canonical turn stays active while the provider reaches a + // safe boundary and incorporates the follow-up. Finalize only segment- + // local stream/tool state here; a later result (or the terminal lifecycle + // frames of a coalesced batch) closes the canonical turn. + yield* finalizeTurnSegment(context, status, message); + context.deferredTurnResult = { + status, + result: message, + ...(errorMessage !== undefined ? { errorMessage } : {}), + }; + yield* Effect.logInfo("claude.followUp.resultBoundaryDeferred", { + threadId: context.session.threadId, + providerInstanceId: boundInstanceId, + completedPromptUuid: completedPromptUuid ?? "", + pendingPromptCount: context.promptLifecycleByUuid.size, + messageLifecycleAdvertised: context.capabilities.has("msg_lifecycle_v1"), + }); + return; + } + + context.deferredTurnResult = undefined; yield* completeTurn(context, status, errorMessage, message); if (authFailure) { yield* stopSessionInternal(context, { @@ -4661,6 +4809,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( promptLifecycleByUuid: new Map(), capabilities: new Set(), turnState: undefined, + deferredTurnResult: undefined, lastKnownContextWindow: selectedContextWindowTokens, lastKnownTokenUsage: undefined, lastAssistantUuid: undefined, @@ -4755,9 +4904,18 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( : undefined; if (context.turnState) { - // Auto-close a stale synthetic turn (from background agent responses - // between user prompts) to prevent blocking the user's next turn. - yield* completeTurn(context, "completed"); + if (context.turnState.origin === "synthetic") { + // Auto-close provider-initiated background output before beginning the + // user's next explicit turn. A real user turn is never eligible for + // this path because closing it would drop or split a queued follow-up. + yield* completeTurn(context, "completed"); + } else { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "turn/start", + detail: `Claude turn '${context.turnState.turnId}' is already active; route additional input through turn/steer so Claude can queue it without interruption.`, + }); + } } if (modelSelection?.model) { @@ -4822,6 +4980,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const turnState = makeClaudeTurnState({ turnId, startedAt: yield* nowIso, + origin: "user", }); turnState.promptTextBytes = Buffer.byteLength(buildPromptText(input, boundInstanceId), "utf8"); turnState.promptAttachmentCount = input.attachments?.length ?? 0; diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 55b3e669..0ab9d898 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -233,7 +233,8 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) { provider, capabilities: { sessionModelSwitch: "in-session", - liveSteer: provider === CODEX_DRIVER ? "supported" : "unsupported", + liveSteer: + provider === CODEX_DRIVER || provider === CLAUDE_AGENT_DRIVER ? "supported" : "unsupported", }, startSession, sendTurn, @@ -1116,6 +1117,44 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); + it.effect( + "routes direct Claude sendTurn into its queued follow-up path while a turn is active", + () => + Effect.gen(function* () { + const provider = yield* ProviderService; + + const session = yield* provider.startSession(asThreadId("thread-claude-active"), { + provider: CLAUDE_AGENT_DRIVER, + providerInstanceId: claudeAgentInstanceId, + threadId: asThreadId("thread-claude-active"), + cwd: "/tmp/project", + runtimeMode: "full-access", + }); + routing.claude.updateSession(session.threadId, (current) => ({ + ...current, + status: "running", + activeTurnId: asTurnId("turn-claude-active"), + })); + routing.claude.sendTurn.mockClear(); + routing.claude.steerTurn.mockClear(); + + const turn = yield* provider.sendTurn({ + threadId: session.threadId, + input: "queue this without interrupting Claude", + attachments: [], + }); + + assert.equal(turn.turnId, asTurnId("turn-claude-active")); + assert.equal(routing.claude.sendTurn.mock.calls.length, 0); + assert.equal(routing.claude.steerTurn.mock.calls.length, 1); + assert.deepEqual(routing.claude.steerTurn.mock.calls[0]?.[0], { + threadId: session.threadId, + expectedTurnId: asTurnId("turn-claude-active"), + input: "queue this without interrupting Claude", + }); + }), + ); + it.effect("recovers stale persisted sessions for rollback by resuming thread identity", () => Effect.gen(function* () { const provider = yield* ProviderService; diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 719d46bb..ef4ae6dc 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -1059,7 +1059,12 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( "provider.kind": routed.adapter.provider, ...(input.modelSelection?.model ? { "provider.model": input.modelSelection.model } : {}), }); - if (routed.adapter.provider === ProviderDriverKind.make("codex")) { + if (routed.adapter.capabilities.liveSteer === "supported") { + // Projection state can lag the provider runtime during long streams or + // reconnects. Ask the adapter for its live session before starting a + // turn, and route additional input through its supported steer/queue + // path whenever it still owns an active turn. This applies to Codex's + // native steer RPC and Claude's non-interrupting SDK input queue. const activeSessions = yield* routed.adapter.listSessions(); const activeSession = activeSessions.find((session) => session.threadId === input.threadId); if (activeSession?.status === "running" && activeSession.activeTurnId !== undefined) { diff --git a/packaging/desktop-runtime/package.json b/packaging/desktop-runtime/package.json index ce8db36e..7708d9af 100644 --- a/packaging/desktop-runtime/package.json +++ b/packaging/desktop-runtime/package.json @@ -6,7 +6,7 @@ "postinstall": "node ../../scripts/ensure-desktop-runtime.ts" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "0.3.215", + "@anthropic-ai/claude-agent-sdk": "0.3.216", "@anthropic-ai/sdk": "^0.98.0", "@effect/platform-node": "catalog:", "@effect/platform-node-shared": "catalog:", diff --git a/scripts/package.json b/scripts/package.json index ae704515..68b42948 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -8,7 +8,7 @@ "test": "vitest run" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "0.3.215", + "@anthropic-ai/claude-agent-sdk": "0.3.216", "@anthropic-ai/sdk": "^0.98.0", "@cafecode/contracts": "workspace:*", "@cafecode/shared": "workspace:*", diff --git a/yarn.lock b/yarn.lock index 12b0918e..f4dea8a7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5,74 +5,74 @@ __metadata: version: 10 cacheKey: 10c0 -"@anthropic-ai/claude-agent-sdk-darwin-arm64@npm:0.3.215": - version: 0.3.215 - resolution: "@anthropic-ai/claude-agent-sdk-darwin-arm64@npm:0.3.215" +"@anthropic-ai/claude-agent-sdk-darwin-arm64@npm:0.3.216": + version: 0.3.216 + resolution: "@anthropic-ai/claude-agent-sdk-darwin-arm64@npm:0.3.216" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@anthropic-ai/claude-agent-sdk-darwin-x64@npm:0.3.215": - version: 0.3.215 - resolution: "@anthropic-ai/claude-agent-sdk-darwin-x64@npm:0.3.215" +"@anthropic-ai/claude-agent-sdk-darwin-x64@npm:0.3.216": + version: 0.3.216 + resolution: "@anthropic-ai/claude-agent-sdk-darwin-x64@npm:0.3.216" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@anthropic-ai/claude-agent-sdk-linux-arm64-musl@npm:0.3.215": - version: 0.3.215 - resolution: "@anthropic-ai/claude-agent-sdk-linux-arm64-musl@npm:0.3.215" +"@anthropic-ai/claude-agent-sdk-linux-arm64-musl@npm:0.3.216": + version: 0.3.216 + resolution: "@anthropic-ai/claude-agent-sdk-linux-arm64-musl@npm:0.3.216" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@anthropic-ai/claude-agent-sdk-linux-arm64@npm:0.3.215": - version: 0.3.215 - resolution: "@anthropic-ai/claude-agent-sdk-linux-arm64@npm:0.3.215" +"@anthropic-ai/claude-agent-sdk-linux-arm64@npm:0.3.216": + version: 0.3.216 + resolution: "@anthropic-ai/claude-agent-sdk-linux-arm64@npm:0.3.216" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@anthropic-ai/claude-agent-sdk-linux-x64-musl@npm:0.3.215": - version: 0.3.215 - resolution: "@anthropic-ai/claude-agent-sdk-linux-x64-musl@npm:0.3.215" +"@anthropic-ai/claude-agent-sdk-linux-x64-musl@npm:0.3.216": + version: 0.3.216 + resolution: "@anthropic-ai/claude-agent-sdk-linux-x64-musl@npm:0.3.216" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@anthropic-ai/claude-agent-sdk-linux-x64@npm:0.3.215": - version: 0.3.215 - resolution: "@anthropic-ai/claude-agent-sdk-linux-x64@npm:0.3.215" +"@anthropic-ai/claude-agent-sdk-linux-x64@npm:0.3.216": + version: 0.3.216 + resolution: "@anthropic-ai/claude-agent-sdk-linux-x64@npm:0.3.216" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@anthropic-ai/claude-agent-sdk-win32-arm64@npm:0.3.215": - version: 0.3.215 - resolution: "@anthropic-ai/claude-agent-sdk-win32-arm64@npm:0.3.215" +"@anthropic-ai/claude-agent-sdk-win32-arm64@npm:0.3.216": + version: 0.3.216 + resolution: "@anthropic-ai/claude-agent-sdk-win32-arm64@npm:0.3.216" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@anthropic-ai/claude-agent-sdk-win32-x64@npm:0.3.215": - version: 0.3.215 - resolution: "@anthropic-ai/claude-agent-sdk-win32-x64@npm:0.3.215" +"@anthropic-ai/claude-agent-sdk-win32-x64@npm:0.3.216": + version: 0.3.216 + resolution: "@anthropic-ai/claude-agent-sdk-win32-x64@npm:0.3.216" conditions: os=win32 & cpu=x64 languageName: node linkType: hard -"@anthropic-ai/claude-agent-sdk@npm:0.3.215": - version: 0.3.215 - resolution: "@anthropic-ai/claude-agent-sdk@npm:0.3.215" +"@anthropic-ai/claude-agent-sdk@npm:0.3.216": + version: 0.3.216 + resolution: "@anthropic-ai/claude-agent-sdk@npm:0.3.216" dependencies: - "@anthropic-ai/claude-agent-sdk-darwin-arm64": "npm:0.3.215" - "@anthropic-ai/claude-agent-sdk-darwin-x64": "npm:0.3.215" - "@anthropic-ai/claude-agent-sdk-linux-arm64": "npm:0.3.215" - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "npm:0.3.215" - "@anthropic-ai/claude-agent-sdk-linux-x64": "npm:0.3.215" - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "npm:0.3.215" - "@anthropic-ai/claude-agent-sdk-win32-arm64": "npm:0.3.215" - "@anthropic-ai/claude-agent-sdk-win32-x64": "npm:0.3.215" + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "npm:0.3.216" + "@anthropic-ai/claude-agent-sdk-darwin-x64": "npm:0.3.216" + "@anthropic-ai/claude-agent-sdk-linux-arm64": "npm:0.3.216" + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "npm:0.3.216" + "@anthropic-ai/claude-agent-sdk-linux-x64": "npm:0.3.216" + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "npm:0.3.216" + "@anthropic-ai/claude-agent-sdk-win32-arm64": "npm:0.3.216" + "@anthropic-ai/claude-agent-sdk-win32-x64": "npm:0.3.216" peerDependencies: "@anthropic-ai/sdk": ">=0.93.0" "@modelcontextprotocol/sdk": ^1.29.0 @@ -94,7 +94,7 @@ __metadata: optional: true "@anthropic-ai/claude-agent-sdk-win32-x64": optional: true - checksum: 10c0/d5be0f1a6823a5571e51470eef0bab685103b4e109f6b9308fd023e2c6f85702a6e2f63b0b82a0b8494878531a313a2015a8d30ef34b3fd9968e9e6ae3a90aee + checksum: 10c0/20fd07ce6c0ec4f7da21333d8c11388afe2d195d0dac0e8638cf496193f949ec76f5067c0102bc23d1e2cf53e9e469d6496ea81b7c9774fc90b9a49e0ed9cea6 languageName: node linkType: hard @@ -420,7 +420,7 @@ __metadata: version: 0.0.0-use.local resolution: "@cafeai/cafe-code@workspace:apps/server" dependencies: - "@anthropic-ai/claude-agent-sdk": "npm:0.3.215" + "@anthropic-ai/claude-agent-sdk": "npm:0.3.216" "@anthropic-ai/sdk": "npm:^0.98.0" "@cafecode/contracts": "workspace:*" "@cafecode/shared": "workspace:*" @@ -479,7 +479,7 @@ __metadata: version: 0.0.0-use.local resolution: "@cafecode/desktop-runtime@workspace:packaging/desktop-runtime" dependencies: - "@anthropic-ai/claude-agent-sdk": "npm:0.3.215" + "@anthropic-ai/claude-agent-sdk": "npm:0.3.216" "@anthropic-ai/sdk": "npm:^0.98.0" "@effect/platform-node": "catalog:" "@effect/platform-node-shared": "catalog:" @@ -560,7 +560,7 @@ __metadata: version: 0.0.0-use.local resolution: "@cafecode/scripts@workspace:scripts" dependencies: - "@anthropic-ai/claude-agent-sdk": "npm:0.3.215" + "@anthropic-ai/claude-agent-sdk": "npm:0.3.216" "@anthropic-ai/sdk": "npm:^0.98.0" "@cafecode/contracts": "workspace:*" "@cafecode/shared": "workspace:*" From 027d6b5dc7a6c6095d888ff078b97b05443c4507 Mon Sep 17 00:00:00 2001 From: cafeai <116491182+cafeai@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:21:35 +0900 Subject: [PATCH 10/13] fix(claude): expose non-interrupting live follow-ups --- AGENTS.md | 1 + apps/server/src/provider/Drivers/ClaudeDriver.ts | 9 +++++++++ apps/server/src/provider/Layers/ClaudeAdapter.test.ts | 5 +++++ .../provider/Layers/ProviderInstanceRegistryLive.test.ts | 7 +++++++ 4 files changed, 22 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 657e9579..4cc76f82 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -315,6 +315,7 @@ Claude lifecycle facts to preserve: - Agent SDK 0.3.212 requires a host wrapping keyboard input to stamp `SDKUserMessage.origin = { kind: "human" }` explicitly. Cafe must apply that provenance only to actual user-submitted prompt/steer messages; never infer or overwrite `peer`, `channel`, or `task-notification` origins on provider-emitted messages. Missing origin is intentionally unattributed and fails closed at upstream human-trust gates. - Agent SDK 0.3.211 fixes streamed user-message replay ordering when partial messages are enabled and includes complete CLI stderr in process-exit errors. It also adds optional assistant timestamps and `resumed_from_incomplete_thinking`; assistant timestamps use the originating host clock and are display-only, so Cafe must retain provider receive order as lifecycle truth and must not sort or terminalize events by that timestamp. The resumed-thinking flag is SDK normalization metadata, not a new assistant item or turn boundary. The new rate-limit prefix exports remain alpha classification helpers rather than a durable protocol, and `BashToolOutput.timedOutAfterMs` is tool-result metadata rather than thread context-window usage. - Official Claude Agent SDK streaming input mode is the supported interactive path for queued messages: the Claude Code changelog documents Enter as queueing additional messages while Claude works, and the SDK's `AsyncIterable`/`streamInput()` path writes those messages into the live command queue without calling `interrupt()`. Cafe therefore advertises Claude live steer support by validating Cafe's expected active turn id locally and queueing the follow-up into the active SDK prompt stream. Claude does not expose a Codex-style expected-turn RPC, so never claim upstream has acknowledged a specific turn beyond Cafe's local active-turn binding. An explicit Cafe stop remains the only path that calls `Query.interrupt()`. +- Claude's public `ServerProvider.runtimeCapabilities.liveSteer` snapshot and its private adapter capability must both remain `supported`. The renderer cannot inspect the daemon adapter directly: it uses the provider snapshot to choose between `thread.turn.steer` and the legacy unsupported-provider interrupt path. Every pending, disabled, warning, authenticated, and refreshed Claude snapshot is stamped in `ClaudeDriver.withInstanceIdentity`; omitting that stamp decodes to `unsupported` and destructively turns a normal Claude correction into `Query.interrupt()`. Keep a cross-driver registry test plus an adapter assertion that queueing a steer makes zero interrupt calls. - The returned `Query` supports operations such as `interrupt()`, `setModel()`, `setPermissionMode()`, and `setMaxThinkingTokens(maxThinkingTokens, thinkingDisplay?)`. - Claude Code 2.1.205+ advertises `interrupt_receipt_v1`; 2.1.206+ also advertises `msg_lifecycle_v1` and emits top-level `command_lifecycle` frames with `queued`, `started`, `completed`, `cancelled`, or `discarded` state. Agent SDK 0.3.216 parses the interrupt receipt but still omits `command_lifecycle` and the implemented `cancelAsyncMessage()` method from its public TypeScript `Query` surface. Cafe must UUID-stamp every streamed user input, decode these runtime frames through a narrow forward-compatible guard, keep normal lifecycle frames out of unhandled-message warnings, and use terminal lifecycle states to retire local delivery tracking. On interrupt, cancel only Cafe-owned UUIDs returned in `still_queued` or still locally known as submitted/queued; upstream receipts can include internal cron/auto-resume UUIDs, which Cafe must ignore. If cancellation cannot be confirmed, retire the Claude process before accepting more input so durable Cafe replay cannot race an unconfirmed provider copy. Never log prompt content in these diagnostics. - Claude streaming input can emit a `result` for one response segment and then immediately promote another queued Cafe message on the same process. This also happens after recoverable execution-error results: log the segment failure as a work-log warning, but do not terminalize the canonical Cafe turn while accepted input remains queued. SDK 0.3.216 adds `user_message_uuid` to successful results for exact correlation. A result must retire only its correlated Cafe input (falling back to the oldest lifecycle-started input for older runtimes and error results), and must not emit Cafe `turn.completed` while another Cafe-owned input remains queued or started. Authentication failures remain terminal because the session must be retired before any stale credentials can be reused. Finalize and reset only response-segment-scoped tool/text block state so the next Claude response may reuse stream block indexes while preserving the same canonical Cafe turn id. Claude may coalesce multiple queued inputs into one model turn, so a deferred result becomes terminal when the remaining tracked UUIDs reach completed lifecycle states; if a queued input is promoted after that result, discard the older deferred boundary and wait for the promoted segment's own result. This mirrors Cafe's Codex steer UX without pretending Claude exposes Codex's expected-turn RPC. diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index ad5fb713..6732fc72 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -102,6 +102,15 @@ const withInstanceIdentity = ...(input.accentColor ? { accentColor: input.accentColor } : {}), ...(input.authActions ? { authActions: input.authActions } : {}), continuation: { groupKey: input.continuationGroupKey }, + // The renderer chooses between a non-interrupting `thread.turn.steer` + // and the legacy interrupt-then-send fallback from this public snapshot, + // not from the daemon-only adapter object. Keep the snapshot aligned with + // `makeClaudeAdapter().capabilities.liveSteer`: Claude's streaming-input + // protocol accepts UUID-stamped messages on the live AsyncIterable and + // does not require Query.interrupt(). Omitting this field silently falls + // back to "unsupported" at the contract decoder and turns a nudge into a + // destructive interrupt even though the adapter can queue it safely. + runtimeCapabilities: { ...snapshot.runtimeCapabilities, liveSteer: "supported" }, }); export const ClaudeDriver: ProviderDriver = { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index f2bf12be..2283e65b 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -786,6 +786,11 @@ describe("ClaudeAdapterLive", () => { ); assert.equal(promptMessageText(messages[0]), "first prompt"); assert.equal(promptMessageText(messages[1]), "follow-up while active"); + // Claude Code's interactive correction path is a streamed user message, + // not an interrupt followed by a replacement turn. A regression here + // would cancel the running tool, collapse Cafe's work log, and reset the + // active-turn timer before Claude had incorporated the correction. + assert.deepEqual(harness.query.interruptCalls, []); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index 2ed9b622..4280117c 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -291,6 +291,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { expect(codexSnapshot.continuation?.groupKey).toBe( `codex:home:${path.resolve("/home/julius/.codex")}`, ); + expect(codexSnapshot.runtimeCapabilities?.liveSteer).toBe("supported"); const claudeSnapshot = yield* claude!.snapshot.getSnapshot; expect(claudeSnapshot.instanceId).toBe(claudeId); @@ -299,6 +300,12 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { expect(claudeSnapshot.continuation?.groupKey).toBe( `claude:home:${path.resolve("/home/julius/.claude-work")}`, ); + // The renderer reads this snapshot capability when deciding whether a + // running-turn follow-up is safe to steer. It cannot see the adapter's + // private capability object, so both surfaces must agree even for the + // initial disabled/pending snapshot before a CLI probe has completed. + expect(claudeSnapshot.runtimeCapabilities?.liveSteer).toBe("supported"); + expect(claude!.adapter.capabilities.liveSteer).toBe("supported"); }).pipe(Effect.provide(testLayer)), ); }); From aea47f3a269be19c826f283fdb91f3ff438a11e7 Mon Sep 17 00:00:00 2001 From: cafeai <116491182+cafeai@users.noreply.github.com> Date: Wed, 22 Jul 2026 00:04:05 +0900 Subject: [PATCH 11/13] feat(claude): expose complete permission modes --- AGENTS.md | 2 + .../Layers/ProviderCommandReactor.ts | 5 +- .../src/provider/Layers/ClaudeAdapter.test.ts | 125 ++++++++++++ .../src/provider/Layers/ClaudeAdapter.ts | 58 ++++-- .../Layers/CodexSessionRuntime.test.ts | 18 ++ .../provider/Layers/CodexSessionRuntime.ts | 10 +- .../src/components/ChatViewBrowser.shared.tsx | 83 +++++++- apps/web/src/components/chat/ChatComposer.tsx | 178 ++++++++++++++---- .../CompactComposerControlsMenu.browser.tsx | 21 +++ .../chat/CompactComposerControlsMenu.tsx | 72 +++++-- .../chat/claudePermissionMode.test.ts | 54 ++++++ .../components/chat/claudePermissionMode.ts | 141 ++++++++++++++ apps/web/src/composerDraftStore.ts | 11 +- packages/contracts/src/orchestration.test.ts | 3 +- packages/contracts/src/orchestration.ts | 8 +- 15 files changed, 705 insertions(+), 84 deletions(-) create mode 100644 apps/web/src/components/chat/claudePermissionMode.test.ts create mode 100644 apps/web/src/components/chat/claudePermissionMode.ts diff --git a/AGENTS.md b/AGENTS.md index 4cc76f82..53719698 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -325,6 +325,8 @@ Claude lifecycle facts to preserve: - Claude Code 2.1.214-2.1.216 / Agent SDK 0.3.216 add no new top-level `SDKMessage` or system-subtype discriminants. Version 2.1.214 includes permission-analysis fixes and fixes truncated `stream-json` output for slow SDK consumers; 0.3.216 adds result correlation/latency fields and tool-result metadata, so Cafe must keep the wrapper and system CLI on the matching patch. The SDK's `canUseTool` callback now exposes `matchedAskRule` when an explicit `permissions.ask` rule requires human confirmation; that specific user policy must override Cafe's broad full-access auto-allow path, and Cafe must not persist the rule's potentially sensitive `ruleContent` in request diagnostics. `tool_progress` now carries periodic `heartbeat`, optional `subagent_type`, and structured `subagent_retry`: treat every progress frame as active-turn liveness, do not project plain heartbeats into durable work-log activities, and persist at most one bounded `task.progress` row per concrete retry attempt. `SDKAssistantMessage.aborted` marks partial text cut off by interruption; preserve the partial output but let the result/interrupt lifecycle event remain terminal truth rather than inferring normal completion from the assistant snapshot. The new `EndConversation` built-in remains a generic dynamic tool call, `SessionStart` source `fork` is hook metadata, and the added Google Cloud account-provider value is account metadata rather than a new Cafe provider driver. - Start Claude sessions with `includePartialMessages: true` and `agentProgressSummaries: true`. `includePartialMessages` keeps assistant streaming granular, and upstream documents `agentProgressSummaries` as the supported way to receive periodic AI-written summaries for long-running foreground/background subagents. Do not replace this with system-prompt nagging; provider progress should come from SDK/runtime events and render through the work log. - Permission mode has two distinct paths in upstream Claude Agent SDK: initial `query()` options and `Query.setPermissionMode()` for an already-active streaming session. Cafe Code must bind the first turn's interaction mode into `query()` when starting a Claude session and must not send a redundant pre-prompt `setPermissionMode()` for default/full-access sends, because current Claude Code can reject that control request before it has a transcript message/conversation to attach it to. +- Claude Code 2.1.216 exposes four normal interactive permission states: Manual/Ask permissions (`default` on the Agent SDK control wire, with `manual` accepted by the CLI), Accept edits (`acceptEdits`), Plan (`plan`), and classifier-backed Auto (`auto`). Cafe's Claude composer must present those as one provider-specific mode selector rather than an independent Build/Plan toggle plus an ambiguous access selector, and focused-composer Shift+Tab must cycle through those four states in that order. Preserve Bypass permissions (`bypassPermissions`) as a separately and dangerously labeled optional state outside the normal shortcut cycle because older Cafe Full access threads already persist that policy; do not mislabel it as Auto. Upstream keeps `dontAsk` CLI-only, so Cafe's desktop-style selector does not expose it. Persist Auto as `interactionMode: "auto"`; preserve the underlying generic runtime policy while Plan or Auto is selected so live `Query.setPermissionMode()` transitions do not require a provider restart. If a thread later switches to Codex, normalize every non-plan Claude interaction value to Codex `default`, because Codex app-server accepts only `default` and `plan` collaboration modes. +- Claude Auto mode's classifier is authoritative. The Agent SDK invokes Cafe's `canUseTool` callback when Auto falls back to a human decision; Cafe must never auto-allow that callback merely because the thread's older generic runtime policy is `full-access`. Only an actually active `bypassPermissions` SDK mode may use Cafe's broad auto-allow path, and explicit upstream `permissions.ask` rules must continue to override even that path. This distinction is security-sensitive: conflating Auto with bypass disables the classifier without telling the user. - Fresh Claude sessions should let upstream `query()` allocate the session id. The official SDK docs mark `sessionId` as optional/default auto-generated and recommend capturing the durable id from `system` init or result messages before passing it back through `resume`; Cafe Code should therefore only pass `resume` when it has a durable resume cursor from a real Claude transcript. Do not generate arbitrary `sessionId` values for new long-lived AsyncIterable sessions, because current Claude Code can reject the first queued turn with "No conversation found with session ID" before the transcript exists. - Do not pass Claude SDK `resumeSessionAt` for ordinary follow-up turns. Upstream SDK types document `resumeSessionAt` as a specific assistant-message checkpoint, not as the normal latest-leaf resume pointer; always applying it to Cafe follow-ups can make current Claude Code reject valid transcripts with "No message found with message.uuid". Only use `resumeSessionAt` for an explicit user-visible rewind/fork-to-message operation with dedicated recovery handling. - Claude `resume` loads a local transcript from the resolved Claude home project directory for the active cwd, not from Cafe's UI state alone. Before passing a durable Cafe cursor through SDK `resume`, verify that `~/.claude/projects//.jsonl` exists in the resolved Claude home or can be copied from another Claude project directory. If the transcript is missing, drop the stale cursor and start a fresh upstream Claude session rather than sending a doomed `--resume` that fails before the user's turn starts. diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index eb620290..fda5c275 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -12,6 +12,7 @@ import { type ProjectId, type OrchestrationSession, type OrchestrationThread, + type ProviderInteractionMode, ThreadId, type ProviderSession, type RuntimeMode, @@ -697,7 +698,7 @@ const make = Effect.gen(function* () { readonly project?: OrchestrationProjectShell; readonly activeSession?: ProviderSession | undefined; readonly activeSessionResolved?: boolean; - readonly interactionMode?: "default" | "plan"; + readonly interactionMode?: ProviderInteractionMode; }, ) { const thread = options?.thread ?? (yield* resolveThread(threadId)); @@ -988,7 +989,7 @@ const make = Effect.gen(function* () { readonly messageText: string; readonly attachments?: ReadonlyArray; readonly modelSelection?: ModelSelection; - readonly interactionMode?: "default" | "plan"; + readonly interactionMode?: ProviderInteractionMode; readonly createdAt: string; readonly thread?: OrchestrationThread; readonly project?: OrchestrationProjectShell; diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 2283e65b..3e82d8df 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -435,6 +435,33 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("starts classifier-backed Claude auto mode without enabling bypass", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "approval-required", + interactionMode: "auto", + }); + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "work autonomously", + interactionMode: "auto", + attachments: [], + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.permissionMode, "auto"); + assert.equal(createInput?.options.allowDangerouslySkipPermissions, undefined); + assert.deepEqual(harness.query.setPermissionModeCalls, []); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("forwards claude effort levels into query options", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -4012,6 +4039,59 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("surfaces an Auto-mode fallback prompt instead of bypassing the classifier", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + interactionMode: "auto", + }); + + yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain); + const canUseTool = harness.getLastCreateQueryInput()?.options.canUseTool; + assert.equal(typeof canUseTool, "function"); + if (!canUseTool) { + return; + } + + const permissionPromise = canUseTool( + "Bash", + { command: "deploy-production" }, + { + signal: new AbortController().signal, + toolUseID: "tool-auto-fallback-1", + requestId: "permission-auto-fallback-1", + }, + ); + const requested = yield* Stream.runHead(adapter.streamEvents); + assert.equal(requested._tag, "Some"); + if (requested._tag !== "Some" || requested.value.type !== "request.opened") { + return; + } + + const runtimeRequestId = requested.value.requestId; + assert.equal(typeof runtimeRequestId, "string"); + if (runtimeRequestId === undefined) { + return; + } + yield* adapter.respondToRequest( + session.threadId, + ApprovalRequestId.make(runtimeRequestId), + "decline", + ); + yield* Stream.runHead(adapter.streamEvents); + + const permissionResult = yield* Effect.promise(() => permissionPromise); + assert.equal((permissionResult as PermissionResult).behavior, "deny"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("classifies Agent tools and read-only Claude tools correctly for approvals", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -4906,6 +4986,51 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("switches an established Claude session into Auto mode through the SDK", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "approval-required", + interactionMode: "default", + }); + const firstTurn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "inspect this", + interactionMode: "default", + attachments: [], + }); + const turnCompletedFiber = yield* Stream.filter( + adapter.streamEvents, + (event) => event.type === "turn.completed", + ).pipe(Stream.runHead, Effect.forkChild); + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-auto-transition", + uuid: "result-auto-transition", + user_message_uuid: firstTurn.turnId, + } as unknown as SDKMessage); + yield* Fiber.join(turnCompletedFiber); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "continue in auto mode", + interactionMode: "auto", + attachments: [], + }); + + assert.deepEqual(harness.query.setPermissionModeCalls, ["auto"]); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect.each<{ runtimeMode: RuntimeMode; expectedBase: PermissionMode }>([ { runtimeMode: "full-access", expectedBase: "bypassPermissions" }, { runtimeMode: "approval-required", expectedBase: "default" }, diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 2dd67380..3eae7161 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -30,6 +30,7 @@ import { type ModelSelection, type ProviderApprovalDecision, ProviderDriverKind, + type ProviderInteractionMode, ProviderInstanceId, ProviderItemId, type ProviderRuntimeEvent, @@ -40,6 +41,7 @@ import { type ProviderSteerTurnInput, type ProviderUserInputAnswers, type RuntimeSessionState, + type RuntimeMode, type RuntimeContentStreamKind, RuntimeItemId, RuntimeRequestId, @@ -93,6 +95,33 @@ const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJ const decodeUnknownJsonStringExit = Schema.decodeUnknownExit(Schema.UnknownFromJsonString); const PROVIDER = ProviderDriverKind.make("claudeAgent"); + +function runtimeModeToClaudePermissionMode(runtimeMode: RuntimeMode): PermissionMode | undefined { + switch (runtimeMode) { + case "approval-required": + // Omitting the initial flag lets Claude Code use its standard/manual + // permission behavior and matches the Agent SDK's documented default. + return undefined; + case "auto-accept-edits": + return "acceptEdits"; + case "full-access": + return "bypassPermissions"; + } +} + +function resolveClaudePermissionMode(input: { + readonly interactionMode: ProviderInteractionMode | undefined; + readonly basePermissionMode: PermissionMode | undefined; +}): PermissionMode | undefined { + // Claude Code 2.1.216 calls the standard UI state "manual", while the Agent + // SDK control protocol continues to spell it `default`. Plan and Auto are + // real SDK modes, not prompt decorations or aliases for Cafe access policy. + if (input.interactionMode === "plan" || input.interactionMode === "auto") { + return input.interactionMode; + } + return input.basePermissionMode; +} + type ClaudeTextStreamKind = Extract; type ClaudeToolResultStreamKind = Extract< RuntimeContentStreamKind, @@ -4424,7 +4453,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } satisfies PermissionResult; } - const runtimeMode = input.runtimeMode ?? "full-access"; const matchedAskRule = callbackOptions.matchedAskRule !== undefined; // Agent SDK 0.3.215 marks prompts forced by a user-configured // permissions.ask rule. That explicit rule is more specific than @@ -4432,7 +4460,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( // of being silently auto-approved. Do not persist ruleContent here: it // can contain sensitive project policy and is not needed to honor the // upstream decision. - if (runtimeMode === "full-access" && !matchedAskRule) { + // Only true bypass mode may skip Cafe's callback. Auto mode can be + // layered over a historical full-access runtime policy, but upstream's + // classifier must remain authoritative. If Auto falls back to a human + // prompt, silently allowing it here would defeat the safety model. + if (context.currentPermissionMode === "bypassPermissions" && !matchedAskRule) { return { behavior: "allow", updatedInput: toolInput, @@ -4561,12 +4593,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const { apiModelId, selectedContextWindowTokens, effectiveEffort, settings } = resolveClaudeModelSessionOptions(modelSelection); const fastMode = settings.fastMode === true; - const runtimeModeToPermission: Record = { - "auto-accept-edits": "acceptEdits", - "full-access": "bypassPermissions", - }; - const permissionMode = runtimeModeToPermission[input.runtimeMode]; - const initialPermissionMode = input.interactionMode === "plan" ? "plan" : permissionMode; + const permissionMode = runtimeModeToClaudePermissionMode(input.runtimeMode); + const initialPermissionMode = resolveClaudePermissionMode({ + interactionMode: input.interactionMode, + basePermissionMode: permissionMode, + }); const claudeAdditionalDirectories = [ ...(input.cwd ? [input.cwd] : []), ...(input.additionalDirectories ?? []), @@ -4944,11 +4975,12 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( // attached the first streamed user message can fail with "No message // found" / "No conversation found" on current Claude Agent SDK releases. const desiredPermissionMode = - input.interactionMode === "plan" - ? "plan" - : input.interactionMode === "default" - ? (context.basePermissionMode ?? "default") - : undefined; + input.interactionMode === undefined + ? undefined + : (resolveClaudePermissionMode({ + interactionMode: input.interactionMode, + basePermissionMode: context.basePermissionMode, + }) ?? "default"); if ( desiredPermissionMode !== undefined && desiredPermissionMode !== context.currentPermissionMode diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 03eedab9..1cc2cbcf 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -669,6 +669,24 @@ describe("buildTurnStartParams", () => { }); }); + it("normalizes a persisted Claude auto mode when a thread switches to Codex", () => { + const params = Effect.runSync( + buildTurnStartParams({ + threadId: "provider-thread-1", + runtimeMode: "auto-accept-edits", + prompt: "Continue with Codex", + model: "gpt-5.3-codex", + interactionMode: "auto", + }), + ); + + assert.equal(params.collaborationMode?.mode, "default"); + assert.equal( + params.collaborationMode?.settings.developer_instructions, + CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, + ); + }); + it("omits collaboration mode when interaction mode is absent", () => { const params = Effect.runSync( buildTurnStartParams({ diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 37f58a75..dad549a3 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -749,14 +749,20 @@ function buildCodexCollaborationMode(input: { if (input.interactionMode === undefined) { return undefined; } + // `auto` is a Claude permission mode persisted in Cafe's provider-neutral + // thread state. A thread can later switch providers, but Codex app-server's + // collaboration protocol only accepts `default` and `plan`; conservatively + // translate every non-plan provider mode to Codex's normal collaboration + // mode instead of forwarding an invalid wire value. + const mode = input.interactionMode === "plan" ? "plan" : "default"; const model = normalizeCodexModelSlug(input.model) ?? DEFAULT_MODEL; return { - mode: input.interactionMode, + mode, settings: { model, reasoning_effort: input.effort ?? "medium", developer_instructions: - input.interactionMode === "plan" + mode === "plan" ? CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS : CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, }, diff --git a/apps/web/src/components/ChatViewBrowser.shared.tsx b/apps/web/src/components/ChatViewBrowser.shared.tsx index 28fcf8fc..ca591a3a 100644 --- a/apps/web/src/components/ChatViewBrowser.shared.tsx +++ b/apps/web/src/components/ChatViewBrowser.shared.tsx @@ -12,6 +12,7 @@ import { type ProjectId, ProviderDriverKind, ProviderInstanceId, + type RuntimeMode, type ServerConfig, type ServerLifecycleWelcomePayload, type ThreadId, @@ -228,7 +229,13 @@ function createSnapshotForTargetUser(options: { targetText: string; targetAttachmentCount?: number; sessionStatus?: OrchestrationSessionStatus; + provider?: "codex" | "claudeAgent"; + runtimeMode?: RuntimeMode; }): OrchestrationReadModel { + const provider = ProviderDriverKind.make(options.provider ?? "codex"); + const instanceId = ProviderInstanceId.make(options.provider ?? "codex"); + const model = provider === "claudeAgent" ? "claude-opus-4-8" : "gpt-5"; + const runtimeMode = options.runtimeMode ?? "full-access"; const messages: Array = []; for (let index = 0; index < 22; index += 1) { @@ -273,8 +280,8 @@ function createSnapshotForTargetUser(options: { workspaceRoot: "/repo/project", additionalWorkspaceRoots: [], defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5", + instanceId, + model, }, scripts: [], createdAt: NOW_ISO, @@ -288,11 +295,11 @@ function createSnapshotForTargetUser(options: { projectId: PROJECT_ID, title: THREAD_TITLE, modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5", + instanceId, + model, }, interactionMode: "default", - runtimeMode: "full-access", + runtimeMode, branch: "main", worktreePath: null, latestTurn: null, @@ -307,8 +314,8 @@ function createSnapshotForTargetUser(options: { session: { threadId: THREAD_ID, status: options.sessionStatus ?? "ready", - providerName: "codex", - runtimeMode: "full-access", + providerName: provider, + runtimeMode, activeTurnId: null, lastError: null, updatedAt: NOW_ISO, @@ -1378,7 +1385,7 @@ async function expectComposerActionsContained(): Promise { } async function waitForInteractionModeButton( - expectedLabel: "Build" | "Plan", + expectedLabel: "Build" | "Plan" | "Manual" | "Accept edits" | "Auto", ): Promise { return waitForElement( () => @@ -3163,6 +3170,66 @@ describe(`ChatView full app (${chatViewBrowserPart})`, () => { } }); + it("cycles Claude's four normal permission modes with Shift+Tab", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-target-claude-mode-hotkey" as MessageId, + targetText: "claude mode hotkey target", + provider: "claudeAgent", + runtimeMode: "approval-required", + }), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + providers: [ + ...nextFixture.serverConfig.providers, + { + driver: ProviderDriverKind.make("claudeAgent"), + instanceId: ProviderInstanceId.make("claudeAgent"), + enabled: true, + installed: true, + version: "2.1.216", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: NOW_ISO, + models: [ + { + slug: "claude-opus-4-8", + name: "Claude Opus 4.8", + isCustom: false, + capabilities: createModelCapabilities({ optionDescriptors: [] }), + }, + ], + slashCommands: [], + skills: [], + }, + ], + }; + }, + }); + + try { + await waitForInteractionModeButton("Manual"); + const composerEditor = await waitForComposerEditor(); + composerEditor.focus(); + + for (const expectedLabel of ["Accept edits", "Plan", "Auto", "Manual"] as const) { + composerEditor.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Tab", + shiftKey: true, + bubbles: true, + cancelable: true, + }), + ); + await waitForInteractionModeButton(expectedLabel); + } + } finally { + await mounted.cleanup(); + } + }); + it("uses the active draft route session when changing the base branch", async () => { const staleDraftId = draftIdFromPath("/draft/draft-stale-branch-session"); const activeDraftId = draftIdFromPath("/draft/draft-active-branch-session"); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 3c0b0d7e..836bab87 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -93,6 +93,7 @@ import { LockIcon, LockOpenIcon, PenLineIcon, + ShieldCheckIcon, Trash2Icon, XIcon, } from "lucide-react"; @@ -114,6 +115,15 @@ import { formatProviderSkillDisplayName } from "../../providerSkillPresentation" import { searchProviderSkills } from "../../providerSkillSearch"; import { useHasOnScreenKeyboard } from "../../hooks/useMediaQuery"; import { domSnapshot, mobileDebugLog } from "../../lib/mobileDebugLog"; +import { + applyClaudePermissionMode, + CLAUDE_PERMISSION_MODE_OPTIONS, + type ClaudePermissionMode, + deriveClaudePermissionMode, + getClaudePermissionModeOption, + getNextClaudePermissionMode, + isClaudePermissionMode, +} from "./claudePermissionMode"; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; @@ -139,6 +149,13 @@ const runtimeModeConfig: Record< }; const runtimeModeOptions = Object.keys(runtimeModeConfig) as RuntimeMode[]; +const claudePermissionModeIcons: Record = { + default: LockIcon, + acceptEdits: PenLineIcon, + plan: BotIcon, + auto: ShieldCheckIcon, + bypassPermissions: LockOpenIcon, +}; const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; const COMPOSER_FLOATING_LAYER_SELECTOR = [ @@ -165,6 +182,7 @@ function isInsideComposerFloatingLayer(element: Element): boolean { } const ComposerFooterModeControls = memo(function ComposerFooterModeControls(props: { + provider: ProviderDriverKind; showInteractionModeToggle: boolean; interactionMode: ProviderInteractionMode; runtimeMode: RuntimeMode; @@ -172,17 +190,67 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop planSidebarLabel: string; planSidebarOpen: boolean; onToggleInteractionMode: () => void; + onClaudePermissionModeChange: (mode: ClaudePermissionMode) => void; onRuntimeModeChange: (mode: RuntimeMode) => void; onTogglePlanSidebar: () => void; }) { + const isClaude = props.provider === "claudeAgent"; const runtimeModeOption = runtimeModeConfig[props.runtimeMode]; const RuntimeModeIcon = runtimeModeOption.icon; + const claudePermissionMode = deriveClaudePermissionMode({ + interactionMode: props.interactionMode, + runtimeMode: props.runtimeMode, + }); + const claudePermissionModeOption = getClaudePermissionModeOption(claudePermissionMode); + const ClaudePermissionModeIcon = claudePermissionModeIcons[claudePermissionMode]; return ( <> - {props.showInteractionModeToggle ? ( + {props.showInteractionModeToggle && isClaude ? ( + <> + + + + + ) : props.showInteractionModeToggle ? ( <> ) : null} @@ -4112,6 +4116,27 @@ export default function Sidebar() { if (!bridge || !desktopUpdateState) return; if (desktopUpdateButtonDisabled || desktopUpdateButtonAction === "none") return; + if (desktopUpdateButtonAction === "manual") { + void bridge + .openExternal(getDesktopUpdateReleaseUrl(desktopUpdateState.availableVersion)) + .then((opened) => { + if (opened) return; + toastManager.add({ + type: "error", + title: "Could not open release", + description: "Open the Cafe Code releases page in your browser to install the DMG.", + }); + }) + .catch(() => { + toastManager.add({ + type: "error", + title: "Could not open release", + description: "Open the Cafe Code releases page in your browser to install the DMG.", + }); + }); + return; + } + if (desktopUpdateButtonAction === "download") { void bridge .downloadUpdate() diff --git a/apps/web/src/components/desktopUpdate.logic.test.ts b/apps/web/src/components/desktopUpdate.logic.test.ts new file mode 100644 index 00000000..6a8ec17c --- /dev/null +++ b/apps/web/src/components/desktopUpdate.logic.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; + +import type { DesktopUpdateState } from "@cafecode/contracts"; + +import { + getDesktopUpdateButtonTooltip, + getDesktopUpdateReleaseUrl, + resolveDesktopUpdateButtonAction, +} from "./desktopUpdate.logic"; + +function makeState(overrides: Partial = {}): DesktopUpdateState { + return { + enabled: true, + status: "available", + channel: "latest", + installMode: "in-app", + currentVersion: "1.0.0", + hostArch: "x64", + appArch: "x64", + runningUnderArm64Translation: false, + availableVersion: "1.1.0", + downloadedVersion: null, + downloadPercent: null, + checkedAt: "2026-07-21T00:00:00.000Z", + message: null, + errorContext: null, + canRetry: false, + ...overrides, + }; +} + +describe("desktop update presentation", () => { + it("keeps supported updates on the in-app download path", () => { + expect(resolveDesktopUpdateButtonAction(makeState())).toBe("download"); + }); + + it("routes unsigned macOS updates to the exact GitHub release", () => { + const state = makeState({ installMode: "manual" }); + + expect(resolveDesktopUpdateButtonAction(state)).toBe("manual"); + expect(getDesktopUpdateButtonTooltip(state)).toContain("GitHub release"); + expect(getDesktopUpdateReleaseUrl(state.availableVersion)).toBe( + "https://github.com/cafeai/cafe-code/releases/tag/v1.1.0", + ); + }); + + it("falls back to the releases index when no version is available", () => { + expect(getDesktopUpdateReleaseUrl(null)).toBe("https://github.com/cafeai/cafe-code/releases"); + }); +}); diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts index 6e1fc42c..64fd5f05 100644 --- a/apps/web/src/components/desktopUpdate.logic.ts +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -1,10 +1,15 @@ import type { DesktopUpdateActionResult, DesktopUpdateState } from "@cafecode/contracts"; -export type DesktopUpdateButtonAction = "download" | "install" | "none"; +export type DesktopUpdateButtonAction = "download" | "install" | "manual" | "none"; + +const DESKTOP_RELEASES_URL = "https://github.com/cafeai/cafe-code/releases"; export function resolveDesktopUpdateButtonAction( state: DesktopUpdateState, ): DesktopUpdateButtonAction { + if (state.installMode === "manual" && state.availableVersion) { + return "manual"; + } if (state.downloadedVersion) { return "install"; } @@ -19,6 +24,11 @@ export function resolveDesktopUpdateButtonAction( return "none"; } +export function getDesktopUpdateReleaseUrl(version: string | null): string { + if (!version) return DESKTOP_RELEASES_URL; + return `${DESKTOP_RELEASES_URL}/tag/v${encodeURIComponent(version)}`; +} + export function shouldShowDesktopUpdateButton(state: DesktopUpdateState | null): boolean { if (!state || !state.enabled) { return false; @@ -54,6 +64,9 @@ export function getArm64IntelBuildWarningDescription(state: DesktopUpdateState): export function getDesktopUpdateButtonTooltip(state: DesktopUpdateState): string { if (state.status === "available") { + if (state.installMode === "manual") { + return `Update ${state.availableVersion ?? "available"} can be installed from its GitHub release`; + } return `Update ${state.availableVersion ?? "available"} ready to download`; } if (state.status === "downloading") { diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index 1478f548..68b144de 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -503,6 +503,7 @@ const createDesktopBridgeStub = (overrides?: { enabled: false, status: "idle", channel: "latest", + installMode: "in-app", currentVersion: "0.0.0-test", hostArch: "arm64", appArch: "arm64", diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index d7e5b74d..3764dbb6 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -12,6 +12,7 @@ import { getDesktopUpdateActionError, getDesktopUpdateButtonTooltip, getDesktopUpdateInstallConfirmationMessage, + getDesktopUpdateReleaseUrl, isDesktopUpdateButtonDisabled, resolveDesktopUpdateButtonAction, shouldShowArm64IntelBuildWarning, @@ -40,6 +41,27 @@ export function SidebarUpdatePill() { if (!bridge || !state) return; if (disabled || action === "none") return; + if (action === "manual") { + void bridge + .openExternal(getDesktopUpdateReleaseUrl(state.availableVersion)) + .then((opened) => { + if (opened) return; + toastManager.add({ + type: "error", + title: "Could not open release", + description: "Open the Cafe Code releases page in your browser to install the DMG.", + }); + }) + .catch(() => { + toastManager.add({ + type: "error", + title: "Could not open release", + description: "Open the Cafe Code releases page in your browser to install the DMG.", + }); + }); + return; + } + if (action === "download") { void bridge .downloadUpdate() @@ -139,6 +161,11 @@ export function SidebarUpdatePill() { Restart to update + ) : action === "manual" ? ( + <> + + View macOS update + ) : state?.status === "downloading" ? ( <> @@ -160,7 +187,7 @@ export function SidebarUpdatePill() { /> {tooltip} - {action === "download" && ( + {(action === "download" || action === "manual") && ( = 1`. The release workflow builds +Windows x64 NSIS, macOS arm64/x64 DMG plus updater ZIP, and Linux x64 AppImage. Linux formats other +than x64 AppImage are not currently supported. + +Before publication, the workflow merges the two macOS manifests and verifies every updater asset's +size and SHA-512 digest. It also publishes `SHA256SUMS.txt` for manual downloads. After publication, +a lower-version packaged AppImage runs a detection-only probe against the public GitHub feed and +must report the exact released version. The probe never downloads or installs the update. + +## Runtime Behavior + +Packaged apps check 15 seconds after launch and every four minutes afterward. Users can also check +from the application menu. Cafe Code does not automatically download an update and does not install +one merely because the app exits. + +- Windows x64: the user downloads from Cafe Code and confirms restart. The unsigned NSIS installer + runs for the current user with Electron Updater's `--updated /S --force-run` arguments. The update + path does not rerun the fresh-install managed-provider bootstrap. Windows may show Unknown + Publisher or SmartScreen warnings because there is no Authenticode certificate. +- macOS arm64/x64: Cafe Code detects the release but opens its exact GitHub release page for manual + DMG installation. Replace Cafe Code in `/Applications` from the DMG. Squirrel.Mac requires a + signed app, so unsigned builds must never offer in-place restart-and-install. Gatekeeper may + require the user to approve the unsigned app in Privacy & Security. +- Linux x64 AppImage: update checks run only when Cafe Code was launched from an AppImage and the + `APPIMAGE` path is available. After an explicit download and restart confirmation, Electron + Updater replaces the AppImage and relaunches it. The containing directory and AppImage must be + writable. If replacement fails, download the newer AppImage from GitHub and run it manually. + +Source checkouts use the separate Git branch update diagnostic. They compare commits on `main` or +`dev`; they do not consume GitHub Release manifests and cannot install packaged updates. + +## Trust Model + +The feed and artifacts are delivered over GitHub HTTPS, and Electron Updater verifies the SHA-512 +digest recorded in the release manifest before using a downloaded Windows or Linux artifact. This +detects transport corruption but does not establish publisher identity. Anyone who can publish to +the GitHub repository can distribute an unsigned executable accepted by the updater. Adding Apple +Developer ID signing/notarization and Windows Authenticode signing is required before describing +these artifacts as publisher-authenticated. diff --git a/docs/release-readiness-worklist.md b/docs/release-readiness-worklist.md index 71cc85ef..723a7542 100644 --- a/docs/release-readiness-worklist.md +++ b/docs/release-readiness-worklist.md @@ -146,7 +146,13 @@ Each item includes the concrete attack or data-exposure vector. Where no malicio These are current incorrect, unreachable, contradictory, crash-prone, or non-portable behaviors. -- [ ] **RB-02 — Packaged automatic updates are unreachable.** The updater is implemented but an unconditional function disables it in packaged builds. Define channels and Linux behavior, then enable and test signed update/recovery flows—or remove the updater surface entirely. +- [x] **RB-02 — Packaged automatic updates are unreachable.** Packaged update detection is enabled, + stable/nightly release feeds are automated, and Linux support is explicitly x64 AppImage-only. + Tagged releases validate all update manifests and run a lower-version packaged AppImage detection + probe against GitHub. Windows NSIS and Linux AppImage installs remain unsigned; macOS detects the + update but uses manual DMG replacement because Squirrel.Mac requires signing. Native install, + recovery, signing, and notarization certification remain release-hardening work. See + [`desktop-releases-and-updates.md`](desktop-releases-and-updates.md). - [ ] **RB-07 — Provider availability checks accept versions outside a tested contract.** Codex and Claude detect versions without enforcing a supported base range; incompatible binaries can fail mid-turn. Publish/enforce version ranges, produce actionable health failures, regenerate Codex schemas in CI, and add supported-version fixtures/live canaries. diff --git a/oxlint-plugin-cafecode/test/utils.ts b/oxlint-plugin-cafecode/test/utils.ts index 6e7a1ad4..ffe09f38 100644 --- a/oxlint-plugin-cafecode/test/utils.ts +++ b/oxlint-plugin-cafecode/test/utils.ts @@ -28,7 +28,9 @@ class OxlintFixtureExpectedFailure extends Data.TaggedError("OxlintFixtureExpect } const encodeOxlintConfig = Schema.encodeEffect(Schema.UnknownFromJsonString); -const OXLINT_FIXTURE_TEST_TIMEOUT_MS = process.platform === "win32" ? 30_000 : undefined; +// The harness shells out to oxlint and loads the local plugin entrypoint, which can +// exceed Vitest's default 5s budget on slower CI machines even when the rule passes. +const OXLINT_FIXTURE_TEST_TIMEOUT_MS = process.platform === "win32" ? 30_000 : 10_000; interface RuleHarness { readonly run: ( diff --git a/package.json b/package.json index 2ba550d4..39d3cf13 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "dist:aur:cafe-code": "makepkg --cleanbuild --force --dir packaging/aur/cafe-code", "dist:arch:local": "node scripts/build-arch-package.ts", "release:smoke": "node scripts/release-smoke.ts", + "validate:desktop-release": "node scripts/validate-update-release.ts", "audit:repository": "node scripts/repository-audit.ts", "db:compact-activity-payloads": "node scripts/compact-activity-payloads.ts", "clean": "node scripts/clean.ts", diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index ebdf7cf6..6d627ba8 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -126,6 +126,7 @@ export type DesktopUpdateStatus = export type DesktopRuntimeArch = "arm64" | "x64" | "other"; export type DesktopTheme = "light" | "dark" | "system"; export type DesktopUpdateChannel = "latest" | "nightly"; +export type DesktopUpdateInstallMode = "in-app" | "manual"; export type DesktopAppStageLabel = "Alpha" | "Dev" | "Nightly"; export const DesktopUpdateStatusSchema = Schema.Literals([ @@ -141,6 +142,7 @@ export const DesktopUpdateStatusSchema = Schema.Literals([ export const DesktopRuntimeArchSchema = Schema.Literals(["arm64", "x64", "other"]); export const DesktopThemeSchema = Schema.Literals(["light", "dark", "system"]); export const DesktopUpdateChannelSchema = Schema.Literals(["latest", "nightly"]); +export const DesktopUpdateInstallModeSchema = Schema.Literals(["in-app", "manual"]); export const DesktopAppStageLabelSchema = Schema.Literals(["Alpha", "Dev", "Nightly"]); export interface DesktopAppBranding { @@ -171,6 +173,7 @@ export interface DesktopUpdateState { enabled: boolean; status: DesktopUpdateStatus; channel: DesktopUpdateChannel; + installMode: DesktopUpdateInstallMode; currentVersion: string; hostArch: DesktopRuntimeArch; appArch: DesktopRuntimeArch; @@ -188,6 +191,7 @@ export const DesktopUpdateStateSchema = Schema.Struct({ enabled: Schema.Boolean, status: DesktopUpdateStatusSchema, channel: DesktopUpdateChannelSchema, + installMode: DesktopUpdateInstallModeSchema, currentVersion: Schema.String, hostArch: DesktopRuntimeArchSchema, appArch: DesktopRuntimeArchSchema, diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 1db59e05..381754a1 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -923,6 +923,12 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* ( target: target === "dmg" ? [target, "zip"] : [target], icon: "icon.icns", category: "public.app-category.developer-tools", + ...(signed + ? {} + : { + identity: null, + hardenedRuntime: false, + }), }; } diff --git a/scripts/json-file.ts b/scripts/json-file.ts new file mode 100644 index 00000000..40eeb81f --- /dev/null +++ b/scripts/json-file.ts @@ -0,0 +1,9 @@ +import { readFile } from "node:fs/promises"; + +export function parseJsonText(jsonText: string): unknown { + return JSON.parse(jsonText.charCodeAt(0) === 0xfeff ? jsonText.slice(1) : jsonText); +} + +export async function readJsonFile(filePath: string): Promise { + return parseJsonText(await readFile(filePath, "utf8")); +} diff --git a/scripts/lib/update-manifest.ts b/scripts/lib/update-manifest.ts index 191a3c0e..3c059efd 100644 --- a/scripts/lib/update-manifest.ts +++ b/scripts/lib/update-manifest.ts @@ -2,6 +2,7 @@ export interface UpdateManifestFile { readonly url: string; readonly sha512: string; readonly size: number; + readonly blockMapSize?: number; } export type UpdateManifestScalar = string | number | boolean; @@ -17,6 +18,7 @@ interface MutableUpdateManifestFile { url?: string; sha512?: string; size?: number; + blockMapSize?: number; } function stripSingleQuotes(value: string): string { @@ -48,6 +50,7 @@ function parseFileRecord( url: currentFile.url, sha512: currentFile.sha512, size: currentFile.size, + ...(currentFile.blockMapSize === undefined ? {} : { blockMapSize: currentFile.blockMapSize }), }; } @@ -113,6 +116,17 @@ export function parseUpdateManifest( continue; } + const fileBlockMapSizeMatch = line.match(/^ blockMapSize:\s*(\d+)$/); + if (fileBlockMapSizeMatch?.[1]) { + if (currentFile === null) { + throw new Error( + `Invalid ${platformLabel} update manifest at ${sourcePath}:${lineNumber}: blockMapSize without a file entry.`, + ); + } + currentFile.blockMapSize = Number(fileBlockMapSizeMatch[1]); + continue; + } + if (line === "files:") { inFiles = true; continue; @@ -219,7 +233,12 @@ export function mergeUpdateManifests( const filesByUrl = new Map(); for (const file of [...primary.files, ...secondary.files]) { const existing = filesByUrl.get(file.url); - if (existing && (existing.sha512 !== file.sha512 || existing.size !== file.size)) { + if ( + existing && + (existing.sha512 !== file.sha512 || + existing.size !== file.size || + existing.blockMapSize !== file.blockMapSize) + ) { throw new Error( `Cannot merge ${platformLabel} update manifests: conflicting file entry for ${file.url}.`, ); @@ -259,6 +278,9 @@ export function serializeUpdateManifest( lines.push(` - url: ${file.url}`); lines.push(` sha512: ${file.sha512}`); lines.push(` size: ${file.size}`); + if (file.blockMapSize !== undefined) { + lines.push(` blockMapSize: ${file.blockMapSize}`); + } } for (const key of Object.keys(manifest.extras).toSorted()) { diff --git a/scripts/merge-update-manifests.test.ts b/scripts/merge-update-manifests.test.ts index 5f5ad591..37a0b592 100644 --- a/scripts/merge-update-manifests.test.ts +++ b/scripts/merge-update-manifests.test.ts @@ -5,6 +5,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import { Command, CliError } from "effect/unstable/cli"; +import { parseUpdateManifest, serializeUpdateManifest } from "./lib/update-manifest.ts"; import { mergePlatformUpdateManifests, mergeUpdateManifestsCommand, @@ -185,6 +186,26 @@ releaseDate: '2026-03-07T10:36:07.540Z' const reparsed = parsePlatformUpdateManifest("win", serialized, "latest-win-x64.yml"); assert.equal(reparsed.version, "1.0"); }); + + it("round-trips embedded AppImage block map metadata", () => { + const original = parseUpdateManifest( + `version: '1.0.0' +files: + - url: Cafe-Code-1.0.0-x86_64.AppImage + sha512: appimagesha + size: 125621344 + blockMapSize: 263625 +releaseDate: '2026-07-21T10:36:07.540Z' +`, + "latest-linux.yml", + "Linux", + ); + + assert.equal(original.files[0]?.blockMapSize, 263625); + const serialized = serializeUpdateManifest(original, { platformLabel: "Linux" }); + const reparsed = parseUpdateManifest(serialized, "latest-linux.yml", "Linux"); + assert.deepStrictEqual(reparsed, original); + }); }); it.layer(NodeServices.layer)("merge-update-manifests cli", (it) => { diff --git a/scripts/native-desktop-runtime-smoke.ts b/scripts/native-desktop-runtime-smoke.ts index 3ad7ec19..4e024a12 100644 --- a/scripts/native-desktop-runtime-smoke.ts +++ b/scripts/native-desktop-runtime-smoke.ts @@ -1,9 +1,11 @@ import { spawn, spawnSync } from "node:child_process"; import { createServer } from "node:net"; -import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, posix, resolve, win32 } from "node:path"; +import { readJsonFile } from "./json-file.ts"; + const SELF_TEST_SWITCH = "--cafe-runtime-self-test"; const SELF_TEST_RESULT_ENV = "CAFE_CODE_RUNTIME_SELF_TEST_RESULT"; const DISABLE_CHROMIUM_SANDBOX_ENV = "CAFE_CODE_NATIVE_SMOKE_DISABLE_CHROMIUM_SANDBOX"; @@ -253,7 +255,7 @@ async function runPackagedRuntimeSelfTest( { env: { ...environment, [SELF_TEST_RESULT_ENV]: resultPath } }, ); if (result.exitCode !== 0) throw new Error("Packaged desktop runtime self-test exited nonzero."); - const decoded = JSON.parse(await readFile(resultPath, "utf8")) as unknown; + const decoded = await readJsonFile(resultPath); return assertRuntimeSelfTestResult(decoded); } diff --git a/scripts/start-cafe-code.test.ts b/scripts/start-cafe-code.test.ts new file mode 100644 index 00000000..e242efb8 --- /dev/null +++ b/scripts/start-cafe-code.test.ts @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +import { describe, it } from "vitest"; + +const startCafeCodeScript = fileURLToPath(new URL("../Start-CafeCode.ps1", import.meta.url)); + +function toPowerShellLiteralPath(path: string): string { + return path.replaceAll("'", "''"); +} + +function hasPowerShell(): boolean { + const result = spawnSync( + "pwsh", + ["-NoLogo", "-NoProfile", "-Command", "$PSVersionTable.PSVersion"], + { + encoding: "utf8", + }, + ); + return result.error === undefined && result.status === 0; +} + +function runPowerShell(script: string): string { + return execFileSync("pwsh", ["-NoLogo", "-NoProfile", "-Command", script], { + encoding: "utf8", + }).trim(); +} + +const powerShellIt = hasPowerShell() ? it : it.skip; + +describe("Start-CafeCode PowerShell helpers", () => { + powerShellIt( + "selects the first Node executable when Get-Command returns multiple matches", + () => { + const selectedPath = runPowerShell(` +. '${toPowerShellLiteralPath(startCafeCodeScript)}' +function Get-Command { + param([string]$Name, [string]$CommandType, [object]$ErrorAction) + + if ($Name -eq "node.exe") { + return @( + [pscustomobject]@{ Path = "C:\\hostedtoolcache\\windows\\node\\24.13.1\\x64\\node.exe" }, + [pscustomobject]@{ Path = "C:\\Program Files\\nodejs\\node.exe" } + ) + } + + return $null +} + +$resolved = Resolve-FirstApplicationPath -Names @("node.exe", "node") +[Console]::Out.Write($resolved) +`); + + assert.equal(selectedPath, "C:\\hostedtoolcache\\windows\\node\\24.13.1\\x64\\node.exe"); + }, + ); + + powerShellIt("falls back to the next candidate name when the first one is absent", () => { + const selectedPath = runPowerShell(` +. '${toPowerShellLiteralPath(startCafeCodeScript)}' +function Get-Command { + param([string]$Name, [string]$CommandType, [object]$ErrorAction) + + if ($Name -eq "node") { + return [pscustomobject]@{ Path = "C:\\Program Files\\nodejs\\node.exe" } + } + + return $null +} + +$resolved = Resolve-FirstApplicationPath -Names @("node.exe", "node") +[Console]::Out.Write($resolved) +`); + + assert.equal(selectedPath, "C:\\Program Files\\nodejs\\node.exe"); + }); +}); diff --git a/scripts/validate-update-release.test.ts b/scripts/validate-update-release.test.ts new file mode 100644 index 00000000..6d689c7e --- /dev/null +++ b/scripts/validate-update-release.test.ts @@ -0,0 +1,105 @@ +import { createHash } from "node:crypto"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { serializeUpdateManifest, type UpdateManifest } from "./lib/update-manifest.ts"; +import { validateDesktopUpdateRelease } from "./validate-update-release.ts"; + +const version = "1.2.3-nightly.20260721.1"; +const releaseDirs: string[] = []; + +function sha512(bytes: Uint8Array): string { + return createHash("sha512").update(bytes).digest("base64"); +} + +async function makeReleaseFile(releaseDir: string, fileName: string): Promise { + const bytes = new TextEncoder().encode(`fixture:${fileName}`); + await writeFile(join(releaseDir, fileName), bytes); + return bytes; +} + +async function writeManifest( + releaseDir: string, + fileName: string, + assetNames: readonly string[], +): Promise { + const files: Array = []; + for (const assetName of assetNames) { + const bytes = await readFile(join(releaseDir, assetName)); + files.push({ + url: assetName, + sha512: sha512(bytes), + size: bytes.length, + ...(assetName.endsWith(".AppImage") ? { blockMapSize: 263625 } : {}), + }); + } + await writeFile( + join(releaseDir, fileName), + serializeUpdateManifest( + { + version, + releaseDate: "2026-07-21T00:00:00.000Z", + files, + extras: {}, + }, + { platformLabel: fileName }, + ), + ); +} + +async function makeValidRelease(): Promise { + const releaseDir = await mkdtemp(join(tmpdir(), "cafe-update-release-")); + releaseDirs.push(releaseDir); + const assets = [ + `Cafe-Code-${version}-x64.exe`, + `Cafe-Code-${version}-arm64.dmg`, + `Cafe-Code-${version}-x64.dmg`, + `Cafe-Code-${version}-arm64.zip`, + `Cafe-Code-${version}-x64.zip`, + `Cafe-Code-${version}-x86_64.AppImage`, + ] as const; + await Promise.all(assets.map((asset) => makeReleaseFile(releaseDir, asset))); + await writeManifest(releaseDir, "nightly.yml", [assets[0]]); + await writeManifest(releaseDir, "nightly-mac.yml", [assets[3], assets[4]]); + await writeManifest(releaseDir, "nightly-linux.yml", [assets[5]]); + return releaseDir; +} + +afterEach(async () => { + await Promise.all(releaseDirs.splice(0).map((releaseDir) => rm(releaseDir, { recursive: true }))); +}); + +describe("validateDesktopUpdateRelease", () => { + it("validates every platform feed and writes sorted manual-download checksums", async () => { + const releaseDir = await makeValidRelease(); + + await expect( + validateDesktopUpdateRelease({ releaseDir, version, channel: "nightly" }), + ).resolves.toMatchObject({ version, channel: "nightly", fileCount: 9 }); + + const checksums = await readFile(join(releaseDir, "SHA256SUMS.txt"), "utf8"); + expect(checksums).toContain(`Cafe-Code-${version}-x86_64.AppImage`); + expect(checksums).toContain("nightly-mac.yml"); + }); + + it("rejects a missing required platform artifact", async () => { + const releaseDir = await makeValidRelease(); + await rm(join(releaseDir, `Cafe-Code-${version}-arm64.dmg`)); + + await expect( + validateDesktopUpdateRelease({ releaseDir, version, channel: "nightly" }), + ).rejects.toThrow("missing required file"); + }); + + it("rejects manifest checksum tampering", async () => { + const releaseDir = await makeValidRelease(); + await writeFile(join(releaseDir, `Cafe-Code-${version}-x86_64.AppImage`), "tampered"); + + await expect( + validateDesktopUpdateRelease({ releaseDir, version, channel: "nightly" }), + ).rejects.toThrow(/size|SHA-512/); + }); +}); diff --git a/scripts/validate-update-release.ts b/scripts/validate-update-release.ts new file mode 100644 index 00000000..89b2d047 --- /dev/null +++ b/scripts/validate-update-release.ts @@ -0,0 +1,214 @@ +#!/usr/bin/env node + +import { createHash } from "node:crypto"; +import { readdir, readFile, stat, writeFile } from "node:fs/promises"; +import { basename, join, resolve } from "node:path"; + +import { parseUpdateManifest, type UpdateManifest } from "./lib/update-manifest.ts"; + +export type DesktopReleaseChannel = "latest" | "nightly"; + +export interface ValidateDesktopUpdateReleaseInput { + readonly releaseDir: string; + readonly version: string; + readonly channel: DesktopReleaseChannel; + readonly writeChecksums?: boolean; +} + +export interface DesktopUpdateReleaseValidationResult { + readonly version: string; + readonly channel: DesktopReleaseChannel; + readonly fileCount: number; + readonly manifests: readonly string[]; +} + +function sha512Base64(bytes: Uint8Array): string { + return createHash("sha512").update(bytes).digest("base64"); +} + +function sha256Hex(bytes: Uint8Array): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +function decodeManifestFileName(url: string, manifestName: string): string { + let fileName: string; + try { + fileName = decodeURIComponent(url); + } catch { + throw new Error(`${manifestName} contains an invalid encoded asset URL: ${url}`); + } + + if (!fileName || basename(fileName) !== fileName || fileName.includes("\\")) { + throw new Error(`${manifestName} contains a non-local asset URL: ${url}`); + } + return fileName; +} + +async function validateManifestFiles( + releaseDir: string, + manifestName: string, + manifest: UpdateManifest, + expectedVersion: string, +): Promise> { + if (manifest.version !== expectedVersion) { + throw new Error( + `${manifestName} has version ${manifest.version}; expected ${expectedVersion}.`, + ); + } + + const referencedFiles = new Set(); + for (const file of manifest.files) { + const fileName = decodeManifestFileName(file.url, manifestName); + if (referencedFiles.has(fileName)) { + throw new Error(`${manifestName} references ${fileName} more than once.`); + } + referencedFiles.add(fileName); + + const filePath = join(releaseDir, fileName); + const fileStat = await stat(filePath).catch(() => null); + if (!fileStat?.isFile()) { + throw new Error(`${manifestName} references missing asset ${fileName}.`); + } + if (fileStat.size !== file.size) { + throw new Error( + `${manifestName} records size ${file.size} for ${fileName}; actual size is ${fileStat.size}.`, + ); + } + const bytes = await readFile(filePath); + if (sha512Base64(bytes) !== file.sha512) { + throw new Error(`${manifestName} has an invalid SHA-512 checksum for ${fileName}.`); + } + } + return referencedFiles; +} + +function expectedReleaseFiles(version: string, channel: DesktopReleaseChannel): readonly string[] { + return [ + `Cafe-Code-${version}-x64.exe`, + `Cafe-Code-${version}-arm64.dmg`, + `Cafe-Code-${version}-x64.dmg`, + `Cafe-Code-${version}-arm64.zip`, + `Cafe-Code-${version}-x64.zip`, + `Cafe-Code-${version}-x86_64.AppImage`, + `${channel}.yml`, + `${channel}-mac.yml`, + `${channel}-linux.yml`, + ]; +} + +async function assertRequiredFiles( + releaseDir: string, + requiredFiles: readonly string[], +): Promise { + for (const fileName of requiredFiles) { + const fileStat = await stat(join(releaseDir, fileName)).catch(() => null); + if (!fileStat?.isFile()) { + throw new Error(`Desktop release is missing required file ${fileName}.`); + } + } +} + +function assertManifestPayloads( + version: string, + manifestFiles: Readonly>>, +): void { + const expectedByManifest: Readonly> = { + windows: [`Cafe-Code-${version}-x64.exe`], + mac: [`Cafe-Code-${version}-arm64.zip`, `Cafe-Code-${version}-x64.zip`], + linux: [`Cafe-Code-${version}-x86_64.AppImage`], + }; + + for (const [platform, expectedFiles] of Object.entries(expectedByManifest)) { + const actualFiles = manifestFiles[platform]; + if (!actualFiles) { + throw new Error(`Desktop release validation did not load the ${platform} manifest.`); + } + for (const fileName of expectedFiles) { + if (!actualFiles.has(fileName)) { + throw new Error(`The ${platform} update manifest does not reference ${fileName}.`); + } + } + } +} + +async function writeReleaseChecksums(releaseDir: string): Promise { + const entries = await readdir(releaseDir, { withFileTypes: true }); + const fileNames = entries + .filter((entry) => entry.isFile() && entry.name !== "SHA256SUMS.txt") + .map((entry) => entry.name) + .toSorted(); + const lines: string[] = []; + for (const fileName of fileNames) { + lines.push(`${sha256Hex(await readFile(join(releaseDir, fileName)))} ${fileName}`); + } + await writeFile(join(releaseDir, "SHA256SUMS.txt"), `${lines.join("\n")}\n`, { + encoding: "utf8", + mode: 0o644, + }); +} + +export async function validateDesktopUpdateRelease( + input: ValidateDesktopUpdateReleaseInput, +): Promise { + const releaseDir = resolve(input.releaseDir); + const entries = await readdir(releaseDir, { withFileTypes: true }); + const unsupportedEntry = entries.find((entry) => !entry.isFile()); + if (unsupportedEntry) { + throw new Error( + `Desktop release directory contains a non-file entry: ${unsupportedEntry.name}.`, + ); + } + + const manifestNames = [ + `${input.channel}.yml`, + `${input.channel}-mac.yml`, + `${input.channel}-linux.yml`, + ] as const; + await assertRequiredFiles(releaseDir, expectedReleaseFiles(input.version, input.channel)); + + const manifestFiles: Record> = {}; + for (const [index, manifestName] of manifestNames.entries()) { + const raw = await readFile(join(releaseDir, manifestName), "utf8"); + const platform = ["windows", "mac", "linux"][index]; + if (!platform) throw new Error(`Unknown manifest index ${index}.`); + const manifest = parseUpdateManifest(raw, manifestName, platform); + manifestFiles[platform] = await validateManifestFiles( + releaseDir, + manifestName, + manifest, + input.version, + ); + } + assertManifestPayloads(input.version, manifestFiles); + + if (input.writeChecksums !== false) { + await writeReleaseChecksums(releaseDir); + } + + return { + version: input.version, + channel: input.channel, + fileCount: entries.length, + manifests: manifestNames, + }; +} + +function readFlag(name: string): string { + const index = process.argv.indexOf(name); + const value = index >= 0 ? process.argv[index + 1] : undefined; + if (!value) throw new Error(`Missing required ${name} argument.`); + return value; +} + +if (import.meta.main) { + const channel = readFlag("--channel"); + if (channel !== "latest" && channel !== "nightly") { + throw new Error(`Invalid --channel value '${channel}'.`); + } + const result = await validateDesktopUpdateRelease({ + releaseDir: readFlag("--release-dir"), + version: readFlag("--version"), + channel, + }); + console.info(JSON.stringify(result)); +} diff --git a/scripts/windows-native-artifact-smoke.test.ts b/scripts/windows-native-artifact-smoke.test.ts index fdc20b85..dc027678 100644 --- a/scripts/windows-native-artifact-smoke.test.ts +++ b/scripts/windows-native-artifact-smoke.test.ts @@ -1,8 +1,15 @@ import { assert, describe, it } from "@effect/vitest"; +import { join } from "node:path"; +import { parseJsonText } from "./json-file.ts"; import { + buildWindowsCmdCommand, + buildWindowsCmdInvocation, + buildManagedProviderProbeEnvironment, + removePathWithRetries, selectInstalledWindowsExecutables, selectWindowsInstaller, + waitForPathToDisappear, } from "./windows-native-artifact-smoke.ts"; describe("Windows native artifact smoke", () => { @@ -48,4 +55,119 @@ describe("Windows native artifact smoke", () => { }, ); }); + + it("parses BOM-prefixed managed runtime JSON", () => { + assert.deepEqual(parseJsonText('\uFEFF{"managedProviderRuntimeEnabled":true,"providers":[]}'), { + managedProviderRuntimeEnabled: true, + providers: [], + }); + }); + + it("probes managed provider shims with the bundled runtime environment", () => { + const managedRoot = "C:\\Users\\runneradmin\\AppData\\Local\\CafeCode\\managed"; + const env = buildManagedProviderProbeEnvironment(managedRoot, "codex", { + Path: "C:\\Windows\\System32", + PATHEXT: ".COM;.EXE;.BAT;.CMD", + }); + const installRoot = join(managedRoot, "providers", "codex", "current"); + + assert.equal( + env.PATH, + [ + join(installRoot, "node_modules", ".bin"), + join(managedRoot, "node", "current"), + "C:\\Windows\\System32", + ].join(";"), + ); + assert.equal(env.Path, undefined); + assert.equal(env.npm_config_prefix, installRoot); + assert.equal(env.npm_config_cache, join(managedRoot, "npm-cache")); + }); + + it("quotes managed Windows shim commands for cmd.exe", () => { + const shim = + "C:\\Users\\runneradmin\\AppData\\Local\\CafeCode\\managed\\providers\\codex\\current\\node_modules\\.bin\\codex.cmd"; + assert.equal(buildWindowsCmdCommand(shim, ["--version"]), `""${shim}" --version"`); + assert.deepEqual(buildWindowsCmdInvocation(shim, ["--version"]), { + command: "cmd.exe", + args: ["/d", "/s", "/c", `""${shim}" --version"`], + windowsVerbatimArguments: true, + }); + }); + + it("supports managed Windows shim commands without arguments", () => { + const shim = + "C:\\Users\\runneradmin\\AppData\\Local\\CafeCode\\managed\\providers\\claude\\current\\node_modules\\.bin\\claude.cmd"; + assert.equal(buildWindowsCmdCommand(shim, []), `""${shim}""`); + }); + + it("retries transient Windows cleanup errors before removing the smoke root", async () => { + let attempts = 0; + const waits: number[] = []; + await removePathWithRetries("C:\\temp\\cafecode-smoke", { + platform: "win32", + remove: async () => { + attempts += 1; + if (attempts < 3) { + const error = new Error("busy") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + }, + sleep: async (ms) => { + waits.push(ms); + }, + }); + assert.equal(attempts, 3); + assert.deepEqual(waits, [250, 250]); + }); + + it("does not retry non-Windows cleanup failures", async () => { + let attempts = 0; + let error: unknown; + try { + await removePathWithRetries("/tmp/cafecode-smoke", { + platform: "linux", + remove: async () => { + attempts += 1; + const busyError = new Error("busy") as NodeJS.ErrnoException; + busyError.code = "EBUSY"; + throw busyError; + }, + sleep: async () => undefined, + }); + } catch (caught) { + error = caught; + } + assert.instanceOf(error, Error); + assert.match((error as Error).message, /busy/); + assert.equal(attempts, 1); + }); + + it("waits for Windows uninstall side effects to remove the app executable", async () => { + let attempts = 0; + const waits: number[] = []; + const disappeared = await waitForPathToDisappear("C:\\temp\\Cafe Code\\Cafe Code.exe", { + platform: "win32", + exists: () => { + attempts += 1; + return attempts < 3; + }, + sleep: async (ms) => { + waits.push(ms); + }, + }); + assert.equal(disappeared, true); + assert.equal(attempts, 3); + assert.deepEqual(waits, [250, 250]); + }); + + it("fails fast for persistent uninstall leftovers on non-Windows", async () => { + const disappeared = await waitForPathToDisappear("/tmp/Cafe Code/Cafe Code.exe", { + platform: "linux", + exists: () => true, + sleep: async () => undefined, + }); + assert.equal(disappeared, false); + }); }); diff --git a/scripts/windows-native-artifact-smoke.ts b/scripts/windows-native-artifact-smoke.ts index 2914dd53..1a944df1 100644 --- a/scripts/windows-native-artifact-smoke.ts +++ b/scripts/windows-native-artifact-smoke.ts @@ -1,12 +1,15 @@ import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; -import { mkdtemp, readFile, readdir, rm } from "node:fs/promises"; +import { mkdtemp, readdir, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { runNativeDesktopRuntimeSmoke } from "./native-desktop-runtime-smoke.ts"; +import { readJsonFile } from "./json-file.ts"; const PROCESS_TIMEOUT_MS = 15 * 60_000; +const WINDOWS_CLEANUP_RETRY_DELAY_MS = 250; +const WINDOWS_CLEANUP_RETRY_ATTEMPTS = 40; interface ProcessResult { readonly exitCode: number | null; @@ -14,16 +17,37 @@ interface ProcessResult { readonly stderr: string; } +interface CleanupDependencies { + readonly platform?: NodeJS.Platform; + readonly remove?: typeof rm; + readonly sleep?: (ms: number) => Promise; +} + +interface PathDisappearanceDependencies { + readonly platform?: NodeJS.Platform; + readonly exists?: (path: string) => boolean; + readonly sleep?: (ms: number) => Promise; +} + +type ManagedProviderSlug = "codex" | "claude"; + +interface ProcessOptions { + readonly env?: NodeJS.ProcessEnv; + readonly timeoutMs?: number; + readonly windowsVerbatimArguments?: boolean; +} + async function runProcess( command: string, args: readonly string[], - options: { readonly env?: NodeJS.ProcessEnv; readonly timeoutMs?: number } = {}, + options: ProcessOptions = {}, ): Promise { return await new Promise((resolveProcess, reject) => { const child = spawn(command, [...args], { env: options.env ?? process.env, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, + windowsVerbatimArguments: options.windowsVerbatimArguments ?? false, }); let stdout = ""; let stderr = ""; @@ -45,6 +69,75 @@ async function runProcess( }); } +function prependWindowsPathEntries( + env: NodeJS.ProcessEnv, + entries: readonly string[], +): NodeJS.ProcessEnv { + const currentPath = env.PATH ?? env.Path ?? ""; + const nextPath = [...entries.filter((entry) => entry.trim().length > 0), currentPath] + .filter((entry) => entry.trim().length > 0) + .join(";"); + return { + ...env, + PATH: nextPath, + Path: undefined, + }; +} + +function isRetryableWindowsCleanupError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + return code === "EBUSY" || code === "EPERM" || code === "ENOTEMPTY"; +} + +async function sleep(ms: number): Promise { + await new Promise((resolveSleep) => setTimeout(resolveSleep, ms)); +} + +export async function removePathWithRetries( + targetPath: string, + dependencies: CleanupDependencies = {}, +): Promise { + const platform = dependencies.platform ?? process.platform; + const remove = dependencies.remove ?? rm; + const wait = dependencies.sleep ?? sleep; + + for (let attempt = 0; ; attempt += 1) { + try { + await remove(targetPath, { recursive: true, force: true }); + return; + } catch (error) { + if ( + platform !== "win32" || + !isRetryableWindowsCleanupError(error) || + attempt >= WINDOWS_CLEANUP_RETRY_ATTEMPTS - 1 + ) { + throw error; + } + // Windows can report parent process exit before the installer/uninstaller + // releases all file handles in the extracted app directory. + await wait(WINDOWS_CLEANUP_RETRY_DELAY_MS); + } + } +} + +export async function waitForPathToDisappear( + targetPath: string, + dependencies: PathDisappearanceDependencies = {}, +): Promise { + const platform = dependencies.platform ?? process.platform; + const pathExists = dependencies.exists ?? existsSync; + const wait = dependencies.sleep ?? sleep; + + for (let attempt = 0; ; attempt += 1) { + if (!pathExists(targetPath)) return true; + if (platform !== "win32" || attempt >= WINDOWS_CLEANUP_RETRY_ATTEMPTS - 1) { + return false; + } + // NSIS can report exit before post-uninstall file deletion finishes. + await wait(WINDOWS_CLEANUP_RETRY_DELAY_MS); + } +} + export function selectWindowsInstaller(fileNames: readonly string[]): string { const matches = fileNames.filter( (fileName) => /^Cafe-Code-.+-x64\.exe$/.test(fileName) && !fileName.startsWith("Uninstall"), @@ -74,7 +167,14 @@ export function selectInstalledWindowsExecutables(fileNames: readonly string[]): } function assertSuccessful(result: ProcessResult, operation: string): void { - if (result.exitCode !== 0) throw new Error(`${operation} exited nonzero.`); + if (result.exitCode === 0) return; + + const details = [ + `exitCode=${result.exitCode === null ? "null" : String(result.exitCode)}`, + result.stdout.trim().length > 0 ? `stdout:\n${result.stdout.trim()}` : null, + result.stderr.trim().length > 0 ? `stderr:\n${result.stderr.trim()}` : null, + ].filter((detail): detail is string => detail !== null); + throw new Error(`${operation} exited nonzero.\n${details.join("\n\n")}`); } async function readUserPathRegistry(): Promise { @@ -90,9 +190,50 @@ function readRecord(value: unknown): Record | undefined { : undefined; } +export function buildManagedProviderProbeEnvironment( + managedRoot: string, + provider: ManagedProviderSlug, + baseEnv: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { + const installRoot = join(managedRoot, "providers", provider, "current"); + const binaryDir = join(installRoot, "node_modules", ".bin"); + const nodeDir = join(managedRoot, "node", "current"); + + // Match the packaged bundled-runtime launcher: npm shims rely on managed + // Node and their local .bin directory being ahead of the ambient user PATH. + return { + ...prependWindowsPathEntries(baseEnv, [binaryDir, nodeDir]), + npm_config_prefix: installRoot, + npm_config_cache: join(managedRoot, "npm-cache"), + }; +} + +export function buildWindowsCmdCommand(commandPath: string, args: readonly string[]): string { + const renderedArgs = args.join(" "); + return `""${commandPath}"${renderedArgs.length > 0 ? ` ${renderedArgs}` : ""}"`; +} + +export function buildWindowsCmdInvocation( + commandPath: string, + args: readonly string[], +): { + readonly command: "cmd.exe"; + readonly args: readonly ["/d", "/s", "/c", string]; + readonly windowsVerbatimArguments: true; +} { + return { + command: "cmd.exe", + args: ["/d", "/s", "/c", buildWindowsCmdCommand(commandPath, args)], + // cmd.exe parses /c payloads differently than CommandLineToArgvW. Passing + // the serialized command through Node's default Windows quoting escapes the + // outer quotes and makes cmd treat the whole payload as a literal filename. + windowsVerbatimArguments: true, + }; +} + async function assertManagedProviderRuntime(managedRoot: string): Promise { const resultPath = join(managedRoot, "install-result.json"); - const result = readRecord(JSON.parse(await readFile(resultPath, "utf8"))); + const result = readRecord(await readJsonFile(resultPath)); const providers = Array.isArray(result?.providers) ? result.providers.map(readRecord) : []; if ( result?.managedProviderRuntimeEnabled !== true || @@ -118,18 +259,14 @@ async function assertManagedProviderRuntime(managedRoot: string): Promise ["codex", "codex.cmd"], ["claude", "claude.cmd"], ] as const) { - const shim = join( - managedRoot, - "providers", - provider, - "current", - "node_modules", - ".bin", - executable, - ); + const installRoot = join(managedRoot, "providers", provider, "current"); + const shim = join(installRoot, "node_modules", ".bin", executable); if (!existsSync(shim)) throw new Error(`Managed ${provider} shim is missing.`); - const probe = await runProcess("cmd.exe", ["/d", "/s", "/c", `"${shim}" --version`], { + const probeInvocation = buildWindowsCmdInvocation(shim, ["--version"]); + const probe = await runProcess(probeInvocation.command, probeInvocation.args, { + env: buildManagedProviderProbeEnvironment(managedRoot, provider, process.env), timeoutMs: 60_000, + windowsVerbatimArguments: probeInvocation.windowsVerbatimArguments, }); assertSuccessful(probe, `Managed ${provider} version probe`); } @@ -171,7 +308,7 @@ export async function runWindowsNativeArtifactSmoke( const uninstall = await runProcess(uninstallerPath, ["/S"], { timeoutMs: 5 * 60_000 }); assertSuccessful(uninstall, "NSIS uninstall"); - if (existsSync(appPath)) + if (!(await waitForPathToDisappear(appPath))) throw new Error("NSIS uninstall left the application executable behind."); uninstallerPath = undefined; console.info("Windows NSIS install/runtime/managed-provider/uninstall smoke passed."); @@ -179,7 +316,7 @@ export async function runWindowsNativeArtifactSmoke( if (uninstallerPath && existsSync(uninstallerPath)) { await runProcess(uninstallerPath, ["/S"], { timeoutMs: 5 * 60_000 }).catch(() => undefined); } - await rm(smokeRoot, { recursive: true, force: true }); + await removePathWithRetries(smokeRoot); } } From 8ffd497e256fa4e4b4da84e40ed14e6c3cdd4be9 Mon Sep 17 00:00:00 2001 From: salt Date: Wed, 22 Jul 2026 01:26:04 +0100 Subject: [PATCH 13/13] chore(release): bump version to 0.1.0 (#10) --- apps/desktop/package.json | 2 +- apps/server/package.json | 2 +- apps/web/package.json | 2 +- packages/contracts/package.json | 2 +- packaging/desktop-runtime/package.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index a691e7ab..45cda8c1 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@cafecode/desktop", - "version": "0.0.51", + "version": "0.1.0", "private": true, "license": "AGPL-3.0-or-later", "type": "module", diff --git a/apps/server/package.json b/apps/server/package.json index a7ff7dd9..601d8e66 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "@cafeai/cafe-code", - "version": "0.0.51", + "version": "0.1.0", "description": "A minimal AI chat harness for coding agents.", "keywords": [ "agent", diff --git a/apps/web/package.json b/apps/web/package.json index e2649064..a03b086a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@cafecode/web", - "version": "0.0.51", + "version": "0.1.0", "private": true, "license": "AGPL-3.0-or-later", "type": "module", diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 8163a38c..6d1ff387 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -1,6 +1,6 @@ { "name": "@cafecode/contracts", - "version": "0.0.51", + "version": "0.1.0", "private": true, "license": "AGPL-3.0-or-later", "files": [ diff --git a/packaging/desktop-runtime/package.json b/packaging/desktop-runtime/package.json index 7708d9af..2c3436ce 100644 --- a/packaging/desktop-runtime/package.json +++ b/packaging/desktop-runtime/package.json @@ -1,6 +1,6 @@ { "name": "@cafecode/desktop-runtime", - "version": "0.0.51", + "version": "0.1.0", "private": true, "scripts": { "postinstall": "node ../../scripts/ensure-desktop-runtime.ts"