@north-light/crouter 0.3.214 → 0.3.216

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 (99) 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/advisor/{00-base.md → advice-contract.md} +1 -1
  8. package/dist/builtin-memory/05-kinds/design/00-base.md +2 -2
  9. package/dist/builtin-memory/05-kinds/design/01-orchestrator.md +1 -1
  10. package/dist/builtin-memory/05-kinds/developer/00-base.md +2 -2
  11. package/dist/builtin-memory/05-kinds/explore/00-base.md +1 -1
  12. package/dist/builtin-memory/05-kinds/general/00-base.md +2 -0
  13. package/dist/builtin-memory/05-kinds/plan/00-base.md +1 -1
  14. package/dist/builtin-memory/05-kinds/plan/reviewers/{00-base.md → lens-contract.md} +1 -1
  15. package/dist/builtin-memory/05-kinds/plan/reviewers/security.md +1 -1
  16. package/dist/builtin-memory/05-kinds/review/00-base.md +1 -3
  17. package/dist/builtin-memory/05-kinds/review/01-orchestrator.md +1 -1
  18. package/dist/builtin-memory/05-kinds/review/security-findings.md +12 -0
  19. package/dist/builtin-memory/05-kinds/spec/00-base.md +1 -1
  20. package/dist/clients/attach/__tests__/context-message.test.js +28 -1
  21. package/dist/clients/attach/chrome/canvas-panels.d.ts +0 -4
  22. package/dist/clients/attach/chrome/canvas-panels.js +5 -7
  23. package/dist/clients/attach/chrome/inbox-strip.d.ts +31 -0
  24. package/dist/clients/attach/chrome/inbox-strip.js +187 -0
  25. package/dist/clients/attach/chrome/roster.d.ts +6 -2
  26. package/dist/clients/attach/chrome/roster.js +13 -52
  27. package/dist/clients/attach/chrome/ticket-panel.d.ts +29 -0
  28. package/dist/clients/attach/chrome/ticket-panel.js +236 -0
  29. package/dist/clients/attach/render/card-presentation.d.ts +8 -3
  30. package/dist/clients/attach/render/card-presentation.js +48 -10
  31. package/dist/clients/attach/render/context-message.d.ts +5 -0
  32. package/dist/clients/attach/render/context-message.js +20 -14
  33. package/dist/clients/attach/session/context.d.ts +3 -0
  34. package/dist/clients/attach/session/frame.d.ts +4 -0
  35. package/dist/clients/attach/session/frame.js +8 -3
  36. package/dist/clients/attach/session/keys.d.ts +7 -0
  37. package/dist/clients/attach/session/keys.js +7 -0
  38. package/dist/clients/attach/session/layout.js +6 -3
  39. package/dist/clients/attach/session/pane-focus.d.ts +8 -0
  40. package/dist/clients/attach/session/pane-focus.js +42 -0
  41. package/dist/clients/attach/viewer.js +793 -789
  42. package/dist/clients/inbox/controller.d.ts +9 -0
  43. package/dist/clients/inbox/controller.js +57 -2
  44. package/dist/clients/inbox/surface.d.ts +2 -0
  45. package/dist/clients/inbox/surface.js +18 -3
  46. package/dist/clients/inbox/tui.d.ts +2 -0
  47. package/dist/clients/inbox/tui.js +11 -1
  48. package/dist/commands/node/create.js +3 -3
  49. package/dist/commands/profile/kind.d.ts +1 -0
  50. package/dist/commands/profile/kind.js +39 -0
  51. package/dist/commands/profile/list.js +5 -1
  52. package/dist/commands/profile/meta.d.ts +4 -0
  53. package/dist/commands/profile/meta.js +69 -0
  54. package/dist/commands/profile/new.js +34 -4
  55. package/dist/commands/profile/pause.d.ts +2 -0
  56. package/dist/commands/profile/pause.js +60 -0
  57. package/dist/commands/profile/show.js +10 -2
  58. package/dist/commands/profile.js +5 -8
  59. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +40 -0
  60. package/dist/core/__tests__/parse-argv-stdin-secret.test.js +28 -0
  61. package/dist/core/__tests__/seam/dormancy-release.test.js +7 -0
  62. package/dist/core/command.js +23 -23
  63. package/dist/core/config.d.ts +5 -0
  64. package/dist/core/config.js +15 -0
  65. package/dist/core/help.d.ts +1 -1
  66. package/dist/core/keybindings/attach-control.d.ts +3 -0
  67. package/dist/core/keybindings/attach-control.js +1 -0
  68. package/dist/core/keybindings/catalog.js +1 -0
  69. package/dist/core/profiles/deletion-reservation.d.ts +7 -3
  70. package/dist/core/profiles/deletion-reservation.js +9 -5
  71. package/dist/core/profiles/manifest.d.ts +39 -1
  72. package/dist/core/profiles/manifest.js +171 -4
  73. package/dist/core/runtime/boot-root.js +3 -3
  74. package/dist/core/runtime/close.js +10 -5
  75. package/dist/core/runtime/revive.js +2 -0
  76. package/dist/core/runtime/spawn-env.d.ts +9 -1
  77. package/dist/core/runtime/spawn-env.js +17 -1
  78. package/dist/core/termrender/termrender.d.ts +7 -2
  79. package/dist/core/termrender/termrender.js +11 -6
  80. package/dist/core/termrender/version.d.ts +1 -1
  81. package/dist/core/termrender/version.js +1 -1
  82. package/dist/daemon/api/__tests__/profile-launch-gates.test.d.ts +1 -0
  83. package/dist/daemon/api/__tests__/profile-launch-gates.test.js +111 -0
  84. package/dist/daemon/api/handlers/messages.js +9 -2
  85. package/dist/daemon/api/handlers/nodes.d.ts +2 -0
  86. package/dist/daemon/api/handlers/nodes.js +15 -8
  87. package/dist/daemon/api/handlers/profiles.js +12 -2
  88. package/dist/daemon/api/map.js +4 -0
  89. package/dist/daemon/fleet.js +1 -6
  90. package/dist/daemon/reconcilers/broker-supervision.js +15 -25
  91. package/dist/daemon/reconcilers/dormant-inbox.js +10 -10
  92. package/dist/daemon/reconcilers/live-obligation.d.ts +5 -1
  93. package/dist/daemon/reconcilers/live-obligation.js +11 -19
  94. package/dist/pi-extensions/canvas-inbox-watcher.js +15 -0
  95. package/dist/types.d.ts +9 -0
  96. package/package.json +1 -1
  97. package/runtime.lock.json +2 -2
  98. package/dist/builtin-memory/01-spine/01-no-manager.md +0 -11
  99. package/dist/builtin-memory/05-kinds/general/01-orchestrator.md +0 -8
