@north-light/crouter 0.3.213 → 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 (113) hide show
  1. package/dist/api/dto/inbox.d.ts +19 -0
  2. package/dist/api/dto/nodes.d.ts +2 -1
  3. package/dist/api/dto/profiles.d.ts +13 -1
  4. package/dist/builtin-memory/00-runtime-base.md +12 -11
  5. package/dist/builtin-memory/01-spine/00-has-manager.md +1 -11
  6. package/dist/builtin-memory/02-lifecycle/00-terminal.md +4 -10
  7. package/dist/builtin-memory/04-orchestration-kernel.md +9 -35
  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/security.md +1 -1
  15. package/dist/builtin-memory/05-kinds/review/00-base.md +1 -3
  16. package/dist/builtin-memory/05-kinds/review/01-orchestrator.md +1 -1
  17. package/dist/builtin-memory/05-kinds/review/security-findings.md +12 -0
  18. package/dist/builtin-memory/05-kinds/spec/00-base.md +1 -1
  19. package/dist/clients/attach/__tests__/context-message.test.js +33 -5
  20. package/dist/clients/attach/chrome/canvas-panels.d.ts +0 -4
  21. package/dist/clients/attach/chrome/canvas-panels.js +5 -7
  22. package/dist/clients/attach/chrome/inbox-strip.d.ts +31 -0
  23. package/dist/clients/attach/chrome/inbox-strip.js +187 -0
  24. package/dist/clients/attach/chrome/roster.d.ts +6 -2
  25. package/dist/clients/attach/chrome/roster.js +13 -52
  26. package/dist/clients/attach/chrome/ticket-panel.d.ts +29 -0
  27. package/dist/clients/attach/chrome/ticket-panel.js +236 -0
  28. package/dist/clients/attach/render/card-presentation.d.ts +8 -3
  29. package/dist/clients/attach/render/card-presentation.js +48 -10
  30. package/dist/clients/attach/render/context-message.d.ts +5 -0
  31. package/dist/clients/attach/render/context-message.js +20 -14
  32. package/dist/clients/attach/session/context.d.ts +3 -0
  33. package/dist/clients/attach/session/frame.d.ts +4 -0
  34. package/dist/clients/attach/session/frame.js +8 -3
  35. package/dist/clients/attach/session/keys.d.ts +7 -0
  36. package/dist/clients/attach/session/keys.js +7 -0
  37. package/dist/clients/attach/session/layout.js +6 -3
  38. package/dist/clients/attach/session/pane-focus.d.ts +8 -0
  39. package/dist/clients/attach/session/pane-focus.js +42 -0
  40. package/dist/clients/attach/viewer.js +794 -790
  41. package/dist/clients/inbox/controller.d.ts +9 -0
  42. package/dist/clients/inbox/controller.js +57 -2
  43. package/dist/clients/inbox/surface.d.ts +2 -0
  44. package/dist/clients/inbox/surface.js +18 -3
  45. package/dist/clients/inbox/tui.d.ts +2 -0
  46. package/dist/clients/inbox/tui.js +11 -1
  47. package/dist/commands/node/create.js +3 -3
  48. package/dist/commands/profile/kind.d.ts +2 -0
  49. package/dist/commands/profile/kind.js +51 -0
  50. package/dist/commands/profile/list.js +5 -1
  51. package/dist/commands/profile/meta.d.ts +4 -0
  52. package/dist/commands/profile/meta.js +67 -0
  53. package/dist/commands/profile/new.js +33 -3
  54. package/dist/commands/profile/pause.d.ts +2 -0
  55. package/dist/commands/profile/pause.js +60 -0
  56. package/dist/commands/profile/show.js +9 -1
  57. package/dist/commands/profile.js +5 -8
  58. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +40 -0
  59. package/dist/core/__tests__/canvas-inbox-watcher.test.js +1 -1
  60. package/dist/core/__tests__/dead-node-policy-table.test.js +13 -0
  61. package/dist/core/__tests__/parse-argv-stdin-secret.test.js +28 -0
  62. package/dist/core/__tests__/seam/dormancy-release.test.js +7 -0
  63. package/dist/core/command.js +23 -23
  64. package/dist/core/feed/inbox.js +1 -6
  65. package/dist/core/help.d.ts +1 -1
  66. package/dist/core/human/component-docs.js +3 -2
  67. package/dist/core/human/page-schema.d.ts +28 -0
  68. package/dist/core/human/page-schema.js +35 -2
  69. package/dist/core/human/scan.js +7 -1
  70. package/dist/core/human/types.d.ts +4 -0
  71. package/dist/core/keybindings/attach-control.d.ts +3 -0
  72. package/dist/core/keybindings/attach-control.js +1 -0
  73. package/dist/core/keybindings/catalog.js +1 -0
  74. package/dist/core/profiles/deletion-reservation.d.ts +7 -3
  75. package/dist/core/profiles/deletion-reservation.js +9 -5
  76. package/dist/core/profiles/manifest.d.ts +27 -1
  77. package/dist/core/profiles/manifest.js +122 -4
  78. package/dist/core/runtime/boot-root.js +3 -3
  79. package/dist/core/runtime/broker/inbox.js +1 -5
  80. package/dist/core/runtime/close.js +10 -5
  81. package/dist/core/runtime/revive.js +2 -0
  82. package/dist/core/runtime/spawn-env.d.ts +9 -1
  83. package/dist/core/runtime/spawn-env.js +17 -1
  84. package/dist/core/substrate/on-read.js +16 -0
  85. package/dist/core/termrender/termrender.d.ts +7 -2
  86. package/dist/core/termrender/termrender.js +11 -6
  87. package/dist/core/termrender/version.d.ts +1 -1
  88. package/dist/core/termrender/version.js +1 -1
  89. package/dist/daemon/api/__tests__/profile-launch-gates.test.d.ts +1 -0
  90. package/dist/daemon/api/__tests__/profile-launch-gates.test.js +111 -0
  91. package/dist/daemon/api/handlers/inbox.js +31 -41
  92. package/dist/daemon/api/handlers/messages.js +9 -2
  93. package/dist/daemon/api/handlers/nodes.d.ts +2 -0
  94. package/dist/daemon/api/handlers/nodes.js +15 -8
  95. package/dist/daemon/api/handlers/profiles.js +7 -2
  96. package/dist/daemon/api/map.js +3 -0
  97. package/dist/daemon/fleet.d.ts +8 -4
  98. package/dist/daemon/fleet.js +37 -9
  99. package/dist/daemon/reconcilers/broker-supervision.js +15 -25
  100. package/dist/daemon/reconcilers/dormant-inbox.js +10 -10
  101. package/dist/daemon/reconcilers/live-obligation.d.ts +14 -1
  102. package/dist/daemon/reconcilers/live-obligation.js +27 -18
  103. package/dist/pi-extensions/canvas-inbox-watcher.js +15 -0
  104. package/dist/shared/__tests__/generated-context-grammar.test.js +4 -6
  105. package/dist/shared/generated-context.d.ts +0 -4
  106. package/dist/shared/generated-context.js +6 -9
  107. package/dist/types.d.ts +9 -0
  108. package/package.json +1 -1
  109. package/runtime.lock.json +2 -2
  110. package/dist/builtin-memory/01-spine/01-no-manager.md +0 -11
  111. package/dist/builtin-memory/05-kinds/general/01-orchestrator.md +0 -8
  112. /package/dist/builtin-memory/05-kinds/advisor/{00-base.md → advice-contract.md} +0 -0
  113. /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
