@crouton-kit/crouter 0.3.68 → 0.3.78

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 (176) hide show
  1. package/README.md +1 -1
  2. package/dist/builtin-memory/00-runtime-base.md +3 -10
  3. package/dist/builtin-memory/04-base-worker.md +18 -0
  4. package/dist/builtin-memory/04-orchestration-kernel.md +1 -1
  5. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +13 -34
  6. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +639 -741
  7. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +241 -58
  8. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/statusline.ts +2 -9
  9. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +365 -94
  10. package/dist/builtin-pi-packages/pi-crtr-extensions/package-lock.json +2 -2
  11. package/dist/builtin-pi-packages/pi-mode-switch/README.md +11 -39
  12. package/dist/builtin-pi-packages/pi-mode-switch/extensions/index.ts +20 -15
  13. package/dist/builtin-pi-packages/pi-mode-switch/package.json +1 -1
  14. package/dist/builtin-views/canvas/tui.mjs +8 -9
  15. package/dist/builtin-views/chat/tui.mjs +12 -12
  16. package/dist/builtin-views/git-pr/tui.mjs +7 -8
  17. package/dist/builtin-views/inbox/tui.mjs +27 -41
  18. package/dist/builtin-views/linkedin/tui.mjs +23 -37
  19. package/dist/builtin-views/prompt-review/tui.mjs +11 -11
  20. package/dist/builtin-views/settings/tui.mjs +11 -11
  21. package/dist/builtin-views/workspace-sidebar/tui.mjs +8 -9
  22. package/dist/cli.d.ts +1 -1
  23. package/dist/cli.js +1 -0
  24. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +11 -7
  25. package/dist/clients/attach/__tests__/crtr-output-render.test.js +1 -1
  26. package/dist/clients/attach/__tests__/mermaid-render.test.d.ts +1 -0
  27. package/dist/clients/attach/__tests__/mermaid-render.test.js +20 -0
  28. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.d.ts +1 -0
  29. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +110 -0
  30. package/dist/clients/attach/attach-cmd.d.ts +1 -1
  31. package/dist/clients/attach/attach-cmd.js +733 -730
  32. package/dist/clients/attach/auth-pickers.d.ts +12 -0
  33. package/dist/clients/attach/auth-pickers.js +11 -0
  34. package/dist/clients/attach/canvas-panels.js +2 -3
  35. package/dist/clients/attach/chat-view.d.ts +7 -8
  36. package/dist/clients/attach/chat-view.js +119 -79
  37. package/dist/clients/attach/edit-diff-render.d.ts +6 -0
  38. package/dist/clients/attach/edit-diff-render.js +263 -0
  39. package/dist/clients/attach/input-controller.d.ts +28 -2
  40. package/dist/clients/attach/input-controller.js +38 -5
  41. package/dist/clients/attach/mermaid-render.js +5 -2
  42. package/dist/clients/attach/pickers.d.ts +8 -7
  43. package/dist/clients/attach/pickers.js +13 -17
  44. package/dist/clients/attach/slash-commands.d.ts +9 -0
  45. package/dist/clients/attach/slash-commands.js +128 -7
  46. package/dist/clients/attach/titled-editor.d.ts +12 -1
  47. package/dist/clients/attach/titled-editor.js +103 -8
  48. package/dist/commands/canvas-browse.js +2 -2
  49. package/dist/commands/memory/lint.js +39 -5
  50. package/dist/commands/memory/write.js +1 -0
  51. package/dist/commands/node.js +12 -5
  52. package/dist/commands/profile/new.js +30 -5
  53. package/dist/commands/surface-tmux-spread.js +1 -3
  54. package/dist/commands/sys/__tests__/config-keybindings.test.d.ts +1 -0
  55. package/dist/commands/sys/__tests__/config-keybindings.test.js +55 -0
  56. package/dist/commands/sys/__tests__/config-model-ladders.test.d.ts +1 -0
  57. package/dist/commands/sys/__tests__/config-model-ladders.test.js +121 -0
  58. package/dist/commands/sys/config.js +18 -21
  59. package/dist/commands/sys/setup-core.d.ts +12 -1
  60. package/dist/commands/sys/setup-core.js +25 -3
  61. package/dist/commands/sys/setup.js +166 -20
  62. package/dist/core/__tests__/base-worker-prompt.test.d.ts +1 -0
  63. package/dist/core/__tests__/base-worker-prompt.test.js +24 -0
  64. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +232 -1
  65. package/dist/core/__tests__/fault-classifier.test.js +15 -0
  66. package/dist/core/__tests__/full/broker-dialogs.test.js +7 -2
  67. package/dist/core/__tests__/full/broker-pane-resolution.test.js +9 -6
  68. package/dist/core/__tests__/full/cascade-close.test.js +5 -2
  69. package/dist/core/__tests__/full/dead-pane-regression.test.js +6 -3
  70. package/dist/core/__tests__/full/detach-focus.test.js +10 -5
  71. package/dist/core/__tests__/full/human-new-window-regression.test.js +6 -3
  72. package/dist/core/__tests__/full/review-render-pane-regression.test.js +2 -2
  73. package/dist/core/__tests__/helpers/harness.d.ts +1 -0
  74. package/dist/core/__tests__/helpers/harness.js +28 -4
  75. package/dist/core/__tests__/host-teardown-process-group.test.js +15 -4
  76. package/dist/core/__tests__/live-mutation-verbs.test.js +5 -4
  77. package/dist/core/__tests__/review-model-floor.test.js +1 -1
  78. package/dist/core/__tests__/stream-watchdog.test.d.ts +1 -0
  79. package/dist/core/__tests__/stream-watchdog.test.js +70 -0
  80. package/dist/core/__tests__/tmux-surface.test.js +72 -0
  81. package/dist/core/canvas/__tests__/remote-event-stream.test.js +11 -9
  82. package/dist/core/canvas/__tests__/render-remote.test.js +7 -4
  83. package/dist/core/canvas/browse/__tests__/model.test.js +23 -5
  84. package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.js +30 -13
  85. package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
  86. package/dist/core/canvas/browse/app.js +164 -5
  87. package/dist/core/canvas/browse/model.d.ts +10 -6
  88. package/dist/core/canvas/browse/model.js +20 -12
  89. package/dist/core/canvas/browse/pins.d.ts +4 -0
  90. package/dist/core/canvas/browse/pins.js +29 -0
  91. package/dist/core/canvas/browse/render.d.ts +13 -1
  92. package/dist/core/canvas/browse/render.js +72 -15
  93. package/dist/core/canvas/nav-model.js +5 -11
  94. package/dist/core/canvas/paths.d.ts +5 -0
  95. package/dist/core/canvas/paths.js +7 -0
  96. package/dist/core/canvas/pid.d.ts +14 -10
  97. package/dist/core/canvas/pid.js +14 -10
  98. package/dist/core/config.d.ts +53 -2
  99. package/dist/core/config.js +228 -110
  100. package/dist/core/fault-classifier.js +1 -1
  101. package/dist/core/fs-utils.d.ts +1 -0
  102. package/dist/core/fs-utils.js +15 -1
  103. package/dist/core/keybindings/__tests__/resolve.test.d.ts +1 -0
  104. package/dist/core/keybindings/__tests__/resolve.test.js +224 -0
  105. package/dist/core/keybindings/catalog.d.ts +14 -0
  106. package/dist/core/keybindings/catalog.js +256 -0
  107. package/dist/core/keybindings/index.d.ts +5 -0
  108. package/dist/core/keybindings/index.js +4 -0
  109. package/dist/core/keybindings/match.d.ts +29 -0
  110. package/dist/core/keybindings/match.js +99 -0
  111. package/dist/core/keybindings/persistence.d.ts +14 -0
  112. package/dist/core/keybindings/persistence.js +31 -0
  113. package/dist/core/keybindings/resolve.d.ts +14 -0
  114. package/dist/core/keybindings/resolve.js +305 -0
  115. package/dist/core/keybindings/types.d.ts +37 -0
  116. package/dist/core/keybindings/types.js +1 -0
  117. package/dist/core/memory-resolver.d.ts +1 -1
  118. package/dist/core/memory-resolver.js +10 -3
  119. package/dist/core/predicate.d.ts +5 -3
  120. package/dist/core/predicate.js +5 -3
  121. package/dist/core/runtime/broker-protocol.d.ts +33 -5
  122. package/dist/core/runtime/broker.js +165 -14
  123. package/dist/core/runtime/launch.d.ts +39 -6
  124. package/dist/core/runtime/launch.js +79 -21
  125. package/dist/core/runtime/naming.js +3 -3
  126. package/dist/core/runtime/pi-cli.d.ts +6 -0
  127. package/dist/core/runtime/pi-cli.js +16 -2
  128. package/dist/core/runtime/placement.d.ts +2 -2
  129. package/dist/core/runtime/placement.js +4 -1
  130. package/dist/core/runtime/promote.js +4 -0
  131. package/dist/core/runtime/recap.d.ts +3 -3
  132. package/dist/core/runtime/recap.js +47 -44
  133. package/dist/core/runtime/recycle.js +6 -1
  134. package/dist/core/runtime/reset.js +5 -0
  135. package/dist/core/runtime/revive.js +28 -18
  136. package/dist/core/runtime/session-list-cache.d.ts +23 -0
  137. package/dist/core/runtime/session-list-cache.js +270 -0
  138. package/dist/core/runtime/spawn.js +51 -32
  139. package/dist/core/runtime/stream-watchdog.d.ts +26 -0
  140. package/dist/core/runtime/stream-watchdog.js +75 -0
  141. package/dist/core/runtime/tmux-chrome.d.ts +1 -1
  142. package/dist/core/runtime/tmux-chrome.js +2 -2
  143. package/dist/core/runtime/tmux.d.ts +18 -15
  144. package/dist/core/runtime/tmux.js +216 -110
  145. package/dist/core/tui/__tests__/host-keybindings.test.d.ts +1 -0
  146. package/dist/core/tui/__tests__/host-keybindings.test.js +112 -0
  147. package/dist/core/tui/host.d.ts +30 -1
  148. package/dist/core/tui/host.js +148 -34
  149. package/dist/core/view/__tests__/transport-remote.test.js +9 -6
  150. package/dist/core/view/contract.d.ts +17 -3
  151. package/dist/daemon/crtrd-cli.d.ts +1 -1
  152. package/dist/daemon/crtrd-cli.js +1 -0
  153. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +41 -0
  154. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.d.ts +1 -0
  155. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.js +121 -0
  156. package/dist/pi-extensions/canvas-inbox-watcher.d.ts +1 -1
  157. package/dist/pi-extensions/canvas-inbox-watcher.js +203 -25
  158. package/dist/pi-extensions/canvas-recap.d.ts +4 -0
  159. package/dist/pi-extensions/canvas-recap.js +38 -37
  160. package/dist/pi-extensions/canvas-stophook.d.ts +11 -0
  161. package/dist/pi-extensions/canvas-stophook.js +33 -4
  162. package/dist/suppress-experimental-warnings.d.ts +1 -0
  163. package/dist/suppress-experimental-warnings.js +15 -0
  164. package/dist/types.d.ts +3 -13
  165. package/dist/types.js +12 -36
  166. package/dist/web-client/assets/{index-CnF5r8ky.js → index--md2ylfi.js} +18 -17
  167. package/dist/web-client/assets/index-BUdm9s9s.css +2 -0
  168. package/dist/web-client/index.html +3 -3
  169. package/dist/web-client/sw.js +1 -1
  170. package/package.json +11 -8
  171. package/scripts/postinstall.mjs +1 -1
  172. package/dist/pi-extensions/canvas-nav.d.ts +0 -43
  173. package/dist/pi-extensions/canvas-nav.js +0 -640
  174. package/dist/pi-extensions/widget-order-bus.d.ts +0 -6
  175. package/dist/pi-extensions/widget-order-bus.js +0 -34
  176. package/dist/web-client/assets/index-BnmSLNLa.css +0 -2
