@north-light/crouter 0.3.240 → 0.3.242

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 (76) hide show
  1. package/dist/api/dto/broker-ops.d.ts +8 -3
  2. package/dist/api/dto/broker.d.ts +4 -2
  3. package/dist/api/dto/inbox.d.ts +1 -0
  4. package/dist/api/dto/nodes.d.ts +4 -0
  5. package/dist/api/dto/review-comments.d.ts +1 -0
  6. package/dist/builtin-memory/04-orchestration-kernel.md +1 -1
  7. package/dist/clients/attach/session/profile-files.js +4 -1
  8. package/dist/clients/attach/viewer.js +366 -366
  9. package/dist/commands/node/create.js +8 -3
  10. package/dist/commands/node/inspect.js +1 -1
  11. package/dist/commands/sys/context/admin/detail-panel.d.ts +3 -0
  12. package/dist/commands/sys/context/admin/detail-panel.js +32 -3
  13. package/dist/commands/sys/context/admin/docs-panel.d.ts +23 -13
  14. package/dist/commands/sys/context/admin/docs-panel.js +101 -103
  15. package/dist/commands/sys/context/admin/list-view.d.ts +53 -0
  16. package/dist/commands/sys/context/admin/list-view.js +125 -0
  17. package/dist/commands/sys/context/admin/model.d.ts +11 -0
  18. package/dist/commands/sys/context/admin/model.js +28 -1
  19. package/dist/commands/sys/context/admin/rail-panel.d.ts +46 -0
  20. package/dist/commands/sys/context/admin/{axes-panel.js → rail-panel.js} +64 -21
  21. package/dist/commands/sys/context/admin/shell.d.ts +19 -2
  22. package/dist/commands/sys/context/admin/shell.js +142 -46
  23. package/dist/commands/sys/daemon.js +1 -1
  24. package/dist/core/__tests__/child-death-wake.test.js +0 -48
  25. package/dist/core/__tests__/daemon-boot.test.js +0 -8
  26. package/dist/core/__tests__/dead-node-policy-table.test.js +1 -2
  27. package/dist/core/__tests__/integration/deferred-no-wake.test.js +14 -2
  28. package/dist/core/__tests__/lifecycle.test.js +5 -5
  29. package/dist/core/__tests__/relaunch-root.test.js +91 -1
  30. package/dist/core/__tests__/revive-capacity.test.js +36 -1
  31. package/dist/core/__tests__/seam/broker-cap-freeze.test.js +73 -0
  32. package/dist/core/__tests__/seam/dormancy-release.test.js +1 -2
  33. package/dist/core/canvas/browse/model.d.ts +0 -9
  34. package/dist/core/canvas/browse/model.js +1 -37
  35. package/dist/core/canvas/browse/render.js +2 -1
  36. package/dist/core/canvas/canvas.d.ts +6 -5
  37. package/dist/core/canvas/canvas.js +8 -7
  38. package/dist/core/canvas/types.d.ts +6 -0
  39. package/dist/core/review/realize.js +1 -1
  40. package/dist/core/runtime/broker/node-named.d.ts +1 -0
  41. package/dist/core/runtime/host.js +114 -103
  42. package/dist/core/runtime/lifecycle.js +3 -2
  43. package/dist/core/runtime/naming.d.ts +26 -8
  44. package/dist/core/runtime/naming.js +75 -43
  45. package/dist/core/runtime/reset.js +12 -5
  46. package/dist/core/runtime/revive-all.d.ts +4 -11
  47. package/dist/core/runtime/revive-all.js +6 -13
  48. package/dist/core/runtime/tmux-bindings.js +4 -3
  49. package/dist/core/runtime/warm-pool.js +4 -4
  50. package/dist/core/tui/fuzzy.d.ts +9 -0
  51. package/dist/core/tui/fuzzy.js +38 -0
  52. package/dist/daemon/api/__tests__/node-create-description.test.js +1 -0
  53. package/dist/daemon/api/handlers/broker-ops.js +14 -7
  54. package/dist/daemon/api/handlers/feedback-comments.js +1 -1
  55. package/dist/daemon/api/map.js +2 -0
  56. package/dist/daemon/crtrd.js +17 -20
  57. package/dist/daemon/messaging/node-message.d.ts +1 -0
  58. package/dist/daemon/messaging/node-message.js +6 -2
  59. package/dist/daemon/reconcilers/live-obligation.d.ts +1 -2
  60. package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.d.ts +0 -5
  61. package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +9 -13
  62. package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.js +0 -6
  63. package/dist/daemon/reconcilers/node-lifecycle/terminating.d.ts +0 -2
  64. package/dist/daemon/reconcilers/node-lifecycle/terminating.js +0 -10
  65. package/dist/daemon/reconcilers/node-lifecycle/tick.d.ts +2 -2
  66. package/dist/daemon/reconcilers/node-lifecycle/tick.js +4 -2
  67. package/dist/daemon/reconcilers/storage-maintenance.js +2 -2
  68. package/dist/daemon/review/comment-notify.d.ts +1 -0
  69. package/dist/daemon/review/comment-notify.js +1 -0
  70. package/dist/pi-extensions/broker-local.d.ts +1 -0
  71. package/dist/pi-extensions/broker-local.js +12 -7
  72. package/dist/pi-extensions/canvas-recap.js +1 -0
  73. package/dist/pi-extensions/canvas-stophook.js +2 -0
  74. package/package.json +1 -1
  75. package/runtime.lock.json +2 -2
  76. package/dist/commands/sys/context/admin/axes-panel.d.ts +0 -21
