@north-light/crouter 0.3.262 → 0.3.269

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 (150) hide show
  1. package/dist/api/client.d.ts +5 -1
  2. package/dist/api/client.js +6 -0
  3. package/dist/api/dto/canvas.d.ts +53 -2
  4. package/dist/api/dto/health.d.ts +2 -0
  5. package/dist/api/dto/nodes.d.ts +6 -2
  6. package/dist/api/routes.d.ts +1 -0
  7. package/dist/api/routes.js +1 -0
  8. package/dist/builtin-memory/05-kinds/advisor/advice-contract.md +2 -2
  9. package/dist/builtin-memory/internal/memory-loading.md +3 -3
  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 +10 -5
  13. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +12 -5
  14. package/dist/clients/attach/input/controller.d.ts +4 -0
  15. package/dist/clients/attach/input/controller.js +8 -0
  16. package/dist/clients/attach/session/bindings.d.ts +3 -0
  17. package/dist/clients/attach/session/bindings.js +7 -0
  18. package/dist/clients/attach/session/keys.d.ts +2 -0
  19. package/dist/clients/attach/session/keys.js +3 -3
  20. package/dist/clients/attach/session/whip-achievements.d.ts +4 -0
  21. package/dist/clients/attach/session/whip-achievements.js +145 -0
  22. package/dist/clients/attach/slash/dispatch.js +5 -3
  23. package/dist/clients/attach/viewer.js +668 -667
  24. package/dist/commands/canvas-history/grep.js +3 -22
  25. package/dist/commands/canvas-history/read.js +5 -3
  26. package/dist/commands/canvas-history/search.js +3 -22
  27. package/dist/commands/canvas-history/shared.d.ts +8 -0
  28. package/dist/commands/canvas-history/shared.js +64 -1
  29. package/dist/commands/canvas-history/stats.d.ts +1 -0
  30. package/dist/commands/canvas-history/stats.js +68 -0
  31. package/dist/commands/canvas-history.js +4 -3
  32. package/dist/commands/memory/read.js +15 -38
  33. package/dist/commands/memory/shared.d.ts +1 -1
  34. package/dist/commands/memory/shared.js +1 -1
  35. package/dist/commands/node/create.js +2 -2
  36. package/dist/commands/sys/config.js +19 -2
  37. package/dist/commands/sys/context/admin/docs-panel.d.ts +3 -3
  38. package/dist/commands/sys/context/admin/model.d.ts +7 -2
  39. package/dist/commands/sys/context/admin/model.js +7 -1
  40. package/dist/commands/sys/context/admin/read-view.d.ts +5 -5
  41. package/dist/commands/sys/context/admin/read-view.js +9 -4
  42. package/dist/commands/sys/context/resolve.d.ts +32 -3
  43. package/dist/commands/sys/context/resolve.js +74 -25
  44. package/dist/commands/sys/panels/models-panel.d.ts +28 -5
  45. package/dist/commands/sys/panels/models-panel.js +560 -75
  46. package/dist/core/__tests__/integration/broker-sdk-wiring.test.js +1 -1
  47. package/dist/core/__tests__/integration/revive.test.js +4 -2
  48. package/dist/core/__tests__/model-pin-durability.test.js +25 -1
  49. package/dist/core/__tests__/model-routes-config.test.d.ts +1 -0
  50. package/dist/core/__tests__/model-routes-config.test.js +142 -0
  51. package/dist/core/__tests__/on-read-nested-store.test.js +8 -0
  52. package/dist/core/__tests__/profile-project-memory-delivery.test.js +264 -1
  53. package/dist/core/__tests__/relaunch-root.test.js +17 -0
  54. package/dist/core/__tests__/seam/broker-provider-retry.test.js +21 -6
  55. package/dist/core/__tests__/seam/memory-slash-node-relative-inventory.test.js +19 -2
  56. package/dist/core/broker-client/__tests__/transport-relay.test.js +3 -1
  57. package/dist/core/broker-client/transport-relay.js +5 -1
  58. package/dist/core/canvas/__tests__/history-transcript.test.d.ts +1 -0
  59. package/dist/core/canvas/__tests__/history-transcript.test.js +102 -0
  60. package/dist/core/canvas/browse/app.d.ts +1 -1
  61. package/dist/core/canvas/browse/app.js +13 -10
  62. package/dist/core/canvas/browse/model.js +16 -1
  63. package/dist/core/canvas/browse/render.d.ts +23 -3
  64. package/dist/core/canvas/browse/render.js +429 -64
  65. package/dist/core/canvas/canvas.js +12 -8
  66. package/dist/core/canvas/extensions.d.ts +1 -1
  67. package/dist/core/canvas/extensions.js +7 -1
  68. package/dist/core/canvas/history.d.ts +36 -1
  69. package/dist/core/canvas/history.js +328 -7
  70. package/dist/core/canvas/labels.d.ts +2 -2
  71. package/dist/core/canvas/migrations.js +32 -0
  72. package/dist/core/canvas/node-recap.d.ts +23 -0
  73. package/dist/core/canvas/node-recap.js +67 -0
  74. package/dist/core/canvas/render-source.d.ts +34 -2
  75. package/dist/core/canvas/render-source.js +83 -24
  76. package/dist/core/canvas/types.d.ts +9 -0
  77. package/dist/core/config.d.ts +19 -2
  78. package/dist/core/config.js +174 -51
  79. package/dist/core/keybindings/catalog.d.ts +2 -2
  80. package/dist/core/keybindings/catalog.js +11 -8
  81. package/dist/core/model-routes.js +11 -4
  82. package/dist/core/runtime/broker/auth-reload.js +4 -1
  83. package/dist/core/runtime/broker/fault-retry.js +7 -2
  84. package/dist/core/runtime/broker/frame-client.js +4 -0
  85. package/dist/core/runtime/broker/frame-dispatch.js +7 -0
  86. package/dist/core/runtime/broker/inbox.d.ts +12 -2
  87. package/dist/core/runtime/broker/inbox.js +68 -38
  88. package/dist/core/runtime/broker-protocol.d.ts +6 -0
  89. package/dist/core/runtime/broker.js +14 -0
  90. package/dist/core/runtime/canvas-extensions.d.ts +1 -0
  91. package/dist/core/runtime/canvas-extensions.js +2 -0
  92. package/dist/core/runtime/interactive-deliver.d.ts +8 -2
  93. package/dist/core/runtime/interactive-deliver.js +12 -3
  94. package/dist/core/runtime/launch-target.js +1 -1
  95. package/dist/core/runtime/launch.d.ts +12 -6
  96. package/dist/core/runtime/launch.js +19 -17
  97. package/dist/core/runtime/model-swap.d.ts +5 -4
  98. package/dist/core/runtime/model-swap.js +7 -5
  99. package/dist/core/runtime/promote.js +2 -1
  100. package/dist/core/runtime/reset.js +4 -2
  101. package/dist/core/runtime/revive.js +22 -0
  102. package/dist/core/runtime/spawn.d.ts +3 -3
  103. package/dist/core/runtime/spawn.js +20 -3
  104. package/dist/core/runtime/stamp/channel.d.ts +18 -0
  105. package/dist/core/runtime/stamp/channel.js +74 -0
  106. package/dist/core/runtime/stamp/protocol.d.ts +86 -0
  107. package/dist/core/runtime/stamp/protocol.js +113 -0
  108. package/dist/core/runtime/tmux-driver.d.ts +11 -1
  109. package/dist/core/runtime/tmux-driver.js +27 -11
  110. package/dist/core/self-update.js +2 -5
  111. package/dist/core/substrate/listings.d.ts +1 -1
  112. package/dist/core/substrate/listings.js +6 -2
  113. package/dist/core/substrate/on-read.d.ts +61 -5
  114. package/dist/core/substrate/on-read.js +129 -22
  115. package/dist/core/substrate/render.d.ts +3 -2
  116. package/dist/core/substrate/render.js +133 -38
  117. package/dist/core/tui/draw.d.ts +1 -0
  118. package/dist/core/tui/draw.js +5 -1
  119. package/dist/core/tui/markdown.d.ts +17 -0
  120. package/dist/core/tui/markdown.js +407 -0
  121. package/dist/core/user-settings.d.ts +4 -0
  122. package/dist/core/user-settings.js +1 -0
  123. package/dist/daemon/api/__tests__/node-model-validation.test.js +21 -0
  124. package/dist/daemon/api/__tests__/seam/api-server.test.js +34 -0
  125. package/dist/daemon/api/bridge.d.ts +3 -2
  126. package/dist/daemon/api/bridge.js +34 -9
  127. package/dist/daemon/api/handlers/canvas.js +80 -18
  128. package/dist/daemon/api/handlers/health.js +17 -12
  129. package/dist/daemon/api/handlers/messages.js +12 -1
  130. package/dist/daemon/api/handlers/nodes.js +10 -3
  131. package/dist/daemon/api/map.js +2 -0
  132. package/dist/daemon/messaging/node-message.js +15 -1
  133. package/dist/daemon/reconcilers/broker-supervision.js +26 -9
  134. package/dist/daemon/reconcilers/live-obligation.d.ts +8 -1
  135. package/dist/daemon/reconcilers/live-obligation.js +17 -0
  136. package/dist/pi-extensions/__tests__/pre-command-gate.test.js +2 -2
  137. package/dist/pi-extensions/canvas-inbox-watcher.js +27 -2
  138. package/dist/pi-extensions/canvas-recap.js +4 -0
  139. package/dist/pi-extensions/canvas-stamp.d.ts +14 -0
  140. package/dist/pi-extensions/canvas-stamp.js +77 -0
  141. package/dist/pi-extensions/canvas-stophook.js +3 -0
  142. package/dist/shared/__tests__/env-boundary.test.js +4 -2
  143. package/dist/shared/crtr-version.d.ts +2 -0
  144. package/dist/shared/crtr-version.js +18 -0
  145. package/dist/shared/env.d.ts +10 -0
  146. package/dist/shared/env.js +19 -4
  147. package/dist/types.d.ts +14 -2
  148. package/dist/types.js +4 -0
  149. package/package.json +1 -1
  150. package/runtime.lock.json +2 -2
