@ian-pascoe/pi-termctrl 0.3.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 +15 -2
- package/package.json +2 -2
- package/src/terminal-tools.ts +88 -21
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.
|
|
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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ian-pascoe/pi-termctrl",
|
|
3
|
-
"version": "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": [
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
]
|
|
54
54
|
},
|
|
55
55
|
"scripts": {
|
|
56
|
-
"test": "vitest run
|
|
56
|
+
"test": "vitest run",
|
|
57
57
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
58
58
|
}
|
|
59
59
|
}
|
package/src/terminal-tools.ts
CHANGED
|
@@ -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(
|
|
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 =
|
|
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 = [
|
|
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
|
|
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
|
-
|
|
674
|
-
|
|
675
|
-
entry
|
|
676
|
-
|
|
677
|
-
|
|
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
|
|
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(
|
|
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,18 @@ export function createTerminalStopTool(registry: TermctrlRegistry) {
|
|
|
752
815
|
const parts = [header];
|
|
753
816
|
if (entry.kind === "terminal") {
|
|
754
817
|
const screen = entry.finalScreen ?? "";
|
|
755
|
-
|
|
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);
|
|
756
821
|
result.changed = previousScreen !== screen;
|
|
757
|
-
result.screen = output.screen;
|
|
758
|
-
result.scrolled_off = output.scrolledOff;
|
|
822
|
+
if (!repeated) result.screen = output.screen;
|
|
823
|
+
if (!repeated || output.scrolledOff !== "") result.scrolled_off = output.scrolledOff;
|
|
759
824
|
if (output.gap) result.output_missing = true;
|
|
760
825
|
if (output.fullOutputPath !== undefined) result.full_output_path = output.fullOutputPath;
|
|
761
826
|
if (output.scrolledOff !== "") parts.push(`--- scrolled off ---\n${output.scrolledOff}`);
|
|
762
|
-
parts.push(
|
|
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}`);
|
|
763
830
|
if (output.notice !== undefined) parts.push(`\n${output.notice}`);
|
|
764
831
|
} else {
|
|
765
832
|
result.output = entry.child.tail();
|