openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
886 lines (885 loc) • 32 kB
JavaScript
import { t as createSubsystemLogger } from "./subsystem-Dy2tqXOS.js";
import { t as truncateUtf8Prefix } from "./utf8-truncate-CCgJrv8A.js";
import { t as spawnTerminalPty } from "./terminal-pty-D0ZyEChB.js";
import { n as stageTerminalUpload, t as ensureTerminalUploadCleanup } from "./terminal-file-upload-BBvX98LE.js";
import { t as TerminalOutputRing } from "./output-ring-C3wasQKf.js";
import { n as DEFAULT_TERMINAL_DETACH_SECONDS } from "./session-limits-BqiIRDwa.js";
import { n as TERMINAL_EVENT_EXIT, t as TERMINAL_EVENT_DATA } from "./gateway-transport-BMjBhNL-.js";
import { randomUUID } from "node:crypto";
//#region src/gateway/terminal/agent-session-drain.ts
function agentTerminalOwnerMatches(owner, expected) {
if (owner?.kind !== "agent") return false;
return owner.agentSessionKey === expected.agentSessionKey && owner.agentSessionId === expected.agentSessionId && owner.agentId === expected.agentId;
}
function terminalTaskOwnerMatches(owner, taskId) {
return owner?.kind === "agent" && owner.taskId === taskId;
}
function drainKey(owner) {
return JSON.stringify([
owner.agentSessionKey,
owner.agentSessionId,
owner.agentId
]);
}
var AgentTerminalSessionDrainTracker = class {
constructor() {
this.active = /* @__PURE__ */ new Set();
this.waiters = /* @__PURE__ */ new Map();
this.exiting = /* @__PURE__ */ new Set();
}
begin(owner, hasWork) {
const key = drainKey(owner);
this.active.add(key);
let resolveDrain;
const drained = new Promise((resolve) => {
resolveDrain = resolve;
const waiters = this.waiters.get(key) ?? /* @__PURE__ */ new Set();
waiters.add(resolve);
this.waiters.set(key, waiters);
});
this.resolveIfIdle(owner, hasWork);
let released = false;
return {
drained,
hasWork,
release: () => {
if (released) return;
released = true;
this.active.delete(key);
const waiters = this.waiters.get(key);
waiters?.delete(resolveDrain);
if (waiters?.size === 0) this.waiters.delete(key);
}
};
}
isActive(owner) {
return this.active.has(drainKey(owner));
}
trackExit(session) {
this.exiting.add(session);
}
observeExit(session) {
this.exiting.delete(session);
}
hasExiting(owner) {
return [...this.exiting].some((session) => agentTerminalOwnerMatches(session.owner, owner));
}
resolveIfIdle(owner, hasWork) {
if (hasWork()) return;
const key = drainKey(owner);
const waiters = this.waiters.get(key);
if (!waiters) return;
this.waiters.delete(key);
for (const resolve of waiters) resolve();
}
};
//#endregion
//#region src/gateway/terminal/backend.ts
async function createLocalTerminalBackend(params, spawn = spawnTerminalPty) {
const pty = await spawn(params);
return {
write: (data) => pty.write(data),
resize: (cols, rows) => pty.resize(cols, rows),
pause: () => pty.pause(),
resume: () => pty.resume(),
kill: () => pty.kill(),
onData: (callback) => pty.onData(callback),
onExit: (callback) => pty.onExit(callback)
};
}
//#endregion
//#region src/gateway/terminal/intro-banner.ts
const RESET = "\x1B[0m";
const TERMINAL_INTRO_ART = [
" .. ..",
" .●●:.: • •●●",
" .●●●•●● ●•●●●●",
" :●●●●●• .. .. ●●●●●●.",
" .●●●●●::.:●••●:..•●●●●●",
" :●●●●. :●●●●. :●●●●.",
" •●●●• ●●●●●● .●●●●:",
" ..:••●●•●●●●●●•●●••...",
" ..:●•●••●●●●●●●●••●●•:..",
" :.:•:•••●●●●●●●••••••:.:",
" .•. ●:..:●●●●●●...:• :•",
" .:. ●●●●●● .:.",
" . ●●●●●• .",
" . :●●●●●●. .",
" ●●●●●●●●●•",
" .::•::•::"
];
function composeTerminalIntroBanner() {
return `\r\n${`\x1b[33mWelcome to the Claw.${RESET}`}\r\n\r\n${`\x1b[91m${TERMINAL_INTRO_ART.join("\r\n")}\r\n\r\n`}${RESET}`;
}
//#endregion
//#region src/gateway/terminal/output-coalescer.ts
const TERMINAL_OUTPUT_COALESCE_WINDOW_MS = 4;
const TERMINAL_OUTPUT_FRAME_BYTES = 65536;
/** Batches adjacent PTY chunks while keeping each emitted frame UTF-8 bounded. */
var TerminalOutputCoalescer = class {
constructor(emit) {
this.chunks = [];
this.bufferedBytes = 0;
this.timer = null;
this.emit = emit;
}
get isEmpty() {
return this.chunks.length === 0;
}
push(data, opts) {
let remaining = data;
while (remaining) {
const available = TERMINAL_OUTPUT_FRAME_BYTES - this.bufferedBytes;
const part = truncateUtf8Prefix(remaining, available);
if (!part) {
this.flush();
continue;
}
this.chunks.push(part);
this.bufferedBytes += Buffer.byteLength(part, "utf8");
remaining = remaining.slice(part.length);
if (this.bufferedBytes >= TERMINAL_OUTPUT_FRAME_BYTES) this.flush();
}
if (opts?.flushNow) this.flush();
else this.schedule();
}
flush() {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
if (this.chunks.length === 0) return;
const data = this.chunks.join("");
this.chunks = [];
this.bufferedBytes = 0;
this.emit(data);
}
clear() {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
this.chunks = [];
this.bufferedBytes = 0;
}
dispose(opts) {
if (opts?.flush) this.flush();
else this.clear();
}
schedule() {
if (this.timer || this.chunks.length === 0) return;
this.timer = setTimeout(() => {
this.timer = null;
this.flush();
}, TERMINAL_OUTPUT_COALESCE_WINDOW_MS);
this.timer.unref?.();
}
};
//#endregion
//#region src/gateway/terminal/output-flow-control.ts
const TERMINAL_OUTPUT_HIGH_WATER_BYTES = 4194304;
const TERMINAL_OUTPUT_LOW_WATER_BYTES = 524288;
const TERMINAL_OUTPUT_REASSERT_MS = 5e3;
const INTERACTIVE_OUTPUT_BYTES = 1024;
const INTERACTIVE_OUTPUT_WINDOW_MS = 100;
/** Couples PTY output batching to the live recipient WebSockets' send pressure. */
var TerminalOutputController = class {
constructor(options) {
this.endOffsetValue = 0;
this.emittedOffset = 0;
this.lastInputAtMs = Number.NEGATIVE_INFINITY;
this.desiredPaused = false;
this.reassertTimer = null;
this.backend = options.backend;
this.getConnIds = options.getConnIds;
this.getBufferedAmount = options.getBufferedAmount;
this.record = options.record;
this.emit = options.emit;
this.now = options.now ?? Date.now;
this.coalescer = new TerminalOutputCoalescer((data) => this.emitBuffered(data));
}
/** Cumulative UTF-16 end offset across streamed and detached output. */
get endOffset() {
return this.endOffsetValue;
}
push(chunk) {
this.record(chunk);
this.endOffsetValue += chunk.length;
const connIds = this.getConnIds();
if (connIds.length === 0) return;
if (this.coalescer.isEmpty) this.reconcile(connIds);
const interactive = Buffer.byteLength(chunk, "utf8") <= INTERACTIVE_OUTPUT_BYTES && this.now() - this.lastInputAtMs <= INTERACTIVE_OUTPUT_WINDOW_MS;
this.coalescer.push(chunk, { flushNow: interactive });
}
noteInput() {
this.lastInputAtMs = this.now();
}
/** Reassesses flow control immediately when the live recipient set changes. */
reconcileRecipients() {
this.reconcile(this.getConnIds());
}
/** Flushes existing viewers, then aligns live frames after the attach snapshot. */
prepareViewerAttach() {
this.coalescer.flush();
this.emittedOffset = this.endOffsetValue;
}
resetOwnership() {
this.coalescer.clear();
this.emittedOffset = this.endOffsetValue;
this.lastInputAtMs = Number.NEGATIVE_INFINITY;
if (this.reassertTimer) {
this.desiredPaused = false;
this.tryResume();
}
}
dispose(opts) {
this.coalescer.dispose(opts);
if (this.reassertTimer) {
clearInterval(this.reassertTimer);
this.reassertTimer = null;
this.desiredPaused = false;
this.tryResume();
}
}
emitBuffered(data) {
const connIds = this.getConnIds();
if (connIds.length === 0) return;
this.emittedOffset += data.length;
this.emit(connIds, data, this.emittedOffset);
this.reconcile(connIds);
}
reconcile(connIds) {
const bufferedAmount = this.maxBufferedAmount(connIds);
if (bufferedAmount === void 0) return;
if (bufferedAmount >= TERMINAL_OUTPUT_HIGH_WATER_BYTES) {
this.ensureReassertTimer();
if (!this.desiredPaused) {
this.desiredPaused = true;
this.tryPause();
}
return;
}
if (bufferedAmount <= TERMINAL_OUTPUT_LOW_WATER_BYTES && this.desiredPaused) {
this.desiredPaused = false;
this.tryResume();
}
}
ensureReassertTimer() {
if (this.reassertTimer) return;
this.reassertTimer = setInterval(() => {
const bufferedAmount = this.maxBufferedAmount(this.getConnIds());
if (bufferedAmount !== void 0) {
if (bufferedAmount >= TERMINAL_OUTPUT_HIGH_WATER_BYTES) this.desiredPaused = true;
else if (bufferedAmount <= TERMINAL_OUTPUT_LOW_WATER_BYTES) this.desiredPaused = false;
} else this.desiredPaused = false;
if (this.desiredPaused) this.tryPause();
else this.tryResume();
}, TERMINAL_OUTPUT_REASSERT_MS);
this.reassertTimer.unref?.();
}
maxBufferedAmount(connIds) {
let maximum;
for (const connId of connIds) {
const amount = this.getBufferedAmount(connId);
if (amount !== void 0 && (maximum === void 0 || amount > maximum)) maximum = amount;
}
return maximum;
}
tryPause() {
try {
this.backend.pause();
} catch {}
}
tryResume() {
try {
this.backend.resume();
} catch {}
}
};
//#endregion
//#region src/gateway/terminal/session-connection-index.ts
function addIndexed(index, connId, value) {
const values = index.get(connId) ?? /* @__PURE__ */ new Set();
values.add(value);
index.set(connId, values);
}
function removeIndexed(index, connId, value) {
const values = index.get(connId);
values?.delete(value);
if (values?.size === 0) index.delete(connId);
}
/** Reverse indexes live sessions and in-flight opens by their browser connection. */
var TerminalConnectionIndex = class {
constructor() {
this.sessions = /* @__PURE__ */ new Map();
this.pendingOpens = /* @__PURE__ */ new Map();
}
addSession(connId, sessionId) {
addIndexed(this.sessions, connId, sessionId);
}
removeSession(connId, sessionId) {
removeIndexed(this.sessions, connId, sessionId);
}
sessionIds(connId) {
const ids = this.sessions.get(connId);
return ids ? [...ids] : void 0;
}
clearSessions(connId) {
this.sessions.delete(connId);
}
addPendingOpen(connId, pending) {
addIndexed(this.pendingOpens, connId, pending);
}
removePendingOpen(connId, pending) {
removeIndexed(this.pendingOpens, connId, pending);
}
pendingFor(connId) {
const pending = this.pendingOpens.get(connId);
return pending ? [...pending] : void 0;
}
};
//#endregion
//#region src/gateway/terminal/session-projection.ts
function terminalAttachSummary(session) {
const { attached: _attached, createdAtMs: _createdAtMs, ...summary } = terminalSessionSummary(session);
return {
...summary,
buffer: session.buffer.snapshot(),
seq: session.output.endOffset
};
}
function terminalSessionSummary(session) {
const owner = session.owner?.kind === "agent" ? `agent:${session.owner.agentSessionKey}` : "conn";
return {
sessionId: session.id,
agentId: session.agentId,
shell: session.shell,
...session.title ? { title: session.title } : {},
cwd: session.cwd,
attached: session.owner?.kind === "conn" || session.owner?.kind === "agent" && session.viewers.size > 0,
owner,
createdAtMs: session.createdAtMs
};
}
function terminalSessionRecipientIds(session) {
const connIds = [...session.viewers];
if (session.owner?.kind === "conn" && !session.viewers.has(session.owner.connId)) connIds.push(session.owner.connId);
return connIds;
}
//#endregion
//#region src/gateway/terminal/session-manager.ts
const log = createSubsystemLogger("gateway/terminal");
/**
* Tracks live PTY sessions keyed by session id, with a reverse index for
* connection owners and viewers so disconnect cleanup stays bounded.
*/
var TerminalSessionManager = class {
constructor(options) {
this.sessions = /* @__PURE__ */ new Map();
this.pendingOpens = /* @__PURE__ */ new Map();
this.agentSessionDrain = new AgentTerminalSessionDrainTracker();
this.connections = new TerminalConnectionIndex();
this.opening = 0;
this.spawning = 0;
ensureTerminalUploadCleanup();
this.emit = options.emit;
this.getBufferedAmount = options.getBufferedAmount ?? (() => void 0);
this.spawn = options.spawn;
this.maxSessions = options.maxSessions ?? 24;
this.detachGraceMs = options.detachGraceMs ?? 0;
this.maxDetachedSessions = options.maxDetachedSessions ?? 8;
this.scrollbackChars = options.scrollbackChars ?? 262144;
}
/** Number of live sessions; used by tests and health surfaces. */
get size() {
return this.sessions.size;
}
/** Spawns a shell and wires its output/exit to its live connection recipients. */
async open(request) {
if (request.signal?.aborted) return {
ok: false,
code: "closed",
message: this.openAbortMessage(request.signal)
};
if (request.owner.kind === "agent" && this.agentSessionDrain.isActive(request.owner)) return {
ok: false,
code: "closed",
message: "terminal session is closing"
};
if (this.spawning >= this.maxSessions * 2) return {
ok: false,
code: "limit",
message: `terminal spawn limit reached (${this.maxSessions * 2})`
};
let evictionCandidate;
if (this.sessions.size + this.opening >= this.maxSessions) {
evictionCandidate = this.claimLongestIdleAgentSession();
if (!evictionCandidate) return {
ok: false,
code: "limit",
message: `terminal session limit reached (${this.maxSessions})`
};
}
const releaseEvictionClaim = () => {
if (evictionCandidate) {
evictionCandidate.evictionClaimed = false;
evictionCandidate = void 0;
}
};
this.opening += 1;
this.spawning += 1;
let reservationActive = true;
const releaseReservation = () => {
if (!reservationActive) return;
reservationActive = false;
this.opening -= 1;
};
const pending = {
agentId: request.agentId,
abort: (message) => {
pending.abortMessage ??= message;
releaseReservation();
releaseEvictionClaim();
}
};
const abortPending = () => {
pending.abort(this.openAbortMessage(request.signal));
};
request.signal?.addEventListener("abort", abortPending, { once: true });
this.trackPendingOpen(request.owner, pending, request.viewerConnId);
let backend;
try {
backend = request.createBackend ? await request.createBackend() : await createLocalTerminalBackend({
file: request.shell,
args: request.args,
cwd: request.cwd,
env: request.env,
cols: request.cols,
rows: request.rows
}, this.spawn);
} catch (err) {
this.spawning -= 1;
releaseReservation();
this.untrackPendingOpen(request.owner, pending, request.viewerConnId);
releaseEvictionClaim();
request.signal?.removeEventListener("abort", abortPending);
return {
ok: false,
code: "spawn_failed",
message: err instanceof Error ? err.message : String(err)
};
}
this.spawning -= 1;
releaseReservation();
request.signal?.removeEventListener("abort", abortPending);
if (pending.abortMessage) {
releaseEvictionClaim();
backend.onExit(() => this.untrackPendingOpen(request.owner, pending, request.viewerConnId));
try {
backend.kill();
} catch {}
return {
ok: false,
code: "closed",
message: pending.abortMessage
};
}
this.untrackPendingOpen(request.owner, pending, request.viewerConnId);
if (evictionCandidate) {
const claimed = evictionCandidate;
evictionCandidate = void 0;
claimed.evictionClaimed = false;
if (this.sessions.size + this.opening >= this.maxSessions) {
const victim = this.claimLongestIdleAgentSession();
if (!victim) {
try {
backend.kill();
} catch {}
return {
ok: false,
code: "limit",
message: `terminal session limit reached (${this.maxSessions})`
};
}
victim.evictionClaimed = false;
log.info(`evicted idle agent terminal session under pool pressure: id=${victim.id} agent=${victim.agentId} idleMs=${Date.now() - victim.lastActivityAtMs}`);
this.finalize(victim, "closed", {});
}
}
const sessionId = randomUUID();
const buffer = new TerminalOutputRing(this.scrollbackChars);
const output = new TerminalOutputController({
backend,
getConnIds: () => terminalSessionRecipientIds(session),
getBufferedAmount: this.getBufferedAmount,
record: (chunk) => buffer.push(chunk),
emit: (connIds, data, seq) => {
for (const connId of connIds) this.emit(connId, TERMINAL_EVENT_DATA, {
sessionId,
seq,
data
});
}
});
const session = {
id: sessionId,
owner: request.owner,
viewers: request.viewerConnId ? /* @__PURE__ */ new Set([request.viewerConnId]) : /* @__PURE__ */ new Set(),
...request.owner.kind === "agent" && request.viewerConnId ? { unadoptedViewerConnId: request.viewerConnId } : {},
agentId: request.agentId,
cwd: request.cwd,
shell: request.shell,
...request.title ? { title: request.title } : {},
backend,
stageUpload: request.stageUpload ?? stageTerminalUpload,
closed: false,
createdAtMs: Date.now(),
buffer,
output,
reaper: null,
detachedAtMs: null,
lastActivityAtMs: Date.now()
};
this.sessions.set(session.id, session);
if (request.owner.kind === "conn") this.connections.addSession(request.owner.connId, session.id);
if (request.viewerConnId) this.connections.addSession(request.viewerConnId, session.id);
if (request.owner.kind === "conn" || request.viewerConnId) session.output.push(composeTerminalIntroBanner());
backend.onData((chunk) => {
if (!session.closed) {
session.lastActivityAtMs = Date.now();
session.output.push(chunk);
}
});
backend.onExit((event) => {
const owner = session.owner?.kind === "agent" ? session.owner : void 0;
this.agentSessionDrain.observeExit(session);
const signal = event.signal && event.signal !== 0 ? event.signal : null;
this.finalize(session, event.error ? "error" : "process_exit", {
exitCode: event.exitCode ?? null,
signal,
...event.error ? { error: event.error } : {}
}, { backendExited: true });
if (owner) this.resolveAgentSessionDrainIfIdle(owner);
});
return {
ok: true,
sessionId: session.id,
agentId: session.agentId,
cwd: session.cwd,
shell: session.shell
};
}
/** Writes client input to a session; returns false when the session is gone. */
write(connId, sessionId, data) {
const session = this.interactiveSession(connId, sessionId);
if (!session) return false;
return this.writeSession(session, data);
}
/** Writes agent input after proving exact agent-session ownership. */
writeAgent(owner, sessionId, data) {
const session = this.agentOwnedSession(owner, sessionId);
if (!session) return {
ok: false,
code: "session_unavailable"
};
return this.writeSession(session, data) ? { ok: true } : {
ok: false,
code: "backend_failed"
};
}
writeSession(session, data) {
try {
session.lastActivityAtMs = Date.now();
session.output.noteInput();
session.backend.write(data);
return true;
} catch {
this.finalize(session, "error", { error: "write failed" });
return false;
}
}
/** Applies a new PTY grid size; returns false when the session is gone. */
resize(connId, sessionId, cols, rows) {
const session = this.interactiveSession(connId, sessionId);
if (!session) return false;
return this.resizeSession(session, cols, rows);
}
/** Resizes an agent-owned PTY after proving exact agent-session ownership. */
resizeAgent(owner, sessionId, cols, rows) {
const session = this.agentOwnedSession(owner, sessionId);
if (!session) return {
ok: false,
code: "session_unavailable"
};
return this.resizeSession(session, cols, rows) ? { ok: true } : {
ok: false,
code: "backend_failed"
};
}
resizeSession(session, cols, rows) {
try {
session.backend.resize(cols, rows);
return true;
} catch {
this.finalize(session, "error", { error: "resize failed" });
return false;
}
}
/** Stages a file on the same host as an owned terminal session. */
async upload(connId, sessionId, file) {
const session = this.interactiveSession(connId, sessionId);
if (!session) return;
const result = await session.stageUpload(file);
return this.interactiveSession(connId, sessionId) === session ? result : void 0;
}
/** Closes one session on operator request. */
close(connId, sessionId) {
const session = this.sessions.get(sessionId);
if (!session) return false;
if (session.owner?.kind === "agent") {
if (!session.viewers.has(connId)) return false;
if (session.unadoptedViewerConnId === connId) {
this.finalize(session, "closed", {});
return true;
}
return this.removeViewer(session, connId);
}
if (session.owner?.kind !== "conn" || session.owner.connId !== connId || session.closed) return false;
this.finalize(session, "closed", {});
return true;
}
/** Closes an agent-owned PTY after proving session-key ownership. */
closeAgent(owner, sessionId) {
const session = this.agentOwnedSession(owner, sessionId);
if (!session) return {
ok: false,
code: "session_unavailable"
};
this.finalize(session, "closed", {});
return { ok: true };
}
/** Closes every live or spawning PTY bound to one exact terminal task. */
closeTaskSessions(taskId) {
for (const [pending, owner] of this.pendingOpens) if (terminalTaskOwnerMatches(owner, taskId)) pending.abort("terminal closed because its task ended");
const owned = [...this.sessions.values()].filter((session) => !session.closed && terminalTaskOwnerMatches(session.owner, taskId));
for (const session of owned) this.finalize(session, "closed", {});
return owned.length;
}
/** Fences and closes one durable agent-session incarnation through archive commit. */
beginAgentSessionDrain(owner) {
const drain = this.agentSessionDrain.begin(owner, () => this.hasAgentSessionWork(owner));
for (const [pending, pendingOwner] of this.pendingOpens) if (agentTerminalOwnerMatches(pendingOwner, owner)) pending.abort("terminal closed because its session was archived");
for (const session of Array.from(this.sessions.values())) if (!session.closed && agentTerminalOwnerMatches(session.owner, owner)) this.finalize(session, "closed", {});
this.resolveAgentSessionDrainIfIdle(owner);
return drain;
}
/**
* Rebinds a connection-owned session, or co-attaches a viewer to an
* agent-owned session. Operator-to-operator attach remains take-over; only
* agent-owned sessions gain shared viewers.
*/
attach(connId, sessionId) {
const session = this.sessions.get(sessionId);
if (!session || session.closed) return;
if (session.owner?.kind === "agent") {
delete session.unadoptedViewerConnId;
session.output.prepareViewerAttach();
session.viewers.add(connId);
this.connections.addSession(connId, session.id);
return terminalAttachSummary(session);
}
if (session.reaper) {
clearTimeout(session.reaper);
session.reaper = null;
}
session.output.resetOwnership();
session.detachedAtMs = null;
const previousConnId = session.owner?.kind === "conn" ? session.owner.connId : null;
if (previousConnId !== null && previousConnId !== connId) {
this.connections.removeSession(previousConnId, session.id);
this.emit(previousConnId, TERMINAL_EVENT_EXIT, {
sessionId: session.id,
exitCode: null,
signal: null,
reason: "detached"
});
}
session.owner = {
kind: "conn",
connId
};
this.connections.addSession(connId, session.id);
return terminalAttachSummary(session);
}
/** Every live session, oldest first; all admin connections see the same list. */
list() {
return [...this.sessions.values()].filter((session) => !session.closed).map(terminalSessionSummary).toSorted((a, b) => a.createdAtMs - b.createdAtMs);
}
/** Raw buffered output for one session, or undefined when it is gone. */
snapshot(sessionId) {
const session = this.sessions.get(sessionId);
if (!session || session.closed) return;
return session.buffer.snapshot();
}
/** Raw buffer for an agent-owned session, guarded by the caller session key. */
snapshotAgent(owner, sessionId) {
return this.agentOwnedSession(owner, sessionId)?.buffer.snapshot();
}
/** Live sessions owned by one agent tool caller. */
listAgent(owner) {
return [...this.sessions.values()].filter((session) => !session.closed && agentTerminalOwnerMatches(session.owner, owner)).map(terminalSessionSummary).toSorted((a, b) => a.createdAtMs - b.createdAtMs);
}
trackPendingOpen(owner, pending, viewerConnId) {
this.pendingOpens.set(pending, owner);
const connId = owner.kind === "conn" ? owner.connId : viewerConnId;
if (!connId) return;
this.connections.addPendingOpen(connId, pending);
}
hasAgentSessionWork(owner) {
return [...this.pendingOpens.values()].some((pendingOwner) => agentTerminalOwnerMatches(pendingOwner, owner)) || [...this.sessions.values()].some((session) => !session.closed && agentTerminalOwnerMatches(session.owner, owner)) || this.agentSessionDrain.hasExiting(owner);
}
resolveAgentSessionDrainIfIdle(owner) {
this.agentSessionDrain.resolveIfIdle(owner, () => this.hasAgentSessionWork(owner));
}
openAbortMessage(signal) {
return signal?.reason instanceof Error ? signal.reason.message : "terminal open cancelled";
}
untrackPendingOpen(owner, pending, viewerConnId) {
this.pendingOpens.delete(pending);
if (owner.kind === "agent") this.resolveAgentSessionDrainIfIdle(owner);
const connId = owner.kind === "conn" ? owner.connId : viewerConnId;
if (!connId) return;
this.connections.removePendingOpen(connId, pending);
}
/**
* Handles a dropped connection: detaches its sessions for later reattach
* when a grace period is configured, otherwise kills them (legacy behavior,
* still selected by detachedSessionTimeoutSeconds: 0).
*/
handleDisconnect(connId) {
const opens = this.connections.pendingFor(connId);
if (opens) for (const pending of opens) pending.abort("connection closed during open");
const ids = this.connections.sessionIds(connId);
if (!ids) return;
for (const id of ids) {
const session = this.sessions.get(id);
if (!session) continue;
if (session.owner?.kind === "agent") {
if (session.unadoptedViewerConnId === connId) {
this.finalize(session, "disconnected", {}, { silent: true });
continue;
}
this.removeViewer(session, connId);
continue;
}
if (session.owner?.kind !== "conn" || session.owner.connId !== connId) continue;
if (this.detachGraceMs > 0) this.detach(session);
else this.finalize(session, "disconnected", {}, { silent: true });
}
this.connections.clearSessions(connId);
}
/** Closes live and pending sessions whose agent no longer permits a host shell. */
closeDisallowedAgents(isAllowed) {
for (const pending of this.pendingOpens.keys()) if (!isAllowed(pending.agentId)) pending.abort("terminal closed because the agent policy changed");
for (const session of Array.from(this.sessions.values())) if (!isAllowed(session.agentId)) this.finalize(session, "closed", { error: "terminal closed because the agent policy changed" });
}
/** Parks a session ownerless with a reaper; PTY output keeps buffering. */
detach(session) {
session.output.resetOwnership();
session.owner = null;
session.detachedAtMs = Date.now();
this.scheduleDetachedExpiry(session, session.detachedAtMs);
this.enforceDetachedCap();
}
updateDetachGraceMs(graceMs) {
if (this.detachGraceMs === graceMs) return;
this.detachGraceMs = graceMs;
for (const session of this.sessions.values()) if (session.detachedAtMs !== null) this.scheduleDetachedExpiry(session, session.detachedAtMs);
}
scheduleDetachedExpiry(session, detachedAtMs) {
if (session.reaper) clearTimeout(session.reaper);
const remainingMs = detachedAtMs + this.detachGraceMs - Date.now();
if (remainingMs <= 0) {
this.finalize(session, "disconnected", {}, { silent: true });
return;
}
session.reaper = setTimeout(() => {
this.finalize(session, "disconnected", {}, { silent: true });
}, remainingMs);
session.reaper.unref?.();
}
enforceDetachedCap() {
const detached = [...this.sessions.values()].filter((session) => !session.closed && session.owner === null).toSorted((a, b) => (a.detachedAtMs ?? 0) - (b.detachedAtMs ?? 0));
for (const session of detached.slice(0, Math.max(0, detached.length - this.maxDetachedSessions))) this.finalize(session, "disconnected", {}, { silent: true });
}
/** Tears down all sessions silently on Gateway shutdown; their sockets are closing too. */
disposeAll() {
for (const pending of this.pendingOpens.keys()) pending.abort("gateway closed during terminal open");
for (const session of Array.from(this.sessions.values())) this.finalize(session, "disconnected", {}, { silent: true });
}
/**
* Claims the longest-idle agent-owned session as an eviction candidate when
* the pool is exhausted. Viewer-attached and connection-owned sessions are
* never evicted; an idle viewer-free background job losing its PTY under
* pressure is the accepted tradeoff for keeping the pool available. Claimed
* sessions are skipped so concurrent opens select distinct victims.
*/
claimLongestIdleAgentSession() {
let candidate;
for (const session of this.sessions.values()) {
if (session.closed || session.evictionClaimed || session.owner?.kind !== "agent" || session.viewers.size > 0) continue;
if (!candidate || session.lastActivityAtMs < candidate.lastActivityAtMs) candidate = session;
}
if (candidate) candidate.evictionClaimed = true;
return candidate;
}
removeViewer(session, connId) {
if (!session.viewers.delete(connId)) return false;
this.connections.removeSession(connId, session.id);
if (session.viewers.size === 0) session.output.resetOwnership();
else session.output.reconcileRecipients();
return true;
}
interactiveSession(connId, sessionId) {
const session = this.sessions.get(sessionId);
if (!session || session.closed) return;
if (session.owner?.kind === "conn") return session.owner.connId === connId ? session : void 0;
if (session.owner?.kind !== "agent" || !session.viewers.has(connId)) return;
delete session.unadoptedViewerConnId;
return session;
}
/** Agents may operate only PTYs created by their exact trusted session key. */
agentOwnedSession(owner, sessionId) {
const session = this.sessions.get(sessionId);
if (!session || session.closed || !agentTerminalOwnerMatches(session.owner, owner)) return;
delete session.unadoptedViewerConnId;
return session;
}
finalize(session, reason, detail, opts) {
if (session.closed) return;
const recipients = terminalSessionRecipientIds(session);
session.output.dispose({ flush: !opts?.silent && recipients.length > 0 });
session.closed = true;
if (session.reaper) {
clearTimeout(session.reaper);
session.reaper = null;
}
if (!opts?.backendExited && session.owner?.kind === "agent") this.agentSessionDrain.trackExit(session);
try {
session.backend.kill();
} catch {}
this.sessions.delete(session.id);
if (session.owner?.kind === "conn") this.connections.removeSession(session.owner.connId, session.id);
for (const viewerConnId of session.viewers) this.connections.removeSession(viewerConnId, session.id);
session.viewers.clear();
if (!opts?.silent) for (const connId of recipients) this.emit(connId, TERMINAL_EVENT_EXIT, {
sessionId: session.id,
exitCode: detail.exitCode ?? null,
signal: detail.signal ?? null,
reason,
...detail.error ? { error: detail.error } : {}
});
}
};
//#endregion
export { DEFAULT_TERMINAL_DETACH_SECONDS, TerminalSessionManager };