@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
@@ -0,0 +1,77 @@
1
+ // Cycle-aware session history — one node, one `.jsonl`, many cycles.
2
+ //
3
+ // A refresh-yield does not start a new session file: reviveNode reuses the
4
+ // node's existing `.jsonl` (buildPiArgv's `--crtr-new-cycle`), and the broker
5
+ // resets the session-tree leaf, appending a `crtr-cycle` custom entry as the
6
+ // new cycle's ROOT. Custom entries never participate in LLM context, so the
7
+ // refreshed window is exactly as clean as a brand-new session — but every
8
+ // cycle lives in the same tree as a sibling root branch, so the /tree
9
+ // navigator shows previous cycles natively.
10
+ //
11
+ // `cycleAwareMessages()` is the display-side counterpart: it rebuilds the FULL
12
+ // node history — each prior cycle's messages (via pi's own `buildSessionContext`,
13
+ // so a compacted prior cycle renders correctly), a synthetic divider message per
14
+ // boundary, then the live cycle's context — so the broker welcome snapshot
15
+ // (broker.ts) and the dormant snapshot (node-snapshot.ts) both render one
16
+ // continuous conversation.
17
+ import { buildSessionContext } from '@earendil-works/pi-coding-agent';
18
+ /** customType of the persisted tree entry that ROOTS each post-yield cycle.
19
+ * Data payload: `{ cycle, fromLeaf }` — `cycle` is the node's cycle counter at
20
+ * relaunch, `fromLeaf` the previous cycle's leaf entry id. */
21
+ export const CRTR_CYCLE_CUSTOM_TYPE = 'crtr-cycle';
22
+ /** customType of the SYNTHETIC divider message spliced between cycles in
23
+ * snapshot rendering. Never persisted to the session file. */
24
+ export const CRTR_CYCLE_DIVIDER_CUSTOM_TYPE = 'crtr-cycle-divider';
25
+ function cycleDivider(cycle, timestamp) {
26
+ const label = cycle !== undefined ? `cycle ${cycle}` : 'new cycle';
27
+ return {
28
+ role: 'custom',
29
+ customType: CRTR_CYCLE_DIVIDER_CUSTOM_TYPE,
30
+ content: `yielded — ${label} begins with a fresh context window`,
31
+ display: true,
32
+ timestamp: new Date(timestamp).getTime(),
33
+ };
34
+ }
35
+ /** The full multi-cycle message history for display: prior cycles (oldest
36
+ * first, each closed by a divider), then the live cycle's context. Falls back
37
+ * to plain `buildSessionContext()` when the session has no cycle markers, when
38
+ * the manager lacks tree accessors (fake engine), or when the current leaf was
39
+ * tree-navigated back into a pre-yield branch (pi's active-branch semantics
40
+ * then apply unchanged). */
41
+ export function cycleAwareMessages(sm) {
42
+ const current = sm.buildSessionContext().messages;
43
+ if (typeof sm.getEntries !== 'function' || typeof sm.getBranch !== 'function')
44
+ return current;
45
+ // File order = append order = chronological cycle order.
46
+ const entries = sm.getEntries();
47
+ const markers = entries.filter((e) => e.type === 'custom' && e.customType === CRTR_CYCLE_CUSTOM_TYPE);
48
+ if (markers.length === 0)
49
+ return current;
50
+ // The current cycle's root: first entry on the leaf→root path. When it is
51
+ // not a cycle marker, the leaf sits in the ORIGINAL (pre-yield) branch —
52
+ // render just that branch. Otherwise replay every cycle up to and including
53
+ // the one the marker closes.
54
+ const currentRootId = sm.getBranch()[0]?.id;
55
+ const rootMarkerIndex = markers.findIndex((m) => m.id === currentRootId);
56
+ if (rootMarkerIndex === -1)
57
+ return current;
58
+ // Shared byId map — buildSessionContext would otherwise rebuild one from
59
+ // `entries` on every call; one pass here amortizes it across every replayed
60
+ // marker.
61
+ const byId = new Map(entries.map((e) => [e.id, e]));
62
+ const out = [];
63
+ for (let i = 0; i <= rootMarkerIndex; i++) {
64
+ const marker = markers[i];
65
+ const fromLeaf = marker.data?.fromLeaf;
66
+ if (typeof fromLeaf === 'string' && fromLeaf !== '') {
67
+ // Replay this prior cycle's own branch through pi's canonical context
68
+ // builder — preserving compaction semantics (one compactionSummary +
69
+ // kept + post-compaction messages only) exactly like the live cycle
70
+ // below.
71
+ out.push(...buildSessionContext(entries, fromLeaf, byId).messages);
72
+ }
73
+ out.push(cycleDivider(marker.data?.cycle, marker.timestamp));
74
+ }
75
+ out.push(...current);
76
+ return out;
77
+ }
@@ -54,6 +54,10 @@ export interface SpawnChildOpts {
54
54
  * choice at fire time (an explicit value re-validates; inheritance re-reads
55
55
  * the spawner's THEN-current profile). */
56
56
  profile?: string | null;
57
+ /** Create a managed worktree for this node and pin its cwd to it. */
58
+ worktree?: boolean;
59
+ /** Preallocated node id, used when a managed worktree must be named before birth. */
60
+ nodeId?: string;
57
61
  }
58
62
  /** Resolve a `--fork-from` value to an ABSOLUTE `.jsonl` source path for the
59
63
  * broker fork (`SessionManager.forkFrom`, which loads a file — never a bare id).
@@ -13,15 +13,18 @@ import { spawnSync } from 'node:child_process';
13
13
  import { readdirSync, existsSync } from 'node:fs';
14
14
  import { isAbsolute, resolve, join } from 'node:path';
15
15
  import { homedir } from 'node:os';
16
- import { spawnNode, currentNodeContext, rootOfSpine } from './nodes.js';
16
+ import { spawnNode, currentNodeContext, rootOfSpine, newNodeId } from './nodes.js';
17
17
  import { loadProfileManifest } from '../profiles/manifest.js';
18
18
  import { selectProfileForCwd } from '../profiles/select.js';
19
19
  import { buildLaunchSpec, buildPiArgv } from './launch.js';
20
+ import { gitSync } from '../git.js';
21
+ import { createManagedWorktree } from '../worktree.js';
22
+ import { usage } from '../errors.js';
20
23
  import { writeGoal } from './kickoff.js';
21
24
  import { hasRoadmap, seedRoadmap } from './roadmap.js';
22
25
  import { buildIdentityAssertion, buildWakeBearings } from './bearings.js';
23
26
  import { installMenuBinding, installNavBindings, installViewNavBindings } from './tmux-chrome.js';
24
- import { getNode, fullName } from '../canvas/index.js';
27
+ import { getNode, fullName, recordPid } from '../canvas/index.js';
25
28
  import { registerViewerFocus, openViewerWindow, waitForBrokerViewSocket, viewerSplitEnv, windowOfPane, currentTmux, inTmux, focusWindow, } from './placement.js';
26
29
  import { transition } from './lifecycle.js';
27
30
  import { headlessBrokerHost } from './host.js';
@@ -134,6 +137,7 @@ export async function bootRoot(opts) {
134
137
  transition(meta.node_id, 'crash');
135
138
  throw new Error(`failed to launch the broker engine for the front-door root ${meta.node_id} — nothing to attach to.`);
136
139
  }
140
+ recordPid(meta.node_id, placed.pid);
137
141
  // The terminal has nothing to attach to until view.sock accepts. The root is
138
142
  // useless without its engine, so a socket timeout IS fatal here (unlike a
139
143
  // background child's optional viewer, which is skipped on timeout).
@@ -170,7 +174,9 @@ function piSessionsRoot() {
170
174
  function resolveSessionUuid(uuid) {
171
175
  const sessionsRoot = piSessionsRoot();
172
176
  if (!existsSync(sessionsRoot)) {
173
- throw new Error(`--fork-from '${uuid}': no pi sessions store at ${sessionsRoot} to resolve it from — pass a node id or an absolute .jsonl path.`);
177
+ throw usage(`--fork-from '${uuid}': no pi sessions store at ${sessionsRoot} to resolve it from — pass a node id or an absolute .jsonl path.`, {
178
+ next: 'Pass a live node id, an absolute .jsonl session path, or drop --fork-from.',
179
+ });
174
180
  }
175
181
  const matches = [];
176
182
  for (const dir of readdirSync(sessionsRoot, { withFileTypes: true })) {
@@ -187,10 +193,15 @@ function resolveSessionUuid(uuid) {
187
193
  }
188
194
  const exact = matches.filter((m) => m.id === uuid);
189
195
  const hit = exact.length > 0 ? exact : matches.filter((m) => m.id.startsWith(uuid));
190
- if (hit.length === 0)
191
- throw new Error(`--fork-from '${uuid}': no pi session with that id found.`);
196
+ if (hit.length === 0) {
197
+ throw usage(`--fork-from '${uuid}': no pi session with that id found.`, {
198
+ next: 'Pass a live node id, a real session uuid/prefix, or an absolute .jsonl session path.',
199
+ });
200
+ }
192
201
  if (hit.length > 1) {
193
- throw new Error(`--fork-from '${uuid}': ambiguous — ${hit.length} sessions match that id prefix; pass the full id or the .jsonl path.`);
202
+ throw usage(`--fork-from '${uuid}': ambiguous — ${hit.length} sessions match that id prefix; pass the full id or the .jsonl path.`, {
203
+ next: 'Pass the full session uuid or an absolute .jsonl path to disambiguate.',
204
+ });
194
205
  }
195
206
  return hit[0].path;
196
207
  }
@@ -203,7 +214,7 @@ function resolveSessionUuid(uuid) {
203
214
  export function resolveForkSource(value) {
204
215
  const v = value.trim();
205
216
  if (v === '')
206
- throw new Error('--fork-from requires a node id, session file, or session uuid.');
217
+ throw usage('--fork-from requires a node id, session file, or session uuid.', { next: 'Pass a live node id, an absolute .jsonl session path, or a session uuid/prefix.' });
207
218
  // A path (contains `/` or ends `.jsonl`) is a session file — ensure it is
208
219
  // ABSOLUTE so the broker, launched in a different cwd, still resolves it.
209
220
  if (v.includes('/') || v.endsWith('.jsonl')) {
@@ -215,7 +226,9 @@ export function resolveForkSource(value) {
215
226
  if (n !== null) {
216
227
  const src = n.pi_session_file;
217
228
  if (src === undefined || src === null || src === '') {
218
- throw new Error(`node ${v} has no pi session yet — it has not captured a session FILE to fork from (the broker forks a .jsonl path, not a bare id).`);
229
+ throw usage(`node ${v} has no pi session yet — it has not captured a session FILE to fork from (the broker forks a .jsonl path, not a bare id).`, {
230
+ next: 'Pick a node that has captured a session, or drop --fork-from.',
231
+ });
219
232
  }
220
233
  return src;
221
234
  }
@@ -279,102 +292,141 @@ export async function spawnChild(opts) {
279
292
  // Born WITHOUT a name — the canvas-goal-capture extension names it async from
280
293
  // its first message (the kickoff task) inside its own pi process, so spawn
281
294
  // never blocks on the LLM naming round-trip (the 2-3s freeze it used to cost).
295
+ const wantsWorktree = opts.worktree === true;
296
+ const nodeId = wantsWorktree ? (opts.nodeId ?? newNodeId()) : opts.nodeId;
297
+ // Preflight: resolve every fallible input BEFORE creating the worktree or the
298
+ // node. `--fork-from <bad>` (resolveForkSource) and an invalid `--profile`
299
+ // (resolveProfileId) must fail HERE, before anything exists to clean up or
300
+ // strand — catching them later (after the worktree/node are created) is what
301
+ // let `--worktree --fork-from <bad>` leave an active node pinned to a deleted
302
+ // cwd with an open managed_worktree.
282
303
  const profileId = await resolveProfileId(opts.profile, spawner, opts.cwd);
283
- const meta = spawnNode({
284
- kind: opts.kind,
285
- mode,
286
- lifecycle,
287
- cwd: opts.cwd,
288
- name: opts.name ?? opts.kind,
289
- // A root has no spine parent (top-level, nobody subscribes); it still
290
- // records spawned_by=spawner when a node (not a human shell) spawned it.
291
- // A child's parent IS its manager.
292
- parent: root ? null : spawner,
293
- spawnedBy: root ? (spawner ?? undefined) : undefined,
294
- profile_id: profileId,
295
- // Persist the RAW fork reference (not the resolved path) as provenance, so
296
- // the boot intro can detect this is a fork and re-assert the node's own
297
- // identity over the source's copied-in conversation.
298
- forkFrom: opts.forkFrom,
299
- modelOverride: opts.model,
300
- launch,
301
- });
302
- // Persist the task as the child's goal for a fresh revive to re-read.
303
- writeGoal(meta.node_id, opts.prompt);
304
- // A fork copies an existing conversation into this child's first session
305
- // (resolved to an absolute file path when forking from a node). Resolved here
306
- // — not in buildPiArgv — so a bad reference fails the spawn loudly before any
307
- // window opens, rather than after pi is already booting.
308
304
  const forkFrom = opts.forkFrom !== undefined ? resolveForkSource(opts.forkFrom) : undefined;
309
- // A fork inherits the SOURCE node's entire first-person conversation, so the
310
- // identity re-assertion must ride the STRONGEST channel too, not only the
311
- // session-start bearings (a trailing custom_message the model weighs only by
312
- // recency). Prepend it to the kickoff PROMPT — the message that actually
313
- // triggers the fork's first turn, so the model acts on "you are node X, a
314
- // FORK of <source>, NOT them" as live instruction. When the daemon births this
315
- // node from a scheduled `spawn` wake, the <crtr-wake> provenance block leads
316
- // (so "a timer birthed you" precedes the task) — mirroring the fork-identity
317
- // prepend. Only the pi argv prompt is reframed; the persisted goal (writeGoal
318
- // above) keeps the raw task.
319
- const wakeBlock = opts.wakeOrigin !== undefined ? `${buildWakeBearings(opts.wakeOrigin)}\n\n` : '';
320
- const idBlock = forkFrom !== undefined ? `${buildIdentityAssertion(meta.node_id)}\n\n` : '';
321
- const kickoff = `${wakeBlock}${idBlock}${opts.prompt}`;
322
- // A child created DIRECTLY as an orchestrator (mode='orchestrator') boots
323
- // with the orchestrator persona but bypasses promote(), which is where a
324
- // roadmap scaffold would normally be seeded. Lay one down here (goal
325
- // pre-filled from the task) so the orchestrator has its memory artifact from
326
- // birth, instead of waking memory-less. Guarded so it never clobbers.
327
- if (mode === 'orchestrator' && !hasRoadmap(meta.node_id)) {
328
- seedRoadmap(meta.node_id, { goal: opts.prompt.trim() });
329
- }
330
- // (The three scoped long-term memory stores are seeded for EVERY node at birth
331
- // in spawnNode — no orchestrator-gated seeding needed here.)
332
- // A --root spawned from inside tmux opens its one viewer in the CALLER'S
333
- // CURRENT session (below), so it appears where the spawner is working; a
334
- // managed child is a headless broker with NO viewer at all.
335
- const here = root ? currentTmux() : null;
336
- const inv = buildPiArgv(meta, { prompt: kickoff, forkFrom });
337
- // CRTR_SUBTREE (the spine root) rides the detached broker's env so it can group
338
- // the subtree; the host sets CRTR_FRONT_DOOR itself.
339
- inv.env = { ...inv.env, CRTR_SUBTREE: rootOfSpine(meta.node_id) };
340
- // Broker is the only host: launch the detached broker ENGINE. A managed child
341
- // gets NO viewer spawning it returns once the engine is launched and a human
342
- // opens a viewer on demand via `crtr node focus` / surface attach. A --root gets
343
- // one foreground viewer below.
344
- const placed = launchBrokerOrCrash(meta.node_id, inv, {
345
- cwd: meta.cwd,
346
- name: fullName(meta),
347
- resuming: false,
348
- });
349
- // Definitive failure: no broker pid the node has no engine. Crash it (so the
350
- // daemon doesn't watch a zombie 'active') and throw so `crtr node new` exits
351
- // non-zero. transition('crash') is a legal from-LIVE move (still 'active' from
352
- // spawnNode). Mirrors the old window===null crash.
353
- if (placed.pid === null) {
354
- transition(meta.node_id, 'crash');
355
- throw new Error(`failed to launch the broker engine for ${meta.node_id} (${meta.name}) the node was not started.`);
356
- }
357
- // A --root is spawned to be DRIVEN directly: when the caller is inside tmux,
358
- // open ONE foreground viewer in the caller's CURRENT session and bring it
359
- // forefront so whoever asked for it picks up the conversation. Outside tmux a
360
- // --root opens no viewer (an agent handing a root off) never a throw. A
361
- // managed child opens nothing. Non-fatal if tmux refuses (openViewerWindow
362
- // returns null) or the socket never binds `focus` opens a viewer later.
363
- let window = null;
364
- let session = null;
365
- if (root && here !== null && await waitForBrokerViewSocket(meta.node_id, placed.exited)) {
366
- const viewer = openViewerWindow(meta.node_id, here.session, { name: fullName(meta), cwd: opts.cwd });
367
- if (viewer !== null && viewer.pane !== null && viewer.pane !== '') {
368
- session = here.session;
369
- window = windowOfPane(viewer.pane);
370
- if (window !== null) {
371
- try {
372
- focusWindow(here.session, window);
305
+ let managedWorktree;
306
+ let spawnCwd = opts.cwd;
307
+ try {
308
+ if (wantsWorktree) {
309
+ managedWorktree = createManagedWorktree(opts.cwd, nodeId);
310
+ spawnCwd = managedWorktree.path;
311
+ }
312
+ const meta = spawnNode({
313
+ kind: opts.kind,
314
+ mode,
315
+ lifecycle,
316
+ cwd: spawnCwd,
317
+ name: opts.name ?? opts.kind,
318
+ // A root has no spine parent (top-level, nobody subscribes); it still
319
+ // records spawned_by=spawner when a node (not a human shell) spawned it.
320
+ // A child's parent IS its manager.
321
+ parent: root ? null : spawner,
322
+ spawnedBy: root ? (spawner ?? undefined) : undefined,
323
+ profile_id: profileId,
324
+ // Persist the RAW fork reference (not the resolved path) as provenance, so
325
+ // the boot intro can detect this is a fork and re-assert the node's own
326
+ // identity over the source's copied-in conversation.
327
+ forkFrom: opts.forkFrom,
328
+ modelOverride: opts.model,
329
+ ...(managedWorktree !== undefined ? { managedWorktree } : {}),
330
+ ...(nodeId !== undefined ? { nodeId } : {}),
331
+ launch,
332
+ });
333
+ // Persist the task as the child's goal for a fresh revive to re-read.
334
+ writeGoal(meta.node_id, opts.prompt);
335
+ // A fork inherits the SOURCE node's entire first-person conversation, so the
336
+ // identity re-assertion must ride the STRONGEST channel too, not only the
337
+ // session-start bearings (a trailing custom_message the model weighs only by
338
+ // recency). Prepend it to the kickoff PROMPT the message that actually
339
+ // triggers the fork's first turn, so the model acts on "you are node X, a
340
+ // FORK of <source>, NOT them" as live instruction. When the daemon births this
341
+ // node from a scheduled `spawn` wake, the <crtr-wake> provenance block leads
342
+ // (so "a timer birthed you" precedes the task) — mirroring the fork-identity
343
+ // prepend. Only the pi argv prompt is reframed; the persisted goal (writeGoal
344
+ // above) keeps the raw task.
345
+ const wakeBlock = opts.wakeOrigin !== undefined ? `${buildWakeBearings(opts.wakeOrigin)}\n\n` : '';
346
+ const idBlock = forkFrom !== undefined ? `${buildIdentityAssertion(meta.node_id)}\n\n` : '';
347
+ const kickoff = `${wakeBlock}${idBlock}${opts.prompt}`;
348
+ // A child created DIRECTLY as an orchestrator (mode='orchestrator') boots
349
+ // with the orchestrator persona but bypasses promote(), which is where a
350
+ // roadmap scaffold would normally be seeded. Lay one down here (goal
351
+ // pre-filled from the task) so the orchestrator has its memory artifact from
352
+ // birth, instead of waking memory-less. Guarded so it never clobbers.
353
+ if (mode === 'orchestrator' && !hasRoadmap(meta.node_id)) {
354
+ seedRoadmap(meta.node_id, { goal: opts.prompt.trim() });
355
+ }
356
+ // (The three scoped long-term memory stores are seeded for EVERY node at birth
357
+ // in spawnNode no orchestrator-gated seeding needed here.)
358
+ // A --root spawned from inside tmux opens its one viewer in the CALLER'S
359
+ // CURRENT session (below), so it appears where the spawner is working; a
360
+ // managed child is a headless broker with NO viewer at all.
361
+ const here = root ? currentTmux() : null;
362
+ const inv = buildPiArgv(meta, { prompt: kickoff, forkFrom });
363
+ // CRTR_SUBTREE (the spine root) rides the detached broker's env so it can group
364
+ // the subtree; the host sets CRTR_FRONT_DOOR itself.
365
+ inv.env = { ...inv.env, CRTR_SUBTREE: rootOfSpine(meta.node_id) };
366
+ // Broker is the only host: launch the detached broker ENGINE. A managed child
367
+ // gets NO viewer — spawning it returns once the engine is launched and a human
368
+ // opens a viewer on demand via `crtr node focus` / surface attach. A --root gets
369
+ // one foreground viewer below.
370
+ const placed = launchBrokerOrCrash(meta.node_id, inv, {
371
+ cwd: meta.cwd,
372
+ name: fullName(meta),
373
+ resuming: false,
374
+ });
375
+ // Definitive failure: no broker pid ⇒ the node has no engine. Crash it (so the
376
+ // daemon doesn't watch a zombie 'active') and throw so `crtr node new` exits
377
+ // non-zero. transition('crash') is a legal from-LIVE move (still 'active' from
378
+ // spawnNode). Mirrors the old window===null crash.
379
+ if (placed.pid === null) {
380
+ transition(meta.node_id, 'crash');
381
+ throw new Error(`failed to launch the broker engine for ${meta.node_id} (${meta.name}) — the node was not started.`);
382
+ }
383
+ recordPid(meta.node_id, placed.pid);
384
+ // A --root is spawned to be DRIVEN directly: when the caller is inside tmux,
385
+ // open ONE foreground viewer in the caller's CURRENT session and bring it
386
+ // forefront so whoever asked for it picks up the conversation. Outside tmux a
387
+ // --root opens no viewer (an agent handing a root off) — never a throw. A
388
+ // managed child opens nothing. Non-fatal if tmux refuses (openViewerWindow
389
+ // returns null) or the socket never binds — `focus` opens a viewer later.
390
+ let window = null;
391
+ let session = null;
392
+ if (root && here !== null && await waitForBrokerViewSocket(meta.node_id, placed.exited)) {
393
+ const viewer = openViewerWindow(meta.node_id, here.session, { name: fullName(meta), cwd: opts.cwd });
394
+ if (viewer !== null && viewer.pane !== null && viewer.pane !== '') {
395
+ session = here.session;
396
+ window = windowOfPane(viewer.pane);
397
+ if (window !== null) {
398
+ try {
399
+ focusWindow(here.session, window);
400
+ }
401
+ catch { /* best-effort */ }
373
402
  }
