From 3779bb8d66f5ebce322c0b0c52d6f403188812a4 Mon Sep 17 00:00:00 2001 From: Lanzelot1 <35410605+Lanzelot1@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:02:14 -0700 Subject: [PATCH 1/6] feat(connector): resume a claude-code session into the mesh Add LaunchOpts.resume; the claude connector emits --resume --fork-session (forks so the original session keeps its identity), composing with the strict-MCP isolation and skipping the greeting prompt. Threaded through cotal spawn --resume and the manager's start/opStart. codex/opencode throw (resume is claude-only). Closes #23. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/claude-code-integration.md | 7 +++++++ extensions/connector-claude-code/src/extension.ts | 13 ++++++++++--- extensions/connector-codex/src/extension.ts | 3 +++ extensions/connector-opencode/src/extension.ts | 3 +++ implementations/cli/src/commands/spawn.ts | 4 +++- implementations/manager/src/commands.ts | 4 +++- implementations/manager/src/manager.ts | 1 + packages/core/src/connector.ts | 5 +++++ 8 files changed, 35 insertions(+), 5 deletions(-) diff --git a/docs/claude-code-integration.md b/docs/claude-code-integration.md index 14c689d0..f5603fbb 100644 --- a/docs/claude-code-integration.md +++ b/docs/claude-code-integration.md @@ -55,6 +55,13 @@ claude --strict-mcp-config --mcp-config '{"mcpServers":{"cotal":{"command":"node stays inert and never joins — so an operator's own sessions in the repo don't appear as stray peers. - **Hands-free spawn.** The dev-channels flag prints a one-time "Enter to confirm" prompt; the PTY runtime auto-clears it via `LaunchSpec.confirm`, so a supervised launch needs no keypress. +- **Resume an existing session.** `cotal spawn --resume ` (and the manager's + `cotal start … --resume `) pulls a session you're already working in into the mesh instead of + starting cold: `buildLaunch` adds `--resume --fork-session`, so claude replays that transcript + into a fresh, cotal-equipped process and **forks** a new session id — your original session keeps + its identity and runs untouched. It composes with the MCP isolation above (the forked process still + loads only cotal) and skips the auto-submitted greeting (the session already has its context). + Resume is claude-only; the codex/opencode connectors reject `--resume`. ## Agent files (persona + identity) diff --git a/extensions/connector-claude-code/src/extension.ts b/extensions/connector-claude-code/src/extension.ts index 1bddd1b8..c88e80b3 100644 --- a/extensions/connector-claude-code/src/extension.ts +++ b/extensions/connector-claude-code/src/extension.ts @@ -45,12 +45,19 @@ export const claudeConnector: Connector = { if (opts.creds) env.COTAL_CREDS = opts.creds; if (opts.servers) env.COTAL_SERVERS = opts.servers; - // A leading positional is claude's first message, auto-submitted on start — - // so a driving session can greet the operator the moment it joins. - const args = opts.prompt + // A leading positional is claude's first message, auto-submitted on start — so a driving + // session can greet the operator the moment it joins. A resumed session already carries its + // own context, so skip the greeting and reattach the prior conversation instead. + const args = opts.prompt && !opts.resume ? [opts.prompt, "--dangerously-load-development-channels", CHANNEL_REF] : ["--dangerously-load-development-channels", CHANNEL_REF]; + // Resume a prior conversation into the mesh. --fork-session makes claude mint a fresh session + // id from the resumed history, so the original session this id points at keeps running + // untouched (we adopt its context, not its identity). Composes with the strict-MCP isolation + // below — the forked process still loads only the cotal server. + if (opts.resume) args.push("--resume", opts.resume, "--fork-session"); + // Pre-allow fetching the public Cotal docs so a doc-grounded persona (e.g. david) // can look something up under `npx` (no repo on disk) without prompting the operator // mid-demo. Additive under the default permission mode — leaves other tools as-is. diff --git a/extensions/connector-codex/src/extension.ts b/extensions/connector-codex/src/extension.ts index 97ce4aea..a7483c73 100644 --- a/extensions/connector-codex/src/extension.ts +++ b/extensions/connector-codex/src/extension.ts @@ -22,6 +22,9 @@ export const codexConnector: Connector = { kind: "connector", name: "codex", buildLaunch(opts: LaunchOpts): LaunchSpec { + // Resume is claude-only (it forks a prior claude transcript). Fail closed rather than + // silently dropping the flag, so `--resume` against the wrong agent is an explicit error. + if (opts.resume) throw new Error("--resume is only supported by the claude connector"); const env: Record = { COTAL_SPACE: opts.space, COTAL_NAME: opts.name }; if (opts.role) env.COTAL_ROLE = opts.role; if (opts.servers) env.COTAL_SERVERS = opts.servers; diff --git a/extensions/connector-opencode/src/extension.ts b/extensions/connector-opencode/src/extension.ts index fb3d6ed0..bb2a859b 100644 --- a/extensions/connector-opencode/src/extension.ts +++ b/extensions/connector-opencode/src/extension.ts @@ -30,6 +30,9 @@ export const opencodeConnector: Connector = { kind: "connector", name: "opencode", buildLaunch(opts: LaunchOpts): LaunchSpec { + // Resume is claude-only (it forks a prior claude transcript). Fail closed rather than + // silently dropping the flag, so `--resume` against the wrong agent is an explicit error. + if (opts.resume) throw new Error("--resume is only supported by the claude connector"); // Identity rides the process env: the plugin runs in the opencode process and inherits it // (unlike the Codex/Claude MCP servers, which get none of the parent env). const env: Record = { COTAL_SPACE: opts.space, COTAL_NAME: opts.name }; diff --git a/implementations/cli/src/commands/spawn.ts b/implementations/cli/src/commands/spawn.ts index 937f172c..0b9387a9 100644 --- a/implementations/cli/src/commands/spawn.ts +++ b/implementations/cli/src/commands/spawn.ts @@ -44,6 +44,7 @@ export async function spawn(argv: string[]): Promise { agent: { type: "string" }, role: { type: "string" }, prompt: { type: "string" }, + resume: { type: "string" }, }, }); @@ -52,7 +53,7 @@ export async function spawn(argv: string[]): Promise { const ref = values.config ?? positionals[0] ?? values.name; if (!ref) { console.error( - "usage: cotal spawn | --config | --name [--agent ] [--role ] [--space ] [--server ] [--prompt ]", + "usage: cotal spawn | --config | --name [--agent ] [--role ] [--resume ] [--space ] [--server ] [--prompt ]", ); process.exit(1); } @@ -115,6 +116,7 @@ export async function spawn(argv: string[]): Promise { servers: server, configPath: path, prompt: values.prompt, + resume: values.resume, }); console.error( diff --git a/implementations/manager/src/commands.ts b/implementations/manager/src/commands.ts index 058e1e37..191f5e93 100644 --- a/implementations/manager/src/commands.ts +++ b/implementations/manager/src/commands.ts @@ -39,6 +39,7 @@ function parse(argv: string[]): Values { agent: { type: "string" }, config: { type: "string" }, creds: { type: "string" }, + resume: { type: "string" }, "console-port": { type: "string" }, drive: { type: "boolean" }, spawn: { type: "string" }, // comma-separated agent names to pre-spawn at startup @@ -108,6 +109,7 @@ async function start(argv: string[]): Promise { role: v.role, agent: v.agent, config: v.config, + resume: v.resume, }, v.creds); failIfNotOk(reply); const d = reply.data as { name: string; role?: string; agent: string; mode: string }; @@ -378,7 +380,7 @@ const managerCommands: Command[] = [ name: "start", group: "Control plane", summary: - "ask the manager to spawn an agent — --name [--role ] [--agent ] [--config ] (auto-discovers .cotal/agents/.md)", + "ask the manager to spawn an agent — --name [--role ] [--agent ] [--config ] [--resume ] (auto-discovers .cotal/agents/.md)", run: start, }, { diff --git a/implementations/manager/src/manager.ts b/implementations/manager/src/manager.ts index 4417b9b4..76a0cf2a 100644 --- a/implementations/manager/src/manager.ts +++ b/implementations/manager/src/manager.ts @@ -231,6 +231,7 @@ export class Manager { creds: credsPath, servers: this.servers, configPath, + resume: args.resume ? String(args.resume) : undefined, }); handle = this.runtime.spawn(name, spec, this.workspaceRoot); } catch (e) { diff --git a/packages/core/src/connector.ts b/packages/core/src/connector.ts index 29e7a74c..0263ceb4 100644 --- a/packages/core/src/connector.ts +++ b/packages/core/src/connector.ts @@ -21,6 +21,11 @@ export interface LaunchOpts { * that support an auto-submitted first prompt (Claude Code) deliver it; others * ignore it. Used to make a driving session greet the operator on launch. */ prompt?: string; + /** Resume a prior session of this agent type instead of starting fresh — the + * connector-specific session id to reattach. The Claude connector forks it + * (`--resume --fork-session`) so the original session keeps its identity; + * connectors that can't resume throw (fail-closed, not a silent no-op). */ + resume?: string; } /** A recipe for starting an agent as a mesh node — command, args, and extra env. */ From 2987ecdde319a80035ad1a3b40758aa47b06cd91 Mon Sep 17 00:00:00 2001 From: Lanzelot1 <35410605+Lanzelot1@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:55:10 -0700 Subject: [PATCH 2/6] test(resume): argv smoke for --resume composition Assert the claude connector folds --resume --fork-session in while keeping --strict-mcp-config and dropping the greeting on resume, and that codex/opencode reject --resume (fail-closed). Wired as pnpm smoke:resume. Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 3 ++- resume.smoke.ts | 50 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 resume.smoke.ts diff --git a/package.json b/package.json index 7723a526..d5761d78 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ "smoke:attention:auth": "tsx extensions/connector-core/attention-auth.smoke.ts", "smoke:feedback": "tsx extensions/connector-core/feedback.smoke.ts", "smoke:attach": "tsx implementations/manager/attach.smoke.ts", - "smoke:install": "tsx implementations/cli/install.smoke.ts" + "smoke:install": "tsx implementations/cli/install.smoke.ts", + "smoke:resume": "tsx resume.smoke.ts" }, "dependencies": { "@cotal-ai/cli": "workspace:*", diff --git a/resume.smoke.ts b/resume.smoke.ts new file mode 100644 index 00000000..f66e057b --- /dev/null +++ b/resume.smoke.ts @@ -0,0 +1,50 @@ +/** + * Resume launch composition — argv assertions (no mesh). Verifies the claude connector folds + * `--resume --fork-session` into the launch while keeping the strict-MCP isolation and + * dropping the auto-submitted greeting, and that the non-claude connectors reject `--resume` + * (claude-only; fail-closed). Lives at the root because it spans all three connectors. + * Run: pnpm smoke:resume + */ +import { strict as assert } from "node:assert"; +import { claudeConnector } from "@cotal-ai/connector-claude-code"; +import { codexConnector } from "@cotal-ai/connector-codex"; +import { opencodeConnector } from "@cotal-ai/connector-opencode"; + +let pass = 0; +const check = (name: string, cond: boolean, extra?: unknown) => { + assert.ok(cond, `${name}${extra !== undefined ? ` — ${JSON.stringify(extra)}` : ""}`); + pass++; + console.log(` ✓ ${name}`); +}; + +const base = { space: "rs", name: "canary" }; + +// claude: resume folds in `--resume --fork-session` and keeps the strict-MCP isolation. +const spec = claudeConnector.buildLaunch({ ...base, resume: "sess-123" }); +const a = spec.args; +check("claude args include --resume", a.includes("--resume"), a); +check("--resume is immediately followed by the session id", a[a.indexOf("--resume") + 1] === "sess-123", a); +check("--fork-session present (original session not hijacked)", a.includes("--fork-session"), a); +check("strict-mcp isolation preserved alongside resume", a.includes("--strict-mcp-config"), a); + +// The greeting prompt is skipped on resume (a resumed session already has its context); without +// resume the prompt IS the leading positional (auto-submitted). +const resumedWithPrompt = claudeConnector.buildLaunch({ ...base, resume: "sess-123", prompt: "hello there" }); +check("greeting prompt is NOT auto-submitted on resume", !resumedWithPrompt.args.includes("hello there"), resumedWithPrompt.args); +const freshWithPrompt = claudeConnector.buildLaunch({ ...base, prompt: "hello there" }); +check("greeting prompt IS the leading positional when not resuming", freshWithPrompt.args[0] === "hello there", freshWithPrompt.args); + +// codex / opencode reject resume — claude-only, fail-closed (not a silent no-op). +assert.throws( + () => codexConnector.buildLaunch({ ...base, resume: "sess-123" }), + /only supported by the claude connector/, +); +check("codex throws on resume", true); +assert.throws( + () => opencodeConnector.buildLaunch({ ...base, resume: "sess-123" }), + /only supported by the claude connector/, +); +check("opencode throws on resume", true); + +console.log(`\nresume smoke: ${pass} checks passed`); +process.exit(0); From 3b3978ae00e544786163d24f9632054469452115 Mon Sep 17 00:00:00 2001 From: Lanzelot1 <35410605+Lanzelot1@users.noreply.github.com> Date: Mon, 15 Jun 2026 16:03:53 -0700 Subject: [PATCH 3/6] feat(connector): make resume forking optional Add fork?: boolean to LaunchOpts; the claude connector emits --fork-session only when fork !== false. Default still forks, so spawn/start --resume and the resume smoke are unchanged; cotal resume --in-place passes fork:false to continue the same session id instead of branching a new one. Co-Authored-By: Claude Opus 4.8 (1M context) --- extensions/connector-claude-code/src/extension.ts | 15 ++++++++++----- packages/core/src/connector.ts | 6 ++++++ 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/extensions/connector-claude-code/src/extension.ts b/extensions/connector-claude-code/src/extension.ts index c88e80b3..45fde598 100644 --- a/extensions/connector-claude-code/src/extension.ts +++ b/extensions/connector-claude-code/src/extension.ts @@ -52,11 +52,16 @@ export const claudeConnector: Connector = { ? [opts.prompt, "--dangerously-load-development-channels", CHANNEL_REF] : ["--dangerously-load-development-channels", CHANNEL_REF]; - // Resume a prior conversation into the mesh. --fork-session makes claude mint a fresh session - // id from the resumed history, so the original session this id points at keeps running - // untouched (we adopt its context, not its identity). Composes with the strict-MCP isolation - // below — the forked process still loads only the cotal server. - if (opts.resume) args.push("--resume", opts.resume, "--fork-session"); + // Resume a prior conversation into the mesh. By default --fork-session makes claude mint a + // fresh session id from the resumed history, so the original session this id points at keeps + // running untouched (we adopt its context, not its identity) — the safe choice when it may + // still be alive. With fork === false (an in-place late-join, original already exited) we omit + // it so the *same* session id continues. Composes with the strict-MCP isolation below — either + // way the process still loads only the cotal server. + if (opts.resume) { + args.push("--resume", opts.resume); + if (opts.fork !== false) args.push("--fork-session"); + } // Pre-allow fetching the public Cotal docs so a doc-grounded persona (e.g. david) // can look something up under `npx` (no repo on disk) without prompting the operator diff --git a/packages/core/src/connector.ts b/packages/core/src/connector.ts index 0263ceb4..8e6464c2 100644 --- a/packages/core/src/connector.ts +++ b/packages/core/src/connector.ts @@ -26,6 +26,12 @@ export interface LaunchOpts { * (`--resume --fork-session`) so the original session keeps its identity; * connectors that can't resume throw (fail-closed, not a silent no-op). */ resume?: string; + /** When resuming, whether to fork the session into a fresh id (`--fork-session`) so the + * original keeps running untouched, vs. continue the *same* session in place. Defaults + * to forking — the safe choice when the original may still be alive (two live processes + * must never share one transcript). `cotal resume --in-place` sets it false to continue + * the same id after the original has exited. Ignored unless `resume` is set. */ + fork?: boolean; } /** A recipe for starting an agent as a mesh node — command, args, and extra env. */ From 58c812af74300bd58b953f78ea833ad5a6a16c86 Mon Sep 17 00:00:00 2001 From: Lanzelot1 <35410605+Lanzelot1@users.noreply.github.com> Date: Mon, 15 Jun 2026 16:04:11 -0700 Subject: [PATCH 4/6] feat(cli): cotal resume to late-join an existing session A live claude can't be hot-attached (MCP/hooks/channel are launch-bound), so late-join means relaunching a session's history wired to the mesh. cotal resume needs no agent file and runs from any directory: it discovers the newest session for --cwd (~/.claude/projects//.jsonl) and launches claude there, with definable --space/--server. Fork by default (original untouched); --in-place continues the same id. New lib/session.ts holds the discovery helpers. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- docs/claude-code-integration.md | 21 ++++-- docs/getting-started.md | 1 + implementations/cli/src/commands/resume.ts | 81 ++++++++++++++++++++++ implementations/cli/src/index.ts | 9 +++ implementations/cli/src/lib/session.ts | 36 ++++++++++ 6 files changed, 142 insertions(+), 8 deletions(-) create mode 100644 implementations/cli/src/commands/resume.ts create mode 100644 implementations/cli/src/lib/session.ts diff --git a/CLAUDE.md b/CLAUDE.md index d72ab3a7..ed32162f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,7 +30,7 @@ pnpm + TypeScript ESM monorepo — four dependency tiers, one-way deps, Node ≥ - **@cotal-ai/connector-opencode** — OpenCode adapter (native in-process plugin injected via `OPENCODE_CONFIG_CONTENT`; renders the shared `cotal_*` tool specs as plugin tools). - **@cotal-ai/cmux** — the cmux integration: a thin driver over the [cmux](https://github.com/) CLI (open/close a tab, send keys) **plus a self-registering `cmux` Runtime**. Importing it registers the runtime with the core `Registry`, so the manager spawns into cmux tabs without depending on this package (opt-in via one import; the `cotal` binary does it). - **`implementations/*` — opinionated surfaces** over core (self-contained; never import each other). - - **@cotal-ai/cli** — mesh CLI: `up`/`down`, `join`, `watch`, `console` (thin NATS clients), `spawn` — a foreground agent launch reusing the connector's launch recipe — and `setup`, the two-tier guided flow (bundled NATS binary, Claude plugin install, interactive Claude handoff on failures). First run (no `~/.cotal/onboarded.json`) is the full narrated/clack flow; later runs are a compact ensure+status; `setup --full` forces the full flow. + - **@cotal-ai/cli** — mesh CLI: `up`/`down`, `join`, `watch`, `console` (thin NATS clients), `spawn` — a foreground agent launch reusing the connector's launch recipe — `resume` — late-join an existing Claude Code session onto the mesh (no agent file, any dir; fork by default, `--in-place` to continue the same id) — and `setup`, the two-tier guided flow (bundled NATS binary, Claude plugin install, interactive Claude handoff on failures). First run (no `~/.cotal/onboarded.json`) is the full narrated/clack flow; later runs are a compact ensure+status; `setup --full` forces the full flow. - **@cotal-ai/manager** — agent supervisor: a mesh endpoint that spawns/manages nodes via a pluggable `Runtime` (`pty` default / `tmux` / `cmux`), plus its own control-plane commands (`start`/`stop`/`ps`/`attach`) and a WS attach endpoint. - **`bin/` — the published `cotal-ai` package**: `cotal.ts` is the composition root for the `cotal` binary — imports the implementations it wants (which self-register their commands) and runs them. `npx cotal-ai` with no args runs the guided `setup`. - **`examples/*` — use-cases** (composition roots; private, never published). diff --git a/docs/claude-code-integration.md b/docs/claude-code-integration.md index f5603fbb..f90fe524 100644 --- a/docs/claude-code-integration.md +++ b/docs/claude-code-integration.md @@ -55,13 +55,20 @@ claude --strict-mcp-config --mcp-config '{"mcpServers":{"cotal":{"command":"node stays inert and never joins — so an operator's own sessions in the repo don't appear as stray peers. - **Hands-free spawn.** The dev-channels flag prints a one-time "Enter to confirm" prompt; the PTY runtime auto-clears it via `LaunchSpec.confirm`, so a supervised launch needs no keypress. -- **Resume an existing session.** `cotal spawn --resume ` (and the manager's - `cotal start … --resume `) pulls a session you're already working in into the mesh instead of - starting cold: `buildLaunch` adds `--resume --fork-session`, so claude replays that transcript - into a fresh, cotal-equipped process and **forks** a new session id — your original session keeps - its identity and runs untouched. It composes with the MCP isolation above (the forked process still - loads only cotal) and skips the auto-submitted greeting (the session already has its context). - Resume is claude-only; the codex/opencode connectors reject `--resume`. +- **Late-join an existing session.** A live `claude` can't be hot-attached (MCP/hooks/channel are + launch-bound), so late-join means relaunching a session's history wired to the mesh. `cotal resume` + does it with no agent file, from any directory: it discovers the newest session for `--cwd` (default + cwd) — `~/.claude/projects//.jsonl`, where the filename is the id — and launches + `claude` there. Two modes, via `buildLaunch`'s `fork` option: + - **fork (default)** — `--resume --fork-session`: claude replays the transcript into a fresh, + cotal-equipped process under a **new** id, so the original keeps its identity and runs untouched + (the mesh peer is a copy that diverges from the fork point). + - **`--in-place`** — plain `--resume `: the **same** id/transcript continues, now mesh-wired. + Exit the original first (two live processes writing one transcript corrupts it). + + Both skip the auto-submitted greeting (the session already has context) and compose with the MCP + isolation above. The same `--resume ` flag also exists on `cotal spawn`/`cotal start` (always + fork). Resume is claude-only; the codex/opencode connectors reject `--resume`. ## Agent files (persona + identity) diff --git a/docs/getting-started.md b/docs/getting-started.md index b251e9be..25211e6c 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -68,6 +68,7 @@ feedback). Prefer commands? cotal go # open or resume your session (reuses what's up) cotal spawn me # the session you drive (consults david/sven) cotal spawn david # ask the engineer (or sven, the guide) +cotal resume # late-join the session you're already in onto the mesh cotal console --space main # live mesh view in the terminal (TUI) cotal web --space main # (re)open the browser dashboard cotal down # stop the background mesh + dashboard + manager diff --git a/implementations/cli/src/commands/resume.ts b/implementations/cli/src/commands/resume.ts new file mode 100644 index 00000000..caead643 --- /dev/null +++ b/implementations/cli/src/commands/resume.ts @@ -0,0 +1,81 @@ +import { spawn as spawnProcess } from "node:child_process"; +import { userInfo } from "node:os"; +import { resolve } from "node:path"; +import { parseArgs } from "node:util"; +import { DEFAULT_SERVER, registry, type Connector } from "@cotal-ai/core"; +import { findClaudeSession } from "../lib/session.js"; +import { resolveSpace } from "../lib/status.js"; + +/** + * `cotal resume` — late-join: bring an EXISTING Claude Code session onto the mesh. + * + * A live `claude` process can't be hot-attached (its MCP, hooks and wake channel are bound + * at launch), so late-join means relaunching the session's history wired to the mesh. Unlike + * `cotal spawn`, this needs NO agent file and runs from any directory: it discovers the newest + * session for `--cwd` (default: where you run it), then launches `claude` there, joined to the + * space. Two modes: + * + * default fork — `--resume --fork-session`: a new id seeded with the history; the + * original keeps running untouched. Always safe; the mesh peer is a copy. + * --in-place continue the SAME id — the true "this session is now on the mesh". Exit the + * original first (two live processes writing one transcript corrupts it). + */ +export async function resume(argv: string[]): Promise { + const { values } = parseArgs({ + args: argv, + allowPositionals: true, + options: { + resume: { type: "string" }, // explicit session id; else auto-discover the newest for cwd + cwd: { type: "string" }, // which project's session to adopt (and where claude is launched) + "in-place": { type: "boolean" }, // continue the same id instead of forking a new one + name: { type: "string" }, + role: { type: "string" }, + space: { type: "string" }, + server: { type: "string" }, + agent: { type: "string" }, + }, + }); + + // The session lives under, and is resumed from, this directory — `claude --resume ` only + // resolves an id within its own project (cwd), so we both discover and launch there. + const cwd = resolve(values.cwd ?? process.cwd()); + const sessionId = values.resume ?? findClaudeSession(cwd)?.id; + if (!sessionId) { + console.error( + `✗ no Claude Code session found for ${cwd}\n` + + ` looked in ~/.claude/projects/${cwd.replace(/[^a-zA-Z0-9]/g, "-")}/\n` + + ` pass --resume , or --cwd to point at another project`, + ); + process.exit(1); + } + + const fork = !values["in-place"]; + const name = values.name ?? userInfo().username; + const role = values.role; + const space = values.space ?? resolveSpace(cwd); + const server = values.server ?? DEFAULT_SERVER; + + const connector = registry.resolve("connector", values.agent ?? "claude"); + const spec = connector.buildLaunch({ space, name, role, servers: server, resume: sessionId, fork }); + + console.error( + `resuming ${name} into ${space} ${fork ? "(fork)" : "(in place — exit the original first)"} ` + + `— session ${sessionId.slice(0, 8)} — press Enter at the dev-channels prompt`, + ); + const child = spawnProcess(spec.command, spec.args, { + cwd, + stdio: "inherit", + env: { ...process.env, ...spec.env }, + }); + await new Promise((res) => { + child.on("error", (e) => { + console.error(`✗ failed to launch ${spec.command}: ${e.message}`); + process.exitCode = 1; + res(); + }); + child.on("exit", (code) => { + process.exitCode = code ?? 0; + res(); + }); + }); +} diff --git a/implementations/cli/src/index.ts b/implementations/cli/src/index.ts index 60187997..98203784 100644 --- a/implementations/cli/src/index.ts +++ b/implementations/cli/src/index.ts @@ -8,6 +8,7 @@ import { console_ } from "./commands/console.js"; import { demo } from "./commands/demo.js"; import { web } from "./commands/web.js"; import { spawn } from "./commands/spawn.js"; +import { resume } from "./commands/resume.js"; import { mint } from "./commands/mint.js"; import { channels } from "./commands/channels.js"; import { history } from "./commands/history.js"; @@ -89,6 +90,14 @@ const baseCommands: Command[] = [ "launch an agent in this terminal from a file — spawn | --name --config [--agent ] [--role ]", run: spawn, }, + { + kind: "command", + name: "resume", + group: "Agents", + summary: + "late-join an existing Claude Code session onto the mesh (no agent file, any dir) — resume [--resume ] [--cwd ] [--in-place] [--space ] [--server ] [--name ]", + run: resume, + }, { kind: "command", name: "mint", diff --git a/implementations/cli/src/lib/session.ts b/implementations/cli/src/lib/session.ts new file mode 100644 index 00000000..16c00fde --- /dev/null +++ b/implementations/cli/src/lib/session.ts @@ -0,0 +1,36 @@ +import { readdirSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +export interface ClaudeSession { + /** The session id `claude --resume ` expects — the transcript filename without `.jsonl`. */ + id: string; + path: string; + mtimeMs: number; +} + +/** Claude Code stores a project's session transcripts under + * `~/.claude/projects//.jsonl`, where `` is the absolute + * working directory with every non-alphanumeric character replaced by `-`. Returns the sessions + * for `cwd`, newest first; empty when the project has none. */ +export function listClaudeSessions(cwd: string): ClaudeSession[] { + const dir = join(homedir(), ".claude", "projects", cwd.replace(/[^a-zA-Z0-9]/g, "-")); + let names: string[]; + try { + names = readdirSync(dir); + } catch { + return []; // no project dir → no sessions for this cwd (the caller turns this into a clear error) + } + return names + .filter((n) => n.endsWith(".jsonl")) + .map((n) => { + const path = join(dir, n); + return { id: n.slice(0, -".jsonl".length), path, mtimeMs: statSync(path).mtimeMs }; + }) + .sort((a, b) => b.mtimeMs - a.mtimeMs); +} + +/** The most-recently-active Claude Code session for `cwd`, or undefined if there is none. */ +export function findClaudeSession(cwd: string): ClaudeSession | undefined { + return listClaudeSessions(cwd)[0]; +} From 7305e0c7f2220617e315b7f99b80dde9152dac41 Mon Sep 17 00:00:00 2001 From: Lanzelot1 <35410605+Lanzelot1@users.noreply.github.com> Date: Mon, 15 Jun 2026 16:04:23 -0700 Subject: [PATCH 5/6] test(resume): in-place fork toggle + session discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assert fork:false drops --fork-session while keeping --resume (and the default still forks), and that findClaudeSession returns the newest session by mtime with the cwd→projects-dir encoding (empty for an unknown cwd). Co-Authored-By: Claude Opus 4.8 (1M context) --- resume.smoke.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/resume.smoke.ts b/resume.smoke.ts index f66e057b..07642904 100644 --- a/resume.smoke.ts +++ b/resume.smoke.ts @@ -6,9 +6,13 @@ * Run: pnpm smoke:resume */ import { strict as assert } from "node:assert"; +import { mkdirSync, mkdtempSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { claudeConnector } from "@cotal-ai/connector-claude-code"; import { codexConnector } from "@cotal-ai/connector-codex"; import { opencodeConnector } from "@cotal-ai/connector-opencode"; +import { findClaudeSession } from "./implementations/cli/src/lib/session.js"; let pass = 0; const check = (name: string, cond: boolean, extra?: unknown) => { @@ -46,5 +50,28 @@ assert.throws( ); check("opencode throws on resume", true); +// fork toggle: `cotal resume --in-place` (fork:false) continues the SAME id — keeps --resume, +// drops --fork-session — while the default (fork undefined) still forks. Powers the late-join modes. +const inPlace = claudeConnector.buildLaunch({ ...base, resume: "sess-123", fork: false }); +check("in-place resume keeps --resume", inPlace.args.includes("--resume"), inPlace.args); +check("in-place resume omits --fork-session (same id continues)", !inPlace.args.includes("--fork-session"), inPlace.args); +check("default resume still forks when fork is unset", spec.args.includes("--fork-session"), spec.args); + +// session discovery: newest-by-mtime, with the cwd → `~/.claude/projects/` mapping. +const home = mkdtempSync(join(tmpdir(), "cotal-resume-")); +process.env.HOME = home; // os.homedir() honors $HOME on POSIX +const projectCwd = "/tmp/My Proj.dir"; +const dir = join(home, ".claude", "projects", projectCwd.replace(/[^a-zA-Z0-9]/g, "-")); +mkdirSync(dir, { recursive: true }); +const older = join(dir, "older-session.jsonl"); +const newer = join(dir, "newer-session.jsonl"); +writeFileSync(older, "{}\n"); +writeFileSync(newer, "{}\n"); +utimesSync(older, 1000, 1000); +utimesSync(newer, 2000, 2000); +const found = findClaudeSession(projectCwd); +check("findClaudeSession returns the newest session by mtime", found?.id === "newer-session", found); +check("findClaudeSession is empty for an unknown cwd", findClaudeSession("/no/such/project") === undefined); + console.log(`\nresume smoke: ${pass} checks passed`); process.exit(0); From 036fd7b4370c0bef96a1373898afce82739f44b7 Mon Sep 17 00:00:00 2001 From: Lanzelot1 <35410605+Lanzelot1@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:40:57 -0700 Subject: [PATCH 6/6] refactor(resume): fold late-join into `cotal spawn --resume` (Davis review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the standalone `cotal resume` command + session-discovery helper; resume is now a pass-through flag on spawn (and start), claude-specific. Other connectors ignore `--resume` like they already ignore `prompt` — no more throws. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- docs/claude-code-integration.md | 14 ++-- docs/getting-started.md | 2 +- extensions/connector-codex/src/extension.ts | 3 - .../connector-opencode/src/extension.ts | 3 - implementations/cli/src/commands/resume.ts | 81 ------------------- implementations/cli/src/commands/spawn.ts | 7 +- implementations/cli/src/index.ts | 11 +-- implementations/cli/src/lib/session.ts | 36 --------- packages/core/src/connector.ts | 8 +- resume.smoke.ts | 40 +-------- 11 files changed, 21 insertions(+), 186 deletions(-) delete mode 100644 implementations/cli/src/commands/resume.ts delete mode 100644 implementations/cli/src/lib/session.ts diff --git a/CLAUDE.md b/CLAUDE.md index ed32162f..49f1b2df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,7 +30,7 @@ pnpm + TypeScript ESM monorepo — four dependency tiers, one-way deps, Node ≥ - **@cotal-ai/connector-opencode** — OpenCode adapter (native in-process plugin injected via `OPENCODE_CONFIG_CONTENT`; renders the shared `cotal_*` tool specs as plugin tools). - **@cotal-ai/cmux** — the cmux integration: a thin driver over the [cmux](https://github.com/) CLI (open/close a tab, send keys) **plus a self-registering `cmux` Runtime**. Importing it registers the runtime with the core `Registry`, so the manager spawns into cmux tabs without depending on this package (opt-in via one import; the `cotal` binary does it). - **`implementations/*` — opinionated surfaces** over core (self-contained; never import each other). - - **@cotal-ai/cli** — mesh CLI: `up`/`down`, `join`, `watch`, `console` (thin NATS clients), `spawn` — a foreground agent launch reusing the connector's launch recipe — `resume` — late-join an existing Claude Code session onto the mesh (no agent file, any dir; fork by default, `--in-place` to continue the same id) — and `setup`, the two-tier guided flow (bundled NATS binary, Claude plugin install, interactive Claude handoff on failures). First run (no `~/.cotal/onboarded.json`) is the full narrated/clack flow; later runs are a compact ensure+status; `setup --full` forces the full flow. + - **@cotal-ai/cli** — mesh CLI: `up`/`down`, `join`, `watch`, `console` (thin NATS clients), `spawn` — a foreground agent launch reusing the connector's launch recipe, with `--resume ` to late-join an existing Claude Code session onto the mesh (fork by default, `--in-place` to continue the same id) — and `setup`, the two-tier guided flow (bundled NATS binary, Claude plugin install, interactive Claude handoff on failures). First run (no `~/.cotal/onboarded.json`) is the full narrated/clack flow; later runs are a compact ensure+status; `setup --full` forces the full flow. - **@cotal-ai/manager** — agent supervisor: a mesh endpoint that spawns/manages nodes via a pluggable `Runtime` (`pty` default / `tmux` / `cmux`), plus its own control-plane commands (`start`/`stop`/`ps`/`attach`) and a WS attach endpoint. - **`bin/` — the published `cotal-ai` package**: `cotal.ts` is the composition root for the `cotal` binary — imports the implementations it wants (which self-register their commands) and runs them. `npx cotal-ai` with no args runs the guided `setup`. - **`examples/*` — use-cases** (composition roots; private, never published). diff --git a/docs/claude-code-integration.md b/docs/claude-code-integration.md index f90fe524..7e44cad2 100644 --- a/docs/claude-code-integration.md +++ b/docs/claude-code-integration.md @@ -56,19 +56,17 @@ claude --strict-mcp-config --mcp-config '{"mcpServers":{"cotal":{"command":"node - **Hands-free spawn.** The dev-channels flag prints a one-time "Enter to confirm" prompt; the PTY runtime auto-clears it via `LaunchSpec.confirm`, so a supervised launch needs no keypress. - **Late-join an existing session.** A live `claude` can't be hot-attached (MCP/hooks/channel are - launch-bound), so late-join means relaunching a session's history wired to the mesh. `cotal resume` - does it with no agent file, from any directory: it discovers the newest session for `--cwd` (default - cwd) — `~/.claude/projects//.jsonl`, where the filename is the id — and launches - `claude` there. Two modes, via `buildLaunch`'s `fork` option: + launch-bound), so late-join means relaunching a session's history wired to the mesh — a `--resume + ` pass-through on `cotal spawn ` (and `cotal start`), where `` is the transcript + filename under `~/.claude/projects//.jsonl`. Two modes: - **fork (default)** — `--resume --fork-session`: claude replays the transcript into a fresh, cotal-equipped process under a **new** id, so the original keeps its identity and runs untouched (the mesh peer is a copy that diverges from the fork point). - - **`--in-place`** — plain `--resume `: the **same** id/transcript continues, now mesh-wired. - Exit the original first (two live processes writing one transcript corrupts it). + - **`--in-place`** (spawn only) — plain `--resume `: the **same** id/transcript continues, now + mesh-wired. Exit the original first (two live processes writing one transcript corrupts it). Both skip the auto-submitted greeting (the session already has context) and compose with the MCP - isolation above. The same `--resume ` flag also exists on `cotal spawn`/`cotal start` (always - fork). Resume is claude-only; the codex/opencode connectors reject `--resume`. + isolation above. Resume is claude-only; the other connectors ignore the flag (like `prompt`). ## Agent files (persona + identity) diff --git a/docs/getting-started.md b/docs/getting-started.md index 25211e6c..ff0849a3 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -68,7 +68,7 @@ feedback). Prefer commands? cotal go # open or resume your session (reuses what's up) cotal spawn me # the session you drive (consults david/sven) cotal spawn david # ask the engineer (or sven, the guide) -cotal resume # late-join the session you're already in onto the mesh +cotal spawn me --resume # late-join an existing Claude session onto the mesh cotal console --space main # live mesh view in the terminal (TUI) cotal web --space main # (re)open the browser dashboard cotal down # stop the background mesh + dashboard + manager diff --git a/extensions/connector-codex/src/extension.ts b/extensions/connector-codex/src/extension.ts index a7483c73..97ce4aea 100644 --- a/extensions/connector-codex/src/extension.ts +++ b/extensions/connector-codex/src/extension.ts @@ -22,9 +22,6 @@ export const codexConnector: Connector = { kind: "connector", name: "codex", buildLaunch(opts: LaunchOpts): LaunchSpec { - // Resume is claude-only (it forks a prior claude transcript). Fail closed rather than - // silently dropping the flag, so `--resume` against the wrong agent is an explicit error. - if (opts.resume) throw new Error("--resume is only supported by the claude connector"); const env: Record = { COTAL_SPACE: opts.space, COTAL_NAME: opts.name }; if (opts.role) env.COTAL_ROLE = opts.role; if (opts.servers) env.COTAL_SERVERS = opts.servers; diff --git a/extensions/connector-opencode/src/extension.ts b/extensions/connector-opencode/src/extension.ts index bb2a859b..fb3d6ed0 100644 --- a/extensions/connector-opencode/src/extension.ts +++ b/extensions/connector-opencode/src/extension.ts @@ -30,9 +30,6 @@ export const opencodeConnector: Connector = { kind: "connector", name: "opencode", buildLaunch(opts: LaunchOpts): LaunchSpec { - // Resume is claude-only (it forks a prior claude transcript). Fail closed rather than - // silently dropping the flag, so `--resume` against the wrong agent is an explicit error. - if (opts.resume) throw new Error("--resume is only supported by the claude connector"); // Identity rides the process env: the plugin runs in the opencode process and inherits it // (unlike the Codex/Claude MCP servers, which get none of the parent env). const env: Record = { COTAL_SPACE: opts.space, COTAL_NAME: opts.name }; diff --git a/implementations/cli/src/commands/resume.ts b/implementations/cli/src/commands/resume.ts deleted file mode 100644 index caead643..00000000 --- a/implementations/cli/src/commands/resume.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { spawn as spawnProcess } from "node:child_process"; -import { userInfo } from "node:os"; -import { resolve } from "node:path"; -import { parseArgs } from "node:util"; -import { DEFAULT_SERVER, registry, type Connector } from "@cotal-ai/core"; -import { findClaudeSession } from "../lib/session.js"; -import { resolveSpace } from "../lib/status.js"; - -/** - * `cotal resume` — late-join: bring an EXISTING Claude Code session onto the mesh. - * - * A live `claude` process can't be hot-attached (its MCP, hooks and wake channel are bound - * at launch), so late-join means relaunching the session's history wired to the mesh. Unlike - * `cotal spawn`, this needs NO agent file and runs from any directory: it discovers the newest - * session for `--cwd` (default: where you run it), then launches `claude` there, joined to the - * space. Two modes: - * - * default fork — `--resume --fork-session`: a new id seeded with the history; the - * original keeps running untouched. Always safe; the mesh peer is a copy. - * --in-place continue the SAME id — the true "this session is now on the mesh". Exit the - * original first (two live processes writing one transcript corrupts it). - */ -export async function resume(argv: string[]): Promise { - const { values } = parseArgs({ - args: argv, - allowPositionals: true, - options: { - resume: { type: "string" }, // explicit session id; else auto-discover the newest for cwd - cwd: { type: "string" }, // which project's session to adopt (and where claude is launched) - "in-place": { type: "boolean" }, // continue the same id instead of forking a new one - name: { type: "string" }, - role: { type: "string" }, - space: { type: "string" }, - server: { type: "string" }, - agent: { type: "string" }, - }, - }); - - // The session lives under, and is resumed from, this directory — `claude --resume ` only - // resolves an id within its own project (cwd), so we both discover and launch there. - const cwd = resolve(values.cwd ?? process.cwd()); - const sessionId = values.resume ?? findClaudeSession(cwd)?.id; - if (!sessionId) { - console.error( - `✗ no Claude Code session found for ${cwd}\n` + - ` looked in ~/.claude/projects/${cwd.replace(/[^a-zA-Z0-9]/g, "-")}/\n` + - ` pass --resume , or --cwd to point at another project`, - ); - process.exit(1); - } - - const fork = !values["in-place"]; - const name = values.name ?? userInfo().username; - const role = values.role; - const space = values.space ?? resolveSpace(cwd); - const server = values.server ?? DEFAULT_SERVER; - - const connector = registry.resolve("connector", values.agent ?? "claude"); - const spec = connector.buildLaunch({ space, name, role, servers: server, resume: sessionId, fork }); - - console.error( - `resuming ${name} into ${space} ${fork ? "(fork)" : "(in place — exit the original first)"} ` + - `— session ${sessionId.slice(0, 8)} — press Enter at the dev-channels prompt`, - ); - const child = spawnProcess(spec.command, spec.args, { - cwd, - stdio: "inherit", - env: { ...process.env, ...spec.env }, - }); - await new Promise((res) => { - child.on("error", (e) => { - console.error(`✗ failed to launch ${spec.command}: ${e.message}`); - process.exitCode = 1; - res(); - }); - child.on("exit", (code) => { - process.exitCode = code ?? 0; - res(); - }); - }); -} diff --git a/implementations/cli/src/commands/spawn.ts b/implementations/cli/src/commands/spawn.ts index 0b9387a9..716ae4d4 100644 --- a/implementations/cli/src/commands/spawn.ts +++ b/implementations/cli/src/commands/spawn.ts @@ -44,7 +44,8 @@ export async function spawn(argv: string[]): Promise { agent: { type: "string" }, role: { type: "string" }, prompt: { type: "string" }, - resume: { type: "string" }, + resume: { type: "string" }, // resume a prior Claude session id (claude-only; others ignore) + "in-place": { type: "boolean" }, // continue the same id instead of forking a fresh one }, }); @@ -53,7 +54,7 @@ export async function spawn(argv: string[]): Promise { const ref = values.config ?? positionals[0] ?? values.name; if (!ref) { console.error( - "usage: cotal spawn | --config | --name [--agent ] [--role ] [--resume ] [--space ] [--server ] [--prompt ]", + "usage: cotal spawn | --config | --name [--agent ] [--role ] [--resume ] [--in-place] [--space ] [--server ] [--prompt ]", ); process.exit(1); } @@ -117,6 +118,8 @@ export async function spawn(argv: string[]): Promise { configPath: path, prompt: values.prompt, resume: values.resume, + // Fork by default (safe — original session untouched); --in-place continues the same id. + fork: values["in-place"] ? false : undefined, }); console.error( diff --git a/implementations/cli/src/index.ts b/implementations/cli/src/index.ts index 98203784..70c87ae4 100644 --- a/implementations/cli/src/index.ts +++ b/implementations/cli/src/index.ts @@ -8,7 +8,6 @@ import { console_ } from "./commands/console.js"; import { demo } from "./commands/demo.js"; import { web } from "./commands/web.js"; import { spawn } from "./commands/spawn.js"; -import { resume } from "./commands/resume.js"; import { mint } from "./commands/mint.js"; import { channels } from "./commands/channels.js"; import { history } from "./commands/history.js"; @@ -87,17 +86,9 @@ const baseCommands: Command[] = [ name: "spawn", group: "Agents", summary: - "launch an agent in this terminal from a file — spawn | --name --config [--agent ] [--role ]", + "launch an agent in this terminal from a file — spawn | --name --config [--agent ] [--role ] [--resume ] [--in-place]", run: spawn, }, - { - kind: "command", - name: "resume", - group: "Agents", - summary: - "late-join an existing Claude Code session onto the mesh (no agent file, any dir) — resume [--resume ] [--cwd ] [--in-place] [--space ] [--server ] [--name ]", - run: resume, - }, { kind: "command", name: "mint", diff --git a/implementations/cli/src/lib/session.ts b/implementations/cli/src/lib/session.ts deleted file mode 100644 index 16c00fde..00000000 --- a/implementations/cli/src/lib/session.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { readdirSync, statSync } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; - -export interface ClaudeSession { - /** The session id `claude --resume ` expects — the transcript filename without `.jsonl`. */ - id: string; - path: string; - mtimeMs: number; -} - -/** Claude Code stores a project's session transcripts under - * `~/.claude/projects//.jsonl`, where `` is the absolute - * working directory with every non-alphanumeric character replaced by `-`. Returns the sessions - * for `cwd`, newest first; empty when the project has none. */ -export function listClaudeSessions(cwd: string): ClaudeSession[] { - const dir = join(homedir(), ".claude", "projects", cwd.replace(/[^a-zA-Z0-9]/g, "-")); - let names: string[]; - try { - names = readdirSync(dir); - } catch { - return []; // no project dir → no sessions for this cwd (the caller turns this into a clear error) - } - return names - .filter((n) => n.endsWith(".jsonl")) - .map((n) => { - const path = join(dir, n); - return { id: n.slice(0, -".jsonl".length), path, mtimeMs: statSync(path).mtimeMs }; - }) - .sort((a, b) => b.mtimeMs - a.mtimeMs); -} - -/** The most-recently-active Claude Code session for `cwd`, or undefined if there is none. */ -export function findClaudeSession(cwd: string): ClaudeSession | undefined { - return listClaudeSessions(cwd)[0]; -} diff --git a/packages/core/src/connector.ts b/packages/core/src/connector.ts index 8e6464c2..297ab63e 100644 --- a/packages/core/src/connector.ts +++ b/packages/core/src/connector.ts @@ -22,14 +22,14 @@ export interface LaunchOpts { * ignore it. Used to make a driving session greet the operator on launch. */ prompt?: string; /** Resume a prior session of this agent type instead of starting fresh — the - * connector-specific session id to reattach. The Claude connector forks it - * (`--resume --fork-session`) so the original session keeps its identity; - * connectors that can't resume throw (fail-closed, not a silent no-op). */ + * connector-specific session id to reattach. Like {@link prompt}, only connectors + * that support resuming apply it (the Claude connector forks it: `--resume + * --fork-session`); others ignore it. */ resume?: string; /** When resuming, whether to fork the session into a fresh id (`--fork-session`) so the * original keeps running untouched, vs. continue the *same* session in place. Defaults * to forking — the safe choice when the original may still be alive (two live processes - * must never share one transcript). `cotal resume --in-place` sets it false to continue + * must never share one transcript). `cotal spawn --in-place` sets it false to continue * the same id after the original has exited. Ignored unless `resume` is set. */ fork?: boolean; } diff --git a/resume.smoke.ts b/resume.smoke.ts index 07642904..e6e8e842 100644 --- a/resume.smoke.ts +++ b/resume.smoke.ts @@ -1,18 +1,12 @@ /** * Resume launch composition — argv assertions (no mesh). Verifies the claude connector folds * `--resume --fork-session` into the launch while keeping the strict-MCP isolation and - * dropping the auto-submitted greeting, and that the non-claude connectors reject `--resume` - * (claude-only; fail-closed). Lives at the root because it spans all three connectors. + * dropping the auto-submitted greeting, and that the `--in-place` fork toggle drops `--fork-session`. + * Resume is claude-only (other connectors ignore the flag, like `prompt`). * Run: pnpm smoke:resume */ import { strict as assert } from "node:assert"; -import { mkdirSync, mkdtempSync, utimesSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { claudeConnector } from "@cotal-ai/connector-claude-code"; -import { codexConnector } from "@cotal-ai/connector-codex"; -import { opencodeConnector } from "@cotal-ai/connector-opencode"; -import { findClaudeSession } from "./implementations/cli/src/lib/session.js"; let pass = 0; const check = (name: string, cond: boolean, extra?: unknown) => { @@ -38,40 +32,12 @@ check("greeting prompt is NOT auto-submitted on resume", !resumedWithPrompt.args const freshWithPrompt = claudeConnector.buildLaunch({ ...base, prompt: "hello there" }); check("greeting prompt IS the leading positional when not resuming", freshWithPrompt.args[0] === "hello there", freshWithPrompt.args); -// codex / opencode reject resume — claude-only, fail-closed (not a silent no-op). -assert.throws( - () => codexConnector.buildLaunch({ ...base, resume: "sess-123" }), - /only supported by the claude connector/, -); -check("codex throws on resume", true); -assert.throws( - () => opencodeConnector.buildLaunch({ ...base, resume: "sess-123" }), - /only supported by the claude connector/, -); -check("opencode throws on resume", true); - -// fork toggle: `cotal resume --in-place` (fork:false) continues the SAME id — keeps --resume, +// fork toggle: `cotal spawn --in-place` (fork:false) continues the SAME id — keeps --resume, // drops --fork-session — while the default (fork undefined) still forks. Powers the late-join modes. const inPlace = claudeConnector.buildLaunch({ ...base, resume: "sess-123", fork: false }); check("in-place resume keeps --resume", inPlace.args.includes("--resume"), inPlace.args); check("in-place resume omits --fork-session (same id continues)", !inPlace.args.includes("--fork-session"), inPlace.args); check("default resume still forks when fork is unset", spec.args.includes("--fork-session"), spec.args); -// session discovery: newest-by-mtime, with the cwd → `~/.claude/projects/` mapping. -const home = mkdtempSync(join(tmpdir(), "cotal-resume-")); -process.env.HOME = home; // os.homedir() honors $HOME on POSIX -const projectCwd = "/tmp/My Proj.dir"; -const dir = join(home, ".claude", "projects", projectCwd.replace(/[^a-zA-Z0-9]/g, "-")); -mkdirSync(dir, { recursive: true }); -const older = join(dir, "older-session.jsonl"); -const newer = join(dir, "newer-session.jsonl"); -writeFileSync(older, "{}\n"); -writeFileSync(newer, "{}\n"); -utimesSync(older, 1000, 1000); -utimesSync(newer, 2000, 2000); -const found = findClaudeSession(projectCwd); -check("findClaudeSession returns the newest session by mtime", found?.id === "newer-session", found); -check("findClaudeSession is empty for an unknown cwd", findClaudeSession("/no/such/project") === undefined); - console.log(`\nresume smoke: ${pass} checks passed`); process.exit(0);