@@ -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));
@@ -141,6 +141,11 @@ export declare function readMergedLaunchConfig(targetCwd?: string, targetProfile
141
141
  * existence/launch-menu enumeration is a caller concern); this only
142
142
  * resolves the config for a kind the caller already knows about. */
143
143
  export declare function resolveKindConfig(kind: string): KindConfig | undefined;
144
+ /** Reject a kind no scope registers. The one validation gate for every
145
+ * surface that PERSISTS a kind (`profile new/kind`, the profile-ensure API
146
+ * route) — a stored unknown kind would poison future omitted-kind creates
147
+ * through `default_kind` resolution. */
148
+ export declare function assertInstalledKind(kind: string): void;
144
149
  /** The sub-kinds available to spawn FROM a given top-level kind — every
145
150
  * registered sub-kind (full path contains `/`) whose `availableTo` (default:
146
151
  * its own top-level ancestor, e.g. `plan/reviewers/security` defaults to
@@ -2,6 +2,7 @@ import { dirname, join } from 'node:path';
2
2
  import { isDeepStrictEqual } from 'node:util';
3
3
  import { CONFIG_FILE, STATE_FILE, CONDENSED_HISTORY_MODES, MOUSE_MODE_DEFAULTS, WHIP_MESSAGE_MODES, DEFAULT_WHIP_MESSAGES, defaultScopeConfig, defaultScopeState, defaultModelLaddersConfig, defaultKindsConfig, defaultRemoteCanvasConfig } from '../types.js';
4
4
  import { BINDING_CATALOG, BINDING_IDS, isAttachPaneBinding } from './keybindings/catalog.js';
5
+ import { usage } from './errors.js';
5
6
  import { emitEvent } from './events/emit.js';
6
7
  import { atomicWriteJson, readJsonIfExists, writeJson, ensureDir } from './fs-utils.js';
7
8
  import { scopeRoot, requireScopeRoot, findProjectScopeRoots } from './scope.js';
@@ -753,6 +754,20 @@ export function readMergedLaunchConfig(targetCwd = process.cwd(), targetProfileI
753
754
  export function resolveKindConfig(kind) {
754
755
  return readMergedLaunchConfig().kinds[kind];
755
756
  }
757
+ /** Reject a kind no scope registers. The one validation gate for every
758
+ * surface that PERSISTS a kind (`profile new/kind`, the profile-ensure API
759
+ * route) — a stored unknown kind would poison future omitted-kind creates
760
+ * through `default_kind` resolution. */
761
+ export function assertInstalledKind(kind) {
762
+ const kinds = Object.keys(readMergedLaunchConfig().kinds).sort();
763
+ if (!kinds.includes(kind)) {
764
+ throw usage(`unknown kind: ${kind}`, {
765
+ received: kind,
766
+ field: 'kind',
767
+ next: `Valid kinds: ${kinds.join(', ')}.`,
768
+ });
769
+ }
770
+ }
756
771
  /** The sub-kinds available to spawn FROM a given top-level kind — every
757
772
  * registered sub-kind (full path contains `/`) whose `availableTo` (default:
758
773
  * its own top-level ancestor, e.g. `plan/reviewers/security` defaults to
@@ -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,46 @@ 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
+ /** The env name a metadata entry surfaces as (spawn-env source F). Also the
43
+ * uniqueness domain for keys: the fold is lossy (`a-b`, `a_b`, `A_B` →
44
+ * `CRTR_PROFILE_META_A_B`), so two stored keys may not share a fold. */
45
+ export declare function metadataEnvName(key: string): string;
46
+ /** Reject anything a valid metadata map may not carry. Takes `unknown`
47
+ * because the API route hands over a caller-supplied body slice — the
48
+ * TypeScript type on the DTO proves nothing at runtime. */
49
+ export declare function assertProfileMetadata(entries: unknown): asserts entries is Record<string, string>;
50
+ /** Drop entries no valid mutation could have written — a manifest is
51
+ * hand-editable, so a projection or merge base must never trust it raw.
52
+ * Never throws; on an env-name collision the first entry wins (matching
53
+ * injection, where a deterministic winner beats insertion-order luck). */
54
+ export declare function sanitizeProfileMetadata(stored: unknown): Record<string, string>;
55
+ export declare function createProfile(name: string, projects?: string[], opts?: {
56
+ defaultKind?: string;
57
+ metadata?: Record<string, string>;
58
+ }): ProfileEntry;
43
59
  export declare function updateProfileLastUsed(profileId: string): ProfileEntry;
