@ian-pascoe/pi-termctrl 0.1.1 → 0.3.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 +30 -9
- package/package.json +3 -3
- package/src/bash-replacement.ts +5 -3
- package/src/exit-notification.ts +34 -19
- package/src/pi-termctrl-extension.ts +39 -0
- package/src/termctrl-registry.ts +67 -17
- package/src/terminal-tools.ts +259 -8
package/README.md
CHANGED
|
@@ -29,6 +29,7 @@ For a local checkout, run `pi -e ./packages/pi-termctrl/src/index.ts`.
|
|
|
29
29
|
| `terminal_send` | `id`, `text?`, `keys?`, `wait_for_text?`, `wait_ms?` |
|
|
30
30
|
| `terminal_stop` | `id`: a Terminal id (`t1`) or Background job id (`b1`) |
|
|
31
31
|
| `terminal_list` | none |
|
|
32
|
+
| `terminal_wait` | `ids?`, `wait_ms?` (default and maximum 300000) |
|
|
32
33
|
|
|
33
34
|
`terminal_start` runs `command` through the shell Pi's `bash` uses (the
|
|
34
35
|
`shellPath` setting, otherwise Pi's default bash), applies `shellCommandPrefix`,
|
|
@@ -87,6 +88,19 @@ or their session shuts down. `terminal_list` lists the caller's Terminals and,
|
|
|
87
88
|
in a separate `background_jobs` section, its Background jobs with their log
|
|
88
89
|
paths.
|
|
89
90
|
|
|
91
|
+
`terminal_wait` blocks until one of the caller's Terminals or Background jobs
|
|
92
|
+
exits: those listed in `ids`, otherwise every one still running. It returns
|
|
93
|
+
every exit by then, in the same form as an Exit notification, plus what is still
|
|
94
|
+
running, and those exits send no Exit notification. It also returns when
|
|
95
|
+
`wait_ms` runs out, when the call is aborted, or as soon as a message is queued
|
|
96
|
+
for the agent, such as the user steering or another entry's Exit notification;
|
|
97
|
+
a watched entry that exits after that is notified as usual. With nothing to
|
|
98
|
+
wait for it returns at once. Terminal exits are noticed within about 500 ms.
|
|
99
|
+
|
|
100
|
+
### Tool annotations
|
|
101
|
+
|
|
102
|
+
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.
|
|
103
|
+
|
|
90
104
|
## `bash` replacement
|
|
91
105
|
|
|
92
106
|
With `termctrl.replaceBash` (default `true`), Pi Termctrl replaces Pi's `bash`
|
|
@@ -106,9 +120,10 @@ older output becomes an omission line in the log. Once backgrounded, a
|
|
|
106
120
|
command's `timeout` is cleared, Esc and turn aborts no
|
|
107
121
|
longer reach it, and its output so far and all later output go to
|
|
108
122
|
`$TMPDIR/pi-termctrl/<pid>-<id>.log`. The result says
|
|
109
|
-
`Command moved to the background as b<n>`, includes the output so far,
|
|
110
|
-
|
|
111
|
-
`
|
|
123
|
+
`Command moved to the background as b<n>`, includes the output so far, gives
|
|
124
|
+
the log path, and tells the agent not to poll the log but to wait for the Exit
|
|
125
|
+
notification or call `terminal_wait`; read the log with `read` and stop the job
|
|
126
|
+
with `terminal_stop`. Its structured result follows Pi's `bash` output schema, except
|
|
112
127
|
that `exit_code` is absent and `background: { id, log_path }` is present.
|
|
113
128
|
|
|
114
129
|
A Background job's log is deleted when the job is stopped, when the user
|
|
@@ -124,11 +139,16 @@ backgrounding paths do not exist.
|
|
|
124
139
|
|
|
125
140
|
When a Terminal or Background job exits and the agent has neither seen the exit
|
|
126
141
|
in a `terminal_*` result nor stopped it, Pi Termctrl sends an Exit notification:
|
|
127
|
-
the id, command, exit code or signal, duration,
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
142
|
+
the id, command, exit code or signal, duration, where to find the full output
|
|
143
|
+
(a Background job's log path, or `terminal_send` for a Terminal's final screen),
|
|
144
|
+
and the last `termctrl.exitTailLines` lines of the final screen or log. Seeing
|
|
145
|
+
a Terminal running in a `terminal_*` result does not count as seeing its exit.
|
|
146
|
+
Exits reported by `terminal_wait` are not notified again. Exits in the same tick
|
|
147
|
+
share one message. While the agent works, notifications wait for the end of its
|
|
148
|
+
current turn, then steer it, so an exit a tool call in that turn reported is
|
|
149
|
+
not notified. When the agent is idle, a notification starts a turn.
|
|
150
|
+
`terminal_start {notify: false}` opts a Terminal out. Notifications go only to
|
|
151
|
+
the session that started the entry, and wait across `/reload`.
|
|
132
152
|
|
|
133
153
|
## `/ps` and footer
|
|
134
154
|
|
|
@@ -184,7 +204,8 @@ GNU/Linux on arm64 or x64 through optional dependencies. Set `TERMCTRL_BINARY`
|
|
|
184
204
|
to use another binary. Where no binary resolves (Windows, musl, or
|
|
185
205
|
`--omit=optional`), Pi Termctrl shows one diagnostic and registers no
|
|
186
206
|
`terminal_start` or `terminal_send`; the `bash` replacement, Background jobs,
|
|
187
|
-
`terminal_stop` and `
|
|
207
|
+
`terminal_stop`, `terminal_list` and `terminal_wait` keep working wherever Pi's
|
|
208
|
+
`bash` does.
|
|
188
209
|
|
|
189
210
|
Not supported: attaching to or taking over a Terminal, recordings, mouse input,
|
|
190
211
|
resize, raw bytes, persistence across Pi restarts, and graceful `SIGTERM`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ian-pascoe/pi-termctrl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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": {
|
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
|
),
|
|
@@ -273,7 +275,7 @@ function backgroundResult(outcome: BackgroundOutcome, startedAt: number) {
|
|
|
273
275
|
if (truncation.truncated) {
|
|
274
276
|
text += `\n\n[Showing lines ${truncation.totalLines - truncation.outputLines + 1}-${truncation.totalLines} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} or ${DEFAULT_MAX_LINES} line limit). Full output: ${outcome.logPath}]`;
|
|
275
277
|
}
|
|
276
|
-
const status = `Command moved to the background as ${outcome.id}. Its output so far and all later output go to ${outcome.logPath}
|
|
278
|
+
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}"}.`;
|
|
277
279
|
return {
|
|
278
280
|
content: [{ type: "text" as const, text: text === "" ? status : `${text}\n\n${status}` }],
|
|
279
281
|
details: undefined,
|
package/src/exit-notification.ts
CHANGED
|
@@ -17,6 +17,7 @@ const ExitNotificationDetailsSchema = Type.Object({
|
|
|
17
17
|
exit_code: Type.Union([Type.Number(), Type.Null()]),
|
|
18
18
|
signal: Type.Union([Type.String(), Type.Null()]),
|
|
19
19
|
duration_ms: Type.Number(),
|
|
20
|
+
log_path: Type.Optional(Type.String()),
|
|
20
21
|
}),
|
|
21
22
|
),
|
|
22
23
|
});
|
|
@@ -41,7 +42,8 @@ export function formatDuration(milliseconds: number): string {
|
|
|
41
42
|
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
|
|
42
43
|
}
|
|
43
44
|
|
|
44
|
-
|
|
45
|
+
/** The last `count` lines of `output`, without trailing blank lines. */
|
|
46
|
+
export function lastLines(output: string, count: number): string {
|
|
45
47
|
if (count === 0) return "";
|
|
46
48
|
const lines = output.replace(/\n+$/u, "").split("\n");
|
|
47
49
|
return lines.slice(-count).join("\n");
|
|
@@ -52,6 +54,24 @@ function headline(notice: ExitNotice): string {
|
|
|
52
54
|
return `${label} ${notice.id} ${describeExitStatus(notice.exit)} after ${formatDuration(notice.durationMs)}: ${notice.command}`;
|
|
53
55
|
}
|
|
54
56
|
|
|
57
|
+
/** Where the agent finds the rest of an exited entry's output. */
|
|
58
|
+
function pointer(notice: ExitNotice): string {
|
|
59
|
+
if (notice.logPath !== undefined) return `Log: ${notice.logPath}`;
|
|
60
|
+
return `terminal_send {"id": "${notice.id}"} returns its final screen.`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Describe one exit: what ended and how, where its full output is, and its last `tailLines` lines.
|
|
65
|
+
* Exit notifications and `terminal_wait` results share this text.
|
|
66
|
+
*/
|
|
67
|
+
export function formatExitNotice(notice: ExitNotice, tailLines: number): string {
|
|
68
|
+
const head = `${headline(notice)}\n${pointer(notice)}`;
|
|
69
|
+
const tail = lastLines(notice.output, tailLines);
|
|
70
|
+
if (tail === "") return head;
|
|
71
|
+
const source = notice.kind === "terminal" ? "final screen" : "output";
|
|
72
|
+
return `${head}\nLast lines of ${source}:\n${tail}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
55
75
|
/** The text and details of one Exit notification message. */
|
|
56
76
|
export interface ExitNotificationMessage {
|
|
57
77
|
readonly content: string;
|
|
@@ -63,26 +83,21 @@ export function formatExitNotification(
|
|
|
63
83
|
notices: readonly ExitNotice[],
|
|
64
84
|
tailLines: number,
|
|
65
85
|
): ExitNotificationMessage {
|
|
66
|
-
const content = notices
|
|
67
|
-
.map((notice) => {
|
|
68
|
-
const tail = lastLines(notice.output, tailLines);
|
|
69
|
-
const source = notice.kind === "terminal" ? "final screen" : "output";
|
|
70
|
-
return tail === ""
|
|
71
|
-
? headline(notice)
|
|
72
|
-
: `${headline(notice)}\nLast lines of ${source}:\n${tail}`;
|
|
73
|
-
})
|
|
74
|
-
.join("\n\n");
|
|
75
86
|
return {
|
|
76
|
-
content,
|
|
87
|
+
content: notices.map((notice) => formatExitNotice(notice, tailLines)).join("\n\n"),
|
|
77
88
|
details: {
|
|
78
|
-
exits: notices.map((notice) =>
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
89
|
+
exits: notices.map((notice) => {
|
|
90
|
+
const exit: ExitNotificationDetails["exits"][number] = {
|
|
91
|
+
id: notice.id,
|
|
92
|
+
kind: notice.kind === "job" ? "background_job" : "terminal",
|
|
93
|
+
command: notice.command,
|
|
94
|
+
exit_code: notice.exit.code,
|
|
95
|
+
signal: notice.exit.signal,
|
|
96
|
+
duration_ms: notice.durationMs,
|
|
97
|
+
};
|
|
98
|
+
if (notice.logPath !== undefined) exit.log_path = notice.logPath;
|
|
99
|
+
return exit;
|
|
100
|
+
}),
|
|
86
101
|
},
|
|
87
102
|
};
|
|
88
103
|
}
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
createTerminalSendTool,
|
|
25
25
|
createTerminalStartTool,
|
|
26
26
|
createTerminalStopTool,
|
|
27
|
+
createTerminalWaitTool,
|
|
27
28
|
type TerminalShell,
|
|
28
29
|
} from "./terminal-tools.js";
|
|
29
30
|
|
|
@@ -40,6 +41,7 @@ interface ActiveSession {
|
|
|
40
41
|
readonly ps: TermctrlPsController;
|
|
41
42
|
readonly shell: () => TerminalShell;
|
|
42
43
|
readonly viewport: TerminalViewport;
|
|
44
|
+
readonly exitTailLines: number;
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
/**
|
|
@@ -50,6 +52,14 @@ interface ActiveSession {
|
|
|
50
52
|
*/
|
|
51
53
|
class PiTermctrlController {
|
|
52
54
|
private active: ActiveSession | undefined;
|
|
55
|
+
/**
|
|
56
|
+
* Exit notifications sent mid-turn would queue in Pi as steering messages, beyond recall, while
|
|
57
|
+
* a tool call in the same turn, such as `terminal_wait`, can still report the exit. During an
|
|
58
|
+
* agent run they therefore wait for the turn's end, when Pi reads steering messages and those
|
|
59
|
+
* tool results are known.
|
|
60
|
+
*/
|
|
61
|
+
private runActive = false;
|
|
62
|
+
private atTurnEnd = false;
|
|
53
63
|
private readonly binary: TermctrlBinaryResolution;
|
|
54
64
|
private readonly registry: TermctrlRegistry;
|
|
55
65
|
|
|
@@ -88,11 +98,36 @@ class PiTermctrlController {
|
|
|
88
98
|
}
|
|
89
99
|
this.pi.on("session_start", (_event, context) => this.start(context));
|
|
90
100
|
this.pi.on("session_shutdown", (event) => this.shutdown(event));
|
|
101
|
+
this.pi.on("agent_start", () => {
|
|
102
|
+
this.runActive = true;
|
|
103
|
+
});
|
|
104
|
+
this.pi.on("turn_end", () => {
|
|
105
|
+
this.atTurnEnd = true;
|
|
106
|
+
try {
|
|
107
|
+
this.flushNotifications();
|
|
108
|
+
} finally {
|
|
109
|
+
this.atTurnEnd = false;
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
this.pi.on("agent_end", () => {
|
|
113
|
+
this.runActive = false;
|
|
114
|
+
this.flushNotifications();
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private flushNotifications(): void {
|
|
119
|
+
(TermctrlRegistry.current() ?? this.registry).flush();
|
|
91
120
|
}
|
|
92
121
|
|
|
93
122
|
private registerManagementTools(): void {
|
|
94
123
|
this.pi.registerTool(createTerminalStopTool(this.registry));
|
|
95
124
|
this.pi.registerTool(createTerminalListTool(this.registry));
|
|
125
|
+
this.pi.registerTool(
|
|
126
|
+
createTerminalWaitTool({
|
|
127
|
+
registry: this.registry,
|
|
128
|
+
exitTailLines: () => this.session().exitTailLines,
|
|
129
|
+
}),
|
|
130
|
+
);
|
|
96
131
|
}
|
|
97
132
|
|
|
98
133
|
private session(): ActiveSession {
|
|
@@ -112,7 +147,9 @@ class PiTermctrlController {
|
|
|
112
147
|
}
|
|
113
148
|
const registry = this.registry;
|
|
114
149
|
const owner = context.sessionManager.getSessionId();
|
|
150
|
+
this.runActive = false;
|
|
115
151
|
registry.bindOwner(owner, (notices) => {
|
|
152
|
+
if (this.runActive && !this.atTurnEnd) return false;
|
|
116
153
|
const message = formatExitNotification(notices, settings.exitTailLines);
|
|
117
154
|
this.pi.sendMessage(
|
|
118
155
|
{
|
|
@@ -123,6 +160,7 @@ class PiTermctrlController {
|
|
|
123
160
|
},
|
|
124
161
|
{ triggerTurn: true, deliverAs: "steer" },
|
|
125
162
|
);
|
|
163
|
+
return true;
|
|
126
164
|
});
|
|
127
165
|
|
|
128
166
|
const shellPath = settingsManager.getShellPath();
|
|
@@ -156,6 +194,7 @@ class PiTermctrlController {
|
|
|
156
194
|
return { shell: shell.shell, args: shell.args, commandPrefix };
|
|
157
195
|
},
|
|
158
196
|
viewport: settings.defaultViewport,
|
|
197
|
+
exitTailLines: settings.exitTailLines,
|
|
159
198
|
};
|
|
160
199
|
}
|
|
161
200
|
|
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 = 4;
|
|
17
17
|
|
|
18
18
|
/** Live Terminals plus Background jobs allowed across the whole process. */
|
|
19
19
|
export const LIVE_ENTRY_CAP = 16;
|
|
@@ -59,6 +59,8 @@ interface EntryBase {
|
|
|
59
59
|
seen: boolean;
|
|
60
60
|
stopRequested: StopRequester | undefined;
|
|
61
61
|
readonly notify: boolean;
|
|
62
|
+
/** `terminal_wait` calls watching this entry; they report its exit instead of a notification. */
|
|
63
|
+
watchers: number;
|
|
62
64
|
}
|
|
63
65
|
|
|
64
66
|
/** A PTY program driven through termctrl. */
|
|
@@ -102,10 +104,16 @@ export interface ExitNotice {
|
|
|
102
104
|
readonly durationMs: number;
|
|
103
105
|
/** The final screen of a Terminal or the recent log output of a Background job. */
|
|
104
106
|
readonly output: string;
|
|
107
|
+
/** A Background job's log. */
|
|
108
|
+
readonly logPath?: string;
|
|
105
109
|
}
|
|
106
110
|
|
|
107
|
-
/**
|
|
108
|
-
|
|
111
|
+
/**
|
|
112
|
+
* Delivers a batch of Exit notifications to one owner session. Returning `false` defers the batch:
|
|
113
|
+
* it stays queued until the next {@link TermctrlRegistry.flush}. Throwing keeps it queued and
|
|
114
|
+
* unbinds the owner.
|
|
115
|
+
*/
|
|
116
|
+
export type NotificationDelivery = (notices: readonly ExitNotice[]) => boolean;
|
|
109
117
|
|
|
110
118
|
/** Request to start one Terminal. */
|
|
111
119
|
export interface TerminalStartRequest {
|
|
@@ -272,12 +280,18 @@ export class TermctrlRegistry {
|
|
|
272
280
|
const due = notices.filter((notice) => this.state.entries.get(notice.id)?.seen !== true);
|
|
273
281
|
this.state.pending.delete(owner);
|
|
274
282
|
if (due.length === 0) continue;
|
|
283
|
+
const requeue = () =>
|
|
284
|
+
this.state.pending.set(owner, [...due, ...(this.state.pending.get(owner) ?? [])]);
|
|
275
285
|
try {
|
|
276
|
-
|
|
286
|
+
// Only an explicit `false` defers: deliveries bound by an older module return nothing.
|
|
287
|
+
if (delivery(due) === false) {
|
|
288
|
+
requeue();
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
277
291
|
for (const notice of due) this.markSeen(notice.id);
|
|
278
292
|
} catch {
|
|
279
293
|
this.state.owners.delete(owner);
|
|
280
|
-
|
|
294
|
+
requeue();
|
|
281
295
|
}
|
|
282
296
|
}
|
|
283
297
|
}
|
|
@@ -289,27 +303,61 @@ export class TermctrlRegistry {
|
|
|
289
303
|
setImmediate(() => state.current.flush());
|
|
290
304
|
}
|
|
291
305
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
const notices = this.state.pending.get(entry.owner) ?? [];
|
|
297
|
-
notices.push({
|
|
306
|
+
/** Describe an exited entry's exit, or `undefined` while it runs. */
|
|
307
|
+
noticeFor(entry: TermctrlEntry): ExitNotice | undefined {
|
|
308
|
+
if (entry.exit === null) return undefined;
|
|
309
|
+
const base = {
|
|
298
310
|
id: entry.id,
|
|
299
311
|
kind: entry.kind,
|
|
300
312
|
command: entry.command,
|
|
301
313
|
exit: entry.exit,
|
|
302
314
|
durationMs: (entry.exitedAt ?? Date.now()) - entry.startedAt,
|
|
303
|
-
|
|
304
|
-
|
|
315
|
+
};
|
|
316
|
+
return entry.kind === "job"
|
|
317
|
+
? { ...base, output: entry.child.tail(), logPath: entry.child.logPath }
|
|
318
|
+
: { ...base, output: entry.finalScreen ?? "" };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private queueNotice(entry: TermctrlEntry): void {
|
|
322
|
+
if (!entry.notify || entry.seen || entry.stopRequested === "agent" || entry.watchers > 0) {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
const notice = this.noticeFor(entry);
|
|
326
|
+
if (notice === undefined) return;
|
|
327
|
+
const notices = this.state.pending.get(entry.owner) ?? [];
|
|
328
|
+
if (notices.some((queued) => queued.id === entry.id)) return;
|
|
329
|
+
notices.push(notice);
|
|
305
330
|
this.state.pending.set(entry.owner, notices);
|
|
306
331
|
this.scheduleFlush();
|
|
307
332
|
}
|
|
308
333
|
|
|
309
|
-
/**
|
|
334
|
+
/**
|
|
335
|
+
* Watch entries for a `terminal_wait` call: their exits queue no Exit notification while watched.
|
|
336
|
+
* The returned release ends the watch and queues the notification of any exit the caller did not
|
|
337
|
+
* mark seen, such as one that happened after its wait ended.
|
|
338
|
+
*/
|
|
339
|
+
watch(entries: readonly TermctrlEntry[]): () => void {
|
|
340
|
+
for (const entry of entries) entry.watchers++;
|
|
341
|
+
let released = false;
|
|
342
|
+
return () => {
|
|
343
|
+
if (released) return;
|
|
344
|
+
released = true;
|
|
345
|
+
for (const entry of entries) {
|
|
346
|
+
entry.watchers--;
|
|
347
|
+
if (entry.state === "exited" && this.state.entries.has(entry.id)) {
|
|
348
|
+
this.queueNotice(entry);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Record that the agent has seen an entry's exit in a tool result. A running entry has no exit to
|
|
356
|
+
* see yet, so seeing it running leaves its later Exit notification due.
|
|
357
|
+
*/
|
|
310
358
|
markSeen(id: string): void {
|
|
311
359
|
const entry = this.state.entries.get(id);
|
|
312
|
-
if (entry
|
|
360
|
+
if (entry?.state === "exited") entry.seen = true;
|
|
313
361
|
}
|
|
314
362
|
|
|
315
363
|
// ── Queries ───────────────────────────────────────────────────────────────
|
|
@@ -404,6 +452,7 @@ export class TermctrlRegistry {
|
|
|
404
452
|
seen: false,
|
|
405
453
|
stopRequested: undefined,
|
|
406
454
|
notify: request.notify,
|
|
455
|
+
watchers: 0,
|
|
407
456
|
handle,
|
|
408
457
|
generation: slot.generation,
|
|
409
458
|
finalScreen: undefined,
|
|
@@ -467,7 +516,7 @@ export class TermctrlRegistry {
|
|
|
467
516
|
entry.exitedAt = Date.now();
|
|
468
517
|
entry.finalScreen = finalScreen;
|
|
469
518
|
if (seen) entry.seen = true;
|
|
470
|
-
this.queueNotice(entry
|
|
519
|
+
this.queueNotice(entry);
|
|
471
520
|
this.syncWatcher();
|
|
472
521
|
this.emitChange();
|
|
473
522
|
this.closeIdleDriver();
|
|
@@ -568,6 +617,7 @@ export class TermctrlRegistry {
|
|
|
568
617
|
seen: false,
|
|
569
618
|
stopRequested: undefined,
|
|
570
619
|
notify: true,
|
|
620
|
+
watchers: 0,
|
|
571
621
|
child,
|
|
572
622
|
settled,
|
|
573
623
|
settle,
|
|
@@ -585,7 +635,7 @@ export class TermctrlRegistry {
|
|
|
585
635
|
entry.exit = exit;
|
|
586
636
|
entry.exitedAt = Date.now();
|
|
587
637
|
entry.settle();
|
|
588
|
-
this.queueNotice(entry
|
|
638
|
+
this.queueNotice(entry);
|
|
589
639
|
this.emitChange();
|
|
590
640
|
}
|
|
591
641
|
|
package/src/terminal-tools.ts
CHANGED
|
@@ -17,7 +17,7 @@ import type {
|
|
|
17
17
|
TermctrlRegistry,
|
|
18
18
|
TerminalEntry,
|
|
19
19
|
} from "./termctrl-registry.js";
|
|
20
|
-
import { describeExitStatus } from "./exit-notification.js";
|
|
20
|
+
import { describeExitStatus, formatExitNotice, lastLines } from "./exit-notification.js";
|
|
21
21
|
import { termctrlTemporaryDirectory } from "./termctrl-driver.js";
|
|
22
22
|
import type { TerminalViewport } from "./pi-termctrl-settings.js";
|
|
23
23
|
import type { TerminalExit, TerminalSnapshot } from "./terminal-driver.js";
|
|
@@ -30,6 +30,8 @@ export const START_WAIT_MS = 2_000;
|
|
|
30
30
|
export const INPUT_WAIT_MS = 500;
|
|
31
31
|
export const POLL_WAIT_MS = 30_000;
|
|
32
32
|
export const MAX_WAIT_MS = 5 * 60_000;
|
|
33
|
+
/** How often `terminal_wait` checks for a queued message, which raises no event it can await. */
|
|
34
|
+
export const PENDING_CHECK_MS = 100;
|
|
33
35
|
|
|
34
36
|
const CONTROL_KEYS = Array.from(
|
|
35
37
|
{ length: 26 },
|
|
@@ -158,7 +160,10 @@ const StartParameters = Type.Object(
|
|
|
158
160
|
cwd: Type.Optional(Type.String({ description: "Working directory (default: current)" })),
|
|
159
161
|
wait_ms: WaitMsSchema,
|
|
160
162
|
notify: Type.Optional(
|
|
161
|
-
Type.Boolean({
|
|
163
|
+
Type.Boolean({
|
|
164
|
+
description:
|
|
165
|
+
"Send an Exit notification when it exits (default true); to block until it exits, use terminal_wait",
|
|
166
|
+
}),
|
|
162
167
|
),
|
|
163
168
|
},
|
|
164
169
|
{ additionalProperties: false },
|
|
@@ -188,6 +193,66 @@ const StopParameters = Type.Object(
|
|
|
188
193
|
|
|
189
194
|
const ListParameters = Type.Object({}, { additionalProperties: false });
|
|
190
195
|
|
|
196
|
+
const WaitParameters = Type.Object(
|
|
197
|
+
{
|
|
198
|
+
ids: Type.Optional(
|
|
199
|
+
Type.Array(Type.String(), {
|
|
200
|
+
description:
|
|
201
|
+
"Terminal or Background job ids to wait for (default: all of yours still running)",
|
|
202
|
+
}),
|
|
203
|
+
),
|
|
204
|
+
wait_ms: Type.Optional(
|
|
205
|
+
Type.Integer({
|
|
206
|
+
minimum: 0,
|
|
207
|
+
description: "Maximum milliseconds to wait (default and maximum 300000)",
|
|
208
|
+
}),
|
|
209
|
+
),
|
|
210
|
+
},
|
|
211
|
+
{ additionalProperties: false },
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
const EntryKindSchema = Type.Union([Type.Literal("terminal"), Type.Literal("background_job")]);
|
|
215
|
+
|
|
216
|
+
const WaitResultSchema = Type.Object({
|
|
217
|
+
reason: Type.Union(
|
|
218
|
+
[
|
|
219
|
+
Type.Literal("exited"),
|
|
220
|
+
Type.Literal("timeout"),
|
|
221
|
+
Type.Literal("message"),
|
|
222
|
+
Type.Literal("aborted"),
|
|
223
|
+
Type.Literal("nothing_running"),
|
|
224
|
+
],
|
|
225
|
+
{
|
|
226
|
+
description:
|
|
227
|
+
"Why the wait ended: an exit, wait_ms, a message queued for you, cancellation, or nothing to wait for",
|
|
228
|
+
},
|
|
229
|
+
),
|
|
230
|
+
exited: Type.Array(
|
|
231
|
+
Type.Object({
|
|
232
|
+
id: Type.String(),
|
|
233
|
+
kind: EntryKindSchema,
|
|
234
|
+
command: Type.String(),
|
|
235
|
+
exit_code: Type.Optional(Type.Number()),
|
|
236
|
+
signal: Type.Optional(Type.String()),
|
|
237
|
+
duration_ms: Type.Number(),
|
|
238
|
+
output: Type.String({
|
|
239
|
+
description: "Last lines of a Terminal's final screen or a Background job's output",
|
|
240
|
+
}),
|
|
241
|
+
log_path: Type.Optional(Type.String({ description: "Background job only: its log" })),
|
|
242
|
+
}),
|
|
243
|
+
),
|
|
244
|
+
running: Type.Array(
|
|
245
|
+
Type.Object({
|
|
246
|
+
id: Type.String(),
|
|
247
|
+
kind: EntryKindSchema,
|
|
248
|
+
command: Type.String(),
|
|
249
|
+
age_seconds: Type.Number(),
|
|
250
|
+
}),
|
|
251
|
+
),
|
|
252
|
+
});
|
|
253
|
+
type WaitResult = Static<typeof WaitResultSchema>;
|
|
254
|
+
type WaitReason = WaitResult["reason"];
|
|
255
|
+
|
|
191
256
|
/** Clamp a requested wait to the fixed 0 to 5 minute range. */
|
|
192
257
|
export function clampWait(requested: number | undefined, fallback: number): number {
|
|
193
258
|
return Math.min(MAX_WAIT_MS, Math.max(0, requested ?? fallback));
|
|
@@ -304,12 +369,32 @@ function locateCursor(
|
|
|
304
369
|
return undefined;
|
|
305
370
|
}
|
|
306
371
|
|
|
372
|
+
/**
|
|
373
|
+
* Find where the result's screen starts in termctrl's log. The screen is read before the log, in a
|
|
374
|
+
* separate termctrl request, so output in between can already have pushed the screen's top lines
|
|
375
|
+
* above the log's last screenful. Output only adds lines to the end of the log, so the screen is at
|
|
376
|
+
* the end or higher up, no higher than the log cursor. The screen is matched by every line except
|
|
377
|
+
* its last, which the program may still be writing. When nothing matches, as when the screen was
|
|
378
|
+
* redrawn in between, the log's last screenful is taken as the screen.
|
|
379
|
+
*/
|
|
380
|
+
function locateScreen(lines: readonly string[], screen: readonly string[], floor: number): number {
|
|
381
|
+
const end = Math.max(0, lines.length - screen.length);
|
|
382
|
+
for (let start = end; start >= floor; start--) {
|
|
383
|
+
let matches = true;
|
|
384
|
+
for (let row = 0; row < screen.length - 1 && matches; row++) {
|
|
385
|
+
matches = lines[start + row] === screen[row];
|
|
386
|
+
}
|
|
387
|
+
if (matches) return start;
|
|
388
|
+
}
|
|
389
|
+
return end;
|
|
390
|
+
}
|
|
391
|
+
|
|
307
392
|
/**
|
|
308
393
|
* Lines that left the screen since the previous result, advancing the Terminal's log cursor.
|
|
309
|
-
* Lines still on the screen stay unread, so a line rewritten after the agent saw it, such
|
|
310
|
-
* progress line, is reported in its final form once it scrolls off. When the cursor cannot be
|
|
311
|
-
* again, because termctrl dropped lines the agent never received or the screen was cleared,
|
|
312
|
-
* line termctrl still holds is reported and the result says that earlier output is missing.
|
|
394
|
+
* Lines still on the result's screen stay unread, so a line rewritten after the agent saw it, such
|
|
395
|
+
* as a progress line, is reported in its final form once it scrolls off. When the cursor cannot be
|
|
396
|
+
* found again, because termctrl dropped lines the agent never received or the screen was cleared,
|
|
397
|
+
* every line termctrl still holds is reported and the result says that earlier output is missing.
|
|
313
398
|
*/
|
|
314
399
|
async function takeScrolledOff(entry: TerminalEntry, screen: string): Promise<ScrolledOff> {
|
|
315
400
|
let logs: string;
|
|
@@ -320,8 +405,8 @@ async function takeScrolledOff(entry: TerminalEntry, screen: string): Promise<Sc
|
|
|
320
405
|
}
|
|
321
406
|
const lines = logs === "" ? [] : logs.split("\n");
|
|
322
407
|
const located = locateCursor(lines, entry.logCursor, entry.logAnchor);
|
|
323
|
-
const screenLines = screen === "" ?
|
|
324
|
-
const boundary =
|
|
408
|
+
const screenLines = screen === "" ? [] : screen.split("\n");
|
|
409
|
+
const boundary = locateScreen(lines, screenLines, located ?? 0);
|
|
325
410
|
const scrolled = lines.slice(Math.min(located ?? 0, boundary), boundary);
|
|
326
411
|
entry.logCursor = boundary;
|
|
327
412
|
entry.logAnchor =
|
|
@@ -520,6 +605,12 @@ export function createTerminalStartTool(runtime: TerminalToolRuntime) {
|
|
|
520
605
|
"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.",
|
|
521
606
|
],
|
|
522
607
|
parameters: StartParameters,
|
|
608
|
+
annotations: {
|
|
609
|
+
readOnlyHint: false,
|
|
610
|
+
destructiveHint: true,
|
|
611
|
+
idempotentHint: false,
|
|
612
|
+
openWorldHint: true,
|
|
613
|
+
},
|
|
523
614
|
outputSchema: TerminalResultSchema,
|
|
524
615
|
async execute(_toolCallId, params, signal, _onUpdate, context) {
|
|
525
616
|
const entry = await runtime.registry.startTerminal(ownerOf(context), {
|
|
@@ -555,6 +646,12 @@ export function createTerminalSendTool(runtime: TerminalToolRuntime) {
|
|
|
555
646
|
"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.",
|
|
556
647
|
promptSnippet: "Send input to a Terminal, or poll it, and read its screen",
|
|
557
648
|
parameters: SendParameters,
|
|
649
|
+
annotations: {
|
|
650
|
+
readOnlyHint: false,
|
|
651
|
+
destructiveHint: true,
|
|
652
|
+
idempotentHint: false,
|
|
653
|
+
openWorldHint: true,
|
|
654
|
+
},
|
|
558
655
|
outputSchema: TerminalResultSchema,
|
|
559
656
|
async execute(_toolCallId, params, signal, _onUpdate, context) {
|
|
560
657
|
const entry = runtime.registry.find(ownerOf(context), params.id);
|
|
@@ -626,6 +723,12 @@ export function createTerminalStopTool(registry: TermctrlRegistry) {
|
|
|
626
723
|
"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.",
|
|
627
724
|
promptSnippet: "Stop a Terminal or Background job",
|
|
628
725
|
parameters: StopParameters,
|
|
726
|
+
annotations: {
|
|
727
|
+
readOnlyHint: false,
|
|
728
|
+
destructiveHint: true,
|
|
729
|
+
idempotentHint: true,
|
|
730
|
+
openWorldHint: false,
|
|
731
|
+
},
|
|
629
732
|
outputSchema: StopResultSchema,
|
|
630
733
|
async execute(_toolCallId, params, _signal, _onUpdate, context) {
|
|
631
734
|
const owner = ownerOf(context);
|
|
@@ -688,6 +791,148 @@ function listLine(entry: TermctrlEntry, now: number): string {
|
|
|
688
791
|
return `${entry.id} ${state} · ${age}s · ${entry.command}${log}`;
|
|
689
792
|
}
|
|
690
793
|
|
|
794
|
+
/** Session-scoped values `terminal_wait` reads at call time. */
|
|
795
|
+
export interface TerminalWaitRuntime {
|
|
796
|
+
readonly registry: TermctrlRegistry;
|
|
797
|
+
readonly exitTailLines: () => number;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/** The entries one `terminal_wait` call watches: the listed ids, or every running entry. */
|
|
801
|
+
function waitTargets(
|
|
802
|
+
registry: TermctrlRegistry,
|
|
803
|
+
owner: string,
|
|
804
|
+
ids: readonly string[] | undefined,
|
|
805
|
+
): TermctrlEntry[] {
|
|
806
|
+
if (ids === undefined) {
|
|
807
|
+
// An exit whose Exit notification is still queued has not reached the agent yet.
|
|
808
|
+
return registry.ownedEntries(owner).filter((entry) => entry.state === "running" || !entry.seen);
|
|
809
|
+
}
|
|
810
|
+
const targets = new Map<string, TermctrlEntry>();
|
|
811
|
+
for (const id of ids) {
|
|
812
|
+
const entry = registry.find(owner, id);
|
|
813
|
+
if (entry === undefined) throw unknownId(id);
|
|
814
|
+
targets.set(id, entry);
|
|
815
|
+
}
|
|
816
|
+
return [...targets.values()];
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/** Wait until a target exits, the wait runs out, the call is aborted, or a message is queued. */
|
|
820
|
+
function awaitExit(
|
|
821
|
+
registry: TermctrlRegistry,
|
|
822
|
+
targets: readonly TermctrlEntry[],
|
|
823
|
+
waitMs: number,
|
|
824
|
+
signal: AbortSignal | undefined,
|
|
825
|
+
hasPendingMessages: () => boolean,
|
|
826
|
+
): Promise<WaitReason> {
|
|
827
|
+
const deadline = Date.now() + waitMs;
|
|
828
|
+
return new Promise((resolve) => {
|
|
829
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
830
|
+
let unsubscribe: (() => void) | undefined;
|
|
831
|
+
const check = () => {
|
|
832
|
+
let reason: WaitReason | undefined;
|
|
833
|
+
if (targets.some((entry) => entry.state === "exited")) reason = "exited";
|
|
834
|
+
else if (signal?.aborted === true) reason = "aborted";
|
|
835
|
+
else if (hasPendingMessages()) reason = "message";
|
|
836
|
+
else if (Date.now() >= deadline) reason = "timeout";
|
|
837
|
+
if (reason === undefined) {
|
|
838
|
+
clearTimeout(timer);
|
|
839
|
+
timer = setTimeout(check, Math.max(0, Math.min(PENDING_CHECK_MS, deadline - Date.now())));
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
clearTimeout(timer);
|
|
843
|
+
unsubscribe?.();
|
|
844
|
+
signal?.removeEventListener("abort", check);
|
|
845
|
+
resolve(reason);
|
|
846
|
+
};
|
|
847
|
+
unsubscribe = registry.onChange(check);
|
|
848
|
+
signal?.addEventListener("abort", check, { once: true });
|
|
849
|
+
check();
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
const WAIT_REASON_TEXT = {
|
|
854
|
+
timeout: (waitMs) => `Nothing exited within ${Math.round(waitMs / 1000)}s.`,
|
|
855
|
+
message: () => "Returned early: a message is waiting for you.",
|
|
856
|
+
aborted: () => "Wait cancelled.",
|
|
857
|
+
nothing_running: () => "Nothing to wait for: you have no running Terminals or Background jobs.",
|
|
858
|
+
} satisfies Record<Exclude<WaitReason, "exited">, (waitMs: number) => string>;
|
|
859
|
+
|
|
860
|
+
/** `terminal_wait`: block until a Terminal or Background job exits. */
|
|
861
|
+
export function createTerminalWaitTool(runtime: TerminalWaitRuntime) {
|
|
862
|
+
const { registry } = runtime;
|
|
863
|
+
return defineTool<typeof WaitParameters, WaitResult>({
|
|
864
|
+
name: "terminal_wait",
|
|
865
|
+
label: "terminal_wait",
|
|
866
|
+
description:
|
|
867
|
+
"Wait until one of your Terminals or Background jobs exits, then return every exit so far and what is still running. Returns early when wait_ms (default 300000) runs out or a message is queued for you. Exits it returns send no Exit notification.",
|
|
868
|
+
promptSnippet: "Wait for a Terminal or Background job to exit",
|
|
869
|
+
promptGuidelines: [
|
|
870
|
+
"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.",
|
|
871
|
+
],
|
|
872
|
+
parameters: WaitParameters,
|
|
873
|
+
annotations: {
|
|
874
|
+
readOnlyHint: true,
|
|
875
|
+
destructiveHint: false,
|
|
876
|
+
idempotentHint: true,
|
|
877
|
+
openWorldHint: false,
|
|
878
|
+
},
|
|
879
|
+
outputSchema: WaitResultSchema,
|
|
880
|
+
async execute(_toolCallId, params, signal, _onUpdate, context) {
|
|
881
|
+
const owner = ownerOf(context);
|
|
882
|
+
const targets = waitTargets(registry, owner, params.ids);
|
|
883
|
+
const waitMs = clampWait(params.wait_ms, MAX_WAIT_MS);
|
|
884
|
+
let reason: WaitReason = "nothing_running";
|
|
885
|
+
const result: WaitResult = { reason, exited: [], running: [] };
|
|
886
|
+
const parts: string[] = [];
|
|
887
|
+
if (targets.length > 0) {
|
|
888
|
+
const release = registry.watch(targets);
|
|
889
|
+
try {
|
|
890
|
+
reason = await awaitExit(registry, targets, waitMs, signal, () =>
|
|
891
|
+
context.hasPendingMessages(),
|
|
892
|
+
);
|
|
893
|
+
const tailLines = runtime.exitTailLines();
|
|
894
|
+
for (const entry of targets) {
|
|
895
|
+
const notice = registry.noticeFor(entry);
|
|
896
|
+
if (notice === undefined) continue;
|
|
897
|
+
registry.markSeen(entry.id);
|
|
898
|
+
parts.push(formatExitNotice(notice, tailLines));
|
|
899
|
+
const exited: WaitResult["exited"][number] = {
|
|
900
|
+
id: notice.id,
|
|
901
|
+
kind: notice.kind === "job" ? "background_job" : "terminal",
|
|
902
|
+
command: notice.command,
|
|
903
|
+
...exitFields(notice.exit),
|
|
904
|
+
duration_ms: notice.durationMs,
|
|
905
|
+
output: lastLines(notice.output, tailLines),
|
|
906
|
+
};
|
|
907
|
+
if (notice.logPath !== undefined) exited.log_path = notice.logPath;
|
|
908
|
+
result.exited.push(exited);
|
|
909
|
+
}
|
|
910
|
+
} finally {
|
|
911
|
+
release();
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
result.reason = reason;
|
|
915
|
+
if (reason !== "exited") parts.unshift(WAIT_REASON_TEXT[reason](waitMs));
|
|
916
|
+
const now = Date.now();
|
|
917
|
+
const running = registry.ownedEntries(owner).filter((entry) => entry.state === "running");
|
|
918
|
+
result.running = running.map((entry) => ({
|
|
919
|
+
id: entry.id,
|
|
920
|
+
kind: entry.kind === "job" ? "background_job" : "terminal",
|
|
921
|
+
command: entry.command,
|
|
922
|
+
age_seconds: Math.round((now - entry.startedAt) / 1000),
|
|
923
|
+
}));
|
|
924
|
+
if (running.length > 0) {
|
|
925
|
+
parts.push(`Still running:\n${running.map((entry) => listLine(entry, now)).join("\n")}`);
|
|
926
|
+
}
|
|
927
|
+
return {
|
|
928
|
+
content: [{ type: "text" as const, text: parts.join("\n\n") }],
|
|
929
|
+
details: result,
|
|
930
|
+
structuredContent: result,
|
|
931
|
+
};
|
|
932
|
+
},
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
|
|
691
936
|
/** `terminal_list`: list the caller's Terminals and Background jobs. */
|
|
692
937
|
export function createTerminalListTool(registry: TermctrlRegistry) {
|
|
693
938
|
return defineTool<typeof ListParameters, ListResult>({
|
|
@@ -697,6 +942,12 @@ export function createTerminalListTool(registry: TermctrlRegistry) {
|
|
|
697
942
|
"List your Terminals and Background jobs, running and exited, with their state. Background jobs include their log path.",
|
|
698
943
|
promptSnippet: "List Terminals and Background jobs",
|
|
699
944
|
parameters: ListParameters,
|
|
945
|
+
annotations: {
|
|
946
|
+
readOnlyHint: true,
|
|
947
|
+
destructiveHint: false,
|
|
948
|
+
idempotentHint: true,
|
|
949
|
+
openWorldHint: false,
|
|
950
|
+
},
|
|
700
951
|
outputSchema: ListResultSchema,
|
|
701
952
|
async execute(_toolCallId, _params, _signal, _onUpdate, context) {
|
|
702
953
|
const now = Date.now();
|