@ian-pascoe/pi-termctrl 0.2.0 → 0.4.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
@@ -33,7 +33,9 @@ For a local checkout, run `pi -e ./packages/pi-termctrl/src/index.ts`.
33
33
 
34
34
  `terminal_start` runs `command` through the shell Pi's `bash` uses (the
35
35
  `shellPath` setting, otherwise Pi's default bash), applies `shellCommandPrefix`,
36
- and opens a PTY sized by `termctrl.defaultViewport`.
36
+ and opens a PTY sized by `termctrl.defaultViewport`. A relative `cwd` resolves against
37
+ the session's directory. A `cwd` that is missing or not a directory fails with an
38
+ error naming the resolved path.
37
39
 
38
40
  `terminal_start` and `terminal_send` wait until the Terminal **settles**: at the
39
41
  first of 250 ms of screen quiet, a `wait_for_text` match, process exit, or
@@ -45,6 +47,14 @@ with input, and 30 s for a `terminal_send` with neither `text` nor `keys` (a
45
47
  poll, which settles on quiet only after new output). Every `wait_ms` clamps to
46
48
  5 minutes.
47
49
 
50
+ Results say why the wait ended in `settle_reason` (`matched`, `timeout`, `quiet`
51
+ or `exited`) and in their first line, such as `t1 running · settled: timeout`; a
52
+ `wait_for_text` that timed out adds that the pattern was not seen. Polls report
53
+ their reason too.
54
+
55
+ Sending `text` or `keys` to an exited Terminal is an error that names its exit
56
+ code or signal; a poll (neither `text` nor `keys`) still returns its final screen.
57
+
48
58
  Results contain the visible screen, the log lines that scrolled off since the
49
59
  agent's previous result, `state`, `exit_code` or `signal` once exited, and
50
60
  `changed: false` when the screen matches the previous result. Scrolled-off lines
@@ -82,7 +92,10 @@ another fails and lists the caller's live entries.
82
92
  `terminal_stop` stops a running entry (termctrl stop, then `SIGKILL` of the
83
93
  Terminal's process group if it is still alive after 3 s) and forgets it. For a
84
94
  Terminal it returns the final screen and scrolled-off lines; for a Background
85
- job, its recent output. Exited entries stay
95
+ job, its recent output. When a Terminal had already exited and its screen is
96
+ unchanged since the agent's last result, the result omits the screen and reports
97
+ `changed: false` with `state` and `exit_code` or `signal`; `scrolled_off` still
98
+ appears when lines scrolled off since that result. Exited entries stay
86
99
  readable and listed until the agent stops them, the user removes them in `/ps`,
87
100
  or their session shuts down. `terminal_list` lists the caller's Terminals and,
88
101
  in a separate `background_jobs` section, its Background jobs with their log
@@ -97,6 +110,10 @@ for the agent, such as the user steering or another entry's Exit notification;
97
110
  a watched entry that exits after that is notified as usual. With nothing to
98
111
  wait for it returns at once. Terminal exits are noticed within about 500 ms.
99
112
 
113
+ ### Tool annotations
114
+
115
+ 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.
116
+
100
117
  ## `bash` replacement
101
118
 
102
119
  With `termctrl.replaceBash` (default `true`), Pi Termctrl replaces Pi's `bash`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ian-pascoe/pi-termctrl",
3
- "version": "0.2.0",
3
+ "version": "0.4.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": {
@@ -53,7 +53,7 @@
53
53
  ]
54
54
  },
55
55
  "scripts": {
56
- "test": "vitest run --config ../../vitest.config.ts --root .",
56
+ "test": "vitest run",
57
57
  "typecheck": "tsc --noEmit -p tsconfig.json"
58
58
  }
59
59
  }
@@ -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
  ),
@@ -1,4 +1,4 @@
1
- import { mkdir, writeFile } from "node:fs/promises";
1
+ import { mkdir, stat, writeFile } from "node:fs/promises";
2
2
  import { resolve } from "node:path";
