@ian-pascoe/pi-termctrl 0.1.1 → 0.2.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
@@ -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,15 @@ 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
+
90
100
  ## `bash` replacement
91
101
 
92
102
  With `termctrl.replaceBash` (default `true`), Pi Termctrl replaces Pi's `bash`
@@ -106,9 +116,10 @@ older output becomes an omission line in the log. Once backgrounded, a
106
116
  command's `timeout` is cleared, Esc and turn aborts no
107
117
  longer reach it, and its output so far and all later output go to
108
118
  `$TMPDIR/pi-termctrl/<pid>-<id>.log`. The result says
109
- `Command moved to the background as b<n>`, includes the output so far, and
110
- gives the log path; read the log with `read` and stop the job with
111
- `terminal_stop`. Its structured result follows Pi's `bash` output schema, except
119
+ `Command moved to the background as b<n>`, includes the output so far, gives
120
+ the log path, and tells the agent not to poll the log but to wait for the Exit
121
+ notification or call `terminal_wait`; read the log with `read` and stop the job
122
+ with `terminal_stop`. Its structured result follows Pi's `bash` output schema, except
112
123
  that `exit_code` is absent and `background: { id, log_path }` is present.
113
124
 
114
125
  A Background job's log is deleted when the job is stopped, when the user
@@ -124,11 +135,16 @@ backgrounding paths do not exist.
124
135
 
125
136
  When a Terminal or Background job exits and the agent has neither seen the exit
126
137
  in a `terminal_*` result nor stopped it, Pi Termctrl sends an Exit notification:
127
- the id, command, exit code or signal, duration, and the last
128
- `termctrl.exitTailLines` lines of the final screen or log. Exits in the same tick
129
- share one message. It steers a working agent and starts a turn when the agent is
130
- idle. `terminal_start {notify: false}` opts a Terminal out. Notifications go
131
- only to the session that started the entry, and wait across `/reload`.
138
+ the id, command, exit code or signal, duration, where to find the full output
139
+ (a Background job's log path, or `terminal_send` for a Terminal's final screen),
140
+ and the last `termctrl.exitTailLines` lines of the final screen or log. Seeing
141
+ a Terminal running in a `terminal_*` result does not count as seeing its exit.
142
+ Exits reported by `terminal_wait` are not notified again. Exits in the same tick
143
+ share one message. While the agent works, notifications wait for the end of its
144
+ current turn, then steer it, so an exit a tool call in that turn reported is
145
+ not notified. When the agent is idle, a notification starts a turn.
146
+ `terminal_start {notify: false}` opts a Terminal out. Notifications go only to
147
+ the session that started the entry, and wait across `/reload`.
132
148
 
133
149
  ## `/ps` and footer
134
150
 
@@ -184,7 +200,8 @@ GNU/Linux on arm64 or x64 through optional dependencies. Set `TERMCTRL_BINARY`
184
200
  to use another binary. Where no binary resolves (Windows, musl, or
185
201
  `--omit=optional`), Pi Termctrl shows one diagnostic and registers no
186
202
  `terminal_start` or `terminal_send`; the `bash` replacement, Background jobs,
187
- `terminal_stop` and `terminal_list` keep working wherever Pi's `bash` does.
203
+ `terminal_stop`, `terminal_list` and `terminal_wait` keep working wherever Pi's
204
+ `bash` does.
188
205
 
189
206
  Not supported: attaching to or taking over a Terminal, recordings, mouse input,
190
207
  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.1.1",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "description": "Interactive Terminals, Background jobs, and a /ps panel for Pi.",
