From f51c0d25485caf1a70989a0b80d7d8d2fa0e5a5f Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:08:15 -0300 Subject: [PATCH 1/7] perf(open): Cap the boot animation at 200 ms Keep the resolving logo within a 200 ms launch budget without changing the final banner. Co-Authored-By: Codex --- src/open/runtime.ts | 4 ++- tests/open-boot.test.ts | 76 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 tests/open-boot.test.ts diff --git a/src/open/runtime.ts b/src/open/runtime.ts index 17f0a94..740be33 100644 --- a/src/open/runtime.ts +++ b/src/open/runtime.ts @@ -425,7 +425,9 @@ export function bootFrame(progress: number, noise: (column: number) => string): } const BOOT_STEPS = 18; -const BOOT_STEP_MS = 40; +const BOOT_BUDGET_MS = 200; +// Keep all 19 resolving frames within one 200 ms launch budget. +const BOOT_STEP_MS = Math.floor(BOOT_BUDGET_MS / (BOOT_STEPS + 1)); const KATAKANA = [ ...new Set([...SPINNER_VERBS.join("")].filter((glyph) => !/[0-9]/.test(glyph))), ]; diff --git a/tests/open-boot.test.ts b/tests/open-boot.test.ts new file mode 100644 index 0000000..9cc1f9b --- /dev/null +++ b/tests/open-boot.test.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { INDENT, LOGO } from "../src/cli/ui.js"; +import { bootFrame, playBoot, renderBanner } from "../src/open/runtime.js"; + +const role = "reviewer"; +const model = "claude-sonnet"; +const effort = "high"; + +function stubStdout(isTTY: boolean) { + const writes: string[] = []; + const stdout = { + isTTY, + write: vi.fn((chunk: string) => { + writes.push(chunk); + return true; + }), + }; + + vi.spyOn(process, "stdout", "get").mockReturnValue(stdout as NodeJS.WriteStream); + return { stdout, writes }; +} + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe("playBoot", () => { + it("keeps the total TTY animation delay within 200 ms", async () => { + vi.useFakeTimers(); + stubStdout(true); + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); + + const boot = playBoot(role, model, effort); + await vi.runAllTimersAsync(); + await boot; + + const delays = setTimeoutSpy.mock.calls.map(([, delay]) => Number(delay)); + expect(delays).toHaveLength(19); + expect(delays.reduce((total, delay) => total + delay, 0)).toBeLessThanOrEqual(200); + }); + + it("ends with the resolved logo, role details, and booting line", async () => { + vi.useFakeTimers(); + const { writes } = stubStdout(true); + + const boot = playBoot(role, model, effort); + await vi.runAllTimersAsync(); + await boot; + + const ending = writes.slice(-5); + expect(ending.slice(0, 3)).toEqual( + bootFrame(1, () => "").map( + (line) => `\r\x1b[2K${INDENT}\x1b[38;2;225;29;72m${line}\x1b[0m\n`, + ), + ); + expect(ending[3]).toBe( + `${INDENT}\x1b[38;2;163;139;143m${role} · ${model} · ${effort}\x1b[0m\n`, + ); + expect(ending[4]).toBe(`${INDENT}\x1b[38;2;163;139;143mbooting…\x1b[0m\n`); + expect(bootFrame(1, () => "")).toEqual(LOGO); + }); + + it("writes one static banner and schedules no timer when stdout is not a TTY", async () => { + vi.useFakeTimers(); + const { stdout, writes } = stubStdout(false); + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); + + await playBoot(role, model, effort); + + expect(writes).toEqual([renderBanner(role, model, effort)]); + expect(stdout.write).toHaveBeenCalledTimes(1); + expect(setTimeoutSpy).not.toHaveBeenCalled(); + }); +}); From 5f38fe0fb1885646f9f8472dbb087554906e9fae Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:05:43 -0300 Subject: [PATCH 2/7] perf(daemon): Poll the socket every 25 ms while the daemon starts Co-Authored-By: Codex --- src/daemon/ipc.ts | 7 +-- tests/ipc-daemon-start.test.ts | 83 ++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 3 deletions(-) create mode 100644 tests/ipc-daemon-start.test.ts diff --git a/src/daemon/ipc.ts b/src/daemon/ipc.ts index a68e5b0..386065b 100644 --- a/src/daemon/ipc.ts +++ b/src/daemon/ipc.ts @@ -210,9 +210,10 @@ export class IpcClient { }); child.unref(); - // Wait for socket to appear - for (let i = 0; i < 30; i++) { - await new Promise((r) => setTimeout(r, 200)); + // Poll the socket until the daemon is ready or the startup budget expires. + const deadline = Date.now() + 6000; + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, Math.min(25, deadline - Date.now()))); if (await isDaemonRunning()) return; } throw new Error("Failed to start daemon"); diff --git a/tests/ipc-daemon-start.test.ts b/tests/ipc-daemon-start.test.ts new file mode 100644 index 0000000..78578b2 --- /dev/null +++ b/tests/ipc-daemon-start.test.ts @@ -0,0 +1,83 @@ +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { IpcClient } from "../src/daemon/ipc.js"; + +const { spawn } = vi.hoisted(() => ({ spawn: vi.fn(() => ({ unref: vi.fn() })) })); + +vi.mock("node:child_process", () => ({ spawn })); + +describe("IpcClient.ensureDaemonStarted", () => { + let tempDir: string; + let server: net.Server | undefined; + let previousRunAgentDir: string | undefined; + let previousConfigDir: string | undefined; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "run-agent-ipc-start-")); + previousRunAgentDir = process.env.RUN_AGENT_DIR; + previousConfigDir = process.env.RUN_AGENT_CONFIG_DIR; + process.env.RUN_AGENT_DIR = tempDir; + process.env.RUN_AGENT_CONFIG_DIR = path.join(tempDir, "config"); + spawn.mockClear(); + server = undefined; + }); + + afterEach(async () => { + if (server?.listening) { + await new Promise((resolve, reject) => { + server!.close((error) => error ? reject(error) : resolve()); + }); + } + fs.rmSync(tempDir, { recursive: true, force: true }); + if (previousRunAgentDir === undefined) delete process.env.RUN_AGENT_DIR; + else process.env.RUN_AGENT_DIR = previousRunAgentDir; + if (previousConfigDir === undefined) delete process.env.RUN_AGENT_CONFIG_DIR; + else process.env.RUN_AGENT_CONFIG_DIR = previousConfigDir; + }); + + it("resolves within 50 ms after the socket starts accepting connections", async () => { + const socketPath = path.join(tempDir, "daemon.sock"); + let listeningAt = 0; + server = net.createServer(); + const started = new IpcClient().ensureDaemonStarted().then(() => Date.now()); + + await new Promise((resolve, reject) => { + setTimeout(() => { + server!.once("error", reject); + server!.listen(socketPath, () => { + listeningAt = Date.now(); + resolve(); + }); + }, 75); + }); + + const resolvedAt = await started; + expect(resolvedAt - listeningAt).toBeLessThanOrEqual(50); + expect(spawn).toHaveBeenCalledTimes(1); + }); + + it("rejects after the six second startup budget when the socket never accepts", async () => { + const startedAt = Date.now(); + + await expect(new IpcClient().ensureDaemonStarted()).rejects.toThrow("Failed to start daemon"); + + expect(Date.now() - startedAt).toBeGreaterThanOrEqual(6000); + expect(spawn).toHaveBeenCalledTimes(1); + }, 8000); + + it("does not spawn when the daemon already accepts on the socket", async () => { + const socketPath = path.join(tempDir, "daemon.sock"); + server = net.createServer(); + await new Promise((resolve, reject) => { + server!.once("error", reject); + server!.listen(socketPath, resolve); + }); + + await new IpcClient().ensureDaemonStarted(); + + expect(spawn).not.toHaveBeenCalled(); + }); +}); From ab7b6491b6f74873d2eedaeb6a47e995bd08248e Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:18:09 -0300 Subject: [PATCH 3/7] test(daemon): Catch slow polls in the daemon start test Co-Authored-By: Codex --- tests/ipc-daemon-start.test.ts | 55 ++++++++++++++++++++++------------ 1 file changed, 36 insertions(+), 19 deletions(-) diff --git a/tests/ipc-daemon-start.test.ts b/tests/ipc-daemon-start.test.ts index 78578b2..e4240c7 100644 --- a/tests/ipc-daemon-start.test.ts +++ b/tests/ipc-daemon-start.test.ts @@ -26,6 +26,8 @@ describe("IpcClient.ensureDaemonStarted", () => { }); afterEach(async () => { + vi.useRealTimers(); + vi.clearAllTimers(); if (server?.listening) { await new Promise((resolve, reject) => { server!.close((error) => error ? reject(error) : resolve()); @@ -40,33 +42,48 @@ describe("IpcClient.ensureDaemonStarted", () => { it("resolves within 50 ms after the socket starts accepting connections", async () => { const socketPath = path.join(tempDir, "daemon.sock"); - let listeningAt = 0; - server = net.createServer(); - const started = new IpcClient().ensureDaemonStarted().then(() => Date.now()); + const offsets = [5, 30, 55, 80, 105, 130]; - await new Promise((resolve, reject) => { - setTimeout(() => { - server!.once("error", reject); - server!.listen(socketPath, () => { - listeningAt = Date.now(); - resolve(); - }); - }, 75); - }); + for (const [index, offset] of offsets.entries()) { + server = net.createServer(); + const attemptServer = server; + let listeningAt = 0; + const started = new IpcClient().ensureDaemonStarted().then(() => Date.now()); + const listening = new Promise((resolve, reject) => { + setTimeout(() => { + attemptServer.once("error", reject); + attemptServer.listen(socketPath, () => { + listeningAt = Date.now(); + resolve(); + }); + }, offset); + }); - const resolvedAt = await started; - expect(resolvedAt - listeningAt).toBeLessThanOrEqual(50); - expect(spawn).toHaveBeenCalledTimes(1); + const [resolvedAt] = await Promise.all([started, listening.then(() => undefined)]); + expect(resolvedAt - listeningAt).toBeLessThanOrEqual(50); + expect(spawn).toHaveBeenCalledTimes(index + 1); + + await new Promise((resolve, reject) => { + attemptServer.close((error) => error ? reject(error) : resolve()); + }); + fs.rmSync(socketPath, { force: true }); + server = undefined; + } }); it("rejects after the six second startup budget when the socket never accepts", async () => { - const startedAt = Date.now(); + vi.useFakeTimers(); + let settled = false; + const started = new IpcClient().ensureDaemonStarted(); + started.finally(() => { settled = true; }).catch(() => {}); - await expect(new IpcClient().ensureDaemonStarted()).rejects.toThrow("Failed to start daemon"); + await vi.advanceTimersByTimeAsync(5999); + expect(settled).toBe(false); - expect(Date.now() - startedAt).toBeGreaterThanOrEqual(6000); + await vi.advanceTimersByTimeAsync(1); + await expect(started).rejects.toThrow("Failed to start daemon"); expect(spawn).toHaveBeenCalledTimes(1); - }, 8000); + }); it("does not spawn when the daemon already accepts on the socket", async () => { const socketPath = path.join(tempDir, "daemon.sock"); From 88ebabf0e1b880edf8d01fff8e3cdb0ede8b2a08 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:19:18 -0300 Subject: [PATCH 4/7] perf(open): Skip repeated Claude probes on every open Resolve Claude from PATH without a version spawn and cache confirmed flag support by binary identity. Co-Authored-By: Codex --- src/open/launchers/claude.ts | 84 +++++++++++++++++++++- tests/open-claude-probes.test.ts | 117 +++++++++++++++++++++++++++++++ 2 files changed, 199 insertions(+), 2 deletions(-) create mode 100644 tests/open-claude-probes.test.ts diff --git a/src/open/launchers/claude.ts b/src/open/launchers/claude.ts index b5023bc..0442c37 100644 --- a/src/open/launchers/claude.ts +++ b/src/open/launchers/claude.ts @@ -1,8 +1,10 @@ import { execFile } from "node:child_process"; +import { randomUUID } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { promisify } from "node:util"; import { DISPATCHER_PRESET, type OrchestratorMode } from "../../config/orchestrator-mode.js"; +import { getPaths } from "../../config/paths.js"; import { detectBinary } from "../../drivers/helpers.js"; import { getRegistry } from "../../drivers/registry.js"; import { autocompactArgs } from "../../core/autocompact.js"; @@ -42,6 +44,62 @@ const THEME_REF = `custom:${PLUGIN_NAME}:codedeck-ultra`; export const CLAUDE_NOT_FOUND = "Claude Code was not found on PATH. Install Claude Code and ensure `claude` is available."; const execFileAsync = promisify(execFile); +const SUPPORT_CACHE_FILE = "claude-support.json"; + +interface SupportRecord { + size: number; + mtimeMs: number; +} + +function findOnPath(command: string): string | undefined { + for (const entry of (process.env.PATH ?? "").split(path.delimiter)) { + const candidate = path.resolve(entry || process.cwd(), command); + try { + if (fs.statSync(candidate).isFile()) { + fs.accessSync(candidate, fs.constants.X_OK); + return candidate; + } + } catch {} + } +} + +function readSupportRecords(): Record { + try { + const parsed: unknown = JSON.parse( + fs.readFileSync(path.join(getPaths().base, SUPPORT_CACHE_FILE), "utf8"), + ); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {}; + const records: Record = {}; + for (const [binary, value] of Object.entries(parsed)) { + if ( + typeof value === "object" && value !== null && + typeof (value as SupportRecord).size === "number" && + typeof (value as SupportRecord).mtimeMs === "number" + ) { + records[binary] = value as SupportRecord; + } + } + return records; + } catch { + return {}; + } +} + +function writeSupportRecord(realpath: string, record: SupportRecord): void { + const cacheFile = path.join(getPaths().base, SUPPORT_CACHE_FILE); + const temporaryFile = `${cacheFile}.${process.pid}.${randomUUID()}.tmp`; + try { + fs.mkdirSync(path.dirname(cacheFile), { recursive: true }); + const records = readSupportRecords(); + records[realpath] = record; + fs.writeFileSync(temporaryFile, JSON.stringify(records)); + fs.renameSync(temporaryFile, cacheFile); + } catch { + try { + fs.rmSync(temporaryFile, { force: true }); + } catch {} + } +} /** * The settings are built here, at launch, rather than shipped as a file, and @@ -207,8 +265,11 @@ export async function preflightModel(model: string, fromConfig: boolean): Promis * hoping it answers the same way. */ export async function resolveBinary(): Promise { - // detectBinary reports failure in its result and never rejects, so there is - // nothing here to catch. + const onPath = findOnPath("claude"); + if (onPath) return onPath; + + // Keep detectBinary's login-shell fallback for installations that PATH + // lookup cannot see in this process. const installation = await detectBinary("claude"); if (!installation.installed || !installation.path) { throw new Error(CLAUDE_NOT_FOUND); @@ -217,6 +278,22 @@ export async function resolveBinary(): Promise { } export async function assertSupport(claudeBin: string, cwd: string): Promise { + let identity: { realpath: string; record: SupportRecord } | undefined; + try { + const realpath = fs.realpathSync(claudeBin); + const stat = fs.statSync(realpath); + identity = { realpath, record: { size: stat.size, mtimeMs: stat.mtimeMs } }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw new Error(CLAUDE_NOT_FOUND); + } + } + + if (identity) { + const cached = readSupportRecords()[identity.realpath]; + if (cached?.size === identity.record.size && cached.mtimeMs === identity.record.mtimeMs) return; + } + try { await execFileAsync(claudeBin, ["--append-system-prompt-file"], { cwd, @@ -239,6 +316,9 @@ export async function assertSupport(claudeBin: string, cwd: string): Promise { + const mod = await importOriginal(); + return { ...mod, detectBinary: vi.fn() }; +}); + +import { detectBinary } from "../src/drivers/helpers.js"; +import { CLAUDE_NOT_FOUND, assertSupport, resolveBinary } from "../src/open/launchers/claude.js"; + +const mockedDetect = vi.mocked(detectBinary); +const originalEnv = { + PATH: process.env.PATH, + RUN_AGENT_DIR: process.env.RUN_AGENT_DIR, + RUN_AGENT_CONFIG_DIR: process.env.RUN_AGENT_CONFIG_DIR, +}; +let root: string; +let binDir: string; +let counterFile: string; +let claudeFile: string; + +function writeClaude(kind: "supported" | "unknown" = "supported"): void { + const message = kind === "supported" + ? "error: option --append-system-prompt-file argument missing" + : "error: unknown option --append-system-prompt-file"; + fs.writeFileSync( + claudeFile, + `#!/bin/sh\nprintf '%s\\n' run >> '${counterFile}'\nprintf '%s\\n' '${message}' >&2\nexit 1\n`, + { mode: 0o755 }, + ); + fs.chmodSync(claudeFile, 0o755); +} + +function runCount(): number { + if (!fs.existsSync(counterFile)) return 0; + return fs.readFileSync(counterFile, "utf8").trim().split("\n").length; +} + +beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "codedeck-claude-probes-")); + binDir = path.join(root, "bin"); + fs.mkdirSync(binDir); + counterFile = path.join(root, "counter.txt"); + claudeFile = path.join(binDir, "claude"); + process.env.PATH = binDir; + process.env.RUN_AGENT_DIR = path.join(root, "run-agent"); + process.env.RUN_AGENT_CONFIG_DIR = path.join(root, "config"); + mockedDetect.mockReset(); +}); + +afterEach(() => { + if (originalEnv.PATH === undefined) delete process.env.PATH; + else process.env.PATH = originalEnv.PATH; + if (originalEnv.RUN_AGENT_DIR === undefined) delete process.env.RUN_AGENT_DIR; + else process.env.RUN_AGENT_DIR = originalEnv.RUN_AGENT_DIR; + if (originalEnv.RUN_AGENT_CONFIG_DIR === undefined) delete process.env.RUN_AGENT_CONFIG_DIR; + else process.env.RUN_AGENT_CONFIG_DIR = originalEnv.RUN_AGENT_CONFIG_DIR; + fs.rmSync(root, { recursive: true, force: true }); +}); + +describe("Claude launcher probes", () => { + it("resolves an executable from PATH without running it and keeps detectBinary as fallback", async () => { + writeClaude(); + + await expect(resolveBinary()).resolves.toBe(claudeFile); + expect(runCount()).toBe(0); + expect(mockedDetect).not.toHaveBeenCalled(); + + process.env.PATH = path.join(root, "empty-path"); + mockedDetect.mockResolvedValueOnce({ installed: true, path: "/fallback/claude" }); + await expect(resolveBinary()).resolves.toBe("/fallback/claude"); + expect(mockedDetect).toHaveBeenCalledWith("claude"); + + mockedDetect.mockResolvedValueOnce({ installed: false }); + await expect(resolveBinary()).rejects.toThrow(CLAUDE_NOT_FOUND); + }); + + it("reuses support for the same realpath, size and mtime", async () => { + writeClaude(); + + await assertSupport(claudeFile, root); + await assertSupport(claudeFile, root); + + expect(runCount()).toBe(1); + }); + + it("probes again when the binary mtime changes", async () => { + writeClaude(); + await assertSupport(claudeFile, root); + + const changedTime = new Date(Date.now() + 5000); + fs.utimesSync(claudeFile, changedTime, changedTime); + await assertSupport(claudeFile, root); + + expect(runCount()).toBe(2); + }); + + it("does not cache unknown-option results or ENOENT failures", async () => { + writeClaude("unknown"); + + await expect(assertSupport(claudeFile, root)).rejects.toThrow(/does not support/); + await expect(assertSupport(claudeFile, root)).rejects.toThrow(/does not support/); + expect(runCount()).toBe(2); + expect(fs.existsSync(path.join(process.env.RUN_AGENT_DIR!, "claude-support.json"))).toBe(false); + + fs.writeFileSync(claudeFile, "#!/no/such/codedeck-interpreter\n", { mode: 0o755 }); + fs.chmodSync(claudeFile, 0o755); + await expect(assertSupport(claudeFile, root)).rejects.toThrow(CLAUDE_NOT_FOUND); + expect(runCount()).toBe(2); + writeClaude("supported"); + await assertSupport(claudeFile, root); + expect(runCount()).toBe(3); + }); +}); From 365ea3ffa78f8b50a0ca61746b02eb0f8ad1b54d Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:28:30 -0300 Subject: [PATCH 5/7] test(open): Pin when the Claude probe records support Repeat ENOENT against the unchanged executable and cover unrelated probe errors. Co-Authored-By: Codex --- tests/open-claude-probes.test.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/tests/open-claude-probes.test.ts b/tests/open-claude-probes.test.ts index 9c81283..61fd470 100644 --- a/tests/open-claude-probes.test.ts +++ b/tests/open-claude-probes.test.ts @@ -22,10 +22,12 @@ let binDir: string; let counterFile: string; let claudeFile: string; -function writeClaude(kind: "supported" | "unknown" = "supported"): void { +function writeClaude(kind: "supported" | "unknown" | "unrelated" = "supported"): void { const message = kind === "supported" ? "error: option --append-system-prompt-file argument missing" - : "error: unknown option --append-system-prompt-file"; + : kind === "unknown" + ? "error: unknown option --append-system-prompt-file" + : "fatal: boom"; fs.writeFileSync( claudeFile, `#!/bin/sh\nprintf '%s\\n' run >> '${counterFile}'\nprintf '%s\\n' '${message}' >&2\nexit 1\n`, @@ -98,6 +100,16 @@ describe("Claude launcher probes", () => { expect(runCount()).toBe(2); }); + it("does not record support for unrelated probe failures", async () => { + writeClaude("unrelated"); + + await expect(assertSupport(claudeFile, root)).resolves.toBeUndefined(); + await expect(assertSupport(claudeFile, root)).resolves.toBeUndefined(); + + expect(runCount()).toBe(2); + expect(fs.existsSync(path.join(process.env.RUN_AGENT_DIR!, "claude-support.json"))).toBe(false); + }); + it("does not cache unknown-option results or ENOENT failures", async () => { writeClaude("unknown"); @@ -110,6 +122,9 @@ describe("Claude launcher probes", () => { fs.chmodSync(claudeFile, 0o755); await expect(assertSupport(claudeFile, root)).rejects.toThrow(CLAUDE_NOT_FOUND); expect(runCount()).toBe(2); + await expect(assertSupport(claudeFile, root)).rejects.toThrow(CLAUDE_NOT_FOUND); + expect(fs.existsSync(path.join(process.env.RUN_AGENT_DIR!, "claude-support.json"))).toBe(false); + writeClaude("supported"); await assertSupport(claudeFile, root); expect(runCount()).toBe(3); From ee41ceceec7156d131fd9347392ff8c6414be04d Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:40:08 -0300 Subject: [PATCH 6/7] test(open): Pin the binary size in the Claude support key --- src/open/launchers/claude.ts | 3 +++ tests/open-claude-probes.test.ts | 16 ++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/open/launchers/claude.ts b/src/open/launchers/claude.ts index 0442c37..8da76d6 100644 --- a/src/open/launchers/claude.ts +++ b/src/open/launchers/claude.ts @@ -280,6 +280,9 @@ export async function resolveBinary(): Promise { export async function assertSupport(claudeBin: string, cwd: string): Promise { let identity: { realpath: string; record: SupportRecord } | undefined; try { + // Version-manager shims (mise, asdf, volta) resolve to the wrapper, so changes + // to the real binary do not invalidate this record. This is accepted for a + // long-standing flag because the real launch still reports any error. const realpath = fs.realpathSync(claudeBin); const stat = fs.statSync(realpath); identity = { realpath, record: { size: stat.size, mtimeMs: stat.mtimeMs } }; diff --git a/tests/open-claude-probes.test.ts b/tests/open-claude-probes.test.ts index 61fd470..06c65bb 100644 --- a/tests/open-claude-probes.test.ts +++ b/tests/open-claude-probes.test.ts @@ -89,6 +89,22 @@ describe("Claude launcher probes", () => { expect(runCount()).toBe(1); }); + it("probes again when the binary size changes but its mtime does not", async () => { + writeClaude(); + await assertSupport(claudeFile, root); + expect(runCount()).toBe(1); + + const original = fs.readFileSync(claudeFile, "utf8"); + const stat = fs.statSync(claudeFile); + fs.writeFileSync(claudeFile, `${original}# changed size\n`, { mode: 0o755 }); + fs.chmodSync(claudeFile, 0o755); + fs.utimesSync(claudeFile, stat.atimeMs / 1000, stat.mtimeMs / 1000); + expect(fs.statSync(claudeFile).mtimeMs).toBe(stat.mtimeMs); + + await assertSupport(claudeFile, root); + expect(runCount()).toBe(2); + }); + it("probes again when the binary mtime changes", async () => { writeClaude(); await assertSupport(claudeFile, root); From a3150a95fb4ee576ea194e855410cabbaeb03597 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:43:28 -0300 Subject: [PATCH 7/7] docs(specs): Add the open-boot-latency spec --- .specs/features/open-boot-latency/spec.md | 64 +++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 .specs/features/open-boot-latency/spec.md diff --git a/.specs/features/open-boot-latency/spec.md b/.specs/features/open-boot-latency/spec.md new file mode 100644 index 0000000..1c439a3 --- /dev/null +++ b/.specs/features/open-boot-latency/spec.md @@ -0,0 +1,64 @@ +# open-boot-latency + +> Cortar o tempo fixo que o CodeDeck soma antes do harness subir no `codedeck open`, e medir o que sobra. + +## Contexto medido (sessões 80c5 e c3ea, fake harness, daemon isolado) + +| Cenário | Mediana | Mín | +|---|---:|---:| +| `open`, daemon quente, animação padrão | 930 ms | 924 ms | +| `open`, daemon quente, `--no-theme` | 189 ms | 151 ms | +| `open`, daemon frio, `--no-theme` | 724 ms | 538 ms | +| `ps` quente / frio | 105 / 305 ms | 91 / 295 ms | +| daemon spawn até aceitar socket | 79 ms | 61 ms | + +- `playBoot` (`src/open/runtime.ts:424-460`) dorme 19 x 40 ms antes do spawn: ~740 ms fixos. +- `ensureDaemonStarted` (`src/daemon/ipc.ts:213-216`) dorme 200 ms antes da primeira checagem, com o socket pronto em ~60-80 ms. + +## Requisitos + +- **R1**: WHEN `playBoot` runs on a TTY THEN the sum of its animation delays SHALL be at most 200 ms. +- **R2**: WHEN `playBoot` runs on a TTY THEN its last logo frame SHALL be the fully resolved frame (progress 1), followed by the `role · model · effort` line and the `booting…` line. +- **R3**: WHILE stdout is not a TTY, `playBoot` SHALL write `renderBanner(...)` once and SHALL NOT wait on any timer. +- **R4**: WHEN `ensureDaemonStarted` spawns the daemon THEN it SHALL resolve within 50 ms after the socket starts accepting connections. +- **R5**: IF the socket never accepts THEN `ensureDaemonStarted` SHALL reject with `Failed to start daemon` no earlier than 6 s after the spawn. +- **R6**: WHEN a daemon already accepts on the socket THEN `ensureDaemonStarted` SHALL NOT spawn a process. + +## Fora de escopo + +- Rodar a animação em paralelo aos probes ou ao daemon. +- Lazy import dos comandos em `src/cli/index.ts` (~48 ms medidos). +- Cache de `detect()` no `doctor`. +- Mudanças no plugin, hooks e status line (dependem da medição D1). + +## Tasks + +| Task | Requisitos | Arquivos | Tests | Gate | +|---|---|---|---|---| +| T1 animação com orçamento | R1, R2, R3 | `src/open/runtime.ts`, `tests/open-boot.test.ts` | unit, fake timers | `npx vitest run tests/open-boot.test.ts` | +| T2 poll do daemon | R4, R5, R6 | `src/daemon/ipc.ts`, `tests/ipc-daemon-start.test.ts` | unit, socket unix real em dir temp, spawn mockado | `npx vitest run tests/ipc-daemon-start.test.ts` | +| D1 medição do boot do claude e probes | discovery | nenhum | none | relatório | + +## Coverage matrix + +| Camada | Tipo | Onde | Comando | +|---|---|---|---| +| `src/open/runtime.ts` (`playBoot`) | unit, fake timers | `tests/open-boot.test.ts` | `npx vitest run tests/open-boot.test.ts` | +| `src/daemon/ipc.ts` (`ensureDaemonStarted`) | unit, socket real | `tests/ipc-daemon-start.test.ts` | `npx vitest run tests/ipc-daemon-start.test.ts` | +| Regressão do open | unit existente | `tests/open-*.test.ts` | `npx vitest run tests/open-` | +| D1 | none (discovery) | - | - | + +## Rodada 2 (medição D1, sessão 9e3f) + +Com cache quente, `resolveBinary` custa 78 ms (`which` + `claude --version`, versão descartada) e `assertSupport` 176 ms (sobe o claude pra testar a flag), em todo `open`. + +- **R7**: WHEN `resolveBinary` (claude launcher) finds `claude` on PATH THEN it SHALL return its absolute path without spawning the `claude` binary. +- **R8**: WHEN `assertSupport` already passed for the same binary (same realpath, size and mtime) THEN it SHALL NOT spawn `claude`. +- **R9**: WHEN no support record exists for the binary, or its realpath, size or mtime changed, THEN `assertSupport` SHALL run the probe. +- **R10**: IF the probe reports an unknown option THEN `assertSupport` SHALL throw the upgrade error and SHALL NOT record support. + +| Task | Requisitos | Arquivos | Tests | Gate | +|---|---|---|---|---| +| T3 probes do claude | R7-R10 | `src/open/launchers/claude.ts`, `tests/open-claude-probes.test.ts` | unit, binário fake em PATH temp | `npx vitest run tests/open-claude-probes.test.ts tests/open-` | + +Fora de escopo nesta rodada: launchers codex/opencode (mesmo padrão), cache do catálogo de modelos (367 ms só no cache frio), status line (138 ms a cada 2 s), boot real do claude (não medido: exige a conta autenticada).