@ian-pascoe/pi-termctrl 0.2.0 → 0.4.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 +19 -2
- package/package.json +4 -4
- package/src/bash-replacement.ts +4 -2
- package/src/terminal-tools.ts +144 -27
package/README.md
CHANGED
|
@@ -33,7 +33,9 @@ For a local checkout, run `pi -e ./packages/pi-termctrl/src/index.ts`.
|
|
|
33
33
|
|
|
34
34
|
`terminal_start` runs `command` through the shell Pi's `bash` uses (the
|
|
35
35
|
`shellPath` setting, otherwise Pi's default bash), applies `shellCommandPrefix`,
|
|
36
|
-
and opens a PTY sized by `termctrl.defaultViewport`.
|
|
36
|
+
and opens a PTY sized by `termctrl.defaultViewport`. A relative `cwd` resolves against
|
|
37
|
+
the session's directory. A `cwd` that is missing or not a directory fails with an
|
|
38
|
+
error naming the resolved path.
|
|
37
39
|
|
|
38
40
|
`terminal_start` and `terminal_send` wait until the Terminal **settles**: at the
|
|
39
41
|
first of 250 ms of screen quiet, a `wait_for_text` match, process exit, or
|
|
@@ -45,6 +47,14 @@ with input, and 30 s for a `terminal_send` with neither `text` nor `keys` (a
|
|
|
45
47
|
poll, which settles on quiet only after new output). Every `wait_ms` clamps to
|
|
46
48
|
5 minutes.
|
|
47
49
|
|
|
50
|
+
Results say why the wait ended in `settle_reason` (`matched`, `timeout`, `quiet`
|
|
51
|
+
or `exited`) and in their first line, such as `t1 running · settled: timeout`; a
|
|
52
|
+
`wait_for_text` that timed out adds that the pattern was not seen. Polls report
|
|
53
|
+
their reason too.
|
|
54
|
+
|
|
55
|
+
Sending `text` or `keys` to an exited Terminal is an error that names its exit
|
|
56
|
+
code or signal; a poll (neither `text` nor `keys`) still returns its final screen.
|
|
57
|
+
|
|
48
58
|
Results contain the visible screen, the log lines that scrolled off since the
|
|
49
59
|
agent's previous result, `state`, `exit_code` or `signal` once exited, and
|
|
50
60
|
`changed: false` when the screen matches the previous result. Scrolled-off lines
|
|
@@ -82,7 +92,10 @@ another fails and lists the caller's live entries.
|
|
|
82
92
|
`terminal_stop` stops a running entry (termctrl stop, then `SIGKILL` of the
|
|
83
93
|
Terminal's process group if it is still alive after 3 s) and forgets it. For a
|
|
84
94
|
Terminal it returns the final screen and scrolled-off lines; for a Background
|
|
85
|
-
job, its recent output.
|
|
95
|
+
job, its recent output. When a Terminal had already exited and its screen is
|
|
96
|
+
unchanged since the agent's last result, the result omits the screen and reports
|
|
97
|
+
`changed: false` with `state` and `exit_code` or `signal`; `scrolled_off` still
|
|
98
|
+
appears when lines scrolled off since that result. Exited entries stay
|
|
86
99
|
readable and listed until the agent stops them, the user removes them in `/ps`,
|
|
87
100
|
or their session shuts down. `terminal_list` lists the caller's Terminals and,
|
|
88
101
|
in a separate `background_jobs` section, its Background jobs with their log
|
|
@@ -97,6 +110,10 @@ for the agent, such as the user steering or another entry's Exit notification;
|
|
|
97
110
|
a watched entry that exits after that is notified as usual. With nothing to
|
|
98
111
|
wait for it returns at once. Terminal exits are noticed within about 500 ms.
|
|
99
112
|
|
|
113
|
+
### Tool annotations
|
|
114
|
+
|
|
115
|
+
Each terminal tool declares MCP-style `annotations`, which Pi reports through `pi.getAllTools()` for permission extensions and does not send to model providers. `terminal_start` and `terminal_send` run arbitrary programs, so they are marked destructive and open-world; neither is idempotent. `terminal_stop` kills processes: destructive and idempotent, closed-world. `terminal_list` and `terminal_wait` are read-only and closed-world. The `bash` replacement keeps the annotations of Pi's built-in `bash`, which declares none.
|
|
116
|
+
|
|
100
117
|
## `bash` replacement
|
|
101
118
|
|
|
102
119
|
With `termctrl.replaceBash` (default `true`), Pi Termctrl replaces Pi's `bash`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ian-pascoe/pi-termctrl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Interactive Terminals, Background jobs, and a /ps panel for Pi.",
|
|
6
6
|
"keywords": [
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"@kitlangton/terminal-control": "^1.2.1"
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
40
|
-
"@earendil-works/pi-coding-agent": "
|
|
41
|
-
"@earendil-works/pi-tui": "
|
|
40
|
+
"@earendil-works/pi-coding-agent": ">=0.99.0",
|
|
41
|
+
"@earendil-works/pi-tui": ">=0.99.0",
|
|
42
42
|
"typebox": "*"
|
|
43
43
|
},
|
|
44
44
|
"engines": {
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
]
|
|
54
54
|
},
|
|
55
55
|
"scripts": {
|
|
56
|
-
"test": "vitest run
|
|
56
|
+
"test": "vitest run",
|
|
57
57
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
58
58
|
}
|
|
59
59
|
}
|
package/src/bash-replacement.ts
CHANGED
|
@@ -248,10 +248,12 @@ export interface BashReplacementOptions {
|
|
|
248
248
|
readonly definitionFactory?: typeof createBashToolDefinition;
|
|
249
249
|
}
|
|
250
250
|
|
|
251
|
+
/** Pi's `bash` declares an object `outputSchema` since 0.99.0; extend it with the background fields. */
|
|
251
252
|
function backgroundOutputSchema(builtin: TSchema | undefined) {
|
|
252
|
-
|
|
253
|
+
if (builtin === undefined || !Type.IsObject(builtin))
|
|
254
|
+
throw new Error("Pi's bash tool does not declare an object outputSchema");
|
|
253
255
|
return Type.Object({
|
|
254
|
-
...properties,
|
|
256
|
+
...builtin.properties,
|
|
255
257
|
exit_code: Type.Optional(
|
|
256
258
|
Type.Number({ description: "Absent only when the command was moved to the background" }),
|
|
257
259
|
),
|
package/src/terminal-tools.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
1
|
+
import { mkdir, stat, writeFile } from "node:fs/promises";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import {
|
|
4
4
|
DEFAULT_MAX_BYTES,
|
|
@@ -77,9 +77,18 @@ export interface TerminalToolRuntime {
|
|
|
77
77
|
|
|
78
78
|
const TerminalStateSchema = Type.Union([Type.Literal("running"), Type.Literal("exited")]);
|
|
79
79
|
|
|
80
|
+
const SettleReasonSchema = Type.Union(
|
|
81
|
+
[Type.Literal("matched"), Type.Literal("timeout"), Type.Literal("quiet"), Type.Literal("exited")],
|
|
82
|
+
{
|
|
83
|
+
description:
|
|
84
|
+
"Why the wait ended: wait_for_text matched, wait_ms ran out or the call was cancelled, the screen was quiet for 250 ms, or the Terminal exited",
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
|
|
80
88
|
const TerminalResultSchema = Type.Object({
|
|
81
89
|
id: Type.String(),
|
|
82
90
|
state: TerminalStateSchema,
|
|
91
|
+
settle_reason: SettleReasonSchema,
|
|
83
92
|
exit_code: Type.Optional(Type.Number({ description: "Present once the Terminal has exited" })),
|
|
84
93
|
signal: Type.Optional(Type.String({ description: "Present when a signal ended the Terminal" })),
|
|
85
94
|
changed: Type.Boolean({ description: "Whether the screen differs from the previous result" }),
|
|
@@ -128,7 +137,12 @@ const StopResultSchema = Type.Object({
|
|
|
128
137
|
description: "Terminal only: whether the screen differs from the previous result",
|
|
129
138
|
}),
|
|
130
139
|
),
|
|
131
|
-
screen: Type.Optional(
|
|
140
|
+
screen: Type.Optional(
|
|
141
|
+
Type.String({
|
|
142
|
+
description:
|
|
143
|
+
"Terminal only: the final screen; omitted for an exited Terminal whose screen is unchanged since your previous result",
|
|
144
|
+
}),
|
|
145
|
+
),
|
|
132
146
|
scrolled_off: Type.Optional(
|
|
133
147
|
Type.String({
|
|
134
148
|
description: "Terminal only: lines that scrolled off since the previous result",
|
|
@@ -270,7 +284,7 @@ export function parseWaitPattern(pattern: string): (screen: string) => boolean {
|
|
|
270
284
|
|
|
271
285
|
type SettleMode = "start" | "input" | "poll";
|
|
272
286
|
/** Why a Terminal wait ended. */
|
|
273
|
-
export type SettleReason =
|
|
287
|
+
export type SettleReason = Static<typeof SettleReasonSchema>;
|
|
274
288
|
|
|
275
289
|
interface SettleRequest {
|
|
276
290
|
readonly mode: SettleMode;
|
|
@@ -369,12 +383,32 @@ function locateCursor(
|
|
|
369
383
|
return undefined;
|
|
370
384
|
}
|
|
371
385
|
|
|
386
|
+
/**
|
|
387
|
+
* Find where the result's screen starts in termctrl's log. The screen is read before the log, in a
|
|
388
|
+
* separate termctrl request, so output in between can already have pushed the screen's top lines
|
|
389
|
+
* above the log's last screenful. Output only adds lines to the end of the log, so the screen is at
|
|
390
|
+
* the end or higher up, no higher than the log cursor. The screen is matched by every line except
|
|
391
|
+
* its last, which the program may still be writing. When nothing matches, as when the screen was
|
|
392
|
+
* redrawn in between, the log's last screenful is taken as the screen.
|
|
393
|
+
*/
|
|
394
|
+
function locateScreen(lines: readonly string[], screen: readonly string[], floor: number): number {
|
|
395
|
+
const end = Math.max(0, lines.length - screen.length);
|
|
396
|
+
for (let start = end; start >= floor; start--) {
|
|
397
|
+
let matches = true;
|
|
398
|
+
for (let row = 0; row < screen.length - 1 && matches; row++) {
|
|
399
|
+
matches = lines[start + row] === screen[row];
|
|
400
|
+
}
|
|
401
|
+
if (matches) return start;
|
|
402
|
+
}
|
|
403
|
+
return end;
|
|
404
|
+
}
|
|
405
|
+
|
|
372
406
|
/**
|
|
373
407
|
* Lines that left the screen since the previous result, advancing the Terminal's log cursor.
|
|
374
|
-
* Lines still on the screen stay unread, so a line rewritten after the agent saw it, such
|
|
375
|
-
* progress line, is reported in its final form once it scrolls off. When the cursor cannot be
|
|
376
|
-
* again, because termctrl dropped lines the agent never received or the screen was cleared,
|
|
377
|
-
* line termctrl still holds is reported and the result says that earlier output is missing.
|
|
408
|
+
* Lines still on the result's screen stay unread, so a line rewritten after the agent saw it, such
|
|
409
|
+
* as a progress line, is reported in its final form once it scrolls off. When the cursor cannot be
|
|
410
|
+
* found again, because termctrl dropped lines the agent never received or the screen was cleared,
|
|
411
|
+
* every line termctrl still holds is reported and the result says that earlier output is missing.
|
|
378
412
|
*/
|
|
379
413
|
async function takeScrolledOff(entry: TerminalEntry, screen: string): Promise<ScrolledOff> {
|
|
380
414
|
let logs: string;
|
|
@@ -385,8 +419,8 @@ async function takeScrolledOff(entry: TerminalEntry, screen: string): Promise<Sc
|
|
|
385
419
|
}
|
|
386
420
|
const lines = logs === "" ? [] : logs.split("\n");
|
|
387
421
|
const located = locateCursor(lines, entry.logCursor, entry.logAnchor);
|
|
388
|
-
const screenLines = screen === "" ?
|
|
389
|
-
const boundary =
|
|
422
|
+
const screenLines = screen === "" ? [] : screen.split("\n");
|
|
423
|
+
const boundary = locateScreen(lines, screenLines, located ?? 0);
|
|
390
424
|
const scrolled = lines.slice(Math.min(located ?? 0, boundary), boundary);
|
|
391
425
|
entry.logCursor = boundary;
|
|
392
426
|
entry.logAnchor =
|
|
@@ -480,6 +514,13 @@ async function truncateOutput(
|
|
|
480
514
|
return { ...fitted, fullOutputPath: path, notice: `${range} Full output: ${path}]` };
|
|
481
515
|
}
|
|
482
516
|
|
|
517
|
+
/** Says that `wait_for_text` was not seen when its wait timed out. */
|
|
518
|
+
function unmatchedNote(reason: SettleReason, pattern: string | undefined): string | undefined {
|
|
519
|
+
return reason === "timeout" && pattern !== undefined
|
|
520
|
+
? `wait_for_text ${JSON.stringify(pattern)} was not seen before the wait ended.`
|
|
521
|
+
: undefined;
|
|
522
|
+
}
|
|
523
|
+
|
|
483
524
|
function formatTerminalText(
|
|
484
525
|
result: TerminalResult,
|
|
485
526
|
note: string | undefined,
|
|
@@ -489,7 +530,9 @@ function formatTerminalText(
|
|
|
489
530
|
result.state === "running"
|
|
490
531
|
? "running"
|
|
491
532
|
: describeExit({ code: result.exit_code ?? null, signal: result.signal ?? null });
|
|
492
|
-
const parts = [
|
|
533
|
+
const parts = [
|
|
534
|
+
`${result.id} ${exit} · settled: ${result.settle_reason}${result.changed ? "" : " · screen unchanged"}`,
|
|
535
|
+
];
|
|
493
536
|
if (note !== undefined) parts.push(note);
|
|
494
537
|
if (result.scrolled_off !== "") {
|
|
495
538
|
parts.push(`--- scrolled off ---\n${result.scrolled_off}`);
|
|
@@ -504,6 +547,7 @@ async function terminalResult(
|
|
|
504
547
|
registry: TermctrlRegistry,
|
|
505
548
|
entry: TerminalEntry,
|
|
506
549
|
snapshot: TerminalSnapshot | undefined,
|
|
550
|
+
settleReason: SettleReason,
|
|
507
551
|
note?: string,
|
|
508
552
|
) {
|
|
509
553
|
const screen = snapshot?.screen ?? entry.finalScreen ?? entry.lastScreen ?? "";
|
|
@@ -520,6 +564,7 @@ async function terminalResult(
|
|
|
520
564
|
const result: TerminalResult = {
|
|
521
565
|
id: entry.id,
|
|
522
566
|
state: exited ? "exited" : "running",
|
|
567
|
+
settle_reason: settleReason,
|
|
523
568
|
...exitFields(exited ? entry.exit : null),
|
|
524
569
|
changed: entry.lastScreen !== screen,
|
|
525
570
|
screen: output.screen,
|
|
@@ -567,6 +612,23 @@ function unknownId(id: string): Error {
|
|
|
567
612
|
);
|
|
568
613
|
}
|
|
569
614
|
|
|
615
|
+
/** Resolve `cwd` against the session's directory and check it is a directory termctrl can start in. */
|
|
616
|
+
async function resolveWorkingDirectory(base: string, cwd: string | undefined): Promise<string> {
|
|
617
|
+
const resolved = resolve(base, cwd ?? ".");
|
|
618
|
+
let isDirectory: boolean;
|
|
619
|
+
try {
|
|
620
|
+
isDirectory = (await stat(resolved)).isDirectory();
|
|
621
|
+
} catch (cause) {
|
|
622
|
+
const code = cause instanceof Error && "code" in cause ? cause.code : undefined;
|
|
623
|
+
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
624
|
+
throw new Error(`Working directory does not exist: ${resolved}`, { cause });
|
|
625
|
+
}
|
|
626
|
+
throw cause;
|
|
627
|
+
}
|
|
628
|
+
if (!isDirectory) throw new Error(`Working directory is not a directory: ${resolved}`);
|
|
629
|
+
return resolved;
|
|
630
|
+
}
|
|
631
|
+
|
|
570
632
|
function shellCommand(shell: TerminalShell, command: string): [string, ...string[]] {
|
|
571
633
|
const script = shell.commandPrefix ? `${shell.commandPrefix}\n${command}` : command;
|
|
572
634
|
return [shell.shell, ...shell.args, script];
|
|
@@ -585,19 +647,26 @@ export function createTerminalStartTool(runtime: TerminalToolRuntime) {
|
|
|
585
647
|
"Terminals keep limited scrollback, so long output can be lost; run commands whose full output you need with bash, or redirect their output to a file.",
|
|
586
648
|
],
|
|
587
649
|
parameters: StartParameters,
|
|
650
|
+
annotations: {
|
|
651
|
+
readOnlyHint: false,
|
|
652
|
+
destructiveHint: true,
|
|
653
|
+
idempotentHint: false,
|
|
654
|
+
openWorldHint: true,
|
|
655
|
+
},
|
|
588
656
|
outputSchema: TerminalResultSchema,
|
|
589
657
|
async execute(_toolCallId, params, signal, _onUpdate, context) {
|
|
658
|
+
const cwd = await resolveWorkingDirectory(context.cwd, params.cwd);
|
|
590
659
|
const entry = await runtime.registry.startTerminal(ownerOf(context), {
|
|
591
660
|
command: shellCommand(runtime.shell(), params.command),
|
|
592
661
|
displayCommand: params.command,
|
|
593
|
-
cwd
|
|
662
|
+
cwd,
|
|
594
663
|
viewport: runtime.viewport(),
|
|
595
664
|
notify: params.notify ?? true,
|
|
596
665
|
});
|
|
597
666
|
// Launching, including a cold driver start, does not count toward the quiet period.
|
|
598
667
|
const startedAt = Date.now();
|
|
599
668
|
return driveTerminal(runtime.registry, entry, async () => {
|
|
600
|
-
const { snapshot } = await settleTerminal(entry, {
|
|
669
|
+
const { snapshot, reason } = await settleTerminal(entry, {
|
|
601
670
|
mode: "start",
|
|
602
671
|
waitMs: clampWait(params.wait_ms, START_WAIT_MS),
|
|
603
672
|
startedAt,
|
|
@@ -605,12 +674,18 @@ export function createTerminalStartTool(runtime: TerminalToolRuntime) {
|
|
|
605
674
|
baseline: undefined,
|
|
606
675
|
signal,
|
|
607
676
|
});
|
|
608
|
-
return terminalResult(runtime.registry, entry, snapshot);
|
|
677
|
+
return terminalResult(runtime.registry, entry, snapshot, reason);
|
|
609
678
|
});
|
|
610
679
|
},
|
|
611
680
|
});
|
|
612
681
|
}
|
|
613
682
|
|
|
683
|
+
function inputToExited(id: string, exit: TerminalExit | null): Error {
|
|
684
|
+
return new Error(
|
|
685
|
+
`${id} ${describeExit(exit)} and accepts no input. Poll it with terminal_send for its final screen, or remove it with terminal_stop.`,
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
|
|
614
689
|
/** `terminal_send`: type text and keys into a Terminal, or poll it, and wait for it to settle. */
|
|
615
690
|
export function createTerminalSendTool(runtime: TerminalToolRuntime) {
|
|
616
691
|
return defineTool<typeof SendParameters, TerminalResult>({
|
|
@@ -620,6 +695,12 @@ export function createTerminalSendTool(runtime: TerminalToolRuntime) {
|
|
|
620
695
|
"Type text and press keys in a Terminal, then return its screen once it settles: 250 ms of quiet, a wait_for_text match, exit, or wait_ms (default 500). With neither text nor keys it polls: it waits up to wait_ms (default 30000) for new output. Results include the lines that scrolled off since your previous result; a truncated result names a file with its full output.",
|
|
621
696
|
promptSnippet: "Send input to a Terminal, or poll it, and read its screen",
|
|
622
697
|
parameters: SendParameters,
|
|
698
|
+
annotations: {
|
|
699
|
+
readOnlyHint: false,
|
|
700
|
+
destructiveHint: true,
|
|
701
|
+
idempotentHint: false,
|
|
702
|
+
openWorldHint: true,
|
|
703
|
+
},
|
|
623
704
|
outputSchema: TerminalResultSchema,
|
|
624
705
|
async execute(_toolCallId, params, signal, _onUpdate, context) {
|
|
625
706
|
const entry = runtime.registry.find(ownerOf(context), params.id);
|
|
@@ -638,22 +719,30 @@ export function createTerminalSendTool(runtime: TerminalToolRuntime) {
|
|
|
638
719
|
}
|
|
639
720
|
const hasInput = (params.text ?? "") !== "" || keys.length > 0;
|
|
640
721
|
if (entry.state === "exited") {
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
entry
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
);
|
|
722
|
+
if (hasInput) {
|
|
723
|
+
// The error tells the agent about the exit, so a deferred Exit notification is redundant.
|
|
724
|
+
runtime.registry.markSeen(entry.id);
|
|
725
|
+
throw inputToExited(entry.id, entry.exit);
|
|
726
|
+
}
|
|
727
|
+
return terminalResult(runtime.registry, entry, undefined, "exited");
|
|
647
728
|
}
|
|
648
729
|
return driveTerminal(runtime.registry, entry, async () => {
|
|
649
730
|
const matches =
|
|
650
731
|
params.wait_for_text === undefined ? undefined : parseWaitPattern(params.wait_for_text);
|
|
651
|
-
const
|
|
732
|
+
const before =
|
|
733
|
+
hasInput || matches !== undefined ? await entry.handle.snapshot() : undefined;
|
|
734
|
+
if (hasInput && before?.state === "exited") {
|
|
735
|
+
// The exit watcher has not noticed this exit yet.
|
|
736
|
+
const exit = before.exit ?? { code: null, signal: null };
|
|
737
|
+
runtime.registry.terminalExited(entry.id, exit, before.screen, true);
|
|
738
|
+
throw inputToExited(entry.id, exit);
|
|
739
|
+
}
|
|
740
|
+
const baseline = matches === undefined ? undefined : before?.screen;
|
|
652
741
|
const startedAt = Date.now();
|
|
653
742
|
if (params.text !== undefined && params.text !== "") await entry.handle.type(params.text);
|
|
654
743
|
const validKeys = keys.filter(isKey);
|
|
655
744
|
if (validKeys.length > 0) await entry.handle.press(validKeys);
|
|
656
|
-
const { snapshot } = await settleTerminal(entry, {
|
|
745
|
+
const { snapshot, reason } = await settleTerminal(entry, {
|
|
657
746
|
mode: hasInput ? "input" : "poll",
|
|
658
747
|
waitMs: clampWait(params.wait_ms, hasInput ? INPUT_WAIT_MS : POLL_WAIT_MS),
|
|
659
748
|
startedAt,
|
|
@@ -661,7 +750,13 @@ export function createTerminalSendTool(runtime: TerminalToolRuntime) {
|
|
|
661
750
|
baseline,
|
|
662
751
|
signal,
|
|
663
752
|
});
|
|
664
|
-
return terminalResult(
|
|
753
|
+
return terminalResult(
|
|
754
|
+
runtime.registry,
|
|
755
|
+
entry,
|
|
756
|
+
snapshot,
|
|
757
|
+
reason,
|
|
758
|
+
unmatchedNote(reason, params.wait_for_text),
|
|
759
|
+
);
|
|
665
760
|
});
|
|
666
761
|
},
|
|
667
762
|
});
|
|
@@ -688,9 +783,15 @@ export function createTerminalStopTool(registry: TermctrlRegistry) {
|
|
|
688
783
|
name: "terminal_stop",
|
|
689
784
|
label: "terminal_stop",
|
|
690
785
|
description:
|
|
691
|
-
"Stop a Terminal (t1) or Background job (b1) and forget it. Running processes are killed; exited ones are removed. Returns a Terminal's final screen and scrolled-off lines, or a Background job's recent output.",
|
|
786
|
+
"Stop a Terminal (t1) or Background job (b1) and forget it. Running processes are killed; exited ones are removed. Returns a Terminal's final screen (omitted when unchanged since your previous result) and scrolled-off lines, or a Background job's recent output.",
|
|
692
787
|
promptSnippet: "Stop a Terminal or Background job",
|
|
693
788
|
parameters: StopParameters,
|
|
789
|
+
annotations: {
|
|
790
|
+
readOnlyHint: false,
|
|
791
|
+
destructiveHint: true,
|
|
792
|
+
idempotentHint: true,
|
|
793
|
+
openWorldHint: false,
|
|
794
|
+
},
|
|
694
795
|
outputSchema: StopResultSchema,
|
|
695
796
|
async execute(_toolCallId, params, _signal, _onUpdate, context) {
|
|
696
797
|
const owner = ownerOf(context);
|
|
@@ -714,14 +815,18 @@ export function createTerminalStopTool(registry: TermctrlRegistry) {
|
|
|
714
815
|
const parts = [header];
|
|
715
816
|
if (entry.kind === "terminal") {
|
|
716
817
|
const screen = entry.finalScreen ?? "";
|
|
717
|
-
|
|
818
|
+
// An exited Terminal's screen the agent already saw is not repeated.
|
|
819
|
+
const repeated = !wasRunning && previousScreen === screen;
|
|
820
|
+
const output = await fitOutput(registry, entry, repeated ? "" : screen, scrolled);
|
|
718
821
|
result.changed = previousScreen !== screen;
|
|
719
|
-
result.screen = output.screen;
|
|
720
|
-
result.scrolled_off = output.scrolledOff;
|
|
822
|
+
if (!repeated) result.screen = output.screen;
|
|
823
|
+
if (!repeated || output.scrolledOff !== "") result.scrolled_off = output.scrolledOff;
|
|
721
824
|
if (output.gap) result.output_missing = true;
|
|
722
825
|
if (output.fullOutputPath !== undefined) result.full_output_path = output.fullOutputPath;
|
|
723
826
|
if (output.scrolledOff !== "") parts.push(`--- scrolled off ---\n${output.scrolledOff}`);
|
|
724
|
-
parts.push(
|
|
827
|
+
if (repeated) parts.push("Its screen is unchanged since your last result.");
|
|
828
|
+
else
|
|
829
|
+
parts.push(`--- final screen ---\n${output.screen === "" ? "(blank)" : output.screen}`);
|
|
725
830
|
if (output.notice !== undefined) parts.push(`\n${output.notice}`);
|
|
726
831
|
} else {
|
|
727
832
|
result.output = entry.child.tail();
|
|
@@ -832,6 +937,12 @@ export function createTerminalWaitTool(runtime: TerminalWaitRuntime) {
|
|
|
832
937
|
"Do not poll a Background job's log or a Terminal in a loop to learn when it finishes: keep working and rely on its Exit notification, or call terminal_wait when nothing else can proceed.",
|
|
833
938
|
],
|
|
834
939
|
parameters: WaitParameters,
|
|
940
|
+
annotations: {
|
|
941
|
+
readOnlyHint: true,
|
|
942
|
+
destructiveHint: false,
|
|
943
|
+
idempotentHint: true,
|
|
944
|
+
openWorldHint: false,
|
|
945
|
+
},
|
|
835
946
|
outputSchema: WaitResultSchema,
|
|
836
947
|
async execute(_toolCallId, params, signal, _onUpdate, context) {
|
|
837
948
|
const owner = ownerOf(context);
|
|
@@ -898,6 +1009,12 @@ export function createTerminalListTool(registry: TermctrlRegistry) {
|
|
|
898
1009
|
"List your Terminals and Background jobs, running and exited, with their state. Background jobs include their log path.",
|
|
899
1010
|
promptSnippet: "List Terminals and Background jobs",
|
|
900
1011
|
parameters: ListParameters,
|
|
1012
|
+
annotations: {
|
|
1013
|
+
readOnlyHint: true,
|
|
1014
|
+
destructiveHint: false,
|
|
1015
|
+
idempotentHint: true,
|
|
1016
|
+
openWorldHint: false,
|
|
1017
|
+
},
|
|
901
1018
|
outputSchema: ListResultSchema,
|
|
902
1019
|
async execute(_toolCallId, _params, _signal, _onUpdate, context) {
|
|
903
1020
|
const now = Date.now();
|