@@ -50,10 +50,10 @@
50
50
  // different directory than the last one simply misses and cold-spawns — the
51
51
  // pool serves the common case of returning to the same working directory.
52
52
  //
53
- // Nothing ever RESUMES a spare: the daemon's recovery sweep reads `listNodes`,
54
- // which hides the pool, so a spare whose broker dies (every daemon handover
55
- // tears down every broker of the epoch) is garbage, not a parked node. It is
56
- // also invisible to the count-based prune for the same reason. So the pool owns
53
+ // Nothing ever resumes a spare: the daemon's row query hides the pool, so a
54
+ // spare whose broker dies (every daemon handover tears down every broker of the
55
+ // epoch) is garbage, not a parked node. It is also invisible to the count-based
56
+ // prune for the same reason. So the pool owns
57
57
  // its own garbage collection — `reapStaleSpares`, below, run at daemon start
58
58
  // and on every supervision tick.
59
59
  import { createHash } from 'node:crypto';
@@ -0,0 +1,9 @@
1
+ /** Case-insensitive subsequence match: every char of `query` appears in `text`
2
+ * in order (gaps allowed). Empty query matches everything. Substrings are a
3
+ * subsequence, so this subsumes substring matching too. */
4
+ export declare function fuzzyMatch(query: string, text: string): boolean;
5
+ /** Indices in `text` consumed by a greedy left-to-right subsequence match of
6
+ * `query` — the same walk as `fuzzyMatch`, but returning WHICH chars matched so
7
+ * the renderer can highlight them. Empty set when `query` is empty OR does not
8
+ * fully match (no partial highlights). */
9
+ export declare function matchIndices(query: string, text: string): Set<number>;
@@ -0,0 +1,38 @@
1
+ // Subsequence matching for TUI filters. One definition, shared by the canvas
2
+ // browser and the context admin list, so the two surfaces cannot drift apart.
3
+ /** Case-insensitive subsequence match: every char of `query` appears in `text`
4
+ * in order (gaps allowed). Empty query matches everything. Substrings are a
5
+ * subsequence, so this subsumes substring matching too. */
6
+ export function fuzzyMatch(query, text) {
7
+ if (query === '')
8
+ return true;
9
+ const q = query.toLowerCase();
10
+ const t = text.toLowerCase();
11
+ let qi = 0;
12
+ for (let ti = 0; ti < t.length && qi < q.length; ti++) {
13
+ if (t[ti] === q[qi])
14
+ qi++;
15
+ }
16
+ return qi === q.length;
17
+ }
18
+ /** Indices in `text` consumed by a greedy left-to-right subsequence match of
19
+ * `query` — the same walk as `fuzzyMatch`, but returning WHICH chars matched so
20
+ * the renderer can highlight them. Empty set when `query` is empty OR does not
21
+ * fully match (no partial highlights). */
22
+ export function matchIndices(query, text) {
23
+ const out = new Set();
24
+ if (query === '')
25
+ return out;
26
+ const q = query.toLowerCase();
27
+ const t = text.toLowerCase();
28
+ let qi = 0;
29
+ for (let ti = 0; ti < t.length && qi < q.length; ti++) {
30
+ if (t[ti] === q[qi]) {
31
+ out.add(ti);
32
+ qi++;
33
+ }
34
+ }
35
+ if (qi < q.length)
36
+ out.clear(); // no full match → no highlight
37
+ return out;
38
+ }
@@ -78,6 +78,7 @@ test('node create stores caller description and automatic naming leaves it and n
78
78
  const generated = await generatedNameRoute.handler(context('POST', `/v1/nodes/${nodeId}/broker/generated-name`, nodeId, {
79
79
  kind: 'initial',
80
80
  description: 'Generated replacement',
81
+ title: 'Generated replacement',
81
82
  icon: '',
82
83
  }));
83
84
  assert.deepEqual(generated.body, { applied: false });
@@ -88,6 +88,7 @@ function extensionNode(node) {
88
88
  node_id: node.node_id,
89
89
  name: node.name,
90
90
  ...(node.description === undefined ? {} : { description: node.description }),
91
+ ...(node.title === undefined ? {} : { title: node.title }),
91
92
  ...(node.icon === undefined ? {} : { icon: node.icon }),
92
93
  kind: node.kind,
93
94
  mode: node.mode,
@@ -153,17 +154,18 @@ function parseGeneratedNameBody(body) {
153
154
  const value = objectBody(body, 'broker generated-name');
154
155
  if (value['kind'] !== 'initial' && value['kind'] !== 'recap')
155
156
  throw usage('kind must be initial or recap');
156
- if (typeof value['description'] !== 'string' || typeof value['icon'] !== 'string') {
157
- throw usage('description and icon must be strings');
157
+ if (typeof value['description'] !== 'string' || typeof value['title'] !== 'string' || typeof value['icon'] !== 'string') {
158
+ throw usage('description, title, and icon must be strings');
159
+ }
160
+ if (value['kind'] === 'initial') {
161
+ return { kind: 'initial', description: value['description'], title: value['title'], icon: value['icon'] };
158
162
  }
159
- if (value['kind'] === 'initial')
160
- return { kind: 'initial', description: value['description'], icon: value['icon'] };
161
163
  const expected = value['expected'];
162
164
  if (typeof expected !== 'object' || expected === null || Array.isArray(expected)
163
- || ['name', 'description', 'icon', 'kind'].some((key) => typeof expected[key] !== 'string')) {
164
- throw usage('recap expected must contain name, description, icon, and kind strings');
165
+ || ['name', 'description', 'title', 'icon', 'kind'].some((key) => typeof expected[key] !== 'string')) {
166
+ throw usage('recap expected must contain name, description, title, icon, and kind strings');
165
167
  }
166
- return { kind: 'recap', description: value['description'], icon: value['icon'], expected: expected };
168
+ return { kind: 'recap', description: value['description'], title: value['title'], icon: value['icon'], expected: expected };
167
169
  }
168
170
  function parsePersonaAckBody(body) {
169
171
  const value = objectBody(body, 'broker persona-ack');
@@ -371,27 +373,32 @@ function handleGeneratedName(ctx) {
371
373
  return { applied: false };
372
374
  const updated = updateNode(nodeId, {
373
375
  description: request.description,
376
+ ...(request.title.trim() === '' ? {} : { title: request.title }),
374
377
  ...(request.icon.trim() === '' ? {} : { icon: request.icon }),
375
378
  });
376
379
  return {
377
380
  applied: true,
378
381
  editorLabel: editorLabel(updated),
379
382
  description: updated.description ?? '',
383
+ title: updated.title ?? '',
380
384
  icon: updated.icon ?? '',
381
385
  };
382
386
  }
383
387
  const expected = request.expected;
384
388
  if (node.name !== expected.name || (node.description ?? '') !== expected.description
389
+ || (node.title ?? '') !== expected.title
385
390
  || (node.icon ?? '') !== expected.icon || node.kind !== expected.kind)
386
391
  return { applied: false };
387
392
  const updated = updateNode(nodeId, {
388
393
  description: request.description,
394
+ title: request.title.trim() === '' ? undefined : request.title,
389
395
  icon: request.icon.trim() === '' ? undefined : request.icon,
390
396
  });
391
397
  return {
392
398
  applied: true,
393
399
  editorLabel: editorLabel(updated),
394
400
  description: updated.description ?? '',
401
+ title: updated.title ?? '',
395
402
  icon: updated.icon ?? '',
396
403
  };
397
404
  });
@@ -67,7 +67,7 @@ async function deliverCommentTurn(args) {
67
67
  mode: 'interactive',
68
68
  data: { page_id: basename(args.dir), comment_id: args.mutation.comment.id, verb: 'create' },
69
69
  });
70
- return { target_node_id: target, status: 'delivered', via: delivery.via, woke: delivery.revived };
70
+ return { target_node_id: target, status: 'delivered', via: delivery.via, woke: delivery.revived, ...(delivery.reason === undefined ? {} : { reason: delivery.reason }) };
71
71
  }
