@north-light/crouter 0.3.241 → 0.3.243

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 (62) 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 +1 -1
  9. package/dist/commands/node/create.js +8 -3
  10. package/dist/commands/sys/context/admin/docs-panel.d.ts +17 -7
  11. package/dist/commands/sys/context/admin/docs-panel.js +43 -23
  12. package/dist/commands/sys/context/admin/list-view.d.ts +22 -11
  13. package/dist/commands/sys/context/admin/list-view.js +112 -53
  14. package/dist/commands/sys/context/admin/shell.d.ts +3 -0
  15. package/dist/commands/sys/context/admin/shell.js +11 -2
  16. package/dist/commands/sys/daemon.js +1 -1
  17. package/dist/core/__tests__/child-death-wake.test.js +0 -48
  18. package/dist/core/__tests__/daemon-boot.test.js +0 -8
  19. package/dist/core/__tests__/dead-node-policy-table.test.js +1 -2
  20. package/dist/core/__tests__/integration/deferred-no-wake.test.js +14 -2
  21. package/dist/core/__tests__/lifecycle.test.js +5 -5
  22. package/dist/core/__tests__/relaunch-root.test.js +91 -1
  23. package/dist/core/__tests__/revive-capacity.test.js +36 -1
  24. package/dist/core/__tests__/seam/broker-cap-freeze.test.js +73 -0
  25. package/dist/core/__tests__/seam/dormancy-release.test.js +1 -2
  26. package/dist/core/canvas/canvas.d.ts +6 -5
  27. package/dist/core/canvas/canvas.js +8 -7
  28. package/dist/core/canvas/types.d.ts +6 -0
  29. package/dist/core/review/realize.js +1 -1
  30. package/dist/core/runtime/broker/node-named.d.ts +1 -0
  31. package/dist/core/runtime/host.js +114 -103
  32. package/dist/core/runtime/lifecycle.js +3 -2
  33. package/dist/core/runtime/naming.d.ts +26 -8
  34. package/dist/core/runtime/naming.js +75 -43
  35. package/dist/core/runtime/reset.js +12 -5
  36. package/dist/core/runtime/revive-all.d.ts +4 -11
  37. package/dist/core/runtime/revive-all.js +6 -13
  38. package/dist/core/runtime/warm-pool.js +4 -4
  39. package/dist/daemon/api/__tests__/node-create-description.test.js +1 -0
  40. package/dist/daemon/api/handlers/broker-ops.js +14 -7
  41. package/dist/daemon/api/handlers/feedback-comments.js +1 -1
  42. package/dist/daemon/api/map.js +2 -0
  43. package/dist/daemon/crtrd.js +17 -20
  44. package/dist/daemon/messaging/node-message.d.ts +1 -0
  45. package/dist/daemon/messaging/node-message.js +6 -2
  46. package/dist/daemon/reconcilers/live-obligation.d.ts +1 -2
  47. package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.d.ts +0 -5
  48. package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +9 -13
  49. package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.js +0 -6
  50. package/dist/daemon/reconcilers/node-lifecycle/terminating.d.ts +0 -2
  51. package/dist/daemon/reconcilers/node-lifecycle/terminating.js +0 -10
  52. package/dist/daemon/reconcilers/node-lifecycle/tick.d.ts +2 -2
  53. package/dist/daemon/reconcilers/node-lifecycle/tick.js +4 -2
  54. package/dist/daemon/reconcilers/storage-maintenance.js +2 -2
  55. package/dist/daemon/review/comment-notify.d.ts +1 -0
  56. package/dist/daemon/review/comment-notify.js +1 -0
  57. package/dist/pi-extensions/broker-local.d.ts +1 -0
  58. package/dist/pi-extensions/broker-local.js +12 -7
  59. package/dist/pi-extensions/canvas-recap.js +1 -0
  60. package/dist/pi-extensions/canvas-stophook.js +2 -0
  61. package/package.json +1 -1
  62. package/runtime.lock.json +2 -2
@@ -1,14 +1,15 @@
1
1
  // Session naming — turn a node's first prompt into a short, human-readable
2
- // handle for the editor label.
2
+ // label.
3
3
  //
4
- // A node's editor label is `<mode-icon> <name> <kind>` (see editorLabel in
5
- // labels.ts). The `<name>` is a 3-8 word kebab-case "description" derived from
6
- // the first prompt by asking Pi headlessly, persisted on the node's meta so it
7
- // survives revives and updates the live session name.
4
+ // One call produces BOTH forms a surface needs, so no consumer has to un-mangle
5
+ // the other's format: `name` is the 3-8 word kebab-case handle that sits in a
6
+ // node's editor label (`<mode-icon> <name> <kind>`, see editorLabel in
7
+ // labels.ts) and a tmux window name, `title` is the prose sentence a person
8
+ // reads in a conversation list, punctuation intact.
8
9
  //
