@north-light/crouter 0.3.263 → 0.3.270

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 (171) hide show
  1. package/dist/api/client.d.ts +5 -1
  2. package/dist/api/client.js +6 -0
  3. package/dist/api/dto/broker.d.ts +11 -2
  4. package/dist/api/dto/broker.js +2 -2
  5. package/dist/api/dto/canvas.d.ts +53 -2
  6. package/dist/api/dto/nodes.d.ts +10 -2
  7. package/dist/api/routes.d.ts +1 -0
  8. package/dist/api/routes.js +1 -0
  9. package/dist/builtin-memory/05-kinds/advisor/advice-contract.md +2 -2
  10. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/integration/provider-rotation.test.ts +50 -0
  11. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/memory-slash-commands.ts +46 -6
  12. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +6 -2
  13. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +7 -2
  14. package/dist/clients/attach/session/whip-achievements.d.ts +4 -0
  15. package/dist/clients/attach/session/whip-achievements.js +145 -0
  16. package/dist/clients/attach/slash/dispatch.js +5 -3
  17. package/dist/clients/attach/viewer.js +607 -606
  18. package/dist/commands/canvas-history/grep.js +3 -22
  19. package/dist/commands/canvas-history/read.js +5 -3
  20. package/dist/commands/canvas-history/search.js +3 -22
  21. package/dist/commands/canvas-history/shared.d.ts +8 -0
  22. package/dist/commands/canvas-history/shared.js +64 -1
  23. package/dist/commands/canvas-history/stats.d.ts +1 -0
  24. package/dist/commands/canvas-history/stats.js +68 -0
  25. package/dist/commands/canvas-history.js +4 -3
  26. package/dist/commands/node/create.js +2 -2
  27. package/dist/commands/pkg/plugin-inspect.js +2 -2
  28. package/dist/commands/pkg/plugin-manage.js +3 -3
  29. package/dist/commands/sys/config.js +19 -2
  30. package/dist/commands/sys/doctor.js +4 -3
  31. package/dist/commands/sys/panels/models-panel.js +3 -1
  32. package/dist/core/__tests__/human-deliver.test.js +1 -1
  33. package/dist/core/__tests__/integration/lifecycle-hooks.test.d.ts +1 -0
  34. package/dist/core/__tests__/integration/lifecycle-hooks.test.js +182 -0
  35. package/dist/core/__tests__/integration/revive.test.js +19 -17
  36. package/dist/core/__tests__/model-pin-durability.test.js +25 -1
  37. package/dist/core/__tests__/push-final-guard.test.js +1 -1
  38. package/dist/core/__tests__/relaunch-root.test.js +17 -0
  39. package/dist/core/__tests__/revive-capacity.test.js +20 -20
  40. package/dist/core/__tests__/revive-parked-fresh.test.js +14 -14
  41. package/dist/core/__tests__/seam/broker-cap-freeze.test.js +2 -2
  42. package/dist/core/__tests__/seam/broker-provider-retry.test.js +19 -4
  43. package/dist/core/__tests__/seam/dormancy-release.test.js +7 -7
  44. package/dist/core/__tests__/seam/held-deferred-human-prompt.test.js +1 -1
  45. package/dist/core/__tests__/seam/memory-slash-node-relative-inventory.test.js +19 -2
  46. package/dist/core/__tests__/seam/yield-refresh-transaction.test.js +2 -2
  47. package/dist/core/broker-client/__tests__/transport-relay.test.js +3 -1
  48. package/dist/core/broker-client/transport-relay.js +5 -1
  49. package/dist/core/canvas/__tests__/history-transcript.test.d.ts +1 -0
  50. package/dist/core/canvas/__tests__/history-transcript.test.js +102 -0
  51. package/dist/core/canvas/browse/app.d.ts +1 -1
  52. package/dist/core/canvas/browse/app.js +13 -10
  53. package/dist/core/canvas/browse/model.js +16 -1
  54. package/dist/core/canvas/browse/render.d.ts +23 -3
  55. package/dist/core/canvas/browse/render.js +429 -64
  56. package/dist/core/canvas/canvas.js +12 -8
  57. package/dist/core/canvas/extensions.d.ts +1 -1
  58. package/dist/core/canvas/extensions.js +7 -1
  59. package/dist/core/canvas/history.d.ts +36 -1
  60. package/dist/core/canvas/history.js +328 -7
  61. package/dist/core/canvas/labels.d.ts +2 -2
  62. package/dist/core/canvas/migrations.js +32 -0
  63. package/dist/core/canvas/node-recap.d.ts +23 -0
  64. package/dist/core/canvas/node-recap.js +67 -0
  65. package/dist/core/canvas/render-source.d.ts +33 -1
  66. package/dist/core/canvas/render-source.js +80 -22
  67. package/dist/core/canvas/types.d.ts +9 -0
  68. package/dist/core/command-hooks/discovery.d.ts +27 -1
  69. package/dist/core/command-hooks/discovery.js +52 -0
  70. package/dist/core/command-hooks/index.d.ts +4 -3
  71. package/dist/core/command-hooks/index.js +2 -1
  72. package/dist/core/command-hooks/lifecycle-catalog.d.ts +3 -0
  73. package/dist/core/command-hooks/lifecycle-catalog.js +4 -0
  74. package/dist/core/command-hooks/report.d.ts +11 -2
  75. package/dist/core/command-hooks/report.js +10 -1
  76. package/dist/core/command-hooks/schema.d.ts +16 -1
  77. package/dist/core/command-hooks/schema.js +128 -40
  78. package/dist/core/command-hooks/transport/exec-lifecycle.d.ts +12 -0
  79. package/dist/core/command-hooks/transport/exec-lifecycle.js +152 -0
  80. package/dist/core/config.js +13 -5
  81. package/dist/core/human/feedback-companion.js +1 -1
  82. package/dist/core/keybindings/catalog.d.ts +2 -2
  83. package/dist/core/keybindings/catalog.js +2 -1
  84. package/dist/core/model-routes.js +1 -1
  85. package/dist/core/review/realize.js +1 -1
  86. package/dist/core/runtime/broker/auth-reload.js +4 -1
  87. package/dist/core/runtime/broker/engine-drive.d.ts +14 -3
  88. package/dist/core/runtime/broker/engine-drive.js +29 -1
  89. package/dist/core/runtime/broker/fault-retry.js +7 -2
  90. package/dist/core/runtime/broker/frame-client.js +3 -0
  91. package/dist/core/runtime/broker/frame-dispatch.js +7 -0
  92. package/dist/core/runtime/broker/inbox.d.ts +12 -2
  93. package/dist/core/runtime/broker/inbox.js +68 -38
  94. package/dist/core/runtime/broker/rebind.js +7 -0
  95. package/dist/core/runtime/broker-protocol.d.ts +16 -1
  96. package/dist/core/runtime/broker.d.ts +1 -1
  97. package/dist/core/runtime/broker.js +27 -3
  98. package/dist/core/runtime/canvas-extensions.d.ts +1 -0
  99. package/dist/core/runtime/canvas-extensions.js +2 -0
  100. package/dist/core/runtime/interactive-deliver.d.ts +8 -2
  101. package/dist/core/runtime/interactive-deliver.js +12 -3
  102. package/dist/core/runtime/launch-target.js +1 -1
  103. package/dist/core/runtime/launch.d.ts +12 -6
  104. package/dist/core/runtime/launch.js +19 -17
  105. package/dist/core/runtime/model-swap.d.ts +5 -4
  106. package/dist/core/runtime/model-swap.js +7 -5
  107. package/dist/core/runtime/node-read.d.ts +2 -0
  108. package/dist/core/runtime/node-read.js +18 -9
  109. package/dist/core/runtime/promote.js +2 -1
  110. package/dist/core/runtime/reset.js +4 -2
  111. package/dist/core/runtime/revive-all.d.ts +1 -1
  112. package/dist/core/runtime/revive-all.js +2 -2
  113. package/dist/core/runtime/revive.d.ts +2 -2
  114. package/dist/core/runtime/revive.js +71 -13
  115. package/dist/core/runtime/session-visibility.d.ts +10 -14
  116. package/dist/core/runtime/session-visibility.js +43 -30
  117. package/dist/core/runtime/spawn.d.ts +3 -3
  118. package/dist/core/runtime/spawn.js +20 -3
  119. package/dist/core/runtime/stamp/channel.d.ts +22 -0
  120. package/dist/core/runtime/stamp/channel.js +83 -0
  121. package/dist/core/runtime/stamp/protocol.d.ts +90 -0
  122. package/dist/core/runtime/stamp/protocol.js +113 -0
  123. package/dist/core/runtime/tmux-driver.d.ts +11 -1
  124. package/dist/core/runtime/tmux-driver.js +27 -11
  125. package/dist/core/runtime/turn-visibility.d.ts +10 -0
  126. package/dist/core/runtime/turn-visibility.js +31 -0
  127. package/dist/core/self-update.js +2 -5
  128. package/dist/core/tui/draw.d.ts +1 -0
  129. package/dist/core/tui/draw.js +5 -1
  130. package/dist/core/tui/markdown.d.ts +17 -0
  131. package/dist/core/tui/markdown.js +407 -0
  132. package/dist/core/user-settings.d.ts +4 -0
  133. package/dist/core/user-settings.js +1 -0
  134. package/dist/daemon/api/__tests__/node-model-validation.test.js +21 -0
  135. package/dist/daemon/api/__tests__/profile-launch-gates.test.js +2 -2
  136. package/dist/daemon/api/bridge.d.ts +3 -2
  137. package/dist/daemon/api/bridge.js +34 -9
  138. package/dist/daemon/api/handlers/attach.js +4 -4
  139. package/dist/daemon/api/handlers/bash-jobs.js +1 -1
  140. package/dist/daemon/api/handlers/canvas.js +62 -16
  141. package/dist/daemon/api/handlers/messages.js +14 -3
  142. package/dist/daemon/api/handlers/nodes.js +17 -7
  143. package/dist/daemon/api/map.js +2 -0
  144. package/dist/daemon/cron/sinks.js +3 -3
  145. package/dist/daemon/fleet.js +1 -1
  146. package/dist/daemon/messaging/node-message.js +16 -2
  147. package/dist/daemon/profile-delete.js +1 -1
  148. package/dist/daemon/reconcilers/broker-supervision.js +26 -9
  149. package/dist/daemon/reconcilers/live-obligation.d.ts +8 -1
  150. package/dist/daemon/reconcilers/live-obligation.js +17 -0
  151. package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +6 -6
  152. package/dist/hook-authoring.d.ts +31 -1
  153. package/dist/hook-authoring.js +42 -5
  154. package/dist/index.d.ts +1 -0
  155. package/dist/index.js +3 -0
  156. package/dist/pi-extensions/canvas-inbox-watcher.js +27 -2
  157. package/dist/pi-extensions/canvas-recap.js +4 -0
  158. package/dist/pi-extensions/canvas-stamp.d.ts +14 -0
  159. package/dist/pi-extensions/canvas-stamp.js +77 -0
  160. package/dist/pi-extensions/canvas-stophook.js +3 -0
  161. package/dist/shared/__tests__/env-boundary.test.js +4 -2
  162. package/dist/shared/crtr-version.d.ts +2 -0
  163. package/dist/shared/crtr-version.js +18 -0
  164. package/dist/shared/env.d.ts +10 -0
  165. package/dist/shared/env.js +19 -4
  166. package/dist/shared/generated-context.d.ts +1 -2
  167. package/dist/shared/generated-context.js +3 -4
  168. package/dist/types.d.ts +10 -0
  169. package/dist/types.js +4 -0
  170. package/package.json +1 -1
  171. package/runtime.lock.json +2 -2