@@ -149,7 +149,7 @@ test('a human message coalesces verbatim — a conversation turn, not a digest',
149
149
  finalizeInboxEntry({ from: 'mqifzplr-0753bde3', tier: 'normal', kind: 'message', label: 'go', data: { body: 'go' } }),
150
150
  ]));
151
151
  assert.equal(agent.startsWith('<runtime kind="inbox"'), true);
152
- assert.match(agent, /<from id="mqifzplr-0753bde3" updates="1">/);
152
+ assert.match(agent, /<from id="mqifzplr-0753bde3">/);
153
153
  });
154
154
  test('a mixed batch splits: human words verbatim, node reports in one card, order preserved', () => {
155
155
  // Concatenating the two into one message left `parseCard` unable to see the
@@ -16,6 +16,7 @@ function row(over = {}) {
16
16
  pi_session_id: null,
17
17
  cycle_pending: null,
18
18
  fork_from: null,
19
+ lifecycle: 'terminal',
19
20
  ...over,
20
21
  };
21
22
  }
@@ -40,6 +41,18 @@ test('row 4: intent=idle-release (chosen dormancy) takes no action — pass-2 in
40
41
  assert.equal(classifyDeadNode(row({ intent: 'idle-release' }), false), 'dormant');
41
42
  assert.equal(classifyDeadNode(row({ intent: 'idle-release', pi_session_id: 'sess' }), false), 'dormant', 'a saved session does not turn chosen dormancy into a crash');
42
43
  });