@@ -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
  }
@@ -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 } 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. */
@@ -143,6 +144,11 @@ export interface DeliverFrame {
143
144
  * between a card and the body it belongs to. Each is its own custom
144
145
  * message and none triggers a turn; the body is the sole trigger. */
145
146
  cards?: string[];
147
+ /** Who authored `text`, for the durable stamp the broker writes. The daemon
148
+ * holds this provenance (an inbox sender, a park request, a person on a
149
+ * dormant viewer) and the broker is the only process that can announce it,
150
+ * so it rides the same frame as the body. */
151
+ origin?: MessageOrigin;
146
152
  }
147
153
  /** Run a `!` bash command — writable clients only. Maps to `session.executeBash()`,
148
154
  * which runs the command, records a `bashExecution` message in context, and
@@ -52,6 +52,8 @@ import { EventProjection } from './broker/event-projection.js';
52
52
  import { createFrameDispatchContext, handleFrame, } from './broker/frame-dispatch.js';
53
53
  import { hydratePersistedAdvertisedCommandMessages, installAdvertisedCommandInvocationContract, } from './advertised-command-invocation.js';
54
54
  import { RebindDriver } from './broker/rebind.js';
55
+ import { decodeKickoffOrigin, KICKOFF_ORIGIN_ENV } from './stamp/protocol.js';
56
+ import { announceMessageOrigin } from './stamp/channel.js';
55
57
  import { brokerExtensionState, commitBrokerModel } from './broker/daemon-ops.js';
56
58
  import { FrameOverflowError, normalizeSnapshotTail, } from './broker-protocol.js';
57
59
  /** A review companion may only navigate or fork within a branch whose ancestry
@@ -201,6 +203,10 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
201
203
  const launchFile = join(dir, 'broker-launch.json');
202
204
  const inv = JSON.parse(readFileSync(launchFile, 'utf8'));
203
205
  const cfg = piInvocationToSdkConfig(inv); // also merges inv.env
206
+ if (cfg.modelExact === true)
207
+ process.env['CRTR_MODEL_EXACT'] = '1';
208
+ else
209
+ delete process.env['CRTR_MODEL_EXACT'];
204
210
  // 2–4. Build the engine session via the SERVICES path (C3) — see
205
211
  // buildBrokerSession below. Register it so the FATAL exit path (M3) can
206
212
  // dispose it and reap detached bash children.
@@ -314,7 +320,9 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
314
320
  const result = await commitBrokerModel(nodeId, { spec, pinnedOverride, userSelected });
315
321
  if (userSelected) {
316
322
  delete process.env['CRTR_MODEL_INTENT'];
323
+ process.env['CRTR_MODEL_EXACT'] = '1';
317
324
  cfg.model = result.modelOverride;
325
+ cfg.modelExact = true;
318
326
  }
319
327
  return result.modelOverride;
320
328
  };
@@ -637,6 +645,12 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
637
645
  // reload_auth re-drive resolves a model, at which point it fires. For the
638
646
  // common model-present path this fires immediately — unchanged behavior.
639
647
  if (cfg.firstPrompt !== undefined && cfg.firstPrompt !== '') {
648
+ // The spawner recorded who authored this kickoff (a birth mandate, a
649
+ // cron wake, a revive card); announce it against the exact text so the
650
+ // stamp writer claims it on the message pi persists.
651
+ const kickoffOrigin = decodeKickoffOrigin(process.env[KICKOFF_ORIGIN_ENV]);
652
+ if (kickoffOrigin !== null)
653
+ announceMessageOrigin(cfg.firstPrompt, kickoffOrigin);
640
654
  frameDispatch.kickoff(cfg.firstPrompt);
641
655
  }
642
656
  });
@@ -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
@@ -19,8 +19,8 @@ import { oneShotControllerRequest } from './broker-request.js';
19
19
  /** True when the node's broker engine is reachable: its recorded pid is alive
20
20
  * AND its view.sock exists. Both are required — a dead pid with a stale socket,
21
21
  * or a live pid mid-boot before it binds the socket, is NOT a live broker. */
