@ian-pascoe/pi-termctrl 0.4.1 → 0.5.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
@@ -119,8 +126,8 @@ Each terminal tool declares MCP-style `annotations`, which Pi reports through `p
119
126
  With `termctrl.replaceBash` (default `true`), Pi Termctrl replaces Pi's `bash`
120
127
  tool with Pi's own definition plus one parameter, `background?: boolean`.
121
128
  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:
129
+ process-tree kills and rendering, and foreground results differ from Pi's only
130
+ by the shorter tail below. A command becomes a Background job in two ways:
124
131
 
125
132
  - **`background: true`** waits 2 s. A command that finishes first returns an
126
133
  ordinary result; otherwise the call returns a background result. If the cap
@@ -139,6 +146,16 @@ notification or call `terminal_wait`; read the log with `read` and stop the job
139
146
  with `terminal_stop`. Its structured result follows Pi's `bash` output schema, except
140
147
  that `exit_code` is absent and `background: { id, log_path }` is present.
141
148
 
149
+ **Output tail.** With `termctrl.bashTail` (default `{ lines: 300, bytes: 16384 }`),
150
+ the output the model sees from a foreground `bash` call, and the "output so far" of
151
+ a backgrounding result, is cut to the last 300 lines or 16 KB, whichever is hit
152
+ first, instead of Pi's 2,000 lines or 50 KB. The cut keeps Pi's notice, for example
153
+ `[Showing lines 4701-5000 of 5000 (16.0KB or 300 line limit). Full output: /tmp/pi-bash-….log]`,
154
+ and names the limits actually used. The named file always holds every line: Pi's own
155
+ full-output file when Pi also cut the output, otherwise one Pi Termctrl writes beside
156
+ it in `$TMPDIR` (like Pi's, it is not deleted). The tool description names the limits in force. Set `bashTail` to `false`
157
+ 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.
158
+
142
159
  A Background job's log is deleted when the job is stopped, when the user
143
160
  removes it in `/ps`, or when its session shuts down for any reason except
144
161
  reload. `bash` calls from `codemode` scripts behave the same. User `!` commands
@@ -196,16 +213,18 @@ by field:
196
213
  "termctrl": {
197
214
  "replaceBash": true,
198
215
  "defaultViewport": { "cols": 120, "rows": 40 },
199
- "exitTailLines": 20
216
+ "exitTailLines": 20,
217
+ "bashTail": { "lines": 300, "bytes": 16384 }
200
218
  }
201
219
  }
202
220
  ```
203
221
 
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 |
222
+ | Setting | Default | Meaning |
223
+ | ----------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------ |
224
+ | `replaceBash` | `true` | Replace `bash` to enable Background jobs |
225
+ | `defaultViewport` | `{ cols: 120, rows: 40 }` | Terminal size; each dimension 1 to 1000 |
226
+ | `exitTailLines` | `20` | Lines of output in an Exit notification, 0–1000 |
227
+ | `bashTail` | `{ lines: 300, bytes: 16384 }` | Model-visible `bash` output tail: `lines` 1–2000, `bytes` 1–51200; `false` or `0` restores Pi's limits |
209
228
 
210
229
  Invalid values keep the lower layer and unknown keys produce warnings at session
211
230
  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.5.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.
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 { BashTailLimits } 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?: BashTailLimits | 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: BashTailLimits,
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: BashTailLimits | 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: BashTailLimits): 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: BashTailLimits): 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: BashTailLimits | 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);
@@ -182,6 +182,7 @@ class PiTermctrlController {
182
182
  shellPath,
183
183
  registry: currentRegistry,
184
184
  calls,
185
+ bashTail: settings.bashTail,
185
186
  }),
186
187
  );
187
188
  }
@@ -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,16 @@ export interface TerminalViewport {
15
20
  readonly rows: number;
16
21
  }
17
22
 