@@ -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,12 @@
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
+ // Brief input = ALL user + assistant LITERAL text, most-recent within a char
17
18
  // budget. Tool calls, tool results, thinking blocks, and extension-injected
18
19
  // custom messages are excluded (only `message` entries with role user/assistant,
19
20
  // and only their `text` content blocks).
20
21
  //
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).
22
+ // Placement: key `crtr-recap`, placement `aboveEditor`.
25
23
  //
26
24
  // Surface gating: shows in BOTH the in-pane pi TUI (mode 'tui') and the
27
25
  // headless broker → attach/web viewers (mode 'print', setWidget broadcasts to
@@ -35,21 +33,19 @@
35
33
  // Plain TS-with-types — no imports from @earendil-works/* so this compiles
36
34
  // inside crouter's own tsc build without a dep on the pi packages.
37
35
  import { generateRecap } from '../core/runtime/recap.js';
38
- import { requestNavRerender } from './widget-order-bus.js';
39
36
  // ---------------------------------------------------------------------------
40
37
  // Tuning constants
41
38
  // ---------------------------------------------------------------------------
42
- /** No-new-message window before a recap is shown. 300s by default; override with
39
+ /** No-new-message window before a recap is shown. 60s by default; override with
43
40
  * CRTR_RECAP_IDLE_MS (for fast runtime verification). */
