diff --git a/CLAUDE.md b/CLAUDE.md index d72ab3a7..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 — 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 14c689d0..7e44cad2 100644 --- a/docs/claude-code-integration.md +++ b/docs/claude-code-integration.md @@ -55,6 +55,18 @@ 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. +- **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 — 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`** (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. 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 b251e9be..ff0849a3 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 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-claude-code/src/extension.ts b/extensions/connector-claude-code/src/extension.ts index 1bddd1b8..45fde598 100644 --- a/extensions/connector-claude-code/src/extension.ts +++ b/extensions/connector-claude-code/src/extension.ts @@ -45,12 +45,24 @@ 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. 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 // mid-demo. Additive under the default permission mode — leaves other tools as-is. diff --git a/implementations/cli/src/commands/spawn.ts b/implementations/cli/src/commands/spawn.ts index 937f172c..716ae4d4 100644 --- a/implementations/cli/src/commands/spawn.ts +++ b/implementations/cli/src/commands/spawn.ts @@ -44,6 +44,8 @@ export async function spawn(argv: string[]): Promise { agent: { type: "string" }, role: { type: "string" }, prompt: { 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 }, }); @@ -52,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 ] [--space ] [--server ] [--prompt ]", + "usage: cotal spawn | --config | --name [--agent ] [--role ] [--resume ] [--in-place] [--space ] [--server ] [--prompt ]", ); process.exit(1); } @@ -115,6 +117,9 @@ export async function spawn(argv: string[]): Promise { servers: server, 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 60187997..70c87ae4 100644 --- a/implementations/cli/src/index.ts +++ b/implementations/cli/src/index.ts @@ -86,7 +86,7 @@ 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, }, { 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/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/packages/core/src/connector.ts b/packages/core/src/connector.ts index 29e7a74c..297ab63e 100644 --- a/packages/core/src/connector.ts +++ b/packages/core/src/connector.ts @@ -21,6 +21,17 @@ 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. 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 spawn --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. */ diff --git a/resume.smoke.ts b/resume.smoke.ts new file mode 100644 index 00000000..e6e8e842 --- /dev/null +++ b/resume.smoke.ts @@ -0,0 +1,43 @@ +/** + * 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 `--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 { claudeConnector } from "@cotal-ai/connector-claude-code"; + +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); + +// 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); + +console.log(`\nresume smoke: ${pass} checks passed`); +process.exit(0);