@crouton-kit/crouter 0.3.39 → 0.3.41

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 (188) 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 +706 -733
  7. package/dist/clients/attach/chat-view.js +11 -0
  8. package/dist/clients/web/server.js +5 -28
  9. package/dist/clients/web/web-cmd.js +1 -1
  10. package/dist/commands/__tests__/revive-now-gate.test.js +60 -0
  11. package/dist/commands/canvas-rebuild-index.js +5 -5
  12. package/dist/commands/node-snapshot.js +9 -1
  13. package/dist/commands/node.js +37 -17
  14. package/dist/commands/push.js +8 -0
  15. package/dist/commands/revive.d.ts +10 -0
  16. package/dist/commands/revive.js +28 -10
  17. package/dist/commands/sys/__tests__/setup-core.test.js +19 -0
  18. package/dist/commands/sys/doctor.js +1 -1
  19. package/dist/commands/sys/setup-core.js +3 -2
  20. package/dist/commands/sys/setup.js +1 -1
  21. package/dist/commands/worktree.d.ts +2 -0
  22. package/dist/commands/worktree.js +94 -0
  23. package/dist/core/__tests__/boot.test.js +4 -4
  24. package/dist/core/__tests__/canvas.test.js +19 -7
  25. package/dist/core/__tests__/child-followup.test.js +15 -5
  26. package/dist/core/__tests__/daemon-boot.test.js +6 -1
  27. package/dist/core/__tests__/daemon-wedge.test.js +18 -1
  28. package/dist/core/__tests__/fault-classifier.test.js +30 -0
  29. package/dist/core/__tests__/fixtures/fake-engine.d.ts +13 -0
  30. package/dist/core/__tests__/fixtures/fake-engine.js +20 -0
  31. package/dist/core/__tests__/full/spike-harness.test.js +8 -5
  32. package/dist/core/__tests__/grace-clock.test.js +18 -2
  33. package/dist/core/__tests__/host-teardown-process-group.test.js +465 -0
  34. package/dist/core/__tests__/prune-to-limit.test.js +14 -0
  35. package/dist/core/__tests__/review-model-floor.test.js +32 -0
  36. package/dist/core/__tests__/revive.test.js +18 -4
  37. package/dist/core/__tests__/session-cycles.test.js +77 -0
  38. package/dist/core/__tests__/worktree.test.js +85 -0
  39. package/dist/core/canvas/boot.js +12 -7
  40. package/dist/core/canvas/canvas.d.ts +27 -8
  41. package/dist/core/canvas/canvas.js +54 -26
  42. package/dist/core/canvas/db.js +14 -0
  43. package/dist/core/canvas/history.js +1 -0
  44. package/dist/core/canvas/paths.d.ts +10 -9
  45. package/dist/core/canvas/paths.js +10 -9
  46. package/dist/core/canvas/pid.d.ts +155 -1
  47. package/dist/core/canvas/pid.js +306 -1
  48. package/dist/core/canvas/status-glyph.d.ts +7 -0
  49. package/dist/core/canvas/status-glyph.js +10 -1
  50. package/dist/core/canvas/types.d.ts +34 -0
  51. package/dist/core/fault-classifier.js +5 -1
  52. package/dist/core/runtime/bearings.js +4 -0
  53. package/dist/core/runtime/branded-host.d.ts +7 -0
  54. package/dist/core/runtime/branded-host.js +44 -17
  55. package/dist/core/runtime/broker-sdk.js +28 -68
  56. package/dist/core/runtime/broker.js +35 -4
  57. package/dist/core/runtime/host.d.ts +3 -3
  58. package/dist/core/runtime/host.js +148 -33
  59. package/dist/core/runtime/launch.d.ts +15 -15
  60. package/dist/core/runtime/launch.js +58 -4
  61. package/dist/core/runtime/naming.js +3 -2
  62. package/dist/core/runtime/nodes.d.ts +3 -1
  63. package/dist/core/runtime/nodes.js +1 -0
  64. package/dist/core/runtime/pi-cli.d.ts +2 -0
  65. package/dist/core/runtime/pi-cli.js +51 -0
  66. package/dist/core/runtime/placement.d.ts +1 -1
  67. package/dist/core/runtime/placement.js +5 -2
  68. package/dist/core/runtime/recap.js +2 -1
  69. package/dist/core/runtime/revive.d.ts +21 -15
  70. package/dist/core/runtime/revive.js +87 -52
  71. package/dist/core/runtime/session-cycles.d.ts +30 -0
  72. package/dist/core/runtime/session-cycles.js +77 -0
  73. package/dist/core/runtime/spawn.d.ts +4 -0
  74. package/dist/core/runtime/spawn.js +152 -100
  75. package/dist/core/runtime/tmux.d.ts +7 -10
  76. package/dist/core/runtime/tmux.js +9 -11
  77. package/dist/core/worktree.d.ts +35 -0
  78. package/dist/core/worktree.js +158 -0
  79. package/dist/daemon/crtrd.d.ts +17 -8
  80. package/dist/daemon/crtrd.js +191 -40
  81. package/dist/index.d.ts +3 -0
  82. package/dist/index.js +7 -0
  83. package/dist/pi-extensions/canvas-context-intro.d.ts +1 -0
  84. package/dist/pi-extensions/canvas-context-intro.js +34 -23
  85. package/dist/pi-extensions/canvas-stophook.js +11 -5
  86. package/dist/web-client/assets/{index-CbO8L0mN.js → index-B00YpRQ1.js} +20 -20
  87. package/dist/web-client/assets/index-DrkcvANq.css +2 -0
  88. package/dist/web-client/index.html +2 -2
  89. package/docs/compat/hearth-crtr-v1.md +191 -0
  90. package/docs/public-api.md +75 -0
  91. package/package.json +3 -4
  92. package/dist/core/__tests__/hearth-bootstrap.test.js +0 -136
  93. package/dist/core/hearth/__tests__/model-auth-guest.test.js +0 -151
  94. package/dist/core/hearth/config.d.ts +0 -3
  95. package/dist/core/hearth/config.js +0 -108
  96. package/dist/core/hearth/guest-env.d.ts +0 -9
  97. package/dist/core/hearth/guest-env.js +0 -27
  98. package/dist/core/hearth/index.d.ts +0 -4
  99. package/dist/core/hearth/index.js +0 -4
  100. package/dist/core/hearth/model-auth-guest.d.ts +0 -8
  101. package/dist/core/hearth/model-auth-guest.js +0 -430
  102. package/dist/core/hearth/provider.d.ts +0 -36
  103. package/dist/core/hearth/provider.js +0 -10
  104. package/dist/core/hearth/providers/__tests__/sweep-and-release.test.js +0 -254
  105. package/dist/core/hearth/providers/blaxel-bootstrap.d.ts +0 -12
  106. package/dist/core/hearth/providers/blaxel-bootstrap.js +0 -147
  107. package/dist/core/hearth/providers/blaxel-home.d.ts +0 -54
  108. package/dist/core/hearth/providers/blaxel-home.js +0 -386
  109. package/dist/core/hearth/providers/blaxel.d.ts +0 -36
  110. package/dist/core/hearth/providers/blaxel.js +0 -364
  111. package/dist/core/hearth/providers/types.d.ts +0 -93
  112. package/dist/core/hearth/types.d.ts +0 -155
  113. package/dist/hearth/control-plane/__tests__/error-serialization.test.js +0 -29
  114. package/dist/hearth/control-plane/__tests__/node-message.test.js +0 -60
  115. package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.d.ts +0 -1
  116. package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.js +0 -44
  117. package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.d.ts +0 -1
  118. package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.js +0 -49
  119. package/dist/hearth/control-plane/__tests__/relay-security.test.d.ts +0 -1
  120. package/dist/hearth/control-plane/__tests__/relay-security.test.js +0 -314
  121. package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.d.ts +0 -1
  122. package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.js +0 -133
  123. package/dist/hearth/control-plane/__tests__/trigger-delivery.test.d.ts +0 -1
  124. package/dist/hearth/control-plane/__tests__/trigger-delivery.test.js +0 -170
  125. package/dist/hearth/control-plane/__tests__/wake-roll.test.d.ts +0 -1
  126. package/dist/hearth/control-plane/__tests__/wake-roll.test.js +0 -230
  127. package/dist/hearth/control-plane/__tests__/webhook-ingress.test.d.ts +0 -1
  128. package/dist/hearth/control-plane/__tests__/webhook-ingress.test.js +0 -167
  129. package/dist/hearth/control-plane/config.d.ts +0 -21
  130. package/dist/hearth/control-plane/config.js +0 -77
  131. package/dist/hearth/control-plane/db.d.ts +0 -30
  132. package/dist/hearth/control-plane/db.js +0 -561
  133. package/dist/hearth/control-plane/hearth-target.d.ts +0 -23
  134. package/dist/hearth/control-plane/hearth-target.js +0 -68
  135. package/dist/hearth/control-plane/ingress/rate-limit.d.ts +0 -24
  136. package/dist/hearth/control-plane/ingress/rate-limit.js +0 -100
  137. package/dist/hearth/control-plane/ingress/route-store.d.ts +0 -31
  138. package/dist/hearth/control-plane/ingress/route-store.js +0 -61
  139. package/dist/hearth/control-plane/ingress/webhook-delivery-store.d.ts +0 -41
  140. package/dist/hearth/control-plane/ingress/webhook-delivery-store.js +0 -69
  141. package/dist/hearth/control-plane/ingress/webhook-route.d.ts +0 -55
  142. package/dist/hearth/control-plane/ingress/webhook-route.js +0 -285
  143. package/dist/hearth/control-plane/main.d.ts +0 -1
  144. package/dist/hearth/control-plane/main.js +0 -88
  145. package/dist/hearth/control-plane/node-message.d.ts +0 -31
  146. package/dist/hearth/control-plane/node-message.js +0 -98
  147. package/dist/hearth/control-plane/register.d.ts +0 -15
  148. package/dist/hearth/control-plane/register.js +0 -34
  149. package/dist/hearth/control-plane/registry.d.ts +0 -22
  150. package/dist/hearth/control-plane/registry.js +0 -168
  151. package/dist/hearth/control-plane/relay.d.ts +0 -44
  152. package/dist/hearth/control-plane/relay.js +0 -711
  153. package/dist/hearth/control-plane/scheduler/fire-store.d.ts +0 -36
  154. package/dist/hearth/control-plane/scheduler/fire-store.js +0 -73
  155. package/dist/hearth/control-plane/scheduler/recurrence.d.ts +0 -7
  156. package/dist/hearth/control-plane/scheduler/recurrence.js +0 -58
  157. package/dist/hearth/control-plane/scheduler/scan-loop.d.ts +0 -38
  158. package/dist/hearth/control-plane/scheduler/scan-loop.js +0 -138
  159. package/dist/hearth/control-plane/scheduler/schedule-store.d.ts +0 -32
  160. package/dist/hearth/control-plane/scheduler/schedule-store.js +0 -66
  161. package/dist/hearth/control-plane/secrets.d.ts +0 -31
  162. package/dist/hearth/control-plane/secrets.js +0 -134
  163. package/dist/hearth/control-plane/server.d.ts +0 -27
  164. package/dist/hearth/control-plane/server.js +0 -482
  165. package/dist/hearth/control-plane/serving.d.ts +0 -15
  166. package/dist/hearth/control-plane/serving.js +0 -106
  167. package/dist/hearth/control-plane/session.d.ts +0 -68
  168. package/dist/hearth/control-plane/session.js +0 -273
  169. package/dist/hearth/control-plane/triggers/acl.d.ts +0 -14
  170. package/dist/hearth/control-plane/triggers/acl.js +0 -52
  171. package/dist/hearth/control-plane/triggers/audit-store.d.ts +0 -38
  172. package/dist/hearth/control-plane/triggers/audit-store.js +0 -79
  173. package/dist/hearth/control-plane/triggers/deliver.d.ts +0 -43
  174. package/dist/hearth/control-plane/triggers/deliver.js +0 -76
  175. package/dist/hearth/control-plane/triggers/envelope.d.ts +0 -29
  176. package/dist/hearth/control-plane/triggers/envelope.js +0 -38
  177. package/dist/hearth/control-plane/types.d.ts +0 -86
  178. package/dist/hearth/control-plane/types.js +0 -1
  179. package/dist/hearth/control-plane/wake.d.ts +0 -86
  180. package/dist/hearth/control-plane/wake.js +0 -550
  181. package/dist/web-client/assets/index-DwO46Cs5.css +0 -2
  182. /package/dist/{core/__tests__/hearth-bootstrap.test.d.ts → commands/__tests__/revive-now-gate.test.d.ts} +0 -0
  183. /package/dist/{core/hearth/__tests__/model-auth-guest.test.d.ts → commands/sys/__tests__/setup-core.test.d.ts} +0 -0
  184. /package/dist/core/{hearth/providers/__tests__/sweep-and-release.test.d.ts → __tests__/fault-classifier.test.d.ts} +0 -0
  185. /package/dist/core/{hearth/providers/types.js → __tests__/host-teardown-process-group.test.d.ts} +0 -0
  186. /package/dist/core/{hearth/types.js → __tests__/review-model-floor.test.d.ts} +0 -0
  187. /package/dist/{hearth/control-plane/__tests__/error-serialization.test.d.ts → core/__tests__/session-cycles.test.d.ts} +0 -0
  188. /package/dist/{hearth/control-plane/__tests__/node-message.test.d.ts → core/__tests__/worktree.test.d.ts} +0 -0