72
72
  catch (error) {
73
73
  // State was committed before delivery; a delivery failure remains a
@@ -124,6 +124,8 @@ export function toNodeDetailDTO(meta, edges) {
124
124
  };
125
125
  if (meta.description !== undefined)
126
126
  dto.description = meta.description;
127
+ if (meta.title !== undefined)
128
+ dto.title = meta.title;
127
129
  if (meta.icon !== undefined)
128
130
  dto.icon = meta.icon;
129
131
  if (meta.cycles !== undefined)
@@ -9,9 +9,8 @@
9
9
  // clock. A broker's real `exit` event enqueues an exit-policy job on the
10
10
  // daemon's serial work lane (never inline on the Node event callback), where
11
11
  // classifyDeadNode — one pure 8-row policy table over durable row state —
12
- // decides forget / dormant / respawn / boot-failure. The startup recovery
13
- // sweep classifies boot-time dead rows through the SAME table, so exit
14
- // recovery and startup recovery can never diverge. Every broker's argv is
12
+ // decides forget / dormant / respawn / boot-failure. The row-driven tick
13
+ // classifies disconnected rows through the same table. Every broker's argv is
15
14
  // stamped `--epoch <id>`: at boot the census reaps every prior-epoch broker
16
15
  // (no adoption), and at shutdown the daemon tears its whole fleet down, so no
17
16
  // broker ever outlives the daemon epoch that launched it. A tmux pane, when
@@ -94,8 +93,8 @@ export function brokerCommandDeclaresCanvasHome(command, home) {
94
93
  // readiness mirror, reap every broker of a PRIOR epoch on its own canvas.
95
94
  // Ownership is never adopted across epochs — a prior daemon's ChildProcess
96
95
  // handles died with it, so its brokers are unsupervisable; tear them down and
97
- // let the startup recovery sweep reclassify their rows through the policy
98
- // table. A broker whose argv carries NO `--epoch` tag is a legacy-generation
96
+ // let the next supervision tick classify their rows through the policy table.
97
+ // A broker whose argv carries NO `--epoch` tag is a legacy-generation
99
98
  // broker and gets the same verdict (reap): it predates epoch ownership.
100
99
  /** The `--epoch <id>` tag host.ts stamps into every broker's argv, or null
101
100
  * for a legacy/untagged broker (which the census treats as prior-epoch). */
@@ -110,9 +109,9 @@ const CENSUS_POLL_MS = 150;
110
109
  async function reapPriorEpochBrokers(epoch) {
111
110
  const snapshot = captureLivenessSnapshot();
112
111
  if (snapshot === null) {
113
- // No process table — the census cannot run. The startup recovery sweep
114
- // still reclassifies rows; a surviving prior-epoch broker is at worst a
115
- // duplicate engine risk the next daemon boot's census retries.
112
+ // No process table — the census cannot run. The supervision tick still
113
+ // classifies rows; a surviving prior-epoch broker is at worst a duplicate
114
+ // engine risk the next daemon boot's census retries.
116
115
  emitEvent({
117
116
  level: 'error',
118
117
  event: 'daemon.census.snapshot_failed',
@@ -184,10 +183,9 @@ async function reapPriorEpochBrokers(epoch) {
184
183
  // Daemon-shutdown fleet teardown (invariant 2: no broker outlives the daemon
185
184
  // epoch that launched it). SIGTERM every live entry, race each handle's real
186
185
  // exit against ONE shared bounded grace, then SIGKILL each survivor's captured
187
- // process tree. Rows are deliberately NOT rewritten here — the NEXT daemon's
188
- // startup recovery sweep classifies them through the policy table, exactly as
189
- // it would after a daemon crash, so clean shutdown and crash converge on one
190
- // recovery path.
186
+ // process tree. Rows are deliberately NOT rewritten here — the next daemon's
187
+ // supervision tick classifies them through the policy table, so clean shutdown
188
+ // and crash converge on one recovery path.
191
189
  const FLEET_TEARDOWN_GRACE_MS = 5_000;
192
190
  async function teardownFleet(fleet) {
193
191
  const entries = Array.from(fleet.entries());
@@ -292,7 +290,7 @@ export async function superviseTick(now = Date.now(), lifecycle = directTickLife
292
290
  // Human bridge rows have no broker engine and participate in none of the
293
291
  // recurring row-driven reconciler passes.
294
292
  rows = listNodes({ status: ['active', 'idle'] }).filter((row) => row.kind !== 'human');
295
- terminating = listNodes({ status: ['done', 'canceled', 'dead'], withRecordedPid: true })
293
+ terminating = listNodes({ status: ['done', 'canceled', 'dead'], withRecordedPidOrFrozen: true })
296
294
  .filter((row) => row.kind !== 'human');
297
295
  }
298
296
  catch (err) {
@@ -427,7 +425,7 @@ export async function runDaemon(opts = {}) {
427
425
  // The grace covers: response flush → the CLI process printing and exiting →
428
426
  // pi persisting the tool result. It is NOT a correctness fallback; a broker
429
427
  // SIGTERMed mid-turn still gets `abortAndExit`'s own bounded persistence
430
- // window, and the successor's startup recovery sweep resumes it either way.
428
+ // window, and the successor's supervision tick resolves it either way.
431
429
  const HANDOVER_ACK_GRACE_MS = 1_000;
432
430
  let handoverArmed = false;
433
431
  /** Arm the handover. Idempotent: a retry while one is already scheduled
@@ -484,7 +482,7 @@ export async function runDaemon(opts = {}) {
484
482
  catch (error) {
485
483
  // The canvas is now daemonless. Loud, and recoverable: the next front-door
486
484
  // command's ensureDaemon() starts one, and every torn-down node is
487
- // reclassified by that daemon's startup recovery sweep.
485
+ // reclassified by that daemon's supervision tick.
488
486
  operationIdContext.fresh(() => {
489
487
  emitEvent({ level: 'error', event: 'daemon.handover.successor_failed', error });
490
488
  });
@@ -530,9 +528,8 @@ export async function runDaemon(opts = {}) {
530
528
  void requestCleanup().catch(() => { });
531
529
  }
532
530
  /** The serial work lane (design D-3): supervision ticks, exit-policy jobs,
533
- * matured backoff respawns, and the startup recovery sweep all append here
534
- * and run strictly one at a time, so policy never interleaves with a tick's
535
- * row reads. `laneTail` never rejects — every append attaches the rejection
531
+ * and matured backoff respawns run strictly one at a time, so policy never
532
+ * interleaves with a tick's row reads. `laneTail` never rejects — every append attaches the rejection
536
533
  * handler. Once admission closes, new jobs are DROPPED (not queued): a
537
534
  * shutdown SIGTERM fans exit events across the whole fleet, and running
538
535
  * their policy jobs would respawn brokers mid-teardown. */
@@ -574,8 +571,8 @@ export async function runDaemon(opts = {}) {
574
571
  bindDaemonEventSource();
575
572
  interval = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
576
573
  // Mint this daemon's epoch and bind the ONE fleet registry for the process
577
- // BEFORE anything can launch a broker (the API server, the recovery sweep,
578
- // any tick): every launch funnels through headlessBrokerHost.launch, which
574
+ // BEFORE anything can launch a broker (the API server or any tick): every
575
+ // launch funnels through headlessBrokerHost.launch, which
579
576
  // registers with the bound fleet — an unbound launch throws (design D-2).
580
577
  const epoch = randomBytes(8).toString('hex');
581
578
  fleet = new DaemonFleet({ epoch, enqueue });
@@ -3,6 +3,7 @@ export interface NodeMessageDelivery {
3
3
  entry_id: string | null;
4
4
  via: 'engine' | 'inbox';
5
5
  revived: boolean;
6
+ reason?: 'capacity_frozen';
6
7
  }
7
8
  export interface RuntimeMessageCard {
8
9
  /** A bare core kind only. This renders with `formatCard`, which leaves the
@@ -58,6 +58,7 @@ export async function deliverNodeMessage(args) {
58
58
  data: { ...args.data, body: args.body },
59
59
  }));
60
60
  let revived = false;
61
+ let reason;
61
62
  if (args.mode !== 'quiet') {
62
63
  target = getNode(args.node_id);
63
64
  if (target === null)
@@ -66,12 +67,15 @@ export async function deliverNodeMessage(args) {
66
67
  try {
67
68
  // Mail is durable, so a wake denied for capacity waits for a slot
68
69
  // rather than being lost: the row freezes and the tick relaunches it.
69
- revived = reviveNode(args.node_id, { resume: true, capacity: 'freeze' }).launch !== undefined;
70
+ const result = reviveNode(args.node_id, { resume: true, capacity: 'freeze' });
71
+ revived = result.launch !== undefined;
72
+ if (result.outcome === 'frozen')
73
+ reason = 'capacity_frozen';
70
74
  }
71
75
  catch {
72
76
  // The durable entry remains authoritative; one wake attempt is enough.
73
77
  }
74
78
  }
75
79
  }
76
- return { entry_id: entry.entry_id, via: 'inbox', revived };
80
+ return { entry_id: entry.entry_id, via: 'inbox', revived, ...(reason === undefined ? {} : { reason }) };
77
81
  }
@@ -12,8 +12,7 @@ export declare function hasLiveObligation(nodeId: string): boolean;
12
12
  * cycle rather than causing one. */
13
13
  export declare function hasPendingWake(nodeId: string): boolean;
14
14
  /** What parking an unattended node actually amounts to — the one rule both the
15
- * live-broker park clock (broker-supervision) and the already-parked
16
- * reconciliation (dormant-inbox) apply.
15
+ * live-broker park clock and the lifecycle tick apply.
17
16
  *
18
17
  * `release` — something can still wake it, so parking is a pause.
19
18
  * `park` — a resident with nothing left to wake it has completed its
@@ -43,11 +43,6 @@ export declare class FreezeLaneReconciler {
43
43
  endEpisode(nodeId: string): void;
44
44
  run(now: number, ctx: FreezeLaneContext): Promise<FrozenCounts>;
45
45
  private enact;
46
- /** A frozen row is owed exactly one thing: the launch the cap refused. Strict
47
- * resume is the request, and `reviveNode` itself vetoes it for a pending
48
- * refresh or an unconfirmed cycle — the same modes the dead-row table would
49
- * have chosen. It is recovery, not a wake: nothing arrived for this node
50
- * while it waited, so an armed deadline survives the thaw. */
51
46
  private enactThaw;
52
47
  private enactIdleRelease;
53
48
  }
@@ -8,11 +8,7 @@
8
8
  // needs to pre-reserve anything. A candidate that finds the ledger full freezes
9
9
  // (`reviveNode` with `capacity: 'freeze'` is the single freeze writer) and waits
10
10
  // for the next tick.
11
- //
12
- // The idle-release episode rules below are the former DormantInboxReconciler's,
13
- // moved here whole: a relaunch that only ever tried to deliver mail is a launch
14
- // like any other, and it now competes for slots through the same lane.
15
- import { getRow, subscribersOf, } from '../../../core/canvas/index.js';
11
+ import { clearFrozen, getNode, getRow, subscribersOf, } from '../../../core/canvas/index.js';
16
12
  import { fullName } from '../../../core/canvas/labels.js';
17
13
  import { isSafeNodeId } from '../../../core/canvas/paths.js';
18
14
  import { emitEvent } from '../../../core/events/emit.js';
@@ -87,7 +83,7 @@ export class FreezeLaneReconciler {
87
83
  const id = entry.row.node_id;
88
84
  try {
89
85
  if (entry.enact === 'thaw')
90
- return this.enactThaw(id);
86
+ return this.enactThaw(entry.row);
91
87
  if (entry.enact === 'idle-release')
92
88
  return this.enactIdleRelease(now, entry.row);
93
89
  // The SAME policy table an exit event runs, so tick recovery and exit
@@ -110,13 +106,13 @@ export class FreezeLaneReconciler {
110
106
  return 'skipped';
111
107
  }
112
108
  }
113
- /** A frozen row is owed exactly one thing: the launch the cap refused. Strict
114
- * resume is the request, and `reviveNode` itself vetoes it for a pending
115
- * refresh or an unconfirmed cycle — the same modes the dead-row table would
116
- * have chosen. It is recovery, not a wake: nothing arrived for this node
117
- * while it waited, so an armed deadline survives the thaw. */
118
- enactThaw(nodeId) {
119
- return reviveNode(nodeId, { resume: true, capacity: 'freeze', recovery: true }).outcome === 'frozen'
109
+ enactThaw(row) {
110
+ if (row.lifecycle === 'resident' && getNode(row.node_id)?.pi_session_id != null && !hasPendingWake(row.node_id)) {
111
+ transition(row.node_id, 'release');
112
+ clearFrozen(row.node_id);
113
+ return 'skipped';
114
+ }
115
+ return reviveNode(row.node_id, { resume: true, capacity: 'freeze', recovery: true }).outcome === 'frozen'
120
116
  ? 'frozen'
121
117
  : 'launched';
122
118
  }
@@ -1,9 +1,3 @@
1
- // respawn-policy.ts — the tick's Backoff gate, and P5's file to own outright.
2
- //
3
- // P3 only READS `respawn_not_before`: nothing writes it yet, so the gate is
4
- // inert in effect. P5 replaces the in-memory throttle in `daemon/fleet.ts` with
5
- // the durable counter writes (`respawn_failures` + `respawn_not_before`) that
6
- // make it bite, and adds them here rather than in `tick.ts`.
7
1
  /** Whether a durable backoff still holds this row back from a relaunch. A row
8
2
  * in backoff holds no slot and is excluded from the saturation figure — it is
9
3
  * waiting on a clock, not on capacity. */
@@ -1,4 +1,2 @@
1
1
  import type { NodeRow } from '../../../core/canvas/index.js';
2
- /** Handle a terminal row that still holds a process. Deliberately inert until
3
- * P4: a row reaching here is already excluded from every other tick duty. */
4
2
  export declare function handleTerminatingRow(row: NodeRow): void;
@@ -1,13 +1,3 @@
1
- // terminating.ts — the Terminating state's owner, and P4's file to own outright.
2
- //
3
- // A terminal row that still holds a process is a node whose teardown has been
4
- // requested but not finished. P4 gives it one bounded shutdown request and an
5
- // escalation after its grace, moving the inline `headlessBrokerHost.teardown`
6
- // calls at the close/reset sites into this daemon-side lane. P3 changes no
7
- // teardown behavior: it only OBSERVES such rows (the tick queries terminal rows
8
- // carrying a recorded pid) and hands them here, so P4 edits this file alone.
9
- /** Handle a terminal row that still holds a process. Deliberately inert until
10
- * P4: a row reaching here is already excluded from every other tick duty. */
11
1
  export function handleTerminatingRow(row) {
12
2
  void row;
13
3
  }
@@ -2,8 +2,8 @@ import { type NodeRow } from '../../../core/canvas/index.js';
2
2
  import type { FleetRegistry } from '../../../core/runtime/fleet.js';
3
3
  import { type FrozenCounts } from './freeze-lane.js';
4
4
  export interface NodeLifecycleContext {
5
- /** Every active and idle row (human rows excluded), plus terminal rows that
6
- * still hold a recorded pid. */
5
+ /** Every active and idle row (human rows excluded), plus terminal rows with
6
+ * a recorded pid or frozen mark. */
7
7
  rows: readonly NodeRow[];
8
8
  fleet: FleetRegistry;
9
9
  }
@@ -64,9 +64,11 @@ export class NodeLifecycleReconciler {
64
64
  this.thawMark(row);
65
65
  return;
66
66
  }
67
- // 2. A terminal row still holding a process is Terminating, and P4's.
68
67
  if (isTerminal(row)) {
69
- handleTerminatingRow(row);
68
+ if (row.pi_pid != null)
69
+ handleTerminatingRow(row);
70
+ else if (row.frozen_at != null)
71
+ candidates.push({ row, enact: 'thaw' });
70
72
  return;
71
73
  }
72
74
  // A paused profile launches nothing, and its stuck-delivery episode is over
@@ -19,8 +19,8 @@ export class StorageMaintenanceReconciler {
19
19
  run(now, ctx) {
20
20
  void ctx; // no consumer today — kept only for six-way call-site uniformity
21
21
  // Placement reap MUST precede focus GC, so GC catches a focus row the reap
22
- // strands. Both run after dormant-inbox reconciliation, so a same-tick
23
- // revive has already had the chance to re-anchor its focus.
22
+ // strands. Both run after the lifecycle tick, so a same-tick revive has
23
+ // already had the chance to re-anchor its focus.
24
24
  this.reapDeadResidue(now);
25
25
  this.gcStaleFocuses();
26
26
  this.reapGhostRows(now);
@@ -7,6 +7,7 @@ export interface CommentDelivery {
7
7
  status: 'delivered' | 'skipped' | 'failed';
8
8
  via?: 'engine' | 'inbox';
9
9
  woke: boolean;
10
+ reason?: 'capacity_frozen';
10
11
  error?: string;
11
12
  }
12
13
  /** Render the one bounded, durable comment notice delivered to a review companion. */
@@ -96,6 +96,7 @@ export async function notifyComment(args) {
96
96
  status: 'delivered',
97
97
  via: delivery.via,
98
98
  woke: delivery.revived,
99
+ ...(delivery.reason === undefined ? {} : { reason: delivery.reason }),
99
100
  };
100
101
  appendDeliveryEvent({
101
102
  review_id: args.review.review_id,
@@ -13,6 +13,7 @@ export declare function generateAndCommitInitialName(nodeId: string, prompt: str
13
13
  export declare function generateAndCommitRecapName(nodeId: string, conversation: string, expected: {
14
14
  name: string;
15
15
  description: string;
16
+ title: string;
16
17
  icon: string;
17
18
  kind: string;
18
19
  }, isStillAutomatic: () => boolean, onNamed?: (editorLabel: string) => void): void;
@@ -10,7 +10,7 @@ import { join } from 'node:path';
10
10
  import { CRTR_DIR_NAME } from '../types.js';
11
11
  import { brokerExtensionState, commitBrokerGeneratedName, } from '../core/runtime/broker/daemon-ops.js';
12
12
  import { publishNodeNamed } from '../core/runtime/broker/node-named.js';
13
- import { headlessName, headlessNameFromConversation, sanitizeSessionName, slugFromPrompt, } from '../core/runtime/naming.js';
13
+ import { headlessName, headlessNameFromConversation, sanitizeSessionName, sanitizeSessionTitle, slugFromPrompt, titleFromPrompt, } from '../core/runtime/naming.js';
14
14
  function contextDir(nodeId) {
15
15
  if (nodeId === '' || nodeId === '.' || nodeId === '..' || /[\\/\0]/u.test(nodeId)) {
16
16
  throw new TypeError('node_id must be a non-empty path segment');
@@ -37,8 +37,14 @@ export function readRoadmap(nodeId) {
37
37
  const path = join(contextDir(nodeId), 'roadmap.md');
38
38
  return existsSync(path) ? readFileSync(path, 'utf8') : null;
39
39
  }
40
- function generatedDescription(source, generated) {
41
- return sanitizeSessionName(generated.name) || slugFromPrompt(source);
40
+ /** The label fields to commit: what the namer produced, or what the source text
41
+ * itself yields when it produced nothing usable. */
42
+ function generatedLabel(source, generated) {
43
+ return {
44
+ description: sanitizeSessionName(generated.name) || slugFromPrompt(source),
45
+ title: sanitizeSessionTitle(generated.title) || titleFromPrompt(source),
46
+ icon: generated.icon,
47
+ };
42
48
  }
43
49
  /** Generate and commit the first name for a node. The initial operation is
44
50
  * guarded by crtrd, so a concurrent rename or another broker cannot be
@@ -53,8 +59,7 @@ export function generateAndCommitInitialName(nodeId, prompt, onNamed) {
53
59
  return undefined;
54
60
  return headlessName(body).then((generated) => commitBrokerGeneratedName(nodeId, {
55
61
  kind: 'initial',
56
- description: generatedDescription(body, generated),
57
- icon: generated.icon,
62
+ ...generatedLabel(body, generated),
58
63
  }));
59
64
  })
60
65
  .then((result) => {
@@ -68,6 +73,7 @@ export function generateAndCommitInitialName(nodeId, prompt, onNamed) {
68
73
  // has nothing left to re-read on.
69
74
  publishNodeNamed({
70
75
  description: result.description ?? '',
76
+ title: result.title ?? '',
71
77
  icon: result.icon ?? '',
72
78
  editorLabel: result.editorLabel,
73
79
  });
@@ -88,8 +94,7 @@ export function generateAndCommitRecapName(nodeId, conversation, expected, isSti
88
94
  return undefined;
89
95
  return commitBrokerGeneratedName(nodeId, {
90
96
  kind: 'recap',
91
- description: generatedDescription(body, generated),
92
- icon: generated.icon,
97
+ ...generatedLabel(body, generated),
93
98
  expected,
94
99
  });
95
100
  })
@@ -267,6 +267,7 @@ export function registerCanvasRecap(pi, recapGenerator = generateRecap, nameRege
267
267
  generateAndCommitRecapName(nodeId, conversation, {
268
268
  name: node.name,
269
269
  description: node.description ?? '',
270
+ title: node.title ?? '',
270
271
  icon: node.icon ?? '',
271
272
  kind: node.kind,
272
273
  }, () => pi.getSessionName?.() === original, (label) => {
@@ -360,6 +360,8 @@ export function registerCanvasStophook(pi) {
360
360
  // back into its calling broker. Do not await the operation that tears
361
361
  // this broker down from Pi's session-start dispatch.
362
362
  void relaunchBrokerRoot(nodeId).catch((error) => {
363
+ const message = error instanceof Error ? error.message : String(error);
364
+ ctx?.ui?.notify?.(`/new refused: ${message}`, 'error');
363
365
  emitEvent({
364
366
  event: 'broker.daemon.operation.failed',
365
367
  level: 'error',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.240",
3
+ "version": "0.3.242",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/runtime.lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.240",
3
+ "version": "0.3.242",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/crouter",
9
- "version": "0.3.240",
9
+ "version": "0.3.242",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "dependencies": {
@@ -1,21 +0,0 @@
1
- import { type Snapshot } from './model.js';
2
- export type AxisId = 'event' | 'payload' | 'kind' | 'mode' | 'lifecycle' | 'manager' | 'depth' | 'profile' | 'cwd';
3
- interface Axis {
4
- id: AxisId;
5
- label: string;
6
- value: string;
7
- /** Free-text axes open a field; the rest cycle in place. */
8
- typed: boolean;
9
- }
10
- /** The rail's rows for a snapshot. The payload row appears only for the events
11
- * that read one, because on boot there is nothing it could change. */
12
- export declare function axesOf(snapshot: Snapshot, profileName: string): Axis[];
13
- /** Cycle one enum axis, returning the changed snapshot. A typed axis is never
14
- * cycled — the shell opens a field for it instead. */
15
- export declare function cycleAxis(snapshot: Snapshot, id: AxisId, delta: number): Snapshot;
16
- /** Apply a typed axis's submitted value. */
17
- export declare function setTypedAxis(snapshot: Snapshot, id: AxisId, value: string): Snapshot;
18
- /** The value a typed axis's field is seeded with. */
19
- export declare function typedAxisValue(snapshot: Snapshot, id: AxisId): string;
20
- export declare function renderAxes(axes: Axis[], index: number, width: number, height: number, focused: boolean): string[];
21
- export {};