fix(terminals): decouple PTY lifecycle from view lifecycle (no kill on navigation)

Navigating (layout/tab switch) tore the xterm view down and called
handle.close(), killing the backend PTY and cutting off running AIs. Now
the view's cleanup only detaches; only an explicit user action kills a PTY.

Backend:
- PortablePtyAdapter: per-session scrollback ring buffer (~100KB, most
  recent) + re-subscribable fan-out broadcast replacing the single-take
  output_rx. Reader thread feeds both the ring buffer and current
  subscribers; on EOF it closes subscribers (streams end) while keeping
  scrollback for late re-attach.
- PtyPort: new scrollback() method; subscribe_output is now re-subscribable
  (all impls + test fakes updated).
- reattach_terminal IPC command: returns scrollback and re-wires a fresh
  output channel on the live session without re-spawning.
- CloseRequested hook kills all live PTYs cleanly on app shutdown.
- TerminalSessions::handles() to enumerate live sessions at shutdown.

Frontend:
- TerminalHandle.detach(); TerminalGateway/AgentGateway.reattach() + mocks.
- TerminalView cleanup detaches (never close); on mount it re-attaches to a
  persisted session (repainting scrollback) instead of opening a new PTY.
- LayoutGrid persists the cell's session id via setSession; AgentsPanel
  tracks per-agent session ids — both drive reattach-vs-open.

Tests: ring buffer bounds to 100KB keeping newest bytes; scrollback retained;
re-subscription delivers post-reattach output; TerminalView detaches (not
closes) on unmount and reattaches with a known session; mock detach/reattach.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 12:24:48 +02:00
parent 307ae71857
commit 0660f52e2b
19 changed files with 879 additions and 150 deletions

View File

