@north-light/crouter 0.3.241 → 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 (56) 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/sys/daemon.js +1 -1
  11. package/dist/core/__tests__/child-death-wake.test.js +0 -48
  12. package/dist/core/__tests__/daemon-boot.test.js +0 -8
  13. package/dist/core/__tests__/dead-node-policy-table.test.js +1 -2
  14. package/dist/core/__tests__/integration/deferred-no-wake.test.js +14 -2
  15. package/dist/core/__tests__/lifecycle.test.js +5 -5
  16. package/dist/core/__tests__/relaunch-root.test.js +91 -1
  17. package/dist/core/__tests__/revive-capacity.test.js +36 -1
  18. package/dist/core/__tests__/seam/broker-cap-freeze.test.js +73 -0
  19. package/dist/core/__tests__/seam/dormancy-release.test.js +1 -2
  20. package/dist/core/canvas/canvas.d.ts +6 -5
  21. package/dist/core/canvas/canvas.js +8 -7
  22. package/dist/core/canvas/types.d.ts +6 -0
  23. package/dist/core/review/realize.js +1 -1
  24. package/dist/core/runtime/broker/node-named.d.ts +1 -0
  25. package/dist/core/runtime/host.js +114 -103
  26. package/dist/core/runtime/lifecycle.js +3 -2
  27. package/dist/core/runtime/naming.d.ts +26 -8
  28. package/dist/core/runtime/naming.js +75 -43
  29. package/dist/core/runtime/reset.js +12 -5
  30. package/dist/core/runtime/revive-all.d.ts +4 -11
  31. package/dist/core/runtime/revive-all.js +6 -13
  32. package/dist/core/runtime/warm-pool.js +4 -4
  33. package/dist/daemon/api/__tests__/node-create-description.test.js +1 -0
  34. package/dist/daemon/api/handlers/broker-ops.js +14 -7
  35. package/dist/daemon/api/handlers/feedback-comments.js +1 -1
  36. package/dist/daemon/api/map.js +2 -0
  37. package/dist/daemon/crtrd.js +17 -20
  38. package/dist/daemon/messaging/node-message.d.ts +1 -0
  39. package/dist/daemon/messaging/node-message.js +6 -2
  40. package/dist/daemon/reconcilers/live-obligation.d.ts +1 -2
  41. package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.d.ts +0 -5
  42. package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +9 -13
  43. package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.js +0 -6
  44. package/dist/daemon/reconcilers/node-lifecycle/terminating.d.ts +0 -2
  45. package/dist/daemon/reconcilers/node-lifecycle/terminating.js +0 -10
  46. package/dist/daemon/reconcilers/node-lifecycle/tick.d.ts +2 -2
  47. package/dist/daemon/reconcilers/node-lifecycle/tick.js +4 -2
  48. package/dist/daemon/reconcilers/storage-maintenance.js +2 -2
  49. package/dist/daemon/review/comment-notify.d.ts +1 -0
  50. package/dist/daemon/review/comment-notify.js +1 -0
  51. package/dist/pi-extensions/broker-local.d.ts +1 -0
  52. package/dist/pi-extensions/broker-local.js +12 -7
  53. package/dist/pi-extensions/canvas-recap.js +1 -0
  54. package/dist/pi-extensions/canvas-stophook.js +2 -0
  55. package/package.json +1 -1
  56. package/runtime.lock.json +2 -2
@@ -177,7 +177,7 @@ export async function realizeReview(reviewId) {
177
177
  throw companionUnbound(review.review_id, COMPANION_BIND_DEADLINE_MS);
178
178
  }
179
179
  // R5: SQLite visibility commits before the ticket projection. A missing