6
6
  "keywords": [
@@ -273,7 +273,7 @@ function backgroundResult(outcome: BackgroundOutcome, startedAt: number) {
273
273
  if (truncation.truncated) {
274
274
  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
275
  }
276
- const status = `Command moved to the background as ${outcome.id}. Its output so far and all later output go to ${outcome.logPath}; read that file to check progress, and stop it with terminal_stop {"id": "${outcome.id}"}. You will get an Exit notification when it ends.`;
276
+ 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
277
  return {
278
278
  content: [{ type: "text" as const, text: text === "" ? status : `${text}\n\n${status}` }],
279
279
  details: undefined,
@@ -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
- function lastLines(output: string, count: number): string {
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
- id: notice.id,
80
- kind: notice.kind === "job" ? ("background_job" as const) : ("terminal" as const),
81
- command: notice.command,
82
- exit_code: notice.exit.code,
83
- signal: notice.exit.signal,
84
- duration_ms: notice.durationMs,
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
 
@@ -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 = 3;
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
- /** Delivers a batch of Exit notifications to one owner session. Throwing keeps the batch queued. */
108
- export type NotificationDelivery = (notices: readonly ExitNotice[]) => void;
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
- delivery(due);
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
- this.state.pending.set(owner, [...due, ...(this.state.pending.get(owner) ?? [])]);
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
- private queueNotice(entry: TermctrlEntry, output: string): void {
293
- if (!entry.notify || entry.seen || entry.stopRequested === "agent" || entry.exit === null) {
294
- return;
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
- output,
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
- /** Record that the agent has seen an entry's exit in a tool result. */
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 !== undefined) entry.seen = true;
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, finalScreen);
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, entry.child.tail());
638
+ this.queueNotice(entry);
589
639
  this.emitChange();
590
640
  }
591
641
 
@@ -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({ description: "Send an Exit notification when it exits (default true)" }),
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));
@@ -688,6 +753,142 @@ function listLine(entry: TermctrlEntry, now: number): string {
688
753
  return `${entry.id} ${state} · ${age}s · ${entry.command}${log}`;
689
754
  }
690
755
 