9
- // The namer returns a STRUCTURED result — {name, icon} — produced by constrained
10
- // sampling through the one-shot `name_session` tool, not by parsing prose. The
11
- // icon is a Nerd Font glyph the model picks freely
10
+ // The namer returns a STRUCTURED result — {name, title, icon} — produced by
11
+ // constrained sampling through the one-shot `name_session` tool, not by parsing
12
+ // prose. The icon is a Nerd Font glyph the model picks freely
12
13
  // (guided by example blocks, not a closed enum) and is stored as its own meta
13
14
  // field, so each surface decides whether to render it.
14
15
  //
@@ -47,13 +48,19 @@ const ICON_EXAMPLES = [
47
48
  'interface/design: U+F1FC paintbrush, U+F03E image, U+F108 display',
48
49
  'communication/coordination: U+F1D8 paper plane, U+F0E6 comments, U+F0E8 sitemap, U+F0C1 link',
49
50
  ].join('\n');
50
- const NAME_SYSTEM_PROMPT = 'You name coding-agent work sessions. The name is a label used to identify the ' +
51
- 'session at a glance among many other concurrent programming sessions, so it must ' +
52
- 'describe what the task is about.\n\n' +
51
+ const NAME_SYSTEM_PROMPT = 'You label agent sessions. A session is whatever a person opened it for: a coding task, ' +
52
+ 'a question, a piece of research, an idea they are turning over, or an ordinary ' +
53
+ 'conversation. You are labelling that session, never taking part in it — do not answer ' +
54
+ 'the message, do not do the work it asks for, do not comment on it. Every session gets a ' +
55
+ 'label, however short, casual, misspelled, or open-ended its opening message is.\n\n' +
53
56
  'Call the `name_session` tool exactly once with:\n' +
54
- '- `name`: a concise 3-8 word kebab-case name — lowercase words joined by single ' +
55
- 'hyphens (e.g. `refactor-auth-token-flow`, `add-csv-export-endpoint`). No punctuation, ' +
56
- 'quotes, or prose.\n' +
57
+ '- `title`: how a person would refer to this session in a list of their conversations — ' +
58
+ '2-8 words, sentence case, written naturally with its punctuation intact (e.g. ' +
59
+ "`What's your favorite color?`, `Payroll export mapping bug`, `Ideas for the launch " +
60
+ 'email`). No quotes, no markdown, no trailing period.\n' +
61
+ '- `name`: the same subject as a concise 3-8 word kebab-case handle — lowercase words ' +
62
+ 'joined by single hyphens (e.g. `favorite-color-question`, `refactor-auth-token-flow`). ' +
63
+ 'No punctuation, quotes, or prose.\n' +
57
64
  '- `icon`: ONE Nerd Font glyph, written as its codepoint in `U+XXXX` form (e.g. ' +
58
65
  '`U+F188`). Emit a codepoint, not a name like `nf-fa-bug`, and not an emoji.\n\n' +
59
66
  'Choosing the icon — work through it in this order:\n' +
@@ -76,14 +83,11 @@ const NAME_SYSTEM_PROMPT = 'You name coding-agent work sessions. The name is a l
76
83
  * text for the instruction. The source is capped first, so the closing tag is
77
84
  * always present. */
78
85
  function nameUserPrompt(source, sourceCap = PROMPT_CAP) {
79
- return `<source>\n${source.slice(0, sourceCap)}\n</source>\n\nName this session based on the work described above, and pick an icon depicting it. Call \`name_session\` once, then stop.`;
86
+ return `<source>\n${source.slice(0, sourceCap)}\n</source>\n\nLabel the session this text is from: call \`name_session\` once with a title, a name, and an icon depicting it, then stop. Do not answer it.`;
80
87
  }
81
- /** A short stop-word set so the local-slug fallback skips filler words. */
82
- const STOPWORDS = new Set([
83
- 'the', 'a', 'an', 'and', 'or', 'but', 'to', 'of', 'in', 'on', 'for', 'with',
84
- 'is', 'are', 'be', 'this', 'that', 'it', 'as', 'at', 'by', 'from', 'into',
85
- 'please', 'can', 'you', 'i', 'we', 'my', 'our', 'me', 'so', 'then',
86
- ]);
88
+ /** Cap on a generated title — room for a full sentence-case phrase, short
89
+ * enough that a conversation list stays scannable. */
90
+ const TITLE_CAP = 72;
87
91
  /** Coerce arbitrary text into a 3-8 word kebab-case name, or '' if nothing
88
92
  * usable survives. Lowercases, keeps [a-z0-9], collapses everything else to a
89
93
  * single hyphen, and clamps to the first 8 words. */