180
- // projection is deliberately under-exposed and recovered by the startup sweep.
180
+ // projection is deliberately under-exposed and retried by the review lane.
181
181
  if (openReviewRow(review.review_id, new Date().toISOString())) {
182
182
  signalReviewActivity();
183
183
  if (review.origin_kind === 'ticket')
@@ -1,6 +1,7 @@
1
1
  /** What crtrd stored, as it stored it — the payload of a `node_named` frame. */
2
2
  export interface NodeNamedNotice {
3
3
  description: string;
4
+ title: string;
4
5
  icon: string;
5
6
  editorLabel: string;
6
7
  }
@@ -206,121 +206,132 @@ export const headlessBrokerHost = {
206
206
  if (!fleet.reserveSlot(nodeId)) {
207
207
  throw brokerCapReached(brokerThresholdsForDaemon().automaticReviveCap, fleet.size());
208
208
  }
209
- // Redirect the detached broker's stdout+stderr to a per-node log under the
210
- // node's existing job/ dir. It retains arbitrary engine/process residue and
211
- // failed-canonical fatal fallback output. Append-mode preserves crash-revive history.
212
- const logDir = jobDir(nodeId);
213
- mkdirSync(logDir, { recursive: true });
214
- const logFd = openSync(join(logDir, 'broker.log'), 'a');
215
- // Launch from the crouter-branded host binary (a copy of node) so the
216
- // broker shows "crouter" in macOS Full Disk Access, not "node". On
217
- // non-darwin / dev / tests this is just process.execPath (see branded-host).
218
- // buildBrokerEnv's default-deny allowlist has no `CRTR_` prefix, so a bare
219
- // `CRTR_BROKER_ENGINE` set only in the LAUNCHING process's ambient env (the
220
- // T11 test seam — see broker-sdk.ts) would never reach the child, which
221
- // would silently fall back to booting the real SDK. Inject the SAME value
222
- // preflightBrokerLaunch already resolved+validated above: it is
223
- // parent-resolved, crtr-trusted data (the same trust category as
224
- // `inv.env`), not a blanket `CRTR_*` passthrough, so this does not weaken
225
- // the allowlist's hardening. In production `engineSpec` is always the real
226
- // SDK default, so this is a no-op there.
227
- const engineSpec = resolveEngineSpec(inv);
228
- const childEnv = { ...buildBrokerEnv(inv), CRTR_BROKER_ENGINE: engineSpec };
229
- // NODE_OPTIONS is deliberately absent from the operational allowlist (it is
230
- // a code-execution vector — `--require`/`--import` — and DAEMON_ENV_STRIP_KEYS
231
- // strips it from the daemon's own env for the identical reason: a poisoned
232
- // NODE_OPTIONS in an inheriting shell must never reach a process that then
233
- // handles model credentials). It is genuinely needed ONLY for the T11 test
234
- // seam above: the fake engine is a `.ts` fixture, and the broker CLI entry
235
- // itself is only a `.js` file under the compiled `dist/`, so the harness
236
- // relies on `NODE_OPTIONS=--import tsx/esm` (set on its own ambient env, same
237
- // seam as CRTR_BROKER_ENGINE) to make the spawned child resolve both under
238
- // tsx. Gating this carry on `engineSpec !== DEFAULT_BROKER_ENGINE` — i.e. only
239
- // when the trusted test seam above already fired — keeps production (where
240
- // the engine is never overridden) byte-identical to today: NODE_OPTIONS is
241
- // never forwarded there, so an ambient poisoned value stays contained.
242
- if (engineSpec !== DEFAULT_BROKER_ENGINE) {
243
- const nodeOptions = inv.env['NODE_OPTIONS'] ?? process.env['NODE_OPTIONS'];
244
- if (nodeOptions !== undefined)
245
- childEnv['NODE_OPTIONS'] = nodeOptions;
246
- }
247
- if (eventSource() === undefined)
248
- bindBrokerEventSource(nodeId);
249
- // `--canvas-home` declares broker ownership in argv so the daemon's
250
- // startup epoch census can match its own canvas' brokers exactly (crtrd's
251
- // brokerCommandDeclaresCanvasHome) instead of inferring from install
252
- // paths; `--epoch` stamps WHICH daemon epoch launched it, so the next
253
- // daemon's census reaps every prior-epoch broker without adoption (D-8).
254
- // Both precede the node id: the node id is by contract the FINAL argv
255
- // token (both broker-cli and the ps census parse it that way).
209
+ let logFd = null;
256
210
  let child;
211
+ let registered = false;
257
212
  try {
258
- child = spawn(hostExecPath(), [resolveBrokerEntry(), '--canvas-home', crtrHome(), '--epoch', fleet.epoch(), nodeId], {
213
+ // Redirect the detached broker's stdout+stderr to a per-node log under the
214
+ // node's existing job/ dir. It retains arbitrary engine/process residue and
215
+ // failed-canonical fatal fallback output. Append-mode preserves crash-revive history.
216
+ const logDir = jobDir(nodeId);
217
+ mkdirSync(logDir, { recursive: true });
218
+ logFd = openSync(join(logDir, 'broker.log'), 'a');
219
+ // Launch from the crouter-branded host binary (a copy of node) so the
220
+ // broker shows "crouter" in macOS Full Disk Access, not "node". On
221
+ // non-darwin / dev / tests this is just process.execPath (see branded-host).
222
+ // buildBrokerEnv's default-deny allowlist has no `CRTR_` prefix, so a bare
223
+ // `CRTR_BROKER_ENGINE` set only in the LAUNCHING process's ambient env (the
224
+ // T11 test seam — see broker-sdk.ts) would never reach the child, which
225
+ // would silently fall back to booting the real SDK. Inject the SAME value
226
+ // preflightBrokerLaunch already resolved+validated above: it is
227
+ // parent-resolved, crtr-trusted data (the same trust category as
228
+ // `inv.env`), not a blanket `CRTR_*` passthrough, so this does not weaken
229
+ // the allowlist's hardening. In production `engineSpec` is always the real
230
+ // SDK default, so this is a no-op there.
231
+ const engineSpec = resolveEngineSpec(inv);
232
+ const childEnv = { ...buildBrokerEnv(inv), CRTR_BROKER_ENGINE: engineSpec };
233
+ // NODE_OPTIONS is deliberately absent from the operational allowlist (it is
234
+ // a code-execution vector — `--require`/`--import` — and DAEMON_ENV_STRIP_KEYS
235
+ // strips it from the daemon's own env for the identical reason: a poisoned
236
+ // NODE_OPTIONS in an inheriting shell must never reach a process that then
237
+ // handles model credentials). It is genuinely needed ONLY for the T11 test
238
+ // seam above: the fake engine is a `.ts` fixture, and the broker CLI entry
239
+ // itself is only a `.js` file under the compiled `dist/`, so the harness
240
+ // relies on `NODE_OPTIONS=--import tsx/esm` (set on its own ambient env, same
241
+ // seam as CRTR_BROKER_ENGINE) to make the spawned child resolve both under
242
+ // tsx. Gating this carry on `engineSpec !== DEFAULT_BROKER_ENGINE` — i.e. only
243
+ // when the trusted test seam above already fired — keeps production (where
244
+ // the engine is never overridden) byte-identical to today: NODE_OPTIONS is
245
+ // never forwarded there, so an ambient poisoned value stays contained.
246
+ if (engineSpec !== DEFAULT_BROKER_ENGINE) {
247
+ const nodeOptions = inv.env['NODE_OPTIONS'] ?? process.env['NODE_OPTIONS'];
248
+ if (nodeOptions !== undefined)
249
+ childEnv['NODE_OPTIONS'] = nodeOptions;
250
+ }
251
+ if (eventSource() === undefined)
252
+ bindBrokerEventSource(nodeId);
253
+ // `--canvas-home` declares broker ownership in argv so the daemon's
254
+ // startup epoch census can match its own canvas' brokers exactly (crtrd's
255
+ // brokerCommandDeclaresCanvasHome) instead of inferring from install
256
+ // paths; `--epoch` stamps WHICH daemon epoch launched it, so the next
257
+ // daemon's census reaps every prior-epoch broker without adoption (D-8).
258
+ // Both precede the node id: the node id is by contract the FINAL argv
259
+ // token (both broker-cli and the ps census parse it that way).
260
+ const spawned = spawn(hostExecPath(), [resolveBrokerEntry(), '--canvas-home', crtrHome(), '--epoch', fleet.epoch(), nodeId], {
259
261
  cwd: opts.cwd,
260
262
  detached: true,
261
263
  stdio: ['ignore', logFd, logFd],
262
264
  env: childEnv,
263
265
  });
264
- }
265
- catch (error) {
266
- fleet.releaseReservation(nodeId);
266
+ child = spawned;
267
+ // The child holds its own dup of the fd; release the parent's copy so the
268
+ // launching process (CLI or daemon) never leaks it.
267
269
  closeSync(logFd);
268
- throw error;
269
- }
270
- // The child holds its own dup of the fd; release the parent's copy so the
271
- // launching process (CLI or daemon) never leaks it.
272
- closeSync(logFd);
273
- // ONE seam for every launch path (revive/spawn/recycle/reset): record the
274
- // child's real exit status as a canonical event. Readiness waits consume
275
- // `exited` for fail-fast and then discard it, so before this a broker that
276
- // was SIGKILLed out from under crouter left NO trace anywhere — an external
277
- // kill was indistinguishable from a silent early return, and post-mortems
278
- // were unprovable (see the 2026-07-27 "brokers die at startup" RCA).
279
- // Emitted through the ordinary bound source: in the daemon (the process
280
- // that actually launches brokers) that is the daemon stream carrying
281
- // `node_id`, which is how every other daemon-observed broker event
282
- // (broker.wedge.detected, broker.yield_stall.*) is recorded and how a
283
- // node-scoped log query finds it. The node's own job/log.jsonl is the
284
- // BROKER's stream — the reader rejects any non-broker-component line there
285
- // (events/read.ts descriptorMatches) — so the daemon must not write into
286
- // it. A process already bound to this node's broker source (in-process
287
- // revive) naturally lands there instead.
288
- const launchedAt = Date.now();
289
- const exited = new Promise((resolve) => {
290
- let settled = false;
291
- const finish = (state) => {
292
- if (settled)
293
- return;
294
- settled = true;
295
- resolve(state);
296
- };
297
- child.once('exit', (code, signal) => {
298
- emitEvent({
299
- level: code === 0 ? 'info' : 'warn',
300
- event: 'broker.exited',
301
- node_id: nodeId,
302
- fields: { code, signal, uptime_ms: Date.now() - launchedAt },
270
+ logFd = null;
271
+ // ONE seam for every launch path (revive/spawn/recycle/reset): record the
272
+ // child's real exit status as a canonical event. Readiness waits consume
273
+ // `exited` for fail-fast and then discard it, so before this a broker that
274
+ // was SIGKILLed out from under crouter left NO trace anywhere — an external
275
+ // kill was indistinguishable from a silent early return, and post-mortems
276
+ // were unprovable (see the 2026-07-27 "brokers die at startup" RCA).
277
+ // Emitted through the ordinary bound source: in the daemon (the process
278
+ // that actually launches brokers) that is the daemon stream carrying
279
+ // `node_id`, which is how every other daemon-observed broker event
280
+ // (broker.wedge.detected, broker.yield_stall.*) is recorded and how a
281
+ // node-scoped log query finds it. The node's own job/log.jsonl is the
282
+ // BROKER's stream — the reader rejects any non-broker-component line there
283
+ // (events/read.ts descriptorMatches) — so the daemon must not write into
284
+ // it. A process already bound to this node's broker source (in-process
285
+ // revive) naturally lands there instead.
286
+ const launchedAt = Date.now();
287
+ const exited = new Promise((resolve) => {
288
+ let settled = false;
289
+ const finish = (state) => {
290
+ if (settled)
291
+ return;
292
+ settled = true;
293
+ resolve(state);
294
+ };
295
+ spawned.once('exit', (code, signal) => {
296
+ emitEvent({
297
+ level: code === 0 ? 'info' : 'warn',
298
+ event: 'broker.exited',
299
+ node_id: nodeId,
300
+ fields: { code, signal, uptime_ms: Date.now() - launchedAt },
301
+ });
302
+ finish({ code, signal });
303
303
  });
304
- finish({ code, signal });
304
+ spawned.once('error', () => finish({ code: null, signal: null }));
305
305
  });
306
- child.once('error', () => finish({ code: null, signal: null }));
307
- });
308
- child.unref();
309
- const handle = { pid: child.pid ?? null, exited };
310
- // Register the live handle synchronously — the fleet entry IS the node's
311
- // liveness from here on, and its real `exit` event (never a pid probe)
312
- // drives dead-node policy. A null pid (spawn itself failed) registers
313
- // nothing: the `exited` promise resolves via the 'error' event and the
314
- // caller's own null-pid check surfaces the failure.
315
- if (handle.pid !== null) {
316
- fleet.register(nodeId, handle);
317
- void exited.then((status) => fleet.onChildExit(nodeId, status));
306
+ spawned.unref();
307
+ const handle = { pid: spawned.pid ?? null, exited };
308
+ // Register the live handle synchronously — the fleet entry IS the node's
309
+ // liveness from here on, and its real `exit` event (never a pid probe)
310
+ // drives dead-node policy. A null pid (spawn itself failed) registers
311
+ // nothing: the `exited` promise resolves via the 'error' event and the
312
+ // caller's own null-pid check surfaces the failure.
313
+ if (handle.pid !== null) {
314
+ fleet.register(nodeId, handle);
315
+ registered = true;
316
+ void exited.then((status) => fleet.onChildExit(nodeId, status));
317
+ }
318
+ return handle;
319
+ }
320
+ catch (error) {
321
+ if (!registered && child?.pid != null) {
322
+ try {
323
+ child.kill('SIGTERM');
324
+ }
325
+ catch { /* no fleet handle exists to tear down */ }
326
+ }
327
+ throw error;
318
328
  }
319
- else {
320
- // Nothing was registered, so nothing will ever free this slot at exit.
321
- fleet.releaseReservation(nodeId);
329
+ finally {
330
+ if (!registered)
331
+ fleet.releaseReservation(nodeId);
332
+ if (logFd !== null)
333
+ closeSync(logFd);
322
334
  }
323
- return handle;
324
335
  },
325
336
  isAlive(node) {
326
337
  return isPidAlive((typeof node === 'string' ? getNode(node) : node)?.pi_pid);
@@ -63,8 +63,9 @@ const TRANSITIONS = {
63
63
  crash: { status: 'dead', from: LIVE },
64
64
  // requestYield · relaunchRoot new-node safety net. Status KEPT (already active).
65
65
  yield: { intent: 'refresh', from: LIVE },
66
- // stophook idle-release: free the host, stay woken by the inbox.
67
- release: { status: 'idle', intent: 'idle-release', from: LIVE },
66
+ // Free the host, stay woken by the inbox. A parked resident can also drain
67
+ // here after a capacity freeze without spending a slot.
68
+ release: { status: 'idle', intent: 'idle-release', from: [...LIVE, 'done'] },
68
69
  // reviveNode provisional refresh launch: activate without committing the
69
70
  // refresh until session_start proves the engine booted.
70
71
  'refresh-launch': { status: 'active', intent: 'refresh', from: ANY },
@@ -2,8 +2,18 @@
2
2
  * usable survives. Lowercases, keeps [a-z0-9], collapses everything else to a
3
3
  * single hyphen, and clamps to the first 8 words. */
4
4
  export declare function sanitizeSessionName(raw: string): string;
5
- /** Local fallback: derive a name straight from the prompt (no pi call). Drops
6
- * stop-words, takes the first few content words. */
5
+ /** Coerce arbitrary text into a one-line prose title, or '' if nothing usable
6
+ * survives. Takes the first non-empty line, collapses runs of whitespace, drops
7
+ * wrapping quotes, clamps at a word boundary, and capitalizes the opening
8
+ * letter. Punctuation INSIDE the line survives — carrying it is what `title`
9
+ * is for. */
10
+ export declare function sanitizeSessionTitle(raw: string): string;
11
+ /** Local fallback: the opening of the prompt as a title (no pi call), with its
12
+ * punctuation. Whitespace collapses first so a multi-line message contributes
13
+ * its actual opening words rather than whatever its first line happened to be. */
14
+ export declare function titleFromPrompt(prompt: string): string;
15
+ /** Local fallback: derive a kebab handle straight from the prompt (no pi call),
16
+ * from the same opening words the fallback title keeps. */
7
17
  export declare function slugFromPrompt(prompt: string): string;
8
18
  /** The namer's model: the `light` rung of the DEFAULT provider's ladder, with
9
19
  * any thinking suffix stripped because the one-shot session uses thinking off.
@@ -11,11 +21,14 @@ export declare function slugFromPrompt(prompt: string): string;
11
21
  * user who never configured Anthropic: an OpenAI default provider resolves to
12
22
  * its own light rung. `CRTR_NAME_MODEL` still overrides. */
13
23
  export declare function lightModel(overrideEnv?: string): string;
14
- /** A generated session name: the kebab-case handle plus the Nerd Font glyph the
15
- * namer chose for it. `icon` is '' when the model returned nothing usable —
16
- * every surface renders the name alone in that case. */
24
+ /** A generated session label: the kebab-case handle, the prose title, and the
25
+ * Nerd Font glyph the namer chose. `title` is '' when the model gave nothing
26
+ * usable, leaving the caller to fall back to the prompt's own opening words;
27
+ * `icon` is '' on the same terms, and every surface renders the name alone in
28
+ * that case. */
17
29
  export interface SessionName {
18
30
  name: string;
31
+ title: string;
19
32
  icon: string;
20
33
  }
21
34
  /** Normalize a model-supplied icon to a single renderable glyph, or '' when
@@ -30,9 +43,14 @@ export interface SessionName {
30
43
  * `nf-fa-bug`, a bare ASCII letter, and anything multi-glyph, all of which
31
44
  * would render as noise or tofu in a label. */
32
45
  export declare function sanitizeIcon(raw: string): string;
33
- /** Ask Pi headlessly for a structured {name, icon} for `body`, async. Resolves
34
- * to a sanitized SessionName, or `{name:'',icon:''}` on timeout, a missing tool
35
- * call, or a malformed emission so the caller can fall back to a local slug.
46
+ /** Ask Pi headlessly for a structured {name, title, icon} for `body`, async.
47
+ * Resolves to a sanitized SessionName, or `{name:'',title:'',icon:''}` on
48
+ * timeout, a missing tool call, or a malformed emission so the caller can fall
49
+ * back to the prompt's own words.
50
+ *
51
+ * Nothing FORCES the tool call — constrained sampling shapes the arguments once
52
+ * the model decides to call it — so a model that answers in prose instead gets
53
+ * one more attempt before the caller falls back.
36
54
  *
37
55
  * This module stays canvas-free so CLI leaves can reach it. The canvas-coupled
38
56
  * commit path lives in pi-extensions/broker-local.ts, which imports this pure
@@ -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;