@crouton-kit/crouter 0.3.40 → 0.3.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/dist/build-root.js +1 -0
  2. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +472 -9
  3. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +284 -76
  4. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/strip-skills-docs.ts +31 -24
  5. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +52 -15
  6. package/dist/clients/attach/attach-cmd.js +706 -733
  7. package/dist/clients/attach/chat-view.js +11 -0
  8. package/dist/clients/web/server.js +5 -28
  9. package/dist/clients/web/web-cmd.js +1 -1
  10. package/dist/commands/__tests__/revive-now-gate.test.js +60 -0
  11. package/dist/commands/canvas-rebuild-index.js +5 -5
  12. package/dist/commands/node-snapshot.js +9 -1
  13. package/dist/commands/node.js +37 -17
  14. package/dist/commands/push.js +8 -0
  15. package/dist/commands/revive.d.ts +10 -0
  16. package/dist/commands/revive.js +28 -10
  17. package/dist/commands/sys/__tests__/setup-core.test.js +19 -0
  18. package/dist/commands/sys/doctor.js +1 -1
  19. package/dist/commands/sys/setup-core.js +3 -2
  20. package/dist/commands/sys/setup.js +1 -1
  21. package/dist/commands/worktree.d.ts +2 -0
  22. package/dist/commands/worktree.js +94 -0
  23. package/dist/core/__tests__/boot.test.js +4 -4
  24. package/dist/core/__tests__/canvas.test.js +19 -7
  25. package/dist/core/__tests__/child-followup.test.js +15 -5
  26. package/dist/core/__tests__/daemon-boot.test.js +6 -1
  27. package/dist/core/__tests__/daemon-wedge.test.js +18 -1
  28. package/dist/core/__tests__/fault-classifier.test.js +30 -0
  29. package/dist/core/__tests__/fixtures/fake-engine.d.ts +13 -0
  30. package/dist/core/__tests__/fixtures/fake-engine.js +20 -0
  31. package/dist/core/__tests__/full/spike-harness.test.js +8 -5
  32. package/dist/core/__tests__/grace-clock.test.js +18 -2
  33. package/dist/core/__tests__/host-teardown-process-group.test.js +465 -0
  34. package/dist/core/__tests__/prune-to-limit.test.js +14 -0
  35. package/dist/core/__tests__/review-model-floor.test.js +32 -0
  36. package/dist/core/__tests__/revive.test.js +18 -4
  37. package/dist/core/__tests__/session-cycles.test.js +77 -0
  38. package/dist/core/__tests__/worktree.test.js +85 -0
  39. package/dist/core/canvas/boot.js +12 -7
  40. package/dist/core/canvas/canvas.d.ts +27 -8
  41. package/dist/core/canvas/canvas.js +54 -26
  42. package/dist/core/canvas/db.js +14 -0
  43. package/dist/core/canvas/history.js +1 -0
  44. package/dist/core/canvas/paths.d.ts +10 -9
  45. package/dist/core/canvas/paths.js +10 -9
  46. package/dist/core/canvas/pid.d.ts +155 -1
  47. package/dist/core/canvas/pid.js +306 -1
  48. package/dist/core/canvas/status-glyph.d.ts +7 -0
  49. package/dist/core/canvas/status-glyph.js +10 -1
  50. package/dist/core/canvas/types.d.ts +34 -0
  51. package/dist/core/fault-classifier.js +5 -1
  52. package/dist/core/runtime/bearings.js +4 -0
  53. package/dist/core/runtime/branded-host.d.ts +7 -0
  54. package/dist/core/runtime/branded-host.js +44 -17
  55. package/dist/core/runtime/broker-sdk.js +28 -68
  56. package/dist/core/runtime/broker.js +35 -4
  57. package/dist/core/runtime/host.d.ts +3 -3
  58. package/dist/core/runtime/host.js +148 -33
  59. package/dist/core/runtime/launch.d.ts +15 -15
  60. package/dist/core/runtime/launch.js +58 -4
  61. package/dist/core/runtime/naming.js +3 -2
  62. package/dist/core/runtime/nodes.d.ts +3 -1
  63. package/dist/core/runtime/nodes.js +1 -0
  64. package/dist/core/runtime/pi-cli.d.ts +2 -0
  65. package/dist/core/runtime/pi-cli.js +51 -0
  66. package/dist/core/runtime/placement.d.ts +1 -1
  67. package/dist/core/runtime/placement.js +5 -2
  68. package/dist/core/runtime/recap.js +2 -1
  69. package/dist/core/runtime/revive.d.ts +21 -15
  70. package/dist/core/runtime/revive.js +87 -52
  71. package/dist/core/runtime/session-cycles.d.ts +30 -0
  72. package/dist/core/runtime/session-cycles.js +77 -0
  73. package/dist/core/runtime/spawn.d.ts +4 -0
  74. package/dist/core/runtime/spawn.js +152 -100
  75. package/dist/core/runtime/tmux.d.ts +7 -10
  76. package/dist/core/runtime/tmux.js +9 -11
  77. package/dist/core/worktree.d.ts +35 -0
  78. package/dist/core/worktree.js +158 -0
  79. package/dist/daemon/crtrd.d.ts +17 -8
  80. package/dist/daemon/crtrd.js +191 -40
  81. package/dist/index.d.ts +3 -0
  82. package/dist/index.js +7 -0
  83. package/dist/pi-extensions/canvas-context-intro.d.ts +1 -0
  84. package/dist/pi-extensions/canvas-context-intro.js +34 -23
  85. package/dist/pi-extensions/canvas-stophook.js +11 -5
  86. package/dist/web-client/assets/{index-CbO8L0mN.js → index-B00YpRQ1.js} +20 -20
  87. package/dist/web-client/assets/index-DrkcvANq.css +2 -0
  88. package/dist/web-client/index.html +2 -2
  89. package/docs/compat/hearth-crtr-v1.md +191 -0
  90. package/docs/public-api.md +75 -0
  91. package/package.json +3 -4
  92. package/dist/core/__tests__/hearth-bootstrap.test.js +0 -136
  93. package/dist/core/hearth/__tests__/model-auth-guest.test.js +0 -151
  94. package/dist/core/hearth/config.d.ts +0 -3
  95. package/dist/core/hearth/config.js +0 -108
  96. package/dist/core/hearth/guest-env.d.ts +0 -9
  97. package/dist/core/hearth/guest-env.js +0 -27
  98. package/dist/core/hearth/index.d.ts +0 -4
  99. package/dist/core/hearth/index.js +0 -4
  100. package/dist/core/hearth/model-auth-guest.d.ts +0 -8
  101. package/dist/core/hearth/model-auth-guest.js +0 -430
  102. package/dist/core/hearth/provider.d.ts +0 -36
  103. package/dist/core/hearth/provider.js +0 -10
  104. package/dist/core/hearth/providers/__tests__/sweep-and-release.test.js +0 -362
  105. package/dist/core/hearth/providers/blaxel-bootstrap.d.ts +0 -12
  106. package/dist/core/hearth/providers/blaxel-bootstrap.js +0 -147
  107. package/dist/core/hearth/providers/blaxel-home.d.ts +0 -60
  108. package/dist/core/hearth/providers/blaxel-home.js +0 -405
  109. package/dist/core/hearth/providers/blaxel.d.ts +0 -36
  110. package/dist/core/hearth/providers/blaxel.js +0 -364
  111. package/dist/core/hearth/providers/types.d.ts +0 -93
  112. package/dist/core/hearth/types.d.ts +0 -155
  113. package/dist/hearth/control-plane/__tests__/error-serialization.test.js +0 -29
  114. package/dist/hearth/control-plane/__tests__/node-message.test.js +0 -60
  115. package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.d.ts +0 -1
  116. package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.js +0 -44
  117. package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.d.ts +0 -1
  118. package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.js +0 -49
  119. package/dist/hearth/control-plane/__tests__/relay-security.test.d.ts +0 -1
  120. package/dist/hearth/control-plane/__tests__/relay-security.test.js +0 -314
  121. package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.d.ts +0 -1
  122. package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.js +0 -133
  123. package/dist/hearth/control-plane/__tests__/trigger-delivery.test.d.ts +0 -1
  124. package/dist/hearth/control-plane/__tests__/trigger-delivery.test.js +0 -170
  125. package/dist/hearth/control-plane/__tests__/wake-roll.test.d.ts +0 -1
  126. package/dist/hearth/control-plane/__tests__/wake-roll.test.js +0 -230
  127. package/dist/hearth/control-plane/__tests__/webhook-ingress.test.d.ts +0 -1
  128. package/dist/hearth/control-plane/__tests__/webhook-ingress.test.js +0 -167
  129. package/dist/hearth/control-plane/config.d.ts +0 -21
  130. package/dist/hearth/control-plane/config.js +0 -77
  131. package/dist/hearth/control-plane/db.d.ts +0 -30
  132. package/dist/hearth/control-plane/db.js +0 -561
  133. package/dist/hearth/control-plane/hearth-target.d.ts +0 -23
  134. package/dist/hearth/control-plane/hearth-target.js +0 -68
  135. package/dist/hearth/control-plane/ingress/rate-limit.d.ts +0 -24
  136. package/dist/hearth/control-plane/ingress/rate-limit.js +0 -100
  137. package/dist/hearth/control-plane/ingress/route-store.d.ts +0 -31
  138. package/dist/hearth/control-plane/ingress/route-store.js +0 -61
  139. package/dist/hearth/control-plane/ingress/webhook-delivery-store.d.ts +0 -41
  140. package/dist/hearth/control-plane/ingress/webhook-delivery-store.js +0 -69
  141. package/dist/hearth/control-plane/ingress/webhook-route.d.ts +0 -55
  142. package/dist/hearth/control-plane/ingress/webhook-route.js +0 -285
  143. package/dist/hearth/control-plane/main.d.ts +0 -1
  144. package/dist/hearth/control-plane/main.js +0 -88
  145. package/dist/hearth/control-plane/node-message.d.ts +0 -31
  146. package/dist/hearth/control-plane/node-message.js +0 -98
  147. package/dist/hearth/control-plane/register.d.ts +0 -15
  148. package/dist/hearth/control-plane/register.js +0 -34
  149. package/dist/hearth/control-plane/registry.d.ts +0 -22
  150. package/dist/hearth/control-plane/registry.js +0 -168
  151. package/dist/hearth/control-plane/relay.d.ts +0 -44
  152. package/dist/hearth/control-plane/relay.js +0 -711
  153. package/dist/hearth/control-plane/scheduler/fire-store.d.ts +0 -36
  154. package/dist/hearth/control-plane/scheduler/fire-store.js +0 -73
  155. package/dist/hearth/control-plane/scheduler/recurrence.d.ts +0 -7
  156. package/dist/hearth/control-plane/scheduler/recurrence.js +0 -58
  157. package/dist/hearth/control-plane/scheduler/scan-loop.d.ts +0 -38
  158. package/dist/hearth/control-plane/scheduler/scan-loop.js +0 -138
  159. package/dist/hearth/control-plane/scheduler/schedule-store.d.ts +0 -32
  160. package/dist/hearth/control-plane/scheduler/schedule-store.js +0 -66
  161. package/dist/hearth/control-plane/secrets.d.ts +0 -31
  162. package/dist/hearth/control-plane/secrets.js +0 -134
  163. package/dist/hearth/control-plane/server.d.ts +0 -27
  164. package/dist/hearth/control-plane/server.js +0 -482
  165. package/dist/hearth/control-plane/serving.d.ts +0 -15
  166. package/dist/hearth/control-plane/serving.js +0 -106
  167. package/dist/hearth/control-plane/session.d.ts +0 -68
  168. package/dist/hearth/control-plane/session.js +0 -273
  169. package/dist/hearth/control-plane/triggers/acl.d.ts +0 -14
  170. package/dist/hearth/control-plane/triggers/acl.js +0 -52
  171. package/dist/hearth/control-plane/triggers/audit-store.d.ts +0 -38
  172. package/dist/hearth/control-plane/triggers/audit-store.js +0 -79
  173. package/dist/hearth/control-plane/triggers/deliver.d.ts +0 -43
  174. package/dist/hearth/control-plane/triggers/deliver.js +0 -76
  175. package/dist/hearth/control-plane/triggers/envelope.d.ts +0 -29
  176. package/dist/hearth/control-plane/triggers/envelope.js +0 -38
  177. package/dist/hearth/control-plane/types.d.ts +0 -86
  178. package/dist/hearth/control-plane/types.js +0 -1
  179. package/dist/hearth/control-plane/wake.d.ts +0 -86
  180. package/dist/hearth/control-plane/wake.js +0 -550
  181. package/dist/web-client/assets/index-DwO46Cs5.css +0 -2
  182. /package/dist/{core/__tests__/hearth-bootstrap.test.d.ts → commands/__tests__/revive-now-gate.test.d.ts} +0 -0
  183. /package/dist/{core/hearth/__tests__/model-auth-guest.test.d.ts → commands/sys/__tests__/setup-core.test.d.ts} +0 -0
  184. /package/dist/core/{hearth/providers/__tests__/sweep-and-release.test.d.ts → __tests__/fault-classifier.test.d.ts} +0 -0
  185. /package/dist/core/{hearth/providers/types.js → __tests__/host-teardown-process-group.test.d.ts} +0 -0
  186. /package/dist/core/{hearth/types.js → __tests__/review-model-floor.test.d.ts} +0 -0
  187. /package/dist/{hearth/control-plane/__tests__/error-serialization.test.d.ts → core/__tests__/session-cycles.test.d.ts} +0 -0
  188. /package/dist/{hearth/control-plane/__tests__/node-message.test.d.ts → core/__tests__/worktree.test.d.ts} +0 -0
