@north-light/crouter 0.3.248 → 0.3.250

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 (172) hide show
  1. package/README.md +1 -1
  2. package/dist/api/__tests__/integration/client.test.js +28 -7
  3. package/dist/api/client.d.ts +1 -1
  4. package/dist/api/client.js +2 -2
  5. package/dist/api/dto/lifecycle.d.ts +3 -0
  6. package/dist/api/dto/messages.d.ts +4 -0
  7. package/dist/api/dto/nodes.d.ts +4 -0
  8. package/dist/api/dto/reports.d.ts +7 -2
  9. package/dist/builtin-memory/00-runtime-base/01-escalation.md +1 -1
  10. package/dist/builtin-memory/05-kinds/review/01-orchestrator.md +3 -0
  11. package/dist/builtin-memory/internal/nodes-and-canvas.md +1 -1
  12. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +0 -1
  13. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +4 -3
  14. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +4 -3
  15. package/dist/clients/attach/session/chrome-refresh.d.ts +3 -0
  16. package/dist/clients/attach/session/chrome-refresh.js +1 -0
  17. package/dist/clients/attach/viewer.js +532 -532
  18. package/dist/clients/inbox/__tests__/integration/stale-row-activation.test.d.ts +1 -0
  19. package/dist/clients/inbox/__tests__/integration/stale-row-activation.test.js +53 -0
  20. package/dist/clients/inbox/controller.js +32 -5
  21. package/dist/clients/inbox/tui/ansi.d.ts +3 -1
  22. package/dist/clients/inbox/tui/ansi.js +14 -10
  23. package/dist/commands/__tests__/api-client-cold-start-diagnostic.test.js +7 -7
  24. package/dist/commands/api-client.d.ts +2 -2
  25. package/dist/commands/api-client.js +5 -5
  26. package/dist/commands/canvas-browse.js +1 -1
  27. package/dist/commands/node/create.js +3 -1
  28. package/dist/commands/node/inspect.js +1 -0
  29. package/dist/commands/node/lifecycle.js +1 -1
  30. package/dist/commands/node/message.js +6 -4
  31. package/dist/commands/node/wait.js +1 -1
  32. package/dist/commands/push.js +36 -37
  33. package/dist/commands/sys/daemon.js +21 -8
  34. package/dist/commands/sys/setup-core.js +2 -2
  35. package/dist/core/__tests__/broker-stream-watchdog-floor.test.d.ts +1 -0
  36. package/dist/core/__tests__/broker-stream-watchdog-floor.test.js +84 -0
  37. package/dist/core/__tests__/broker-turn-admission.test.d.ts +1 -0
  38. package/dist/core/__tests__/broker-turn-admission.test.js +44 -0
  39. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +5 -1
  40. package/dist/core/__tests__/child-death-wake.test.js +11 -7
  41. package/dist/core/__tests__/collectible.test.d.ts +1 -0
  42. package/dist/core/__tests__/collectible.test.js +88 -0
  43. package/dist/core/__tests__/context-intro.test.js +4 -41
  44. package/dist/core/__tests__/daemon-boot.test.js +30 -14
  45. package/dist/core/__tests__/daemon-wedge.test.js +25 -19
  46. package/dist/core/__tests__/fixtures/fake-engine.js +11 -0
  47. package/dist/core/__tests__/human-deliver.test.js +5 -5
  48. package/dist/core/__tests__/integration/deferred-no-wake.test.js +65 -4
  49. package/dist/core/__tests__/integration/human-deliver-e2e.test.js +3 -3
  50. package/dist/core/__tests__/integration/revive.test.js +48 -0
  51. package/dist/core/__tests__/integration/spawn-root.test.js +36 -1
  52. package/dist/core/__tests__/integration/worktree-land.test.js +82 -3
  53. package/dist/core/__tests__/integration/worktree-reap.test.js +44 -6
  54. package/dist/core/__tests__/migration.test.js +30 -0
  55. package/dist/core/__tests__/passive-subscription.test.js +39 -1
  56. package/dist/core/__tests__/prune.test.js +12 -3
  57. package/dist/core/__tests__/relaunch-root.test.js +1 -1
  58. package/dist/core/__tests__/respawn-throttle.test.js +16 -146
  59. package/dist/core/__tests__/seam/broker-crash-teardown.test.js +23 -11
  60. package/dist/core/__tests__/seam/broker-provider-retry.test.js +81 -2
  61. package/dist/core/__tests__/seam/dormancy-release.test.js +1 -1
  62. package/dist/core/__tests__/seam/self-close-teardown.test.d.ts +1 -0
  63. package/dist/core/__tests__/seam/self-close-teardown.test.js +49 -0
  64. package/dist/core/__tests__/seam/yield-refresh-transaction.test.js +18 -10
  65. package/dist/core/canvas/canvas.d.ts +9 -15
  66. package/dist/core/canvas/canvas.js +41 -47
  67. package/dist/core/canvas/crons.d.ts +5 -8
  68. package/dist/core/canvas/crons.js +5 -8
  69. package/dist/core/canvas/history.d.ts +5 -0
  70. package/dist/core/canvas/history.js +12 -1
  71. package/dist/core/canvas/migrations.js +58 -1
  72. package/dist/core/canvas/pid.d.ts +1 -1
  73. package/dist/core/canvas/pid.js +5 -2
  74. package/dist/core/canvas/types.d.ts +4 -0
  75. package/dist/core/feed/feed.d.ts +4 -0
  76. package/dist/core/feed/feed.js +13 -8
  77. package/dist/core/feed/inbox.d.ts +2 -1
  78. package/dist/core/feed/inbox.js +22 -3
  79. package/dist/core/git.d.ts +3 -0
  80. package/dist/core/git.js +3 -1
  81. package/dist/core/human/feedback-companion.js +1 -0
  82. package/dist/core/preview-registry.js +0 -1
  83. package/dist/core/review/companion.js +1 -0
  84. package/dist/core/review/realize.js +1 -0
  85. package/dist/core/runtime/broker/auth-reload.d.ts +2 -0
  86. package/dist/core/runtime/broker/auth-reload.js +9 -3
  87. package/dist/core/runtime/broker/engine-drive.d.ts +2 -0
  88. package/dist/core/runtime/broker/engine-drive.js +28 -23
  89. package/dist/core/runtime/broker/event-projection.d.ts +4 -0
  90. package/dist/core/runtime/broker/event-projection.js +43 -23
  91. package/dist/core/runtime/broker/fault-retry.d.ts +9 -0
  92. package/dist/core/runtime/broker/fault-retry.js +94 -8
  93. package/dist/core/runtime/broker/frame-dispatch.d.ts +2 -0
  94. package/dist/core/runtime/broker/frame-dispatch.js +4 -1
  95. package/dist/core/runtime/broker/inbox.js +4 -0
  96. package/dist/core/runtime/broker/rebind.js +18 -0
  97. package/dist/core/runtime/broker/turn-admission.d.ts +11 -0
  98. package/dist/core/runtime/broker/turn-admission.js +31 -0
  99. package/dist/core/runtime/broker/turn-ignition.d.ts +2 -0
  100. package/dist/core/runtime/broker/turn-ignition.js +25 -15
  101. package/dist/core/runtime/broker.js +21 -2
  102. package/dist/core/runtime/close.d.ts +11 -9
  103. package/dist/core/runtime/close.js +23 -19
  104. package/dist/core/runtime/fault.d.ts +21 -0
  105. package/dist/core/runtime/fault.js +129 -19
  106. package/dist/core/runtime/fleet.d.ts +0 -6
  107. package/dist/core/runtime/host.d.ts +7 -1
  108. package/dist/core/runtime/host.js +20 -2
  109. package/dist/core/runtime/nodes.d.ts +5 -1
  110. package/dist/core/runtime/nodes.js +24 -1
  111. package/dist/core/runtime/placement.d.ts +6 -6
  112. package/dist/core/runtime/placement.js +15 -8
  113. package/dist/core/runtime/reset.js +2 -2
  114. package/dist/core/runtime/revive.js +40 -4
  115. package/dist/core/runtime/spawn.d.ts +3 -0
  116. package/dist/core/runtime/spawn.js +1 -0
  117. package/dist/core/runtime/warm-pool.d.ts +4 -0
  118. package/dist/core/runtime/warm-pool.js +2 -0
  119. package/dist/core/worktree.d.ts +4 -3
  120. package/dist/core/worktree.js +73 -27
  121. package/dist/daemon/__tests__/integration/api-startup-readiness.test.d.ts +1 -0
  122. package/dist/daemon/__tests__/integration/api-startup-readiness.test.js +81 -0
  123. package/dist/daemon/api/__tests__/reopen-delivery.test.js +38 -0
  124. package/dist/daemon/api/__tests__/seam/api-server.test.js +1 -0
  125. package/dist/daemon/api/__tests__/seam/leaf-api-parity.test.js +13 -2
  126. package/dist/daemon/api/handlers/bash-jobs.js +2 -0
  127. package/dist/daemon/api/handlers/broker-ops.js +23 -2
  128. package/dist/daemon/api/handlers/human.js +1 -1
  129. package/dist/daemon/api/handlers/messages.js +11 -2
  130. package/dist/daemon/api/handlers/nodes.js +13 -1
  131. package/dist/daemon/api/handlers/reports.js +14 -6
  132. package/dist/daemon/api/map.js +1 -0
  133. package/dist/daemon/api/server.d.ts +9 -12
  134. package/dist/daemon/api/server.js +30 -19
  135. package/dist/daemon/companion-retire.js +3 -9
  136. package/dist/daemon/cron/sinks.js +7 -2
  137. package/dist/daemon/crtrd.js +4 -1
  138. package/dist/daemon/fleet.d.ts +2 -16
  139. package/dist/daemon/fleet.js +63 -150
  140. package/dist/daemon/human/finish.js +2 -0
  141. package/dist/daemon/manage.d.ts +11 -0
  142. package/dist/daemon/manage.js +32 -3
  143. package/dist/daemon/messaging/node-message.js +7 -1
  144. package/dist/daemon/reconcilers/bash-deadline.js +2 -0
  145. package/dist/daemon/reconcilers/broker-supervision.d.ts +1 -0
  146. package/dist/daemon/reconcilers/broker-supervision.js +22 -9
  147. package/dist/daemon/reconcilers/live-obligation.d.ts +16 -6
  148. package/dist/daemon/reconcilers/live-obligation.js +18 -6
  149. package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.d.ts +15 -4
  150. package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.js +34 -3
  151. package/dist/daemon/reconcilers/node-lifecycle/terminating.d.ts +4 -1
  152. package/dist/daemon/reconcilers/node-lifecycle/terminating.js +11 -2
  153. package/dist/daemon/reconcilers/node-lifecycle/tick.js +14 -7
  154. package/dist/daemon/reconcilers/storage-maintenance.d.ts +2 -2
  155. package/dist/daemon/reconcilers/storage-maintenance.js +3 -3
  156. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +4 -4
  157. package/dist/pi-extensions/canvas-context-intro.d.ts +3 -34
  158. package/dist/pi-extensions/canvas-context-intro.js +4 -76
  159. package/dist/pi-extensions/canvas-review-boundary.d.ts +3 -24
  160. package/dist/pi-extensions/canvas-review-boundary.js +4 -29
  161. package/dist/pi-extensions/canvas-stophook.js +1 -6
  162. package/dist/shared/birth-announcement.d.ts +12 -0
  163. package/dist/shared/birth-announcement.js +25 -0
  164. package/dist/shared/generated-context.d.ts +8 -4
  165. package/dist/shared/generated-context.js +15 -9
  166. package/package.json +4 -4
  167. package/runtime.lock.json +2 -2
  168. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/statusline.ts +0 -254
  169. package/dist/clients/inbox/tui/clipboard.d.ts +0 -2
  170. package/dist/clients/inbox/tui/clipboard.js +0 -26
  171. package/dist/pi-extensions/truncate.d.ts +0 -5
  172. package/dist/pi-extensions/truncate.js +0 -14
