@ian-pascoe/pi-termctrl 0.1.0 → 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.0",
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": [
@@ -2,6 +2,7 @@
2
2
  name: pi-termctrl
3
3
  description: Configure or diagnose Pi Termctrl when Terminal tools are missing, the bash replacement conflicts with another extension, Background jobs misbehave, or termctrl settings need changing.
4
4
  license: MIT
5
+ disable-model-invocation: true
5
6
  ---
6
7
 
7
8
  # Pi Termctrl
@@ -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,13 +160,14 @@ 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();
129
167
  const commandPrefix = settingsManager.getShellCommandPrefix();
130
168
  if (this.binary.kind === "missing") {
131
169
  context.ui.notify(
132
- `Pi Termctrl: Terminal tools are unavailable: ${this.binary.reason}`,
170
+ `Pi Termctrl: Terminal tools are unavailable: ${this.binary.reason}\nRun /skill:pi-termctrl to diagnose.`,
133
171
  "warning",
134
172
  );
135
173
  if (settings.replaceBash) this.registerManagementTools();
@@ -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
 
@@ -7,12 +7,13 @@ import type {
7
7
  TerminalLaunchRequest,
8
8
  } from "./terminal-driver.js";
9
9
  import { isDriverGone } from "./terminal-driver.js";
10
+ import { TROUBLESHOOTING_HINT } from "./troubleshooting-skill.js";
10
11
  import type { TerminalViewport } from "./pi-termctrl-settings.js";
11
12
 
12
13
  /** The `globalThis` key every registry version shares. Its value always satisfies {@link RegistrySlot}. */
13
14
  export const REGISTRY_KEY = Symbol.for("@ian-pascoe/pi-termctrl/registry");
14
15
  /** Bump on any change to the state's shape: a reloaded module must not adopt an older shape. */
15
- export const REGISTRY_VERSION = 3;
16
+ export const REGISTRY_VERSION = 4;
16
17
 
17
18
  /** Live Terminals plus Background jobs allowed across the whole process. */
18
19
  export const LIVE_ENTRY_CAP = 16;
@@ -58,6 +59,8 @@ interface EntryBase {
58
59
  seen: boolean;
59
60
  stopRequested: StopRequester | undefined;
60
61
  readonly notify: boolean;
62
+ /** `terminal_wait` calls watching this entry; they report its exit instead of a notification. */
63
+ watchers: number;
61
64
  }
62
65
 
63
66
  /** A PTY program driven through termctrl. */
@@ -101,10 +104,16 @@ export interface ExitNotice {
101
104
  readonly durationMs: number;
102
105
  /** The final screen of a Terminal or the recent log output of a Background job. */
103
106
  readonly output: string;
107
+ /** A Background job's log. */
108
+ readonly logPath?: string;
104
109
  }
105
110
 
106
- /** Delivers a batch of Exit notifications to one owner session. Throwing keeps the batch queued. */
107
- 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;
108
117
 
109
118
  /** Request to start one Terminal. */
110
119
  export interface TerminalStartRequest {
@@ -271,12 +280,18 @@ export class TermctrlRegistry {
271
280
  const due = notices.filter((notice) => this.state.entries.get(notice.id)?.seen !== true);
272
281
  this.state.pending.delete(owner);
273
282
  if (due.length === 0) continue;
283
+ const requeue = () =>
284
+ this.state.pending.set(owner, [...due, ...(this.state.pending.get(owner) ?? [])]);
274
285
  try {
275
- 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
+ }
276
291
  for (const notice of due) this.markSeen(notice.id);
277
292
  } catch {
278
293
  this.state.owners.delete(owner);
279
- this.state.pending.set(owner, [...due, ...(this.state.pending.get(owner) ?? [])]);
294
+ requeue();
280
295
  }
281
296
  }
282
297
  }
@@ -288,27 +303,61 @@ export class TermctrlRegistry {
288
303
  setImmediate(() => state.current.flush());
289
304
  }
290
305
 
291
- private queueNotice(entry: TermctrlEntry, output: string): void {
292
- if (!entry.notify || entry.seen || entry.stopRequested === "agent" || entry.exit === null) {
293
- return;
294
- }
295
- const notices = this.state.pending.get(entry.owner) ?? [];
296
- 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 = {
297
310
  id: entry.id,
298
311
  kind: entry.kind,
299
312
  command: entry.command,
300
313
  exit: entry.exit,
301
314
  durationMs: (entry.exitedAt ?? Date.now()) - entry.startedAt,
302
- output,
303
- });
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);
304
330
  this.state.pending.set(entry.owner, notices);
305
331
  this.scheduleFlush();
306
332
  }
307
333
 
308
- /** 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
+ */
309
358
  markSeen(id: string): void {
310
359
  const entry = this.state.entries.get(id);
311
- if (entry !== undefined) entry.seen = true;
360
+ if (entry?.state === "exited") entry.seen = true;
312
361
  }
313
362
 
314
363
  // ── Queries ───────────────────────────────────────────────────────────────
@@ -379,7 +428,7 @@ export class TermctrlRegistry {
379
428
  cwd: request.cwd,
380
429
  viewport: request.viewport,
381
430
  };
382
- let slot = await this.ensureDriver();
431
+ let slot = await this.startDriver();
383
432
  let handle: TerminalHandle;
