@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
@@ -25,6 +25,55 @@
25
25
  // (kind 'final') ALSO steers — a completion the subscriber may be blocked on
26
26
  // must interrupt the current turn, not wait behind it as a follow-up.
27
27
  //
28
+ // Durable delivery — honest at-least-once with one conservative boundary:
29
+ // pi's sendUserMessage/sendMessage return `void`. Their loader dispatches an
30
+ // asynchronous runtime action and does NOT return the promise; a later
31
+ // delivery/prompt/queue/persist failure is caught and reported by the runtime,
32
+ // NOT thrown back here. So a send returning is a HANDOFF, not an acknowledgment
33
+ // — pi now owns the message, but has not necessarily accepted it.
34
+ //
35
+ // We do NOT try to correlate an exactly-once acknowledgment (every scheme —
36
+ // content-matching message_start, getEntries confirmation, per-send identity —
37
+ // adds worse defects than it closes: input-hook transforms break content
38
+ // matching, message_start precedes persistence, getEntries matches historical
39
+ // entries). Instead the model is deliberately at-least-once:
40
+ // • The read `cursor` advances at buffer time (so a burst is never re-read
41
+ // into the same watcher's buffer) — NOT the durability boundary.
42
+ // • `handedOffWatermark` advances when a send call RETURNS NORMALLY. A sync
43
+ // throw or a critical-abort re-buffer never advances it.
44
+ // • The durable cursor commits `handedOffWatermark` (clamped below anything
45
+ // still in `buffer`) ONLY at `agent_settled` — and at `session_shutdown` as
46
+ // the graceful safety net. NOT at agent_end (fires on failure/abort before
47
+ // pi decides on retry/continuation), NOT on the idle send return, NOT on
48
+ // message_start. One rule: an idle send starts a turn, and that turn's
49
+ // settle commits it.
50
+ // • Defensive hold at settle when pi still reports unconsumed sends. In the
51
+ // installed pi 0.80.6 `AgentSession`, retry/compaction/queued
52
+ // continuations are all drained WITHIN the same prompt cycle (the
53
+ // `_handlePostAgentRun`→`agent.continue()` loop) BEFORE the sole
54
+ // extension-facing `agent_settled` fires — a low-level failure/abort emits
55
+ // `agent_end` first, but any handed-off steer/followUp is consumed before
56
+ // that same cycle's settle. So the NORMAL production settle already sees
57
+ // `ctx.hasPendingMessages() === false`. The hold is therefore a
58
+ // conservative coarse guard, not a load-bearing part of a
59
+ // failure→pending-settle→later-settle sequence: IF a context ever reports
60
+ // pending sends at settle (steer+followUp queue length > 0), committing
61
+ // would durably consume a message pi has not yet delivered, so we DECLINE
62
+ // and let a later drained settle advance the cursor. It is a global
63
+ // boolean we never match to a specific send, and it clears on its own once
64
+ // pi drains, so it can never permanently stall the cursor.
65
+ //
66
+ // Accepted, deliberate residual windows (at-least-once by design):
67
+ // • A send whose promise pi later rejects INTERNALLY (swallowed by the
68
+ // runtime) never enters the queue, so hasPendingMessages() cannot see it
69
+ // and settle commits past it → rare silent loss. The alternative
70
+ // (correlation) demonstrably breaks the normal flow, so we accept this.
71
+ // • Crash/SIGKILL before settle → the fresh watcher re-reads from the last
72
+ // durable cursor and re-delivers (a duplicate coalesced pointer list, and
73
+ // an idempotent situational upsert re-read). Duplicate re-delivery, never
74
+ // silent loss.
75
+ // See `commitTarget` / `commitDurableCursor` / the agent_settled handler below.
76
+ //
28
77
  // Double-notify prevention (copied from legacy watcher):
29
78
  // A module-level `liveTimer` ensures that a /reload re-init clears the previous
30
79
  // setInterval before starting a new one — exactly one watcher is live at a time.
