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