756
+ /** Session-scoped values `terminal_wait` reads at call time. */
757
+ export interface TerminalWaitRuntime {
758
+ readonly registry: TermctrlRegistry;
759
+ readonly exitTailLines: () => number;
760
+ }
761
+
762
+ /** The entries one `terminal_wait` call watches: the listed ids, or every running entry. */
763
+ function waitTargets(
764
+ registry: TermctrlRegistry,
765
+ owner: string,
766
+ ids: readonly string[] | undefined,
767
+ ): TermctrlEntry[] {
768
+ if (ids === undefined) {
769
+ // An exit whose Exit notification is still queued has not reached the agent yet.
770
+ return registry.ownedEntries(owner).filter((entry) => entry.state === "running" || !entry.seen);
771
+ }
772
+ const targets = new Map<string, TermctrlEntry>();
773
+ for (const id of ids) {
774
+ const entry = registry.find(owner, id);
775
+ if (entry === undefined) throw unknownId(id);
776
+ targets.set(id, entry);
777
+ }
778
+ return [...targets.values()];
779
+ }
780
+
781
+ /** Wait until a target exits, the wait runs out, the call is aborted, or a message is queued. */
782
+ function awaitExit(
783
+ registry: TermctrlRegistry,
784
+ targets: readonly TermctrlEntry[],
785
+ waitMs: number,
786
+ signal: AbortSignal | undefined,
787
+ hasPendingMessages: () => boolean,
788
+ ): Promise<WaitReason> {
789
+ const deadline = Date.now() + waitMs;
790
+ return new Promise((resolve) => {
791
+ let timer: ReturnType<typeof setTimeout> | undefined;
792
+ let unsubscribe: (() => void) | undefined;
793
+ const check = () => {
794
+ let reason: WaitReason | undefined;
795
+ if (targets.some((entry) => entry.state === "exited")) reason = "exited";
796
+ else if (signal?.aborted === true) reason = "aborted";
797
+ else if (hasPendingMessages()) reason = "message";
798
+ else if (Date.now() >= deadline) reason = "timeout";
799
+ if (reason === undefined) {
800
+ clearTimeout(timer);
801
+ timer = setTimeout(check, Math.max(0, Math.min(PENDING_CHECK_MS, deadline - Date.now())));
802
+ return;
803
+ }
804
+ clearTimeout(timer);
805
+ unsubscribe?.();
806
+ signal?.removeEventListener("abort", check);
807
+ resolve(reason);
808
+ };
809
+ unsubscribe = registry.onChange(check);
810
+ signal?.addEventListener("abort", check, { once: true });
811
+ check();
812
+ });
813
+ }
814
+
815
+ const WAIT_REASON_TEXT = {
816
+ timeout: (waitMs) => `Nothing exited within ${Math.round(waitMs / 1000)}s.`,
817
+ message: () => "Returned early: a message is waiting for you.",
818
+ aborted: () => "Wait cancelled.",
819
+ nothing_running: () => "Nothing to wait for: you have no running Terminals or Background jobs.",
820
+ } satisfies Record<Exclude<WaitReason, "exited">, (waitMs: number) => string>;
821
+
822
+ /** `terminal_wait`: block until a Terminal or Background job exits. */
823
+ export function createTerminalWaitTool(runtime: TerminalWaitRuntime) {
824
+ const { registry } = runtime;
825
+ return defineTool<typeof WaitParameters, WaitResult>({
826
+ name: "terminal_wait",
827
+ label: "terminal_wait",
828
+ description:
829
+ "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.",
830
+ promptSnippet: "Wait for a Terminal or Background job to exit",
831
+ promptGuidelines: [
832
+ "Do not poll a Background job's log or a Terminal in a loop to learn when it finishes: keep working and rely on its Exit notification, or call terminal_wait when nothing else can proceed.",
833
+ ],
834
+ parameters: WaitParameters,
835
+ outputSchema: WaitResultSchema,
836
+ async execute(_toolCallId, params, signal, _onUpdate, context) {
837
+ const owner = ownerOf(context);
838
+ const targets = waitTargets(registry, owner, params.ids);
839
+ const waitMs = clampWait(params.wait_ms, MAX_WAIT_MS);
840
+ let reason: WaitReason = "nothing_running";
841
+ const result: WaitResult = { reason, exited: [], running: [] };
842
+ const parts: string[] = [];
843
+ if (targets.length > 0) {
844
+ const release = registry.watch(targets);
845
+ try {
846
+ reason = await awaitExit(registry, targets, waitMs, signal, () =>
847
+ context.hasPendingMessages(),
848
+ );
849
+ const tailLines = runtime.exitTailLines();
850
+ for (const entry of targets) {
851
+ const notice = registry.noticeFor(entry);
852
+ if (notice === undefined) continue;
853
+ registry.markSeen(entry.id);
854
+ parts.push(formatExitNotice(notice, tailLines));
855
+ const exited: WaitResult["exited"][number] = {
856
+ id: notice.id,
857
+ kind: notice.kind === "job" ? "background_job" : "terminal",
858
+ command: notice.command,
859
+ ...exitFields(notice.exit),
860
+ duration_ms: notice.durationMs,
861
+ output: lastLines(notice.output, tailLines),
862
+ };
863
+ if (notice.logPath !== undefined) exited.log_path = notice.logPath;
864
+ result.exited.push(exited);
865
+ }
866
+ } finally {
867
+ release();
868
+ }
869
+ }
870
+ result.reason = reason;
871
+ if (reason !== "exited") parts.unshift(WAIT_REASON_TEXT[reason](waitMs));
872
+ const now = Date.now();
873
+ const running = registry.ownedEntries(owner).filter((entry) => entry.state === "running");
874
+ result.running = running.map((entry) => ({
875
+ id: entry.id,
876
+ kind: entry.kind === "job" ? "background_job" : "terminal",
877
+ command: entry.command,
878
+ age_seconds: Math.round((now - entry.startedAt) / 1000),
879
+ }));
880
+ if (running.length > 0) {
881
+ parts.push(`Still running:\n${running.map((entry) => listLine(entry, now)).join("\n")}`);
882
+ }
883
+ return {
884
+ content: [{ type: "text" as const, text: parts.join("\n\n") }],
885
+ details: result,
886
+ structuredContent: result,
887
+ };
888
+ },
889
+ });
890
+ }
891
+
691
892
  /** `terminal_list`: list the caller's Terminals and Background jobs. */
692
893
  export function createTerminalListTool(registry: TermctrlRegistry) {
693
894
  return defineTool<typeof ListParameters, ListResult>({