@crouton-kit/crouter 0.3.40 → 0.3.42

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 (195) hide show
  1. package/dist/build-root.js +1 -0
  2. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +472 -9
  3. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +284 -76
  4. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/strip-skills-docs.ts +31 -24
  5. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +52 -15
  6. package/dist/clients/attach/attach-cmd.js +720 -747
  7. package/dist/clients/attach/chat-view.js +11 -0
  8. package/dist/clients/attach/view-socket.d.ts +8 -1
  9. package/dist/clients/attach/view-socket.js +15 -1
  10. package/dist/clients/web/server.js +5 -28
  11. package/dist/clients/web/web-cmd.js +1 -1
  12. package/dist/commands/__tests__/revive-now-gate.test.js +60 -0
  13. package/dist/commands/canvas-rebuild-index.js +5 -5
  14. package/dist/commands/memory/shared.d.ts +7 -3
  15. package/dist/commands/memory/shared.js +35 -5
  16. package/dist/commands/memory/write.js +5 -3
  17. package/dist/commands/node-snapshot.js +9 -1
  18. package/dist/commands/node.js +37 -17
  19. package/dist/commands/push.js +8 -0
  20. package/dist/commands/revive.d.ts +10 -0
  21. package/dist/commands/revive.js +28 -10
  22. package/dist/commands/sys/__tests__/setup-core.test.js +19 -0
  23. package/dist/commands/sys/doctor.js +1 -1
  24. package/dist/commands/sys/setup-core.js +3 -2
  25. package/dist/commands/sys/setup.js +1 -1
  26. package/dist/commands/worktree.d.ts +2 -0
  27. package/dist/commands/worktree.js +94 -0
  28. package/dist/core/__tests__/boot.test.js +4 -4
  29. package/dist/core/__tests__/canvas.test.js +19 -7
  30. package/dist/core/__tests__/child-followup.test.js +15 -5
  31. package/dist/core/__tests__/daemon-boot.test.js +6 -1
  32. package/dist/core/__tests__/daemon-wedge.test.js +18 -1
  33. package/dist/core/__tests__/fault-classifier.test.js +30 -0
  34. package/dist/core/__tests__/fixtures/fake-engine.d.ts +13 -0
  35. package/dist/core/__tests__/fixtures/fake-engine.js +20 -0
  36. package/dist/core/__tests__/full/spike-harness.test.js +8 -5
  37. package/dist/core/__tests__/grace-clock.test.js +18 -2
  38. package/dist/core/__tests__/host-teardown-process-group.test.js +465 -0
  39. package/dist/core/__tests__/prune-to-limit.test.js +14 -0
  40. package/dist/core/__tests__/review-model-floor.test.js +32 -0
  41. package/dist/core/__tests__/revive.test.js +18 -4
  42. package/dist/core/__tests__/session-cycles.test.js +77 -0
  43. package/dist/core/__tests__/worktree.test.js +85 -0
  44. package/dist/core/canvas/boot.js +12 -7
  45. package/dist/core/canvas/canvas.d.ts +27 -8
  46. package/dist/core/canvas/canvas.js +54 -26
  47. package/dist/core/canvas/db.js +14 -0
  48. package/dist/core/canvas/history.js +1 -0
  49. package/dist/core/canvas/paths.d.ts +10 -9
  50. package/dist/core/canvas/paths.js +10 -9
  51. package/dist/core/canvas/pid.d.ts +155 -1
  52. package/dist/core/canvas/pid.js +306 -1
  53. package/dist/core/canvas/status-glyph.d.ts +7 -0
  54. package/dist/core/canvas/status-glyph.js +10 -1
  55. package/dist/core/canvas/types.d.ts +34 -0
  56. package/dist/core/fault-classifier.js +5 -1
  57. package/dist/core/profiles/select.d.ts +4 -2
  58. package/dist/core/profiles/select.js +30 -4
  59. package/dist/core/runtime/bearings.js +4 -0
  60. package/dist/core/runtime/branded-host.d.ts +7 -0
  61. package/dist/core/runtime/branded-host.js +44 -17
  62. package/dist/core/runtime/broker-sdk.js +28 -68
  63. package/dist/core/runtime/broker.js +35 -4
  64. package/dist/core/runtime/host.d.ts +3 -3
  65. package/dist/core/runtime/host.js +148 -33
  66. package/dist/core/runtime/launch.d.ts +15 -15
  67. package/dist/core/runtime/launch.js +58 -4
  68. package/dist/core/runtime/naming.js +3 -2
  69. package/dist/core/runtime/nodes.d.ts +3 -1
  70. package/dist/core/runtime/nodes.js +1 -0
  71. package/dist/core/runtime/pi-cli.d.ts +2 -0
  72. package/dist/core/runtime/pi-cli.js +51 -0
  73. package/dist/core/runtime/placement.d.ts +1 -1
  74. package/dist/core/runtime/placement.js +5 -2
  75. package/dist/core/runtime/recap.js +2 -1
  76. package/dist/core/runtime/revive.d.ts +21 -15
  77. package/dist/core/runtime/revive.js +87 -52
  78. package/dist/core/runtime/session-cycles.d.ts +30 -0
  79. package/dist/core/runtime/session-cycles.js +77 -0
  80. package/dist/core/runtime/spawn.d.ts +4 -0
  81. package/dist/core/runtime/spawn.js +152 -100
  82. package/dist/core/runtime/tmux.d.ts +7 -10
  83. package/dist/core/runtime/tmux.js +9 -11
  84. package/dist/core/worktree.d.ts +35 -0
  85. package/dist/core/worktree.js +158 -0
  86. package/dist/daemon/crtrd.d.ts +17 -8
  87. package/dist/daemon/crtrd.js +191 -40
  88. package/dist/index.d.ts +3 -0
  89. package/dist/index.js +7 -0
  90. package/dist/pi-extensions/canvas-context-intro.d.ts +1 -0
  91. package/dist/pi-extensions/canvas-context-intro.js +34 -23
  92. package/dist/pi-extensions/canvas-stophook.js +11 -5
  93. package/dist/web-client/assets/{index-CbO8L0mN.js → index-B00YpRQ1.js} +20 -20
  94. package/dist/web-client/assets/index-DrkcvANq.css +2 -0
  95. package/dist/web-client/index.html +2 -2
  96. package/docs/compat/hearth-crtr-v1.md +191 -0
  97. package/docs/public-api.md +75 -0
  98. package/package.json +3 -4
  99. package/dist/core/__tests__/hearth-bootstrap.test.js +0 -136
  100. package/dist/core/hearth/__tests__/model-auth-guest.test.js +0 -151
  101. package/dist/core/hearth/config.d.ts +0 -3
  102. package/dist/core/hearth/config.js +0 -108
  103. package/dist/core/hearth/guest-env.d.ts +0 -9
  104. package/dist/core/hearth/guest-env.js +0 -27
  105. package/dist/core/hearth/index.d.ts +0 -4
  106. package/dist/core/hearth/index.js +0 -4
  107. package/dist/core/hearth/model-auth-guest.d.ts +0 -8
  108. package/dist/core/hearth/model-auth-guest.js +0 -430
  109. package/dist/core/hearth/provider.d.ts +0 -36
  110. package/dist/core/hearth/provider.js +0 -10
  111. package/dist/core/hearth/providers/__tests__/sweep-and-release.test.js +0 -362
  112. package/dist/core/hearth/providers/blaxel-bootstrap.d.ts +0 -12
  113. package/dist/core/hearth/providers/blaxel-bootstrap.js +0 -147
  114. package/dist/core/hearth/providers/blaxel-home.d.ts +0 -60
  115. package/dist/core/hearth/providers/blaxel-home.js +0 -405
  116. package/dist/core/hearth/providers/blaxel.d.ts +0 -36
  117. package/dist/core/hearth/providers/blaxel.js +0 -364
  118. package/dist/core/hearth/providers/types.d.ts +0 -93
  119. package/dist/core/hearth/types.d.ts +0 -155
  120. package/dist/hearth/control-plane/__tests__/error-serialization.test.js +0 -29
  121. package/dist/hearth/control-plane/__tests__/node-message.test.js +0 -60
  122. package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.d.ts +0 -1
  123. package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.js +0 -44
  124. package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.d.ts +0 -1
  125. package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.js +0 -49
  126. package/dist/hearth/control-plane/__tests__/relay-security.test.d.ts +0 -1
  127. package/dist/hearth/control-plane/__tests__/relay-security.test.js +0 -314
  128. package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.d.ts +0 -1
  129. package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.js +0 -133
  130. package/dist/hearth/control-plane/__tests__/trigger-delivery.test.d.ts +0 -1
  131. package/dist/hearth/control-plane/__tests__/trigger-delivery.test.js +0 -170
  132. package/dist/hearth/control-plane/__tests__/wake-roll.test.d.ts +0 -1
  133. package/dist/hearth/control-plane/__tests__/wake-roll.test.js +0 -230
  134. package/dist/hearth/control-plane/__tests__/webhook-ingress.test.d.ts +0 -1
  135. package/dist/hearth/control-plane/__tests__/webhook-ingress.test.js +0 -167
  136. package/dist/hearth/control-plane/config.d.ts +0 -21
  137. package/dist/hearth/control-plane/config.js +0 -77
  138. package/dist/hearth/control-plane/db.d.ts +0 -30
  139. package/dist/hearth/control-plane/db.js +0 -561
  140. package/dist/hearth/control-plane/hearth-target.d.ts +0 -23
  141. package/dist/hearth/control-plane/hearth-target.js +0 -68
  142. package/dist/hearth/control-plane/ingress/rate-limit.d.ts +0 -24
  143. package/dist/hearth/control-plane/ingress/rate-limit.js +0 -100
  144. package/dist/hearth/control-plane/ingress/route-store.d.ts +0 -31
  145. package/dist/hearth/control-plane/ingress/route-store.js +0 -61
  146. package/dist/hearth/control-plane/ingress/webhook-delivery-store.d.ts +0 -41
  147. package/dist/hearth/control-plane/ingress/webhook-delivery-store.js +0 -69
  148. package/dist/hearth/control-plane/ingress/webhook-route.d.ts +0 -55
  149. package/dist/hearth/control-plane/ingress/webhook-route.js +0 -285
  150. package/dist/hearth/control-plane/main.d.ts +0 -1
  151. package/dist/hearth/control-plane/main.js +0 -88
  152. package/dist/hearth/control-plane/node-message.d.ts +0 -31
  153. package/dist/hearth/control-plane/node-message.js +0 -98
  154. package/dist/hearth/control-plane/register.d.ts +0 -15
  155. package/dist/hearth/control-plane/register.js +0 -34
  156. package/dist/hearth/control-plane/registry.d.ts +0 -22
  157. package/dist/hearth/control-plane/registry.js +0 -168
  158. package/dist/hearth/control-plane/relay.d.ts +0 -44
  159. package/dist/hearth/control-plane/relay.js +0 -711
  160. package/dist/hearth/control-plane/scheduler/fire-store.d.ts +0 -36
  161. package/dist/hearth/control-plane/scheduler/fire-store.js +0 -73
  162. package/dist/hearth/control-plane/scheduler/recurrence.d.ts +0 -7
  163. package/dist/hearth/control-plane/scheduler/recurrence.js +0 -58
  164. package/dist/hearth/control-plane/scheduler/scan-loop.d.ts +0 -38
  165. package/dist/hearth/control-plane/scheduler/scan-loop.js +0 -138
  166. package/dist/hearth/control-plane/scheduler/schedule-store.d.ts +0 -32
  167. package/dist/hearth/control-plane/scheduler/schedule-store.js +0 -66
  168. package/dist/hearth/control-plane/secrets.d.ts +0 -31
  169. package/dist/hearth/control-plane/secrets.js +0 -134
  170. package/dist/hearth/control-plane/server.d.ts +0 -27
  171. package/dist/hearth/control-plane/server.js +0 -482
  172. package/dist/hearth/control-plane/serving.d.ts +0 -15
  173. package/dist/hearth/control-plane/serving.js +0 -106
  174. package/dist/hearth/control-plane/session.d.ts +0 -68
  175. package/dist/hearth/control-plane/session.js +0 -273
  176. package/dist/hearth/control-plane/triggers/acl.d.ts +0 -14
  177. package/dist/hearth/control-plane/triggers/acl.js +0 -52
  178. package/dist/hearth/control-plane/triggers/audit-store.d.ts +0 -38
  179. package/dist/hearth/control-plane/triggers/audit-store.js +0 -79
  180. package/dist/hearth/control-plane/triggers/deliver.d.ts +0 -43
  181. package/dist/hearth/control-plane/triggers/deliver.js +0 -76
  182. package/dist/hearth/control-plane/triggers/envelope.d.ts +0 -29
  183. package/dist/hearth/control-plane/triggers/envelope.js +0 -38
  184. package/dist/hearth/control-plane/types.d.ts +0 -86
  185. package/dist/hearth/control-plane/types.js +0 -1
  186. package/dist/hearth/control-plane/wake.d.ts +0 -86
  187. package/dist/hearth/control-plane/wake.js +0 -550
  188. package/dist/web-client/assets/index-DwO46Cs5.css +0 -2
  189. /package/dist/{core/__tests__/hearth-bootstrap.test.d.ts → commands/__tests__/revive-now-gate.test.d.ts} +0 -0
  190. /package/dist/{core/hearth/__tests__/model-auth-guest.test.d.ts → commands/sys/__tests__/setup-core.test.d.ts} +0 -0
  191. /package/dist/core/{hearth/providers/__tests__/sweep-and-release.test.d.ts → __tests__/fault-classifier.test.d.ts} +0 -0
  192. /package/dist/core/{hearth/providers/types.js → __tests__/host-teardown-process-group.test.d.ts} +0 -0
  193. /package/dist/core/{hearth/types.js → __tests__/review-model-floor.test.d.ts} +0 -0
  194. /package/dist/{hearth/control-plane/__tests__/error-serialization.test.d.ts → core/__tests__/session-cycles.test.d.ts} +0 -0
  195. /package/dist/{hearth/control-plane/__tests__/node-message.test.d.ts → core/__tests__/worktree.test.d.ts} +0 -0