@@ -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
  },
@@ -37,17 +37,6 @@ export interface EquivalentProviderModel {
37
37
  * is per-turn state: once a provider has produced a retryable outage error, do
38
38
  * not bounce back to it or keep reattempting the same failed provider. */
39
39
  export declare function equivalentOtherProviderModel(currentModel: string, failedProviders?: ReadonlySet<ModelProvider>): EquivalentProviderModel | null;
40
- /** Resolve a persona/caller `model:` token to the concrete `model:thinking`
41
- * spec pi gets via `--model`, in order:
42
- * 1. `provider/rest` — apply strength aliases to `rest`; if `provider` is
43
- * `anthropic`/`openai` AND `rest` is a strength, return that ladder cell
44
- * (the word `openai` maps to the openai-codex ladder). Otherwise it is a
45
- * concrete `provider/id` (incl. any `:thinking` suffix) → pass through.
46
- * 2. Bare strength (incl. aliases normal/weak) → the default-provider ladder
47
- * cell (config default provider, then CRTR_MODEL_PROVIDER, else anthropic).
48
- * 3. Bare family alias (opus/sonnet/haiku) → the Anthropic ladder's
49
- * strong/medium/light, ALWAYS anthropic (ignores the env/config default).
50
- * 4. Anything else → pass through unchanged. */
51
40
  export declare function normalizeModel(model: string): string;
52
41
  /** The minimal static system prompt every node launches with — a thin launcher
53
42
  * frame keeping the `\n\nGuidelines:` anchor the `before_agent_start` substrate
@@ -101,20 +90,26 @@ export interface PiInvocation {
101
90
  * `headlessBrokerHost`'s preflight (host.ts) fatally rejects a bare
102
91
  * `--session` id before ever spawning the broker, so a real revive only
103
92
  * ever resumes by path (or falls through to fresh when no path exists).
104
- * - refresh-yield: fresh again (no resume) the node re-reads its roadmap. */
93
+ * - refresh-yield: same `.jsonl` (`resumeSessionPath`) PLUS `newCycle`
94
+ * emits the valueless `--crtr-new-cycle` token alongside `--session`. The
95
+ * broker resets the session-tree leaf and roots the new cycle as a fresh
96
+ * sibling branch instead of starting a new file; the node re-reads its
97
+ * roadmap exactly as a fresh launch would. */
105
98
  export declare function buildPiArgv(meta: NodeMeta, opts?: {
106
99
  prompt?: string;
107
100
  resumeSessionId?: string;
108
101
  resumeSessionPath?: string;
109
102
  forkFrom?: string;
103
+ newCycle?: boolean;
110
104
  }): PiInvocation;
111
105
  /** The pi-SDK launch config the headless broker drives an in-process engine
112
106
  * with — the structural inverse of `buildPiArgv`'s flag vocabulary. Each field
113
107
  * maps one of buildPiArgv's emitted flags back to its SDK option:
114
108
  * `-e`→`extensionPaths`, `-n`→`editorName`, `--fork`→`forkFrom`,
115
- * `--session`→`resumeSessionPath|resumeSessionId`, `--model`→`model`,
116
- * `--tools`→`tools`, `--append-system-prompt`→`appendSystemPromptPath`, the
117
- * trailing positional→`firstPrompt`. */
109
+ * `--session`→`resumeSessionPath|resumeSessionId`, `--crtr-new-cycle`→
110
+ * `newCycle`, `--model`→`model`, `--tools`→`tools`,
111
+ * `--append-system-prompt`→`appendSystemPromptPath`, the trailing
112
+ * positional→`firstPrompt`. */
118
113
  export interface BrokerSdkConfig {
119
114
  /** The node's pinned working dir (from `CRTR_NODE_CWD`, else `process.cwd()`). */
120
115
  cwd: string;
@@ -130,6 +125,11 @@ export interface BrokerSdkConfig {
130
125
  * caller sets it — revive resumes by absolute path only, and the broker
131
126
  * preflight rejects a bare `--session`. */
132
127
  resumeSessionId?: string;
128
+ /** A refresh-yield's marker: reset the session-tree leaf and root a fresh
129
+ * cycle as a new branch of the SAME resumed `.jsonl`, instead of starting a
130
+ * new session file (`--crtr-new-cycle`). Only meaningful alongside
131
+ * `resumeSessionPath`; never set together with `forkFrom`. */
132
+ newCycle?: boolean;
133
133
  /** Model spec, e.g. `anthropic/sonnet` (`--model`). */
134
134
  model?: string;
135
135
  /** Tool allowlist (`--tools a,b,c` → `['a','b','c']`). */