44
41
  function idleMs() {
45
42
  const raw = process.env['CRTR_RECAP_IDLE_MS'];
46
43
  const n = raw !== undefined ? parseInt(raw, 10) : NaN;
47
- return Number.isFinite(n) && n > 0 ? n : 300_000;
44
+ return Number.isFinite(n) && n > 0 ? n : 60_000;
48
45
  }
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. */
46
+ /** How often the single low-rate timer checks elapsed-since-last-message. */
51
47
  const POLL_MS = 7_000;
52
- /** Char budget on the conversation fed to Haikuthe model only needs the gist,
48
+ /** Char budget on the conversation fed to the brief model it only needs the gist,
53
49
  * so we keep the MOST-RECENT text within this cap (a recap is about where we
54
50
  * left off, not the whole history). */
55
51
  const CONVO_CHAR_BUDGET = 12_000;
@@ -124,12 +120,22 @@ export function registerCanvasRecap(pi) {
124
120
  // Captured on session_start; used by the timer and the message handlers.
125
121
  let ui;
126
122
  let sessionManager;
123
+ /** Exact provider/model that most recently ran this conversation successfully.
124
+ * The recap subprocess prefers it over a hard-pinned provider that may be in
125
+ * cooldown (the failure mode that made the old recap silently disappear). */
126
+ let liveModel;
127
+ const captureLiveModel = (ctx) => {
128
+ const provider = ctx.model?.provider?.trim();
129
+ const id = ctx.model?.id?.trim();
130
+ if (provider && id)
131
+ liveModel = `${provider}/${id}`;
132
+ };
127
133
  /** Wall-clock of the most recent user/assistant message. The idle clock. */
128
134
  let lastMessageAt = Date.now();
129
135
  /** True while a recap is currently shown (so we generate at most once per
130
136
  * idle window, and know whether a clear is needed on the next message). */
131
137
  let recapShown = false;
132
- /** True while a Haiku generation is in flight (so we don't fire a second). */
138
+ /** True while a briefing generation is in flight (so we don't fire a second). */
133
139
  let generating = false;
134
140
  const canPaint = () => ui !== undefined && typeof ui.setWidget === 'function';
135
141
  const clearRecap = () => {
@@ -140,21 +146,14 @@ export function registerCanvasRecap(pi) {
140
146
  }
141
147
  catch { /* best-effort */ }
142
148
  };
143
- /** Render the three fragments as the topmost above-editor card. */
144
- const showRecap = (fragments) => {
145
- if (!canPaint())
146
- return;
147
- const labels = ['◷ goal', '· now ', '→ next'];
148
- const lines = fragments.slice(0, 3).map((f, i) => `${labels[i] ?? ' '} ${f}`);
149
- if (lines.length === 0)
149
+ /** Render the briefing into the viewer's low-contrast above-editor slot. */
150
+ const showRecap = (briefing) => {
151
+ if (!canPaint() || briefing === '')
150
152
  return;
151
153
  try {
152
- ui.setWidget(WIDGET_KEY, lines, { placement: 'aboveEditor' });
154
+ ui.setWidget(WIDGET_KEY, [`Brief: ${briefing}`], { placement: 'aboveEditor' });
153
155
  }
154
156
  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
157
  };
159
158
  /** A user or assistant message landed: reset the idle clock and clear any
160
159
  * shown recap (the conversation has moved on). */
@@ -163,7 +162,6 @@ export function registerCanvasRecap(pi) {
163
162
  if (recapShown) {
164
163
  recapShown = false;
165
164
  clearRecap();
166
- requestNavRerender();
167
165
  }
168
166
  };
169
167
  // -------------------------------------------------------------------------
@@ -172,6 +170,7 @@ export function registerCanvasRecap(pi) {
172
170
  pi.on('session_start', (_event, ctx) => {
173
171
  ui = ctx.ui;
174
172
  sessionManager = ctx.sessionManager;
173
+ captureLiveModel(ctx);
175
174
  // Fresh session / hot-swap: start the idle clock now and drop any stale
176
175
  // recap that bled through a /reload.
177
176
  lastMessageAt = Date.now();
@@ -183,12 +182,14 @@ export function registerCanvasRecap(pi) {
183
182
  // Every user message (any source) is activity — reset + clear.
184
183
  onMessage();
185
184
  });
186
- pi.on('turn_end', (_event, _ctx) => {
187
- // A completed assistant turn is activity — reset + clear.
185
+ pi.on('turn_end', (_event, ctx) => {
186
+ // A completed assistant turn is activity — reset + clear, and remember the
187
+ // exact model/provider that successfully produced it for the later recap.
188
+ captureLiveModel(ctx);
188
189
  onMessage();
189
190
  });
190
191
  // -------------------------------------------------------------------------
191
- // The single low-rate idle poll — no busy-loop, mirrors canvas-nav's timer.
192
+ // The single low-rate idle poll.
192
193
  // -------------------------------------------------------------------------
193
194
  if (liveTimer !== undefined)
194
195
  clearInterval(liveTimer);
@@ -203,19 +204,19 @@ export function registerCanvasRecap(pi) {
203
204
  const convo = buildConversation(sessionManager);
204
205
  if (convo === '')
205
206
  return; // nothing to summarize yet
206
- // Snapshot the clock: if a message arrives while Haiku runs, discard the
207
+ // Snapshot the clock: if a message arrives while generation runs, discard the
207
208
  // (now-stale) result instead of painting over the live conversation.
208
209
  const startedAt = lastMessageAt;
209
210
  generating = true;
210
- generateRecap(convo, (fragments) => {
211
+ generateRecap(convo, (briefing) => {
211
212
  generating = false;
212
213
  if (lastMessageAt !== startedAt)
213
214
  return; // a message landed mid-flight
214
215
  if (!canPaint())
215
216
  return;
216
217
  recapShown = true;
217
- showRecap(fragments);
218
- });
218
+ showRecap(briefing);
219
+ }, liveModel);
219
220
  // generateRecap is silent on failure (never calls back), so clear the
220
221
  // in-flight flag after the timeout window even if no callback fires.
221
222
  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
@@ -481,12 +481,35 @@ export function registerCanvasStophook(pi) {
481
481
  flushTelemetry(jobDirPath, totalIn, totalOut, model, contextTokens, lastActivity);
482
482
  touchBusy(nodeId); // wedge heartbeat: a turn ending is a sign of life
483
483
  clearFault(nodeId, { link: 'daemon→node' }); // reaching turn_end proves this turn wasn't wedged
484
+ clearFault(nodeId, { link: 'pi→provider' }); // a completed provider round-trip proves this episode is over
485
+ // Will the agent run CONTINUE past this turn? Deterministic at turn_end:
486
+ // the loop keeps going exactly when this assistant message carried tool
487
+ // calls and the run wasn't aborted/errored (those end unconditionally,
488
+ // before tools execute). A tool-calling turn ends with stopReason
489
+ // 'toolUse' — or 'length' when truncation failed the calls, which still
490
+ // continues the loop — NEVER 'stop', so gate on the abort/error reasons,
491
+ // not on an allowlist that excludes 'toolUse'.
492
+ // This handler runs BEFORE the loop drains the steering queue, so the read
493
+ // is race-free: zero tool calls ⇒ this is the run's FINAL message, and a
494
+ // steer sent now would not redirect at a boundary — it would EXTEND the run
495
+ // past the agent's closing reply, burying the summary the human comes back
496
+ // to read once the conversation goes quiet.
497
+ const turnContent = Array.isArray(event?.message?.content) ? event.message.content : [];
498
+ const turnStopReason = event?.message?.stopReason ?? '';
499
+ const runContinues = turnStopReason !== 'error' &&
500
+ turnStopReason !== 'aborted' &&
501
+ turnContent.some((c) => c?.type === 'toolCall');
484
502
  // Context-size steering: fire the current band once, with lifecycle-specific
485
503
  // guidance (lifecycle is read live — a terminal worker may have promoted to
486
504
  // resident since launch).
487
- // Delivered as a STEER, not a followUp: guidance to become an orchestrator /
488
- // delegate / yield must redirect the node at the turn boundary, not queue
489
- // behind whatever it does next (where it rides along, easy to ignore).
505
+ // Delivery splits on runContinues:
506
+ // mid-run STEER, not a followUp: guidance to become an orchestrator /
507
+ // delegate / yield must redirect the node at the turn boundary, not queue
508
+ // behind whatever it does next (where it rides along, easy to ignore).
509
+ // • final message → HOLD via pi's 'nextTurn' queue: it injects alongside
510
+ // the next user message without triggering a turn, so the nudge is read
511
+ // at the start of the next engagement instead of spawning a phantom
512
+ // continuation after the agent already said its piece.
490
513
  // Never the cumulative totalIn: under prompt caching that never grows (input
491
514
  // is a ~2-token uncached delta each turn), so the bands were unreachable and
492
515
  // the nudge never fired.
@@ -497,7 +520,13 @@ export function registerCanvasStophook(pi) {
497
520
  const at = contextTokens !== null ? steerBand(contextTokens) : null;
498
521
  if (at !== null && !firedBands.has(at)) {
499
522
  firedBands.add(at);
500
- pi.sendUserMessage(`[crtr] ${steerNote(at, lifecycle, mode)}`, { deliverAs: 'steer' });
523
+ const note = `[crtr] ${steerNote(at, lifecycle, mode)}`;
524
+ if (runContinues) {
525
+ pi.sendUserMessage(note, { deliverAs: 'steer' });
526
+ }
527
+ else {
528
+ pi.sendMessage({ customType: 'crtr-context-nudge', content: note, display: true }, { deliverAs: 'nextTurn' });
529
+ }
501
530
  }
502
531
  }
503
532
  catch {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,15 @@
1
+ const originalEmit = process.emit;
2
+ process.emit = function (name, ...args) {
3
+ const [data] = args;
4
+ if (name === 'warning' &&
5
+ data instanceof Error &&
6
+ data.name === 'ExperimentalWarning' &&
7
+ data.message.includes('SQLite')) {
8
+ return false;
9
+ }
10
+ return originalEmit.apply(this, [
11
+ name,
12
+ ...args,
13
+ ]);
14
+ };
15
+ export {};