@north-light/crouter 0.3.236 → 0.3.238

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 (177) hide show
  1. package/dist/api/client.d.ts +2 -1
  2. package/dist/api/client.js +3 -0
  3. package/dist/api/dto/broker-ops.d.ts +1 -1
  4. package/dist/api/dto/common.d.ts +2 -0
  5. package/dist/api/dto/lifecycle.d.ts +9 -2
  6. package/dist/api/dto/messages.d.ts +5 -0
  7. package/dist/api/dto/nodes.d.ts +10 -2
  8. package/dist/api/dto/worktree.d.ts +10 -0
  9. package/dist/api/routes.d.ts +1 -0
  10. package/dist/api/routes.js +1 -0
  11. package/dist/builtin-memory/00-runtime-base/00-authoring.md +1 -1
  12. package/dist/builtin-memory/internal/agent-shaping.md +1 -1
  13. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +1 -1
  14. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/sysprompt-window.ts +1 -1
  15. package/dist/clients/attach/render/tool-calls.js +18 -3
  16. package/dist/clients/attach/viewer.js +442 -442
  17. package/dist/clients/inbox/review/__tests__/editor-roundtrip.test.js +6 -0
  18. package/dist/clients/inbox/review/roundtrip.js +1 -1
  19. package/dist/commands/memory/lint.js +22 -13
  20. package/dist/commands/memory/shared.js +1 -1
  21. package/dist/commands/node/create.d.ts +1 -1
  22. package/dist/commands/node/create.js +1 -1
  23. package/dist/commands/node/lifecycle.js +1 -1
  24. package/dist/commands/node-worktree.js +39 -8
  25. package/dist/commands/revive.js +11 -2
  26. package/dist/commands/sys/context/admin/actions.d.ts +48 -0
  27. package/dist/commands/sys/context/admin/actions.js +246 -0
  28. package/dist/commands/sys/context/admin/axes-panel.d.ts +21 -0
  29. package/dist/commands/sys/context/admin/axes-panel.js +117 -0
  30. package/dist/commands/sys/context/admin/detail-panel.d.ts +53 -0
  31. package/dist/commands/sys/context/admin/detail-panel.js +178 -0
  32. package/dist/commands/sys/context/admin/docs-panel.d.ts +45 -0
  33. package/dist/commands/sys/context/admin/docs-panel.js +186 -0
  34. package/dist/commands/sys/context/admin/model.d.ts +75 -0
  35. package/dist/commands/sys/context/admin/model.js +209 -0
  36. package/dist/commands/sys/context/admin/shell.d.ts +82 -0
  37. package/dist/commands/sys/context/admin/shell.js +669 -0
  38. package/dist/commands/sys/context/admin.d.ts +1 -0
  39. package/dist/commands/sys/context/admin.js +124 -0
  40. package/dist/commands/sys/context/doc.d.ts +1 -0
  41. package/dist/commands/sys/context/doc.js +196 -0
  42. package/dist/commands/sys/context/prompt-review.d.ts +1 -0
  43. package/dist/commands/sys/{prompt-review.js → context/prompt-review.js} +10 -10
  44. package/dist/commands/sys/context/resolve.d.ts +55 -0
  45. package/dist/commands/sys/context/resolve.js +428 -0
  46. package/dist/commands/sys/context/sysprompt.d.ts +1 -0
  47. package/dist/commands/sys/{sysprompt.js → context/sysprompt.js} +11 -11
  48. package/dist/commands/sys/context.d.ts +2 -0
  49. package/dist/commands/sys/context.js +20 -0
  50. package/dist/commands/sys.js +3 -4
  51. package/dist/core/__tests__/child-death-wake.test.js +38 -22
  52. package/dist/core/__tests__/cron-self-cancel-delivery.test.js +72 -0
  53. package/dist/core/__tests__/daemon-boot.test.js +40 -28
  54. package/dist/core/__tests__/human-deliver.test.js +1 -1
  55. package/dist/core/__tests__/human-node-not-supervised.test.js +10 -15
  56. package/dist/core/__tests__/integration/revive.test.js +12 -12
  57. package/dist/core/__tests__/integration/spawn-root.test.js +6 -0
  58. package/dist/core/__tests__/integration/worktree-reap.test.js +32 -0
  59. package/dist/core/__tests__/lifecycle-terminal-reasons.test.d.ts +1 -0
  60. package/dist/core/__tests__/lifecycle-terminal-reasons.test.js +61 -0
  61. package/dist/core/__tests__/migration.test.js +38 -1
  62. package/dist/core/__tests__/push-final-guard.test.js +1 -1
  63. package/dist/core/__tests__/revive-capacity.test.d.ts +1 -0
  64. package/dist/core/__tests__/revive-capacity.test.js +127 -0
  65. package/dist/core/__tests__/revive-parked-fresh.test.js +7 -7
  66. package/dist/core/__tests__/seam/broker-cap-freeze.test.d.ts +1 -0
  67. package/dist/core/__tests__/seam/broker-cap-freeze.test.js +81 -0
  68. package/dist/core/__tests__/seam/dormancy-release.test.js +7 -7
  69. package/dist/core/__tests__/seam/yield-refresh-transaction.test.js +1 -1
  70. package/dist/core/canvas/browse/render.js +11 -3
  71. package/dist/core/canvas/canvas.d.ts +15 -3
  72. package/dist/core/canvas/canvas.js +52 -16
  73. package/dist/core/canvas/migrations.js +21 -0
  74. package/dist/core/canvas/nav-render.js +3 -1
  75. package/dist/core/canvas/remote-canvas-source.js +2 -0
  76. package/dist/core/canvas/render-source.d.ts +3 -1
  77. package/dist/core/canvas/render-source.js +7 -3
  78. package/dist/core/canvas/status-glyph.d.ts +10 -1
  79. package/dist/core/canvas/status-glyph.js +19 -3
  80. package/dist/core/canvas/types.d.ts +20 -1
  81. package/dist/core/feed/feed.js +1 -1
  82. package/dist/core/human/feedback-companion.js +5 -2
  83. package/dist/core/keybindings/catalog.d.ts +1 -1
  84. package/dist/core/keybindings/catalog.js +6 -2
  85. package/dist/core/memory/lint.d.ts +19 -0
  86. package/dist/core/memory/lint.js +97 -0
  87. package/dist/core/memory-resolver.d.ts +4 -0
  88. package/dist/core/memory-resolver.js +7 -7
  89. package/dist/core/preview-registry.js +4 -4
  90. package/dist/core/review/realize.js +4 -2
  91. package/dist/core/runtime/close.js +1 -1
  92. package/dist/core/runtime/fleet.d.ts +29 -1
  93. package/dist/core/runtime/fleet.js +23 -0
  94. package/dist/core/runtime/host.js +32 -11
  95. package/dist/core/runtime/launch-prompt.d.ts +1 -1
  96. package/dist/core/runtime/launch-prompt.js +2 -2
  97. package/dist/core/runtime/lifecycle.d.ts +4 -2
  98. package/dist/core/runtime/lifecycle.js +12 -1
  99. package/dist/core/runtime/memory.js +1 -1
  100. package/dist/core/runtime/reopen.js +10 -1
  101. package/dist/core/runtime/reset.js +1 -1
  102. package/dist/core/runtime/revive-all.d.ts +3 -0
  103. package/dist/core/runtime/revive-all.js +10 -4
  104. package/dist/core/runtime/revive.d.ts +12 -0
  105. package/dist/core/runtime/revive.js +62 -3
  106. package/dist/core/runtime/spawn.d.ts +4 -0
  107. package/dist/core/runtime/spawn.js +36 -7
  108. package/dist/core/runtime/tmux-bindings.js +4 -0
  109. package/dist/core/substrate/frontmatter-validation.js +1 -1
  110. package/dist/core/substrate/index.d.ts +6 -3
  111. package/dist/core/substrate/index.js +4 -3
  112. package/dist/core/substrate/on-read.d.ts +0 -8
  113. package/dist/core/substrate/on-read.js +45 -124
  114. package/dist/core/substrate/plan.d.ts +94 -0
  115. package/dist/core/substrate/plan.js +266 -0
  116. package/dist/core/substrate/render.d.ts +5 -6
  117. package/dist/core/substrate/render.js +76 -129
  118. package/dist/core/substrate/schema.d.ts +1 -6
  119. package/dist/core/substrate/schema.js +1 -15
  120. package/dist/core/substrate/session-cache.d.ts +2 -10
  121. package/dist/core/substrate/session-cache.js +9 -47
  122. package/dist/core/substrate/subject-fields.d.ts +1 -4
  123. package/dist/core/substrate/subject-fields.js +2 -5
  124. package/dist/core/substrate/surface-match.d.ts +37 -17
  125. package/dist/core/substrate/surface-match.js +44 -53
  126. package/dist/core/tui/page-host.d.ts +6 -0
  127. package/dist/core/tui/page-host.js +12 -3
  128. package/dist/core/worktree.d.ts +8 -0
  129. package/dist/core/worktree.js +65 -0
  130. package/dist/daemon/__tests__/reconciler-signature.test.js +3 -2
  131. package/dist/daemon/__tests__/thaw-order.test.d.ts +1 -0
  132. package/dist/daemon/__tests__/thaw-order.test.js +55 -0
  133. package/dist/daemon/api/__tests__/canvas-snapshot-fields.test.js +6 -0
  134. package/dist/daemon/api/__tests__/node-create-description.test.js +6 -0
  135. package/dist/daemon/api/__tests__/profile-launch-gates.test.js +1 -1
  136. package/dist/daemon/api/bridge.js +5 -1
  137. package/dist/daemon/api/handlers/attach.js +5 -1
  138. package/dist/daemon/api/handlers/bash-jobs.js +1 -1
  139. package/dist/daemon/api/handlers/messages.js +10 -2
  140. package/dist/daemon/api/handlers/nodes.js +8 -1
  141. package/dist/daemon/api/handlers/worktree.js +10 -1
  142. package/dist/daemon/api/map.js +7 -0
  143. package/dist/daemon/companion-retire.js +1 -1
  144. package/dist/daemon/cron/passes.d.ts +0 -5
  145. package/dist/daemon/cron/passes.js +0 -20
  146. package/dist/daemon/cron/sinks.js +1 -1
  147. package/dist/daemon/cron-run.d.ts +5 -6
  148. package/dist/daemon/cron-run.js +7 -16
  149. package/dist/daemon/crtrd.d.ts +0 -10
  150. package/dist/daemon/crtrd.js +23 -61
  151. package/dist/daemon/fleet.d.ts +10 -7
  152. package/dist/daemon/fleet.js +71 -12
  153. package/dist/daemon/messaging/node-message.js +3 -1
  154. package/dist/daemon/profile-delete.js +1 -1
  155. package/dist/daemon/reconcilers/broker-supervision.d.ts +26 -9
  156. package/dist/daemon/reconcilers/broker-supervision.js +39 -52
  157. package/dist/daemon/reconcilers/controller-death.d.ts +7 -0
  158. package/dist/daemon/reconcilers/controller-death.js +26 -0
  159. package/dist/daemon/reconcilers/cron-lane.d.ts +0 -3
  160. package/dist/daemon/reconcilers/cron-lane.js +0 -3
  161. package/dist/daemon/reconcilers/live-obligation.js +3 -1
  162. package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.d.ts +53 -0
  163. package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +170 -0
  164. package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.d.ts +5 -0
  165. package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.js +16 -0
  166. package/dist/daemon/reconcilers/node-lifecycle/terminating.d.ts +4 -0
  167. package/dist/daemon/reconcilers/node-lifecycle/terminating.js +13 -0
  168. package/dist/daemon/reconcilers/node-lifecycle/tick.d.ts +26 -0
  169. package/dist/daemon/reconcilers/node-lifecycle/tick.js +140 -0
  170. package/package.json +1 -1
  171. package/runtime.lock.json +2 -2
  172. package/dist/commands/sys/prompt-review.d.ts +0 -1
  173. package/dist/commands/sys/sysprompt.d.ts +0 -1
  174. package/dist/core/__tests__/cron-broker-capacity.test.js +0 -119
  175. package/dist/daemon/reconcilers/dormant-inbox.d.ts +0 -18
  176. package/dist/daemon/reconcilers/dormant-inbox.js +0 -157
  177. /package/dist/core/__tests__/{cron-broker-capacity.test.d.ts → cron-self-cancel-delivery.test.d.ts} +0 -0