44
+ // Row 4b is why a canvas of parked conversations does not stampede its host at
45
+ // boot: a daemon restart classifies every one of them at once, and resuming an
46
+ // engine that has nothing to come back to is pure cost.
47
+ test('row 4b: a resident that died between turns with nothing to wake it is released, not resumed', () => {
48
+ const resident = row({ lifecycle: 'resident', pi_session_id: 'sess' });
49
+ assert.equal(classifyDeadNode(resident, false, false), 'release-idle');
50
+ assert.equal(classifyDeadNode(resident, false), 'respawn-resume', 'an uninformed caller keeps the conservative respawn');
51
+ assert.equal(classifyDeadNode(resident, false, true), 'respawn-resume', 'unseen mail, a live obligation, or an attached human brings it back');
52
+ assert.equal(classifyDeadNode(resident, true, false), 'respawn-resume', 'an interrupted turn outranks it — the continuation still owes that turn');
53
+ assert.equal(classifyDeadNode(row({ lifecycle: 'terminal', pi_session_id: 'sess' }), false, false), 'respawn-resume', 'a terminal node with nothing live has STALLED; coming back to be reprompted is the point');
54
+ assert.equal(classifyDeadNode(row({ lifecycle: 'resident' }), false, false), 'boot-failure', 'no saved session means it never booted — releasing would hide a boot failure');
55
+ });
43
56
  test('row 5: every saved session resumes strictly unless its own pending cycle must be retried', () => {
44
57
  assert.equal(classifyDeadNode(row({ pi_session_id: 'sess' }), false), 'respawn-resume');
45
58
  assert.equal(classifyDeadNode(row({ pi_session_id: 'sess' }), true), 'respawn-resume', 'a dirty interrupted turn resumes in place; the respawn continuation re-drives it');
@@ -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));
@@ -356,10 +356,6 @@ function cardEntry(e) {
356
356
  ...(e.disposition === undefined ? {} : { disposition: e.disposition }),
357
357
  };
358
358
  }
359
- /** The sender's display name as its last entry snapshotted it. */
360
- function sectionName(items) {
361
- return items.map((item) => item.from_name).filter((name) => name !== undefined && name !== '').at(-1);
362
- }
363
359
  /** Split unread inbox pointers into ordered deliveries: each human entry
364
360
  * verbatim, and every other sender in one `<runtime kind="inbox">` card. */
365
361
  export function coalesce(entries) {
@@ -395,8 +391,7 @@ export function coalesce(entries) {
395
391
  cardSlot = deliveries.length;
396
392
  deliveries.push({ kind: 'card', text: '', entries: [] });
397
393
  }
398
- const name = sectionName(items);
399
- sections.push({ id: sender, ...(name === undefined ? {} : { name }), entries: items.map(cardEntry) });
394
+ sections.push({ id: sender, entries: items.map(cardEntry) });
400
395
  }
