@crouton-kit/crouter 0.3.70 → 0.3.79

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 (193) hide show
  1. package/README.md +1 -1
  2. package/dist/build-root.d.ts +12 -4
  3. package/dist/build-root.js +25 -6
  4. package/dist/builtin-memory/00-runtime-base.md +3 -10
  5. package/dist/builtin-memory/04-base-worker.md +18 -0
  6. package/dist/builtin-memory/04-orchestration-kernel.md +1 -1
  7. package/dist/builtin-memory/crouter-development/plugins.md +82 -5
  8. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +13 -34
  9. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +1115 -1
  10. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +234 -71
  11. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/statusline.ts +2 -9
  12. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +2 -516
  13. package/dist/builtin-pi-packages/pi-crtr-extensions/package-lock.json +2 -2
  14. package/dist/builtin-pi-packages/pi-mode-switch/README.md +11 -39
  15. package/dist/builtin-pi-packages/pi-mode-switch/extensions/index.ts +20 -15
  16. package/dist/builtin-pi-packages/pi-mode-switch/package.json +1 -1
  17. package/dist/builtin-views/canvas/tui.mjs +8 -9
  18. package/dist/builtin-views/chat/tui.mjs +12 -12
  19. package/dist/builtin-views/git-pr/tui.mjs +7 -8
  20. package/dist/builtin-views/inbox/tui.mjs +27 -41
  21. package/dist/builtin-views/linkedin/tui.mjs +23 -37
  22. package/dist/builtin-views/prompt-review/tui.mjs +11 -11
  23. package/dist/builtin-views/settings/tui.mjs +11 -11
  24. package/dist/builtin-views/workspace-sidebar/tui.mjs +8 -9
  25. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +11 -6
  26. package/dist/clients/attach/__tests__/attach-keybindings.test.d.ts +1 -0
  27. package/dist/clients/attach/__tests__/attach-keybindings.test.js +113 -0
  28. package/dist/clients/attach/__tests__/crtr-output-render.test.js +1 -1
  29. package/dist/clients/attach/__tests__/mermaid-render.test.d.ts +1 -0
  30. package/dist/clients/attach/__tests__/mermaid-render.test.js +28 -0
  31. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.d.ts +1 -0
  32. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +110 -0
  33. package/dist/clients/attach/attach-cmd.d.ts +9 -1
  34. package/dist/clients/attach/attach-cmd.js +841 -792
  35. package/dist/clients/attach/auth-pickers.d.ts +0 -12
  36. package/dist/clients/attach/auth-pickers.js +64 -15
  37. package/dist/clients/attach/canvas-panels.js +2 -3
  38. package/dist/clients/attach/chat-view.d.ts +7 -8
  39. package/dist/clients/attach/chat-view.js +119 -79
  40. package/dist/clients/attach/edit-diff-render.d.ts +6 -0
  41. package/dist/clients/attach/edit-diff-render.js +263 -0
  42. package/dist/clients/attach/graph-overlay.d.ts +12 -2
  43. package/dist/clients/attach/graph-overlay.js +83 -33
  44. package/dist/clients/attach/input-controller.d.ts +28 -2
  45. package/dist/clients/attach/input-controller.js +38 -5
  46. package/dist/clients/attach/mermaid-render.js +5 -2
  47. package/dist/clients/attach/pickers.d.ts +8 -7
  48. package/dist/clients/attach/pickers.js +11 -17
  49. package/dist/clients/attach/slash-commands.d.ts +9 -0
  50. package/dist/clients/attach/slash-commands.js +127 -7
  51. package/dist/clients/attach/titled-editor.d.ts +12 -1
  52. package/dist/clients/attach/titled-editor.js +103 -8
  53. package/dist/commands/canvas-browse.js +2 -2
  54. package/dist/commands/human/queue.js +3 -4
  55. package/dist/commands/memory/lint.js +39 -5
  56. package/dist/commands/memory/write.js +1 -0
  57. package/dist/commands/node.js +9 -2
  58. package/dist/commands/pkg/plugin-inspect.js +22 -1
  59. package/dist/commands/pkg/plugin-manage.js +31 -9
  60. package/dist/commands/surface-tmux-spread.js +1 -3
  61. package/dist/commands/sys/__tests__/config-keybindings.test.d.ts +1 -0
  62. package/dist/commands/sys/__tests__/config-keybindings.test.js +55 -0
  63. package/dist/commands/sys/__tests__/config-model-ladders.test.d.ts +1 -0
  64. package/dist/commands/sys/__tests__/config-model-ladders.test.js +121 -0
  65. package/dist/commands/sys/__tests__/setup-core.test.js +158 -1
  66. package/dist/commands/sys/config.js +18 -21
  67. package/dist/commands/sys/doctor.js +42 -4
  68. package/dist/commands/sys/setup-core.d.ts +49 -1
  69. package/dist/commands/sys/setup-core.js +161 -4
  70. package/dist/commands/sys/setup.d.ts +88 -0
  71. package/dist/commands/sys/setup.js +943 -169
  72. package/dist/commands/view-pick.d.ts +4 -0
  73. package/dist/commands/view-pick.js +17 -7
  74. package/dist/core/__tests__/base-worker-prompt.test.d.ts +1 -0
  75. package/dist/core/__tests__/base-worker-prompt.test.js +24 -0
  76. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +232 -1
  77. package/dist/core/__tests__/canvas-inbox-watcher.test.js +34 -9
  78. package/dist/core/__tests__/command-plugins-surfaces.test.d.ts +1 -0
  79. package/dist/core/__tests__/command-plugins-surfaces.test.js +298 -0
  80. package/dist/core/__tests__/command-plugins.test.d.ts +1 -0
  81. package/dist/core/__tests__/command-plugins.test.js +444 -0
  82. package/dist/core/__tests__/fault-classifier.test.js +15 -0
  83. package/dist/core/__tests__/fixtures/fake-engine.d.ts +6 -0
  84. package/dist/core/__tests__/fixtures/fake-engine.js +9 -1
  85. package/dist/core/__tests__/full/broker-dialogs.test.js +7 -2
  86. package/dist/core/__tests__/host-teardown-process-group.test.js +15 -4
  87. package/dist/core/__tests__/preview-registry-sync.test.js +30 -1
  88. package/dist/core/__tests__/scope-crouter-home-fence.test.d.ts +1 -0
  89. package/dist/core/__tests__/scope-crouter-home-fence.test.js +55 -0
  90. package/dist/core/__tests__/stream-watchdog.test.d.ts +1 -0
  91. package/dist/core/__tests__/stream-watchdog.test.js +70 -0
  92. package/dist/core/__tests__/tmux-surface.test.js +72 -0
  93. package/dist/core/canvas/browse/__tests__/model.test.js +23 -5
  94. package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
  95. package/dist/core/canvas/browse/app.d.ts +6 -0
  96. package/dist/core/canvas/browse/app.js +230 -41
  97. package/dist/core/canvas/browse/model.d.ts +10 -6
  98. package/dist/core/canvas/browse/model.js +20 -12
  99. package/dist/core/canvas/browse/pins.d.ts +4 -0
  100. package/dist/core/canvas/browse/pins.js +29 -0
  101. package/dist/core/canvas/browse/render.d.ts +13 -1
  102. package/dist/core/canvas/browse/render.js +72 -15
  103. package/dist/core/canvas/nav-model.js +5 -11
  104. package/dist/core/canvas/paths.d.ts +5 -0
  105. package/dist/core/canvas/paths.js +7 -0
  106. package/dist/core/command-plugins/adapter.d.ts +15 -0
  107. package/dist/core/command-plugins/adapter.js +145 -0
  108. package/dist/core/command-plugins/compose.d.ts +5 -0
  109. package/dist/core/command-plugins/compose.js +56 -0
  110. package/dist/core/command-plugins/discovery.d.ts +104 -0
  111. package/dist/core/command-plugins/discovery.js +565 -0
  112. package/dist/core/config.d.ts +48 -0
  113. package/dist/core/config.js +228 -110
  114. package/dist/core/fault-classifier.js +1 -1
  115. package/dist/core/fs-utils.d.ts +1 -0
  116. package/dist/core/fs-utils.js +15 -1
  117. package/dist/core/keybindings/__tests__/bespoke-consumers.test.d.ts +1 -0
  118. package/dist/core/keybindings/__tests__/bespoke-consumers.test.js +40 -0
  119. package/dist/core/keybindings/__tests__/resolve.test.d.ts +1 -0
  120. package/dist/core/keybindings/__tests__/resolve.test.js +224 -0
  121. package/dist/core/keybindings/catalog.d.ts +14 -0
  122. package/dist/core/keybindings/catalog.js +257 -0
  123. package/dist/core/keybindings/index.d.ts +5 -0
  124. package/dist/core/keybindings/index.js +4 -0
  125. package/dist/core/keybindings/match.d.ts +29 -0
  126. package/dist/core/keybindings/match.js +99 -0
  127. package/dist/core/keybindings/persistence.d.ts +14 -0
  128. package/dist/core/keybindings/persistence.js +31 -0
  129. package/dist/core/keybindings/resolve.d.ts +14 -0
  130. package/dist/core/keybindings/resolve.js +305 -0
  131. package/dist/core/keybindings/types.d.ts +37 -0
  132. package/dist/core/keybindings/types.js +1 -0
  133. package/dist/core/memory-resolver.d.ts +1 -1
  134. package/dist/core/memory-resolver.js +10 -3
  135. package/dist/core/predicate.d.ts +5 -3
  136. package/dist/core/predicate.js +5 -3
  137. package/dist/core/profiles/select.d.ts +6 -0
  138. package/dist/core/profiles/select.js +86 -52
  139. package/dist/core/provider-management.d.ts +12 -0
  140. package/dist/core/provider-management.js +24 -0
  141. package/dist/core/runtime/broker-protocol.d.ts +33 -5
  142. package/dist/core/runtime/broker.js +164 -14
  143. package/dist/core/runtime/launch.d.ts +39 -6
  144. package/dist/core/runtime/launch.js +78 -20
  145. package/dist/core/runtime/naming.js +3 -3
  146. package/dist/core/runtime/pi-cli.d.ts +6 -0
  147. package/dist/core/runtime/pi-cli.js +16 -2
  148. package/dist/core/runtime/pi-vendored.d.ts +8 -0
  149. package/dist/core/runtime/pi-vendored.js +14 -0
  150. package/dist/core/runtime/placement.d.ts +2 -2
  151. package/dist/core/runtime/placement.js +4 -1
  152. package/dist/core/runtime/promote.js +4 -0
  153. package/dist/core/runtime/recap.d.ts +3 -3
  154. package/dist/core/runtime/recap.js +75 -47
  155. package/dist/core/runtime/recycle.js +6 -1
  156. package/dist/core/runtime/reset.js +5 -0
  157. package/dist/core/runtime/session-list-cache.d.ts +33 -0
  158. package/dist/core/runtime/session-list-cache.js +338 -0
  159. package/dist/core/runtime/spawn.js +52 -45
  160. package/dist/core/runtime/stream-watchdog.d.ts +26 -0
  161. package/dist/core/runtime/stream-watchdog.js +75 -0
  162. package/dist/core/runtime/tmux-chrome.d.ts +1 -1
  163. package/dist/core/runtime/tmux-chrome.js +2 -2
  164. package/dist/core/runtime/tmux.d.ts +18 -15
  165. package/dist/core/runtime/tmux.js +217 -110
  166. package/dist/core/scope.js +27 -4
  167. package/dist/core/subscription-state.d.ts +90 -0
  168. package/dist/core/subscription-state.js +762 -0
  169. package/dist/core/tui/__tests__/host-keybindings.test.d.ts +1 -0
  170. package/dist/core/tui/__tests__/host-keybindings.test.js +112 -0
  171. package/dist/core/tui/host.d.ts +30 -1
  172. package/dist/core/tui/host.js +148 -34
  173. package/dist/core/view/contract.d.ts +17 -3
  174. package/dist/daemon/crtrd.js +253 -12
  175. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +41 -0
  176. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.d.ts +1 -0
  177. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.js +121 -0
  178. package/dist/pi-extensions/canvas-inbox-watcher.d.ts +1 -1
  179. package/dist/pi-extensions/canvas-inbox-watcher.js +203 -25
  180. package/dist/pi-extensions/canvas-recap.d.ts +4 -0
  181. package/dist/pi-extensions/canvas-recap.js +73 -46
  182. package/dist/pi-extensions/canvas-stophook.d.ts +11 -0
  183. package/dist/pi-extensions/canvas-stophook.js +33 -4
  184. package/dist/types.d.ts +9 -26
  185. package/dist/types.js +1 -28
  186. package/dist/web-client/assets/{index-DiFuLcp6.js → index--md2ylfi.js} +1 -1
  187. package/dist/web-client/index.html +1 -1
  188. package/dist/web-client/sw.js +1 -1
  189. package/package.json +8 -4
  190. package/dist/pi-extensions/canvas-nav.d.ts +0 -43
  191. package/dist/pi-extensions/canvas-nav.js +0 -640
  192. package/dist/pi-extensions/widget-order-bus.d.ts +0 -6
  193. package/dist/pi-extensions/widget-order-bus.js +0 -34