@@ -96,17 +100,34 @@ export function sanitizeSessionName(raw) {
96
100
  .filter((w) => w !== '');
97
101
  return words.slice(0, 8).join('-');
98
102
  }
99
- /** Local fallback: derive a name straight from the prompt (no pi call). Drops
100
- * stop-words, takes the first few content words. */
103
+ /** Coerce arbitrary text into a one-line prose title, or '' if nothing usable
104
+ * survives. Takes the first non-empty line, collapses runs of whitespace, drops
105
+ * wrapping quotes, clamps at a word boundary, and capitalizes the opening
106
+ * letter. Punctuation INSIDE the line survives — carrying it is what `title`
107
+ * is for. */
108
+ export function sanitizeSessionTitle(raw) {
109
+ const line = (raw ?? '').split('\n').map((l) => l.trim()).find((l) => l !== '') ?? '';
110
+ const collapsed = line.replace(/\s+/g, ' ').replace(/^["'`]+|["'`]+$/g, '').trim();
111
+ if (collapsed === '')
112
+ return '';
113
+ let clamped = collapsed;
114
+ if (collapsed.length > TITLE_CAP) {
115
+ const cut = collapsed.slice(0, TITLE_CAP);
116
+ const boundary = cut.lastIndexOf(' ');
117
+ clamped = `${(boundary > TITLE_CAP / 2 ? cut.slice(0, boundary) : cut).trimEnd()}\u2026`;
118
+ }
119
+ return clamped.charAt(0).toUpperCase() + clamped.slice(1);
120
+ }
121
+ /** Local fallback: the opening of the prompt as a title (no pi call), with its
122
+ * punctuation. Whitespace collapses first so a multi-line message contributes
123
+ * its actual opening words rather than whatever its first line happened to be. */
124
+ export function titleFromPrompt(prompt) {
125
+ return sanitizeSessionTitle((prompt ?? '').replace(/\s+/g, ' '));
126
+ }
127
+ /** Local fallback: derive a kebab handle straight from the prompt (no pi call),
128
+ * from the same opening words the fallback title keeps. */
101
129
  export function slugFromPrompt(prompt) {
102
- const words = (prompt ?? '')
103
- .toLowerCase()
104
- .replace(/[^a-z0-9]+/g, ' ')
105
- .split(' ')
106
- .filter((w) => w !== '' && !STOPWORDS.has(w));
107
- const picked = (words.length > 0 ? words : (prompt ?? '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').split(' ').filter(Boolean))
108
- .slice(0, 3);
109
- return sanitizeSessionName(picked.join('-')) || 'session';
130
+ return sanitizeSessionName(titleFromPrompt(prompt)) || 'session';
110
131
  }
111
132
  /** The namer's model: the `light` rung of the DEFAULT provider's ladder, with
112
133
  * any thinking suffix stripped because the one-shot session uses thinking off.
@@ -134,6 +155,10 @@ function nameModelRequest() {
134
155
  const NAME_SESSION_SCHEMA = {
135
156
  type: 'object',
136
157
  properties: {
158
+ title: {
159
+ type: 'string',
160
+ description: 'A 2-8 word sentence-case title a person would recognize this session by in a list, with its punctuation intact, e.g. Payroll export mapping bug. No quotes, markdown, or trailing period.',
161
+ },
137
162
  name: {
138
163
  type: 'string',
139
164
  description: 'A 3-8 word kebab-case session name: lowercase words joined by single hyphens, e.g. refactor-auth-token-flow. No punctuation, quotes, or prose.',
@@ -143,7 +168,7 @@ const NAME_SESSION_SCHEMA = {
143
168
  description: 'The Nerd Font glyph depicting this session, as a codepoint in U+XXXX form (e.g. U+F188).',
144
169
  },
145
170
  },
146
- required: ['name', 'icon'],
171
+ required: ['title', 'name', 'icon'],
147
172
  additionalProperties: false,
148
173
  };
149
174
  /** Normalize a model-supplied icon to a single renderable glyph, or '' when
@@ -181,35 +206,42 @@ export function sanitizeIcon(raw) {
181
206
  return '';
182
207
  return points[0].codePointAt(0) >= 0x2000 ? trimmed : '';
183
208
  }
184
- /** Ask Pi headlessly for a structured {name, icon} for `body`, async. Resolves
185
- * to a sanitized SessionName, or `{name:'',icon:''}` on timeout, a missing tool
186
- * call, or a malformed emission so the caller can fall back to a local slug.
209
+ /** Ask Pi headlessly for a structured {name, title, icon} for `body`, async.
210
+ * Resolves to a sanitized SessionName, or `{name:'',title:'',icon:''}` on
211
+ * timeout, a missing tool call, or a malformed emission so the caller can fall
212
+ * back to the prompt's own words.
213
+ *
214
+ * Nothing FORCES the tool call — constrained sampling shapes the arguments once
215
+ * the model decides to call it — so a model that answers in prose instead gets
216
+ * one more attempt before the caller falls back.
187
217
  *
188
218
  * This module stays canvas-free so CLI leaves can reach it. The canvas-coupled
189
219
  * commit path lives in pi-extensions/broker-local.ts, which imports this pure
190
220
  * helper and writes through crtrd's guarded generated-name operation. */
191
221
  export async function headlessName(body, sourceCap = PROMPT_CAP) {
192
- const empty = { name: '', icon: '' };
193
- const route = nameModelRequest();
194
- const emitted = await headlessToolCall({
222
+ const empty = { name: '', title: '', icon: '' };
223
+ const request = {
195
224
  systemPrompt: NAME_SYSTEM_PROMPT,
196
225
  userPrompt: nameUserPrompt(body, sourceCap),
197
- ...route,
226
+ ...nameModelRequest(),
198
227
  timeoutMs: NAME_TIMEOUT_MS,
199
228
  tool: {
200
229
  name: 'name_session',
201
230
  label: 'Name session',
202
- description: 'Record the name and icon for this coding session. Call exactly once, then stop.',
231
+ description: 'Record the title, name, and icon for this session. Call exactly once, then stop.',
203
232
  parameters: NAME_SESSION_SCHEMA,
204
233
  constrainedSampling: { type: 'json_schema', strict: 'prefer' },
205
234
  },
206
- });
235
+ };
236
+ const emitted = await headlessToolCall(request) ?? await headlessToolCall(request);
207
237
  if (emitted === null || typeof emitted !== 'object')
208
238
  return empty;
209
239
  const parsed = emitted;
210
- const name = sanitizeSessionName(typeof parsed['name'] === 'string' ? parsed['name'] : '');
240
+ const title = sanitizeSessionTitle(typeof parsed['title'] === 'string' ? parsed['title'] : '');
241
+ const name = sanitizeSessionName(typeof parsed['name'] === 'string' ? parsed['name'] : '')
242
+ || sanitizeSessionName(title);
211
243
  const icon = sanitizeIcon(typeof parsed['icon'] === 'string' ? parsed['icon'] : '');
212
- return name !== '' ? { name, icon } : empty;
244
+ return name !== '' ? { name, title, icon } : empty;
213
245
  }
214
246
  /** Generate a name from a bounded conversation source rather than a single
215
247
  * kickoff message. The caller owns selecting representative conversation text;
@@ -111,11 +111,18 @@ export async function relaunchRoot(oldId, deps = {}) {
111
111
  ...inv.env,
112
112
  CRTR_SUBTREE: rootOfSpine(newMeta.node_id),
113
113
  };
114
- const placed = launchBroker(newMeta.node_id, inv, {
115
- cwd: old.cwd,
116
- name: fullName(newMeta),
117
- resuming: false,
118
- });
114
+ let placed;
115
+ try {
116
+ placed = launchBroker(newMeta.node_id, inv, {
117
+ cwd: old.cwd,
118
+ name: fullName(newMeta),
119
+ resuming: false,
120
+ });
121
+ }
122
+ catch (error) {
123
+ transition(newMeta.node_id, 'crash');
124
+ throw error;
125
+ }
119
126
  if (placed.pid == null) {
120
127
  // The new broker never started — crash the half-born node and BAIL. The old
121
128
  // root is still live and untouched (nothing below this point has run).
@@ -7,16 +7,9 @@ import type { NodeMeta, NodeStatus } from '../canvas/types.js';
7
7
  * is involuntary, so it is swept. (Silas, 2026-06-13: no opt-in to include these
8
8
  * — the exclusion is unconditional.) */
9
9
  export declare const TERMINAL_BY_CHOICE: readonly NodeStatus[];
10
- /** True when `meta` has no natural future cycle ahead of it — either its own
11
- * work is finished (`final_report` set) or it landed deliberately in one of
12
- * TERMINAL_BY_CHOICE. Nothing in the runtime (daemon supervision, the live
13
- * inbox watcher's own hold-and-flush) ever brings such a node back on its
14
- * own — only an explicit revive/reopen does. Shared by `isDisconnected`
15
- * (mass-revive scope, below) and `node message send --tier deferred`'s terminal-edge
16
- * rejection — a deferred entry appended to such a target would
17
- * never be delivered, so that doorway rejects instead of silently holding
18
- * it forever. */
19
- export declare function hasNoNaturalCycle(meta: Pick<NodeMeta, 'status' | 'final_report'>): boolean;
10
+ /** True when `meta` has no natural future cycle ahead of it. A frozen row is
11
+ * excluded because its mark records a thaw cycle already owed. */
12
+ export declare function hasNoNaturalCycle(meta: Pick<NodeMeta, 'status' | 'final_report' | 'frozen_at'>): boolean;
20
13
  /** True when `meta`'s engine is NOT running but it has a resumable saved session
21
14
  * — the precise "disconnected" predicate.
22
15
  *
@@ -28,7 +21,7 @@ export declare function hasNoNaturalCycle(meta: Pick<NodeMeta, 'status' | 'final
28
21
  * it — so such a node comes back fresh).
29
22
  * - `human`-kind rows are the `crtr human` bridge, never a pi engine, so they
30
23
  * are never "disconnected" (mirrors the daemon's superviseTick carve-out).
31
- * - terminal-by-choice (`done`/`canceled`) is always excluded.
24
+ * - unfrozen terminal-by-choice (`done`/`canceled`) is excluded.
32
25
  * - a node that already pushed its FINAL REPORT (`final_report` set) FINISHED
33
26
  * its own work — it is excluded regardless of status. A crash mid-reap can
34
27
  * leave it `dead` (not terminal-by-choice) with its report already committed;
@@ -3,8 +3,8 @@
3
3
  // After a mass-disconnect event (a reboot, a killed login/tmux session, a mass
4
4
  // crash, or the daemon being down a while) many nodes end up with their
5
5
  // canvas.db row + saved conversation intact but NO broker engine running. The
6
- // daemon recovers active|idle rows during its explicit startup sweep and from
7
- // broker exit events during its own epoch, but it deliberately never touches
6
+ // daemon recovers active|idle rows during its row-driven supervision tick and
7
+ // from broker exit events during its own epoch, but it deliberately never touches
8
8
  // terminal/dormant states, so the operator is left reviving survivors one id at
9
9
  // a time.
10
10
  //
@@ -24,17 +24,10 @@ import { reviveNode } from './revive.js';
24
24
  * is involuntary, so it is swept. (Silas, 2026-06-13: no opt-in to include these
25
25
  * — the exclusion is unconditional.) */
26
26
  export const TERMINAL_BY_CHOICE = ['done', 'canceled'];
27
- /** True when `meta` has no natural future cycle ahead of it — either its own
28
- * work is finished (`final_report` set) or it landed deliberately in one of
29
- * TERMINAL_BY_CHOICE. Nothing in the runtime (daemon supervision, the live
30
- * inbox watcher's own hold-and-flush) ever brings such a node back on its
31
- * own — only an explicit revive/reopen does. Shared by `isDisconnected`
32
- * (mass-revive scope, below) and `node message send --tier deferred`'s terminal-edge
33
- * rejection — a deferred entry appended to such a target would
34
- * never be delivered, so that doorway rejects instead of silently holding
35
- * it forever. */
27
+ /** True when `meta` has no natural future cycle ahead of it. A frozen row is
28
+ * excluded because its mark records a thaw cycle already owed. */
36
29
  export function hasNoNaturalCycle(meta) {
37
- return meta.final_report != null || TERMINAL_BY_CHOICE.includes(meta.status);
30
+ return meta.frozen_at == null && (meta.final_report != null || TERMINAL_BY_CHOICE.includes(meta.status));
38
31
  }
39
32
  /** True when `meta`'s engine is NOT running but it has a resumable saved session
40
33
  * — the precise "disconnected" predicate.
@@ -47,7 +40,7 @@ export function hasNoNaturalCycle(meta) {
47
40
  * it — so such a node comes back fresh).
48
41
  * - `human`-kind rows are the `crtr human` bridge, never a pi engine, so they
49
42
  * are never "disconnected" (mirrors the daemon's superviseTick carve-out).
50
- * - terminal-by-choice (`done`/`canceled`) is always excluded.
43
+ * - unfrozen terminal-by-choice (`done`/`canceled`) is excluded.
51
44
  * - a node that already pushed its FINAL REPORT (`final_report` set) FINISHED
52
45
  * its own work — it is excluded regardless of status. A crash mid-reap can
53
46
  * leave it `dead` (not terminal-by-choice) with its report already committed;
@@ -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';
@@ -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;