60
+ /** Paused check for an ALREADY-RESOLVED durable id (a canvas row's `profile_id`,
61
+ * `CRTR_PROFILE_ID`) — exact-id only, no name matching, so it costs one small
62
+ * read on the hot delivery paths that call it every poll. Fails OPEN: an id
63
+ * whose manifest is missing or corrupt is not paused, because treating it as
64
+ * paused would strand that node's queued inbox forever. */
65
+ export declare function isProfilePaused(profileId: string | null | undefined): boolean;
66
+ /** Refuse a launch under a paused profile, naming the resume command. Shared by
67
+ * every gate that already holds a loaded manifest. */
68
+ export declare function assertProfileEntryActive(entry: ProfileEntry): void;
69
+ export declare function assertProfileActive(profileId: string | null | undefined): ProfileEntry | null;
70
+ export declare function pauseProfile(profileId: string): ProfileEntry;
71
+ export declare function resumeProfile(profileId: string): ProfileEntry;
72
+ export declare function setProfileDefaultKind(profileId: string, defaultKind: string): ProfileEntry;
73
+ /** Merge `set` entries over the stored map and drop `unset` keys; the
74
+ * `metadata` field is omitted entirely when the result is empty. */
75
+ export declare function updateProfileMetadata(profileId: string, set: Record<string, string>, unset?: string[]): ProfileEntry;
76
+ /** A profile's stored metadata for broker-env injection — sanitized entry by
77
+ * entry (a manifest is hand-editable, so a bad key/value is dropped rather
78
+ * than trusted) and never throwing: a stale/invalid/absent profile id is a
79
+ * hot-path no-op, because broker-env resolution must never fail a launch
80
+ * over a bad profile id (mirrors `readProfileEnvVars`). */
81
+ export declare function readProfileMetadata(profileId: string | null): Record<string, string>;
44
82
  export declare function renameProfile(profileId: string, name: string): ProfileEntry;