@@ -22,20 +22,23 @@ import { tmpdir } from 'node:os';
22
22
  import { join } from 'node:path';
23
23
  import { randomUUID } from 'node:crypto';
24
24
  import { getAgentDir, initTheme, } from '@earendil-works/pi-coding-agent';
25
- import { jobDir, nodeDir, viewSocketPath } from '../canvas/paths.js';
25
+ import { jobDir, nodeDir, sessionListCachePath, viewSocketPath } from '../canvas/paths.js';
26
+ import { SessionListCache } from './session-list-cache.js';
26
27
  import { getNode, updateNode } from '../canvas/index.js';
27
28
  import { FRONT_DOOR_ENV } from './front-door.js';
28
- import { equivalentOtherProviderModel, normalizeModel, piInvocationToSdkConfig, } from './launch.js';
29
+ import { equivalentOtherProviderModel, nextLadderModel, normalizeModel, piInvocationToSdkConfig, } from './launch.js';
29
30
  import { assertEngineVersion, loadBrokerEngine } from './broker-sdk.js';
31
+ import { resolveBundledPiPackageDir } from './pi-cli.js';
30
32
  import { classify } from '../fault-classifier.js';
31
33
  import { isModelNotFoundError, readFault, recordFault, recordFaultForCurrentNode } from './fault.js';