@@ -12,6 +12,7 @@ import { markFatalOnExhaust, nextFaultRetry, policyFor } from '../fault-recovery
12
12
  import { parseModelSpec } from '../model-selection.js';
13
13
  import { endedByAbort } from '../session-cycles.js';
14
14
  import { resolveProviderCandidates } from '../../subscription-state.js';
15
+ import { announceMessageOrigin } from '../stamp/channel.js';
15
16
  const MAX_TIMEOUT_MS = 2_147_483_647;
16
17
  /** Owns provider fallback and daemon-driven fault retry state for one broker session. */
17
18
  export class FaultRetry {
@@ -266,7 +267,9 @@ export class FaultRetry {
266
267
  return;
267
268
  if (unconfigured)
268
269
  clearFault(this.deps.nodeId, { link: 'pi→provider' });
269
- await this.deps.turnAdmission.admit((release) => operationIdContext.fresh(() => candidateSession.prompt(formatCard('recovery', {}, formatModelFallbackRecovery(currentSpec, resolved, unconfigured ? 'credential' : 'not-found')), { preflightResult: release })));
270
+ const fallbackCard = formatCard('recovery', {}, formatModelFallbackRecovery(currentSpec, resolved, unconfigured ? 'credential' : 'not-found'));
271
+ announceMessageOrigin(fallbackCard, { type: 'runtime', kind: 'recovery' });
272
+ await this.deps.turnAdmission.admit((release) => operationIdContext.fresh(() => candidateSession.prompt(fallbackCard, { preflightResult: release })));
270
273
  })().catch((err) => {
271
274
  emitEvent({ level: 'debug', event: 'broker.runtime.diagnostic', fields: { message: `${unconfigured ? 'unconfigured-provider' : 'not_found'} fallback failed: ${err instanceof Error ? err.message : String(err)}` } });
272
275
  }).finally(() => {
@@ -405,7 +408,9 @@ export class FaultRetry {
405
408
  return Promise.resolve();
406
409
  }
407
410
  recordFault(this.deps.nodeId, plan.fault);
408
- return operationIdContext.fresh(() => candidateSession.prompt(formatCard('recovery', {}, fault.kind === 'connection' ? CONNECTION_FAULT_RECOVERY_BODY : PROVIDER_FAULT_RECOVERY_BODY), { preflightResult: release }));
411
+ const retryCard = formatCard('recovery', {}, fault.kind === 'connection' ? CONNECTION_FAULT_RECOVERY_BODY : PROVIDER_FAULT_RECOVERY_BODY);
412
+ announceMessageOrigin(retryCard, { type: 'runtime', kind: 'recovery' });
413
+ return operationIdContext.fresh(() => candidateSession.prompt(retryCard, { preflightResult: release }));
409
414
  });
