Skip to content
Open
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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>` 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).
Expand Down
12 changes: 12 additions & 0 deletions docs/claude-code-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<id>` pass-through on `cotal spawn <name>` (and `cotal start`), where `<id>` is the transcript
filename under `~/.claude/projects/<encoded-cwd>/<id>.jsonl`. Two modes:
- **fork (default)** — `--resume <id> --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 <id>`: 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)

Expand Down
1 change: 1 addition & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id> # 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
Expand Down
18 changes: 15 additions & 3 deletions extensions/connector-claude-code/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 6 additions & 1 deletion implementations/cli/src/commands/spawn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ export async function spawn(argv: string[]): Promise<void> {
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
},
});

Expand All @@ -52,7 +54,7 @@ export async function spawn(argv: string[]): Promise<void> {
const ref = values.config ?? positionals[0] ?? values.name;
if (!ref) {
console.error(
"usage: cotal spawn <name-or-path> | --config <path> | --name <n> [--agent <a>] [--role <r>] [--space <s>] [--server <url>] [--prompt <text>]",
"usage: cotal spawn <name-or-path> | --config <path> | --name <n> [--agent <a>] [--role <r>] [--resume <id>] [--in-place] [--space <s>] [--server <url>] [--prompt <text>]",
);
process.exit(1);
}
Expand Down Expand Up @@ -115,6 +117,9 @@ export async function spawn(argv: string[]): Promise<void> {
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(
Expand Down
2 changes: 1 addition & 1 deletion implementations/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ const baseCommands: Command[] = [
name: "spawn",
group: "Agents",
summary:
"launch an agent in this terminal from a file — spawn <name-or-path> | --name <n> --config <path> [--agent <a>] [--role <r>]",
"launch an agent in this terminal from a file — spawn <name-or-path> | --name <n> --config <path> [--agent <a>] [--role <r>] [--resume <id>] [--in-place]",
run: spawn,
},
{
Expand Down
4 changes: 3 additions & 1 deletion implementations/manager/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -108,6 +109,7 @@ async function start(argv: string[]): Promise<void> {
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 };
Expand Down Expand Up @@ -378,7 +380,7 @@ const managerCommands: Command[] = [
name: "start",
group: "Control plane",
summary:
"ask the manager to spawn an agent — --name <n> [--role <r>] [--agent <a>] [--config <file>] (auto-discovers .cotal/agents/<n>.md)",
"ask the manager to spawn an agent — --name <n> [--role <r>] [--agent <a>] [--config <file>] [--resume <id>] (auto-discovers .cotal/agents/<n>.md)",
run: start,
},
{
Expand Down
1 change: 1 addition & 0 deletions implementations/manager/src/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/connector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>
* --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. */
Expand Down
43 changes: 43 additions & 0 deletions resume.smoke.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* Resume launch composition — argv assertions (no mesh). Verifies the claude connector folds
* `--resume <id> --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 <id> --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);