@aragon-agent/cli 0.6.3 → 0.6.4
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/CHANGELOG.md +105 -0
- package/README.md +95 -2
- package/dist/agent/controller.d.ts +119 -0
- package/dist/agent/controller.js +275 -1
- package/dist/agent/reducer.d.ts +69 -0
- package/dist/agent/reducer.js +100 -0
- package/dist/agent/system-prompt.d.ts +14 -0
- package/dist/agent/system-prompt.js +2 -0
- package/dist/cli.js +84 -22
- package/dist/commands/builtins.js +92 -1
- package/dist/compaction/child.js +5 -0
- package/dist/compaction/command.js +14 -1
- package/dist/compaction/compactor.d.ts +34 -0
- package/dist/compaction/compactor.js +158 -37
- package/dist/compaction/types.d.ts +36 -0
- package/dist/compaction/wiring.d.ts +9 -0
- package/dist/compaction/wiring.js +22 -1
- package/dist/config/env.d.ts +1 -1
- package/dist/config/env.js +48 -1
- package/dist/config/load.d.ts +11 -0
- package/dist/config/load.js +39 -1
- package/dist/config/schema.d.ts +85 -0
- package/dist/config/schema.js +33 -0
- package/dist/config/store.js +21 -1
- package/dist/input/limits.d.ts +72 -0
- package/dist/input/limits.js +63 -0
- package/dist/input/paste-parse.d.ts +71 -0
- package/dist/input/paste-parse.js +95 -0
- package/dist/input/stdin-filter.d.ts +81 -0
- package/dist/input/stdin-filter.js +379 -0
- package/dist/logging/install.d.ts +2 -0
- package/dist/logging/install.js +53 -1
- package/dist/proc/classify.d.ts +52 -0
- package/dist/proc/classify.js +143 -0
- package/dist/proc/kill-tree.d.ts +42 -0
- package/dist/proc/kill-tree.js +119 -0
- package/dist/proc/limits.d.ts +84 -0
- package/dist/proc/limits.js +84 -0
- package/dist/proc/log-ring.d.ts +68 -0
- package/dist/proc/log-ring.js +102 -0
- package/dist/proc/prompt.d.ts +24 -0
- package/dist/proc/prompt.js +49 -0
- package/dist/proc/readiness.d.ts +102 -0
- package/dist/proc/readiness.js +219 -0
- package/dist/proc/supervisor.d.ts +103 -0
- package/dist/proc/supervisor.js +465 -0
- package/dist/proc/types.d.ts +175 -0
- package/dist/proc/types.js +15 -0
- package/dist/session/persist.js +36 -1
- package/dist/tools/bash-tool.d.ts +51 -2
- package/dist/tools/bash-tool.js +321 -51
- package/dist/tools/fs-tools.d.ts +17 -0
- package/dist/tools/index.d.ts +55 -2
- package/dist/tools/index.js +39 -1
- package/dist/tools/proc-tools.d.ts +25 -0
- package/dist/tools/proc-tools.js +102 -0
- package/dist/ui/App.d.ts +15 -2
- package/dist/ui/App.js +256 -7
- package/dist/ui/Composer.d.ts +54 -1
- package/dist/ui/Composer.js +50 -6
- package/dist/ui/PromptInput.d.ts +49 -3
- package/dist/ui/PromptInput.js +184 -52
- package/dist/ui/StatusBar.d.ts +19 -0
- package/dist/ui/StatusBar.js +5 -2
- package/dist/ui/Transcript.js +29 -1
- package/dist/ui/composer-limits.d.ts +31 -0
- package/dist/ui/composer-limits.js +50 -0
- package/dist/ui/composer-rows.d.ts +73 -0
- package/dist/ui/composer-rows.js +201 -0
- package/dist/ui/editor-reducer.d.ts +37 -0
- package/dist/ui/editor-reducer.js +88 -12
- package/dist/ui/entries/ServiceCard.d.ts +66 -0
- package/dist/ui/entries/ServiceCard.js +116 -0
- package/dist/ui/entries/UserEntry.d.ts +17 -3
- package/dist/ui/entries/UserEntry.js +34 -5
- package/dist/ui/layout/budget.d.ts +34 -6
- package/dist/ui/layout/budget.js +32 -7
- package/dist/ui/layout/virtual-window.js +51 -1
- package/dist/ui/overlays/HelpOverlay.js +3 -0
- package/dist/ui/overlays/PlanReviewOverlay.js +10 -2
- package/dist/ui/overlays/QuestionOverlay.js +5 -1
- package/dist/ui/overlays/SettingsScreen.js +14 -1
- package/dist/ui/paste-frames.d.ts +87 -0
- package/dist/ui/paste-frames.js +112 -0
- package/dist/ui/paste-tokens.d.ts +90 -0
- package/dist/ui/paste-tokens.js +147 -0
- package/dist/ui/screen.d.ts +16 -0
- package/dist/ui/screen.js +45 -2
- package/dist/ui/selection/selection-controller.d.ts +1 -1
- package/dist/ui/transcript-text.js +49 -0
- package/dist/ui/use-wheel-routing.d.ts +1 -1
- package/package.json +2 -2
- package/dist/input/stdin-mouse-filter.d.ts +0 -47
- package/dist/input/stdin-mouse-filter.js +0 -153
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProcSupervisor — the owner of every child process this session spawned
|
|
3
|
+
* (background-service-supervision §3.3 / §3.5 / §3.6).
|
|
4
|
+
*
|
|
5
|
+
* ASCII ONLY: `src/proc/**` is inside the glyph scanner's scope.
|
|
6
|
+
*
|
|
7
|
+
* ONE REGISTRY, TWO LIFETIMES (I-4). Foreground `bash` children are tracked so
|
|
8
|
+
* `forceStop()` can reach them; background services are tracked so `Ctrl+C`,
|
|
9
|
+
* `bash_kill` and process exit can reach them. Splitting those into two
|
|
10
|
+
* registries is how a force-stop ends up killing the user's dev server, or how a
|
|
11
|
+
* quit leaves it running. They are two collections in ONE object, and the two
|
|
12
|
+
* kill entry points are named for which one they own.
|
|
13
|
+
*
|
|
14
|
+
* CONSTRUCTED UNCONDITIONALLY (P1-6 / D-9). `cfg.bash.background` gates the
|
|
15
|
+
* SERVICE half — the `background` tool param, `bash_output`/`bash_kill`, the
|
|
16
|
+
* prompt block, the status chip — and never the foreground half, because G2
|
|
17
|
+
* ("bash always settles") and G3 ("Esc, Esc always works") are promised
|
|
18
|
+
* unconditionally. With the flag off this object holds foreground pids, nothing
|
|
19
|
+
* else, and allocates no timers.
|
|
20
|
+
*/
|
|
21
|
+
import { spawn } from 'node:child_process';
|
|
22
|
+
import process from 'node:process';
|
|
23
|
+
import { getLogger } from '../logging/logger.js';
|
|
24
|
+
import { extractPortHint } from './classify.js';
|
|
25
|
+
import { isAlive, killTree, killTreeSync } from './kill-tree.js';
|
|
26
|
+
import { PROC_LIMITS } from './limits.js';
|
|
27
|
+
import { LogRing } from './log-ring.js';
|
|
28
|
+
import { probePort, ReadinessWatcher } from './readiness.js';
|
|
29
|
+
import { isTerminalStatus, } from './types.js';
|
|
30
|
+
export class ProcSupervisor {
|
|
31
|
+
opts;
|
|
32
|
+
services = new Map();
|
|
33
|
+
/**
|
|
34
|
+
* Live FOREGROUND `bash` children, by pid.
|
|
35
|
+
*
|
|
36
|
+
* A `Set` of pids rather than of `ChildProcess` objects: the only thing
|
|
37
|
+
* `killForeground` needs is the number, and holding the object would keep a
|
|
38
|
+
* reference to a settled child's stdio for the life of the session.
|
|
39
|
+
*/
|
|
40
|
+
foreground = new Set();
|
|
41
|
+
listeners = new Set();
|
|
42
|
+
nextId = 1;
|
|
43
|
+
disposed = false;
|
|
44
|
+
constructor(opts) {
|
|
45
|
+
this.opts = opts;
|
|
46
|
+
}
|
|
47
|
+
// -----------------------------------------------------------------------
|
|
48
|
+
// Foreground children (unconditional; see the class comment)
|
|
49
|
+
// -----------------------------------------------------------------------
|
|
50
|
+
trackForeground(pid) {
|
|
51
|
+
if (pid === undefined || !Number.isFinite(pid) || pid <= 0)
|
|
52
|
+
return () => { };
|
|
53
|
+
this.foreground.add(pid);
|
|
54
|
+
return () => {
|
|
55
|
+
this.foreground.delete(pid);
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Hard-kill every tracked foreground child. Returns how many were signalled.
|
|
60
|
+
*
|
|
61
|
+
* THIS IS WHAT ACTUALLY UNBLOCKS A WEDGED `await tool.execute` on rung two of
|
|
62
|
+
* the Esc ladder. It NEVER touches services: `Esc` interrupts the AGENT,
|
|
63
|
+
* `Ctrl+C` stops the SERVICES, and that asymmetry is the user's own two
|
|
64
|
+
* sentences (I-4). A later reader will want to "fix" this by making it kill
|
|
65
|
+
* everything; that is the bug, not the fix.
|
|
66
|
+
*/
|
|
67
|
+
killForeground(_reason) {
|
|
68
|
+
let n = 0;
|
|
69
|
+
for (const pid of [...this.foreground]) {
|
|
70
|
+
killTree(pid, 'SIGKILL');
|
|
71
|
+
this.foreground.delete(pid);
|
|
72
|
+
n += 1;
|
|
73
|
+
}
|
|
74
|
+
return n;
|
|
75
|
+
}
|
|
76
|
+
// -----------------------------------------------------------------------
|
|
77
|
+
// Services
|
|
78
|
+
// -----------------------------------------------------------------------
|
|
79
|
+
async start(req) {
|
|
80
|
+
if (this.disposed)
|
|
81
|
+
return { ok: false, error: 'The process supervisor is shut down.' };
|
|
82
|
+
this.evictTerminal();
|
|
83
|
+
if (this.services.size >= PROC_LIMITS.maxServices) {
|
|
84
|
+
// NO EVICTION AND NO SILENT FOREGROUNDING (P2-8). Every record is live, so
|
|
85
|
+
// dropping the oldest would orphan a running server that nothing can any
|
|
86
|
+
// longer name or stop. Naming the ids is what makes the error actionable.
|
|
87
|
+
const live = [...this.services.values()]
|
|
88
|
+
.filter((e) => !isTerminalStatus(e.record.status))
|
|
89
|
+
.slice(0, 4)
|
|
90
|
+
.map((e) => e.record.id)
|
|
91
|
+
.join(', ');
|
|
92
|
+
return {
|
|
93
|
+
ok: false,
|
|
94
|
+
error: `Too many background services (${PROC_LIMITS.maxServices}). ` +
|
|
95
|
+
`Stop one first with bash_kill({ service: "<id>" }) - oldest live: ${live}.`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
const id = `s${this.nextId}`;
|
|
99
|
+
this.nextId += 1;
|
|
100
|
+
// THE PRE-FLIGHT PROBE RUNS BEFORE THE SPAWN (R-3). An unrelated process
|
|
101
|
+
// already answering on 3000 would otherwise make every launch instantly and
|
|
102
|
+
// falsely ready; finding it there disables the probe detector for this
|
|
103
|
+
// service and annotates the card, leaving the URL detector to do the work.
|
|
104
|
+
const hint = extractPortHint(req.command);
|
|
105
|
+
const preoccupied = hint === undefined ? false : await (this.opts.probe ?? probePort)(hint).catch(() => false);
|
|
106
|
+
const ring = new LogRing();
|
|
107
|
+
const record = {
|
|
108
|
+
id,
|
|
109
|
+
toolCallId: req.toolCallId,
|
|
110
|
+
command: req.command,
|
|
111
|
+
cwd: req.cwd,
|
|
112
|
+
pid: undefined,
|
|
113
|
+
status: 'starting',
|
|
114
|
+
startedAt: Date.now(),
|
|
115
|
+
exitCode: null,
|
|
116
|
+
signal: null,
|
|
117
|
+
rows: [],
|
|
118
|
+
rowsSeen: 0,
|
|
119
|
+
truncated: false,
|
|
120
|
+
...(hint !== undefined ? { port: hint } : {}),
|
|
121
|
+
...(preoccupied ? { portPreoccupied: true } : {}),
|
|
122
|
+
};
|
|
123
|
+
const entry = {
|
|
124
|
+
record,
|
|
125
|
+
child: undefined,
|
|
126
|
+
ring,
|
|
127
|
+
watcher: undefined,
|
|
128
|
+
outputTimer: undefined,
|
|
129
|
+
waiters: [],
|
|
130
|
+
escalation: undefined,
|
|
131
|
+
stopRequested: false,
|
|
132
|
+
};
|
|
133
|
+
this.services.set(id, entry);
|
|
134
|
+
let child;
|
|
135
|
+
try {
|
|
136
|
+
child = spawn(req.command, {
|
|
137
|
+
shell: true,
|
|
138
|
+
cwd: req.cwd,
|
|
139
|
+
env: process.env,
|
|
140
|
+
windowsHide: true,
|
|
141
|
+
// W1.2, one level down: a service that prompts on stdin must fail rather
|
|
142
|
+
// than block on a read nobody will ever satisfy.
|
|
143
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
144
|
+
// NOT `false`. On POSIX this makes the child a process-group leader, so
|
|
145
|
+
// `process.kill(-pid, sig)` reaches the whole tree; with `detached:
|
|
146
|
+
// false` the child shares THIS process's group and there is no group to
|
|
147
|
+
// signal, which is why today's foreground killTree only ever kills the
|
|
148
|
+
// shell and lets every `npm run dev` grandchild survive. On Windows the
|
|
149
|
+
// flag is irrelevant to reaping - `taskkill /t` walks the parent-child
|
|
150
|
+
// table - so it is off there to avoid detaching a console.
|
|
151
|
+
//
|
|
152
|
+
// IT DOES NOT MEAN "SURVIVES US": `child.unref()` is deliberately NEVER
|
|
153
|
+
// called, and both reapers signal the group. It buys the group and
|
|
154
|
+
// nothing else (D-13).
|
|
155
|
+
detached: process.platform !== 'win32',
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
record.status = 'failed';
|
|
160
|
+
record.endedAt = Date.now();
|
|
161
|
+
this.emit({ type: 'exited', service: this.snapshot(entry) });
|
|
162
|
+
return {
|
|
163
|
+
ok: false,
|
|
164
|
+
error: `Failed to start background service: ${err instanceof Error ? err.message : String(err)}`,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
entry.child = child;
|
|
168
|
+
record.pid = child.pid;
|
|
169
|
+
const onChunk = (chunk) => {
|
|
170
|
+
const rows = ring.append(typeof chunk === 'string' ? chunk : chunk.toString());
|
|
171
|
+
record.rowsSeen = ring.rowsSeen;
|
|
172
|
+
record.truncated = ring.truncated;
|
|
173
|
+
record.rows = ring.tail(PROC_LIMITS.cardTailRows);
|
|
174
|
+
if (rows.length > 0)
|
|
175
|
+
entry.watcher?.offerRows(rows);
|
|
176
|
+
this.scheduleOutput(entry);
|
|
177
|
+
};
|
|
178
|
+
child.stdout?.on('data', onChunk);
|
|
179
|
+
child.stderr?.on('data', onChunk);
|
|
180
|
+
child.on('error', (err) => {
|
|
181
|
+
if (isTerminalStatus(record.status))
|
|
182
|
+
return;
|
|
183
|
+
record.status = 'failed';
|
|
184
|
+
record.endedAt = Date.now();
|
|
185
|
+
ring.append(`\n${err.message}\n`);
|
|
186
|
+
record.rows = ring.tail(PROC_LIMITS.cardTailRows);
|
|
187
|
+
record.rowsSeen = ring.rowsSeen;
|
|
188
|
+
this.finish(entry, 'exited');
|
|
189
|
+
});
|
|
190
|
+
// SETTLED ON `exit`, NOT ON `close`, for the reason defence 1 exists: a
|
|
191
|
+
// detached grandchild holding the pipe open would otherwise keep a dead
|
|
192
|
+
// service showing as `starting` for the rest of the session.
|
|
193
|
+
child.on('exit', (code, signalName) => {
|
|
194
|
+
if (isTerminalStatus(record.status))
|
|
195
|
+
return;
|
|
196
|
+
record.exitCode = code;
|
|
197
|
+
record.signal = signalName;
|
|
198
|
+
// `stopped` VS `exited` IS DECIDED BY INTENT, NEVER BY ARRIVAL ORDER. The
|
|
199
|
+
// exit code and signal are still recorded either way - they are evidence -
|
|
200
|
+
// but a process that ended because we asked it to is `stopped`, which is
|
|
201
|
+
// the status the card draws in muted rather than in the error colour.
|
|
202
|
+
const asked = entry.stopRequested;
|
|
203
|
+
record.status = asked ? 'stopped' : 'exited';
|
|
204
|
+
record.endedAt = Date.now();
|
|
205
|
+
this.finish(entry, asked ? 'stopped' : 'exited');
|
|
206
|
+
});
|
|
207
|
+
const watcher = new ReadinessWatcher({
|
|
208
|
+
// The hint is dropped when the port was ALREADY answering: probing it
|
|
209
|
+
// would report ready in the first 40 ms for somebody else's server.
|
|
210
|
+
...(hint !== undefined && !preoccupied ? { portHint: hint } : {}),
|
|
211
|
+
readyTimeoutMs: this.opts.readyTimeoutMs(),
|
|
212
|
+
...(this.opts.probe ? { probe: this.opts.probe } : {}),
|
|
213
|
+
onReady: (result) => {
|
|
214
|
+
if (isTerminalStatus(record.status) || record.status === 'ready')
|
|
215
|
+
return;
|
|
216
|
+
record.status = 'ready';
|
|
217
|
+
record.readyAt = Date.now();
|
|
218
|
+
if (result.url)
|
|
219
|
+
record.url = result.url;
|
|
220
|
+
if (result.port !== undefined)
|
|
221
|
+
record.port = result.port;
|
|
222
|
+
record.detectedBy = result.detectedBy;
|
|
223
|
+
this.emit({ type: 'ready', service: this.snapshot(entry) });
|
|
224
|
+
},
|
|
225
|
+
onTimeout: () => {
|
|
226
|
+
if (isTerminalStatus(record.status) || record.status === 'ready')
|
|
227
|
+
return;
|
|
228
|
+
// AN HONEST THIRD STATE (D-4). It is alive and it listens on nothing.
|
|
229
|
+
record.status = 'running';
|
|
230
|
+
this.emit({ type: 'ready', service: this.snapshot(entry) });
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
entry.watcher = watcher;
|
|
234
|
+
watcher.start();
|
|
235
|
+
this.emit({ type: 'started', service: this.snapshot(entry) });
|
|
236
|
+
return { ok: true, service: this.snapshot(entry) };
|
|
237
|
+
}
|
|
238
|
+
get(id) {
|
|
239
|
+
const entry = this.services.get(id);
|
|
240
|
+
return entry ? this.snapshot(entry) : undefined;
|
|
241
|
+
}
|
|
242
|
+
list() {
|
|
243
|
+
return [...this.services.values()].map((e) => this.snapshot(e));
|
|
244
|
+
}
|
|
245
|
+
read(id, since) {
|
|
246
|
+
const entry = this.services.get(id);
|
|
247
|
+
if (!entry)
|
|
248
|
+
return undefined;
|
|
249
|
+
return entry.ring.page(since);
|
|
250
|
+
}
|
|
251
|
+
liveCount() {
|
|
252
|
+
let n = 0;
|
|
253
|
+
for (const entry of this.services.values()) {
|
|
254
|
+
if (!isTerminalStatus(entry.record.status))
|
|
255
|
+
n += 1;
|
|
256
|
+
}
|
|
257
|
+
return n;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Stop one service, or every service.
|
|
261
|
+
*
|
|
262
|
+
* `SIGTERM` FIRST, `SIGKILL` AFTER `stopGraceMs` — the graceful ladder, which
|
|
263
|
+
* belongs here rather than in `reapSync` because this path has a live event
|
|
264
|
+
* loop to run it on. It then CHECKS `process.kill(pid, 0)` and records
|
|
265
|
+
* `killIncomplete` when the pid is still there (P2-6): an honest warning beats
|
|
266
|
+
* a silent leak, and `killTree` cannot know on its own whether it worked.
|
|
267
|
+
*/
|
|
268
|
+
async stop(id) {
|
|
269
|
+
const targets = id === 'all'
|
|
270
|
+
? [...this.services.values()].filter((e) => !isTerminalStatus(e.record.status))
|
|
271
|
+
: (() => {
|
|
272
|
+
const one = this.services.get(id);
|
|
273
|
+
return one && !isTerminalStatus(one.record.status) ? [one] : [];
|
|
274
|
+
})();
|
|
275
|
+
if (targets.length === 0) {
|
|
276
|
+
const one = this.services.get(id);
|
|
277
|
+
return one ? [this.snapshot(one)] : [];
|
|
278
|
+
}
|
|
279
|
+
await Promise.all(targets.map((entry) => this.stopOne(entry)));
|
|
280
|
+
return targets.map((entry) => this.snapshot(entry));
|
|
281
|
+
}
|
|
282
|
+
stopOne(entry) {
|
|
283
|
+
const { record } = entry;
|
|
284
|
+
return new Promise((resolve) => {
|
|
285
|
+
if (isTerminalStatus(record.status)) {
|
|
286
|
+
resolve();
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
entry.waiters.push(resolve);
|
|
290
|
+
// BEFORE THE KILL, so the child's own `'exit'` handler - which was
|
|
291
|
+
// registered first and therefore always wins - reports this as `stopped`.
|
|
292
|
+
entry.stopRequested = true;
|
|
293
|
+
killTree(record.pid, 'SIGTERM');
|
|
294
|
+
entry.escalation = setTimeout(() => {
|
|
295
|
+
if (isTerminalStatus(record.status))
|
|
296
|
+
return;
|
|
297
|
+
killTree(record.pid, 'SIGKILL');
|
|
298
|
+
// Give the SIGKILL one tick to land before declaring the outcome, then
|
|
299
|
+
// settle whether or not `exit` fired: a promise that never resolves is
|
|
300
|
+
// the failure this whole feature exists to remove.
|
|
301
|
+
const settle = setTimeout(() => {
|
|
302
|
+
if (isTerminalStatus(record.status))
|
|
303
|
+
return;
|
|
304
|
+
record.status = 'stopped';
|
|
305
|
+
record.endedAt = Date.now();
|
|
306
|
+
if (isAlive(record.pid))
|
|
307
|
+
record.killIncomplete = true;
|
|
308
|
+
this.finish(entry, 'stopped');
|
|
309
|
+
}, 250);
|
|
310
|
+
settle.unref?.();
|
|
311
|
+
}, PROC_LIMITS.stopGraceMs);
|
|
312
|
+
entry.escalation.unref?.();
|
|
313
|
+
// NO SECOND `'exit'` LISTENER HERE. The one `start()` registered already
|
|
314
|
+
// reads `stopRequested` and resolves every waiter through `finish`; a
|
|
315
|
+
// second listener could only ever run after it and would be dead code that
|
|
316
|
+
// reads as the thing deciding the status.
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
// -----------------------------------------------------------------------
|
|
320
|
+
// Exit reaping (G5 / I-9)
|
|
321
|
+
// -----------------------------------------------------------------------
|
|
322
|
+
/**
|
|
323
|
+
* SYNCHRONOUS, TOTAL, IDEMPOTENT. The only entry point a signal hook,
|
|
324
|
+
* `process.on('exit')` or `handleFatal` may call.
|
|
325
|
+
*
|
|
326
|
+
* No escalation ladder, no promises, no logging: a reaper that can block or
|
|
327
|
+
* throw is worse than one that misses a child. It reaps FOREGROUND children
|
|
328
|
+
* too, because a `bash` call in flight at quit time is a process nobody else
|
|
329
|
+
* will ever kill.
|
|
330
|
+
*/
|
|
331
|
+
reapSync() {
|
|
332
|
+
for (const pid of [...this.foreground]) {
|
|
333
|
+
killTreeSync(pid);
|
|
334
|
+
this.foreground.delete(pid);
|
|
335
|
+
}
|
|
336
|
+
for (const entry of this.services.values()) {
|
|
337
|
+
if (isTerminalStatus(entry.record.status))
|
|
338
|
+
continue;
|
|
339
|
+
killTreeSync(entry.record.pid);
|
|
340
|
+
entry.record.status = 'stopped';
|
|
341
|
+
entry.record.endedAt = Date.now();
|
|
342
|
+
entry.watcher?.stop();
|
|
343
|
+
if (entry.outputTimer)
|
|
344
|
+
clearTimeout(entry.outputTimer);
|
|
345
|
+
if (entry.escalation)
|
|
346
|
+
clearTimeout(entry.escalation);
|
|
347
|
+
entry.outputTimer = undefined;
|
|
348
|
+
entry.escalation = undefined;
|
|
349
|
+
for (const wake of entry.waiters.splice(0))
|
|
350
|
+
wake();
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
dispose() {
|
|
354
|
+
this.disposed = true;
|
|
355
|
+
for (const entry of this.services.values()) {
|
|
356
|
+
entry.watcher?.stop();
|
|
357
|
+
if (entry.outputTimer)
|
|
358
|
+
clearTimeout(entry.outputTimer);
|
|
359
|
+
if (entry.escalation)
|
|
360
|
+
clearTimeout(entry.escalation);
|
|
361
|
+
entry.outputTimer = undefined;
|
|
362
|
+
entry.escalation = undefined;
|
|
363
|
+
}
|
|
364
|
+
this.listeners.clear();
|
|
365
|
+
}
|
|
366
|
+
// -----------------------------------------------------------------------
|
|
367
|
+
// Events
|
|
368
|
+
// -----------------------------------------------------------------------
|
|
369
|
+
subscribe(listener) {
|
|
370
|
+
this.listeners.add(listener);
|
|
371
|
+
return () => {
|
|
372
|
+
this.listeners.delete(listener);
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* EVERY LISTENER IS WRAPPED, and that is not defensive noise (I-5). This runs
|
|
377
|
+
* inside `child.stdout.on('data')`, so a throw here would kill a ten-minute
|
|
378
|
+
* build because of a render bug. `debug` rather than `warn` because a listener
|
|
379
|
+
* that throws once throws on every chunk — the same rule, the same reason, as
|
|
380
|
+
* `AgentController.emitToolOutput`.
|
|
381
|
+
*/
|
|
382
|
+
emit(event) {
|
|
383
|
+
for (const listener of this.listeners) {
|
|
384
|
+
try {
|
|
385
|
+
listener(event);
|
|
386
|
+
}
|
|
387
|
+
catch (err) {
|
|
388
|
+
getLogger().debug('tool', 'proc_listener_threw', {
|
|
389
|
+
type: event.type,
|
|
390
|
+
service: event.service.id,
|
|
391
|
+
error: err instanceof Error ? err.message : String(err),
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
/** Coalesce `output` at the SOURCE — `/bg` and `bash_output` read it too. */
|
|
397
|
+
scheduleOutput(entry) {
|
|
398
|
+
if (entry.outputTimer)
|
|
399
|
+
return;
|
|
400
|
+
entry.outputTimer = setTimeout(() => {
|
|
401
|
+
entry.outputTimer = undefined;
|
|
402
|
+
this.emit({ type: 'output', service: this.snapshot(entry) });
|
|
403
|
+
}, PROC_LIMITS.outputCoalesceMs);
|
|
404
|
+
entry.outputTimer.unref?.();
|
|
405
|
+
}
|
|
406
|
+
/** Release every timer, wake every waiter, and announce the terminal state. */
|
|
407
|
+
finish(entry, type) {
|
|
408
|
+
entry.watcher?.stop();
|
|
409
|
+
if (entry.outputTimer) {
|
|
410
|
+
clearTimeout(entry.outputTimer);
|
|
411
|
+
entry.outputTimer = undefined;
|
|
412
|
+
}
|
|
413
|
+
if (entry.escalation) {
|
|
414
|
+
clearTimeout(entry.escalation);
|
|
415
|
+
entry.escalation = undefined;
|
|
416
|
+
}
|
|
417
|
+
entry.record.rows = entry.ring.tail(PROC_LIMITS.cardTailRows);
|
|
418
|
+
entry.record.rowsSeen = entry.ring.rowsSeen;
|
|
419
|
+
entry.record.truncated = entry.ring.truncated;
|
|
420
|
+
this.emit({ type, service: this.snapshot(entry) });
|
|
421
|
+
for (const wake of entry.waiters.splice(0))
|
|
422
|
+
wake();
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Evict TERMINAL records only, oldest first, to make room.
|
|
426
|
+
*
|
|
427
|
+
* IDS ARE NEVER REUSED (I-6): `nextId` only ever increases, so a stale
|
|
428
|
+
* `bash_kill({ service: 's1' })` can only be a no-op and never a kill of
|
|
429
|
+
* something else.
|
|
430
|
+
*/
|
|
431
|
+
evictTerminal() {
|
|
432
|
+
if (this.services.size < PROC_LIMITS.maxServices)
|
|
433
|
+
return;
|
|
434
|
+
for (const [id, entry] of this.services) {
|
|
435
|
+
if (this.services.size < PROC_LIMITS.maxServices)
|
|
436
|
+
break;
|
|
437
|
+
if (isTerminalStatus(entry.record.status))
|
|
438
|
+
this.services.delete(id);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
snapshot(entry) {
|
|
442
|
+
const r = entry.record;
|
|
443
|
+
return Object.freeze({
|
|
444
|
+
id: r.id,
|
|
445
|
+
toolCallId: r.toolCallId,
|
|
446
|
+
command: r.command,
|
|
447
|
+
cwd: r.cwd,
|
|
448
|
+
pid: r.pid,
|
|
449
|
+
status: r.status,
|
|
450
|
+
startedAt: r.startedAt,
|
|
451
|
+
...(r.readyAt !== undefined ? { readyAt: r.readyAt } : {}),
|
|
452
|
+
...(r.endedAt !== undefined ? { endedAt: r.endedAt } : {}),
|
|
453
|
+
exitCode: r.exitCode,
|
|
454
|
+
signal: r.signal,
|
|
455
|
+
...(r.url !== undefined ? { url: r.url } : {}),
|
|
456
|
+
...(r.port !== undefined ? { port: r.port } : {}),
|
|
457
|
+
...(r.detectedBy !== undefined ? { detectedBy: r.detectedBy } : {}),
|
|
458
|
+
...(r.portPreoccupied ? { portPreoccupied: true } : {}),
|
|
459
|
+
rows: r.rows,
|
|
460
|
+
rowsSeen: r.rowsSeen,
|
|
461
|
+
truncated: r.truncated,
|
|
462
|
+
...(r.killIncomplete ? { killIncomplete: true } : {}),
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime shapes for the process supervisor (background-service-supervision §6).
|
|
3
|
+
*
|
|
4
|
+
* ASCII ONLY: `src/proc/**` is inside the glyph scanner's scope.
|
|
5
|
+
*
|
|
6
|
+
* `ProcEvent` is CLI-LOCAL and is deliberately NOT a member of core's
|
|
7
|
+
* `AgentEvent` union, exactly as `TeamEvent` and `TodoEvent` are not: core
|
|
8
|
+
* freezes its runtime export list (`public-api.test.ts`) and forbids host
|
|
9
|
+
* coupling (`no-host-coupling.test.ts`), so a new event member would break the
|
|
10
|
+
* first and teach core about a host shape it has no business defining.
|
|
11
|
+
*/
|
|
12
|
+
export type ServiceStatus =
|
|
13
|
+
/** Spawned, not yet ready, still alive. */
|
|
14
|
+
'starting'
|
|
15
|
+
/** A loopback URL was printed, or the hinted port answered. */
|
|
16
|
+
| 'ready'
|
|
17
|
+
/**
|
|
18
|
+
* Alive past `readyTimeoutMs` with nothing to detect.
|
|
19
|
+
*
|
|
20
|
+
* A REAL STATUS, NOT A FALLBACK (D-4). `tsc --watch` and `nodemon` listen on
|
|
21
|
+
* nothing; calling them `ready` would be a lie and leaving them `starting`
|
|
22
|
+
* forever would be worse — and worse in a way that costs, because `starting`
|
|
23
|
+
* is the one status that blocks the transcript's settled boundary.
|
|
24
|
+
*/
|
|
25
|
+
| 'running'
|
|
26
|
+
/** The process ended on its own. */
|
|
27
|
+
| 'exited'
|
|
28
|
+
/** Spawn itself failed. */
|
|
29
|
+
| 'failed'
|
|
30
|
+
/** We killed it (Ctrl+C, `bash_kill`, `/bg stop`, exit). */
|
|
31
|
+
| 'stopped';
|
|
32
|
+
/** A status from which nothing further will happen. */
|
|
33
|
+
export declare function isTerminalStatus(status: ServiceStatus): boolean;
|
|
34
|
+
export interface ServiceRecord {
|
|
35
|
+
/** `s1`, `s2`, ... Short because the model has to quote it back. */
|
|
36
|
+
id: string;
|
|
37
|
+
/** The `bash` call that started it. */
|
|
38
|
+
toolCallId: string;
|
|
39
|
+
command: string;
|
|
40
|
+
cwd: string;
|
|
41
|
+
pid: number | undefined;
|
|
42
|
+
status: ServiceStatus;
|
|
43
|
+
startedAt: number;
|
|
44
|
+
readyAt?: number;
|
|
45
|
+
endedAt?: number;
|
|
46
|
+
exitCode: number | null;
|
|
47
|
+
signal: NodeJS.Signals | null;
|
|
48
|
+
url?: string;
|
|
49
|
+
port?: number;
|
|
50
|
+
detectedBy?: 'url' | 'probe';
|
|
51
|
+
/** The pre-flight probe already answered, so the port detector is disabled. */
|
|
52
|
+
portPreoccupied?: boolean;
|
|
53
|
+
/** Bounded, sanitised ring. */
|
|
54
|
+
rows: readonly string[];
|
|
55
|
+
/**
|
|
56
|
+
* MONOTONIC row cursor, for `bash_output({ since })` AND for the transcript's
|
|
57
|
+
* `entryRevision` term.
|
|
58
|
+
*
|
|
59
|
+
* NEVER `rows.length`: the ring evicts its oldest row while appending a new
|
|
60
|
+
* one, so the joined length can be identical across two different tails. That
|
|
61
|
+
* is verbatim the non-append mutation `virtual-window.ts::entryRevision`'s
|
|
62
|
+
* I-L3-1 comment forbids leaving out — a length term would go on matching
|
|
63
|
+
* while the card changed underneath it.
|
|
64
|
+
*/
|
|
65
|
+
rowsSeen: number;
|
|
66
|
+
/** The ring has dropped at least one row. */
|
|
67
|
+
truncated: boolean;
|
|
68
|
+
/** A stop reported the pid still alive afterwards (R-2 / P2-6). */
|
|
69
|
+
killIncomplete?: boolean;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The frozen, JSON-serialisable projection listeners receive.
|
|
73
|
+
*
|
|
74
|
+
* LISTENERS GET SNAPSHOTS, NEVER THE RECORD, so a render-side bug cannot mutate
|
|
75
|
+
* supervisor state. `rows` carries only the last `PROC_LIMITS.cardTailRows`.
|
|
76
|
+
*/
|
|
77
|
+
export interface ServiceSnapshot {
|
|
78
|
+
id: string;
|
|
79
|
+
toolCallId: string;
|
|
80
|
+
command: string;
|
|
81
|
+
cwd: string;
|
|
82
|
+
pid: number | undefined;
|
|
83
|
+
status: ServiceStatus;
|
|
84
|
+
startedAt: number;
|
|
85
|
+
readyAt?: number;
|
|
86
|
+
endedAt?: number;
|
|
87
|
+
exitCode: number | null;
|
|
88
|
+
signal: NodeJS.Signals | null;
|
|
89
|
+
url?: string;
|
|
90
|
+
port?: number;
|
|
91
|
+
detectedBy?: 'url' | 'probe';
|
|
92
|
+
portPreoccupied?: boolean;
|
|
93
|
+
rows: readonly string[];
|
|
94
|
+
rowsSeen: number;
|
|
95
|
+
truncated: boolean;
|
|
96
|
+
killIncomplete?: boolean;
|
|
97
|
+
}
|
|
98
|
+
export type ProcEvent = {
|
|
99
|
+
type: 'started';
|
|
100
|
+
service: ServiceSnapshot;
|
|
101
|
+
}
|
|
102
|
+
/** Coalesced at `PROC_LIMITS.outputCoalesceMs` inside the supervisor. */
|
|
103
|
+
| {
|
|
104
|
+
type: 'output';
|
|
105
|
+
service: ServiceSnapshot;
|
|
106
|
+
} | {
|
|
107
|
+
type: 'ready';
|
|
108
|
+
service: ServiceSnapshot;
|
|
109
|
+
} | {
|
|
110
|
+
type: 'exited';
|
|
111
|
+
service: ServiceSnapshot;
|
|
112
|
+
} | {
|
|
113
|
+
type: 'stopped';
|
|
114
|
+
service: ServiceSnapshot;
|
|
115
|
+
};
|
|
116
|
+
export type ProcEventListener = (event: ProcEvent) => void;
|
|
117
|
+
export interface ServiceStartRequest {
|
|
118
|
+
command: string;
|
|
119
|
+
cwd: string;
|
|
120
|
+
toolCallId: string;
|
|
121
|
+
}
|
|
122
|
+
export interface ServiceStartResult {
|
|
123
|
+
ok: boolean;
|
|
124
|
+
/** Present when `ok`. */
|
|
125
|
+
service?: ServiceSnapshot;
|
|
126
|
+
/** Present when not `ok` — already model-facing prose. */
|
|
127
|
+
error?: string;
|
|
128
|
+
}
|
|
129
|
+
/** What `bash_output({ service, since })` reads back. */
|
|
130
|
+
export interface ServiceLogPage {
|
|
131
|
+
rows: readonly string[];
|
|
132
|
+
/** Cursor to pass as the next `since`. */
|
|
133
|
+
cursor: number;
|
|
134
|
+
/** Rows were evicted between `since` and what is returned. */
|
|
135
|
+
truncated: boolean;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* The port `bash`, the tools, the controller and `/bg` see.
|
|
139
|
+
*
|
|
140
|
+
* A PORT RATHER THAN THE CLASS so `tools/` keeps no import edge into `proc/`'s
|
|
141
|
+
* implementation, which is the same discipline `ToolDeps` already follows for
|
|
142
|
+
* the diff and live-output side channels.
|
|
143
|
+
*/
|
|
144
|
+
export interface ProcSupervisorPort {
|
|
145
|
+
start(req: ServiceStartRequest): Promise<ServiceStartResult>;
|
|
146
|
+
get(id: string): ServiceSnapshot | undefined;
|
|
147
|
+
list(): ServiceSnapshot[];
|
|
148
|
+
/** `id`, or `'all'`. Resolves once every target is terminal or the grace ran out. */
|
|
149
|
+
stop(id: string): Promise<ServiceSnapshot[]>;
|
|
150
|
+
read(id: string, since?: number): ServiceLogPage | undefined;
|
|
151
|
+
subscribe(listener: ProcEventListener): () => void;
|
|
152
|
+
/**
|
|
153
|
+
* Track a FOREGROUND `bash` child so `forceStop()` can reach it.
|
|
154
|
+
*
|
|
155
|
+
* Returns a release function the tool calls from its own `settle`. One
|
|
156
|
+
* registry, two lifetimes: splitting foreground children and services into two
|
|
157
|
+
* registries is how a force-stop ends up killing the user's dev server, or how
|
|
158
|
+
* a quit leaves it running (I-4).
|
|
159
|
+
*/
|
|
160
|
+
trackForeground(pid: number | undefined): () => void;
|
|
161
|
+
/** Hard-kill every tracked FOREGROUND child. Never touches services (I-4). */
|
|
162
|
+
killForeground(reason: 'force'): number;
|
|
163
|
+
/** Live services: `starting | ready | running`. */
|
|
164
|
+
liveCount(): number;
|
|
165
|
+
/**
|
|
166
|
+
* SYNCHRONOUS, TOTAL reaper — the only thing a signal hook,
|
|
167
|
+
* `process.on('exit')` or `handleFatal` may call (I-9).
|
|
168
|
+
*
|
|
169
|
+
* It neither awaits, spawns asynchronously, nor throws, and it is idempotent:
|
|
170
|
+
* a normal quit calls it twice.
|
|
171
|
+
*/
|
|
172
|
+
reapSync(): void;
|
|
173
|
+
/** Release every timer/socket. Idempotent. */
|
|
174
|
+
dispose(): void;
|
|
175
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime shapes for the process supervisor (background-service-supervision §6).
|
|
3
|
+
*
|
|
4
|
+
* ASCII ONLY: `src/proc/**` is inside the glyph scanner's scope.
|
|
5
|
+
*
|
|
6
|
+
* `ProcEvent` is CLI-LOCAL and is deliberately NOT a member of core's
|
|
7
|
+
* `AgentEvent` union, exactly as `TeamEvent` and `TodoEvent` are not: core
|
|
8
|
+
* freezes its runtime export list (`public-api.test.ts`) and forbids host
|
|
9
|
+
* coupling (`no-host-coupling.test.ts`), so a new event member would break the
|
|
10
|
+
* first and teach core about a host shape it has no business defining.
|
|
11
|
+
*/
|
|
12
|
+
/** A status from which nothing further will happen. */
|
|
13
|
+
export function isTerminalStatus(status) {
|
|
14
|
+
return status === 'exited' || status === 'failed' || status === 'stopped';
|
|
15
|
+
}
|