diff --git a/CHANGELOG.md b/CHANGELOG.md index 634fb61..d39affa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,33 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.1] - 2026-06-07 + +### Fixed + +- **`completed` now guarantees the reply is on disk *across processes*.** A stable + idle pane can settle *before* the transcript flush of a large reply, so a + SEPARATE process calling `messages`/`messagesSince` right after a `wait` that + reported `completed` could read `[]` while `capture` showed the full reply — a + silent empty read for orchestrators that use the structured path instead of + screen-scraping. The README's "race-free after `completed`" guarantee held only + for a single in-process observer; the CLI `send`→`wait`→`messages` split crosses + three processes whose only shared channel is the on-disk transcript. `wait` now + holds `completed` until the reply record is actually on disk (the newest + transcript message is `assistant` — a claude tool-result is `user`-role, so a + tool turn waits for its FINAL answer). No deadline is introduced: a blind + transcript falls back to the pane, and a readable-but-unflushed reply is bounded + by the consumer's existing patience, never a library timeout ("time is the + policy's"). In-process this is a no-op. Triggered most easily by a long reply. +- **`resume` forwards `-- ` like `spawn` does.** The post-`--` + passthrough landed on `spawn` only; `resume -- --model opus` now + forwards them too. The two boot constructors share one `withBootOptions` builder + so they can't drift again. +- **CLI subprocess tests no longer leak onto the default socket.** The test + harness exported `TMUX_SOCKET` (read by nobody — tmux uses `-L`, the substrate + uses `CLAUDEMUX_SOCKET`), so harness-spawned CLI processes silently used the + *default* socket and could observe sessions from other consumers on the box. + ## [0.2.0] - 2026-06-05 ### Changed @@ -252,6 +279,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Windows-native is not supported. tmux is Unix-only; WSL is community-contributable, undocumented by the maintainers. +[0.2.1]: https://github.com/wastedcode/claudemux/compare/v0.2.0...v0.2.1 [0.2.0]: https://github.com/wastedcode/claudemux/compare/v0.1.0...v0.2.0 [0.1.0]: https://github.com/wastedcode/claudemux/compare/v0.0.1...v0.1.0 [0.0.1]: https://github.com/wastedcode/claudemux/releases/tag/v0.0.1 diff --git a/package.json b/package.json index 38888ab..a7989a2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@wastedcode/claudemux", - "version": "0.2.0", + "version": "0.2.1", "publishConfig": { "access": "public" }, diff --git a/src/cli/main.ts b/src/cli/main.ts index af16f3f..cd07c20 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -47,26 +47,34 @@ export function buildProgram(): Command { 'agent (default: "claude"; only "claude" supported currently)', ); - withAgent(program.command("spawn ")) + // The two boot constructors — `spawn` (fresh) and `resume` (continue in a + // fresh pane) — take the SAME lifecycle flags (cwd / boot-timeout / + // trust-workspace) and the SAME `-- ` passthrough. Factor that + // shared shape into one builder so the two can't drift (a `--`-passthrough + // that lived only on `spawn` is exactly how they diverged once before). + const withBootOptions = (cmd: Command) => + withAgent(cmd) + .requiredOption("--cwd ", "working directory for the session") + .option("--boot-timeout-ms ", "boot timeout (default 60000)", parseIntOpt) + .option( + "--trust-workspace", + "grant the agent read/edit/execute on --cwd (writes a persistent per-folder trust flag); without it, an untrusted folder fails closed", + ) + .addHelpText("after", PASSTHROUGH_HELP); + + withBootOptions(program.command("spawn [claudeArgs...]")) .description("start a session and wait for the REPL to be ready") - .requiredOption("--cwd ", "working directory for the session") - .option("--boot-timeout-ms ", "boot timeout (default 60000)", parseIntOpt) - .option( - "--trust-workspace", - "grant the agent read/edit/execute on --cwd (writes a persistent per-folder trust flag); without it, an untrusted folder fails closed", - ) - .action(async (name: string, opts: SpawnCliOpts) => { - await spawnCli(name, opts); + .action(async (name: string, claudeArgs: string[], opts: SpawnCliOpts) => { + await spawnCli(name, withExtraArgs(opts, claudeArgs)); }); - withAgent(program.command("resume ")) + withBootOptions(program.command("resume [claudeArgs...]")) .description("continue an existing conversation in a fresh pane") - .requiredOption("--cwd ", "working directory for the session") - .option("--boot-timeout-ms ", "boot timeout (default 60000)", parseIntOpt) - .option("--trust-workspace", "grant the agent read/edit/execute on --cwd (see spawn)") - .action(async (name: string, agentSessionId: string, opts: ResumeCliOpts) => { - await resumeCli(name, agentSessionId, opts); - }); + .action( + async (name: string, agentSessionId: string, claudeArgs: string[], opts: ResumeCliOpts) => { + await resumeCli(name, agentSessionId, withExtraArgs(opts, claudeArgs)); + }, + ); withAgent(program.command("send ")) .description("deliver text as one logical user turn (use '-' to read from stdin)") @@ -155,6 +163,24 @@ function parseIntOpt(raw: string): number { return n; } +/** Shared help for the `-- ` passthrough on `spawn`/`resume`. */ +const PASSTHROUGH_HELP = + "\nArgs after `--` are forwarded verbatim to the agent's CLI, e.g.:\n" + + " claudemux spawn pm --cwd ./repo --trust-workspace -- \\\n" + + " --append-system-prompt-file ./prompt.md --allowed-tools Read Grep --model opus"; + +/** + * Fold a commander variadic (the tokens after `--`) into a boot verb's + * `extraArgs`. Shared by `spawn` and `resume` so the passthrough lands on both + * identically — an empty tail leaves `extraArgs` unset (the library default). + */ +function withExtraArgs( + opts: T, + claudeArgs: string[], +): T & { extraArgs?: string[] } { + return claudeArgs.length > 0 ? { ...opts, extraArgs: claudeArgs } : opts; +} + /** Entry point — handles typed-error exit codes uniformly. */ export async function runCli(argv: string[]): Promise { const program = buildProgram(); diff --git a/src/cli/resume.ts b/src/cli/resume.ts index e0281be..faf490d 100644 --- a/src/cli/resume.ts +++ b/src/cli/resume.ts @@ -4,6 +4,7 @@ import { type CommonOpts, backend, resolveAgent, resolveNamespace } from "./cont export interface ResumeCliOpts extends CommonOpts { cwd: string; bootTimeoutMs?: number; + extraArgs?: string[]; trustWorkspace?: boolean; } @@ -24,6 +25,7 @@ export async function resumeCli( agent: resolveAgent(opts.agent), namespace: resolveNamespace(opts.namespace), ...(opts.bootTimeoutMs === undefined ? {} : { bootTimeoutMs: opts.bootTimeoutMs }), + ...(opts.extraArgs === undefined ? {} : { extraArgs: opts.extraArgs }), ...(opts.trustWorkspace === undefined ? {} : { trustWorkspace: opts.trustWorkspace }), }); // Same shape as spawn — the resumed conversation id, for the next hop. diff --git a/src/io/wait.test.ts b/src/io/wait.test.ts index f7b3461..a5e19df 100644 --- a/src/io/wait.test.ts +++ b/src/io/wait.test.ts @@ -238,3 +238,61 @@ describe("waitForOutcome — idle/no-progress budget vs a working turn (S8 / F17 expect(Date.now() - t0).toBeGreaterThanOrEqual(550); // burned maxMs, not the idle window }); }); + +/** + * The cross-process flush gate: a stable idle pane can precede the transcript + * flush of a large reply, so `completed` also requires the reply record on disk + * (newest message `assistant`) — otherwise a SEPARATE process reading + * `messagesSince` right after `completed` sees `[]`. The pane is a settled idle + * box throughout (armed off the post-submit baseline); only the transcript tail + * evolves. No library deadline: an unflushed reply is bounded by the CONSUMER's + * patience, never a timeout. + */ +describe("waitForOutcome — cross-process transcript flush gate", () => { + const POST_SUBMIT = "you asked: ping\n❯ "; + // A reader pinned to a DONE idle pane (armed via baseline divergence), whose + // transcript tail role is supplied per-poll by `roleAt`. transcriptCount>0 + // means "transcript readable", so the gate is active. + const flushReader = + (roleAt: (i: number) => "user" | "assistant", counter: { n: number }): BeliefReader => + async () => { + const role = roleAt(counter.n++); + const pane = { state: classify(DONE, agent.rules), interrupted: false }; + return { + belief: believe({ edges: [], transcriptCount: 1, lastMessageRole: role, pane }), + paneText: DONE, + }; + }; + const runFlush = (r: BeliefReader, opts: { maxMs?: number; idleMs?: number }) => + waitForOutcome( + new FrameBackend([DONE], paneFingerprint(POST_SUBMIT)), + ref, + opts, + { stabilize }, + r, + ); + + it("holds `completed` while the tail is `user`, releases once it is `assistant`", async () => { + const counter = { n: 0 }; + // `user` for the first 4 polls, then the assistant reply lands on disk. + const out = await runFlush( + flushReader((i) => (i < 4 ? "user" : "assistant"), counter), + { + maxMs: 5_000, + }, + ); + expect(out).toEqual({ kind: "completed" }); + expect(counter.n).toBeGreaterThan(4); // kept polling through the unflushed window + }); + + it("never falsely completes while the reply is unflushed — the consumer's patience bounds it", async () => { + const counter = { n: 0 }; + // The assistant record never flushes; with a readable transcript the gate + // holds, so it must time out as budget-exceeded — NOT report a false completed. + const out = await runFlush( + flushReader(() => "user", counter), + { maxMs: 600 }, + ); + expect(out.kind).toBe("budget-exceeded"); + }); +}); diff --git a/src/io/wait.ts b/src/io/wait.ts index 6ff6ff8..d35d0c7 100644 --- a/src/io/wait.ts +++ b/src/io/wait.ts @@ -46,6 +46,16 @@ export type BeliefReader = () => Promise<{ belief: Belief; paneText: string }>; * (bug 8a500a52). Either way, the previous turn's lingering idle never counts: * `completed` requires a `stop` edge newer than this wait, or a fresh arm. * + * **Completion is also flush-safe ACROSS processes.** The pane settling can + * precede the transcript flush of a large reply, so an idle pane alone doesn't + * prove the reply is on disk — and a *separate* process reading `messagesSince` + * right after `completed` shares only that on-disk file. So `completed` also + * requires the reply record to be present (the newest message is `assistant`); a + * blind transcript (count 0) falls back to the pane. No deadline is added — if a + * readable transcript never yields the reply, the CONSUMER's patience bounds it + * (the library owns none). In-process this is a no-op (the same observer already + * cached the record); it only pays on the cross-process race. + * * `dialog`/`permission-prompt`/`aborted` are actionable and return immediately * (no settle). Never throws on timeout: a budget overrun is a returned * `budget-exceeded`, not an exception. (A capture failure inside the reader DOES @@ -104,7 +114,23 @@ export async function waitForOutcome( const hookDone = belief.lastStopAt !== undefined && belief.lastStopAt >= start; if (belief.state !== "idle") armed = true; else if (baseline !== undefined && paneFingerprint(paneText) !== baseline) armed = true; - if (belief.state === "idle" && (hookDone || armed)) { + + // ── cross-process flush gate (bug: messages() reads [] after completed) ── + // A stable idle pane can precede the transcript flush of a LARGE reply, so a + // SEPARATE process calling messages()/messagesSince() right after `completed` + // reads `[]` — the on-disk transcript is the only channel it shares with this + // wait. So `completed` also requires the reply record to be on disk: the + // newest message is `assistant` (the final reply; a claude tool-result is + // `user`-role, so a tool turn waits for its FINAL answer). A blind transcript + // (count 0 — hooks off, not yet located, or non-claude) has no record to gate + // on, so it falls back to the pane (today's behavior). NO deadline lives here: + // if a readable transcript never yields the reply the CONSUMER's patience + // bounds it (budget-exceeded), never a library-invented timeout — "time is the + // policy's." In-process the same observer already cached the record, so the + // gate is a no-op; it only pays on the cross-process race. + const paneDone = belief.state === "idle" && (hookDone || armed); + const replyOnDisk = belief.transcriptCount === 0 || belief.lastMessageRole === "assistant"; + if (paneDone && replyOnDisk) { // The stabilize debounce is a transport detail (legitimately the library's, // per the read/write-split RFC); cap it by any remaining wall-clock budget. const remaining = diff --git a/src/observe/observer.ts b/src/observe/observer.ts index 11faddf..fb58f73 100644 --- a/src/observe/observer.ts +++ b/src/observe/observer.ts @@ -93,6 +93,18 @@ export interface Belief extends Progress { readonly lastStopAt?: number; /** ms of the most recent lifecycle edge of any kind — a liveness heartbeat. */ readonly lastActivityAt?: number; + /** + * Role of the NEWEST message record on disk (`undefined` when the transcript is + * empty/blind). `assistant` is the cross-process flush signal `wait()` gates + * `completed` on: a stable idle pane can precede the transcript flush of a large + * reply, so a SEPARATE process reading `messagesSince` right after `completed` + * would see `[]` (the only channel it shares is the on-disk file). The final + * reply is the last record, and {@link import('./incremental.js').TailReader} + * only surfaces a record once its terminating newline lands — so an `assistant` + * tail means the reply is fully on disk. A claude tool-result is `user`-role, so + * a tool turn correctly reads `user` until its FINAL answer flushes. + */ + readonly lastMessageRole?: "user" | "assistant"; } /** @@ -132,6 +144,9 @@ export function believe(o: { * content. Optional: absent ⇒ treated as empty (no drift judgment). */ pane: { state: State; interrupted: boolean; nonEmpty?: boolean }; + /** Role of the newest transcript message — the cross-process flush signal `wait()` + * gates `completed` on (see {@link Belief.lastMessageRole}). Absent ⇒ blind. */ + lastMessageRole?: "user" | "assistant"; /** * Authoritative "this handle issued an interrupt not yet superseded by a send." * An interrupt fires NO `stop` edge AND leaves the spinner's `esc to interrupt` @@ -185,6 +200,7 @@ export function believe(o: { agentChannelHealthy, ...(lastStop === undefined ? {} : { lastStopAt: lastStop.at }), ...(lastEdge === undefined ? {} : { lastActivityAt: lastEdge.at }), + ...(o.lastMessageRole === undefined ? {} : { lastMessageRole: o.lastMessageRole }), }; } diff --git a/src/observe/session-observer.ts b/src/observe/session-observer.ts index 4ba5b49..55d6c9e 100644 --- a/src/observe/session-observer.ts +++ b/src/observe/session-observer.ts @@ -96,9 +96,11 @@ export class SessionObserver { ): Belief { this.#refreshEdges(); this.#refreshTranscript(); + const lastMessage = this.#messages[this.#messages.length - 1]; return believe({ edges: this.#edges, transcriptCount: this.#messages.length, + ...(lastMessage === undefined ? {} : { lastMessageRole: lastMessage.role }), pane, weInterrupted, }); diff --git a/test/cli/passthrough.test.ts b/test/cli/passthrough.test.ts new file mode 100644 index 0000000..4a5b3fd --- /dev/null +++ b/test/cli/passthrough.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { buildProgram } from "../../src/cli/main.js"; + +/** + * Parse-level regression: the two boot constructors (`spawn`, `resume`) must + * BOTH accept the `-- ` passthrough. The passthrough once lived + * only on `spawn` while `resume` silently dropped it; this locks the symmetry + * at the command-definition layer (no boot, no backend). + */ +describe("CLI — boot verbs share the `-- ` passthrough", () => { + const program = buildProgram(); + const cmd = (name: string) => { + const found = program.commands.find((c) => c.name() === name); + if (found === undefined) throw new Error(`command ${name} not registered`); + return found; + }; + + for (const verb of ["spawn", "resume"] as const) { + it(`${verb} ends in a variadic that captures post-\`--\` agent flags`, () => { + const args = cmd(verb).registeredArguments; + const last = args.at(-1); + expect(last?.name()).toBe("claudeArgs"); + expect(last?.variadic).toBe(true); + }); + + it(`${verb} declares --cwd, --boot-timeout-ms and --trust-workspace`, () => { + const flags = cmd(verb).options.map((o) => o.long); + expect(flags).toEqual( + expect.arrayContaining(["--cwd", "--boot-timeout-ms", "--trust-workspace"]), + ); + }); + } +}); diff --git a/test/harness/index.ts b/test/harness/index.ts index 6bd9bce..96c4352 100644 --- a/test/harness/index.ts +++ b/test/harness/index.ts @@ -68,7 +68,11 @@ function buildEnv(sandbox: SandboxHome, socket: string): Record XDG_CACHE_HOME: sandbox.xdgCache, XDG_DATA_HOME: sandbox.xdgData, XDG_STATE_HOME: sandbox.xdgState, - TMUX_SOCKET: socket, + // The substrate selects its private socket from CLAUDEMUX_SOCKET (see + // default-backend.ts); a bare TMUX_SOCKET is read by nobody (tmux uses -L, + // not an env var), so CLI subprocesses would silently fall to the DEFAULT + // socket and see sessions from any other consumer on the box. + CLAUDEMUX_SOCKET: socket, LC_ALL: "C.UTF-8", TERM: "xterm-256color", PATH: CURATED_PATH,