@ian-pascoe/pi-termctrl 0.2.0 → 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
@@ -97,6 +97,10 @@ for the agent, such as the user steering or another entry's Exit notification;
97
97
  a watched entry that exits after that is notified as usual. With nothing to
98
98
  wait for it returns at once. Terminal exits are noticed within about 500 ms.
99
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
+
100
104
  ## `bash` replacement
101
105
 
102
106
  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.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
  ),
@@ -369,12 +369,32 @@ function locateCursor(
369
369
  return undefined;
370
370
  }
371
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
+
372
392
  /**
373
393
  * 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.
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.
378
398
  */
379
399
  async function takeScrolledOff(entry: TerminalEntry, screen: string): Promise<ScrolledOff> {
380
400
  let logs: string;
@@ -385,8 +405,8 @@ async function takeScrolledOff(entry: TerminalEntry, screen: string): Promise<Sc
385
405
  }
386
406
  const lines = logs === "" ? [] : logs.split("\n");
387
407
  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);
408
+ const screenLines = screen === "" ? [] : screen.split("\n");
409
+ const boundary = locateScreen(lines, screenLines, located ?? 0);
390
410
  const scrolled = lines.slice(Math.min(located ?? 0, boundary), boundary);
391
411
  entry.logCursor = boundary;
392
412
  entry.logAnchor =
@@ -585,6 +605,12 @@ export function createTerminalStartTool(runtime: TerminalToolRuntime) {
585
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.",
586
606
  ],
587
607
  parameters: StartParameters,
608
+ annotations: {
609
+ readOnlyHint: false,
610
+ destructiveHint: true,
611
+ idempotentHint: false,
612
+ openWorldHint: true,
613
+ },
588
614
  outputSchema: TerminalResultSchema,
589
615
  async execute(_toolCallId, params, signal, _onUpdate, context) {
590
616
  const entry = await runtime.registry.startTerminal(ownerOf(context), {
@@ -620,6 +646,12 @@ export function createTerminalSendTool(runtime: TerminalToolRuntime) {
620
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.",
621
647
  promptSnippet: "Send input to a Terminal, or poll it, and read its screen",
622
648
  parameters: SendParameters,
649
+ annotations: {
650
+ readOnlyHint: false,
651
+ destructiveHint: true,
652
+ idempotentHint: false,
653
+ openWorldHint: true,
654
+ },
623
655
  outputSchema: TerminalResultSchema,
624
656
  async execute(_toolCallId, params, signal, _onUpdate, context) {
625
657
  const entry = runtime.registry.find(ownerOf(context), params.id);
@@ -691,6 +723,12 @@ export function createTerminalStopTool(registry: TermctrlRegistry) {
691
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.",
692
724
  promptSnippet: "Stop a Terminal or Background job",
693
725
  parameters: StopParameters,
726
+ annotations: {
727
+ readOnlyHint: false,
728
+ destructiveHint: true,
729
+ idempotentHint: true,
730
+ openWorldHint: false,
731
+ },
694
732
  outputSchema: StopResultSchema,
695
733
  async execute(_toolCallId, params, _signal, _onUpdate, context) {
696
734
  const owner = ownerOf(context);
@@ -832,6 +870,12 @@ export function createTerminalWaitTool(runtime: TerminalWaitRuntime) {
832
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.",
833
871
  ],
834
872
  parameters: WaitParameters,
873
+ annotations: {
874
+ readOnlyHint: true,
875
+ destructiveHint: false,
876
+ idempotentHint: true,
877
+ openWorldHint: false,
878
+ },
835
879
  outputSchema: WaitResultSchema,
836
880
  async execute(_toolCallId, params, signal, _onUpdate, context) {
837
881
  const owner = ownerOf(context);
@@ -898,6 +942,12 @@ export function createTerminalListTool(registry: TermctrlRegistry) {
898
942
  "List your Terminals and Background jobs, running and exited, with their state. Background jobs include their log path.",
899
943
  promptSnippet: "List Terminals and Background jobs",
900
944
  parameters: ListParameters,
945
+ annotations: {
946
+ readOnlyHint: true,
947
+ destructiveHint: false,
948
+ idempotentHint: true,
949
+ openWorldHint: false,
950
+ },
901
951
  outputSchema: ListResultSchema,
902
952
  async execute(_toolCallId, _params, _signal, _onUpdate, context) {
903
953
  const now = Date.now();