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
8 changes: 8 additions & 0 deletions .changeset/attach-runtime-hint.md
Original file line number Diff line number Diff line change
@@ -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-<space>:<name>`, cmux → switch to the `cotal-<name>` tab.
6 changes: 4 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,12 +263,14 @@ dependency on them). Selectable backends:
or types in via `cotal attach <name>` (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-<space>:<name>` 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-<name>` 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
Expand Down
100 changes: 100 additions & 0 deletions implementations/manager/attach.smoke.ts
Original file line number Diff line number Diff line change
@@ -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 <session>:<name>`.
{
let rt: ReturnType<typeof createRuntime> | 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-<name>` tab, NOT tmux.
{
let rt: ReturnType<typeof createRuntime> | null = null;
try {
rt = createRuntime("cmux", SESSION);
} catch (e) {
skip("cmux", (e as Error).message);
}
if (rt) {
let h: ReturnType<typeof rt.spawn> | 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);
14 changes: 9 additions & 5 deletions implementations/manager/src/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) } };
}
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down