@ian-pascoe/pi-termctrl 0.8.3 → 0.9.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 +34 -10
- package/package.json +2 -2
- package/src/bash-replacement.ts +53 -19
- package/src/pi-termctrl-extension.ts +2 -0
- package/src/terminal-render.ts +1 -1
- package/src/terminal-tools.ts +76 -39
package/README.md
CHANGED
|
@@ -38,8 +38,8 @@ the session's directory. A `cwd` that is missing or not a directory fails with a
|
|
|
38
38
|
error naming the resolved path.
|
|
39
39
|
|
|
40
40
|
`terminal_start` and `terminal_send` wait until the Terminal **settles**: at the
|
|
41
|
-
first of 250 ms of screen quiet, a `wait_for_text` match, process exit,
|
|
42
|
-
`wait_ms` running out. `wait_for_text` is a literal substring, or a regex when
|
|
41
|
+
first of 250 ms of screen quiet, a `wait_for_text` match, process exit,
|
|
42
|
+
`wait_ms` running out, or a Queued Steer (see below). `wait_for_text` is a literal substring, or a regex when
|
|
43
43
|
written as `/source/flags`; with `wait_for_text`, quiet alone does not end the
|
|
44
44
|
wait, and text already on the screen before `terminal_send` counts only after
|
|
45
45
|
the screen changes. Default waits are 2 s for `terminal_start`, 500 ms for `terminal_send`
|
|
@@ -54,10 +54,22 @@ turn too, then reports what the calls ahead of it left. A call queued behind a
|
|
|
54
54
|
stop finds the Terminal gone. Calls to different Terminals stay concurrent. Aborting a waiting
|
|
55
55
|
call removes it without affecting the running one.
|
|
56
56
|
|
|
57
|
-
Results say why the wait ended in `settle_reason` (`matched`, `timeout`, `quiet
|
|
58
|
-
or `
|
|
59
|
-
`wait_for_text` that timed out
|
|
60
|
-
their reason too.
|
|
57
|
+
Results say why the wait ended in `settle_reason` (`matched`, `timeout`, `quiet`,
|
|
58
|
+
`exited` or `steered`) and in their first line, such as
|
|
59
|
+
`t1 running · settled: timeout`; a `wait_for_text` that timed out or was
|
|
60
|
+
steered adds that the pattern was not seen. Polls report their reason too.
|
|
61
|
+
|
|
62
|
+
**Steer Yield.** Pi hands the agent a Queued Steer (a message the user or a
|
|
63
|
+
parent agent queued while it works) only after every tool call in the current
|
|
64
|
+
batch has returned, so a long wait would delay it. When a Queued Steer is
|
|
65
|
+
pending for the session, the settle wait of `terminal_start` and `terminal_send`
|
|
66
|
+
ends at once with `settle_reason: "steered"` (`t1 running · settled: steered`).
|
|
67
|
+
Input (`text`, `keys`) is still typed first, and the Terminal keeps running; poll
|
|
68
|
+
it again with `terminal_send` once you have read the message. A steer stays
|
|
69
|
+
pending until Pi starts the next turn, so a call that starts while one is
|
|
70
|
+
pending settles immediately after typing its input. Calls waiting in the Call
|
|
71
|
+
queue still run in order, each yielding as soon as it reaches its settle wait.
|
|
72
|
+
Follow-ups never count.
|
|
61
73
|
|
|
62
74
|
Sending `text` or `keys` to an exited Terminal is an error that names its exit
|
|
63
75
|
code or signal; a poll (neither `text` nor `keys`) still returns its final screen.
|
|
@@ -134,9 +146,12 @@ paths.
|
|
|
134
146
|
exits: those listed in `ids`, otherwise every one still running. It returns
|
|
135
147
|
every exit by then, in the same form as an Exit notification, plus what is still
|
|
136
148
|
running, and those exits send no Exit notification. It also returns when
|
|
137
|
-
`wait_ms` runs out, when the call is aborted, or as
|
|
138
|
-
|
|
139
|
-
|
|
149
|
+
`wait_ms` runs out, when the call is aborted, or as a Steer Yield with
|
|
150
|
+
`reason: "steered"` as soon as a Queued Steer is pending for the session, such
|
|
151
|
+
as the user steering or a parent agent's message; whatever it waited on keeps
|
|
152
|
+
running, and a watched entry that exits after that is notified as usual. A
|
|
153
|
+
follow-up never ends the wait, and neither does another entry's Exit
|
|
154
|
+
notification: Exit notifications wait for the end of the turn. With nothing to
|
|
140
155
|
wait for it returns at once. Terminal exits are noticed within about 500 ms.
|
|
141
156
|
|
|
142
157
|
### Tool annotations
|
|
@@ -149,13 +164,22 @@ With `termctrl.replaceBash` (default `true`), Pi Termctrl replaces Pi's `bash`
|
|
|
149
164
|
tool with Pi's own definition plus one parameter, `background?: boolean`.
|
|
150
165
|
Commands still run on pipes through Pi's local `exec`, so Pi keeps spawning,
|
|
151
166
|
process-tree kills and rendering, and foreground results differ from Pi's only
|
|
152
|
-
by the shorter tail below. A command becomes a Background job in
|
|
167
|
+
by the shorter tail below. A command becomes a Background job in three ways:
|
|
153
168
|
|
|
154
169
|
- **`background: true`** waits 2 s. A command that finishes first returns an
|
|
155
170
|
ordinary result; otherwise the call returns a background result. If the cap
|
|
156
171
|
is reached during those 2 s, the command stays in the foreground.
|
|
157
172
|
- **Ctrl+B** while an agent `bash` call runs moves every running call to the
|
|
158
173
|
background. At other times Ctrl+B stays the editor's cursor-left.
|
|
174
|
+
- **Steer Yield**: when a Queued Steer becomes pending for the session, every
|
|
175
|
+
running `bash` call of that session moves to the background at once, exactly
|
|
176
|
+
as Ctrl+B does, so the message reaches the agent without waiting for the
|
|
177
|
+
command. A call that starts while a Queued Steer is pending becomes a
|
|
178
|
+
Background job as soon as it has started, even with `background: false`. The
|
|
179
|
+
result says why, as in
|
|
180
|
+
`Command moved to the background as b3 because a message was queued for you`.
|
|
181
|
+
If the job cap is reached, the call stays in the foreground and the user gets
|
|
182
|
+
a notification saying so. Follow-ups never count.
|
|
159
183
|
|
|
160
184
|
Up to 16 MiB of a command's output is kept in memory until it is backgrounded;
|
|
161
185
|
older output becomes an omission line in the log. Once backgrounded, a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ian-pascoe/pi-termctrl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Interactive Terminals, Background jobs, and a /ps panel for Pi.",
|
|
6
6
|
"keywords": [
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"provenance": true
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@ian-pascoe/pi-utils": "^0.
|
|
37
|
+
"@ian-pascoe/pi-utils": "^0.9.0",
|
|
38
38
|
"@kitlangton/terminal-control": "^1.2.1"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
package/src/bash-replacement.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
type TruncationResult,
|
|
20
20
|
} from "@earendil-works/pi-coding-agent";
|
|
21
21
|
import { matchesKey } from "@earendil-works/pi-tui";
|
|
22
|
+
import { hasQueuedSteer, onQueuedSteer } from "@ian-pascoe/pi-utils/queued-steer";
|
|
22
23
|
import { noticeText } from "@ian-pascoe/pi-utils/ui";
|
|
23
24
|
import { Type, type TSchema } from "typebox";
|
|
24
25
|
import { KILLED_EXIT, TermctrlRegistry } from "./termctrl-registry.js";
|
|
@@ -47,9 +48,14 @@ export function backgroundLogPath(id: string): string {
|
|
|
47
48
|
return join(termctrlTemporaryDirectory(), `${process.pid}-${id}.log`);
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
/** What moved a running call to the background without the model asking: the user, or a Steer Yield. */
|
|
52
|
+
export type BackgroundCause = "user" | "steer";
|
|
53
|
+
|
|
50
54
|
/** What a backgrounded call reports to the agent. */
|
|
51
55
|
interface BackgroundOutcome {
|
|
52
56
|
readonly id: string;
|
|
57
|
+
/** What moved the call; undefined when the model asked for the background. */
|
|
58
|
+
readonly cause: BackgroundCause | undefined;
|
|
53
59
|
readonly logPath: string;
|
|
54
60
|
readonly output: string;
|
|
55
61
|
/** What the in-memory buffer dropped from the head of `output`. */
|
|
@@ -85,9 +91,11 @@ class BashCall {
|
|
|
85
91
|
private resolveBackground!: (outcome: BackgroundOutcome) => void;
|
|
86
92
|
readonly backgrounded: Promise<BackgroundOutcome>;
|
|
87
93
|
private isBackgrounded = false;
|
|
94
|
+
/** Runs right after the command was handed to Pi's `exec`, before it can print anything. */
|
|
95
|
+
onStarted: (() => void) | undefined;
|
|
88
96
|
|
|
89
97
|
constructor(
|
|
90
|
-
|
|
98
|
+
readonly owner: string,
|
|
91
99
|
private readonly command: string,
|
|
92
100
|
private readonly local: BashOperations,
|
|
93
101
|
) {
|
|
@@ -142,7 +150,9 @@ class BashCall {
|
|
|
142
150
|
signal: this.controller.signal,
|
|
143
151
|
};
|
|
144
152
|
if (env !== undefined) localOptions.env = env;
|
|
145
|
-
const
|
|
153
|
+
const running = this.local.exec(command, cwd, localOptions);
|
|
154
|
+
this.onStarted?.();
|
|
155
|
+
const result = await running;
|
|
146
156
|
this.finish({ code: result.exitCode, signal: null });
|
|
147
157
|
return result;
|
|
148
158
|
} catch (error) {
|
|
@@ -188,7 +198,7 @@ class BashCall {
|
|
|
188
198
|
}
|
|
189
199
|
|
|
190
200
|
/** Move the running command to the background. Returns false when it already finished. */
|
|
191
|
-
background(registry: TermctrlRegistry): boolean {
|
|
201
|
+
background(registry: TermctrlRegistry, cause?: BackgroundCause): boolean {
|
|
192
202
|
if (this.isBackgrounded || this.settled || !this.started) return false;
|
|
193
203
|
// An aborted or timed-out command is already dying; let Pi report it.
|
|
194
204
|
if (this.controller.signal.aborted) return false;
|
|
@@ -226,6 +236,7 @@ class BashCall {
|
|
|
226
236
|
this.remember(output);
|
|
227
237
|
this.resolveBackground({
|
|
228
238
|
id: entry.id,
|
|
239
|
+
cause,
|
|
229
240
|
logPath: entry.child.logPath,
|
|
230
241
|
output,
|
|
231
242
|
dropped: this.dropped(),
|
|
@@ -234,22 +245,31 @@ class BashCall {
|
|
|
234
245
|
}
|
|
235
246
|
}
|
|
236
247
|
|
|
237
|
-
/**
|
|
248
|
+
/** Pi's `bash` calls running in one session, and the Ctrl+B and Queued Steer subscriptions while any run. */
|
|
238
249
|
export class RunningBashCalls {
|
|
239
250
|
private readonly calls = new Set<BashCall>();
|
|
240
251
|
private unsubscribe: (() => void) | undefined;
|
|
241
252
|
|
|
242
253
|
constructor(private readonly registry: () => TermctrlRegistry) {}
|
|
243
254
|
|
|
244
|
-
/** Track a call; the first one subscribes to Ctrl+B. */
|
|
255
|
+
/** Track a call; the first one subscribes to Ctrl+B and to Queued Steers for its session. */
|
|
245
256
|
add(call: BashCall, ui: ExtensionUIContext): void {
|
|
246
257
|
this.calls.add(call);
|
|
258
|
+
// A call that starts while a steer is pending moves at once; later steers reach it below.
|
|
259
|
+
call.onStarted = () => {
|
|
260
|
+
if (hasQueuedSteer(call.owner)) this.backgroundOne(call, ui, "steer");
|
|
261
|
+
};
|
|
247
262
|
if (this.unsubscribe !== undefined) return;
|
|
248
|
-
|
|
263
|
+
const stopSteers = onQueuedSteer(call.owner, () => this.backgroundAll(ui, "steer"));
|
|
264
|
+
const stopInput = ui.onTerminalInput((data) => {
|
|
249
265
|
if (!matchesKey(data, "ctrl+b")) return undefined;
|
|
250
266
|
this.backgroundAll(ui);
|
|
251
267
|
return { consume: true };
|
|
252
268
|
});
|
|
269
|
+
this.unsubscribe = () => {
|
|
270
|
+
stopSteers();
|
|
271
|
+
stopInput();
|
|
272
|
+
};
|
|
253
273
|
}
|
|
254
274
|
|
|
255
275
|
/** Stop tracking a call; the last one releases Ctrl+B back to the editor. */
|
|
@@ -259,17 +279,29 @@ export class RunningBashCalls {
|
|
|
259
279
|
this.dispose();
|
|
260
280
|
}
|
|
261
281
|
|
|
262
|
-
/** Move every running call to the background. */
|
|
263
|
-
backgroundAll(ui: Pick<ExtensionUIContext, "notify"
|
|
264
|
-
for (const call of this.calls)
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
282
|
+
/** Move every running call to the background, as Ctrl+B does and as a Queued Steer does. */
|
|
283
|
+
backgroundAll(ui: Pick<ExtensionUIContext, "notify">, cause: BackgroundCause = "user"): void {
|
|
284
|
+
for (const call of this.calls) this.backgroundOne(call, ui, cause);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private backgroundOne(
|
|
288
|
+
call: BashCall,
|
|
289
|
+
ui: Pick<ExtensionUIContext, "notify">,
|
|
290
|
+
cause: BackgroundCause,
|
|
291
|
+
): void {
|
|
292
|
+
try {
|
|
293
|
+
call.background(this.registry(), cause === "steer" ? cause : undefined);
|
|
294
|
+
} catch (error) {
|
|
295
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
296
|
+
ui.notify(
|
|
297
|
+
noticeText(
|
|
298
|
+
"Termctrl",
|
|
299
|
+
cause === "steer"
|
|
300
|
+
? `A message was queued for the agent, but its running bash call stayed in the foreground: ${reason}`
|
|
301
|
+
: reason,
|
|
302
|
+
),
|
|
303
|
+
"warning",
|
|
304
|
+
);
|
|
273
305
|
}
|
|
274
306
|
}
|
|
275
307
|
|
|
@@ -416,7 +448,8 @@ function backgroundResult(
|
|
|
416
448
|
}
|
|
417
449
|
// Scripts get the same contract as a finished call's structured result: Pi's limits, not the tail.
|
|
418
450
|
const structured = truncateBuffered(outcome.output, outcome.dropped, {});
|
|
419
|
-
const
|
|
451
|
+
const why = outcome.cause === "steer" ? " because a message was queued for you" : "";
|
|
452
|
+
const status = `Command moved to the background as ${outcome.id}${why}. 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}"}.`;
|
|
420
453
|
return {
|
|
421
454
|
content: [{ type: "text" as const, text: text === "" ? status : `${text}\n\n${status}` }],
|
|
422
455
|
details: undefined,
|
|
@@ -513,6 +546,8 @@ export function createBashReplacement(options: BashReplacementOptions) {
|
|
|
513
546
|
};
|
|
514
547
|
const startedAt = performance.now();
|
|
515
548
|
const limits = options.bashTail;
|
|
549
|
+
// Tracked before Pi's `execute` runs, which may reach `exec` at once.
|
|
550
|
+
options.calls.add(call, context.ui);
|
|
516
551
|
const piRunning = inner.execute(
|
|
517
552
|
toolCallId,
|
|
518
553
|
params.timeout === undefined
|
|
@@ -534,7 +569,6 @@ export function createBashReplacement(options: BashReplacementOptions) {
|
|
|
534
569
|
},
|
|
535
570
|
);
|
|
536
571
|
running.catch(() => {});
|
|
537
|
-
options.calls.add(call, context.ui);
|
|
538
572
|
const yieldTimer =
|
|
539
573
|
params.background === true
|
|
540
574
|
? setTimeout(() => {
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type SessionShutdownEvent,
|
|
9
9
|
} from "@earendil-works/pi-coding-agent";
|
|
10
10
|
import { acceptNullForOptionalArguments } from "@ian-pascoe/pi-utils/null-optional-arguments";
|
|
11
|
+
import { trackQueuedSteers } from "@ian-pascoe/pi-utils/queued-steer";
|
|
11
12
|
import { expandMessageOnClick, noticeText } from "@ian-pascoe/pi-utils/ui";
|
|
12
13
|
import { createBashReplacement, RunningBashCalls } from "./bash-replacement.js";
|
|
13
14
|
import {
|
|
@@ -86,6 +87,7 @@ class PiTermctrlController {
|
|
|
86
87
|
}
|
|
87
88
|
|
|
88
89
|
register(): void {
|
|
90
|
+
trackQueuedSteers(this.pi);
|
|
89
91
|
this.pi.registerMessageRenderer(
|
|
90
92
|
EXIT_NOTIFICATION_TYPE,
|
|
91
93
|
expandMessageOnClick(renderExitNotification),
|
package/src/terminal-render.ts
CHANGED
|
@@ -351,7 +351,7 @@ export function renderListResult(
|
|
|
351
351
|
const WAIT_REASON_SUMMARY = {
|
|
352
352
|
exited: undefined,
|
|
353
353
|
timeout: "Nothing exited before the wait ended",
|
|
354
|
-
|
|
354
|
+
steered: "Returned early: a message is queued for you",
|
|
355
355
|
aborted: "Wait cancelled",
|
|
356
356
|
nothing_running: "Nothing to wait for",
|
|
357
357
|
} as const satisfies Record<WaitResult["reason"], string | undefined>;
|
package/src/terminal-tools.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
truncateTail,
|
|
9
9
|
type ExtensionToolContext,
|
|
10
10
|
} from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { watchQueuedSteer } from "@ian-pascoe/pi-utils/queued-steer";
|
|
11
12
|
import type { Key } from "@kitlangton/terminal-control";
|
|
12
13
|
import { Type, type Static } from "typebox";
|
|
13
14
|
import type {
|
|
@@ -40,8 +41,6 @@ export const START_WAIT_MS = 2_000;
|
|
|
40
41
|
export const INPUT_WAIT_MS = 500;
|
|
41
42
|
export const POLL_WAIT_MS = 30_000;
|
|
42
43
|
export const MAX_WAIT_MS = 5 * 60_000;
|
|
43
|
-
/** How often `terminal_wait` checks for a queued message, which raises no event it can await. */
|
|
44
|
-
export const PENDING_CHECK_MS = 100;
|
|
45
44
|
|
|
46
45
|
const CONTROL_KEYS = Array.from(
|
|
47
46
|
{ length: 26 },
|
|
@@ -93,10 +92,16 @@ export type TerminalResultRuntime = Pick<TerminalToolRuntime, "registry" | "scro
|
|
|
93
92
|
const TerminalStateSchema = Type.Union([Type.Literal("running"), Type.Literal("exited")]);
|
|
94
93
|
|
|
95
94
|
const SettleReasonSchema = Type.Union(
|
|
96
|
-
[
|
|
95
|
+
[
|
|
96
|
+
Type.Literal("matched"),
|
|
97
|
+
Type.Literal("timeout"),
|
|
98
|
+
Type.Literal("quiet"),
|
|
99
|
+
Type.Literal("exited"),
|
|
100
|
+
Type.Literal("steered"),
|
|
101
|
+
],
|
|
97
102
|
{
|
|
98
103
|
description:
|
|
99
|
-
"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
|
|
104
|
+
"Why the wait ended: wait_for_text matched, wait_ms ran out or the call was cancelled, the screen was quiet for 250 ms, the Terminal exited, or a message was queued for you (steered; the Terminal keeps running)",
|
|
100
105
|
},
|
|
101
106
|
);
|
|
102
107
|
|
|
@@ -280,13 +285,13 @@ const WaitResultSchema = Type.Object({
|
|
|
280
285
|
[
|
|
281
286
|
Type.Literal("exited"),
|
|
282
287
|
Type.Literal("timeout"),
|
|
283
|
-
Type.Literal("
|
|
288
|
+
Type.Literal("steered"),
|
|
284
289
|
Type.Literal("aborted"),
|
|
285
290
|
Type.Literal("nothing_running"),
|
|
286
291
|
],
|
|
287
292
|
{
|
|
288
293
|
description:
|
|
289
|
-
"Why the wait ended: an exit, wait_ms, a
|
|
294
|
+
"Why the wait ended: an exit, wait_ms, a steer queued for you (steered), cancellation, or nothing to wait for",
|
|
290
295
|
},
|
|
291
296
|
),
|
|
292
297
|
exited: Type.Array(
|
|
@@ -342,12 +347,28 @@ interface SettleRequest {
|
|
|
342
347
|
/** The screen before the call. A match already on it counts only once the screen changes. */
|
|
343
348
|
readonly baseline: string | undefined;
|
|
344
349
|
readonly signal: AbortSignal | undefined;
|
|
350
|
+
/** Aborts once a Queued Steer is pending, which ends the wait with a Steer Yield. */
|
|
351
|
+
readonly steer: AbortSignal | undefined;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Sleep `milliseconds`, or until `steer` aborts. */
|
|
355
|
+
function pause(milliseconds: number, steer: AbortSignal | undefined): Promise<void> {
|
|
356
|
+
return new Promise((resolve) => {
|
|
357
|
+
const done = () => {
|
|
358
|
+
clearTimeout(timer);
|
|
359
|
+
steer?.removeEventListener("abort", done);
|
|
360
|
+
resolve();
|
|
361
|
+
};
|
|
362
|
+
const timer = setTimeout(done, milliseconds);
|
|
363
|
+
steer?.addEventListener("abort", done, { once: true });
|
|
364
|
+
});
|
|
345
365
|
}
|
|
346
366
|
|
|
347
367
|
/**
|
|
348
368
|
* Poll a Terminal until it settles: screen quiet for 250 ms, a `wait_for_text` match, process exit,
|
|
349
|
-
*
|
|
350
|
-
* With `wait_for_text`, quiet does not
|
|
369
|
+
* the wait budget running out, or a Queued Steer (a Steer Yield; the Terminal keeps running). A
|
|
370
|
+
* poll (no input) settles on quiet only after new output. With `wait_for_text`, quiet does not
|
|
371
|
+
* settle the wait.
|
|
351
372
|
*/
|
|
352
373
|
export async function settleTerminal(
|
|
353
374
|
entry: TerminalEntry,
|
|
@@ -380,7 +401,8 @@ export async function settleTerminal(
|
|
|
380
401
|
return { snapshot, reason: "quiet" };
|
|
381
402
|
}
|
|
382
403
|
if (now >= deadline || request.signal?.aborted === true) return { snapshot, reason: "timeout" };
|
|
383
|
-
|
|
404
|
+
if (request.steer?.aborted === true) return { snapshot, reason: "steered" };
|
|
405
|
+
await pause(Math.min(POLL_MS, deadline - now), request.steer);
|
|
384
406
|
}
|
|
385
407
|
}
|
|
386
408
|
|
|
@@ -769,7 +791,7 @@ function screenMarker(label: string, screen: string, view: ScreenView): string {
|
|
|
769
791
|
|
|
770
792
|
/** Says that `wait_for_text` was not seen when its wait timed out. */
|
|
771
793
|
function unmatchedNote(reason: SettleReason, pattern: string | undefined): string | undefined {
|
|
772
|
-
return reason === "timeout" && pattern !== undefined
|
|
794
|
+
return (reason === "timeout" || reason === "steered") && pattern !== undefined
|
|
773
795
|
? `wait_for_text ${JSON.stringify(pattern)} was not seen before the wait ended.`
|
|
774
796
|
: undefined;
|
|
775
797
|
}
|
|
@@ -971,13 +993,27 @@ function shellCommand(shell: TerminalShell, command: string): [string, ...string
|
|
|
971
993
|
return [shell.shell, ...shell.args, script];
|
|
972
994
|
}
|
|
973
995
|
|
|
996
|
+
/** Settle a Terminal for the session, ending the wait early when a Queued Steer is pending. */
|
|
997
|
+
async function settleForSession(
|
|
998
|
+
entry: TerminalEntry,
|
|
999
|
+
sessionId: string,
|
|
1000
|
+
request: Omit<SettleRequest, "steer">,
|
|
1001
|
+
): Promise<{ readonly snapshot: TerminalSnapshot; readonly reason: SettleReason }> {
|
|
1002
|
+
const watch = watchQueuedSteer(sessionId);
|
|
1003
|
+
try {
|
|
1004
|
+
return await settleTerminal(entry, { ...request, steer: watch.signal });
|
|
1005
|
+
} finally {
|
|
1006
|
+
watch.dispose();
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
974
1010
|
/** `terminal_start`: run a program in a new Terminal and wait for it to settle. */
|
|
975
1011
|
export function createTerminalStartTool(runtime: TerminalToolRuntime) {
|
|
976
1012
|
return defineTool<typeof StartParameters, TerminalResult>({
|
|
977
1013
|
name: "terminal_start",
|
|
978
1014
|
label: "terminal_start",
|
|
979
1015
|
description:
|
|
980
|
-
"Start a program in a new interactive Terminal (a PTY) and return its screen once it settles: 250 ms of quiet, exit,
|
|
1016
|
+
"Start a program in a new interactive Terminal (a PTY) and return its screen once it settles: 250 ms of quiet, exit, wait_ms (default 2000), or a message queued for you (settle_reason steered; the Terminal keeps running). Use it for REPLs, TUIs, prompts and other programs that need input or a screen. Drive it with terminal_send and end it with terminal_stop.",
|
|
981
1017
|
promptSnippet: "Start an interactive program (REPL, TUI, prompt) in a Terminal",
|
|
982
1018
|
promptGuidelines: [
|
|
983
1019
|
"Use terminal_start, terminal_send and terminal_stop for programs that need input or a screen; use bash for everything else.",
|
|
@@ -1007,7 +1043,7 @@ export function createTerminalStartTool(runtime: TerminalToolRuntime) {
|
|
|
1007
1043
|
// The Terminal is running and nothing else knows its id yet, so the call never queues; an
|
|
1008
1044
|
// abort ends the wait but must still return the id, or the agent could not reach the Terminal.
|
|
1009
1045
|
return driveTerminal(runtime.registry, entry, undefined, async () => {
|
|
1010
|
-
const { snapshot, reason } = await
|
|
1046
|
+
const { snapshot, reason } = await settleForSession(entry, ownerOf(context), {
|
|
1011
1047
|
mode: "start",
|
|
1012
1048
|
waitMs: clampWait(params.wait_ms, START_WAIT_MS),
|
|
1013
1049
|
startedAt,
|
|
@@ -1040,7 +1076,7 @@ export function createTerminalSendTool(runtime: TerminalToolRuntime) {
|
|
|
1040
1076
|
name: "terminal_send",
|
|
1041
1077
|
label: "terminal_send",
|
|
1042
1078
|
description:
|
|
1043
|
-
"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,
|
|
1079
|
+
"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, wait_ms (default 500), or a message queued for you (settle_reason steered; the Terminal keeps running). 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. When the rows above the previous result's cursor row are unchanged, the screen shows only the rows from there down; an unchanged screen shows no rows; full_screen: true shows the whole screen.",
|
|
1044
1080
|
promptSnippet: "Send input to a Terminal, or poll it, and read its screen",
|
|
1045
1081
|
parameters: SendParameters,
|
|
1046
1082
|
annotations: {
|
|
@@ -1094,7 +1130,7 @@ export function createTerminalSendTool(runtime: TerminalToolRuntime) {
|
|
|
1094
1130
|
if (params.text !== undefined && params.text !== "") await entry.handle.type(params.text);
|
|
1095
1131
|
const validKeys = keys.filter(isKey);
|
|
1096
1132
|
if (validKeys.length > 0) await entry.handle.press(validKeys);
|
|
1097
|
-
const { snapshot, reason } = await
|
|
1133
|
+
const { snapshot, reason } = await settleForSession(entry, ownerOf(context), {
|
|
1098
1134
|
mode: hasInput ? "input" : "poll",
|
|
1099
1135
|
waitMs: clampWait(params.wait_ms, hasInput ? INPUT_WAIT_MS : POLL_WAIT_MS),
|
|
1100
1136
|
startedAt,
|
|
@@ -1267,43 +1303,44 @@ function waitTargets(
|
|
|
1267
1303
|
return [...targets.values()];
|
|
1268
1304
|
}
|
|
1269
1305
|
|
|
1270
|
-
/**
|
|
1306
|
+
/**
|
|
1307
|
+
* Wait until a target exits, the wait runs out, the call is aborted, or a Queued Steer is pending
|
|
1308
|
+
* (`steer` aborts; the targets keep running).
|
|
1309
|
+
*/
|
|
1271
1310
|
function awaitExit(
|
|
1272
1311
|
registry: TermctrlRegistry,
|
|
1273
1312
|
targets: readonly TermctrlEntry[],
|
|
1274
1313
|
waitMs: number,
|
|
1275
1314
|
signal: AbortSignal | undefined,
|
|
1276
|
-
|
|
1315
|
+
steer: AbortSignal,
|
|
1277
1316
|
): Promise<WaitReason> {
|
|
1278
|
-
const deadline = Date.now() + waitMs;
|
|
1279
1317
|
return new Promise((resolve) => {
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
let reason: WaitReason | undefined;
|
|
1284
|
-
if (targets.some((entry) => entry.state === "exited")) reason = "exited";
|
|
1285
|
-
else if (signal?.aborted === true) reason = "aborted";
|
|
1286
|
-
else if (hasPendingMessages()) reason = "message";
|
|
1287
|
-
else if (Date.now() >= deadline) reason = "timeout";
|
|
1288
|
-
if (reason === undefined) {
|
|
1289
|
-
clearTimeout(timer);
|
|
1290
|
-
timer = setTimeout(check, Math.max(0, Math.min(PENDING_CHECK_MS, deadline - Date.now())));
|
|
1291
|
-
return;
|
|
1292
|
-
}
|
|
1293
|
-
clearTimeout(timer);
|
|
1294
|
-
unsubscribe?.();
|
|
1295
|
-
signal?.removeEventListener("abort", check);
|
|
1318
|
+
const cleanups: (() => void)[] = [];
|
|
1319
|
+
const finish = (reason: WaitReason) => {
|
|
1320
|
+
for (const cleanup of cleanups.splice(0)) cleanup();
|
|
1296
1321
|
resolve(reason);
|
|
1297
1322
|
};
|
|
1298
|
-
|
|
1323
|
+
const check = () => {
|
|
1324
|
+
if (targets.some((entry) => entry.state === "exited")) finish("exited");
|
|
1325
|
+
else if (signal?.aborted === true) finish("aborted");
|
|
1326
|
+
else if (steer.aborted) finish("steered");
|
|
1327
|
+
};
|
|
1328
|
+
const timer = setTimeout(() => finish("timeout"), waitMs);
|
|
1329
|
+
cleanups.push(
|
|
1330
|
+
() => clearTimeout(timer),
|
|
1331
|
+
registry.onChange(check),
|
|
1332
|
+
() => signal?.removeEventListener("abort", check),
|
|
1333
|
+
() => steer.removeEventListener("abort", check),
|
|
1334
|
+
);
|
|
1299
1335
|
signal?.addEventListener("abort", check, { once: true });
|
|
1336
|
+
steer.addEventListener("abort", check, { once: true });
|
|
1300
1337
|
check();
|
|
1301
1338
|
});
|
|
1302
1339
|
}
|
|
1303
1340
|
|
|
1304
1341
|
const WAIT_REASON_TEXT = {
|
|
1305
1342
|
timeout: (waitMs) => `Nothing exited within ${Math.round(waitMs / 1000)}s.`,
|
|
1306
|
-
|
|
1343
|
+
steered: () => "Returned early: a message is queued for you.",
|
|
1307
1344
|
aborted: () => "Wait cancelled.",
|
|
1308
1345
|
nothing_running: () => "Nothing to wait for: you have no running Terminals or Background jobs.",
|
|
1309
1346
|
} satisfies Record<Exclude<WaitReason, "exited">, (waitMs: number) => string>;
|
|
@@ -1315,7 +1352,7 @@ export function createTerminalWaitTool(runtime: TerminalWaitRuntime) {
|
|
|
1315
1352
|
name: "terminal_wait",
|
|
1316
1353
|
label: "terminal_wait",
|
|
1317
1354
|
description:
|
|
1318
|
-
"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
|
|
1355
|
+
"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 steer is queued for you; whatever it waited on keeps running. Exits it returns send no Exit notification.",
|
|
1319
1356
|
promptSnippet: "Wait for a Terminal or Background job to exit",
|
|
1320
1357
|
promptGuidelines: [
|
|
1321
1358
|
"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.",
|
|
@@ -1339,10 +1376,9 @@ export function createTerminalWaitTool(runtime: TerminalWaitRuntime) {
|
|
|
1339
1376
|
const parts: string[] = [];
|
|
1340
1377
|
if (targets.length > 0) {
|
|
1341
1378
|
const release = registry.watch(targets);
|
|
1379
|
+
const steer = watchQueuedSteer(owner);
|
|
1342
1380
|
try {
|
|
1343
|
-
reason = await awaitExit(registry, targets, waitMs, signal,
|
|
1344
|
-
context.hasPendingMessages(),
|
|
1345
|
-
);
|
|
1381
|
+
reason = await awaitExit(registry, targets, waitMs, signal, steer.signal);
|
|
1346
1382
|
const tailLines = runtime.exitTailLines();
|
|
1347
1383
|
for (const entry of targets) {
|
|
1348
1384
|
const notice = registry.noticeFor(entry);
|
|
@@ -1361,6 +1397,7 @@ export function createTerminalWaitTool(runtime: TerminalWaitRuntime) {
|
|
|
1361
1397
|
result.exited.push(exited);
|
|
1362
1398
|
}
|
|
1363
1399
|
} finally {
|
|
1400
|
+
steer.dispose();
|
|
1364
1401
|
release();
|
|
1365
1402
|
}
|
|
1366
1403
|
}
|