@@ -20,7 +20,7 @@ import { ensureHome, ensureNodeDirs, nodeMetaPath, nodeDir, nodesRoot, jobDir, }
20
20
  * can ever leak onto disk even when a fully-hydrated NodeMeta is handed in. */
21
21
  const IDENTITY_KEYS = [
22
22
  'node_id', 'name', 'description', 'title', 'icon', 'cycles', 'created', 'cwd', 'host_kind', 'profile_id', 'kind', 'mode',
23
- 'lifecycle', 'persona_ack', 'parent', 'spawned_by', 'fork_from', 'fork_source_file', 'review_binding', 'passive_default', 'pi_session_id',
23
+ 'lifecycle', 'persona_ack', 'parent', 'spawned_by', 'creator', 'fork_from', 'fork_source_file', 'review_binding', 'passive_default', 'pi_session_id',
24
24
  'pi_session_file', 'cycle_pending', 'model_override',
25
25
  'launch', 'managed_worktree',
26
26
  ];
@@ -208,12 +208,13 @@ export function canonicalSessionFile(sessionFile) {
208
208
  /** Resolve the crouter node that owns one exact Pi session file. */
209
209
  export function findNodeBySessionFile(sessionFile) {
210
210
  const canonical = canonicalSessionFile(sessionFile);
211
- const matches = openDb()
212
- .prepare('SELECT node_id FROM nodes WHERE pi_session_file = ? ORDER BY created')
213
- .all(canonical);
214
- if (matches.length > 1)
215
- throw new Error(`multiple nodes own pi session file: ${canonical}`);
216
- return matches[0] === undefined ? null : getNode(matches[0].node_id);
211
+ // v36 makes this unique. LIMIT 1 also keeps the lookup total for a canvas
212
+ // opened during repair of an older duplicate projection: the deterministic
213
+ // oldest row remains the owner instead of turning a lookup into a crash.
214
+ const match = openDb()
215
+ .prepare('SELECT node_id FROM nodes WHERE pi_session_file = ? ORDER BY created, node_id LIMIT 1')
216
+ .get(canonical);
217
+ return match === undefined ? null : getNode(match.node_id);
217
218
  }
218
219
  /** The indexed row (from the db) — cheap for queries that don't need full meta. */
219
220
  export function getRow(nodeId) {
@@ -839,6 +840,16 @@ export function rebuildIndex() {
839
840
  function hasOpenManagedWorktree(nodeId) {
840
841
  return getNode(nodeId)?.managed_worktree?.state === 'open';
841
842
  }
843
+ /** Whether a row may be removed by an automatic collection sweep. */
844
+ export function isCollectible(row, opts = {}) {
845
+ if (!['done', 'dead', 'canceled'].includes(row.status)
846
+ || row.pi_pid !== null
847
+ || row.frozen_at !== null
848
+ || hasOpenManagedWorktree(row.node_id)
849
+ || row.node_id === envNodeId())
850
+ return false;
851
+ return opts.pastTtl === true || !['crashed', 'stranded', null].includes(row.terminal_reason ?? null);
852
+ }
842
853
  /** Hard-delete ONE node: drop its row (the edges→nodes FK, ON DELETE CASCADE,
843
854
  * GCs its edges) and remove its on-disk `nodes/<id>/` dir. The single-node
844
855
  * analogue of {@link pruneNodes}, for IMMEDIATE reaping — e.g. an empty node the
@@ -905,14 +916,13 @@ export function pruneNodes(opts) {
905
916
  const selfId = envNodeId() ?? '';
906
917
  const db = openDb();
907
918
  const terminal = db
908
- .prepare(`SELECT node_id, status, created FROM nodes
909
- WHERE status IN ('dead', 'done', 'canceled') AND created < ?
910
- ORDER BY created`)
911
- .all(cutoff).map((r) => ({
912
- node_id: r['node_id'],
913
- status: r['status'],
914
- created: r['created'],
915
- }));
919
+ .prepare(`SELECT * FROM nodes
920
+ WHERE status IN ('dead', 'done', 'canceled') AND created < ?
921
+ ORDER BY created`)
922
+ .all(cutoff)
923
+ .map(rowFrom)
924
+ .filter((row) => isCollectible(row, { pastTtl: true }))
925
+ .map((row) => ({ node_id: row.node_id, status: row.status, created: row.created }));
916
926
  // Stale non-terminal sweep (opt-in): active | idle past the TTL whose process
917
927
  // is provably gone (pi_pid NULL or not alive). Never the caller itself.
918
928
  const stale = !includeStale ? [] : db
@@ -930,8 +940,9 @@ export function pruneNodes(opts) {
930
940
  node_id: r['node_id'],
931
941
  status: r['status'],
932
942
  created: r['created'],
933
- }));
934
- const candidates = [...terminal, ...stale].filter((node) => !hasOpenManagedWorktree(node.node_id));
943
+ }))
944
+ .filter((node) => !hasOpenManagedWorktree(node.node_id));
945
+ const candidates = [...terminal, ...stale];
935
946
  if (dryRun || candidates.length === 0)
936
947
  return { pruned: candidates, dryRun };
937
948
  deletePrunedNodes(candidates);
@@ -1015,50 +1026,33 @@ function recencyMs(nodeId, created) {
1015
1026
  return Number.isNaN(t) ? 0 : t;
1016
1027
  }
1017
1028
  /** Count-based retention sweep: retain the `maxNodes` MOST-RECENTLY-ACTIVE
1018
- * nodes plus every protected node, so the real retained floor is
1019
- * `max(maxNodes, protectedCount)`. Bounds the otherwise-unbounded growth the
1020
- * TTL sweep can't (a canvas that never crosses the TTL still grows without
1029
+ * nodes plus every row the collection authority retains, so the real retained
1030
+ * floor is `max(maxNodes, retainedCount)`. Bounds the otherwise-unbounded growth
1031
+ * the TTL sweep can't (a canvas that never crosses the TTL still grows without
1021
1032
  * limit).
1022
1033
  *
1023
- * Protected (never a prune candidate, regardless of recency rank): any
1024
- * non-terminal node — status active|idle — REGARDLESS of `pi_pid` liveness,
1025
- * or a node with an open managed worktree. A crashed-but-non-terminal row
1026
- * (dead pid, status still active/idle) must survive for daemon crash-recovery
1027
- * to resume, and an open worktree must retain its node ownership until
1028
- * `node worktree close`; neither is eligible for count-based pruning. The caller
1029
- * ($CRTR_NODE_ID) is also always protected.
1030
- *
1031
1034
  * Algorithm: order ALL nodes by recency DESC (telemetry.json mtime, falling back
1032
- * to `created`); `target = max(maxNodes, protectedCount)`; delete every node
1033
- * ranked beyond `target` (0-indexed rank >= target) that is NOT protected and
1034
- * NOT the caller. Retained = all protected + the newest terminal nodes up to
1035
- * `target`.
1035
+ * to `created`); `target = max(maxNodes, retainedCount)`; delete every node
1036
+ * ranked beyond `target` (0-indexed rank >= target) that is collectible.
1036
1037
  *
1037
1038
  * Mirrors {@link pruneNodes} exactly for the delete (shared `deletePrunedNodes`:
1038
1039
  * transactioned row sweep + FK edge cascade + best-effort dir removal). `dryRun`
1039
1040
  * reports the candidate set and deletes NOTHING. */
1040
1041
  export function pruneToLimit(opts) {
1041
1042
  const dryRun = opts.dryRun ?? false;
1042
- const selfId = envNodeId() ?? '';
1043
1043
  const rows = listNodes();
1044
- // Rank ALL nodes by recency DESC, tagging protection as we go. Protection is
1045
- // status-only (active|idle) — NOT gated on pi_pid liveness — so a node whose
1046
- // broker crashed but whose row is still non-terminal stays protected for
1047
- // crash-recovery instead of being hard-deleted here.
1048
1044
  const ranked = rows
1049
- .map((r) => ({
1050
- node_id: r.node_id,
1051
- status: r.status,
1052
- created: r.created,
1053
- recency: recencyMs(r.node_id, r.created),
1054
- protected: r.status === 'active' || r.status === 'idle' || hasOpenManagedWorktree(r.node_id),
1045
+ .map((row) => ({
1046
+ row,
1047
+ recency: recencyMs(row.node_id, row.created),
1048
+ retained: !isCollectible(row),
1055
1049
  }))
1056
1050
  .sort((a, b) => b.recency - a.recency);
1057
- const protectedCount = ranked.filter((r) => r.protected).length;
1058
- const target = Math.max(opts.maxNodes, protectedCount);
1051
+ const retainedCount = ranked.filter((r) => r.retained).length;
1052
+ const target = Math.max(opts.maxNodes, retainedCount);
1059
1053
  const candidates = ranked
1060
- .filter((r, rank) => rank >= target && !r.protected && r.node_id !== selfId)
1061
- .map((r) => ({ node_id: r.node_id, status: r.status, created: r.created }));
1054
+ .filter((r, rank) => rank >= target && !r.retained)
1055
+ .map(({ row }) => ({ node_id: row.node_id, status: row.status, created: row.created }));
1062
1056
  if (dryRun || candidates.length === 0)
1063
1057
  return { pruned: candidates, dryRun };
1064
1058
  deletePrunedNodes(candidates);
@@ -185,14 +185,11 @@ export declare function setCronLastOutputHash(cron_id: string, hash: string): vo
185
185
  * terminal node that stopped without finishing. */
186
186
  export declare function hasPendingCancelOnWakeCron(anchor_node: string): boolean;
187
187
  /** DELETE every cancel-on-wake cron anchored to this node — the deadline rule.
188
- * Two seams call it: reviveNode on a WAKE — every revive except a recovery
189
- * relaunch, which only replaces a dead broker instance and leaves the wait
190
- * open (the wake won the race), and the `finish`/`cancel` lifecycle transitions (the wait ended
191
- * with the node, so its deadline must not fire against a finalized or closed
192
- * row). Standing declarative crons are untouched — only cancel-on-wake rows.
193
- * Kills any in-flight
194
- * run process group first (the cancelCron discipline), under one canvas write
195
- * boundary. Returns the removed cron ids. */
188
+ * A real wake consumes the wait at its delivery boundary or, as a dormant
189
+ * fallback, in reviveNode; recovery replacement is not a wake. Terminal
190
+ * lifecycle transitions also clear the wait. Standing declarative crons are
191
+ * untouched. Kills an in-flight run process group first (the cancelCron
192
+ * discipline), under one canvas write boundary. Returns removed cron ids. */
196
193
  export declare function cancelCronsOnWake(anchor_node: string): string[];
197
194
  /** UPDATE the next fire time — recurrence advancement. The daemon settles a
198
195
  * recurring row to its next slot BEFORE enacting the run (crash-safe). */
@@ -269,14 +269,11 @@ export function hasPendingCancelOnWakeCron(anchor_node) {
269
269
  .get(anchor_node) !== undefined);
270
270
  }
271
271
  /** DELETE every cancel-on-wake cron anchored to this node — the deadline rule.
272
- * Two seams call it: reviveNode on a WAKE — every revive except a recovery
273
- * relaunch, which only replaces a dead broker instance and leaves the wait
274
- * open (the wake won the race), and the `finish`/`cancel` lifecycle transitions (the wait ended
275
- * with the node, so its deadline must not fire against a finalized or closed
276
- * row). Standing declarative crons are untouched — only cancel-on-wake rows.
277
- * Kills any in-flight
278
- * run process group first (the cancelCron discipline), under one canvas write
279
- * boundary. Returns the removed cron ids. */
272
+ * A real wake consumes the wait at its delivery boundary or, as a dormant
273
+ * fallback, in reviveNode; recovery replacement is not a wake. Terminal
274
+ * lifecycle transitions also clear the wait. Standing declarative crons are
275
+ * untouched. Kills an in-flight run process group first (the cancelCron
276
+ * discipline), under one canvas write boundary. Returns removed cron ids. */
280
277
  export function cancelCronsOnWake(anchor_node) {
281
278
  return withCanvasWrite((db) => {
282
279
  const rows = db
@@ -53,6 +53,11 @@ export interface CorpusFilter {
53
53
  * CRTR_NODE_ID via the canvas data layer), falling back to the process cwd
54
54
  * when not run as a node. */
55
55
  export declare function callerCwd(): string;
56
+ /** The history ref of a node's newest report, or null when it left none.
57
+ * Report filenames lead with a compact timestamp, so the lexicographic maximum
58
+ * is the latest one. Callers use this to hand a reader something they can
59
+ * actually open (`crtr canvas history read <ref>`) instead of a bare node id. */
60
+ export declare function newestReportRef(nodeId: string): string | null;
56
61
  /** Build the searchable artifact set for a scope + corpus filter. Node facets
57
62
  * are applied against the db rows first (cheap); only surviving nodes' files
58
63
  * are read. */
@@ -220,7 +220,7 @@ function metaArtifact(row, desc) {
220
220
  loadBody: () => body,
221
221
  };
222
222
  }
223
- /** List the markdown reports a node left, newest filename first. */
223
+ /** List the markdown reports a node left, in directory order. */
224
224
  function listReportFiles(nodeId) {
225
225
  const dir = reportsDir(nodeId);
226
226
  if (!pathExists(dir))
@@ -232,6 +232,17 @@ function listReportFiles(nodeId) {
232
232
  return [];
233
233
  }
234
234
  }
235
+ /** The history ref of a node's newest report, or null when it left none.
236
+ * Report filenames lead with a compact timestamp, so the lexicographic maximum
237
+ * is the latest one. Callers use this to hand a reader something they can
238
+ * actually open (`crtr canvas history read <ref>`) instead of a bare node id. */
239
+ export function newestReportRef(nodeId) {
240
+ let newest = null;
241
+ for (const f of listReportFiles(nodeId))
242
+ if (newest === null || f > newest)
243
+ newest = f;
244
+ return newest === null ? null : `${nodeId}:reports/${newest}`;
245
+ }
235
246
  /** Enumerate every context markdown doc a node left (recursive). */
236
247
  function listDocFiles(nodeId) {
237
248
  const dir = contextDir(nodeId);
@@ -21,7 +21,7 @@
21
21
  // `db.ts` itself keeps only connection management (`openDb`/`withCanvasWrite`
22
22
  // /`closeDb`) and re-exports `migrate`/`MIGRATIONS` from here so external
23
23
  // imports are unaffected by this split.
24
- import { existsSync, readFileSync, readdirSync } from 'node:fs';
24
+ import { existsSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
25
25
  import { basename, join, resolve } from 'node:path';
26
26
  import { crtrHome, isSafeNodeId, nodesRoot, nodeMetaPath } from './paths.js';
27
27
  import { STATE_MIGRATIONS } from '../../migrations/registry.js';
@@ -1215,6 +1215,62 @@ function addNodeSessionFileIndex(db) {
1215
1215
  }
1216
1216
  db.exec('CREATE INDEX IF NOT EXISTS idx_nodes_pi_session_file ON nodes(pi_session_file);');
1217
1217
  }
1218
+ /** v36 — one Pi transcript may have exactly one node owner. Normalize every
1219
+ * stored path first (including meta), then retain the earliest created row
1220
+ * (node_id breaks equal timestamps) and clear both durable session coordinates
1221
+ * from every later claimant before creating the UNIQUE index. */
1222
+ function makeNodeSessionFileUnique(db) {
1223
+ const canonical = (sessionFile) => {
1224
+ const absolute = resolve(sessionFile);
1225
+ return existsSync(absolute) ? realpathSync(absolute) : absolute;
1226
+ };
1227
+ const rows = db.prepare(`
1228
+ SELECT node_id, pi_session_file
1229
+ FROM nodes
1230
+ WHERE pi_session_file IS NOT NULL
1231
+ ORDER BY created ASC, node_id ASC
1232
+ `).all();
1233
+ const setFile = db.prepare('UPDATE nodes SET pi_session_file = ? WHERE node_id = ?');
1234
+ for (const row of rows) {
1235
+ const sessionFile = canonical(row.pi_session_file);
1236
+ if (sessionFile === row.pi_session_file)
1237
+ continue;
1238
+ // Identity is authoritative, so normalize both stores before grouping.
1239
+ updateJsonFileDurably(nodeMetaPath(row.node_id), (meta) => {
1240
+ meta['pi_session_file'] = sessionFile;
1241
+ return true;
1242
+ });
1243
+ setFile.run(sessionFile, row.node_id);
1244
+ }
1245
+ const duplicates = db.prepare(`
1246
+ SELECT pi_session_file
1247
+ FROM nodes
1248
+ WHERE pi_session_file IS NOT NULL
1249
+ GROUP BY pi_session_file
1250
+ HAVING COUNT(*) > 1
1251
+ `).all();
1252
+ const clearRow = db.prepare('UPDATE nodes SET pi_session_file = NULL WHERE node_id = ?');
1253
+ for (const { pi_session_file } of duplicates) {
1254
+ const owners = db.prepare(`
1255
+ SELECT node_id
1256
+ FROM nodes
1257
+ WHERE pi_session_file = ?
1258
+ ORDER BY created ASC, node_id ASC
1259
+ `).all(pi_session_file);
1260
+ for (const { node_id } of owners.slice(1)) {
1261
+ // Clear both durable coordinates so a later reindex cannot restore the
1262
+ // duplicate projection.
1263
+ updateJsonFileDurably(nodeMetaPath(node_id), (meta) => {
1264
+ meta['pi_session_id'] = null;
1265
+ meta['pi_session_file'] = null;
1266
+ return true;
1267
+ });
1268
+ clearRow.run(node_id);
1269
+ }
1270
+ }
1271
+ db.exec('DROP INDEX IF EXISTS idx_nodes_pi_session_file;');
1272
+ db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_nodes_pi_session_file ON nodes(pi_session_file);');
1273
+ }
1218
1274
  /** v35 — durable node lifecycle execution state and terminal cause. */
1219
1275
  function addNodeLifecycleColumns(db) {
1220
1276
  const columns = nodeColumns(db);
@@ -1283,6 +1339,7 @@ export const MIGRATIONS = [
1283
1339
  /* v33 */ addHumanActionDeliveries,
1284
1340
  /* v34 */ addNodeSessionFileIndex,
1285
1341
  /* v35 */ addNodeLifecycleColumns,
1342
+ /* v36 */ makeNodeSessionFileUnique,
1286
1343
  ...JOURNALED_STATE_MIGRATIONS,
1287
1344
  ];
1288
1345
  /** Migration indexes that manage their OWN transaction and therefore must not
@@ -252,7 +252,7 @@ export interface TeardownSnapshot {
252
252
  * before this snapshot was taken. Callers must skip ALL signaling when true. */
253
253
  reused: boolean;
254
254
  }
255
- export declare function captureTeardownSnapshot(rootPid: number, expectedIdentity: string | null): TeardownSnapshot;
255
+ export declare function captureTeardownSnapshot(rootPid: number, expectedIdentity: string | null, excludePid?: number): TeardownSnapshot;
256
256
  /** Best-effort signal against every pid in an ALREADY-CAPTURED tree snapshot
257
257
  * (typically `[rootPid, ...descendantPids(rootPid)]`, taken ONCE by the
258
258
  * caller before sending any signal — see the doc on `isProcessTreeAlive` for
@@ -500,7 +500,7 @@ export function capturePidCommand(pid) {
500
500
  return null;
501
501
  }
502
502
  }
503
- export function captureTeardownSnapshot(rootPid, expectedIdentity) {
503
+ export function captureTeardownSnapshot(rootPid, expectedIdentity, excludePid) {
504
504
  if (!Number.isInteger(rootPid) || rootPid <= 0)
505
505
  return { tree: [], identities: null, reused: false };
506
506
  const table = captureLivenessSnapshot();
@@ -512,7 +512,10 @@ export function captureTeardownSnapshot(rootPid, expectedIdentity) {
512
512
  !identitiesMatch(currentRootIdentity, expectedIdentity);
513
513
  if (reused)
514
514
  return { tree: [], identities: null, reused: true };
515
- const tree = [rootPid, ...descendantPidsFrom(rootPid, table.childrenOf)];
515
+ const excluded = excludePid !== undefined && Number.isInteger(excludePid) && excludePid > 0
516
+ ? new Set([excludePid, ...descendantPidsFrom(excludePid, table.childrenOf)])
517
+ : undefined;
518
+ const tree = [rootPid, ...descendantPidsFrom(rootPid, table.childrenOf)].filter((pid) => !excluded?.has(pid));
516
519
  const identities = new Map();
517
520
  for (const pid of tree) {
518
521
  const id = table.processes.get(pid)?.identity;
@@ -143,6 +143,10 @@ export interface NodeIdentity {
143
143
  * `parent` so an INDEPENDENT root (parent=null) still records its lineage.
144
144
  * Audit only; null for a user-opened root. Defaults to `parent` for a child. */
145
145
  spawned_by?: string | null;
146
+ /** Actor that created this node; null when an external process did. Unlike
147
+ * `parent` (manager) and `spawned_by` (provenance edge), this determines
148
+ * whether birth is announced to the manager. */
149
+ creator?: string | null;
146
150
  /** Raw `--fork-from` operand retained for identity framing and audit. */
147
151
  fork_from?: string | null;
148
152
  /** Resolved absolute source session file used for every pre-bind fork retry. */
@@ -1,7 +1,10 @@
1
1
  import type { InboxDisposition } from './inbox.js';
2
2
  export type PushKind = 'update' | 'urgent' | 'final';
3
+ export type PushDeliveryTier = 'deferred' | 'normal' | 'urgent';
3
4
  export interface PushOpts {
4
5
  kind: PushKind;
6
+ /** Update-only subscriber delivery urgency; report kind remains `kind`. */
7
+ deliveryTier?: PushDeliveryTier;
5
8
  body: string;
6
9
  from?: string;
7
10
  /** Attribution snapshotted by the human-ticket settlement paths; absent means an ordinary report. */
@@ -25,6 +28,7 @@ export declare class FinalDeliveryError extends Error {
25
28
  export declare function push(nodeId: string, opts: PushOpts): Promise<PushResult>;
26
29
  export declare function pushUpdate(nodeId: string, body: string, opts?: {
27
30
  from?: string;
31
+ deliveryTier?: PushDeliveryTier;
28
32
  }): Promise<PushResult>;
29
33
  export declare function pushUrgent(nodeId: string, body: string, opts?: {
30
34
  from?: string;
@@ -51,18 +51,21 @@ function firstLine(text, maxLen = 80) {
51
51
  const line = text.split('\n')[0] ?? '';
52
52
  return line.length > maxLen ? `${line.slice(0, maxLen - 1)}…` : line;
53
53
  }
54
- function tierFor(kind) {
55
- return kind === 'urgent' ? 'urgent' : 'normal';
54
+ /** Requested update delivery urgency wins; legacy report-kind mapping is the fallback. */
55
+ function tierFor(kind, requestedTier) {
56
+ return requestedTier ?? (kind === 'urgent' ? 'urgent' : 'normal');
56
57
  }
57
- function deliver(subscribers, from, fromName, kind, reportRef, body, disposition) {
58
+ function deliver(subscribers, from, fromName, kind, requestedTier, reportRef, body, disposition) {
58
59
  const deliveredTo = [];
59
60
  const failedTo = [];
60
61
  const label = firstLine(body);
61
62
  for (const sub of subscribers) {
62
63
  try {
63
- const entry = { from, ...(fromName === undefined ? {} : { from_name: fromName }), tier: tierFor(kind), kind, ref: reportRef, label, ...(disposition === undefined ? {} : { disposition }) };
64
- if (sub.active)
64
+ const entry = { from, ...(fromName === undefined ? {} : { from_name: fromName }), tier: tierFor(kind, requestedTier), kind, ref: reportRef, label, ...(disposition === undefined ? {} : { disposition }) };
65
+ if (sub.active) {
65
66
  appendInbox(sub.node_id, entry);
67
+ cancelCronsOnWake(sub.node_id);
68
+ }
66
69
  else
67
70
  appendPassive(sub.node_id, entry);
68
71
  deliveredTo.push(sub.node_id);
@@ -108,6 +111,8 @@ function commitCanonicalFinal(nodeId, staged) {
108
111
  async function pushWithContext(nodeId, opts) {
109
112
  if (!nodeId)
110
113
  throw new Error(`push: empty nodeId (kind=${opts.kind}) — a report must belong to a node`);
114
+ if (opts.kind === 'final' && opts.deliveryTier !== undefined)
115
+ throw new Error('push final does not accept a delivery tier');
111
116
  const staged = stageReport(nodeId, opts.kind, opts.body);
112
117
  const from = opts.from ?? nodeId;
113
118
  const fromName = getNode(from)?.name;
@@ -121,14 +126,14 @@ async function pushWithContext(nodeId, opts) {
121
126
  // cancel-on-wake rows anchored here go; standing crons survive. Outside
122
127
  // commitCanonicalFinal's transaction — it takes its own write boundary.
123
128
  cancelCronsOnWake(nodeId);
124
- const { deliveredTo, failedTo } = deliver(subscribers, from, fromName, opts.kind, `${nodeId}:reports/${staged.basename}`, opts.body, opts.disposition);
129
+ const { deliveredTo, failedTo } = deliver(subscribers, from, fromName, opts.kind, undefined, `${nodeId}:reports/${staged.basename}`, opts.body, opts.disposition);
125
130
  if (failedTo.length > 0)
126
131
  throw new FinalDeliveryError(staged.finalPath, deliveredTo, failedTo);
127
132
  return { reportPath: staged.finalPath, deliveredTo };
128
133
  }
129
134
  try {
130
135
  install(staged);
131
- const { deliveredTo, failedTo } = deliver(subscribersOf(nodeId), from, fromName, opts.kind, `${nodeId}:reports/${staged.basename}`, opts.body, opts.disposition);
136
+ const { deliveredTo, failedTo } = deliver(subscribersOf(nodeId), from, fromName, opts.kind, opts.deliveryTier, `${nodeId}:reports/${staged.basename}`, opts.body, opts.disposition);
132
137
  if (failedTo.length > 0)
133
138
  throw new Error(`report published at ${staged.finalPath}; delivery failed for ${failedTo.join(', ')}`);
134
139
  return { reportPath: staged.finalPath, deliveredTo };
@@ -156,7 +161,7 @@ export function redeliverFinalReport(nodeId, originNodeId, reportBasename, body,
156
161
  if (reportBasename === '' || reportBasename.includes('/') || reportBasename.includes('\\')) {
157
162
  throw new Error(`invalid canonical final report basename: ${reportBasename}`);
158
163
  }
159
- const { deliveredTo, failedTo } = deliver([{ node_id: originNodeId, active: true, created: '' }], nodeId, getNode(nodeId)?.name, 'final', `${nodeId}:reports/${reportBasename}`, body, opts?.disposition);
164
+ const { deliveredTo, failedTo } = deliver([{ node_id: originNodeId, active: true, created: '' }], nodeId, getNode(nodeId)?.name, 'final', undefined, `${nodeId}:reports/${reportBasename}`, body, opts?.disposition);
160
165
  const reportPath = join(reportsDir(nodeId), reportBasename);
161
166
  if (failedTo.length > 0)
162
167
  throw new FinalDeliveryError(reportPath, deliveredTo, failedTo);
@@ -41,7 +41,8 @@ export type InboxEntryInput = Omit<InboxEntry, 'entry_id' | 'ts' | 'operation_id
41
41
  };
42
42
  /** Complete an entry exactly once before any spill or append work. */
43
43
  export declare function finalizeInboxEntry(input: InboxEntryInput): InboxEntry;
44
- /** Atomically append one finalized inbox entry to `nodes/<nodeId>/inbox.jsonl`. */
44
+ /** Atomically append one finalized inbox entry to `nodes/<nodeId>/inbox.jsonl`,
45
+ * at the tier `effectiveTier` resolves for this target. */
45
46
  export declare function appendInbox(nodeId: string, entry: InboxEntryInput): InboxEntry;
46
47
  /** Deliver the one system notice for a controller that can no longer satisfy a
47
48
  * wait. Returns null when another durable operation already settled or re-armed
@@ -13,12 +13,13 @@
13
13
  import { appendFileSync, existsSync, readFileSync, writeFileSync, renameSync, mkdirSync, } from 'node:fs';
14
14
  import { dirname, join } from 'node:path';
15
15
  import { randomBytes, randomUUID } from 'node:crypto';
16
- import { deliverAndSettleMessageWait, inboxPath, messagesDir, settleDeadMessageWait } from '../canvas/index.js';
16
+ import { deliverAndSettleMessageWait, getRow, inboxPath, messagesDir, settleDeadMessageWait } from '../canvas/index.js';
17
17
  import { resolveRef } from '../canvas/history.js';
18
18
  import { emitEvent } from '../events/emit.js';
19
19
  import { errorClassFromError } from '../events/errors.js';
20
20
  import { generateOperationId, operationIdContext, validateOperationId } from '../events/operation-id.js';
21
21
  import { formatInboxCard } from '../../shared/generated-context.js';
22
+ import { renderBirthAnnouncementBody } from '../../shared/birth-announcement.js';
22
23
  const INBOX_ENTRY_ID = /^(?!0{32}$)[0-9a-f]{32}$/;
23
24
  function validateInboxEntryId(value) {
24
25
  if (typeof value !== 'string' || !INBOX_ENTRY_ID.test(value))
@@ -64,9 +65,24 @@ function writeMessageBody(nodeId, ts, body) {
64
65
  renameSync(tmpPath, finalPath);
65
66
  return finalPath;
66
67
  }
67
- /** Atomically append one finalized inbox entry to `nodes/<nodeId>/inbox.jsonl`. */
68
+ /** The tier a live watcher injects into the turn a node is ALREADY running
69
+ * (`visibleRoute`, canvas-inbox-watcher). `normal` and `deferred` both wait
70
+ * for a later cycle instead. */
71
+ const STEERING_TIER = 'urgent';
72
+ /** The tier an entry is actually appended at. A terminal node owes one final
73
+ * result and reaps on it, so the later cycle `normal` and `deferred` wait for
74
+ * is one it may never take — mail handed to such a node is raised to the
75
+ * steering tier so the turn it is running now carries it, and so a dormant one
76
+ * is woken for it rather than holding it forever. */
77
+ function effectiveTier(nodeId, tier) {
78
+ if (tier !== 'normal' && tier !== 'deferred')
79
+ return tier;
80
+ return getRow(nodeId)?.lifecycle === 'terminal' ? STEERING_TIER : tier;
81
+ }
82
+ /** Atomically append one finalized inbox entry to `nodes/<nodeId>/inbox.jsonl`,
83
+ * at the tier `effectiveTier` resolves for this target. */
68
84
  export function appendInbox(nodeId, entry) {
69
- const full = finalizeInboxEntry(entry);
85
+ const full = finalizeInboxEntry({ ...entry, tier: effectiveTier(nodeId, entry.tier) });
70
86
  try {
71
87
  // A push pointer already carries its canonical history `ref` — its body
72
88
  // lives in the report file, never inline. A direct message / system notice has no ref yet: when
@@ -353,6 +369,9 @@ function inlineReport(e) {
353
369
  * behavior.
354
370
  */
355
371
  function entryBody(e) {
372
+ const birth = renderBirthAnnouncementBody(e.data, e.ts);
373
+ if (birth !== null)
374
+ return birth;
356
375
  const report = inlineReport(e);
357
376
  if (report !== null)
358
377
  return report.trimEnd();
@@ -3,6 +3,9 @@ export interface GitResult {
3
3
  stdout: string;
4
4
  stderr: string;
5
5
  timedOut?: boolean;
6
+ /** The child did not exit with a numeric status (for example, spawn failure or timeout). */
7
+ executionFailed?: true;
8
+ processError?: string;
6
9
  }
7
10
  export interface GitSyncOptions {
8
11
  timeoutMs?: number;
package/dist/core/git.js CHANGED
@@ -3,10 +3,12 @@ import { network } from './errors.js';
3
3
  export function gitSync(args, cwd, input, options = {}) {
4
4
  const res = spawnSync('git', args, { cwd, encoding: 'utf8', input, timeout: options.timeoutMs });
5
5
  const status = typeof res.status === 'number' ? res.status : 1;
6
+ const completed = typeof res.status === 'number';
6
7
  const stdout = typeof res.stdout === 'string' ? res.stdout : '';
7
8
  const stderr = typeof res.stderr === 'string' ? res.stderr : '';
9
+ const processError = res.error === undefined ? undefined : String(res.error);
8
10
  const timedOut = res.error?.code === 'ETIMEDOUT';
9
- return { status, stdout, stderr, ...(timedOut ? { timedOut: true } : {}) };
11
+ return { status, stdout, stderr, ...(completed ? {} : { executionFailed: true }), ...(processError === undefined ? {} : { processError }), ...(timedOut ? { timedOut: true } : {}) };
10
12
  }
11
13
  export async function gitAsync(args, cwd) {
12
14
  return new Promise((resolve) => {
@@ -72,6 +72,7 @@ export async function realizeFeedbackCompanion(args) {
72
72
  cwd: origin.cwd,
73
73
  profile_id: origin.profile_id ?? null,
74
74
  parent: args.senderNodeId,
75
+ creator: args.senderNodeId,
75
76
  subscribe: false,
76
77
  forkFrom: branchFile,
77
78
  forkSourceFile: branchFile,
@@ -150,7 +150,6 @@ export const LEAF_ICON_CP = {
150
150
  'pkg market list': 0xf03a,
151
151
  'pkg market browse': 0xf14e,
152
152
  'push update': 0xf093,
153
- 'push urgent': 0xf0e7,
154
153
  'push final': 0xf11e,
155
154
  'search web': 0xf0ac,
156
155
  'search answer': 0xf075,
@@ -164,6 +164,7 @@ export function spawnReviewCompanion(spec) {
164
164
  cwd: spec.cwd,
165
165
  profile_id: spec.profileId,
166
166
  parent: spec.originNodeId,
167
+ creator: spec.originNodeId,
167
168
  subscribe: false,
168
169
  forkFrom: spec.branchFile,
169
170
  forkSourceFile: spec.branchFile,
@@ -104,6 +104,7 @@ function realizeBridge(review) {
104
104
  kind: 'human',
105
105
  lifecycle: 'terminal',
106
106
  parent: current.origin_node_id,
107
+ creator: current.origin_node_id,
107
108
  cwd: origin.cwd,
108
109
  profile_id: origin.profile_id ?? null,
109
110
  name: 'human-review',
@@ -4,6 +4,7 @@ import type { BrokerClient } from './client-registry.js';
4
4
  import type { FrameReplies } from './frame-replies.js';
5
5
  import type { RebindDriver } from './rebind.js';
6
6
  import type { BrokerSession } from './read-ops.js';
7
+ import type { TurnAdmissionGate } from './turn-admission.js';
7
8
  type ResolvedLaunchModel = {
8
9
  model: NonNullable<ReturnType<ModelRegistry['find']>>;
9
10
  thinkingLevel: string | undefined;
@@ -35,5 +36,6 @@ export declare function createAuthReloadGate(deps: {
35
36
  resolveLaunchModel: (services: AgentSessionServices, cfg: BrokerSdkConfig) => Promise<ResolvedLaunchModel>;
36
37
  /** A reconcile that finally resolves a model releases the held launch prompt. */
37
38
  driveKickoff: () => void;
39
+ turnAdmission: TurnAdmissionGate;
38
40
  }): AuthReloadGate;
39
41
  export {};
@@ -4,7 +4,7 @@ import { emitEvent } from '../../events/emit.js';
4
4
  import { clearFault, readFault } from '../fault.js';
5
5
  import { isUnknownModel } from '../model-selection.js';
6
6
  export function createAuthReloadGate(deps) {
7
- const { nodeId, cfg, replies, rebind, session, services, broadcastModelChanged, formatModelSpec, resolveLaunchModel, driveKickoff, } = deps;
7
+ const { nodeId, cfg, replies, rebind, session, services, broadcastModelChanged, formatModelSpec, resolveLaunchModel, driveKickoff, turnAdmission, } = deps;
8
8
  let reloadInFlight = null;
9
9
  let faultRedriveInFlight = false;
10
10
  /** True once the engine this work started against has been replaced — every
@@ -112,8 +112,14 @@ export function createAuthReloadGate(deps) {
112
112
  fresh.kind !== 'auth' ||
113
113
  fresh.since !== authFault.since)
114
114
  return;
115
- clearFault(nodeId, { link: 'pi→provider' });
116
- await faultSession.prompt(formatCard('recovery', {}, AUTH_FAULT_RECOVERY_BODY));
115
+ await turnAdmission.admit((release) => {
116
+ if (supersededBy(generation, faultSession) || faultSession.isStreaming) {
117
+ release();
118
+ return Promise.resolve();
119
+ }
120
+ clearFault(nodeId, { link: 'pi→provider' });
121
+ return faultSession.prompt(formatCard('recovery', {}, AUTH_FAULT_RECOVERY_BODY), { preflightResult: release });
122
+ });
117
123
  }
118
124
  catch (err) {
119
125
  emitEvent({
@@ -8,6 +8,7 @@ import type { BrokerSession } from './read-ops.js';
8
8
  import type { RebindDriver } from './rebind.js';
9
9
  import type { ToolGroupTracker } from './tool-groups.js';
10
10
  import type { PromptDispatchTracker } from './turn-ignition.js';
11
+ import type { TurnAdmissionGate } from './turn-admission.js';
11
12
  /** The frames that drive the live engine — the viewer's input vocabulary. */
12
13
  export type DriveFrame = Extract<ClientToBroker, {
13
14
  type: 'prompt' | 'steer' | 'follow_up' | 'abort' | 'bash';
@@ -23,4 +24,5 @@ export declare function createEngineDriver(deps: {
23
24
  rebind: RebindDriver;
24
25
  projection: EventProjection;
25
26
  toolGroups: ToolGroupTracker;
27
+ turnAdmission: TurnAdmissionGate;
26
28
  }): EngineDriver;