@aragon-agent/cli 0.6.2 → 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.
Files changed (167) hide show
  1. package/CHANGELOG.md +321 -0
  2. package/README.md +297 -2
  3. package/dist/agent/controller.d.ts +193 -1
  4. package/dist/agent/controller.js +475 -2
  5. package/dist/agent/headless.d.ts +8 -0
  6. package/dist/agent/headless.js +47 -0
  7. package/dist/agent/reducer.d.ts +189 -0
  8. package/dist/agent/reducer.js +301 -2
  9. package/dist/agent/system-prompt.d.ts +14 -0
  10. package/dist/agent/system-prompt.js +2 -0
  11. package/dist/cli.js +297 -16
  12. package/dist/commands/builtins.js +165 -24
  13. package/dist/commands/registry.d.ts +27 -0
  14. package/dist/compaction/archive.d.ts +175 -0
  15. package/dist/compaction/archive.js +267 -0
  16. package/dist/compaction/child.d.ts +97 -0
  17. package/dist/compaction/child.js +118 -0
  18. package/dist/compaction/command.d.ts +41 -0
  19. package/dist/compaction/command.js +355 -0
  20. package/dist/compaction/compactor.d.ts +302 -0
  21. package/dist/compaction/compactor.js +810 -0
  22. package/dist/compaction/digest.d.ts +62 -0
  23. package/dist/compaction/digest.js +164 -0
  24. package/dist/compaction/limits.d.ts +165 -0
  25. package/dist/compaction/limits.js +172 -0
  26. package/dist/compaction/pressure.d.ts +135 -0
  27. package/dist/compaction/pressure.js +167 -0
  28. package/dist/compaction/summarize-call.d.ts +70 -0
  29. package/dist/compaction/summarize-call.js +86 -0
  30. package/dist/compaction/summary-prompt.d.ts +85 -0
  31. package/dist/compaction/summary-prompt.js +197 -0
  32. package/dist/compaction/tail-budget.d.ts +52 -0
  33. package/dist/compaction/tail-budget.js +67 -0
  34. package/dist/compaction/types.d.ts +215 -0
  35. package/dist/compaction/types.js +22 -0
  36. package/dist/compaction/wiring.d.ts +264 -0
  37. package/dist/compaction/wiring.js +577 -0
  38. package/dist/config/app-paths.d.ts +14 -0
  39. package/dist/config/app-paths.js +16 -0
  40. package/dist/config/cli-commands.d.ts +41 -0
  41. package/dist/config/cli-commands.js +77 -1
  42. package/dist/config/env.d.ts +5 -2
  43. package/dist/config/env.js +140 -3
  44. package/dist/config/load.d.ts +42 -0
  45. package/dist/config/load.js +101 -1
  46. package/dist/config/schema.d.ts +244 -0
  47. package/dist/config/schema.js +170 -0
  48. package/dist/config/store.js +37 -1
  49. package/dist/config/ui-state.d.ts +28 -1
  50. package/dist/config/ui-state.js +9 -0
  51. package/dist/exec/events.d.ts +50 -1
  52. package/dist/exec/runner.js +42 -0
  53. package/dist/input/limits.d.ts +72 -0
  54. package/dist/input/limits.js +63 -0
  55. package/dist/input/mouse-events.d.ts +31 -5
  56. package/dist/input/mouse-events.js +86 -27
  57. package/dist/input/paste-parse.d.ts +71 -0
  58. package/dist/input/paste-parse.js +95 -0
  59. package/dist/input/stdin-filter.d.ts +81 -0
  60. package/dist/input/stdin-filter.js +379 -0
  61. package/dist/logging/install.d.ts +53 -1
  62. package/dist/logging/install.js +81 -1
  63. package/dist/logging/logger.d.ts +1 -1
  64. package/dist/proc/classify.d.ts +52 -0
  65. package/dist/proc/classify.js +143 -0
  66. package/dist/proc/kill-tree.d.ts +42 -0
  67. package/dist/proc/kill-tree.js +119 -0
  68. package/dist/proc/limits.d.ts +84 -0
  69. package/dist/proc/limits.js +84 -0
  70. package/dist/proc/log-ring.d.ts +68 -0
  71. package/dist/proc/log-ring.js +102 -0
  72. package/dist/proc/prompt.d.ts +24 -0
  73. package/dist/proc/prompt.js +49 -0
  74. package/dist/proc/readiness.d.ts +102 -0
  75. package/dist/proc/readiness.js +219 -0
  76. package/dist/proc/supervisor.d.ts +103 -0
  77. package/dist/proc/supervisor.js +465 -0
  78. package/dist/proc/types.d.ts +175 -0
  79. package/dist/proc/types.js +15 -0
  80. package/dist/session/persist.js +55 -1
  81. package/dist/team/report.js +7 -1
  82. package/dist/team/runtime.d.ts +10 -0
  83. package/dist/team/runtime.js +7 -0
  84. package/dist/team/subagent.d.ts +31 -1
  85. package/dist/team/subagent.js +42 -15
  86. package/dist/team/types.d.ts +8 -0
  87. package/dist/tools/bash-tool.d.ts +51 -2
  88. package/dist/tools/bash-tool.js +321 -51
  89. package/dist/tools/fs-tools.d.ts +17 -0
  90. package/dist/tools/index.d.ts +55 -2
  91. package/dist/tools/index.js +39 -1
  92. package/dist/tools/proc-tools.d.ts +25 -0
  93. package/dist/tools/proc-tools.js +102 -0
  94. package/dist/ui/ActivityLine.d.ts +19 -1
  95. package/dist/ui/ActivityLine.js +10 -2
  96. package/dist/ui/App.d.ts +62 -2
  97. package/dist/ui/App.js +546 -16
  98. package/dist/ui/Composer.d.ts +66 -1
  99. package/dist/ui/Composer.js +50 -6
  100. package/dist/ui/PromptInput.d.ts +82 -3
  101. package/dist/ui/PromptInput.js +232 -49
  102. package/dist/ui/StatusBar.d.ts +59 -0
  103. package/dist/ui/StatusBar.js +16 -4
  104. package/dist/ui/TeamPanel.js +7 -1
  105. package/dist/ui/Transcript.d.ts +21 -1
  106. package/dist/ui/Transcript.js +61 -3
  107. package/dist/ui/clipboard.d.ts +52 -0
  108. package/dist/ui/clipboard.js +91 -0
  109. package/dist/ui/composer-limits.d.ts +31 -0
  110. package/dist/ui/composer-limits.js +50 -0
  111. package/dist/ui/composer-rows.d.ts +73 -0
  112. package/dist/ui/composer-rows.js +201 -0
  113. package/dist/ui/editor-reducer.d.ts +37 -0
  114. package/dist/ui/editor-reducer.js +88 -12
  115. package/dist/ui/entries/CompactionCard.d.ts +75 -0
  116. package/dist/ui/entries/CompactionCard.js +158 -0
  117. package/dist/ui/entries/ServiceCard.d.ts +66 -0
  118. package/dist/ui/entries/ServiceCard.js +116 -0
  119. package/dist/ui/entries/UserEntry.d.ts +17 -3
  120. package/dist/ui/entries/UserEntry.js +34 -5
  121. package/dist/ui/frame-differ.d.ts +46 -0
  122. package/dist/ui/frame-differ.js +94 -10
  123. package/dist/ui/gauge.d.ts +16 -1
  124. package/dist/ui/gauge.js +25 -6
  125. package/dist/ui/glyphs.d.ts +11 -0
  126. package/dist/ui/glyphs.js +2 -0
  127. package/dist/ui/layout/ScrollViewport.d.ts +43 -6
  128. package/dist/ui/layout/ScrollViewport.js +128 -21
  129. package/dist/ui/layout/budget.d.ts +44 -9
  130. package/dist/ui/layout/budget.js +42 -10
  131. package/dist/ui/layout/follow-state.d.ts +137 -0
  132. package/dist/ui/layout/follow-state.js +150 -0
  133. package/dist/ui/layout/virtual-window.d.ts +11 -0
  134. package/dist/ui/layout/virtual-window.js +85 -1
  135. package/dist/ui/overlays/HelpOverlay.js +9 -1
  136. package/dist/ui/overlays/PlanReviewOverlay.js +10 -2
  137. package/dist/ui/overlays/QuestionOverlay.js +5 -1
  138. package/dist/ui/overlays/SettingsScreen.d.ts +47 -1
  139. package/dist/ui/overlays/SettingsScreen.js +74 -2
  140. package/dist/ui/palettes.d.ts +16 -0
  141. package/dist/ui/palettes.js +6 -0
  142. package/dist/ui/paste-frames.d.ts +87 -0
  143. package/dist/ui/paste-frames.js +112 -0
  144. package/dist/ui/paste-tokens.d.ts +90 -0
  145. package/dist/ui/paste-tokens.js +147 -0
  146. package/dist/ui/screen.d.ts +38 -0
  147. package/dist/ui/screen.js +113 -8
  148. package/dist/ui/selection/highlight.d.ts +39 -0
  149. package/dist/ui/selection/highlight.js +64 -0
  150. package/dist/ui/selection/screen-mirror.d.ts +24 -0
  151. package/dist/ui/selection/screen-mirror.js +35 -0
  152. package/dist/ui/selection/selection-controller.d.ts +88 -0
  153. package/dist/ui/selection/selection-controller.js +282 -0
  154. package/dist/ui/selection/selection.d.ts +90 -0
  155. package/dist/ui/selection/selection.js +223 -0
  156. package/dist/ui/stdout-frame-writer.d.ts +33 -0
  157. package/dist/ui/stdout-frame-writer.js +15 -0
  158. package/dist/ui/theme.d.ts +11 -0
  159. package/dist/ui/theme.js +2 -0
  160. package/dist/ui/transcript-text.js +72 -0
  161. package/dist/ui/use-startup-notices.d.ts +27 -1
  162. package/dist/ui/use-startup-notices.js +43 -11
  163. package/dist/ui/use-wheel-routing.d.ts +1 -1
  164. package/dist/ui/use-wheel-routing.js +8 -0
  165. package/package.json +4 -2
  166. package/dist/input/stdin-mouse-filter.d.ts +0 -42
  167. package/dist/input/stdin-mouse-filter.js +0 -153