410
415
  }
411
416
  }
@@ -86,6 +86,9 @@ export function createClientHandlers(deps) {
86
86
  type: 'prompt',
87
87
  text: frame.text,
88
88
  ...(frame.images !== undefined ? { images: frame.images } : {}),
89
+ delivery: {
90
+ ...(frame.origin === undefined ? {} : { origin: frame.origin }),
91
+ },
89
92
  });
90
93
  if (via === 'steer' || outcome === undefined) {
91
94
  turnAcceptance.cancel(ackAccepted);
@@ -1,5 +1,6 @@
1
1
  import { createFrameReplies } from './frame-replies.js';
2
2
  import { createEngineDriver } from './engine-drive.js';
3
+ import { announceMessageOrigin } from '../stamp/channel.js';
3
4
  import { createClientHandlers } from './frame-client.js';
4
5
  import { createMemoryRefGuidance } from './frame-memory-refs.js';
5
6
  import { createAuthReloadGate } from './auth-reload.js';
@@ -51,6 +52,12 @@ export function createFrameDispatchContext(deps) {
51
52
  const handleDriveFrame = (client, frame) => {
52
53
  if (notWritable(client, 'drive the engine'))
53
54
  return;
55
+ // These frames are the viewer's input vocabulary — a person typed them.
56
+ // (crtrd's `deliver` never lands here: frame-client synthesizes its own
57
+ // prompt frame and calls driveEngine directly, announcing its own origin.)
58
+ if (frame.type === 'prompt' || frame.type === 'steer' || frame.type === 'follow_up') {
59
+ announceMessageOrigin(frame.text, { type: 'human', channel: { type: 'viewer' } });
60
+ }
54
61
  driveEngine(client, frame);
55
62
  };
56
63
  const clientHandlers = createClientHandlers({
@@ -27,7 +27,9 @@ export declare function readBrokerCanceledEntryIds(nodeId: string): Set<BrokerIn
27
27
  export declare function reportRefNodeId(ref: string | undefined): string | undefined;
28
28
  /** One-shot runtime cards an entry carries (rendered envelopes written by the
29
29
  * message API). They ride the delivery their own entry belongs to, so a note
30
- * can never land ahead of an unrelated person's message. */
30
+ * can never land ahead of an unrelated person's message — except in the one
31
+ * deliberate fold below, where deferred entries join a person's delivery as
32
+ * the context their turn reads. */
31
33
  export declare function entryCards(entry: BrokerInboxEntry): readonly string[];
32
34
  /** One deliverable user message: either a person's verbatim words or the whole
33
35
  * `<runtime kind="inbox">` envelope. Never both — a reader parses an envelope
@@ -40,5 +42,13 @@ export interface InboxDelivery {
40
42
  entries: readonly BrokerInboxEntry[];
41
43
  }
42
44
  /** Split unread entries into ordered deliveries: each human entry verbatim, and
43
- * every node/system entry in one card landing where the first such sender fell. */
45
+ * each CONTIGUOUS run of node/system entries as one card in the physical slot
46
+ * that run occupies. A deferred-only card is folded into the person's delivery
47
+ * waiting immediately behind it rather than being deliverable on its own.
48
+ *
49
+ * The runs are what keep the cursor honest. Exactly one delivery goes out per
50
+ * cycle and the rest requeue, while a sent delivery commits the cursor across
51
+ * its own entries — so a delivery that spanned a person's message would commit
52
+ * past words this cycle never sent. A run can never span one, and node mail
53
+ * that arrived after a person's message now waits behind it. */
44
54
  export declare function coalesceBrokerInbox(entries: readonly BrokerInboxEntry[], reportNodes: ReadonlyMap<string, BrokerReportNodeDTO>): readonly InboxDelivery[];
@@ -163,7 +163,9 @@ function cardEntry(entry, reportNodes) {
163
163
  }
164
164
  /** One-shot runtime cards an entry carries (rendered envelopes written by the
165
165
  * message API). They ride the delivery their own entry belongs to, so a note
166
- * can never land ahead of an unrelated person's message. */
166
+ * can never land ahead of an unrelated person's message — except in the one
167
+ * deliberate fold below, where deferred entries join a person's delivery as
168
+ * the context their turn reads. */
167
169
  export function entryCards(entry) {
168
170
  const cards = entry.data?.['cards'];
169
171
  if (!Array.isArray(cards))
@@ -171,49 +173,77 @@ export function entryCards(entry) {
171
173
  return cards.filter((card) => typeof card === 'string');
172
174
  }
173
175
  /** Split unread entries into ordered deliveries: each human entry verbatim, and
174
- * every node/system entry in one card landing where the first such sender fell. */
176
+ * each CONTIGUOUS run of node/system entries as one card in the physical slot
177
+ * that run occupies. A deferred-only card is folded into the person's delivery
178
+ * waiting immediately behind it rather than being deliverable on its own.
179
+ *
180
+ * The runs are what keep the cursor honest. Exactly one delivery goes out per
181
+ * cycle and the rest requeue, while a sent delivery commits the cursor across
182
+ * its own entries — so a delivery that spanned a person's message would commit
183
+ * past words this cycle never sent. A run can never span one, and node mail
184
+ * that arrived after a person's message now waits behind it. */
175
185
  export function coalesceBrokerInbox(entries, reportNodes) {
176
- if (entries.length === 0)
177
- return [];
178
- const groups = new Map();
179
- for (const entry of entries) {
180
- const key = entry.from ?? 'system';
181
- if (!groups.has(key))
182
- groups.set(key, []);
183
- groups.get(key).push(entry);
184
- }
185
186
  const deliveries = [];
186
- const sections = [];
187
- let cardSlot = -1;
188
- for (const [sender, items] of groups) {
189
- if (sender === 'human') {
190
- for (const entry of items) {
191
- const body = inlineBody(entry);
192
- deliveries.push({
193
- kind: 'human',
194
- text: body === '' ? entry.label : body,
195
- cards: entryCards(entry),
196
- entries: [entry],
197
- });
198
- }
199
- continue;
187
+ let run = [];
188
+ const flushRun = () => {
189
+ if (run.length === 0)
190
+ return;
191
+ // Within one run the card still groups by sender, so a child that sent
192
+ // several entries reads as one section rather than a repeated header.
193
+ const sections = new Map();
194
+ for (const entry of run) {
195
+ const key = entry.from ?? 'system';
196
+ if (!sections.has(key))
197
+ sections.set(key, []);
198
+ sections.get(key).push(entry);
200
199
  }
201
- if (cardSlot < 0) {
202
- cardSlot = deliveries.length;
203
- deliveries.push({ kind: 'card', text: '', cards: [], entries: [] });
200
+ const card = [...sections].map(([id, items]) => ({
201
+ id,
202
+ entries: items.map((entry) => cardEntry(entry, reportNodes)),
203
+ }));
204
+ deliveries.push({
205
+ kind: 'card',
206
+ text: formatInboxCard(card),
207
+ cards: run.flatMap((entry) => entryCards(entry)),
208
+ entries: run,
209
+ });
210
+ run = [];
211
+ };
212
+ for (const entry of entries) {
213
+ if ((entry.from ?? 'system') !== 'human') {
214
+ run.push(entry);
215
+ continue;
204
216
  }
205
- sections.push({ id: sender, entries: items.map((entry) => cardEntry(entry, reportNodes)) });
217
+ flushRun();
218
+ const body = inlineBody(entry);
219
+ deliveries.push({
220
+ kind: 'human',
221
+ text: body === '' ? entry.label : body,
222
+ cards: entryCards(entry),
223
+ entries: [entry],
224
+ });
206
225
  }
207
- if (cardSlot >= 0) {
208
- // Card sections group by sender, so its entry list comes from the input
209
- // array instead: a delivery's entry ids must be in physical inbox order.
210
- const cardEntries = entries.filter((entry) => (entry.from ?? 'system') !== 'human');
211
- deliveries[cardSlot] = {
212
- kind: 'card',
213
- text: formatInboxCard(sections),
214
- cards: cardEntries.flatMap((entry) => entryCards(entry)),
215
- entries: cardEntries,
226
+ flushRun();
227
+ // Deferred mail is background context, never a prompt of its own. With a
228
+ // person's words waiting immediately behind a deferred-only card, the card
229
+ // rides ahead of those words inside that SAME delivery: one turn, prompted by
230
+ // what the person actually said, with the card already in the transcript.
231
+ // Left as its own delivery it takes an earlier turn whose only prompt is
232
+ // invisible to the person reading along, so the agent answers something they
233
+ // cannot see. Folding backwards keeps the remaining indices valid.
234
+ for (let i = deliveries.length - 2; i >= 0; i -= 1) {
235
+ const card = deliveries[i];
236
+ const human = deliveries[i + 1];
237
+ if (card.kind !== 'card' || human.kind !== 'human')
238
+ continue;
239
+ if (!card.entries.every((entry) => entry.tier === 'deferred'))
240
+ continue;
241
+ deliveries[i] = {
242
+ ...human,
243
+ cards: [...card.cards, card.text, ...human.cards],
244
+ entries: [...card.entries, ...human.entries],
216
245
  };
246
+ deliveries.splice(i + 1, 1);
217
247
  }
218
248
  return deliveries;
219
249
  }
@@ -6,6 +6,7 @@ import { operationIdContext } from '../../events/operation-id.js';
6
6
  import { installPersonaTransitionGate } from '../broker-persona-guidance.js';
7
7
  import { abortSessionFromExtension } from './extension-abort.js';
8
8
  import { clearFault, invalidateProviderRetryEpisode, readFault } from '../fault.js';
9
+ import { clearTurnVisibility } from '../turn-visibility.js';
9
10
  const MAX_ADMITTED_REPLACEMENTS = 2;
10
11
  const REPLACEMENT_DEADLINE_MS = 30_000;
11
12
  const REPLACEMENT_DEADLINE_EXCEEDED = Symbol('replacement-deadline-exceeded');
@@ -302,6 +303,12 @@ export class RebindDriver {
302
303
  catch (err) {
303
304
  emitEvent({ level: 'debug', event: 'broker.runtime.diagnostic', fields: { message: `event relay threw: ${String(err)}` } });
304
305
  }
306
+ finally {
307
+ // Projection sees the settlement while the run is still internal; every
308
+ // later input and output fails open to visible after this boundary.
309
+ if (event.type === 'agent_settled')
310
+ clearTurnVisibility();
311
+ }
305
312
  }
306
313
  ackTo(client, op, ok = true, detail, id) {
307
314
  this.deps.registry.sendFrame(client, { type: 'ack', for: op, ok, ...(detail !== undefined ? { detail } : {}), ...(id !== undefined ? { id } : {}) });
@@ -2,6 +2,7 @@ import type { AgentSession, AgentSessionEvent, RpcExtensionUIRequest, RpcExtensi
2
2
  import type { Api, ImageContent, Model } from '@earendil-works/pi-ai';
3
3
  import type { BrokerErrorFrame, BrokerNodeNamedFrame } from '../../api/dto/broker.js';
4
4
  import type { ToolGroupSummary } from '../../shared/tool-groups.js';
5
+ import type { MessageOrigin, MessageVisibility } from './stamp/protocol.js';
5
6
  /** pi's own RPC types, re-exported unchanged. `broker-protocol` stays the one
6
7
  * import site for the wire union; the shapes themselves are pi's, versioned by
7
8
  * pi, so a pi change is a compile error here instead of silent drift. */
@@ -21,6 +22,10 @@ export interface BrokerSnapshot {
21
22
  /** Stable session-entry ids aligned 1:1 with `messages`. Absent when the
22
23
  * session manager cannot expose its entry tree. */
23
24
  messageIds?: string[];
25
+ /** Presentation visibility aligned 1:1 with `messages`. */
26
+ messageVisibility: MessageVisibility[];
27
+ /** Presentation visibility of the run currently owned by the engine. */
28
+ turnVisibility: MessageVisibility;
24
29
  stats: SessionStats;
25
30
  /** pi's OWN `get_state` payload, verbatim — not a mirror. `buildSnapshot`
26
31
  * fills it exactly as pi's `rpc-mode.js` does, from the same getters, so a
@@ -143,6 +148,11 @@ export interface DeliverFrame {
143
148
  * between a card and the body it belongs to. Each is its own custom
144
149
  * message and none triggers a turn; the body is the sole trigger. */
145
150
  cards?: string[];
151
+ /** Who authored `text`, for the durable stamp the broker writes. The daemon
152
+ * holds this provenance (an inbox sender, a park request, a person on a
153
+ * dormant viewer) and the broker is the only process that can announce it,
154
+ * so it rides the same frame as the body. */
155
+ origin?: MessageOrigin;
146
156
  }
147
157
  /** Run a `!` bash command — writable clients only. Maps to `session.executeBash()`,
148
158
  * which runs the command, records a `bashExecution` message in context, and
@@ -392,6 +402,11 @@ export interface WelcomeFrame {
392
402
  * the other four learn nothing); the PAYLOAD is pi's — `Model`, exactly what
393
403
  * pi's own `set_model` response carries and what `snapshot.state.model` holds.
394
404
  * Undefined when the engine has none. */
405
+ export interface TurnVisibilityFrame {
406
+ type: 'turn_visibility';
407
+ /** Presentation visibility of the run whose native events follow. */
408
+ visibility: MessageVisibility;
409
+ }
395
410
  export interface WorkingActivityFrame {
396
411
  type: 'working_activity';
397
412
  /** Full display label selected from the user's configured gerunds. */
@@ -637,7 +652,7 @@ export interface ToolGroupSummaryFrame {
637
652
  /** Everything the broker can send. Live `AgentSessionEvent`s are relayed
638
653
  * verbatim (the broker adds nothing); the broker's own control frames carry
639
654
  * non-colliding `type` discriminants. */
640
- export type BrokerToClient = WelcomeFrame | WorkingActivityFrame | ModelChangedFrame | ResourcesReloadedFrame | ErrorFrame | NodeNamedFrame | AckFrame | BrokerDataFrame | BashStartFrame | BashOutputFrame | BashEndFrame | ExtensionUIRequestFrame | ExtensionUIDismissFrame | ToolGroupSummaryFrame | AgentSessionEvent;
655
+ export type BrokerToClient = WelcomeFrame | TurnVisibilityFrame | WorkingActivityFrame | ModelChangedFrame | ResourcesReloadedFrame | ErrorFrame | NodeNamedFrame | AckFrame | BrokerDataFrame | BashStartFrame | BashOutputFrame | BashEndFrame | ExtensionUIRequestFrame | ExtensionUIDismissFrame | ToolGroupSummaryFrame | AgentSessionEvent;
641
656
  /** Encode one frame as a single newline-terminated JSON line. */
642
657
  export declare function encodeFrame(frame: ClientToBroker | BrokerToClient): string;
643
658
  /** Byte bounds for a {@link FrameDecoder} (C5). */
@@ -29,7 +29,7 @@ interface PendingDialog {
29
29
  * bash children. No-op before the session is built or after a clean dispose. */
30
30
  export declare function disposeActiveSession(): void;
31
31
  export declare function runBroker(nodeId: string, startupAt?: bigint, startupTs?: string): Promise<void>;
32
- export declare function snapshotMessages(session: CreateAgentSessionResult['session'], boundaryReviewId?: string): import("./session-cycles.js").IdentifiedMessage[] | undefined;
32
+ export declare function snapshotMessages(session: CreateAgentSessionResult['session'], boundaryReviewId?: string): import("./session-visibility.js").ProjectedMessage[] | undefined;
33
33
  export declare function buildBrokerSession(engine: BrokerEngine, cfg: BrokerSdkConfig): Promise<{
34
34
  session: CreateAgentSessionResult['session'];
35
35
  services: AgentSessionServices;
@@ -40,7 +40,8 @@ import { modelRequestFromConfig, selectInitialModelRoute, unregisteredConcreteMo
40
40
  import { KNOWN_STREAM_WAIT_UI } from './stream-watchdog.js';
41
41
  import { probeOnline as probeOnlineReal } from './connectivity.js';
42
42
  import { CRTR_CYCLE_CUSTOM_TYPE, cycleAwareMessages } from './session-cycles.js';
43
- import { visibleMessages } from './session-visibility.js';
43
+ import { projectTranscript } from './session-visibility.js';
44
+ import { currentTurnVisibility } from './turn-visibility.js';
44
45
  import { isUnknownModel } from './model-selection.js';
45
46
  import { BrokerClientRegistry } from './broker/client-registry.js';
46
47
  import { ToolGroupTracker } from './broker/tool-groups.js';
@@ -52,6 +53,8 @@ import { EventProjection } from './broker/event-projection.js';
52
53
  import { createFrameDispatchContext, handleFrame, } from './broker/frame-dispatch.js';
53
54
  import { hydratePersistedAdvertisedCommandMessages, installAdvertisedCommandInvocationContract, } from './advertised-command-invocation.js';
54
55
  import { RebindDriver } from './broker/rebind.js';
56
+ import { decodeKickoffOrigin, KICKOFF_ORIGIN_ENV } from './stamp/protocol.js';
57
+ import { announceMessageOrigin } from './stamp/channel.js';
55
58
  import { brokerExtensionState, commitBrokerModel } from './broker/daemon-ops.js';
56
59
  import { FrameOverflowError, normalizeSnapshotTail, } from './broker-protocol.js';
57
60
  /** A review companion may only navigate or fork within a branch whose ancestry
@@ -201,6 +204,10 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
201
204
  const launchFile = join(dir, 'broker-launch.json');
202
205
  const inv = JSON.parse(readFileSync(launchFile, 'utf8'));
203
206
  const cfg = piInvocationToSdkConfig(inv); // also merges inv.env
207
+ if (cfg.modelExact === true)
208
+ process.env['CRTR_MODEL_EXACT'] = '1';
209
+ else
210
+ delete process.env['CRTR_MODEL_EXACT'];
204
211
  // 2–4. Build the engine session via the SERVICES path (C3) — see
205
212
  // buildBrokerSession below. Register it so the FATAL exit path (M3) can
206
213
  // dispose it and reap detached bash children.
@@ -314,7 +321,9 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
314
321
  const result = await commitBrokerModel(nodeId, { spec, pinnedOverride, userSelected });
315
322
  if (userSelected) {
316
323
  delete process.env['CRTR_MODEL_INTENT'];
324
+ process.env['CRTR_MODEL_EXACT'] = '1';
317
325
  cfg.model = result.modelOverride;
326
+ cfg.modelExact = true;
318
327
  }
319
328
  return result.modelOverride;
320
329
  };
@@ -389,6 +398,9 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
389
398
  return ({
390
399
  messages,
391
400
  ...(retained === undefined ? {} : { messageIds: retained.map(({ id }) => id) }),
401
+ messageVisibility: retained?.map(({ visibility }) => visibility)
402
+ ?? messages.map(() => 'visible'),
403
+ turnVisibility: currentTurnVisibility(),
392
404
  stats: liveSession.getSessionStats(),
393
405
  // pi's `get_state` (rpc-mode.js), field for field, from the same getters.
394
406
  // The ONE divergence: a model-less session (undefined OR the SDK
@@ -637,6 +649,12 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
637
649
  // reload_auth re-drive resolves a model, at which point it fires. For the
638
650
  // common model-present path this fires immediately — unchanged behavior.
639
651
  if (cfg.firstPrompt !== undefined && cfg.firstPrompt !== '') {
652
+ // The spawner recorded who authored this kickoff (a birth mandate, a
653
+ // cron wake, a revive card); announce it against the exact text so the
654
+ // stamp writer claims it on the message pi persists.
655
+ const kickoffOrigin = decodeKickoffOrigin(process.env[KICKOFF_ORIGIN_ENV]);
656
+ if (kickoffOrigin !== null)
657
+ announceMessageOrigin(cfg.firstPrompt, kickoffOrigin);
640
658
  frameDispatch.kickoff(cfg.firstPrompt);
641
659
  }
642
660
  });
@@ -740,11 +758,17 @@ export function snapshotMessages(session, boundaryReviewId) {
740
758
  // payload), narrower than `CycleSessionManagerLike`'s structural read of the
741
759
  // `crtr-cycle` marker's `{cycle, fromLeaf}` shape — the fields we read off it
742
760
  // are ones ONLY this module's own `appendCustomEntry` call (above) ever writes.
743
- const messages = cycleAwareMessages(session.sessionManager);
761
+ const manager = session.sessionManager;
762
+ const messages = cycleAwareMessages(manager);
744
763
  if (messages !== undefined) {
745
764
  hydratePersistedAdvertisedCommandMessages(messages.map(({ message }) => message));
746
765
  }
747
- return messages === undefined ? undefined : visibleMessages(messages, { boundaryReviewId });
766
+ return messages === undefined
767
+ ? undefined
768
+ : projectTranscript(messages, {
769
+ boundaryReviewId,
770
+ sessionEntries: manager.getEntries?.(),
771
+ });
748
772
  }
749
773
  // resolveLaunchModel — the SOLE launch model-selection policy, module-scoped
750
774
  // so both buildForManager (initial boot, inside buildBrokerSession below) and
@@ -11,6 +11,7 @@ export declare const CANVAS_STRUCTURED_OUTPUT_PATH: string;
11
11
  export declare const CANVAS_BASH_VALVE_PATH: string;
12
12
  export declare const CANVAS_PREVIEW_RESULT_PATH: string;
13
13
  export declare const CANVAS_PROMPT_SCRUB_PATH: string;
14
+ export declare const CANVAS_STAMP_PATH: string;
14
15
  /** The canvas extensions every node loads, in order. The inbox watcher commits
15
16
  * its cursor at agent_settled before the stophook checks it for idle release.
16
17
  * The review boundary must run before the stophook commits companion session
@@ -23,6 +23,7 @@ export const CANVAS_STRUCTURED_OUTPUT_PATH = resolveExtension('canvas-structured
23
23
  export const CANVAS_BASH_VALVE_PATH = resolveExtension('canvas-bash-valve');
24
24
  export const CANVAS_PREVIEW_RESULT_PATH = resolveExtension('canvas-preview-result');
25
25
  export const CANVAS_PROMPT_SCRUB_PATH = resolveExtension('canvas-prompt-scrub');
26
+ export const CANVAS_STAMP_PATH = resolveExtension('canvas-stamp');
26
27
  /** The canvas extensions every node loads, in order. The inbox watcher commits
27
28
  * its cursor at agent_settled before the stophook checks it for idle release.
28
29
  * The review boundary must run before the stophook commits companion session
@@ -46,6 +47,7 @@ export const CANVAS_EXTENSIONS = [
46
47
  CANVAS_BASH_VALVE_PATH,
47
48
  CANVAS_PREVIEW_RESULT_PATH,
48
49
  CANVAS_PROMPT_SCRUB_PATH,
50
+ CANVAS_STAMP_PATH,
49
51
  ];
50
52
  /** Preserve the invariant: mandatory extensions are always present exactly once.
51
53
  *
@@ -1,3 +1,4 @@
1
+ import type { MessageOrigin } from './stamp/protocol.js';
1
2
  /** Which engine call the broker routed a live deliver to (its ack `detail`):
2
3
  * idle → `prompt`, turn in flight → `steer`. */
3
4
  export type LiveDeliverRoute = 'prompt' | 'steer';
@@ -7,8 +8,13 @@ export type LiveInterruptOutcome = 'turn' | 'bash' | 'idle';
7
8
  /** Deliver `text` to a LIVE node's engine on its serialized frame loop. The
8
9
  * broker routes it itself (idle → prompt, streaming → steer) and acks once
9
10
  * routed; resolves with the route taken. `cards` are rendered runtime-card
10
- * envelopes placed ahead of the body inside that same frame handling. */
11
- export declare function deliverLive(nodeId: string, text: string, cards?: readonly string[]): Promise<LiveDeliverRoute>;
11
+ * envelopes placed ahead of the body inside that same frame handling, and
12
+ * `origin` is the authorship the broker stamps onto the persisted message —
13
+ * it rides the same frame as the body it describes. */
14
+ export declare function deliverLive(nodeId: string, text: string, opts?: {
15
+ cards?: readonly string[];
16
+ origin?: MessageOrigin;
17
+ }): Promise<LiveDeliverRoute>;
12
18
  /** Abort whatever a LIVE node's engine is doing (turn or `!` bash). Resolves
13
19
  * once the broker has PROCESSED the abort — which, on the single frame loop,
14
20
  * also implies every earlier-accepted deliver was already routed (so a
@@ -15,14 +15,23 @@ import { oneShotControllerRequest } from './broker-request.js';
15
15
  /** Deliver `text` to a LIVE node's engine on its serialized frame loop. The
16
16
  * broker routes it itself (idle → prompt, streaming → steer) and acks once
17
17
  * routed; resolves with the route taken. `cards` are rendered runtime-card
18
- * envelopes placed ahead of the body inside that same frame handling. */
19
- export function deliverLive(nodeId, text, cards = []) {
18
+ * envelopes placed ahead of the body inside that same frame handling, and
19
+ * `origin` is the authorship the broker stamps onto the persisted message —
20
+ * it rides the same frame as the body it describes. */
21
+ export function deliverLive(nodeId, text, opts = {}) {
22
+ const cards = opts.cards ?? [];
20
23
  return oneShotControllerRequest({
21
24
  nodeId,
22
25
  what: 'the interactive delivery',
23
26
  ackFor: 'deliver',
24
27
  correlated: true,
25
- frame: (id) => ({ type: 'deliver', id, text, ...(cards.length > 0 ? { cards: [...cards] } : {}) }),
28
+ frame: (id) => ({
29
+ type: 'deliver',
30
+ id,
31
+ text,
32
+ ...(cards.length > 0 ? { cards: [...cards] } : {}),
33
+ ...(opts.origin !== undefined ? { origin: opts.origin } : {}),
34
+ }),
26
35
  result: (detail) => (detail === 'steer' ? 'steer' : 'prompt'),
27
36
  });
28
37
  }
@@ -34,7 +34,7 @@ export async function resolveLaunchTarget(req, options = {}) {
34
34
  const launch = (await buildLaunchSpecAsync(kind, mode, {
35
35
  lifecycle: req.root_lifecycle ?? (req.root === true ? 'resident' : 'terminal'),
36
36
  hasManager: req.root !== true,
37
- ...(req.model !== undefined ? { model: req.model } : {}),
37
+ ...(req.model !== undefined ? { model: req.model, modelOverride: true } : {}),
38
38
  cwd,
39
39
  profileId,
40
40
  })).launch;
@@ -15,12 +15,12 @@ export type { PortableModelSelection } from './model-selection.js';
15
15
  * stays unpinned. */
16
16
  export declare function isProviderPinnedModelToken(model: string): boolean;
17
17
  /** True when `model` is a PORTABLE ladder token — a bare strength/alias
18
- * (`ultra`, `normal`, `opus`, …), an `anthropic|openai / strength` pair, or
19
- * an anthropic family alias — i.e. a form `normalizeModel` resolves through the ladders rather than
20
- * passing through as a concrete id. Portable tokens are safe to PERSIST as
21
- * defaults (`persistDefaultKindModel`): they re-resolve against whatever
22
- * ladder the running build ships, so they never freeze a stale concrete
23
- * model id to disk. Mirrors `normalizeModel`'s branch structure exactly. */
18
+ * (`ultra`, `normal`, `opus`, …), a `<family>/<strength>` pair, or an
19
+ * anthropic family alias — i.e. a logical preference rather than a concrete
20
+ * provider/model id. Portable tokens are safe to PERSIST as defaults
21
+ * (`persistDefaultKindModel`): they re-resolve against the declared routes or
22
+ * ladder the running build ships, so they never freeze a stale concrete model
23
+ * id to disk. */
24
24
  export declare function isPortableModelToken(model: string): boolean;
25
25
  export declare function logicalModelIntent(model: string, ladders?: ScopeConfig['modelLadders']): {
26
26
  family: string;
@@ -65,6 +65,12 @@ export interface LaunchBuildOptions {
65
65
  hasManager: boolean;
66
66
  extraEnv?: Record<string, string>;
67
67
  model?: string;
68
+ /** A newly supplied `--model` override. Resolve it through the target
69
+ * ladder and any quality floor once, then freeze the resulting concrete
70
+ * provider/id[:thinking] spec into the launch recipe so every launch,
71
+ * revive, and retry consults exactly that model. Existing recipes instead
72
+ * carry `modelExact`/`modelIntent` below verbatim. */
73
+ modelOverride?: boolean;
68
74
  /** The TARGET node's cwd — anchors the project-scope stack for kind/ladder
69
75
  * resolution. Defaults to this process's own cwd, which is correct only
70
76
  * when the caller IS resolving for itself; spawn/polymorph paths must
@@ -85,7 +85,7 @@ function findLadderCell(normalized, ladders, providers) {
85
85
  * strong/medium/light, ALWAYS anthropic (ignores the env/config default).
86
86
  * 4. Anything else → pass through unchanged. */
87
87
  function modelTokenStrength(model) {
88
- const slash = model.indexOf('/');
88
+ const slash = model.lastIndexOf('/');
89
89
  const provider = slash === -1 ? undefined : model.slice(0, slash);
90
90
  const rest = slash === -1 ? model : model.slice(slash + 1);
91
91
  const strength = STRENGTH_ALIASES[rest];
@@ -137,19 +137,19 @@ export function isProviderPinnedModelToken(model) {
137
137
  return model.includes('/') && !isPortableModelToken(model);
138
138
  }
139
139
  /** True when `model` is a PORTABLE ladder token — a bare strength/alias
140
- * (`ultra`, `normal`, `opus`, …), an `anthropic|openai / strength` pair, or
141
- * an anthropic family alias — i.e. a form `normalizeModel` resolves through the ladders rather than
142
- * passing through as a concrete id. Portable tokens are safe to PERSIST as
143
- * defaults (`persistDefaultKindModel`): they re-resolve against whatever
144
- * ladder the running build ships, so they never freeze a stale concrete
145
- * model id to disk. Mirrors `normalizeModel`'s branch structure exactly. */
140
+ * (`ultra`, `normal`, `opus`, …), a `<family>/<strength>` pair, or an
141
+ * anthropic family alias — i.e. a logical preference rather than a concrete
142
+ * provider/model id. Portable tokens are safe to PERSIST as defaults
143
+ * (`persistDefaultKindModel`): they re-resolve against the declared routes or
144
+ * ladder the running build ships, so they never freeze a stale concrete model
145
+ * id to disk. */
146
146
  export function isPortableModelToken(model) {
147
147
  if (model.includes('/')) {
148
- const slash = model.indexOf('/');
149
- const provider = model.slice(0, slash);
148
+ const slash = model.lastIndexOf('/');
149
+ const family = model.slice(0, slash);
150
150
  const rest = model.slice(slash + 1);
151
- return (provider === 'anthropic' || provider === 'openai') && (STRENGTH_ALIASES[rest] !== undefined
152
- || (provider === 'anthropic' && (rest === 'opus' || rest === 'sonnet' || rest === 'haiku')));
151
+ return family !== '' && (STRENGTH_ALIASES[rest] !== undefined
152
+ || (family === 'anthropic' && (rest === 'opus' || rest === 'sonnet' || rest === 'haiku')));
153
153
  }
154
154
  return STRENGTH_ALIASES[model] !== undefined || model === 'opus' || model === 'sonnet' || model === 'haiku';
155
155
  }
@@ -158,7 +158,7 @@ export function logicalModelIntent(model, ladders = modelLadders()) {
158
158
  if (strength === null)
159
159
  return undefined;
160
160
  if (model.includes('/'))
161
- return { family: model.slice(0, model.indexOf('/')), strength };
161
+ return { family: model.slice(0, model.lastIndexOf('/')), strength };
162
162
  if (model === 'opus' || model === 'sonnet' || model === 'haiku')
163
163
  return { family: 'anthropic', strength };
164
164
  return { family: defaultProvider(ladders), strength };
@@ -251,16 +251,18 @@ function buildLaunchSpecFromMerged(kind, mode, opts, merged) {
251
251
  const ladders = merged.modelLadders;
252
252
  const kindModel = mode === 'orchestrator' ? kindConfig?.orchestratorModel ?? kindConfig?.model : kindConfig?.model;
253
253
  const chosenModel = opts.modelExact === true ? opts.model : floorReviewModel(kind, opts.model, kindModel, ladders);
254
- const intent = opts.modelExact === true ? undefined : (opts.modelIntent ?? (chosenModel !== undefined ? logicalModelIntent(chosenModel, ladders) : undefined));
254
+ const exact = opts.modelExact === true || opts.modelOverride === true;
255
+ const resolvedModel = opts.modelExact === true
256
+ ? chosenModel
257
+ : (chosenModel !== undefined ? normalizeModel(chosenModel, ladders) : undefined);
258
+ const intent = exact ? undefined : (opts.modelIntent ?? (chosenModel !== undefined ? logicalModelIntent(chosenModel, ladders) : undefined));
255
259
  const launch = {
256
- model: opts.modelExact === true
257
- ? chosenModel
258
- : (chosenModel !== undefined ? normalizeModel(chosenModel, ladders) : undefined),
260
+ model: resolvedModel,
259
261
  modelIntent: intent,
260
262
  modelProviderPinned: opts.modelProviderPinned !== undefined
261
263
  ? opts.modelProviderPinned
262
264
  : opts.model !== undefined && isProviderPinnedModelToken(opts.model),
263
- modelExact: opts.modelExact === true ? true : undefined,
265
+ modelExact: exact ? true : undefined,
264
266
  tools: kindConfig?.tools ?? [],
265
267
  extensions: mergeCanvasExtensions([...CANVAS_EXTENSIONS, ...(kindConfig?.extensions ?? [])]),
266
268
  systemPrompt: LAUNCH_SYSTEM_PROMPT,
@@ -2,7 +2,7 @@ import { type NodeMeta } from '../canvas/index.js';
2
2
  /** True when the node's broker engine is reachable: its recorded pid is alive
3
3
  * AND its view.sock exists. Both are required — a dead pid with a stale socket,
4
4
  * or a live pid mid-boot before it binds the socket, is NOT a live broker. */
5
- export declare function isBrokerLive(meta: NodeMeta): boolean;
5
+ export declare function isBrokerLive(node: Pick<NodeMeta, 'node_id' | 'pi_pid'>): boolean;
6
6
  /** Swap a LIVE node's model over its view.sock. Connects as a controller,
7
7
  * sends `set_model`, and resolves with the broker-resolved durable spec from its
8
8
  * `model_changed` frame. Rejects with the broker's message on an `error` frame (e.g.
@@ -16,9 +16,10 @@ export declare function isBrokerLive(meta: NodeMeta): boolean;
16
16
  export declare function setModelLive(nodeId: string, spec: string, pinned: boolean): Promise<string>;
17
17
  /** Persist a model choice into a DORMANT node's durable recipe — mirrors exactly
18
18
  * what the broker's persistModelChoice writes on a live switch: `model_override`
19
- * (so polymorphs preserve it via buildLaunchSpec) and `launch.model` (the recipe
20
- * buildPiArgv replays on revive). `spec` must already be a concrete `provider/id`
21
- * (normalizeModel maps tiers/aliases; a bare substring cannot resolve offline).
19
+ * (so polymorphs preserve it via buildLaunchSpec), `launch.model`, and its
20
+ * exact-consult marker (the recipe buildPiArgv replays on revive). `spec` must
21
+ * already be a concrete `provider/id` (normalizeModel maps tiers/aliases; a bare
22
+ * substring cannot resolve offline).
22
23
  * `pinned` is the durable-pin decision for this change — decided by the CALLER
23
24
  * from the RAW pre-normalize token, never re-derived here from `spec`'s shape
24
25
  * (a concrete `spec` always reads as "pinned", which would wrongly re-pin a