diff --git a/docs/PTY_AGENTS.md b/docs/PTY_AGENTS.md new file mode 100644 index 0000000..6c7ea91 --- /dev/null +++ b/docs/PTY_AGENTS.md @@ -0,0 +1,75 @@ +# PTY agents — steering real CLIs (Stage C spike) + +**Status: EXPERIMENTAL. Off by default.** This is the Stage-C spike from the agent +control-plane plan — the path toward commanding *real* `claude` / `codex` CLIs, +not just LISA's own managed agents. + +## What it is + +A **managed agent** (Phase 3) runs LISA's *own* agent loop — its tools, its +provider. A **PTY agent** instead spawns the **real `claude` / `codex` binary** +inside a pseudo-terminal (`node-pty`), so you get that CLI's full configuration — +its skills, MCP servers, hooks, model — while LISA owns stdin/stdout: + +- types your task and any follow-ups into the CLI, +- can answer its prompts (you type into it from the roster), +- reads the terminal stream for a coarse live status + a viewable output tail. + +In the GUI agents card these appear under their **real kind** (`claude-code` / +`codex`), marked controllable: a **type-into-the-CLI** box, a **▤ output** button +(shows the captured terminal tail in a modal), and **⏹ cancel**. + +## Enabling it + +1. Install the optional native dep (it has zero JS deps; if your machine can't + build it, nothing else in LISA is affected): + ```sh + npm i node-pty + ``` +2. Turn the spike on: + ```sh + LISA_PTY_AGENTS=1 lisa serve --web + ``` +3. In the agents card, pick `claude` or `codex` in the delegate picker, type a + task, hit ▶. (Without the flag the start endpoint returns `503` and the GUI + shows the hint.) + +Binary resolution is env-overridable: `LISA_PTY_CLAUDE_CMD`, `LISA_PTY_CODEX_CMD`. + +## Honest limits (why it's a flagged spike, not a shipped feature) + +- **Only CLIs LISA spawns.** It cannot adopt a `claude`/`codex` session you + already opened in your own terminal — those have no control channel and stay + **observe-only**. (Commanding *those* would need Claude Code's undocumented, + version-locked `peerProtocol` — not attempted here.) +- **Best-effort output parsing.** The CLI's TUI is ANSI / box-drawn and + version-sensitive, so `state` is inferred from output *quiescence* + (streaming → working, quiet → waiting), not from parsed intent. +- **Native dep.** `node-pty` is an `optionalDependency`; installs and CI never + fail if it can't build — PTY agents are simply unavailable then. +- **Privacy.** A PTY agent captures the full terminal, including model replies. + That content is shown to **you** on demand (`/api/agents/pty//output`) and + is **never** folded into the structural cross-agent roster, which stays + metadata-only like every observer. + +## Endpoints (all behind the standard loopback-or-token auth gate) + +| Method + path | Body | Effect | +| --- | --- | --- | +| `POST /api/agents/pty/start` | `{ agent, task, cwd? }` | spawn a PTY agent (503 if flag off) | +| `POST /api/agents/pty//send` | `{ text }` | type a line into the CLI | +| `POST /api/agents/pty//cancel` | — | kill the CLI | +| `GET /api/agents/pty//output` | — | ANSI-stripped terminal tail | + +## Code + +- `src/agents/pty.ts` — `PtyAgent` + `PtyRegistry` (+ pure `stripAnsi` / + `derivePtyState` / `resolveCli`), dynamic `node-pty` import with graceful + fallback, flag gate. +- `src/integrations/pty/observer.ts` — surfaces PTY agents in the hub roster + (real kind, `controllable: "pty"`). +- `src/web/server.ts` — the endpoints above. +- GUI: delegate kind picker + `controllable`-family row controls in + `lisa-html.ts` / `lisa-client.ts` / `lisa-css.ts`. +- Tests: `src/agents/pty.test.ts` (pure helpers + lifecycle via an injected fake + pty; one real-`node-pty` round-trip that skips when the dep can't spawn). diff --git a/package-lock.json b/package-lock.json index 9ca4e1d..5f52920 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@anthropic-ai/sdk": "^0.92.0", "@google/genai": "^2.0.1", "@modelcontextprotocol/sdk": "^1.29.0", + "node-pty": "*", "openai": "^6.35.0", "undici": "^8.2.0" }, @@ -26,6 +27,9 @@ }, "engines": { "node": ">=20.0.0" + }, + "optionalDependencies": { + "node-pty": "^1.1.0" } }, "node_modules/@anthropic-ai/sdk": { @@ -1041,7 +1045,6 @@ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", "license": "MIT", - "peer": true, "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", @@ -1575,7 +1578,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -1903,7 +1905,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.16.tgz", "integrity": "sha512-jN0ZewiNAWSe5khM3EyCmBb250+b40wWbwNILNfEvq84VREWwOIkuUsFONk/3i3nqkz7Oe1PcpM2mwQEK2L9Kg==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -2133,6 +2134,13 @@ "node": ">= 0.6" } }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT", + "optional": true + }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -2171,6 +2179,17 @@ "url": "https://opencollective.com/node-fetch" } }, + "node_modules/node-pty": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz", + "integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-addon-api": "^7.1.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -2801,7 +2820,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.2.tgz", "integrity": "sha512-IynmDyxsEsb9RKzO3J9+4SxXnl2FTFSzNBaKKaMV6tsSk0rw9gYw9gs+JFCq/qk2LCZ78KDwyj+Z289TijSkUw==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 28a0b7a..2114e40 100644 --- a/package.json +++ b/package.json @@ -76,5 +76,8 @@ "sharp": "^0.34.5", "tsx": "^4.21.0", "typescript": "^5.7.0" + }, + "optionalDependencies": { + "node-pty": "^1.1.0" } } diff --git a/src/agents/pty.test.ts b/src/agents/pty.test.ts new file mode 100644 index 0000000..cc25982 --- /dev/null +++ b/src/agents/pty.test.ts @@ -0,0 +1,195 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + stripAnsi, + derivePtyState, + ptyEnabled, + resolveCli, + normalizeAgentKind, + PtyAgent, + PtyRegistry, + type IPtyLike, + type PtyModuleLike, +} from "./pty.js"; + +const ESC = String.fromCharCode(27); +const BEL = String.fromCharCode(7); + +/** A fake node-pty: capture writes/kills, drive data/exit by hand. */ +function fakePty() { + let dataCb: ((d: string) => void) | null = null; + let exitCb: ((e: { exitCode: number }) => void) | null = null; + const written: string[] = []; + let killed = false; + const proc: IPtyLike = { + onData(cb) { + dataCb = cb; + }, + onExit(cb) { + exitCb = cb; + }, + write(d) { + written.push(d); + }, + kill() { + killed = true; + }, + }; + const module: PtyModuleLike = { spawn: () => proc }; + return { + module, + written, + emitData: (s: string) => dataCb?.(s), + emitExit: (code: number) => exitCb?.({ exitCode: code }), + isKilled: () => killed, + }; +} + +async function withFlag(fn: () => Promise | T): Promise { + const prev = process.env.LISA_PTY_AGENTS; + process.env.LISA_PTY_AGENTS = "1"; + try { + return await fn(); + } finally { + if (prev === undefined) delete process.env.LISA_PTY_AGENTS; + else process.env.LISA_PTY_AGENTS = prev; + } +} + +// ── pure helpers ── + +test("stripAnsi removes color, OSC-8 hyperlinks, and bare control bytes", () => { + const s = + ESC + "[31mred" + ESC + "[0m " + ESC + "]8;;http://example.com/x" + BEL + "link" + ESC + "]8;;" + BEL + " done" + ESC + "[2K"; + assert.equal(stripAnsi(s), "red link done"); + assert.equal(stripAnsi("a\rb\bc"), "abc"); + assert.equal(stripAnsi("plain"), "plain"); +}); + +test("derivePtyState: streaming → working, quiet → waiting", () => { + assert.equal(derivePtyState(1000, 2000, 4000), "working"); + assert.equal(derivePtyState(1000, 4999, 4000), "working"); + assert.equal(derivePtyState(1000, 5001, 4000), "waiting"); +}); + +test("ptyEnabled reflects LISA_PTY_AGENTS", async () => { + const prev = process.env.LISA_PTY_AGENTS; + delete process.env.LISA_PTY_AGENTS; + assert.equal(ptyEnabled(), false); + await withFlag(() => assert.equal(ptyEnabled(), true)); + if (prev !== undefined) process.env.LISA_PTY_AGENTS = prev; +}); + +test("resolveCli + normalizeAgentKind map agent kinds", () => { + assert.equal(resolveCli("claude"), "claude"); + assert.equal(resolveCli("claude-code"), "claude"); + assert.equal(resolveCli("codex"), "codex"); + assert.equal(normalizeAgentKind("claude"), "claude-code"); + assert.equal(normalizeAgentKind("claude-code"), "claude-code"); + assert.equal(normalizeAgentKind("codex"), "codex"); +}); + +// ── lifecycle (fake pty) ── + +test("start is blocked unless the spike flag is on", async () => { + const prev = process.env.LISA_PTY_AGENTS; + delete process.env.LISA_PTY_AGENTS; + await assert.rejects( + () => PtyAgent.start({ agent: "claude", task: "x", cwd: "/tmp", ptyModule: fakePty().module }), + /disabled/, + ); + if (prev !== undefined) process.env.LISA_PTY_AGENTS = prev; +}); + +test("start types the task; send appends; data drives state; cancel kills", async () => { + await withFlag(async () => { + const f = fakePty(); + const clock = { t: 1000 }; + const reg = new PtyRegistry(); + const v = await reg.start({ + agent: "claude", + task: "do the thing", + cwd: "/Users/me/myproj", + ptyModule: f.module, + now: () => clock.t, + }); + // identity + initial task typed in + assert.equal(v.agent, "claude-code"); + assert.equal(v.cli, "claude"); + assert.equal(v.project, "myproj"); + assert.equal(f.written[0], "do the thing\r"); + + // follow-up + assert.equal(reg.send(v.id, "also lint"), true); + assert.equal(f.written[1], "also lint\r"); + + // output capture (ANSI-stripped) + working while recent + clock.t = 2000; + f.emitData(ESC + "[32mhello" + ESC + "[0m"); + assert.match(reg.output(v.id) ?? "", /hello/); + clock.t = 2500; + assert.equal(reg.list()[0].state, "working"); + clock.t = 7000; + assert.equal(reg.list()[0].state, "waiting"); + + // cancel → killed + done; idempotent; no writes after + assert.equal(reg.cancel(v.id), true); + assert.equal(f.isKilled(), true); + const after = reg.list()[0]; + assert.equal(after.state, "done"); + assert.equal(after.stateReason, "cancelled"); + const writes = f.written.length; + reg.send(v.id, "ignored"); + assert.equal(f.written.length, writes); + reg.cancel(v.id); // idempotent, no throw + assert.equal(reg.list()[0].state, "done"); + }); +}); + +test("process exit marks the agent done", async () => { + await withFlag(async () => { + const f = fakePty(); + const reg = new PtyRegistry(); + const v = await reg.start({ agent: "codex", task: "go", cwd: "/tmp/p", ptyModule: f.module }); + f.emitExit(0); + const view = reg.list()[0]; + assert.equal(view.agent, "codex"); + assert.equal(view.state, "done"); + assert.equal(view.stateReason, "exit 0"); + }); +}); + +test("registry actions on an unknown id are no-ops", () => { + const reg = new PtyRegistry(); + assert.equal(reg.send("nope", "x"), false); + assert.equal(reg.cancel("nope"), false); + assert.equal(reg.output("nope"), null); + assert.deepEqual(reg.list(), []); +}); + +// ── real node-pty round-trip (skipped if the optional dep isn't built) ── + +test("real PTY round-trip via `cat` echoes input", async (t) => { + // `cat` under a PTY echoes typed input back on stdout — proves the real + // spawn → write → read → kill path without depending on a heavy CLI. + // Skips when node-pty isn't built OR its native binding can't spawn in this + // environment (e.g. under the tsx test loader, which resolves node-pty's TS + // source rather than its compiled native lib — a runner artifact, not a + // defect: the shipped path runs against compiled JS). + const reg = new PtyRegistry(); + let v; + try { + await import("node-pty"); + v = await withFlag(() => + reg.start({ agent: "claude", task: "", cwd: process.cwd(), cli: "cat", args: [] }), + ); + } catch (e) { + t.skip("node-pty unavailable here: " + (e as Error).message); + return; + } + reg.send(v.id, "ping-marker-42"); + await new Promise((r) => setTimeout(r, 300)); + assert.match(reg.output(v.id) ?? "", /ping-marker-42/); + reg.cancel(v.id); + assert.equal(reg.list()[0].state, "done"); +}); diff --git a/src/agents/pty.ts b/src/agents/pty.ts new file mode 100644 index 0000000..dcdd371 --- /dev/null +++ b/src/agents/pty.ts @@ -0,0 +1,321 @@ +/** + * PTY-backed agents — EXPERIMENTAL spike (Stage C), OFF BY DEFAULT. + * + * Phase-3 managed agents run LISA's OWN agent loop (its tools, its provider). + * A PTY agent instead spawns the REAL `claude` / `codex` CLI inside a + * pseudo-terminal, so you get that tool's full configuration — its skills, MCP + * servers, hooks, model — while LISA owns stdin/stdout: it types your task and + * follow-ups, can answer prompts, and reads the stream for a coarse live status. + * + * HONEST LIMITS (why this is a flagged spike, not a shipped feature): + * - Native dep: needs `node-pty` (an *optional* dependency). If it isn't built, + * PTY agents are simply unavailable — nothing else in LISA changes. + * - Off by default: set `LISA_PTY_AGENTS=1` to enable. + * - Controls only CLIs LISA SPAWNS — NOT sessions you already opened in your + * own terminal (those have no control channel; they stay observe-only). + * - Output parsing is best-effort: the CLI's TUI is ANSI / box-drawn and + * version-sensitive, so "state" is inferred from output quiescence, not from + * parsed intent. The captured tail is shown to you (your own terminal), never + * folded into the structural cross-agent roster. + */ +import { EventEmitter } from "node:events"; + +export type PtyState = "working" | "waiting" | "error" | "done"; + +/** Public, serializable snapshot of a PTY agent (structural — no output text). */ +export interface PtyView { + id: string; + /** Normalized roster kind, e.g. "claude-code" | "codex". */ + agent: string; + /** The spawned binary, e.g. "claude". */ + cli: string; + project: string; + cwd: string; + state: PtyState; + stateReason: string; + lastMtime: number; + /** Bytes of terminal output seen so far — a rough liveness signal. */ + bytesOut: number; +} + +export interface PtyStartOpts { + /** "claude" | "claude-code" | "codex" | … */ + agent: string; + task: string; + cwd: string; + /** Override the binary (else resolved from `agent`). */ + cli?: string; + /** Override argv (else interactive: []). */ + args?: string[]; + cols?: number; + rows?: number; + /** Clock override (tests). */ + now?: () => number; + /** Injected pty module (tests) — avoids spawning a real process. */ + ptyModule?: PtyModuleLike; +} + +// ── minimal node-pty surface (so this file needs no compile-time dep on its +// types — node-pty is optional and may be absent when `tsc` runs) ── +export interface IPtyLike { + onData(cb: (data: string) => void): void; + onExit(cb: (e: { exitCode: number; signal?: number }) => void): void; + write(data: string): void; + kill(signal?: string): void; +} +export interface PtyModuleLike { + spawn( + file: string, + args: string[], + opts: { + name?: string; + cols?: number; + rows?: number; + cwd?: string; + env?: Record; + }, + ): IPtyLike; +} + +/** Is the PTY-agent spike enabled? Off unless LISA_PTY_AGENTS=1. */ +export function ptyEnabled(): boolean { + return process.env.LISA_PTY_AGENTS === "1"; +} + +/** Map a roster agent-kind to its CLI binary (env-overridable). */ +export function resolveCli(agent: string): string { + const a = agent.toLowerCase(); + if (a === "codex") return process.env.LISA_PTY_CODEX_CMD || "codex"; + // default to claude (covers "claude" / "claude-code") + return process.env.LISA_PTY_CLAUDE_CMD || "claude"; +} + +/** Normalize a loose agent label to a roster AgentKind. */ +export function normalizeAgentKind(agent: string): string { + const a = agent.toLowerCase(); + if (a === "codex") return "codex"; + if (a === "claude" || a === "claude-code") return "claude-code"; + return agent; +} + +// Strip ANSI / VT100 escape sequences so the captured tail is plain-ish text. +// Control anchors come via String.fromCharCode so the SOURCE carries no raw +// control bytes (which trip greps, diffs, and some editors). +const ESC = String.fromCharCode(27); // U+001B +const CSI = String.fromCharCode(155); // U+009B +const BEL = String.fromCharCode(7); // U+0007 +const ANSI = new RegExp( + "[" + ESC + CSI + "][[\\]()#;?]*" + + "(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?" + + BEL + + ")|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))", + "g", +); + +/** Strip ANSI escape sequences + common bare control bytes (CR/backspace). Pure. */ +export function stripAnsi(s: string): string { + // eslint-disable-next-line no-control-regex + return s.replace(ANSI, "").replace(/[\r\b]/g, ""); +} + +/** + * Coarse live state from output quiescence: actively streaming ⇒ "working"; + * quiet longer than idleMs ⇒ "waiting" (likely awaiting input or a prompt). + * `done`/`error` are decided by lifecycle flags, not here. Pure. + */ +export function derivePtyState(lastChunkAtMs: number, nowMs: number, idleMs: number): PtyState { + return nowMs - lastChunkAtMs < idleMs ? "working" : "waiting"; +} + +const RING_MAX = 4000; // chars of ANSI-stripped tail kept +const IDLE_MS = 4000; // quiet longer than this ⇒ "waiting" + +let counter = 0; + +async function loadPty(): Promise { + try { + // Non-literal specifier so `tsc` treats this as `any` and never hard-requires + // node-pty (it's an optionalDependency that may be absent). Resolved at runtime. + const spec: string = "node-pty"; + const mod: { spawn?: unknown; default?: unknown } = await import(spec); + const resolved = + mod && typeof mod.spawn === "function" ? mod : ((mod && mod.default) ?? mod); + return resolved as PtyModuleLike; + } catch { + return null; + } +} + +/** One real CLI running under a pseudo-terminal that LISA drives. */ +export class PtyAgent { + readonly id: string; + readonly agent: string; + readonly cli: string; + readonly cwd: string; + readonly project: string; + onChange: () => void = () => {}; + + private readonly proc: IPtyLike; + private readonly now: () => number; + private ring = ""; + private bytesOut = 0; + private aborted = false; + private exited = false; + private exitReason = ""; + private lastChunkAt: number; + private lastMtime: number; + + private constructor(id: string, opts: PtyStartOpts, cli: string, proc: IPtyLike, now: () => number) { + this.id = id; + this.agent = normalizeAgentKind(opts.agent); + this.cli = cli; + this.cwd = opts.cwd; + this.project = opts.cwd.split("/").filter(Boolean).pop() || opts.cwd; + this.proc = proc; + this.now = now; + this.lastChunkAt = now(); + this.lastMtime = now(); + proc.onData((d) => this.onData(d)); + proc.onExit((e) => this.onExit(e)); + } + + /** Spawn a real CLI under a PTY and type the initial task. */ + static async start(opts: PtyStartOpts): Promise { + if (!ptyEnabled()) { + throw new Error("PTY agents are disabled — set LISA_PTY_AGENTS=1 to enable this spike"); + } + const pty = opts.ptyModule ?? (await loadPty()); + if (!pty) { + throw new Error("node-pty is not installed — run `npm i node-pty` to enable PTY agents"); + } + const cli = opts.cli ?? resolveCli(opts.agent); + const now = opts.now ?? Date.now; + const proc = pty.spawn(cli, opts.args ?? [], { + name: "xterm-256color", + cols: opts.cols ?? 120, + rows: opts.rows ?? 32, + cwd: opts.cwd, + env: process.env, + }); + const id = "p" + (++counter).toString(36) + "-" + Date.now().toString(36).slice(-4); + const agent = new PtyAgent(id, opts, cli, proc, now); + if (opts.task) agent.send(opts.task); + return agent; + } + + /** Type a line into the CLI (initial task or a follow-up). */ + send(text: string): void { + if (this.aborted || this.exited) return; + this.proc.write(text + "\r"); + this.touch(); + } + + /** Kill the CLI. Idempotent. */ + cancel(): void { + if (this.exited) return; + this.aborted = true; + try { + this.proc.kill(); + } catch { + /* already gone */ + } + this.exitReason = "cancelled"; + this.touch(); + } + + /** + * ANSI-stripped tail of the terminal — the user's window into THEIR agent. + * Exposed only via an explicit endpoint, never folded into the structural + * cross-agent roster (which stays metadata-only). + */ + output(): string { + return this.ring; + } + + view(): PtyView { + let state: PtyState; + let reason: string; + if (this.aborted) { + state = "done"; + reason = "cancelled"; + } else if (this.exited) { + state = "done"; + reason = this.exitReason || "exited"; + } else { + state = derivePtyState(this.lastChunkAt, this.now(), IDLE_MS); + reason = state === "working" ? "streaming" : "idle"; + } + return { + id: this.id, + agent: this.agent, + cli: this.cli, + project: this.project, + cwd: this.cwd, + state, + stateReason: reason, + lastMtime: this.lastMtime, + bytesOut: this.bytesOut, + }; + } + + // ── internals ── + private onData(d: string): void { + this.bytesOut += d.length; + this.ring = (this.ring + stripAnsi(d)).slice(-RING_MAX); + this.lastChunkAt = this.now(); + this.touch(); + } + + private onExit(e: { exitCode: number }): void { + this.exited = true; + if (!this.exitReason) this.exitReason = "exit " + e.exitCode; + this.touch(); + } + + private touch(): void { + this.lastMtime = this.now(); + this.onChange(); + } +} + +/** Process-wide registry of PTY agents; emits "update" with a PtyView. */ +export class PtyRegistry extends EventEmitter { + private agents = new Map(); + + async start(opts: PtyStartOpts): Promise { + const a = await PtyAgent.start(opts); + a.onChange = () => this.emit("update", a.view()); + this.agents.set(a.id, a); + this.emit("update", a.view()); + return a.view(); + } + + send(id: string, text: string): boolean { + const a = this.agents.get(id); + if (!a) return false; + a.send(text); + return true; + } + + cancel(id: string): boolean { + const a = this.agents.get(id); + if (!a) return false; + a.cancel(); + return true; + } + + /** ANSI-stripped tail of a PTY agent's terminal, or null if unknown. */ + output(id: string): string | null { + return this.agents.get(id)?.output() ?? null; + } + + get(id: string): PtyAgent | undefined { + return this.agents.get(id); + } + + list(): PtyView[] { + return [...this.agents.values()].map((a) => a.view()); + } +} + +export const ptyRegistry = new PtyRegistry(); diff --git a/src/integrations/hub.ts b/src/integrations/hub.ts index e21c63d..b03d807 100644 --- a/src/integrations/hub.ts +++ b/src/integrations/hub.ts @@ -58,6 +58,10 @@ export const DEFAULT_ORCHESTRATOR_CONFIG: OrchestratorConfig = { // default — it just reflects the in-process registry (empty until you start // one), so it adds nothing at rest. managed: { enabled: true }, + // PTY (Stage C spike): real `claude`/`codex` CLIs LISA spawns under a + // pseudo-terminal (send/cancel). On by default but inert unless the spike + // flag LISA_PTY_AGENTS=1 is set — the registry stays empty otherwise. + pty: { enabled: true }, }, visibility: "activity", }; diff --git a/src/integrations/managed/observer.ts b/src/integrations/managed/observer.ts index b0bfaa8..8ab88f4 100644 --- a/src/integrations/managed/observer.ts +++ b/src/integrations/managed/observer.ts @@ -18,6 +18,7 @@ function toSession(v: ManagedView): AgentSession { state: v.state, stateReason: v.stateReason, lastMtime: v.lastMtime, + controllable: "managed", activity: { turnCount: v.turnCount, lastTools: v.lastTools, diff --git a/src/integrations/pty/observer.ts b/src/integrations/pty/observer.ts new file mode 100644 index 0000000..cfad911 --- /dev/null +++ b/src/integrations/pty/observer.ts @@ -0,0 +1,53 @@ +/** + * PTY-agent observer — surfaces LISA's PTY-backed CLI agents (Stage C spike) + * in the orchestrator hub, so a real `claude`/`codex` LISA spawned under a + * pseudo-terminal appears in the roster (island / GUI / menu bar) under its + * REAL kind ("claude-code"/"codex"), marked controllable so the UI offers + * send/cancel. Reads the in-process ptyRegistry; emits on its "update" event. + * + * The registry is empty unless the spike is enabled (LISA_PTY_AGENTS=1), so at + * rest this observer contributes nothing. + * + * PRIVACY: the roster session is structural only (kind/state/cli) — the captured + * terminal output is NEVER folded in here; it's served separately, on demand, + * via /api/agents/pty//output. + */ +import { EventEmitter } from "node:events"; +import { registerIntegration } from "../registry.js"; +import { ptyRegistry, type PtyView } from "../../agents/pty.js"; +import type { AgentIntegrationConfig, AgentObserver, AgentSession } from "../types.js"; + +function toSession(v: PtyView): AgentSession { + return { + agent: v.agent, // real roster kind: "claude-code" | "codex" + sessionId: v.id, + project: v.project, + cwd: v.cwd, + state: v.state, + stateReason: v.stateReason, + lastMtime: v.lastMtime, + controllable: "pty", + }; +} + +export class PtyObserver extends EventEmitter implements AgentObserver { + readonly agent = "pty"; + private emitFn: ((s: AgentSession) => void) | null = null; + private readonly onUpdate = (v: PtyView) => this.emitFn?.(toSession(v)); + + async start(emit: (s: AgentSession) => void): Promise { + this.emitFn = emit; + ptyRegistry.on("update", this.onUpdate); + } + + list(): AgentSession[] { + return ptyRegistry.list().map(toSession); + } + + async stop(): Promise { + ptyRegistry.off("update", this.onUpdate); + this.emitFn = null; + } +} + +registerIntegration("pty", (_cfg: AgentIntegrationConfig) => new PtyObserver()); diff --git a/src/integrations/registry.ts b/src/integrations/registry.ts index 5f9e7d7..f62e7a1 100644 --- a/src/integrations/registry.ts +++ b/src/integrations/registry.ts @@ -61,4 +61,5 @@ export async function registerBuiltinIntegrations(): Promise { await import("./shell/observer.js"); await import("./takoapi/observer.js"); await import("./managed/observer.js"); + await import("./pty/observer.js"); } diff --git a/src/integrations/types.ts b/src/integrations/types.ts index f35a2fe..7046b09 100644 --- a/src/integrations/types.ts +++ b/src/integrations/types.ts @@ -75,6 +75,14 @@ export interface AgentSession { lastMtime: number; /** L2 activity, present when the adapter runs at tier ≥ "activity". */ activity?: SessionActivity; + /** + * If LISA can CONTROL this session (not just observe it), which control-endpoint + * family drives it: POST /api/agents///{send,cancel,…}. + * - "managed" — LISA runs the agent loop itself (send/cancel/approve). + * - "pty" — a real CLI LISA spawned under a pseudo-terminal (send/cancel). + * Absent ⇒ observe-only (an externally-started CLI; no control channel). + */ + controllable?: "managed" | "pty"; } /** Visibility tier — how deeply LISA may inspect a session. See plan §3. */ diff --git a/src/web/lisa-client.ts b/src/web/lisa-client.ts index 5099b18..25d32df 100644 --- a/src/web/lisa-client.ts +++ b/src/web/lisa-client.ts @@ -1039,9 +1039,9 @@ if ('serviceWorker' in navigator) { return bits.join(' · '); } - // POST a managed-agent control action (start/send/cancel/approve), then refresh. - function managedAction(id, action, body) { - fetch('/api/agents/managed/' + encodeURIComponent(id) + '/' + action, { + // POST a control action to the right agent family (managed|pty), then refresh. + function agentAction(fam, id, action, body) { + fetch('/api/agents/' + fam + '/' + encodeURIComponent(id) + '/' + action, { method: 'POST', headers: body ? { 'content-type': 'application/json' } : {}, body: body ? JSON.stringify(body) : undefined, @@ -1050,6 +1050,17 @@ if ('serviceWorker' in navigator) { }).catch(function () {}); } + // Show a PTY agent's captured terminal tail in the modal — explicit + on + // demand (it's content, so it's never folded into the structural roster). + function ptyOutput(id) { + fetch('/api/agents/pty/' + encodeURIComponent(id) + '/output').then(function (r) { + return r.ok ? r.json() : null; + }).then(function (d) { + if (!d) return; + openModal('agent output', '
' + escapeHtml(d.output || '(no output yet)') + '
'); + }).catch(function () {}); + } + function setClaudeSessions(sessions) { const cutoff = Date.now() - ACTIVE_WINDOW_MS; const recent = sessions.filter(s => new Date(s.lastMtime).getTime() >= cutoff); @@ -1111,34 +1122,43 @@ if ('serviceWorker' in navigator) { act.title = actText; row.appendChild(act); } - // Managed agents are controllable: approve/deny a pending tool, send a - // follow-up, or cancel. (Externally-started CLIs aren't — observe only.) - if (s.agent === 'managed') { + // Controllable agents get inline controls: managed → approve/deny a pending + // tool, send a follow-up, cancel; pty (real CLI under a PTY) → send, view + // terminal output, cancel. Externally-started CLIs have no control channel + // (no s.controllable) → observe only. + const fam = s.controllable; + if (fam) { const id = s.sessionId; const ctrl = document.createElement('div'); ctrl.className = 'session-ctrl'; - const pending = s.activity && s.activity.pendingPermission; + const pending = fam === 'managed' && s.activity && s.activity.pendingPermission; if (pending) { const ap = document.createElement('button'); ap.className = 'mc approve'; ap.textContent = '✓ approve'; - ap.addEventListener('click', function (e) { e.stopPropagation(); managedAction(id, 'approve', { allow: true }); }); + ap.addEventListener('click', function (e) { e.stopPropagation(); agentAction('managed', id, 'approve', { allow: true }); }); const dn = document.createElement('button'); dn.className = 'mc deny'; dn.textContent = '✕ deny'; - dn.addEventListener('click', function (e) { e.stopPropagation(); managedAction(id, 'approve', { allow: false }); }); + dn.addEventListener('click', function (e) { e.stopPropagation(); agentAction('managed', id, 'approve', { allow: false }); }); ctrl.appendChild(ap); ctrl.appendChild(dn); } else if (s.state !== 'done') { const inp = document.createElement('input'); - inp.className = 'mc-send'; inp.type = 'text'; inp.placeholder = 'send a follow-up…'; + inp.className = 'mc-send'; inp.type = 'text'; inp.placeholder = fam === 'pty' ? 'type into the CLI…' : 'send a follow-up…'; inp.addEventListener('click', function (e) { e.stopPropagation(); }); inp.addEventListener('keydown', function (e) { - if (e.key === 'Enter' && inp.value.trim()) { e.preventDefault(); managedAction(id, 'send', { text: inp.value.trim() }); inp.value = ''; } + if (e.key === 'Enter' && inp.value.trim()) { e.preventDefault(); agentAction(fam, id, 'send', { text: inp.value.trim() }); inp.value = ''; } }); ctrl.appendChild(inp); } + if (fam === 'pty') { + const out = document.createElement('button'); + out.className = 'mc'; out.textContent = '▤'; out.title = 'View terminal output'; + out.addEventListener('click', function (e) { e.stopPropagation(); ptyOutput(id); }); + ctrl.appendChild(out); + } if (s.state !== 'done') { const cancel = document.createElement('button'); cancel.className = 'mc cancel'; cancel.textContent = '⏹'; cancel.title = 'Cancel agent'; - cancel.addEventListener('click', function (e) { e.stopPropagation(); managedAction(id, 'cancel', null); }); + cancel.addEventListener('click', function (e) { e.stopPropagation(); agentAction(fam, id, 'cancel', null); }); ctrl.appendChild(cancel); } if (ctrl.childNodes.length) row.appendChild(ctrl); @@ -1172,19 +1192,26 @@ if ('serviceWorker' in navigator) { } catch {} }; - // "Delegate a task" → start a managed agent (LISA-run, controllable). + // "Delegate a task" → start an agent. managed = LISA-run (controllable); + // claude/codex = a real CLI under a PTY (Stage C spike, needs LISA_PTY_AGENTS=1 + // — a 503 surfaces its hint in the modal). const sbDelegate = document.getElementById('sbDelegate'); if (sbDelegate) { sbDelegate.addEventListener('submit', function (e) { e.preventDefault(); const inp = document.getElementById('sbDelegateTask'); + const kindEl = document.getElementById('sbDelegateKind'); const task = inp && inp.value.trim(); if (!task) return; - fetch('/api/agents/managed/start', { + const kind = kindEl ? kindEl.value : 'managed'; + const url = kind === 'managed' ? '/api/agents/managed/start' : '/api/agents/pty/start'; + const body = kind === 'managed' ? { task: task } : { agent: kind, task: task }; + fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ task: task }), - }).then(function () { + body: JSON.stringify(body), + }).then(function (r) { + if (!r.ok) { return r.text().then(function (t) { openModal('agent', '
' + escapeHtml(t) + '
'); }); } if (inp) inp.value = ''; if (typeof refreshClaudeSessions === 'function') refreshClaudeSessions(); }).catch(function () {}); diff --git a/src/web/lisa-css.ts b/src/web/lisa-css.ts index 98f7d01..c668b80 100644 --- a/src/web/lisa-css.ts +++ b/src/web/lisa-css.ts @@ -380,6 +380,15 @@ export const MAIN_CSS = ` :root { background: rgba(0,0,0,0.25); color: var(--fg); } + .delegate select { + font-size: 10.5px; + padding: 3px 4px; + border-radius: 7px; + border: 1px solid var(--border); + background: rgba(0,0,0,0.25); + color: var(--fg-2); + cursor: pointer; + } .delegate button { font-size: 11px; padding: 4px 10px; diff --git a/src/web/lisa-html-snapshot.test.ts b/src/web/lisa-html-snapshot.test.ts index 45b1d11..5f4212a 100644 --- a/src/web/lisa-html-snapshot.test.ts +++ b/src/web/lisa-html-snapshot.test.ts @@ -16,11 +16,12 @@ import { MAIN_HTML } from "./lisa-html.js"; * change the GUI markup/CSS/JS, recompute them: * node --import tsx -e 'import("./src/web/lisa-html.ts").then(async m=>{const {createHash}=await import("node:crypto");console.log(m.MAIN_HTML.length, createHash("sha256").update(m.MAIN_HTML).digest("hex"))})' * - * Last updated: managed-agent controls (delegate form + per-row approve/deny/send/cancel). + * Last updated: PTY-agent spike controls (delegate kind picker + per-row pty + * send/output/cancel + controllable-family routing). */ -const EXPECTED_LENGTH = 84648; +const EXPECTED_LENGTH = 86661; const EXPECTED_SHA256 = - "e6ec7da0e4d36c462839c4078a4588735c18d718d71ff421ea6607087e2b924d"; + "6443ecf6afae341ce84ac6d3301a45420dd351e466692ea880a4340da9676037"; test("MAIN_HTML length is byte-identical to the pre-split snapshot", () => { assert.equal(MAIN_HTML.length, EXPECTED_LENGTH); diff --git a/src/web/lisa-html.ts b/src/web/lisa-html.ts index 1fd7fd3..c07f831 100644 --- a/src/web/lisa-html.ts +++ b/src/web/lisa-html.ts @@ -81,8 +81,13 @@ ${MAIN_CSS}
▶︎ 0
- - + + +
(idle)
diff --git a/src/web/server.ts b/src/web/server.ts index 0a403ed..ea86cc2 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -35,6 +35,7 @@ import { polishDictation, type DictationProvider } from "../voice/dictation.js"; import { listGrants, grant, revoke, revokeAll, isGranted, SENSE_SIGNALS, SIGNAL_DESCRIPTIONS } from "../consent/store.js"; import { signalAgentTool } from "../tools/signal_agent.js"; import { managedRegistry } from "../agents/managed.js"; +import { ptyRegistry, ptyEnabled } from "../agents/pty.js"; import { SenseService } from "../sense/service.js"; import { ScreenSource } from "../sense/screen.js"; import { VoiceSource } from "../sense/voice.js"; @@ -823,6 +824,70 @@ export async function startWebServer(opts: WebServerOptions): Promise= 0 ? rest.slice(0, slash) : rest); + const action = slash >= 0 ? rest.slice(slash + 1) : ""; + let pBody = ""; + for await (const chunk of req) pBody += chunk.toString("utf8"); + let payload: { text?: unknown } = {}; + try { payload = pBody ? JSON.parse(pBody) : {}; } catch { /* tolerate */ } + let ok = false; + if (action === "send" && typeof payload.text === "string") ok = ptyRegistry.send(id, payload.text); + else if (action === "cancel") ok = ptyRegistry.cancel(id); + else { + res.writeHead(400, { "content-type": "text/plain" }); + res.end("action must be send|cancel"); + return; + } + res.writeHead(ok ? 200 : 404, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok })); + return; + } + // Recent ambient sense events, for the island's "recently sensed" list. if (req.method === "GET" && url === "/api/sense/recent") { const events = readSenseEvents().slice(-30).reverse(); // newest first