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
8 changes: 8 additions & 0 deletions src/agents/pty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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/<id>/stream). Distinct from onChange (state snapshots). */
Expand All @@ -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();
Expand Down Expand Up @@ -304,6 +311,7 @@ export class PtyAgent {
stateReason: reason,
lastMtime: this.lastMtime,
bytesOut: this.bytesOut,
...(this.adoptedSessionId ? { adoptedSessionId: this.adoptedSessionId } : {}),
};
}

Expand Down
39 changes: 39 additions & 0 deletions src/integrations/hub-dedup.test.ts
Original file line number Diff line number Diff line change
@@ -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>): 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);
});
18 changes: 17 additions & 1 deletion src/integrations/hub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
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;
Expand Down Expand Up @@ -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. */
Expand Down
1 change: 1 addition & 0 deletions src/integrations/pty/observer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ function toSession(v: PtyView): AgentSession {
stateReason: v.stateReason,
lastMtime: v.lastMtime,
controllable: "pty",
...(v.adoptedSessionId ? { adoptedSessionId: v.adoptedSessionId } : {}),
};
}

Expand Down
3 changes: 3 additions & 0 deletions src/integrations/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
23 changes: 22 additions & 1 deletion src/web/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -838,6 +838,27 @@ export async function startWebServer(opts: WebServerOptions): Promise<http.Serve
return;
}

// Status + captured-output tail of ONE dispatched agent. The tail is raw
// stdout (potentially sensitive), so it's behind the remote-control gate
// (loopback always; remote per policy). ?id=<dispatch id>.
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 = "";
Expand Down