@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
@@ -24,6 +24,7 @@ import { Container, Loader, Spacer, Text } from '@earendil-works/pi-tui';
24
24
  import { AssistantMessageComponent, BashExecutionComponent, BranchSummaryMessageComponent, CompactionSummaryMessageComponent, CustomMessageComponent, getMarkdownTheme, parseSkillBlock, SkillInvocationMessageComponent, ToolExecutionComponent, UserMessageComponent, } from '@earendil-works/pi-coding-agent';
25
25
  import { ContextMessageComponent } from './context-message.js';
26
26
  import { CRTR_OUTPUT_CUSTOM_TYPE, CrtrOutputMessageComponent, createCrtrBashToolDefinition, } from './crtr-output-render.js';
27
+ import { CRTR_CYCLE_DIVIDER_CUSTOM_TYPE } from '../../core/runtime/session-cycles.js';
27
28
  /** customType stamped on the injected `<crtr-context>` bearings block (mirrors
28
29
  * CONTEXT_INTRO_CUSTOM_TYPE in canvas-context-intro.ts — kept as a local literal
29
30
  * so the viewer never imports the in-process extension). */
@@ -438,6 +439,16 @@ export class ChatView {
438
439
  this.append(new CrtrOutputMessageComponent(this.userMessageText(message), this.toolOutputExpanded, this.crtrOutputTheme()));
439
440
  break;
440
441
  }
442
+ // A refresh-yield cycle boundary (session-cycles.ts, synthetic — never
443
+ // persisted): render as a dim rule rather than the generic custom-
444
+ // message card, so scrolling through a multi-cycle node's full history
445
+ // reads as one conversation with clear seams between cycles.
446
+ if (message.customType === CRTR_CYCLE_DIVIDER_CUSTOM_TYPE) {
447
+ this.append(new Spacer(1));
448
+ this.append(new Text(this.dimStyle(`── ${this.userMessageText(message)} ──`), 1, 0));
449
+ this.append(new Spacer(1));
450
+ break;
451
+ }
441
452
  const component = new CustomMessageComponent(message, undefined, getMarkdownTheme());
442
453
  component.setExpanded(this.toolOutputExpanded);
443
454
  this.append(component);
@@ -16,7 +16,8 @@ export type ReadOpRequest = Omit<ListModelsFrame, 'id'> | Omit<ListSessionsFrame
16
16
  * broker on the same path. It gives up only when the node is genuinely gone:
17
17
  * a terminal status (done/dead/canceled) or a reaped row (null). `idle` is NOT
18
18
  * terminal — an idle-release node revives on its next inbox wake, so keep