@@ -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);
@@ -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',
@@ -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;
@@ -3,8 +3,10 @@
3
3
  // Bypasses the daemon: directly relaunches the broker engine for a node that is
4
4
  // done, idle, or dead. Default behavior resumes the saved pi conversation by its
5
5
  // absolute session-file path (--session <path>) when that .jsonl still exists,
6
- // else falls through to a fresh launch; pass --fresh to force a clean pi session
7
- // against the context dir.
6
+ // else falls through to a fresh launch. `--fresh` skips the true resume: if the
7
+ // `.jsonl` already exists it CYCLES in place instead (same file, a fresh
8
+ // `crtr-cycle` branch — `resumed:false`), and only starts a genuinely clean
9
+ // session when no `.jsonl` exists yet (also `resumed:false`).
8
10
  //
9
11
  // `--all` sweeps EVERY disconnected node (engine not running, resumable session)
10
12
  // in one shot — the recovery for a reboot / mass-crash / daemon-down-a-while
@@ -19,6 +21,22 @@ import { getNode, fullName } from '../core/canvas/index.js';
19
21
  import { isPidAlive } from '../core/canvas/pid.js';
20
22
  import { readFault } from '../core/runtime/fault.js';
21
23
  import { isBusy } from '../core/runtime/busy.js';
