diff --git a/src/agents/pty.ts b/src/agents/pty.ts index da4c58e..77c3d7e 100644 --- a/src/agents/pty.ts +++ b/src/agents/pty.ts @@ -39,6 +39,9 @@ export interface PtyView { lastMtime: number; /** Bytes of terminal output seen so far — a rough liveness signal. */ bytesOut: number; + /** When this PTY is a resume-adopt, the real claude session id it continues + * (so the roster can drop the observe-only duplicate of the same session). */ + adoptedSessionId?: string; } export interface PtyStartOpts { @@ -195,6 +198,8 @@ export class PtyAgent { readonly cli: string; readonly cwd: string; readonly project: string; + /** Real claude session id, when this agent is a resume-adopt (claude only). */ + readonly adoptedSessionId?: string; onChange: () => void = () => {}; /** Called with each ANSI-stripped output chunk — drives the live attach stream * (GET /api/agents/pty//stream). Distinct from onChange (state snapshots). */ @@ -216,6 +221,8 @@ export class PtyAgent { this.cli = cli; this.cwd = opts.cwd; this.project = opts.cwd.split("/").filter(Boolean).pop() || opts.cwd; + this.adoptedSessionId = + opts.resumeSessionId && this.agent === "claude-code" ? opts.resumeSessionId : undefined; this.proc = proc; this.now = now; this.lastChunkAt = now(); @@ -304,6 +311,7 @@ export class PtyAgent { stateReason: reason, lastMtime: this.lastMtime, bytesOut: this.bytesOut, + ...(this.adoptedSessionId ? { adoptedSessionId: this.adoptedSessionId } : {}), }; } diff --git a/src/integrations/hub-dedup.test.ts b/src/integrations/hub-dedup.test.ts new file mode 100644 index 0000000..802e07e --- /dev/null +++ b/src/integrations/hub-dedup.test.ts @@ -0,0 +1,39 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { dedupeAdoptedSessions } from "./hub.js"; +import type { AgentSession } from "./types.js"; + +const S = (o: Partial): AgentSession => ({ + agent: "claude-code", + sessionId: "x", + project: "p", + state: "working", + stateReason: "", + lastMtime: 0, + ...o, +}); + +test("drops the observe-only twin of a resume-adopted session", () => { + const out = dedupeAdoptedSessions([ + S({ sessionId: "uuid-1" }), // observe-only claude-code transcript + S({ sessionId: "p1-aaaa", controllable: "pty", adoptedSessionId: "uuid-1" }), // the PTY adopting it + S({ sessionId: "uuid-2" }), // unrelated → kept + ]); + assert.deepEqual( + out.map((s) => s.sessionId), + ["p1-aaaa", "uuid-2"], + ); +}); + +test("no adopts → returns the input unchanged (same reference)", () => { + const sessions = [S({ sessionId: "a" }), S({ sessionId: "b" })]; + assert.equal(dedupeAdoptedSessions(sessions), sessions); +}); + +test("only observe-only twins are dropped — a controllable same-id session stays", () => { + const out = dedupeAdoptedSessions([ + S({ sessionId: "uuid-1", controllable: "managed" }), // controllable → never dropped + S({ sessionId: "p1", controllable: "pty", adoptedSessionId: "uuid-1" }), + ]); + assert.equal(out.length, 2); +}); diff --git a/src/integrations/hub.ts b/src/integrations/hub.ts index b03d807..253cf80 100644 --- a/src/integrations/hub.ts +++ b/src/integrations/hub.ts @@ -68,6 +68,22 @@ export const DEFAULT_ORCHESTRATOR_CONFIG: OrchestratorConfig = { type Log = (msg: string) => void; +/** + * Drop observe-only sessions that a controllable session already represents. + * When LISA resume-adopts an idle claude session under a PTY, that same real + * session is ALSO seen by the claude-code observer (a JSONL transcript). The PTY + * entry carries the adopted sessionId, so we drop the observe-only twin and keep + * the controllable one. Pure — operates on the merged list. See PTY_AGENTS.md. + */ +export function dedupeAdoptedSessions(sessions: AgentSession[]): AgentSession[] { + const adopted = new Set(); + for (const s of sessions) { + if (s.controllable && s.adoptedSessionId) adopted.add(s.adoptedSessionId); + } + if (adopted.size === 0) return sessions; + return sessions.filter((s) => !(s.controllable == null && adopted.has(s.sessionId))); +} + export class OrchestratorHub extends EventEmitter { private observers: AgentObserver[] = []; private readonly cfg: OrchestratorConfig; @@ -129,7 +145,7 @@ export class OrchestratorHub extends EventEmitter { // one flaky observer shouldn't break the merge } } - return all.sort((a, b) => b.lastMtime - a.lastMtime); + return dedupeAdoptedSessions(all).sort((a, b) => b.lastMtime - a.lastMtime); } /** Sessions for one agent kind. */ diff --git a/src/integrations/pty/observer.ts b/src/integrations/pty/observer.ts index cfad911..35f1ee7 100644 --- a/src/integrations/pty/observer.ts +++ b/src/integrations/pty/observer.ts @@ -27,6 +27,7 @@ function toSession(v: PtyView): AgentSession { stateReason: v.stateReason, lastMtime: v.lastMtime, controllable: "pty", + ...(v.adoptedSessionId ? { adoptedSessionId: v.adoptedSessionId } : {}), }; } diff --git a/src/integrations/types.ts b/src/integrations/types.ts index 7455ba2..32e1615 100644 --- a/src/integrations/types.ts +++ b/src/integrations/types.ts @@ -89,6 +89,9 @@ export interface AgentSession { * control. Set by the API layer for claude-code sessions not currently live. */ resumable?: boolean; + /** When a controllable PTY is a resume-adopt, the real claude sessionId it + * continues — lets the roster drop the observe-only duplicate of that session. */ + adoptedSessionId?: string; } /** Visibility tier — how deeply LISA may inspect a session. See plan §3. */ diff --git a/src/web/server.ts b/src/web/server.ts index d991993..8e3b57e 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -37,7 +37,7 @@ import { signalAgentTool } from "../tools/signal_agent.js"; import { managedRegistry } from "../agents/managed.js"; import { ptyRegistry, ptyEnabled } from "../agents/pty.js"; import { liveClaudeSessionIds } from "../integrations/claude-code/liveness.js"; -import { listRecentDispatches, isAlive, toDispatchView } from "../integrations/dispatch-ledger.js"; +import { listRecentDispatches, isAlive, toDispatchView, readDispatchOutput } from "../integrations/dispatch-ledger.js"; import { loadControlPolicy, saveControlPolicy, type ControlPolicy } from "../control/policy.js"; import { SenseService } from "../sense/service.js"; import { ScreenSource } from "../sense/screen.js"; @@ -838,6 +838,27 @@ export async function startWebServer(opts: WebServerOptions): Promise. + if (req.method === "GET" && url.startsWith("/api/dispatch/status")) { + if (denyRemote("control")) return; + const id = new URL(url, "http://localhost").searchParams.get("id") ?? ""; + const entry = id ? listRecentDispatches().find((e) => e.id === id) : undefined; + if (!entry) { + res.writeHead(id ? 404 : 400, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: false, error: id ? "no such dispatch" : "id required" })); + return; + } + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ + ok: true, + ...toDispatchView(entry, isAlive(entry.pid)), + tail: readDispatchOutput(entry, 4000), + })); + return; + } + if (req.method === "POST" && url === "/api/agents/managed/start") { if (denyRemote("control")) return; let mBody = "";