@@ -91,6 +140,12 @@ export function registerCanvasInboxWatcher(pi) {
91
140
  pi.on('agent_end', (_e, ctx) => {
92
141
  captureCtx(_e, ctx);
93
142
  streaming = false;
143
+ // agent_end is NOT a durable-commit point. It fires when a low-level run
144
+ // ends, but pi may still auto-retry, auto-compact, or run a queued
145
+ // continuation (it fires on the failure/abort path too, before pi decides).
146
+ // Committing here would durably consume a still-queued followUp that a
147
+ // failure agent_end preceded. Commit happens only at agent_settled (or the
148
+ // session_shutdown safety net), never here.
94
149
  });
95
150
  /**
96
151
  * True when pi is not currently streaming a response.
@@ -108,21 +163,117 @@ export function registerCanvasInboxWatcher(pi) {
108
163
  return !streaming;
109
164
  };
110
165
  // ---------------------------------------------------------------------------
111
- // Debounce state
166
+ // Debounce + durable-cursor state
112
167
  // ---------------------------------------------------------------------------
113
168
  /** Entries received since the last flush — coalesced into one message. */
114
169
  let buffer = [];
115
170
  /** Epoch-ms of the most recent entry arrival. Used to detect burst-quiet. */
116
171
  let lastArrival = 0;
117
172
  /**
118
- * Durable cursor — ISO 8601 of the last entry we've consumed.
119
- * Seeded from the persisted cursor file on first resolution; undefined means
120
- * "read from the beginning" (no prior cursor → process all existing entries).
121
- * NOT reset to `now` on first tick: that would silently drop entries that
122
- * arrived between node creation and watcher startup (the startup race).
173
+ * In-memory read watermark — ISO 8601 of the last entry we've consumed FROM
174
+ * THE FILE. Seeded from the persisted cursor on first resolution; undefined
175
+ * means "read from the beginning" (no prior cursor → process all existing
176
+ * entries). NOT reset to `now` on first tick: that would silently drop
177
+ * entries that arrived between node creation and watcher startup (the
178
+ * startup race).
179
+ *
180
+ * This advances at buffer time (tick(), before delivery) purely to stop the
181
+ * same entries being re-read into `buffer` on the next tick — it is NOT the
182
+ * durability boundary. See `handedOffWatermark` / `commitDurableCursor` for that.
123
183
  */
124
184
  let cursor;
185
+ /**
186
+ * Cursor value actually persisted to `inbox.jsonl.cursor` on disk — the
187
+ * durability boundary a fresh watcher reseeds from after a crash/kick. It
188
+ * tracks work committed at a settle, never merely read.
189
+ */
190
+ let durableCursor;
191
+ /**
192
+ * High-water ts of batches whose send call RETURNED NORMALLY — pi now owns
193
+ * them (a handoff, not an acknowledgment). It never advances on a sync send
194
+ * throw or a critical-abort re-buffer (those roll back to `buffer`), and it is
195
+ * never itself the durability boundary: it is only committed, clamped, at a
196
+ * settle. The read `cursor` races ahead of it (advancing at buffer time before
197
+ * any send), which is why `cursor` is never persisted.
198
+ */
199
+ let handedOffWatermark;
125
200
  let seeded = false;
201
+ /** Record a batch handoff — pi's send returned normally. Never moves back. */
202
+ const handOff = (maxTs) => {
203
+ if (handedOffWatermark === undefined || maxTs > handedOffWatermark)
204
+ handedOffWatermark = maxTs;
205
+ };
206
+ /** Max ts across a batch of entries (its high-water for advancement). */
207
+ const batchMaxTs = (entries) => entries.reduce((a, b) => (a.ts > b.ts ? a : b)).ts;
208
+ /**
209
+ * The ts safe to persist right now: the handed-off high-water, but NEVER at or
210
+ * past anything still in `buffer` (debounced, re-buffered after a sync send
211
+ * throw, or held for a critical-abort retry). When a buffered entry sits at or
212
+ * below the handed-off high-water — a rare out-of-order case — we decline to
213
+ * advance at all rather than risk committing past it. Conservative by design:
214
+ * at worst a fresh watcher re-delivers an already-handled entry, never loses
215
+ * one.
216
+ */
217
+ const commitTarget = () => {
218
+ if (handedOffWatermark === undefined)
219
+ return undefined;
220
+ let earliest;
221
+ for (const e of buffer)
222
+ if (earliest === undefined || e.ts < earliest)
223
+ earliest = e.ts;
224
+ if (earliest === undefined)
225
+ return handedOffWatermark;
226
+ return handedOffWatermark < earliest ? handedOffWatermark : undefined;
227
+ };
228
+ /** Persist the commit watermark to disk if it has advanced past what's durable. */
229
+ const commitDurableCursor = (nodeId) => {
230
+ const target = commitTarget();
231
+ if (target === undefined)
232
+ return;
233
+ if (durableCursor === undefined || target > durableCursor) {
234
+ writeCursor(nodeId, target);
235
+ durableCursor = target;
236
+ }
237
+ };
238
+ const commitIfNode = () => {
239
+ const nodeId = process.env['CRTR_NODE_ID'];
240
+ if (nodeId !== undefined && nodeId.trim() !== '')
241
+ commitDurableCursor(nodeId);
242
+ };
243
+ /**
244
+ * Commit at a settle boundary, with a defensive HOLD while pi still reports
245
+ * unconsumed sends. `ctx.hasPendingMessages()` is the steer+followUp queue
246
+ * length — pi's own coarse truth. In the installed pi 0.80.6 `AgentSession`
247
+ * retry/compaction/queued continuations drain within the same prompt cycle
248
+ * before the sole extension-facing settle, so the normal production settle
249
+ * already sees this false. The hold is a conservative guard: IF a context ever
250
+ * reports pending sends at settle, committing would durably consume an entry
251
+ * pi has not yet delivered, so we decline. It is a global boolean, never
252
+ * matched to a specific send, and it clears on its own once pi drains, so it
253
+ * can never permanently stall the cursor. When the API is absent (older pi) we
254
+ * fall back to the plain commit-at-settle baseline.
255
+ */
256
+ const commitAtSettle = (ctx) => {
257
+ try {
258
+ if (ctx?.hasPendingMessages?.() === true)
259
+ return;
260
+ }
261
+ catch {
262
+ /* can't tell — fall through to the commit-at-settle baseline */
263
+ }
264
+ commitIfNode();
265
+ };
266
+ // ---------------------------------------------------------------------------
267
+ // agent_settled — the ONLY durable-commit boundary (session_shutdown is its
268
+ // graceful safety net). It fires once pi will not continue automatically (no
269
+ // retry, compaction, or queued continuation left). Commit the handed-off
270
+ // high-water, clamped below anything still buffered, and only when pi holds no
271
+ // unconsumed sends (hasPendingMessages()).
272
+ // ---------------------------------------------------------------------------
273
+ pi.on('agent_settled', (event, ctx) => {
274
+ captureCtx(event, ctx);
275
+ commitAtSettle(ctx);
276
+ });
126
277
  // ---------------------------------------------------------------------------
127
278
  // Flush: deliver the buffered entries as a single pi user message.
128
279
  // ---------------------------------------------------------------------------
@@ -145,28 +296,34 @@ export function registerCanvasInboxWatcher(pi) {
145
296
  // (context alone, no accompanying visible body) are dropped from the
146
297
  // visible batch entirely — they exist purely as a wake marker for this
147
298
  // delivery and must never surface a line in the digest.
148
- const hasSituational = batch.some((e) => e.data?.['situational'] === true);
299
+ const situationalEntries = batch.filter((e) => e.data?.['situational'] === true);
149
300
  const visibleBatch = batch.filter((e) => e.data?.['situationalOnly'] !== true);
150
- if (hasSituational) {
301
+ if (situationalEntries.length > 0) {
151
302
  const nodeId = process.env['CRTR_NODE_ID'];
152
303
  const text = nodeId !== undefined ? readSituationalContext(nodeId) : null;
153
304
  if (text !== null) {
305
+ const situationalContent = `<situational-context>\n${text}\n</situational-context>`;
154
306
  try {
155
307
  pi.sendMessage({
156
308
  customType: SITUATIONAL_CONTEXT_CUSTOM_TYPE,
157
- content: `<situational-context>\n${text}\n</situational-context>`,
309
+ content: situationalContent,
158
310
  display: true,
159
311
  details: { nodeId },
160
312
  }, { deliverAs: 'followUp', triggerTurn: true });
313
+ // Handed off — pi's send returned normally. It commits at the next
314
+ // agent_settled (a custom send that async-rejected is the accepted
315
+ // silent-loss window; hasPendingMessages() covers only user steer/
316
+ // followUp, so a situational miss is not held).
317
+ handOff(batchMaxTs(situationalEntries));
161
318
  }
162
319
  catch {
163
- // Re-queue on delivery failure same durability contract as the visible
164
- // digest path below. The cursor already advanced past these entries in
165
- // tick(), so a swallowed failure here would otherwise drop the hidden
166
- // wake permanently (never re-read from the inbox, never retried). Only
167
- // the situational-only entries go back; a real re-send re-reads the
168
- // CURRENT sidecar value (upsert, not append), so retrying is idempotent
169
- // even if the text changed again in between.
320
+ // Re-queue on a SYNCHRONOUS send failure (a stale-extension assertion
321
+ // an async delivery failure is swallowed by the runtime, not thrown
322
+ // here). The read cursor already advanced past these entries in
323
+ // tick(), so a swallowed failure with no re-queue would drop the
324
+ // hidden wake permanently. Only the situational-only entries go back;
325
+ // a real re-send re-reads the CURRENT sidecar value (upsert, not
326
+ // append), so retrying is idempotent even if the text changed again.
170
327
  buffer = batch.filter((e) => e.data?.['situationalOnly'] === true).concat(buffer);
171
328
  }
172
329
  }
@@ -184,8 +341,11 @@ export function registerCanvasInboxWatcher(pi) {
184
341
  const steerMidStream = anyCritical || visibleBatch.some((e) => e.tier === 'urgent' || e.kind === 'final');
185
342
  try {
186
343
  if (isIdle()) {
187
- // Idle → trigger a new turn immediately (sendUserMessage always triggers).
344
+ // Idle → trigger a new turn immediately (sendUserMessage always
345
+ // triggers). The send returns void (a handoff, not an ack), so we do NOT
346
+ // commit here — the turn this starts commits at its agent_settled.
188
347
  pi.sendUserMessage(digest);
348
+ handOff(batchMaxTs(visibleBatch));
189
349
  }
190
350
  else if (anyCritical) {
191
351
  // Critical mid-stream → TRUE preempt. ctx.abort() cancels the live LLM
@@ -194,7 +354,9 @@ export function registerCanvasInboxWatcher(pi) {
194
354
  // path — by then the turn has torn down and sendUserMessage starts a fresh
195
355
  // turn. Relying on the proven idle path (not steer-after-abort semantics)
196
356
  // keeps this robust; if abort hasn't settled by the next tick we simply
197
- // abort again and retry — idempotent and self-healing.
357
+ // abort again and retry — idempotent and self-healing. Nothing is handed
358
+ // off here (handedOffWatermark stays put) and the entry stays buffered,
359
+ // so the abort-generated agent_end/settle cannot commit past it.
198
360
  try {
199
361
  lastCtx?.abort?.();
200
362
  }
@@ -203,13 +365,19 @@ export function registerCanvasInboxWatcher(pi) {
203
365
  }
204
366
  else {
205
367
  // Mid-stream → steer on urgency or a finished node, else enqueue for the
206
- // turn after this one.
368
+ // turn after this one. Handed to pi's in-memory queue now (returns
369
+ // normally = handoff). It commits at the turn's agent_settled — and if
370
+ // the turn fails/aborts before pi consumes it, hasPendingMessages() holds
371
+ // the commit until a later settle drains it.
207
372
  pi.sendUserMessage(digest, { deliverAs: steerMidStream ? 'steer' : 'followUp' });
373
+ handOff(batchMaxTs(visibleBatch));
208
374
  }
209
375
  }
210
376
  catch {
211
- // Re-queue on delivery failure so a transient error doesn't silently drop
212
- // inbox entries. They will be retried on the next flush.
377
+ // Re-queue on a SYNCHRONOUS delivery failure so a transient error doesn't
378
+ // silently drop inbox entries. handedOffWatermark was not advanced and the
379
+ // entries are back in `buffer`, so no settle can commit past them. They
380
+ // will be retried on the next flush.
213
381
  buffer = visibleBatch.concat(buffer);
214
382
  }
215
383
  };
@@ -230,6 +398,7 @@ export function registerCanvasInboxWatcher(pi) {
230
398
  // with undefined returns ALL entries (no truncation to `now`).
231
399
  if (!seeded) {
232
400
  cursor = readCursor(nodeId);
401
+ durableCursor = cursor;
233
402
  seeded = true;
234
403
  }
235
404
  const newEntries = readInboxSince(nodeId, cursor);
@@ -244,12 +413,12 @@ export function registerCanvasInboxWatcher(pi) {
244
413
  return;
245
414
  }
246
415
  if (newEntries.length > 0) {
247
- // Advance and persist the cursor BEFORE buffering, so a crash after this
248
- // point loses at most one coalesced message rather than re-injecting
249
- // already-delivered entries on restart (exactly-once over restart contract).
416
+ // Advance the in-memory read watermark BEFORE buffering, so a re-tick
417
+ // during the debounce window never re-reads the same entries into
418
+ // `buffer`. This does NOT persist to disk — the durable cursor commits
419
+ // the handed-off high-water only at agent_settled (see commitDurableCursor).
250
420
  const latest = newEntries.reduce((a, b) => (a.ts > b.ts ? a : b));
251
421
  cursor = latest.ts;
252
- writeCursor(nodeId, cursor);
253
422
  buffer.push(...newEntries);
254
423
  lastArrival = Date.now();
255
424
  }
@@ -277,6 +446,15 @@ export function registerCanvasInboxWatcher(pi) {
277
446
  // pi DOES fire session_shutdown — use it as the authoritative teardown so a
278
447
  // re-init (e.g. /reload) never discovers a live sibling timer.
279
448
  pi.on('session_shutdown', () => {
449
+ // Safety net on the graceful-teardown path: a last chance to persist the
450
+ // handed-off high-water. Same settle gate — commitTarget clamps below
451
+ // anything still buffered, and we HOLD if pi still holds unconsumed sends
452
+ // (they die with the process on shutdown, so a fresh watcher must re-read
453
+ // them). This does NOT cover the abrupt-kill path: the broker's SIGTERM
454
+ // handler runs disposeAndExit (session.dispose() + process.exit) without
455
+ // emitting this extension's session_shutdown, so this never fires on a kill
456
+ // — exactly why the durable cursor only ever advances at a settle boundary.
457
+ commitAtSettle(lastCtx);
280
458
  clearInterval(timer);
281
459
  if (liveTimer === timer)
282
460
  liveTimer = undefined;
@@ -22,6 +22,10 @@ interface ExtensionCtxLike {
22
22
  ui?: UIContextLike;
23
23
  mode?: string;
24
24
  sessionManager?: SessionManagerLike;
25
+ model?: {
26
+ provider?: string;
27
+ id?: string;
28
+ };
25
29
  }
26
30
  type PiEvents = 'session_start' | 'input' | 'turn_end' | 'session_shutdown';
27
31
  interface PiLike {
@@ -1,11 +1,12 @@
1
1
  // canvas-recap.ts — pi extension for pi-native canvas agent nodes.
2
2
  //
3
- // A per-node INACTIVITY RECAP, drawn as the topmost chrome above the editor.
4
- // When a node has had NO new message (user or assistant) for IDLE_MS (300s by
5
- // default), it runs Haiku over the literal conversation and pins a terse
6
- // three-fragment card goal / doing-now / next above canvas-nav's manager
7
- // line. The card disappears the instant the next message arrives, and the idle
8
- // clock restarts.
3
+ // A per-node INACTIVITY BRIEF, drawn faintly immediately above the editor border.
4
+ // When a node has had NO new message (user or assistant) for IDLE_MS (60s by
5
+ // default), it runs a stripped-down headless call on the conversation's current
6
+ // working model and pins one concise, wrapped briefing above the editor, so a
7
+ // developer returning to the conversation sees where it left off at a glance.
8
+ // The card disappears the instant the next message arrives, and the idle clock
9
+ // restarts.
9
10
  //
10
11
  // trigger/clear are keyed off MESSAGES, not turns-of-work:
11
12
  // • a user `input` event OR an assistant `turn_end` resets the idle clock
@@ -13,15 +14,15 @@
13
14
  // • a single low-rate timer (no busy-loop) checks elapsed-since-last-message
14
15
  // and, on crossing IDLE_MS, generates + shows the recap once.
15
16
  //
16
- // Haiku input = ALL user + assistant LITERAL text, most-recent within a char
17
- // budget. Tool calls, tool results, thinking blocks, and extension-injected
18
- // custom messages are excluded (only `message` entries with role user/assistant,
19
- // and only their `text` content blocks).
17
+ // Brief input = the FIRST 5 and MOST RECENT 5 user/assistant messages (literal
18
+ // text only the first messages carry the original request, the last carry
19
+ // where it left off), plus the node's roadmap file when one exists. Tool calls,
20
+ // tool results, thinking blocks, and extension-injected custom messages are
21
+ // excluded (only `message` entries with role user/assistant, and only their
22
+ // `text` content blocks). Long messages are truncated per-message so the
23
+ // opening request is never crowded out by a recent wall of text.
20
24
  //
21
- // Placement: key `crtr-recap`, placement `aboveEditor`, ordered ABOVE
22
- // `crtr-managers` via the widget-order bus (pi's widget store is insertion-
23
- // ordered and re-setting moves a key to the bottom; after we paint the recap
24
- // we ask canvas-nav to re-assert its manager line, which drops it below us).
25
+ // Placement: key `crtr-recap`, placement `aboveEditor`.
25
26
  //
26
27
  // Surface gating: shows in BOTH the in-pane pi TUI (mode 'tui') and the
27
28
  // headless broker → attach/web viewers (mode 'print', setWidget broadcasts to
@@ -35,24 +36,25 @@
35
36
  // Plain TS-with-types — no imports from @earendil-works/* so this compiles
36
37
  // inside crouter's own tsc build without a dep on the pi packages.
37
38
  import { generateRecap } from '../core/runtime/recap.js';
38
- import { requestNavRerender } from './widget-order-bus.js';
39
+ import { readRoadmap } from '../core/runtime/roadmap.js';
39
40
  // ---------------------------------------------------------------------------
40
41
  // Tuning constants
41
42
  // ---------------------------------------------------------------------------
42
- /** No-new-message window before a recap is shown. 300s by default; override with
43
+ /** No-new-message window before a recap is shown. 60s by default; override with
43
44
  * CRTR_RECAP_IDLE_MS (for fast runtime verification). */
44
45
  function idleMs() {
45
46
  const raw = process.env['CRTR_RECAP_IDLE_MS'];
46
47
  const n = raw !== undefined ? parseInt(raw, 10) : NaN;
47
- return Number.isFinite(n) && n > 0 ? n : 300_000;
48
+ return Number.isFinite(n) && n > 0 ? n : 60_000;
48
49
  }
49
- /** How often the (single, low-rate) timer checks elapsed-since-last-message.
50
- * Mirrors canvas-nav's ASK_POLL_MS — cheap and tmux/crtr-free. */
50
+ /** How often the single low-rate timer checks elapsed-since-last-message. */
51
51
  const POLL_MS = 7_000;
52
- /** Char budget on the conversation fed to Haiku the model only needs the gist,
53
- * so we keep the MOST-RECENT text within this cap (a recap is about where we
54
- * left off, not the whole history). */
55
- const CONVO_CHAR_BUDGET = 12_000;
52
+ /** How many messages from each end of the conversation feed the brief model
53
+ * the first N carry the original request, the last N carry where it left off. */
54
+ const CONVO_EDGE_MESSAGES = 5;
55
+ /** Per-message char cap — keeps one giant paste from crowding out the rest
56
+ * (10 messages × this ≈ the total budget the model needs for the gist). */
57
+ const MESSAGE_CHAR_BUDGET = 1_200;
56
58
  const WIDGET_KEY = 'crtr-recap';
57
59
  // ---------------------------------------------------------------------------
58
60
  // Module-level state — persists across /reload so the timer doesn't stack.
@@ -77,9 +79,11 @@ function messageText(msg) {
77
79
  .join('\n')
78
80
  .trim();
79
81
  }
80
- /** Concatenate the conversation's user + assistant literal text into a single
81
- * `User:`/`Agent:` transcript, keeping only the MOST-RECENT CONVO_CHAR_BUDGET
82
- * characters. Excludes tool calls/results (role toolResult, plus toolCall/
82
+ /** Concatenate the FIRST and MOST-RECENT CONVO_EDGE_MESSAGES user/assistant
83
+ * messages into a single `User:`/`Agent:` transcript (each message capped at
84
+ * MESSAGE_CHAR_BUDGET chars), with an omission marker where the middle was
85
+ * dropped — the opening messages carry the original request the brief exists
86
+ * to surface. Excludes tool calls/results (role toolResult, plus toolCall/
83
87
  * thinking content blocks dropped by messageText) and extension-injected
84
88
  * custom messages (entry.type !== 'message'). Returns '' when there is nothing
85
89
  * to summarize. */
@@ -98,15 +102,23 @@ function buildConversation(sm) {
98
102
  const role = e.message.role;
99
103
  if (role !== 'user' && role !== 'assistant')
100
104
  continue; // drop toolResult etc.
101
- const text = messageText(e.message);
105
+ let text = messageText(e.message);
102
106
  if (text === '')
103
107
  continue;
108
+ if (text.length > MESSAGE_CHAR_BUDGET)
109
+ text = `${text.slice(0, MESSAGE_CHAR_BUDGET)} …[truncated]`;
104
110
  parts.push(`${role === 'user' ? 'User' : 'Agent'}: ${text}`);
105
111
  }
106
112
  if (parts.length === 0)
107
113
  return '';
108
- const full = parts.join('\n\n');
109
- return full.length > CONVO_CHAR_BUDGET ? full.slice(full.length - CONVO_CHAR_BUDGET) : full;
114
+ if (parts.length <= CONVO_EDGE_MESSAGES * 2)
115
+ return parts.join('\n\n');
116
+ const omitted = parts.length - CONVO_EDGE_MESSAGES * 2;
117
+ return [
118
+ ...parts.slice(0, CONVO_EDGE_MESSAGES),
119
+ `[… ${omitted} message${omitted === 1 ? '' : 's'} omitted …]`,
120
+ ...parts.slice(parts.length - CONVO_EDGE_MESSAGES),
121
+ ].join('\n\n');
110
122
  }
111
123
  // ---------------------------------------------------------------------------
112
124
  // Extension
@@ -124,12 +136,22 @@ export function registerCanvasRecap(pi) {
124
136
  // Captured on session_start; used by the timer and the message handlers.
125
137
  let ui;
126
138
  let sessionManager;
139
+ /** Exact provider/model that most recently ran this conversation successfully.
140
+ * The recap subprocess prefers it over a hard-pinned provider that may be in
141
+ * cooldown (the failure mode that made the old recap silently disappear). */
142
+ let liveModel;
143
+ const captureLiveModel = (ctx) => {
144
+ const provider = ctx.model?.provider?.trim();
145
+ const id = ctx.model?.id?.trim();
146
+ if (provider && id)
147
+ liveModel = `${provider}/${id}`;
148
+ };
127
149
  /** Wall-clock of the most recent user/assistant message. The idle clock. */
128
150
  let lastMessageAt = Date.now();
129
151
  /** True while a recap is currently shown (so we generate at most once per
130
152
  * idle window, and know whether a clear is needed on the next message). */
131
153
  let recapShown = false;
132
- /** True while a Haiku generation is in flight (so we don't fire a second). */
154
+ /** True while a briefing generation is in flight (so we don't fire a second). */
133
155
  let generating = false;
134
156
  const canPaint = () => ui !== undefined && typeof ui.setWidget === 'function';
135
157
  const clearRecap = () => {
@@ -140,21 +162,19 @@ export function registerCanvasRecap(pi) {
140
162
  }
141
163
  catch { /* best-effort */ }
142
164
  };
143
- /** Render the three fragments as the topmost above-editor card. */
144
- const showRecap = (fragments) => {
145
- if (!canPaint())
165
+ /** Render the briefing into the viewer's low-contrast above-editor slot.
166
+ * The briefing arrives as the two-line `Goal:`/`State:` format — one widget
167
+ * line each (the viewer wraps long lines itself). */
168
+ const showRecap = (briefing) => {
169
+ if (!canPaint() || briefing === '')
146
170
  return;
147
- const labels = ['◷ goal', now ', '→ next'];
148
- const lines = fragments.slice(0, 3).map((f, i) => `${labels[i] ?? ' '} ${f}`);
171
+ const lines = briefing.split('\n').filter((l) => l.trim() !== '');
149
172
  if (lines.length === 0)
150
173
  return;
151
174
  try {
152
175
  ui.setWidget(WIDGET_KEY, lines, { placement: 'aboveEditor' });
153
176
  }
154
177
  catch { /* best-effort */ }
155
- // Re-assert the manager line BELOW us (pi's widget store is insertion-
156
- // ordered; re-setting crtr-managers drops it under crtr-recap).
157
- requestNavRerender();
158
178
  };
159
179
  /** A user or assistant message landed: reset the idle clock and clear any
160
180
  * shown recap (the conversation has moved on). */
@@ -163,7 +183,6 @@ export function registerCanvasRecap(pi) {
163
183
  if (recapShown) {
164
184
  recapShown = false;
165
185
  clearRecap();
166
- requestNavRerender();
167
186
  }
168
187
  };
169
188
  // -------------------------------------------------------------------------
@@ -172,6 +191,7 @@ export function registerCanvasRecap(pi) {
172
191
  pi.on('session_start', (_event, ctx) => {
173
192
  ui = ctx.ui;
174
193
  sessionManager = ctx.sessionManager;
194
+ captureLiveModel(ctx);
175
195
  // Fresh session / hot-swap: start the idle clock now and drop any stale
176
196
  // recap that bled through a /reload.
177
197
  lastMessageAt = Date.now();
@@ -183,12 +203,14 @@ export function registerCanvasRecap(pi) {
183
203
  // Every user message (any source) is activity — reset + clear.
184
204
  onMessage();
185
205
  });
186
- pi.on('turn_end', (_event, _ctx) => {
187
- // A completed assistant turn is activity — reset + clear.
206
+ pi.on('turn_end', (_event, ctx) => {
207
+ // A completed assistant turn is activity — reset + clear, and remember the
208
+ // exact model/provider that successfully produced it for the later recap.
209
+ captureLiveModel(ctx);
188
210
  onMessage();
189
211
  });
190
212
  // -------------------------------------------------------------------------
191
- // The single low-rate idle poll — no busy-loop, mirrors canvas-nav's timer.
213
+ // The single low-rate idle poll.
192
214
  // -------------------------------------------------------------------------
193
215
  if (liveTimer !== undefined)
194
216
  clearInterval(liveTimer);
@@ -203,19 +225,24 @@ export function registerCanvasRecap(pi) {
203
225
  const convo = buildConversation(sessionManager);
204
226
  if (convo === '')
205
227
  return; // nothing to summarize yet
206
- // Snapshot the clock: if a message arrives while Haiku runs, discard the
228
+ // Snapshot the clock: if a message arrives while generation runs, discard the
207
229
  // (now-stale) result instead of painting over the live conversation.
208
230
  const startedAt = lastMessageAt;
209
231
  generating = true;
210
- generateRecap(convo, (fragments) => {
232
+ let roadmap;
233
+ try {
234
+ roadmap = readRoadmap(nodeId) ?? undefined;
235
+ }
236
+ catch { /* best-effort */ }
237
+ generateRecap(convo, (briefing) => {
211
238
  generating = false;
212
239
  if (lastMessageAt !== startedAt)
213
240
  return; // a message landed mid-flight
214
241
  if (!canPaint())
215
242
  return;
216
243
  recapShown = true;
217
- showRecap(fragments);
218
- });
244
+ showRecap(briefing);
245
+ }, liveModel, roadmap);
219
246
  // generateRecap is silent on failure (never calls back), so clear the
220
247
  // in-flight flag after the timeout window even if no callback fires.
221
248
  setTimeout(() => { generating = false; }, 30_000).unref?.();
@@ -4,6 +4,17 @@ interface PiLike {
4
4
  sendUserMessage: (content: string, options?: {
5
5
  deliverAs?: 'steer' | 'followUp';
6
6
  }) => void;
7
+ /** Custom-message injection — used for the 'nextTurn' hold (queued silently,
8
+ * injected alongside the NEXT user message, never triggers a turn). */
9
+ sendMessage: (message: {
10
+ customType: string;
11
+ content: string;
12
+ display?: boolean;
13
+ details?: Record<string, unknown>;
14
+ }, options?: {
15
+ deliverAs?: 'steer' | 'followUp' | 'nextTurn';
16
+ triggerTurn?: boolean;
17
+ }) => void;
7
18
  }
8
19
  /** The nudge text for a crossed band, specialized to the node's (mode,
9
20
  * lifecycle) persona + how far along the escalation it is. `crtr node yield` is