@@ -5,7 +5,7 @@
5
5
  import { existsSync, realpathSync } from 'node:fs';
6
6
  import { basename, resolve as resolvePath, sep } from 'node:path';
7
7
  import { createInterface } from 'node:readline/promises';
8
- import { listProfiles, loadProfileManifest, updateProfileLastUsed, createProfile, } from './manifest.js';
8
+ import { listProfiles, loadProfileManifest, updateProfileLastUsed, createProfile, addProfileProject, } from './manifest.js';
9
9
  /** Resolve cwd to an absolute, realpath'd form so it compares against the
10
10
  * realpath'd project dirs `createProfile`/`addProfileProject` already store
11
11
  * (manifest.ts resolves every project dir through `realpathSync`). Falls back
@@ -66,6 +66,25 @@ function recoveryMessage(cwd) {
66
66
  * artifact or a queued interaction, not a two-choice gate on the same TTY pi
67
67
  * is about to take over — a raw `readline` question is the actual minimal
68
68
  * fit here. */
69
+ /** Explicit `--profile` names a profile whose manifest does NOT cover cwd.
70
+ * Interactive only: offer to widen the profile's purview to include this
71
+ * directory (default yes), so the next node started here is covered without a
72
+ * separate `crtr profile add-project`. Declining leaves the manifest untouched
73
+ * and runs the node under the profile anyway. Same raw-readline rationale as
74
+ * `promptCreateOrRoot` — this gates pi's boot on the same TTY. */
75
+ async function promptAddDirToProfile(entry, cwd) {
76
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
77
+ try {
78
+ process.stdout.write(`\nProfile "${entry.manifest.name}" does not cover this directory:\n ${cwd}\n\n`);
79
+ const choice = (await rl.question('Add it to the profile? [Y/n]: ')).trim().toLowerCase();
80
+ if (choice === 'n' || choice === 'no')
81
+ return;
82
+ addProfileProject(entry.profileId, cwd);
83
+ }
84
+ finally {
85
+ rl.close();
86
+ }
87
+ }
69
88
  async function promptCreateOrRoot(cwd) {
70
89
  const rl = createInterface({ input: process.stdin, output: process.stdout });
71
90
  try {
@@ -86,8 +105,10 @@ async function promptCreateOrRoot(cwd) {
86
105
  }
87
106
  /** Select the profile a node about to boot at `cwd` should run under.
88
107
  *
89
- * 1. `explicitProfile` present → resolve id/name via `loadProfileManifest`,
90
- * bump `last_used_at`, return the id.
108
+ * 1. `explicitProfile` present → resolve id/name via `loadProfileManifest`. If
109
+ * its manifest does not already cover `cwd` and the session is interactive,
110
+ * offer to add `cwd` to its purview (default yes). Bump `last_used_at`,
111
+ * return the id.
91
112
  * 2. Else, among profiles whose project list COVERS `cwd`, pick the
92
113
  * most-recently-used (nulls oldest), bump its `last_used_at`, return it.
93
114
  * 3. Else — nothing covers cwd. Interactive session: prompt synchronously to
@@ -95,12 +116,17 @@ async function promptCreateOrRoot(cwd) {
95
116
  * interactive (no TTY): default to root (null) and print the recovery
96
117
  * instruction to STDERR — never stdout, which the caller may be piping. */
97
118
  export async function selectProfileForCwd(cwd, explicitProfile) {
119
+ const resolvedCwd = resolveCwd(cwd);
98
120
  if (explicitProfile !== undefined && explicitProfile !== null && explicitProfile !== '') {
99
121
  const entry = loadProfileManifest(explicitProfile);
122
+ // Selecting a profile from a directory it does not yet cover: offer to
123
+ // widen its purview (interactive only, default yes) before pi boots.
124
+ if (!profileCoversCwd(entry, resolvedCwd) && isInteractive()) {
125
+ await promptAddDirToProfile(entry, resolvedCwd);
126
+ }
100
127
  updateProfileLastUsed(entry.profileId);
101
128
  return entry.profileId;
102
129
  }
103
- const resolvedCwd = resolveCwd(cwd);
104
130
  const covering = listProfiles().filter((p) => profileCoversCwd(p, resolvedCwd));
105
131
  if (covering.length > 0) {
106
132
  const winner = pickMostRecent(covering);
@@ -446,6 +446,10 @@ export function buildContextBearings(nodeId) {
446
446
  const profileSection = profileNote(nodeId);
447
447
  if (profileSection !== '')
448
448
  bearings.push('', profileSection);
449
+ const wt = node?.managed_worktree;
450
+ if (wt?.state === 'open') {
451
+ bearings.push('', '## Your managed worktree', `Branch: \`${wt.branch}\`\nWorktree: \`${wt.path}\`\nBase: \`${wt.base_ref}\` @ \`${wt.base_sha}\`\nCommit work here. Land it with \`crtr worktree close\` before finishing; \`crtr push final\` is blocked until this worktree is closed.`);
452
+ }
449
453
  // Orchestrator-only: the across-cycles framing (a terminal has no future cycle).
450
454
  if (node?.mode === 'orchestrator')
451
455
  bearings.push('', orchestratorContextNote(nodeId));
@@ -3,6 +3,13 @@
3
3
  * to the installed node, shared across every canvas home. */
4
4
  export declare function brandedHostDir(): string;
5
5
  export declare function brandedHostBin(): string;
6
+ /** macOS keys FDA grants on the Mach-O cdhash. A real developer identity
7
+ * keeps that hash stable across branded-host rebuilds; ad-hoc signing is the
8
+ * portable default for machines without a local signing certificate. */
9
+ export declare function codesignIdentitiesForBrandedHost(opts?: {
10
+ configured?: string | null;
11
+ discovered?: string | null;
12
+ }): string[];
6
13
  export declare function signBrandedHostArtifacts(binPath: string, libnodePath: string | null, sign?: (path: string) => void): void;
7
14
  export declare function brandedHostWorks(binPath: string): boolean;
8
15
  /** Ensure the branded host binary exists and matches the live node; return its
@@ -72,30 +72,57 @@ function readMarker() {
72
72
  return null;
73
73
  }
74
74
  }
75
- /** Sign a copied branded-host artifact with the Apple Development identity.
76
- * macOS keys FDA grants on the Mach-O cdhash, so ad-hoc signing loses grants
77
- * after each rebuild. Stable signing preserves them. Non-fatal if signing
78
- * fails (no certificate, non-macOS, etc), return silently. */
75
+ /** macOS keys FDA grants on the Mach-O cdhash. A real developer identity
76
+ * keeps that hash stable across branded-host rebuilds; ad-hoc signing is the
77
+ * portable default for machines without a local signing certificate. */
78
+ export function codesignIdentitiesForBrandedHost(opts = {}) {
79
+ const configured = opts.configured?.trim();
80
+ if (configured)
81
+ return [configured];
82
+ const discovered = opts.discovered?.trim();
83
+ return [discovered || '-'];
84
+ }
85
+ function discoverCodesignIdentity() {
86
+ if (process.platform !== 'darwin')
87
+ return null;
88
+ const res = spawnSync('security', ['find-identity', '-v', '-p', 'codesigning'], { stdio: ['pipe', 'pipe', 'pipe'] });
89
+ if (res.status !== 0)
90
+ return null;
91
+ const stdout = (res.stdout ?? '').toString();
92
+ const identities = stdout
93
+ .split('\n')
94
+ .map((line) => line.match(/\)\s+[A-Fa-f0-9]{40}\s+"([^"]+)"/)?.[1] ?? null)
95
+ .filter((identity) => identity !== null);
96
+ return identities.find((identity) => identity.startsWith('Apple Development:'))
97
+ ?? identities.find((identity) => identity.startsWith('Developer ID Application:'))
98
+ ?? identities[0]
99
+ ?? null;
100
+ }
101
+ function runCodesign(binPath, identity) {
102
+ const res = spawnSync('codesign', ['-s', identity, '--deep', '--force', binPath], { stdio: ['pipe', 'pipe', 'pipe'] });
103
+ return { ok: res.status === 0, stderr: (res.stderr ?? '').toString().trim() };
104
+ }
79
105
  function signBrandedHost(binPath) {
80
106
  if (process.platform !== 'darwin')
81
107
  return;
82
108
  try {
83
- const res = spawnSync('codesign', [
84
- '-s',
85
- 'Apple Development: Silas Rhyneer (8W9CF7WS32)',
86
- '--deep',
87
- '--force',
88
- binPath,
89
- ], { stdio: ['pipe', 'pipe', 'pipe'] });
90
- if (res.status !== 0) {
91
- const stderr = (res.stderr ?? '').toString().trim();
92
- if (stderr && !stderr.includes('valid on disk')) {
93
- console.debug(`[crouter] codesign failed (expected on some systems): ${stderr}`);
94
- }
109
+ const identities = codesignIdentitiesForBrandedHost({
110
+ configured: process.env['CRTR_CODESIGN_IDENTITY'],
111
+ discovered: discoverCodesignIdentity(),
112
+ });
113
+ let lastError = '';
114
+ for (const identity of identities) {
115
+ const res = runCodesign(binPath, identity);
116
+ if (res.ok)
117
+ return;
118
+ lastError = res.stderr;
119
+ }
120
+ if (lastError && !lastError.includes('valid on disk')) {
121
+ console.debug(`[crouter] codesign failed (expected on some systems): ${lastError}`);
95
122
  }
96
123
  }
97
124
  catch {
98
- // codesign not found or other spawn error — non-fatal, continue.
125
+ // codesign/security not found or other spawn error — non-fatal, continue.
99
126
  }
100
127
  }
101
128
  export function signBrandedHostArtifacts(binPath, libnodePath, sign = signBrandedHost) {
@@ -1,20 +1,18 @@
1
1
  // broker-sdk.ts — the single import-and-version-assert shim between the headless
2
2
  // broker and the pi SDK (`@earendil-works/pi-coding-agent`).
3
3
  //
4
- // crouter has never imported the SDK before it only ever forked the `pi` binary
5
- // (see `launch.ts buildPiArgv`). The broker (T4) is the first consumer. This shim
6
- // is the one place the dependency is named, so:
4
+ // The broker imports the SDK in-process, and the bundled pi CLI is used only for
5
+ // incidental headless helper commands and the version tripwire. This shim is the
6
+ // one place the dependency is named, so:
7
7
  // - the version tripwire (`assertEngineVersion`) lives here, and
8
8
  // - the `CRTR_BROKER_ENGINE` test seam (T11) has a single dynamic-import
9
9
  // indirection point (`loadBrokerEngine`) to swap in the fake engine.
10
- //
11
- // Nothing else in the tree imports this yet; it compiles but stays inert so the
12
- // lifecycle suite stays green until the broker is wired up.
13
10
  import { execFileSync } from 'node:child_process';
14
- import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
15
- import { delimiter, join } from 'node:path';
11
+ import { readFileSync, statSync, writeFileSync } from 'node:fs';
12
+ import { join } from 'node:path';
16
13
  import { createAgentSessionServices, createAgentSessionFromServices, createAgentSessionRuntime, SessionManager, VERSION, } from '@earendil-works/pi-coding-agent';
17
14
  import { crtrHome } from '../canvas/paths.js';
15
+ import { resolveBundledPiCliPath } from './pi-cli.js';
18
16
  // Static re-exports — the real SDK surface the broker drives in production.
19
17
  export { createAgentSessionServices, createAgentSessionFromServices, createAgentSessionRuntime, SessionManager, VERSION, };
20
18
  /**
@@ -44,44 +42,10 @@ export async function loadBrokerEngine() {
44
42
  VERSION: typeof mod.VERSION === 'string' ? mod.VERSION : 'unknown',
45
43
  };
46
44
  }
47
- /**
48
- * Boot-time tripwire: compare the imported SDK `VERSION` against the `pi` binary
49
- * crouter forks elsewhere (`pi --version`). On mismatch (or if the binary can't be
50
- * probed) it logs a LOUD warning to stderr and proceeds — it NEVER throws. The v3
51
- * session format auto-migrates on load, so minor skew round-trips; a hard fail here
52
- * would risk a grace-revive crash loop. Phase 5 may harden this to fail-fast once
53
- * the broker is the only host.
54
- *
55
- * @param engineVersion the version actually loaded (defaults to the statically
56
- * imported `VERSION`; pass `engine.VERSION` from `loadBrokerEngine` to assert the
57
- * version the broker is really driving).
58
- */
59
- /** Resolve the `pi` executable on PATH without spawning anything — a plain walk
60
- * of `$PATH` entries (`statSync` follows symlinks, so an npm/brew bin shim
61
- * resolves to the real target). Returns the first match, or undefined. */
62
- function resolvePiBinary() {
63
- const path = process.env['PATH'];
64
- if (path === undefined || path === '')
65
- return undefined;
66
- for (const dir of path.split(delimiter)) {
67
- if (dir === '')
68
- continue;
69
- const candidate = join(dir, 'pi');
70
- try {
71
- if (existsSync(candidate))
72
- return candidate;
73
- }
74
- catch {
75
- /* unreadable PATH entry — skip */
76
- }
77
- }
78
- return undefined;
79
- }
80
45
  const PI_VERSION_CACHE = () => join(crtrHome(), 'pi-version.json');
81
- /** Cached `pi --version`, keyed on the binary's path+mtime+size so a pi upgrade
82
- * re-probes but the steady state never spawns. Returns undefined when the
83
- * binary can't be resolved/stat'd or the cache misses the caller then spawns
84
- * once and back-fills via {@link writePiVersionCache}. */
46
+ /** Cached `pi --version`, keyed on the CLI's path+mtime+size so a pi upgrade
47
+ * re-probes but the steady state never spawns. Returns undefined when the cache
48
+ * misses the caller then spawns once and back-fills via {@link writePiVersionCache}. */
85
49
  function readPiVersionCache(binPath, mtimeMs, size) {
86
50
  try {
87
51
  const c = JSON.parse(readFileSync(PI_VERSION_CACHE(), 'utf8'));
@@ -103,41 +67,37 @@ function writePiVersionCache(entry) {
103
67
  }
104
68
  }
105
69
  export function assertEngineVersion(engineVersion = VERSION) {
106
- // Resolve + stat the binary (no spawn). When its path+mtime+size matches the
107
- // cache, reuse the stored version and skip the ~0.4s `pi --version` boot — the
108
- // common path on every broker start. A pi upgrade changes the stat and re-probes.
109
- const binPath = resolvePiBinary();
70
+ // Resolve + stat the bundled CLI (no spawn). When its path+mtime+size matches
71
+ // the cache, reuse the stored version and skip the ~0.4s `pi --version` boot —
72
+ // the common path on every broker start. A pi upgrade changes the stat and re-probes.
73
+ let binPath;
110
74
  let stat;
111
- if (binPath !== undefined) {
112
- try {
113
- const s = statSync(binPath);
114
- stat = { mtimeMs: s.mtimeMs, size: s.size };
115
- }
116
- catch {
117
- stat = undefined;
118
- }
75
+ try {
76
+ binPath = resolveBundledPiCliPath();
77
+ const s = statSync(binPath);
78
+ stat = { mtimeMs: s.mtimeMs, size: s.size };
119
79
  }
120
- let binaryVersion;
121
- if (binPath !== undefined && stat !== undefined) {
122
- binaryVersion = readPiVersionCache(binPath, stat.mtimeMs, stat.size);
80
+ catch (err) {
81
+ process.stderr.write(`[broker] WARNING: could not resolve bundled pi CLI to verify engine parity ` +
82
+ `(SDK ${engineVersion}): ${err.message}\n`);
83
+ return;
123
84
  }
85
+ let binaryVersion = readPiVersionCache(binPath, stat.mtimeMs, stat.size);
124
86
  if (binaryVersion === undefined) {
125
87
  try {
126
- binaryVersion = execFileSync('pi', ['--version'], { encoding: 'utf8' }).trim();
88
+ binaryVersion = execFileSync(process.execPath, [binPath, '--version'], { encoding: 'utf8' }).trim();
127
89
  }
128
90
  catch (err) {
129
- process.stderr.write(`[broker] WARNING: could not run 'pi --version' to verify engine parity ` +
91
+ process.stderr.write(`[broker] WARNING: could not run bundled 'pi --version' to verify engine parity ` +
130
92
  `(SDK ${engineVersion}): ${err.message}\n`);
131
93
  return;
132
94
  }
133
- if (binPath !== undefined && stat !== undefined) {
134
- writePiVersionCache({ path: binPath, mtimeMs: stat.mtimeMs, size: stat.size, version: binaryVersion });
135
- }
95
+ writePiVersionCache({ path: binPath, mtimeMs: stat.mtimeMs, size: stat.size, version: binaryVersion });
136
96
  }
137
97
  if (binaryVersion !== engineVersion) {
138
98
  process.stderr.write(`[broker] WARNING: pi SDK version mismatch — imported SDK is ${engineVersion} but ` +
139
- `the 'pi' binary is ${binaryVersion}. The v3 session format auto-migrates on load so ` +
140
- `minor skew round-trips, but pin '@earendil-works/pi-coding-agent' to the binary version ` +
141
- `to keep the in-process engine and the forked binary in lockstep.\n`);
99
+ `the bundled pi CLI is ${binaryVersion}. The v3 session format auto-migrates on load so ` +
100
+ `minor skew round-trips, but keep the crouter dependency on '@earendil-works/pi-coding-agent' ` +
101
+ `in lockstep with the in-process engine.\n`);
142
102
  }
143
103
  }
@@ -32,6 +32,7 @@ import { isModelNotFoundError, readFault, recordFault } from './fault.js';
32
32
  import { CONNECTION_FAULT_RECOVERY_BODY, PROVIDER_FAULT_RECOVERY_BODY } from './fault-recovery-nudge.js';
33
33
  import { markFatalOnExhaust, nextFaultRetry, policyFor } from './fault-recovery.js';
34
34
  import { probeOnline as probeOnlineReal } from './connectivity.js';
35
+ import { CRTR_CYCLE_CUSTOM_TYPE, cycleAwareMessages } from './session-cycles.js';
35
36
  import { BUILTIN_SLASH_COMMANDS } from './pi-vendored.js';
36
37
  import { encodeFrame, FrameDecoder, FrameOverflowError, BROKER_READ_CAPS, } from './broker-protocol.js';
37
38
  const THINKING_LEVELS = new Set(['off', 'minimal', 'low', 'medium', 'high', 'xhigh']);
@@ -1646,9 +1647,19 @@ export async function runBroker(nodeId) {
1646
1647
  //
1647
1648
  // `AgentSession.sessionManager` is a public readonly field of the real pi SDK; the
1648
1649
  // fake-engine test fixture mirrors the same `sessionManager.buildSessionContext()`
1649
- // surface, so this is a single path with no engine-capability fallback.
1650
+ // surface (plus, where present, the tree accessors `cycleAwareMessages` reads).
1651
+ // A refresh-yielded node keeps every prior cycle in the SAME `.jsonl` as sibling
1652
+ // branches (session-cycles.ts) — `cycleAwareMessages` replays them ahead of the
1653
+ // live cycle's context so the welcome snapshot renders one continuous
1654
+ // conversation instead of just the post-yield window; single-cycle sessions and
1655
+ // the fake-engine fixture (no tree accessors) fall through to plain
1656
+ // `buildSessionContext().messages` unchanged.
1650
1657
  export function snapshotMessages(session) {
1651
- return session.sessionManager.buildSessionContext().messages;
1658
+ // Cast: the real SDK's `SessionEntry.data` is `unknown` (untyped custom-entry
1659
+ // payload), narrower than `CycleSessionManagerLike`'s structural read of the
1660
+ // `crtr-cycle` marker's `{cycle, fromLeaf}` shape — the fields we read off it
1661
+ // are ones ONLY this module's own `appendCustomEntry` call (above) ever writes.
1662
+ return cycleAwareMessages(session.sessionManager);
1652
1663
  }
1653
1664
  // ---------------------------------------------------------------------------
1654
1665
  // buildBrokerSession (plan T4 steps 2–4) — turn the launch recipe into a live
@@ -1745,7 +1756,19 @@ export async function buildBrokerSession(engine, cfg) {
1745
1756
  // A fork is a fresh spawn (resuming=false) so the kickoff firstPrompt fires: a
1746
1757
  // `node new --fork-from <id>` gives the new node the source's full history AND a
1747
1758
  // new task to start on. A resume replays the inbox instead and sends no kickoff.
1748
- const resuming = !forking && resumePath !== undefined && resumePath !== '';
1759
+ //
1760
+ // A refresh-yield CYCLES instead: `cfg.newCycle` (the `--crtr-new-cycle` token)
1761
+ // marks a revive that reuses this SAME `.jsonl` rather than resuming it — the
1762
+ // session manager opens the file, then resets the leaf and roots a `crtr-cycle`
1763
+ // marker entry (data: `{cycle, fromLeaf}`) as the new cycle's ROOT before the
1764
+ // session is built. Custom entries never enter LLM context, so the freshly
1765
+ // rooted branch is exactly as clean as a brand-new session; `cycleAwareMessages`
1766
+ // (session-cycles.ts) is the display-side counterpart that replays every prior
1767
+ // cycle for snapshot rendering. cycling ⇒ resuming=false, so the kickoff
1768
+ // firstPrompt fires below just like a true fresh launch.
1769
+ const hasSessionPath = !forking && resumePath !== undefined && resumePath !== '';
1770
+ const cycling = hasSessionPath && cfg.newCycle === true;
1771
+ const resuming = hasSessionPath && !cycling;
1749
1772
  let sessionManager;
1750
1773
  if (forking) {
1751
1774
  // Spawn-time fork via pi's REAL fork seam, SessionManager.forkFrom — the exact
@@ -1759,8 +1782,16 @@ export async function buildBrokerSession(engine, cfg) {
1759
1782
  // fails loudly here (forkFrom throws on an empty/invalid/header-less source).
1760
1783
  sessionManager = engine.SessionManager.forkFrom(cfg.forkFrom, cfg.cwd);
1761
1784
  }
1762
- else if (resuming) {
1785
+ else if (resuming || cycling) {
1763
1786
  sessionManager = engine.SessionManager.open(resumePath);
1787
+ if (cycling) {
1788
+ const fromLeaf = sessionManager.getLeafId();
1789
+ sessionManager.resetLeaf();
1790
+ sessionManager.appendCustomEntry(CRTR_CYCLE_CUSTOM_TYPE, {
1791
+ cycle: Number(process.env['CRTR_CYCLES']),
1792
+ fromLeaf,
1793
+ });
1794
+ }
1764
1795
  }
1765
1796
  else {
1766
1797
  sessionManager = engine.SessionManager.create(cfg.cwd);
@@ -11,9 +11,9 @@ export interface LaunchPlacement {
11
11
  * the broker pid recorded as pi_pid; no tmux placement coords exist (the engine
12
12
  * is never in a pane). */
13
13
  export interface HostHandle {
14
- /** Supervised pid (signal-0 target). May be null right at launch before the
15
- * detached child reports its pid; the broker re-records pi_pid during its
16
- * extension bind, and reviveNode clearPid()s right after launch. */
14
+ /** Supervised pid (signal-0 target). Null means the host failed to spawn an
15
+ * engine at all. Spawn/revive record this pid immediately; the broker
16
+ * re-records the same pid during extension bind. */
17
17
  pid: number | null;
18
18
  /** Real child-exit signal from the launch handle. Resolves when the detached
19
19
  * broker exits, so readiness waits can fail fast without pid probes. */
@@ -20,7 +20,8 @@ import { nodeDir, jobDir, viewSocketPath } from '../canvas/paths.js';
20
20
  import { encodeFrame } from './broker-protocol.js';
21
21
  import { FRONT_DOOR_ENV } from './front-door.js';
22
22
  import { hostExecPath } from './branded-host.js';
23
- import { isPidAlive } from '../canvas/pid.js';
23
+ import { isPidAlive, killProcessTreePids, isAnyPidAlive, captureTeardownSnapshot } from '../canvas/pid.js';
24
+ import { logger } from '../log.js';
24
25
  // ---------------------------------------------------------------------------
25
26
  // HeadlessBrokerHost (design §3.3): a detached broker PROCESS, no tmux pane. It
26
27
  // boots the dedicated broker-cli entry directly (NEVER src/cli.ts), supervises
@@ -88,8 +89,80 @@ export function preflightBrokerLaunch(nodeId, inv, cwd) {
88
89
  }
89
90
  }
90
91
  /** How long teardown waits after sending the graceful `shutdown` frame before
91
- * SIGTERMing a broker that connected but never exited (e.g. a hung dispose()). */
92
+ * escalating a broker/tree that connected but never fully exited (e.g. a hung
93
+ * dispose(), or a dispose() that reaped the broker but left a descendant
94
+ * behind). */
92
95
  const BROKER_SHUTDOWN_GRACE_MS = 2_000;
96
+ /** How long teardown waits after the tree-wide SIGTERM before the final
97
+ * tree-wide SIGKILL sweep — the last resort for a broker (or its
98
+ * descendants) that ignores SIGTERM entirely. */
99
+ const BROKER_SIGKILL_GRACE_MS = 2_000;
100
+ /** How often `pollUntilTreeDead` re-checks the process TREE. Short enough that
101
+ * the common/happy path (tree already dead) pays only a couple of ticks; the
102
+ * full grace ceilings above are reserved for the genuinely-wedged case,
103
+ * where the added latency is the whole point (crouter#98 review finding 3). */
104
+ const TEARDOWN_POLL_MS = 150;
105
+ /** Poll `isAlive` every `TEARDOWN_POLL_MS` until it reports dead. If it is
106
+ * STILL alive once `timeoutMs` elapses, fire `onTimeout` exactly once (the
107
+ * next escalation rung) and keep polling so the caller can confirm that
108
+ * rung actually worked — up to `hardCeilingMs` total, after which this gives
109
+ * up (an unkillable/D-state descendant is out of scope for this primitive).
110
+ *
111
+ * Deliberately a recursive, NEVER-`.unref()`'d `setTimeout` rather than a
112
+ * fixed unref'd wait (crouter#98 review finding 3): most teardown callers
113
+ * are short-lived CLI subprocesses (`node lifecycle close`, `canvas prune`)
114
+ * that would otherwise exit before an unref'd SIGKILL timer ever fires,
115
+ * silently skipping the last-resort rung. Polling (instead of a blind fixed
116
+ * wait) is what keeps the happy path cheap despite being ref'd. */
117
+ function pollUntil(isAlive, timeoutMs, onTimeout, hardCeilingMs = timeoutMs + 5_000) {
118
+ const start = Date.now();
119
+ let escalated = false;
120
+ const tick = () => {
121
+ if (!isAlive())
122
+ return; // confirmed dead — stop polling
123
+ const elapsed = Date.now() - start;
124
+ if (!escalated && elapsed >= timeoutMs) {
125
+ escalated = true;
126
+ onTimeout();
127
+ }
128
+ if (elapsed >= hardCeilingMs)
129
+ return; // best-effort exhausted — give up
130
+ setTimeout(tick, TEARDOWN_POLL_MS);
131
+ };
132
+ tick();
133
+ }
134
+ /** Escalate teardown of a broker/tree that did not exit cleanly off the
135
+ * graceful `shutdown` frame (or was never reachable at all): SIGTERM the
136
+ * whole tree, then — once `BROKER_SIGKILL_GRACE_MS` has elapsed with the tree
137
+ * still alive — SIGKILL the whole tree as the last resort.
138
+ *
139
+ * Takes an ALREADY-CAPTURED `tree` (pid list) and `identities` (a
140
+ * `captureTeardownSnapshot` identity map over that same list) — both taken
141
+ * ONCE by `teardown()` synchronously at its very top, before EITHER
142
+ * escalation path (connect-error or post-shutdown-frame) can run, and
143
+ * threaded through here unchanged (crouter#98 review finding 1). This
144
+ * function must NEVER re-walk the process tree itself: `launch()` spawns the
145
+ * broker `detached: true`, so
146
+ * signaling the broker's OWN group (the first thing `killProcessTreePids`
147
+ * does) typically kills the broker itself almost immediately — and the
148
+ * kernel reparents any surviving child away from the broker's pid the
149
+ * INSTANT the broker exits, severing the very ppid link a fresh
150
+ * `descendantPids` walk depends on. Re-deriving descendants at ANY point
151
+ * after the broker may have exited (including a per-rung re-walk here, or a
152
+ * poll that re-derives instead of checking this fixed list) would silently
153
+ * "lose" a still-alive detached descendant — e.g. the pi bash tool's shell
154
+ * child, itself `detached: true` and thus its own group leader, distinct
155
+ * from the broker's group, so a plain `kill(-brokerPid)` never reaches it
156
+ * either (crouton-labs/crouter#98). `identities` guards each signal against
157
+ * PID REUSE across the multi-second escalation window (review finding 3) —
158
+ * see `killProcessTreePids`. `null` means the initial `captureTeardownSnapshot`
159
+ * probe itself failed (no baseline to compare against) — `killProcessTreePids`
160
+ * falls through to its normal unguarded signal path in that case, never
161
+ * reading a probe failure as proof the pid is gone or reused. */
162
+ function escalateBrokerTeardown(tree, identities) {
163
+ killProcessTreePids(tree, 'SIGTERM', identities);
164
+ pollUntil(() => isAnyPidAlive(tree), BROKER_SIGKILL_GRACE_MS, () => killProcessTreePids(tree, 'SIGKILL', identities));
165
+ }
93
166
  export const headlessBrokerHost = {
94
167
  launch(nodeId, inv, opts) {
95
168
  // Fast-fail known launch errors before we detach a broker process.
@@ -136,8 +209,51 @@ export const headlessBrokerHost = {
136
209
  // Graceful: connect to view.sock + send a `shutdown` frame → the broker
137
210
  // dispose()s the engine, unlinks the socket, exits 0. Status is already
138
211
  // flipped done/canceled by the caller (crash-safe ordering), so the daemon
139
- // won't revive. On connect failure (broker dead/crashed) fall back to a
140
- // SIGTERM of the broker pid + unlink the stale socket.
212
+ // won't revive. On connect failure (broker dead/crashed/wedged) fall back
213
+ // to escalateBrokerTeardown a tree-wide SIGTERM now, a tree-wide SIGKILL
214
+ // as the last resort — so no orphaned descendant of a dead/wedged broker
215
+ // survives the node (crouton-labs/crouter#98) — + unlink the stale socket.
216
+ //
217
+ // Capture the broker pid ONCE, synchronously, right here — never re-query
218
+ // `getNode(nodeId)` inside the async callbacks/timers below. Callers such
219
+ // as placement.ts's `reapIfEmpty()` call `teardown()` then immediately
220
+ // `deleteNode()` in the same tick; by the time an async callback fires the
221
+ // row can already be gone, so a re-query would silently resolve to
222
+ // `undefined` and skip escalation entirely (crouter#98 review finding 2).
223
+ const node = getNode(nodeId);
224
+ const pid = node?.pi_pid;
225
+ // Snapshot the full descendant TREE (crouter#98 review finding 1) — the
226
+ // root pid plus every transitive descendant — and a stable identity
227
+ // fingerprint for every pid in it (review finding 3), BOTH from ONE `ps`
228
+ // table read (`captureTeardownSnapshot`, final review: closes the small
229
+ // race between two separate probes), exactly ONCE, right here, while the
230
+ // broker (if alive) still holds parentage over any detached descendant.
231
+ // EVERY escalation path below — connect-error AND post-shutdown-frame —
232
+ // reuses this ONE fixed snapshot; neither may re-walk `ps` by ppid once
233
+ // the broker might have exited, because the kernel reparents any
234
+ // surviving child away from `pid` the INSTANT the broker exits, severing
235
+ // the ppid link a fresh walk depends on — exactly the reparenting hazard
236
+ // this fix closes. (If the broker is ALREADY dead when `teardown()`
237
+ // starts, its detached descendants were already reparented before we ever
238
+ // got here — unrecoverable by ppid, and out of scope: the #98 repro is
239
+ // the wedged-but-ALIVE broker, which this covers.)
240
+ //
241
+ // `node?.pi_pid_identity` is the LAUNCH-time baseline `recordPid` captured
242
+ // the instant this broker was spawned/bound (final review, Fix 1): if
243
+ // `pid` has ALREADY been recycled for an unrelated process before
244
+ // teardown ever ran — the pid-reuse hazard this guard exists for — the
245
+ // snapshot's `reused: true` comes back with an EMPTY tree, so nothing
246
+ // below ever signals the stranger (or walks its process tree looking for
247
+ // "descendants", which would be the stranger's children, not ours).
248
+ const snapshot = pid != null ? captureTeardownSnapshot(pid, node?.pi_pid_identity ?? null) : null;
249
+ if (snapshot?.reused === true) {
250
+ logger('host').warn('refusing to signal a reused pid — launch-time identity mismatch', {
251
+ nodeId,
252
+ pid,
253
+ });
254
+ }
255
+ const tree = snapshot?.tree ?? [];
256
+ const identities = snapshot?.identities ?? null;
141
257
  const sockPath = viewSocketPath(nodeId);
142
258
  const cleanupSocket = () => {
143
259
  try {
@@ -152,18 +268,11 @@ export const headlessBrokerHost = {
152
268
  let connected = false;
153
269
  sock.on('error', () => {
154
270
  // A post-connect error (broker exiting after we sent shutdown) is benign
155
- // and swallowed; only a connect failure triggers the SIGTERM fallback.
271
+ // and swallowed; only a connect failure triggers the escalation fallback.
156
272
  if (connected)
157
273
  return;
158
- const pid = getNode(nodeId)?.pi_pid;
159
- if (pid != null) {
160
- try {
161
- process.kill(pid, 'SIGTERM');
162
- }
163
- catch {
164
- /* already gone */
165
- }
166
- }
274
+ if (tree.length > 0)
275
+ escalateBrokerTeardown(tree, identities);
167
276
  cleanupSocket();
168
277
  });
169
278
  sock.once('connect', () => {
@@ -176,25 +285,31 @@ export const headlessBrokerHost = {
176
285
  }
177
286
  sock.end();
178
287
  cleanupSocket();
179
- // Bounded exit confirmation (review Mn-3): a broker that connects fine but
180
- // then HANGS inside session.dispose() (disposeAndExit catches a throw, not
181
- // a hang) would leak the process holding the sole .jsonl writer. Arm a
182
- // short UNREF'd timer; if the captured pid is still alive when it fires,
183
- // SIGTERM it. unref'd so the happy path adds NO latency a short-lived CLI
184
- // caller's loop is kept alive only by the still-open socket, i.e. precisely
185
- // the hung case where the fallback must run.
186
- const pid = getNode(nodeId)?.pi_pid;
187
- if (pid != null) {
188
- setTimeout(() => {
189
- if (isPidAlive(pid)) {
190
- try {
191
- process.kill(pid, 'SIGTERM');
192
- }
193
- catch {
194
- /* already gone */
195
- }
196
- }
197
- }, BROKER_SHUTDOWN_GRACE_MS).unref();
288
+ // Bounded exit confirmation: a broker that connects fine but then HANGS
289
+ // inside session.dispose() (disposeAndExit catches a throw, not a hang)
290
+ // would leak the process holding the sole .jsonl writer and any
291
+ // bash→test→app descendants it spawned along the way (crouter#98). Poll
292
+ // the whole process TREE (not just the broker's own pid) for up to
293
+ // `BROKER_SHUTDOWN_GRACE_MS`, UNCONDITIONAL on the broker's own liveness
294
+ // (crouter#98 review finding 1): a broker that exits cleanly can still
295
+ // leave a live descendant behind (dispose() partially failed, or never
296
+ // reached a detached SDK child), which is exactly the shape this
297
+ // closes. `pollUntil` is ref'd — not `.unref()`'d so a short-lived
298
+ // CLI caller stays alive long enough for the SIGKILL rung to actually
299
+ // fire, while the happy path (tree already dead) still pays only a
300
+ // couple of poll ticks.
301
+ //
302
+ // Checks `isAnyPidAlive(tree)` against the FIXED pre-signal snapshot,
303
+ // NEVER a fresh `isProcessTreeAlive`/`descendantPids` re-walk (crouter#98
304
+ // review finding 1, fixed): the broker can exit at any point during this
305
+ // grace window while leaving a detached descendant alive, and the
306
+ // instant it does, the kernel reparents that descendant away from `pid`
307
+ // — a re-walk-by-ppid would then find nothing and silently skip
308
+ // escalation, even though the descendant is still very much alive. The
309
+ // fixed `tree` snapshot (taken before the shutdown frame was even sent)
310
+ // has no such blind spot.
311
+ if (tree.length > 0) {
312
+ pollUntil(() => isAnyPidAlive(tree), BROKER_SHUTDOWN_GRACE_MS, () => escalateBrokerTeardown(tree, identities));
198
313
  }
199
314
  });
200
315
  },