@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 +26 -9
- package/package.json +1 -1
- package/skills/pi-termctrl/SKILL.md +1 -0
- package/src/bash-replacement.ts +1 -1
- package/src/exit-notification.ts +34 -19
- package/src/pi-termctrl-extension.ts +40 -1
- package/src/termctrl-registry.ts +80 -19
- package/src/terminal-tools.ts +208 -3
- package/src/troubleshooting-skill.ts +9 -0
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,
|
|
110
|
-
|
|
111
|
-
`
|
|
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,
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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 `
|
|
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
|
@@ -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
|
package/src/bash-replacement.ts
CHANGED
|
@@ -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}
|
|
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,
|
package/src/exit-notification.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
package/src/termctrl-registry.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
/**
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
638
|
+
this.queueNotice(entry);
|
|
578
639
|
this.emitChange();
|
|
579
640
|
}
|
|
580
641
|
|
package/src/terminal-tools.ts
CHANGED
|
@@ -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({
|
|
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(
|
|
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}.`;
|