@north-light/crouter 0.3.214 → 0.3.215

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 (97) hide show
  1. package/dist/api/dto/nodes.d.ts +2 -1
  2. package/dist/api/dto/profiles.d.ts +13 -1
  3. package/dist/builtin-memory/00-runtime-base.md +12 -11
  4. package/dist/builtin-memory/01-spine/00-has-manager.md +1 -11
  5. package/dist/builtin-memory/02-lifecycle/00-terminal.md +4 -10
  6. package/dist/builtin-memory/04-orchestration-kernel.md +9 -35
  7. package/dist/builtin-memory/05-kinds/design/00-base.md +2 -2
  8. package/dist/builtin-memory/05-kinds/design/01-orchestrator.md +1 -1
  9. package/dist/builtin-memory/05-kinds/developer/00-base.md +2 -2
  10. package/dist/builtin-memory/05-kinds/explore/00-base.md +1 -1
  11. package/dist/builtin-memory/05-kinds/general/00-base.md +2 -0
  12. package/dist/builtin-memory/05-kinds/plan/00-base.md +1 -1
  13. package/dist/builtin-memory/05-kinds/plan/reviewers/security.md +1 -1
  14. package/dist/builtin-memory/05-kinds/review/00-base.md +1 -3
  15. package/dist/builtin-memory/05-kinds/review/01-orchestrator.md +1 -1
  16. package/dist/builtin-memory/05-kinds/review/security-findings.md +12 -0
  17. package/dist/builtin-memory/05-kinds/spec/00-base.md +1 -1
  18. package/dist/clients/attach/__tests__/context-message.test.js +28 -1
  19. package/dist/clients/attach/chrome/canvas-panels.d.ts +0 -4
  20. package/dist/clients/attach/chrome/canvas-panels.js +5 -7
  21. package/dist/clients/attach/chrome/inbox-strip.d.ts +31 -0
  22. package/dist/clients/attach/chrome/inbox-strip.js +187 -0
  23. package/dist/clients/attach/chrome/roster.d.ts +6 -2
  24. package/dist/clients/attach/chrome/roster.js +13 -52
  25. package/dist/clients/attach/chrome/ticket-panel.d.ts +29 -0
  26. package/dist/clients/attach/chrome/ticket-panel.js +236 -0
  27. package/dist/clients/attach/render/card-presentation.d.ts +8 -3
  28. package/dist/clients/attach/render/card-presentation.js +48 -10
  29. package/dist/clients/attach/render/context-message.d.ts +5 -0
  30. package/dist/clients/attach/render/context-message.js +20 -14
  31. package/dist/clients/attach/session/context.d.ts +3 -0
  32. package/dist/clients/attach/session/frame.d.ts +4 -0
  33. package/dist/clients/attach/session/frame.js +8 -3
  34. package/dist/clients/attach/session/keys.d.ts +7 -0
  35. package/dist/clients/attach/session/keys.js +7 -0
  36. package/dist/clients/attach/session/layout.js +6 -3
  37. package/dist/clients/attach/session/pane-focus.d.ts +8 -0
  38. package/dist/clients/attach/session/pane-focus.js +42 -0
  39. package/dist/clients/attach/viewer.js +793 -789
  40. package/dist/clients/inbox/controller.d.ts +9 -0
  41. package/dist/clients/inbox/controller.js +57 -2
  42. package/dist/clients/inbox/surface.d.ts +2 -0
  43. package/dist/clients/inbox/surface.js +18 -3
  44. package/dist/clients/inbox/tui.d.ts +2 -0
  45. package/dist/clients/inbox/tui.js +11 -1
  46. package/dist/commands/node/create.js +3 -3
  47. package/dist/commands/profile/kind.d.ts +2 -0
  48. package/dist/commands/profile/kind.js +51 -0
  49. package/dist/commands/profile/list.js +5 -1
  50. package/dist/commands/profile/meta.d.ts +4 -0
  51. package/dist/commands/profile/meta.js +67 -0
  52. package/dist/commands/profile/new.js +33 -3
  53. package/dist/commands/profile/pause.d.ts +2 -0
  54. package/dist/commands/profile/pause.js +60 -0
  55. package/dist/commands/profile/show.js +9 -1
  56. package/dist/commands/profile.js +5 -8
  57. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +40 -0
  58. package/dist/core/__tests__/parse-argv-stdin-secret.test.js +28 -0
  59. package/dist/core/__tests__/seam/dormancy-release.test.js +7 -0
  60. package/dist/core/command.js +23 -23
  61. package/dist/core/help.d.ts +1 -1
  62. package/dist/core/keybindings/attach-control.d.ts +3 -0
  63. package/dist/core/keybindings/attach-control.js +1 -0
  64. package/dist/core/keybindings/catalog.js +1 -0
  65. package/dist/core/profiles/deletion-reservation.d.ts +7 -3
  66. package/dist/core/profiles/deletion-reservation.js +9 -5
  67. package/dist/core/profiles/manifest.d.ts +27 -1
  68. package/dist/core/profiles/manifest.js +122 -4
  69. package/dist/core/runtime/boot-root.js +3 -3
  70. package/dist/core/runtime/close.js +10 -5
  71. package/dist/core/runtime/revive.js +2 -0
  72. package/dist/core/runtime/spawn-env.d.ts +9 -1
  73. package/dist/core/runtime/spawn-env.js +17 -1
  74. package/dist/core/termrender/termrender.d.ts +7 -2
  75. package/dist/core/termrender/termrender.js +11 -6
  76. package/dist/core/termrender/version.d.ts +1 -1
  77. package/dist/core/termrender/version.js +1 -1
  78. package/dist/daemon/api/__tests__/profile-launch-gates.test.d.ts +1 -0
  79. package/dist/daemon/api/__tests__/profile-launch-gates.test.js +111 -0
  80. package/dist/daemon/api/handlers/messages.js +9 -2
  81. package/dist/daemon/api/handlers/nodes.d.ts +2 -0
  82. package/dist/daemon/api/handlers/nodes.js +15 -8
  83. package/dist/daemon/api/handlers/profiles.js +7 -2
  84. package/dist/daemon/api/map.js +3 -0
  85. package/dist/daemon/fleet.js +1 -6
  86. package/dist/daemon/reconcilers/broker-supervision.js +15 -25
  87. package/dist/daemon/reconcilers/dormant-inbox.js +10 -10
  88. package/dist/daemon/reconcilers/live-obligation.d.ts +5 -1
  89. package/dist/daemon/reconcilers/live-obligation.js +11 -19
  90. package/dist/pi-extensions/canvas-inbox-watcher.js +15 -0
  91. package/dist/types.d.ts +9 -0
  92. package/package.json +1 -1
  93. package/runtime.lock.json +2 -2
  94. package/dist/builtin-memory/01-spine/01-no-manager.md +0 -11
  95. package/dist/builtin-memory/05-kinds/general/01-orchestrator.md +0 -8
  96. /package/dist/builtin-memory/05-kinds/advisor/{00-base.md → advice-contract.md} +0 -0
  97. /package/dist/builtin-memory/05-kinds/plan/reviewers/{00-base.md → lens-contract.md} +0 -0
