Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `-- <agent flags>` like `spawn` does.** The post-`--`
passthrough landed on `spawn` only; `resume <name> <id> -- --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
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@wastedcode/claudemux",
"version": "0.2.0",
"version": "0.2.1",
"publishConfig": {
"access": "public"
},
Expand Down
58 changes: 42 additions & 16 deletions src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,26 +47,34 @@ export function buildProgram(): Command {
'agent (default: "claude"; only "claude" supported currently)',
);

withAgent(program.command("spawn <name>"))
// 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 `-- <agent flags>` 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 <path>", "working directory for the session")
.option("--boot-timeout-ms <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 <name> [claudeArgs...]"))
.description("start a session and wait for the REPL to be ready")
.requiredOption("--cwd <path>", "working directory for the session")
.option("--boot-timeout-ms <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 <name> <agentSessionId>"))
withBootOptions(program.command("resume <name> <agentSessionId> [claudeArgs...]"))
.description("continue an existing conversation in a fresh pane")
.requiredOption("--cwd <path>", "working directory for the session")
.option("--boot-timeout-ms <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 <name> <text>"))
.description("deliver text as one logical user turn (use '-' to read from stdin)")
Expand Down Expand Up @@ -155,6 +163,24 @@ function parseIntOpt(raw: string): number {
return n;
}

/** Shared help for the `-- <agent flags>` 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<T extends object>(
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<number> {
const program = buildProgram();
Expand Down
2 changes: 2 additions & 0 deletions src/cli/resume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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.
Expand Down
58 changes: 58 additions & 0 deletions src/io/wait.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
28 changes: 27 additions & 1 deletion src/io/wait.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 =
Expand Down
16 changes: 16 additions & 0 deletions src/observe/observer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}

/**
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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 }),
};
}

Expand Down
2 changes: 2 additions & 0 deletions src/observe/session-observer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
33 changes: 33 additions & 0 deletions test/cli/passthrough.test.ts
Original file line number Diff line number Diff line change
@@ -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 `-- <agent flags>` 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 `-- <agent flags>` 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"]),
);
});
}
});
6 changes: 5 additions & 1 deletion test/harness/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,11 @@ function buildEnv(sandbox: SandboxHome, socket: string): Record<string, string>
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,
Expand Down
Loading