374
- catch { /* best-effort */ }
375
403
  }
376
404
  }
405
+ const saved = getNode(meta.node_id);
406
+ return { node: saved, window, session };
407
+ }
408
+ catch (err) {
409
+ // Cleanup scope: a managed worktree is only removed on failure while NO
410
+ // persisted node identity exists yet. spawnNode() persists the node row
411
+ // (and its meta files) BEFORE the later fallible steps in this try block
412
+ // (writeGoal, roadmap seeding, launch, viewer placement) — so a flag set
413
+ // after spawnNode() merely returns would still read false if one of THOSE
414
+ // later steps threw, and this catch would then delete the worktree out
415
+ // from under an already-persisted node whose cwd points at it. Check the
416
+ // canvas directly instead: once any node row exists for this id, keep the
417
+ // worktree — the node stays alive (crashed, if the launch itself failed)
418
+ // with its worktree intact rather than pinned to a deleted path.
419
+ if (managedWorktree !== undefined && nodeId !== undefined && getNode(nodeId) === null) {
420
+ const repoRoot = managedWorktree.repo_root;
421
+ try {
422
+ gitSync(['worktree', 'remove', managedWorktree.path], repoRoot);
423
+ }
424
+ catch { /* best-effort */ }
425
+ try {
426
+ gitSync(['branch', '-d', managedWorktree.branch], repoRoot);
427
+ }
428
+ catch { /* best-effort */ }
429
+ }
430
+ throw err;
377
431
  }
