diff --git a/.changeset/attach-runtime-hint.md b/.changeset/attach-runtime-hint.md new file mode 100644 index 00000000..46b165c1 --- /dev/null +++ b/.changeset/attach-runtime-hint.md @@ -0,0 +1,8 @@ +--- +"@cotal-ai/manager": patch +--- + +`cotal attach` now gives runtime-correct guidance. The manager previously assumed any non-`pty` +runtime was tmux, so cmux users were told to run a `tmux attach` command that doesn't apply. +`opAttach` now delegates to the agent handle's own `attach()`, so each runtime speaks for itself: +tmux → `tmux attach -t cotal-:`, cmux → switch to the `cotal-` tab. diff --git a/docs/architecture.md b/docs/architecture.md index f7978171..b9343c98 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -263,12 +263,14 @@ dependency on them). Selectable backends: or types in via `cotal attach ` (stream the PTY), and the manager keeps full OS-signal control (group-kill, restart). No external software to install. - **`tmux` / `iTerm2` (opt-in)** — for users already living in a multiplexer who want native - panes / persistence; auto-detect (if already inside tmux, use it). + panes / persistence; auto-detect (if already inside tmux, use it). You watch it natively, so + `cotal attach` points you at `tmux attach -t cotal-:` rather than streaming. - **`cmux` (integration)** — each agent gets its own [cmux](https://github.com/) tab. This is a true plug-in: the `cmux` runtime lives in **`@cotal-ai/cmux`** and self-registers a `RuntimeProvider` on import, so the manager spawns into tabs without depending on the package — a composition root opts in with one `import "@cotal-ai/cmux"` (the `cotal` binary does). Like tmux you watch it - natively, so `attach` points you at the tab rather than streaming. Teardown is real: the runtime + natively, so `cotal attach` points you at the `cotal-` tab rather than streaming (it is + *not* tmux — cmux is its own CLI/app). Teardown is real: the runtime keeps the tab's workspace + surface ids, so `stop` types `/exit` for a clean leave then closes the tab (graceful) or closes it outright (hard). The manager must run inside a live cmux surface (cmux only authorizes its control socket from a real pane). Drives diff --git a/implementations/manager/attach.smoke.ts b/implementations/manager/attach.smoke.ts new file mode 100644 index 00000000..0c84f57f --- /dev/null +++ b/implementations/manager/attach.smoke.ts @@ -0,0 +1,100 @@ +/** + * Runtime attach smoke (no NATS, no test runner) — run with: pnpm smoke:attach + * + * Guards that `cotal attach` gives runtime-correct guidance: only `pty` streams over the WS + * attach endpoint; `tmux`/`cmux` are watched natively, so each handle's attach() must throw its + * OWN hint (this is what manager.opAttach surfaces). Regression target: before, opAttach assumed + * "not pty == tmux" and told cmux users to run a tmux command. Spawns a throwaway `sleep` so it + * needs no claude/mesh; tmux/cmux are skipped (logged) when not present on the machine. + */ +import { execFileSync } from "node:child_process"; +import { createRuntime } from "./src/index.js"; +import "@cotal-ai/cmux"; // registers the `cmux` runtime provider +import type { LaunchSpec } from "@cotal-ai/core"; + +let failures = 0; +function check(label: string, cond: boolean): void { + console.log(`${cond ? "✓" : "✗"} ${label}`); + if (!cond) failures++; +} +function skip(label: string, why: string): void { + console.log(`• ${label} skipped (${why})`); +} +function attachError(fn: () => unknown): string { + try { + fn(); + return ""; + } catch (e) { + return (e as Error).message; + } +} + +const SESSION = "cotal-smoke"; +const spec: LaunchSpec = { command: "sleep", args: ["60"] }; +const cwd = process.cwd(); + +// pty (default) — the one streamable backend: attach() returns a live session, never throws. +{ + const h = createRuntime("pty", SESSION).spawn("smoke-pty", spec, cwd); + let sess: { onData?: unknown; cols?: unknown } | undefined; + const err = attachError(() => (sess = h.attach())); + check( + "pty: attach() returns a live session (streams, no throw)", + err === "" && typeof sess?.onData === "function" && typeof sess?.cols === "number", + ); + h.stop({ graceful: false }); +} + +// tmux — watched natively: attach() points you at `tmux attach -t :`. +{ + let rt: ReturnType | null = null; + try { + rt = createRuntime("tmux", SESSION); + } catch (e) { + skip("tmux", (e as Error).message); + } + if (rt) { + const h = rt.spawn("smoke-tmux", spec, cwd); + const err = attachError(() => h.attach()); + check( + "tmux: attach() points to `tmux attach -t cotal-smoke:smoke-tmux` (not a stream)", + err.includes("tmux attach -t cotal-smoke:smoke-tmux"), + ); + check("tmux: hint is tmux-specific, never a cmux tab", !err.includes("cmux tab")); + h.stop({ graceful: false }); + try { + execFileSync("tmux", ["kill-session", "-t", SESSION], { stdio: "ignore" }); + } catch { + /* session already gone */ + } + } +} + +// cmux — watched natively: attach() points you at the `cotal-` tab, NOT tmux. +{ + let rt: ReturnType | null = null; + try { + rt = createRuntime("cmux", SESSION); + } catch (e) { + skip("cmux", (e as Error).message); + } + if (rt) { + let h: ReturnType | null = null; + try { + h = rt.spawn("smoke-cmux", spec, cwd); + } catch (e) { + skip("cmux", `spawn: ${(e as Error).message}`); // e.g. not inside a live cmux surface + } + if (h) { + const err = attachError(() => h!.attach()); + check( + 'cmux: attach() points to the "cotal-smoke-cmux" tab (not tmux)', + err.includes('switch to the "cotal-smoke-cmux" cmux tab') && !err.includes("tmux attach"), + ); + h.stop({ graceful: false }); + } + } +} + +console.log(failures ? `\n${failures} check(s) failed` : "\nall checks passed"); +process.exit(failures ? 1 : 0); diff --git a/implementations/manager/src/manager.ts b/implementations/manager/src/manager.ts index 378513d6..62ee5533 100644 --- a/implementations/manager/src/manager.ts +++ b/implementations/manager/src/manager.ts @@ -265,11 +265,15 @@ export class Manager { const name = String(args.name ?? "").trim(); const a = this.agents.get(name); if (!a) return { ok: false, error: `no agent "${name}"` }; - if (this.runtime.kind !== "pty") { - return { - ok: false, - error: `attach needs the pty runtime; under tmux run \`tmux attach -t cotal-${this.space}:${name}\``, - }; + // Only pty streams over the WS attach endpoint. tmux/cmux are watched natively, and + // each handle's attach() throws with the right per-runtime guidance (tmux attach … / + // switch to the cmux tab) — surface that instead of assuming tmux. + if (a.handle.kind !== "pty") { + try { + a.handle.attach(); + } catch (e) { + return { ok: false, error: (e as Error).message }; + } } return { ok: true, data: { ws: this.attach.url(name) } }; } diff --git a/package.json b/package.json index dd71a6d2..f1c141ed 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "smoke:channels:auth": "tsx packages/core/channels-auth.smoke.ts", "smoke:attention": "tsx extensions/connector-core/attention.smoke.ts", "smoke:attention:auth": "tsx extensions/connector-core/attention-auth.smoke.ts", - "smoke:feedback": "tsx extensions/connector-core/feedback.smoke.ts" + "smoke:feedback": "tsx extensions/connector-core/feedback.smoke.ts", + "smoke:attach": "tsx implementations/manager/attach.smoke.ts" }, "dependencies": { "@cotal-ai/cli": "workspace:*",