@@ -14,6 +14,7 @@ import { appendInbox, readCursor } from '../feed/inbox.js';
14
14
  import { drainBearings } from '../runtime/kickoff.js';
15
15
  import { appendSituationalContext } from '../runtime/situational-context.js';
16
16
  import { createNode, getNode, recordPid, setIntent } from '../canvas/canvas.js';
17
+ import { createProfile, pauseProfile, resumeProfile } from '../profiles/manifest.js';
17
18
  import { closeDb } from '../canvas/db.js';
18
19
  import { createApiServer } from '../../daemon/api/server.js';
19
20
  import { CrtrClient } from '../../api/client.js';
@@ -257,6 +258,45 @@ describe('canvas inbox watcher — hold + idle delivery', () => {
257
258
  assert.equal(pi.injected.length, 1);
258
259
  assert.equal(pi.injected[0].deliverAs, undefined, 'idle → sendUserMessage triggers a turn, no deliverAs');
259
260
  });
261
+ // Pause makes the profile inert while its broker is still alive: the entry
262
+ // must be RETAINED, not consumed. A critical entry is the sharpest case —
263
+ // its route aborts the live turn, exactly the punch-through pause forbids.
264
+ test('a paused profile holds queued entries in a live broker until resume', async () => {
265
+ freshNode('node-paused-profile');
266
+ closeDb();
267
+ const origUserHome = process.env.HOME;
268
+ const origProfile = process.env['CRTR_PROFILE_ID'];
269
+ const userHome = join(tmpdir(), `crtr-w-home-${Math.random().toString(36).slice(2, 8)}`);
270
+ mkdirSync(userHome, { recursive: true });
271
+ homes.push(userHome);
272
+ process.env.HOME = userHome;
273
+ try {
274
+ const profile = createProfile('paused watcher', ['/tmp']);
275
+ pauseProfile(profile.profileId);
276
+ process.env['CRTR_PROFILE_ID'] = profile.profileId;
277
+ const pi = makeFakePi();
278
+ disposers.push(registerWatcher(pi));
279
+ await wait(TICK_MS + 100);
280
+ appendInbox('node-paused-profile', { from: 'child-1', tier: 'critical', kind: 'update', label: 'sent while paused' });
281
+ await wait(SETTLE_MS);
282
+ assert.equal(pi.injected.length, 0, 'a paused profile delivers nothing to its live broker');
283
+ assert.equal(readCursor('node-paused-profile'), undefined, 'the held entry stays unconsumed');
284
+ resumeProfile(profile.profileId);
285
+ await wait(SETTLE_MS);
286
+ assert.equal(pi.injected.length, 1, 'resume delivers the entry that waited');
287
+ assert.match(pi.injected[0].content, /sent while paused/);
288
+ }
289
+ finally {
290
+ if (origUserHome === undefined)
291
+ delete process.env.HOME;
292
+ else
293
+ process.env.HOME = origUserHome;
294
+ if (origProfile === undefined)
295
+ delete process.env['CRTR_PROFILE_ID'];
296
+ else
297
+ process.env['CRTR_PROFILE_ID'] = origProfile;
298
+ }
299
+ });
260
300
  });
261
301
  // Lifecycle-ordering regression (the live inbox-delivery startup loop): pi
262
302
  // deliberately makes action methods (sendUserMessage/sendMessage) throw until
@@ -9,11 +9,39 @@
9
9
  import { test } from 'node:test';
10
10
  import assert from 'node:assert/strict';
11
11
  import { parseArgv } from '../command.js';