@@ -22,7 +22,7 @@ import { buildBrokerEnv } from './spawn-env.js';
22
22
  import { hostExecPath } from './branded-host.js';
23
23
  import { isPidAlive, killProcessTreePids, isAnyPidAlive, captureTeardownSnapshot } from '../canvas/pid.js';
24
24
  import { assertMandatoryCanvasExtensions } from './canvas-extensions.js';
25
- import { boundFleet } from './fleet.js';
25
+ import { boundFleet, brokerCapReached, brokerThresholdsForDaemon } from './fleet.js';
26
26
  import { emitEvent } from '../events/emit.js';
27
27
  import { bindBrokerEventSource, eventSource } from '../events/source.js';
28
28
  import { envBrokerEngine } from '../../shared/env.js';
@@ -193,6 +193,19 @@ export const headlessBrokerHost = {
193
193
  launch(nodeId, inv, opts) {
194
194
  // Fast-fail known launch errors before we detach a broker process.
195
195
  preflightBrokerLaunch(nodeId, inv, opts.cwd);
196
+ // Resolve the fleet BEFORE opening any handle: an unbound-fleet launch must
197
+ // fail fast with nothing to clean up (design D-2 — broker launches happen
198
+ // only inside crtrd, or a test harness that explicitly bound a fleet).
199
+ const fleet = boundFleet();
200
+ // The ledger is the ONE admission gate for a broker process: every launch
201
+ // path (revive, spawn, recycle, reset) funnels through here, so claiming
202
+ // the slot before anything is opened — and giving it back on any path that
203
+ // does not end in a registered handle — is what makes the cap real rather
204
+ // than advisory. A caller that cannot proceed without a slot sees this
205
+ // typed refusal; a caller that can wait catches it and freezes instead.
206
+ if (!fleet.reserveSlot(nodeId)) {
207
+ throw brokerCapReached(brokerThresholdsForDaemon().automaticReviveCap, fleet.size());
208
+ }
196
209
  // Redirect the detached broker's stdout+stderr to a per-node log under the
197
210
  // node's existing job/ dir. It retains arbitrary engine/process residue and
198
211
  // failed-canonical fatal fallback output. Append-mode preserves crash-revive history.
@@ -233,10 +246,6 @@ export const headlessBrokerHost = {
233
246
  }
234
247
  if (eventSource() === undefined)
235
248
  bindBrokerEventSource(nodeId);
236
- // Resolve the fleet BEFORE spawning: an unbound-fleet launch must fail
237
- // fast with nothing to clean up (design D-2 — broker launches happen only
238
- // inside crtrd, or a test harness that explicitly bound a fleet).
239
- const fleet = boundFleet();
240
249
  // `--canvas-home` declares broker ownership in argv so the daemon's
241
250
  // startup epoch census can match its own canvas' brokers exactly (crtrd's
242
251
  // brokerCommandDeclaresCanvasHome) instead of inferring from install
@@ -244,12 +253,20 @@ export const headlessBrokerHost = {
244
253
  // daemon's census reaps every prior-epoch broker without adoption (D-8).
245
254
  // Both precede the node id: the node id is by contract the FINAL argv
246
255
  // token (both broker-cli and the ps census parse it that way).
247
- const child = spawn(hostExecPath(), [resolveBrokerEntry(), '--canvas-home', crtrHome(), '--epoch', fleet.epoch(), nodeId], {
248
- cwd: opts.cwd,
249
- detached: true,
250
- stdio: ['ignore', logFd, logFd],
251
- env: childEnv,
252
- });
256
+ let child;
257
+ try {
258
+ child = spawn(hostExecPath(), [resolveBrokerEntry(), '--canvas-home', crtrHome(), '--epoch', fleet.epoch(), nodeId], {
259
+ cwd: opts.cwd,
260
+ detached: true,
261
+ stdio: ['ignore', logFd, logFd],
262
+ env: childEnv,
263
+ });
264
+ }
265
+ catch (error) {
266
+ fleet.releaseReservation(nodeId);
267
+ closeSync(logFd);
268
+ throw error;
269
+ }
253
270
  // The child holds its own dup of the fd; release the parent's copy so the
254
271
  // launching process (CLI or daemon) never leaks it.
255
272
  closeSync(logFd);
@@ -299,6 +316,10 @@ export const headlessBrokerHost = {
299
316
  fleet.register(nodeId, handle);
300
317
  void exited.then((status) => fleet.onChildExit(nodeId, status));
301
318
  }
319
+ else {
320
+ // Nothing was registered, so nothing will ever free this slot at exit.
321
+ fleet.releaseReservation(nodeId);
322
+ }
302
323
  return handle;
303
324
  },
304
325
  isAlive(node) {
@@ -4,5 +4,5 @@
4
4
  * before. ALL runtime/spine/lifecycle/kind prompting now lives as gated builtin
5
5
  * memory docs (src/builtin-memory/), spliced in fresh every turn — this scaffold
6
6
  * must never duplicate that prose. Imported by launch.ts (its own systemPrompt)
7
- * and `sys prompt-review` so the two can never drift. */
7
+ * and `sys context prompt-review` so the two can never drift. */
8
8
  export declare const LAUNCH_SYSTEM_PROMPT: string;
@@ -1,5 +1,5 @@
1
1
  // launch-prompt.ts — the minimal static system-prompt scaffold, isolated from
2
- // launch.ts so CLI consumers (`sys prompt-review`) can import the exact string
2
+ // launch.ts so CLI consumers (`sys context prompt-review`) can import the exact string
3
3
  // boot uses WITHOUT pulling launch.ts's canvas-db reach (editorLabel et al.).
4
4
  // Pure: zero imports.
5
5
  /** The minimal static system prompt every node launches with — a thin launcher
@@ -8,6 +8,6 @@
8
8
  * before. ALL runtime/spine/lifecycle/kind prompting now lives as gated builtin
9
9
  * memory docs (src/builtin-memory/), spliced in fresh every turn — this scaffold
10
10
  * must never duplicate that prose. Imported by launch.ts (its own systemPrompt)
11
- * and `sys prompt-review` so the two can never drift. */
11
+ * and `sys context prompt-review` so the two can never drift. */
12
12
  export const LAUNCH_SYSTEM_PROMPT = 'You are a node in the crtr canvas — an agentic runtime.\n\nGuidelines:\n' +
13
13
  '- Use the crtr canvas and memory substrate according to injected context.';
@@ -1,4 +1,4 @@
1
- import type { NodeMeta, NodeStatus, ExitIntent } from '../canvas/types.js';
1
+ import type { NodeMeta, NodeStatus, ExitIntent, TerminalReason } from '../canvas/types.js';
2
2
  /** The lifecycle events — the only vocabulary for moving a node's status/intent.
3
3
  * Each maps (in the table below) to a target status and/or intent plus the set
4
4
  * of from-statuses it is legal from. */
@@ -9,7 +9,9 @@ export type LifecycleEvent = 'finish' | 'park' | 'cancel' | 'crash' | 'yield' |
9
9
  *
10
10
  * Throws on an unknown node or an illegal move. The conditional update makes
11
11
  * legality authoritative at SQLite write time rather than at a stale read. */
12
- export declare function transition(nodeId: string, event: LifecycleEvent): NodeMeta;
12
+ export declare function transition(nodeId: string, event: LifecycleEvent, opts?: {
13
+ reason?: TerminalReason;
14
+ }): NodeMeta;
13
15
  /** Whether this row is a PARKED node — terminalized by the unattended clock
14
16
  * rather than by finishing its own work. The one read of the park marker:
15
17
  * `done` qualified by `intent='parked'`, both written in the same atomic
@@ -33,6 +33,14 @@
33
33
  // inside `core/canvas/`).
34
34
  import { getNode, cancelCronsOnWake, transitionStatusIntent } from '../canvas/index.js';
35
35
  const ANY = '*';
36
+ const TERMINAL_REASONS = {
37
+ finish: 'finished',
38
+ park: 'parked',
39
+ cancel: 'stranded',
40
+ crash: 'crashed',
41
+ 'refresh-launch': null,
42
+ revive: null,
43
+ };
36
44
  /** The supervised statuses — a live node the daemon watches. */
37
45
  const LIVE = ['active', 'idle'];
38
46
  /** The legal transition table — derived directly from the (status, intent) pairs
@@ -69,15 +77,18 @@ const TRANSITIONS = {
69
77
  *
70
78
  * Throws on an unknown node or an illegal move. The conditional update makes
71
79
  * legality authoritative at SQLite write time rather than at a stale read. */
72
- export function transition(nodeId, event) {
80
+ export function transition(nodeId, event, opts) {
73
81
  const spec = TRANSITIONS[event];
74
82
  const writeStatus = Object.prototype.hasOwnProperty.call(spec, 'status');
75
83
  const writeIntent = Object.prototype.hasOwnProperty.call(spec, 'intent');
84
+ const reason = opts?.reason ?? TERMINAL_REASONS[event];
76
85
  const fields = {};
77
86
  if (writeStatus)
78
87
  fields.status = spec.status;
79
88
  if (writeIntent)
80
89
  fields.intent = spec.intent ?? null;
90
+ if (reason !== undefined)
91
+ fields.terminal_reason = reason;
81
92
  // A committed canonical result is protected from a concurrent close.
82
93
  const changes = transitionStatusIntent(nodeId, fields, {
83
94
  fromStatuses: spec.from === ANY ? undefined : spec.from,
@@ -8,7 +8,7 @@ import { join } from 'node:path';
8
8
  // Import the pure path helper from paths.ts directly, NOT the canvas/index.js
9
9
  // barrel: the barrel re-exports `openDb`/canvas.ts accessors, so pulling it here
10
10
  // would drag canvas.db into the runtime graph of every pure-client CLI leaf that
11
- // resolves memory docs (e.g. `memory origin`, `sys prompt-review`). Stage B-9.
11
+ // resolves memory docs (e.g. `memory origin`, `sys context prompt-review`). Stage B-9.
12
12
  import { contextDir } from '../canvas/paths.js';
13
13
  /** The node-local memory directory in a node's context dir — holds substrate
14
14
  * docs (.md files with kind/when/why frontmatter). */
@@ -38,8 +38,9 @@
38
38
  // mutating the live pointer — the explicit operation must not retract a
39
39
  // canonical final unless it can actually enact the requested re-task.
40
40
  import { join } from 'node:path';
41
- import { clearFinalReportIfMatches, getFinalReport, reportsDir, updateNode } from '../canvas/index.js';
41
+ import { clearFinalReportIfMatches, getFinalReport, getNode, reportsDir, updateNode } from '../canvas/index.js';
42
42
  import { InputError } from '../io.js';
43
+ import { transition } from './lifecycle.js';
43
44
  function currentFinalReport(nodeId) {
44
45
  return getFinalReport(nodeId);
45
46
  }
@@ -106,6 +107,14 @@ export function commitReopen(nodeId, expectedFinalReport) {
106
107
  export function commitReopenResident(nodeId) {
107
108
  const finalReport = currentFinalReport(nodeId);
108
109
  updateNode(nodeId, { lifecycle: 'resident' });
110
+ // A reopened node that is still parked in a terminal status has to come back
111
+ // to `active`, or its row keeps claiming it is finished while a broker runs
112
+ // its new mandate. Gated on terminal so a message landing on a live node
113
+ // never rewrites the intent it is working under.
114
+ const meta = getNode(nodeId);
115
+ if (meta !== null && (meta.status === 'done' || meta.status === 'canceled' || meta.status === 'dead')) {
116
+ transition(nodeId, 'revive');
117
+ }
109
118
  if (finalReport !== null)
110
119
  commitReopen(nodeId, finalReport);
111
120
  }
@@ -50,7 +50,7 @@ export function reapDescendants(rootId) {
50
50
  // sole .jsonl writer; tearDownNode then proactively closes the on-screen
51
51
  // viewer pane + registry row (attach auto-reconnects, so the viewer must be
52
52
  // closed here or it lingers ~30s in a misleading "reconnecting…" state).
53
- transition(id, 'cancel');
53
+ transition(id, 'cancel', { reason: 'closed' });
54
54
  headlessBrokerHost.teardown(id);
55
55
  tearDownNode(id);
56
56
  reaped.push(id);
@@ -52,6 +52,9 @@ export interface ReviveAllResult {
52
52
  node_id: string;
53
53
  error: string;
54
54
  }[];
55
+ /** Node ids the cap turned away. They are not failures: each row is frozen
56
+ * and the daemon's tick relaunches it as slots free. */
57
+ frozen: string[];
55
58
  }
56
59
  /** RESUME every disconnected node. reviveNode is the ONLY sanctioned launcher
57
60
  * and self-guards the double-spawn (a node whose broker pid is already live is a
@@ -92,13 +92,19 @@ export function listDisconnected() {
92
92
  * with resume:true. One failing node never aborts the sweep — its error is
93
93
  * collected and the rest proceed. */
94
94
  export function reviveAll() {
95
- const result = { revived: [], failed: [] };
95
+ const result = { revived: [], failed: [], frozen: [] };
96
96
  for (const meta of listDisconnected()) {
97
97
  try {
98
98
  // Reconnecting a disconnected broker is recovery, not a wake — a node
99
- // waiting on an armed deadline keeps it across the sweep.
100
- reviveNode(meta.node_id, { resume: true, recovery: true });
101
- result.revived.push(meta.node_id);
99
+ // waiting on an armed deadline keeps it across the sweep. A sweep over a
100
+ // whole canvas is the likeliest way to meet the cap, and every node in it
101
+ // is one the daemon wants back: freeze the overflow and let the tick
102
+ // bring them up as slots free.
103
+ const outcome = reviveNode(meta.node_id, { resume: true, recovery: true, capacity: 'freeze' }).outcome;
104
+ if (outcome === 'frozen')
105
+ result.frozen.push(meta.node_id);
106
+ else
107
+ result.revived.push(meta.node_id);
102
108
  }
103
109
  catch (err) {
104
110
  result.failed.push({ node_id: meta.node_id, error: err.message });
@@ -51,7 +51,18 @@ export interface ReviveResult {
51
51
  * Present only when a fresh launch actually happened (absent on the
52
52
  * double-revive no-op, where nothing was cleared or relaunched). */
53
53
  faultExplanation?: () => string | null;
54
+ /** What this call actually did. `already-live` means a broker was already
55
+ * running for the node and nothing was launched; `frozen` means no slot was
56
+ * free and the row is now waiting for one — the tick thaws it. */
57
+ outcome: ReviveOutcome;
54
58
  }
59
+ export type ReviveOutcome = 'launched' | 'already-live' | 'frozen';
60
+ /** What a caller wants when the broker cap is full. `freeze` suits a caller
61
+ * whose reason to run survives waiting (durable mail, a cron sink, a recovery
62
+ * respawn): the row is marked frozen and the tick relaunches it when a slot
63
+ * frees. `refuse` suits a caller that must have a live broker NOW (an attach,
64
+ * a bridge, a companion bind) and can only report the refusal. */
65
+ export type ReviveCapacityPolicy = 'freeze' | 'refuse';
55
66
  /** Relaunch `nodeId`'s broker engine from its persisted recipe and update canvas
56
67
  * meta. Opens no viewer (engine-only).
57
68
  *
@@ -66,6 +77,7 @@ export interface ReviveResult {
66
77
  */
67
78
  export declare function reviveNode(nodeId: string, opts: {
68
79
  resume: boolean;
80
+ capacity: ReviveCapacityPolicy;
69
81
  wakeReason?: ReviveWakeReason;
70
82
  recovery?: boolean;
71
83
  }): ReviveResult;
@@ -24,7 +24,8 @@
24
24
  // reviveNode remains the ONLY sanctioned launcher of the node engine.
25
25
  import { existsSync } from 'node:fs';
26
26
  import { isAbsolute } from 'node:path';
27
- import { findNodeBySessionFile, getNode, updateNode, clearPid, recordPid, fullName, subscribersOf, } from '../canvas/index.js';
27
+ import { findNodeBySessionFile, getNode, updateNode, clearPid, recordPid, setFrozen, clearFrozen, fullName, subscribersOf, } from '../canvas/index.js';
28
+ import { emitEvent } from '../events/emit.js';
28
29
  import { cancelCronsOnWake } from '../canvas/crons.js';
29
30
  import { isParked, transition } from './lifecycle.js';
30
31
  import { fanDoctrineWake } from './close.js';
@@ -34,7 +35,7 @@ import { buildReviveKickoff, drainBearings, readGoal, readYieldMessage, RUNTIME_
34
35
  import { readRoadmap } from './roadmap.js';
35
36
  import { hasUnseenWakeMail } from '../feed/inbox.js';
36
37
  import { headlessBrokerHost } from './host.js';
37
- import { boundFleet } from './fleet.js';
38
+ import { boundFleet, brokerCapReached, brokerThresholdsForDaemon } from './fleet.js';
38
39
  import { clearFault, beginBootFaultAttempt } from './fault.js';
39
40
  import { clearContextExposureState, initializeForkContextExposure, } from '../substrate/injected-store.js';
40
41
  import { rootOfSpine } from './nodes.js';
@@ -126,13 +127,67 @@ export function reviveNode(nodeId, opts) {
126
127
  // check before the first's registration lands. A node with a live fleet
127
128
  // entry was already revived by another path — re-launching would put a
128
129
  // SECOND broker on the same session file.
129
- if (boundFleet().has(nodeId)) {
130
+ const fleet = boundFleet();
131
+ if (fleet.has(nodeId)) {
132
+ // The node is already running, but the ROW may still say terminal: a reopen
133
+ // or a retask lands on a live broker, and only this transition puts the row
134
+ // back where its liveness already is. Both specs accept any prior status,
135
+ // so this is a no-op for a row that is already active.
136
+ transition(nodeId, meta.intent === 'refresh' ? 'refresh-launch' : 'revive');
130
137
  return {
131
138
  window: null,
132
139
  session: meta.tmux_session ?? null,
133
140
  resumed: false,
141
+ outcome: 'already-live',
134
142
  };
135
143
  }
144
+ // The cap, consulted BEFORE any mutation below: a node that cannot get a
145
+ // broker must not have its cycle counter bumped, its session identity
146
+ // cleared, or its bearings drained on the way to being turned away.
147
+ if (!fleet.reserveSlot(nodeId)) {
148
+ if (opts.capacity === 'refuse') {
149
+ throw brokerCapReached(brokerThresholdsForDaemon().automaticReviveCap, fleet.size());
150
+ }
151
+ // Freezing is durable and once-per-episode: the mark stays until a launch
152
+ // clears it, so a node frozen across many saturated ticks announces itself
153
+ // exactly once and keeps its original wait timestamp (the thaw order reads
154
+ // it).
155
+ if (meta.frozen_at == null) {
156
+ setFrozen(nodeId, new Date().toISOString());
157
+ // The row holds no engine and is not getting one this pass, so a pid left
158
+ // over from the instance that just died would read as stale-active on
159
+ // every status surface. Clearing it is what makes `isFrozenRow` true and
160
+ // what tells the tick this row has never launched OR has already been
161
+ // written off, so the freeze lane owes it a launch rather than a verdict.
162
+ clearPid(nodeId);
163
+ emitEvent({
164
+ level: 'info',
165
+ event: 'broker.frozen',
166
+ node_id: nodeId,
167
+ fields: { live_count: fleet.size(), hard_cap: brokerThresholdsForDaemon().automaticReviveCap },
168
+ });
169
+ }
170
+ return {
171
+ window: null,
172
+ session: meta.tmux_session ?? null,
173
+ resumed: false,
174
+ outcome: 'frozen',
175
+ };
176
+ }
177
+ try {
178
+ return launchRevive(nodeId, meta, opts);
179
+ }
180
+ finally {
181
+ // The slot is either consumed by the fleet registration inside `launch`
182
+ // (making this a no-op) or given back here — the one release path covering
183
+ // every preflight refusal and launch failure between the claim and the
184
+ // registration.
185
+ fleet.releaseReservation(nodeId);
186
+ }
187
+ }
188
+ /** The launch itself, from the cwd preflight through `recordPid`. Split out so
189
+ * the capacity claim above has exactly one release path around it. */
190
+ function launchRevive(nodeId, meta, opts) {
136
191
  // Preflight, before any state mutation below: a recorded cwd that no longer
137
192
  // exists (e.g. a closed managed worktree whose tombstone dir was later
138
193
  // reaped) can never host a launch. Fail loud with a typed, user-visible
@@ -345,11 +400,15 @@ export function reviveNode(nodeId, opts) {
345
400
  // and status surfaces (canvas views, the daemon's identity rechecks at
346
401
  // signal boundaries) still read it.
347
402
  recordPid(nodeId, launched.pid);
403
+ // A launch is the definition of not-frozen, and every launcher passes
404
+ // through here — so this is the one place the wait mark is retired.
405
+ clearFrozen(nodeId);
348
406
  return {
349
407
  window: null,
350
408
  session: meta.tmux_session ?? null,
351
409
  resumed: resuming,
352
410
  launch: launched,
353
411
  faultExplanation: bootFault.explanation,
412
+ outcome: 'launched',
354
413
  };
355
414
  }
@@ -95,6 +95,10 @@ export declare function assertLaunchModelRegistered(request: ModelRequest | unde
95
95
  }, registry?: ModelRegistryLike): Promise<void>;
96
96
  export interface SpawnChildResult {
97
97
  node: NodeMeta;
98
+ /** The birth found the broker cap full: the row exists and is marked frozen,
99
+ * but no engine was launched. The node-lifecycle tick starts it when a slot
100
+ * frees. */
101
+ frozen: boolean;
98
102
  /** The viewer window opened for a --root in tmux; null otherwise (a managed
99
103
  * child opens no viewer, and a --root spawned outside tmux opens none). */
100
104
  window: string | null;
@@ -21,7 +21,9 @@ 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 { canonicalSessionFile, contextDir, findNodeBySessionFile, getNode, fullName, recordPid } from '../canvas/index.js';
24
+ import { canonicalSessionFile, contextDir, findNodeBySessionFile, getNode, fullName, recordPid, setFrozen } from '../canvas/index.js';
25
+ import { boundFleet, brokerThresholdsForDaemon } from './fleet.js';
26
+ import { emitEvent } from '../events/emit.js';
25
27
  import { jobDir } from '../canvas/paths.js';
26
28
  import { openViewerWindow, focusOf, windowOfPane, } from './placement.js';
27
29
  import { waitForBrokerViewSocket } from './placement-tmux.js';
@@ -335,16 +337,43 @@ export async function spawnChildPrepared(opts, beforeBrokerLaunch) {
335
337
  // Snapshot the boot-fault attempt the instant before launch — see
336
338
  // beginBootFaultAttempt — so a later diagnostic read (root+await only, below)
337
339
  // can only ever surface a fault THIS launch recorded, never a stale one.
340
+ // Claim the slot BEFORE the launch: `launchBrokerOrCrash` turns any launch
341
+ // failure — including the ledger's refusal — into a crashed row, and a birth
342
+ // that merely has to wait for capacity must not be crashed. Consulting the
343
+ // ledger here keeps the freeze decision ahead of that conversion. The
344
+ // reservation is consumed by the fleet's own `register` on a successful
345
+ // launch, so the release below is a no-op then and a give-back on any path
346
+ // that never registered a handle.
347
+ const fleet = boundFleet();
348
+ if (!fleet.reserveSlot(meta.node_id)) {
349
+ // Freezing is durable and once-per-episode; a birth is by definition the
350
+ // first, so the mark and its announcement always land here.
351
+ setFrozen(meta.node_id, new Date().toISOString());
352
+ emitEvent({
353
+ level: 'info',
354
+ event: 'broker.frozen',
355
+ node_id: meta.node_id,
356
+ fields: { live_count: fleet.size(), hard_cap: brokerThresholdsForDaemon().automaticReviveCap },
357
+ });
358
+ // No engine, so no readiness to wait for and no viewer to place.
359
+ return { node: getNode(meta.node_id), frozen: true, window: null, session: null };
360
+ }
338
361
  const bootFault = beginBootFaultAttempt(meta.node_id);
339
362
  // Broker is the only host: launch the detached broker ENGINE. A managed child
340
363
  // gets NO viewer — spawning it returns once the engine is launched and a human
341
364
  // opens a viewer on demand via `crtr surface node focus` / surface attach. A --root gets
342
365
  // a foreground viewer below.
343
- const placed = launchBrokerOrCrash(meta.node_id, inv, {
344
- cwd: meta.cwd,
345
- name: fullName(meta),
346
- resuming: false,
347
- });
366
+ let placed;
367
+ try {
368
+ placed = launchBrokerOrCrash(meta.node_id, inv, {
369
+ cwd: meta.cwd,
370
+ name: fullName(meta),
371
+ resuming: false,
372
+ });
373
+ }
374
+ finally {
375
+ fleet.releaseReservation(meta.node_id);
376
+ }
348
377
  // Definitive failure: no broker pid ⇒ the node has no engine. Crash it (so the
349
378
  // daemon doesn't watch a zombie 'active') and throw so `crtr node new` exits
350
379
  // non-zero. transition('crash') is a legal from-LIVE move (still 'active' from
@@ -412,7 +441,7 @@ export async function spawnChildPrepared(opts, beforeBrokerLaunch) {
412
441
  catch { /* best-effort — a headless spawn must never fail on viewer placement */ }
413
442
  }
414
443
  const saved = getNode(meta.node_id);
415
- return { node: saved, window, session };
444
+ return { node: saved, frozen: false, window, session };
416
445
  }
417
446
  catch (err) {
418
447
  // Cleanup scope: a managed worktree is only removed on failure while NO
@@ -36,6 +36,9 @@ const LEAF_MENU_ACTIONS = {
36
36
  // default dialog-sized popup clipped both the rail and the models matrix.
37
37
  'crtr.tmux.menu.providers': { description: 'settings', action: { kind: 'popup', run: 'sys settings', size: { w: '90%', h: '85%' } } },
38
38
  'crtr.tmux.menu.plugins': { description: 'plugins', action: { kind: 'popup', run: 'pkg browse', size: { w: '90%', h: '85%' } } },
39
+ // Three zones side by side; the snapshot rail and document list both lose
40
+ // usable width before the dossier does, so this takes the settings size.
41
+ 'crtr.tmux.menu.context-admin': { description: 'context surface', action: { kind: 'popup', run: 'sys context admin', size: { w: '90%', h: '85%' } } },
39
42
  'crtr.tmux.menu.graph': { description: 'canvas graph', action: { kind: 'keys', keys: '/graph' } },
40
43
  'crtr.tmux.menu.focus-manager': { description: 'go to manager', action: { kind: 'command', run: 'surface node focus {manager}' } },
41
44
  'crtr.tmux.menu.copy-id': { description: 'copy node id', action: { kind: 'command', run: 'surface node id --pane {pane}' } },
@@ -307,6 +310,7 @@ function menuItems(bindings, entries) {
307
310
  addGroup('System', boundItems([
308
311
  'crtr.tmux.menu.providers',
309
312
  'crtr.tmux.menu.plugins',
313
+ 'crtr.tmux.menu.context-admin',
310
314
  'crtr.tmux.menu.issues',
311
315
  ]));
312
316
  const prefixGestures = bindings.gestures('crtr.tmux.menu.open');
@@ -1,7 +1,7 @@
1
1
  import { isDocKind, parseSubstrateFrontmatter, SURFACE_EVENTS, SURFACE_RUNGS } from './schema.js';
2
2
  const RETIRED_FIELDS = ['system-prompt-visibility', 'file-read-visibility', 'applies-to', 'read-when'];
3
3
  const SURFACE_ENTRY_KEYS = ['on', 'match', 'match-frontmatter', 'gate', 'at'];
4
- export const SUPPRESSIBLE_RULES = ['length', 'broad-memory-read'];
4
+ export const SUPPRESSIBLE_RULES = ['length', 'broad-memory-read', 'orphan'];
5
5
  /** Strict validation of one raw routing entry. Runtime parsing deliberately
6
6
  * tolerates malformed docs; this authoring/preflight contract does not. */
7
7
  export function lintSubstrateSurfaces(value) {
@@ -1,7 +1,10 @@
1
- export { KINDS, isDocKind, RUNGS, rungRank, rungAtLeast, SURFACE_EVENTS, SURFACE_RUNGS, bootRung, parseSubstrateFrontmatter, parseSubstrateDoc, previewLine, normalizeNameSegment, normalizeDocName, resolveDocName, } from './schema.js';
1
+ export { KINDS, isDocKind, RUNGS, rungRank, rungAtLeast, SURFACE_EVENTS, SURFACE_RUNGS, parseSubstrateFrontmatter, parseSubstrateDoc, previewLine, normalizeNameSegment, normalizeDocName, resolveDocName, } from './schema.js';
2
2
  export type { DocKind, Rung, GatePredicate, SubstrateSchema, SubstrateDoc, SurfaceEntry, SurfaceEvent, SurfaceRung } from './schema.js';
3
3
  export { lintSubstrateFrontmatter, lintSubstrateSchema, lintSubstrateSurfaces, parsedSubstrateSurfaces } from './frontmatter-validation.js';
4
- export { matchesReadEntry, matchesMemoryReadEntry, matchesCommandEntry, readDeliveryRung, workspaceOpenRung, memoryReadDeliveryRung, commandDeliveryRung, owningRootOf, underOwningRoot } from './surface-match.js';
4
+ export { matchesReadEntry, matchesMemoryReadEntry, matchesCommandEntry, readDeliveryRung, workspaceOpenRung, memoryReadDeliveryRung, commandDeliveryRung, matchingSurfaceEntry, surfaceEntryParticipates, owningRootOf, underOwningRoot } from './surface-match.js';
5
+ export type { SurfaceMatchPayload } from './surface-match.js';
6
+ export { planDelivery, bootPartitionOf } from './plan.js';
7
+ export type { DeliveryPlan, DeliveryRecord, DeliveryPayload, EntryDecision, GateOutcome } from './plan.js';
5
8
  export { dirDedupKey, docsByName, isDirName, ancestorDirsOf, renderDirListing } from './listings.js';
6
9
  export { clearContextExposureState, cloneContextExposureState, cloneTranscriptExposureState, contentExposureIdentity, contextExposureStatesEqual, documentExposedAtOrAbove, emptyContextExposureState, exposedAtOrAbove, exposureTarget, freezePreferenceSnapshot, hasTranscriptExposure, initializeForkContextExposure, loadContextExposureState, mergeContextExposureState, registerDocumentExposure, registerExposure, replaceContextExposureState, saveContextExposureState, sharedContextExposureState, } from './injected-store.js';
7
10
  export type { ContextExposureState, ExposureSource, ExposureTarget } from './injected-store.js';
@@ -11,5 +14,5 @@ export { spineDepth, assembleNodeSubject } from './subject.js';
11
14
  export { gatePasses } from './gate.js';
12
15
  export { renderPreferencesForSubject, renderKnowledgeForSubject } from './render.js';
13
16
  export { renderPreferencesSection, renderKnowledgeBlock } from './render-node.js';
14
- export { renderOnReadDocsForSubject, renderWorkspaceOpenDocsForSubject, renderOnCommandDocsForSubject, renderCandidateBlocks, memoryReadDocBlocks } from './on-read.js';
17
+ export { renderOnReadDocsForSubject, renderWorkspaceOpenDocsForSubject, renderOnCommandDocsForSubject, memoryReadDocBlocks } from './on-read.js';
15
18
  export { renderOnReadDocs, renderWorkspaceOpenDocs } from './on-read-node.js';
@@ -9,7 +9,7 @@ KINDS, isDocKind,
9
9
  // ladder
10
10
  RUNGS, rungRank, rungAtLeast,
11
11
  // surfaces (event routing)
12
- SURFACE_EVENTS, SURFACE_RUNGS, bootRung,
12
+ SURFACE_EVENTS, SURFACE_RUNGS,
13
13
  // parse + render-shared helpers
14
14
  parseSubstrateFrontmatter, parseSubstrateDoc, previewLine,
15
15
  // display-name normalization
@@ -17,7 +17,8 @@ normalizeNameSegment, normalizeDocName,
17
17
  // substrate identity (explicit-name-then-path-fallback)
18
18
  resolveDocName, } from './schema.js';
19
19
  export { lintSubstrateFrontmatter, lintSubstrateSchema, lintSubstrateSurfaces, parsedSubstrateSurfaces } from './frontmatter-validation.js';
20
- export { matchesReadEntry, matchesMemoryReadEntry, matchesCommandEntry, readDeliveryRung, workspaceOpenRung, memoryReadDeliveryRung, commandDeliveryRung, owningRootOf, underOwningRoot } from './surface-match.js';
20
+ export { matchesReadEntry, matchesMemoryReadEntry, matchesCommandEntry, readDeliveryRung, workspaceOpenRung, memoryReadDeliveryRung, commandDeliveryRung, matchingSurfaceEntry, surfaceEntryParticipates, owningRootOf, underOwningRoot } from './surface-match.js';
21
+ export { planDelivery, bootPartitionOf } from './plan.js';
21
22
  export { dirDedupKey, docsByName, isDirName, ancestorDirsOf, renderDirListing } from './listings.js';
22
23
  export { clearContextExposureState, cloneContextExposureState, cloneTranscriptExposureState, contentExposureIdentity, contextExposureStatesEqual, documentExposedAtOrAbove, emptyContextExposureState, exposedAtOrAbove, exposureTarget, freezePreferenceSnapshot, hasTranscriptExposure, initializeForkContextExposure, loadContextExposureState, mergeContextExposureState, registerDocumentExposure, registerExposure, replaceContextExposureState, saveContextExposureState, sharedContextExposureState, } from './injected-store.js';
23
24
  export { scopeForCwd, profileNameFor } from './subject-fields.js';
@@ -25,5 +26,5 @@ export { spineDepth, assembleNodeSubject } from './subject.js';
25
26
  export { gatePasses } from './gate.js';
26
27
  export { renderPreferencesForSubject, renderKnowledgeForSubject } from './render.js';
27
28
  export { renderPreferencesSection, renderKnowledgeBlock } from './render-node.js';
28
- export { renderOnReadDocsForSubject, renderWorkspaceOpenDocsForSubject, renderOnCommandDocsForSubject, renderCandidateBlocks, memoryReadDocBlocks } from './on-read.js';
29
+ export { renderOnReadDocsForSubject, renderWorkspaceOpenDocsForSubject, renderOnCommandDocsForSubject, memoryReadDocBlocks } from './on-read.js';
29
30
  export { renderOnReadDocs, renderWorkspaceOpenDocs } from './on-read-node.js';
@@ -1,12 +1,5 @@
1
1
  import { type ContextExposureState, type ExposureTarget } from './injected-store.js';
2
- import { type Rung, type SubstrateDoc } from './schema.js';
3
2
  import type { NodeConfigSubject } from './subject-fields.js';
4
- interface EventCandidate {
5
- doc: SubstrateDoc;
6
- rung: Rung;
7
- }
8
- /** Render the selected candidates not already loaded at their matched rung. */
9
- export declare function renderCandidateBlocks(subject: NodeConfigSubject | null, candidates: EventCandidate[], target: ExposureTarget): string[];
10
3
  /** Surface docs whose `read` entries match a successfully read file, using
11
4
  * the caller-supplied daemon snapshot subject. */
12
5
  export declare function renderOnReadDocsForSubject(subject: NodeConfigSubject, readFilePath: string, target?: ExposureTarget): string;
@@ -46,4 +39,3 @@ export declare function corpusHasPreCommandSurfaces(): boolean;
46
39
  * held once and then silently absent for the rest of the session. Lives here
47
40
  * rather than in the extension because the corpus accessor is private. */
48
41
  export declare function demotePreCommandExposures(state: ContextExposureState): void;
49
- export {};