@ian-pascoe/pi-termctrl 0.4.1 → 0.6.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.
package/README.md CHANGED
@@ -47,6 +47,13 @@ with input, and 30 s for a `terminal_send` with neither `text` nor `keys` (a
47
47
  poll, which settles on quiet only after new output). Every `wait_ms` clamps to
48
48
  5 minutes.
49
49
 
50
+ Calls to one Terminal run one at a time, in arrival order: a parallel batch of
51
+ `terminal_send` calls each types, presses keys, and settles before the next
52
+ starts, so every result shows its own call's screen; a `terminal_stop` waits its
53
+ turn too, then reports what the calls ahead of it left. A call queued behind a
54
+ stop finds the Terminal gone. Calls to different Terminals stay concurrent. Aborting a waiting
55
+ call removes it without affecting the running one.
56
+
50
57
  Results say why the wait ended in `settle_reason` (`matched`, `timeout`, `quiet`
51
58
  or `exited`) and in their first line, such as `t1 running · settled: timeout`; a
52
59
  `wait_for_text` that timed out adds that the pattern was not seen. Polls report
@@ -57,10 +64,11 @@ code or signal; a poll (neither `text` nor `keys`) still returns its final scree
57
64
 
58
65
  Results contain the visible screen, the log lines that scrolled off since the
59
66
  agent's previous result, `state`, `exit_code` or `signal` once exited, and
60
- `changed: false` when the screen matches the previous result. Scrolled-off lines
61
- include lines the agent saw on an earlier screen, so a line rewritten in place,
62
- such as a progress line, arrives in its final form. Tools that declare
63
- structured output give `codemode` scripts typed values.
67
+ `changed: false` when the screen matches the previous result. A line the agent
68
+ saw on an earlier screen is not repeated when it scrolls off unchanged; a line
69
+ rewritten in place since, such as a progress line or a prompt the agent typed
70
+ at, arrives again in its final form. Tools that declare structured output give
71
+ `codemode` scripts typed values.
64
72
 
65
73
  Scrolled-off lines are best effort: they come from termctrl's scrollback, which
66
74
  is limited (several hundred lines; fewer when lines are long) and drops its
@@ -73,11 +81,14 @@ scrollback holds before that result, its oldest lines are lost without
73
81
  `output_missing`, because termctrl does not report the trimming. Run commands
74
82
  whose complete output matters with `bash`, or redirect their output to a file.
75
83
 
76
- A result is limited to Pi's 2000 lines or 50 KB. The screen is kept first, from
77
- its bottom, and the newest scrolled-off lines fill the rest. When anything is
78
- cut, every line of the result goes to
79
- `$TMPDIR/pi-termctrl/<pid>-<id>-output-<n>.log`, named in the result's notice
80
- and `full_output_path`.
84
+ Scrolled-off lines are limited by `termctrl.scrollback` (100 lines or 16 KB by
85
+ default). Past it, a result keeps the first and last lines and puts a
86
+ `[… N lines omitted …]` line between them. A whole result is limited to Pi's
87
+ 2000 lines or 50 KB: the screen is kept first, from its bottom, and the
88
+ scrolled-off lines fill the rest. With `scrollback` set to `false`, only Pi's
89
+ limits apply and the newest scrolled-off lines are kept. When anything is cut,
90
+ every line of the result goes to `$TMPDIR/pi-termctrl/<pid>-<id>-output-<n>.log`,
91
+ named in the result's notice and `full_output_path`.
81
92
 
82
93
  `keys` are termctrl key names: `Enter`, `Escape`, `ArrowUp`, `ArrowDown`,
83
94
  `ArrowLeft`, `ArrowRight`, `Tab`, `Shift+Tab`, `Backspace`, `Delete`, `Home`,
@@ -119,8 +130,8 @@ Each terminal tool declares MCP-style `annotations`, which Pi reports through `p
119
130
  With `termctrl.replaceBash` (default `true`), Pi Termctrl replaces Pi's `bash`
120
131
  tool with Pi's own definition plus one parameter, `background?: boolean`.
121
132
  Commands still run on pipes through Pi's local `exec`, so Pi keeps spawning,
122
- process-tree kills, truncation and rendering, and foreground results are
123
- unchanged. A command becomes a Background job in two ways:
133
+ process-tree kills and rendering, and foreground results differ from Pi's only
134
+ by the shorter tail below. A command becomes a Background job in two ways:
124
135
 
125
136
  - **`background: true`** waits 2 s. A command that finishes first returns an
126
137
  ordinary result; otherwise the call returns a background result. If the cap
@@ -139,6 +150,16 @@ notification or call `terminal_wait`; read the log with `read` and stop the job
139
150
  with `terminal_stop`. Its structured result follows Pi's `bash` output schema, except
140
151
  that `exit_code` is absent and `background: { id, log_path }` is present.
141
152
 
153
+ **Output tail.** With `termctrl.bashTail` (default `{ lines: 300, bytes: 16384 }`),
154
+ the output the model sees from a foreground `bash` call, and the "output so far" of
155
+ a backgrounding result, is cut to the last 300 lines or 16 KB, whichever is hit
156
+ first, instead of Pi's 2,000 lines or 50 KB. The cut keeps Pi's notice, for example
157
+ `[Showing lines 4701-5000 of 5000 (16.0KB or 300 line limit). Full output: /tmp/pi-bash-….log]`,
158
+ and names the limits actually used. The named file always holds every line: Pi's own
159
+ full-output file when Pi also cut the output, otherwise one Pi Termctrl writes beside
160
+ it in `$TMPDIR` (like Pi's, it is not deleted). The tool description names the limits in force. Set `bashTail` to `false`
161
+ or `0` to restore Pi's limits. Values above Pi's limits are rejected, and an object with no valid field leaves a lower layer's setting in place.
162
+
142
163
  A Background job's log is deleted when the job is stopped, when the user
143
164
  removes it in `/ps`, or when its session shuts down for any reason except
144
165
  reload. `bash` calls from `codemode` scripts behave the same. User `!` commands
@@ -196,16 +217,20 @@ by field:
196
217
  "termctrl": {
197
218
  "replaceBash": true,
198
219
  "defaultViewport": { "cols": 120, "rows": 40 },
199
- "exitTailLines": 20
220
+ "exitTailLines": 20,
221
+ "bashTail": { "lines": 300, "bytes": 16384 },
222
+ "scrollback": { "lines": 100, "bytes": 16384 }
200
223
  }
201
224
  }
202
225
  ```
203
226
 
204
- | Setting | Default | Meaning |
205
- | ----------------- | ------------------------- | ----------------------------------------------- |
206
- | `replaceBash` | `true` | Replace `bash` to enable Background jobs |
207
- | `defaultViewport` | `{ cols: 120, rows: 40 }` | Terminal size; each dimension 1 to 1000 |
208
- | `exitTailLines` | `20` | Lines of output in an Exit notification, 0–1000 |
227
+ | Setting | Default | Meaning |
228
+ | ----------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------- |
229
+ | `replaceBash` | `true` | Replace `bash` to enable Background jobs |
230
+ | `defaultViewport` | `{ cols: 120, rows: 40 }` | Terminal size; each dimension 1 to 1000 |
231
+ | `exitTailLines` | `20` | Lines of output in an Exit notification, 0–1000 |
232
+ | `bashTail` | `{ lines: 300, bytes: 16384 }` | Model-visible `bash` output tail: `lines` 1–2000, `bytes` 1–51200; `false` or `0` restores Pi's limits |
233
+ | `scrollback` | `{ lines: 100, bytes: 16384 }` | Scrolled-off lines in a Terminal result, keeping the first and last; same ranges and opt-out as `bashTail` |
209
234
 
210
235
  Invalid values keep the lower layer and unknown keys produce warnings at session
211
236
  start. Use `/reload` after editing settings.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ian-pascoe/pi-termctrl",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
4
  "private": false,
5
5
  "description": "Interactive Terminals, Background jobs, and a /ps panel for Pi.",