45
83
  export declare function addProfileProject(profileId: string, dir: string, opts?: {
46
84
  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,89 @@ 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
+ /** The env name a metadata entry surfaces as (spawn-env source F). Also the
299
+ * uniqueness domain for keys: the fold is lossy (`a-b`, `a_b`, `A_B` →
300
+ * `CRTR_PROFILE_META_A_B`), so two stored keys may not share a fold. */
301
+ export function metadataEnvName(key) {
302
+ return `CRTR_PROFILE_META_${key.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}`;
303
+ }
304
+ /** Reject anything a valid metadata map may not carry. Takes `unknown`
305
+ * because the API route hands over a caller-supplied body slice — the
306
+ * TypeScript type on the DTO proves nothing at runtime. */
307
+ export function assertProfileMetadata(entries) {
308
+ if (typeof entries !== 'object' || entries === null || Array.isArray(entries)) {
309
+ throw usage('metadata must be a map of string keys to string values', {
310
+ field: 'metadata',
311
+ next: 'Pass an object, e.g. {"role": "support"}.',
312
+ });
313
+ }
314
+ const claimed = new Map();
315
+ for (const [key, value] of Object.entries(entries)) {
316
+ if (!METADATA_KEY_SHAPE.test(key)) {
317
+ throw usage(`invalid metadata key: ${JSON.stringify(key)}`, {
318
+ received: key,
319
+ field: 'metadata',
320
+ next: 'Keys start with a letter or digit, followed by letters, digits, `_`, or `-`.',
321
+ });
322
+ }
323
+ if (typeof value !== 'string') {
324
+ throw usage(`metadata value for ${key} must be a string`, {
325
+ received: typeof value,
326
+ field: 'metadata',
327
+ next: 'Every metadata value is an env-var value — pass strings only.',
328
+ });
329
+ }
330
+ if (value.includes('\0')) {
331
+ throw usage(`metadata value for ${key} contains a NUL byte, which no OS accepts in an environment variable`, {
332
+ field: 'metadata',
333
+ next: 'Remove NUL bytes from the value and retry.',
334
+ });
335
+ }
336
+ const envName = metadataEnvName(key);
337
+ const prior = claimed.get(envName);
338
+ if (prior !== undefined) {
339
+ throw usage(`metadata keys ${JSON.stringify(prior)} and ${JSON.stringify(key)} both surface as ${envName}`, {
340
+ field: 'metadata',
341
+ next: 'Rename or drop one — keys must stay distinct after uppercasing and `_`-folding.',
342
+ });
343
+ }
344
+ claimed.set(envName, key);
345
+ }
346
+ }
347
+ /** Drop entries no valid mutation could have written — a manifest is
348
+ * hand-editable, so a projection or merge base must never trust it raw.
349
+ * Never throws; on an env-name collision the first entry wins (matching
350
+ * injection, where a deterministic winner beats insertion-order luck). */
351
+ export function sanitizeProfileMetadata(stored) {
352
+ if (typeof stored !== 'object' || stored === null || Array.isArray(stored))
353
+ return {};
354
+ const out = {};
355
+ const claimed = new Set();
356
+ for (const [key, value] of Object.entries(stored)) {
357
+ if (typeof value !== 'string')
358
+ continue;
359
+ if (!METADATA_KEY_SHAPE.test(key))
360
+ continue;
361
+ if (value.includes('\0'))
362
+ continue;
363
+ const envName = metadataEnvName(key);
364
+ if (claimed.has(envName))
365
+ continue;
366
+ claimed.add(envName);
367
+ out[key] = value;
368
+ }
369
+ return out;
370
+ }
371
+ export function createProfile(name, projects = [], opts = {}) {
294
372
  const trimmed = name.trim();
295
373
  if (trimmed === '')
296
374
  throw usage('profile name must not be empty');
375
+ if (opts.metadata !== undefined)
376
+ assertProfileMetadata(opts.metadata);
297
377
  const resolvedProjects = dedupeOrdered(projects.map(resolveExistingProjectDir));
298
378
  const profileId = generateProfileId(trimmed);
299
379
  return withProfileManifestLock(profileId, () => {
@@ -302,6 +382,9 @@ export function createProfile(name, projects = []) {
302
382
  name: trimmed,
303
383
  projects: resolvedProjects,
304
384
  home: resolvedProjects[0] ?? null,
385
+ paused_at: null,
386
+ ...(opts.defaultKind !== undefined ? { default_kind: opts.defaultKind } : {}),
387
+ ...(opts.metadata !== undefined && Object.keys(opts.metadata).length > 0 ? { metadata: { ...opts.metadata } } : {}),
305
388
  created_at: nowIso(),
306
389
  last_used_at: null,
307
390
  };
@@ -335,6 +418,90 @@ function mutateManifest(profileId, mutate) {
335
418
  export function updateProfileLastUsed(profileId) {
336
419
  return mutateManifest(profileId, (m) => ({ ...m, last_used_at: nowIso() }));
337
420
  }
421
+ /** Resolve an existing profile for a launch gate without making stale durable
422
+ * profile ids a new failure mode on a hot path. */
423
+ function loadProfileManifestOrNull(profileId) {
424
+ if (profileId === null || profileId === undefined || profileId === '')
425
+ return null;
426
+ try {
427
+ return loadProfileManifest(profileId);
428
+ }
429
+ catch {
430
+ return null;
431
+ }
432
+ }
433
+ /** Paused check for an ALREADY-RESOLVED durable id (a canvas row's `profile_id`,
434
+ * `CRTR_PROFILE_ID`) — exact-id only, no name matching, so it costs one small
435
+ * read on the hot delivery paths that call it every poll. Fails OPEN: an id
436
+ * whose manifest is missing or corrupt is not paused, because treating it as
437
+ * paused would strand that node's queued inbox forever. */
438
+ export function isProfilePaused(profileId) {
439
+ if (profileId === null || profileId === undefined || profileId === '')
440
+ return false;
441
+ try {
442
+ return loadExactProfileManifest(profileId).manifest.paused_at !== null;
443
+ }
444
+ catch {
445
+ return false;
446
+ }
447
+ }
448
+ /** Refuse a launch under a paused profile, naming the resume command. Shared by
449
+ * every gate that already holds a loaded manifest. */
450
+ export function assertProfileEntryActive(entry) {
451
+ if (entry.manifest.paused_at === null)
452
+ return;
453
+ throw usage(`profile "${entry.manifest.name}" is paused and cannot start nodes`, {
454
+ received: entry.profileId,
455
+ next: `Resume it with \`crtr profile resume ${entry.profileId}\`.`,
456
+ });
457
+ }
458
+ export function assertProfileActive(profileId) {
459
+ const entry = loadProfileManifestOrNull(profileId);
460
+ if (entry === null)
461
+ return null;
462
+ assertProfileEntryActive(entry);
463
+ return entry;
464
+ }
465
+ export function pauseProfile(profileId) {
466
+ return mutateManifest(profileId, (m) => ({ ...m, paused_at: m.paused_at ?? nowIso() }));
467
+ }
468
+ export function resumeProfile(profileId) {
469
+ return mutateManifest(profileId, (m) => ({ ...m, paused_at: null }));
470
+ }
471
+ export function setProfileDefaultKind(profileId, defaultKind) {
472
+ return mutateManifest(profileId, (m) => ({ ...m, default_kind: defaultKind }));
473
+ }
474
+ /** Merge `set` entries over the stored map and drop `unset` keys; the
475
+ * `metadata` field is omitted entirely when the result is empty. */
476
+ export function updateProfileMetadata(profileId, set, unset = []) {
477
+ assertProfileMetadata(set);
478
+ return mutateManifest(profileId, (m) => {
479
+ const next = { ...sanitizeProfileMetadata(m.metadata), ...set };
480
+ for (const key of unset)
481
+ delete next[key];
482
+ // Re-assert the merged result: a set key may collide with a STORED key in
483
+ // the env-name domain (`a-b` over stored `a_b`), which per-argument
484
+ // validation cannot see.
485
+ assertProfileMetadata(next);
486
+ const { metadata: _dropped, ...rest } = m;
487
+ return { ...rest, ...(Object.keys(next).length > 0 ? { metadata: next } : {}) };
488
+ });
489
+ }
490
+ /** A profile's stored metadata for broker-env injection — sanitized entry by
491
+ * entry (a manifest is hand-editable, so a bad key/value is dropped rather
492
+ * than trusted) and never throwing: a stale/invalid/absent profile id is a
493
+ * hot-path no-op, because broker-env resolution must never fail a launch
494
+ * over a bad profile id (mirrors `readProfileEnvVars`). */
495
+ export function readProfileMetadata(profileId) {
496
+ if (profileId === null || profileId === '')
497
+ return {};
498
+ try {
499
+ return sanitizeProfileMetadata(loadExactProfileManifest(profileId).manifest.metadata);
500
+ }
501
+ catch {
502
+ return {};
503
+ }
504
+ }
338
505
  export function renameProfile(profileId, name) {
339
506
  const trimmed = name.trim();
340
507
  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