23
+ /** The most lines and bytes of the model-visible `bash` output: a foreground result or the output so far of a backgrounding one. */
24
+ export type BashTailLimits = 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
+ /** Limits on the model-visible `bash` tail; undefined restores Pi's own limits. */
32
+ readonly bashTail: BashTailLimits | undefined;
23
33
  readonly warnings: readonly string[];
24
34
  }
25
35
 
@@ -39,18 +49,26 @@ interface TermctrlLayer {
39
49
  cols?: number;
40
50
  rows?: number;
41
51
  exitTailLines?: number;
52
+ bashTailEnabled?: boolean;
53
+ bashTailLines?: number;
54
+ bashTailBytes?: number;
42
55
  }
43
56
 
44
- export const DEFAULT_TERMCTRL_SETTINGS: Omit<ResolvedTermctrlSettings, "warnings"> = {
57
+ export const DEFAULT_TERMCTRL_SETTINGS: Omit<ResolvedTermctrlSettings, "warnings" | "bashTail"> & {
58
+ readonly bashTail: BashTailLimits;
59
+ } = {
45
60
  replaceBash: true,
46
61
  defaultViewport: { cols: 120, rows: 40 },
47
62
  exitTailLines: 20,
63
+ bashTail: { maxLines: 300, maxBytes: 16 * 1024 },
48
64
  };
49
65
 
50
66
  const JsonObjectSchema = Type.Record(Type.String(), Type.Any());
51
67
  const SettingsDocumentSchema = Type.Object({ termctrl: Type.Optional(Type.Any()) });
52
68
  const BooleanSchema = Type.Boolean();
53
69
  const ViewportDimensionSchema = Type.Integer({ minimum: 1, maximum: VIEWPORT_LIMIT });
70
+ const BashTailLinesSchema = Type.Integer({ minimum: 1, maximum: DEFAULT_MAX_LINES });
71
+ const BashTailBytesSchema = Type.Integer({ minimum: 1, maximum: DEFAULT_MAX_BYTES });
54
72
  const ExitTailLinesSchema = Type.Integer({ minimum: 0, maximum: EXIT_TAIL_LIMIT });
55
73
 
56
74
  function isJsonObject(value: JsonValue): value is JsonObject {
@@ -84,6 +102,9 @@ function readLayer(
84
102
  if (Value.Check(ExitTailLinesSchema, value)) layer.exitTailLines = value;
85
103
  else warnings.push(`${path}: expected an integer from 0 to ${EXIT_TAIL_LIMIT}`);
86
104
  break;
105
+ case "bashTail":
106
+ readBashTail(value, path, layer, warnings);
107
+ break;
87
108
  case "defaultViewport":
88
109
  readViewport(value, path, layer, warnings);
89
110
  break;
@@ -109,6 +130,39 @@ function readViewport(value: JsonValue, path: string, layer: TermctrlLayer, warn
109
130
  }
110
131
  }
111
132
 
133
+ function readBashTail(value: JsonValue, path: string, layer: TermctrlLayer, warnings: string[]) {
134
+ if (Value.Check(BooleanSchema, value)) {
135
+ layer.bashTailEnabled = value;
136
+ return;
137
+ }
138
+ if (value === 0) {
139
+ layer.bashTailEnabled = false;
140
+ return;
141
+ }
142
+ if (!isJsonObject(value)) {
143
+ warnings.push(`${path}: expected a boolean, 0, or a JSON object`);
144
+ return;
145
+ }
146
+ let valid = 0;
147
+ for (const [key, limit] of Object.entries(value)) {
148
+ if (key === "lines") {
149
+ if (Value.Check(BashTailLinesSchema, limit)) {
150
+ layer.bashTailLines = limit;
151
+ valid++;
152
+ } else warnings.push(`${path}.lines: expected an integer from 1 to ${DEFAULT_MAX_LINES}`);
153
+ } else if (key === "bytes") {
154
+ if (Value.Check(BashTailBytesSchema, limit)) {
155
+ layer.bashTailBytes = limit;
156
+ valid++;
157
+ } else warnings.push(`${path}.bytes: expected an integer from 1 to ${DEFAULT_MAX_BYTES}`);
158
+ } else {
159
+ warnings.push(`${path}.${key}: unknown field`);
160
+ }
161
+ }
162
+ // An object with only invalid fields must not override a lower layer's opt-out.
163
+ if (valid > 0 || Object.keys(value).length === 0) layer.bashTailEnabled = true;
164
+ }
165
+
112
166
  /** Resolve global and trusted-project `termctrl` settings, keeping valid fields around warnings. */
113
167
  export function resolveTermctrlSettings(reader: TermctrlSettingsReader): ResolvedTermctrlSettings {
114
168
  const warnings: string[] = [];
@@ -123,6 +177,15 @@ export function resolveTermctrlSettings(reader: TermctrlSettingsReader): Resolve
123
177
  },
124
178
  exitTailLines:
125
179
  projectLayer.exitTailLines ?? globalLayer.exitTailLines ?? defaults.exitTailLines,
180
+ bashTail:
181
+ (projectLayer.bashTailEnabled ?? globalLayer.bashTailEnabled ?? true)
182
+ ? {
183
+ maxLines:
184
+ projectLayer.bashTailLines ?? globalLayer.bashTailLines ?? defaults.bashTail.maxLines,
185
+ maxBytes:
186
+ projectLayer.bashTailBytes ?? globalLayer.bashTailBytes ?? defaults.bashTail.maxBytes,
187
+ }
188
+ : undefined,
126
189
  warnings,
127
190
  };
128
191
  }
