diff --git a/docs/IOS_COMPANION_PLAN.md b/docs/IOS_COMPANION_PLAN.md index a375f67..916eb7b 100644 --- a/docs/IOS_COMPANION_PLAN.md +++ b/docs/IOS_COMPANION_PLAN.md @@ -197,6 +197,11 @@ lisa agents pty codex "跑测试" # codex 同理;--port N 指定非默认 resume 一个**还活着**的会话会损坏 transcript,端点直接 **409**。`detectClaudeBinary()` 还优先用 app 内置的 claude(版本对得上 transcript)。 - **入口**:①GUI roster 对 `resumable` 会话给"接管"按钮(#111);②**终端** `lisa agents pty --resume `(本次接通,复用 §4.1 attach 客户端;命中 409 会提示"先关掉它");③岛仍保留手动"复制 `claude --resume`"。 - 覆盖:**用户已起、现已空闲**的 claude 会话:"我之前那个会话,手机上让它接着干。" +- **codex 对等?已查、暂不做(诚实记账,2026-06-19)**:codex CLI **支持续写**(`codex resume `,会话存 `$CODEX_HOME/sessions/YYYY/MM/DD/rollout-*.jsonl`), + 但 resume-adopt 的 **liveness 守卫对 codex 不成立**——codex **不写 pid 锁**(claude 有 `~/.claude/sessions/.json`), + 只能从 rollout 文件 mtime 猜"最近"而非"空闲",无法防"续写一个还活着的会话 → 双写损坏 transcript";且 rollout 文件名→resume-id 映射未经证实,本机也无 codex 可验。 + 故 LISA **显式拒绝** codex resume:`PtyAgent.start` 抛错、`POST /api/agents/pty/start` 对非 claude 的 `resumeSessionId` 返回 **400**(旧行为是悄悄起个新会话——已修为诚实拒绝)。 + codex 的**接管即启**(§4.1,新起)不受影响。解锁条件:codex 给出可靠的 liveness 信号 + 确认 id 映射。详见 [PTY_AGENTS.md](./PTY_AGENTS.md)。 ### 4.3 为什么 tmux / peer 协议不是这里的答案(诚实记账) @@ -211,6 +216,7 @@ lisa agents pty codex "跑测试" # codex 同理;--port N 指定非默认 |---|---|---| | 我想**新起**一个 claude/codex,且手机可控 | §4.1 接管即启(`lisa agents pty`) | ✅ 已建 | | 我**之前起过、现在空闲**的 claude 会话,想接着指挥 | §4.2 resume-adopt(`claude --resume` + liveness 守卫) | ✅ 已落地(#111 后端/GUI + `lisa agents pty --resume`) | +| 我**之前起过、现在空闲**的 **codex** 会话,想接着指挥 | 暂不支持——codex 无 liveness 信号,无法防双写损坏(§4.2) | ⛔ 已查/显式拒绝(400) | | 一个**正在跑**的桌面 app / IDE 会话 | **不可控**,只能观察;等它空闲再 §4.2 | —— | **iOS 侧零特判**:两条可行路都产出 `controllable:"pty"` + 现成 `/api/agents/pty/*`。app 控制 UI 仍只 key off `controllable`, diff --git a/docs/PTY_AGENTS.md b/docs/PTY_AGENTS.md index 3602cd5..89ff01e 100644 --- a/docs/PTY_AGENTS.md +++ b/docs/PTY_AGENTS.md @@ -41,6 +41,40 @@ owns its stdin/stdout), but every session has a stable `sessionId`, and - Binary: LISA resumes with `LISA_PTY_CLAUDE_CMD` → the newest app-bundled `claude` (version-matched to your sessions) → PATH `claude`. +## Why codex resume-adopt isn't supported (yet) + +Resume-adopt is **claude-only by design** — not an oversight. The codex CLI *does* +support continuation (`codex resume [prompt]`; sessions live at +`$CODEX_HOME/sessions/YYYY/MM/DD/rollout-*.jsonl`), so in principle LISA could spawn +`codex resume ` under a PTY the same way it spawns `claude --resume `. The +blocker is **safety, not capability**: + +- **No liveness signal.** The adopt guard refuses resuming a session that's still + *live* — two writers to one JSONL transcript interleave and corrupt it. For claude + we read `~/.claude/sessions/.json` and `kill -0` the owning pid + (`liveClaudeSessionIds()`): an authoritative "is it running right now?" check. Codex + leaves **no equivalent** — its rollout carries no pid/lock, and the observer can + only infer "recent" from file mtime, which is *not* "idle". An mtime heuristic + would let a paused-but-live session look adoptable and then get corrupted — + precisely what the guard must prevent. +- **Unverified id mapping.** Our observer derives a codex `sessionId` from the rollout + filename (`rollout-.jsonl` → ``); whether that's the exact token + `codex resume ` wants is unconfirmed, and codex isn't installed here to check. +- **Can't be exercised here.** node-pty won't spawn under Node 26 (see the local-dev + caveat), so a speculative implementation couldn't be verified end-to-end anyway. + +Rather than ship a guessed, unguarded path on a transcript-**corruption** surface, +LISA refuses codex resume explicitly: `PtyAgent.start` throws, and +`POST /api/agents/pty/start` returns **400** when `resumeSessionId` is set for a +non-claude agent. (It previously dropped the id silently and started a *fresh* +session — honest-by-refusal beats silently-wrong.) codex **adopt-at-launch** +(`lisa agents pty codex ""`, a brand-new session) is unaffected and works. + +**What would unblock it:** a reliable codex liveness check — codex writing a pid/lock +we can `kill -0`, or an `is-live` signal it exposes — plus a confirmed +rollout→resume-id mapping. Then the guard generalizes and the PTY arg-builder simply +adds the `codex resume ` form. + ## Enabling it 1. Install the optional native dep (it has zero JS deps; if your machine can't @@ -112,7 +146,7 @@ a full arrow-key TUI. Raw attach is future work. | Method + path | Body | Effect | | --- | --- | --- | | `POST /api/agents/pty/start` | `{ agent, task, cwd? }` | spawn a fresh PTY agent (503 if flag off) | -| `POST /api/agents/pty/start` | `{ agent:"claude", resumeSessionId, cwd? }` | **adopt** an idle session (409 if it's live) | +| `POST /api/agents/pty/start` | `{ agent:"claude", resumeSessionId, cwd? }` | **adopt** an idle session (claude-only: **400** for other agents; **409** if it's live) | | `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 (one-shot) | diff --git a/src/agents/pty.test.ts b/src/agents/pty.test.ts index 0000372..617713e 100644 --- a/src/agents/pty.test.ts +++ b/src/agents/pty.test.ts @@ -37,8 +37,10 @@ function fakePty() { }; let spawnFile = ""; let spawnArgs: string[] = []; + let spawnCount = 0; const module: PtyModuleLike = { spawn: (file, args) => { + spawnCount++; spawnFile = file; spawnArgs = args; return proc; @@ -51,6 +53,9 @@ function fakePty() { emitExit: (code: number) => exitCb?.({ exitCode: code }), isKilled: () => killed, getSpawn: () => ({ file: spawnFile, args: spawnArgs }), + get spawnCount() { + return spawnCount; + }, }; } @@ -173,12 +178,18 @@ test("resumeSessionId adopts an existing session via `--resume `", async () }); }); -test("resume is claude-only (codex ignores resumeSessionId)", async () => { +test("resume-adopt is claude-only — codex resume is refused, not silently downgraded", async () => { await withFlag(async () => { const f = fakePty(); const reg = new PtyRegistry(); - await reg.start({ agent: "codex", task: "", cwd: "/tmp/p", resumeSessionId: "abc-123", cli: "codex", ptyModule: f.module }); - assert.equal(f.getSpawn().args.includes("--resume"), false); + // Codex has no liveness signal, so adopting one can't be guarded against + // transcript corruption. Refusing (vs. silently starting a fresh session) + // keeps the API honest. See docs/PTY_AGENTS.md. + await assert.rejects( + () => reg.start({ agent: "codex", task: "", cwd: "/tmp/p", resumeSessionId: "abc-123", cli: "codex", ptyModule: f.module }), + /only supported for claude/i, + ); + assert.equal(f.spawnCount, 0); // never spawned anything }); }); diff --git a/src/agents/pty.ts b/src/agents/pty.ts index 77c3d7e..eef10de 100644 --- a/src/agents/pty.ts +++ b/src/agents/pty.ts @@ -241,9 +241,22 @@ export class PtyAgent { throw new Error("node-pty is not installed — run `npm i node-pty` to enable PTY agents"); } const kind = normalizeAgentKind(opts.agent); + // Resume-adopt is claude-only. The codex CLI *does* support `codex resume + // `, but LISA can't adopt it SAFELY: unlike claude (which writes + // ~/.claude/sessions/.json), codex leaves no liveness signal, so we + // can't tell whether a rollout is idle vs. open right now — and resuming a + // live session double-writes its transcript and corrupts it. Refuse rather + // than silently dropping the id and spawning a fresh session. See + // docs/PTY_AGENTS.md ("Why codex resume-adopt isn't supported (yet)"). + if (opts.resumeSessionId && kind !== "claude-code") { + throw new Error( + `resume-adopt is only supported for claude sessions (got "${opts.agent}") — ` + + "codex has no liveness signal to guard against transcript corruption", + ); + } const cli = opts.cli ?? (kind === "claude-code" ? detectClaudeBinary() : resolveCli(opts.agent)); - // Adopt an existing session by id (claude only): `claude --resume `. - const resumeArgs = opts.resumeSessionId && kind === "claude-code" ? ["--resume", opts.resumeSessionId] : []; + // Adopt an existing session by id: `claude --resume ` (claude-only, guarded above). + const resumeArgs = opts.resumeSessionId ? ["--resume", opts.resumeSessionId] : []; const args = [...resumeArgs, ...(opts.args ?? [])]; const now = opts.now ?? Date.now; const proc = pty.spawn(cli, args, { diff --git a/src/web/server.ts b/src/web/server.ts index 5a32a67..0a85f1b 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -37,7 +37,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 { ptyRegistry, ptyEnabled, normalizeAgentKind } from "../agents/pty.js"; import { liveClaudeSessionIds } from "../integrations/claude-code/liveness.js"; import { listRecentDispatches, isAlive, toDispatchView, readDispatchOutput } from "../integrations/dispatch-ledger.js"; import { loadControlPolicy, saveControlPolicy, type ControlPolicy } from "../control/policy.js"; @@ -1043,6 +1043,14 @@ export async function startWebServer(opts: WebServerOptions): Promise