@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 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, or
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 `exited`) and in their first line, such as `t1 running · settled: timeout`; a
59
- `wait_for_text` that timed out adds that the pattern was not seen. Polls report
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 soon as a message is queued
138
- for the agent, such as the user steering or another entry's Exit notification;
139
- a watched entry that exits after that is notified as usual. With nothing to
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 two ways:
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.8.3",
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.8.0",
37
+ "@ian-pascoe/pi-utils": "^0.9.0",
38
38
  "@kitlangton/terminal-control": "^1.2.1"
39
39
  },
40
40
  "peerDependencies": {
@@ -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
- private readonly owner: string,
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 result = await this.local.exec(command, cwd, localOptions);
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
- /** The agent `bash` calls running in one session, and the Ctrl+B subscription while any run. */
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
- this.unsubscribe = ui.onTerminalInput((data) => {
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">): void {
264
- for (const call of this.calls) {
265
- try {
266
- call.background(this.registry());
267
- } catch (error) {
268
- ui.notify(
269
- noticeText("Termctrl", error instanceof Error ? error.message : String(error)),
270
- "warning",
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 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}"}.`;
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),
@@ -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
- message: "Returned early: a message is waiting",
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>;
@@ -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
- [Type.Literal("matched"), Type.Literal("timeout"), Type.Literal("quiet"), Type.Literal("exited")],
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 exited",
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("message"),
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 message queued for you, cancellation, or nothing to wait for",
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
- * or the wait budget running out. A poll (no input) settles on quiet only after new output.
350
- * With `wait_for_text`, quiet does not settle the wait.
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
- await new Promise((resolve) => setTimeout(resolve, Math.min(POLL_MS, deadline - now)));
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, or wait_ms (default 2000). 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.",
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 settleTerminal(entry, {
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, 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. 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.",
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 settleTerminal(entry, {
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
- /** Wait until a target exits, the wait runs out, the call is aborted, or a message is queued. */
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
- hasPendingMessages: () => boolean,
1315
+ steer: AbortSignal,
1277
1316
  ): Promise<WaitReason> {
1278
- const deadline = Date.now() + waitMs;
1279
1317
  return new Promise((resolve) => {
1280
- let timer: ReturnType<typeof setTimeout> | undefined;
1281
- let unsubscribe: (() => void) | undefined;
1282
- const check = () => {
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
- unsubscribe = registry.onChange(check);
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
- message: () => "Returned early: a message is waiting for you.",
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 message is queued for you. Exits it returns send no Exit notification.",
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
  }