@@ -0,0 +1,102 @@
1
+ /**
2
+ * The bounded, sanitised output tail one supervised service keeps
3
+ * (background-service-supervision §4).
4
+ *
5
+ * ASCII ONLY: `src/proc/**` is inside the glyph scanner's scope.
6
+ *
7
+ * DELIBERATELY NOT `tools/tool-output-store.ts` (P2-4), which is the closest
8
+ * existing thing and would be the reflex merge. Three differences, each
9
+ * load-bearing:
10
+ *
11
+ * - that store keeps 8 rows, this keeps 200 — one is what a card DRAWS, the
12
+ * other is what `bash_output` can page back through;
13
+ * - that store is cleared at `tool_execution_end`, and a service outlives by
14
+ * minutes or hours the tool call that started it;
15
+ * - that store has no cursor, and paging is the whole point of `since`.
16
+ *
17
+ * It reuses `sanitizeChunk` for the same reason that store does — the contract
18
+ * is "terminal-safe rows", and no consumer can forget — and it follows the same
19
+ * "two bounds are two different literals" rule.
20
+ */
21
+ import { sanitizeChunk, toDisplayRow } from '../tools/terminal-output.js';
22
+ import { PROC_LIMITS } from './limits.js';
23
+ /**
24
+ * A per-service ring.
25
+ *
26
+ * `rowsSeen` is the MONOTONIC count of completed rows ever appended, which is
27
+ * both the paging cursor and the transcript's revision term. It is deliberately
28
+ * not `rows.length`: the ring evicts while appending, so the length can be
29
+ * identical across two different tails.
30
+ */
31
+ export class LogRing {
32
+ capacity;
33
+ rows = [];
34
+ carry = '';
35
+ seen = 0;
36
+ evicted = 0;
37
+ constructor(capacity = PROC_LIMITS.serviceTailRows) {
38
+ this.capacity = capacity;
39
+ }
40
+ /**
41
+ * Append a raw chunk; returns the rows it completed.
42
+ *
43
+ * The IN-PROGRESS line is NOT a row and is not counted: a command whose output
44
+ * is one continuously-rewritten line (every progress bar there is) would
45
+ * otherwise burn the whole ring on one logical line. It is rendered through
46
+ * `toDisplayRow` by `tail()` instead, so the display still shows it.
47
+ */
48
+ append(chunk) {
49
+ const { rows, carry } = sanitizeChunk(this.carry, chunk);
50
+ this.carry = carry;
51
+ if (rows.length === 0)
52
+ return [];
53
+ this.rows.push(...rows);
54
+ this.seen += rows.length;
55
+ if (this.rows.length > this.capacity) {
56
+ const drop = this.rows.length - this.capacity;
57
+ this.rows.splice(0, drop);
58
+ this.evicted += drop;
59
+ }
60
+ return rows;
61
+ }
62
+ /** The last `n` display rows, in-progress line included when non-empty. */
63
+ tail(n) {
64
+ const live = toDisplayRow(this.carry);
65
+ const all = live.length > 0 ? [...this.rows, live] : this.rows;
66
+ return Object.freeze(all.length > n ? all.slice(-n) : [...all]);
67
+ }
68
+ /** Every completed row plus the in-progress line. */
69
+ all() {
70
+ return this.tail(this.capacity + 1);
71
+ }
72
+ /** Completed rows ever appended — the paging cursor and the revision term. */
73
+ get rowsSeen() {
74
+ return this.seen;
75
+ }
76
+ /** The ring has dropped at least one row. */
77
+ get truncated() {
78
+ return this.evicted > 0;
79
+ }
80
+ /**
81
+ * Rows after cursor `since`, or the whole tail when `since` is omitted.
82
+ *
83
+ * `truncated` reports that the cursor FELL OFF the ring — the caller asked for
84
+ * row 3 and the oldest row still held is 57 — which is a real loss the model
85
+ * is entitled to be told about rather than have silently papered over.
86
+ */
87
+ page(since) {
88
+ const oldest = this.seen - this.rows.length;
89
+ if (since === undefined || since < 0) {
90
+ return { rows: this.all(), cursor: this.seen, truncated: this.truncated };
91
+ }
92
+ const from = Math.max(since, oldest);
93
+ const rows = this.rows.slice(from - oldest);
94
+ const live = toDisplayRow(this.carry);
95
+ const withLive = live.length > 0 ? [...rows, live] : rows;
96
+ return {
97
+ rows: Object.freeze(withLive),
98
+ cursor: this.seen,
99
+ truncated: since < oldest,
100
+ };
101
+ }
102
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The `<background_services>` system-prompt block
3
+ * (background-service-supervision §5.2).
4
+ *
5
+ * ASCII ONLY: `src/proc/**` is inside the glyph scanner's scope, and this string
6
+ * also reaches a model that may be running in a `cmd.exe` terminal.
7
+ *
8
+ * SPLICED CONDITIONALLY by `buildSystemPrompt`, which is what preserves I-2:
9
+ * with `bash.background: false` the prompt is BYTE-IDENTICAL to the pre-feature
10
+ * output for a fixed tool array. An unconditional guidance line anywhere here
11
+ * would quietly break that, `--no-todo`, `--no-team` and `--no-skills` all at
12
+ * once.
13
+ *
14
+ * IT NAMES THE `[note]` SENTENCE VERBATIM. That line is the whole of defence 1
15
+ * as the model experiences it, and telling it what the words mean is the
16
+ * difference between "re-run with background: true" and reaching for
17
+ * `Start-Process` - which is exactly what produced the reported screenshot.
18
+ *
19
+ * `BACKGROUND_SERVICES_BLOCK_VERSION` exists so a change to this wording is
20
+ * greppable from a behaviour report.
21
+ */
22
+ import { BACKGROUND_SERVICES_BLOCK_VERSION } from './limits.js';
23
+ export { BACKGROUND_SERVICES_BLOCK_VERSION };
24
+ export declare function buildBackgroundServicesBlock(): string;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The `<background_services>` system-prompt block
3
+ * (background-service-supervision §5.2).
4
+ *
5
+ * ASCII ONLY: `src/proc/**` is inside the glyph scanner's scope, and this string
6
+ * also reaches a model that may be running in a `cmd.exe` terminal.
7
+ *
8
+ * SPLICED CONDITIONALLY by `buildSystemPrompt`, which is what preserves I-2:
9
+ * with `bash.background: false` the prompt is BYTE-IDENTICAL to the pre-feature
10
+ * output for a fixed tool array. An unconditional guidance line anywhere here
11
+ * would quietly break that, `--no-todo`, `--no-team` and `--no-skills` all at
12
+ * once.
13
+ *
14
+ * IT NAMES THE `[note]` SENTENCE VERBATIM. That line is the whole of defence 1
15
+ * as the model experiences it, and telling it what the words mean is the
16
+ * difference between "re-run with background: true" and reaching for
17
+ * `Start-Process` - which is exactly what produced the reported screenshot.
18
+ *
19
+ * `BACKGROUND_SERVICES_BLOCK_VERSION` exists so a change to this wording is
20
+ * greppable from a behaviour report.
21
+ */
22
+ import { BACKGROUND_SERVICES_BLOCK_VERSION } from './limits.js';
23
+ export { BACKGROUND_SERVICES_BLOCK_VERSION };
24
+ export function buildBackgroundServicesBlock() {
25
+ return [
26
+ '<background_services>',
27
+ 'Some commands never exit on their own: dev servers, watchers, `docker compose up`,',
28
+ '`tail -f`. Running one in the foreground stalls the turn until the tool timeout',
29
+ 'kills it, and you learn nothing.',
30
+ '',
31
+ 'Rules:',
32
+ '- Set `background: true` on `bash` for anything that does not exit on its own.',
33
+ ' The call returns within a few seconds with a service id, a status, and a log tail.',
34
+ '- NEVER detach a command yourself with `Start-Process`, `&`, `nohup` or `screen`.',
35
+ ' A self-detached process cannot be reported on, read from, or stopped.',
36
+ '- A background result is PROVISIONAL. `ready` means a port answered or a URL was',
37
+ ' printed - it does not mean the app works. Verify with `bash_output({ service })`',
38
+ ' AND with an actual request against the URL (for example `curl -s <url>`).',
39
+ '- Read more output at any time with `bash_output({ service, since })`; `since` is the',
40
+ ' cursor returned by the previous call.',
41
+ '- Stop a service with `bash_kill({ service })`, or `bash_kill({ service: "all" })`,',
42
+ ' when you are finished with it - unless the user asked for it to stay up.',
43
+ '- If a foreground result ends with',
44
+ ' `[note] the command exited but a background child is still holding its output stream.`',
45
+ ' then the command detached something. Re-run it with `background: true`.',
46
+ '- Services are stopped when this CLI exits. They do not survive the session.',
47
+ '</background_services>',
48
+ ].join('\n');
49
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Readiness detection (background-service-supervision §3.5).
3
+ *
4
+ * Two detectors, raced, first one wins:
5
+ *
6
+ * - the URL detector, which is PURE and therefore testable without a socket;
7
+ * - the port probe, which is one TCP connect and only runs when the command
8
+ * text named a port.
9
+ *
10
+ * ASCII ONLY: `src/proc/**` is inside the glyph scanner's scope.
11
+ *
12
+ * EVERY TIMER AND SOCKET HERE IS `unref()`d (I-3). Readiness detection must
13
+ * never be the reason the CLI does not exit; only the child processes themselves
14
+ * hold the loop open, and they are reaped on exit.
15
+ */
16
+ /**
17
+ * A loopback/wildcard `http(s)` URL, with an optional port and path.
18
+ *
19
+ * ONE ANCHORED SOURCE OF TRUTH, and written out rather than assembled, because
20
+ * the obvious shorthand is not a regex at all: `[::1]` inside a pattern is a
21
+ * CHARACTER CLASS matching `:` or `1`, and unescaped dots match any character.
22
+ * Both mistakes read as correct.
23
+ */
24
+ export declare const READY_URL_RE: RegExp;
25
+ export interface ReadyUrlMatch {
26
+ /** Normalised so a human can click it; absent for a bare-port phrase. */
27
+ url?: string;
28
+ port?: number;
29
+ }
30
+ /**
31
+ * Normalise a wildcard/IPv6-loopback host to something clickable.
32
+ *
33
+ * `0.0.0.0` means "every interface", which a browser cannot open; `::1` and
34
+ * `[::1]` are correct but unfamiliar. All three become `127.0.0.1`.
35
+ */
36
+ export declare function normalizeHost(url: string): string;
37
+ /**
38
+ * The first readiness candidate in `rows`, or `undefined`.
39
+ *
40
+ * A MATCH IS A CANDIDATE, NOT A VERDICT (P2-2). A loopback URL printed in a
41
+ * banner or a `--help` blurb appears BEFORE anything binds, and a bare pattern
42
+ * would call that service ready in the first 40 ms. Two guards make the trade
43
+ * explicit, and neither lives here:
44
+ *
45
+ * 1. only rows emitted AFTER spawn are scanned (the ring starts empty, so this
46
+ * is free), and
47
+ * 2. when the match yields a PORT, `ReadinessWatcher` confirms with one TCP
48
+ * connect before the status flips.
49
+ *
50
+ * When the match yields no port the URL is accepted as-is: there is nothing to
51
+ * probe, and a hostname-only loopback URL in a banner is rare enough to trade
52
+ * against the complexity of guessing 80 vs 443.
53
+ */
54
+ export declare function detectReadyUrl(rows: readonly string[]): ReadyUrlMatch | undefined;
55
+ /**
56
+ * One TCP connect. Resolves `true` when something answered.
57
+ *
58
+ * Never rejects, and the socket is destroyed on every path — a probe that leaked
59
+ * a socket would hold the event loop open exactly as long as the service runs.
60
+ */
61
+ export declare function probePort(port: number, host?: string, timeoutMs?: 1000): Promise<boolean>;
62
+ export interface ReadinessResult {
63
+ url?: string;
64
+ port?: number;
65
+ detectedBy: 'url' | 'probe';
66
+ }
67
+ export interface ReadinessWatcherOptions {
68
+ /** From `extractPortHint`, and only when the pre-flight probe found it FREE. */
69
+ portHint?: number;
70
+ readyTimeoutMs: number;
71
+ onReady: (result: ReadinessResult) => void;
72
+ /** Alive past `readyTimeoutMs` with nothing detected (`tsc --watch` et al). */
73
+ onTimeout: () => void;
74
+ /** Injected for the tests; production passes nothing. */
75
+ probe?: (port: number) => Promise<boolean>;
76
+ }
77
+ /**
78
+ * Races the URL detector against the port probe and stops at the first hit.
79
+ *
80
+ * ONE SHOT. `stop()` is idempotent and is called from every terminal path, so a
81
+ * service that exits during the window leaves nothing behind.
82
+ */
83
+ export declare class ReadinessWatcher {
84
+ private readonly opts;
85
+ private timer;
86
+ private deadline;
87
+ private finished;
88
+ private probing;
89
+ private readonly probeFn;
90
+ constructor(opts: ReadinessWatcherOptions);
91
+ start(): void;
92
+ /**
93
+ * Offer the rows emitted since the last call.
94
+ *
95
+ * A MATCH WITH A PORT IS CONFIRMED BY ONE CONNECT before the status flips; a
96
+ * refused connect leaves the service `starting` and the row is simply not
97
+ * re-considered on the next call, because the caller passes only NEW rows.
98
+ */
99
+ offerRows(rows: readonly string[]): void;
100
+ stop(): void;
101
+ private settle;
102
+ }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Readiness detection (background-service-supervision §3.5).
3
+ *
4
+ * Two detectors, raced, first one wins:
5
+ *
6
+ * - the URL detector, which is PURE and therefore testable without a socket;
7
+ * - the port probe, which is one TCP connect and only runs when the command
8
+ * text named a port.
9
+ *
10
+ * ASCII ONLY: `src/proc/**` is inside the glyph scanner's scope.
11
+ *
12
+ * EVERY TIMER AND SOCKET HERE IS `unref()`d (I-3). Readiness detection must
13
+ * never be the reason the CLI does not exit; only the child processes themselves
14
+ * hold the loop open, and they are reaped on exit.
15
+ */
16
+ import net from 'node:net';
17
+ import { PROC_LIMITS } from './limits.js';
18
+ /**
19
+ * A loopback/wildcard `http(s)` URL, with an optional port and path.
20
+ *
21
+ * ONE ANCHORED SOURCE OF TRUTH, and written out rather than assembled, because
22
+ * the obvious shorthand is not a regex at all: `[::1]` inside a pattern is a
23
+ * CHARACTER CLASS matching `:` or `1`, and unescaped dots match any character.
24
+ * Both mistakes read as correct.
25
+ */
26
+ export const READY_URL_RE = /https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|::1)(?::(\d{1,5}))?(\/\S*)?/i;
27
+ /**
28
+ * Servers that announce a bare port instead of a URL.
29
+ *
30
+ * A SEPARATE, SMALLER LIST rather than more alternation in the URL pattern: what
31
+ * these produce is a PORT and no URL, which is a different result shape and a
32
+ * different confirmation path (the probe must succeed before anything flips).
33
+ */
34
+ const PORT_PHRASES = Object.freeze([
35
+ /listening on (?:port )?(\d{2,5})\b/i,
36
+ /server running at .*?:(\d{2,5})\b/i,
37
+ /started server on .*?:(\d{2,5})\b/i,
38
+ /\bLocal:\s+\S*?:(\d{2,5})\b/i,
39
+ ]);
40
+ /**
41
+ * Normalise a wildcard/IPv6-loopback host to something clickable.
42
+ *
43
+ * `0.0.0.0` means "every interface", which a browser cannot open; `::1` and
44
+ * `[::1]` are correct but unfamiliar. All three become `127.0.0.1`.
45
+ */
46
+ export function normalizeHost(url) {
47
+ return url
48
+ .replace(/:\/\/0\.0\.0\.0/i, '://127.0.0.1')
49
+ .replace(/:\/\/\[::1\]/i, '://127.0.0.1')
50
+ .replace(/:\/\/::1/i, '://127.0.0.1');
51
+ }
52
+ /**
53
+ * The first readiness candidate in `rows`, or `undefined`.
54
+ *
55
+ * A MATCH IS A CANDIDATE, NOT A VERDICT (P2-2). A loopback URL printed in a
56
+ * banner or a `--help` blurb appears BEFORE anything binds, and a bare pattern
57
+ * would call that service ready in the first 40 ms. Two guards make the trade
58
+ * explicit, and neither lives here:
59
+ *
60
+ * 1. only rows emitted AFTER spawn are scanned (the ring starts empty, so this
61
+ * is free), and
62
+ * 2. when the match yields a PORT, `ReadinessWatcher` confirms with one TCP
63
+ * connect before the status flips.
64
+ *
65
+ * When the match yields no port the URL is accepted as-is: there is nothing to
66
+ * probe, and a hostname-only loopback URL in a banner is rare enough to trade
67
+ * against the complexity of guessing 80 vs 443.
68
+ */
69
+ export function detectReadyUrl(rows) {
70
+ for (const row of rows) {
71
+ const m = READY_URL_RE.exec(row);
72
+ if (m) {
73
+ const url = normalizeHost(m[0]);
74
+ const port = m[1] ? Number.parseInt(m[1], 10) : undefined;
75
+ return port === undefined ? { url } : { url, port };
76
+ }
77
+ }
78
+ for (const row of rows) {
79
+ for (const re of PORT_PHRASES) {
80
+ const m = re.exec(row);
81
+ if (!m || !m[1])
82
+ continue;
83
+ const port = Number.parseInt(m[1], 10);
84
+ if (Number.isFinite(port) && port > 0 && port <= 65535)
85
+ return { port };
86
+ }
87
+ }
88
+ return undefined;
89
+ }
90
+ /**
91
+ * One TCP connect. Resolves `true` when something answered.
92
+ *
93
+ * Never rejects, and the socket is destroyed on every path — a probe that leaked
94
+ * a socket would hold the event loop open exactly as long as the service runs.
95
+ */
96
+ export function probePort(port, host = '127.0.0.1', timeoutMs = PROC_LIMITS.probeSocketTimeoutMs) {
97
+ return new Promise((resolve) => {
98
+ let settled = false;
99
+ const done = (answered) => {
100
+ if (settled)
101
+ return;
102
+ settled = true;
103
+ try {
104
+ socket.destroy();
105
+ }
106
+ catch {
107
+ /* ignore */
108
+ }
109
+ resolve(answered);
110
+ };
111
+ const socket = new net.Socket();
112
+ socket.setTimeout(timeoutMs);
113
+ socket.once('connect', () => done(true));
114
+ socket.once('timeout', () => done(false));
115
+ socket.once('error', () => done(false));
116
+ try {
117
+ socket.connect(port, host);
118
+ socket.unref();
119
+ }
120
+ catch {
121
+ done(false);
122
+ }
123
+ });
124
+ }
125
+ /**
126
+ * Races the URL detector against the port probe and stops at the first hit.
127
+ *
128
+ * ONE SHOT. `stop()` is idempotent and is called from every terminal path, so a
129
+ * service that exits during the window leaves nothing behind.
130
+ */
131
+ export class ReadinessWatcher {
132
+ opts;
133
+ timer;
134
+ deadline;
135
+ finished = false;
136
+ probing = false;
137
+ probeFn;
138
+ constructor(opts) {
139
+ this.opts = opts;
140
+ this.probeFn = opts.probe ?? ((port) => probePort(port));
141
+ }
142
+ start() {
143
+ if (this.finished)
144
+ return;
145
+ this.deadline = setTimeout(() => {
146
+ if (this.finished)
147
+ return;
148
+ this.stop();
149
+ this.opts.onTimeout();
150
+ }, this.opts.readyTimeoutMs);
151
+ this.deadline.unref?.();
152
+ const hint = this.opts.portHint;
153
+ if (hint === undefined)
154
+ return;
155
+ this.timer = setInterval(() => {
156
+ if (this.finished || this.probing)
157
+ return;
158
+ this.probing = true;
159
+ void this.probeFn(hint)
160
+ .then((answered) => {
161
+ this.probing = false;
162
+ if (!answered || this.finished)
163
+ return;
164
+ this.settle({ port: hint, url: `http://127.0.0.1:${hint}`, detectedBy: 'probe' });
165
+ })
166
+ .catch(() => {
167
+ this.probing = false;
168
+ });
169
+ }, PROC_LIMITS.probeIntervalMs);
170
+ this.timer.unref?.();
171
+ }
172
+ /**
173
+ * Offer the rows emitted since the last call.
174
+ *
175
+ * A MATCH WITH A PORT IS CONFIRMED BY ONE CONNECT before the status flips; a
176
+ * refused connect leaves the service `starting` and the row is simply not
177
+ * re-considered on the next call, because the caller passes only NEW rows.
178
+ */
179
+ offerRows(rows) {
180
+ if (this.finished || rows.length === 0)
181
+ return;
182
+ const hit = detectReadyUrl(rows);
183
+ if (!hit)
184
+ return;
185
+ if (hit.port === undefined) {
186
+ this.settle({ ...(hit.url ? { url: hit.url } : {}), detectedBy: 'url' });
187
+ return;
188
+ }
189
+ const port = hit.port;
190
+ void this.probeFn(port)
191
+ .then((answered) => {
192
+ if (!answered || this.finished)
193
+ return;
194
+ this.settle({
195
+ port,
196
+ url: hit.url ?? `http://127.0.0.1:${port}`,
197
+ detectedBy: 'url',
198
+ });
199
+ })
200
+ .catch(() => {
201
+ /* a refused connect is not an error; it just is not ready yet */
202
+ });
203
+ }
204
+ stop() {
205
+ this.finished = true;
206
+ if (this.timer)
207
+ clearInterval(this.timer);
208
+ if (this.deadline)
209
+ clearTimeout(this.deadline);
210
+ this.timer = undefined;
211
+ this.deadline = undefined;
212
+ }
213
+ settle(result) {
214
+ if (this.finished)
215
+ return;
216
+ this.stop();
217
+ this.opts.onReady(result);
218
+ }
219
+ }
@@ -0,0 +1,103 @@
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 { type ProcEventListener, type ProcSupervisorPort, type ServiceLogPage, type ServiceSnapshot, type ServiceStartRequest, type ServiceStartResult } from './types.js';
22
+ export interface ProcSupervisorOptions {
23
+ /** `cfg.bash.readyTimeoutMs`, read once per service at spawn. */
24
+ readyTimeoutMs: () => number;
25
+ /** Injected by the tests; production uses a real TCP connect. */
26
+ probe?: (port: number) => Promise<boolean>;
27
+ }
28
+ export declare class ProcSupervisor implements ProcSupervisorPort {
29
+ private readonly opts;
30
+ private readonly services;
31
+ /**
32
+ * Live FOREGROUND `bash` children, by pid.
33
+ *
34
+ * A `Set` of pids rather than of `ChildProcess` objects: the only thing
35
+ * `killForeground` needs is the number, and holding the object would keep a
36
+ * reference to a settled child's stdio for the life of the session.
37
+ */
38
+ private readonly foreground;
39
+ private readonly listeners;
40
+ private nextId;
41
+ private disposed;
42
+ constructor(opts: ProcSupervisorOptions);
43
+ trackForeground(pid: number | undefined): () => void;
44
+ /**
45
+ * Hard-kill every tracked foreground child. Returns how many were signalled.
46
+ *
47
+ * THIS IS WHAT ACTUALLY UNBLOCKS A WEDGED `await tool.execute` on rung two of
48
+ * the Esc ladder. It NEVER touches services: `Esc` interrupts the AGENT,
49
+ * `Ctrl+C` stops the SERVICES, and that asymmetry is the user's own two
50
+ * sentences (I-4). A later reader will want to "fix" this by making it kill
51
+ * everything; that is the bug, not the fix.
52
+ */
53
+ killForeground(_reason: 'force'): number;
54
+ start(req: ServiceStartRequest): Promise<ServiceStartResult>;
55
+ get(id: string): ServiceSnapshot | undefined;
56
+ list(): ServiceSnapshot[];
57
+ read(id: string, since?: number): ServiceLogPage | undefined;
58
+ liveCount(): number;
59
+ /**
60
+ * Stop one service, or every service.
61
+ *
62
+ * `SIGTERM` FIRST, `SIGKILL` AFTER `stopGraceMs` — the graceful ladder, which
63
+ * belongs here rather than in `reapSync` because this path has a live event
64
+ * loop to run it on. It then CHECKS `process.kill(pid, 0)` and records
65
+ * `killIncomplete` when the pid is still there (P2-6): an honest warning beats
66
+ * a silent leak, and `killTree` cannot know on its own whether it worked.
67
+ */
68
+ stop(id: string): Promise<ServiceSnapshot[]>;
69
+ private stopOne;
70
+ /**
71
+ * SYNCHRONOUS, TOTAL, IDEMPOTENT. The only entry point a signal hook,
72
+ * `process.on('exit')` or `handleFatal` may call.
73
+ *
74
+ * No escalation ladder, no promises, no logging: a reaper that can block or
75
+ * throw is worse than one that misses a child. It reaps FOREGROUND children
76
+ * too, because a `bash` call in flight at quit time is a process nobody else
77
+ * will ever kill.
78
+ */
79
+ reapSync(): void;
80
+ dispose(): void;
81
+ subscribe(listener: ProcEventListener): () => void;
82
+ /**
83
+ * EVERY LISTENER IS WRAPPED, and that is not defensive noise (I-5). This runs
84
+ * inside `child.stdout.on('data')`, so a throw here would kill a ten-minute
85
+ * build because of a render bug. `debug` rather than `warn` because a listener
86
+ * that throws once throws on every chunk — the same rule, the same reason, as
87
+ * `AgentController.emitToolOutput`.
88
+ */
89
+ private emit;
90
+ /** Coalesce `output` at the SOURCE — `/bg` and `bash_output` read it too. */
91
+ private scheduleOutput;
92
+ /** Release every timer, wake every waiter, and announce the terminal state. */
93
+ private finish;
94
+ /**
95
+ * Evict TERMINAL records only, oldest first, to make room.
96
+ *
97
+ * IDS ARE NEVER REUSED (I-6): `nextId` only ever increases, so a stale
98
+ * `bash_kill({ service: 's1' })` can only be a no-op and never a kill of
99
+ * something else.
100
+ */
101
+ private evictTerminal;
102
+ private snapshot;
103
+ }