32
34
  import { CONNECTION_FAULT_RECOVERY_BODY, PROVIDER_FAULT_RECOVERY_BODY } from './fault-recovery-nudge.js';
33
35
  import { markFatalOnExhaust, nextFaultRetry, policyFor } from './fault-recovery.js';
34
36
  import { probeOnline as probeOnlineReal } from './connectivity.js';
35
37
  import { CRTR_CYCLE_CUSTOM_TYPE, cycleAwareMessages } from './session-cycles.js';
36
- import { BUILTIN_SLASH_COMMANDS } from './pi-vendored.js';
38
+ import { BUILTIN_SLASH_COMMANDS, piSessionsRoot } from './pi-vendored.js';
39
+ import { StreamWatchdog, resolveStreamWatchdogMs } from './stream-watchdog.js';
37
40
  import { encodeFrame, FrameDecoder, FrameOverflowError, BROKER_READ_CAPS, } from './broker-protocol.js';
38
- const THINKING_LEVELS = new Set(['off', 'minimal', 'low', 'medium', 'high', 'xhigh']);
41
+ const THINKING_LEVELS = new Set(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']);
39
42
  function parseModelSpec(spec) {
40
43
  const i = spec.lastIndexOf(':');
41
44
  if (i <= 0)
@@ -166,7 +169,12 @@ export async function runBroker(nodeId) {
166
169
  // before_provider_request handler throwing, so its injected Claude-Code billing
167
170
  // header was dropped and subscription turns 400'd ("draws from extra usage").
168
171
  // headless ⇒ no file watcher (false). Idempotent global; call once at boot.
172
+ // Always replace an inherited PI_PACKAGE_DIR: a viewer launched from a pnpm
173
+ // path may pass its `.pnpm` package copy down to the daemon/broker, but that
174
+ // copy can omit the JSON theme assets. The broker must use crouter's actual
175
+ // bundled package root, exactly like its incidental pi subprocesses.
169
176
  try {
177
+ process.env['PI_PACKAGE_DIR'] = resolveBundledPiPackageDir();
170
178
  initTheme(services.settingsManager.getTheme(), false);
171
179
  }
172
180
  catch (err) {
@@ -182,6 +190,14 @@ export async function runBroker(nodeId) {
182
190
  const attachPath = join(jobDir(nodeId), 'attach.json');
183
191
  const clients = new Set();
184
192
  const pendingDialogs = new Map();
193
+ // Fire-and-forget extension display state must survive a viewer detach. The
194
+ // extension may paint a widget (notably the inactivity brief) while nobody is
195
+ // attached; retain the latest keyed state so the next hello can replay it
196
+ // immediately after welcome instead of making "persistent" mean "only if you
197
+ // happened to be watching when the frame fired".
198
+ const displayStatuses = new Map();
199
+ const displayWidgets = new Map();
200
+ let displayTitle;
185
201
  let controllerId = null;
186
202
  let disposed = false;
187
203
  let server;
@@ -311,6 +327,26 @@ export async function runBroker(nodeId) {
311
327
  if (c.helloed)
312
328
  sendFrame(c, frame);
313
329
  };
330
+ /** Retain and broadcast extension UI frames. Updating a keyed display entry
331
+ * moves it to the end, matching pi's insertion-ordered widget behavior;
332
+ * non-display UI frames (notify/dialog dismissal) simply pass through. */
333
+ const broadcastUi = (frame) => {
334
+ if (frame.type === 'display_status') {
335
+ displayStatuses.delete(frame.key);
336
+ if (frame.text !== undefined)
337
+ displayStatuses.set(frame.key, frame.text);
338
+ }
339
+ else if (frame.type === 'display_widget') {
340
+ displayWidgets.delete(frame.key);
341
+ if (frame.lines !== undefined) {
342
+ displayWidgets.set(frame.key, { lines: [...frame.lines], placement: frame.placement });
343
+ }
344
+ }
345
+ else if (frame.type === 'display_title') {
346
+ displayTitle = frame.title;
347
+ }
348
+ broadcast(frame);
349
+ };
314
350
  // ---------------------------------------------------------------------------
315
351
  // F2: message_update coalescing. `relayEvent` is the single entry point for
316
352
  // engine events headed to viewers: a message_update is held (latest wins) and
@@ -321,6 +357,26 @@ export async function runBroker(nodeId) {
321
357
  let pendingUpdate = null;
322
358
  let pendingUpdateTimer = null;
323
359
  let faultRetryTimer = null;
360
+ // Dead-provider-stream detector (see stream-watchdog.ts): poked while a turn
361
+ // is awaiting the provider (NOT while a tool executes — a silent long tool
362
+ // must not trip it), cleared at agent_end. Fires at most once per stall,
363
+ // aborting the session so the fault-retry machinery (armed on agent_end)
364
+ // re-drives the turn instead of the broker wedging until the daemon's
365
+ // 20-minute hard kick.
366
+ //
367
+ // `sessionGeneration` bumps on every rebindSession; each poke stamps its arm
368
+ // with the current generation and the stall callback ignores a fire whose
369
+ // stamp is stale — so an old timer that fires while a rebind is in flight
370
+ // can't abort the NEW session it dereferences.
371
+ const streamWatchdogMs = resolveStreamWatchdogMs();
372
+ let sessionGeneration = 0;
373
+ const executingTools = new Set();
374
+ const streamWatchdog = new StreamWatchdog((armedGeneration) => {
375
+ if (armedGeneration !== sessionGeneration)
376
+ return; // superseded session — stale timer, no-op
377
+ logBroker(`stream watchdog: no engine event for ${streamWatchdogMs}ms while streaming — aborting stalled provider stream`);
378
+ void session.abort().catch((err) => logBroker(`stream watchdog abort failed: ${String(err)}`));
379
+ }, streamWatchdogMs);
324
380
  const flushPendingUpdate = () => {
325
381
  if (pendingUpdateTimer !== null) {
326
382
  clearTimeout(pendingUpdateTimer);
@@ -546,6 +602,31 @@ export async function runBroker(nodeId) {
546
602
  clearFaultRetryTimer();
547
603
  if (type === 'agent_end' && !session.isStreaming)
548
604
  scheduleFaultRetry();
605
+ // The watchdog covers ONLY time awaiting the provider. `session.isStreaming`
606
+ // stays true through tool execution, so a legitimately silent tool lasting
607
+ // longer than the timeout would otherwise be aborted mid-run: disarm while
608
+ // any tool executes and rearm once the last one ends. Multiple tools can run
609
+ // concurrently, so track in-flight ids and only rearm when the set drains.
610
+ if (type === 'agent_end') {
611
+ // Clear the in-flight set too: an abnormal run can emit agent_end after a
612
+ // tool_execution_start with no matching tool_execution_end. A leftover id
613
+ // would keep executingTools non-empty forever, gating every future poke
614
+ // (size === 0) and permanently disarming the watchdog until a rebind.
615
+ executingTools.clear();
616
+ streamWatchdog.clear();
617
+ }
618
+ else if (type === 'tool_execution_start') {
619
+ executingTools.add(event.toolCallId ?? '');
620
+ streamWatchdog.clear();
621
+ }
622
+ else if (type === 'tool_execution_end') {
623
+ executingTools.delete(event.toolCallId ?? '');
624
+ if (executingTools.size === 0 && session.isStreaming)
625
+ streamWatchdog.poke(sessionGeneration);
626
+ }
627
+ else if (session.isStreaming && executingTools.size === 0) {
628
+ streamWatchdog.poke(sessionGeneration);
629
+ }
549
630
  if (type === 'message_update') {
550
631
  pendingUpdate = event; // latest full-message update supersedes the held one
551
632
  if (pendingUpdateTimer === null) {
@@ -595,7 +676,7 @@ export async function runBroker(nodeId) {
595
676
  // footer shows the stale model until the next unrelated event.
596
677
  const broadcastModelChanged = () => {
597
678
  persistModelChoice();
598
- broadcast({ type: 'model_changed', model: session.model?.id });
679
+ broadcast({ type: 'model_changed', model: session.model?.id, spec: formatModelSpec(session.model, session.thinkingLevel) });
599
680
  };
600
681
  const buildSnapshot = () => ({
601
682
  messages: snapshotMessages(session),
@@ -629,6 +710,17 @@ export async function runBroker(nodeId) {
629
710
  pending_dialog: first !== undefined ? first.request : null,
630
711
  agentDir: getAgentDir(),
631
712
  }, true);
713
+ // `welcome` tells viewers to reset display chrome; replay the broker's
714
+ // retained extension state immediately after it so attaching AFTER an idle
715
+ // brief was generated still shows the brief on first paint.
716
+ for (const [key, text] of displayStatuses) {
717
+ sendFrame(client, { type: 'display_status', key, text });
718
+ }
719
+ for (const [key, widget] of displayWidgets) {
720
+ sendFrame(client, { type: 'display_widget', key, lines: widget.lines, placement: widget.placement });
721
+ }
722
+ if (displayTitle !== undefined)
723
+ sendFrame(client, { type: 'display_title', title: displayTitle });
632
724
  };
633
725
  // T4 re-route on become-controller: a dialog raised while a prior controller was
634
726
  // attached stays pending after that controller detaches (it is NOT cancelled —
@@ -655,6 +747,7 @@ export async function runBroker(nodeId) {
655
747
  return;
656
748
  disposed = true;
657
749
  activeSession = null; // graceful path owns dispose; keep the fatal hook a no-op
750
+ streamWatchdog.clear();
658
751
  try {
659
752
  session.dispose();
660
753
  }
@@ -696,7 +789,7 @@ export async function runBroker(nodeId) {
696
789
  controller: controllerClient,
697
790
  forward: (client, request) => sendFrame(client, request),
698
791
  pending: pendingDialogs,
699
- broadcast,
792
+ broadcast: broadcastUi,
700
793
  });
701
794
  // -------------------------------------------------------------------------
702
795
  // 3+4 (plan): bind the FULL canvas extensions (mode 'print') AND fan the single
@@ -733,6 +826,15 @@ export async function runBroker(nodeId) {
733
826
  }
734
827
  };
735
828
  const rebindSession = async () => {
829
+ // Disarm + invalidate the old watchdog BEFORE swapping the session reference
830
+ // or crossing the bindExtensions await: an old timer that fires during that
831
+ // await would otherwise dereference the mutable `session` (now the NEW one)
832
+ // and abort the replacement. Bumping the generation makes any surviving
833
+ // stale arm a no-op (its stamp no longer matches); clearing removes the arm
834
+ // outright. executingTools is per-session in-flight state — reset it too.
835
+ streamWatchdog.clear();
836
+ sessionGeneration += 1;
837
+ executingTools.clear();
736
838
  if (runtime !== undefined) {
737
839
  session = runtime.session; // the runtime replaced it; follow the new one
738
840
  services = runtime.services;
@@ -760,6 +862,7 @@ export async function runBroker(nodeId) {
760
862
  pendingUpdateTimer = null;
761
863
  }
762
864
  clearFaultRetryTimer();
865
+ streamWatchdog.clear();
763
866
  pendingUpdate = null;
764
867
  unsubscribe = session.subscribe((event) => {
765
868
  try {
@@ -1093,16 +1196,18 @@ export async function runBroker(nodeId) {
1093
1196
  };
1094
1197
  return { type: 'data', id, kind: 'get_settings', settings };
1095
1198
  };
1199
+ // Cache per-session wire info keyed by (path, mtime, size) so reopening the
1200
+ // picker doesn't re-read the whole corpus (see session-list-cache.ts).
1201
+ const sessionListCache = new SessionListCache(sessionListCachePath());
1202
+ // Pre-warm the cwd scope (the picker's default tab) in the background so the
1203
+ // first /resume open on this fresh broker is already warm.
1204
+ sessionListCache.prewarm(session.sessionManager.getSessionDir());
1096
1205
  const buildSessionsData = async (id, scope) => {
1097
1206
  const sm = session.sessionManager;
1098
- const raw = scope === 'all'
1099
- ? await engine.SessionManager.listAll()
1100
- : await engine.SessionManager.list(sm.getCwd(), sm.getSessionDir());
1101
- const sessions = raw.map((s) => ({
1102
- ...s,
1103
- created: s.created.toISOString(),
1104
- modified: s.modified.toISOString(),
1105
- }));
1207
+ const sessionDir = sm.getSessionDir();
1208
+ const sessions = scope === 'all'
1209
+ ? await sessionListCache.listAll(piSessionsRoot())
1210
+ : await sessionListCache.listDir(sessionDir);
1106
1211
  return {
1107
1212
  type: 'data',
1108
1213
  id,
@@ -1264,6 +1369,40 @@ export async function runBroker(nodeId) {
1264
1369
  .catch(engineErrorTo(client));
1265
1370
  break;
1266
1371
  }
1372
+ case 'cycle_ladder': {
1373
+ if (notController(client, 'cycle the model ladder'))
1374
+ break;
1375
+ // Resolve the next interleaved-ladder rung from the current model+thinking
1376
+ // spec, then reuse the set_model path (registry resolve + thinking apply).
1377
+ const currentSpec = formatModelSpec(session.model, session.thinkingLevel) ?? '';
1378
+ const nextSpec = parseModelSpec(nextLadderModel(currentSpec, frame.direction === 'backward' ? -1 : 1));
1379
+ let ladderModel;
1380
+ try {
1381
+ ladderModel = resolveModelQuery(nextSpec.modelSpec);
1382
+ }
1383
+ catch (err) {
1384
+ engineErrorTo(client)(err);
1385
+ break;
1386
+ }
1387
+ if (ladderModel === undefined) {
1388
+ sendFrame(client, {
1389
+ type: 'error',
1390
+ code: 'engine_error',
1391
+ message: `ladder model '${nextSpec.modelSpec}' not in the registry`,
1392
+ });
1393
+ break;
1394
+ }
1395
+ void session
1396
+ .setModel(ladderModel)
1397
+ .then(() => {
1398
+ if (nextSpec.thinkingLevel !== undefined)
1399
+ session.setThinkingLevel(nextSpec.thinkingLevel);
1400
+ ackTo(client, 'cycle_ladder');
1401
+ broadcastModelChanged();
1402
+ })
1403
+ .catch(engineErrorTo(client));
1404
+ break;
1405
+ }
1267
1406
  case 'cycle_thinking': {
1268
1407
  if (notController(client, 'cycle the thinking level'))
1269
1408
  break;
@@ -1920,6 +2059,13 @@ export function makeBrokerUiContext(deps) {
1920
2059
  };
1921
2060
  const onAbort = () => {
1922
2061
  cleanup();
2062
+ // The extension aborted this request out-of-band (e.g. an OAuth loopback
2063
+ // callback won the race against a still-open manual-paste dialog). Tell
2064
+ // the controller to tear down THIS overlay by id — an unanswered dialog
2065
+ // whose broker entry we just dropped would otherwise linger onscreen with
2066
+ // nothing left to answer it. Keyed to request.id so only the abandoned
2067
+ // dialog is dismissed, never an unrelated one.
2068
+ deps.broadcast({ type: 'extension_ui_dismiss', id: request.id });
1923
2069
  resolve(defaultValue);
1924
2070
  };
1925
2071
  opts?.signal?.addEventListener('abort', onAbort, { once: true });
@@ -1930,6 +2076,10 @@ export function makeBrokerUiContext(deps) {
1930
2076
  const ms = opts?.timeout !== undefined ? opts.timeout : DEFAULT_DIALOG_TIMEOUT_MS;
1931
2077
  timer = setTimeout(() => {
1932
2078
  cleanup();
2079
+ // Same correlated teardown as onAbort: the broker resolved this dialog on
2080
+ // its own timeout, so the controller's overlay (which has no independent
2081
+ // timer) must be dismissed by id or it lingers forever.
2082
+ deps.broadcast({ type: 'extension_ui_dismiss', id: request.id });
1933
2083
  resolve(defaultValue);
1934
2084
  }, ms);
1935
2085
  if (typeof timer.unref === 'function')
@@ -1,8 +1,7 @@
1
1
  import type { NodeMeta, LaunchSpec, Mode, Lifecycle } from '../canvas/index.js';
2
- import type { ModelProvider, ModelStrength } from '../../types.js';
2
+ import type { ModelProvider, ModelStrength, ScopeConfig } from '../../types.js';
3
3
  export declare const CANVAS_STOPHOOK_PATH: string;
4
4
  export declare const CANVAS_INBOX_WATCHER_PATH: string;
5
- export declare const CANVAS_NAV_PATH: string;
6
5
  export declare const CANVAS_RECAP_PATH: string;
7
6
  export declare const CANVAS_GOAL_CAPTURE_PATH: string;
8
7
  export declare const CANVAS_PASSIVE_CONTEXT_PATH: string;
@@ -11,9 +10,9 @@ export declare const CANVAS_DOC_SUBSTRATE_PATH: string;
11
10
  export declare const CANVAS_STRUCTURED_OUTPUT_PATH: string;
12
11
  export declare const CANVAS_BASH_VALVE_PATH: string;
13
12
  /** The canvas extensions every node loads, in order: stophook (routing +
14
- * telemetry + session-id capture), inbox-watcher (wake), nav (in-editor
15
- * graph chrome), recap (the per-node inactivity recap card Haiku over the
16
- * conversation, shown above the manager line after 300s of no message),
13
+ * telemetry + session-id capture), inbox-watcher (wake), recap (the per-node
14
+ * inactivity brief a headless summary of the conversation, shown faintly
15
+ * above the editor border after 60s of no message),
17
16
  * goal-capture (persist the first user message as the goal),
18
17
  * passive-context (drain passive backlog as pre-text on the next message),
19
18
  * context-intro (inject the <crtr-bearings> block + the <memory kind="knowledge">
@@ -43,7 +42,29 @@ export interface EquivalentProviderModel {
43
42
  * is per-turn state: once a provider has produced a retryable outage error, do
44
43
  * not bounce back to it or keep reattempting the same failed provider. */
45
44
  export declare function equivalentOtherProviderModel(currentModel: string, failedProviders?: ReadonlySet<ModelProvider>): EquivalentProviderModel | null;
46
- export declare function normalizeModel(model: string): string;
45
+ /** True when `model` is a PORTABLE ladder token — a bare strength/alias
46
+ * (`ultra`, `normal`, `opus`, …) or a `anthropic|openai / strength` pair —
47
+ * i.e. a form `normalizeModel` resolves through the ladders rather than
48
+ * passing through as a concrete id. Portable tokens are safe to PERSIST as
49
+ * defaults (`persistDefaultKindModel`): they re-resolve against whatever
50
+ * ladder the running build ships, so they never freeze a stale concrete
51
+ * model id to disk. Mirrors `normalizeModel`'s branch structure exactly. */
52
+ export declare function isPortableModelToken(model: string): boolean;
53
+ export declare function normalizeModel(model: string, ladders?: ScopeConfig['modelLadders']): string;
54
+ /** The interleaved model-ladder cycle Alt+M / Alt+Shift+M walks: for each strength tier
55
+ * from strongest to weakest, BOTH providers before descending — anthropic/ultra,
56
+ * openai/ultra, anthropic/strong, openai/strong, anthropic/medium, … Returns the
57
+ * concrete `provider/id:thinking` ladder cells in that order. */
58
+ export declare function interleavedLadderCycle(): string[];
59
+ /** The next model in the interleaved ladder cycle, starting from `currentSpec`
60
+ * (a `provider/id:thinking` spec — pass `formatModelSpec(session.model,
61
+ * session.thinkingLevel)`). `direction` is +1 (forward) or -1 (backward).
62
+ *
63
+ * Rungs are matched on bare id AND thinking level first, so ladder cells that
64
+ * share a bare model id (e.g. anthropic ultra=…:high vs strong=…:medium) don't
65
+ * collapse onto each other; a bare-id-only match is the fallback. An off-ladder
66
+ * current model starts the cycle at its top (the strongest anthropic rung). */
67
+ export declare function nextLadderModel(currentSpec: string, direction?: 1 | -1): string;
47
68
  /** The minimal static system prompt every node launches with — a thin launcher
48
69
  * frame keeping the `\n\nGuidelines:` anchor the `before_agent_start` substrate
49
70
  * hook (canvas-doc-substrate.ts) splices its `<memory kind="preference">` block
@@ -74,6 +95,18 @@ export declare function buildLaunchSpec(kind: string, mode: Mode, opts: {
74
95
  hasManager: boolean;
75
96
  extraEnv?: Record<string, string>;
76
97
  model?: string;
98
+ /** The TARGET node's cwd — anchors the project-scope stack for kind/ladder
99
+ * resolution. Defaults to this process's own cwd, which is correct only
100
+ * when the caller IS resolving for itself; spawn/polymorph paths must
101
+ * pass the node's own cwd (same rule as `readMergedLaunchConfig`). */
102
+ cwd?: string;
103
+ /** The TARGET node's profile id — REQUIRED for a profile's kind/ladder
104
+ * overrides (e.g. a `/model` default persisted by
105
+ * `persistDefaultKindModel`) to be honored. Defaults to the ambient
106
+ * `CRTR_PROFILE_ID`, which is EMPTY for a front-door shell invocation —
107
+ * exactly the path that creates the user's sessions — so callers that
108
+ * know the node's profile must pass it explicitly (null = no profile). */
109
+ profileId?: string | null;
77
110
  }): {
78
111
  launch: LaunchSpec;
79
112
  lifecycle: 'terminal' | 'resident';
@@ -11,7 +11,7 @@
11
11
  import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
12
12
  import { dirname, join } from 'node:path';
13
13
  import { fileURLToPath } from 'node:url';
14
- import { readMergedLaunchConfig, resolveKindConfig } from '../config.js';
14
+ import { readMergedLaunchConfig } from '../config.js';
15
15
  import { nodeEnv } from './nodes.js';
16
16
  import { editorLabel } from '../canvas/index.js';
17
17
  import { nodeDir } from '../canvas/paths.js';
@@ -29,7 +29,6 @@ function resolveExtension(name) {
29
29
  }
30
30
  export const CANVAS_STOPHOOK_PATH = resolveExtension('canvas-stophook');
31
31
  export const CANVAS_INBOX_WATCHER_PATH = resolveExtension('canvas-inbox-watcher');
32
- export const CANVAS_NAV_PATH = resolveExtension('canvas-nav');
33
32
  export const CANVAS_RECAP_PATH = resolveExtension('canvas-recap');
34
33
  export const CANVAS_GOAL_CAPTURE_PATH = resolveExtension('canvas-goal-capture');
35
34
  export const CANVAS_PASSIVE_CONTEXT_PATH = resolveExtension('canvas-passive-context');
@@ -38,9 +37,9 @@ export const CANVAS_DOC_SUBSTRATE_PATH = resolveExtension('canvas-doc-substrate'
38
37
  export const CANVAS_STRUCTURED_OUTPUT_PATH = resolveExtension('canvas-structured-output');
39
38
  export const CANVAS_BASH_VALVE_PATH = resolveExtension('canvas-bash-valve');
40
39
  /** The canvas extensions every node loads, in order: stophook (routing +
41
- * telemetry + session-id capture), inbox-watcher (wake), nav (in-editor
42
- * graph chrome), recap (the per-node inactivity recap card Haiku over the
43
- * conversation, shown above the manager line after 300s of no message),
40
+ * telemetry + session-id capture), inbox-watcher (wake), recap (the per-node
41
+ * inactivity brief a headless summary of the conversation, shown faintly
42
+ * above the editor border after 60s of no message),
44
43
  * goal-capture (persist the first user message as the goal),
45
44
  * passive-context (drain passive backlog as pre-text on the next message),
46
45
  * context-intro (inject the <crtr-bearings> block + the <memory kind="knowledge">
@@ -56,7 +55,6 @@ export const CANVAS_BASH_VALVE_PATH = resolveExtension('canvas-bash-valve');
56
55
  export const CANVAS_EXTENSIONS = [
57
56
  CANVAS_STOPHOOK_PATH,
58
57
  CANVAS_INBOX_WATCHER_PATH,
59
- CANVAS_NAV_PATH,
60
58
  CANVAS_RECAP_PATH,
61
59
  CANVAS_GOAL_CAPTURE_PATH,
62
60
  CANVAS_PASSIVE_CONTEXT_PATH,
@@ -93,8 +91,8 @@ function ladderFor(provider, ladders = modelLadders()) {
93
91
  * on the OpenAI ladder; anything else defaults to Anthropic. A configured
94
92
  * defaultProvider in config.json wins over this env. */
95
93
  export const OPENAI_PROVIDER_ENV = 'CRTR_MODEL_PROVIDER';
96
- function defaultProvider() {
97
- const cfgProvider = readMergedLaunchConfig().modelLadders.defaultProvider;
94
+ function defaultProvider(ladders = modelLadders()) {
95
+ const cfgProvider = ladders.defaultProvider;
98
96
  if (cfgProvider === 'anthropic' || cfgProvider === 'openai')
99
97
  return cfgProvider;
100
98
  return (process.env[OPENAI_PROVIDER_ENV] ?? '').toLowerCase() === 'openai' ? 'openai' : 'anthropic';
@@ -163,12 +161,11 @@ function modelTokenStrength(model) {
163
161
  return 'light';
164
162
  return null;
165
163
  }
166
- function resolvedModelStrength(model) {
164
+ function resolvedModelStrength(model, ladders = modelLadders()) {
167
165
  const tokenStrength = modelTokenStrength(model);
168
166
  if (tokenStrength !== null)
169
167
  return tokenStrength;
170
- const ladders = modelLadders();
171
- const comparable = stripThinkingSuffix(normalizeModel(model));
168
+ const comparable = stripThinkingSuffix(normalizeModel(model, ladders));
172
169
  for (const provider of MODEL_PROVIDER_KEYS) {
173
170
  for (const strength of MODEL_STRENGTHS) {
174
171
  if (stripThinkingSuffix(ladders[provider][strength]) === comparable)
@@ -180,21 +177,36 @@ function resolvedModelStrength(model) {
180
177
  function isReviewQualityKind(kind) {
181
178
  return kind === 'review' || kind.startsWith('plan/reviewers/');
182
179
  }
183
- function floorReviewModel(kind, requestedModel, kindModel) {
180
+ function floorReviewModel(kind, requestedModel, kindModel, ladders = modelLadders()) {
184
181
  if (requestedModel === undefined)
185
182
  return kindModel;
186
183
  if (!isReviewQualityKind(kind) || kindModel === undefined)
187
184
  return requestedModel;
188
- const requestedStrength = resolvedModelStrength(requestedModel);
189
- const floorStrength = resolvedModelStrength(kindModel);
185
+ const requestedStrength = resolvedModelStrength(requestedModel, ladders);
186
+ const floorStrength = resolvedModelStrength(kindModel, ladders);
190
187
  if (requestedStrength === null || floorStrength === null)
191
188
  return requestedModel;
192
189
  return MODEL_STRENGTH_RANK[requestedStrength] < MODEL_STRENGTH_RANK[floorStrength]
193
190
  ? kindModel
194
191
  : requestedModel;
195
192
  }
196
- export function normalizeModel(model) {
197
- const ladders = modelLadders();
193
+ /** True when `model` is a PORTABLE ladder token — a bare strength/alias
194
+ * (`ultra`, `normal`, `opus`, …) or a `anthropic|openai / strength` pair —
195
+ * i.e. a form `normalizeModel` resolves through the ladders rather than
196
+ * passing through as a concrete id. Portable tokens are safe to PERSIST as
197
+ * defaults (`persistDefaultKindModel`): they re-resolve against whatever
198
+ * ladder the running build ships, so they never freeze a stale concrete
199
+ * model id to disk. Mirrors `normalizeModel`'s branch structure exactly. */
200
+ export function isPortableModelToken(model) {
201
+ if (model.includes('/')) {
202
+ const slash = model.indexOf('/');
203
+ const provider = model.slice(0, slash);
204
+ const rest = model.slice(slash + 1);
205
+ return (provider === 'anthropic' || provider === 'openai') && STRENGTH_ALIASES[rest] !== undefined;
206
+ }
207
+ return STRENGTH_ALIASES[model] !== undefined || model === 'opus' || model === 'sonnet' || model === 'haiku';
208
+ }
209
+ export function normalizeModel(model, ladders = modelLadders()) {
198
210
  // 1. Qualified provider/rest.
199
211
  if (model.includes('/')) {
200
212
  const slash = model.indexOf('/');
@@ -210,7 +222,7 @@ export function normalizeModel(model) {
210
222
  // 2. Bare strength → default-provider ladder.
211
223
  const strength = STRENGTH_ALIASES[model];
212
224
  if (strength !== undefined)
213
- return ladderFor(defaultProvider(), ladders)[strength];
225
+ return ladderFor(defaultProvider(ladders), ladders)[strength];
214
226
  // 3. Bare family alias → always Anthropic.
215
227
  if (model === 'opus')
216
228
  return ladders.anthropic.strong;
@@ -221,6 +233,50 @@ export function normalizeModel(model) {
221
233
  // 4. Unknown — pass through.
222
234
  return model;
223
235
  }
236
+ /** The thinking suffix of a ladder cell / model spec (`…:high` → `high`), or
237
+ * undefined when the trailing `:token` is a model-id segment rather than a
238
+ * known thinking level. */
239
+ function thinkingSuffixOf(spec) {
240
+ const i = spec.lastIndexOf(':');
241
+ if (i <= 0)
242
+ return undefined;
243
+ const suffix = spec.slice(i + 1);
244
+ return THINKING_SUFFIXES.has(suffix) ? suffix : undefined;
245
+ }
246
+ /** The interleaved model-ladder cycle Alt+M / Alt+Shift+M walks: for each strength tier
247
+ * from strongest to weakest, BOTH providers before descending — anthropic/ultra,
248
+ * openai/ultra, anthropic/strong, openai/strong, anthropic/medium, … Returns the
249
+ * concrete `provider/id:thinking` ladder cells in that order. */
250
+ export function interleavedLadderCycle() {
251
+ const ladders = modelLadders();
252
+ const order = [];
253
+ for (const strength of MODEL_STRENGTHS) {
254
+ for (const provider of MODEL_PROVIDER_KEYS) {
255
+ order.push(ladders[provider][strength]);
256
+ }
257
+ }
258
+ return order;
259
+ }
260
+ /** The next model in the interleaved ladder cycle, starting from `currentSpec`
261
+ * (a `provider/id:thinking` spec — pass `formatModelSpec(session.model,
262
+ * session.thinkingLevel)`). `direction` is +1 (forward) or -1 (backward).
263
+ *
264
+ * Rungs are matched on bare id AND thinking level first, so ladder cells that
265
+ * share a bare model id (e.g. anthropic ultra=…:high vs strong=…:medium) don't
266
+ * collapse onto each other; a bare-id-only match is the fallback. An off-ladder
267
+ * current model starts the cycle at its top (the strongest anthropic rung). */
268
+ export function nextLadderModel(currentSpec, direction = 1) {
269
+ const order = interleavedLadderCycle();
270
+ const normalized = normalizeModel(currentSpec);
271
+ const currentBare = stripThinkingSuffix(normalized);
272
+ const currentThinking = thinkingSuffixOf(normalized);
273
+ let idx = order.findIndex((ref) => stripThinkingSuffix(ref) === currentBare && thinkingSuffixOf(ref) === currentThinking);
274
+ if (idx === -1)
275
+ idx = order.findIndex((ref) => stripThinkingSuffix(ref) === currentBare);
276
+ if (idx === -1)
277
+ return order[0];
278
+ return order[(idx + direction + order.length) % order.length];
279
+ }
224
280
  // ---------------------------------------------------------------------------
225
281
  // Build the launch spec from {kind, mode}
226
282
  // ---------------------------------------------------------------------------
@@ -251,11 +307,13 @@ export const LAUNCH_SYSTEM_PROMPT = 'You are a node in the crtr canvas — an ag
251
307
  * as-given — it is no longer persona-frontmatter-derived. The two canvas
252
308
  * extensions are always first; kind-declared extensions follow. */
253
309
  export function buildLaunchSpec(kind, mode, opts) {
254
- const kindConfig = resolveKindConfig(kind);
310
+ const merged = readMergedLaunchConfig(opts.cwd ?? process.cwd(), opts.profileId !== undefined ? opts.profileId : (process.env['CRTR_PROFILE_ID'] || null));
311
+ const kindConfig = merged.kinds[kind];
312
+ const ladders = merged.modelLadders;
255
313
  const kindModel = mode === 'orchestrator' ? kindConfig?.orchestratorModel ?? kindConfig?.model : kindConfig?.model;
256
- const chosenModel = floorReviewModel(kind, opts.model, kindModel);
314
+ const chosenModel = floorReviewModel(kind, opts.model, kindModel, ladders);
257
315
  const launch = {
258
- model: chosenModel !== undefined ? normalizeModel(chosenModel) : undefined,
316
+ model: chosenModel !== undefined ? normalizeModel(chosenModel, ladders) : undefined,
259
317
  tools: kindConfig?.tools ?? [],
260
318
  extensions: [...CANVAS_EXTENSIONS, ...(kindConfig?.extensions ?? [])],
261
319
  systemPrompt: LAUNCH_SYSTEM_PROMPT,
@@ -17,7 +17,7 @@
17
17
  // prompt, so a node always gets a sane name.
18
18
  import { execFile } from 'node:child_process';
19
19
  import { getNode, updateNode } from '../canvas/index.js';
20
- import { resolveBundledPiCliPath } from './pi-cli.js';
20
+ import { bundledPiSubprocessEnv, resolveBundledPiCliPath } from './pi-cli.js';
21
21
  /** Cap on prompt text fed to the namer — a name needs only the gist. */
22
22
  const PROMPT_CAP = 2000;
23
23
  /** Wall-clock budget for the headless pi call before we fall back to a slug. */
@@ -107,7 +107,7 @@ function nameArgs(prompt) {
107
107
  function headlessName(body) {
108
108
  return new Promise((resolve) => {
109
109
  try {
110
- const child = execFile(process.execPath, [resolveBundledPiCliPath(), ...nameArgs(body)], { encoding: 'utf8', timeout: NAME_TIMEOUT_MS }, (err, stdout) => {
110
+ const child = execFile(process.execPath, [resolveBundledPiCliPath(), ...nameArgs(body)], { encoding: 'utf8', timeout: NAME_TIMEOUT_MS, env: bundledPiSubprocessEnv() }, (err, stdout) => {
111
111
  if (err || typeof stdout !== 'string')
112
112
  return resolve('');
113
113
  resolve(sanitizeSessionName(stdout));
@@ -188,7 +188,7 @@ export function generateIssueTitle(note) {
188
188
  return Promise.resolve('untitled');
189
189
  return new Promise((resolve) => {
190
190
  try {
191
- const child = execFile(process.execPath, [resolveBundledPiCliPath(), ...piTextArgs(TITLE_SYSTEM_PROMPT, titleUserPrompt(body))], { encoding: 'utf8', timeout: NAME_TIMEOUT_MS }, (err, stdout) => {
191
+ const child = execFile(process.execPath, [resolveBundledPiCliPath(), ...piTextArgs(TITLE_SYSTEM_PROMPT, titleUserPrompt(body))], { encoding: 'utf8', timeout: NAME_TIMEOUT_MS, env: bundledPiSubprocessEnv() }, (err, stdout) => {
192
192
  if (err || typeof stdout !== 'string')
193
193
  return resolve(firstLineTitle(body));
194
194
  const t = stdout.trim().split('\n').map((l) => l.trim()).find((l) => l !== '') ?? '';
@@ -1,2 +1,8 @@
1
+ export declare function resolveBundledPiPackageDir(): string;
2
+ /** Environment for a bundled pi subprocess. pi resolves theme assets relative
3
+ * to its package root; pnpm/npm mixed layouts can make its own module URL point
4
+ * through `.pnpm` at a package copy without those JSON assets, so explicitly
5
+ * pin the real bundled package directory (the same contract attach uses). */
6
+ export declare function bundledPiSubprocessEnv(): NodeJS.ProcessEnv;
1
7
  export declare function resolveBundledPiCliPath(): string;
2
8
  export declare function bundledPiCommandForShell(quote: (value: string) => string): string;
@@ -21,7 +21,7 @@ function findPackageRootFromEntry(entryPath, expectedName) {
21
21
  dir = dirname(dir);
22
22
  }
23
23
  }
24
- export function resolveBundledPiCliPath() {
24
+ export function resolveBundledPiPackageDir() {
25
25
  const packageName = '@earendil-works/pi-coding-agent';
26
26
  let entryPath;
27
27
  try {
@@ -34,11 +34,25 @@ export function resolveBundledPiCliPath() {
34
34
  if (packageRoot === null) {
35
35
  throw new Error(`could not locate bundled ${packageName} package root from ${entryPath}`);
36
36
  }
37
+ return packageRoot;
38
+ }
39
+ /** Environment for a bundled pi subprocess. pi resolves theme assets relative
40
+ * to its package root; pnpm/npm mixed layouts can make its own module URL point
41
+ * through `.pnpm` at a package copy without those JSON assets, so explicitly
42
+ * pin the real bundled package directory (the same contract attach uses). */
43
+ export function bundledPiSubprocessEnv() {
44
+ return {
45
+ ...process.env,
46
+ PI_PACKAGE_DIR: resolveBundledPiPackageDir(),
47
+ };
48
+ }
49
+ export function resolveBundledPiCliPath() {
50
+ const packageRoot = resolveBundledPiPackageDir();
37
51
  const pkgPath = join(packageRoot, 'package.json');
38
52
  const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
39
53
  const bin = pkg.bin && typeof pkg.bin === 'object' ? pkg.bin.pi : undefined;
40
54
  if (typeof bin !== 'string' || bin.trim() === '') {
41
- throw new Error(`bundled ${packageName} does not declare a pi binary in ${pkgPath}`);
55
+ throw new Error(`bundled @earendil-works/pi-coding-agent does not declare a pi binary in ${pkgPath}`);
42
56
  }
43
57
  const cliPath = join(packageRoot, bin);
44
58
  if (!existsSync(cliPath)) {
@@ -1,3 +1,11 @@
1
+ /** pi's sessions root, VENDORED from pi `config.getSessionsDir()` (= `<agentDir>/
2
+ * sessions`). pi's package `exports` map is `.`-only, so config.js can't be
3
+ * deep-imported, and a ROOT import of `getAgentDir` would eager-load the entire
4
+ * heavy pi SDK index on crtr's front-door hot path (the reason broker-sdk.ts
5
+ * dynamic-imports the engine). Mirrors pi: `PI_CODING_AGENT_DIR` env, else
6
+ * `~/.pi/agent` (APP_NAME defaults to 'pi'). Re-sync on a pi SDK bump that moves
7
+ * the sessions dir — same vendoring rationale as the slash-command list below. */
8
+ export declare function piSessionsRoot(): string;
1
9
  /**
2
10
  * pi's builtin slash commands — vendored verbatim from pi `core/slash-commands.js`
3
11
  * `BUILTIN_SLASH_COMMANDS` (review C1). **21 entries at 0.78.1** (review n1 — NOT 23).