@ian-pascoe/pi-termctrl 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -8
- 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 +1 -0
- package/src/pi-termctrl-settings.ts +65 -2
- package/src/termctrl-registry.ts +5 -2
- package/src/terminal-tools.ts +114 -26
package/README.md
CHANGED
|
@@ -47,6 +47,13 @@ with input, and 30 s for a `terminal_send` with neither `text` nor `keys` (a
|
|
|
47
47
|
poll, which settles on quiet only after new output). Every `wait_ms` clamps to
|
|
48
48
|
5 minutes.
|
|
49
49
|
|
|
50
|
+
Calls to one Terminal run one at a time, in arrival order: a parallel batch of
|
|
51
|
+
`terminal_send` calls each types, presses keys, and settles before the next
|
|
52
|
+
starts, so every result shows its own call's screen; a `terminal_stop` waits its
|
|
53
|
+
turn too, then reports what the calls ahead of it left. A call queued behind a
|
|
54
|
+
stop finds the Terminal gone. Calls to different Terminals stay concurrent. Aborting a waiting
|
|
55
|
+
call removes it without affecting the running one.
|
|
56
|
+
|
|
50
57
|
Results say why the wait ended in `settle_reason` (`matched`, `timeout`, `quiet`
|
|
51
58
|
or `exited`) and in their first line, such as `t1 running · settled: timeout`; a
|
|
52
59
|
`wait_for_text` that timed out adds that the pattern was not seen. Polls report
|
|
@@ -119,8 +126,8 @@ Each terminal tool declares MCP-style `annotations`, which Pi reports through `p
|
|
|
119
126
|
With `termctrl.replaceBash` (default `true`), Pi Termctrl replaces Pi's `bash`
|
|
120
127
|
tool with Pi's own definition plus one parameter, `background?: boolean`.
|
|
121
128
|
Commands still run on pipes through Pi's local `exec`, so Pi keeps spawning,
|
|
122
|
-
process-tree kills
|
|
123
|
-
|
|
129
|
+
process-tree kills and rendering, and foreground results differ from Pi's only
|
|
130
|
+
by the shorter tail below. A command becomes a Background job in two ways:
|
|
124
131
|
|
|
125
132
|
- **`background: true`** waits 2 s. A command that finishes first returns an
|
|
126
133
|
ordinary result; otherwise the call returns a background result. If the cap
|
|
@@ -139,6 +146,16 @@ notification or call `terminal_wait`; read the log with `read` and stop the job
|
|
|
139
146
|
with `terminal_stop`. Its structured result follows Pi's `bash` output schema, except
|
|
140
147
|
that `exit_code` is absent and `background: { id, log_path }` is present.
|
|
141
148
|
|
|
149
|
+
**Output tail.** With `termctrl.bashTail` (default `{ lines: 300, bytes: 16384 }`),
|
|
150
|
+
the output the model sees from a foreground `bash` call, and the "output so far" of
|
|
151
|
+
a backgrounding result, is cut to the last 300 lines or 16 KB, whichever is hit
|
|
152
|
+
first, instead of Pi's 2,000 lines or 50 KB. The cut keeps Pi's notice, for example
|
|
153
|
+
`[Showing lines 4701-5000 of 5000 (16.0KB or 300 line limit). Full output: /tmp/pi-bash-….log]`,
|
|
154
|
+
and names the limits actually used. The named file always holds every line: Pi's own
|
|
155
|
+
full-output file when Pi also cut the output, otherwise one Pi Termctrl writes beside
|
|
156
|
+
it in `$TMPDIR` (like Pi's, it is not deleted). The tool description names the limits in force. Set `bashTail` to `false`
|
|
157
|
+
or `0` to restore Pi's limits. Values above Pi's limits are rejected, and an object with no valid field leaves a lower layer's setting in place.
|
|
158
|
+
|
|
142
159
|
A Background job's log is deleted when the job is stopped, when the user
|
|
143
160
|
removes it in `/ps`, or when its session shuts down for any reason except
|
|
144
161
|
reload. `bash` calls from `codemode` scripts behave the same. User `!` commands
|
|
@@ -196,16 +213,18 @@ by field:
|
|
|
196
213
|
"termctrl": {
|
|
197
214
|
"replaceBash": true,
|
|
198
215
|
"defaultViewport": { "cols": 120, "rows": 40 },
|
|
199
|
-
"exitTailLines": 20
|
|
216
|
+
"exitTailLines": 20,
|
|
217
|
+
"bashTail": { "lines": 300, "bytes": 16384 }
|
|
200
218
|
}
|
|
201
219
|
}
|
|
202
220
|
```
|
|
203
221
|
|
|
204
|
-
| Setting | Default
|
|
205
|
-
| ----------------- |
|
|
206
|
-
| `replaceBash` | `true`
|
|
207
|
-
| `defaultViewport` | `{ cols: 120, rows: 40 }`
|
|
208
|
-
| `exitTailLines` | `20`
|
|
222
|
+
| Setting | Default | Meaning |
|
|
223
|
+
| ----------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------ |
|
|
224
|
+
| `replaceBash` | `true` | Replace `bash` to enable Background jobs |
|
|
225
|
+
| `defaultViewport` | `{ cols: 120, rows: 40 }` | Terminal size; each dimension 1 to 1000 |
|
|
226
|
+
| `exitTailLines` | `20` | Lines of output in an Exit notification, 0–1000 |
|
|
227
|
+
| `bashTail` | `{ lines: 300, bytes: 16384 }` | Model-visible `bash` output tail: `lines` 1–2000, `bytes` 1–51200; `false` or `0` restores Pi's limits |
|
|
209
228
|
|
|
210
229
|
Invalid values keep the lower layer and unknown keys produce warnings at session
|
|
211
230
|
start. Use `/reload` after editing settings.
|
package/package.json
CHANGED
|
@@ -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.
|
|
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 { BashTailLimits } from "./pi-termctrl-settings.js";
|
|
19
25
|
import { termctrlTemporaryDirectory } from "./termctrl-driver.js";
|
|
20
26
|
import type { TerminalExit } from "./terminal-driver.js";
|
|
21
27
|
|
|
@@ -29,6 +35,12 @@ const TAIL_LIMIT_CHARS = 64 * 1024;
|
|
|
29
35
|
|
|
30
36
|
type ExecOptions = Parameters<BashOperations["exec"]>[2];
|
|
31
37
|
|
|
38
|
+
function countNewlines(data: Buffer): number {
|
|
39
|
+
let count = 0;
|
|
40
|
+
for (let at = data.indexOf(10); at !== -1; at = data.indexOf(10, at + 1)) count++;
|
|
41
|
+
return count;
|
|
42
|
+
}
|
|
43
|
+
|
|
32
44
|
/** Path of a Background job's log. The pid keeps concurrent Pi processes apart. */
|
|
33
45
|
export function backgroundLogPath(id: string): string {
|
|
34
46
|
return join(termctrlTemporaryDirectory(), `${process.pid}-${id}.log`);
|
|
@@ -39,6 +51,14 @@ interface BackgroundOutcome {
|
|
|
39
51
|
readonly id: string;
|
|
40
52
|
readonly logPath: string;
|
|
41
53
|
readonly output: string;
|
|
54
|
+
/** What the in-memory buffer dropped from the head of `output`. */
|
|
55
|
+
readonly dropped: DroppedOutput;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Output that left the in-memory buffer, which a tail's totals still have to count. */
|
|
59
|
+
interface DroppedOutput {
|
|
60
|
+
readonly lines: number;
|
|
61
|
+
readonly bytes: number;
|
|
42
62
|
}
|
|
43
63
|
|
|
44
64
|
/**
|
|
@@ -50,6 +70,7 @@ class BashCall {
|
|
|
50
70
|
private readonly chunks: Buffer[] = [];
|
|
51
71
|
private bufferedBytes = 0;
|
|
52
72
|
private droppedBytes = 0;
|
|
73
|
+
private droppedNewlines = 0;
|
|
53
74
|
private recent = "";
|
|
54
75
|
private readonly decoder = new TextDecoder();
|
|
55
76
|
private log: WriteStream | undefined;
|
|
@@ -74,6 +95,17 @@ class BashCall {
|
|
|
74
95
|
});
|
|
75
96
|
}
|
|
76
97
|
|
|
98
|
+
/** Everything the foreground call printed that is still buffered, for the model-visible tail. */
|
|
99
|
+
foregroundOutput() {
|
|
100
|
+
if (this.isBackgrounded) return undefined;
|
|
101
|
+
const raw = Buffer.concat(this.chunks);
|
|
102
|
+
return { text: new TextDecoder().decode(raw), raw, dropped: this.dropped() };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private dropped(): DroppedOutput {
|
|
106
|
+
return { lines: this.droppedNewlines, bytes: this.droppedBytes };
|
|
107
|
+
}
|
|
108
|
+
|
|
77
109
|
/** The `BashOperations.exec` Pi's `execute` calls. */
|
|
78
110
|
async exec(
|
|
79
111
|
command: string,
|
|
@@ -136,6 +168,7 @@ class BashCall {
|
|
|
136
168
|
const dropped = this.chunks.shift();
|
|
137
169
|
this.bufferedBytes -= dropped?.length ?? 0;
|
|
138
170
|
this.droppedBytes += dropped?.length ?? 0;
|
|
171
|
+
if (dropped !== undefined) this.droppedNewlines += countNewlines(dropped);
|
|
139
172
|
}
|
|
140
173
|
this.forwardData?.(data);
|
|
141
174
|
}
|
|
@@ -190,7 +223,12 @@ class BashCall {
|
|
|
190
223
|
const output = this.decoder.decode(Buffer.concat(this.chunks), { stream: true });
|
|
191
224
|
this.chunks.length = 0;
|
|
192
225
|
this.remember(output);
|
|
193
|
-
this.resolveBackground({
|
|
226
|
+
this.resolveBackground({
|
|
227
|
+
id: entry.id,
|
|
228
|
+
logPath: entry.child.logPath,
|
|
229
|
+
output,
|
|
230
|
+
dropped: this.dropped(),
|
|
231
|
+
});
|
|
194
232
|
return true;
|
|
195
233
|
}
|
|
196
234
|
}
|
|
@@ -244,6 +282,11 @@ export interface BashReplacementOptions {
|
|
|
244
282
|
readonly shellPath: string | undefined;
|
|
245
283
|
readonly registry: () => TermctrlRegistry;
|
|
246
284
|
readonly calls: RunningBashCalls;
|
|
285
|
+
/**
|
|
286
|
+
* Limits on the model-visible tail of a result and of "output so far"; undefined keeps Pi's own
|
|
287
|
+
* limits. Pi's `bash` accepts no limits, so the replacement tightens what Pi returns.
|
|
288
|
+
*/
|
|
289
|
+
readonly bashTail?: BashTailLimits | undefined;
|
|
247
290
|
/** Builds Pi's `bash` definition; tests wrap it to observe what reaches Pi's accumulator. */
|
|
248
291
|
readonly definitionFactory?: typeof createBashToolDefinition;
|
|
249
292
|
}
|
|
@@ -269,25 +312,156 @@ function backgroundOutputSchema(builtin: TSchema | undefined) {
|
|
|
269
312
|
});
|
|
270
313
|
}
|
|
271
314
|
|
|
272
|
-
|
|
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: BashTailLimits,
|
|
381
|
+
hasStatus: boolean,
|
|
382
|
+
) {
|
|
383
|
+
const output = call.foregroundOutput();
|
|
384
|
+
if (output === undefined) return undefined;
|
|
385
|
+
const truncation = truncateBuffered(output.text, output.dropped, {
|
|
386
|
+
maxLines: limits.maxLines,
|
|
387
|
+
maxBytes: limits.maxBytes,
|
|
388
|
+
});
|
|
389
|
+
if (!truncation.truncated) return undefined;
|
|
390
|
+
const piCut = truncateBuffered(output.text, output.dropped, {}).truncated;
|
|
391
|
+
const logPath =
|
|
392
|
+
(piCut ? piFullOutputPath(piText, piDetails) : undefined) ?? saveFullOutput(output.raw);
|
|
393
|
+
const notice = tailNotice(truncation, output.text, logPath ?? "(could not be saved)");
|
|
394
|
+
// Pi appends a status only to a failure; a successful command's output may merely look like one.
|
|
395
|
+
const status = hasStatus ? PI_STATUS.exec(piText) : null;
|
|
396
|
+
const suffix = status === null ? "" : `\n\n${status[1] ?? ""}`;
|
|
397
|
+
return {
|
|
398
|
+
text: `${truncation.content}\n\n${notice}${suffix}`,
|
|
399
|
+
details: logPath === undefined ? { truncation } : { truncation, fullOutputPath: logPath },
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function backgroundResult(
|
|
404
|
+
outcome: BackgroundOutcome,
|
|
405
|
+
startedAt: number,
|
|
406
|
+
limits: BashTailLimits | undefined,
|
|
407
|
+
) {
|
|
408
|
+
const truncation = truncateBuffered(outcome.output, outcome.dropped, limits ?? {});
|
|
274
409
|
let text = truncation.content;
|
|
275
410
|
if (truncation.truncated) {
|
|
276
|
-
text += `\n\n
|
|
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: BashTailLimits): BashResult {
|
|
431
|
+
const [first] = result.content;
|
|
432
|
+
if (first?.type !== "text") return result;
|
|
433
|
+
const limited = limitForegroundText(
|
|
434
|
+
call,
|
|
435
|
+
first.text,
|
|
436
|
+
result.details,
|
|
437
|
+
limits,
|
|
438
|
+
result.isError === true,
|
|
439
|
+
);
|
|
440
|
+
if (limited === undefined) return result;
|
|
441
|
+
return {
|
|
442
|
+
...result,
|
|
443
|
+
content: [{ type: "text", text: limited.text }],
|
|
444
|
+
details: limited.details,
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function limitError(call: BashCall, error: Error, limits: BashTailLimits): Error {
|
|
449
|
+
// Only Pi's output-plus-status messages are rewritten; any other error passes through.
|
|
450
|
+
if (!PI_STATUS.test(error.message)) return error;
|
|
451
|
+
const limited = limitForegroundText(call, error.message, undefined, limits, true);
|
|
452
|
+
return limited === undefined ? error : new Error(limited.text, { cause: error });
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Pi's description names its own limits; name the ones in force instead. */
|
|
456
|
+
function describeTail(description: string, limits: BashTailLimits | undefined): string {
|
|
457
|
+
if (limits === undefined) return description;
|
|
458
|
+
const piLimits = `${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB`;
|
|
459
|
+
const ours = `${limits.maxLines} lines or ${formatSize(limits.maxBytes)}`;
|
|
460
|
+
// If Pi rewords its description, still tell the model the limits in force.
|
|
461
|
+
if (!description.includes(piLimits)) return `${description} Output is truncated to last ${ours}.`;
|
|
462
|
+
return description.replace(piLimits, ours);
|
|
463
|
+
}
|
|
464
|
+
|
|
291
465
|
/** Pi's `bash` definition plus `background`, executed through Pi's own pipes. */
|
|
292
466
|
export function createBashReplacement(options: BashReplacementOptions) {
|
|
293
467
|
const builtinOptions: BashToolOptions = {};
|
|
@@ -308,6 +482,7 @@ export function createBashReplacement(options: BashReplacementOptions) {
|
|
|
308
482
|
});
|
|
309
483
|
return {
|
|
310
484
|
...builtin,
|
|
485
|
+
description: describeTail(builtin.description, options.bashTail),
|
|
311
486
|
parameters,
|
|
312
487
|
outputSchema: backgroundOutputSchema(builtin.outputSchema),
|
|
313
488
|
async execute(
|
|
@@ -333,7 +508,8 @@ export function createBashReplacement(options: BashReplacementOptions) {
|
|
|
333
508
|
if (!backgrounded) onUpdate(update);
|
|
334
509
|
};
|
|
335
510
|
const startedAt = performance.now();
|
|
336
|
-
const
|
|
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);
|
|
@@ -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,16 @@ export interface TerminalViewport {
|
|
|
15
20
|
readonly rows: number;
|
|
16
21
|
}
|
|
17
22
|
|
|
23
|
+
/** The most lines and bytes of the model-visible `bash` output: a foreground result or the output so far of a backgrounding one. */
|
|
24
|
+
export type BashTailLimits = Readonly<Required<TruncationOptions>>;
|
|
25
|
+
|
|
18
26
|
/** The effective `settings.termctrl` values after layering global and trusted project settings. */
|
|
19
27
|
export interface ResolvedTermctrlSettings {
|
|
20
28
|
readonly replaceBash: boolean;
|
|
21
29
|
readonly defaultViewport: TerminalViewport;
|
|
22
30
|
readonly exitTailLines: number;
|
|
31
|
+
/** Limits on the model-visible `bash` tail; undefined restores Pi's own limits. */
|
|
32
|
+
readonly bashTail: BashTailLimits | undefined;
|
|
23
33
|
readonly warnings: readonly string[];
|
|
24
34
|
}
|
|
25
35
|
|
|
@@ -39,18 +49,26 @@ interface TermctrlLayer {
|
|
|
39
49
|
cols?: number;
|
|
40
50
|
rows?: number;
|
|
41
51
|
exitTailLines?: number;
|
|
52
|
+
bashTailEnabled?: boolean;
|
|
53
|
+
bashTailLines?: number;
|
|
54
|
+
bashTailBytes?: number;
|
|
42
55
|
}
|
|
43
56
|
|
|
44
|
-
export const DEFAULT_TERMCTRL_SETTINGS: Omit<ResolvedTermctrlSettings, "warnings">
|
|
57
|
+
export const DEFAULT_TERMCTRL_SETTINGS: Omit<ResolvedTermctrlSettings, "warnings" | "bashTail"> & {
|
|
58
|
+
readonly bashTail: BashTailLimits;
|
|
59
|
+
} = {
|
|
45
60
|
replaceBash: true,
|
|
46
61
|
defaultViewport: { cols: 120, rows: 40 },
|
|
47
62
|
exitTailLines: 20,
|
|
63
|
+
bashTail: { maxLines: 300, maxBytes: 16 * 1024 },
|
|
48
64
|
};
|
|
49
65
|
|
|
50
66
|
const JsonObjectSchema = Type.Record(Type.String(), Type.Any());
|
|
51
67
|
const SettingsDocumentSchema = Type.Object({ termctrl: Type.Optional(Type.Any()) });
|
|
52
68
|
const BooleanSchema = Type.Boolean();
|
|
53
69
|
const ViewportDimensionSchema = Type.Integer({ minimum: 1, maximum: VIEWPORT_LIMIT });
|
|
70
|
+
const BashTailLinesSchema = Type.Integer({ minimum: 1, maximum: DEFAULT_MAX_LINES });
|
|
71
|
+
const BashTailBytesSchema = Type.Integer({ minimum: 1, maximum: DEFAULT_MAX_BYTES });
|
|
54
72
|
const ExitTailLinesSchema = Type.Integer({ minimum: 0, maximum: EXIT_TAIL_LIMIT });
|
|
55
73
|
|
|
56
74
|
function isJsonObject(value: JsonValue): value is JsonObject {
|
|
@@ -84,6 +102,9 @@ function readLayer(
|
|
|
84
102
|
if (Value.Check(ExitTailLinesSchema, value)) layer.exitTailLines = value;
|
|
85
103
|
else warnings.push(`${path}: expected an integer from 0 to ${EXIT_TAIL_LIMIT}`);
|
|
86
104
|
break;
|
|
105
|
+
case "bashTail":
|
|
106
|
+
readBashTail(value, path, layer, warnings);
|
|
107
|
+
break;
|
|
87
108
|
case "defaultViewport":
|
|
88
109
|
readViewport(value, path, layer, warnings);
|
|
89
110
|
break;
|
|
@@ -109,6 +130,39 @@ function readViewport(value: JsonValue, path: string, layer: TermctrlLayer, warn
|
|
|
109
130
|
}
|
|
110
131
|
}
|
|
111
132
|
|
|
133
|
+
function readBashTail(value: JsonValue, path: string, layer: TermctrlLayer, warnings: string[]) {
|
|
134
|
+
if (Value.Check(BooleanSchema, value)) {
|
|
135
|
+
layer.bashTailEnabled = value;
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (value === 0) {
|
|
139
|
+
layer.bashTailEnabled = false;
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (!isJsonObject(value)) {
|
|
143
|
+
warnings.push(`${path}: expected a boolean, 0, or a JSON object`);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
let valid = 0;
|
|
147
|
+
for (const [key, limit] of Object.entries(value)) {
|
|
148
|
+
if (key === "lines") {
|
|
149
|
+
if (Value.Check(BashTailLinesSchema, limit)) {
|
|
150
|
+
layer.bashTailLines = limit;
|
|
151
|
+
valid++;
|
|
152
|
+
} else warnings.push(`${path}.lines: expected an integer from 1 to ${DEFAULT_MAX_LINES}`);
|
|
153
|
+
} else if (key === "bytes") {
|
|
154
|
+
if (Value.Check(BashTailBytesSchema, limit)) {
|
|
155
|
+
layer.bashTailBytes = limit;
|
|
156
|
+
valid++;
|
|
157
|
+
} else warnings.push(`${path}.bytes: expected an integer from 1 to ${DEFAULT_MAX_BYTES}`);
|
|
158
|
+
} else {
|
|
159
|
+
warnings.push(`${path}.${key}: unknown field`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// An object with only invalid fields must not override a lower layer's opt-out.
|
|
163
|
+
if (valid > 0 || Object.keys(value).length === 0) layer.bashTailEnabled = true;
|
|
164
|
+
}
|
|
165
|
+
|
|
112
166
|
/** Resolve global and trusted-project `termctrl` settings, keeping valid fields around warnings. */
|
|
113
167
|
export function resolveTermctrlSettings(reader: TermctrlSettingsReader): ResolvedTermctrlSettings {
|
|
114
168
|
const warnings: string[] = [];
|
|
@@ -123,6 +177,15 @@ export function resolveTermctrlSettings(reader: TermctrlSettingsReader): Resolve
|
|
|
123
177
|
},
|
|
124
178
|
exitTailLines:
|
|
125
179
|
projectLayer.exitTailLines ?? globalLayer.exitTailLines ?? defaults.exitTailLines,
|
|
180
|
+
bashTail:
|
|
181
|
+
(projectLayer.bashTailEnabled ?? globalLayer.bashTailEnabled ?? true)
|
|
182
|
+
? {
|
|
183
|
+
maxLines:
|
|
184
|
+
projectLayer.bashTailLines ?? globalLayer.bashTailLines ?? defaults.bashTail.maxLines,
|
|
185
|
+
maxBytes:
|
|
186
|
+
projectLayer.bashTailBytes ?? globalLayer.bashTailBytes ?? defaults.bashTail.maxBytes,
|
|
187
|
+
}
|
|
188
|
+
: undefined,
|
|
126
189
|
warnings,
|
|
127
190
|
};
|
|
128
191
|
}
|
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 = 5;
|
|
17
17
|
|
|
18
18
|
/** Live Terminals plus Background jobs allowed across the whole process. */
|
|
19
19
|
export const LIVE_ENTRY_CAP = 16;
|
|
@@ -71,8 +71,10 @@ export interface TerminalEntry extends EntryBase {
|
|
|
71
71
|
readonly cwd: string;
|
|
72
72
|
/** The screen captured when the Terminal exited. */
|
|
73
73
|
finalScreen: string | undefined;
|
|
74
|
-
/** Number of agent tool calls
|
|
74
|
+
/** Number of agent tool calls running or queued to drive this Terminal; the exit watcher skips them. */
|
|
75
75
|
activeCalls: number;
|
|
76
|
+
/** The agent's calls to this Terminal: one runs at a time and the rest wait in arrival order. */
|
|
77
|
+
readonly callQueue: { running: boolean; readonly waiting: (() => void)[] };
|
|
76
78
|
/** The screen the agent last received, for `changed: false`. */
|
|
77
79
|
lastScreen: string | undefined;
|
|
78
80
|
/** Count of log lines that scrolled off before the agent's last result. */
|
|
@@ -457,6 +459,7 @@ export class TermctrlRegistry {
|
|
|
457
459
|
generation: slot.generation,
|
|
458
460
|
finalScreen: undefined,
|
|
459
461
|
activeCalls: 0,
|
|
462
|
+
callQueue: { running: false, waiting: [] },
|
|
460
463
|
lastScreen: undefined,
|
|
461
464
|
logCursor: 0,
|
|
462
465
|
logAnchor: [],
|
package/src/terminal-tools.ts
CHANGED
|
@@ -580,17 +580,72 @@ async function terminalResult(
|
|
|
580
580
|
};
|
|
581
581
|
}
|
|
582
582
|
|
|
583
|
+
/** A call that was removed from its Terminal's queue before it started. */
|
|
584
|
+
class CallCancelledError extends Error {
|
|
585
|
+
constructor(id: string) {
|
|
586
|
+
super(`The call to ${id} was cancelled before it started.`);
|
|
587
|
+
this.name = "CallCancelledError";
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Wait for this call's turn on a Terminal. Calls to one Terminal run one at a time, each typing,
|
|
593
|
+
* pressing keys and settling before the next starts, so a parallel batch of sends gets screens
|
|
594
|
+
* that match its calls. Calls to other Terminals are unaffected. Aborting a waiting call removes
|
|
595
|
+
* it from the queue and rejects it; the running call is untouched.
|
|
596
|
+
*/
|
|
597
|
+
function awaitTurn(entry: TerminalEntry, signal: AbortSignal | undefined): Promise<() => void> {
|
|
598
|
+
const queue = entry.callQueue;
|
|
599
|
+
const release = () => {
|
|
600
|
+
const next = queue.waiting.shift();
|
|
601
|
+
if (next === undefined) queue.running = false;
|
|
602
|
+
else next();
|
|
603
|
+
};
|
|
604
|
+
if (signal?.aborted === true) return Promise.reject(new CallCancelledError(entry.id));
|
|
605
|
+
if (!queue.running) {
|
|
606
|
+
queue.running = true;
|
|
607
|
+
return Promise.resolve(release);
|
|
608
|
+
}
|
|
609
|
+
return new Promise((resolve, reject) => {
|
|
610
|
+
const onAbort = () => {
|
|
611
|
+
const index = queue.waiting.indexOf(start);
|
|
612
|
+
if (index !== -1) queue.waiting.splice(index, 1);
|
|
613
|
+
reject(new CallCancelledError(entry.id));
|
|
614
|
+
};
|
|
615
|
+
const start = () => {
|
|
616
|
+
signal?.removeEventListener("abort", onAbort);
|
|
617
|
+
resolve(release);
|
|
618
|
+
};
|
|
619
|
+
queue.waiting.push(start);
|
|
620
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Run one call against a Terminal in its turn. A call that waited finds the Terminal as the calls
|
|
626
|
+
* ahead left it, so `run` must read the Terminal's state itself. A Terminal that a call ahead
|
|
627
|
+
* stopped is gone by then.
|
|
628
|
+
*/
|
|
583
629
|
async function driveTerminal<T>(
|
|
584
630
|
registry: TermctrlRegistry,
|
|
585
631
|
entry: TerminalEntry,
|
|
632
|
+
signal: AbortSignal | undefined,
|
|
586
633
|
run: () => Promise<T>,
|
|
587
634
|
): Promise<T> {
|
|
635
|
+
// Counted while queued too, so the exit watcher leaves a Terminal the agent is driving alone.
|
|
588
636
|
entry.activeCalls++;
|
|
637
|
+
let release: (() => void) | undefined;
|
|
589
638
|
try {
|
|
639
|
+
release = await awaitTurn(entry, signal);
|
|
640
|
+
if (registry.find(entry.owner, entry.id) !== entry) throw unknownId(entry.id);
|
|
590
641
|
return await run();
|
|
591
642
|
} catch (cause) {
|
|
592
643
|
const error = cause instanceof Error ? cause : new Error(String(cause));
|
|
593
|
-
if (
|
|
644
|
+
if (
|
|
645
|
+
release !== undefined &&
|
|
646
|
+
!(error instanceof InputToExitedError) &&
|
|
647
|
+
registry.reportTerminalError(entry, error)
|
|
648
|
+
) {
|
|
594
649
|
throw new Error(
|
|
595
650
|
`${entry.id} was lost because the termctrl driver exited\n\n${TROUBLESHOOTING_HINT}`,
|
|
596
651
|
{ cause },
|
|
@@ -598,6 +653,7 @@ async function driveTerminal<T>(
|
|
|
598
653
|
}
|
|
599
654
|
throw error;
|
|
600
655
|
} finally {
|
|
656
|
+
release?.();
|
|
601
657
|
entry.activeCalls--;
|
|
602
658
|
}
|
|
603
659
|
}
|
|
@@ -665,7 +721,9 @@ export function createTerminalStartTool(runtime: TerminalToolRuntime) {
|
|
|
665
721
|
});
|
|
666
722
|
// Launching, including a cold driver start, does not count toward the quiet period.
|
|
667
723
|
const startedAt = Date.now();
|
|
668
|
-
|
|
724
|
+
// The Terminal is running and nothing else knows its id yet, so the call never queues; an
|
|
725
|
+
// abort ends the wait but must still return the id, or the agent could not reach the Terminal.
|
|
726
|
+
return driveTerminal(runtime.registry, entry, undefined, async () => {
|
|
669
727
|
const { snapshot, reason } = await settleTerminal(entry, {
|
|
670
728
|
mode: "start",
|
|
671
729
|
waitMs: clampWait(params.wait_ms, START_WAIT_MS),
|
|
@@ -680,10 +738,17 @@ export function createTerminalStartTool(runtime: TerminalToolRuntime) {
|
|
|
680
738
|
});
|
|
681
739
|
}
|
|
682
740
|
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
741
|
+
/**
|
|
742
|
+
* Input to a Terminal that exited. It is the agent's mistake, not a driver failure, even when the
|
|
743
|
+
* exit says the driver died: that loss was already reported.
|
|
744
|
+
*/
|
|
745
|
+
class InputToExitedError extends Error {
|
|
746
|
+
constructor(id: string, exit: TerminalExit | null) {
|
|
747
|
+
super(
|
|
748
|
+
`${id} ${describeExit(exit)} and accepts no input. Poll it with terminal_send for its final screen, or remove it with terminal_stop.`,
|
|
749
|
+
);
|
|
750
|
+
this.name = "InputToExitedError";
|
|
751
|
+
}
|
|
687
752
|
}
|
|
688
753
|
|
|
689
754
|
/** `terminal_send`: type text and keys into a Terminal, or poll it, and wait for it to settle. */
|
|
@@ -718,15 +783,13 @@ export function createTerminalSendTool(runtime: TerminalToolRuntime) {
|
|
|
718
783
|
);
|
|
719
784
|
}
|
|
720
785
|
const hasInput = (params.text ?? "") !== "" || keys.length > 0;
|
|
721
|
-
|
|
722
|
-
if (
|
|
786
|
+
return driveTerminal(runtime.registry, entry, signal, async () => {
|
|
787
|
+
if (entry.state === "exited") {
|
|
788
|
+
if (!hasInput) return terminalResult(runtime.registry, entry, undefined, "exited");
|
|
723
789
|
// The error tells the agent about the exit, so a deferred Exit notification is redundant.
|
|
724
790
|
runtime.registry.markSeen(entry.id);
|
|
725
|
-
throw
|
|
791
|
+
throw new InputToExitedError(entry.id, entry.exit);
|
|
726
792
|
}
|
|
727
|
-
return terminalResult(runtime.registry, entry, undefined, "exited");
|
|
728
|
-
}
|
|
729
|
-
return driveTerminal(runtime.registry, entry, async () => {
|
|
730
793
|
const matches =
|
|
731
794
|
params.wait_for_text === undefined ? undefined : parseWaitPattern(params.wait_for_text);
|
|
732
795
|
const before =
|
|
@@ -735,7 +798,7 @@ export function createTerminalSendTool(runtime: TerminalToolRuntime) {
|
|
|
735
798
|
// The exit watcher has not noticed this exit yet.
|
|
736
799
|
const exit = before.exit ?? { code: null, signal: null };
|
|
737
800
|
runtime.registry.terminalExited(entry.id, exit, before.screen, true);
|
|
738
|
-
throw
|
|
801
|
+
throw new InputToExitedError(entry.id, exit);
|
|
739
802
|
}
|
|
740
803
|
const baseline = matches === undefined ? undefined : before?.screen;
|
|
741
804
|
const startedAt = Date.now();
|
|
@@ -763,20 +826,41 @@ export function createTerminalSendTool(runtime: TerminalToolRuntime) {
|
|
|
763
826
|
}
|
|
764
827
|
|
|
765
828
|
/** Capture the lines a Terminal scrolled off since the agent's last result, before it is stopped. */
|
|
766
|
-
async function finalScrolledOff(
|
|
767
|
-
registry: TermctrlRegistry,
|
|
768
|
-
entry: TerminalEntry,
|
|
769
|
-
): Promise<ScrolledOff> {
|
|
829
|
+
async function finalScrolledOff(entry: TerminalEntry): Promise<ScrolledOff> {
|
|
770
830
|
try {
|
|
771
831
|
if (entry.state === "exited") return await takeScrolledOff(entry, entry.finalScreen ?? "");
|
|
772
|
-
return await
|
|
773
|
-
takeScrolledOff(entry, (await entry.handle.snapshot()).screen),
|
|
774
|
-
);
|
|
832
|
+
return await takeScrolledOff(entry, (await entry.handle.snapshot()).screen);
|
|
775
833
|
} catch {
|
|
776
834
|
return NOTHING_SCROLLED;
|
|
777
835
|
}
|
|
778
836
|
}
|
|
779
837
|
|
|
838
|
+
/** What a stop saw of a Terminal at its turn, before stopping it. */
|
|
839
|
+
interface StopView {
|
|
840
|
+
readonly wasRunning: boolean;
|
|
841
|
+
readonly previousScreen: string | undefined;
|
|
842
|
+
readonly scrolled: ScrolledOff;
|
|
843
|
+
readonly entry: TermctrlEntry;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* Stop a Terminal in its turn: the calls ahead have finished, so `wasRunning` and the previous
|
|
848
|
+
* screen are what they left, and calls queued behind find the Terminal gone.
|
|
849
|
+
*/
|
|
850
|
+
function stopTerminalInTurn(
|
|
851
|
+
registry: TermctrlRegistry,
|
|
852
|
+
known: TerminalEntry,
|
|
853
|
+
signal: AbortSignal | undefined,
|
|
854
|
+
): Promise<StopView> {
|
|
855
|
+
return driveTerminal(registry, known, signal, async () => {
|
|
856
|
+
const wasRunning = known.state === "running";
|
|
857
|
+
const previousScreen = known.lastScreen;
|
|
858
|
+
const scrolled = await finalScrolledOff(known);
|
|
859
|
+
const entry = (await registry.stop(known.owner, known.id)) ?? known;
|
|
860
|
+
return { wasRunning, previousScreen, scrolled, entry };
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
|
|
780
864
|
/** `terminal_stop`: stop a Terminal or Background job and forget it. */
|
|
781
865
|
export function createTerminalStopTool(registry: TermctrlRegistry) {
|
|
782
866
|
return defineTool<typeof StopParameters, StopResult>({
|
|
@@ -793,15 +877,19 @@ export function createTerminalStopTool(registry: TermctrlRegistry) {
|
|
|
793
877
|
openWorldHint: false,
|
|
794
878
|
},
|
|
795
879
|
outputSchema: StopResultSchema,
|
|
796
|
-
async execute(_toolCallId, params,
|
|
880
|
+
async execute(_toolCallId, params, signal, _onUpdate, context) {
|
|
797
881
|
const owner = ownerOf(context);
|
|
798
882
|
const known = registry.find(owner, params.id);
|
|
799
883
|
if (known === undefined) throw unknownId(params.id);
|
|
800
|
-
const wasRunning
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
884
|
+
const { wasRunning, previousScreen, scrolled, entry } =
|
|
885
|
+
known.kind === "terminal"
|
|
886
|
+
? await stopTerminalInTurn(registry, known, signal)
|
|
887
|
+
: {
|
|
888
|
+
wasRunning: known.state === "running",
|
|
889
|
+
previousScreen: undefined,
|
|
890
|
+
scrolled: NOTHING_SCROLLED,
|
|
891
|
+
entry: (await registry.stop(owner, params.id)) ?? known,
|
|
892
|
+
};
|
|
805
893
|
const label = entry.kind === "terminal" ? "Terminal" : "Background job";
|
|
806
894
|
const header = wasRunning
|
|
807
895
|
? `${label} ${entry.id} stopped.`
|