@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
@@ -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
  };
@@ -32,6 +32,8 @@ export interface NodeMessagesPageRead {
32
32
  messages: unknown[];
33
33
  /** Stable session-entry ids aligned 1:1 with `messages`. */
34
34
  messageIds?: string[];
35
+ /** Presentation visibility aligned 1:1 with `messages`. */
36
+ messageVisibility: Array<'visible' | 'internal'>;
35
37
  nextCursor: string | null;
36
38
  }
37
39
  export interface NodeMessagesPageOptions {
@@ -18,7 +18,7 @@ import { SessionManager } from './broker-sdk.js';
18
18
  import { openModelRegistry } from './model-registry.js';
19
19
  import { BUILTIN_SLASH_COMMANDS } from './pi-vendored.js';
20
20
  import { cycleAwareMessages } from './session-cycles.js';
21
- import { visibleMessages } from './session-visibility.js';
21
+ import { projectTranscript } from './session-visibility.js';
22
22
  import { normalizeToolGroupSummaryRecord } from '../../shared/tool-groups.js';
23
23
  /** A session `.jsonl` records only `{ provider, modelId }` — pi never persists a
24
24
  * whole `Model`. Resolve it back through pi's own registry so a dormant
@@ -149,10 +149,10 @@ function readToolGroupSummaries(nodeId) {
149
149
  return {};
150
150
  }
151
151
  }
152
- /** Reconstruct the visible message history from a stable at-rest session copy.
152
+ /** Reconstruct transcript messages from a stable at-rest session copy.
153
153
  * Both snapshot and paged reads use this one path so their histories cannot
154
154
  * diverge. */
155
- async function reconstructVisibleMessages(nodeId) {
155
+ async function reconstructTranscript(nodeId) {
156
156
  const node = getNode(nodeId);
157
157
  if (node === null) {
158
158
  throw new InputError({ error: 'not_found', message: `no node: ${nodeId}`, field: 'id', next: 'List nodes with `crtr node inspect list`.' });
@@ -174,7 +174,10 @@ async function reconstructVisibleMessages(nodeId) {
174
174
  }
175
175
  const identified = reconstructed === undefined
176
176
  ? undefined
177
- : visibleMessages(reconstructed, { boundaryReviewId: node.review_binding?.review_id });
177
+ : projectTranscript(reconstructed, {
178
+ boundaryReviewId: node.review_binding?.review_id,
179
+ sessionEntries: manager.getEntries(),
180
+ });
178
181
  const messages = identified?.map(({ message }) => message) ?? context.messages;
179
182
  return {
180
183
  sessionFile,
@@ -228,10 +231,13 @@ function encodeMessagesCursor(before) {
228
231
  /** Reconstruct a persisted session without launching its broker. Backs the
229
232
  * machine-readable snapshot endpoint and the human transcript render. */
230
233
  export async function readNodeSnapshot(nodeId) {
231
- const read = await reconstructVisibleMessages(nodeId);
234
+ const read = await reconstructTranscript(nodeId);
232
235
  const snapshot = {
233
236
  messages: read.messages,
234
237
  ...(read.identified === undefined ? {} : { messageIds: read.identified.map(({ id }) => id) }),
238
+ messageVisibility: read.identified?.map(({ visibility }) => visibility)
239
+ ?? read.messages.map(() => 'visible'),
240
+ turnVisibility: 'visible',
235
241
  stats: statsFromMessages(read.messages, read.sessionId, read.sessionFile),
236
242
  // pi's `get_state` shape (RpcSessionState), reconstructed offline: the
237
243
  // live-only fields report their at-rest values (nothing is streaming or
@@ -284,15 +290,16 @@ export async function readNodeMessagesPage(nodeId, { cursor, limit = 200 }) {
284
290
  // transcript read is how a UI opens a new conversation. Only this paged read
285
291
  // can answer honestly — `/snapshot` would have to invent the engine state a
286
292
  // never-run node does not have, so it and the raw `/session` export still
287
- // reject the same state through `reconstructVisibleMessages`.
293
+ // reject the same state through `reconstructTranscript`.
288
294
  // Same shape a session-file-backed empty read produces: `messageIds` stays
289
295
  // aligned 1:1 with `messages` (an absent `message_ids` would claim this read
290
296
  // cannot identify entries, which is a different statement than "there are
291
297
  // none"), and `nextCursor: null` is the same end-of-history marker an
292
298
  // exhausted backward walk returns.
293
- if (readableSessionFile(nodeId) === null)
294
- return { messages: [], messageIds: [], nextCursor: null };
295
- const { messages, identified } = await reconstructVisibleMessages(nodeId);
299
+ if (readableSessionFile(nodeId) === null) {
300
+ return { messages: [], messageIds: [], messageVisibility: [], nextCursor: null };
301
+ }
302
+ const { messages, identified } = await reconstructTranscript(nodeId);
296
303
  const before = cursor === undefined ? messages.length : decodeMessagesCursor(cursor, messages.length);
297
304
  let start = Math.max(0, before - limit);
298
305
  while (start > 0
@@ -305,6 +312,8 @@ export async function readNodeMessagesPage(nodeId, { cursor, limit = 200 }) {
305
312
  return {
306
313
  messages: page?.map(({ message }) => message) ?? messages.slice(start, before),
307
314
  ...(page === undefined ? {} : { messageIds: page.map(({ id }) => id) }),
315
+ messageVisibility: page?.map(({ visibility }) => visibility)
316
+ ?? messages.slice(start, before).map(() => 'visible'),
308
317
  nextCursor: start === 0 ? null : encodeMessagesCursor(start),
309
318
  };
310
319
  }
@@ -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,
@@ -54,4 +54,4 @@ export interface ReviveAllResult {
54
54
  * no-op), so this is a thin orchestrator over `listDisconnected`: revive each
55
55
  * with resume:true. One failing node never aborts the sweep — its error is
56
56
  * collected and the rest proceed. */
57
- export declare function reviveAll(): ReviveAllResult;
57
+ export declare function reviveAll(): Promise<ReviveAllResult>;
@@ -84,7 +84,7 @@ export function listDisconnected() {
84
84
  * no-op), so this is a thin orchestrator over `listDisconnected`: revive each
85
85
  * with resume:true. One failing node never aborts the sweep — its error is
86
86
  * collected and the rest proceed. */
87
- export function reviveAll() {
87
+ export async function reviveAll() {
88
88
  const result = { revived: [], failed: [], frozen: [] };
89
89
  for (const meta of listDisconnected()) {
90
90
  try {
@@ -93,7 +93,7 @@ export function reviveAll() {
93
93
  // whole canvas is the likeliest way to meet the cap, and every node in it
94
94
  // is one the daemon wants back: freeze the overflow and let the tick
95
95
  // bring them up as slots free.
96
- const outcome = reviveNode(meta.node_id, { resume: true, recovery: true, capacity: 'freeze' }).outcome;
96
+ const outcome = (await reviveNode(meta.node_id, { resume: true, recovery: true, capacity: 'freeze' })).outcome;
97
97
  if (outcome === 'frozen')
98
98
  result.frozen.push(meta.node_id);
99
99
  else
@@ -87,10 +87,10 @@ export type ReviveCapacityPolicy = 'freeze' | 'refuse';
87
87
  * Throws if the node does not exist. All other failures propagate as-is —
88
88
  * callers (daemon, command) decide how to handle.
89
89
  */
90
- export declare function reviveNode(nodeId: string, opts: ReviveOptions): ReviveResult;
90
+ export declare function reviveNode(nodeId: string, opts: ReviveOptions): Promise<ReviveResult>;
91
91
  /** The attach-specific entry to the revive boundary. A focus viewer may decline
92
92
  * to wake an exact parked resident while preserving the ordinary activating
93
93
  * attach default. The fleet check and parked decision are synchronous with the
94
94
  * launch below, so an already-running broker wins before dormancy is observed. */
95
- export declare function decideAttachRevive(nodeId: string, opts: ReviveOptions, wakeParked: boolean): AttachReviveDecision;
95
+ export declare function decideAttachRevive(nodeId: string, opts: ReviveOptions, wakeParked: boolean): Promise<AttachReviveDecision>;
96
96
  export {};
@@ -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';
@@ -42,6 +43,9 @@ import { rootOfSpine } from './nodes.js';
42
43
  import { isReviewCompanionBound } from '../review/companion.js';
43
44
  import { assertProfileActive } from '../profiles/manifest.js';
44
45
  import { isPidAlive } from '../canvas/pid.js';
46
+ import { crtrHome, contextDir, ensureNodeDirs, jobDir, nodeDir, reportsDir } from '../canvas/paths.js';
47
+ import { discoverLifecycleHookRegistry } from '../command-hooks/discovery.js';
48
+ import { invokeLifecycleHooks } from '../command-hooks/transport/exec-lifecycle.js';
45
49
  // resumeArgs — which session source a revive resumes from
46
50
  /** Pick the `--session` source for a revive. Both resume=true (a true resume)
47
51
  * and resume=false (a refresh-yield) resume by the absolute session-file path
@@ -139,8 +143,8 @@ export function hasFreshGroundState(nodeId) {
139
143
  * Throws if the node does not exist. All other failures propagate as-is —
140
144
  * callers (daemon, command) decide how to handle.
141
145
  */
142
- export function reviveNode(nodeId, opts) {
143
- const decision = decideRevive(nodeId, opts, true);
146
+ export async function reviveNode(nodeId, opts) {
147
+ const decision = await decideRevive(nodeId, opts, true);
144
148
  if (decision.parked)
145
149
  throw new Error(`reviveNode: internal parked decision for activating revive ${nodeId}`);
146
150
  return decision.result;
@@ -149,10 +153,10 @@ export function reviveNode(nodeId, opts) {
149
153
  * to wake an exact parked resident while preserving the ordinary activating
150
154
  * attach default. The fleet check and parked decision are synchronous with the
151
155
  * launch below, so an already-running broker wins before dormancy is observed. */
152
- export function decideAttachRevive(nodeId, opts, wakeParked) {
156
+ export async function decideAttachRevive(nodeId, opts, wakeParked) {
153
157
  return decideRevive(nodeId, opts, wakeParked);
154
158
  }
155
- function decideRevive(nodeId, opts, wakeParked) {
159
+ async function decideRevive(nodeId, opts, wakeParked) {
156
160
  let meta = getNode(nodeId);
157
161
  if (meta === null) {
158
162
  throw new Error(`reviveNode: unknown node ${nodeId}`);
@@ -168,13 +172,10 @@ function decideRevive(nodeId, opts, wakeParked) {
168
172
  throw new Error(`reviveNode: refusing to revive ${nodeId} — its finalization latch is set (final_report=${meta.final_report}). A final landed between the caller's reopen-gate check and this revive; re-run with --reopen if this retask is still wanted.`);
169
173
  }
170
174
  assertProfileActive(meta.profile_id);
171
- // Double-launch guard: a fleet entry IS liveness (design D-1). reviveNode
172
- // runs fully SYNCHRONOUSLY on the daemon thread — this check, the launch,
173
- // and the fleet registration inside `headlessBrokerHost.launch` complete
174
- // with no awaited gap, so no second contender can interleave past this
175
- // check before the first's registration lands. A node with a live fleet
176
- // entry was already revived by another path — re-launching would put a
177
- // SECOND broker on the same session file.
175
+ // Double-launch guard: a fleet entry IS liveness (design D-1). A lifecycle
176
+ // hook is awaited before the broker can register, so its reservation also
177
+ // owns the node while launch is in flight; a second contender must not cross
178
+ // that awaited gap and start a second broker on the same session file.
178
179
  const fleet = boundFleet();
179
180
  if (fleet.has(nodeId)) {
180
181
  // The node is already running, but the ROW may still say terminal: a reopen
@@ -192,6 +193,17 @@ function decideRevive(nodeId, opts, wakeParked) {
192
193
  },
193
194
  };
194
195
  }
196
+ if (fleet.isClaimed(nodeId)) {
197
+ return {
198
+ parked: false,
199
+ result: {
200
+ window: null,
201
+ session: meta.tmux_session ?? null,
202
+ resumed: false,
203
+ outcome: 'already-live',
204
+ },
205
+ };
206
+ }
195
207
  // Focus may establish a viewport without establishing an engine. This exact
196
208
  // row has no final latch and no fleet owner; returning before reservation is
197
209
  // what keeps lifecycle, cycle count, pid, inbox cursor, and session untouched.
@@ -235,7 +247,7 @@ function decideRevive(nodeId, opts, wakeParked) {
235
247
  };
236
248
  }
237
249
  try {
238
- return { parked: false, result: launchRevive(nodeId, meta, opts) };
250
+ return { parked: false, result: await launchRevive(nodeId, meta, opts) };
239
251
  }
240
252
  finally {
241
253
  // The slot is either consumed by the fleet registration inside `launch`
@@ -247,7 +259,7 @@ function decideRevive(nodeId, opts, wakeParked) {
247
259
  }
248
260
  /** The launch itself, from the cwd preflight through `recordPid`. Split out so
249
261
  * the capacity claim above has exactly one release path around it. */
250
- function launchRevive(nodeId, meta, opts) {
262
+ async function launchRevive(nodeId, meta, opts) {
251
263
  // Preflight, before any state mutation below: a recorded cwd that no longer
252
264
  // exists (e.g. a closed managed worktree whose tombstone dir was later
253
265
  // reaped) can never host a launch. Fail loud with a typed, user-visible
@@ -266,6 +278,10 @@ function launchRevive(nodeId, meta, opts) {
266
278
  next,
267
279
  });
268
280
  }
281
+ // This is the same durable distinction used to decide between a birth prompt
282
+ // and a revive kickoff below. Compute it before launch mutations so a stale
283
+ // session identity cannot make an ordinary fresh revive look like a birth.
284
+ const isBirth = isUnstartedBirth(nodeId);
269
285
  // Lazy host_kind coerce (§C): every launch now uses the broker host. Persist
270
286
  // that identity only after this call has passed the no-op liveness guard.
271
287
  if (meta.host_kind !== 'broker') {
@@ -367,12 +383,23 @@ function launchRevive(nodeId, meta, opts) {
367
383
  // unchanged (empty for true-fresh, both set for cycling) so buildPiArgv emits
368
384
  // the right argv either way.
369
385
  let inv;
386
+ // The provenance of whatever kickoff this branch composes, handed to the
387
+ // broker process through inv.env below. A promptless launch announces
388
+ // nothing — there is no message to stamp.
389
+ let kickoffOrigin = null;
390
+ const birthOrigin = () => ({
391
+ type: 'runtime',
392
+ kind: 'kickoff',
393
+ ...(meta.spawned_by != null && meta.spawned_by !== '' ? { spawnedBy: meta.spawned_by } : {}),
394
+ });
370
395
  if (forkBirthPending) {
371
396
  const goal = readGoal(nodeId);
372
397
  inv = buildPiArgv(meta, {
373
398
  forkFrom: forkSourceFile,
374
399
  ...(goal != null && goal !== '' ? { prompt: goal } : {}),
375
400
  });
401
+ if (goal != null && goal !== '')
402
+ kickoffOrigin = birthOrigin();
376
403
  initializeForkContextExposure(nodeId, findNodeBySessionFile(forkSourceFile)?.node_id ?? null);
377
404
  }
378
405
  else if (resuming) {
@@ -380,6 +407,9 @@ function launchRevive(nodeId, meta, opts) {
380
407
  ...resume,
381
408
  ...(opts.wakeReason === 'runtime-restart-abort' ? { prompt: RUNTIME_RESTART_CONTINUATION } : {}),
382
409
  });
410
+ if (opts.wakeReason === 'runtime-restart-abort') {
411
+ kickoffOrigin = { type: 'runtime', kind: 'restart-continuation' };
412
+ }
383
413
  }
384
414
  else if (!hasSessionPath &&
385
415
  // A clock-driven wake must keep its <crtr-wake> kickoff — the schedule IS
@@ -391,6 +421,8 @@ function launchRevive(nodeId, meta, opts) {
391
421
  // interactive node boots promptless and takes no turn at all.
392
422
  const goal = readGoal(nodeId)?.trim();
393
423
  inv = buildPiArgv(meta, goal !== undefined && goal !== '' ? { prompt: goal } : {});
424
+ if (goal !== undefined && goal !== '')
425
+ kickoffOrigin = birthOrigin();
394
426
  clearContextExposureState(nodeId);
395
427
  }
396
428
  else {
@@ -404,11 +436,16 @@ function launchRevive(nodeId, meta, opts) {
404
436
  ...resume,
405
437
  prompt: buildReviveKickoff(meta, bearings, opts.wakeReason === 'runtime-restart-abort' ? undefined : opts.wakeReason),
406
438
  });
439
+ kickoffOrigin = opts.wakeReason !== undefined && opts.wakeReason !== 'runtime-restart-abort'
440
+ ? { type: 'runtime', kind: 'wake', wake: opts.wakeReason }
441
+ : { type: 'runtime', kind: 'revive', cycle: meta.cycles ?? 0 };
407
442
  clearContextExposureState(nodeId);
408
443
  }
409
444
  // Every lifecycle launch emits the authoritative spine root rather than
410
445
  // inheriting whichever node happened to invoke reviveNode.
411
446
  inv.env = { ...inv.env, CRTR_SUBTREE: rootOfSpine(nodeId) };
447
+ if (kickoffOrigin !== null)
448
+ inv.env[KICKOFF_ORIGIN_ENV] = encodeKickoffOrigin(kickoffOrigin);
412
449
  // The broker host launches the detached engine. reviveNode keeps
413
450
  // transition+clearPid around it (the crash-safety ordering, unchanged). It
414
451
  // opens NO viewer — engine-only; existing viewers reconnect, and a viewer-less
@@ -430,6 +467,27 @@ function launchRevive(nodeId, meta, opts) {
430
467
  // `crash` transition (a deadline MUST survive instance death).
431
468
  if (opts.recovery !== true)
432
469
  cancelCronsOnWake(nodeId);
470
+ ensureNodeDirs(nodeId);
471
+ const lifecycleHooks = discoverLifecycleHookRegistry(meta.cwd, meta.profile_id).plans.get('node:start') ?? [];
472
+ await invokeLifecycleHooks(lifecycleHooks, {
473
+ node: {
474
+ id: nodeId,
475
+ name: meta.name,
476
+ kind: meta.kind,
477
+ mode: meta.mode,
478
+ lifecycle: meta.lifecycle,
479
+ cwd: meta.cwd,
480
+ nodeDir: nodeDir(nodeId),
481
+ contextDir: contextDir(nodeId),
482
+ jobDir: jobDir(nodeId),
483
+ reportsDir: reportsDir(nodeId),
484
+ },
485
+ runtime: {
486
+ isBirth,
487
+ canvasHome: crtrHome(),
488
+ profile: meta.profile_id ?? null,
489
+ },
490
+ });
433
491
  let launched;
434
492
  try {
435
493
  launched = headlessBrokerHost.launch(nodeId, inv, {
@@ -1,30 +1,26 @@
1
1
  import type { WireSessionTreeNode } from './broker-protocol.js';
2
2
  import type { IdentifiedMessage } from './session-cycles.js';
3
+ import { type MessageVisibility } from './stamp/protocol.js';
3
4
  import { REVIEW_BOUNDARY_CUSTOM_TYPE } from '../../shared/generated-context.js';
4
5
  export { REVIEW_BOUNDARY_CUSTOM_TYPE };
6
+ export type ProjectedMessage = IdentifiedMessage & {
7
+ visibility: MessageVisibility;
8
+ };
5
9
  /** True when an entry is a durable review boundary, optionally for one review. */
6
10
  export declare function isReviewBoundary(entry: unknown, reviewId?: string): boolean;
7
11
  /**
8
- * Project identified snapshot messages to the visible window for a review companion.
12
+ * Project identified snapshot messages for transcript presentation.
9
13
  *
10
- * A companion passes its binding's review id so the slice starts at ITS OWN
14
+ * Every retained message carries its durable visibility classification. A
15
+ * companion then passes its binding's review id so its slice starts at ITS OWN
11
16
  * boundary marker: a node forked from another companion inherits that
12
17
  * companion's earlier marker, and slicing at the first marker of any review
13
18
  * would reveal the origin companion's transcript.
14
- *
15
- * Contract (in evaluation order):
16
- * 1. This binding's marker present (any marker when the node has no binding) →
17
- * slice from the **first** occurrence (marker included) to the end.
18
- * Everything before the marker is inherited context, hidden from the person.
19
- * 2. Not a companion (`boundaryReviewId` undefined) → return input unchanged
20
- * (same reference). Ordinary nodes are not filtered.
21
- * 3. Bound but no matching marker → a leading compaction summary is replaced
22
- * with a synthetic boundary while its retained tail remains visible. Every
23
- * other markerless shape fails closed with the synthetic boundary alone.
24
19
  */
25
- export declare function visibleMessages(messages: IdentifiedMessage[], opts: {
20
+ export declare function projectTranscript(messages: IdentifiedMessage[], opts: {
26
21
  boundaryReviewId: string | undefined;
27
- }): IdentifiedMessage[];
22
+ sessionEntries?: readonly unknown[];
23
+ }): ProjectedMessage[];
28
24
  /**
29
25
  * Project a session tree to the review companion's visible transcript boundary.
30
26
  *
@@ -1,14 +1,7 @@
1
- // session-visibility.ts — transcript boundary projection for review companions.
2
- //
3
- // The companion node inherits the origin's full session context but the person
4
- // must see only the transcript from the moment the review started. A durable
5
- // marker (the boundary message) is appended to the session before the first
6
- // human-visible message, and this projection uses it to slice the visible
7
- // window.
8
- //
9
- // Applied at the two BrokerSnapshot.messages producers (live broker and
10
- // dormant node read) so every presenter — attach, web node viewer, inspect — is
11
- // boundary-safe by construction with no presenter-side changes.
1
+ // session-visibility.ts — the transcript projection shared by live and dormant
2
+ // reads. Raw session entries remain authoritative; durable stamps and review
3
+ // boundaries select which reconstructed messages a person can see.
4
+ import { readStamps, STAMP_SCHEMA_VERSION, } from './stamp/protocol.js';
12
5
  import { REVIEW_BOUNDARY_CUSTOM_TYPE } from '../../shared/generated-context.js';
13
6
  export { REVIEW_BOUNDARY_CUSTOM_TYPE };
14
7
  /** True when an entry is a durable review boundary, optionally for one review. */
@@ -29,35 +22,55 @@ function syntheticBoundary(timestamp) {
29
22
  timestamp: timestamp ?? Date.now(),
30
23
  };
31
24
  }
25
+ function classifyMessages(messages, sessionEntries) {
26
+ const internalByTimestamp = new Map();
27
+ for (const stamp of readStamps(sessionEntries ?? [])) {
28
+ if (stamp.kind !== 'message' || stamp.v !== STAMP_SCHEMA_VERSION)
29
+ continue;
30
+ const payload = stamp.payload;
31
+ if (typeof payload.msgTs !== 'number')
32
+ continue;
33
+ // Last stamp wins, matching every other message-stamp reader.
34
+ internalByTimestamp.set(payload.msgTs, payload.visibility === 'internal');
35
+ }
36
+ let visibility = 'visible';
37
+ return messages.map((identified) => {
38
+ const { message } = identified;
39
+ if (message.role === 'user') {
40
+ visibility = typeof message.timestamp === 'number'
41
+ && internalByTimestamp.get(message.timestamp) === true
42
+ ? 'internal'
43
+ : 'visible';
44
+ }
45
+ return { ...identified, visibility };
46
+ });
47
+ }
32
48
  /**
33
- * Project identified snapshot messages to the visible window for a review companion.
49
+ * Project identified snapshot messages for transcript presentation.
34
50
  *
35
- * A companion passes its binding's review id so the slice starts at ITS OWN
51
+ * Every retained message carries its durable visibility classification. A
52
+ * companion then passes its binding's review id so its slice starts at ITS OWN
36
53
  * boundary marker: a node forked from another companion inherits that
37
54
  * companion's earlier marker, and slicing at the first marker of any review
38
55
  * would reveal the origin companion's transcript.
39
- *
40
- * Contract (in evaluation order):
41
- * 1. This binding's marker present (any marker when the node has no binding) →
42
- * slice from the **first** occurrence (marker included) to the end.
43
- * Everything before the marker is inherited context, hidden from the person.
44
- * 2. Not a companion (`boundaryReviewId` undefined) → return input unchanged
45
- * (same reference). Ordinary nodes are not filtered.
46
- * 3. Bound but no matching marker → a leading compaction summary is replaced
47
- * with a synthetic boundary while its retained tail remains visible. Every
48
- * other markerless shape fails closed with the synthetic boundary alone.
49
56
  */
50
- export function visibleMessages(messages, opts) {
51
- const markerIndex = messages.findIndex(({ message }) => isReviewBoundary(message, opts.boundaryReviewId));
57
+ export function projectTranscript(messages, opts) {
58
+ const projected = classifyMessages(messages, opts.sessionEntries);
59
+ const markerIndex = projected.findIndex(({ message }) => isReviewBoundary(message, opts.boundaryReviewId));
52
60
  if (markerIndex !== -1)
53
- return messages.slice(markerIndex);
61
+ return projected.slice(markerIndex);
54
62
  if (opts.boundaryReviewId === undefined)
55
- return messages;
56
- const [first, ...retainedTail] = messages;
63
+ return projected;
64
+ const [first, ...retainedTail] = projected;
65
+ const boundary = {
66
+ id: '~boundary',
67
+ message: syntheticBoundary(first?.message.timestamp),
68
+ visibility: 'visible',
69
+ };
57
70
  if (first?.message.role === 'compactionSummary') {
58
- return [{ id: '~boundary', message: syntheticBoundary(first.message.timestamp) }, ...retainedTail];
71
+ return [boundary, ...retainedTail];
59
72
  }
60
- return [{ id: '~boundary', message: syntheticBoundary(first?.message.timestamp) }];
73
+ return [boundary];
61
74
  }
62
75
  /**
63
76
  * Project a session tree to the review companion's visible transcript boundary.
@@ -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.
@@ -0,0 +1,22 @@
1
+ import type { MessageOrigin, MessageVisibility } from './protocol.js';
2
+ export interface MessageAnnouncement {
3
+ origin: MessageOrigin;
4
+ visibility?: MessageVisibility;
5
+ }
6
+ /** The correlation key for one message body. Announcers pass the text they are
7
+ * about to send; the writer passes the text pi persisted. */
8
+ export declare function messageSignature(text: string): string;
9
+ /** Announce the origin and visibility of the user message `text` that is about
10
+ * to be sent. Every ordinary announcement promotes the run to visible. */
11
+ export declare function announceMessageOrigin(text: string, origin: MessageOrigin, visibility?: MessageVisibility): void;
12
+ /** Take the announced metadata for a persisted message body, or null when no
13
+ * seam announced this message. */
14
+ export declare function claimMessageAnnouncement(text: string): MessageAnnouncement | null;
15
+ /** Contribute namespaced fields to the next stamp the writer emits. Unknown
16
+ * namespaces pass through untouched; the core writer never interprets them. */
17
+ export declare function contributeStampExt(namespace: string, fields: Record<string, unknown>): void;
18
+ /** Whether any contribution is waiting — an assistant message is stamped only
19
+ * when one is, so an ordinary assistant turn costs no line. */
20
+ export declare function hasPendingStampExt(): boolean;
21
+ /** Take every buffered contribution, merged per namespace. */
22
+ export declare function drainStampExt(): Record<string, Record<string, unknown>> | undefined;