22
- export function isBrokerLive(meta) {
23
- return isPidAlive(meta.pi_pid) && existsSync(viewSocketPath(meta.node_id));
22
+ export function isBrokerLive(node) {
23
+ return isPidAlive(node.pi_pid) && existsSync(viewSocketPath(node.node_id));
24
24
  }
25
25
  /** Swap a LIVE node's model over its view.sock. Connects as a controller,
26
26
  * sends `set_model`, and resolves with the broker-resolved durable spec from its
@@ -49,9 +49,10 @@ export function setModelLive(nodeId, spec, pinned) {
49
49
  }
50
50
  /** Persist a model choice into a DORMANT node's durable recipe — mirrors exactly
51
51
  * what the broker's persistModelChoice writes on a live switch: `model_override`
52
- * (so polymorphs preserve it via buildLaunchSpec) and `launch.model` (the recipe
53
- * buildPiArgv replays on revive). `spec` must already be a concrete `provider/id`
54
- * (normalizeModel maps tiers/aliases; a bare substring cannot resolve offline).
52
+ * (so polymorphs preserve it via buildLaunchSpec), `launch.model`, and its
53
+ * exact-consult marker (the recipe buildPiArgv replays on revive). `spec` must
54
+ * already be a concrete `provider/id` (normalizeModel maps tiers/aliases; a bare
55
+ * substring cannot resolve offline).
55
56
  * `pinned` is the durable-pin decision for this change — decided by the CALLER
56
57
  * from the RAW pre-normalize token, never re-derived here from `spec`'s shape
57
58
  * (a concrete `spec` always reads as "pinned", which would wrongly re-pin a
@@ -66,6 +67,7 @@ export function updateModelRecipe(meta, spec, pinnedOverride, userSelected) {
66
67
  return {
67
68
  ...rest,
68
69
  model: spec,
70
+ modelExact: true,
69
71
  ...(pinnedOverride !== undefined ? { modelProviderPinned: pinnedOverride } : {}),
70
72
  env: remainingEnv,
71
73
  };
@@ -23,6 +23,7 @@ function reshapeInputs(nodeId, opts) {
23
23
  node, kind, mode, lifecycle, profileId,
24
24
  launchOpts: {
25
25
  lifecycle, hasManager: node.parent !== null, model: opts.model ?? node.model_override ?? undefined,
26
+ modelOverride: opts.model !== undefined,
26
27
  modelProviderPinned: opts.model === undefined ? node.launch?.modelProviderPinned : undefined,
27
28
  modelIntent: opts.model === undefined ? node.launch?.modelIntent : undefined,
28
29
  modelExact: opts.model === undefined ? node.launch?.modelExact : undefined,
@@ -36,7 +37,7 @@ function persistReshape(nodeId, opts, input, launch, beforePersist) {
36
37
  return updateNode(nodeId, {
37
38
  kind: input.kind, mode: input.mode, lifecycle: input.lifecycle, profile_id: input.profileId, launch,
38
39
  ...(carriesDefaultHandle ? { name: input.kind } : {}),
39
- ...(opts.model !== undefined ? { model_override: opts.model } : {}),
40
+ ...(opts.model !== undefined ? { model_override: launch?.model ?? opts.model } : {}),
40
41
  });
41
42
  }
42
43
  /** Synchronous reshape for CLI callers outside the daemon. */
@@ -85,9 +85,11 @@ export async function relaunchRoot(oldId, deps = {}) {
85
85
  lifecycle: 'resident',
86
86
  hasManager: false,
87
87
  model: old.model_override ?? undefined,
88
- // A `/new` relaunch carries no new --model — preserve the old root's pin
89
- // verbatim rather than re-deriving it from model_override's shape.
88
+ // A `/new` relaunch carries no new --model — preserve the old root's routing
89
+ // contract verbatim rather than re-deriving it from model_override's shape.
90
90
  modelProviderPinned: old.launch?.modelProviderPinned,
91
+ modelIntent: old.launch?.modelIntent,
92
+ modelExact: old.launch?.modelExact,
91
93
  // The OLD root's cwd + profile, not this process's ambient scope — the
92
94
  // relaunch is the same seat, so profile-scope kind defaults keep applying.
93
95
  cwd: old.cwd,
@@ -34,6 +34,7 @@ import { buildPiArgv } from './launch.js';
34
34
  import { buildReviveKickoff, drainBearings, readGoal, readYieldMessage, RUNTIME_RESTART_CONTINUATION } from './kickoff.js';
35
35
  import { readRoadmap } from './roadmap.js';
36
36
  import { hasUnseenWakeMail } from '../feed/inbox.js';
37
+ import { encodeKickoffOrigin, KICKOFF_ORIGIN_ENV } from './stamp/protocol.js';
37
38
  import { headlessBrokerHost } from './host.js';
38
39
  import { boundFleet, brokerCapReached, brokerThresholdsForDaemon } from './fleet.js';
39
40
  import { clearFault, beginBootFaultAttempt } from './fault.js';
@@ -367,12 +368,23 @@ function launchRevive(nodeId, meta, opts) {
367
368
  // unchanged (empty for true-fresh, both set for cycling) so buildPiArgv emits
368
369
  // the right argv either way.
369
370
  let inv;
371
+ // The provenance of whatever kickoff this branch composes, handed to the
372
+ // broker process through inv.env below. A promptless launch announces
373
+ // nothing — there is no message to stamp.
374
+ let kickoffOrigin = null;
375
+ const birthOrigin = () => ({
376
+ type: 'runtime',
377
+ kind: 'kickoff',
378
+ ...(meta.spawned_by != null && meta.spawned_by !== '' ? { spawnedBy: meta.spawned_by } : {}),
379
+ });
370
380
  if (forkBirthPending) {
371
381
  const goal = readGoal(nodeId);
372
382
  inv = buildPiArgv(meta, {
373
383
  forkFrom: forkSourceFile,
374
384
  ...(goal != null && goal !== '' ? { prompt: goal } : {}),
375
385
  });
386
+ if (goal != null && goal !== '')
387
+ kickoffOrigin = birthOrigin();
376
388
  initializeForkContextExposure(nodeId, findNodeBySessionFile(forkSourceFile)?.node_id ?? null);
377
389
  }
378
390
  else if (resuming) {
@@ -380,6 +392,9 @@ function launchRevive(nodeId, meta, opts) {
380
392
  ...resume,
381
393
  ...(opts.wakeReason === 'runtime-restart-abort' ? { prompt: RUNTIME_RESTART_CONTINUATION } : {}),
382
394
  });
395
+ if (opts.wakeReason === 'runtime-restart-abort') {
396
+ kickoffOrigin = { type: 'runtime', kind: 'restart-continuation' };
397
+ }
383
398
  }
384
399
  else if (!hasSessionPath &&
385
400
  // A clock-driven wake must keep its <crtr-wake> kickoff — the schedule IS
@@ -391,6 +406,8 @@ function launchRevive(nodeId, meta, opts) {
391
406
  // interactive node boots promptless and takes no turn at all.
392
407
  const goal = readGoal(nodeId)?.trim();
393
408
  inv = buildPiArgv(meta, goal !== undefined && goal !== '' ? { prompt: goal } : {});
409
+ if (goal !== undefined && goal !== '')
410
+ kickoffOrigin = birthOrigin();
394
411
  clearContextExposureState(nodeId);
395
412
  }
396
413
  else {
@@ -404,11 +421,16 @@ function launchRevive(nodeId, meta, opts) {
404
421
  ...resume,
405
422
  prompt: buildReviveKickoff(meta, bearings, opts.wakeReason === 'runtime-restart-abort' ? undefined : opts.wakeReason),
406
423
  });
424
+ kickoffOrigin = opts.wakeReason !== undefined && opts.wakeReason !== 'runtime-restart-abort'
425
+ ? { type: 'runtime', kind: 'wake', wake: opts.wakeReason }
426
+ : { type: 'runtime', kind: 'revive', cycle: meta.cycles ?? 0 };
407
427
  clearContextExposureState(nodeId);
408
428
  }
409
429
  // Every lifecycle launch emits the authoritative spine root rather than
410
430
  // inheriting whichever node happened to invoke reviveNode.
411
431
  inv.env = { ...inv.env, CRTR_SUBTREE: rootOfSpine(nodeId) };
432
+ if (kickoffOrigin !== null)
433
+ inv.env[KICKOFF_ORIGIN_ENV] = encodeKickoffOrigin(kickoffOrigin);
412
434
  // The broker host launches the detached engine. reviveNode keeps
413
435
  // transition+clearPid around it (the crash-safety ordering, unchanged). It
414
436
  // opens NO viewer — engine-only; existing viewers reconnect, and a viewer-less
@@ -44,9 +44,9 @@ export interface SpawnChildOpts {
44
44
  /** Pin the node to a model spec — exact `provider/id`, `provider/tier`
45
45
  * (e.g. `openai/ultra`), bare tier (ultra/strong/medium/light), or family
46
46
  * alias (opus/sonnet/haiku) — overriding the persona's declared default.
47
- * The RAW spec is persisted to `meta.model_override` (buildLaunchSpec
48
- * re-normalizes through the current ladders on every revive/polymorph, so
49
- * ladder config edits keep propagating). Omit to use the persona default. */
47
+ * The supplied spec resolves once through the target ladders and its concrete
48
+ * provider/model is persisted as the exact launch recipe. Omit to use the
49
+ * persona default. */
50
50
  model?: string;
51
51
  /** Select the profile this node runs under — an exact profile id or a unique
52
52
  * manifest name (or the closest match to one), resolved through
@@ -21,6 +21,7 @@ import { writeGoal } from './kickoff.js';
21
21
  import { appendSituationalContext, formatSituationalProse } from './situational-context.js';
22
22
  import { hasRoadmap, seedRoadmap } from './roadmap.js';
23
23
  import { buildWakeBearings } from './bearings.js';
24
+ import { encodeKickoffOrigin, KICKOFF_ORIGIN_ENV } from './stamp/protocol.js';
24
25
  import { canonicalSessionFile, contextDir, findNodeBySessionFile, getNode, fullName, recordPid, setFrozen } from '../canvas/index.js';
25
26
  import { boundFleet, brokerThresholdsForDaemon } from './fleet.js';
26
27
  import { emitEvent } from '../events/emit.js';
@@ -238,7 +239,9 @@ export async function spawnChildPrepared(opts, beforeBrokerLaunch) {
238
239
  if (opts.nodeId !== undefined)
239
240
  preflightNodeId(opts.nodeId);
240
241
  const profileId = await resolveProfileId(opts.profile, spawner, opts.cwd);
241
- const preflightLaunch = (await buildLaunchSpecAsync(opts.kind, mode, { lifecycle, hasManager: !root, model: opts.model, cwd: opts.cwd, profileId })).launch;
242
+ const preflightLaunch = (await buildLaunchSpecAsync(opts.kind, mode, {
243
+ lifecycle, hasManager: !root, model: opts.model, modelOverride: opts.model !== undefined, cwd: opts.cwd, profileId,
244
+ })).launch;
242
245
  await assertLaunchModelRegistered(requestFromLaunch(preflightLaunch), { cwd: opts.cwd, profileId });
243
246
  const forkFrom = opts.forkFrom !== undefined ? resolveForkSource(opts.forkFrom) : undefined;
244
247
  const forkSourceNodeId = forkFrom === undefined
@@ -259,7 +262,9 @@ export async function spawnChildPrepared(opts, beforeBrokerLaunch) {
259
262
  // this process's ambient scope (a front-door shell has no CRTR_PROFILE_ID,
260
263
  // which silently hid every profile-scope kind override).
261
264
  const launch = wantsWorktree
262
- ? (await buildLaunchSpecAsync(opts.kind, mode, { lifecycle, hasManager: !root, model: opts.model, cwd: spawnCwd, profileId })).launch
265
+ ? (await buildLaunchSpecAsync(opts.kind, mode, {
266
+ lifecycle, hasManager: !root, model: opts.model, modelOverride: opts.model !== undefined, cwd: spawnCwd, profileId,
267
+ })).launch
263
268
  : preflightLaunch;
264
269
  if (wantsWorktree) {
265
270
  await assertLaunchModelRegistered(requestFromLaunch(launch), { cwd: spawnCwd, profileId });
@@ -280,7 +285,7 @@ export async function spawnChildPrepared(opts, beforeBrokerLaunch) {
280
285
  profile_id: profileId,
281
286
  forkFrom: opts.forkFrom,
282
287
  forkSourceFile: forkFrom,
283
- modelOverride: opts.model,
288
+ modelOverride: opts.model === undefined ? undefined : launch.model,
284
289
  ...(managedWorktree !== undefined ? { managedWorktree } : {}),
285
290
  ...(nodeId !== undefined ? { nodeId } : {}),
286
291
  launch,
@@ -335,6 +340,18 @@ export async function spawnChildPrepared(opts, beforeBrokerLaunch) {
335
340
  // CRTR_SUBTREE (the spine root) rides the detached broker's env so it can group
336
341
  // the subtree; the host sets CRTR_FRONT_DOOR itself.
337
342
  inv.env = { ...inv.env, CRTR_SUBTREE: rootOfSpine(meta.node_id) };
343
+ // The kickoff's provenance rides the same env: it is composed here but sent
344
+ // by the broker process, which stamps it against the message pi persists.
345
+ if (hasKickoff) {
346
+ const kickoffOrigin = opts.wakeOrigin !== undefined
347
+ ? { type: 'runtime', kind: 'wake', wake: opts.wakeOrigin }
348
+ : {
349
+ type: 'runtime',
350
+ kind: 'kickoff',
351
+ ...(meta.spawned_by != null && meta.spawned_by !== '' ? { spawnedBy: meta.spawned_by } : {}),
352
+ };
353
+ inv.env[KICKOFF_ORIGIN_ENV] = encodeKickoffOrigin(kickoffOrigin);
354
+ }
338
355
  // Snapshot the boot-fault attempt the instant before launch — see
339
356
  // beginBootFaultAttempt — so a later diagnostic read (root+await only, below)
340
357
  // can only ever surface a fault THIS launch recorded, never a stale one.