384
433
  try {
385
434
  handle = await slot.driver.launch(launch);
@@ -387,7 +436,7 @@ export class TermctrlRegistry {
387
436
  const error = cause instanceof Error ? cause : new Error(String(cause));
388
437
  if (!isDriverGone(error)) throw error;
389
438
  this.driverDied(slot.generation);
390
- slot = await this.ensureDriver();
439
+ slot = await this.startDriver();
391
440
  handle = await slot.driver.launch(launch);
392
441
  }
393
442
  const entry: TerminalEntry = {
@@ -403,6 +452,7 @@ export class TermctrlRegistry {
403
452
  seen: false,
404
453
  stopRequested: undefined,
405
454
  notify: request.notify,
455
+ watchers: 0,
406
456
  handle,
407
457
  generation: slot.generation,
408
458
  finalScreen: undefined,
@@ -420,6 +470,16 @@ export class TermctrlRegistry {
420
470
  }
421
471
  }
422
472
 
473
+ /** Ensure a driver, pointing the agent at the troubleshooting Skill when none can start. */
474
+ private async startDriver(): Promise<DriverSlot> {
475
+ try {
476
+ return await this.ensureDriver();
477
+ } catch (cause) {
478
+ const error = cause instanceof Error ? cause : new Error(String(cause));
479
+ throw new Error(`${error.message}\n\n${TROUBLESHOOTING_HINT}`, { cause });
480
+ }
481
+ }
482
+
423
483
  private async ensureDriver(): Promise<DriverSlot> {
424
484
  if (this.state.driver !== undefined) return this.state.driver;
425
485
  if (this.state.driverStarting !== undefined) return this.state.driverStarting;
@@ -456,7 +516,7 @@ export class TermctrlRegistry {
456
516
  entry.exitedAt = Date.now();
457
517
  entry.finalScreen = finalScreen;
458
518
  if (seen) entry.seen = true;
459
- this.queueNotice(entry, finalScreen);
519
+ this.queueNotice(entry);
460
520
  this.syncWatcher();
461
521
  this.emitChange();
462
522
  this.closeIdleDriver();
@@ -557,6 +617,7 @@ export class TermctrlRegistry {
557
617
  seen: false,
558
618
  stopRequested: undefined,
559
619
  notify: true,
620
+ watchers: 0,
560
621
  child,
561
622
  settled,
562
623
  settle,
@@ -574,7 +635,7 @@ export class TermctrlRegistry {
574
635
  entry.exit = exit;
575
636
  entry.exitedAt = Date.now();
576
637
  entry.settle();
577
- this.queueNotice(entry, entry.child.tail());
638
+ this.queueNotice(entry);
578
639
  this.emitChange();
579
640
  }
580
641
 
@@ -17,10 +17,11 @@ 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";
24
+ import { TROUBLESHOOTING_HINT } from "./troubleshooting-skill.js";
24
25
 
25
26
  /** Screen quiet period that settles a Terminal. */
26
27
  export const QUIET_MS = 250;
@@ -29,6 +30,8 @@ export const START_WAIT_MS = 2_000;
29
30
  export const INPUT_WAIT_MS = 500;
30
31
  export const POLL_WAIT_MS = 30_000;
31
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;
32
35
 
33
36
  const CONTROL_KEYS = Array.from(
34
37
  { length: 26 },
@@ -157,7 +160,10 @@ const StartParameters = Type.Object(
157
160
  cwd: Type.Optional(Type.String({ description: "Working directory (default: current)" })),
158
161
  wait_ms: WaitMsSchema,
159
162
  notify: Type.Optional(
160
- 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
+ }),
161
167
  ),
162
168
  },
163
169
  { additionalProperties: false },
@@ -187,6 +193,66 @@ const StopParameters = Type.Object(
187
193
 
188
194
  const ListParameters = Type.Object({}, { additionalProperties: false });
189
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
+
190
256
  /** Clamp a requested wait to the fixed 0 to 5 minute range. */
191
257
  export function clampWait(requested: number | undefined, fallback: number): number {
192
258
  return Math.min(MAX_WAIT_MS, Math.max(0, requested ?? fallback));
@@ -480,7 +546,10 @@ async function driveTerminal<T>(
480
546
  } catch (cause) {
481
547
  const error = cause instanceof Error ? cause : new Error(String(cause));
482
548
  if (registry.reportTerminalError(entry, error)) {
483
- throw new Error(`${entry.id} was lost because the termctrl driver exited`, { cause });
549
+ throw new Error(
550
+ `${entry.id} was lost because the termctrl driver exited\n\n${TROUBLESHOOTING_HINT}`,
551
+ { cause },
552
+ );
484
553
  }
485
554
  throw error;
486
555
  } finally {
@@ -684,6 +753,142 @@ function listLine(entry: TermctrlEntry, now: number): string {
684
753
  return `${entry.id} ${state} · ${age}s · ${entry.command}${log}`;
685
754
  }
686
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
+
687
892
  /** `terminal_list`: list the caller's Terminals and Background jobs. */
688
893
  export function createTerminalListTool(registry: TermctrlRegistry) {
689
894
  return defineTool<typeof ListParameters, ListResult>({
@@ -0,0 +1,9 @@
1
+ import { fileURLToPath } from "node:url";
2
+
3
+ /** Absolute path of this package's user-invoked troubleshooting Skill. */
4
+ export const TROUBLESHOOTING_SKILL_PATH = fileURLToPath(
5
+ new URL("../skills/pi-termctrl/SKILL.md", import.meta.url),
6
+ );
7
+
8
+ /** Model-facing pointer appended to failures this package's Skill diagnoses. */
9
+ export const TROUBLESHOOTING_HINT = `For diagnosis, read the pi-termctrl troubleshooting Skill at ${TROUBLESHOOTING_SKILL_PATH}.`;