@aragon-agent/cli 0.6.3 → 0.6.7

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.
Files changed (138) hide show
  1. package/CHANGELOG.md +142 -0
  2. package/README.md +203 -13
  3. package/dist/agent/controller.d.ts +158 -2
  4. package/dist/agent/controller.js +400 -1
  5. package/dist/agent/reducer.d.ts +102 -19
  6. package/dist/agent/reducer.js +162 -71
  7. package/dist/agent/system-prompt.d.ts +14 -0
  8. package/dist/agent/system-prompt.js +2 -0
  9. package/dist/agent/usage.d.ts +34 -0
  10. package/dist/agent/usage.js +43 -0
  11. package/dist/cli.js +132 -23
  12. package/dist/commands/builtins.js +101 -1
  13. package/dist/compaction/child.js +5 -0
  14. package/dist/compaction/command.js +14 -1
  15. package/dist/compaction/compactor.d.ts +94 -24
  16. package/dist/compaction/compactor.js +247 -98
  17. package/dist/compaction/context-command.d.ts +33 -0
  18. package/dist/compaction/context-command.js +166 -0
  19. package/dist/compaction/meter.d.ts +251 -0
  20. package/dist/compaction/meter.js +428 -0
  21. package/dist/compaction/pressure.d.ts +6 -0
  22. package/dist/compaction/pressure.js +4 -0
  23. package/dist/compaction/types.d.ts +77 -0
  24. package/dist/compaction/wiring.d.ts +56 -0
  25. package/dist/compaction/wiring.js +107 -25
  26. package/dist/config/cli-commands.js +5 -0
  27. package/dist/config/env.d.ts +1 -1
  28. package/dist/config/env.js +63 -2
  29. package/dist/config/load.d.ts +28 -0
  30. package/dist/config/load.js +83 -1
  31. package/dist/config/schema.d.ts +129 -0
  32. package/dist/config/schema.js +69 -0
  33. package/dist/config/store.js +47 -1
  34. package/dist/exec/events.d.ts +138 -1
  35. package/dist/exec/events.js +42 -0
  36. package/dist/exec/index.js +43 -6
  37. package/dist/exec/progress-reporter.d.ts +23 -0
  38. package/dist/exec/progress-reporter.js +88 -0
  39. package/dist/exec/runner.d.ts +34 -0
  40. package/dist/exec/runner.js +137 -25
  41. package/dist/fast/reviewer.d.ts +19 -1
  42. package/dist/fast/reviewer.js +22 -0
  43. package/dist/fast/types.d.ts +15 -0
  44. package/dist/fast/wiring.d.ts +10 -1
  45. package/dist/fast/wiring.js +18 -1
  46. package/dist/input/limits.d.ts +72 -0
  47. package/dist/input/limits.js +63 -0
  48. package/dist/input/paste-parse.d.ts +71 -0
  49. package/dist/input/paste-parse.js +95 -0
  50. package/dist/input/stdin-filter.d.ts +81 -0
  51. package/dist/input/stdin-filter.js +379 -0
  52. package/dist/logging/install.d.ts +2 -0
  53. package/dist/logging/install.js +53 -1
  54. package/dist/proc/classify.d.ts +52 -0
  55. package/dist/proc/classify.js +143 -0
  56. package/dist/proc/kill-tree.d.ts +42 -0
  57. package/dist/proc/kill-tree.js +119 -0
  58. package/dist/proc/limits.d.ts +84 -0
  59. package/dist/proc/limits.js +84 -0
  60. package/dist/proc/log-ring.d.ts +68 -0
  61. package/dist/proc/log-ring.js +102 -0
  62. package/dist/proc/prompt.d.ts +24 -0
  63. package/dist/proc/prompt.js +49 -0
  64. package/dist/proc/readiness.d.ts +102 -0
  65. package/dist/proc/readiness.js +219 -0
  66. package/dist/proc/supervisor.d.ts +103 -0
  67. package/dist/proc/supervisor.js +465 -0
  68. package/dist/proc/types.d.ts +175 -0
  69. package/dist/proc/types.js +15 -0
  70. package/dist/session/persist.js +36 -1
  71. package/dist/todo/limits.d.ts +4 -17
  72. package/dist/todo/limits.js +4 -17
  73. package/dist/todo/panel-rows.d.ts +8 -6
  74. package/dist/todo/panel-rows.js +19 -6
  75. package/dist/tools/bash-tool.d.ts +51 -2
  76. package/dist/tools/bash-tool.js +321 -51
  77. package/dist/tools/fs-tools.d.ts +17 -0
  78. package/dist/tools/index.d.ts +55 -2
  79. package/dist/tools/index.js +39 -1
  80. package/dist/tools/proc-tools.d.ts +25 -0
  81. package/dist/tools/proc-tools.js +102 -0
  82. package/dist/ui/App.d.ts +15 -2
  83. package/dist/ui/App.js +330 -69
  84. package/dist/ui/AutocompletePopup.d.ts +3 -1
  85. package/dist/ui/AutocompletePopup.js +9 -11
  86. package/dist/ui/BottomStatusRow.d.ts +1 -1
  87. package/dist/ui/Composer.d.ts +56 -1
  88. package/dist/ui/Composer.js +50 -6
  89. package/dist/ui/PromptInput.d.ts +52 -3
  90. package/dist/ui/PromptInput.js +201 -55
  91. package/dist/ui/StatusBar.d.ts +40 -15
  92. package/dist/ui/StatusBar.js +51 -9
  93. package/dist/ui/TeamPanel.d.ts +3 -1
  94. package/dist/ui/TeamPanel.js +13 -9
  95. package/dist/ui/TodoPanel.d.ts +5 -5
  96. package/dist/ui/TodoPanel.js +56 -24
  97. package/dist/ui/Transcript.js +29 -1
  98. package/dist/ui/UpdateLine.d.ts +1 -1
  99. package/dist/ui/capabilities.js +5 -5
  100. package/dist/ui/composer-limits.d.ts +31 -0
  101. package/dist/ui/composer-limits.js +50 -0
  102. package/dist/ui/composer-rows.d.ts +73 -0
  103. package/dist/ui/composer-rows.js +201 -0
  104. package/dist/ui/editor-reducer.d.ts +37 -0
  105. package/dist/ui/editor-reducer.js +88 -12
  106. package/dist/ui/entries/ServiceCard.d.ts +66 -0
  107. package/dist/ui/entries/ServiceCard.js +116 -0
  108. package/dist/ui/entries/UserEntry.d.ts +17 -3
  109. package/dist/ui/entries/UserEntry.js +34 -5
  110. package/dist/ui/exit-snapshot.d.ts +9 -0
  111. package/dist/ui/layout/ScrollViewport.d.ts +1 -1
  112. package/dist/ui/layout/autocomplete.d.ts +18 -0
  113. package/dist/ui/layout/autocomplete.js +26 -0
  114. package/dist/ui/layout/budget.d.ts +34 -6
  115. package/dist/ui/layout/budget.js +32 -7
  116. package/dist/ui/layout/rail.d.ts +4 -53
  117. package/dist/ui/layout/rail.js +10 -62
  118. package/dist/ui/layout/team-panel.d.ts +18 -0
  119. package/dist/ui/layout/team-panel.js +22 -0
  120. package/dist/ui/layout/todo-layout.d.ts +24 -0
  121. package/dist/ui/layout/todo-layout.js +27 -0
  122. package/dist/ui/layout/virtual-window.js +51 -1
  123. package/dist/ui/overlays/HelpOverlay.js +3 -0
  124. package/dist/ui/overlays/PlanReviewOverlay.js +10 -2
  125. package/dist/ui/overlays/QuestionOverlay.js +5 -1
  126. package/dist/ui/overlays/SettingsScreen.js +14 -1
  127. package/dist/ui/paste-frames.d.ts +87 -0
  128. package/dist/ui/paste-frames.js +112 -0
  129. package/dist/ui/paste-tokens.d.ts +90 -0
  130. package/dist/ui/paste-tokens.js +147 -0
  131. package/dist/ui/screen.d.ts +16 -0
  132. package/dist/ui/screen.js +45 -2
  133. package/dist/ui/selection/selection-controller.d.ts +1 -1
  134. package/dist/ui/transcript-text.js +55 -2
  135. package/dist/ui/use-wheel-routing.d.ts +1 -1
  136. package/package.json +2 -2
  137. package/dist/input/stdin-mouse-filter.d.ts +0 -47
  138. 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
+ }