@ian-pascoe/pi-termctrl 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,726 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { resolve } from "node:path";
3
+ import {
4
+ DEFAULT_MAX_BYTES,
5
+ DEFAULT_MAX_LINES,
6
+ defineTool,
7
+ formatSize,
8
+ truncateTail,
9
+ type ExtensionToolContext,
10
+ type TruncationResult,
11
+ } from "@earendil-works/pi-coding-agent";
12
+ import type { Key } from "@kitlangton/terminal-control";
13
+ import { Type, type Static } from "typebox";
14
+ import type {
15
+ BackgroundJobEntry,
16
+ TermctrlEntry,
17
+ TermctrlRegistry,
18
+ TerminalEntry,
19
+ } from "./termctrl-registry.js";
20
+ import { describeExitStatus } from "./exit-notification.js";
21
+ import { termctrlTemporaryDirectory } from "./termctrl-driver.js";
22
+ import type { TerminalViewport } from "./pi-termctrl-settings.js";
23
+ import type { TerminalExit, TerminalSnapshot } from "./terminal-driver.js";
24
+
25
+ /** Screen quiet period that settles a Terminal. */
26
+ export const QUIET_MS = 250;
27
+ const POLL_MS = 50;
28
+ export const START_WAIT_MS = 2_000;
29
+ export const INPUT_WAIT_MS = 500;
30
+ export const POLL_WAIT_MS = 30_000;
31
+ export const MAX_WAIT_MS = 5 * 60_000;
32
+
33
+ const CONTROL_KEYS = Array.from(
34
+ { length: 26 },
35
+ (_, index) => `Control+${String.fromCodePoint(65 + index)}`,
36
+ );
37
+ /** Every termctrl `Key` name the agent may send. */
38
+ export const TERMINAL_KEYS: readonly string[] = [
39
+ "Enter",
40
+ "Escape",
41
+ "ArrowUp",
42
+ "ArrowDown",
43
+ "ArrowLeft",
44
+ "ArrowRight",
45
+ "Tab",
46
+ "Shift+Tab",
47
+ "Backspace",
48
+ "Delete",
49
+ "Home",
50
+ "End",
51
+ "PageUp",
52
+ "PageDown",
53
+ ...CONTROL_KEYS,
54
+ ];
55
+ const TERMINAL_KEY_SET: ReadonlySet<string> = new Set(TERMINAL_KEYS);
56
+
57
+ function isKey(name: string): name is Key {
58
+ return TERMINAL_KEY_SET.has(name);
59
+ }
60
+
61
+ /** The shell Pi's `bash` uses, plus its `shellCommandPrefix`. */
62
+ export interface TerminalShell {
63
+ readonly shell: string;
64
+ readonly args: readonly string[];
65
+ readonly commandPrefix: string | undefined;
66
+ }
67
+
68
+ /** Session-scoped values the Terminal tools read at call time. */
69
+ export interface TerminalToolRuntime {
70
+ readonly registry: TermctrlRegistry;
71
+ readonly shell: () => TerminalShell;
72
+ readonly viewport: () => TerminalViewport;
73
+ }
74
+
75
+ const TerminalStateSchema = Type.Union([Type.Literal("running"), Type.Literal("exited")]);
76
+
77
+ const TerminalResultSchema = Type.Object({
78
+ id: Type.String(),
79
+ state: TerminalStateSchema,
80
+ exit_code: Type.Optional(Type.Number({ description: "Present once the Terminal has exited" })),
81
+ signal: Type.Optional(Type.String({ description: "Present when a signal ended the Terminal" })),
82
+ changed: Type.Boolean({ description: "Whether the screen differs from the previous result" }),
83
+ screen: Type.String({ description: "The visible screen" }),
84
+ scrolled_off: Type.String({
85
+ description: "Log lines that scrolled off the screen since the previous result",
86
+ }),
87
+ output_missing: Type.Optional(
88
+ Type.Boolean({
89
+ description:
90
+ "Present when output before scrolled_off is missing: termctrl dropped it or the screen was cleared",
91
+ }),
92
+ ),
93
+ full_output_path: Type.Optional(
94
+ Type.String({
95
+ description: "Present when the result was truncated: the file with all its lines",
96
+ }),
97
+ ),
98
+ });
99
+ type TerminalResult = Static<typeof TerminalResultSchema>;
100
+
101
+ const ListEntrySchema = Type.Object({
102
+ id: Type.String(),
103
+ command: Type.String(),
104
+ state: TerminalStateSchema,
105
+ exit_code: Type.Optional(Type.Number()),
106
+ signal: Type.Optional(Type.String()),
107
+ age_seconds: Type.Number(),
108
+ });
109
+ const ListResultSchema = Type.Object({
110
+ terminals: Type.Array(ListEntrySchema),
111
+ background_jobs: Type.Array(
112
+ Type.Object({ ...ListEntrySchema.properties, log_path: Type.String() }),
113
+ ),
114
+ });
115
+ type ListResult = Static<typeof ListResultSchema>;
116
+
117
+ const StopResultSchema = Type.Object({
118
+ id: Type.String(),
119
+ kind: Type.Union([Type.Literal("terminal"), Type.Literal("background_job")]),
120
+ state: TerminalStateSchema,
121
+ exit_code: Type.Optional(Type.Number()),
122
+ signal: Type.Optional(Type.String()),
123
+ changed: Type.Optional(
124
+ Type.Boolean({
125
+ description: "Terminal only: whether the screen differs from the previous result",
126
+ }),
127
+ ),
128
+ screen: Type.Optional(Type.String({ description: "Terminal only: the final screen" })),
129
+ scrolled_off: Type.Optional(
130
+ Type.String({
131
+ description: "Terminal only: lines that scrolled off since the previous result",
132
+ }),
133
+ ),
134
+ output_missing: Type.Optional(
135
+ Type.Boolean({
136
+ description:
137
+ "Terminal only: present when output before scrolled_off is missing: termctrl dropped it or the screen was cleared",
138
+ }),
139
+ ),
140
+ full_output_path: Type.Optional(
141
+ Type.String({
142
+ description:
143
+ "Terminal only: present when the result was truncated: the file with all its lines",
144
+ }),
145
+ ),
146
+ output: Type.Optional(Type.String({ description: "Background job only: its recent output" })),
147
+ });
148
+ type StopResult = Static<typeof StopResultSchema>;
149
+
150
+ const WaitMsSchema = Type.Optional(
151
+ Type.Integer({ minimum: 0, description: "Maximum milliseconds to wait (clamped to 300000)" }),
152
+ );
153
+
154
+ const StartParameters = Type.Object(
155
+ {
156
+ command: Type.String({ description: "Shell command that starts the program" }),
157
+ cwd: Type.Optional(Type.String({ description: "Working directory (default: current)" })),
158
+ wait_ms: WaitMsSchema,
159
+ notify: Type.Optional(
160
+ Type.Boolean({ description: "Send an Exit notification when it exits (default true)" }),
161
+ ),
162
+ },
163
+ { additionalProperties: false },
164
+ );
165
+
166
+ const SendParameters = Type.Object(
167
+ {
168
+ id: Type.String({ description: "Terminal id, such as t1" }),
169
+ text: Type.Optional(Type.String({ description: "Text to type; \\n presses Enter" })),
170
+ keys: Type.Optional(
171
+ Type.Array(Type.String(), {
172
+ description: `Keys pressed after text: ${TERMINAL_KEYS.slice(0, 14).join(", ")}, Control+A to Control+Z`,
173
+ }),
174
+ ),
175
+ wait_for_text: Type.Optional(
176
+ Type.String({ description: "Wait until the screen shows this text, or /regex/flags" }),
177
+ ),
178
+ wait_ms: WaitMsSchema,
179
+ },
180
+ { additionalProperties: false },
181
+ );
182
+
183
+ const StopParameters = Type.Object(
184
+ { id: Type.String({ description: "A Terminal id (t1) or Background job id (b1)" }) },
185
+ { additionalProperties: false },
186
+ );
187
+
188
+ const ListParameters = Type.Object({}, { additionalProperties: false });
189
+
190
+ /** Clamp a requested wait to the fixed 0 to 5 minute range. */
191
+ export function clampWait(requested: number | undefined, fallback: number): number {
192
+ return Math.min(MAX_WAIT_MS, Math.max(0, requested ?? fallback));
193
+ }
194
+
195
+ /** Parse `wait_for_text`: `/source/flags` is a regex; anything else is a literal substring. */
196
+ export function parseWaitPattern(pattern: string): (screen: string) => boolean {
197
+ const match = /^\/(.+)\/([dgimsuvy]*)$/su.exec(pattern);
198
+ if (match?.[1] !== undefined) {
199
+ const regex = new RegExp(match[1], (match[2] ?? "").replaceAll(/[gy]/gu, ""));
200
+ return (screen) => regex.test(screen);
201
+ }
202
+ return (screen) => screen.includes(pattern);
203
+ }
204
+
205
+ type SettleMode = "start" | "input" | "poll";
206
+ /** Why a Terminal wait ended. */
207
+ export type SettleReason = "quiet" | "matched" | "exited" | "timeout";
208
+
209
+ interface SettleRequest {
210
+ readonly mode: SettleMode;
211
+ readonly waitMs: number;
212
+ readonly startedAt: number;
213
+ readonly matches: ((screen: string) => boolean) | undefined;
214
+ /** The screen before the call. A match already on it counts only once the screen changes. */
215
+ readonly baseline: string | undefined;
216
+ readonly signal: AbortSignal | undefined;
217
+ }
218
+
219
+ /**
220
+ * Poll a Terminal until it settles: screen quiet for 250 ms, a `wait_for_text` match, process exit,
221
+ * or the wait budget running out. A poll (no input) settles on quiet only after new output.
222
+ * With `wait_for_text`, quiet does not settle the wait.
223
+ */
224
+ export async function settleTerminal(
225
+ entry: TerminalEntry,
226
+ request: SettleRequest,
227
+ ): Promise<{ readonly snapshot: TerminalSnapshot; readonly reason: SettleReason }> {
228
+ const deadline = request.startedAt + request.waitMs;
229
+ let previousScreen: string | undefined;
230
+ let lastChangeAt = request.startedAt;
231
+ let sawOutput = false;
232
+ for (;;) {
233
+ const snapshot = await entry.handle.snapshot();
234
+ const now = Date.now();
235
+ if (previousScreen !== undefined && snapshot.screen !== previousScreen) lastChangeAt = now;
236
+ previousScreen = snapshot.screen;
237
+ const outputAt =
238
+ snapshot.idleForMs === null ? lastChangeAt : Math.max(lastChangeAt, now - snapshot.idleForMs);
239
+ if (outputAt > request.startedAt) sawOutput = true;
240
+
241
+ if (snapshot.state === "exited") return { snapshot, reason: "exited" };
242
+ if (request.matches !== undefined) {
243
+ const stale =
244
+ request.baseline !== undefined &&
245
+ snapshot.screen === request.baseline &&
246
+ request.matches(request.baseline);
247
+ if (!stale && request.matches(snapshot.screen)) return { snapshot, reason: "matched" };
248
+ } else if (
249
+ (request.mode !== "poll" || sawOutput) &&
250
+ now - Math.max(outputAt, request.startedAt) >= QUIET_MS
251
+ ) {
252
+ return { snapshot, reason: "quiet" };
253
+ }
254
+ if (now >= deadline || request.signal?.aborted === true) return { snapshot, reason: "timeout" };
255
+ await new Promise((resolve) => setTimeout(resolve, Math.min(POLL_MS, deadline - now)));
256
+ }
257
+ }
258
+
259
+ function exitFields(exit: TerminalExit | null): Pick<TerminalResult, "exit_code" | "signal"> {
260
+ const fields: Pick<TerminalResult, "exit_code" | "signal"> = {};
261
+ if (exit?.code !== null && exit?.code !== undefined) fields.exit_code = exit.code;
262
+ if (exit?.signal !== null && exit?.signal !== undefined) fields.signal = exit.signal;
263
+ return fields;
264
+ }
265
+
266
+ function describeExit(exit: TerminalExit | null): string {
267
+ return exit === null ? "exited" : describeExitStatus(exit);
268
+ }
269
+
270
+ /** Lines kept before the log cursor to find it again after termctrl trims its scrollback. */
271
+ const ANCHOR_LINES = 5;
272
+
273
+ /** Scrolled-off lines, and whether output before them is missing from termctrl's log. */
274
+ interface ScrolledOff {
275
+ readonly lines: readonly string[];
276
+ readonly gap: boolean;
277
+ }
278
+
279
+ const NOTHING_SCROLLED: ScrolledOff = { lines: [], gap: false };
280
+
281
+ /**
282
+ * Find the log cursor in a fresh copy of termctrl's log, or `undefined` when its anchor is gone.
283
+ * termctrl keeps limited scrollback and drops lines from the top, so the cursor can only move up.
284
+ * Lines above the screen never change; matching only the anchor's newest lines still finds a
285
+ * cursor whose older anchor lines were dropped. While the cursor is 0 the anchor is the log's first
286
+ * line, which was on the screen and may have been extended since, as by typing at a prompt.
287
+ */
288
+ function locateCursor(
289
+ lines: readonly string[],
290
+ cursor: number,
291
+ anchor: readonly string[],
292
+ ): number | undefined {
293
+ if (anchor.length === 0) return 0;
294
+ if (cursor === 0) return lines[0]?.startsWith(anchor[0] ?? "") === true ? 0 : undefined;
295
+ for (let end = Math.min(cursor, lines.length); end >= 1; end--) {
296
+ const count = Math.min(anchor.length, end);
297
+ let matches = true;
298
+ for (let offset = 1; offset <= count && matches; offset++) {
299
+ matches = lines[end - offset] === anchor[anchor.length - offset];
300
+ }
301
+ if (matches) return end;
302
+ }
303
+ return undefined;
304
+ }
305
+
306
+ /**
307
+ * Lines that left the screen since the previous result, advancing the Terminal's log cursor.
308
+ * Lines still on the screen stay unread, so a line rewritten after the agent saw it, such as a
309
+ * progress line, is reported in its final form once it scrolls off. When the cursor cannot be found
310
+ * again, because termctrl dropped lines the agent never received or the screen was cleared, every
311
+ * line termctrl still holds is reported and the result says that earlier output is missing.
312
+ */
313
+ async function takeScrolledOff(entry: TerminalEntry, screen: string): Promise<ScrolledOff> {
314
+ let logs: string;
315
+ try {
316
+ logs = await entry.handle.logs();
317
+ } catch {
318
+ return NOTHING_SCROLLED;
319
+ }
320
+ const lines = logs === "" ? [] : logs.split("\n");
321
+ const located = locateCursor(lines, entry.logCursor, entry.logAnchor);
322
+ const screenLines = screen === "" ? 0 : screen.split("\n").length;
323
+ const boundary = Math.max(0, lines.length - screenLines);
324
+ const scrolled = lines.slice(Math.min(located ?? 0, boundary), boundary);
325
+ entry.logCursor = boundary;
326
+ entry.logAnchor =
327
+ boundary > 0 ? lines.slice(Math.max(0, boundary - ANCHOR_LINES), boundary) : lines.slice(0, 1);
328
+ return { lines: scrolled, gap: located === undefined };
329
+ }
330
+
331
+ const GAP_NOTICE =
332
+ "[Earlier output is missing: termctrl keeps limited scrollback and dropped lines, or the screen was cleared. For complete output, run the command with bash or redirect it to a file.]";
333
+
334
+ /** A Terminal result's screen and scrolled-off lines, fitted to Pi's tool output limits. */
335
+ interface FittedOutput {
336
+ readonly screen: string;
337
+ readonly scrolledOff: string;
338
+ readonly fullOutputPath?: string;
339
+ /** Output before the scrolled-off lines is missing from termctrl's log. */
340
+ readonly gap: boolean;
341
+ /** Explains missing or truncated output; appended to the result text. */
342
+ readonly notice?: string;
343
+ }
344
+
345
+ function emptyTruncation(content: string): TruncationResult {
346
+ const totalLines = content === "" ? 0 : content.split("\n").length;
347
+ return {
348
+ content: "",
349
+ truncated: content !== "",
350
+ truncatedBy: content === "" ? null : "bytes",
351
+ totalLines,
352
+ totalBytes: Buffer.byteLength(content, "utf8"),
353
+ outputLines: 0,
354
+ outputBytes: 0,
355
+ lastLinePartial: false,
356
+ firstLineExceedsLimit: false,
357
+ maxLines: 0,
358
+ maxBytes: 0,
359
+ };
360
+ }
361
+
362
+ /**
363
+ * Fit a result into Pi's 2000-line and 50 KB limits. The screen is kept first, from its bottom;
364
+ * the newest scrolled-off lines fill what is left. When anything is cut, every line of the result
365
+ * goes to a full output file that lasts until the owner's session shuts down.
366
+ */
367
+ async function fitOutput(
368
+ registry: TermctrlRegistry,
369
+ entry: TerminalEntry,
370
+ screen: string,
371
+ scrolled: ScrolledOff,
372
+ ): Promise<FittedOutput> {
373
+ const fitted = await truncateOutput(registry, entry, screen, scrolled.lines);
374
+ if (!scrolled.gap) return { ...fitted, gap: false };
375
+ const notice = fitted.notice === undefined ? GAP_NOTICE : `${GAP_NOTICE}\n${fitted.notice}`;
376
+ return { ...fitted, gap: true, notice };
377
+ }
378
+
379
+ async function truncateOutput(
380
+ registry: TermctrlRegistry,
381
+ entry: TerminalEntry,
382
+ screen: string,
383
+ scrolled: readonly string[],
384
+ ): Promise<Omit<FittedOutput, "gap">> {
385
+ const limits = { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES };
386
+ const scrolledText = scrolled.join("\n");
387
+ const shownScreen = truncateTail(screen, limits);
388
+ const lineBudget = limits.maxLines - shownScreen.outputLines;
389
+ // One byte for the newline that joins the scrolled-off lines to the screen.
390
+ const byteBudget = limits.maxBytes - shownScreen.outputBytes - 1;
391
+ const shownScrolled =
392
+ lineBudget > 0 && byteBudget > 0
393
+ ? truncateTail(scrolledText, { maxLines: lineBudget, maxBytes: byteBudget })
394
+ : emptyTruncation(scrolledText);
395
+ if (!shownScreen.truncated && !shownScrolled.truncated)
396
+ return { screen, scrolledOff: scrolledText };
397
+
398
+ const total = shownScrolled.totalLines + shownScreen.totalLines;
399
+ const shown = shownScrolled.outputLines + shownScreen.outputLines;
400
+ const fitted = { screen: shownScreen.content, scrolledOff: shownScrolled.content };
401
+ const range = `[Showing lines ${total - shown + 1}-${total} of ${total} (${formatSize(DEFAULT_MAX_BYTES)} or ${DEFAULT_MAX_LINES} line limit).`;
402
+ const directory = termctrlTemporaryDirectory();
403
+ let path: string;
404
+ try {
405
+ path = registry.reserveOutputFile(entry.owner, entry.id, directory);
406
+ await mkdir(directory, { recursive: true });
407
+ const full =
408
+ scrolledText === "" ? screen : screen === "" ? scrolledText : `${scrolledText}\n${screen}`;
409
+ await writeFile(path, full, { encoding: "utf8", mode: 0o600 });
410
+ } catch (cause) {
411
+ const reason = cause instanceof Error ? cause.message : String(cause);
412
+ return { ...fitted, notice: `${range} The full output could not be saved: ${reason}]` };
413
+ }
414
+ return { ...fitted, fullOutputPath: path, notice: `${range} Full output: ${path}]` };
415
+ }
416
+
417
+ function formatTerminalText(
418
+ result: TerminalResult,
419
+ note: string | undefined,
420
+ notice: string | undefined,
421
+ ): string {
422
+ const exit =
423
+ result.state === "running"
424
+ ? "running"
425
+ : describeExit({ code: result.exit_code ?? null, signal: result.signal ?? null });
426
+ const parts = [`${result.id} ${exit}${result.changed ? "" : " · screen unchanged"}`];
427
+ if (note !== undefined) parts.push(note);
428
+ if (result.scrolled_off !== "") {
429
+ parts.push(`--- scrolled off ---\n${result.scrolled_off}`);
430
+ }
431
+ parts.push(`--- screen ---\n${result.screen === "" ? "(blank)" : result.screen}`);
432
+ const text = parts.join("\n");
433
+ return notice === undefined ? text : `${text}\n\n${notice}`;
434
+ }
435
+
436
+ /** Build the agent's view of a Terminal after a wait, recording the exit as seen. */
437
+ async function terminalResult(
438
+ registry: TermctrlRegistry,
439
+ entry: TerminalEntry,
440
+ snapshot: TerminalSnapshot | undefined,
441
+ note?: string,
442
+ ) {
443
+ const screen = snapshot?.screen ?? entry.finalScreen ?? entry.lastScreen ?? "";
444
+ const scrolled =
445
+ entry.state === "running" || snapshot !== undefined
446
+ ? await takeScrolledOff(entry, screen)
447
+ : NOTHING_SCROLLED;
448
+ const output = await fitOutput(registry, entry, screen, scrolled);
449
+ if (snapshot?.state === "exited") {
450
+ registry.terminalExited(entry.id, snapshot.exit ?? { code: null, signal: null }, screen, true);
451
+ }
452
+ registry.markSeen(entry.id);
453
+ const exited = entry.state === "exited";
454
+ const result: TerminalResult = {
455
+ id: entry.id,
456
+ state: exited ? "exited" : "running",
457
+ ...exitFields(exited ? entry.exit : null),
458
+ changed: entry.lastScreen !== screen,
459
+ screen: output.screen,
460
+ scrolled_off: output.scrolledOff,
461
+ };
462
+ if (output.gap) result.output_missing = true;
463
+ if (output.fullOutputPath !== undefined) result.full_output_path = output.fullOutputPath;
464
+ entry.lastScreen = screen;
465
+ return {
466
+ content: [{ type: "text" as const, text: formatTerminalText(result, note, output.notice) }],
467
+ details: result,
468
+ structuredContent: result,
469
+ };
470
+ }
471
+
472
+ async function driveTerminal<T>(
473
+ registry: TermctrlRegistry,
474
+ entry: TerminalEntry,
475
+ run: () => Promise<T>,
476
+ ): Promise<T> {
477
+ entry.activeCalls++;
478
+ try {
479
+ return await run();
480
+ } catch (cause) {
481
+ const error = cause instanceof Error ? cause : new Error(String(cause));
482
+ if (registry.reportTerminalError(entry, error)) {
483
+ throw new Error(`${entry.id} was lost because the termctrl driver exited`, { cause });
484
+ }
485
+ throw error;
486
+ } finally {
487
+ entry.activeCalls--;
488
+ }
489
+ }
490
+
491
+ function ownerOf(context: ExtensionToolContext): string {
492
+ return context.sessionManager.getSessionId();
493
+ }
494
+
495
+ function unknownId(id: string): Error {
496
+ return new Error(
497
+ `Unknown id ${id}. Call terminal_list to see your Terminals and Background jobs.`,
498
+ );
499
+ }
500
+
501
+ function shellCommand(shell: TerminalShell, command: string): [string, ...string[]] {
502
+ const script = shell.commandPrefix ? `${shell.commandPrefix}\n${command}` : command;
503
+ return [shell.shell, ...shell.args, script];
504
+ }
505
+
506
+ /** `terminal_start`: run a program in a new Terminal and wait for it to settle. */
507
+ export function createTerminalStartTool(runtime: TerminalToolRuntime) {
508
+ return defineTool<typeof StartParameters, TerminalResult>({
509
+ name: "terminal_start",
510
+ label: "terminal_start",
511
+ description:
512
+ "Start a program in a new interactive Terminal (a PTY) and return its screen once it settles: 250 ms of quiet, exit, or wait_ms (default 2000). Use it for REPLs, TUIs, prompts and other programs that need input or a screen. Drive it with terminal_send and end it with terminal_stop.",
513
+ promptSnippet: "Start an interactive program (REPL, TUI, prompt) in a Terminal",
514
+ promptGuidelines: [
515
+ "Use terminal_start, terminal_send and terminal_stop for programs that need input or a screen; use bash for everything else.",
516
+ "Terminals keep limited scrollback, so long output can be lost; run commands whose full output you need with bash, or redirect their output to a file.",
517
+ ],
518
+ parameters: StartParameters,
519
+ outputSchema: TerminalResultSchema,
520
+ async execute(_toolCallId, params, signal, _onUpdate, context) {
521
+ const entry = await runtime.registry.startTerminal(ownerOf(context), {
522
+ command: shellCommand(runtime.shell(), params.command),
523
+ displayCommand: params.command,
524
+ cwd: resolve(context.cwd, params.cwd ?? "."),
525
+ viewport: runtime.viewport(),
526
+ notify: params.notify ?? true,
527
+ });
528
+ // Launching, including a cold driver start, does not count toward the quiet period.
529
+ const startedAt = Date.now();
530
+ return driveTerminal(runtime.registry, entry, async () => {
531
+ const { snapshot } = await settleTerminal(entry, {
532
+ mode: "start",
533
+ waitMs: clampWait(params.wait_ms, START_WAIT_MS),
534
+ startedAt,
535
+ matches: undefined,
536
+ baseline: undefined,
537
+ signal,
538
+ });
539
+ return terminalResult(runtime.registry, entry, snapshot);
540
+ });
541
+ },
542
+ });
543
+ }
544
+
545
+ /** `terminal_send`: type text and keys into a Terminal, or poll it, and wait for it to settle. */
546
+ export function createTerminalSendTool(runtime: TerminalToolRuntime) {
547
+ return defineTool<typeof SendParameters, TerminalResult>({
548
+ name: "terminal_send",
549
+ label: "terminal_send",
550
+ description:
551
+ "Type text and press keys in a Terminal, then return its screen once it settles: 250 ms of quiet, a wait_for_text match, exit, or wait_ms (default 500). With neither text nor keys it polls: it waits up to wait_ms (default 30000) for new output. Results include the lines that scrolled off since your previous result; a truncated result names a file with its full output.",
552
+ promptSnippet: "Send input to a Terminal, or poll it, and read its screen",
553
+ parameters: SendParameters,
554
+ outputSchema: TerminalResultSchema,
555
+ async execute(_toolCallId, params, signal, _onUpdate, context) {
556
+ const entry = runtime.registry.find(ownerOf(context), params.id);
557
+ if (entry === undefined) throw unknownId(params.id);
558
+ if (entry.kind !== "terminal") {
559
+ throw new Error(
560
+ `${params.id} is a Background job, which accepts no input. Read its log or stop it with terminal_stop.`,
561
+ );
562
+ }
563
+ const keys = params.keys ?? [];
564
+ const invalid = keys.filter((key) => !isKey(key));
565
+ if (invalid.length > 0) {
566
+ throw new Error(
567
+ `Unknown keys: ${invalid.join(", ")}. Valid keys: ${TERMINAL_KEYS.slice(0, 14).join(", ")}, Control+A to Control+Z.`,
568
+ );
569
+ }
570
+ const hasInput = (params.text ?? "") !== "" || keys.length > 0;
571
+ if (entry.state === "exited") {
572
+ return terminalResult(
573
+ runtime.registry,
574
+ entry,
575
+ undefined,
576
+ hasInput ? "Input was not sent because the Terminal has exited." : undefined,
577
+ );
578
+ }
579
+ return driveTerminal(runtime.registry, entry, async () => {
580
+ const matches =
581
+ params.wait_for_text === undefined ? undefined : parseWaitPattern(params.wait_for_text);
582
+ const baseline = matches === undefined ? undefined : (await entry.handle.snapshot()).screen;
583
+ const startedAt = Date.now();
584
+ if (params.text !== undefined && params.text !== "") await entry.handle.type(params.text);
585
+ const validKeys = keys.filter(isKey);
586
+ if (validKeys.length > 0) await entry.handle.press(validKeys);
587
+ const { snapshot } = await settleTerminal(entry, {
588
+ mode: hasInput ? "input" : "poll",
589
+ waitMs: clampWait(params.wait_ms, hasInput ? INPUT_WAIT_MS : POLL_WAIT_MS),
590
+ startedAt,
591
+ matches,
592
+ baseline,
593
+ signal,
594
+ });
595
+ return terminalResult(runtime.registry, entry, snapshot);
596
+ });
597
+ },
598
+ });
599
+ }
600
+
601
+ /** Capture the lines a Terminal scrolled off since the agent's last result, before it is stopped. */
602
+ async function finalScrolledOff(
603
+ registry: TermctrlRegistry,
604
+ entry: TerminalEntry,
605
+ ): Promise<ScrolledOff> {
606
+ try {
607
+ if (entry.state === "exited") return await takeScrolledOff(entry, entry.finalScreen ?? "");
608
+ return await driveTerminal(registry, entry, async () =>
609
+ takeScrolledOff(entry, (await entry.handle.snapshot()).screen),
610
+ );
611
+ } catch {
612
+ return NOTHING_SCROLLED;
613
+ }
614
+ }
615
+
616
+ /** `terminal_stop`: stop a Terminal or Background job and forget it. */
617
+ export function createTerminalStopTool(registry: TermctrlRegistry) {
618
+ return defineTool<typeof StopParameters, StopResult>({
619
+ name: "terminal_stop",
620
+ label: "terminal_stop",
621
+ description:
622
+ "Stop a Terminal (t1) or Background job (b1) and forget it. Running processes are killed; exited ones are removed. Returns a Terminal's final screen and scrolled-off lines, or a Background job's recent output.",
623
+ promptSnippet: "Stop a Terminal or Background job",
624
+ parameters: StopParameters,
625
+ outputSchema: StopResultSchema,
626
+ async execute(_toolCallId, params, _signal, _onUpdate, context) {
627
+ const owner = ownerOf(context);
628
+ const known = registry.find(owner, params.id);
629
+ if (known === undefined) throw unknownId(params.id);
630
+ const wasRunning = known.state === "running";
631
+ const previousScreen = known.kind === "terminal" ? known.lastScreen : undefined;
632
+ const scrolled =
633
+ known.kind === "terminal" ? await finalScrolledOff(registry, known) : NOTHING_SCROLLED;
634
+ const entry = (await registry.stop(owner, params.id)) ?? known;
635
+ const label = entry.kind === "terminal" ? "Terminal" : "Background job";
636
+ const header = wasRunning
637
+ ? `${label} ${entry.id} stopped.`
638
+ : `${label} ${entry.id} had already ${describeExit(entry.exit)}; removed.`;
639
+ const result: StopResult = {
640
+ id: entry.id,
641
+ kind: entry.kind === "terminal" ? "terminal" : "background_job",
642
+ state: "exited",
643
+ ...exitFields(entry.exit),
644
+ };
645
+ const parts = [header];
646
+ if (entry.kind === "terminal") {
647
+ const screen = entry.finalScreen ?? "";
648
+ const output = await fitOutput(registry, entry, screen, scrolled);
649
+ result.changed = previousScreen !== screen;
650
+ result.screen = output.screen;
651
+ result.scrolled_off = output.scrolledOff;
652
+ if (output.gap) result.output_missing = true;
653
+ if (output.fullOutputPath !== undefined) result.full_output_path = output.fullOutputPath;
654
+ if (output.scrolledOff !== "") parts.push(`--- scrolled off ---\n${output.scrolledOff}`);
655
+ parts.push(`--- final screen ---\n${output.screen === "" ? "(blank)" : output.screen}`);
656
+ if (output.notice !== undefined) parts.push(`\n${output.notice}`);
657
+ } else {
658
+ result.output = entry.child.tail();
659
+ if (result.output !== "") parts.push(`--- recent output ---\n${result.output}`);
660
+ }
661
+ return {
662
+ content: [{ type: "text" as const, text: parts.join("\n") }],
663
+ details: result,
664
+ structuredContent: result,
665
+ };
666
+ },
667
+ });
668
+ }
669
+
670
+ function listEntry(entry: TermctrlEntry, now: number) {
671
+ return {
672
+ id: entry.id,
673
+ command: entry.command,
674
+ state: entry.state,
675
+ ...exitFields(entry.exit),
676
+ age_seconds: Math.round((now - entry.startedAt) / 1000),
677
+ };
678
+ }
679
+
680
+ function listLine(entry: TermctrlEntry, now: number): string {
681
+ const state = entry.state === "running" ? "running" : describeExit(entry.exit);
682
+ const age = Math.round((now - entry.startedAt) / 1000);
683
+ const log = entry.kind === "job" ? ` · log ${entry.child.logPath}` : "";
684
+ return `${entry.id} ${state} · ${age}s · ${entry.command}${log}`;
685
+ }
686
+
687
+ /** `terminal_list`: list the caller's Terminals and Background jobs. */
688
+ export function createTerminalListTool(registry: TermctrlRegistry) {
689
+ return defineTool<typeof ListParameters, ListResult>({
690
+ name: "terminal_list",
691
+ label: "terminal_list",
692
+ description:
693
+ "List your Terminals and Background jobs, running and exited, with their state. Background jobs include their log path.",
694
+ promptSnippet: "List Terminals and Background jobs",
695
+ parameters: ListParameters,
696
+ outputSchema: ListResultSchema,
697
+ async execute(_toolCallId, _params, _signal, _onUpdate, context) {
698
+ const now = Date.now();
699
+ const entries = registry.ownedEntries(ownerOf(context));
700
+ const terminals = entries.filter(
701
+ (entry): entry is TerminalEntry => entry.kind === "terminal",
702
+ );
703
+ const jobs = entries.filter((entry): entry is BackgroundJobEntry => entry.kind === "job");
704
+ for (const entry of entries) if (entry.state === "exited") registry.markSeen(entry.id);
705
+ const result: ListResult = {
706
+ terminals: terminals.map((entry) => listEntry(entry, now)),
707
+ background_jobs: jobs.map((entry) => ({
708
+ ...listEntry(entry, now),
709
+ log_path: entry.child.logPath,
710
+ })),
711
+ };
712
+ const section = (title: string, items: readonly TermctrlEntry[]) =>
713
+ `${title}:\n${items.length === 0 ? "(none)" : items.map((entry) => listLine(entry, now)).join("\n")}`;
714
+ return {
715
+ content: [
716
+ {
717
+ type: "text" as const,
718
+ text: `${section("Terminals", terminals)}\n\n${section("Background jobs", jobs)}`,
719
+ },
720
+ ],
721
+ details: result,
722
+ structuredContent: result,
723
+ };
724
+ },
725
+ });
726
+ }