24
+ import { faultNeedsYou } from '../core/canvas/status-glyph.js';
25
+ /** --now eligibility for a live, non-busy fault: a daemon-owned AUTO retry
26
+ * (kick it early instead of waiting out the schedule), or any fault the
27
+ * canvas dashboard flags "needs you" (`faultNeedsYou`, status-glyph.ts) — the
28
+ * disposition that otherwise has NO sanctioned recovery verb (issue #115).
29
+ * Both are scoped to `pi→provider`: refuses a client-owned auto retry
30
+ * (redialing) already in flight, and refuses any OTHER link — notably a
31
+ * `daemon→node` wedge fault, which --now can't clear (SIGTERM doesn't touch
32
+ * the runaway subprocess; see `wedged-child-on-runaway-bash`). */
33
+ export function isNowEligibleFault(fault) {
34
+ if (fault === null || fault.link !== 'pi→provider')
35
+ return false;
36
+ if (faultNeedsYou(fault))
37
+ return true;
38
+ return fault.retry.by === 'daemon';
39
+ }
22
40
  // ---------------------------------------------------------------------------
23
41
  // revive node
24
42
  // ---------------------------------------------------------------------------
@@ -50,7 +68,7 @@ export const reviveLeaf = defineLeaf({
50
68
  type: 'bool',
51
69
  required: false,
52
70
  default: false,
53
- constraint: 'When set, start a clean pi session (no --session). Default: resume the saved conversation. Ignored with --all (which always resumes).',
71
+ constraint: 'When set, do NOT do a true resume. If the node already has a session `.jsonl`, this instead CYCLES it in place — reuses that SAME file, resets the tree leaf, and roots a fresh `crtr-cycle` branch (a clean-context restart that still carries the file\'s history, output `resumed:false`). Only when no `.jsonl` exists yet does this start a genuinely brand-new session (also `resumed:false`). Default (no --fresh): a true resume — replays the existing `.jsonl` as-is (`resumed:true`). Ignored with --all (which always resumes).',
54
72
  },
55
73
  {
56
74
  kind: 'flag',
@@ -58,7 +76,7 @@ export const reviveLeaf = defineLeaf({
58
76
  type: 'bool',
59
77
  required: false,
60
78
  default: false,
61
- constraint: 'On-demand kick of an active-fault node (parked on a provider-owned fault, broker still alive). Its broker pid is alive, so an ordinary revive no-ops (double-launch guard) — --now SIGTERMs the live broker instead, so the daemon\'s crash→grace→resume path brings it back in ~20s instead of waiting out the auto-recovery grace. Only valid for a node that has a live daemon-owned auto provider fault and a live pid. Mutually exclusive with --all/--fresh.',
79
+ constraint: 'On-demand kick of an active-fault node (parked on a `pi→provider` fault, broker still alive). Its broker pid is alive, so an ordinary revive no-ops (double-launch guard) — --now SIGTERMs the live broker instead, so the daemon\'s crash→grace→resume path brings it back in ~20s. Valid for a live `pi→provider` fault that is either a daemon-owned auto retry (kicks it early), or ANY fault the canvas dashboard flags "needs you" (e.g. exhausted retries, an auth fault you just fixed) — that disposition otherwise has no recovery verb. Mutually exclusive with --all/--fresh.',
62
80
  },
63
81
  // --force is a DELIBERATELY UNDOCUMENTED confirmation gate for --all (hidden
64
82
  // from -h; see FlagParam.hidden). Silas's intent (2026-06-13): a mass revive
@@ -80,7 +98,7 @@ export const reviveLeaf = defineLeaf({
80
98
  output: [
81
99
  { name: 'window', type: 'string', required: false, constraint: 'Always null — the revived broker is headless and opens no tmux window. Kept for caller back-compat.' },
82
100
  { name: 'session', type: 'string', required: false, constraint: 'The node\'s last live location session, or null — the headless broker has no tmux session of its own.' },
83
- { name: 'resumed', type: 'boolean', required: false, constraint: 'True when pi was told to --session the saved conversation. Single-node revive only.' },
101
+ { name: 'resumed', type: 'boolean', required: false, constraint: 'True ONLY for a true resume (the existing `.jsonl` replayed as-is). False for a --fresh revive, whether that landed as an in-place CYCLE (an existing `.jsonl` reused with a fresh `crtr-cycle` branch — pi is still told --session, but this is not a resume) or a genuinely fresh launch (no `.jsonl` existed). Single-node revive only.' },
84
102
  { name: 'ready', type: 'boolean', required: false, constraint: 'True when the revived broker\'s view.sock accepted a connection before return — the node is immediately attachable/drivable. Single-node revive only.' },
85
103
  { name: 'mode', type: 'string', required: false, constraint: '"preview" (the --all candidate list, nothing launched), "revived" (the --all sweep ran), or absent for a single-node revive.' },
86
104
  { name: 'candidates', type: 'string', required: false, constraint: '--all preview: the node ids that WOULD be revived (newline-joined). Empty when none are disconnected.' },
@@ -145,8 +163,8 @@ export const reviveLeaf = defineLeaf({
145
163
  // reviveNode would no-op the double-launch guard) — SIGTERM it so the daemon's
146
164
  // ordinary crash→grace→resume path recovers it on the saved session, the same
147
165
  // thing the daemon does at the auto-recovery grace, just on demand. Gated on
148
- // a live pid and a daemon-owned provider fault so it can't be used to nuke a
149
- // healthy node.
166
+ // a live pid and an eligible pi→provider fault (`isNowEligibleFault` above) so
167
+ // it can't be used to nuke a healthy node.
150
168
  if (now) {
151
169
  const pid = meta.pi_pid;
152
170
  const stall = readFault(nodeId);
@@ -157,11 +175,11 @@ export const reviveLeaf = defineLeaf({
157
175
  next: 'Revive a dormant node with `crtr canvas revive ' + nodeId + '` (no --now).',
158
176
  });
159
177
  }
160
- if (stall === null || stall.link !== 'pi→provider' || stall.retry.by !== 'daemon' || stall.retry.disposition !== 'auto') {
178
+ if (!isNowEligibleFault(stall)) {
161
179
  throw new InputError({
162
180
  error: 'not_hanging',
163
- message: `${nodeId} is live but not an active daemon-owned provider fault — --now refuses to SIGTERM a healthy node.`,
164
- next: 'Use --now only on a node the canvas shows as hanging (⚠). For a routine relaunch, omit --now.',
181
+ message: `${nodeId} is live but has no pi→provider fault eligible for --now (either healthy, or a client-owned retry / non-provider fault already being handled elsewhere) — --now refuses to SIGTERM a node that isn't stuck on a kickable provider fault.`,
182
+ next: 'Use --now only on a node the canvas shows as hanging (⚠) with a `pi→provider` fault. For a routine relaunch, omit --now.',
165
183
  });
166
184
  }
167
185
  // The daemon never kills a BUSY engine (its verdict paths all return 'leave'
@@ -0,0 +1,19 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { existsSync } from 'node:fs';
4
+ import { resolveBundledPiCliPath } from '../../../core/runtime/pi-cli.js';
5
+ test('resolveBundledPiCliPath uses crouter bundled pi instead of a standalone pi on PATH', () => {
6
+ const prevPath = process.env.PATH;
7
+ process.env.PATH = '';
8
+ try {
9
+ const cliPath = resolveBundledPiCliPath();
10
+ assert.equal(existsSync(cliPath), true);
11
+ assert.match(cliPath, /@earendil-works[\/]pi-coding-agent[\/]dist[\/]cli\.js$/);
12
+ }
13
+ finally {
14
+ if (prevPath === undefined)
15
+ delete process.env.PATH;
16
+ else
17
+ process.env.PATH = prevPath;
18
+ }
19
+ });
@@ -71,7 +71,7 @@ function faultRemediation(fault) {
71
71
  auth: 're-authenticate',
72
72
  protocol: 'fix the protocol mismatch',
73
73
  other: 'fix the underlying issue',
74
- wedged: 'read `crtr memory read wedged-child-on-runaway-bash` and, if a runaway subprocess is to blame, kill the subprocess (not the node)',
74
+ wedged: 'read `crtr memory read wedged-child-on-runaway-bash` if a runaway subprocess is to blame, kill it (not the node); if the broker had no subprocess, the daemon already SIGTERM-kicked it to resume on its own (recheck node status; nudge it if it came back idle)',
75
75
  };
76
76
  const progress = fault.retry.disposition === 'auto'
77
77
  ? fault.retry.by === 'client'
@@ -4,6 +4,7 @@ import { homedir } from 'node:os';
4
4
  import { basename, dirname, join, resolve } from 'node:path';
5
5
  import { builtinPiPackageDir } from '../../core/scope.js';
6
6
  import { readJsonIfExists } from '../../core/fs-utils.js';
7
+ import { resolveBundledPiCliPath } from '../../core/runtime/pi-cli.js';
7
8
  const PACKAGE_COPY = {
8
9
  'crtr-extensions': {
9
10
  name: 'pi-crtr-extensions',
@@ -285,8 +286,8 @@ function runShellCommand(command) {
285
286
  throw new Error(`command failed: ${command}`);
286
287
  }
287
288
  }
288
- function installPiPackage(source) {
289
- const result = spawnSync('pi', ['install', source], { stdio: 'inherit' });
289
+ function installPiPackage(source, piCliPath = resolveBundledPiCliPath()) {
290
+ const result = spawnSync(process.execPath, [piCliPath, 'install', source], { stdio: 'inherit' });
290
291
  if (result.status !== 0) {
291
292
  throw new Error(`pi install failed: ${source}`);
292
293
  }
@@ -506,7 +506,7 @@ export const sysSetupLeaf = defineLeaf({
506
506
  outputKind: 'object',
507
507
  effects: [
508
508
  'Opens a pi-tui wizard that collects companion package installs, system dependency installs, and an Exa API key.',
509
- 'Runs pi installs for the selected bundled packages, writes EXA_API_KEY to ~/.crouter/exa.key when provided, and prints a summary.',
509
+ 'Runs the bundled pi CLI to install the selected companion packages, writes EXA_API_KEY to ~/.crouter/exa.key when provided, and prints a summary.',
510
510
  ],
511
511
  },
512
512
  run: async () => {
@@ -0,0 +1,2 @@
1
+ import { type BranchDef } from '../core/command.js';
2
+ export declare function registerWorktree(): BranchDef;