@ian-pascoe/pi-termctrl 0.5.0 → 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
@@ -64,10 +64,11 @@ code or signal; a poll (neither `text` nor `keys`) still returns its final scree
64
64
 
65
65
  Results contain the visible screen, the log lines that scrolled off since the
66
66
  agent's previous result, `state`, `exit_code` or `signal` once exited, and
67
- `changed: false` when the screen matches the previous result. Scrolled-off lines
68
- include lines the agent saw on an earlier screen, so a line rewritten in place,
69
- such as a progress line, arrives in its final form. Tools that declare
70
- 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.
71
72
 
72
73
  Scrolled-off lines are best effort: they come from termctrl's scrollback, which
73
74
  is limited (several hundred lines; fewer when lines are long) and drops its
@@ -80,11 +81,14 @@ scrollback holds before that result, its oldest lines are lost without
80
81
  `output_missing`, because termctrl does not report the trimming. Run commands
81
82
  whose complete output matters with `bash`, or redirect their output to a file.
82
83
 
83
- A result is limited to Pi's 2000 lines or 50 KB. The screen is kept first, from
84
- its bottom, and the newest scrolled-off lines fill the rest. When anything is
85
- cut, every line of the result goes to
86
- `$TMPDIR/pi-termctrl/<pid>-<id>-output-<n>.log`, named in the result's notice
87
- 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`.
88
92
 
89
93
  `keys` are termctrl key names: `Enter`, `Escape`, `ArrowUp`, `ArrowDown`,
90
94
  `ArrowLeft`, `ArrowRight`, `Tab`, `Shift+Tab`, `Backspace`, `Delete`, `Home`,
@@ -214,17 +218,19 @@ by field:
214
218
  "replaceBash": true,
215
219
  "defaultViewport": { "cols": 120, "rows": 40 },
216
220
  "exitTailLines": 20,
217
- "bashTail": { "lines": 300, "bytes": 16384 }
221
+ "bashTail": { "lines": 300, "bytes": 16384 },
222
+ "scrollback": { "lines": 100, "bytes": 16384 }
218
223
  }
219
224
  }
220
225
  ```