19
- * trying (the supervisor's own ~30s bound caps an indefinite wait). */
19
+ * trying (the supervisor waits at a relaxed cadence for as long as the row
20
+ * stays live). */
20
21
  export declare function reconnectShouldGiveUp(row: NodeRow | null): boolean;
21
22
  export declare class BrokerUnavailableError extends Error {
22
23
  readonly nodeId: string;
@@ -45,6 +46,12 @@ export declare class ViewSocketClient extends EventEmitter {
45
46
  * Resolved by the matching `data` frame / rejected by the matching `error`
46
47
  * frame in {@link onData}, the request's timeout, or socket teardown. */
47
48
  private pending;
49
+ /** One redial fault line per reconnect EPISODE, not per attempt: the
50
+ * supervisor redials for as long as the node can come back (a dormant node
51
+ * may be gone for hours), so a per-attempt record would grow fault.log
52
+ * without bound. Set on the first failed redial, reset on any successful
53
+ * connect. */
54
+ private redialFaultRecorded;
48
55
  constructor(nodeId: string);
49
56
  private recordSocketFault;
50
57
  /** Issue a correlated read-op and resolve with the broker's `data` reply (or
@@ -38,7 +38,8 @@ const REQUEST_TIMEOUT_MS = 10_000;
38
38
  * broker on the same path. It gives up only when the node is genuinely gone:
39
39
  * a terminal status (done/dead/canceled) or a reaped row (null). `idle` is NOT
40
40
  * terminal — an idle-release node revives on its next inbox wake, so keep
41
- * trying (the supervisor's own ~30s bound caps an indefinite wait). */
41
+ * trying (the supervisor waits at a relaxed cadence for as long as the row
42
+ * stays live). */
42
43
  export function reconnectShouldGiveUp(row) {
43
44
  if (row === null)
44
45
  return true;
@@ -61,11 +62,22 @@ export class ViewSocketClient extends EventEmitter {
61
62
  * Resolved by the matching `data` frame / rejected by the matching `error`
62
63
  * frame in {@link onData}, the request's timeout, or socket teardown. */
63
64
  pending = new Map();
65
+ /** One redial fault line per reconnect EPISODE, not per attempt: the
66
+ * supervisor redials for as long as the node can come back (a dormant node
67
+ * may be gone for hours), so a per-attempt record would grow fault.log
68
+ * without bound. Set on the first failed redial, reset on any successful
69
+ * connect. */
70
+ redialFaultRecorded = false;
64
71
  constructor(nodeId) {
65
72
  super();
66
73
  this.nodeId = nodeId;
67
74
  }
68
75
  recordSocketFault(op, err) {
76
+ if (op === 'redial') {
77
+ if (this.redialFaultRecorded)
78
+ return;
79
+ this.redialFaultRecorded = true;
80
+ }
69
81
  const classified = classify('viewer↔broker', err);
70
82
  recordFault(this.nodeId, {
71
83
  link: 'viewer↔broker',
@@ -122,6 +134,7 @@ export class ViewSocketClient extends EventEmitter {
122
134
  const socket = createConnection(this.socketPath);
123
135
  this.socket = socket;
124
136
  socket.on('connect', () => {
137
+ this.redialFaultRecorded = false;
125
138
  clearFault(this.nodeId, { link: 'viewer↔broker' });
126
139
  this.emit('connect');
127
140
  });
@@ -148,6 +161,7 @@ export class ViewSocketClient extends EventEmitter {
148
161
  if (settled)
149
162
  return;
150
163
  settled = true;
164
+ this.redialFaultRecorded = false;
151
165
  clearFault(this.nodeId, { link: 'viewer↔broker' });
152
166
  this.emit('connect');
153
167
  resolve();
@@ -7,8 +7,9 @@
7
7
  // • POST /__crtr/source → the source + command bridge: decode a SourceRequest
8
8
  // and run it through the LOCAL transport (exec/file/
9
9
  // http in this cwd). This serves views' READS and the
10
- // command WRITES (`crtr …` subprocesses). Lifted here
11
- // from the deleted `view serve`.
10
+ // command WRITES (`crtr …` subprocesses via shelled-out
11
+ // CLI). The bridge is the ONLY sanctioned writer to the
12
+ // canvas; relay clients NEVER launch pi directly.
12
13
  // • GET /__crtr/events → the SSE change-invalidation lane (events.ts).
13
14
  // • WS /node/<id> → open that node's ALREADY-running broker `view.sock`
14
15
  // and relay frames VERBATIM both directions. The relay
@@ -43,12 +44,6 @@ import { runSourceRequest } from '../../core/view/bridge.js';
43
44
  import { startEventHub } from './events.js';
44
45
  import { createDevServer } from './dev-server.js';
45
46
  import { SOURCE_CACHE_TTL_MS, isCoalescableSourceRequest } from './source-cache.js';
46
- // ── Hearth product seam: model-auth lane (P3.3b, Silas-approved exception) ──
47
- // This is the ONLY Hearth-aware import in this file. All OAuth/status/start/
48
- // finish product logic lives in core/hearth/model-auth-guest.ts; this module
49
- // provides only routing + relay-token enforcement for the lane (see the route
50
- // block in startWebServer below).
51
- import { handleHearthModelAuthRequest } from '../../core/hearth/model-auth-guest.js';
52
47
  const HERE = dirname(fileURLToPath(import.meta.url));
53
48
  /** Candidate dirs to serve the shell SPA bundle from, in priority order: an
54
49
  * explicit override, the compiled-module-relative `dist/web-client/` (what
@@ -381,8 +376,8 @@ export async function startWebServer(opts) {
381
376
  });
382
377
  // The SSE change lane — one hub, fanned to every connected EventSource.
383
378
  const eventHub = startEventHub();
384
- // Same-origin allowlist: tokenless localhost pages keep the legacy guard,
385
- // while a valid relay token can authorize remote Hearth route origins that would
379
+ // Same-origin allowlist: tokenless localhost pages are implicitly allowed,
380
+ // while a valid relay token can authorize remote/guest route origins that would
386
381
  // otherwise be rejected by originAllowed().
387
382
  let allowedOrigins = new Set();
388
383
  const originAllowed = (origin) => {
@@ -447,24 +442,6 @@ export async function startWebServer(opts) {
447
442
  eventHub.addClient(res);
448
443
  return;
449
444
  }
450
- // ── Hearth product seam: model-auth lane ──
451
- // Isolated per the P3.3a/P3.3b approved exception. ALL Hearth/OAuth logic
452
- // lives in core/hearth/model-auth-guest.ts; this block is ONLY routing +
453
- // the relay-token gate. CRITICAL: this lane uses the STRICT token-only
454
- // predicate requestTokenMatches(req, token), never requestAllowed() —
455
- // requestAllowed() also accepts a same-origin loopback request with NO
456
- // token at all, which would make this lane tokenless on local/dev binds.
457
- // Every /__hearth/model-auth/* request fails closed (401) without an
458
- // exact relay-token match, even on loopback.
459
- if (pathname.startsWith('/__hearth/model-auth/anthropic/')) {
460
- if (!requestTokenMatches(req, token)) {
461
- res.writeHead(401, { 'content-type': 'text/plain; charset=utf-8' });
462
- res.end('relay token required\n');
463
- return;
464
- }
465
- void handleHearthModelAuthRequest(req, res);
466
- return;
467
- }
468
445
  // --dev: Vite middleware owns all remaining asset/HTML serving (incl. its
469
446
  // own SPA fallback). It is mounted AFTER the bridge + SSE checks above, so
470
447
  // those never fall through to Vite.
@@ -86,7 +86,7 @@ const webServeLeaf = defineLeaf({
86
86
  outputKind: 'object',
87
87
  effects: [
88
88
  'Binds a long-running HTTP+WebSocket server on the requested host and port and keeps serving until interrupted (ctrl+c / SIGTERM). Safe to run non-interactively (daemon-like): it prints the URL and serves.',
89
- 'Serves the shell SPA (static dist/web-client/, or Vite middleware under --dev), runs the POST /__crtr/source bridge (exec/file/http in this cwd), streams the GET /__crtr/events SSE change lane, and on a browser ws://HOST:<port>/node/<id> connection opens that node\'s running broker view.sock and relays frames VERBATIM both directions. Non-loopback binds require a token so the remote Hearth route is not an unauthenticated command surface.',
89
+ 'Serves the shell SPA (static dist/web-client/, or Vite middleware under --dev), runs the POST /__crtr/source bridge (exec/file/http in this cwd), streams the GET /__crtr/events SSE change lane, and on a browser ws://HOST:<port>/node/<id> connection opens that node\'s running broker view.sock and relays frames VERBATIM both directions. Non-loopback binds require a token so the remote route is not an unauthenticated command surface.',
90
90
  'The relay NEVER spawns pi and NEVER writes any session — it only opens a socket to an already-running broker; if the broker is not running, the browser connection is closed with a clear reason. All graph mutations go through `crtr` subprocesses the bridge runs.',
91
91
  ],
92
92
  },
@@ -0,0 +1,60 @@
1
+ // Regression for issue #115: `crtr canvas revive --now` rejected exactly the
2
+ // provider-fault nodes the canvas dashboard flags as ⚠ "needs you"
3
+ // (status-glyph.ts `faultDispositionText`), because the old gate required
4
+ // `retry.disposition === 'auto'` — the one disposition "needs you" NEVER is.
5
+ // Run with: node --import tsx/esm --test 'src/commands/__tests__/revive-now-gate.test.ts'
6
+ import { test } from 'node:test';
7
+ import assert from 'node:assert/strict';
8
+ import { isNowEligibleFault } from '../revive.js';
9
+ import { faultNeedsYou } from '../../core/canvas/status-glyph.js';
10
+ function fault(over) {
11
+ return {
12
+ link: 'pi→provider',
13
+ op: 'generation turn',
14
+ kind: 'overloaded',
15
+ retry: { disposition: 'auto', by: 'daemon' },
16
+ message: 'boom',
17
+ since: new Date().toISOString(),
18
+ logPath: '/tmp/fault.log',
19
+ ...over,
20
+ };
21
+ }
22
+ test('null fault is never --now eligible', () => {
23
+ assert.equal(isNowEligibleFault(null), false);
24
+ });
25
+ test('daemon-owned auto retry is eligible (kick it early)', () => {
26
+ assert.equal(isNowEligibleFault(fault({ retry: { disposition: 'auto', by: 'daemon' } })), true);
27
+ });
28
+ test('client-owned auto retry (redialing) is refused — already being handled', () => {
29
+ assert.equal(isNowEligibleFault(fault({ retry: { disposition: 'auto', by: 'client' } })), false);
30
+ });
31
+ // The bug: exhausted-retry / immediate-fatal pi→provider faults render "needs
32
+ // you" on the dashboard (faultNeedsYou === true) but the old gate rejected
33
+ // them outright — no sanctioned recovery verb existed for exactly the nodes
34
+ // the dashboard told the user to attend to.
35
+ test('fatal pi→provider fault ("needs you") is now eligible', () => {
36
+ const f = fault({ retry: { disposition: 'fatal' } });
37
+ assert.equal(faultNeedsYou(f), true);
38
+ assert.equal(isNowEligibleFault(f), true);
39
+ });
40
+ test('manual pi→provider fault ("needs you") is eligible', () => {
41
+ const f = fault({ retry: { disposition: 'manual' } });
42
+ assert.equal(faultNeedsYou(f), true);
43
+ assert.equal(isNowEligibleFault(f), true);
44
+ });
45
+ test('a non-pi→provider fault (e.g. a daemon→node wedge) is refused — --now cannot clear it', () => {
46
+ const f = fault({ link: 'daemon→node', kind: 'wedged', retry: { disposition: 'manual' } });
47
+ assert.equal(isNowEligibleFault(f), false);
48
+ });
49
+ test('faultNeedsYou matches the gate for every disposition (no drift between dashboard and --now)', () => {
50
+ for (const disposition of ['auto', 'manual', 'fatal']) {
51
+ const f = fault({ retry: { disposition, by: 'daemon' } });
52
+ if (disposition === 'auto') {
53
+ assert.equal(faultNeedsYou(f), false);
54
+ }
55
+ else {
56
+ assert.equal(faultNeedsYou(f), true);
57
+ assert.equal(isNowEligibleFault(f), true);
58
+ }
59
+ }
60
+ });
@@ -1,10 +1,10 @@
1
1
  // `crtr canvas rebuild-index` — re-derive the node-table index from on-disk metas.
2
2
  //
3
3
  // canvas.db's node rows are a derived index over `nodes/<id>/meta.json`. When the
4
- // db is absent or stale relative to the metas — most concretely a Hearth guest
5
- // recreate-from-template, where node data lives on the durable Volume but
6
- // canvas.db lives on local disk that the recreate wipes — this verb rebuilds the
7
- // rows (identity columns) from the surviving metas via `rebuildIndex()`, so a
4
+ // db is absent or stale relative to the metas — most concretely a remote/guest
5
+ // deployment's recreate-from-template, where node data lives on a durable Volume
6
+ // but canvas.db lives on local disk that the recreate wipes — this verb rebuilds
7
+ // the rows (identity columns) from the surviving metas via `rebuildIndex()`, so a
8
8
  // subsequent `canvas revive` finds its node again.
9
9
  //
10
10
  // Fails loud if metas are present but the rebuild yields zero rows: a silent
@@ -29,7 +29,7 @@ function countOnDiskMetas() {
29
29
  export const canvasRebuildIndexLeaf = defineLeaf({
30
30
  name: 'rebuild-index',
31
31
  description: 're-derive node rows in canvas.db from on-disk nodes/<id>/meta.json',
32
- whenToUse: 'canvas.db is missing or stale relative to the on-disk node metas and you need its node rows re-derived — most concretely after a Hearth guest recreate-from-template, where node data survives on the durable Volume but canvas.db (local disk) was wiped, so a `canvas revive` would otherwise find no row. Identity columns are rebuilt from each meta; runtime columns (status/intent/pi_pid/window/tmux) take their quiescent defaults, and subscribes_to edges are left intact',
32
+ whenToUse: 'canvas.db is missing or stale relative to the on-disk node metas and you need its node rows re-derived — most concretely after a remote/guest deployment\'s recreate-from-template, where node data survives on a durable Volume but canvas.db (local disk) was wiped, so a `canvas revive` would otherwise find no row. Identity columns are rebuilt from each meta; runtime columns (status/intent/pi_pid/window/tmux) take their quiescent defaults, and subscribes_to edges are left intact',
33
33
  help: {
34
34
  name: 'canvas rebuild-index',
35
35
  summary: 're-derive canvas.db node rows from on-disk metas; fails loud if metas exist but yield no rows',
@@ -12,9 +12,13 @@ export declare function scopeRank(scope: MemoryScope): number;
12
12
  * always resolves. `--scope profile` is NEVER a default — it requires a
13
13
  * selected profile, from `profileArg` (an explicit `--profile <id-or-name>`)
14
14
  * or else the process's `CRTR_PROFILE_ID`, resolved through the centralized
15
- * `loadProfileManifest` (never a raw path join). Returns the absolute
16
- * `<root>/memory` dir to write under. */
17
- export declare function resolveWriteTarget(scopeArg: string | undefined, profileArg?: string): {
15
+ * `loadProfileManifest` (never a raw path join). `dirArg` (an explicit
16
+ * `--dir <path>`) pins the EXACT project directory, cwd-free: the target is
17
+ * `<dir>/.crouter/memory/`, scaffolded if absent, never the cwd-ancestor walk
18
+ * — the way a profiled agent writes into any project in its purview, and the
19
+ * only way to target a dir shadowed by an ancestor store. Returns the
20
+ * absolute `<root>/memory` dir to write under. */
21
+ export declare function resolveWriteTarget(scopeArg: string | undefined, profileArg?: string, dirArg?: string): {
18
22
  scope: MemoryScope;
19
23
  memoryDir: string;
20
24
  };
@@ -3,10 +3,12 @@
3
3
  // scope modules and build their documented output objects on top of the small
4
4
  // helpers here. Nothing in this file forks on kind or re-implements the
5
5
  // schema/gate/resolver — it only composes them.
6
- import { join } from 'node:path';
6
+ import { existsSync, realpathSync, statSync } from 'node:fs';
7
+ import { join, resolve as resolvePath } from 'node:path';
7
8
  import { stringify as yamlStringify, parse as yamlParse } from 'yaml';
8
9
  import { usage } from '../../core/errors.js';
9
- import { scopeMemoryDir, projectScopeRoot, ensureProjectScopeRoot, } from '../../core/scope.js';
10
+ import { CRTR_DIR_NAME } from '../../types.js';
11
+ import { scopeMemoryDir, projectScopeRoot, ensureProjectScopeRoot, resetScopeCache, } from '../../core/scope.js';
10
12
  import { loadProfileManifest, profileMemoryDir } from '../../core/profiles/manifest.js';
11
13
  // The two memory kinds — knowledge (consult: procedural playbooks + factual
12
14
  // references merged) vs preference (behave: standing directives). Used as the
@@ -29,9 +31,37 @@ export function scopeRank(scope) {
29
31
  * always resolves. `--scope profile` is NEVER a default — it requires a
30
32
  * selected profile, from `profileArg` (an explicit `--profile <id-or-name>`)
31
33
  * or else the process's `CRTR_PROFILE_ID`, resolved through the centralized
32
- * `loadProfileManifest` (never a raw path join). Returns the absolute
33
- * `<root>/memory` dir to write under. */
34
- export function resolveWriteTarget(scopeArg, profileArg) {
34
+ * `loadProfileManifest` (never a raw path join). `dirArg` (an explicit
35
+ * `--dir <path>`) pins the EXACT project directory, cwd-free: the target is
36
+ * `<dir>/.crouter/memory/`, scaffolded if absent, never the cwd-ancestor walk
37
+ * — the way a profiled agent writes into any project in its purview, and the
38
+ * only way to target a dir shadowed by an ancestor store. Returns the
39
+ * absolute `<root>/memory` dir to write under. */
40
+ export function resolveWriteTarget(scopeArg, profileArg, dirArg) {
41
+ if (dirArg !== undefined && dirArg !== '') {
42
+ if (scopeArg !== undefined && scopeArg !== 'project') {
43
+ throw usage(`--dir targets a project store and cannot combine with --scope ${scopeArg}`);
44
+ }
45
+ const abs = resolvePath(dirArg);
46
+ if (!existsSync(abs) || !statSync(abs).isDirectory()) {
47
+ throw usage(`--dir does not exist or is not a directory: ${dirArg}`, {
48
+ received: dirArg,
49
+ next: 'Pass an existing project directory.',
50
+ });
51
+ }
52
+ let real = abs;
53
+ try {
54
+ real = realpathSync(abs);
55
+ }
56
+ catch {
57
+ /* fall back to the resolved path */
58
+ }
59
+ // The scaffold (mkdir of `.crouter/memory/`) happens at write time via the
60
+ // writer's ensureDir; drop the scope cache so this process's later
61
+ // resolves see the new root.
62
+ resetScopeCache();
63
+ return { scope: 'project', memoryDir: join(real, CRTR_DIR_NAME, 'memory') };
64
+ }
35
65
  let scope;
36
66
  if (scopeArg === 'user' || scopeArg === 'project' || scopeArg === 'profile') {
37
67
  scope = scopeArg;
@@ -13,7 +13,7 @@ export const writeLeaf = defineLeaf({
13
13
  guide: 'The body is the easy part; the craft is routing — every frontmatter flag decides who sees this doc, when, and at what context cost. Each rung up is paid by every future agent at every boot or read, forever, so default each rung down.\n\n' +
14
14
  'Pick the kind. knowledge is consulted for facts or procedures; preference directs behavior. The kind choice is about how the doc is used, not about how long it is.\n\n' +
15
15
  'Set both visibility rungs explicitly on create. There is no kind default. `none`, `name`, `preview`, and `content` move from least to most loaded: `none` keeps the doc out of auto-load and on-read surfaces, `name` is the bare doc tag only, `preview` is the name + envelope + routing line (`when-and-why-to-read`), and `content` inlines the whole body when the body is short enough to justify it. Each axis is independent; usually one carries a real rung and the other is `none`. When a doc is short enough to state in a single sentence — a one-line preference, or a knowledge fact that fits in a sentence — skip `preview` and use `name` or `content` directly: the routing line would run longer than the doc itself, so a `preview` rung just adds words. Sentence-length `content` docs are not just acceptable, they are correct. Never pad a memory to be more verbose than the rule or fact it carries.\n\n' +
16
- 'Choose the hook — boot vs file-read. Behavior and how-to procedure surface at boot. Knowledge about code belongs next to the code: put the file in that directory’s .crouter/memory/ and it fires positionally when files there are read, costing nothing at boot. A knowledge doc about a person or process has no code directory to anchor to, so on-read triggering is meaningless — route it through boot instead and set the boot rung to `preview` when you want the routing line to surface.\n\n' +
16
+ 'Choose the hook — boot vs file-read. Behavior and how-to procedure surface at boot. Knowledge about code belongs next to the code: put the file in that directory’s .crouter/memory/ (`--dir <project-dir>` targets it exactly, from anywhere) and it fires positionally when files there are read, costing nothing at boot. A knowledge doc about a person or process has no code directory to anchor to, so on-read triggering is meaningless — route it through boot instead and set the boot rung to `preview` when you want the routing line to surface.\n\n' +
17
17
  'Write the routing line (--when-and-why-to-read) first, before storing anything: "When <circumstance>, this <kind> should be read because <payoff>." The test is whether a stranger mid-task can decide from that one line alone whether to spend the read. If you cannot name the concrete situation that triggers it, you do not yet understand the memory — ask the user one sharp question instead of improvising. Never paraphrase the advice in the routing line; keep it about when to read and why the read is worth it.\n\n' +
18
18
  'Gate and read-when share the same predicate language: a field map is AND-ed across fields; dotted fields resolve nested values; field matchers may be scalar, array, or object. Scalar matchers do exact equality, with arrays matching any element. Array matchers do membership or intersection. Object matchers accept `eq`, `ne`, `in`, `nin`, `exists`, `contains`, `containsAll`, `containsAny`, `matches`, `imatches`, `gt`, `gte`, `lt`, and `lte`. Combinators are `all`, `any`, and `not`; sibling field matchers next to them are AND-ed in. An empty condition is inert. An unknown op never matches.\n\n' +
19
19
  'Find before write. Group related docs with path names (area/topic). Do not store what is already recorded or what only matters to this conversation. Body is for current truth, not history. Provenance is automatic on create and preserved on update. Validate after authoring.',
@@ -27,7 +27,8 @@ export const writeLeaf = defineLeaf({
27
27
  { kind: 'flag', name: 'gate', type: 'string', required: false, constraint: 'Frontmatter gate — YAML/JSON object predicate over node config using the same field/matcher vocabulary described in the guide.' },
28
28
  { kind: 'flag', name: 'applies-to', type: 'string', required: false, constraint: 'Frontmatter applies-to — glob/path scope the document applies to.' },
29
29
  { kind: 'flag', name: 'read-when', type: 'string', required: false, constraint: 'Frontmatter read-when — YAML/JSON object predicate over a read file’s own frontmatter using the same field/matcher vocabulary described in the guide.' },
30
- { kind: 'flag', name: 'scope', type: 'enum', choices: [...MEMORY_SCOPES], required: false, constraint: 'Target scope. Default: project when inside a project, else user. `profile` requires a selected profile (CRTR_PROFILE_ID) or an explicit --profile.' },
30
+ { kind: 'flag', name: 'scope', type: 'enum', choices: [...MEMORY_SCOPES], required: false, constraint: 'Target scope. Default: project when inside a project, else user. `project` resolves to the NEAREST ancestor `.crouter/` walking up from cwd — in a nested workspace that can be a parent’s store, not the dir you are standing in; pass --dir to pin the exact project directory. `profile` requires a selected profile (CRTR_PROFILE_ID) or an explicit --profile.' },
31
+ { kind: 'flag', name: 'dir', type: 'string', required: false, constraint: 'Exact project directory to write under — targets `<dir>/.crouter/memory/` regardless of cwd or ancestor stores, scaffolding `.crouter/` there if absent. THE way to place a doc in a specific project’s store (e.g. another project in your profile’s purview) without cd’ing there, and the only way to target a dir shadowed by an ancestor store. Implies --scope project; rejects --scope user/profile.' },
31
32
  { kind: 'flag', name: 'profile', type: 'string', required: false, constraint: 'Profile id or name to write under, for --scope profile. Default: the process CRTR_PROFILE_ID (the node\u2019s selected profile). Resolved through the same profile lookup as `crtr profile show`; ignored for any other --scope.' },
32
33
  { kind: 'stdin', name: 'body', required: true, constraint: 'Document body (markdown, no frontmatter). Piped on stdin, or passed as the bare positional after <name>.' },
33
34
  ],
@@ -50,7 +51,8 @@ export const writeLeaf = defineLeaf({
50
51
  const scopeArg = input['scope'];
51
52
  const profileArg = input['profile'];
52
53
  const body = input['body'] ?? '';
53
- const { scope, memoryDir } = resolveWriteTarget(scopeArg, profileArg);
54
+ const dirArg = input['dir'];
55
+ const { scope, memoryDir } = resolveWriteTarget(scopeArg, profileArg, dirArg);
54
56
  const path = memoryFilePath(memoryDir, name);
55
57
  const created = !pathExists(path);
56
58
  // CREATE requires the read-routing line that becomes the preview — without
@@ -7,6 +7,7 @@ import { getNode } from '../core/canvas/index.js';
7
7
  import { sessionPtrPath } from '../core/canvas/paths.js';
8
8
  import { SessionManager } from '../core/runtime/broker-sdk.js';
9
9
  import { BUILTIN_SLASH_COMMANDS } from '../core/runtime/pi-vendored.js';
10
+ import { cycleAwareMessages } from '../core/runtime/session-cycles.js';
10
11
  function resolveSessionFile(nodeId, metaFile) {
11
12
  if (typeof metaFile === 'string' && metaFile !== '' && existsSync(metaFile))
12
13
  return metaFile;
@@ -95,7 +96,14 @@ export const nodeSnapshotLeaf = defineLeaf({
95
96
  copyFileSync(sessionFile, copy);
96
97
  const manager = SessionManager.open(copy);
97
98
  const context = manager.buildSessionContext();
98
- const messages = context.messages;
99
+ // Every prior refresh-yield cycle lives in this SAME `.jsonl` as a sibling
100
+ // branch (session-cycles.ts) — replay them ahead of the live cycle's
101
+ // context so a dormant node's snapshot reads as one continuous
102
+ // conversation, matching the broker's live welcome snapshot.
103
+ // Cast: the real SDK's `SessionEntry.data` is `unknown`, narrower than
104
+ // `CycleSessionManagerLike`'s structural read of the `crtr-cycle` marker's
105
+ // `{cycle, fromLeaf}` shape (see the matching cast in broker.ts).
106
+ const messages = cycleAwareMessages(manager);
99
107
  const sessionId = manager.getSessionId();
100
108
  const sessionName = manager.getSessionName();
101
109
  const snapshot = {
@@ -23,6 +23,7 @@ import { readFaultAsync } from '../core/runtime/fault.js';
23
23
  import { activeFaultForDisplay } from '../core/canvas/render.js';
24
24
  import { detachToBackground, focus as placementFocus, windowAlive, windowOfPane, currentTmux, getPaneOption, renameWindow } from '../core/runtime/placement.js';
25
25
  import { buildLaunchSpec, normalizeModel } from '../core/runtime/launch.js';
26
+ import { WorktreeError } from '../core/worktree.js';
26
27
  import { closeNode } from '../core/runtime/close.js';
27
28
  import { isBrokerLive, setModelLive, persistDormantModel } from '../core/runtime/model-swap.js';
28
29
  import { appendInbox } from '../core/feed/inbox.js';
@@ -36,7 +37,7 @@ import { getNode, updateNode, listNodes, subscribe, unsubscribe, subscriptionsOf
36
37
  // the result instead of this bloated one. Below the steering bands (130k+) on
37
38
  // purpose — catch it at spawn, before the window is critically full.
38
39
  const YIELD_NUDGE_THRESHOLD = 100_000;
39
- const STD_CHILD_FOLLOW_UP = "Do not wait or poll on this child — there is no result to await and stopping will not strand you. You're auto-subscribed, so its finish wakes you on its own. Two moves only: continue other independent work right now, or stop and end your turn — the wake brings you back.";
40
+ const STD_CHILD_FOLLOW_UP = "Do not wait, poll, or duplicate this child's assignment in your own context — there is no result to await, stopping will not strand you, and duplicated work wastes the delegated context. You're auto-subscribed, so its finish wakes you on its own. Two moves only: do other independent work right now, or stop and end your turn — the wake brings you back.";
40
41
  /** Decision road sign for a managed (non-root) child. Normally STD_CHILD_FOLLOW_UP,
41
42
  * but when the SPAWNER is an orchestrator whose context has already grown past
42
43
  * YIELD_NUDGE_THRESHOLD, steer it to yield now and let its fresh revive handle
@@ -51,7 +52,12 @@ export function childFollowUp(spawnerId) {
51
52
  if (ctxTokens === null || ctxTokens < YIELD_NUDGE_THRESHOLD)
52
53
  return STD_CHILD_FOLLOW_UP;
53
54
  const k = Math.round(ctxTokens / 1000);
54
- return `Child spawned — you're auto-subscribed, so its finish wakes you on its own; never wait or poll. But you're an orchestrator already carrying ~${k}k of context: rather than hold this window open for the result, checkpoint context/roadmap.md and \`crtr node yield\` now. Yielding ends this turn, and the child's completion then revives you fresh against your roadmap — let that clean revive absorb the result instead of this bloated context.`;
55
+ return `Child spawned — you're auto-subscribed, so its finish wakes you on its own; never wait, poll, or duplicate the child's assignment in your own context. But you're an orchestrator already carrying ~${k}k of context: rather than hold this window open for the result, checkpoint context/roadmap.md and \`crtr node yield\` now. Yielding ends this turn, and the child's completion then revives you fresh against your roadmap — let that clean revive absorb the result instead of this bloated context.`;
56
+ }
57
+ function mapWorktreeError(err) {
58
+ if (!(err instanceof WorktreeError))
59
+ return null;
60
+ return new InputError({ error: err.code, message: err.message, next: err.next });
55
61
  }
56
62
  /** Validate a `--kind` against the installed personas; throws a listing InputError. */
57
63
  function assertKind(kind) {
@@ -120,7 +126,8 @@ const nodeNew = defineLeaf({
120
126
  { kind: 'flag', name: 'cwd', type: 'path', required: false, constraint: 'Dir the node is pinned to. Defaults to the caller cwd, resolved now — for a triggered spawn it must still exist at fire time or the spawn fails loud.' },
121
127
  { kind: 'flag', name: 'name', type: 'string', required: false, constraint: 'Display name (tmux window + resume picker). Defaults to the kind.' },
122
128
  { kind: 'flag', name: 'parent', type: 'string', required: false, constraint: 'Parent node id. Defaults to the calling node (CRTR_NODE_ID).' },
123
- { kind: 'flag', name: 'root', type: 'bool', required: false, constraint: 'Spawn an INDEPENDENT root instead of a managed child: no parent (top-level on the canvas), NO subscription back to you (you are NOT woken by it), resident lifecycle. It records spawned_by=you for provenance and is brought forefront so it can be driven directly. Use for a node you hand off and do not manage (e.g. a sub-orchestrator a human will discuss with).' },
129
+ { kind: 'flag', name: 'root', type: 'bool', required: false, constraint: 'Spawn an INDEPENDENT root instead of a managed child: no parent (top-level on the canvas), NO subscription back to you (you are NOT woken by it), resident lifecycle. It records spawned_by=you for provenance and is brought forefront so it can be driven directly. Use for a node you hand off and do not manage (e.g. a sub-orchestrator a human will discuss with). Mutually exclusive with --worktree.' },
130
+ { kind: 'flag', name: 'worktree', type: 'bool', required: false, constraint: 'Use when the child will COMMIT to the repo while you or other agents may also be editing it — the worktree isolates its work so nothing collides; skip it for read-only work or when the child is the sole writer. Creates a crouter-managed git worktree for the spawned child, keyed by the child node id, at `~/.crouter/canvas/worktrees/<node-id>/` on branch `crtr/<node-id>` based on `origin/main`; the child\'s cwd is pinned there, and it lands the work serially onto origin/main later with `crtr worktree close`. Mutually exclusive with --root.' },
124
131
  { kind: 'flag', name: 'fork-from', type: 'string', required: false, constraint: 'FORK the new node from an existing pi conversation instead of starting it fresh: pass a node id (forks from that node\'s session), an absolute session `.jsonl` path, or a partial pi session uuid. pi copies that whole history into a NEW session for the child (the source is untouched), then the prompt is delivered as the next message — i.e. the child wakes up as a continuation of that conversation. Use to branch exploratory work off a node that already built up the context you need, instead of re-deriving it. One-shot at birth: the fork resumes its own session thereafter.' },
125
132
  { kind: 'flag', name: 'model', type: 'enum', choices: ['ultra', 'strong', 'medium', 'light'], required: false, constraint: 'Override the model the node runs on, by capability TIER: ultra (frontier), strong (opus), medium (sonnet), light (haiku). Omit to use the kind\'s persona default (advisor=ultra, explore=light, most other builtins=strong). The override is durable — it survives revives and polymorphs (promote/demote).' },
126
133
  { kind: 'flag', name: 'profile', type: 'string', required: false, constraint: 'Select the profile this node runs under — an exact profile id or a unique manifest name (see the <profiles> list below, or `crtr profile list`). Omit to INHERIT the calling node\'s current profile (root has none, so an uninherited child has none either). --root does NOT reset this — it only means top-level, still inherits/uses the selected profile unless this overrides it.' },
@@ -143,12 +150,12 @@ const nodeNew = defineLeaf({
143
150
  { name: 'next_eval_at', type: 'string', required: false, constraint: 'The next predicate-evaluation instant (always "now" at arm time), present only for an armed --when trigger.' },
144
151
  { name: 'timeout_at', type: 'string', required: false, constraint: 'Absolute UTC wall-clock bound, present only for an armed --when trigger.' },
145
152
  { name: 'recur', type: 'string', required: false, constraint: 'Cadence display ("every 6h", cron, or eval cadence for --when), present only when --every was given.' },
146
- { name: 'follow_up', type: 'string', required: true, constraint: 'Decision road sign for the caller. Spawned now: the child runs independently and its finish wakes you on its own, so never wait or poll on it — either pick up other work now or end your turn (an orchestrator already deep in context is instead steered to `crtr node yield`). Armed: no node exists yet — inspect/cancel via `crtr node triggers list`/`cancel <id>`, then pick up other work or end your turn.' },
153
+ { name: 'follow_up', type: 'string', required: true, constraint: 'Decision road sign for the caller. Spawned now: the child runs independently and its finish wakes you on its own, so never wait, poll, or duplicate its assignment — either pick up other independent work now or end your turn (an orchestrator already deep in context is instead steered to `crtr node yield`). Armed: no node exists yet — inspect/cancel via `crtr node triggers list`/`cancel <id>`, then pick up other work or end your turn.' },
147
154
  ],
148
155
  dynamicState: () => [kindsStateBlock(), profilesStateBlock()].join('\n'),
149
156
  outputKind: 'object',
150
157
  effects: [
151
- 'Spawning now: creates a node under ~/.crouter/canvas/nodes/<id>/ and indexes it in canvas.db. Default (managed child): parent auto-subscribes (active) and is woken on the child\'s pushes. With --root: no subscription — records a spawned_by edge for provenance only. Launches the node\'s engine as a detached broker (the only host); a managed child opens NO viewer, a --root opens one in your current tmux session.',
158
+ 'Spawning now: creates a node under ~/.crouter/canvas/nodes/<id>/ and indexes it in canvas.db. Default (managed child): parent auto-subscribes (active) and is woken on the child\'s pushes. With --root: no subscription — records a spawned_by edge for provenance only. With --worktree: also creates a managed git worktree at ~/.crouter/canvas/worktrees/<node-id>/ on branch crtr/<node-id> based on origin/main; the child closes it later with `crtr worktree close` before finishing. Launches the node\'s engine as a detached broker (the only host); a managed child opens NO viewer, a --root opens one in your current tmux session.',
152
159
  'Armed (--at/--every/--when): inserts one detached `new`/`node_birth` triggers row (node_id NULL, owner=you, parent/cwd resolved now); NO node and NO window exist until fire time. The daemon spawns from the stored recipe at fire time, re-deriving the launch spec live.',
153
160
  ],
154
161
  },
@@ -163,6 +170,7 @@ const nodeNew = defineLeaf({
163
170
  const name = input['name'];
164
171
  const parent = input['parent'];
165
172
  const root = input['root'] === true;
173
+ const worktree = input['worktree'] === true;
166
174
  const forkFrom = input['forkFrom'];
167
175
  const model = input['model'];
168
176
  const profile = input['profile'];
@@ -183,19 +191,30 @@ const nodeNew = defineLeaf({
183
191
  if (hasTz && !hasAt && !hasEvery) {
184
192
  throw new InputError({ error: 'tz_needs_clock', message: '--tz has no effect without --at or --every to parse it against', field: 'tz', next: 'Drop --tz, or pass --at/--every.' });
185
193
  }
194
+ if (root && worktree) {
195
+ throw new InputError({ error: 'worktree_root_conflict', message: '--worktree is mutually exclusive with --root', field: 'worktree', next: 'Drop either --root or --worktree.' });
196
+ }
186
197
  if (!triggered) {
187
- const res = await spawnChild({ kind, mode, cwd, name, prompt, parent, root, forkFrom, model, profile });
188
- return {
189
- node_id: res.node.node_id,
190
- name: res.node.name,
191
- window: res.window ?? undefined,
192
- session: res.session ?? undefined,
193
- status: res.node.status,
194
- armed: false,
195
- follow_up: root
196
- ? "Independent root spawned — it is NOT under you. You are not subscribed, so its finish will NOT wake you and it does not hold you alive; it carries spawned_by=you for lineage only. Inside tmux it opened a viewer in your current session (the client switched to it) so it can be driven directly. Hand it off and move on — you will not be notified of its progress."
197
- : childFollowUp(parent ?? process.env['CRTR_NODE_ID']),
198
- };
198
+ try {
199
+ const res = await spawnChild({ kind, mode, cwd, name, prompt, parent, root, worktree, forkFrom, model, profile });
200
+ return {
201
+ node_id: res.node.node_id,
202
+ name: res.node.name,
203
+ window: res.window ?? undefined,
204
+ session: res.session ?? undefined,
205
+ status: res.node.status,
206
+ armed: false,
207
+ follow_up: root
208
+ ? "Independent root spawned — it is NOT under you. You are not subscribed, so its finish will NOT wake you and it does not hold you alive; it carries spawned_by=you for lineage only. Inside tmux it opened a viewer in your current session (the client switched to it) so it can be driven directly. Hand it off and move on — do not duplicate its assignment; you will not be notified of its progress."
209
+ : childFollowUp(parent ?? process.env['CRTR_NODE_ID']),
210
+ };
211
+ }
212
+ catch (err) {
213
+ const mapped = mapWorktreeError(err);
214
+ if (mapped !== null)
215
+ throw mapped;
216
+ throw err;
217
+ }
199
218
  }
200
219
  if (hasWhen && hasAt) {
201
220
  throw new InputError({ error: 'bad_when_combo', message: '--when cannot combine with --at', next: 'A trigger is either clock-driven (--at/--every) or predicate-driven (--when).' });
@@ -219,6 +238,7 @@ const nodeNew = defineLeaf({
219
238
  parent: recipeParent,
220
239
  ...(name !== undefined ? { name } : {}),
221
240
  ...(root ? { root: true } : {}),
241
+ ...(worktree ? { worktree: true } : {}),
222
242
  ...(forkFrom !== undefined ? { forkFrom } : {}),
223
243
  ...(model !== undefined ? { model } : {}),
224
244
  ...(profile !== undefined ? { profile } : {}),
@@ -12,6 +12,7 @@ import { defineBranch, defineLeaf } from '../core/command.js';
12
12
  import { InputError, readStdinRaw } from '../core/io.js';
13
13
  import { push } from '../core/feed/feed.js';
14
14
  import { getNode, subscribersOf } from '../core/canvas/index.js';
15
+ import { hasOpenManagedWorktree } from '../core/worktree.js';
15
16
  function requireCallerNode() {
16
17
  const id = process.env['CRTR_NODE_ID'];
17
18
  if (id === undefined || id.trim() === '') {
@@ -96,6 +97,13 @@ function makeTierLeaf(tier) {
96
97
  if (node !== null && node.status !== 'active' && node.status !== 'idle') {
97
98
  throw alreadyFinalizedError(node.status);
98
99
  }
100
+ if (node !== null && hasOpenManagedWorktree(node)) {
101
+ throw new InputError({
102
+ error: 'open_managed_worktree',
103
+ message: 'This node still owns an open managed worktree — `push final` is blocked until it is closed.',
104
+ next: 'Commit the remaining work in the worktree, run `crtr worktree close`, then rerun `crtr push final`.',
105
+ });
106
+ }
99
107
  // A RESIDENT node with no subscribers is human-driven and has no one to
100
108
  // submit a canonical result to: `push final` fans to subscribers, and a
101
109
  // resident root conversation has none. Finishing it would shut its
@@ -1,2 +1,12 @@
1
1
  import type { LeafDef } from '../core/command.js';
2
+ import type { Fault } from '../core/runtime/fault.js';
3
+ /** --now eligibility for a live, non-busy fault: a daemon-owned AUTO retry
4
+ * (kick it early instead of waiting out the schedule), or any fault the
5
+ * canvas dashboard flags "needs you" (`faultNeedsYou`, status-glyph.ts) — the
6
+ * disposition that otherwise has NO sanctioned recovery verb (issue #115).
7
+ * Both are scoped to `pi→provider`: refuses a client-owned auto retry
8
+ * (redialing) already in flight, and refuses any OTHER link — notably a
9
+ * `daemon→node` wedge fault, which --now can't clear (SIGTERM doesn't touch
10
+ * the runaway subprocess; see `wedged-child-on-runaway-bash`). */
11
+ export declare function isNowEligibleFault(fault: Fault | null): boolean;
2
12
  export declare const reviveLeaf: LeafDef;