@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 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, 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
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, 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`.
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 `terminal_list` keep working wherever Pi's `bash` does.
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.1.1",
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": {
@@ -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
- const properties = builtin !== undefined && Type.IsObject(builtin) ? builtin.properties : {};
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}; read that file to check progress, and stop it with terminal_stop {"id": "${outcome.id}"}. You will get an Exit notification when it ends.`;
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,
@@ -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));
@@ -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 as a
310
- * progress line, is reported in its final form once it scrolls off. When the cursor cannot be found
311
- * again, because termctrl dropped lines the agent never received or the screen was cleared, every
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 === "" ? 0 : screen.split("\n").length;
324
- const boundary = Math.max(0, lines.length - screenLines);
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();