6
6
  "keywords": [
@@ -11,7 +11,8 @@ disable-model-invocation: true
11
11
  2. If `terminal_start` is missing, check the startup diagnostic. Confirm the platform has a packaged `termctrl` binary or that `TERMCTRL_BINARY` names an executable one.
12
12
  3. If `bash` lacks the `background` parameter, check `termctrl.replaceBash` and look for another extension that also registers `bash`.
13
13
  4. Call `terminal_list` to inspect live and exited Terminals and Background jobs. Ask the user to open `/ps` for a live preview.
14
- 5. Classify the failure as settings, binary resolution, `bash` conflict, cap, or process behaviour.
15
- 6. For a requested change, edit one settings layer, validate its JSON, and reload Pi. Finish when the expected tools appear or one exact cause remains.
14
+ 5. If `bash` results are shorter than expected, check `termctrl.bashTail` (default 300 lines or 16 KB; `false` restores Pi's limits); the result's notice names the limits and the file holding every line. If a Terminal result's `scrolled_off` shows `[… N lines omitted …]`, check `termctrl.scrollback` (default 100 lines or 16 KB) the same way.
15
+ 6. Classify the failure as settings, binary resolution, `bash` conflict, cap, or process behaviour.
16
+ 7. For a requested change, edit one settings layer, validate its JSON, and reload Pi. Finish when the expected tools appear or one exact cause remains.
16
17
 
17
18
  Ask before stopping a Terminal or Background job you did not start.
@@ -1,4 +1,6 @@
1
- import { createWriteStream, mkdirSync, openSync, type WriteStream } from "node:fs";
1
+ import { randomBytes } from "node:crypto";
2
+ import { createWriteStream, mkdirSync, openSync, writeFileSync, type WriteStream } from "node:fs";
3
+ import { tmpdir } from "node:os";
2
4
  import { rm } from "node:fs/promises";
3
5
  import { join } from "node:path";
4
6
  import {
@@ -9,13 +11,17 @@ import {
9
11
  DEFAULT_MAX_BYTES,
10
12
  DEFAULT_MAX_LINES,
11
13
  type BashOperations,
14
+ type BashToolDetails,
12
15
  type BashToolOptions,
13
16
  type ExtensionToolContext,
14
17
  type ExtensionUIContext,
18
+ type TruncationOptions,
19
+ type TruncationResult,
15
20
  } from "@earendil-works/pi-coding-agent";
16
21
  import { matchesKey } from "@earendil-works/pi-tui";
17
22
  import { Type, type TSchema } from "typebox";
18
23
  import { KILLED_EXIT, TermctrlRegistry } from "./termctrl-registry.js";
24
+ import type { LineByteLimits } from "./pi-termctrl-settings.js";
19
25
  import { termctrlTemporaryDirectory } from "./termctrl-driver.js";
20
26
  import type { TerminalExit } from "./terminal-driver.js";
21
27
 
@@ -29,6 +35,12 @@ const TAIL_LIMIT_CHARS = 64 * 1024;
29
35
 
30
36
  type ExecOptions = Parameters<BashOperations["exec"]>[2];
31
37
 
38
+ function countNewlines(data: Buffer): number {
39
+ let count = 0;
40
+ for (let at = data.indexOf(10); at !== -1; at = data.indexOf(10, at + 1)) count++;
41
+ return count;
42
+ }
43
+
32
44
  /** Path of a Background job's log. The pid keeps concurrent Pi processes apart. */
33
45
  export function backgroundLogPath(id: string): string {
34
46
  return join(termctrlTemporaryDirectory(), `${process.pid}-${id}.log`);
@@ -39,6 +51,14 @@ interface BackgroundOutcome {
39
51
  readonly id: string;
40
52
  readonly logPath: string;
41
53
  readonly output: string;
54
+ /** What the in-memory buffer dropped from the head of `output`. */
55
+ readonly dropped: DroppedOutput;
56
+ }
57
+
58
+ /** Output that left the in-memory buffer, which a tail's totals still have to count. */
59
+ interface DroppedOutput {
60
+ readonly lines: number;
61
+ readonly bytes: number;
42
62
  }
43
63
 
44
64
  /**
@@ -50,6 +70,7 @@ class BashCall {
50
70
  private readonly chunks: Buffer[] = [];
51
71
  private bufferedBytes = 0;
52
72
  private droppedBytes = 0;
73
+ private droppedNewlines = 0;
53
74
  private recent = "";
54
75
  private readonly decoder = new TextDecoder();
55
76
  private log: WriteStream | undefined;
@@ -74,6 +95,17 @@ class BashCall {
74
95
  });
75
96
  }
76
97
 
98
+ /** Everything the foreground call printed that is still buffered, for the model-visible tail. */
99
+ foregroundOutput() {
100
+ if (this.isBackgrounded) return undefined;
101
+ const raw = Buffer.concat(this.chunks);
102
+ return { text: new TextDecoder().decode(raw), raw, dropped: this.dropped() };
103
+ }
104
+
105
+ private dropped(): DroppedOutput {
106
+ return { lines: this.droppedNewlines, bytes: this.droppedBytes };
107
+ }
108
+
77
109
  /** The `BashOperations.exec` Pi's `execute` calls. */
78
110
  async exec(
79
111
  command: string,
@@ -136,6 +168,7 @@ class BashCall {
136
168
  const dropped = this.chunks.shift();
137
169
  this.bufferedBytes -= dropped?.length ?? 0;
138
170
  this.droppedBytes += dropped?.length ?? 0;
171
+ if (dropped !== undefined) this.droppedNewlines += countNewlines(dropped);
139
172
  }
140
173
  this.forwardData?.(data);
141
174
  }
@@ -190,7 +223,12 @@ class BashCall {
190
223
  const output = this.decoder.decode(Buffer.concat(this.chunks), { stream: true });
191
224
  this.chunks.length = 0;
192
225
  this.remember(output);
193
- this.resolveBackground({ id: entry.id, logPath: entry.child.logPath, output });
226
+ this.resolveBackground({
227
+ id: entry.id,
228
+ logPath: entry.child.logPath,
229
+ output,
230
+ dropped: this.dropped(),
231
+ });
194
232
  return true;
195
233
  }
196
234
  }
@@ -244,6 +282,11 @@ export interface BashReplacementOptions {
244
282
  readonly shellPath: string | undefined;
245
283
  readonly registry: () => TermctrlRegistry;
246
284
  readonly calls: RunningBashCalls;
285
+ /**
286
+ * Limits on the model-visible tail of a result and of "output so far"; undefined keeps Pi's own
287
+ * limits. Pi's `bash` accepts no limits, so the replacement tightens what Pi returns.
288
+ */
289
+ readonly bashTail?: LineByteLimits | undefined;
247
290
  /** Builds Pi's `bash` definition; tests wrap it to observe what reaches Pi's accumulator. */
248
291
  readonly definitionFactory?: typeof createBashToolDefinition;
249
292
  }
@@ -269,25 +312,156 @@ function backgroundOutputSchema(builtin: TSchema | undefined) {
269
312
  });
270
313
  }
271
314
 
272
- function backgroundResult(outcome: BackgroundOutcome, startedAt: number) {
273
- const truncation = truncateTail(outcome.output);
315
+ /** The notice after a cut tail, naming the limits that cut it and where every line is. */
316
+ function tailNotice(truncation: TruncationResult, fullOutput: string, logPath: string): string {
317
+ const lastLine = truncation.totalLines;
318
+ if (truncation.lastLinePartial) {
319
+ const lineBytes = Buffer.byteLength(fullOutput.slice(fullOutput.lastIndexOf("\n") + 1));
320
+ return `[Showing last ${formatSize(truncation.outputBytes)} of line ${lastLine} (line is ${formatSize(lineBytes)}). Full output: ${logPath}]`;
321
+ }
322
+ const first = truncation.totalLines - truncation.outputLines + 1;
323
+ return `[Showing lines ${first}-${lastLine} of ${truncation.totalLines} (${formatSize(truncation.maxBytes)} or ${truncation.maxLines} line limit). Full output: ${logPath}]`;
324
+ }
325
+
326
+ /** The status Pi appends after the output on a failure, abort, or timeout. */
327
+ const PI_STATUS =
328
+ /\n\n(Command exited with code -?\d+|Command aborted|Command timed out after [^\n]+|Command terminated without an exit code)$/u;
329
+ /** Pi's cut notice, as the last thing before its status. */
330
+ const PI_NOTICE_AT_END = /\[Showing [^\n]*Full output: ([^\]\n]+)\]$/u;
331
+
332
+ /**
333
+ * Where Pi saved the full output of a call whose output Pi itself cut. A command can print text
334
+ * that looks like Pi's notice, so this is consulted only when Pi's own limits were exceeded, and a
335
+ * rejection's text is read only at the notice Pi puts directly before its status.
336
+ */
337
+ function piFullOutputPath(text: string, details: BashToolDetails | undefined): string | undefined {
338
+ if (details?.fullOutputPath !== undefined) return details.fullOutputPath;
339
+ const status = PI_STATUS.exec(text);
340
+ if (status === null) return undefined;
341
+ return PI_NOTICE_AT_END.exec(text.slice(0, status.index))?.[1];
342
+ }
343
+
344
+ /** Save the whole output beside Pi's own `pi-bash-*.log` files. */
345
+ function saveFullOutput(raw: Buffer): string | undefined {
346
+ const path = join(tmpdir(), `pi-bash-${randomBytes(8).toString("hex")}.log`);
347
+ try {
348
+ writeFileSync(path, raw, { mode: 0o600, flag: "wx" });
349
+ return path;
350
+ } catch {
351
+ return undefined;
352
+ }
353
+ }
354
+
355
+ /** `truncateTail` over a buffer that dropped its head: the totals count the dropped output too. */
356
+ function truncateBuffered(
357
+ output: string,
358
+ dropped: DroppedOutput,
359
+ limits: TruncationOptions,
360
+ ): TruncationResult {
361
+ const truncation = truncateTail(output, limits);
362
+ if (dropped.bytes === 0) return truncation;
363
+ return {
364
+ ...truncation,
365
+ truncated: true,
366
+ truncatedBy: truncation.truncatedBy ?? "bytes",
367
+ totalLines: truncation.totalLines + dropped.lines,
368
+ totalBytes: truncation.totalBytes + dropped.bytes,
369
+ };
370
+ }
371
+
372
+ /**
373
+ * Cut a foreground result's text to `limits`. Returns undefined when the output already fits, which
374
+ * leaves Pi's text as it is: Pi's limits are never tighter than `limits`.
375
+ */
376
+ function limitForegroundText(
377
+ call: BashCall,
378
+ piText: string,
379
+ piDetails: BashToolDetails | undefined,
380
+ limits: LineByteLimits,
381
+ hasStatus: boolean,
382
+ ) {
383
+ const output = call.foregroundOutput();
384
+ if (output === undefined) return undefined;
385
+ const truncation = truncateBuffered(output.text, output.dropped, {
386
+ maxLines: limits.maxLines,
387
+ maxBytes: limits.maxBytes,
388
+ });
389
+ if (!truncation.truncated) return undefined;
390
+ const piCut = truncateBuffered(output.text, output.dropped, {}).truncated;
391
+ const logPath =
392
+ (piCut ? piFullOutputPath(piText, piDetails) : undefined) ?? saveFullOutput(output.raw);
393
+ const notice = tailNotice(truncation, output.text, logPath ?? "(could not be saved)");
394
+ // Pi appends a status only to a failure; a successful command's output may merely look like one.
395
+ const status = hasStatus ? PI_STATUS.exec(piText) : null;
396
+ const suffix = status === null ? "" : `\n\n${status[1] ?? ""}`;
397
+ return {
398
+ text: `${truncation.content}\n\n${notice}${suffix}`,
399
+ details: logPath === undefined ? { truncation } : { truncation, fullOutputPath: logPath },
400
+ };
401
+ }
402
+
403
+ function backgroundResult(
404
+ outcome: BackgroundOutcome,
405
+ startedAt: number,
406
+ limits: LineByteLimits | undefined,
407
+ ) {
408
+ const truncation = truncateBuffered(outcome.output, outcome.dropped, limits ?? {});
274
409
  let text = truncation.content;
275
410
  if (truncation.truncated) {
276
- text += `\n\n[Showing lines ${truncation.totalLines - truncation.outputLines + 1}-${truncation.totalLines} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} or ${DEFAULT_MAX_LINES} line limit). Full output: ${outcome.logPath}]`;
411
+ text += `\n\n${tailNotice(truncation, outcome.output, outcome.logPath)}`;
277
412
  }
413
+ // Scripts get the same contract as a finished call's structured result: Pi's limits, not the tail.
414
+ const structured = truncateBuffered(outcome.output, outcome.dropped, {});
278
415
  const status = `Command moved to the background as ${outcome.id}. Its output so far and all later output go to ${outcome.logPath}. You will get an Exit notification when it ends, so do not poll the log in a loop; to block until it ends, call terminal_wait. Stop it with terminal_stop {"id": "${outcome.id}"}.`;
279
416
  return {
280
417
  content: [{ type: "text" as const, text: text === "" ? status : `${text}\n\n${status}` }],
281
418
  details: undefined,
282
419
  structuredContent: {
283
- output: truncation.content,
284
- truncated: truncation.truncated,
420
+ output: structured.content,
421
+ truncated: structured.truncated,
285
422
  wall_time_seconds: Math.round((performance.now() - startedAt) / 100) / 10,
286
423
  background: { id: outcome.id, log_path: outcome.logPath },
287
424
  },
288
425
  };
289
426
  }
290
427
 
428
+ type BashResult = Awaited<ReturnType<ReturnType<typeof createBashToolDefinition>["execute"]>>;
429
+
430
+ function limitResult(call: BashCall, result: BashResult, limits: LineByteLimits): BashResult {
431
+ const [first] = result.content;
432
+ if (first?.type !== "text") return result;
433
+ const limited = limitForegroundText(
434
+ call,
435
+ first.text,
436
+ result.details,
437
+ limits,
438
+ result.isError === true,
439
+ );
440
+ if (limited === undefined) return result;
441
+ return {
442
+ ...result,
443
+ content: [{ type: "text", text: limited.text }],
444
+ details: limited.details,
445
+ };
446
+ }
447
+
448
+ function limitError(call: BashCall, error: Error, limits: LineByteLimits): Error {
449
+ // Only Pi's output-plus-status messages are rewritten; any other error passes through.
450
+ if (!PI_STATUS.test(error.message)) return error;
451
+ const limited = limitForegroundText(call, error.message, undefined, limits, true);
452
+ return limited === undefined ? error : new Error(limited.text, { cause: error });
453
+ }
454
+
455
+ /** Pi's description names its own limits; name the ones in force instead. */
456
+ function describeTail(description: string, limits: LineByteLimits | undefined): string {
457
+ if (limits === undefined) return description;
458
+ const piLimits = `${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB`;
459
+ const ours = `${limits.maxLines} lines or ${formatSize(limits.maxBytes)}`;
460
+ // If Pi rewords its description, still tell the model the limits in force.
461
+ if (!description.includes(piLimits)) return `${description} Output is truncated to last ${ours}.`;
462
+ return description.replace(piLimits, ours);
463
+ }
464
+
291
465
  /** Pi's `bash` definition plus `background`, executed through Pi's own pipes. */
292
466
  export function createBashReplacement(options: BashReplacementOptions) {
293
467
  const builtinOptions: BashToolOptions = {};
@@ -308,6 +482,7 @@ export function createBashReplacement(options: BashReplacementOptions) {
308
482
  });
309
483
  return {
310
484
  ...builtin,
485
+ description: describeTail(builtin.description, options.bashTail),
311
486
  parameters,
312
487
  outputSchema: backgroundOutputSchema(builtin.outputSchema),
313
488
  async execute(
@@ -333,7 +508,8 @@ export function createBashReplacement(options: BashReplacementOptions) {
333
508
  if (!backgrounded) onUpdate(update);
334
509
  };
335
510
  const startedAt = performance.now();
336
- const running = inner.execute(
511
+ const limits = options.bashTail;
512
+ const piRunning = inner.execute(
337
513
  toolCallId,
338
514
  params.timeout === undefined
339
515
  ? { command: params.command }
@@ -342,6 +518,17 @@ export function createBashReplacement(options: BashReplacementOptions) {
342
518
  forwardUpdate,
343
519
  context,
344
520
  );
521
+ const running =
522
+ limits === undefined
523
+ ? piRunning
524
+ : piRunning.then(
525
+ (result) => limitResult(call, result, limits),
526
+ (error: Error) => {
527
+ // Pi's `bash` rejects only with Errors; anything else passes through unchanged.
528
+ if (!(error instanceof Error)) throw error;
529
+ throw limitError(call, error, limits);
530
+ },
531
+ );
345
532
  running.catch(() => {});
346
533
  options.calls.add(call, context.ui);
347
534
  const yieldTimer =
@@ -361,7 +548,7 @@ export function createBashReplacement(options: BashReplacementOptions) {
361
548
  ]);
362
549
  if (winner.kind === "finished") return winner.result;
363
550
  backgrounded = true;
364
- return backgroundResult(winner.outcome, startedAt);
551
+ return backgroundResult(winner.outcome, startedAt, options.bashTail);
365
552
  } finally {
366
553
  clearTimeout(yieldTimer);
367
554
  options.calls.delete(call);
@@ -17,7 +17,11 @@ import { TermctrlPsController } from "./ps-panel.js";
17
17
  import { resolveTermctrlBinary, type TermctrlBinaryResolution } from "./termctrl-binary.js";
18
18
  import { createTermctrlDriver } from "./termctrl-driver.js";
19
19
  import { TermctrlRegistry } from "./termctrl-registry.js";
20
- import { resolveTermctrlSettings, type TerminalViewport } from "./pi-termctrl-settings.js";
20
+ import {
21
+ resolveTermctrlSettings,
22
+ type LineByteLimits,
23
+ type TerminalViewport,
24
+ } from "./pi-termctrl-settings.js";
21
25
  import type { TerminalDriver } from "./terminal-driver.js";
22
26
  import {
23
27
  createTerminalListTool,
@@ -41,6 +45,7 @@ interface ActiveSession {
41
45
  readonly ps: TermctrlPsController;
42
46
  readonly shell: () => TerminalShell;
43
47
  readonly viewport: TerminalViewport;
48
+ readonly scrollback: LineByteLimits | undefined;
44
49
  readonly exitTailLines: number;
45
50
  }
46
51
 
@@ -91,6 +96,7 @@ class PiTermctrlController {
91
96
  registry: this.registry,
92
97
  shell: () => this.session().shell(),
93
98
  viewport: () => this.session().viewport,
99
+ scrollback: () => this.session().scrollback,
94
100
  };
95
101
  this.pi.registerTool(createTerminalStartTool(runtime));
96
102
  this.pi.registerTool(createTerminalSendTool(runtime));
@@ -120,7 +126,12 @@ class PiTermctrlController {
120
126
  }
121
127
 
122
128
  private registerManagementTools(): void {
123
- this.pi.registerTool(createTerminalStopTool(this.registry));
129
+ this.pi.registerTool(
130
+ createTerminalStopTool({
131
+ registry: this.registry,
132
+ scrollback: () => this.session().scrollback,
133
+ }),
134
+ );
124
135
  this.pi.registerTool(createTerminalListTool(this.registry));
125
136
  this.pi.registerTool(
126
137
  createTerminalWaitTool({
@@ -182,6 +193,7 @@ class PiTermctrlController {
182
193
  shellPath,
183
194
  registry: currentRegistry,
184
195
  calls,
196
+ bashTail: settings.bashTail,
185
197
  }),
186
198
  );
187
199
  }
@@ -194,6 +206,7 @@ class PiTermctrlController {
194
206
  return { shell: shell.shell, args: shell.args, commandPrefix };
195
207
  },
196
208
  viewport: settings.defaultViewport,
209
+ scrollback: settings.scrollback,
197
210
  exitTailLines: settings.exitTailLines,
198
211
  };
199
212
  }
@@ -1,4 +1,9 @@
1
- import type { SettingsManager } from "@earendil-works/pi-coding-agent";
1
+ import {
2
+ DEFAULT_MAX_BYTES,
3
+ DEFAULT_MAX_LINES,
4
+ type SettingsManager,
5
+ type TruncationOptions,
6
+ } from "@earendil-works/pi-coding-agent";
2
7
  import { Type } from "typebox";
3
8
  import { Value } from "typebox/value";
4
9
 
@@ -15,11 +20,24 @@ export interface TerminalViewport {
15
20
  readonly rows: number;
16
21
  }
17
22
 
23
+ /** The most lines and bytes of model-visible output that a setting allows. */
24
+ export type LineByteLimits = Readonly<Required<TruncationOptions>>;
25
+
18
26
  /** The effective `settings.termctrl` values after layering global and trusted project settings. */
19
27
  export interface ResolvedTermctrlSettings {
20
28
  readonly replaceBash: boolean;
21
29
  readonly defaultViewport: TerminalViewport;
22
30
  readonly exitTailLines: number;
31
+ /**
32
+ * Limits on the model-visible `bash` output: a foreground result or the output so far of a
33
+ * backgrounding one. Undefined restores Pi's own limits.
34
+ */
35
+ readonly bashTail: LineByteLimits | undefined;
36
+ /**
37
+ * Limits on the scrolled-off lines a Terminal result shows, kept from their start and end.
38
+ * Undefined restores Pi's own limits on the whole result.
39
+ */
40
+ readonly scrollback: LineByteLimits | undefined;
23
41
  readonly warnings: readonly string[];
24
42
  }
25
43
 
@@ -34,23 +52,42 @@ export interface TermctrlSettingsReader {
34
52
  getProjectSettings(): TermctrlSettingsDocumentInput;
35
53
  }
36
54
 
55
+ /** The settings that limit model-visible output. */
56
+ type LineByteLimitKey = "bashTail" | "scrollback";
57
+
58
+ /** One layer's value for a `bashTail` or `scrollback` setting. */
59
+ interface LineByteLimitsLayer {
60
+ enabled?: boolean;
61
+ lines?: number;
62
+ bytes?: number;
63
+ }
64
+
37
65
  interface TermctrlLayer {
38
66
  replaceBash?: boolean;
39
67
  cols?: number;
40
68
  rows?: number;
41
69
  exitTailLines?: number;
70
+ bashTail?: LineByteLimitsLayer;
71
+ scrollback?: LineByteLimitsLayer;
42
72
  }
43
73
 
44
- export const DEFAULT_TERMCTRL_SETTINGS: Omit<ResolvedTermctrlSettings, "warnings"> = {
74
+ export const DEFAULT_TERMCTRL_SETTINGS: Omit<
75
+ ResolvedTermctrlSettings,
76
+ "warnings" | LineByteLimitKey
77
+ > & { readonly [key in LineByteLimitKey]: LineByteLimits } = {
45
78
  replaceBash: true,
46
79
  defaultViewport: { cols: 120, rows: 40 },
47
80
  exitTailLines: 20,
81
+ bashTail: { maxLines: 300, maxBytes: 16 * 1024 },
82
+ scrollback: { maxLines: 100, maxBytes: 16 * 1024 },
48
83
  };
49
84
 
50
85
  const JsonObjectSchema = Type.Record(Type.String(), Type.Any());
51
86
  const SettingsDocumentSchema = Type.Object({ termctrl: Type.Optional(Type.Any()) });
52
87
  const BooleanSchema = Type.Boolean();
53
88
  const ViewportDimensionSchema = Type.Integer({ minimum: 1, maximum: VIEWPORT_LIMIT });
89
+ const LimitLinesSchema = Type.Integer({ minimum: 1, maximum: DEFAULT_MAX_LINES });
90
+ const LimitBytesSchema = Type.Integer({ minimum: 1, maximum: DEFAULT_MAX_BYTES });
54
91
  const ExitTailLinesSchema = Type.Integer({ minimum: 0, maximum: EXIT_TAIL_LIMIT });
55
92
 
56
93
  function isJsonObject(value: JsonValue): value is JsonObject {
@@ -84,6 +121,10 @@ function readLayer(
84
121
  if (Value.Check(ExitTailLinesSchema, value)) layer.exitTailLines = value;
85
122
  else warnings.push(`${path}: expected an integer from 0 to ${EXIT_TAIL_LIMIT}`);
86
123
  break;
124
+ case "bashTail":
125
+ case "scrollback":
126
+ layer[key] = readLineByteLimits(value, path, warnings);
127
+ break;
87
128
  case "defaultViewport":
88
129
  readViewport(value, path, layer, warnings);
89
130
  break;
@@ -109,6 +150,55 @@ function readViewport(value: JsonValue, path: string, layer: TermctrlLayer, warn
109
150
  }
110
151
  }
111
152
 
153
+ function readLineByteLimits(
154
+ value: JsonValue,
155
+ path: string,
156
+ warnings: string[],
157
+ ): LineByteLimitsLayer {
158
+ if (Value.Check(BooleanSchema, value)) return { enabled: value };
159
+ if (value === 0) return { enabled: false };
160
+ if (!isJsonObject(value)) {
161
+ warnings.push(`${path}: expected a boolean, 0, or a JSON object`);
162
+ return {};
163
+ }
164
+ const limits: LineByteLimitsLayer = {};
165
+ let valid = 0;
166
+ for (const [key, limit] of Object.entries(value)) {
167
+ if (key === "lines") {
168
+ if (Value.Check(LimitLinesSchema, limit)) {
169
+ limits.lines = limit;
170
+ valid++;
171
+ } else warnings.push(`${path}.lines: expected an integer from 1 to ${DEFAULT_MAX_LINES}`);
172
+ } else if (key === "bytes") {
173
+ if (Value.Check(LimitBytesSchema, limit)) {
174
+ limits.bytes = limit;
175
+ valid++;
176
+ } else warnings.push(`${path}.bytes: expected an integer from 1 to ${DEFAULT_MAX_BYTES}`);
177
+ } else {
178
+ warnings.push(`${path}.${key}: unknown field`);
179
+ }
180
+ }
181
+ // An object with only invalid fields must not override a lower layer's opt-out.
182
+ if (valid > 0 || Object.keys(value).length === 0) limits.enabled = true;
183
+ return limits;
184
+ }
185
+
186
+ /** Layer a `bashTail` or `scrollback` setting: the project's fields over the global ones, over the defaults. */
187
+ function resolveLineByteLimits(
188
+ key: LineByteLimitKey,
189
+ globalLayer: TermctrlLayer,
190
+ projectLayer: TermctrlLayer,
191
+ ): LineByteLimits | undefined {
192
+ const global = globalLayer[key];
193
+ const project = projectLayer[key];
194
+ if (!(project?.enabled ?? global?.enabled ?? true)) return undefined;
195
+ const defaults = DEFAULT_TERMCTRL_SETTINGS[key];
196
+ return {
197
+ maxLines: project?.lines ?? global?.lines ?? defaults.maxLines,
198
+ maxBytes: project?.bytes ?? global?.bytes ?? defaults.maxBytes,
199
+ };
200
+ }
201
+
112
202
  /** Resolve global and trusted-project `termctrl` settings, keeping valid fields around warnings. */
113
203
  export function resolveTermctrlSettings(reader: TermctrlSettingsReader): ResolvedTermctrlSettings {
114
204
  const warnings: string[] = [];
@@ -123,6 +213,8 @@ export function resolveTermctrlSettings(reader: TermctrlSettingsReader): Resolve
123
213
  },
124
214
  exitTailLines:
125
215
  projectLayer.exitTailLines ?? globalLayer.exitTailLines ?? defaults.exitTailLines,
216
+ bashTail: resolveLineByteLimits("bashTail", globalLayer, projectLayer),
217
+ scrollback: resolveLineByteLimits("scrollback", globalLayer, projectLayer),
126
218
  warnings,
127
219
  };
128
220
  }
@@ -13,7 +13,7 @@ import type { TerminalViewport } from "./pi-termctrl-settings.js";
13
13
  /** The `globalThis` key every registry version shares. Its value always satisfies {@link RegistrySlot}. */
14
14
  export const REGISTRY_KEY = Symbol.for("@ian-pascoe/pi-termctrl/registry");
15
15
  /** Bump on any change to the state's shape: a reloaded module must not adopt an older shape. */
16
- export const REGISTRY_VERSION = 4;
16
+ export const REGISTRY_VERSION = 6;
17
17
 
18
18
  /** Live Terminals plus Background jobs allowed across the whole process. */
19
19
  export const LIVE_ENTRY_CAP = 16;
@@ -71,8 +71,10 @@ export interface TerminalEntry extends EntryBase {
71
71
  readonly cwd: string;
72
72
  /** The screen captured when the Terminal exited. */
73
73
  finalScreen: string | undefined;
74
- /** Number of agent tool calls currently driving this Terminal; the exit watcher skips them. */
74
+ /** Number of agent tool calls running or queued to drive this Terminal; the exit watcher skips them. */
75
75
  activeCalls: number;
76
+ /** The agent's calls to this Terminal: one runs at a time and the rest wait in arrival order. */
77
+ readonly callQueue: { running: boolean; readonly waiting: (() => void)[] };
76
78
  /** The screen the agent last received, for `changed: false`. */
77
79
  lastScreen: string | undefined;
78
80
  /** Count of log lines that scrolled off before the agent's last result. */
@@ -82,6 +84,11 @@ export interface TerminalEntry extends EntryBase {
82
84
  * last lines before the cursor, or, while the cursor is 0, the log's first line.
83
85
  */
84
86
  logAnchor: readonly string[];
87
+ /**
88
+ * The screen rows the agent last received, which start at `logCursor` in the log; `undefined`
89
+ * marks a row cut from that result. Lines that scroll off unchanged are not reported again.
90
+ */
91
+ seenRows: readonly (string | undefined)[];
85
92
  }
86
93
 
87
94
  /** A `bash` command moved to the background. */
@@ -457,9 +464,11 @@ export class TermctrlRegistry {
457
464
  generation: slot.generation,
458
465
  finalScreen: undefined,
459
466
  activeCalls: 0,
467
+ callQueue: { running: false, waiting: [] },
460
468
  lastScreen: undefined,
461
469
  logCursor: 0,
462
470
  logAnchor: [],
471
+ seenRows: [],
463
472
  };
464
473
  this.state.entries.set(id, entry);
465
474
  this.syncWatcher();
@@ -7,7 +7,6 @@ import {
7
7
  formatSize,
8
8
  truncateTail,
9
9
  type ExtensionToolContext,
10
- type TruncationResult,
11
10
  } from "@earendil-works/pi-coding-agent";
12
11
  import type { Key } from "@kitlangton/terminal-control";
13
12
  import { Type, type Static } from "typebox";
@@ -19,7 +18,7 @@ import type {
19
18
  } from "./termctrl-registry.js";
20
19
  import { describeExitStatus, formatExitNotice, lastLines } from "./exit-notification.js";
21
20
  import { termctrlTemporaryDirectory } from "./termctrl-driver.js";
22
- import type { TerminalViewport } from "./pi-termctrl-settings.js";
21
+ import type { LineByteLimits, TerminalViewport } from "./pi-termctrl-settings.js";
23
22
  import type { TerminalExit, TerminalSnapshot } from "./terminal-driver.js";
24
23
  import { TROUBLESHOOTING_HINT } from "./troubleshooting-skill.js";
25
24
 
@@ -73,8 +72,13 @@ export interface TerminalToolRuntime {
73
72
  readonly registry: TermctrlRegistry;
74
73
  readonly shell: () => TerminalShell;
75
74
  readonly viewport: () => TerminalViewport;
75
+ /** The `termctrl.scrollback` limits; undefined leaves only Pi's limits on the whole result. */
76
+ readonly scrollback: () => LineByteLimits | undefined;
76
77
  }
77
78
 
79
+ /** What building a Terminal result reads; `terminal_stop` also stops Background jobs and needs only this. */
80
+ export type TerminalResultRuntime = Pick<TerminalToolRuntime, "registry" | "scrollback">;
81
+
78
82
  const TerminalStateSchema = Type.Union([Type.Literal("running"), Type.Literal("exited")]);
79
83
 
80
84
  const SettleReasonSchema = Type.Union(
@@ -405,10 +409,11 @@ function locateScreen(lines: readonly string[], screen: readonly string[], floor
405
409
 
406
410
  /**
407
411
  * Lines that left the screen since the previous result, advancing the Terminal's log cursor.
408
- * Lines still on the result's screen stay unread, so a line rewritten after the agent saw it, such
409
- * as a progress line, is reported in its final form once it scrolls off. When the cursor cannot be
410
- * found again, because termctrl dropped lines the agent never received or the screen was cleared,
411
- * every line termctrl still holds is reported and the result says that earlier output is missing.
412
+ * Lines still on the result's screen stay unread. Once they scroll off, a line the agent saw is
413
+ * skipped if it is unchanged, while a line rewritten since, such as a progress line or a prompt the
414
+ * agent typed at, is reported in its final form. When the cursor cannot be found again, because
415
+ * termctrl dropped lines the agent never received or the screen was cleared, every line termctrl
416
+ * still holds is reported and the result says that earlier output is missing.
412
417
  */
413
418
  async function takeScrolledOff(entry: TerminalEntry, screen: string): Promise<ScrolledOff> {
414
419
  let logs: string;
@@ -421,7 +426,10 @@ async function takeScrolledOff(entry: TerminalEntry, screen: string): Promise<Sc
421
426
  const located = locateCursor(lines, entry.logCursor, entry.logAnchor);
422
427
  const screenLines = screen === "" ? [] : screen.split("\n");
423
428
  const boundary = locateScreen(lines, screenLines, located ?? 0);
424
- const scrolled = lines.slice(Math.min(located ?? 0, boundary), boundary);
429
+ const seen = located === undefined ? [] : entry.seenRows;
430
+ const scrolled = lines
431
+ .slice(Math.min(located ?? 0, boundary), boundary)
432
+ .filter((line, row) => line !== seen[row]);
425
433
  entry.logCursor = boundary;
426
434
  entry.logAnchor =
427
435
  boundary > 0 ? lines.slice(Math.max(0, boundary - ANCHOR_LINES), boundary) : lines.slice(0, 1);
@@ -442,35 +450,135 @@ interface FittedOutput {
442
450
  readonly notice?: string;
443
451
  }
444
452
 
445
- function emptyTruncation(content: string): TruncationResult {
446
- const totalLines = content === "" ? 0 : content.split("\n").length;
453
+ /** The scrolled-off lines a result shows. */
454
+ interface ShownScrolled {
455
+ readonly content: string;
456
+ readonly truncated: boolean;
457
+ readonly totalLines: number;
458
+ readonly outputLines: number;
459
+ /** Describes a cut to the `termctrl.scrollback` limits; absent for a cut to Pi's limits alone. */
460
+ readonly summary?: string;
461
+ }
462
+
463
+ function omissionMarker(count: number): string {
464
+ return `[… ${count} lines omitted …]`;
465
+ }
466
+
467
+ function describeLimits(limits: LineByteLimits): string {
468
+ return `(${formatSize(limits.maxBytes)} or ${limits.maxLines} line limit)`;
469
+ }
470
+
471
+ /**
472
+ * Keep the first and last scrolled-off lines within `limits`, splitting them evenly and marking
473
+ * the omitted lines between them on a line of its own. Returns `undefined` when the limits leave
474
+ * no room for a line beside the marker.
475
+ */
476
+ function keepStartAndEnd(
477
+ lines: readonly string[],
478
+ limits: LineByteLimits,
479
+ ): ShownScrolled | undefined {
480
+ // Each kept line is counted with the newline that joins it to the marker or its neighbour.
481
+ const bodyLines = limits.maxLines - 1;
482
+ const bodyBytes = limits.maxBytes - Buffer.byteLength(omissionMarker(lines.length), "utf8");
483
+ if (bodyLines < 1 || bodyBytes < 2) return undefined;
484
+ const head: string[] = [];
485
+ let headBytes = 0;
486
+ const headLines = Math.ceil(bodyLines / 2);
487
+ const headByteLimit = Math.floor(bodyBytes / 2);
488
+ for (const line of lines) {
489
+ const size = Buffer.byteLength(line, "utf8") + 1;
490
+ if (head.length >= headLines || headBytes + size > headByteLimit) break;
491
+ head.push(line);
492
+ headBytes += size;
493
+ }
494
+ const tail: string[] = [];
495
+ let tailBytes = 0;
496
+ let lastLineCut = false;
497
+ const tailByteLimit = bodyBytes - headBytes;
498
+ for (let index = lines.length - 1; index >= head.length; index--) {
499
+ if (tail.length >= bodyLines - head.length) break;
500
+ const line = lines[index] ?? "";
501
+ const size = Buffer.byteLength(line, "utf8") + 1;
502
+ if (tailBytes + size > tailByteLimit) {
503
+ // A last line too long for the limits on its own still shows its end.
504
+ if (head.length === 0 && tail.length === 0) {
505
+ tail.push(truncateTail(line, { maxLines: 1, maxBytes: tailByteLimit - 1 }).content);
506
+ lastLineCut = true;
507
+ }
508
+ break;
509
+ }
510
+ tail.unshift(line);
511
+ tailBytes += size;
512
+ }
513
+ const omitted = lines.length - head.length - tail.length;
514
+ const kept = omitted > 0 ? [...head, omissionMarker(omitted), ...tail] : [...head, ...tail];
515
+ const shown = lastLineCut
516
+ ? `the end of scrolled-off line ${lines.length} of ${lines.length}`
517
+ : `the first ${head.length} and last ${tail.length} of ${lines.length} scrolled-off lines`;
518
+ return {
519
+ content: kept.join("\n"),
520
+ truncated: true,
521
+ totalLines: lines.length,
522
+ outputLines: head.length + tail.length,
523
+ summary: `Showing ${shown} ${describeLimits(limits)}.`,
524
+ };
525
+ }
526
+
527
+ /** Keep the newest scrolled-off lines within `limits`. */
528
+ function keepNewest(lines: readonly string[], limits: LineByteLimits): ShownScrolled {
529
+ const newest = truncateTail(lines.join("\n"), limits);
530
+ return {
531
+ content: newest.content,
532
+ truncated: newest.truncated,
533
+ totalLines: lines.length,
534
+ outputLines: newest.outputLines,
535
+ };
536
+ }
537
+
538
+ /**
539
+ * Fit the scrolled-off lines into the lines and bytes the screen left. With `termctrl.scrollback`
540
+ * limits, a cut keeps their start and end; without them, their newest lines.
541
+ */
542
+ function fitScrolled(
543
+ lines: readonly string[],
544
+ room: LineByteLimits,
545
+ scrollback: LineByteLimits | undefined,
546
+ ): ShownScrolled {
547
+ if (room.maxLines <= 0 || room.maxBytes <= 0) {
548
+ return { content: "", truncated: lines.length > 0, totalLines: lines.length, outputLines: 0 };
549
+ }
550
+ if (scrollback === undefined) return keepNewest(lines, room);
551
+ const limits = {
552
+ maxLines: Math.min(scrollback.maxLines, room.maxLines),
553
+ maxBytes: Math.min(scrollback.maxBytes, room.maxBytes),
554
+ };
555
+ const text = lines.join("\n");
556
+ if (lines.length <= limits.maxLines && Buffer.byteLength(text, "utf8") <= limits.maxBytes) {
557
+ return { content: text, truncated: false, totalLines: lines.length, outputLines: lines.length };
558
+ }
559
+ const kept = keepStartAndEnd(lines, limits);
560
+ if (kept !== undefined) return kept;
561
+ const newest = keepNewest(lines, limits);
447
562
  return {
448
- content: "",
449
- truncated: content !== "",
450
- truncatedBy: content === "" ? null : "bytes",
451
- totalLines,
452
- totalBytes: Buffer.byteLength(content, "utf8"),
453
- outputLines: 0,
454
- outputBytes: 0,
455
- lastLinePartial: false,
456
- firstLineExceedsLimit: false,
457
- maxLines: 0,
458
- maxBytes: 0,
563
+ ...newest,
564
+ summary: `Showing the last ${newest.outputLines} of ${lines.length} scrolled-off lines ${describeLimits(limits)}.`,
459
565
  };
460
566
  }
461
567
 
462
568
  /**
463
569
  * Fit a result into Pi's 2000-line and 50 KB limits. The screen is kept first, from its bottom;
464
- * the newest scrolled-off lines fill what is left. When anything is cut, every line of the result
465
- * goes to a full output file that lasts until the owner's session shuts down.
570
+ * the scrolled-off lines fill what is left, within the `termctrl.scrollback` limits. When anything
571
+ * is cut, every line of the result goes to a full output file that lasts until the owner's session
572
+ * shuts down.
466
573
  */
467
574
  async function fitOutput(
468
575
  registry: TermctrlRegistry,
469
576
  entry: TerminalEntry,
470
577
  screen: string,
471
578
  scrolled: ScrolledOff,
579
+ scrollback: LineByteLimits | undefined,
472
580
  ): Promise<FittedOutput> {
473
- const fitted = await truncateOutput(registry, entry, screen, scrolled.lines);
581
+ const fitted = await truncateOutput(registry, entry, screen, scrolled.lines, scrollback);
474
582
  if (!scrolled.gap) return { ...fitted, gap: false };
475
583
  const notice = fitted.notice === undefined ? GAP_NOTICE : `${GAP_NOTICE}\n${fitted.notice}`;
476
584
  return { ...fitted, gap: true, notice };
@@ -481,24 +589,29 @@ async function truncateOutput(
481
589
  entry: TerminalEntry,
482
590
  screen: string,
483
591
  scrolled: readonly string[],
592
+ scrollback: LineByteLimits | undefined,
484
593
  ): Promise<Omit<FittedOutput, "gap">> {
485
594
  const limits = { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES };
486
- const scrolledText = scrolled.join("\n");
487
595
  const shownScreen = truncateTail(screen, limits);
488
- const lineBudget = limits.maxLines - shownScreen.outputLines;
489
- // One byte for the newline that joins the scrolled-off lines to the screen.
490
- const byteBudget = limits.maxBytes - shownScreen.outputBytes - 1;
491
- const shownScrolled =
492
- lineBudget > 0 && byteBudget > 0
493
- ? truncateTail(scrolledText, { maxLines: lineBudget, maxBytes: byteBudget })
494
- : emptyTruncation(scrolledText);
596
+ // A screen cut to fit leaves no room. One byte for the newline that joins the scrolled-off lines
597
+ // to the screen.
598
+ const room = shownScreen.truncated
599
+ ? { maxLines: 0, maxBytes: 0 }
600
+ : {
601
+ maxLines: limits.maxLines - shownScreen.outputLines,
602
+ maxBytes: limits.maxBytes - shownScreen.outputBytes - 1,
603
+ };
604
+ const shownScrolled = fitScrolled(scrolled, room, scrollback);
605
+ const scrolledText = scrolled.join("\n");
495
606
  if (!shownScreen.truncated && !shownScrolled.truncated)
496
607
  return { screen, scrolledOff: scrolledText };
497
608
 
498
609
  const total = shownScrolled.totalLines + shownScreen.totalLines;
499
610
  const shown = shownScrolled.outputLines + shownScreen.outputLines;
500
611
  const fitted = { screen: shownScreen.content, scrolledOff: shownScrolled.content };
501
- const range = `[Showing lines ${total - shown + 1}-${total} of ${total} (${formatSize(DEFAULT_MAX_BYTES)} or ${DEFAULT_MAX_LINES} line limit).`;
612
+ const summary =
613
+ shownScrolled.summary ??
614
+ `Showing lines ${total - shown + 1}-${total} of ${total} (${formatSize(DEFAULT_MAX_BYTES)} or ${DEFAULT_MAX_LINES} line limit).`;
502
615
  const directory = termctrlTemporaryDirectory();
503
616
  let path: string;
504
617
  try {
@@ -509,9 +622,21 @@ async function truncateOutput(
509
622
  await writeFile(path, full, { encoding: "utf8", mode: 0o600 });
510
623
  } catch (cause) {
511
624
  const reason = cause instanceof Error ? cause.message : String(cause);
512
- return { ...fitted, notice: `${range} The full output could not be saved: ${reason}]` };
625
+ return { ...fitted, notice: `[${summary} The full output could not be saved: ${reason}]` };
513
626
  }
514
- return { ...fitted, fullOutputPath: path, notice: `${range} Full output: ${path}]` };
627
+ return { ...fitted, fullOutputPath: path, notice: `[${summary} Full output: ${path}]` };
628
+ }
629
+
630
+ /**
631
+ * The rows of `screen` that the agent received, aligned with `screen`: rows cut from the top of a
632
+ * screen too large for the result, or shortened to fit it, are `undefined`.
633
+ */
634
+ function shownRows(screen: string, shown: string): (string | undefined)[] {
635
+ const rows = screen === "" ? [] : screen.split("\n");
636
+ if (shown === screen) return rows;
637
+ const kept = shown === "" ? [] : shown.split("\n");
638
+ const cut = rows.length - kept.length;
639
+ return rows.map((row, index) => (kept[index - cut] === row ? row : undefined));
515
640
  }
516
641
 
517
642
  /** Says that `wait_for_text` was not seen when its wait timed out. */
@@ -544,7 +669,7 @@ function formatTerminalText(
544
669
 
545
670
  /** Build the agent's view of a Terminal after a wait, recording the exit as seen. */
546
671
  async function terminalResult(
547
- registry: TermctrlRegistry,
672
+ { registry, scrollback }: TerminalResultRuntime,
548
673
  entry: TerminalEntry,
549
674
  snapshot: TerminalSnapshot | undefined,
550
675
  settleReason: SettleReason,
@@ -555,7 +680,9 @@ async function terminalResult(
555
680
  entry.state === "running" || snapshot !== undefined
556
681
  ? await takeScrolledOff(entry, screen)
557
682
  : NOTHING_SCROLLED;
558
- const output = await fitOutput(registry, entry, screen, scrolled);
683
+ const output = await fitOutput(registry, entry, screen, scrolled, scrollback());
684
+ // The screen now starts at the log cursor; when the cursor did not move, neither do the rows.
685
+ if (scrolled !== NOTHING_SCROLLED) entry.seenRows = shownRows(screen, output.screen);
559
686
  if (snapshot?.state === "exited") {
560
687
  registry.terminalExited(entry.id, snapshot.exit ?? { code: null, signal: null }, screen, true);
561
688
  }
@@ -580,17 +707,72 @@ async function terminalResult(
580
707
  };
581
708
  }
582
709
 
710
+ /** A call that was removed from its Terminal's queue before it started. */
711
+ class CallCancelledError extends Error {
712
+ constructor(id: string) {
713
+ super(`The call to ${id} was cancelled before it started.`);
714
+ this.name = "CallCancelledError";
715
+ }
716
+ }
717
+
718
+ /**
719
+ * Wait for this call's turn on a Terminal. Calls to one Terminal run one at a time, each typing,
720
+ * pressing keys and settling before the next starts, so a parallel batch of sends gets screens
721
+ * that match its calls. Calls to other Terminals are unaffected. Aborting a waiting call removes
722
+ * it from the queue and rejects it; the running call is untouched.
723
+ */
724
+ function awaitTurn(entry: TerminalEntry, signal: AbortSignal | undefined): Promise<() => void> {
725
+ const queue = entry.callQueue;
726
+ const release = () => {
727
+ const next = queue.waiting.shift();
728
+ if (next === undefined) queue.running = false;
729
+ else next();
730
+ };
731
+ if (signal?.aborted === true) return Promise.reject(new CallCancelledError(entry.id));
732
+ if (!queue.running) {
733
+ queue.running = true;
734
+ return Promise.resolve(release);
735
+ }
736
+ return new Promise((resolve, reject) => {
737
+ const onAbort = () => {
738
+ const index = queue.waiting.indexOf(start);
739
+ if (index !== -1) queue.waiting.splice(index, 1);
740
+ reject(new CallCancelledError(entry.id));
741
+ };
742
+ const start = () => {
743
+ signal?.removeEventListener("abort", onAbort);
744
+ resolve(release);
745
+ };
746
+ queue.waiting.push(start);
747
+ signal?.addEventListener("abort", onAbort, { once: true });
748
+ });
749
+ }
750
+
751
+ /**
752
+ * Run one call against a Terminal in its turn. A call that waited finds the Terminal as the calls
753
+ * ahead left it, so `run` must read the Terminal's state itself. A Terminal that a call ahead
754
+ * stopped is gone by then.
755
+ */
583
756
  async function driveTerminal<T>(
584
757
  registry: TermctrlRegistry,
585
758
  entry: TerminalEntry,
759
+ signal: AbortSignal | undefined,
586
760
  run: () => Promise<T>,
587
761
  ): Promise<T> {
762
+ // Counted while queued too, so the exit watcher leaves a Terminal the agent is driving alone.
588
763
  entry.activeCalls++;
764
+ let release: (() => void) | undefined;
589
765
  try {
766
+ release = await awaitTurn(entry, signal);
767
+ if (registry.find(entry.owner, entry.id) !== entry) throw unknownId(entry.id);
590
768
  return await run();
591
769
  } catch (cause) {
592
770
  const error = cause instanceof Error ? cause : new Error(String(cause));
593
- if (registry.reportTerminalError(entry, error)) {
771
+ if (
772
+ release !== undefined &&
773
+ !(error instanceof InputToExitedError) &&
774
+ registry.reportTerminalError(entry, error)
775
+ ) {
594
776
  throw new Error(
595
777
  `${entry.id} was lost because the termctrl driver exited\n\n${TROUBLESHOOTING_HINT}`,
596
778
  { cause },
@@ -598,6 +780,7 @@ async function driveTerminal<T>(
598
780
  }
599
781
  throw error;
600
782
  } finally {
783
+ release?.();
601
784
  entry.activeCalls--;
602
785
  }
603
786
  }
@@ -665,7 +848,9 @@ export function createTerminalStartTool(runtime: TerminalToolRuntime) {
665
848
  });
666
849
  // Launching, including a cold driver start, does not count toward the quiet period.
667
850
  const startedAt = Date.now();
668
- return driveTerminal(runtime.registry, entry, async () => {
851
+ // The Terminal is running and nothing else knows its id yet, so the call never queues; an
852
+ // abort ends the wait but must still return the id, or the agent could not reach the Terminal.
853
+ return driveTerminal(runtime.registry, entry, undefined, async () => {
669
854
  const { snapshot, reason } = await settleTerminal(entry, {
670
855
  mode: "start",
671
856
  waitMs: clampWait(params.wait_ms, START_WAIT_MS),
@@ -674,16 +859,23 @@ export function createTerminalStartTool(runtime: TerminalToolRuntime) {
674
859
  baseline: undefined,
675
860
  signal,
676
861
  });
677
- return terminalResult(runtime.registry, entry, snapshot, reason);
862
+ return terminalResult(runtime, entry, snapshot, reason);
678
863
  });
679
864
  },
680
865
  });
681
866
  }
682
867
 
683
- function inputToExited(id: string, exit: TerminalExit | null): Error {
684
- return new Error(
685
- `${id} ${describeExit(exit)} and accepts no input. Poll it with terminal_send for its final screen, or remove it with terminal_stop.`,
686
- );
868
+ /**
869
+ * Input to a Terminal that exited. It is the agent's mistake, not a driver failure, even when the
870
+ * exit says the driver died: that loss was already reported.
871
+ */
872
+ class InputToExitedError extends Error {
873
+ constructor(id: string, exit: TerminalExit | null) {
874
+ super(
875
+ `${id} ${describeExit(exit)} and accepts no input. Poll it with terminal_send for its final screen, or remove it with terminal_stop.`,
876
+ );
877
+ this.name = "InputToExitedError";
878
+ }
687
879
  }
688
880
 
689
881
  /** `terminal_send`: type text and keys into a Terminal, or poll it, and wait for it to settle. */
@@ -718,15 +910,13 @@ export function createTerminalSendTool(runtime: TerminalToolRuntime) {
718
910
  );
719
911
  }
720
912
  const hasInput = (params.text ?? "") !== "" || keys.length > 0;
721
- if (entry.state === "exited") {
722
- if (hasInput) {
913
+ return driveTerminal(runtime.registry, entry, signal, async () => {
914
+ if (entry.state === "exited") {
915
+ if (!hasInput) return terminalResult(runtime, entry, undefined, "exited");
723
916
  // The error tells the agent about the exit, so a deferred Exit notification is redundant.
724
917
  runtime.registry.markSeen(entry.id);
725
- throw inputToExited(entry.id, entry.exit);
918
+ throw new InputToExitedError(entry.id, entry.exit);
726
919
  }
727
- return terminalResult(runtime.registry, entry, undefined, "exited");
728
- }
729
- return driveTerminal(runtime.registry, entry, async () => {
730
920
  const matches =
731
921
  params.wait_for_text === undefined ? undefined : parseWaitPattern(params.wait_for_text);
732
922
  const before =
@@ -735,7 +925,7 @@ export function createTerminalSendTool(runtime: TerminalToolRuntime) {
735
925
  // The exit watcher has not noticed this exit yet.
736
926
  const exit = before.exit ?? { code: null, signal: null };
737
927
  runtime.registry.terminalExited(entry.id, exit, before.screen, true);
738
- throw inputToExited(entry.id, exit);
928
+ throw new InputToExitedError(entry.id, exit);
739
929
  }
740
930
  const baseline = matches === undefined ? undefined : before?.screen;
741
931
  const startedAt = Date.now();
@@ -751,7 +941,7 @@ export function createTerminalSendTool(runtime: TerminalToolRuntime) {
751
941
  signal,
752
942
  });
753
943
  return terminalResult(
754
- runtime.registry,
944
+ runtime,
755
945
  entry,
756
946
  snapshot,
757
947
  reason,
@@ -763,22 +953,43 @@ export function createTerminalSendTool(runtime: TerminalToolRuntime) {
763
953
  }
764
954
 
765
955
  /** Capture the lines a Terminal scrolled off since the agent's last result, before it is stopped. */
766
- async function finalScrolledOff(
767
- registry: TermctrlRegistry,
768
- entry: TerminalEntry,
769
- ): Promise<ScrolledOff> {
956
+ async function finalScrolledOff(entry: TerminalEntry): Promise<ScrolledOff> {
770
957
  try {
771
958
  if (entry.state === "exited") return await takeScrolledOff(entry, entry.finalScreen ?? "");
772
- return await driveTerminal(registry, entry, async () =>
773
- takeScrolledOff(entry, (await entry.handle.snapshot()).screen),
774
- );
959
+ return await takeScrolledOff(entry, (await entry.handle.snapshot()).screen);
775
960
  } catch {
776
961
  return NOTHING_SCROLLED;
777
962
  }
778
963
  }
779
964
 
965
+ /** What a stop saw of a Terminal at its turn, before stopping it. */
966
+ interface StopView {
967
+ readonly wasRunning: boolean;
968
+ readonly previousScreen: string | undefined;
969
+ readonly scrolled: ScrolledOff;
970
+ readonly entry: TermctrlEntry;
971
+ }
972
+
973
+ /**
974
+ * Stop a Terminal in its turn: the calls ahead have finished, so `wasRunning` and the previous
975
+ * screen are what they left, and calls queued behind find the Terminal gone.
976
+ */
977
+ function stopTerminalInTurn(
978
+ registry: TermctrlRegistry,
979
+ known: TerminalEntry,
980
+ signal: AbortSignal | undefined,
981
+ ): Promise<StopView> {
982
+ return driveTerminal(registry, known, signal, async () => {
983
+ const wasRunning = known.state === "running";
984
+ const previousScreen = known.lastScreen;
985
+ const scrolled = await finalScrolledOff(known);
986
+ const entry = (await registry.stop(known.owner, known.id)) ?? known;
987
+ return { wasRunning, previousScreen, scrolled, entry };
988
+ });
989
+ }
990
+
780
991
  /** `terminal_stop`: stop a Terminal or Background job and forget it. */
781
- export function createTerminalStopTool(registry: TermctrlRegistry) {
992
+ export function createTerminalStopTool({ registry, scrollback }: TerminalResultRuntime) {
782
993
  return defineTool<typeof StopParameters, StopResult>({
783
994
  name: "terminal_stop",
784
995
  label: "terminal_stop",
@@ -793,15 +1004,19 @@ export function createTerminalStopTool(registry: TermctrlRegistry) {
793
1004
  openWorldHint: false,
794
1005
  },
795
1006
  outputSchema: StopResultSchema,
796
- async execute(_toolCallId, params, _signal, _onUpdate, context) {
1007
+ async execute(_toolCallId, params, signal, _onUpdate, context) {
797
1008
  const owner = ownerOf(context);
798
1009
  const known = registry.find(owner, params.id);
799
1010
  if (known === undefined) throw unknownId(params.id);
800
- const wasRunning = known.state === "running";
801
- const previousScreen = known.kind === "terminal" ? known.lastScreen : undefined;
802
- const scrolled =
803
- known.kind === "terminal" ? await finalScrolledOff(registry, known) : NOTHING_SCROLLED;
804
- const entry = (await registry.stop(owner, params.id)) ?? known;
1011
+ const { wasRunning, previousScreen, scrolled, entry } =
1012
+ known.kind === "terminal"
1013
+ ? await stopTerminalInTurn(registry, known, signal)
1014
+ : {
1015
+ wasRunning: known.state === "running",
1016
+ previousScreen: undefined,
1017
+ scrolled: NOTHING_SCROLLED,
1018
+ entry: (await registry.stop(owner, params.id)) ?? known,
1019
+ };
805
1020
  const label = entry.kind === "terminal" ? "Terminal" : "Background job";
806
1021
  const header = wasRunning
807
1022
  ? `${label} ${entry.id} stopped.`
@@ -818,7 +1033,13 @@ export function createTerminalStopTool(registry: TermctrlRegistry) {
818
1033
  const changed = previousScreen !== screen;
819
1034
  // A screen the agent already saw is not repeated, whether the Terminal was running or exited.
820
1035
  const repeated = !changed;
821
- const output = await fitOutput(registry, entry, repeated ? "" : screen, scrolled);
1036
+ const output = await fitOutput(
1037
+ registry,
1038
+ entry,
1039
+ repeated ? "" : screen,
1040
+ scrolled,
1041
+ scrollback(),
1042
+ );
822
1043
  result.changed = changed;
823
1044
  if (!repeated) result.screen = output.screen;
824
1045
  if (!repeated || output.scrolledOff !== "") result.scrolled_off = output.scrolledOff;