@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.
- package/dist/api/dto/broker-ops.d.ts +8 -3
- package/dist/api/dto/broker.d.ts +4 -2
- package/dist/api/dto/inbox.d.ts +1 -0
- package/dist/api/dto/nodes.d.ts +4 -0
- package/dist/api/dto/review-comments.d.ts +1 -0
- package/dist/builtin-memory/04-orchestration-kernel.md +1 -1
- package/dist/clients/attach/session/profile-files.js +4 -1
- package/dist/clients/attach/viewer.js +366 -366
- package/dist/commands/node/create.js +8 -3
- package/dist/commands/node/inspect.js +1 -1
- package/dist/commands/sys/context/admin/detail-panel.d.ts +3 -0
- package/dist/commands/sys/context/admin/detail-panel.js +32 -3
- package/dist/commands/sys/context/admin/docs-panel.d.ts +23 -13
- package/dist/commands/sys/context/admin/docs-panel.js +101 -103
- package/dist/commands/sys/context/admin/list-view.d.ts +53 -0
- package/dist/commands/sys/context/admin/list-view.js +125 -0
- package/dist/commands/sys/context/admin/model.d.ts +11 -0
- package/dist/commands/sys/context/admin/model.js +28 -1
- package/dist/commands/sys/context/admin/rail-panel.d.ts +46 -0
- package/dist/commands/sys/context/admin/{axes-panel.js → rail-panel.js} +64 -21
- package/dist/commands/sys/context/admin/shell.d.ts +19 -2
- package/dist/commands/sys/context/admin/shell.js +142 -46
- package/dist/commands/sys/daemon.js +1 -1
- package/dist/core/__tests__/child-death-wake.test.js +0 -48
- package/dist/core/__tests__/daemon-boot.test.js +0 -8
- package/dist/core/__tests__/dead-node-policy-table.test.js +1 -2
- package/dist/core/__tests__/integration/deferred-no-wake.test.js +14 -2
- package/dist/core/__tests__/lifecycle.test.js +5 -5
- package/dist/core/__tests__/relaunch-root.test.js +91 -1
- package/dist/core/__tests__/revive-capacity.test.js +36 -1
- package/dist/core/__tests__/seam/broker-cap-freeze.test.js +73 -0
- package/dist/core/__tests__/seam/dormancy-release.test.js +1 -2
- package/dist/core/canvas/browse/model.d.ts +0 -9
- package/dist/core/canvas/browse/model.js +1 -37
- package/dist/core/canvas/browse/render.js +2 -1
- package/dist/core/canvas/canvas.d.ts +6 -5
- package/dist/core/canvas/canvas.js +8 -7
- package/dist/core/canvas/types.d.ts +6 -0
- package/dist/core/review/realize.js +1 -1
- package/dist/core/runtime/broker/node-named.d.ts +1 -0
- package/dist/core/runtime/host.js +114 -103
- package/dist/core/runtime/lifecycle.js +3 -2
- package/dist/core/runtime/naming.d.ts +26 -8
- package/dist/core/runtime/naming.js +75 -43
- package/dist/core/runtime/reset.js +12 -5
- package/dist/core/runtime/revive-all.d.ts +4 -11
- package/dist/core/runtime/revive-all.js +6 -13
- package/dist/core/runtime/tmux-bindings.js +4 -3
- package/dist/core/runtime/warm-pool.js +4 -4
- package/dist/core/tui/fuzzy.d.ts +9 -0
- package/dist/core/tui/fuzzy.js +38 -0
- package/dist/daemon/api/__tests__/node-create-description.test.js +1 -0
- package/dist/daemon/api/handlers/broker-ops.js +14 -7
- package/dist/daemon/api/handlers/feedback-comments.js +1 -1
- package/dist/daemon/api/map.js +2 -0
- package/dist/daemon/crtrd.js +17 -20
- package/dist/daemon/messaging/node-message.d.ts +1 -0
- package/dist/daemon/messaging/node-message.js +6 -2
- package/dist/daemon/reconcilers/live-obligation.d.ts +1 -2
- package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.d.ts +0 -5
- package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +9 -13
- package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.js +0 -6
- package/dist/daemon/reconcilers/node-lifecycle/terminating.d.ts +0 -2
- package/dist/daemon/reconcilers/node-lifecycle/terminating.js +0 -10
- package/dist/daemon/reconcilers/node-lifecycle/tick.d.ts +2 -2
- package/dist/daemon/reconcilers/node-lifecycle/tick.js +4 -2
- package/dist/daemon/reconcilers/storage-maintenance.js +2 -2
- package/dist/daemon/review/comment-notify.d.ts +1 -0
- package/dist/daemon/review/comment-notify.js +1 -0
- package/dist/pi-extensions/broker-local.d.ts +1 -0
- package/dist/pi-extensions/broker-local.js +12 -7
- package/dist/pi-extensions/canvas-recap.js +1 -0
- package/dist/pi-extensions/canvas-stophook.js +2 -0
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
- package/dist/commands/sys/context/admin/axes-panel.d.ts +0 -21
|
@@ -206,121 +206,132 @@ export const headlessBrokerHost = {
|
|
|
206
206
|
if (!fleet.reserveSlot(nodeId)) {
|
|
207
207
|
throw brokerCapReached(brokerThresholdsForDaemon().automaticReviveCap, fleet.size());
|
|
208
208
|
}
|
|
209
|
-
|
|
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
|
-
|
|
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
|
-
|
|
266
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
//
|
|
67
|
-
|
|
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
|
-
/**
|
|
6
|
-
*
|
|
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
|
|
15
|
-
* namer chose
|
|
16
|
-
*
|
|
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.
|
|
34
|
-
* to a sanitized SessionName, or `{name:'',icon:''}` on
|
|
35
|
-
* call, or a malformed emission so the caller can fall
|
|
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
|
-
//
|
|
2
|
+
// label.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
|
10
|
-
// sampling through the one-shot `name_session` tool, not by parsing
|
|
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
|
|
51
|
-
'
|
|
52
|
-
'
|
|
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
|
-
'- `
|
|
55
|
-
'
|
|
56
|
-
'
|
|
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\
|
|
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
|
-
/**
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
/**
|
|
100
|
-
*
|
|
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
|
-
|
|
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.
|
|
185
|
-
* to a sanitized SessionName, or `{name:'',icon:''}` on
|
|
186
|
-
* call, or a malformed emission so the caller can fall
|
|
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
|
|
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
|
-
...
|
|
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
|
|
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
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
11
|
-
*
|
|
12
|
-
|
|
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
|
|
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
|
|
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
|
|
28
|
-
*
|
|
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
|
|
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;
|
|
@@ -36,9 +36,10 @@ const LEAF_MENU_ACTIONS = {
|
|
|
36
36
|
// default dialog-sized popup clipped both the rail and the models matrix.
|
|
37
37
|
'crtr.tmux.menu.providers': { description: 'settings', action: { kind: 'popup', run: 'sys settings', size: { w: '90%', h: '85%' } } },
|
|
38
38
|
'crtr.tmux.menu.plugins': { description: 'plugins', action: { kind: 'popup', run: 'pkg browse', size: { w: '90%', h: '85%' } } },
|
|
39
|
-
// Three zones side by side
|
|
40
|
-
//
|
|
41
|
-
|
|
39
|
+
// Three zones side by side over a corpus of hundreds of documents: the list
|
|
40
|
+
// spends every column it is given on canonical names. Near-full rather than
|
|
41
|
+
// full, so the pane border still marks it as a popup.
|
|
42
|
+
'crtr.tmux.menu.context-admin': { description: 'context surface', action: { kind: 'popup', run: 'sys context admin', size: { w: '98%', h: '96%' } } },
|
|
42
43
|
'crtr.tmux.menu.graph': { description: 'canvas graph', action: { kind: 'keys', keys: '/graph' } },
|
|
43
44
|
'crtr.tmux.menu.focus-manager': { description: 'go to manager', action: { kind: 'command', run: 'surface node focus {manager}' } },
|
|
44
45
|
'crtr.tmux.menu.copy-id': { description: 'copy node id', action: { kind: 'command', run: 'surface node id --pane {pane}' } },
|