@ -36,6 +36,7 @@ import type {
OpenTerminalOptions,
ProfileGateway,
ProjectGateway,
ReattachResult,
RemoteGateway,
SystemGateway,
TemplateGateway,
@ -100,6 +101,47 @@ function slugify(name: string): string {
return out.replace(/^-+|-+$/g, "");
}
/**
* A live in-memory mock PTY session: it retains a scrollback (everything written
* to `onData`) and tracks the *current* output sink so a view can detach (sink
* cleared, session stays alive) and later re-attach (new sink, scrollback
* replayed). Only `close` ends the session — mirroring the backend's decoupling
* of PTY lifecycle from view lifecycle.
*/
class MockPtySession {
/** Accumulated output (the scrollback ring; unbounded in the mock — fine for tests). */
private scrollback: number[] = [];
/** Current view sink; `null` while detached. */
private sink: ((bytes: Uint8Array) => void) | null = null;
/** Whether the session was explicitly closed (PTY killed). */
closed = false;
constructor(
readonly sessionId: string,
sink: (bytes: Uint8Array) => void,
) {
this.sink = sink;
}
/** Records output into the scrollback and forwards it to the current sink. */
emit(bytes: Uint8Array): void {
if (this.closed) return;
for (const b of bytes) this.scrollback.push(b);
this.sink?.(bytes);
}
/** Detaches the current view: stop delivering, keep the session alive. */
detach(): void {
this.sink = null;
}
/** Re-attaches a new view, returning the retained scrollback to repaint. */
reattach(sink: (bytes: Uint8Array) => void): Uint8Array {
this.sink = sink;
return Uint8Array.from(this.scrollback);
}
}
/**
* Stateful in-memory agent gateway — mirrors the backend `CreateAgentFromScratch`,
* `ListAgents`, `ReadAgentContext`, `UpdateAgentContext`, `DeleteAgent`, and
@ -115,6 +157,8 @@ export class MockAgentGateway implements AgentGateway {
private contexts = new Map<string, string>();
/** Monotonic session counter for deterministic session ids in tests. */
private sessionSeq = 0;
/** Live agent PTY sessions, kept across detach so reattach can find them. */
private sessions = new Map<string, MockPtySession>();
private getAgents(projectId: string): Agent[] {
if (!this.agents.has(projectId)) this.agents.set(projectId, []);
@ -256,31 +300,66 @@ export class MockAgentGateway implements AgentGateway {
const sessionId = `mock-agent-session-${this.sessionSeq}`;
const cwd = options.cwd;
const enc = new TextEncoder();
const session = new MockPtySession(sessionId, onData);
this.sessions.set(sessionId, session);
// Greet so something is visible immediately (mirrors MockTerminalGateway).
queueMicrotask(() =>
onData(enc.encode(`agent ${agentId} @ ${cwd}\r\n`)),
session.emit(enc.encode(`agent ${agentId} @ ${cwd}\r\n`)),
);
let closed = false;
return makeMockHandle(session, () => this.sessions.delete(sessionId));
}
async reattach(
sessionId: string,
onData: (bytes: Uint8Array) => void,
): Promise<ReattachResult> {
const session = this.sessions.get(sessionId);
if (!session || session.closed) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `agent session ${sessionId} is not alive`,
};
throw err;
}
const scrollback = session.reattach(onData);
return {
sessionId,
async write(data: Uint8Array): Promise<void> {
if (closed) return;
// Echo back, translating CR to CRLF like a cooked terminal.
const out: number[] = [];
for (const b of data) {
if (b === 0x0d) out.push(0x0d, 0x0a);
else out.push(b);
}
onData(Uint8Array.from(out));
},
async resize(): Promise<void> {},
async close(): Promise<void> {
closed = true;
},
handle: makeMockHandle(session, () => this.sessions.delete(sessionId)),
scrollback,
};
}
}
/**
* Builds a {@link TerminalHandle} over a {@link MockPtySession}. `write` echoes
* (cooked-terminal CRLF translation) through the session so the scrollback
* records it; `detach` keeps the session alive; `close` ends it and unregisters.
*/
function makeMockHandle(
session: MockPtySession,
unregister: () => void,
): TerminalHandle {
return {
sessionId: session.sessionId,
async write(data: Uint8Array): Promise<void> {
if (session.closed) return;
const out: number[] = [];
for (const b of data) {
if (b === 0x0d) out.push(0x0d, 0x0a);
else out.push(b);
}
session.emit(Uint8Array.from(out));
},
async resize(): Promise<void> {},
detach(): void {
session.detach();
},
async close(): Promise<void> {
session.closed = true;
unregister();
},
};
}
/**
* In-memory fake terminal: a shell-less PTY that **echoes** whatever is written
* back to `onData` (so the xterm wrapper renders typed input) and greets on
@ -288,6 +367,8 @@ export class MockAgentGateway implements AgentGateway {
*/
export class MockTerminalGateway implements TerminalGateway {
private seq = 0;
/** Live sessions kept across detach so reattach can find them. */
private sessions = new Map<string, MockPtySession>();
async openTerminal(
options: OpenTerminalOptions,
@ -296,27 +377,31 @@ export class MockTerminalGateway implements TerminalGateway {
this.seq += 1;
const sessionId = `mock-session-${this.seq}`;
const enc = new TextEncoder();
const session = new MockPtySession(sessionId, onData);
this.sessions.set(sessionId, session);
// Greet so something is visible immediately.
queueMicrotask(() =>
onData(enc.encode(`mock terminal @ ${options.cwd}\r\n`)),
session.emit(enc.encode(`mock terminal @ ${options.cwd}\r\n`)),
);
let closed = false;
return makeMockHandle(session, () => this.sessions.delete(sessionId));
}
async reattach(
sessionId: string,
onData: (bytes: Uint8Array) => void,
): Promise<ReattachResult> {
const session = this.sessions.get(sessionId);
if (!session || session.closed) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `terminal session ${sessionId} is not alive`,
};
throw err;
}
const scrollback = session.reattach(onData);
return {
sessionId,
async write(data: Uint8Array): Promise<void> {
if (closed) return;
// Echo back, translating CR to CRLF like a cooked terminal.
const out: number[] = [];
for (const b of data) {
if (b === 0x0d) out.push(0x0d, 0x0a);
else out.push(b);
}
onData(Uint8Array.from(out));
},
async resize(): Promise<void> {},
async close(): Promise<void> {
closed = true;
},
handle: makeMockHandle(session, () => this.sessions.delete(sessionId)),
scrollback,
};
}
}