1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
2/**
3 * TerminalManager - Terminal session orchestration service interface.
4 *
5 * Owns terminal lifecycle operations, output fanout, and session state
6 * transitions for thread-scoped terminals.
7 *
8 * @module TerminalManager
9 */
10import { withWorkspaceLease } from "../workspace/workspaceLease.ts";
11import {
12 DEFAULT_TERMINAL_ID,
13 ThreadId,
14 TerminalCwdError,
15 TerminalCwdNotDirectoryError,
16 TerminalCwdNotFoundError,
17 TerminalCwdStatError,
18 TerminalError,
19 TerminalHistoryError,
20 TerminalNotRunningError,
21 TerminalProviderInstanceNotFoundError,
22 TerminalProviderEnvironmentError,
23 TerminalResizeError,
24 TerminalSessionLookupError,
25 TerminalWriteError,
26 type TerminalAttachInput,
27 type TerminalAttachStreamEvent,
28 type TerminalClearInput,
29 type TerminalCloseInput,
30 type TerminalEvent,
31 type TerminalMetadataStreamEvent,
32 type TerminalOpenInput,
33 type TerminalObserveInput,
34 type TerminalResizeInput,
35 type ResourceMonitorProcessTableEntry,
36 type TerminalRestartInput,
37 type TerminalSessionSnapshot,
38 type TerminalSessionStatus,
39 type TerminalSummary,
40 type TerminalWriteInput,
41 ClaudeSettings,
42 CodexSettings,
43 ProviderInstanceId,
44} from "@t3tools/contracts";
45import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker";
46import * as KeyedLock from "@t3tools/shared/KeyedLock";
47import { AgentScope } from "@t3tools/shared/AgentScope";
48import * as HostProcess from "@t3tools/shared/HostProcess";
49import { mergePathEntries } from "@t3tools/shared/shell";
50
51import { acpRegistryManagedBinaryDirectories } from "@t3tools/provider-acp-registry/server";
52import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
53import * as DateTime from "effect/DateTime";
54import * as Context from "effect/Context";
55import * as Effect from "effect/Effect";
56import * as Base64Url from "effect/encoding/Base64Url";
57import * as Equal from "effect/Equal";
58import * as Exit from "effect/Exit";
59import * as Fiber from "effect/Fiber";
60import * as FileSystem from "effect/FileSystem";
61import * as Layer from "effect/Layer";
62import * as Option from "effect/Option";
63import * as Path from "effect/Path";
64import * as Schema from "effect/Schema";
65import * as Scope from "effect/Scope";
66import * as SynchronizedRef from "effect/SynchronizedRef";
67
68import * as ServerConfig from "../config.ts";
69import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment";
70import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts";
71import { makeClaudeEnvironment } from "../provider/Drivers/ClaudeHome.ts";
72import { deriveProviderInstanceConfigMap } from "../provider/ProviderInstanceRegistryHydration.ts";
73import * as ServerSettings from "../serverSettings.ts";
74import {
75 increment,
76 terminalRestartsTotal,
77 terminalSessionsTotal,
78} from "../observability/Metrics.ts";
79import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
80import * as ProcessRunner from "../processRunner.ts";
81import * as PortScanner from "../preview/PortScanner.ts";
82import * as NativeTelemetryClient from "../resourceTelemetry/NativeTelemetryClient.ts";
83import * as PtyAdapter from "@t3tools/shared/PtyAdapter";
84
85export {
86 TerminalCwdError,
87 TerminalCwdNotDirectoryError,
88 TerminalCwdNotFoundError,
89 TerminalCwdStatError,
90 TerminalError,
91 TerminalHistoryError,
92 TerminalNotRunningError,
93 TerminalProviderInstanceNotFoundError,
94 TerminalProviderEnvironmentError,
95 TerminalResizeError,
96 TerminalSessionLookupError,
97 TerminalWriteError,
98};
99
100const DEFAULT_HISTORY_LINE_LIMIT = 5_000;
101const DEFAULT_HISTORY_BYTE_LIMIT = 8 * 1024 * 1024;
102const MAX_HISTORY_CHUNK_LENGTH = 16 * 1024;
103const DEFAULT_PERSIST_DEBOUNCE_MS = 250;
104const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000;
105const MAX_SUBPROCESS_POLL_INTERVAL_MS = 60_000;
106const DEFAULT_PROCESS_KILL_GRACE_MS = 1_000;
107const DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS = 128;
108const DEFAULT_OPEN_COLS = 120;
109const DEFAULT_OPEN_ROWS = 30;
110const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]);
111const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
112const MAX_TERMINAL_LABEL_LENGTH = 128;
113const decodeClaudeSettings = Schema.decodeUnknownOption(ClaudeSettings);
114const decodeCodexSettings = Schema.decodeUnknownOption(CodexSettings);
115
116class TerminalSubprocessCheckError extends Schema.TaggedError<TerminalSubprocessCheckError>()(
117 "TerminalSubprocessCheckError",
118 {
119 cause: Schema.optional(Schema.Defect()),
120 command: Schema.Literals(["powershell", "ps", "resource-monitor"]),
121 exitCode: Schema.optional(Schema.NullOr(Schema.Number)),
122 timedOut: Schema.optional(Schema.Boolean),
123 stdoutTruncated: Schema.optional(Schema.Boolean),
124 },
125) {
126 override get message(): string {
127 const details = [
128 this.exitCode !== undefined && this.exitCode !== null ? `exit code ${this.exitCode}` : null,
129 this.timedOut ? "timed out" : null,
130 this.stdoutTruncated ? "output truncated" : null,
131 ]
132 .filter((detail) => detail !== null)
133 .join(", ");
134 return `Failed to inspect terminal subprocesses with ${this.command}${details.length > 0 ? ` (${details})` : ""}`;
135 }
136}
137
138class TerminalProcessSignalError extends Schema.TaggedError<TerminalProcessSignalError>()(
139 "TerminalProcessSignalError",
140 {
141 cause: Schema.optional(Schema.Defect()),
142 signal: Schema.Literals(["SIGTERM", "SIGKILL"]),
143 terminalPid: Schema.Number,
144 },
145) {
146 override get message(): string {
147 return `Failed to send ${this.signal} to terminal process ${this.terminalPid}`;
148 }
149}
150
151/**
152 * TerminalManager - Service tag for terminal session orchestration.
153 */
154export class TerminalManager extends Context.Service<
155 TerminalManager,
156 {
157 /**
158 * Open or attach to a terminal session.
159 *
160 * Reuses an existing session for the same thread/terminal id and restores
161 * persisted history on first open.
162 */
163 readonly open: (
164 input: TerminalOpenInput,
165 ) => Effect.Effect<TerminalSessionSnapshot, TerminalError>;
166
167 /**
168 * Attach to a terminal and stream its initial snapshot followed by live events.
169 *
170 * Returns an unsubscribe function.
171 */
172 readonly attachStream: (
173 input: TerminalAttachInput,
174 listener: (event: TerminalAttachStreamEvent) => Effect.Effect<void>,
175 ) => Effect.Effect<() => void, TerminalError>;
176
177 /** Observe an existing session without starting or changing its process. */
178 readonly observeStream: (
179 input: TerminalObserveInput,
180 listener: (event: TerminalAttachStreamEvent) => Effect.Effect<void>,
181 ) => Effect.Effect<() => void, TerminalError>;
182
183 /**
184 * Write input bytes to a terminal session.
185 */
186 readonly write: (input: TerminalWriteInput) => Effect.Effect<void, TerminalError>;
187
188 /**
189 * Resize the PTY backing a terminal session.
190 */
191 readonly resize: (input: TerminalResizeInput) => Effect.Effect<void, TerminalError>;
192
193 /**
194 * Clear terminal output history.
195 */
196 readonly clear: (input: TerminalClearInput) => Effect.Effect<void, TerminalError>;
197
198 /**
199 * Restart a terminal session in place.
200 *
201 * Always resets history before spawning the new process.
202 */
203 readonly restart: (
204 input: TerminalRestartInput,
205 ) => Effect.Effect<TerminalSessionSnapshot, TerminalError>;
206
207 /**
208 * Close an active terminal session.
209 *
210 * When `terminalId` is omitted, closes all sessions for the thread.
211 */
212 readonly close: (input: TerminalCloseInput) => Effect.Effect<void, TerminalError>;
213
214 readonly list: (input: {
215 readonly threadId: string;
216 }) => Effect.Effect<ReadonlyArray<TerminalSummary>>;
217
218 /**
219 * Close a thread's terminals that wait at an idle shell prompt. A terminal
220 * that runs a command stays open. When `terminalId` is set, only that
221 * terminal is considered.
222 */
223 readonly closeIdle: (input: {
224 readonly threadId: string;
225 readonly terminalId?: string;
226 }) => Effect.Effect<void>;
227
228 /**
229 * Subscribe to terminal runtime events with a direct callback.
230 *
231 * Returns an unsubscribe function.
232 */
233 readonly subscribe: (
234 listener: (event: TerminalEvent) => Effect.Effect<void>,
235 ) => Effect.Effect<() => void>;
236
237 /**
238 * Subscribe to lightweight terminal metadata with an initial full snapshot.
239 *
240 * Returns an unsubscribe function.
241 */
242 readonly subscribeMetadata: (
243 listener: (event: TerminalMetadataStreamEvent) => Effect.Effect<void>,
244 ) => Effect.Effect<() => void>;
245 }
246>()("t3/terminal/Manager/TerminalManager") {}
247
248interface TerminalSubprocessInspectResult {
249 readonly hasRunningSubprocess: boolean;
250 readonly childCommand: string | null;
251 readonly processIds: ReadonlyArray<number>;
252}
253
254interface TerminalSubprocessInspector {
255 (
256 terminalPid: number,
257 ): Effect.Effect<TerminalSubprocessInspectResult, TerminalSubprocessCheckError>;
258}
259
260const resizePtyProcess = (
261 session: TerminalSessionState,
262 process: PtyAdapter.PtyProcess,
263 cols: number,
264 rows: number,
265) =>
266 Effect.try({
267 try: () => process.resize(cols, rows),
268 catch: (cause) =>
269 new TerminalResizeError({
270 threadId: session.threadId,
271 terminalId: session.terminalId,
272 terminalPid: process.pid,
273 cols,
274 rows,
275 cause,
276 }),
277 });
278
279export interface ShellCandidate {
280 shell: string;
281 args?: string[];
282}
283
284export interface TerminalStartInput extends TerminalOpenInput {
285 cols: number;
286 rows: number;
287}
288
289interface TerminalSessionState {
290 threadId: string;
291 terminalId: string;
292 cwd: string;
293 worktreePath: string | null;
294 status: TerminalSessionStatus;
295 pid: number | null;
296 history: BoundedTerminalHistory;
297 pendingHistoryControlSequence: string;
298 pendingProcessEvents: Array<PendingProcessEvent>;
299 pendingProcessEventIndex: number;
300 processEventDrainRunning: boolean;
301 exitCode: number | null;
302 exitSignal: number | null;
303 updatedAt: string;
304 eventSequence: number;
305 /** Counts writes, so closeIdle can see input that has not echoed yet. */
306 inputCount: number;
307 cols: number;
308 rows: number;
309 process: PtyAdapter.PtyProcess | null;
310 unsubscribeData: (() => void) | null;
311 unsubscribeExit: (() => void) | null;
312 hasRunningSubprocess: boolean;
313 /** Normalized child command name when `hasRunningSubprocess`; cleared when idle. */
314 childCommandLabel: string | null;
315 runtimeEnv: Record<string, string> | null;
316}
317
318interface PersistHistoryRequest {
319 history: BoundedTerminalHistory;
320 immediate: boolean;
321}
322
323type PendingProcessEvent =
324 | { type: "output"; data: string }
325 | { type: "exit"; event: PtyAdapter.PtyExitEvent };
326
327type DrainProcessEventAction =
328 | { type: "idle" }
329 | {
330 type: "output";
331 threadId: string;
332 terminalId: string;
333 sequence: number;
334 history: BoundedTerminalHistory | null;
335 data: string;
336 }
337 | {
338 type: "exit";
339 process: PtyAdapter.PtyProcess | null;
340 threadId: string;
341 terminalId: string;
342 sequence: number;
343 exitCode: number | null;
344 exitSignal: number | null;
345 };
346
347interface TerminalManagerState {
348 sessions: Map<string, TerminalSessionState>;
349 killFibers: Map<PtyAdapter.PtyProcess, Fiber.Fiber<void, never>>;
350}
351
352function truncateTerminalWireLabel(value: string): string {
353 if (value.length <= MAX_TERMINAL_LABEL_LENGTH) return value;
354 return value.slice(0, MAX_TERMINAL_LABEL_LENGTH);
355}
356
357function normalizeChildCommandName(raw: string, platform: NodeJS.Platform): string | null {
358 let trimmed = raw.trim();
359 if (trimmed.length === 0) return null;
360 if (
361 (trimmed.startsWith("[") && trimmed.endsWith("]")) ||
362 (trimmed.startsWith("(") && trimmed.endsWith(")"))
363 ) {
364 trimmed = trimmed.slice(1, -1).trim();
365 }
366 const firstToken = (trimmed.split(/\s+/)[0] ?? trimmed).trim();
367 if (firstToken.length === 0) return null;
368 const separators = platform === "win32" ? /[\\/]/ : /\//;
369 const base = firstToken.split(separators).at(-1) ?? firstToken;
370 const withoutExe =
371 platform === "win32" && base.toLowerCase().endsWith(".exe") ? base.slice(0, -4) : base;
372 return withoutExe.length > 0 ? withoutExe : null;
373}
374
375function terminalWireLabel(session: TerminalSessionState): string {
376 if (session.hasRunningSubprocess && session.childCommandLabel) {
377 const trimmed = session.childCommandLabel.trim();
378 if (trimmed.length > 0) {
379 return truncateTerminalWireLabel(trimmed);
380 }
381 }
382 return truncateTerminalWireLabel(getTerminalLabel(session.terminalId));
383}
384
385function snapshot(session: TerminalSessionState): TerminalSessionSnapshot {
386 return {
387 threadId: session.threadId,
388 terminalId: session.terminalId,
389 cwd: session.cwd,
390 worktreePath: session.worktreePath,
391 status: session.status,
392 pid: session.pid,
393 history: session.history.value(),
394 exitCode: session.exitCode,
395 exitSignal: session.exitSignal,
396 label: terminalWireLabel(session),
397 updatedAt: session.updatedAt,
398 sequence: session.eventSequence,
399 };
400}
401
402function summary(session: TerminalSessionState): TerminalSummary {
403 return {
404 threadId: session.threadId,
405 terminalId: session.terminalId,
406 cwd: session.cwd,
407 worktreePath: session.worktreePath,
408 status: session.status,
409 pid: session.pid,
410 exitCode: session.exitCode,
411 exitSignal: session.exitSignal,
412 hasRunningSubprocess: session.hasRunningSubprocess,
413 label: terminalWireLabel(session),
414 updatedAt: session.updatedAt,
415 };
416}
417
418function shouldPublishTerminalMetadataEvent(event: TerminalEvent): boolean {
419 switch (event.type) {
420 case "started":
421 case "restarted":
422 case "exited":
423 case "closed":
424 case "error":
425 case "activity":
426 return true;
427 case "output":
428 case "cleared":
429 return false;
430 }
431}
432
433function terminalEventToAttachEvent(event: TerminalEvent): TerminalAttachStreamEvent | null {
434 switch (event.type) {
435 case "started":
436 return {
437 type: "snapshot",
438 snapshot: event.snapshot,
439 };
440 case "output":
441 case "exited":
442 case "closed":
443 case "error":
444 case "cleared":
445 case "restarted":
446 case "activity":
447 return event;
448 }
449}
450
451function isDuplicateAttachSnapshotEvent(
452 event: TerminalEvent,
453 initialSnapshot: TerminalSessionSnapshot,
454) {
455 return typeof event.sequence === "number" && typeof initialSnapshot.sequence === "number"
456 ? event.sequence <= initialSnapshot.sequence
457 : event.type === "started" &&
458 event.snapshot.threadId === initialSnapshot.threadId &&
459 event.snapshot.terminalId === initialSnapshot.terminalId &&
460 event.snapshot.updatedAt <= initialSnapshot.updatedAt;
461}
462
463function advanceEventSequence(session: TerminalSessionState): {
464 readonly updatedAt: string;
465 readonly sequence: number;
466} {
467 const updatedAt = DateTime.formatIso(DateTime.nowUnsafe());
468 session.eventSequence += 1;
469 session.updatedAt = updatedAt;
470 return { updatedAt, sequence: session.eventSequence };
471}
472
473function cleanupProcessHandles(session: TerminalSessionState): void {
474 session.unsubscribeData?.();
475 session.unsubscribeData = null;
476 session.unsubscribeExit?.();
477 session.unsubscribeExit = null;
478}
479
480function enqueueProcessEvent(
481 session: TerminalSessionState,
482 expectedPid: number,
483 event: PendingProcessEvent,
484): boolean {
485 if (!session.process || session.status !== "running" || session.pid !== expectedPid) {
486 return false;
487 }
488
489 session.pendingProcessEvents.push(event);
490 if (session.processEventDrainRunning) {
491 return false;
492 }
493
494 session.processEventDrainRunning = true;
495 return true;
496}
497
498function defaultShellResolver(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): string {
499 if (platform === "win32") {
500 return "pwsh.exe";
501 }
502 return env.SHELL ?? "bash";
503}
504
505function normalizeShellCommand(
506 value: string | undefined,
507 platform: NodeJS.Platform,
508): string | null {
509 if (!value) return null;
510 const trimmed = value.trim();
511 if (trimmed.length === 0) return null;
512
513 if (platform === "win32") {
514 return trimmed;
515 }
516
517 const firstToken = trimmed.split(/\s+/g)[0]?.trim();
518 if (!firstToken) return null;
519 return firstToken.replace(/^['"]|['"]$/g, "");
520}
521
522function basenameForPlatform(command: string, platform: NodeJS.Platform): string {
523 const normalized =
524 platform === "win32" ? command.replaceAll("/", "\\") : command.replaceAll("\\", "/");
525 const parts = normalized
526 .split(platform === "win32" ? /\\+/ : /\/+/)
527 .filter((part) => part.length > 0);
528 return parts.at(-1) ?? normalized;
529}
530
531function joinWindowsPath(...parts: ReadonlyArray<string>): string {
532 return parts
533 .map((part, index) => {
534 if (index === 0) return part.replace(/[\\/]+$/g, "");
535 return part.replace(/^[\\/]+|[\\/]+$/g, "");
536 })
537 .filter((part) => part.length > 0)
538 .join("\\");
539}
540
541function shellCandidateFromCommand(
542 command: string | null,
543 platform: NodeJS.Platform,
544): ShellCandidate | null {
545 if (!command || command.length === 0) return null;
546 const shellName = basenameForPlatform(command, platform).toLowerCase();
547 if (platform === "win32" && (shellName === "pwsh.exe" || shellName === "powershell.exe")) {
548 return { shell: command, args: ["-NoLogo"] };
549 }
550 if (platform !== "win32" && shellName === "zsh") {
551 return { shell: command, args: ["-o", "nopromptsp"] };
552 }
553 return { shell: command };
554}
555
556function windowsSystemRoot(env: NodeJS.ProcessEnv): string {
557 return env.SystemRoot?.trim() || env.windir?.trim() || "C:\\Windows";
558}
559
560function windowsPowerShellPath(env: NodeJS.ProcessEnv): string {
561 return joinWindowsPath(
562 windowsSystemRoot(env),
563 "System32",
564 "WindowsPowerShell",
565 "v1.0",
566 "powershell.exe",
567 );
568}
569
570function windowsCmdPath(env: NodeJS.ProcessEnv): string {
571 return joinWindowsPath(windowsSystemRoot(env), "System32", "cmd.exe");
572}
573
574function formatShellCandidate(candidate: ShellCandidate): string {
575 if (!candidate.args || candidate.args.length === 0) return candidate.shell;
576 return `${candidate.shell} ${candidate.args.join(" ")}`;
577}
578
579function uniqueShellCandidates(candidates: Array<ShellCandidate | null>): ShellCandidate[] {
580 const seen = new Set<string>();
581 const ordered: ShellCandidate[] = [];
582 for (const candidate of candidates) {
583 if (!candidate) continue;
584 const key = formatShellCandidate(candidate);
585 if (seen.has(key)) continue;
586 seen.add(key);
587 ordered.push(candidate);
588 }
589 return ordered;
590}
591
592function resolveShellCandidates(
593 shellResolver: () => string,
594 platform: NodeJS.Platform,
595 env: NodeJS.ProcessEnv,
596): ShellCandidate[] {
597 const requested = shellCandidateFromCommand(
598 normalizeShellCommand(shellResolver(), platform),
599 platform,
600 );
601
602 if (platform === "win32") {
603 return uniqueShellCandidates([
604 requested,
605 shellCandidateFromCommand("pwsh.exe", platform),
606 shellCandidateFromCommand(windowsPowerShellPath(env), platform),
607 shellCandidateFromCommand("powershell.exe", platform),
608 shellCandidateFromCommand(env.ComSpec ?? null, platform),
609 shellCandidateFromCommand(windowsCmdPath(env), platform),
610 shellCandidateFromCommand("cmd.exe", platform),
611 ]);
612 }
613
614 return uniqueShellCandidates([
615 requested,
616 shellCandidateFromCommand(normalizeShellCommand(env.SHELL, platform), platform),
617 shellCandidateFromCommand("/bin/zsh", platform),
618 shellCandidateFromCommand("/bin/bash", platform),
619 shellCandidateFromCommand("/bin/sh", platform),
620 shellCandidateFromCommand("zsh", platform),
621 shellCandidateFromCommand("bash", platform),
622 shellCandidateFromCommand("sh", platform),
623 ]);
624}
625
626function isRetryableShellSpawnError(error: PtyAdapter.PtySpawnError): boolean {
627 const queue: unknown[] = [error];
628 const seen = new Set<unknown>();
629 const messages: string[] = [];
630
631 while (queue.length > 0) {
632 const current = queue.shift();
633 if (!current || seen.has(current)) {
634 continue;
635 }
636 seen.add(current);
637
638 if (typeof current === "string") {
639 messages.push(current);
640 continue;
641 }
642
643 if (current instanceof Error) {
644 messages.push(current.message);
645 if (current.cause) {
646 queue.push(current.cause);
647 }
648 continue;
649 }
650
651 if (typeof current === "object") {
652 const value = current as { message?: unknown; cause?: unknown };
653 if (typeof value.message === "string") {
654 messages.push(value.message);
655 }
656 if (value.cause) {
657 queue.push(value.cause);
658 }
659 }
660 }
661
662 const message = messages.join(" ").toLowerCase();
663 return (
664 message.includes("posix_spawnp failed") ||
665 message.includes("enoent") ||
666 message.includes("not found") ||
667 message.includes("file not found") ||
668 message.includes("no such file")
669 );
670}
671
672interface TerminalProcessTableSnapshot {
673 readonly childrenByParent: ReadonlyMap<number, ReadonlyArray<number>>;
674 readonly commandById: ReadonlyMap<number, string>;
675}
676
677export function subprocessSnapshotPollDelayMs(
678 pollIntervalMs: number,
679 failureCount: number,
680): number {
681 return Math.min(pollIntervalMs * 2 ** failureCount, MAX_SUBPROCESS_POLL_INTERVAL_MS);
682}
683
684function parsePosixProcessTable(stdout: string): TerminalProcessTableSnapshot {
685 const childrenByParent = new Map<number, number[]>();
686 const commandById = new Map<number, string>();
687 for (const line of stdout.split(/\r?\n/g)) {
688 // `comm=` is the final column and may itself contain spaces, so only the
689 // first two tokens are structural.
690 const match = /^\s*(\d+)\s+(\d+)\s+(.+)$/.exec(line);
691 if (!match) continue;
692 const pid = Number(match[1]);
693 const ppid = Number(match[2]);
694 if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue;
695 commandById.set(pid, (match[3] ?? "").trim());
696 const children = childrenByParent.get(ppid) ?? [];
697 children.push(pid);
698 childrenByParent.set(ppid, children);
699 }
700 return { childrenByParent, commandById };
701}
702
703function processTableSnapshotFromProcesses(
704 processes: ReadonlyArray<ResourceMonitorProcessTableEntry>,
705): TerminalProcessTableSnapshot {
706 const childrenByParent = new Map<number, number[]>();
707 const commandById = new Map<number, string>();
708 for (const process of processes) {
709 const { pid, ppid: parentPid, name } = process;
710 if (!Number.isInteger(pid) || !Number.isInteger(parentPid)) continue;
711 commandById.set(pid, name.trim());
712 const children = childrenByParent.get(parentPid) ?? [];
713 children.push(pid);
714 childrenByParent.set(parentPid, children);
715 }
716 return { childrenByParent, commandById };
717}
718
719function deriveSubprocessInspectResult(
720 snapshot: TerminalProcessTableSnapshot,
721 terminalPid: number,
722 platform: NodeJS.Platform,
723): TerminalSubprocessInspectResult {
724 const commandName = (pid: number) =>
725 normalizeChildCommandName(snapshot.commandById.get(pid) ?? "", platform);
726 const shellName = commandName(terminalPid);
727 // Async prompt themes fork the shell into a helper that waits with no
728 // children of its own. That copy is not a command the user started.
729 const childPid = (snapshot.childrenByParent.get(terminalPid) ?? []).find(
730 (pid) =>
731 shellName === null ||
732 commandName(pid) !== shellName ||
733 (snapshot.childrenByParent.get(pid)?.length ?? 0) > 0,
734 );
735 if (childPid === undefined) {
736 return { hasRunningSubprocess: false, childCommand: null, processIds: [] };
737 }
738 const processIds = new Set<number>([terminalPid]);
739 const pending = [terminalPid];
740 while (pending.length > 0) {
741 const parentPid = pending.pop();
742 if (parentPid === undefined) continue;
743 for (const pid of snapshot.childrenByParent.get(parentPid) ?? []) {
744 if (processIds.has(pid)) continue;
745 processIds.add(pid);
746 pending.push(pid);
747 }
748 }
749 const normalized = commandName(childPid);
750 return {
751 hasRunningSubprocess: true,
752 childCommand: normalized ? truncateTerminalWireLabel(normalized) : null,
753 processIds: [...processIds],
754 };
755}
756
757const POSIX_PS_ABSOLUTE_PATHS = ["/bin/ps", "/usr/bin/ps"] as const;
758
759// Resolve `ps` to an absolute path once at startup. Spawning by bare name
760// walks every PATH entry per spawn (one failed posix_spawn per directory
761// until the hit), which is measurable at a 1s poll cadence on long PATHs.
762const resolvePosixPsCommand = Effect.fn("terminal.resolvePosixPsCommand")(function* () {
763 const fileSystem = yield* FileSystem.FileSystem;
764 for (const candidate of POSIX_PS_ABSOLUTE_PATHS) {
765 const exists = yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false));
766 if (exists) return candidate;
767 }
768 return "ps";
769});
770
771const posixProcessTableSnapshot = Effect.fn("terminal.posixProcessTableSnapshot")(function* (
772 psCommand: string,
773): Effect.fn.Return<
774 TerminalProcessTableSnapshot,
775 TerminalSubprocessCheckError,
776 ProcessRunner.ProcessRunner
777> {
778 const processRunner = yield* ProcessRunner.ProcessRunner;
779 const result = yield* processRunner
780 .run({
781 command: psCommand,
782 args: ["-eo", "pid=,ppid=,comm="],
783 timeout: "1 second",
784 maxOutputBytes: 524_288,
785 outputMode: "truncate",
786 timeoutBehavior: "timedOutResult",
787 })
788 .pipe(
789 Effect.mapError(
790 (cause) =>
791 new TerminalSubprocessCheckError({
792 cause,
793 command: "ps",
794 }),
795 ),
796 );
797 if (result.code !== 0 || result.timedOut || result.stdoutTruncated) {
798 // Not authoritative: an empty or partial table would mark every terminal
799 // idle and clear its registered process ids. Failing skips the tick.
800 return yield* new TerminalSubprocessCheckError({
801 command: "ps",
802 exitCode: result.code,
803 timedOut: result.timedOut,
804 stdoutTruncated: result.stdoutTruncated,
805 });
806 }
807 return parsePosixProcessTable(result.stdout);
808});
809
810const windowsProcessTableSnapshot = Effect.fn("terminal.windowsProcessTableSnapshot")(
811 function* (): Effect.fn.Return<
812 TerminalProcessTableSnapshot,
813 TerminalSubprocessCheckError,
814 ProcessRunner.ProcessRunner
815 > {
816 const processRunner = yield* ProcessRunner.ProcessRunner;
817 const command =
818 'Get-CimInstance Win32_Process -ErrorAction Stop | ForEach-Object { Write-Output "$($_.ProcessId)|$($_.ParentProcessId)|$($_.Name)" }';
819 const result = yield* processRunner
820 .run({
821 command: "powershell.exe",
822 args: ["-NoProfile", "-NonInteractive", "-Command", command],
823 timeout: "1500 millis",
824 maxOutputBytes: 262_144,
825 outputMode: "truncate",
826 timeoutBehavior: "timedOutResult",
827 })
828 .pipe(
829 Effect.mapError(
830 (cause) => new TerminalSubprocessCheckError({ cause, command: "powershell" }),
831 ),
832 );
833 if (result.code !== 0 || result.timedOut || result.stdoutTruncated) {
834 return yield* new TerminalSubprocessCheckError({
835 command: "powershell",
836 exitCode: result.code,
837 timedOut: result.timedOut,
838 stdoutTruncated: result.stdoutTruncated,
839 });
840 }
841 const processes = result.stdout.split(/\r?\n/g).flatMap((line) => {
842 const [pidRaw, ppidRaw, name = ""] = line.trim().split("|", 3);
843 const pid = Number(pidRaw);
844 const ppid = Number(ppidRaw);
845 return Number.isInteger(pid) && pid > 0 && Number.isInteger(ppid)
846 ? [{ pid, ppid, name }]
847 : [];
848 });
849 return processTableSnapshotFromProcesses(processes);
850 },
851);
852
853interface TerminalHistoryChunk {
854 data: string;
855 byteLength: number;
856 lineBreaks: number;
857}
858
859export class BoundedTerminalHistory {
860 private readonly maxLines: number;
861 private readonly maxBytes: number;
862 private chunks: Array<TerminalHistoryChunk | undefined> = [];
863 private start = 0;
864 private byteLength = 0;
865 private lineBreaks = 0;
866 // Reading the old string's tail on each append can force chunk concatenation.
867 private lastCodeUnit: number | undefined;
868 private cachedValue: string | null = "";
869
870 constructor(maxLines: number, initial: string, maxBytes = DEFAULT_HISTORY_BYTE_LIMIT) {
871 this.maxLines = maxLines;
872 this.maxBytes = maxBytes;
873 this.append(initial);
874 }
875
876 append(text: string): void {
877 if (text.length === 0) return;
878 this.cachedValue = null;
879 if (this.maxBytes <= 0 || this.maxLines <= 0) {
880 this.clear();
881 // Preserve the existing zero-line limit's trailing newline behavior.
882 if (this.maxBytes > 0 && text.endsWith("\n")) this.appendChunk("\n");
883 return;
884 }
885
886 let offset = 0;
887 const previous = this.chunks.at(-1);
888 const lastCode = this.lastCodeUnit;
889 const firstCode = text.charCodeAt(0);
890 if (
891 previous &&
892 lastCode !== undefined &&
893 lastCode >= 0xd800 &&
894 lastCode <= 0xdbff &&
895 firstCode >= 0xdc00 &&
896 firstCode <= 0xdfff
897 ) {
898 // Joining a split surrogate changes its UTF-8 size from 3 to 4 bytes.
899 previous.data += text[0];
900 previous.byteLength += 1;
901 this.byteLength += 1;
902 this.lastCodeUnit = firstCode;
903 offset = 1;
904 this.trim();
905 }
906
907 while (offset < text.length) {
908 let end = Math.min(offset + MAX_HISTORY_CHUNK_LENGTH, text.length);
909 const before = text.charCodeAt(end - 1);
910 const after = text.charCodeAt(end);
911 if (before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff) {
912 end -= 1;
913 }
914 const data = text.slice(offset, end);
915 // Detach small chunks from large input strings so evicted prefixes can be collected.
916 this.appendChunk(
917 text.length > MAX_HISTORY_CHUNK_LENGTH
918 ? Buffer.from(data, "utf16le").toString("utf16le")
919 : data,
920 );
921 this.trim();
922 offset = end;
923 }
924 }
925
926 private appendChunk(data: string): void {
927 const byteLength = Buffer.byteLength(data);
928 let lineBreaks = 0;
929 for (let index = data.indexOf("\n"); index !== -1; index = data.indexOf("\n", index + 1)) {
930 lineBreaks += 1;
931 }
932 const previous = this.chunks.at(-1);
933 if (previous && previous.data.length + data.length <= MAX_HISTORY_CHUNK_LENGTH) {
934 previous.data += data;
935 previous.byteLength += byteLength;
936 previous.lineBreaks += lineBreaks;
937 } else {
938 this.chunks.push({ data, byteLength, lineBreaks });
939 }
940 this.byteLength += byteLength;
941 this.lineBreaks += lineBreaks;
942 this.lastCodeUnit = data.charCodeAt(data.length - 1);
943 this.cachedValue = null;
944 }
945
946 private discardChunk(): void {
947 const first = this.chunks[this.start]!;
948 this.byteLength -= first.byteLength;
949 this.lineBreaks -= first.lineBreaks;
950 this.chunks[this.start++] = undefined;
951 }
952
953 private trimChunk(offset: number, byteLength: number, lineBreaks: number): void {
954 const first = this.chunks[this.start]!;
955 if (offset === first.data.length) {
956 this.discardChunk();
957 return;
958 }
959 first.data = first.data.slice(offset);
960 first.byteLength -= byteLength;
961 first.lineBreaks -= lineBreaks;
962 this.byteLength -= byteLength;
963 this.lineBreaks -= lineBreaks;
964 }
965
966 private trim(): void {
967 const trailingNewline = this.lastCodeUnit === 10;
968 let linesToDrop = this.lineBreaks + (trailingNewline ? 0 : 1) - this.maxLines;
969 while (linesToDrop > 0) {
970 const first = this.chunks[this.start]!;
971 if (first.lineBreaks < linesToDrop) {
972 linesToDrop -= first.lineBreaks;
973 this.discardChunk();
974 continue;
975 }
976 let offset = 0;
977 for (let line = 0; line < linesToDrop; line += 1) {
978 offset = first.data.indexOf("\n", offset) + 1;
979 }
980 this.trimChunk(offset, Buffer.byteLength(first.data.slice(0, offset)), linesToDrop);
981 linesToDrop = 0;
982 }
983
984 while (this.byteLength > this.maxBytes) {
985 const first = this.chunks[this.start]!;
986 const bytesToDrop = this.byteLength - this.maxBytes;
987 if (first.byteLength <= bytesToDrop) {
988 this.discardChunk();
989 continue;
990 }
991 if (first.byteLength === first.data.length && first.lineBreaks === 0) {
992 // ASCII without newlines needs no scan to find the byte cutoff.
993 this.trimChunk(bytesToDrop, bytesToDrop, 0);
994 continue;
995 }
996 let offset = 0;
997 let bytes = 0;
998 let lineBreaks = 0;
999 // Scan only the discarded prefix of one small chunk, never all history.
1000 while (bytes < bytesToDrop) {
1001 const codePoint = first.data.codePointAt(offset)!;
1002 bytes += codePoint <= 0x7f ? 1 : codePoint <= 0x7ff ? 2 : codePoint <= 0xffff ? 3 : 4;
1003 offset += codePoint <= 0xffff ? 1 : 2;
1004 if (codePoint === 10) lineBreaks += 1;
1005 }
1006 this.trimChunk(offset, bytes, lineBreaks);
1007 }
1008 if (
1009 this.start === this.chunks.length ||
1010 (this.start > 2_048 && this.start * 2 >= this.chunks.length)
1011 ) {
1012 this.chunks = this.chunks.slice(this.start);
1013 this.start = 0;
1014 if (this.chunks.length === 0) this.lastCodeUnit = undefined;
1015 }
1016 }
1017
1018 clear(): void {
1019 this.chunks = [];
1020 this.start = 0;
1021 this.byteLength = 0;
1022 this.lineBreaks = 0;
1023 this.lastCodeUnit = undefined;
1024 this.cachedValue = "";
1025 }
1026
1027 value(): string {
1028 if (this.cachedValue !== null) return this.cachedValue;
1029 this.cachedValue = this.chunks
1030 .slice(this.start)
1031 .map((chunk) => chunk!.data)
1032 .join("");
1033 return this.cachedValue;
1034 }
1035}
1036
1037function isCsiFinalByte(codePoint: number): boolean {
1038 return codePoint >= 0x40 && codePoint <= 0x7e;
1039}
1040
1041function shouldStripCsiSequence(body: string, finalByte: string): boolean {
1042 if (finalByte === "n") {
1043 return true;
1044 }
1045 if (finalByte === "R" && /^[0-9;?]*$/.test(body)) {
1046 return true;
1047 }
1048 if (finalByte === "c" && /^[>0-9;?]*$/.test(body)) {
1049 return true;
1050 }
1051 // DECRQM mode queries (…$p) and DECRPM replies (…$y): replaying a stored
1052 // query makes the terminal answer again, and the shell echoes the answer as
1053 // junk at the prompt. The `$` guard keeps setters like DECSTR (!p) and
1054 // DECSCL ("p) intact.
1055 if ((finalByte === "p" || finalByte === "y") && /^[0-9;?]*\$$/.test(body)) {
1056 return true;
1057 }
1058 // XTVERSION query (>q). DECSCUSR (space-intermediate q) stays.
1059 if (finalByte === "q" && /^>[0-9;]*$/.test(body)) {
1060 return true;
1061 }
1062 // Kitty keyboard protocol query/reply (?u). Restore-cursor (bare u) stays.
1063 if (finalByte === "u" && body.startsWith("?")) {
1064 return true;
1065 }
1066 return false;
1067}
1068
1069// DECRQSS ($q) and XTGETTCAP (+q) queries plus their replies ([01]$r / [01]+r):
1070// pure request/response traffic with no visual value, and replaying a stored
1071// query triggers a fresh reply.
1072function shouldStripDcsSequence(content: string): boolean {
1073 return /^[01]?[$+][qr]/.test(content);
1074}
1075
1076function shouldStripOscSequence(content: string): boolean {
1077 return /^(10|11|12);(?:\?|rgb:)/.test(content);
1078}
1079
1080function stripStringTerminator(value: string): string {
1081 if (value.endsWith("\u001b\\")) {
1082 return value.slice(0, -2);
1083 }
1084 const lastCharacter = value.at(-1);
1085 if (lastCharacter === "\u0007" || lastCharacter === "\u009c") {
1086 return value.slice(0, -1);
1087 }
1088 return value;
1089}
1090
1091function findStringTerminatorIndex(input: string, start: number): number | null {
1092 for (let index = start; index < input.length; index += 1) {
1093 const codePoint = input.charCodeAt(index);
1094 if (codePoint === 0x07 || codePoint === 0x9c) {
1095 return index + 1;
1096 }
1097 if (codePoint === 0x1b && input.charCodeAt(index + 1) === 0x5c) {
1098 return index + 2;
1099 }
1100 }
1101 return null;
1102}
1103
1104function isEscapeIntermediateByte(codePoint: number): boolean {
1105 return codePoint >= 0x20 && codePoint <= 0x2f;
1106}
1107
1108function isEscapeFinalByte(codePoint: number): boolean {
1109 return codePoint >= 0x30 && codePoint <= 0x7e;
1110}
1111
1112function findEscapeSequenceEndIndex(input: string, start: number): number | null {
1113 let cursor = start;
1114 while (cursor < input.length && isEscapeIntermediateByte(input.charCodeAt(cursor))) {
1115 cursor += 1;
1116 }
1117 if (cursor >= input.length) {
1118 return null;
1119 }
1120 return isEscapeFinalByte(input.charCodeAt(cursor)) ? cursor + 1 : start + 1;
1121}
1122
1123// eslint-disable-next-line no-control-regex -- Terminal sequence introducers must reach the parser.
1124const HISTORY_SEQUENCE_START = /[\u001b\u0090\u009b\u009d\u009e\u009f]/;
1125
1126function sanitizeTerminalHistoryChunk(
1127 pendingControlSequence: string,
1128 data: string,
1129): { visibleText: string; pendingControlSequence: string } {
1130 // Plain output has nothing to filter. A pending sequence still needs the parser
1131 // even when this chunk only contains its final bytes.
1132 if (pendingControlSequence.length === 0 && !HISTORY_SEQUENCE_START.test(data)) {
1133 return { visibleText: data, pendingControlSequence: "" };
1134 }
1135
1136 const input = `${pendingControlSequence}${data}`;
1137 let visibleText = "";
1138 let index = 0;
1139
1140 const append = (value: string) => {
1141 visibleText += value;
1142 };
1143
1144 while (index < input.length) {
1145 const codePoint = input.charCodeAt(index);
1146
1147 if (codePoint === 0x1b) {
1148 const nextCodePoint = input.charCodeAt(index + 1);
1149 if (Number.isNaN(nextCodePoint)) {
1150 return { visibleText, pendingControlSequence: input.slice(index) };
1151 }
1152
1153 if (nextCodePoint === 0x5b) {
1154 let cursor = index + 2;
1155 while (cursor < input.length) {
1156 if (isCsiFinalByte(input.charCodeAt(cursor))) {
1157 const sequence = input.slice(index, cursor + 1);
1158 const body = input.slice(index + 2, cursor);
1159 if (!shouldStripCsiSequence(body, input[cursor] ?? "")) {
1160 append(sequence);
1161 }
1162 index = cursor + 1;
1163 break;
1164 }
1165 cursor += 1;
1166 }
1167 if (cursor >= input.length) {
1168 return { visibleText, pendingControlSequence: input.slice(index) };
1169 }
1170 continue;
1171 }
1172
1173 if (
1174 nextCodePoint === 0x5d ||
1175 nextCodePoint === 0x50 ||
1176 nextCodePoint === 0x5e ||
1177 nextCodePoint === 0x5f
1178 ) {
1179 const terminatorIndex = findStringTerminatorIndex(input, index + 2);
1180 if (terminatorIndex === null) {
1181 return { visibleText, pendingControlSequence: input.slice(index) };
1182 }
1183 const sequence = input.slice(index, terminatorIndex);
1184 const content = stripStringTerminator(input.slice(index + 2, terminatorIndex));
1185 const strip =
1186 (nextCodePoint === 0x5d && shouldStripOscSequence(content)) ||
1187 (nextCodePoint === 0x50 && shouldStripDcsSequence(content));
1188 if (!strip) {
1189 append(sequence);
1190 }
1191 index = terminatorIndex;
1192 continue;
1193 }
1194
1195 const escapeSequenceEndIndex = findEscapeSequenceEndIndex(input, index + 1);
1196 if (escapeSequenceEndIndex === null) {
1197 return { visibleText, pendingControlSequence: input.slice(index) };
1198 }
1199 append(input.slice(index, escapeSequenceEndIndex));
1200 index = escapeSequenceEndIndex;
1201 continue;
1202 }
1203
1204 if (codePoint === 0x9b) {
1205 let cursor = index + 1;
1206 while (cursor < input.length) {
1207 if (isCsiFinalByte(input.charCodeAt(cursor))) {
1208 const sequence = input.slice(index, cursor + 1);
1209 const body = input.slice(index + 1, cursor);
1210 if (!shouldStripCsiSequence(body, input[cursor] ?? "")) {
1211 append(sequence);
1212 }
1213 index = cursor + 1;
1214 break;
1215 }
1216 cursor += 1;
1217 }
1218 if (cursor >= input.length) {
1219 return { visibleText, pendingControlSequence: input.slice(index) };
1220 }
1221 continue;
1222 }
1223
1224 if (codePoint === 0x9d || codePoint === 0x90 || codePoint === 0x9e || codePoint === 0x9f) {
1225 const terminatorIndex = findStringTerminatorIndex(input, index + 1);
1226 if (terminatorIndex === null) {
1227 return { visibleText, pendingControlSequence: input.slice(index) };
1228 }
1229 const sequence = input.slice(index, terminatorIndex);
1230 const content = stripStringTerminator(input.slice(index + 1, terminatorIndex));
1231 const strip =
1232 (codePoint === 0x9d && shouldStripOscSequence(content)) ||
1233 (codePoint === 0x90 && shouldStripDcsSequence(content));
1234 if (!strip) {
1235 append(sequence);
1236 }
1237 index = terminatorIndex;
1238 continue;
1239 }
1240
1241 append(input[index] ?? "");
1242 index += 1;
1243 }
1244
1245 return { visibleText, pendingControlSequence: "" };
1246}
1247
1248function legacySafeThreadId(threadId: string): string {
1249 return threadId.replace(/[^a-zA-Z0-9._-]/g, "_");
1250}
1251
1252function toSafeThreadId(threadId: string): string {
1253 return `terminal_${Base64Url.encode(threadId)}`;
1254}
1255
1256function toSafeTerminalId(terminalId: string): string {
1257 return Base64Url.encode(terminalId);
1258}
1259
1260function toSessionKey(threadId: string, terminalId: string): string {
1261 return `${threadId}\u0000${terminalId}`;
1262}
1263
1264function shouldExcludeTerminalEnvKey(key: string): boolean {
1265 const normalizedKey = key.toUpperCase();
1266 if (normalizedKey.startsWith("T3CODE_")) {
1267 return true;
1268 }
1269 if (normalizedKey.startsWith("VITE_")) {
1270 return true;
1271 }
1272 return TERMINAL_ENV_BLOCKLIST.has(normalizedKey);
1273}
1274
1275// Marker variables the AppImage runtime injects into the process it launches.
1276// They describe the AppImage itself, not the user's session, so terminals must
1277// not inherit them.
1278const APPIMAGE_RUNTIME_ENV_KEYS = ["APPIMAGE", "APPDIR", "ARGV0", "OWD"] as const;
1279// Colon-separated search-path variables the AppImage runtime points at its
1280// temporary mount (e.g. /tmp/.mount_T3-XXXX/usr/bin, the bundled glib schemas,
1281// and an $APPDIR/usr/share XDG data entry). Only the mount segments are
1282// dropped; the user's real entries are preserved. When nothing but mount
1283// segments remain the variable is removed entirely so consumers fall back to
1284// their platform default (e.g. gsettings finds the host schemas instead of
1285// reporting "No schemas installed"). See issues #1699 and #5059.
1286const APPIMAGE_PATH_LIKE_ENV_KEYS = [
1287 "PATH",
1288 "LD_LIBRARY_PATH",
1289 "XDG_DATA_DIRS",
1290 "GSETTINGS_SCHEMA_DIR",
1291] as const;
1292
1293function isPathSegmentUnderAppDir(segment: string, appDir: string): boolean {
1294 return segment === appDir || segment.startsWith(`${appDir}/`);
1295}
1296
1297// On Linux AppImage builds the runtime mounts the app under a temporary dir and
1298// injects APPIMAGE/APPDIR/ARGV0/OWD plus mount entries on PATH/LD_LIBRARY_PATH.
1299// The integrated terminal inherits the server process environment, so without
1300// this scrub those leak into the PTY and tools resolve against the AppImage
1301// mount instead of the user's real environment (e.g. `php` reporting
1302// PHP_BINARY as the AppImage path). See issue #1699. The scrub is gated on an
1303// actual AppImage launch so non-AppImage environments are left untouched.
1304function stripAppImageRuntimeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
1305 if (env.APPIMAGE === undefined && env.APPDIR === undefined) return env;
1306
1307 const scrubbed: NodeJS.ProcessEnv = { ...env };
1308 for (const key of APPIMAGE_RUNTIME_ENV_KEYS) {
1309 delete scrubbed[key];
1310 }
1311
1312 const appDir = env.APPDIR?.replace(/\/+$/, "");
1313 if (appDir) {
1314 for (const key of APPIMAGE_PATH_LIKE_ENV_KEYS) {
1315 const value = scrubbed[key];
1316 if (value === undefined) continue;
1317 const kept = value
1318 .split(":")
1319 .filter((segment) => segment.length > 0 && !isPathSegmentUnderAppDir(segment, appDir));
1320 if (kept.length > 0) {
1321 scrubbed[key] = kept.join(":");
1322 } else {
1323 delete scrubbed[key];
1324 }
1325 }
1326 }
1327
1328 return scrubbed;
1329}
1330
1331function createTerminalSpawnEnv(
1332 baseEnv: NodeJS.ProcessEnv,
1333 runtimeEnv: Record<string, string> | null | undefined,
1334 platform: NodeJS.Platform,
1335 home: string,
1336): NodeJS.ProcessEnv {
1337 const spawnEnv: NodeJS.ProcessEnv = {};
1338 for (const [key, value] of Object.entries(baseEnv)) {
1339 if (value === undefined) continue;
1340 if (shouldExcludeTerminalEnvKey(key)) continue;
1341 spawnEnv[key] = value;
1342 }
1343 if (runtimeEnv) {
1344 for (const [key, value] of Object.entries(runtimeEnv)) {
1345 const existingKey =
1346 platform === "win32"
1347 ? Object.keys(spawnEnv).find((candidate) => candidate.toLowerCase() === key.toLowerCase())
1348 : undefined;
1349 spawnEnv[existingKey ?? key] =
1350 key === "CODEX_HOME" || key === "CLAUDE_CONFIG_DIR" ? expandHomePath(value, home) : value;
1351 }
1352 }
1353 // An explicit empty override opts out for terminals started without a client.
1354 // Otherwise both PTY backends feed truecolor-capable terminal clients.
1355 if (!spawnEnv.COLORTERM && runtimeEnv?.COLORTERM === undefined) {
1356 spawnEnv.COLORTERM = "truecolor";
1357 }
1358 return stripAppImageRuntimeEnv(spawnEnv);
1359}
1360
1361function normalizedRuntimeEnv(
1362 env: Record<string, string> | undefined,
1363): Record<string, string> | null {
1364 if (!env) return null;
1365 const entries = Object.entries(env);
1366 if (entries.length === 0) return null;
1367 return Object.fromEntries(entries.toSorted(([left], [right]) => left.localeCompare(right)));
1368}
1369
1370interface TerminalManagerOptions {
1371 logsDir: string;
1372 historyLineLimit?: number;
1373 historyByteLimit?: number;
1374 ptyAdapter: PtyAdapter.PtyAdapter["Service"];
1375 shellResolver?: () => string;
1376 env?: NodeJS.ProcessEnv;
1377 /**
1378 * Catalog cache and tool directories for managed ACP Registry installs. Their
1379 * install directories are appended to the terminal PATH so users can run
1380 * managed agents by name (e.g. `kimi login`).
1381 */
1382 managedBinaryCacheDir?: string;
1383 managedBinaryToolsDir?: string;
1384 subprocessInspector?: TerminalSubprocessInspector;
1385 processTable?: Effect.Effect<
1386 ReadonlyArray<ResourceMonitorProcessTableEntry>,
1387 TerminalSubprocessCheckError
1388 >;
1389 subprocessPollIntervalMs?: number;
1390 processKillGraceMs?: number;
1391 maxRetainedInactiveSessions?: number;
1392 registerTerminalProcesses?: (input: {
1393 readonly threadId: string;
1394 readonly terminalId: string;
1395 readonly processIds: ReadonlyArray<number>;
1396 }) => Effect.Effect<void>;
1397 unregisterTerminal?: (input: {
1398 readonly threadId: string;
1399 readonly terminalId: string;
1400 }) => Effect.Effect<void>;
1401 resolveProviderInstanceEnvironment?: (
1402 providerInstanceId: string,
1403 env: Record<string, string> | undefined,
1404 ) => Effect.Effect<
1405 Record<string, string>,
1406 TerminalProviderInstanceNotFoundError | TerminalProviderEnvironmentError
1407 >;
1408}
1409
1410export const resolveProviderInstanceTerminalEnvironment = Effect.fn(
1411 "terminal.resolveProviderInstanceTerminalEnvironment",
1412)(function* (input: {
1413 readonly serverSettings: ServerSettings.ServerSettingsService["Service"];
1414 readonly path: Path.Path;
1415 readonly rawProviderInstanceId: string;
1416 readonly env: Record<string, string> | undefined;
1417}) {
1418 const providerInstanceId = ProviderInstanceId.make(input.rawProviderInstanceId);
1419 const settings = yield* input.serverSettings.getSettings.pipe(
1420 Effect.mapError((cause) => new TerminalProviderEnvironmentError({ providerInstanceId, cause })),
1421 );
1422 const instance = deriveProviderInstanceConfigMap(settings)[providerInstanceId];
1423 if (instance === undefined) {
1424 return yield* new TerminalProviderInstanceNotFoundError({ providerInstanceId });
1425 }
1426
1427 let resolved = yield* mergeProviderInstanceEnvironment(instance.environment, input.env ?? {});
1428 if (instance.driver === "codex") {
1429 const config = decodeCodexSettings(instance.config ?? {});
1430 if (Option.isSome(config)) {
1431 const layout = yield* resolveCodexHomeLayout(config.value).pipe(
1432 Effect.provideService(Path.Path, input.path),
1433 );
1434 if (layout.effectiveHomePath)
1435 resolved = { ...resolved, CODEX_HOME: layout.effectiveHomePath };
1436 }
1437 } else if (instance.driver === "claudeAgent") {
1438 const config = decodeClaudeSettings(instance.config ?? {});
1439 if (Option.isSome(config)) {
1440 resolved = yield* makeClaudeEnvironment(config.value, resolved).pipe(
1441 Effect.provideService(Path.Path, input.path),
1442 );
1443 }
1444 }
1445
1446 return Object.fromEntries(
1447 Object.entries(resolved).filter((entry): entry is [string, string] => entry[1] !== undefined),
1448 );
1449});
1450
1451/** @public Service construction is part of the canonical Effect module API. */
1452export const make = Effect.fn("TerminalManager.make")(function* () {
1453 const { terminalLogsDir, providerStatusCacheDir, baseDir } = yield* ServerConfig.ServerConfig;
1454 const ptyAdapter = yield* PtyAdapter.PtyAdapter;
1455 const portDiscovery = yield* PortScanner.PortDiscovery;
1456 const nativeTelemetry = yield* NativeTelemetryClient.NativeTelemetryClient;
1457 const serverSettings = yield* ServerSettings.ServerSettingsService;
1458 const path = yield* Path.Path;
1459 const resolveProviderInstanceEnvironment = Effect.fn(
1460 "terminal.resolveProviderInstanceEnvironment",
1461 )((rawProviderInstanceId: string, env: Record<string, string> | undefined) =>
1462 resolveProviderInstanceTerminalEnvironment({
1463 serverSettings,
1464 path,
1465 rawProviderInstanceId,
1466 env,
1467 }),
1468 );
1469 return yield* makeWithOptions({
1470 logsDir: terminalLogsDir,
1471 ptyAdapter,
1472 processTable: nativeTelemetry.processTable.pipe(
1473 Effect.mapError(
1474 (cause) => new TerminalSubprocessCheckError({ cause, command: "resource-monitor" }),
1475 ),
1476 ),
1477 managedBinaryCacheDir: providerStatusCacheDir,
1478 managedBinaryToolsDir: path.join(baseDir, "tools"),
1479 registerTerminalProcesses: portDiscovery.registerTerminalProcesses,
1480 unregisterTerminal: portDiscovery.unregisterTerminal,
1481 resolveProviderInstanceEnvironment,
1482 });
1483});
1484
1485export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(function* (
1486 options: TerminalManagerOptions,
1487) {
1488 const fileSystem = yield* FileSystem.FileSystem;
1489 const path = yield* Path.Path;
1490 const context = yield* Effect.context<never>();
1491 const runFork = Effect.runForkWith(context);
1492
1493 const logsDir = options.logsDir;
1494 const historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT;
1495 const historyByteLimit = options.historyByteLimit ?? DEFAULT_HISTORY_BYTE_LIMIT;
1496 const platform = yield* HostProcess.Platform;
1497 const architecture = yield* HostProcess.Architecture;
1498 const agentScope = yield* AgentScope;
1499 const scriptProgress = yield* ScriptProgress.ScriptProgress;
1500 // Terminals must inherit the user's full environment (minus the blocklist
1501 // applied in createTerminalSpawnEnv) — an allowlist here silently strips
1502 // things like PSModulePath, DISPLAY, proxies, and toolchain variables.
1503 // `options.env` is the test seam.
1504 const baseEnv = options.env ?? process.env;
1505 const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv));
1506 const processRunner = yield* ProcessRunner.ProcessRunner;
1507 const resolveLaunchInputEnvironment = Effect.fn("terminal.resolveLaunchInputEnvironment")(
1508 function* <Input extends TerminalOpenInput | TerminalAttachInput | TerminalRestartInput>(
1509 input: Input,
1510 ): Effect.fn.Return<
1511 Input,
1512 TerminalProviderInstanceNotFoundError | TerminalProviderEnvironmentError
1513 > {
1514 if (input.providerInstanceId === undefined) return input;
1515 const resolver = options.resolveProviderInstanceEnvironment;
1516 if (resolver === undefined) {
1517 return yield* new TerminalProviderInstanceNotFoundError({
1518 providerInstanceId: ProviderInstanceId.make(input.providerInstanceId),
1519 });
1520 }
1521 const env = yield* resolver(input.providerInstanceId, input.env);
1522 return { ...input, env };
1523 },
1524 );
1525 // One process-table snapshot per poll tick, shared across every terminal.
1526 // Per-terminal `pgrep`/`ps` calls multiply spawn load by terminal count and
1527 // can exhaust the PID space on hosts with many sessions (#6332).
1528 const fallbackProcessTableSnapshot = (
1529 platform === "win32"
1530 ? windowsProcessTableSnapshot()
1531 : posixProcessTableSnapshot(yield* resolvePosixPsCommand())
1532 ).pipe(Effect.provideService(ProcessRunner.ProcessRunner, processRunner));
1533 const fetchProcessTableSnapshot: Effect.Effect<
1534 {
1535 readonly snapshot: TerminalProcessTableSnapshot;
1536 /**
1537 * False when the sidecar snapshot failed and this table came from the
1538 * spawned fallback. The data is still applied, but the tick counts as
1539 * a failure so polling backs off instead of hot-looping the fallback.
1540 */
1541 readonly snapshotSucceeded: boolean;
1542 },
1543 TerminalSubprocessCheckError
1544 > = options.processTable
1545 ? options.processTable.pipe(
1546 Effect.map((entries) => ({
1547 snapshot: processTableSnapshotFromProcesses(entries),
1548 snapshotSucceeded: true,
1549 })),
1550 Effect.catch(() =>
1551 fallbackProcessTableSnapshot.pipe(
1552 Effect.map((snapshot) => ({ snapshot, snapshotSucceeded: false })),
1553 ),
1554 ),
1555 )
1556 : fallbackProcessTableSnapshot.pipe(
1557 Effect.map((snapshot) => ({ snapshot, snapshotSucceeded: true })),
1558 );
1559 const customSubprocessInspector = options.subprocessInspector;
1560 const acquireSubprocessInspector: Effect.Effect<
1561 {
1562 readonly inspector: TerminalSubprocessInspector;
1563 readonly snapshotSucceeded: boolean;
1564 },
1565 TerminalSubprocessCheckError
1566 > =
1567 customSubprocessInspector !== undefined
1568 ? Effect.succeed({ inspector: customSubprocessInspector, snapshotSucceeded: true })
1569 : Effect.map(
1570 fetchProcessTableSnapshot,
1571 ({
1572 snapshot,
1573 snapshotSucceeded,
1574 }): {
1575 readonly inspector: TerminalSubprocessInspector;
1576 readonly snapshotSucceeded: boolean;
1577 } => ({
1578 inspector: (terminalPid) =>
1579 Effect.succeed(deriveSubprocessInspectResult(snapshot, terminalPid, platform)),
1580 snapshotSucceeded,
1581 }),
1582 );
1583 const subprocessPollIntervalMs =
1584 options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS;
1585 const processKillGraceMs = options.processKillGraceMs ?? DEFAULT_PROCESS_KILL_GRACE_MS;
1586 const maxRetainedInactiveSessions =
1587 options.maxRetainedInactiveSessions ?? DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS;
1588 const registerTerminalProcesses = options.registerTerminalProcesses ?? (() => Effect.void);
1589 const unregisterTerminal = options.unregisterTerminal ?? (() => Effect.void);
1590
1591 yield* fileSystem.makeDirectory(logsDir, { recursive: true }).pipe(Effect.orDie);
1592
1593 const managerStateRef = yield* SynchronizedRef.make<TerminalManagerState>({
1594 sessions: new Map(),
1595 killFibers: new Map(),
1596 });
1597 const threadLocks = yield* KeyedLock.make<string>();
1598 const terminalEventListeners = new Set<(event: TerminalEvent) => Effect.Effect<void>>();
1599 const workerScope = yield* Scope.make("sequential");
1600 yield* Effect.addFinalizer(() => Scope.close(workerScope, Exit.void));
1601
1602 const publishEvent = (event: TerminalEvent) =>
1603 Effect.gen(function* () {
1604 for (const listener of terminalEventListeners) {
1605 yield* listener(event).pipe(Effect.ignoreCause({ log: true }));
1606 }
1607 });
1608
1609 const historyPath = (threadId: string, terminalId: string) => {
1610 const threadPart = toSafeThreadId(threadId);
1611 if (terminalId === DEFAULT_TERMINAL_ID) {
1612 return path.join(logsDir, `${threadPart}.log`);
1613 }
1614 return path.join(logsDir, `${threadPart}_${toSafeTerminalId(terminalId)}.log`);
1615 };
1616
1617 const legacyHistoryPath = (threadId: string) =>
1618 path.join(logsDir, `${legacySafeThreadId(threadId)}.log`);
1619
1620 const readManagerState = SynchronizedRef.get(managerStateRef);
1621
1622 const modifyManagerState = <A>(
1623 f: (state: TerminalManagerState) => readonly [A, TerminalManagerState],
1624 ) => SynchronizedRef.modify(managerStateRef, f);
1625
1626 const withThreadLock = <A, E, R>(
1627 threadId: string,
1628 effect: Effect.Effect<A, E, R>,
1629 ): Effect.Effect<A, E, R> => threadLocks.withLock(threadId, effect);
1630
1631 const clearKillFiber = Effect.fn("terminal.clearKillFiber")(function* (
1632 process: PtyAdapter.PtyProcess | null,
1633 ) {
1634 if (!process) return;
1635 const fiber: Option.Option<Fiber.Fiber<void, never>> = yield* modifyManagerState<
1636 Option.Option<Fiber.Fiber<void, never>>
1637 >((state) => {
1638 const existing: Option.Option<Fiber.Fiber<void, never>> = Option.fromNullishOr(
1639 state.killFibers.get(process),
1640 );
1641 if (Option.isNone(existing)) {
1642 return [Option.none<Fiber.Fiber<void, never>>(), state] as const;
1643 }
1644 const killFibers = new Map(state.killFibers);
1645 killFibers.delete(process);
1646 return [existing, { ...state, killFibers }] as const;
1647 });
1648 if (Option.isSome(fiber)) {
1649 yield* Fiber.interrupt(fiber.value).pipe(Effect.ignore);
1650 }
1651 });
1652
1653 const registerKillFiber = Effect.fn("terminal.registerKillFiber")(function* (
1654 process: PtyAdapter.PtyProcess,
1655 fiber: Fiber.Fiber<void, never>,
1656 ) {
1657 yield* modifyManagerState((state) => {
1658 const killFibers = new Map(state.killFibers);
1659 killFibers.set(process, fiber);
1660 return [undefined, { ...state, killFibers }] as const;
1661 });
1662 });
1663
1664 const runKillEscalation = Effect.fn("terminal.runKillEscalation")(function* (
1665 process: PtyAdapter.PtyProcess,
1666 threadId: string,
1667 terminalId: string,
1668 ) {
1669 const terminated = yield* Effect.try({
1670 try: () => process.kill("SIGTERM"),
1671 catch: (cause) =>
1672 new TerminalProcessSignalError({
1673 cause,
1674 signal: "SIGTERM",
1675 terminalPid: process.pid,
1676 }),
1677 }).pipe(
1678 Effect.as(true),
1679 Effect.catch((error) =>
1680 Effect.logWarning("failed to kill terminal process", {
1681 threadId,
1682 terminalId,
1683 signal: "SIGTERM",
1684 cause: error,
1685 }).pipe(Effect.as(false)),
1686 ),
1687 );
1688 if (!terminated) {
1689 return;
1690 }
1691
1692 yield* Effect.sleep(processKillGraceMs);
1693
1694 yield* Effect.try({
1695 try: () => process.kill("SIGKILL"),
1696 catch: (cause) =>
1697 new TerminalProcessSignalError({
1698 cause,
1699 signal: "SIGKILL",
1700 terminalPid: process.pid,
1701 }),
1702 }).pipe(
1703 Effect.catch((error) =>
1704 Effect.logWarning("failed to force-kill terminal process", {
1705 threadId,
1706 terminalId,
1707 signal: "SIGKILL",
1708 cause: error,
1709 }),
1710 ),
1711 );
1712 });
1713
1714 const startKillEscalation = Effect.fn("terminal.startKillEscalation")(function* (
1715 process: PtyAdapter.PtyProcess,
1716 threadId: string,
1717 terminalId: string,
1718 ) {
1719 const fiber = yield* runKillEscalation(process, threadId, terminalId).pipe(
1720 Effect.ensuring(
1721 modifyManagerState((state) => {
1722 if (!state.killFibers.has(process)) {
1723 return [undefined, state] as const;
1724 }
1725 const killFibers = new Map(state.killFibers);
1726 killFibers.delete(process);
1727 return [undefined, { ...state, killFibers }] as const;
1728 }),
1729 ),
1730 Effect.forkIn(workerScope),
1731 );
1732
1733 yield* registerKillFiber(process, fiber);
1734 });
1735
1736 const persistWorker = yield* makeKeyedCoalescingWorker<
1737 string,
1738 PersistHistoryRequest,
1739 never,
1740 never
1741 >({
1742 merge: (current, next) => ({
1743 history: next.history,
1744 immediate: current.immediate || next.immediate,
1745 }),
1746 process: Effect.fn("terminal.persistHistoryWorker")(function* (sessionKey, request) {
1747 if (!request.immediate) {
1748 yield* Effect.sleep(DEFAULT_PERSIST_DEBOUNCE_MS);
1749 }
1750
1751 const [threadId, terminalId] = sessionKey.split("\u0000");
1752 if (!threadId || !terminalId) {
1753 return;
1754 }
1755
1756 yield* fileSystem
1757 .writeFileString(historyPath(threadId, terminalId), request.history.value())
1758 .pipe(
1759 Effect.catch((error) =>
1760 Effect.logWarning("failed to persist terminal history", {
1761 threadId,
1762 terminalId,
1763 error,
1764 }),
1765 ),
1766 );
1767 }),
1768 });
1769
1770 const queuePersist = Effect.fn("terminal.queuePersist")(function* (
1771 threadId: string,
1772 terminalId: string,
1773 history: BoundedTerminalHistory,
1774 ) {
1775 yield* persistWorker.enqueue(toSessionKey(threadId, terminalId), {
1776 history,
1777 immediate: false,
1778 });
1779 });
1780
1781 const flushPersist = Effect.fn("terminal.flushPersist")(function* (
1782 threadId: string,
1783 terminalId: string,
1784 ) {
1785 yield* persistWorker.drainKey(toSessionKey(threadId, terminalId));
1786 });
1787
1788 const persistHistory = Effect.fn("terminal.persistHistory")(function* (
1789 threadId: string,
1790 terminalId: string,
1791 history: BoundedTerminalHistory,
1792 ) {
1793 yield* persistWorker.enqueue(toSessionKey(threadId, terminalId), {
1794 history,
1795 immediate: true,
1796 });
1797 yield* flushPersist(threadId, terminalId);
1798 });
1799
1800 const readHistoryTail = Effect.fn("terminal.readHistoryTail")(function* (filePath: string) {
1801 const file = yield* fileSystem.open(filePath, { flag: "r" });
1802 const info = yield* file.stat;
1803 const limit = BigInt(historyByteLimit);
1804 const offset = info.size > limit ? info.size - limit : 0n;
1805 yield* file.seek(offset, "start");
1806 const bytes = new Uint8Array(Number(info.size - offset));
1807 let length = 0;
1808 while (length < bytes.length) {
1809 const read = Number(yield* file.read(bytes.subarray(length)));
1810 if (read === 0) break;
1811 length += read;
1812 }
1813 let start = 0;
1814 if (offset > 0n) {
1815 // A tail read can start inside a UTF-8 code point. Skip its remaining bytes.
1816 while (start < length && ((bytes[start] ?? 0) & 0xc0) === 0x80) start += 1;
1817 }
1818 return {
1819 history: new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes.subarray(start, length)),
1820 truncated: offset > 0n,
1821 };
1822 });
1823
1824 const readHistory = Effect.fn("terminal.readHistory")(function* (
1825 threadId: string,
1826 terminalId: string,
1827 ) {
1828 const nextPath = historyPath(threadId, terminalId);
1829 if (
1830 yield* fileSystem
1831 .exists(nextPath)
1832 .pipe(
1833 Effect.mapError(
1834 (cause) => new TerminalHistoryError({ operation: "read", threadId, terminalId, cause }),
1835 ),
1836 )
1837 ) {
1838 const { history: raw, truncated } = yield* readHistoryTail(nextPath).pipe(
1839 Effect.scoped,
1840 Effect.mapError(
1841 (cause) => new TerminalHistoryError({ operation: "read", threadId, terminalId, cause }),
1842 ),
1843 );
1844 const history = new BoundedTerminalHistory(historyLineLimit, raw, historyByteLimit);
1845 const capped = history.value();
1846 if (truncated || capped !== raw) {
1847 yield* fileSystem
1848 .writeFileString(nextPath, capped)
1849 .pipe(
1850 Effect.mapError(
1851 (cause) =>
1852 new TerminalHistoryError({ operation: "truncate", threadId, terminalId, cause }),
1853 ),
1854 );
1855 }
1856 return history;
1857 }
1858
1859 if (terminalId !== DEFAULT_TERMINAL_ID) {
1860 return new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit);
1861 }
1862
1863 const legacyPath = legacyHistoryPath(threadId);
1864 if (
1865 !(yield* fileSystem
1866 .exists(legacyPath)
1867 .pipe(
1868 Effect.mapError(
1869 (cause) =>
1870 new TerminalHistoryError({ operation: "migrate", threadId, terminalId, cause }),
1871 ),
1872 ))
1873 ) {
1874 return new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit);
1875 }
1876
1877 const { history: raw } = yield* readHistoryTail(legacyPath).pipe(
1878 Effect.scoped,
1879 Effect.mapError(
1880 (cause) => new TerminalHistoryError({ operation: "migrate", threadId, terminalId, cause }),
1881 ),
1882 );
1883 const history = new BoundedTerminalHistory(historyLineLimit, raw, historyByteLimit);
1884 const capped = history.value();
1885 yield* fileSystem
1886 .writeFileString(nextPath, capped)
1887 .pipe(
1888 Effect.mapError(
1889 (cause) =>
1890 new TerminalHistoryError({ operation: "migrate", threadId, terminalId, cause }),
1891 ),
1892 );
1893 yield* fileSystem.remove(legacyPath, { force: true }).pipe(
1894 Effect.catch((cleanupError) =>
1895 Effect.logWarning("failed to remove legacy terminal history", {
1896 threadId,
1897 error: cleanupError,
1898 }),
1899 ),
1900 );
1901 return history;
1902 });
1903
1904 const deleteHistory = Effect.fn("terminal.deleteHistory")(function* (
1905 threadId: string,
1906 terminalId: string,
1907 ) {
1908 yield* fileSystem.remove(historyPath(threadId, terminalId), { force: true }).pipe(
1909 Effect.catch((error) =>
1910 Effect.logWarning("failed to delete terminal history", {
1911 threadId,
1912 terminalId,
1913 error,
1914 }),
1915 ),
1916 );
1917 if (terminalId === DEFAULT_TERMINAL_ID) {
1918 yield* fileSystem.remove(legacyHistoryPath(threadId), { force: true }).pipe(
1919 Effect.catch((error) =>
1920 Effect.logWarning("failed to delete terminal history", {
1921 threadId,
1922 terminalId,
1923 error,
1924 }),
1925 ),
1926 );
1927 }
1928 });
1929
1930 const deleteAllHistoryForThread = Effect.fn("terminal.deleteAllHistoryForThread")(function* (
1931 threadId: string,
1932 ) {
1933 const threadPrefix = `${toSafeThreadId(threadId)}_`;
1934 const entries = yield* fileSystem
1935 .readDirectory(logsDir, { recursive: false })
1936 .pipe(Effect.orElseSucceed(() => [] as Array<string>));
1937 yield* Effect.forEach(
1938 entries.filter(
1939 (name) =>
1940 name === `${toSafeThreadId(threadId)}.log` ||
1941 name === `${legacySafeThreadId(threadId)}.log` ||
1942 name.startsWith(threadPrefix),
1943 ),
1944 (name) =>
1945 fileSystem.remove(path.join(logsDir, name), { force: true }).pipe(
1946 Effect.catch((error) =>
1947 Effect.logWarning("failed to delete terminal histories for thread", {
1948 threadId,
1949 error,
1950 }),
1951 ),
1952 ),
1953 { discard: true },
1954 );
1955 });
1956
1957 const assertValidCwd = Effect.fn("terminal.assertValidCwd")(function* (cwd: string) {
1958 const stats = yield* fileSystem.stat(cwd).pipe(
1959 Effect.catchTags({
1960 PlatformError: (cause) =>
1961 cause.reason._tag === "NotFound"
1962 ? new TerminalCwdNotFoundError({ cwd })
1963 : new TerminalCwdStatError({ cwd, cause }),
1964 }),
1965 );
1966 if (stats.type !== "Directory") {
1967 return yield* new TerminalCwdNotDirectoryError({ cwd });
1968 }
1969 });
1970
1971 const getSession = Effect.fn("terminal.getSession")(function* (
1972 threadId: string,
1973 terminalId: string,
1974 ): Effect.fn.Return<Option.Option<TerminalSessionState>> {
1975 return yield* Effect.map(readManagerState, (state) =>
1976 Option.fromNullishOr(state.sessions.get(toSessionKey(threadId, terminalId))),
1977 );
1978 });
1979
1980 const requireSession = Effect.fn("terminal.requireSession")(function* (
1981 threadId: string,
1982 terminalId: string,
1983 ): Effect.fn.Return<TerminalSessionState, TerminalSessionLookupError> {
1984 return yield* Effect.flatMap(getSession(threadId, terminalId), (session) =>
1985 Effect.fromOption(session, () => new TerminalSessionLookupError({ threadId, terminalId })),
1986 );
1987 });
1988
1989 const sessionsForThread = Effect.fn("terminal.sessionsForThread")(function* (threadId: string) {
1990 return yield* readManagerState.pipe(
1991 Effect.map((state) =>
1992 [...state.sessions.values()].filter((session) => session.threadId === threadId),
1993 ),
1994 );
1995 });
1996
1997 const evictInactiveSessionsIfNeeded = Effect.fn("terminal.evictInactiveSessionsIfNeeded")(
1998 function* () {
1999 yield* modifyManagerState((state) => {
2000 const inactiveSessions = [...state.sessions.values()].filter(
2001 (session) => session.status !== "running",
2002 );
2003 if (inactiveSessions.length <= maxRetainedInactiveSessions) {
2004 return [undefined, state] as const;
2005 }
2006
2007 inactiveSessions.sort(
2008 (left, right) =>
2009 left.updatedAt.localeCompare(right.updatedAt) ||
2010 left.threadId.localeCompare(right.threadId) ||
2011 left.terminalId.localeCompare(right.terminalId),
2012 );
2013
2014 const sessions = new Map(state.sessions);
2015
2016 const toEvict = inactiveSessions.length - maxRetainedInactiveSessions;
2017 for (const session of inactiveSessions.slice(0, toEvict)) {
2018 const key = toSessionKey(session.threadId, session.terminalId);
2019 sessions.delete(key);
2020 }
2021
2022 return [undefined, { ...state, sessions }] as const;
2023 });
2024 },
2025 );
2026
2027 const drainProcessEvents = Effect.fn("terminal.drainProcessEvents")(function* (
2028 session: TerminalSessionState,
2029 expectedPid: number,
2030 ) {
2031 while (true) {
2032 const action: DrainProcessEventAction = yield* Effect.sync(() => {
2033 if (session.pid !== expectedPid || !session.process || session.status !== "running") {
2034 session.pendingProcessEvents = [];
2035 session.pendingProcessEventIndex = 0;
2036 session.processEventDrainRunning = false;
2037 return { type: "idle" } as const;
2038 }
2039
2040 const nextEvent = session.pendingProcessEvents[session.pendingProcessEventIndex];
2041 if (!nextEvent) {
2042 session.pendingProcessEvents = [];
2043 session.pendingProcessEventIndex = 0;
2044 session.processEventDrainRunning = false;
2045 return { type: "idle" } as const;
2046 }
2047
2048 session.pendingProcessEventIndex += 1;
2049 if (session.pendingProcessEventIndex >= session.pendingProcessEvents.length) {
2050 session.pendingProcessEvents = [];
2051 session.pendingProcessEventIndex = 0;
2052 }
2053
2054 if (nextEvent.type === "output") {
2055 const sanitized = sanitizeTerminalHistoryChunk(
2056 session.pendingHistoryControlSequence,
2057 nextEvent.data,
2058 );
2059 session.pendingHistoryControlSequence = sanitized.pendingControlSequence;
2060 if (sanitized.visibleText.length > 0) {
2061 session.history.append(sanitized.visibleText);
2062 }
2063 const eventStamp = advanceEventSequence(session);
2064
2065 return {
2066 type: "output",
2067 threadId: session.threadId,
2068 terminalId: session.terminalId,
2069 sequence: eventStamp.sequence,
2070 history: sanitized.visibleText.length > 0 ? session.history : null,
2071 data: nextEvent.data,
2072 } as const;
2073 }
2074
2075 const process = session.process;
2076 cleanupProcessHandles(session);
2077 session.process = null;
2078 session.pid = null;
2079 session.hasRunningSubprocess = false;
2080 session.childCommandLabel = null;
2081 session.status = "exited";
2082 session.pendingHistoryControlSequence = "";
2083 session.pendingProcessEvents = [];
2084 session.pendingProcessEventIndex = 0;
2085 session.processEventDrainRunning = false;
2086 session.exitCode = Number.isInteger(nextEvent.event.exitCode)
2087 ? nextEvent.event.exitCode
2088 : null;
2089 session.exitSignal = Number.isInteger(nextEvent.event.signal)
2090 ? nextEvent.event.signal
2091 : null;
2092 const eventStamp = advanceEventSequence(session);
2093
2094 return {
2095 type: "exit",
2096 process,
2097 threadId: session.threadId,
2098 terminalId: session.terminalId,
2099 sequence: eventStamp.sequence,
2100 exitCode: session.exitCode,
2101 exitSignal: session.exitSignal,
2102 } as const;
2103 });
2104
2105 if (action.type === "idle") {
2106 return;
2107 }
2108
2109 if (action.type === "output") {
2110 if (action.history !== null) {
2111 yield* queuePersist(action.threadId, action.terminalId, action.history);
2112 }
2113
2114 yield* publishEvent({
2115 type: "output",
2116 threadId: action.threadId,
2117 terminalId: action.terminalId,
2118 sequence: action.sequence,
2119 data: action.data,
2120 });
2121 continue;
2122 }
2123
2124 yield* clearKillFiber(action.process);
2125 yield* unregisterTerminal({
2126 threadId: action.threadId,
2127 terminalId: action.terminalId,
2128 });
2129 yield* publishEvent({
2130 type: "exited",
2131 threadId: action.threadId,
2132 terminalId: action.terminalId,
2133 sequence: action.sequence,
2134 exitCode: action.exitCode,
2135 exitSignal: action.exitSignal,
2136 });
2137 yield* evictInactiveSessionsIfNeeded();
2138 return;
2139 }
2140 });
2141
2142 const stopProcess = Effect.fn("terminal.stopProcess")(function* (session: TerminalSessionState) {
2143 const process = session.process;
2144 if (!process) return;
2145
2146 const updatedAt = yield* nowIso;
2147 yield* modifyManagerState((state) => {
2148 cleanupProcessHandles(session);
2149 session.process = null;
2150 session.pid = null;
2151 session.hasRunningSubprocess = false;
2152 session.childCommandLabel = null;
2153 session.status = "exited";
2154 session.pendingHistoryControlSequence = "";
2155 session.pendingProcessEvents = [];
2156 session.pendingProcessEventIndex = 0;
2157 session.processEventDrainRunning = false;
2158 session.updatedAt = updatedAt;
2159 return [undefined, state] as const;
2160 });
2161
2162 yield* clearKillFiber(process);
2163 yield* unregisterTerminal({
2164 threadId: session.threadId,
2165 terminalId: session.terminalId,
2166 });
2167 yield* startKillEscalation(process, session.threadId, session.terminalId);
2168 yield* evictInactiveSessionsIfNeeded();
2169 });
2170
2171 const trySpawn = Effect.fn("terminal.trySpawn")(function* (
2172 shellCandidates: ReadonlyArray<ShellCandidate>,
2173 spawnEnv: NodeJS.ProcessEnv,
2174 session: TerminalSessionState,
2175 index = 0,
2176 lastError: PtyAdapter.PtySpawnError | null = null,
2177 ): Effect.fn.Return<
2178 { process: PtyAdapter.PtyProcess; shellLabel: string },
2179 PtyAdapter.PtySpawnError
2180 > {
2181 if (index >= shellCandidates.length) {
2182 return yield* new PtyAdapter.PtySpawnError({
2183 adapter: "terminal-manager",
2184 attemptedShells: shellCandidates.map((candidate) => formatShellCandidate(candidate)),
2185 ...(lastError ? { cause: lastError } : {}),
2186 });
2187 }
2188
2189 const candidate = shellCandidates[index];
2190 if (!candidate) {
2191 return yield* (
2192 lastError ??
2193 new PtyAdapter.PtySpawnError({
2194 adapter: "terminal-manager",
2195 attemptedShells: [],
2196 })
2197 );
2198 }
2199
2200 const launch = yield* agentScope.wrap({
2201 command: candidate.shell,
2202 args: candidate.args ?? [],
2203 name: "terminal",
2204 env: spawnEnv,
2205 survivesRestart: true,
2206 });
2207 const attempt = yield* Effect.result(
2208 options.ptyAdapter.spawn({
2209 shell: launch.command,
2210 ...(launch.args.length > 0 ? { args: [...launch.args] } : {}),
2211 cwd: session.cwd,
2212 cols: session.cols,
2213 rows: session.rows,
2214 env: spawnEnv,
2215 }),
2216 );
2217
2218 if (attempt._tag === "Success") {
2219 return {
2220 process: attempt.success,
2221 shellLabel: formatShellCandidate(candidate),
2222 };
2223 }
2224
2225 const spawnError = attempt.failure;
2226 if (!isRetryableShellSpawnError(spawnError)) {
2227 return yield* spawnError;
2228 }
2229
2230 return yield* trySpawn(shellCandidates, spawnEnv, session, index + 1, spawnError);
2231 });
2232
2233 const startSession = Effect.fn("terminal.startSession")(function* (
2234 session: TerminalSessionState,
2235 input: TerminalStartInput,
2236 eventType: "started" | "restarted",
2237 ) {
2238 yield* stopProcess(session);
2239 yield* Effect.annotateCurrentSpan({
2240 "terminal.thread_id": session.threadId,
2241 "terminal.id": session.terminalId,
2242 "terminal.event_type": eventType,
2243 "terminal.cwd": input.cwd,
2244 });
2245
2246 const startingAt = yield* nowIso;
2247 yield* modifyManagerState((state) => {
2248 session.status = "starting";
2249 session.cwd = input.cwd;
2250 session.worktreePath = input.worktreePath ?? null;
2251 session.cols = input.cols;
2252 session.rows = input.rows;
2253 session.exitCode = null;
2254 session.exitSignal = null;
2255 session.hasRunningSubprocess = false;
2256 session.childCommandLabel = null;
2257 session.pendingProcessEvents = [];
2258 session.pendingProcessEventIndex = 0;
2259 session.processEventDrainRunning = false;
2260 session.updatedAt = startingAt;
2261 return [undefined, state] as const;
2262 });
2263
2264 let ptyProcess: PtyAdapter.PtyProcess | null = null;
2265 let startedShell: string | null = null;
2266
2267 const startResult = yield* Effect.result(
2268 increment(terminalSessionsTotal, { lifecycle: eventType }).pipe(
2269 Effect.andThen(
2270 Effect.gen(function* () {
2271 const shellCandidates = resolveShellCandidates(shellResolver, platform, baseEnv);
2272 const terminalEnv = ScriptProgress.withScriptProgressEnvironment(
2273 createTerminalSpawnEnv(
2274 baseEnv,
2275 session.runtimeEnv,
2276 platform,
2277 yield* HostProcess.HomeDirectory,
2278 ),
2279 yield* scriptProgress.environmentForThread(ThreadId.make(session.threadId)),
2280 );
2281 // Append (never prepend) managed ACP agent install directories so
2282 // `kimi login` and friends resolve by name without shadowing any
2283 // system or user tool of the same name.
2284 if (
2285 options.managedBinaryCacheDir !== undefined &&
2286 options.managedBinaryToolsDir !== undefined
2287 ) {
2288 const managedDirectories = yield* acpRegistryManagedBinaryDirectories({
2289 cacheDir: options.managedBinaryCacheDir,
2290 toolsDir: options.managedBinaryToolsDir,
2291 platform,
2292 architecture,
2293 }).pipe(
2294 Effect.provideService(FileSystem.FileSystem, fileSystem),
2295 Effect.provideService(Path.Path, path),
2296 );
2297 if (managedDirectories.length > 0) {
2298 const delimiter = platform === "win32" ? ";" : ":";
2299 const pathKey =
2300 platform === "win32"
2301 ? (Object.keys(terminalEnv).find(
2302 (candidate) => candidate.toLowerCase() === "path",
2303 ) ?? "PATH")
2304 : "PATH";
2305 const merged = mergePathEntries(
2306 terminalEnv[pathKey],
2307 managedDirectories.join(delimiter),
2308 platform,
2309 );
2310 if (merged !== undefined) {
2311 terminalEnv[pathKey] = merged;
2312 }
2313 }
2314 }
2315 const spawnResult = yield* trySpawn(shellCandidates, terminalEnv, session);
2316 ptyProcess = spawnResult.process;
2317 startedShell = spawnResult.shellLabel;
2318
2319 const processPid = ptyProcess.pid;
2320 let eventsActivated = false;
2321
2322 let eventStamp: ReturnType<typeof advanceEventSequence> = {
2323 updatedAt: session.updatedAt,
2324 sequence: session.eventSequence,
2325 };
2326 yield* modifyManagerState((state) => {
2327 session.process = ptyProcess;
2328 session.pid = processPid;
2329 session.status = "running";
2330 // onExit may replay an exit immediately; accept it before subscribing.
2331 session.unsubscribeData = spawnResult.process.onData((data) => {
2332 if (!enqueueProcessEvent(session, processPid, { type: "output", data })) {
2333 return;
2334 }
2335 if (eventsActivated) runFork(drainProcessEvents(session, processPid));
2336 });
2337 session.unsubscribeExit = spawnResult.process.onExit((event) => {
2338 if (!enqueueProcessEvent(session, processPid, { type: "exit", event })) {
2339 return;
2340 }
2341 if (eventsActivated) runFork(drainProcessEvents(session, processPid));
2342 });
2343 eventStamp = advanceEventSequence(session);
2344 return [undefined, state] as const;
2345 });
2346
2347 yield* publishEvent({
2348 type: eventType,
2349 threadId: session.threadId,
2350 terminalId: session.terminalId,
2351 sequence: eventStamp.sequence,
2352 snapshot: snapshot(session),
2353 });
2354 // Publish startup before draining any events replayed during subscription.
2355 eventsActivated = true;
2356 if (session.processEventDrainRunning) runFork(drainProcessEvents(session, processPid));
2357 }),
2358 ),
2359 ),
2360 );
2361
2362 if (startResult._tag === "Success") {
2363 return;
2364 }
2365
2366 {
2367 const error = startResult.failure;
2368 if (ptyProcess) {
2369 yield* startKillEscalation(ptyProcess, session.threadId, session.terminalId);
2370 }
2371
2372 yield* modifyManagerState((state) => {
2373 cleanupProcessHandles(session);
2374 session.status = "error";
2375 session.pid = null;
2376 session.process = null;
2377 session.hasRunningSubprocess = false;
2378 session.childCommandLabel = null;
2379 session.pendingProcessEvents = [];
2380 session.pendingProcessEventIndex = 0;
2381 session.processEventDrainRunning = false;
2382 advanceEventSequence(session);
2383 return [undefined, state] as const;
2384 });
2385 yield* unregisterTerminal({
2386 threadId: session.threadId,
2387 terminalId: session.terminalId,
2388 });
2389
2390 yield* evictInactiveSessionsIfNeeded();
2391
2392 const message = error.message;
2393 yield* publishEvent({
2394 type: "error",
2395 threadId: session.threadId,
2396 terminalId: session.terminalId,
2397 sequence: session.eventSequence,
2398 message,
2399 });
2400 yield* Effect.logError("failed to start terminal", {
2401 threadId: session.threadId,
2402 terminalId: session.terminalId,
2403 cause: error,
2404 ...(startedShell ? { shell: startedShell } : {}),
2405 });
2406 }
2407 });
2408
2409 const closeSession = Effect.fn("terminal.closeSession")(function* (
2410 threadId: string,
2411 terminalId: string,
2412 deleteHistoryOnClose: boolean,
2413 ) {
2414 const key = toSessionKey(threadId, terminalId);
2415 const session = yield* getSession(threadId, terminalId);
2416 const closedEventSequence = Option.isSome(session) ? session.value.eventSequence + 1 : 0;
2417
2418 if (Option.isSome(session)) {
2419 yield* stopProcess(session.value);
2420 yield* unregisterTerminal({ threadId, terminalId });
2421 yield* persistHistory(threadId, terminalId, session.value.history);
2422 }
2423
2424 yield* flushPersist(threadId, terminalId);
2425
2426 const removed = yield* modifyManagerState((state) => {
2427 if (!state.sessions.has(key)) {
2428 return [false, state] as const;
2429 }
2430 const sessions = new Map(state.sessions);
2431 sessions.delete(key);
2432 return [true, { ...state, sessions }] as const;
2433 });
2434
2435 if (removed) {
2436 yield* publishEvent({
2437 type: "closed",
2438 threadId,
2439 terminalId,
2440 sequence: closedEventSequence,
2441 });
2442 }
2443
2444 if (deleteHistoryOnClose) {
2445 yield* deleteHistory(threadId, terminalId);
2446 }
2447 });
2448
2449 const pollSubprocessActivity = Effect.fn("terminal.pollSubprocessActivity")(function* () {
2450 const state = yield* readManagerState;
2451 const runningSessions = [...state.sessions.values()].filter(
2452 (session): session is TerminalSessionState & { pid: number } =>
2453 session.status === "running" && Number.isInteger(session.pid),
2454 );
2455
2456 if (runningSessions.length === 0) {
2457 return true;
2458 }
2459
2460 const inspectorOption = yield* acquireSubprocessInspector.pipe(
2461 Effect.asSome,
2462 Effect.catch((reason) =>
2463 Effect.logWarning("failed to snapshot processes for terminal subprocess polling", {
2464 reason,
2465 }).pipe(
2466 Effect.as(
2467 Option.none<{
2468 readonly inspector: TerminalSubprocessInspector;
2469 readonly snapshotSucceeded: boolean;
2470 }>(),
2471 ),
2472 ),
2473 ),
2474 );
2475
2476 if (Option.isNone(inspectorOption)) {
2477 return false;
2478 }
2479
2480 const { inspector: subprocessInspector, snapshotSucceeded } = inspectorOption.value;
2481
2482 const checkSubprocessActivity = Effect.fn("terminal.checkSubprocessActivity")(function* (
2483 session: TerminalSessionState & { pid: number },
2484 ) {
2485 const terminalPid = session.pid;
2486 const inspectResult = yield* subprocessInspector(terminalPid).pipe(
2487 Effect.asSome,
2488 Effect.catch((reason) =>
2489 Effect.logWarning("failed to check terminal subprocess activity", {
2490 threadId: session.threadId,
2491 terminalId: session.terminalId,
2492 terminalPid,
2493 reason,
2494 }).pipe(Effect.as(Option.none<TerminalSubprocessInspectResult>())),
2495 ),
2496 );
2497
2498 if (Option.isNone(inspectResult)) {
2499 return;
2500 }
2501
2502 const next = inspectResult.value;
2503 yield* registerTerminalProcesses({
2504 threadId: session.threadId,
2505 terminalId: session.terminalId,
2506 processIds: next.processIds,
2507 });
2508 const nextChildLabel = next.hasRunningSubprocess ? next.childCommand : null;
2509 const event = yield* modifyManagerState((state) => {
2510 const liveSession: Option.Option<TerminalSessionState> = Option.fromNullishOr(
2511 state.sessions.get(toSessionKey(session.threadId, session.terminalId)),
2512 );
2513 if (
2514 Option.isNone(liveSession) ||
2515 liveSession.value.status !== "running" ||
2516 liveSession.value.pid !== terminalPid ||
2517 (liveSession.value.hasRunningSubprocess === next.hasRunningSubprocess &&
2518 liveSession.value.childCommandLabel === nextChildLabel)
2519 ) {
2520 return [Option.none(), state] as const;
2521 }
2522
2523 liveSession.value.hasRunningSubprocess = next.hasRunningSubprocess;
2524 liveSession.value.childCommandLabel = nextChildLabel;
2525 const eventStamp = advanceEventSequence(liveSession.value);
2526
2527 return [
2528 Option.some({
2529 type: "activity" as const,
2530 threadId: liveSession.value.threadId,
2531 terminalId: liveSession.value.terminalId,
2532 sequence: eventStamp.sequence,
2533 hasRunningSubprocess: next.hasRunningSubprocess,
2534 label: terminalWireLabel(liveSession.value),
2535 }),
2536 state,
2537 ] as const;
2538 });
2539
2540 if (Option.isSome(event)) {
2541 yield* publishEvent(event.value);
2542 }
2543 });
2544
2545 yield* Effect.forEach(runningSessions, checkSubprocessActivity, {
2546 concurrency: "unbounded",
2547 discard: true,
2548 });
2549 return snapshotSucceeded;
2550 });
2551
2552 const hasRunningSessions = readManagerState.pipe(
2553 Effect.map((state) =>
2554 [...state.sessions.values()].some((session) => session.status === "running"),
2555 ),
2556 );
2557
2558 let subprocessSnapshotFailureCount = 0;
2559 yield* Effect.forever(
2560 hasRunningSessions.pipe(
2561 Effect.flatMap((active) =>
2562 active
2563 ? pollSubprocessActivity().pipe(
2564 Effect.flatMap((snapshotSucceeded) => {
2565 subprocessSnapshotFailureCount = snapshotSucceeded
2566 ? 0
2567 : Math.min(subprocessSnapshotFailureCount + 1, 30);
2568 const delayMs = subprocessSnapshotPollDelayMs(
2569 subprocessPollIntervalMs,
2570 subprocessSnapshotFailureCount,
2571 );
2572 return Effect.sleep(delayMs);
2573 }),
2574 )
2575 : Effect.sync(() => {
2576 subprocessSnapshotFailureCount = 0;
2577 }).pipe(Effect.flatMap(() => Effect.sleep(subprocessPollIntervalMs))),
2578 ),
2579 ),
2580 ).pipe(Effect.forkIn(workerScope));
2581
2582 yield* Effect.addFinalizer(() =>
2583 Effect.gen(function* () {
2584 const sessions = yield* modifyManagerState(
2585 (state) =>
2586 [
2587 [...state.sessions.values()],
2588 {
2589 ...state,
2590 sessions: new Map(),
2591 },
2592 ] as const,
2593 );
2594
2595 const cleanupSession = Effect.fn("terminal.cleanupSession")(function* (
2596 session: TerminalSessionState,
2597 ) {
2598 cleanupProcessHandles(session);
2599 if (!session.process) return;
2600 yield* clearKillFiber(session.process);
2601 yield* runKillEscalation(session.process, session.threadId, session.terminalId);
2602 });
2603
2604 yield* Effect.forEach(sessions, cleanupSession, {
2605 concurrency: "unbounded",
2606 discard: true,
2607 });
2608 }).pipe(Effect.ignoreCause({ log: true })),
2609 );
2610
2611 const openWithWorkspaceLease = Effect.fn("terminal.openLocked")(function* (
2612 input: TerminalOpenInput,
2613 ) {
2614 const terminalId = input.terminalId;
2615 yield* assertValidCwd(input.cwd);
2616
2617 const sessionKey = toSessionKey(input.threadId, terminalId);
2618 const existing = yield* getSession(input.threadId, terminalId);
2619 if (Option.isNone(existing)) {
2620 yield* flushPersist(input.threadId, terminalId);
2621 const history = yield* readHistory(input.threadId, terminalId);
2622 const cols = input.cols ?? DEFAULT_OPEN_COLS;
2623 const rows = input.rows ?? DEFAULT_OPEN_ROWS;
2624 const session: TerminalSessionState = {
2625 threadId: input.threadId,
2626 terminalId,
2627 cwd: input.cwd,
2628 worktreePath: input.worktreePath ?? null,
2629 status: "starting",
2630 pid: null,
2631 history,
2632 pendingHistoryControlSequence: "",
2633 pendingProcessEvents: [],
2634 pendingProcessEventIndex: 0,
2635 processEventDrainRunning: false,
2636 exitCode: null,
2637 exitSignal: null,
2638 updatedAt: yield* nowIso,
2639 eventSequence: 0,
2640 inputCount: 0,
2641 cols,
2642 rows,
2643 process: null,
2644 unsubscribeData: null,
2645 unsubscribeExit: null,
2646 hasRunningSubprocess: false,
2647 childCommandLabel: null,
2648 runtimeEnv: normalizedRuntimeEnv(input.env),
2649 };
2650
2651 const createdSession = session;
2652 yield* modifyManagerState((state) => {
2653 const sessions = new Map(state.sessions);
2654 sessions.set(sessionKey, createdSession);
2655 return [undefined, { ...state, sessions }] as const;
2656 });
2657
2658 yield* evictInactiveSessionsIfNeeded();
2659 yield* startSession(
2660 session,
2661 {
2662 threadId: input.threadId,
2663 terminalId,
2664 cwd: input.cwd,
2665 ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}),
2666 cols,
2667 rows,
2668 ...(input.env ? { env: input.env } : {}),
2669 },
2670 "started",
2671 );
2672 return snapshot(session);
2673 }
2674
2675 const liveSession = existing.value;
2676 const nextRuntimeEnv = normalizedRuntimeEnv(input.env);
2677 const currentRuntimeEnv = liveSession.runtimeEnv;
2678 const targetCols = input.cols ?? liveSession.cols;
2679 const targetRows = input.rows ?? liveSession.rows;
2680 const runtimeEnvChanged = !Equal.equals(currentRuntimeEnv, nextRuntimeEnv);
2681 const nextWorktreePath =
2682 input.worktreePath !== undefined ? (input.worktreePath ?? null) : liveSession.worktreePath;
2683 const launchContextChanged =
2684 liveSession.cwd !== input.cwd ||
2685 runtimeEnvChanged ||
2686 liveSession.worktreePath !== nextWorktreePath;
2687
2688 if (launchContextChanged) {
2689 yield* stopProcess(liveSession);
2690 liveSession.cwd = input.cwd;
2691 liveSession.worktreePath = nextWorktreePath;
2692 liveSession.runtimeEnv = nextRuntimeEnv;
2693 liveSession.history.clear();
2694 liveSession.pendingHistoryControlSequence = "";
2695 liveSession.pendingProcessEvents = [];
2696 liveSession.pendingProcessEventIndex = 0;
2697 liveSession.processEventDrainRunning = false;
2698 yield* persistHistory(liveSession.threadId, liveSession.terminalId, liveSession.history);
2699 } else if (liveSession.status === "exited" || liveSession.status === "error") {
2700 liveSession.runtimeEnv = nextRuntimeEnv;
2701 liveSession.worktreePath = nextWorktreePath;
2702 liveSession.history.clear();
2703 liveSession.pendingHistoryControlSequence = "";
2704 liveSession.pendingProcessEvents = [];
2705 liveSession.pendingProcessEventIndex = 0;
2706 liveSession.processEventDrainRunning = false;
2707 yield* persistHistory(liveSession.threadId, liveSession.terminalId, liveSession.history);
2708 }
2709
2710 if (!liveSession.process) {
2711 yield* startSession(
2712 liveSession,
2713 {
2714 threadId: input.threadId,
2715 terminalId,
2716 cwd: input.cwd,
2717 worktreePath: liveSession.worktreePath,
2718 cols: targetCols,
2719 rows: targetRows,
2720 ...(input.env ? { env: input.env } : {}),
2721 },
2722 "started",
2723 );
2724 return snapshot(liveSession);
2725 }
2726
2727 if (liveSession.cols !== targetCols || liveSession.rows !== targetRows) {
2728 yield* resizePtyProcess(liveSession, liveSession.process, targetCols, targetRows);
2729 liveSession.cols = targetCols;
2730 liveSession.rows = targetRows;
2731 liveSession.updatedAt = yield* nowIso;
2732 }
2733
2734 return snapshot(liveSession);
2735 });
2736
2737 const openLocked = (input: TerminalOpenInput) =>
2738 withWorkspaceLease(
2739 path.resolve(input.worktreePath ?? input.cwd),
2740 openWithWorkspaceLease(input),
2741 );
2742
2743 const open: TerminalManager["Service"]["open"] = (input) =>
2744 withThreadLock(
2745 input.threadId,
2746 resolveLaunchInputEnvironment(input).pipe(Effect.flatMap(openLocked)),
2747 );
2748
2749 const openOrAttachForStream = (input: TerminalAttachInput) =>
2750 withThreadLock(
2751 input.threadId,
2752 Effect.gen(function* () {
2753 const terminalId = input.terminalId;
2754 const existing = yield* getSession(input.threadId, terminalId);
2755
2756 if (Option.isNone(existing)) {
2757 if (!input.cwd) {
2758 return yield* new TerminalSessionLookupError({
2759 threadId: input.threadId,
2760 terminalId,
2761 });
2762 }
2763
2764 const resolvedInput = yield* resolveLaunchInputEnvironment({
2765 ...input,
2766 terminalId,
2767 cwd: input.cwd,
2768 });
2769 return yield* openLocked(resolvedInput);
2770 }
2771
2772 const session = existing.value;
2773 const targetCols = input.cols ?? session.cols;
2774 const targetRows = input.rows ?? session.rows;
2775
2776 if (!session.process && input.cwd && input.restartIfNotRunning === true) {
2777 const resolvedInput = yield* resolveLaunchInputEnvironment({
2778 ...input,
2779 terminalId,
2780 cwd: input.cwd,
2781 });
2782 return yield* openLocked(resolvedInput);
2783 }
2784
2785 if (
2786 session.process &&
2787 session.status === "running" &&
2788 (session.cols !== targetCols || session.rows !== targetRows)
2789 ) {
2790 const process = session.process;
2791 yield* resizePtyProcess(session, process, targetCols, targetRows);
2792 session.cols = targetCols;
2793 session.rows = targetRows;
2794 session.updatedAt = yield* nowIso;
2795 }
2796
2797 return snapshot(session);
2798 }),
2799 );
2800
2801 const readAllTerminalMetadata = () =>
2802 readManagerState.pipe(
2803 Effect.map((state) =>
2804 [...state.sessions.values()]
2805 .map(summary)
2806 .sort(
2807 (left, right) =>
2808 right.updatedAt.localeCompare(left.updatedAt) ||
2809 left.threadId.localeCompare(right.threadId) ||
2810 left.terminalId.localeCompare(right.terminalId),
2811 ),
2812 ),
2813 );
2814
2815 const readTerminalMetadata = (input: {
2816 readonly threadId: string;
2817 readonly terminalId: string;
2818 }) =>
2819 getSession(input.threadId, input.terminalId).pipe(
2820 Effect.map((session) => (Option.isSome(session) ? summary(session.value) : null)),
2821 );
2822
2823 const subscribe: TerminalManager["Service"]["subscribe"] = (listener) =>
2824 Effect.sync(() => {
2825 terminalEventListeners.add(listener);
2826 return () => {
2827 terminalEventListeners.delete(listener);
2828 };
2829 });
2830
2831 const streamSession = (
2832 input: TerminalObserveInput,
2833 initial: Effect.Effect<TerminalSessionSnapshot, TerminalError>,
2834 listener: (event: TerminalAttachStreamEvent) => Effect.Effect<void>,
2835 ) => {
2836 let unsubscribe: (() => void) | null = null;
2837
2838 return Effect.gen(function* () {
2839 const bufferedEvents: TerminalEvent[] = [];
2840 let deliverLive = false;
2841
2842 unsubscribe = yield* subscribe((event) => {
2843 if (event.threadId !== input.threadId || event.terminalId !== input.terminalId) {
2844 return Effect.void;
2845 }
2846
2847 if (!deliverLive) {
2848 bufferedEvents.push(event);
2849 return Effect.void;
2850 }
2851
2852 const attachEvent = terminalEventToAttachEvent(event);
2853 return attachEvent ? listener(attachEvent) : Effect.void;
2854 });
2855
2856 const initialSnapshot = yield* initial;
2857
2858 yield* listener({
2859 type: "snapshot",
2860 snapshot: initialSnapshot,
2861 });
2862
2863 for (const event of bufferedEvents) {
2864 if (isDuplicateAttachSnapshotEvent(event, initialSnapshot)) {
2865 continue;
2866 }
2867
2868 const attachEvent = terminalEventToAttachEvent(event);
2869 if (attachEvent) {
2870 yield* listener(attachEvent);
2871 }
2872 }
2873
2874 deliverLive = true;
2875 return () => {
2876 unsubscribe?.();
2877 unsubscribe = null;
2878 };
2879 }).pipe(
2880 Effect.catchCause((cause) =>
2881 Effect.flatMap(
2882 Effect.sync(() => {
2883 unsubscribe?.();
2884 unsubscribe = null;
2885 }),
2886 () => Effect.failCause(cause),
2887 ),
2888 ),
2889 );
2890 };
2891
2892 const attachStream: TerminalManager["Service"]["attachStream"] = (input, listener) =>
2893 streamSession(input, openOrAttachForStream(input), listener);
2894
2895 const observeStream: TerminalManager["Service"]["observeStream"] = (input, listener) =>
2896 streamSession(
2897 input,
2898 withThreadLock(
2899 input.threadId,
2900 requireSession(input.threadId, input.terminalId).pipe(Effect.map(snapshot)),
2901 ),
2902 listener,
2903 );
2904
2905 const metadataEventFromTerminalEvent = (
2906 event: TerminalEvent,
2907 ): Effect.Effect<TerminalMetadataStreamEvent | null> => {
2908 if (!shouldPublishTerminalMetadataEvent(event)) {
2909 return Effect.succeed(null);
2910 }
2911
2912 if (event.type === "closed") {
2913 return Effect.succeed({
2914 type: "remove" as const,
2915 threadId: event.threadId,
2916 terminalId: event.terminalId,
2917 });
2918 }
2919
2920 return readTerminalMetadata({
2921 threadId: event.threadId,
2922 terminalId: event.terminalId,
2923 }).pipe(
2924 Effect.map((terminal) =>
2925 terminal
2926 ? {
2927 type: "upsert" as const,
2928 terminal,
2929 }
2930 : null,
2931 ),
2932 );
2933 };
2934
2935 const offerMetadataEvent = (
2936 listener: (event: TerminalMetadataStreamEvent) => Effect.Effect<void>,
2937 event: TerminalEvent,
2938 ) =>
2939 metadataEventFromTerminalEvent(event).pipe(
2940 Effect.flatMap((metadataEvent) => (metadataEvent ? listener(metadataEvent) : Effect.void)),
2941 );
2942
2943 const subscribeMetadata: TerminalManager["Service"]["subscribeMetadata"] = (listener) => {
2944 let unsubscribe: (() => void) | null = null;
2945
2946 return Effect.gen(function* () {
2947 const bufferedEvents: TerminalEvent[] = [];
2948 let deliverLive = false;
2949
2950 unsubscribe = yield* subscribe((event) => {
2951 if (!deliverLive) {
2952 bufferedEvents.push(event);
2953 return Effect.void;
2954 }
2955
2956 return offerMetadataEvent(listener, event);
2957 });
2958
2959 const terminals = yield* readAllTerminalMetadata();
2960 yield* listener({
2961 type: "snapshot",
2962 terminals,
2963 });
2964
2965 for (const event of bufferedEvents) {
2966 yield* offerMetadataEvent(listener, event);
2967 }
2968
2969 deliverLive = true;
2970 return () => {
2971 unsubscribe?.();
2972 unsubscribe = null;
2973 };
2974 }).pipe(
2975 Effect.catchCause((cause) =>
2976 Effect.flatMap(
2977 Effect.sync(() => {
2978 unsubscribe?.();
2979 unsubscribe = null;
2980 }),
2981 () => Effect.failCause(cause),
2982 ),
2983 ),
2984 );
2985 };
2986
2987 const write: TerminalManager["Service"]["write"] = Effect.fn("terminal.write")(function* (input) {
2988 const terminalId = input.terminalId;
2989 const session = yield* requireSession(input.threadId, terminalId);
2990 const process = session.process;
2991 if (!process || session.status !== "running") {
2992 if (session.status === "exited") return;
2993 return yield* new TerminalNotRunningError({
2994 threadId: input.threadId,
2995 terminalId,
2996 });
2997 }
2998 session.inputCount += 1;
2999 yield* Effect.try({
3000 try: () => process.write(input.data),
3001 catch: (cause) =>
3002 new TerminalWriteError({
3003 threadId: input.threadId,
3004 terminalId,
3005 terminalPid: process.pid,
3006 cause,
3007 }),
3008 });
3009 });
3010
3011 const resizeLocked = Effect.fn("terminal.resize")(function* (input: TerminalResizeInput) {
3012 const session = yield* getSession(input.threadId, input.terminalId);
3013 // ResizeObserver traffic can already be in flight when the UI closes the session.
3014 if (Option.isNone(session)) {
3015 return;
3016 }
3017 const process = session.value.process;
3018 if (!process || session.value.status !== "running") {
3019 return;
3020 }
3021 yield* resizePtyProcess(session.value, process, input.cols, input.rows);
3022 session.value.cols = input.cols;
3023 session.value.rows = input.rows;
3024 session.value.updatedAt = yield* nowIso;
3025 });
3026
3027 const resize: TerminalManager["Service"]["resize"] = (input) =>
3028 withThreadLock(input.threadId, resizeLocked(input));
3029
3030 const clear: TerminalManager["Service"]["clear"] = (input) =>
3031 withThreadLock(
3032 input.threadId,
3033 Effect.gen(function* () {
3034 const terminalId = input.terminalId;
3035 const session = yield* requireSession(input.threadId, terminalId);
3036 session.history.clear();
3037 session.pendingHistoryControlSequence = "";
3038 session.pendingProcessEvents = [];
3039 session.pendingProcessEventIndex = 0;
3040 session.processEventDrainRunning = false;
3041 const eventStamp = advanceEventSequence(session);
3042 yield* persistHistory(input.threadId, terminalId, session.history);
3043 yield* publishEvent({
3044 type: "cleared",
3045 threadId: input.threadId,
3046 terminalId,
3047 sequence: eventStamp.sequence,
3048 });
3049 }),
3050 );
3051
3052 const restartResolved = (input: TerminalRestartInput) =>
3053 Effect.gen(function* () {
3054 yield* increment(terminalRestartsTotal, { scope: "thread" });
3055 const terminalId = input.terminalId;
3056 yield* assertValidCwd(input.cwd);
3057
3058 const sessionKey = toSessionKey(input.threadId, terminalId);
3059 const existingSession = yield* getSession(input.threadId, terminalId);
3060 let session: TerminalSessionState;
3061 if (Option.isNone(existingSession)) {
3062 const cols = input.cols ?? DEFAULT_OPEN_COLS;
3063 const rows = input.rows ?? DEFAULT_OPEN_ROWS;
3064 session = {
3065 threadId: input.threadId,
3066 terminalId,
3067 cwd: input.cwd,
3068 worktreePath: input.worktreePath ?? null,
3069 status: "starting",
3070 pid: null,
3071 history: new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit),
3072 pendingHistoryControlSequence: "",
3073 pendingProcessEvents: [],
3074 pendingProcessEventIndex: 0,
3075 processEventDrainRunning: false,
3076 exitCode: null,
3077 exitSignal: null,
3078 updatedAt: yield* nowIso,
3079 eventSequence: 0,
3080 inputCount: 0,
3081 cols,
3082 rows,
3083 process: null,
3084 unsubscribeData: null,
3085 unsubscribeExit: null,
3086 hasRunningSubprocess: false,
3087 childCommandLabel: null,
3088 runtimeEnv: normalizedRuntimeEnv(input.env),
3089 };
3090 const createdSession = session;
3091 yield* modifyManagerState((state) => {
3092 const sessions = new Map(state.sessions);
3093 sessions.set(sessionKey, createdSession);
3094 return [undefined, { ...state, sessions }] as const;
3095 });
3096 yield* evictInactiveSessionsIfNeeded();
3097 } else {
3098 session = existingSession.value;
3099 yield* stopProcess(session);
3100 session.cwd = input.cwd;
3101 session.worktreePath = input.worktreePath ?? null;
3102 session.runtimeEnv = normalizedRuntimeEnv(input.env);
3103 }
3104
3105 const cols = input.cols ?? session.cols;
3106 const rows = input.rows ?? session.rows;
3107
3108 session.history.clear();
3109 session.pendingHistoryControlSequence = "";
3110 session.pendingProcessEvents = [];
3111 session.pendingProcessEventIndex = 0;
3112 session.processEventDrainRunning = false;
3113 yield* persistHistory(input.threadId, terminalId, session.history);
3114 yield* startSession(
3115 session,
3116 {
3117 threadId: input.threadId,
3118 terminalId,
3119 cwd: input.cwd,
3120 ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}),
3121 cols,
3122 rows,
3123 ...(input.env ? { env: input.env } : {}),
3124 },
3125 "restarted",
3126 );
3127 return snapshot(session);
3128 });
3129
3130 const restart: TerminalManager["Service"]["restart"] = (input) =>
3131 withThreadLock(
3132 input.threadId,
3133 resolveLaunchInputEnvironment(input).pipe(
3134 Effect.flatMap((resolved) =>
3135 withWorkspaceLease(
3136 path.resolve(resolved.worktreePath ?? resolved.cwd),
3137 restartResolved(resolved),
3138 ),
3139 ),
3140 ),
3141 );
3142
3143 const close: TerminalManager["Service"]["close"] = (input) =>
3144 withThreadLock(
3145 input.threadId,
3146 Effect.gen(function* () {
3147 if (input.terminalId) {
3148 yield* closeSession(input.threadId, input.terminalId, input.deleteHistory === true);
3149 return;
3150 }
3151
3152 const threadSessions = yield* sessionsForThread(input.threadId);
3153 yield* Effect.forEach(
3154 threadSessions,
3155 (session) => closeSession(input.threadId, session.terminalId, false),
3156 { discard: true },
3157 );
3158
3159 if (input.deleteHistory) {
3160 yield* deleteAllHistoryForThread(input.threadId);
3161 }
3162 }),
3163 );
3164
3165 const closeIdle: TerminalManager["Service"]["closeIdle"] = (input) =>
3166 withThreadLock(
3167 input.threadId,
3168 Effect.gen(function* () {
3169 const running = (yield* sessionsForThread(input.threadId)).filter(
3170 (session): session is TerminalSessionState & { pid: number } =>
3171 session.status === "running" &&
3172 Number.isInteger(session.pid) &&
3173 (input.terminalId === undefined || session.terminalId === input.terminalId),
3174 );
3175 if (running.length === 0) return;
3176 // A command started during the process check can miss the snapshot,
3177 // but its input or echo still lands. Both counters only grow, so the
3178 // sum changes when either one does.
3179 const activityMark = (session: TerminalSessionState) =>
3180 session.eventSequence + session.inputCount;
3181 const marks = new Map(
3182 running.map((session) => [session.terminalId, activityMark(session)]),
3183 );
3184 // Inspect now instead of trusting the last poll, so a command started
3185 // since then keeps its terminal.
3186 const { inspector } = yield* acquireSubprocessInspector;
3187 yield* Effect.forEach(
3188 running,
3189 (session) =>
3190 inspector(session.pid).pipe(
3191 Effect.flatMap((result) =>
3192 result.hasRunningSubprocess ||
3193 activityMark(session) !== marks.get(session.terminalId)
3194 ? Effect.void
3195 : closeSession(input.threadId, session.terminalId, false),
3196 ),
3197 ),
3198 { discard: true },
3199 );
3200 }),
3201 ).pipe(
3202 // The process check failed, so every terminal stays open.
3203 Effect.catch((error) =>
3204 Effect.logWarning("failed to close idle terminals", {
3205 threadId: input.threadId,
3206 error: error.message,
3207 }),
3208 ),
3209 );
3210
3211 return TerminalManager.of({
3212 open,
3213 attachStream,
3214 observeStream,
3215 write,
3216 resize,
3217 clear,
3218 restart,
3219 close,
3220 list: ({ threadId }) =>
3221 sessionsForThread(threadId).pipe(Effect.map((sessions) => sessions.map(summary))),
3222 closeIdle,
3223 subscribe,
3224 subscribeMetadata,
3225 });
3226});
3227
3228export const layer = Layer.effect(TerminalManager, make()).pipe(Layer.provide(ProcessRunner.layer));