@ian-pascoe/pi-termctrl 0.3.0 → 0.4.1

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's 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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ian-pascoe/pi-termctrl",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "private": false,
5
5
  "description": "Interactive Terminals, Background jobs, and a /ps panel for Pi.",
6
6
  "keywords": [
@@ -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
  }
@@ -36,19 +36,38 @@ async function readPid(pidFile: string): Promise<number | undefined> {
36
36
  return undefined;
37
37
  }
38
38
 
39
+ /**
40
+ * The requests made to one driver. termctrl answers every request sent before its shutdown request
41
+ * and none sent after it, and the SDK sends a request while its close is in flight without ever
42
+ * failing it, so that request would wait forever. The registry closes an idle driver without
43
+ * waiting, and a tool call may still read one of its exited Terminals, as `terminal_stop` does.
44
+ * Requests are therefore refused once closing starts, with the SDK's message for a closed driver.
45
+ */
46
+ class DriverRequests {
47
+ private closing = false;
48
+
49
+ send<T>(request: () => Promise<T>): Promise<T> {
50
+ return this.closing ? Promise.reject(new Error("termctrl driver is closed")) : request();
51
+ }
52
+
53
+ close(shutdown: () => Promise<void>): Promise<void> {
54
+ this.closing = true;
55
+ return shutdown();
56
+ }
57
+ }
58
+
39
59
  class TermctrlTerminal implements TerminalHandle {
40
60
  constructor(
41
61
  private readonly session: Session,
62
+ private readonly requests: DriverRequests,
42
63
  private readonly pid: number | undefined,
43
64
  ) {}
44
65
 
45
66
  async snapshot(): Promise<TerminalSnapshot> {
46
- const status = await this.session.status();
47
- const capture = await this.session.screen.capture({
48
- allowIncomplete: true,
49
- settleMs: 0,
50
- deadlineMs: 0,
51
- });
67
+ const status = await this.requests.send(() => this.session.status());
68
+ const capture = await this.requests.send(() =>
69
+ this.session.screen.capture({ allowIncomplete: true, settleMs: 0, deadlineMs: 0 }),
70
+ );
52
71
  return {
53
72
  screen: capture.text,
54
73
  state: status.state,
@@ -58,19 +77,19 @@ class TermctrlTerminal implements TerminalHandle {
58
77
  }
59
78
 
60
79
  logs(): Promise<string> {
61
- return this.session.logs.text();
80
+ return this.requests.send(() => this.session.logs.text());
62
81
  }
63
82
 
64
83
  type(text: string): Promise<void> {
65
- return this.session.keyboard.type(text);
84
+ return this.requests.send(() => this.session.keyboard.type(text));
66
85
  }
67
86
 
68
87
  press(keys: readonly Key[]): Promise<void> {
69
- return this.session.keyboard.sequence(keys);
88
+ return this.requests.send(() => this.session.keyboard.sequence(keys));
70
89
  }
71
90
 
72
91
  stop(): Promise<void> {
73
- return this.session.stop();
92
+ return this.requests.send(() => this.session.stop());
74
93
  }
75
94
 
76
95
  isAlive(): boolean {
@@ -98,6 +117,8 @@ class TermctrlTerminal implements TerminalHandle {
98
117
  }
99
118
 
100
119
  class TermctrlDriver implements TerminalDriver {
120
+ private readonly requests = new DriverRequests();
121
+
101
122
  constructor(private readonly control: TerminalControl) {}
102
123
 
103
124
  async launch(request: TerminalLaunchRequest): Promise<TerminalHandle> {
@@ -105,19 +126,21 @@ class TermctrlDriver implements TerminalDriver {
105
126
  await mkdir(directory, { recursive: true });
106
127
  const pidFile = join(directory, `${process.pid}-${request.id}.pid`);
107
128
  try {
108
- const session = await this.control.launch({
109
- command: ["/bin/sh", "-c", PID_WRAPPER, "pi-termctrl", pidFile, ...request.command],
110
- cwd: request.cwd,
111
- viewport: request.viewport,
112
- });
113
- return new TermctrlTerminal(session, await readPid(pidFile));
129
+ const session = await this.requests.send(() =>
130
+ this.control.launch({
131
+ command: ["/bin/sh", "-c", PID_WRAPPER, "pi-termctrl", pidFile, ...request.command],
132
+ cwd: request.cwd,
133
+ viewport: request.viewport,
134
+ }),
135
+ );
136
+ return new TermctrlTerminal(session, this.requests, await readPid(pidFile));
114
137
  } finally {
115
138
  await rm(pidFile, { force: true });
116
139
  }
117
140
  }
118
141
 
119
142
  close(): Promise<void> {
120
- return this.control.close();
143
+ return this.requests.close(() => this.control.close());
121
144
  }
122
145
  }
123
146
 
@@ -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 when 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;
@@ -500,6 +514,13 @@ async function truncateOutput(
500
514
  return { ...fitted, fullOutputPath: path, notice: `${range} Full output: ${path}]` };
501
515
  }
502
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
+
503
524
  function formatTerminalText(
504
525
  result: TerminalResult,
505
526
  note: string | undefined,
@@ -509,7 +530,9 @@ function formatTerminalText(
509
530
  result.state === "running"
510
531
  ? "running"
511
532
  : describeExit({ code: result.exit_code ?? null, signal: result.signal ?? null });
512
- 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
+ ];
513
536
  if (note !== undefined) parts.push(note);
514
537
  if (result.scrolled_off !== "") {
515
538
  parts.push(`--- scrolled off ---\n${result.scrolled_off}`);
@@ -524,6 +547,7 @@ async function terminalResult(
524
547
  registry: TermctrlRegistry,
525
548
  entry: TerminalEntry,
526
549
  snapshot: TerminalSnapshot | undefined,
550
+ settleReason: SettleReason,
527
551
  note?: string,
528
552
  ) {
529
553
  const screen = snapshot?.screen ?? entry.finalScreen ?? entry.lastScreen ?? "";
@@ -540,6 +564,7 @@ async function terminalResult(
540
564
  const result: TerminalResult = {
541
565
  id: entry.id,
542
566
  state: exited ? "exited" : "running",
567
+ settle_reason: settleReason,
543
568
  ...exitFields(exited ? entry.exit : null),
544
569
  changed: entry.lastScreen !== screen,
545
570
  screen: output.screen,
@@ -587,6 +612,23 @@ function unknownId(id: string): Error {
587
612
  );
588
613
  }
589
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
+
590
632
  function shellCommand(shell: TerminalShell, command: string): [string, ...string[]] {
591
633
  const script = shell.commandPrefix ? `${shell.commandPrefix}\n${command}` : command;
592
634
  return [shell.shell, ...shell.args, script];
@@ -613,17 +655,18 @@ export function createTerminalStartTool(runtime: TerminalToolRuntime) {
613
655
  },
614
656
  outputSchema: TerminalResultSchema,
615
657
  async execute(_toolCallId, params, signal, _onUpdate, context) {
658
+ const cwd = await resolveWorkingDirectory(context.cwd, params.cwd);
616
659
  const entry = await runtime.registry.startTerminal(ownerOf(context), {
617
660
  command: shellCommand(runtime.shell(), params.command),
618
661
  displayCommand: params.command,
619
- cwd: resolve(context.cwd, params.cwd ?? "."),
662
+ cwd,
620
663
  viewport: runtime.viewport(),
621
664
  notify: params.notify ?? true,
622
665
  });
623
666
  // Launching, including a cold driver start, does not count toward the quiet period.
624
667
  const startedAt = Date.now();
625
668
  return driveTerminal(runtime.registry, entry, async () => {
626
- const { snapshot } = await settleTerminal(entry, {
669
+ const { snapshot, reason } = await settleTerminal(entry, {
627
670
  mode: "start",
628
671
  waitMs: clampWait(params.wait_ms, START_WAIT_MS),
629
672
  startedAt,
@@ -631,12 +674,18 @@ export function createTerminalStartTool(runtime: TerminalToolRuntime) {
631
674
  baseline: undefined,
632
675
  signal,
633
676
  });
634
- return terminalResult(runtime.registry, entry, snapshot);
677
+ return terminalResult(runtime.registry, entry, snapshot, reason);
635
678
  });
636
679
  },
637
680
  });
638
681
  }
639
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
+
640
689
  /** `terminal_send`: type text and keys into a Terminal, or poll it, and wait for it to settle. */
641
690
  export function createTerminalSendTool(runtime: TerminalToolRuntime) {
642
691
  return defineTool<typeof SendParameters, TerminalResult>({
@@ -670,22 +719,30 @@ export function createTerminalSendTool(runtime: TerminalToolRuntime) {
670
719
  }
671
720
  const hasInput = (params.text ?? "") !== "" || keys.length > 0;
672
721
  if (entry.state === "exited") {
673
- return terminalResult(
674
- runtime.registry,
675
- entry,
676
- undefined,
677
- hasInput ? "Input was not sent because the Terminal has exited." : undefined,
678
- );
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");
679
728
  }
680
729
  return driveTerminal(runtime.registry, entry, async () => {
681
730
  const matches =
682
731
  params.wait_for_text === undefined ? undefined : parseWaitPattern(params.wait_for_text);
683
- 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;
684
741
  const startedAt = Date.now();
685
742
  if (params.text !== undefined && params.text !== "") await entry.handle.type(params.text);
686
743
  const validKeys = keys.filter(isKey);
687
744
  if (validKeys.length > 0) await entry.handle.press(validKeys);
688
- const { snapshot } = await settleTerminal(entry, {
745
+ const { snapshot, reason } = await settleTerminal(entry, {
689
746
  mode: hasInput ? "input" : "poll",
690
747
  waitMs: clampWait(params.wait_ms, hasInput ? INPUT_WAIT_MS : POLL_WAIT_MS),
691
748
  startedAt,
@@ -693,7 +750,13 @@ export function createTerminalSendTool(runtime: TerminalToolRuntime) {
693
750
  baseline,
694
751
  signal,
695
752
  });
696
- 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
+ );
697
760
  });
698
761
  },
699
762
  });
@@ -720,7 +783,7 @@ export function createTerminalStopTool(registry: TermctrlRegistry) {
720
783
  name: "terminal_stop",
721
784
  label: "terminal_stop",
722
785
  description:
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.",
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.",
724
787
  promptSnippet: "Stop a Terminal or Background job",
725
788
  parameters: StopParameters,
726
789
  annotations: {
@@ -752,14 +815,19 @@ export function createTerminalStopTool(registry: TermctrlRegistry) {
752
815
  const parts = [header];
753
816
  if (entry.kind === "terminal") {
754
817
  const screen = entry.finalScreen ?? "";
755
- const output = await fitOutput(registry, entry, screen, scrolled);
756
- result.changed = previousScreen !== screen;
757
- result.screen = output.screen;
758
- result.scrolled_off = output.scrolledOff;
818
+ const changed = previousScreen !== screen;
819
+ // A screen the agent already saw is not repeated, whether the Terminal was running or exited.
820
+ const repeated = !changed;
821
+ const output = await fitOutput(registry, entry, repeated ? "" : screen, scrolled);
822
+ result.changed = changed;
823
+ if (!repeated) result.screen = output.screen;
824
+ if (!repeated || output.scrolledOff !== "") result.scrolled_off = output.scrolledOff;
759
825
  if (output.gap) result.output_missing = true;
760
826
  if (output.fullOutputPath !== undefined) result.full_output_path = output.fullOutputPath;
761
827
  if (output.scrolledOff !== "") parts.push(`--- scrolled off ---\n${output.scrolledOff}`);
762
- parts.push(`--- final screen ---\n${output.screen === "" ? "(blank)" : output.screen}`);
828
+ if (repeated) parts.push("Its screen is unchanged since your last result.");
829
+ else
830
+ parts.push(`--- final screen ---\n${output.screen === "" ? "(blank)" : output.screen}`);
763
831
  if (output.notice !== undefined) parts.push(`\n${output.notice}`);
764
832
  } else {
765
833
  result.output = entry.child.tail();