12
+ // Companion lock: `crtr node yield "note"`, `crtr push "done"` and 11 sibling
13
+ // leaves declare a `stdin` body and NO positional param, and take that body as
14
+ // one bare token. A parser refactor dropped the branch that accepts it while
15
+ // leaving the resolution code below it intact, so every one of them exited 2
16
+ // with empty stderr — caught only indirectly, two suites away.
17
+ const YIELD_SHAPED_PARAMS = [
18
+ { kind: 'flag', name: 'promote', type: 'bool', required: false, constraint: 'promote too' },
19
+ { kind: 'stdin', name: 'message', required: true, constraint: 'note to your future self' },
20
+ ];
12
21
  const PROFILE_SET_SHAPED_PARAMS = [
13
22
  { kind: 'positional', name: 'profile', required: true, constraint: 'profile id' },
14
23
  { kind: 'flag', name: 'name', type: 'string', required: true, constraint: 'var name' },
15
24
  { kind: 'stdin', name: 'value', required: true, constraint: 'value piped on stdin' },
16
25
  ];
26
+ test('a lone positional supplies the stdin body on a leaf declaring no positional param', async () => {
27
+ const result = await parseArgv(YIELD_SHAPED_PARAMS, ['refresh against the original goal']);
28
+ assert.equal(result['message'], 'refresh against the original goal');
29
+ });
30
+ test('flags parse alongside the positional that supplies the stdin body', async () => {
31
+ const result = await parseArgv(YIELD_SHAPED_PARAMS, ['--promote', 'coordinate the fan-out']);
32
+ assert.equal(result['message'], 'coordinate the fan-out');
33
+ assert.equal(result['promote'], true);
34
+ });
35
+ test('allowPositional:false keeps a stdin-only leaf rejecting positionals by arity', async () => {
36
+ const params = [
37
+ { kind: 'stdin', name: 'document', required: false, allowPositional: false, constraint: 'page source on stdin' },
38
+ ];
39
+ await assert.rejects(() => parseArgv(params, ['page.tsx']), (err) => {
40
+ assert.equal(err.code, 'bad_invocation');
41
+ assert.match(err.message, /takes no positional arguments/);
42
+ return true;
43
+ });
44
+ });
17
45
  test('an extra positional on a stdin-bearing leaf is rejected without echoing it', async () => {
18
46
  const secret = 'SUPERSECRET_ARGV';
19
47
  await assert.rejects(() => parseArgv(PROFILE_SET_SHAPED_PARAMS, ['some-profile', secret, '--name', 'REVIEW_TOKEN']), (err) => {
@@ -127,9 +127,12 @@ test('an already idle resident with no live obligation reconciles into completed
127
127
  intent: 'idle-release',
128
128
  pi_session_id: 'parked-session',
129
129
  });
130
+ subscribe(root, nodeId, true);
130
131
  await h.tick();
131
132
  assert.equal(h.node(nodeId)?.status, 'done', 'an older idle row becomes pruning-eligible without needing a broker revive');
132
133
  assert.equal(h.node(nodeId)?.intent, 'done', 'the reconciled row records clean completion');
134
+ const wake = readInboxSince(root).find((entry) => entry.from === nodeId && entry.data?.['reason'] === 'child-auto-done');
135
+ assert.ok(wake, 'the subscribing root learns that the parked resident completed without a final report');
133
136
  });
134
137
  test('an unattended resident with no live obligation completes on the daemon clock', { timeout: 30_000 }, async () => {
135
138
  // The park clock is read per supervision pass (unattendedParkMsForDaemon), so
@@ -153,6 +156,7 @@ test('an unattended resident with no live obligation completes on the daemon clo
153
156
  env: {},
154
157
  },
155
158
  });
159
+ subscribe(root, nodeId, true);
156
160
  reviveNode(nodeId, { resume: true });
157
161
  const boot = await h.awaitBoot(nodeId);
158
162
  assert.equal(h.fleet.has(nodeId), true, 'the revived broker is fleet-owned');
@@ -170,6 +174,9 @@ test('an unattended resident with no live obligation completes on the daemon clo
170
174
  await h.awaitFleetExit(nodeId);
171
175
  assert.equal(h.fleet.has(nodeId), false, 'exit policy forgets the completed broker');
172
176
  assert.equal(h.node(nodeId)?.status, 'done', 'the row is terminal and eligible for history pruning');
177
+ const wake = readInboxSince(root).find((entry) => entry.from === nodeId && entry.data?.['reason'] === 'child-auto-done');
178
+ assert.ok(wake, 'the subscribing root is woken when the unattended clock completes the resident');
179
+ assert.match(wake.label, /sent no final report/, 'the wake distinguishes automatic completion from a final report');
173
180
  await h.tick();
174
181
  assert.equal(h.fleet.has(nodeId), false, 'a supervision tick does not revive completed history');
175
182
  }
@@ -301,7 +301,7 @@ export async function parseArgv(params, tokens, options) {
301
301
  const provided = new Set(); // Track explicitly-provided parameter names
302
302
  const contextFilePaths = new Map();
303
303
  // Index params by kind for quick lookup
304
- const positionalParam = params.find((p) => p.kind === 'positional');
304
+ const positionalParams = params.filter((p) => p.kind === 'positional');
305
305
  const stdinParam = params.find((p) => p.kind === 'stdin');
306
306
  const contextFileParam = params.find((p) => p.kind === 'context-file');
307
307
  const flagParams = params.filter((p) => p.kind === 'flag');
@@ -316,7 +316,7 @@ export async function parseArgv(params, tokens, options) {
316
316
  }
317
317
  }
318
318
  const positionalValues = [];
319
- const repeatablePositional = positionalParam?.repeatable === true;
319
+ let positionalIndex = 0;
320
320
  let positionalWasSupplied = false; // Track if positional came from argv (not stdin)
321
321
  let forcedPositional = false; // true after bare --
322
322
  let i = 0;
@@ -419,46 +419,50 @@ export async function parseArgv(params, tokens, options) {
419
419
  continue;
420
420
  }
421
421
  // Positional (or token after --)
422
- if (repeatablePositional && positionalParam !== undefined) {
422
+ const positionalParam = positionalParams[positionalIndex];
423
+ if (positionalParam !== undefined) {
424
+ const key = flagNameToKey(positionalParam.name);
425
+ if (positionalParam.repeatable === true) {
426
+ const values = result[key] ?? [];
427
+ values.push(token);
428
+ result[key] = values;
429
+ }
430
+ else {
431
+ result[key] = token;
432
+ positionalIndex++;
433
+ }
423
434
  positionalValues.push(token);
424
435
  positionalWasSupplied = true;
436
+ provided.add(key);
425
437
  i++;
426
438
  continue;
427
439
  }
428
440
  if (positionalValues.length > 0) {
429
441
  // A leaf that also declares a `stdin` param treats its stdin value as
430
442
  // write-only/sensitive by convention (`profile env set`'s value, etc.).
431
- // An unexpected extra positional here is the single most likely misuse
432
- // of that contract — a caller typing the secret as an argv token instead
433
- // of piping it — so never echo the token or the full argv back; a
434
- // sensitive value must not be disclosed by the very error that rejects
435
- // the mistake.
443
+ // An unexpected extra positional is the likely secret-as-argv mistake, so
444
+ // never echo it back in the rejection.
436
445
  if (stdinParam !== undefined) {
437
446
  throw parseArgvError('bad_invocation', `unexpected extra positional argument (value withheld: this leaf reads "${stdinParam.name}" from stdin, never argv)`, undefined, undefined, `Pipe it on stdin instead of passing it as a positional argument.`);
438
447
  }
439
- throw parseArgvError('bad_invocation', `unexpected extra positional argument: ${token}`, tokens.join(' '), undefined, 'Use --flag for parameters; only one positional allowed.');
448
+ throw parseArgvError('bad_invocation', `unexpected extra positional argument: ${token}`, tokens.join(' '), undefined, 'Use declared flags or positional parameters. Run -h for the schema.');
440
449
  }
441
- // A bare positional is accepted when the leaf declares a positional param,
442
- // or when its stdin parameter explicitly permits a positional body.
443
- if (positionalParam === undefined && (stdinParam === undefined || stdinParam.allowPositional === false)) {
450
+ // A leaf declaring no positional param may still take one bare token as its
451
+ // stdin body (`crtr node yield "note"`); the resolution below turns it into
452
+ // the stdin value. `allowPositional: false` opts out.
453
+ if (positionalParams.length === 0 && (stdinParam === undefined || stdinParam.allowPositional === false)) {
444
454
  throw parseArgvError('bad_invocation', `this leaf takes no positional arguments: ${token}`, token, undefined, 'Use --flag for parameters. Run -h for the schema.');
445
455
  }
446
456
  positionalValues.push(token);
447
457
  positionalWasSupplied = true;
448
458
  i++;
449
459
  }
450
- // Assign positional (already tracked in provided set above)
451
- if (positionalValues.length > 0 && positionalParam !== undefined) {
452
- result[flagNameToKey(positionalParam.name)] = repeatablePositional
453
- ? positionalValues
454
- : positionalValues[0];
455
- }
456
460
  // Resolve stdin if declared. Some leaves permit one positional token to
457
461
  // supply the stdin body; `-` then means read stdin. A positional alongside
458
462
  // genuinely piped, non-empty stdin is ambiguous and errors loudly.
459
463
  let stdinWasSupplied = false;
460
464
  if (stdinParam !== undefined) {
461
- if (positionalValues.length > 0 && positionalParam === undefined && stdinParam.allowPositional !== false) {
465
+ if (positionalValues.length > 0 && positionalParams.length === 0 && stdinParam.allowPositional !== false) {
462
466
  const positionalValue = positionalValues[0];
463
467
  if (positionalValue === '-') {
464
468
  const raw = await readStdinRaw();
@@ -505,10 +509,6 @@ export async function parseArgv(params, tokens, options) {
505
509
  stdinWasSupplied = !stdinIsTTY || positionalWasSupplied;
506
510
  }
507
511
  }
508
- // Track provided positional param
509
- if (positionalWasSupplied && positionalParam !== undefined) {
510
- provided.add(flagNameToKey(positionalParam.name));
511
- }
512
512
  // Track provided stdin param
513
513
  if (stdinWasSupplied && stdinParam !== undefined) {
514
514
  provided.add(flagNameToKey(stdinParam.name));
@@ -6,7 +6,7 @@ export interface Field {
6
6
  * token caps. Lives here, never in a separate Preconditions section. */
7
7
  constraint: string;
8
8
  }
9
- /** Positional argument — at most one parameter per leaf; optionally repeatable. */
9
+ /** Positional argument. A repeatable positional consumes every remaining positional token. */
10
10
  export interface PositionalParam {
11
11
  kind: 'positional';
12
12
  name: string;
@@ -13,6 +13,9 @@ export declare const ATTACH_CONTROL_BINDINGS: readonly [{
13
13
  }, {
14
14
  readonly menuId: "crtr.tmux.menu.attach.inbox.toggle";
15
15
  readonly actionId: "crtr.attach.inbox.toggle";
16
+ }, {
17
+ readonly menuId: "crtr.tmux.menu.attach.inbox-strip";
18
+ readonly actionId: "crtr.attach.inbox-strip";
16
19
  }, {
17
20
  readonly menuId: "crtr.tmux.menu.attach.model-ladder.next";
18
21
  readonly actionId: "crtr.attach.model-ladder.next";
@@ -4,6 +4,7 @@ export const ATTACH_CONTROL_BINDINGS = [
4
4
  { menuId: 'crtr.tmux.menu.attach.graph.toggle', actionId: 'crtr.attach.graph.toggle' },
5
5
  { menuId: 'crtr.tmux.menu.attach.help.toggle', actionId: 'crtr.attach.help.toggle' },
6
6
  { menuId: 'crtr.tmux.menu.attach.inbox.toggle', actionId: 'crtr.attach.inbox.toggle' },
7
+ { menuId: 'crtr.tmux.menu.attach.inbox-strip', actionId: 'crtr.attach.inbox-strip' },
7
8
  { menuId: 'crtr.tmux.menu.attach.model-ladder.next', actionId: 'crtr.attach.model-ladder.next' },
8
9
  { menuId: 'crtr.tmux.menu.attach.model-ladder.previous', actionId: 'crtr.attach.model-ladder.previous' },
9
10
  { menuId: 'crtr.tmux.menu.attach.command.inspect', actionId: 'crtr.attach.command.inspect' },
@@ -72,6 +72,7 @@ export const BINDING_CATALOG = Object.freeze([
72
72
  ...attachEntry('crtr.tmux.menu.attach.graph.toggle', 'crtr.attach.graph.toggle', 'Toggle graph', 'Open or close the graph overlay.', ['g'], ['alt+g']),
73
73
  ...attachEntry('crtr.tmux.menu.attach.help.toggle', 'crtr.attach.help.toggle', 'Help', 'Open the attach help overlay.', ['k'], ['alt+shift+k']),
74
74
  ...attachEntry('crtr.tmux.menu.attach.inbox.toggle', 'crtr.attach.inbox.toggle', 'Toggle inbox', 'Open or close the human inbox surface in this viewer.', ['i'], []),
75
+ ...attachEntry('crtr.tmux.menu.attach.inbox-strip', 'crtr.attach.inbox-strip', 'Pending requests', 'Jump to the pending human-request strip at the top of the pane.', ['u'], ['alt+u']),
75
76
  ...attachEntry('crtr.tmux.menu.attach.model-ladder.next', 'crtr.attach.model-ladder.next', 'Next model', 'Move to the next model ladder rung.', [']'], ['alt+m']),
76
77
  ...attachEntry('crtr.tmux.menu.attach.model-ladder.previous', 'crtr.attach.model-ladder.previous', 'Previous model', 'Move to the previous model ladder rung.', ['['], ['alt+shift+m']),
77
78
  ...attachEntry('crtr.tmux.menu.attach.command.inspect', 'crtr.attach.command.inspect', 'Inspect loaded command', 'Open the loaded slash command’s expanded prompt.', ['h'], ['alt+shift+h']),
@@ -2,7 +2,11 @@
2
2
  * The caller must release the returned lease after completion or failure. */
3
3
  export declare function reserveProfileDeletion(profileId: string): () => void;
4
4
  /** The synchronous birth gate. spawnNode calls this immediately before the
5
- * canvas row is persisted. A reservation rejects a birth that reaches the gate
6
- * during deletion; the exact manifest read rejects one whose async preflight
7
- * started before a fast hard deletion and resumed after its lease was released. */
5
+ * canvas row is persisted, so EVERY birth passes here — the API create, forks,
6
+ * cron spawns, warm-pool mints, `/new` relaunch, and recycle alike. A
7
+ * reservation rejects a birth that reaches the gate during deletion; the exact
8
+ * manifest read rejects one whose async preflight started before a fast hard
9
+ * deletion and resumed after its lease was released; a paused profile is inert
10
+ * and creates nothing. `POST /v1/nodes` gates earlier only to phrase the paused
11
+ * refusal against the operand the caller actually typed. */
8
12
  export declare function assertProfileAvailableForBirth(profileId: string | null): void;
@@ -1,5 +1,5 @@
1
1
  import { usage } from '../errors.js';
2
- import { loadExactProfileManifest } from './manifest.js';
2
+ import { assertProfileEntryActive, loadExactProfileManifest } from './manifest.js';
3
3
  const deletingProfileIds = new Set();
4
4
  /** Reserve one profile identity for its complete daemon-owned deletion lifecycle.
5
5
  * The caller must release the returned lease after completion or failure. */
@@ -21,9 +21,13 @@ export function reserveProfileDeletion(profileId) {
21
21
  };
22
22
  }
23
23
  /** The synchronous birth gate. spawnNode calls this immediately before the
24
- * canvas row is persisted. A reservation rejects a birth that reaches the gate
25
- * during deletion; the exact manifest read rejects one whose async preflight
26
- * started before a fast hard deletion and resumed after its lease was released. */
24
+ * canvas row is persisted, so EVERY birth passes here — the API create, forks,
25
+ * cron spawns, warm-pool mints, `/new` relaunch, and recycle alike. A
26
+ * reservation rejects a birth that reaches the gate during deletion; the exact
27
+ * manifest read rejects one whose async preflight started before a fast hard
28
+ * deletion and resumed after its lease was released; a paused profile is inert
29
+ * and creates nothing. `POST /v1/nodes` gates earlier only to phrase the paused
30
+ * refusal against the operand the caller actually typed. */
27
31
  export function assertProfileAvailableForBirth(profileId) {
28
32
  if (profileId === null)
29
33
  return;
@@ -34,5 +38,5 @@ export function assertProfileAvailableForBirth(profileId) {
34
38
  next: 'Choose another profile or wait for this deletion to finish.',
35
39
  });
36
40
  }
37
- loadExactProfileManifest(profileId);
41
+ assertProfileEntryActive(loadExactProfileManifest(profileId));
38
42
  }
@@ -39,8 +39,34 @@ export declare function loadProfileManifest(profileIdOrName: string): ProfileEnt
39
39
  * below run inside this. */
40
40
  export declare function withProfileManifestLock<T>(profileId: string, fn: () => T): T;
41
41
  export declare function ensureRootProfile(): ProfileEntry;
42
- export declare function createProfile(name: string, projects?: string[]): ProfileEntry;
42
+ export declare function assertProfileMetadata(entries: Record<string, string>): void;
43
+ export declare function createProfile(name: string, projects?: string[], opts?: {
44
+ defaultKind?: string;
45
+ metadata?: Record<string, string>;
46
+ }): ProfileEntry;
43
47
  export declare function updateProfileLastUsed(profileId: string): ProfileEntry;
48
+ /** Paused check for an ALREADY-RESOLVED durable id (a canvas row's `profile_id`,
49
+ * `CRTR_PROFILE_ID`) — exact-id only, no name matching, so it costs one small
50
+ * read on the hot delivery paths that call it every poll. Fails OPEN: an id
51
+ * whose manifest is missing or corrupt is not paused, because treating it as
52
+ * paused would strand that node's queued inbox forever. */
53
+ export declare function isProfilePaused(profileId: string | null | undefined): boolean;
54
+ /** Refuse a launch under a paused profile, naming the resume command. Shared by
55
+ * every gate that already holds a loaded manifest. */
56
+ export declare function assertProfileEntryActive(entry: ProfileEntry): void;
57
+ export declare function assertProfileActive(profileId: string | null | undefined): ProfileEntry | null;
58
+ export declare function pauseProfile(profileId: string): ProfileEntry;
59
+ export declare function resumeProfile(profileId: string): ProfileEntry;
60
+ export declare function setProfileDefaultKind(profileId: string, defaultKind: string): ProfileEntry;
61
+ /** Merge `set` entries over the stored map and drop `unset` keys; the
62
+ * `metadata` field is omitted entirely when the result is empty. */
63
+ export declare function updateProfileMetadata(profileId: string, set: Record<string, string>, unset?: string[]): ProfileEntry;
64
+ /** A profile's stored metadata for broker-env injection — sanitized entry by
65
+ * entry (a manifest is hand-editable, so a bad key/value is dropped rather
66
+ * than trusted) and never throwing: a stale/invalid/absent profile id is a
67
+ * hot-path no-op, because broker-env resolution must never fail a launch
68
+ * over a bad profile id (mirrors `readProfileEnvVars`). */
69
+ export declare function readProfileMetadata(profileId: string | null): Record<string, string>;
44
70
  export declare function renameProfile(profileId: string, name: string): ProfileEntry;
45
71
  export declare function addProfileProject(profileId: string, dir: string, opts?: {
46
72
  home?: boolean;
@@ -84,9 +84,9 @@ export function profileMemoryDir(profileId) {
84
84
  * the user started them, not in whatever dir was last added to its purview. */
85
85
  function normalizeHome(profileId, manifest) {
86
86
  const stored = typeof manifest.home === 'string' && manifest.home !== '' ? manifest.home : null;
87
- if (profileId === ROOT_PROFILE_ID)
88
- return { ...manifest, home: stored };
89
- return { ...manifest, home: stored ?? manifest.projects[0] ?? null };
87
+ const home = profileId === ROOT_PROFILE_ID ? stored : stored ?? manifest.projects[0] ?? null;
88
+ const pausedAt = typeof manifest.paused_at === 'string' && manifest.paused_at !== '' ? manifest.paused_at : null;
89
+ return { ...manifest, home, paused_at: pausedAt };
90
90
  }
91
91
  function readManifestFile(path, profileId) {
92
92
  if (!existsSync(path))
@@ -282,6 +282,7 @@ export function ensureRootProfile() {
282
282
  name: ROOT_PROFILE_NAME,
283
283
  projects: [],
284
284
  home: null,
285
+ paused_at: null,
285
286
  created_at: nowIso(),
286
287
  last_used_at: nowIso(),
287
288
  };
@@ -290,10 +291,33 @@ export function ensureRootProfile() {
290
291
  return { profileId: ROOT_PROFILE_ID, manifest: next };
291
292
  });
292
293
  }
293
- export function createProfile(name, projects = []) {
294
+ /** Key shape every stored metadata entry must satisfy — kept env-mappable
295
+ * (`CRTR_PROFILE_META_<KEY>`, see `core/runtime/spawn-env.ts`) by
296
+ * construction. */
297
+ const METADATA_KEY_SHAPE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
298
+ export function assertProfileMetadata(entries) {
299
+ for (const [key, value] of Object.entries(entries)) {
300
+ if (!METADATA_KEY_SHAPE.test(key)) {
301
+ throw usage(`invalid metadata key: ${JSON.stringify(key)}`, {
302
+ received: key,
303
+ field: 'metadata',
304
+ next: 'Keys start with a letter or digit, followed by letters, digits, `_`, or `-`.',
305
+ });
306
+ }
307
+ if (value.includes('\0')) {
308
+ throw usage(`metadata value for ${key} contains a NUL byte, which no OS accepts in an environment variable`, {
309
+ field: 'metadata',
310
+ next: 'Remove NUL bytes from the value and retry.',
311
+ });
312
+ }
313
+ }
314
+ }
315
+ export function createProfile(name, projects = [], opts = {}) {
294
316
  const trimmed = name.trim();
295
317
  if (trimmed === '')
296
318
  throw usage('profile name must not be empty');
319
+ if (opts.metadata !== undefined)
320
+ assertProfileMetadata(opts.metadata);
297
321
  const resolvedProjects = dedupeOrdered(projects.map(resolveExistingProjectDir));
298
322
  const profileId = generateProfileId(trimmed);
299
323
  return withProfileManifestLock(profileId, () => {
@@ -302,6 +326,9 @@ export function createProfile(name, projects = []) {
302
326
  name: trimmed,
303
327
  projects: resolvedProjects,
304
328
  home: resolvedProjects[0] ?? null,
329
+ paused_at: null,
330
+ ...(opts.defaultKind !== undefined ? { default_kind: opts.defaultKind } : {}),
331
+ ...(opts.metadata !== undefined && Object.keys(opts.metadata).length > 0 ? { metadata: { ...opts.metadata } } : {}),
305
332
  created_at: nowIso(),
306
333
  last_used_at: null,
307
334
  };
@@ -335,6 +362,97 @@ function mutateManifest(profileId, mutate) {
335
362
  export function updateProfileLastUsed(profileId) {
336
363
  return mutateManifest(profileId, (m) => ({ ...m, last_used_at: nowIso() }));
337
364
  }
365
+ /** Resolve an existing profile for a launch gate without making stale durable
366
+ * profile ids a new failure mode on a hot path. */
367
+ function loadProfileManifestOrNull(profileId) {
368
+ if (profileId === null || profileId === undefined || profileId === '')
369
+ return null;
370
+ try {
371
+ return loadProfileManifest(profileId);
372
+ }
373
+ catch {
374
+ return null;
375
+ }
376
+ }
377
+ /** Paused check for an ALREADY-RESOLVED durable id (a canvas row's `profile_id`,
378
+ * `CRTR_PROFILE_ID`) — exact-id only, no name matching, so it costs one small
379
+ * read on the hot delivery paths that call it every poll. Fails OPEN: an id
380
+ * whose manifest is missing or corrupt is not paused, because treating it as
381
+ * paused would strand that node's queued inbox forever. */
382
+ export function isProfilePaused(profileId) {
383
+ if (profileId === null || profileId === undefined || profileId === '')
384
+ return false;
385
+ try {
386
+ return loadExactProfileManifest(profileId).manifest.paused_at !== null;
387
+ }
388
+ catch {
389
+ return false;
390
+ }
391
+ }
392
+ /** Refuse a launch under a paused profile, naming the resume command. Shared by
393
+ * every gate that already holds a loaded manifest. */
394
+ export function assertProfileEntryActive(entry) {
395
+ if (entry.manifest.paused_at === null)
396
+ return;
397
+ throw usage(`profile "${entry.manifest.name}" is paused and cannot start nodes`, {
398
+ received: entry.profileId,
399
+ next: `Resume it with \`crtr profile resume ${entry.profileId}\`.`,
400
+ });
401
+ }
402
+ export function assertProfileActive(profileId) {
403
+ const entry = loadProfileManifestOrNull(profileId);
404
+ if (entry === null)
405
+ return null;
406
+ assertProfileEntryActive(entry);
407
+ return entry;
408
+ }
409
+ export function pauseProfile(profileId) {
410
+ return mutateManifest(profileId, (m) => ({ ...m, paused_at: m.paused_at ?? nowIso() }));
411
+ }
412
+ export function resumeProfile(profileId) {
413
+ return mutateManifest(profileId, (m) => ({ ...m, paused_at: null }));
414
+ }
415
+ export function setProfileDefaultKind(profileId, defaultKind) {
416
+ return mutateManifest(profileId, (m) => ({ ...m, default_kind: defaultKind }));
417
+ }
418
+ /** Merge `set` entries over the stored map and drop `unset` keys; the
419
+ * `metadata` field is omitted entirely when the result is empty. */
420
+ export function updateProfileMetadata(profileId, set, unset = []) {
421
+ assertProfileMetadata(set);
422
+ return mutateManifest(profileId, (m) => {
423
+ const next = { ...(m.metadata ?? {}), ...set };
424
+ for (const key of unset)
425
+ delete next[key];
426
+ const { metadata: _dropped, ...rest } = m;
427
+ return { ...rest, ...(Object.keys(next).length > 0 ? { metadata: next } : {}) };
428
+ });
429
+ }
430
+ /** A profile's stored metadata for broker-env injection — sanitized entry by
431
+ * entry (a manifest is hand-editable, so a bad key/value is dropped rather
432
+ * than trusted) and never throwing: a stale/invalid/absent profile id is a
433
+ * hot-path no-op, because broker-env resolution must never fail a launch
434
+ * over a bad profile id (mirrors `readProfileEnvVars`). */
435
+ export function readProfileMetadata(profileId) {
436
+ if (profileId === null || profileId === '')
437
+ return {};
438
+ try {
439
+ const stored = loadExactProfileManifest(profileId).manifest.metadata ?? {};
440
+ const out = {};
441
+ for (const [key, value] of Object.entries(stored)) {
442
+ if (typeof value !== 'string')
443
+ continue;
444
+ if (!METADATA_KEY_SHAPE.test(key))
445
+ continue;
446
+ if (value.includes('\0'))
447
+ continue;
448
+ out[key] = value;
449
+ }
450
+ return out;
451
+ }
452
+ catch {
453
+ return {};
454
+ }
455
+ }
338
456
  export function renameProfile(profileId, name) {
339
457
  const trimmed = name.trim();
340
458
  if (trimmed === '')
@@ -30,7 +30,7 @@ export async function bootRoot(opts) {
30
30
  if (!inTmux()) {
31
31
  throw new Error('crtr must be started from inside a tmux session — start tmux first (e.g. `tmux new -s work`), then run `crtr` there.');
32
32
  }
33
- const kind = opts.kind ?? 'general';
33
+ const kind = opts.kind;
34
34
  // The front door's only source of profile identity — a root has no spawner to
35
35
  // inherit from. Runs BEFORE the create call: explicit --profile > MRU profile
36
36
  // covering cwd > a synchronous create-or-root-profile prompt (the stable root
@@ -47,12 +47,12 @@ export async function bootRoot(opts) {
47
47
  // viewer paints the crouton banner the instant the engine attaches (~1s),
48
48
  // which is the real "we're up" signal.
49
49
  const detail = await cliClient().createNode({
50
- kind,
50
+ ...(kind !== undefined ? { kind } : {}),
51
51
  mode: 'base',
52
52
  prompt: opts.prompt,
53
53
  profile: profileId,
54
54
  cwd: opts.cwd,
55
- name: opts.name ?? kind,
55
+ ...(opts.name !== undefined ? { name: opts.name } : kind !== undefined ? { name: kind } : {}),
56
56
  root: true,
57
57
  // The front door is exactly the wait a warm spare exists to remove: a bare
58
58
  // resident root with no kickoff. A miss costs nothing (cold spawn) and
@@ -46,11 +46,16 @@ import { appendPassive } from '../feed/passive.js';
46
46
  * crash path (revive.ts). */
47
47
  export function fanDoctrineWake(fromId, subscribers, label, data) {
48
48
  for (const sub of subscribers) {
49
- const notice = { from: fromId, tier: 'normal', kind: 'message', label, data };
50
- if (sub.active)
51
- appendInbox(sub.node_id, notice);
52
- else
53
- appendPassive(sub.node_id, notice);
49
+ try {
50
+ const notice = { from: fromId, tier: 'normal', kind: 'message', label, data };
51
+ if (sub.active)
52
+ appendInbox(sub.node_id, notice);
53
+ else
54
+ appendPassive(sub.node_id, notice);
55
+ }
56
+ catch {
57
+ /* one unavailable subscriber never blocks the rest */
58
+ }
54
59
  }
55
60
  }
56
61
  /** The set of nodes to close: the root plus every descendant reachable down the
@@ -36,6 +36,7 @@ import { clearFault, beginBootFaultAttempt } from './fault.js';
36
36
  import { clearInjectedDocs } from '../substrate/injected-store.js';
37
37
  import { rootOfSpine } from './nodes.js';
38
38
  import { isReviewCompanionBound } from '../review/companion.js';
39
+ import { assertProfileActive } from '../profiles/manifest.js';
39
40
  // ---------------------------------------------------------------------------
40
41
  // resumeArgs — which session source a revive resumes from
41
42
  // ---------------------------------------------------------------------------
@@ -108,6 +109,7 @@ export function reviveNode(nodeId, opts) {
108
109
  if (meta.final_report !== null) {
109
110
  throw new Error(`reviveNode: refusing to revive ${nodeId} — its finalization latch is set (final_report=${meta.final_report}). A final landed between the caller's reopen-gate check and this revive; re-run with --reopen if this retask is still wanted.`);
110
111
  }
112
+ assertProfileActive(meta.profile_id);
111
113
  // Double-launch guard: a fleet entry IS liveness (design D-1). reviveNode
112
114
  // runs fully SYNCHRONOUSLY on the daemon thread — this check, the launch,
113
115
  // and the fleet registration inside `headlessBrokerHost.launch` complete
@@ -22,6 +22,12 @@ export declare function buildOperationalEnvBase(opts: {
22
22
  targetProfileId: string | null;
23
23
  host?: NodeJS.ProcessEnv;
24
24
  }): NodeJS.ProcessEnv;
25
+ /** Source F — the TARGET profile's manifest `metadata`, each entry surfaced
26
+ * as `CRTR_PROFILE_META_<KEY>` (key uppercased, every non-alphanumeric run
27
+ * → `_`). Identity facts rather than secrets — unlike the env store these
28
+ * are readable back (`profile show`, ProfileDTO). Read straight off the
29
+ * manifest at every launch; never throws (a bad profile id yields `{}`). */
30
+ export declare function profileMetadataEnv(profileId: string | null): NodeJS.ProcessEnv;
25
31
  /** The one authoritative broker child env: sources A–C (the operational
26
32
  * base, resolved from the TARGET node's own cwd/profile — `inv.env`'s
27
33
  * `CRTR_NODE_CWD`/`CRTR_PROFILE_ID`, never this process's ambient
@@ -30,7 +36,9 @@ export declare function buildOperationalEnvBase(opts: {
30
36
  * own `profile env` store (`readProfileEnvVars`, `core/profiles/env-store.ts`)
31
37
  * read directly off disk and injected regardless of the host env or any
32
38
  * `spawnEnv.allow` entry (setting a value there IS the consent to cross this
33
- * boundary), plus D — the trusted, crtr-constructed `inv.env` overlay and the
39
+ * boundary), F — the TARGET profile's manifest metadata as
40
+ * `CRTR_PROFILE_META_*` (`profileMetadataEnv` above), plus D — the trusted,
41
+ * crtr-constructed `inv.env` overlay and the
34
42
  * fork-bomb recursion guard `FRONT_DOOR_ENV=1`. D is layered last so crtr's
35
43
  * own constructed env always wins a name collision with a stored profile
36
44
  * value. Every broker launch (front-door root, managed child, `--root`,