378
- const saved = getNode(meta.node_id);
379
- return { node: saved, window, session };
380
432
  }
@@ -164,16 +164,13 @@ export declare function respawnPaneSync(opts: RespawnPaneOpts): boolean;
164
164
  /** Turn a pi argv array into a single shell command string.
165
165
  *
166
166
  * The binary defaults to `CRTR_PI_BINARY` when that env var is set, else the
167
- * literal `pi`. This is a TEST-ONLY substitution seam: when CRTR_PI_BINARY is
168
- * unset (every production path) the behavior is byte-identical to exec'ing
169
- * `pi`. The integration-test harness points it at a deterministic fake-pi
170
- * vehicle so a real `crtr node new` reaches the fake instead of the LLM `pi`,
171
- * without any dependence on tmux/shell PATH inheritance the substitution is
172
- * baked into the command string at build time, in the process that calls
173
- * piCommand. An explicit `binary` arg still overrides the env (no caller passes
174
- * one today). The value may be a multi-word launcher (e.g. `node --import
175
- * tsx/esm host.ts`); only the argv entries are shell-quoted, so a multi-word
176
- * binary is spliced verbatim ahead of them. */
167
+ * bundled pi CLI launched with the current Node binary. CRTR_PI_BINARY is a
168
+ * TEST-ONLY substitution seam: the integration-test harness points it at a
169
+ * deterministic fake-pi vehicle so a real `crtr node new` reaches the fake
170
+ * instead of the LLM pi, without depending on tmux/shell PATH inheritance. An
171
+ * explicit `binary` arg still overrides the env. The value may be a multi-word
172
+ * launcher (e.g. `node --import tsx/esm host.ts`); only argv entries are
173
+ * shell-quoted, so a multi-word binary is spliced verbatim ahead of them. */
177
174
  export declare function piCommand(argv: string[], binary?: string): string;
