Skip to content
Merged
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
6 changes: 6 additions & 0 deletions docs/IOS_COMPANION_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <session-id>`(本次接通,复用 §4.1 attach 客户端;命中 409 会提示"先关掉它");③岛仍保留手动"复制 `claude --resume`"。
- 覆盖:**用户已起、现已空闲**的 claude 会话:"我之前那个会话,手机上让它接着干。"
- **codex 对等?已查、暂不做(诚实记账,2026-06-19)**:codex CLI **支持续写**(`codex resume <id>`,会话存 `$CODEX_HOME/sessions/YYYY/MM/DD/rollout-*.jsonl`),
但 resume-adopt 的 **liveness 守卫对 codex 不成立**——codex **不写 pid 锁**(claude 有 `~/.claude/sessions/<pid>.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 协议不是这里的答案(诚实记账)

Expand All @@ -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`,
Expand Down
36 changes: 35 additions & 1 deletion docs/PTY_AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <session-id> [prompt]`; sessions live at
`$CODEX_HOME/sessions/YYYY/MM/DD/rollout-*.jsonl`), so in principle LISA could spawn
`codex resume <id>` under a PTY the same way it spawns `claude --resume <id>`. 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/<pid>.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-<id>.jsonl` → `<id>`); whether that's the exact token
`codex resume <id>` 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 "<task>"`, 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 <id>` form.

## Enabling it

1. Install the optional native dep (it has zero JS deps; if your machine can't
Expand Down Expand Up @@ -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/<id>/send` | `{ text }` | type a line into the CLI |
| `POST /api/agents/pty/<id>/cancel` | — | kill the CLI |
| `GET /api/agents/pty/<id>/output` | — | ANSI-stripped terminal tail (one-shot) |
Expand Down
17 changes: 14 additions & 3 deletions src/agents/pty.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -51,6 +53,9 @@ function fakePty() {
emitExit: (code: number) => exitCb?.({ exitCode: code }),
isKilled: () => killed,
getSpawn: () => ({ file: spawnFile, args: spawnArgs }),
get spawnCount() {
return spawnCount;
},
};
}

Expand Down Expand Up @@ -173,12 +178,18 @@ test("resumeSessionId adopts an existing session via `--resume <id>`", 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
});
});

Expand Down
17 changes: 15 additions & 2 deletions src/agents/pty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// <id>`, but LISA can't adopt it SAFELY: unlike claude (which writes
// ~/.claude/sessions/<pid>.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 <id>`.
const resumeArgs = opts.resumeSessionId && kind === "claude-code" ? ["--resume", opts.resumeSessionId] : [];
// Adopt an existing session by id: `claude --resume <id>` (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, {
Expand Down
10 changes: 9 additions & 1 deletion src/web/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -1043,6 +1043,14 @@ export async function startWebServer(opts: WebServerOptions): Promise<http.Serve
// Adopting an external session is the highest-risk control action — gate it
// behind remoteAdoptExternal; starting a fresh agent is ordinary control.
if (denyRemote(resumeSessionId ? "adoptExternal" : "control")) return;
// Resume-adopt is claude-only (docs/PTY_AGENTS.md). Codex has no liveness
// signal, so we can't guard against resuming a *live* rollout and corrupting
// it — refuse with a clear 400 rather than silently spawning a fresh session.
if (resumeSessionId && normalizeAgentKind(agent) !== "claude-code") {
res.writeHead(400, { "content-type": "text/plain" });
res.end(`resume-adopt is only supported for claude sessions (got "${agent}"); start a fresh agent instead`);
return;
}
// Adopting an existing session needs no task (it continues the convo); a
// fresh agent does. Guard: never resume a session that's currently live.
if (!resumeSessionId && (typeof payload.task !== "string" || !payload.task.trim())) {
Expand Down