@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 +22 -16
- package/package.json +1 -1
- package/skills/pi-termctrl/SKILL.md +1 -1
- package/src/bash-replacement.ts +7 -7
- package/src/pi-termctrl-extension.ts +14 -2
- package/src/pi-termctrl-settings.ts +66 -37
- package/src/termctrl-registry.ts +7 -1
- package/src/terminal-tools.ts +174 -41
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.
|
|
68
|
-
|
|
69
|
-
such as a progress line
|
|
70
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
@@ -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
|
|
package/src/bash-replacement.ts
CHANGED
|
@@ -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 {
|
|
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?:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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 {
|
|
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(
|
|
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
|
|
24
|
-
export type
|
|
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
|
-
/**
|
|
32
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
bashTailBytes?: number;
|
|
70
|
+
bashTail?: LineByteLimitsLayer;
|
|
71
|
+
scrollback?: LineByteLimitsLayer;
|
|
55
72
|
}
|
|
56
73
|
|
|
57
|
-
export const DEFAULT_TERMCTRL_SETTINGS: Omit<
|
|
58
|
-
|
|
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
|
|
71
|
-
const
|
|
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
|
-
|
|
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
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
if (value
|
|
139
|
-
|
|
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(
|
|
150
|
-
|
|
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(
|
|
155
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
}
|
package/src/termctrl-registry.ts
CHANGED
|
@@ -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 =
|
|
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();
|
package/src/terminal-tools.ts
CHANGED
|
@@ -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,
|
|
409
|
-
*
|
|
410
|
-
*
|
|
411
|
-
*
|
|
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
|
|
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
|
-
|
|
446
|
-
|
|
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
|
-
|
|
449
|
-
|
|
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
|
|
465
|
-
* goes to a full output file that lasts until the owner's session
|
|
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
|
-
|
|
489
|
-
//
|
|
490
|
-
const
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
|
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:
|
|
625
|
+
return { ...fitted, notice: `[${summary} The full output could not be saved: ${reason}]` };
|
|
513
626
|
}
|
|
514
|
-
return { ...fitted, fullOutputPath: path, notice:
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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(
|
|
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;
|