@ian-pascoe/pi-termctrl 0.0.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/LICENSE +21 -0
- package/README.md +191 -0
- package/package.json +59 -0
- package/skills/pi-termctrl/SKILL.md +16 -0
- package/src/bash-replacement.ts +369 -0
- package/src/exit-notification.ts +113 -0
- package/src/index.ts +1 -0
- package/src/pi-termctrl-extension.ts +186 -0
- package/src/pi-termctrl-settings.ts +128 -0
- package/src/ps-panel.ts +343 -0
- package/src/termctrl-binary.ts +25 -0
- package/src/termctrl-driver.ts +127 -0
- package/src/termctrl-registry.ts +710 -0
- package/src/terminal-driver.ts +51 -0
- package/src/terminal-tools.ts +726 -0
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
import { rm } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type {
|
|
4
|
+
TerminalDriver,
|
|
5
|
+
TerminalExit,
|
|
6
|
+
TerminalHandle,
|
|
7
|
+
TerminalLaunchRequest,
|
|
8
|
+
} from "./terminal-driver.js";
|
|
9
|
+
import { isDriverGone } from "./terminal-driver.js";
|
|
10
|
+
import type { TerminalViewport } from "./pi-termctrl-settings.js";
|
|
11
|
+
|
|
12
|
+
/** The `globalThis` key every registry version shares. Its value always satisfies {@link RegistrySlot}. */
|
|
13
|
+
export const REGISTRY_KEY = Symbol.for("@ian-pascoe/pi-termctrl/registry");
|
|
14
|
+
/** Bump on any change to the state's shape: a reloaded module must not adopt an older shape. */
|
|
15
|
+
export const REGISTRY_VERSION = 3;
|
|
16
|
+
|
|
17
|
+
/** Live Terminals plus Background jobs allowed across the whole process. */
|
|
18
|
+
export const LIVE_ENTRY_CAP = 16;
|
|
19
|
+
const DEFAULT_POLL_INTERVAL_MS = 500;
|
|
20
|
+
const DEFAULT_STOP_GRACE_MS = 3_000;
|
|
21
|
+
/** How long a stopped Background job may take to report its exit before it is recorded as killed. */
|
|
22
|
+
const JOB_STOP_WAIT_MS = 5_000;
|
|
23
|
+
const STOP_POLL_MS = 50;
|
|
24
|
+
/** How an entry the registry killed is reported. */
|
|
25
|
+
export const KILLED_EXIT: TerminalExit = { code: null, signal: "SIGKILL" };
|
|
26
|
+
const DRIVER_EXITED: TerminalExit = { code: null, signal: "termctrl driver exited" };
|
|
27
|
+
|
|
28
|
+
/** The contract every registry version keeps, so a newer module can stop an older registry. */
|
|
29
|
+
interface RegistrySlot {
|
|
30
|
+
readonly version: number;
|
|
31
|
+
teardown(): Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The process side of a Background job: owned by the `bash` replacement, controlled by the registry. */
|
|
35
|
+
export interface BackgroundJobChild {
|
|
36
|
+
readonly logPath: string;
|
|
37
|
+
/** Abort the command; Pi's `exec` then kills its whole process tree. */
|
|
38
|
+
stop(): void;
|
|
39
|
+
/** Recent output for previews and Exit notifications. */
|
|
40
|
+
tail(): string;
|
|
41
|
+
removeLog(): Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Who asked an entry to stop. Agent stops are never notified; user stops are. */
|
|
45
|
+
type StopRequester = "agent" | "user";
|
|
46
|
+
|
|
47
|
+
interface EntryBase {
|
|
48
|
+
readonly id: string;
|
|
49
|
+
/** Session id of the Pi session that started the entry. */
|
|
50
|
+
readonly owner: string;
|
|
51
|
+
/** The command as the agent wrote it. */
|
|
52
|
+
readonly command: string;
|
|
53
|
+
readonly startedAt: number;
|
|
54
|
+
state: "running" | "exited";
|
|
55
|
+
exit: TerminalExit | null;
|
|
56
|
+
exitedAt: number | undefined;
|
|
57
|
+
/** The agent already saw this exit, so no Exit notification is due. */
|
|
58
|
+
seen: boolean;
|
|
59
|
+
stopRequested: StopRequester | undefined;
|
|
60
|
+
readonly notify: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A PTY program driven through termctrl. */
|
|
64
|
+
export interface TerminalEntry extends EntryBase {
|
|
65
|
+
readonly kind: "terminal";
|
|
66
|
+
readonly handle: TerminalHandle;
|
|
67
|
+
readonly generation: number;
|
|
68
|
+
readonly cwd: string;
|
|
69
|
+
/** The screen captured when the Terminal exited. */
|
|
70
|
+
finalScreen: string | undefined;
|
|
71
|
+
/** Number of agent tool calls currently driving this Terminal; the exit watcher skips them. */
|
|
72
|
+
activeCalls: number;
|
|
73
|
+
/** The screen the agent last received, for `changed: false`. */
|
|
74
|
+
lastScreen: string | undefined;
|
|
75
|
+
/** Count of log lines that scrolled off before the agent's last result. */
|
|
76
|
+
logCursor: number;
|
|
77
|
+
/**
|
|
78
|
+
* Lines that locate `logCursor` again after termctrl trims its scrollback from the top: the
|
|
79
|
+
* last lines before the cursor, or, while the cursor is 0, the log's first line.
|
|
80
|
+
*/
|
|
81
|
+
logAnchor: readonly string[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A `bash` command moved to the background. */
|
|
85
|
+
export interface BackgroundJobEntry extends EntryBase {
|
|
86
|
+
readonly kind: "job";
|
|
87
|
+
readonly child: BackgroundJobChild;
|
|
88
|
+
readonly settled: Promise<void>;
|
|
89
|
+
readonly settle: () => void;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A Terminal or a Background job. */
|
|
93
|
+
export type TermctrlEntry = TerminalEntry | BackgroundJobEntry;
|
|
94
|
+
|
|
95
|
+
/** One exit the agent has not seen, captured when it happened. */
|
|
96
|
+
export interface ExitNotice {
|
|
97
|
+
readonly id: string;
|
|
98
|
+
readonly kind: TermctrlEntry["kind"];
|
|
99
|
+
readonly command: string;
|
|
100
|
+
readonly exit: TerminalExit;
|
|
101
|
+
readonly durationMs: number;
|
|
102
|
+
/** The final screen of a Terminal or the recent log output of a Background job. */
|
|
103
|
+
readonly output: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Delivers a batch of Exit notifications to one owner session. Throwing keeps the batch queued. */
|
|
107
|
+
export type NotificationDelivery = (notices: readonly ExitNotice[]) => void;
|
|
108
|
+
|
|
109
|
+
/** Request to start one Terminal. */
|
|
110
|
+
export interface TerminalStartRequest {
|
|
111
|
+
readonly command: TerminalLaunchRequest["command"];
|
|
112
|
+
readonly displayCommand: string;
|
|
113
|
+
readonly cwd: string;
|
|
114
|
+
readonly viewport: TerminalViewport;
|
|
115
|
+
readonly notify: boolean;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Collaborators rebound by each module load. */
|
|
119
|
+
export interface RegistryDependencies {
|
|
120
|
+
readonly createDriver: () => Promise<TerminalDriver>;
|
|
121
|
+
readonly pollIntervalMs?: number;
|
|
122
|
+
readonly stopGraceMs?: number;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
interface DriverSlot {
|
|
126
|
+
readonly driver: TerminalDriver;
|
|
127
|
+
readonly generation: number;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Version 2 of the process-wide state. Only plain data and SDK handles; never `pi` or `ctx`. */
|
|
131
|
+
interface RegistryStateV2 extends RegistrySlot {
|
|
132
|
+
readonly version: typeof REGISTRY_VERSION;
|
|
133
|
+
nextTerminal: number;
|
|
134
|
+
nextJob: number;
|
|
135
|
+
nextOutputFile: number;
|
|
136
|
+
/** Full output files of truncated Terminal results, by owner; deleted when the owner shuts down. */
|
|
137
|
+
readonly outputFiles: Map<string, string[]>;
|
|
138
|
+
reserved: number;
|
|
139
|
+
generation: number;
|
|
140
|
+
readonly entries: Map<string, TermctrlEntry>;
|
|
141
|
+
driver: DriverSlot | undefined;
|
|
142
|
+
driverStarting: Promise<DriverSlot> | undefined;
|
|
143
|
+
readonly owners: Map<string, NotificationDelivery>;
|
|
144
|
+
readonly pending: Map<string, ExitNotice[]>;
|
|
145
|
+
flushScheduled: boolean;
|
|
146
|
+
watcher: ReturnType<typeof setInterval> | undefined;
|
|
147
|
+
polling: boolean;
|
|
148
|
+
readonly changeListeners: Set<() => void>;
|
|
149
|
+
/** The registry built by the most recently loaded module; listeners always call through it. */
|
|
150
|
+
current: TermctrlRegistry;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function readSlot(): RegistrySlot | undefined {
|
|
154
|
+
const slot: RegistrySlot | undefined = Object.getOwnPropertyDescriptor(
|
|
155
|
+
globalThis,
|
|
156
|
+
REGISTRY_KEY,
|
|
157
|
+
)?.value;
|
|
158
|
+
return slot;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function isCurrentVersion(slot: RegistrySlot): slot is RegistryStateV2 {
|
|
162
|
+
return slot.version === REGISTRY_VERSION;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function writeSlot(state: RegistryStateV2): void {
|
|
166
|
+
Object.defineProperty(globalThis, REGISTRY_KEY, {
|
|
167
|
+
value: state,
|
|
168
|
+
configurable: true,
|
|
169
|
+
enumerable: false,
|
|
170
|
+
writable: true,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function clearSlot(state: RegistrySlot): void {
|
|
175
|
+
if (readSlot() === state) Reflect.deleteProperty(globalThis, REGISTRY_KEY);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function describeEntry(entry: TermctrlEntry): string {
|
|
179
|
+
return `${entry.id} (${entry.kind === "terminal" ? "Terminal" : "Background job"}) ${entry.command}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function delay(milliseconds: number): Promise<"timeout"> {
|
|
183
|
+
return new Promise((resolve) => setTimeout(() => resolve("timeout"), milliseconds));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The process-wide registry of Terminals and Background jobs. One instance exists per module load;
|
|
188
|
+
* all of them share the versioned `globalThis` state, and only the newest is called by listeners.
|
|
189
|
+
*/
|
|
190
|
+
export class TermctrlRegistry {
|
|
191
|
+
private constructor(
|
|
192
|
+
private readonly state: RegistryStateV2,
|
|
193
|
+
private readonly dependencies: RegistryDependencies,
|
|
194
|
+
) {}
|
|
195
|
+
|
|
196
|
+
/** Adopt the process registry, tearing down an unknown version, and rebind it to this module. */
|
|
197
|
+
static acquire(dependencies: RegistryDependencies): TermctrlRegistry {
|
|
198
|
+
const slot = readSlot();
|
|
199
|
+
if (slot !== undefined && isCurrentVersion(slot)) {
|
|
200
|
+
const registry = new TermctrlRegistry(slot, dependencies);
|
|
201
|
+
slot.current = registry;
|
|
202
|
+
if (slot.watcher !== undefined) {
|
|
203
|
+
clearInterval(slot.watcher);
|
|
204
|
+
slot.watcher = undefined;
|
|
205
|
+
registry.syncWatcher();
|
|
206
|
+
}
|
|
207
|
+
return registry;
|
|
208
|
+
}
|
|
209
|
+
if (slot !== undefined) {
|
|
210
|
+
clearSlot(slot);
|
|
211
|
+
void slot.teardown().catch(() => {});
|
|
212
|
+
}
|
|
213
|
+
const state: Omit<RegistryStateV2, "current" | "teardown"> & Partial<RegistryStateV2> = {
|
|
214
|
+
version: REGISTRY_VERSION,
|
|
215
|
+
nextTerminal: 1,
|
|
216
|
+
nextJob: 1,
|
|
217
|
+
nextOutputFile: 1,
|
|
218
|
+
outputFiles: new Map(),
|
|
219
|
+
reserved: 0,
|
|
220
|
+
generation: 0,
|
|
221
|
+
entries: new Map(),
|
|
222
|
+
driver: undefined,
|
|
223
|
+
driverStarting: undefined,
|
|
224
|
+
owners: new Map(),
|
|
225
|
+
pending: new Map(),
|
|
226
|
+
flushScheduled: false,
|
|
227
|
+
watcher: undefined,
|
|
228
|
+
polling: false,
|
|
229
|
+
changeListeners: new Set(),
|
|
230
|
+
};
|
|
231
|
+
const complete: RegistryStateV2 = Object.assign(state, {
|
|
232
|
+
teardown: () => complete.current.teardownAll(),
|
|
233
|
+
current: undefined!,
|
|
234
|
+
});
|
|
235
|
+
const registry = new TermctrlRegistry(complete, dependencies);
|
|
236
|
+
complete.current = registry;
|
|
237
|
+
writeSlot(complete);
|
|
238
|
+
return registry;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The registry of the most recently loaded module, for listeners that outlive their module. */
|
|
242
|
+
static current(): TermctrlRegistry | undefined {
|
|
243
|
+
const slot = readSlot();
|
|
244
|
+
return slot !== undefined && isCurrentVersion(slot) ? slot.current : undefined;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Stop everything in the process registry and remove it. Tests use this between cases. */
|
|
248
|
+
static async teardownForTests(): Promise<void> {
|
|
249
|
+
await readSlot()?.teardown();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ── Owners and notifications ──────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
/** Bind an owner session so its Exit notifications are delivered, flushing any queued ones. */
|
|
255
|
+
bindOwner(owner: string, delivery: NotificationDelivery): void {
|
|
256
|
+
this.state.owners.set(owner, delivery);
|
|
257
|
+
if (this.state.pending.has(owner)) this.scheduleFlush();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Unbind an owner whose `pi` is going stale; its notifications queue until it binds again. */
|
|
261
|
+
unbindOwner(owner: string): void {
|
|
262
|
+
this.state.owners.delete(owner);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Deliver queued notifications to bound owners. Called through the current registry only. */
|
|
266
|
+
flush(): void {
|
|
267
|
+
this.state.flushScheduled = false;
|
|
268
|
+
for (const [owner, notices] of Array.from(this.state.pending)) {
|
|
269
|
+
const delivery = this.state.owners.get(owner);
|
|
270
|
+
if (delivery === undefined) continue;
|
|
271
|
+
const due = notices.filter((notice) => this.state.entries.get(notice.id)?.seen !== true);
|
|
272
|
+
this.state.pending.delete(owner);
|
|
273
|
+
if (due.length === 0) continue;
|
|
274
|
+
try {
|
|
275
|
+
delivery(due);
|
|
276
|
+
for (const notice of due) this.markSeen(notice.id);
|
|
277
|
+
} catch {
|
|
278
|
+
this.state.owners.delete(owner);
|
|
279
|
+
this.state.pending.set(owner, [...due, ...(this.state.pending.get(owner) ?? [])]);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private scheduleFlush(): void {
|
|
285
|
+
if (this.state.flushScheduled) return;
|
|
286
|
+
this.state.flushScheduled = true;
|
|
287
|
+
const state = this.state;
|
|
288
|
+
setImmediate(() => state.current.flush());
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private queueNotice(entry: TermctrlEntry, output: string): void {
|
|
292
|
+
if (!entry.notify || entry.seen || entry.stopRequested === "agent" || entry.exit === null) {
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const notices = this.state.pending.get(entry.owner) ?? [];
|
|
296
|
+
notices.push({
|
|
297
|
+
id: entry.id,
|
|
298
|
+
kind: entry.kind,
|
|
299
|
+
command: entry.command,
|
|
300
|
+
exit: entry.exit,
|
|
301
|
+
durationMs: (entry.exitedAt ?? Date.now()) - entry.startedAt,
|
|
302
|
+
output,
|
|
303
|
+
});
|
|
304
|
+
this.state.pending.set(entry.owner, notices);
|
|
305
|
+
this.scheduleFlush();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Record that the agent has seen an entry's exit in a tool result. */
|
|
309
|
+
markSeen(id: string): void {
|
|
310
|
+
const entry = this.state.entries.get(id);
|
|
311
|
+
if (entry !== undefined) entry.seen = true;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ── Queries ───────────────────────────────────────────────────────────────
|
|
315
|
+
|
|
316
|
+
/** Every entry in the process, oldest first. Only the human-facing `/ps` uses this. */
|
|
317
|
+
entries(): readonly TermctrlEntry[] {
|
|
318
|
+
return [...this.state.entries.values()];
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Entries started by one owner session. */
|
|
322
|
+
ownedEntries(owner: string): readonly TermctrlEntry[] {
|
|
323
|
+
return this.entries().filter((entry) => entry.owner === owner);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Look up an entry the owner may act on. */
|
|
327
|
+
find(owner: string, id: string): TermctrlEntry | undefined {
|
|
328
|
+
const entry = this.state.entries.get(id);
|
|
329
|
+
return entry?.owner === owner ? entry : undefined;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Look up any entry, for the human-facing `/ps`. */
|
|
333
|
+
get(id: string): TermctrlEntry | undefined {
|
|
334
|
+
return this.state.entries.get(id);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Live Terminals and Background jobs across the process. */
|
|
338
|
+
runningCount(): number {
|
|
339
|
+
return this.entries().filter((entry) => entry.state === "running").length;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Subscribe to entry additions, exits and removals. */
|
|
343
|
+
onChange(listener: () => void): () => void {
|
|
344
|
+
this.state.changeListeners.add(listener);
|
|
345
|
+
return () => this.state.changeListeners.delete(listener);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
private emitChange(): void {
|
|
349
|
+
for (const listener of Array.from(this.state.changeListeners)) {
|
|
350
|
+
try {
|
|
351
|
+
listener();
|
|
352
|
+
} catch {
|
|
353
|
+
// A failing UI listener must not break process bookkeeping.
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Throw the cap error, listing the caller's live entries, when no new entry may start. */
|
|
359
|
+
checkCapacity(owner: string): void {
|
|
360
|
+
if (this.runningCount() + this.state.reserved < LIVE_ENTRY_CAP) return;
|
|
361
|
+
const live = this.ownedEntries(owner).filter((entry) => entry.state === "running");
|
|
362
|
+
const listed = live.length === 0 ? "none" : live.map(describeEntry).join(", ");
|
|
363
|
+
throw new Error(
|
|
364
|
+
`Cannot start: ${LIVE_ENTRY_CAP} Terminals and Background jobs are already running (the limit). Your live entries: ${listed}. Stop one with terminal_stop.`,
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ── Terminals ─────────────────────────────────────────────────────────────
|
|
369
|
+
|
|
370
|
+
/** Start a Terminal on the shared driver, starting a driver first when none is alive. */
|
|
371
|
+
async startTerminal(owner: string, request: TerminalStartRequest): Promise<TerminalEntry> {
|
|
372
|
+
this.checkCapacity(owner);
|
|
373
|
+
this.state.reserved++;
|
|
374
|
+
try {
|
|
375
|
+
const id = `t${this.state.nextTerminal++}`;
|
|
376
|
+
const launch: TerminalLaunchRequest = {
|
|
377
|
+
id,
|
|
378
|
+
command: request.command,
|
|
379
|
+
cwd: request.cwd,
|
|
380
|
+
viewport: request.viewport,
|
|
381
|
+
};
|
|
382
|
+
let slot = await this.ensureDriver();
|
|
383
|
+
let handle: TerminalHandle;
|
|
384
|
+
try {
|
|
385
|
+
handle = await slot.driver.launch(launch);
|
|
386
|
+
} catch (cause) {
|
|
387
|
+
const error = cause instanceof Error ? cause : new Error(String(cause));
|
|
388
|
+
if (!isDriverGone(error)) throw error;
|
|
389
|
+
this.driverDied(slot.generation);
|
|
390
|
+
slot = await this.ensureDriver();
|
|
391
|
+
handle = await slot.driver.launch(launch);
|
|
392
|
+
}
|
|
393
|
+
const entry: TerminalEntry = {
|
|
394
|
+
kind: "terminal",
|
|
395
|
+
id,
|
|
396
|
+
owner,
|
|
397
|
+
command: request.displayCommand,
|
|
398
|
+
cwd: request.cwd,
|
|
399
|
+
startedAt: Date.now(),
|
|
400
|
+
state: "running",
|
|
401
|
+
exit: null,
|
|
402
|
+
exitedAt: undefined,
|
|
403
|
+
seen: false,
|
|
404
|
+
stopRequested: undefined,
|
|
405
|
+
notify: request.notify,
|
|
406
|
+
handle,
|
|
407
|
+
generation: slot.generation,
|
|
408
|
+
finalScreen: undefined,
|
|
409
|
+
activeCalls: 0,
|
|
410
|
+
lastScreen: undefined,
|
|
411
|
+
logCursor: 0,
|
|
412
|
+
logAnchor: [],
|
|
413
|
+
};
|
|
414
|
+
this.state.entries.set(id, entry);
|
|
415
|
+
this.syncWatcher();
|
|
416
|
+
this.emitChange();
|
|
417
|
+
return entry;
|
|
418
|
+
} finally {
|
|
419
|
+
this.state.reserved--;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
private async ensureDriver(): Promise<DriverSlot> {
|
|
424
|
+
if (this.state.driver !== undefined) return this.state.driver;
|
|
425
|
+
if (this.state.driverStarting !== undefined) return this.state.driverStarting;
|
|
426
|
+
const state = this.state;
|
|
427
|
+
const starting = this.dependencies.createDriver().then((driver) => {
|
|
428
|
+
const slot = { driver, generation: ++state.generation };
|
|
429
|
+
state.driver = slot;
|
|
430
|
+
return slot;
|
|
431
|
+
});
|
|
432
|
+
state.driverStarting = starting;
|
|
433
|
+
try {
|
|
434
|
+
return await starting;
|
|
435
|
+
} finally {
|
|
436
|
+
state.driverStarting = undefined;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Mark every Terminal of a dead driver exited; the next start creates a new driver. */
|
|
441
|
+
driverDied(generation: number): void {
|
|
442
|
+
if (this.state.driver?.generation === generation) this.state.driver = undefined;
|
|
443
|
+
for (const entry of this.entries()) {
|
|
444
|
+
if (entry.kind === "terminal" && entry.generation === generation) {
|
|
445
|
+
this.terminalExited(entry.id, DRIVER_EXITED, entry.lastScreen ?? "", false);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** Record a Terminal's exit, as seen by a tool call (`seen`) or by the exit watcher. */
|
|
451
|
+
terminalExited(id: string, exit: TerminalExit, finalScreen: string, seen: boolean): void {
|
|
452
|
+
const entry = this.state.entries.get(id);
|
|
453
|
+
if (entry?.kind !== "terminal" || entry.state === "exited") return;
|
|
454
|
+
entry.state = "exited";
|
|
455
|
+
entry.exit = exit;
|
|
456
|
+
entry.exitedAt = Date.now();
|
|
457
|
+
entry.finalScreen = finalScreen;
|
|
458
|
+
if (seen) entry.seen = true;
|
|
459
|
+
this.queueNotice(entry, finalScreen);
|
|
460
|
+
this.syncWatcher();
|
|
461
|
+
this.emitChange();
|
|
462
|
+
this.closeIdleDriver();
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Report a driver failure seen while a tool drove one of its Terminals. */
|
|
466
|
+
reportTerminalError(entry: TerminalEntry, cause: Error): boolean {
|
|
467
|
+
if (!isDriverGone(cause)) return false;
|
|
468
|
+
this.driverDied(entry.generation);
|
|
469
|
+
return true;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
private syncWatcher(): void {
|
|
473
|
+
const needed = this.entries().some(
|
|
474
|
+
(entry) => entry.kind === "terminal" && entry.state === "running",
|
|
475
|
+
);
|
|
476
|
+
if (needed && this.state.watcher === undefined) {
|
|
477
|
+
const state = this.state;
|
|
478
|
+
state.watcher = setInterval(
|
|
479
|
+
() => void state.current.pollTerminals(),
|
|
480
|
+
this.dependencies.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
|
|
481
|
+
);
|
|
482
|
+
state.watcher.unref?.();
|
|
483
|
+
} else if (!needed && this.state.watcher !== undefined) {
|
|
484
|
+
clearInterval(this.state.watcher);
|
|
485
|
+
this.state.watcher = undefined;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Detect Terminal exits nobody is waiting on. Called by the watcher through the current registry. */
|
|
490
|
+
async pollTerminals(): Promise<void> {
|
|
491
|
+
if (this.state.polling) return;
|
|
492
|
+
this.state.polling = true;
|
|
493
|
+
try {
|
|
494
|
+
for (const entry of this.entries()) {
|
|
495
|
+
if (entry.kind !== "terminal" || entry.state !== "running") continue;
|
|
496
|
+
if (entry.activeCalls > 0 || entry.stopRequested !== undefined) continue;
|
|
497
|
+
try {
|
|
498
|
+
const snapshot = await entry.handle.snapshot();
|
|
499
|
+
if (entry.activeCalls > 0 || entry.state !== "running") continue;
|
|
500
|
+
if (snapshot.state === "exited") {
|
|
501
|
+
this.terminalExited(entry.id, snapshot.exit ?? DRIVER_EXITED, snapshot.screen, false);
|
|
502
|
+
}
|
|
503
|
+
} catch (cause) {
|
|
504
|
+
this.reportTerminalError(
|
|
505
|
+
entry,
|
|
506
|
+
cause instanceof Error ? cause : new Error(String(cause)),
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
} finally {
|
|
511
|
+
this.state.polling = false;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
private closeIdleDriver(): void {
|
|
516
|
+
const slot = this.state.driver;
|
|
517
|
+
if (slot === undefined) return;
|
|
518
|
+
const inUse = this.entries().some(
|
|
519
|
+
(entry) =>
|
|
520
|
+
entry.kind === "terminal" &&
|
|
521
|
+
entry.generation === slot.generation &&
|
|
522
|
+
entry.state === "running",
|
|
523
|
+
);
|
|
524
|
+
if (inUse || this.state.reserved > 0) return;
|
|
525
|
+
this.state.driver = undefined;
|
|
526
|
+
void slot.driver.close().catch(() => {});
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// ── Background jobs ───────────────────────────────────────────────────────
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Register a Background job. `start` receives the new id and returns the job's process side;
|
|
533
|
+
* `startedAt` is when the command itself started, before it was backgrounded.
|
|
534
|
+
*/
|
|
535
|
+
createJob(
|
|
536
|
+
owner: string,
|
|
537
|
+
command: string,
|
|
538
|
+
start: (id: string) => BackgroundJobChild,
|
|
539
|
+
startedAt = Date.now(),
|
|
540
|
+
): BackgroundJobEntry {
|
|
541
|
+
this.checkCapacity(owner);
|
|
542
|
+
const id = `b${this.state.nextJob++}`;
|
|
543
|
+
const child = start(id);
|
|
544
|
+
let settle!: () => void;
|
|
545
|
+
const settled = new Promise<void>((resolve) => {
|
|
546
|
+
settle = resolve;
|
|
547
|
+
});
|
|
548
|
+
const entry: BackgroundJobEntry = {
|
|
549
|
+
kind: "job",
|
|
550
|
+
id,
|
|
551
|
+
owner,
|
|
552
|
+
command,
|
|
553
|
+
startedAt,
|
|
554
|
+
state: "running",
|
|
555
|
+
exit: null,
|
|
556
|
+
exitedAt: undefined,
|
|
557
|
+
seen: false,
|
|
558
|
+
stopRequested: undefined,
|
|
559
|
+
notify: true,
|
|
560
|
+
child,
|
|
561
|
+
settled,
|
|
562
|
+
settle,
|
|
563
|
+
};
|
|
564
|
+
this.state.entries.set(id, entry);
|
|
565
|
+
this.emitChange();
|
|
566
|
+
return entry;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** Record a Background job's exit. Called by the job's `exec` listener through the current registry. */
|
|
570
|
+
jobExited(id: string, exit: TerminalExit): void {
|
|
571
|
+
const entry = this.state.entries.get(id);
|
|
572
|
+
if (entry?.kind !== "job" || entry.state === "exited") return;
|
|
573
|
+
entry.state = "exited";
|
|
574
|
+
entry.exit = exit;
|
|
575
|
+
entry.exitedAt = Date.now();
|
|
576
|
+
entry.settle();
|
|
577
|
+
this.queueNotice(entry, entry.child.tail());
|
|
578
|
+
this.emitChange();
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// ── Stopping and removal ──────────────────────────────────────────────────
|
|
582
|
+
|
|
583
|
+
/** The agent stops an entry: no notification follows, and the entry is removed. */
|
|
584
|
+
async stop(owner: string, id: string): Promise<TermctrlEntry | undefined> {
|
|
585
|
+
const entry = this.find(owner, id);
|
|
586
|
+
if (entry === undefined) return undefined;
|
|
587
|
+
await this.retire(entry);
|
|
588
|
+
const pending = this.state.pending.get(owner)?.filter((notice) => notice.id !== id);
|
|
589
|
+
if (pending !== undefined) this.state.pending.set(owner, pending);
|
|
590
|
+
return entry;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** Stop and remove an entry on the agent's or its session's behalf, without a notification. */
|
|
594
|
+
private async retire(entry: TermctrlEntry): Promise<void> {
|
|
595
|
+
entry.stopRequested ??= "agent";
|
|
596
|
+
entry.seen = true;
|
|
597
|
+
await this.stopEntry(entry);
|
|
598
|
+
await this.removeEntry(entry);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** The user stops an entry from `/ps`: it stays listed as exited and the agent is notified. */
|
|
602
|
+
async stopByUser(id: string): Promise<void> {
|
|
603
|
+
const entry = this.state.entries.get(id);
|
|
604
|
+
if (entry === undefined || entry.state === "exited") return;
|
|
605
|
+
entry.stopRequested ??= "user";
|
|
606
|
+
await this.stopEntry(entry);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** Remove an exited entry, deleting a Background job's log. Running entries are kept. */
|
|
610
|
+
async remove(id: string): Promise<boolean> {
|
|
611
|
+
const entry = this.state.entries.get(id);
|
|
612
|
+
if (entry === undefined || entry.state === "running") return false;
|
|
613
|
+
await this.removeEntry(entry);
|
|
614
|
+
return true;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** Stop and remove every entry an owner started, and drop its queued notifications. */
|
|
618
|
+
async shutdownOwner(owner: string): Promise<void> {
|
|
619
|
+
this.unbindOwner(owner);
|
|
620
|
+
this.state.pending.delete(owner);
|
|
621
|
+
await Promise.all(this.ownedEntries(owner).map((entry) => this.retire(entry)));
|
|
622
|
+
await this.removeOutputFiles(owner);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Reserve a path in `directory` for the full output of a truncated Terminal result. The file
|
|
627
|
+
* outlives the Terminal, so `terminal_stop` can point to it, until its owner's session shuts down.
|
|
628
|
+
*/
|
|
629
|
+
reserveOutputFile(owner: string, id: string, directory: string): string {
|
|
630
|
+
const path = join(directory, `${process.pid}-${id}-output-${this.state.nextOutputFile++}.log`);
|
|
631
|
+
const paths = this.state.outputFiles.get(owner) ?? [];
|
|
632
|
+
paths.push(path);
|
|
633
|
+
this.state.outputFiles.set(owner, paths);
|
|
634
|
+
return path;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
private async removeOutputFiles(owner: string): Promise<void> {
|
|
638
|
+
const paths = this.state.outputFiles.get(owner) ?? [];
|
|
639
|
+
this.state.outputFiles.delete(owner);
|
|
640
|
+
await Promise.all(paths.map((path) => rm(path, { force: true }).catch(() => {})));
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** Stop everything in the process and release the global slot. */
|
|
644
|
+
async teardownAll(): Promise<void> {
|
|
645
|
+
clearSlot(this.state);
|
|
646
|
+
if (this.state.watcher !== undefined) clearInterval(this.state.watcher);
|
|
647
|
+
this.state.watcher = undefined;
|
|
648
|
+
this.state.pending.clear();
|
|
649
|
+
this.state.owners.clear();
|
|
650
|
+
await Promise.all(this.entries().map((entry) => this.retire(entry)));
|
|
651
|
+
await Promise.all(
|
|
652
|
+
[...this.state.outputFiles.keys()].map((owner) => this.removeOutputFiles(owner)),
|
|
653
|
+
);
|
|
654
|
+
const slot = this.state.driver;
|
|
655
|
+
this.state.driver = undefined;
|
|
656
|
+
await slot?.driver.close().catch(() => {});
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
private async stopEntry(entry: TermctrlEntry): Promise<void> {
|
|
660
|
+
if (entry.state === "exited") return;
|
|
661
|
+
if (entry.kind === "job") {
|
|
662
|
+
entry.child.stop();
|
|
663
|
+
const outcome = await Promise.race([entry.settled, delay(JOB_STOP_WAIT_MS)]);
|
|
664
|
+
if (outcome === "timeout") this.jobExited(entry.id, KILLED_EXIT);
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
await this.stopTerminal(entry);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
private async stopTerminal(entry: TerminalEntry): Promise<void> {
|
|
671
|
+
const seen = entry.stopRequested === "agent";
|
|
672
|
+
let screen = entry.lastScreen ?? "";
|
|
673
|
+
let exit = KILLED_EXIT;
|
|
674
|
+
try {
|
|
675
|
+
const snapshot = await entry.handle.snapshot();
|
|
676
|
+
screen = snapshot.screen;
|
|
677
|
+
if (snapshot.state === "exited" && snapshot.exit !== null) exit = snapshot.exit;
|
|
678
|
+
} catch {
|
|
679
|
+
// The Terminal may already be gone; keep the last screen the agent saw.
|
|
680
|
+
}
|
|
681
|
+
const graceMs = this.dependencies.stopGraceMs ?? DEFAULT_STOP_GRACE_MS;
|
|
682
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
683
|
+
const grace = new Promise<"timeout">((resolve) => {
|
|
684
|
+
timer = setTimeout(() => resolve("timeout"), graceMs);
|
|
685
|
+
});
|
|
686
|
+
const startedAt = performance.now();
|
|
687
|
+
const stopped = entry.handle.stop().then(
|
|
688
|
+
() => "stopped" as const,
|
|
689
|
+
() => "failed" as const,
|
|
690
|
+
);
|
|
691
|
+
const outcome = await Promise.race([stopped, grace]);
|
|
692
|
+
clearTimeout(timer);
|
|
693
|
+
// termctrl may forget the Terminal while its process still runs; give it the rest of the grace.
|
|
694
|
+
let remainingMs = graceMs - (performance.now() - startedAt);
|
|
695
|
+
while (outcome === "stopped" && entry.handle.isAlive() && remainingMs > 0) {
|
|
696
|
+
await delay(STOP_POLL_MS);
|
|
697
|
+
remainingMs -= STOP_POLL_MS;
|
|
698
|
+
}
|
|
699
|
+
if (outcome !== "stopped" || entry.handle.isAlive()) entry.handle.kill();
|
|
700
|
+
this.terminalExited(entry.id, exit, screen, seen);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
private async removeEntry(entry: TermctrlEntry): Promise<void> {
|
|
704
|
+
if (!this.state.entries.delete(entry.id)) return;
|
|
705
|
+
if (entry.kind === "job") await entry.child.removeLog().catch(() => {});
|
|
706
|
+
this.syncWatcher();
|
|
707
|
+
this.emitChange();
|
|
708
|
+
this.closeIdleDriver();
|
|
709
|
+
}
|
|
710
|
+
}
|