401
396
  if (cardSlot >= 0) {
402
397
  // Card sections group by sender, so its entry list comes from the input
@@ -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;
@@ -76,7 +76,8 @@ ${SLOT_RULES}
76
76
 
77
77
  Props
78
78
  - \`id\` — required component id.
79
- - \`options\` — required nonempty array of \`{id:string (nonempty, unique), label:string (nonempty), description?:string}\`.
79
+ - \`options\` — required nonempty array of \`{id:string (nonempty, unique), label:string (nonempty), description?:string, recommended?:boolean}\`.
80
+ - \`recommended\` on one option marks it the suggested answer. At most one option per question may set it. The inbox shows it on the row, and when this question is the page's only response-bearing component the user can accept it from the list with one keystroke, without opening the page. Recommend when you have a view worth acting on; leave it off when the choice is genuinely theirs.
80
81
  - \`mode\` — required: \`single\` or \`multi\`.
81
82
  - \`label\` — required nonempty string: the question itself, drawn as the bold first line.
82
83
  - \`body\` — required nonempty string: markdown rendered above the choices — the context under the question a label cannot carry. Display-only; contributes nothing to the response.
@@ -92,7 +93,7 @@ Example
92
93
  <UserQuestion id="lane" label="Release lane" body="The audit closes tomorrow." mode="single" allowFreetext
93
94
  freetextLabel="Something else"
94
95
  options={[
95
- { id: 'now', label: 'Ship now', description: 'Both gates are green.' },
96
+ { id: 'now', label: 'Ship now', description: 'Both gates are green.', recommended: true },
96
97
  { id: 'hold', label: 'Hold for the audit' },
97
98
  ]} />
98
99
  \`\`\``,
@@ -26,6 +26,7 @@ export declare const optionSchema: z.ZodObject<{
26
26
  id: z.ZodString;
27
27
  label: z.ZodString;
28
28
  description: z.ZodOptional<z.ZodString>;
29
+ recommended: z.ZodOptional<z.ZodBoolean>;
29
30
  }, z.core.$strict>;
30
31
  export declare const optionsConfigSchema: z.ZodObject<{
31
32
  label: z.ZodOptional<z.ZodString>;
@@ -34,6 +35,7 @@ export declare const optionsConfigSchema: z.ZodObject<{
34
35
  id: z.ZodString;
35
36
  label: z.ZodString;
36
37
  description: z.ZodOptional<z.ZodString>;
38
+ recommended: z.ZodOptional<z.ZodBoolean>;
37
39
  }, z.core.$strict>>;
38
40
  mode: z.ZodEnum<{
39
41
  single: "single";
@@ -229,6 +231,32 @@ export interface PageManifest {
229
231
  export declare const BUILTIN_PAGE_CONFIG_SCHEMAS: Record<(typeof BUILTIN_PAGE_KINDS)[number], z.ZodType>;
230
232
  export declare const BUILTIN_PAGE_RESPONSE_SCHEMAS: Partial<Record<(typeof BUILTIN_PAGE_KINDS)[number], z.ZodType>>;
231
233
  export declare function issueText(error: z.ZodError): string;
234
+ /** One question's recommended option, named well enough for a list row to draw it without reading the page. */
235
+ export interface RecommendedOption {
236
+ slotId: string;
237
+ optionId: string;
238
+ label: string;
239
+ }
240
+ /**
241
+ * What one keypress on an inbox row publishes. `responses` is a complete, already-valid
242
+ * final response map, so the surface that sends it never assembles or interprets one.
243
+ * `kind` carries the semantic only — the words shown to a person belong to the surface.
244
+ */
245
+ export interface PageFastAction {
246
+ kind: 'answer' | 'acknowledge';
247
+ responses: PageResponses;
248
+ }
249
+ /**
250
+ * The two independent overview facts: what a row displays, and what it can publish.
251
+ * A page can recommend without being fast-settleable — a recommendation beside a sibling
252
+ * input is still worth showing, but only the page itself can answer the rest of it.
253
+ */
254
+ export interface PageOverviewActions {
255
+ recommendedOptions: RecommendedOption[];
256
+ fastAction?: PageFastAction;
257
+ }
258
+ /** Derive both overview facts from a manifest alone. Pure: no ticket state, no second read. */
259
+ export declare function pageOverviewActions(manifest: PageManifest): PageOverviewActions;
232
260
  /** The canonical response-bearing predicate used by every page consumer. */
233
261
  export declare function isResponseBearingSlot(slot: PageSlot): boolean;
234
262
  /** Validate a persisted manifest. Publish-time validation made its slots authoritative, so reads do not resolve a component catalog. */
@@ -22,7 +22,7 @@ export const commentAnchorSchema = z.discriminatedUnion('kind', [
22
22
  z.object({ kind: z.literal('row'), rowId: itemIdSchema }).strict(),
23
23
  ]);
24
24
  export const commentSchema = z.object({ id: z.string().min(1), anchor: commentAnchorSchema, text: z.string() }).strict();
25
- export const optionSchema = z.object({ id: itemIdSchema, label: z.string().min(1), description: z.string().optional() }).strict();
25
+ export const optionSchema = z.object({ id: itemIdSchema, label: z.string().min(1), description: z.string().optional(), recommended: z.boolean().optional() }).strict();
26
26
  export const optionsConfigSchema = z.object({
27
27
  label: z.string().optional(),
28
28
  body: z.string().optional(),
@@ -31,7 +31,11 @@ export const optionsConfigSchema = z.object({
31
31
  allowFreetext: z.boolean().optional(),
32
32
  freetextLabel: z.string().optional(),
33
33
  freetextPlaceholder: z.string().optional(),
34
- }).strict();
34
+ }).strict().superRefine((config, ctx) => {
35
+ const recommended = config.options.filter((option) => option.recommended === true);
36
+ if (recommended.length > 1)
37
+ ctx.addIssue({ code: 'custom', path: ['options'], message: `at most one option may be recommended, but ${recommended.length} are` });
38
+ });
35
39
  export const optionsResponseSchema = z.object({ selectedOptionIds: z.array(itemIdSchema), comments: z.array(commentSchema), freetext: z.string().optional() }).strict();
36
40
  // Text is a writing surface, always: its whole point is the string the user hands back, so
37
41
  // there is no read-only mode. `singleLine` only swaps the multi-line surface for one compact
@@ -111,6 +115,35 @@ export const BUILTIN_PAGE_RESPONSE_SCHEMAS = {
111
115
  export function issueText(error) {
112
116
  return error.issues.map((issue) => `${issue.path.length === 0 ? 'value' : issue.path.join('.')}: ${issue.message}`).join('; ');
113
117
  }
118
+ /** Derive both overview facts from a manifest alone. Pure: no ticket state, no second read. */
119
+ export function pageOverviewActions(manifest) {
120
+ const recommendedOptions = [];
121
+ for (const slot of manifest.slots) {
122
+ if (slot.kind !== 'options' || slot.id === undefined)
123
+ continue;
124
+ // Persisted manifests predate the one-recommendation rule, so take the first rather than assuming.
125
+ const option = slot.config.options.find((candidate) => candidate.recommended === true);
126
+ if (option !== undefined)
127
+ recommendedOptions.push({ slotId: slot.id, optionId: option.id, label: option.label });
128
+ }
129
+ // An inline page lives in the transcript, where there is no row to press.
130
+ if (manifest.delivery.placement !== 'panel')
131
+ return { recommendedOptions };
132
+ const answerable = manifest.slots.filter(isResponseBearingSlot);
133
+ // A notice settles on the empty map, which is exactly what Acknowledge publishes from the page.
134
+ if (answerable.length === 0)
135
+ return { recommendedOptions, fastAction: { kind: 'acknowledge', responses: {} } };
136
+ if (answerable.length > 1)
137
+ return { recommendedOptions };
138
+ const slot = answerable[0];
139
+ const recommendation = recommendedOptions.find((candidate) => candidate.slotId === slot.id);
140
+ if (slot.kind !== 'options' || recommendation === undefined)
141
+ return { recommendedOptions };
142
+ return {
143
+ recommendedOptions,
144
+ fastAction: { kind: 'answer', responses: { [slot.id]: { selectedOptionIds: [recommendation.optionId], comments: [] } } },
145
+ };
146
+ }
114
147
  /** The canonical response-bearing predicate used by every page consumer. */
115
148
  export function isResponseBearingSlot(slot) {
116
149
  if (slot.display === true)
@@ -3,6 +3,7 @@ import { basename, join } from 'node:path';
3
3
  import { claimPath, isResolved, pageManifestPath, reviewPath } from './convention.js';
4
4
  import { readJsonOrNull } from '../fs-utils.js';
5
5
  import { validateReviewDescriptor } from './review-schema.js';
6
+ import { pageOverviewActions } from './page-schema.js';
6
7
  import { parsePage } from './page.js';
7
8
  import { pageTicketState, readTicketResult } from './tickets.js';
8
9
  import { ticketsRoot } from './root.js';
@@ -42,6 +43,8 @@ function pageSummary(dir, id) {
42
43
  return null;
43
44
  }
44
45
  }
46
+ const state = pageTicketState(dir);
47
+ const { recommendedOptions, fastAction } = pageOverviewActions(manifest);
45
48
  return {
46
49
  dir,
47
50
  id,
@@ -56,7 +59,10 @@ function pageSummary(dir, id) {
56
59
  awaitsResponse: manifest.delivery.reply,
57
60
  source: manifest.source ?? {},
58
61
  emittedAt,
59
- state: pageTicketState(dir),
62
+ state,
63
+ recommendedOptions,
64
+ // A settled ticket has nothing left to publish, so only a pending one offers the keypress.
65
+ ...(fastAction !== undefined && state === 'pending' ? { fastAction } : {}),
60
66
  claim: claimSummary(dir),
61
67
  };
62
68
  }
@@ -65,6 +65,10 @@ export interface PageTicketSummary {
65
65
  steps: number;
66
66
  slotKinds: string[];
67
67
  awaitsResponse: boolean;
68
+ /** Every question's recommended option, for a row that says what is suggested without opening the page. */
69
+ recommendedOptions: import('./page-schema.js').RecommendedOption[];
70
+ /** Present when this ticket can be settled straight from a list row; absent when only the page can answer it. */
71
+ fastAction?: import('./page-schema.js').PageFastAction;
68
72
  state: 'pending' | 'resolved' | 'canceled' | 'passive';
69
73
  }
70
74
  /** The only pending-ticket shape scanners expose. */
@@ -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;