@@ -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 = 5;
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. */
@@ -457,6 +459,7 @@ export class TermctrlRegistry {
457
459
  generation: slot.generation,
458
460
  finalScreen: undefined,
459
461
  activeCalls: 0,
462
+ callQueue: { running: false, waiting: [] },
460
463
  lastScreen: undefined,
461
464
  logCursor: 0,
462
465
  logAnchor: [],
@@ -580,17 +580,72 @@ async function terminalResult(
580
580
  };
581
581
  }
582
582
 
583
+ /** A call that was removed from its Terminal's queue before it started. */
584
+ class CallCancelledError extends Error {
585
+ constructor(id: string) {
586
+ super(`The call to ${id} was cancelled before it started.`);
587
+ this.name = "CallCancelledError";
588
+ }
589
+ }
590
+
591
+ /**
592
+ * Wait for this call's turn on a Terminal. Calls to one Terminal run one at a time, each typing,
593
+ * pressing keys and settling before the next starts, so a parallel batch of sends gets screens
594
+ * that match its calls. Calls to other Terminals are unaffected. Aborting a waiting call removes
595
+ * it from the queue and rejects it; the running call is untouched.
596
+ */
597
+ function awaitTurn(entry: TerminalEntry, signal: AbortSignal | undefined): Promise<() => void> {
598
+ const queue = entry.callQueue;
599
+ const release = () => {
600
+ const next = queue.waiting.shift();
601
+ if (next === undefined) queue.running = false;
602
+ else next();
603
+ };
604
+ if (signal?.aborted === true) return Promise.reject(new CallCancelledError(entry.id));
605
+ if (!queue.running) {
606
+ queue.running = true;
607
+ return Promise.resolve(release);
608
+ }
609
+ return new Promise((resolve, reject) => {
610
+ const onAbort = () => {
611
+ const index = queue.waiting.indexOf(start);
612
+ if (index !== -1) queue.waiting.splice(index, 1);
613
+ reject(new CallCancelledError(entry.id));
614
+ };
615
+ const start = () => {
616
+ signal?.removeEventListener("abort", onAbort);
617
+ resolve(release);
618
+ };
619
+ queue.waiting.push(start);
620
+ signal?.addEventListener("abort", onAbort, { once: true });
621
+ });
622
+ }
623
+
624
+ /**
625
+ * Run one call against a Terminal in its turn. A call that waited finds the Terminal as the calls
626
+ * ahead left it, so `run` must read the Terminal's state itself. A Terminal that a call ahead
627
+ * stopped is gone by then.
628
+ */
583
629
  async function driveTerminal<T>(
584
630
  registry: TermctrlRegistry,
585
631
  entry: TerminalEntry,
632
+ signal: AbortSignal | undefined,
586
633
  run: () => Promise<T>,
587
634
  ): Promise<T> {
635
+ // Counted while queued too, so the exit watcher leaves a Terminal the agent is driving alone.
588
636
  entry.activeCalls++;
637
+ let release: (() => void) | undefined;
589
638
  try {
639
+ release = await awaitTurn(entry, signal);
640
+ if (registry.find(entry.owner, entry.id) !== entry) throw unknownId(entry.id);
590
641
  return await run();
591
642
  } catch (cause) {
592
643
  const error = cause instanceof Error ? cause : new Error(String(cause));
593
- if (registry.reportTerminalError(entry, error)) {
644
+ if (
645
+ release !== undefined &&
646
+ !(error instanceof InputToExitedError) &&
647
+ registry.reportTerminalError(entry, error)
648
+ ) {
594
649
  throw new Error(
595
650
  `${entry.id} was lost because the termctrl driver exited\n\n${TROUBLESHOOTING_HINT}`,
596
651
  { cause },
@@ -598,6 +653,7 @@ async function driveTerminal<T>(
598
653
  }
599
654
  throw error;
600
655
  } finally {
656
+ release?.();
601
657
  entry.activeCalls--;
602
658
  }
603
659
  }
@@ -665,7 +721,9 @@ export function createTerminalStartTool(runtime: TerminalToolRuntime) {
665
721
  });
666
722
  // Launching, including a cold driver start, does not count toward the quiet period.
667
723
  const startedAt = Date.now();
668
- return driveTerminal(runtime.registry, entry, async () => {
724
+ // The Terminal is running and nothing else knows its id yet, so the call never queues; an
725
+ // abort ends the wait but must still return the id, or the agent could not reach the Terminal.
726
+ return driveTerminal(runtime.registry, entry, undefined, async () => {
669
727
  const { snapshot, reason } = await settleTerminal(entry, {
670
728
  mode: "start",
671
729
  waitMs: clampWait(params.wait_ms, START_WAIT_MS),
@@ -680,10 +738,17 @@ export function createTerminalStartTool(runtime: TerminalToolRuntime) {
680
738
  });
681
739
  }
682
740
 
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
- );
741
+ /**
742
+ * Input to a Terminal that exited. It is the agent's mistake, not a driver failure, even when the
743
+ * exit says the driver died: that loss was already reported.
744
+ */
745
+ class InputToExitedError extends Error {
746
+ constructor(id: string, exit: TerminalExit | null) {
747
+ super(
748
+ `${id} ${describeExit(exit)} and accepts no input. Poll it with terminal_send for its final screen, or remove it with terminal_stop.`,
749
+ );
750
+ this.name = "InputToExitedError";
751
+ }
687
752
  }
688
753
 
689
754
  /** `terminal_send`: type text and keys into a Terminal, or poll it, and wait for it to settle. */
@@ -718,15 +783,13 @@ export function createTerminalSendTool(runtime: TerminalToolRuntime) {
718
783
  );
719
784
  }
720
785
  const hasInput = (params.text ?? "") !== "" || keys.length > 0;
721
- if (entry.state === "exited") {
722
- if (hasInput) {
786
+ return driveTerminal(runtime.registry, entry, signal, async () => {
787
+ if (entry.state === "exited") {
788
+ if (!hasInput) return terminalResult(runtime.registry, entry, undefined, "exited");
723
789
  // The error tells the agent about the exit, so a deferred Exit notification is redundant.
724
790
  runtime.registry.markSeen(entry.id);
725
- throw inputToExited(entry.id, entry.exit);
791
+ throw new InputToExitedError(entry.id, entry.exit);
726
792
  }
727
- return terminalResult(runtime.registry, entry, undefined, "exited");
728
- }
729
- return driveTerminal(runtime.registry, entry, async () => {
730
793
  const matches =
731
794
  params.wait_for_text === undefined ? undefined : parseWaitPattern(params.wait_for_text);
732
795
  const before =
@@ -735,7 +798,7 @@ export function createTerminalSendTool(runtime: TerminalToolRuntime) {
735
798
  // The exit watcher has not noticed this exit yet.
736
799
  const exit = before.exit ?? { code: null, signal: null };
737
800
  runtime.registry.terminalExited(entry.id, exit, before.screen, true);
738
- throw inputToExited(entry.id, exit);
801
+ throw new InputToExitedError(entry.id, exit);
739
802
  }
740
803
  const baseline = matches === undefined ? undefined : before?.screen;
741
804
  const startedAt = Date.now();
@@ -763,20 +826,41 @@ export function createTerminalSendTool(runtime: TerminalToolRuntime) {
763
826
  }
764
827
 
765
828
  /** 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> {
829
+ async function finalScrolledOff(entry: TerminalEntry): Promise<ScrolledOff> {
770
830
  try {
771
831
  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
- );
832
+ return await takeScrolledOff(entry, (await entry.handle.snapshot()).screen);
775
833
  } catch {
776
834
  return NOTHING_SCROLLED;
777
835
  }
778
836
  }
779
837
 
838
+ /** What a stop saw of a Terminal at its turn, before stopping it. */
839
+ interface StopView {
840
+ readonly wasRunning: boolean;
841
+ readonly previousScreen: string | undefined;
842
+ readonly scrolled: ScrolledOff;
843
+ readonly entry: TermctrlEntry;
844
+ }
845
+
846
+ /**
847
+ * Stop a Terminal in its turn: the calls ahead have finished, so `wasRunning` and the previous
848
+ * screen are what they left, and calls queued behind find the Terminal gone.
849
+ */
850
+ function stopTerminalInTurn(
851
+ registry: TermctrlRegistry,
852
+ known: TerminalEntry,
853
+ signal: AbortSignal | undefined,
854
+ ): Promise<StopView> {
855
+ return driveTerminal(registry, known, signal, async () => {
856
+ const wasRunning = known.state === "running";
857
+ const previousScreen = known.lastScreen;
858
+ const scrolled = await finalScrolledOff(known);
859
+ const entry = (await registry.stop(known.owner, known.id)) ?? known;
860
+ return { wasRunning, previousScreen, scrolled, entry };
861
+ });
862
+ }
863
+
780
864
  /** `terminal_stop`: stop a Terminal or Background job and forget it. */
781
865
  export function createTerminalStopTool(registry: TermctrlRegistry) {
782
866
  return defineTool<typeof StopParameters, StopResult>({
@@ -793,15 +877,19 @@ export function createTerminalStopTool(registry: TermctrlRegistry) {
793
877
  openWorldHint: false,
794
878
  },
795
879
  outputSchema: StopResultSchema,
796
- async execute(_toolCallId, params, _signal, _onUpdate, context) {
880
+ async execute(_toolCallId, params, signal, _onUpdate, context) {
797
881
  const owner = ownerOf(context);
798
882
  const known = registry.find(owner, params.id);
799
883
  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;
884
+ const { wasRunning, previousScreen, scrolled, entry } =
885
+ known.kind === "terminal"
886
+ ? await stopTerminalInTurn(registry, known, signal)
887
+ : {
888
+ wasRunning: known.state === "running",
889
+ previousScreen: undefined,
890
+ scrolled: NOTHING_SCROLLED,
891
+ entry: (await registry.stop(owner, params.id)) ?? known,
892
+ };
805
893
  const label = entry.kind === "terminal" ? "Terminal" : "Background job";
806
894
  const header = wasRunning
807
895
  ? `${label} ${entry.id} stopped.`