221
226
 
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 |
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` |
228
234
 
229
235
  Invalid values keep the lower layer and unknown keys produce warnings at session
230
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.5.0",
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,7 @@ 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. 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.
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
15
  6. Classify the failure as settings, binary resolution, `bash` conflict, cap, or process behaviour.
16
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.
17
17
 
@@ -21,7 +21,7 @@ import {
21
21
  import { matchesKey } from "@earendil-works/pi-tui";
22
22
  import { Type, type TSchema } from "typebox";
23
23
  import { KILLED_EXIT, TermctrlRegistry } from "./termctrl-registry.js";
24
- import type { BashTailLimits } from "./pi-termctrl-settings.js";
24
+ import type { LineByteLimits } from "./pi-termctrl-settings.js";
25
25
  import { termctrlTemporaryDirectory } from "./termctrl-driver.js";
26
26
  import type { TerminalExit } from "./terminal-driver.js";
27
27
 
@@ -286,7 +286,7 @@ export interface BashReplacementOptions {
286
286
  * Limits on the model-visible tail of a result and of "output so far"; undefined keeps Pi's own
287
287
  * limits. Pi's `bash` accepts no limits, so the replacement tightens what Pi returns.
288
288
  */
289
- readonly bashTail?: BashTailLimits | undefined;
289
+ readonly bashTail?: LineByteLimits | undefined;
290
290
  /** Builds Pi's `bash` definition; tests wrap it to observe what reaches Pi's accumulator. */
291
291
  readonly definitionFactory?: typeof createBashToolDefinition;
292
292
  }
@@ -377,7 +377,7 @@ function limitForegroundText(
377
377
  call: BashCall,
378
378
  piText: string,
379
379
  piDetails: BashToolDetails | undefined,
380
- limits: BashTailLimits,
380
+ limits: LineByteLimits,
381
381
  hasStatus: boolean,
382
382
  ) {
383
383
  const output = call.foregroundOutput();
@@ -403,7 +403,7 @@ function limitForegroundText(
403
403
  function backgroundResult(
404
404
  outcome: BackgroundOutcome,
405
405
  startedAt: number,
406
- limits: BashTailLimits | undefined,
406
+ limits: LineByteLimits | undefined,
407
407
  ) {
408
408
  const truncation = truncateBuffered(outcome.output, outcome.dropped, limits ?? {});
409
409
  let text = truncation.content;
@@ -427,7 +427,7 @@ function backgroundResult(
427
427
 
428
428
  type BashResult = Awaited<ReturnType<ReturnType<typeof createBashToolDefinition>["execute"]>>;
429
429
 
430
- function limitResult(call: BashCall, result: BashResult, limits: BashTailLimits): BashResult {
430
+ function limitResult(call: BashCall, result: BashResult, limits: LineByteLimits): BashResult {
431
431
  const [first] = result.content;
432
432
  if (first?.type !== "text") return result;
433
433
  const limited = limitForegroundText(
@@ -445,7 +445,7 @@ function limitResult(call: BashCall, result: BashResult, limits: BashTailLimits)
445
445
  };
446
446
  }
447
447
 
448
- function limitError(call: BashCall, error: Error, limits: BashTailLimits): Error {
448
+ function limitError(call: BashCall, error: Error, limits: LineByteLimits): Error {
449
449
  // Only Pi's output-plus-status messages are rewritten; any other error passes through.
450
450
  if (!PI_STATUS.test(error.message)) return error;
451
451
  const limited = limitForegroundText(call, error.message, undefined, limits, true);
@@ -453,7 +453,7 @@ function limitError(call: BashCall, error: Error, limits: BashTailLimits): Error
453
453
  }
454
454
 
455
455
  /** Pi's description names its own limits; name the ones in force instead. */
456
- function describeTail(description: string, limits: BashTailLimits | undefined): string {
456
+ function describeTail(description: string, limits: LineByteLimits | undefined): string {
457
457
  if (limits === undefined) return description;
458
458
  const piLimits = `${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB`;
459
459
  const ours = `${limits.maxLines} lines or ${formatSize(limits.maxBytes)}`;
@@ -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({
@@ -195,6 +206,7 @@ class PiTermctrlController {
195
206
  return { shell: shell.shell, args: shell.args, commandPrefix };
196
207
  },
197
208
  viewport: settings.defaultViewport,
209
+ scrollback: settings.scrollback,
198
210
  exitTailLines: settings.exitTailLines,
199
211
  };
200
212
  }
@@ -20,16 +20,24 @@ export interface TerminalViewport {
20
20
  readonly rows: number;
21
21
  }
22
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>>;
23
+ /** The most lines and bytes of model-visible output that a setting allows. */
24
+ export type LineByteLimits = Readonly<Required<TruncationOptions>>;
25
25
 
26
26
  /** The effective `settings.termctrl` values after layering global and trusted project settings. */
27
27
  export interface ResolvedTermctrlSettings {
28
28
  readonly replaceBash: boolean;
29
29
  readonly defaultViewport: TerminalViewport;
30
30
  readonly exitTailLines: number;
31
- /** Limits on the model-visible `bash` tail; undefined restores Pi's own limits. */
32
- readonly bashTail: BashTailLimits | undefined;
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;
33
41
  readonly warnings: readonly string[];
34
42
  }
35
43
 
@@ -44,31 +52,42 @@ export interface TermctrlSettingsReader {
44
52
  getProjectSettings(): TermctrlSettingsDocumentInput;
45
53
  }
46
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
+
47
65
  interface TermctrlLayer {
48
66
  replaceBash?: boolean;
49
67
  cols?: number;
50
68
  rows?: number;
51
69
  exitTailLines?: number;
52
- bashTailEnabled?: boolean;
53
- bashTailLines?: number;
54
- bashTailBytes?: number;
70
+ bashTail?: LineByteLimitsLayer;
71
+ scrollback?: LineByteLimitsLayer;
55
72
  }
56
73
 
57
- export const DEFAULT_TERMCTRL_SETTINGS: Omit<ResolvedTermctrlSettings, "warnings" | "bashTail"> & {
58
- readonly bashTail: BashTailLimits;
59
- } = {
74
+ export const DEFAULT_TERMCTRL_SETTINGS: Omit<
75
+ ResolvedTermctrlSettings,
76
+ "warnings" | LineByteLimitKey
77
+ > & { readonly [key in LineByteLimitKey]: LineByteLimits } = {
60
78
  replaceBash: true,
61
79
  defaultViewport: { cols: 120, rows: 40 },
62
80
  exitTailLines: 20,
63
81
  bashTail: { maxLines: 300, maxBytes: 16 * 1024 },
82
+ scrollback: { maxLines: 100, maxBytes: 16 * 1024 },
64
83
  };
65
84
 
66
85
  const JsonObjectSchema = Type.Record(Type.String(), Type.Any());
67
86
  const SettingsDocumentSchema = Type.Object({ termctrl: Type.Optional(Type.Any()) });
68
87
  const BooleanSchema = Type.Boolean();
69
88
  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 });
89
+ const LimitLinesSchema = Type.Integer({ minimum: 1, maximum: DEFAULT_MAX_LINES });
90
+ const LimitBytesSchema = Type.Integer({ minimum: 1, maximum: DEFAULT_MAX_BYTES });
72
91
  const ExitTailLinesSchema = Type.Integer({ minimum: 0, maximum: EXIT_TAIL_LIMIT });
73
92
 
74
93
  function isJsonObject(value: JsonValue): value is JsonObject {
@@ -103,7 +122,8 @@ function readLayer(
103
122
  else warnings.push(`${path}: expected an integer from 0 to ${EXIT_TAIL_LIMIT}`);
104
123
  break;
105
124
  case "bashTail":
106
- readBashTail(value, path, layer, warnings);
125
+ case "scrollback":
126
+ layer[key] = readLineByteLimits(value, path, warnings);
107
127
  break;
108
128
  case "defaultViewport":
109
129
  readViewport(value, path, layer, warnings);
@@ -130,29 +150,28 @@ function readViewport(value: JsonValue, path: string, layer: TermctrlLayer, warn
130
150
  }
131
151
  }
132
152
 
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
- }
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 };
142
160
  if (!isJsonObject(value)) {
143
161
  warnings.push(`${path}: expected a boolean, 0, or a JSON object`);
144
- return;
162
+ return {};
145
163
  }
164
+ const limits: LineByteLimitsLayer = {};
146
165
  let valid = 0;
147
166
  for (const [key, limit] of Object.entries(value)) {
148
167
  if (key === "lines") {
149
- if (Value.Check(BashTailLinesSchema, limit)) {
150
- layer.bashTailLines = limit;
168
+ if (Value.Check(LimitLinesSchema, limit)) {
169
+ limits.lines = limit;
151
170
  valid++;
152
171
  } else warnings.push(`${path}.lines: expected an integer from 1 to ${DEFAULT_MAX_LINES}`);
153
172
  } else if (key === "bytes") {
154
- if (Value.Check(BashTailBytesSchema, limit)) {
155
- layer.bashTailBytes = limit;
173
+ if (Value.Check(LimitBytesSchema, limit)) {
174
+ limits.bytes = limit;
156
175
  valid++;
157
176
  } else warnings.push(`${path}.bytes: expected an integer from 1 to ${DEFAULT_MAX_BYTES}`);
158
177
  } else {
@@ -160,7 +179,24 @@ function readBashTail(value: JsonValue, path: string, layer: TermctrlLayer, warn
160
179
  }
161
180
  }
162
181
  // 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;
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
+ };
164
200
  }
165
201
 
166
202
  /** Resolve global and trusted-project `termctrl` settings, keeping valid fields around warnings. */
@@ -177,15 +213,8 @@ export function resolveTermctrlSettings(reader: TermctrlSettingsReader): Resolve
177
213
  },
178
214
  exitTailLines:
179
215
  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,
216
+ bashTail: resolveLineByteLimits("bashTail", globalLayer, projectLayer),
217
+ scrollback: resolveLineByteLimits("scrollback", globalLayer, projectLayer),
189
218
  warnings,
190
219
  };
191
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 = 5;
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;
@@ -84,6 +84,11 @@ export interface TerminalEntry extends EntryBase {
84
84
  * last lines before the cursor, or, while the cursor is 0, the log's first line.
85
85
  */
86
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)[];
87
92
  }
88
93
 
89
94
  /** A `bash` command moved to the background. */
@@ -463,6 +468,7 @@ export class TermctrlRegistry {
463
468
  lastScreen: undefined,
464
469
  logCursor: 0,
465
470
  logAnchor: [],
471
+ seenRows: [],
466
472
  };
467
473
  this.state.entries.set(id, entry);
468
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
  }
@@ -732,7 +859,7 @@ export function createTerminalStartTool(runtime: TerminalToolRuntime) {
732
859
  baseline: undefined,
733
860
  signal,
734
861
  });
735
- return terminalResult(runtime.registry, entry, snapshot, reason);
862
+ return terminalResult(runtime, entry, snapshot, reason);
736
863
  });
737
864
  },
738
865
  });
@@ -785,7 +912,7 @@ export function createTerminalSendTool(runtime: TerminalToolRuntime) {
785
912
  const hasInput = (params.text ?? "") !== "" || keys.length > 0;
786
913
  return driveTerminal(runtime.registry, entry, signal, async () => {
787
914
  if (entry.state === "exited") {
788
- if (!hasInput) return terminalResult(runtime.registry, entry, undefined, "exited");
915
+ if (!hasInput) return terminalResult(runtime, entry, undefined, "exited");
789
916
  // The error tells the agent about the exit, so a deferred Exit notification is redundant.
790
917
  runtime.registry.markSeen(entry.id);
791
918
  throw new InputToExitedError(entry.id, entry.exit);
@@ -814,7 +941,7 @@ export function createTerminalSendTool(runtime: TerminalToolRuntime) {
814
941
  signal,
815
942
  });
816
943
  return terminalResult(
817
- runtime.registry,
944
+ runtime,
818
945
  entry,
819
946
  snapshot,
820
947
  reason,
@@ -862,7 +989,7 @@ function stopTerminalInTurn(
862
989
  }
863
990
 
864
991
  /** `terminal_stop`: stop a Terminal or Background job and forget it. */
865
- export function createTerminalStopTool(registry: TermctrlRegistry) {
992
+ export function createTerminalStopTool({ registry, scrollback }: TerminalResultRuntime) {
866
993
  return defineTool<typeof StopParameters, StopResult>({
867
994
  name: "terminal_stop",
868
995
  label: "terminal_stop",
@@ -906,7 +1033,13 @@ export function createTerminalStopTool(registry: TermctrlRegistry) {
906
1033
  const changed = previousScreen !== screen;
907
1034
  // A screen the agent already saw is not repeated, whether the Terminal was running or exited.
908
1035
  const repeated = !changed;
909
- 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
+ );
910
1043
  result.changed = changed;
911
1044
  if (!repeated) result.screen = output.screen;
912
1045
  if (!repeated || output.scrolledOff !== "") result.scrolled_off = output.scrolledOff;