3
3
  import {
4
4
  DEFAULT_MAX_BYTES,
@@ -77,9 +77,18 @@ export interface TerminalToolRuntime {
77
77
 
78
78
  const TerminalStateSchema = Type.Union([Type.Literal("running"), Type.Literal("exited")]);
79
79
 
80
+ const SettleReasonSchema = Type.Union(
81
+ [Type.Literal("matched"), Type.Literal("timeout"), Type.Literal("quiet"), Type.Literal("exited")],
82
+ {
83
+ description:
84
+ "Why the wait ended: wait_for_text matched, wait_ms ran out or the call was cancelled, the screen was quiet for 250 ms, or the Terminal exited",
85
+ },
86
+ );
87
+
80
88
  const TerminalResultSchema = Type.Object({
81
89
  id: Type.String(),
82
90
  state: TerminalStateSchema,
91
+ settle_reason: SettleReasonSchema,
83
92
  exit_code: Type.Optional(Type.Number({ description: "Present once the Terminal has exited" })),
84
93
  signal: Type.Optional(Type.String({ description: "Present when a signal ended the Terminal" })),
85
94
  changed: Type.Boolean({ description: "Whether the screen differs from the previous result" }),
@@ -128,7 +137,12 @@ const StopResultSchema = Type.Object({
128
137
  description: "Terminal only: whether the screen differs from the previous result",
129
138
  }),
130
139
  ),
131
- screen: Type.Optional(Type.String({ description: "Terminal only: the final screen" })),
140
+ screen: Type.Optional(
141
+ Type.String({
142
+ description:
143
+ "Terminal only: the final screen; omitted for an exited Terminal whose screen is unchanged since your previous result",
144
+ }),
145
+ ),
132
146
  scrolled_off: Type.Optional(
133
147
  Type.String({
134
148
  description: "Terminal only: lines that scrolled off since the previous result",
@@ -270,7 +284,7 @@ export function parseWaitPattern(pattern: string): (screen: string) => boolean {
270
284
 
271
285
  type SettleMode = "start" | "input" | "poll";
272
286
  /** Why a Terminal wait ended. */
273
- export type SettleReason = "quiet" | "matched" | "exited" | "timeout";
287
+ export type SettleReason = Static<typeof SettleReasonSchema>;
274
288
 
275
289
  interface SettleRequest {
276
290
  readonly mode: SettleMode;
@@ -369,12 +383,32 @@ function locateCursor(
369
383
  return undefined;
370
384
  }
371
385
 
386
+ /**
387
+ * Find where the result's screen starts in termctrl's log. The screen is read before the log, in a
388
+ * separate termctrl request, so output in between can already have pushed the screen's top lines
389
+ * above the log's last screenful. Output only adds lines to the end of the log, so the screen is at
390
+ * the end or higher up, no higher than the log cursor. The screen is matched by every line except
391
+ * its last, which the program may still be writing. When nothing matches, as when the screen was
392
+ * redrawn in between, the log's last screenful is taken as the screen.
393
+ */
394
+ function locateScreen(lines: readonly string[], screen: readonly string[], floor: number): number {
395
+ const end = Math.max(0, lines.length - screen.length);
396
+ for (let start = end; start >= floor; start--) {
397
+ let matches = true;
398
+ for (let row = 0; row < screen.length - 1 && matches; row++) {
399
+ matches = lines[start + row] === screen[row];
400
+ }
401
+ if (matches) return start;
402
+ }
403
+ return end;
404
+ }
405
+
372
406
  /**
373
407
  * Lines that left the screen since the previous result, advancing the Terminal's log cursor.
374
- * Lines still on the screen stay unread, so a line rewritten after the agent saw it, such as a
375
- * progress line, is reported in its final form once it scrolls off. When the cursor cannot be found
376
- * again, because termctrl dropped lines the agent never received or the screen was cleared, every
377
- * line termctrl still holds is reported and the result says that earlier output is missing.
408
+ * Lines still on the result's screen stay unread, so a line rewritten after the agent saw it, such
409
+ * as a progress line, is reported in its final form once it scrolls off. When the cursor cannot be
410
+ * found again, because termctrl dropped lines the agent never received or the screen was cleared,
411
+ * every line termctrl still holds is reported and the result says that earlier output is missing.
378
412
  */
379
413
  async function takeScrolledOff(entry: TerminalEntry, screen: string): Promise<ScrolledOff> {
380
414
  let logs: string;
@@ -385,8 +419,8 @@ async function takeScrolledOff(entry: TerminalEntry, screen: string): Promise<Sc
385
419
  }
386
420
  const lines = logs === "" ? [] : logs.split("\n");
387
421
  const located = locateCursor(lines, entry.logCursor, entry.logAnchor);
388
- const screenLines = screen === "" ? 0 : screen.split("\n").length;
389
- const boundary = Math.max(0, lines.length - screenLines);
422
+ const screenLines = screen === "" ? [] : screen.split("\n");
423
+ const boundary = locateScreen(lines, screenLines, located ?? 0);
390
424
  const scrolled = lines.slice(Math.min(located ?? 0, boundary), boundary);
391
425
  entry.logCursor = boundary;
392
426
  entry.logAnchor =
@@ -480,6 +514,13 @@ async function truncateOutput(
480
514
  return { ...fitted, fullOutputPath: path, notice: `${range} Full output: ${path}]` };
481
515
  }
482
516
 
517
+ /** Says that `wait_for_text` was not seen when its wait timed out. */
518
+ function unmatchedNote(reason: SettleReason, pattern: string | undefined): string | undefined {
519
+ return reason === "timeout" && pattern !== undefined
520
+ ? `wait_for_text ${JSON.stringify(pattern)} was not seen before the wait ended.`
521
+ : undefined;
522
+ }
523
+
483
524
  function formatTerminalText(
484
525
  result: TerminalResult,
485
526
  note: string | undefined,
@@ -489,7 +530,9 @@ function formatTerminalText(
489
530
  result.state === "running"
490
531
  ? "running"
491
532
  : describeExit({ code: result.exit_code ?? null, signal: result.signal ?? null });
492
- const parts = [`${result.id} ${exit}${result.changed ? "" : " · screen unchanged"}`];
533
+ const parts = [
534
+ `${result.id} ${exit} · settled: ${result.settle_reason}${result.changed ? "" : " · screen unchanged"}`,
535
+ ];
493
536
  if (note !== undefined) parts.push(note);
494
537
  if (result.scrolled_off !== "") {
495
538
  parts.push(`--- scrolled off ---\n${result.scrolled_off}`);
@@ -504,6 +547,7 @@ async function terminalResult(
504
547
  registry: TermctrlRegistry,
505
548
  entry: TerminalEntry,
506
549
  snapshot: TerminalSnapshot | undefined,
550
+ settleReason: SettleReason,
507
551
  note?: string,
508
552
  ) {
509
553
  const screen = snapshot?.screen ?? entry.finalScreen ?? entry.lastScreen ?? "";
@@ -520,6 +564,7 @@ async function terminalResult(
520
564
  const result: TerminalResult = {
521
565
  id: entry.id,
522
566
  state: exited ? "exited" : "running",
567
+ settle_reason: settleReason,
523
568
  ...exitFields(exited ? entry.exit : null),
524
569
  changed: entry.lastScreen !== screen,
525
570
  screen: output.screen,
@@ -567,6 +612,23 @@ function unknownId(id: string): Error {
567
612
  );
568
613
  }
569
614
 
615
+ /** Resolve `cwd` against the session's directory and check it is a directory termctrl can start in. */
616
+ async function resolveWorkingDirectory(base: string, cwd: string | undefined): Promise<string> {
617
+ const resolved = resolve(base, cwd ?? ".");
618
+ let isDirectory: boolean;
619
+ try {
620
+ isDirectory = (await stat(resolved)).isDirectory();
621
+ } catch (cause) {
622
+ const code = cause instanceof Error && "code" in cause ? cause.code : undefined;
623
+ if (code === "ENOENT" || code === "ENOTDIR") {
624
+ throw new Error(`Working directory does not exist: ${resolved}`, { cause });
625
+ }
626
+ throw cause;
627
+ }
628
+ if (!isDirectory) throw new Error(`Working directory is not a directory: ${resolved}`);
629
+ return resolved;
630
+ }
631
+
570
632
  function shellCommand(shell: TerminalShell, command: string): [string, ...string[]] {
571
633
  const script = shell.commandPrefix ? `${shell.commandPrefix}\n${command}` : command;
572
634
  return [shell.shell, ...shell.args, script];
@@ -585,19 +647,26 @@ export function createTerminalStartTool(runtime: TerminalToolRuntime) {
585
647
  "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.",
586
648
  ],
587
649
  parameters: StartParameters,
650
+ annotations: {
651
+ readOnlyHint: false,
652
+ destructiveHint: true,
653
+ idempotentHint: false,
654
+ openWorldHint: true,
655
+ },
588
656
  outputSchema: TerminalResultSchema,
589
657
  async execute(_toolCallId, params, signal, _onUpdate, context) {
658
+ const cwd = await resolveWorkingDirectory(context.cwd, params.cwd);
590
659
  const entry = await runtime.registry.startTerminal(ownerOf(context), {
591
660
  command: shellCommand(runtime.shell(), params.command),
592
661
  displayCommand: params.command,
593
- cwd: resolve(context.cwd, params.cwd ?? "."),
662
+ cwd,
594
663
  viewport: runtime.viewport(),
595
664
  notify: params.notify ?? true,
596
665
  });
597
666
  // Launching, including a cold driver start, does not count toward the quiet period.
598
667
  const startedAt = Date.now();
599
668
  return driveTerminal(runtime.registry, entry, async () => {
600
- const { snapshot } = await settleTerminal(entry, {
669
+ const { snapshot, reason } = await settleTerminal(entry, {
601
670
  mode: "start",
602
671
  waitMs: clampWait(params.wait_ms, START_WAIT_MS),
603
672
  startedAt,
@@ -605,12 +674,18 @@ export function createTerminalStartTool(runtime: TerminalToolRuntime) {
605
674
  baseline: undefined,
606
675
  signal,
607
676
  });
608
- return terminalResult(runtime.registry, entry, snapshot);
677
+ return terminalResult(runtime.registry, entry, snapshot, reason);
609
678
  });
610
679
  },
611
680
  });
612
681
  }
613
682
 
683
+ function inputToExited(id: string, exit: TerminalExit | null): Error {
684
+ return new Error(
685
+ `${id} ${describeExit(exit)} and accepts no input. Poll it with terminal_send for its final screen, or remove it with terminal_stop.`,
686
+ );
687
+ }
688
+
614
689
  /** `terminal_send`: type text and keys into a Terminal, or poll it, and wait for it to settle. */
615
690
  export function createTerminalSendTool(runtime: TerminalToolRuntime) {
616
691
  return defineTool<typeof SendParameters, TerminalResult>({
@@ -620,6 +695,12 @@ export function createTerminalSendTool(runtime: TerminalToolRuntime) {
620
695
  "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.",
621
696
  promptSnippet: "Send input to a Terminal, or poll it, and read its screen",
622
697
  parameters: SendParameters,
698
+ annotations: {
699
+ readOnlyHint: false,
700
+ destructiveHint: true,
701
+ idempotentHint: false,
702
+ openWorldHint: true,
703
+ },
623
704
  outputSchema: TerminalResultSchema,
624
705
  async execute(_toolCallId, params, signal, _onUpdate, context) {
625
706
  const entry = runtime.registry.find(ownerOf(context), params.id);
@@ -638,22 +719,30 @@ export function createTerminalSendTool(runtime: TerminalToolRuntime) {
638
719
  }
639
720
  const hasInput = (params.text ?? "") !== "" || keys.length > 0;
640
721
  if (entry.state === "exited") {
641
- return terminalResult(
642
- runtime.registry,
643
- entry,
644
- undefined,
645
- hasInput ? "Input was not sent because the Terminal has exited." : undefined,
646
- );
722
+ if (hasInput) {
723
+ // The error tells the agent about the exit, so a deferred Exit notification is redundant.
724
+ runtime.registry.markSeen(entry.id);
725
+ throw inputToExited(entry.id, entry.exit);
726
+ }
727
+ return terminalResult(runtime.registry, entry, undefined, "exited");
647
728
  }
648
729
  return driveTerminal(runtime.registry, entry, async () => {
649
730
  const matches =
650
731
  params.wait_for_text === undefined ? undefined : parseWaitPattern(params.wait_for_text);
651
- const baseline = matches === undefined ? undefined : (await entry.handle.snapshot()).screen;
732
+ const before =
733
+ hasInput || matches !== undefined ? await entry.handle.snapshot() : undefined;
734
+ if (hasInput && before?.state === "exited") {
735
+ // The exit watcher has not noticed this exit yet.
736
+ const exit = before.exit ?? { code: null, signal: null };
737
+ runtime.registry.terminalExited(entry.id, exit, before.screen, true);
738
+ throw inputToExited(entry.id, exit);
739
+ }
740
+ const baseline = matches === undefined ? undefined : before?.screen;
652
741
  const startedAt = Date.now();
653
742
  if (params.text !== undefined && params.text !== "") await entry.handle.type(params.text);
654
743
  const validKeys = keys.filter(isKey);
655
744
  if (validKeys.length > 0) await entry.handle.press(validKeys);
656
- const { snapshot } = await settleTerminal(entry, {
745
+ const { snapshot, reason } = await settleTerminal(entry, {
657
746
  mode: hasInput ? "input" : "poll",
658
747
  waitMs: clampWait(params.wait_ms, hasInput ? INPUT_WAIT_MS : POLL_WAIT_MS),
659
748
  startedAt,
@@ -661,7 +750,13 @@ export function createTerminalSendTool(runtime: TerminalToolRuntime) {
661
750
  baseline,
662
751
  signal,
663
752
  });
664
- return terminalResult(runtime.registry, entry, snapshot);
753
+ return terminalResult(
754
+ runtime.registry,
755
+ entry,
756
+ snapshot,
757
+ reason,
758
+ unmatchedNote(reason, params.wait_for_text),
759
+ );
665
760
  });
666
761
  },
667
762
  });
@@ -688,9 +783,15 @@ export function createTerminalStopTool(registry: TermctrlRegistry) {
688
783
  name: "terminal_stop",
689
784
  label: "terminal_stop",
690
785
  description:
691
- "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.",
786
+ "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 (omitted when unchanged since your previous result) and scrolled-off lines, or a Background job's recent output.",
692
787
  promptSnippet: "Stop a Terminal or Background job",
693
788
  parameters: StopParameters,
789
+ annotations: {
790
+ readOnlyHint: false,
791
+ destructiveHint: true,
792
+ idempotentHint: true,
793
+ openWorldHint: false,
794
+ },
694
795
  outputSchema: StopResultSchema,
695
796
  async execute(_toolCallId, params, _signal, _onUpdate, context) {
696
797
  const owner = ownerOf(context);
@@ -714,14 +815,18 @@ export function createTerminalStopTool(registry: TermctrlRegistry) {
714
815
  const parts = [header];
715
816
  if (entry.kind === "terminal") {
716
817
  const screen = entry.finalScreen ?? "";
717
- const output = await fitOutput(registry, entry, screen, scrolled);
818
+ // An exited Terminal's screen the agent already saw is not repeated.
819
+ const repeated = !wasRunning && previousScreen === screen;
820
+ const output = await fitOutput(registry, entry, repeated ? "" : screen, scrolled);
718
821
  result.changed = previousScreen !== screen;
719
- result.screen = output.screen;
720
- result.scrolled_off = output.scrolledOff;
822
+ if (!repeated) result.screen = output.screen;
823
+ if (!repeated || output.scrolledOff !== "") result.scrolled_off = output.scrolledOff;
721
824
  if (output.gap) result.output_missing = true;
722
825
  if (output.fullOutputPath !== undefined) result.full_output_path = output.fullOutputPath;
723
826
  if (output.scrolledOff !== "") parts.push(`--- scrolled off ---\n${output.scrolledOff}`);
724
- parts.push(`--- final screen ---\n${output.screen === "" ? "(blank)" : output.screen}`);
827
+ if (repeated) parts.push("Its screen is unchanged since your last result.");
828
+ else
829
+ parts.push(`--- final screen ---\n${output.screen === "" ? "(blank)" : output.screen}`);
725
830
  if (output.notice !== undefined) parts.push(`\n${output.notice}`);
726
831
  } else {
727
832
  result.output = entry.child.tail();
@@ -832,6 +937,12 @@ export function createTerminalWaitTool(runtime: TerminalWaitRuntime) {
832
937
  "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
938
  ],
834
939
  parameters: WaitParameters,
940
+ annotations: {
941
+ readOnlyHint: true,
942
+ destructiveHint: false,
943
+ idempotentHint: true,
944
+ openWorldHint: false,
945
+ },
835
946
  outputSchema: WaitResultSchema,
836
947
  async execute(_toolCallId, params, signal, _onUpdate, context) {
837
948
  const owner = ownerOf(context);
@@ -898,6 +1009,12 @@ export function createTerminalListTool(registry: TermctrlRegistry) {
898
1009
  "List your Terminals and Background jobs, running and exited, with their state. Background jobs include their log path.",
899
1010
  promptSnippet: "List Terminals and Background jobs",
900
1011
  parameters: ListParameters,
1012
+ annotations: {
1013
+ readOnlyHint: true,
1014
+ destructiveHint: false,
1015
+ idempotentHint: true,
1016
+ openWorldHint: false,
1017
+ },
901
1018
  outputSchema: ListResultSchema,
902
1019
  async execute(_toolCallId, _params, _signal, _onUpdate, context) {
903
1020
  const now = Date.now();