178
175
  /** List all window ids present in `session`. Returns [] if the session does
179
176
  * not exist or tmux fails for any reason. Each entry is the raw window id
@@ -10,6 +10,7 @@
10
10
  import { spawnSync } from 'node:child_process';
11
11
  import { readConfig } from '../config.js';
12
12
  import { defaultCanvasNavConfig } from '../../types.js';
13
+ import { bundledPiCommandForShell } from './pi-cli.js';
13
14
  import { surfaceTmuxStyleArgs } from './surface-bg.js';
14
15
  // ---------------------------------------------------------------------------
15
16
  // Shell quoting + tmux invocation
@@ -327,17 +328,14 @@ export function respawnPaneSync(opts) {
327
328
  /** Turn a pi argv array into a single shell command string.
328
329
  *
329
330
  * The binary defaults to `CRTR_PI_BINARY` when that env var is set, else the
330
- * literal `pi`. This is a TEST-ONLY substitution seam: when CRTR_PI_BINARY is
331
- * unset (every production path) the behavior is byte-identical to exec'ing
332
- * `pi`. The integration-test harness points it at a deterministic fake-pi
333
- * vehicle so a real `crtr node new` reaches the fake instead of the LLM `pi`,
334
- * without any dependence on tmux/shell PATH inheritance the substitution is
335
- * baked into the command string at build time, in the process that calls
336
- * piCommand. An explicit `binary` arg still overrides the env (no caller passes
337
- * one today). The value may be a multi-word launcher (e.g. `node --import
338
- * tsx/esm host.ts`); only the argv entries are shell-quoted, so a multi-word
339
- * binary is spliced verbatim ahead of them. */
340
- export function piCommand(argv, binary = process.env['CRTR_PI_BINARY'] ?? 'pi') {
331
+ * bundled pi CLI launched with the current Node binary. CRTR_PI_BINARY is a
332
+ * TEST-ONLY substitution seam: the integration-test harness points it at a
333
+ * deterministic fake-pi vehicle so a real `crtr node new` reaches the fake
334
+ * instead of the LLM pi, without depending on tmux/shell PATH inheritance. An
335
+ * explicit `binary` arg still overrides the env. The value may be a multi-word
336
+ * launcher (e.g. `node --import tsx/esm host.ts`); only argv entries are
337
+ * shell-quoted, so a multi-word binary is spliced verbatim ahead of them. */
338
+ export function piCommand(argv, binary = process.env['CRTR_PI_BINARY'] ?? bundledPiCommandForShell(shellQuote)) {
341
339
  return [binary, ...argv.map(shellQuote)].join(' ');
342
340
  }
343
341
  // ---------------------------------------------------------------------------
@@ -0,0 +1,35 @@
1
+ import { type NodeMeta, type ManagedWorktree } from './canvas/index.js';
2
+ export interface WorktreeCommandResult {
3
+ ok: boolean;
4
+ stdout: string;
5
+ stderr: string;
6
+ }
7
+ export declare class WorktreeError extends Error {
8
+ code: string;
9
+ next: string;
10
+ detail?: string;
11
+ constructor(code: string, message: string, next: string, detail?: string);
12
+ }
13
+ export declare function managedWorktreesRoot(): string;
14
+ export declare function managedWorktreePath(nodeId: string): string;
15
+ export declare function hasOpenManagedWorktree(node: NodeMeta | null | undefined): boolean;
16
+ export declare function openManagedWorktreeForNode(nodeId: string): ManagedWorktree | null;
17
+ export declare function createManagedWorktree(cwd: string, nodeId: string): ManagedWorktree;
18
+ export declare function isRebaseInProgress(cwd: string): boolean;
19
+ export interface CloseManagedWorktreeResult {
20
+ node_id: string;
21
+ branch: string;
22
+ worktree_path: string;
23
+ pushed_sha: string;
24
+ /** False when the push landed but the worktree checkout could not be removed
25
+ * automatically afterward — the close still counts as done (state is
26
+ * closed, push-final is unblocked); `worktree_remove_error` carries the
27
+ * manual cleanup note. */
28
+ worktree_removed: boolean;
29
+ worktree_remove_error?: string;
30
+ /** False when the push landed but the local branch could not be deleted
31
+ * afterward — same non-fatal treatment as worktree_removed. */
32
+ branch_deleted: boolean;
33
+ branch_delete_error?: string;
34
+ }
35
+ export declare function closeManagedWorktree(nodeId: string): CloseManagedWorktreeResult;