@crouton-kit/crouter 0.3.83 → 0.3.85

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 (37) hide show
  1. package/dist/clients/attach/__tests__/attach-keybindings.test.js +14 -1
  2. package/dist/clients/attach/__tests__/crtr-output-render.test.js +5 -4
  3. package/dist/clients/attach/attach-cmd.d.ts +1 -1
  4. package/dist/clients/attach/attach-cmd.js +491 -491
  5. package/dist/commands/human/prompts.js +12 -6
  6. package/dist/commands/human.js +4 -2
  7. package/dist/commands/node.js +2 -1
  8. package/dist/commands/sys/__tests__/setup-front-door.test.js +8 -21
  9. package/dist/commands/sys/setup-core.d.ts +5 -17
  10. package/dist/commands/sys/setup-core.js +12 -36
  11. package/dist/commands/sys/setup.js +6 -4
  12. package/dist/core/__tests__/boot.test.js +141 -6
  13. package/dist/core/__tests__/pid-identity-match.test.js +63 -1
  14. package/dist/core/__tests__/tmux-surface.test.js +180 -2
  15. package/dist/core/canvas/boot.d.ts +44 -0
  16. package/dist/core/canvas/boot.js +35 -2
  17. package/dist/core/canvas/canvas.d.ts +40 -0
  18. package/dist/core/canvas/canvas.js +65 -1
  19. package/dist/core/canvas/pid.d.ts +43 -0
  20. package/dist/core/canvas/pid.js +57 -11
  21. package/dist/core/keybindings/__tests__/inbox-affordance.test.d.ts +1 -0
  22. package/dist/core/keybindings/__tests__/inbox-affordance.test.js +38 -0
  23. package/dist/core/keybindings/__tests__/resolve.test.js +1 -0
  24. package/dist/core/keybindings/catalog.d.ts +2 -2
  25. package/dist/core/keybindings/catalog.js +1 -0
  26. package/dist/core/keybindings/inbox.d.ts +21 -0
  27. package/dist/core/keybindings/inbox.js +35 -0
  28. package/dist/core/keybindings/index.d.ts +1 -0
  29. package/dist/core/keybindings/index.js +1 -0
  30. package/dist/core/preview-registry.js +7 -6
  31. package/dist/core/runtime/spawn.js +2 -10
  32. package/dist/core/runtime/tmux-chrome.d.ts +1 -1
  33. package/dist/core/runtime/tmux-chrome.js +1 -1
  34. package/dist/core/runtime/tmux.d.ts +1 -0
  35. package/dist/core/runtime/tmux.js +55 -1
  36. package/dist/daemon/crtrd.js +29 -5
  37. package/package.json +2 -2
@@ -8,6 +8,7 @@ import { randomBytes } from 'node:crypto';
8
8
  import { tmuxServerReachable } from '../../core/spawn.js';
9
9
  import { validateDeck, notifyDeck, atomicWriteJson, submitDeck, submitReview, display, } from '@crouton-kit/humanloop';
10
10
  import { DECK_SCHEMA_HINT, registerCrouterRoot, resolveMaxPanes } from './shared.js';
11
+ import { inboxOpenInstruction, inboxPopupHint } from '../../core/keybindings/index.js';
11
12
  /** The asking node's id, or null when run from a bare shell (no parent to route to). */
12
13
  function askingNode() {
13
14
  return process.env['CRTR_NODE_ID'] ?? null;
@@ -32,7 +33,12 @@ function buildHumanAskDeck(question) {
32
33
  ],
33
34
  });
34
35
  }
35
- const QUEUED_FOLLOW_UP = "Queued for the human. Their answer is pushed to your inbox and wakes you when they respond — never poll, verify it opened, or block. Press ⌥I (or run `hl inbox open` at a terminal) to see the queue.";
36
+ /** The non-blocking status peek returned by ask/review. Resolved per call so
37
+ * the inbox affordance reflects the human's current keybinding (or the CLI
38
+ * fallback when the toggle is disabled). */
39
+ function queuedFollowUp() {
40
+ return `Queued for the human. Their answer is pushed to your inbox and wakes you when they respond — never poll, verify it opened, or block. To see the queue, ${inboxOpenInstruction()}.`;
41
+ }
36
42
  // ---------------------------------------------------------------------------
37
43
  // ask
38
44
  // ---------------------------------------------------------------------------
@@ -56,7 +62,7 @@ export const humanAsk = defineLeaf({
56
62
  outputKind: 'object',
57
63
  effects: [
58
64
  'Creates a kind:"human" node under you and enqueues a decision deck in the humanloop inbox.',
59
- 'Returns immediately; nothing opens on screen. The human answers from the inbox popup (⌥I) on their own time.',
65
+ `Returns immediately; nothing opens on screen. The human answers from the inbox popup (${inboxPopupHint()}) on their own time.`,
60
66
  ],
61
67
  },
62
68
  run: async (input) => {
@@ -100,7 +106,7 @@ export const humanAsk = defineLeaf({
100
106
  atomicWriteJson(join(idir, 'run.json'), rc);
101
107
  const root = registerCrouterRoot(cwd);
102
108
  const { dir } = submitDeck({ root, id: jobId, deck });
103
- return { job_id: jobId, dir, follow_up: QUEUED_FOLLOW_UP };
109
+ return { job_id: jobId, dir, follow_up: queuedFollowUp() };
104
110
  },
105
111
  });
106
112
  // ---------------------------------------------------------------------------
@@ -113,7 +119,7 @@ export const humanReview = defineLeaf({
113
119
  help: {
114
120
  name: 'human review',
115
121
  summary: "queue a .md for anchored, line-by-line human comments; returns a job handle instantly (a non-blocking kickoff, like ask). The human opens it from the inbox popup, which previews the live doc and re-reads it on every save, so edit in place rather than re-presenting.",
116
- guide: "A kickoff, not a blocking call: it returns at once and the human reviews on their own time from the inbox (⌥I). When they submit, the FeedbackResult (anchored comments, plus any line edits they made) is pushed to your inbox — waking you — and autosaved to `output`; you never poll, verify it opened, or background it. The inbox preview is a LIVE view of the file: keep editing the .md in place and it updates, so do not cancel and re-present to show a change. The .md is directive-flavored markdown rendered by termrender (panels, columns, trees, callouts, mermaid) — see `termrender doc -h` for the directive set before authoring one.",
122
+ guide: `A kickoff, not a blocking call: it returns at once and the human reviews on their own time from the inbox (${inboxPopupHint()}). When they submit, the FeedbackResult (anchored comments, plus any line edits they made) is pushed to your inbox — waking you — and autosaved to \`output\`; you never poll, verify it opened, or background it. The inbox preview is a LIVE view of the file: keep editing the .md in place and it updates, so do not cancel and re-present to show a change. The .md is directive-flavored markdown rendered by termrender (panels, columns, trees, callouts, mermaid) — see \`termrender doc -h\` for the directive set before authoring one.`,
117
123
  params: [
118
124
  { kind: 'positional', name: 'file', type: 'path', required: true, constraint: 'Absolute path to an existing .md file.' },
119
125
  { kind: 'flag', name: 'output', type: 'path', required: false, constraint: 'Where the FeedbackResult JSON is written. Default: <dir>/feedback.json.' },
@@ -127,7 +133,7 @@ export const humanReview = defineLeaf({
127
133
  outputKind: 'object',
128
134
  effects: [
129
135
  'Creates a kind:"human" node under you and enqueues an anchored review in the humanloop inbox.',
130
- 'Returns immediately; nothing opens on screen. The human reviews from the inbox popup (⌥I); comments fan into your inbox when they submit.',
136
+ `Returns immediately; nothing opens on screen. The human reviews from the inbox popup (${inboxPopupHint()}); comments fan into your inbox when they submit.`,
131
137
  ],
132
138
  },
133
139
  run: async (input) => {
@@ -168,7 +174,7 @@ export const humanReview = defineLeaf({
168
174
  },
169
175
  });
170
176
  const output = outputArg !== undefined ? resolve(outputArg) : join(dir, 'feedback.json');
171
- return { job_id: jobId, dir, output, follow_up: QUEUED_FOLLOW_UP };
177
+ return { job_id: jobId, dir, output, follow_up: queuedFollowUp() };
172
178
  },
173
179
  });
174
180
  // ---------------------------------------------------------------------------
@@ -4,13 +4,15 @@
4
4
  // the asking node, register the per-cwd interaction root with humanloop (owner
5
5
  // 'crouter', `_deliver` handler), submit the deck/review, and return at once —
6
6
  // nothing opens on screen. When the human answers from humanloop's inbox popup
7
- // (⌥I), humanloop invokes the registered `_deliver` handler, which pushFinals
7
+ // (the inbox-toggle keybinding, or `hl inbox open`), humanloop invokes the
8
+ // registered `_deliver` handler, which pushFinals
8
9
  // the answer as the bridge node's final report, fanning it into the asking
9
10
  // node's inbox. notify enqueues a durable deck with no node; show is passive
10
11
  // display. Humanloop owns the queue UI, claims, and atomic finalization.
11
12
  import { defineBranch } from '../core/command.js';
12
13
  import { humanAsk, humanReview, humanNotify, humanShow } from './human/prompts.js';
13
14
  import { humanList, humanDeck, humanResolve, humanCancel, humanDeliver } from './human/queue.js';
15
+ import { inboxOpenInstruction } from '../core/keybindings/index.js';
14
16
  export function registerHuman() {
15
17
  return defineBranch({
16
18
  name: 'human',
@@ -22,7 +24,7 @@ export function registerHuman() {
22
24
  help: {
23
25
  name: 'human',
24
26
  summary: 'human-in-the-loop decisions, document review, and live display',
25
- model: "Every body and displayed file is directive-flavored markdown rendered by termrender (panels, columns, trees, callouts, mermaid) — see `termrender doc -h` for the directive set before authoring one. ask and review are kickoffs: they create a kind:'human' bridge node under you and return instantly, never blocking. Nothing opens on screen — the ticket is queued in the humanloop inbox; the human opens it with ⌥I (or `hl inbox open`) on their own time, and their response is pushed to your inbox when they answer, so keep working (or just end your turn) and you'll be woken with it. ask covers everything from a yes/no sign-off gate (two options) to an open-ended judgment call; review queues a .md for anchored comments and the inbox previews the live file, so edit in place rather than canceling and re-presenting. notify enqueues a durable acknowledgement with no node; show is passive live display.",
27
+ model: `Every body and displayed file is directive-flavored markdown rendered by termrender (panels, columns, trees, callouts, mermaid) — see \`termrender doc -h\` for the directive set before authoring one. ask and review are kickoffs: they create a kind:'human' bridge node under you and return instantly, never blocking. Nothing opens on screen — the ticket is queued in the humanloop inbox; the human opens it ${inboxOpenInstruction()} on their own time, and their response is pushed to your inbox when they answer, so keep working (or just end your turn) and you'll be woken with it. ask covers everything from a yes/no sign-off gate (two options) to an open-ended judgment call; review queues a .md for anchored comments and the inbox previews the live file, so edit in place rather than canceling and re-presenting. notify enqueues a durable acknowledgement with no node; show is passive live display.`,
26
28
  },
27
29
  children: [
28
30
  humanAsk,
@@ -15,6 +15,7 @@ import { writeYieldMessage, readGoal } from '../core/runtime/kickoff.js';
15
15
  import { reviveNode } from '../core/runtime/revive.js';
16
16
  import { parseOutputSchemaValue, writeOutputSchema } from '../core/runtime/structured-output.js';
17
17
  import { newNodeId } from '../core/runtime/nodes.js';
18
+ import { inboxPopupHint } from '../core/keybindings/index.js';
18
19
  import { readRoadmap, hasRoadmap, seedRoadmap } from '../core/runtime/roadmap.js';
19
20
  import { parseWhen, parseCadence, cadenceDisplay } from '../core/wake.js';
20
21
  import { nodeSnapshotLeaf } from './node-snapshot.js';
@@ -642,7 +643,7 @@ const nodeFocus = defineLeaf({
642
643
  throw new InputError({
643
644
  error: 'not_focusable',
644
645
  message: `node ${id} is a human-ask (kind:human), not a conversation — it has no pi session to focus.`,
645
- next: `The pending question is queued for the human (⌥I opens the inbox popup); see it with \`crtr human list\`, or retract it with \`crtr human cancel ${id}\`.`,
646
+ next: `The pending question is queued for the human (${inboxPopupHint()} opens the inbox popup); see it with \`crtr human list\`, or retract it with \`crtr human cancel ${id}\`.`,
646
647
  });
647
648
  }
648
649
  // Placement owns the whole act: ensure the target's broker engine is alive
@@ -1,11 +1,11 @@
1
1
  // Run with: node --import tsx/esm --test src/commands/sys/__tests__/setup-front-door.test.ts
2
2
  //
3
- // M1 — `crtr sys setup` front-door reconciliation. Setup installs the
4
- // collision-safe ⌥I inbox popup binding and registers known nodes' interaction
5
- // roots to owner 'crouter' so a resolved humanloop ticket can call back into the
6
- // asking node. Both are best-effort. The humanloop registry is isolated with a
7
- // temp XDG_STATE_HOME (see human-deliver.test.ts); the canvas is isolated with a
8
- // temp CRTR_HOME + HOME.
3
+ // M1 — `crtr sys setup` front-door reconciliation. Setup registers known nodes'
4
+ // interaction roots to owner 'crouter' so a resolved humanloop ticket can call
5
+ // back into the asking node (best-effort). The inbox-toggle tmux key is a
6
+ // crouter keybinding installed by `installTmuxBindings()`, not reconciled here.
7
+ // The humanloop registry is isolated with a temp XDG_STATE_HOME (see
8
+ // human-deliver.test.ts); the canvas is isolated with a temp CRTR_HOME + HOME.
9
9
  import { test, before, beforeEach, after } from 'node:test';
10
10
  import assert from 'node:assert/strict';
11
11
  import { mkdtempSync, realpathSync } from 'node:fs';
@@ -14,7 +14,7 @@ import { join } from 'node:path';
14
14
  import { listInboxRoots } from '@crouton-kit/humanloop';
15
15
  import { spawnNode } from '../../../core/runtime/nodes.js';
16
16
  import { closeDb } from '../../../core/canvas/index.js';
17
- import { reconcileInboxFrontDoor, formatInboxBindingLine } from '../setup-core.js';
17
+ import { reconcileInboxFrontDoor } from '../setup-core.js';
18
18
  import { sysSetupLeaf } from '../setup.js';
19
19
  let home;
20
20
  let stateHome;
@@ -46,14 +46,11 @@ test('reconcile registers each known node cwd as an owner-crouter interaction ro
46
46
  spawnNode({ kind: 'general', parent: null, cwd: home, name: 'a' });
47
47
  spawnNode({ kind: 'general', parent: null, cwd: home, name: 'b' });
48
48
  spawnNode({ kind: 'general', parent: null, cwd: otherCwd, name: 'c' });
49
- const { binding, interactionRootsRegistered } = reconcileInboxFrontDoor();
49
+ const { interactionRootsRegistered } = reconcileInboxFrontDoor();
50
50
  // Deduped by cwd: two cwds → two registrations.
51
51
  assert.equal(interactionRootsRegistered, 2, 'one registration per distinct node cwd');
52
52
  const crouterRoots = listInboxRoots().filter((r) => r.owner === 'crouter');
53
53
  assert.ok(crouterRoots.length >= 2, 'both interaction roots landed in the humanloop registry as owner crouter');
54
- // The binding is best-effort: with or without a tmux server the state is one
55
- // of the known values and never throws.
56
- assert.ok(['installed', 'collision', 'unbound', 'unavailable'].includes(binding.state), `unexpected binding state: ${binding.state}`);
57
54
  });
58
55
  test('invoking `crtr sys setup` (non-TTY leaf run) reconciles the front door', async () => {
59
56
  // Exercise the real control flow — sysSetupLeaf.run() → runSetup() →
@@ -86,13 +83,3 @@ test('reconcile is idempotent for owner crouter — re-running does not error an
86
83
  assert.equal(interactionRootsRegistered, 1);
87
84
  assert.ok(listInboxRoots().some((r) => r.owner === 'crouter'));
88
85
  });
89
- test('the summary binding line surfaces the collision remedy', () => {
90
- const collision = { state: 'collision', key: 'M-i', isDefault: true };
91
- const line = formatInboxBindingLine(collision);
92
- assert.match(line, /NOT installed/);
93
- assert.match(line, /hl inbox bind --key/);
94
- const installed = { state: 'installed', key: 'M-i', isDefault: true };
95
- assert.match(formatInboxBindingLine(installed), /\u2325I \(M-i\).*installed/);
96
- const unavailable = { state: 'unavailable', key: '', isDefault: false };
97
- assert.match(formatInboxBindingLine(unavailable), /unavailable/);
98
- });
@@ -44,13 +44,6 @@ export interface SetupSubmission {
44
44
  composioKey: string;
45
45
  composioKeyPath: string;
46
46
  }
47
- /** Outcome of reconciling humanloop's collision-safe ⌥I inbox popup binding
48
- * during setup. `unavailable` means the installer threw (no tmux server, etc). */
49
- export interface SetupInboxBindingResult {
50
- state: 'installed' | 'collision' | 'unbound' | 'unavailable';
51
- key: string;
52
- isDefault: boolean;
53
- }
54
47
  export interface SetupExecutionSummary {
55
48
  installed: string[];
56
49
  skipped: string[];
@@ -58,7 +51,6 @@ export interface SetupExecutionSummary {
58
51
  exaKeyPath: string;
59
52
  composioKeyWritten: boolean;
60
53
  composioKeyPath: string;
61
- inboxBinding: SetupInboxBindingResult;
62
54
  interactionRootsRegistered: number;
63
55
  }
64
56
  export interface SetupInitialState {
@@ -131,14 +123,12 @@ export declare function buildSetupStaticInstructions(opts: {
131
123
  export declare function renderSetupStaticInstructions(state: Pick<SetupInitialState, 'manifest' | 'installedPackageIds' | 'systemTools' | 'exaKeyPath' | 'composioKeyPath'>): string;
132
124
  export declare function writeExaKey(exaKeyPath: string, exaKey: string): Promise<void>;
133
125
  export declare function writeComposioKey(composioKeyPath: string, composioKey: string): Promise<void>;
134
- /** Reconcile humanloop's collision-safe ⌥I inbox popup binding on the current
135
- * tmux server, and register each known node's interaction root to owner
136
- * `crouter` so a resolved ticket can call back into `human _deliver`. Both are
137
- * best-effort: setup must never fail because tmux is absent or the canvas is
138
- * unreadable. Crouter calls humanloop's installer — it never authors its own
139
- * tmux command. */
126
+ /** Register each known node's interaction root to owner `crouter` so a resolved
127
+ * ticket can call back into `human _deliver`. Best-effort: setup must never
128
+ * fail because the canvas is unreadable. The inbox-toggle tmux key is NOT
129
+ * reconciled here it is a crouter keybinding installed by `installTmuxBindings()`
130
+ * alongside every other binding. */
140
131
  export declare function reconcileInboxFrontDoor(): {
141
- binding: SetupInboxBindingResult;
142
132
  interactionRootsRegistered: number;
143
133
  };
144
134
  export declare function summarizeSetupExecution(result: {
@@ -150,9 +140,7 @@ export declare function summarizeSetupExecution(result: {
150
140
  exaKeyPath: string;
151
141
  composioKeyWritten: boolean;
152
142
  composioKeyPath: string;
153
- inboxBinding: SetupInboxBindingResult;
154
143
  interactionRootsRegistered: number;
155
144
  }): SetupExecutionSummary;
156
- export declare function formatInboxBindingLine(binding: SetupInboxBindingResult): string;
157
145
  export declare function formatSetupExecutionSummary(summary: SetupExecutionSummary): string;
158
146
  export declare function executeSetupSubmission(submission: SetupSubmission): Promise<SetupExecutionSummary>;
@@ -4,11 +4,10 @@ import { readFileSync } from 'node:fs';
4
4
  import { homedir } from 'node:os';
5
5
  import { basename, dirname, join, resolve } from 'node:path';
6
6
  import { isDeepStrictEqual } from 'node:util';
7
- import { BINDING_CATALOG, normalizeGesture, resolveKeybindings, } from '../../core/keybindings/index.js';
7
+ import { BINDING_CATALOG, normalizeGesture, inboxShortcut, resolveKeybindings, } from '../../core/keybindings/index.js';
8
8
  import { builtinPiPackageDir } from '../../core/scope.js';
9
9
  import { readJsonIfExists } from '../../core/fs-utils.js';
10
10
  import { resolveBundledPiCliPath } from '../../core/runtime/pi-cli.js';
11
- import { installInboxBinding } from '@crouton-kit/humanloop';
12
11
  import { listNodes } from '../../core/canvas/index.js';
13
12
  import { registerCrouterRoot } from '../human/shared.js';
14
13
  function isKeybindingsObject(value) {
@@ -426,7 +425,7 @@ export function renderSetupStaticInstructions(state) {
426
425
  instructions.exaLine,
427
426
  instructions.composioLine,
428
427
  '',
429
- 'Inbox popup: running `crtr sys setup` installs the collision-safe ⌥I humanloop inbox popup binding and registers known nodes\' interaction roots (best-effort; the binding needs a running tmux server). On a key collision, re-bind with `hl inbox bind --key <tmux-key>`.',
428
+ `Inbox popup: the inbox-toggle key (${inboxShortcut() ?? 'disabled'}) is a crouter keybinding view or rebind it in the \`crtr sys setup\` keybindings tab; it is installed on the same tmux server as every other crouter binding. \`crtr sys setup\` also registers known nodes' interaction roots (best-effort).`,
430
429
  instructions.footer,
431
430
  ].join('\n') + '\n';
432
431
  }
@@ -452,21 +451,12 @@ export async function writeComposioKey(composioKeyPath, composioKey) {
452
451
  await writeFile(composioKeyPath, `${composioKey}\n`, 'utf8');
453
452
  await chmod(composioKeyPath, 0o600).catch(() => undefined);
454
453
  }
455
- /** Reconcile humanloop's collision-safe ⌥I inbox popup binding on the current
456
- * tmux server, and register each known node's interaction root to owner
457
- * `crouter` so a resolved ticket can call back into `human _deliver`. Both are
458
- * best-effort: setup must never fail because tmux is absent or the canvas is
459
- * unreadable. Crouter calls humanloop's installer — it never authors its own
460
- * tmux command. */
454
+ /** Register each known node's interaction root to owner `crouter` so a resolved
455
+ * ticket can call back into `human _deliver`. Best-effort: setup must never
456
+ * fail because the canvas is unreadable. The inbox-toggle tmux key is NOT
457
+ * reconciled here it is a crouter keybinding installed by `installTmuxBindings()`
458
+ * alongside every other binding. */
461
459
  export function reconcileInboxFrontDoor() {
462
- let binding = { state: 'unavailable', key: '', isDefault: false };
463
- try {
464
- const state = installInboxBinding();
465
- binding = { state: state.state, key: state.key, isDefault: state.isDefault };
466
- }
467
- catch {
468
- /* best-effort: no tmux server / installer unavailable */
469
- }
470
460
  let interactionRootsRegistered = 0;
471
461
  try {
472
462
  const seen = new Set();
@@ -486,7 +476,7 @@ export function reconcileInboxFrontDoor() {
486
476
  catch {
487
477
  /* best-effort: canvas unavailable */
488
478
  }
489
- return { binding, interactionRootsRegistered };
479
+ return { interactionRootsRegistered };
490
480
  }
491
481
  export function summarizeSetupExecution(result) {
492
482
  return {
@@ -496,22 +486,9 @@ export function summarizeSetupExecution(result) {
496
486
  exaKeyPath: result.exaKeyPath,
497
487
  composioKeyWritten: result.composioKeyWritten,
498
488
  composioKeyPath: result.composioKeyPath,
499
- inboxBinding: result.inboxBinding,
500
489
  interactionRootsRegistered: result.interactionRootsRegistered,
501
490
  };
502
491
  }
503
- export function formatInboxBindingLine(binding) {
504
- switch (binding.state) {
505
- case 'installed':
506
- return `inbox popup binding: ⌥I (${binding.key})${binding.isDefault ? '' : ' — custom key'} installed`;
507
- case 'collision':
508
- return `inbox popup binding: NOT installed — ${binding.key} is already bound by another action. Re-bind explicitly with \`hl inbox bind --key <tmux-key>\`.`;
509
- case 'unbound':
510
- return 'inbox popup binding: not bound';
511
- default:
512
- return 'inbox popup binding: unavailable (no tmux server)';
513
- }
514
- }
515
492
  export function formatSetupExecutionSummary(summary) {
516
493
  return [
517
494
  'crtr sys setup complete',
@@ -519,7 +496,6 @@ export function formatSetupExecutionSummary(summary) {
519
496
  summary.skipped.length > 0 ? `skipped: ${summary.skipped.join(', ')}` : 'skipped: none',
520
497
  summary.exaKeyWritten ? `exa key written: ${summary.exaKeyPath}` : 'exa key written: no',
521
498
  summary.composioKeyWritten ? `composio key written: ${summary.composioKeyPath}` : 'composio key written: no',
522
- formatInboxBindingLine(summary.inboxBinding),
523
499
  `interaction roots registered: ${summary.interactionRootsRegistered}`,
524
500
  ].join('\n') + '\n';
525
501
  }
@@ -556,9 +532,10 @@ export async function executeSetupSubmission(submission) {
556
532
  if (composioKeyWritten) {
557
533
  await writeComposioKey(submission.composioKeyPath, submission.composioKey);
558
534
  }
559
- // Front-door reconciliation: install the ⌥I inbox popup binding and register
560
- // known nodes' interaction roots for `human _deliver` callback. Best-effort.
561
- const { binding, interactionRootsRegistered } = reconcileInboxFrontDoor();
535
+ // Front-door reconciliation: register known nodes' interaction roots for
536
+ // `human _deliver` callback. Best-effort. The inbox-toggle key is a crouter
537
+ // keybinding installed by `installTmuxBindings()`, not reconciled here.
538
+ const { interactionRootsRegistered } = reconcileInboxFrontDoor();
562
539
  return summarizeSetupExecution({
563
540
  installedPackages,
564
541
  skippedPackages,
@@ -568,7 +545,6 @@ export async function executeSetupSubmission(submission) {
568
545
  exaKeyPath: submission.exaKeyPath,
569
546
  composioKeyWritten,
570
547
  composioKeyPath: submission.composioKeyPath,
571
- inboxBinding: binding,
572
548
  interactionRootsRegistered,
573
549
  });
574
550
  }
@@ -1300,10 +1300,12 @@ export class SetupWizard {
1300
1300
  async function runSetup() {
1301
1301
  const state = buildSetupInitialState();
1302
1302
  // Front-door reconciliation: invoking `crtr sys setup` — TTY or not —
1303
- // installs the collision-safe ⌥I inbox popup binding and registers known
1304
- // nodes' interaction roots. Best-effort + idempotent, so it is safe to run on
1303
+ // registers known nodes' interaction roots so resolved tickets can call back
1304
+ // into the asking node. Best-effort + idempotent, so it is safe to run on
1305
1305
  // every invocation regardless of whether the wizard is opened, canceled, or
1306
- // submitted (the post-submission call re-runs it harmlessly).
1306
+ // submitted (the post-submission call re-runs it harmlessly). The inbox-toggle
1307
+ // tmux key is a crouter keybinding, installed by the catalog/save path through
1308
+ // installTmuxBindings() like every other binding — not reconciled here.
1307
1309
  reconcileInboxFrontDoor();
1308
1310
  if (!process.stdout.isTTY) {
1309
1311
  process.stdout.write(renderSetupStaticInstructions(state));
@@ -1403,7 +1405,7 @@ export const sysSetupLeaf = defineLeaf({
1403
1405
  'Collects companion package installs, system dependency installs, API keys, and sparse user overrides for crouter-owned keybindings across the remaining tabs.',
1404
1406
  'Saves validated keybinding changes atomically in the user config and refreshes crouter-owned tmux bindings without overwriting occupied user keys.',
1405
1407
  'Runs the bundled pi CLI to install the selected companion packages, writes EXA_API_KEY to ~/.crouter/exa.key and COMPOSIO_API_KEY to ~/.crouter/composio.key when provided, and prints a summary.',
1406
- 'On every invocation (best-effort, idempotent), installs the collision-safe ⌥I humanloop inbox popup binding on the current tmux server (leaving any pre-existing binding untouched and reporting the collision remedy) and registers known nodes\' interaction roots so resolved tickets can call back into the asking node.',
1408
+ 'On every invocation (best-effort, idempotent), registers known nodes\' interaction roots so resolved tickets can call back into the asking node. The inbox popup toggle is a crouter-owned keybinding, edited on the keybindings tab and installed through the catalog/save path alongside every other tmux binding.',
1407
1409
  ],
1408
1410
  },
1409
1411
  run: async () => {
@@ -17,15 +17,22 @@ import assert from 'node:assert/strict';
17
17
  import { mkdtempSync, rmSync } from 'node:fs';
18
18
  import { tmpdir } from 'node:os';
19
19
  import { join } from 'node:path';
20
- import { createNode, getRow, recordPid } from '../canvas/canvas.js';
21
- import { closeDb } from '../canvas/db.js';
22
- import { bootChanged, reconcileBootLiveness } from '../canvas/boot.js';
20
+ import { createNode, getRow, recordPid, migrateLegacyPidIdentities } from '../canvas/canvas.js';
21
+ import { closeDb, openDb } from '../canvas/db.js';
22
+ import { bootChanged, bootProvenSame, reconcileBootLiveness, reconcileAndMigrateBoot, } from '../canvas/boot.js';
23
23
  let home;
24
24
  /** Build a BootIdentity for injection. `bootId` null models the non-Linux
25
25
  * fallback (wall-clock window); a string models the exact kernel boot_id. */
26
26
  function ident(bootId, bootEpochMs) {
27
27
  return { bootId, bootEpochMs };
28
28
  }
29
+ const BOOT_A = '4b0c1d2e-3f40-4152-8637-a1b2c3d4e5f6';
30
+ /** Force a row's recorded pid + identity baseline directly (recordPid derives
31
+ * the identity from the real `ps`, which can't produce a controlled legacy
32
+ * `lstart#ticks` value on a non-Linux test host). */
33
+ function setPidIdentity(id, pid, identity) {
34
+ openDb().prepare('UPDATE nodes SET pi_pid = ?, pi_pid_identity = ? WHERE node_id = ?').run(pid, identity, id);
35
+ }
29
36
  function node(id, over = {}) {
30
37
  return {
31
38
  node_id: id,
@@ -80,12 +87,60 @@ test('bootChanged: wall-clock fallback (no boot_id) — epochs far apart are a D
80
87
  // ---------------------------------------------------------------------------
81
88
  // reconcileBootLiveness — the impure sweep against a real canvas.db
82
89
  // ---------------------------------------------------------------------------
90
+ // ---------------------------------------------------------------------------
91
+ // migrateLegacyPidIdentities — one-time in-place format migration at startup
92
+ // ---------------------------------------------------------------------------
93
+ test('migrateLegacyPidIdentities: a live legacy lstart#ticks broker is re-recorded to <bootId>#<ticks>', () => {
94
+ createNode(node('n1'));
95
+ setPidIdentity('n1', 111, 'Sat Jul 11 17:51:45 2026#53459'); // legacy baseline
96
+ // The pid still names the same live process; its CURRENT identity on this
97
+ // (now boot_id-capable) platform reads bootId#ticks with the SAME ticks.
98
+ const result = migrateLegacyPidIdentities((pid) => (pid === 111 ? `${BOOT_A}#53459` : undefined));
99
+ assert.deepEqual(result.migrated, ['n1']);
100
+ assert.equal(getRow('n1')?.pi_pid_identity, `${BOOT_A}#53459`, 'legacy row completed to boot-scoped format');
101
+ assert.equal(getRow('n1')?.pi_pid, 111, 'the pid itself is untouched');
102
+ });
103
+ test('migrateLegacyPidIdentities: a legacy row whose pid is GONE is left to normal liveness handling', () => {
104
+ createNode(node('n1'));
105
+ setPidIdentity('n1', 111, 'Sat Jul 11 17:51:45 2026#53459');
106
+ const result = migrateLegacyPidIdentities(() => undefined); // pid gone / probe failed
107
+ assert.deepEqual(result.migrated, []);
108
+ assert.equal(getRow('n1')?.pi_pid_identity, 'Sat Jul 11 17:51:45 2026#53459', 'untouched — liveness handles it');
109
+ });
110
+ test('migrateLegacyPidIdentities: a REUSED pid (different ticks) is NOT re-recorded', () => {
111
+ createNode(node('n1'));
112
+ setPidIdentity('n1', 111, 'Sat Jul 11 17:51:45 2026#53459');
113
+ // The pid now names a DIFFERENT process (different jiffies) — no match.
114
+ const result = migrateLegacyPidIdentities(() => `${BOOT_A}#99999`);
115
+ assert.deepEqual(result.migrated, []);
116
+ assert.equal(getRow('n1')?.pi_pid_identity, 'Sat Jul 11 17:51:45 2026#53459', 'reused pid left for liveness to clear');
117
+ });
118
+ test('migrateLegacyPidIdentities: an already boot-scoped row is skipped (not legacy)', () => {
119
+ createNode(node('n1'));
120
+ setPidIdentity('n1', 111, `${BOOT_A}#53459`); // already new-format
121
+ let captured = false;
122
+ const result = migrateLegacyPidIdentities(() => {
123
+ captured = true;
124
+ return `${BOOT_A}#53459`;
125
+ });
126
+ assert.deepEqual(result.migrated, []);
127
+ assert.equal(captured, false, 'a non-legacy row is filtered out before any ps probe');
128
+ });
129
+ test('migrateLegacyPidIdentities: no readable boot_id (current identity still legacy) leaves the row untouched', () => {
130
+ createNode(node('n1'));
131
+ setPidIdentity('n1', 111, 'Sat Jul 11 17:51:45 2026#53459');
132
+ // Platform still composes legacy-shaped identities (no boot_id) — nothing to
133
+ // upgrade to; the legacy compare lane legitimately stays in play.
134
+ const result = migrateLegacyPidIdentities(() => 'Sun Jul 12 09:00:00 2026#53459');
135
+ assert.deepEqual(result.migrated, []);
136
+ assert.equal(getRow('n1')?.pi_pid_identity, 'Sat Jul 11 17:51:45 2026#53459', 'no boot_id → not migrated');
137
+ });
83
138
  test('reconcileBootLiveness: first-ever call seeds the marker and clears nothing', () => {
84
139
  const m = node('n1');
85
140
  createNode(m);
86
141
  recordPid('n1', 12345);
87
142
  const result = reconcileBootLiveness(ident('boot-A', 1_000_000));
88
- assert.deepEqual(result, { changed: false, cleared: [] });
143
+ assert.deepEqual(result, { changed: false, cleared: [], sameBootProven: false });
89
144
  assert.equal(getRow('n1')?.pi_pid, 12345, 'a first-ever run never clears an existing pid');
90
145
  });
91
146
  test('reconcileBootLiveness: same boot (same boot_id) leaves pids untouched — MUST NOT weaken the normal daemon-restart case', () => {
@@ -94,7 +149,7 @@ test('reconcileBootLiveness: same boot (same boot_id) leaves pids untouched —
94
149
  recordPid('n1', 12345);
95
150
  reconcileBootLiveness(ident('boot-A', 1_000_000)); // seed
96
151
  const result = reconcileBootLiveness(ident('boot-A', 1_000_000 + 3_000)); // 3s later, same boot
97
- assert.deepEqual(result, { changed: false, cleared: [] });
152
+ assert.deepEqual(result, { changed: false, cleared: [], sameBootProven: true });
98
153
  assert.equal(getRow('n1')?.pi_pid, 12345, 'a live pid on the same boot is never cleared');
99
154
  });
100
155
  test('reconcileBootLiveness: a boot change clears every recorded pid, regardless of status', () => {
@@ -135,6 +190,86 @@ test('reconcileBootLiveness: after reconciling, the SAME boot no longer re-trigg
135
190
  reconcileBootLiveness(ident('boot-B', 1_000_000 + 60 * 60_000)); // boot change → clears + persists boot B
136
191
  recordPid('n1', 999); // a fresh broker re-records its pid post-revive
137
192
  const result = reconcileBootLiveness(ident('boot-B', 1_000_000 + 60 * 60_000 + 2_000)); // still boot B, 2s later
138
- assert.deepEqual(result, { changed: false, cleared: [] });
193
+ assert.deepEqual(result, { changed: false, cleared: [], sameBootProven: true });
139
194
  assert.equal(getRow('n1')?.pi_pid, 999, 'a pid recorded AFTER reconciliation, within the same boot, is never touched');
140
195
  });
196
+ // ---------------------------------------------------------------------------
197
+ // bootProvenSame + the daemon migration gate (Major-1 regression)
198
+ //
199
+ // The legacy identity migration blesses a recorded pid as a current-boot
200
+ // process on a bare ticks match, so it may run ONLY when same-boot provenance
201
+ // is POSITIVELY proven (exact boot_id equal on both sides). `!bootChanged` is
202
+ // NOT that proof — it is also true with no/malformed marker or the wall-clock
203
+ // epoch fallback, where an unrelated cross-boot process can collide on
204
+ // pid+ticks and be permanently baptized.
205
+ // ---------------------------------------------------------------------------
206
+ test('bootProvenSame: exact equal boot_id on both sides is proof', () => {
207
+ assert.equal(bootProvenSame(ident('boot-A', 2_000), ident('boot-A', 1_000)), true);
208
+ });
209
+ test('bootProvenSame: no history is NOT proof (absent/malformed marker)', () => {
210
+ assert.equal(bootProvenSame(ident('boot-A', 1_000), null), false);
211
+ });
212
+ test('bootProvenSame: a missing boot_id on EITHER side is NOT proof (epoch fallback)', () => {
213
+ assert.equal(bootProvenSame(ident(null, 1_000), ident('boot-A', 1_000)), false, 'current lacks boot_id');
214
+ assert.equal(bootProvenSame(ident('boot-A', 1_000), ident(null, 1_000)), false, 'known lacks boot_id');
215
+ assert.equal(bootProvenSame(ident(null, 1_000), ident(null, 1_000)), false, 'both lack boot_id');
216
+ });
217
+ test('bootProvenSame: a different boot_id is not proof (it is a change)', () => {
218
+ assert.equal(bootProvenSame(ident('boot-B', 1_000), ident('boot-A', 1_000)), false);
219
+ });
220
+ // Drive the ACTUAL production startup composition (reconcile, then gate the
221
+ // migration on proven same-boot provenance) rather than a test-side copy, so a
222
+ // revert that inverts/drops the gate or reorders migration ahead of
223
+ // reconciliation is caught here. `runDaemon` calls this same helper.
224
+ function daemonBootStartup(current, captureIdentity) {
225
+ return reconcileAndMigrateBoot(migrateLegacyPidIdentities, current, captureIdentity);
226
+ }
227
+ const LEGACY = 'Sat Jul 11 17:51:45 2026#53459';
228
+ test('migration gate: a cross-boot equal-ticks legacy row is NOT migrated when the marker is ABSENT (first-ever startup)', () => {
229
+ createNode(node('n1'));
230
+ setPidIdentity('n1', 111, LEGACY);
231
+ // No marker yet. A current-boot stranger process happens to hold pid 111 with
232
+ // the same ticks — an equal-ticks collision that would falsely migrate if the
233
+ // sweep ran. It must NOT: provenance is unproven.
234
+ const result = daemonBootStartup(ident(BOOT_A, 1_000_000), () => `${BOOT_A}#53459`);
235
+ assert.deepEqual(result.migrated, [], 'no marker → unproven → migration skipped');
236
+ assert.equal(getRow('n1')?.pi_pid_identity, LEGACY, 'the stranger is NOT baptized as the recorded process');
237
+ });
238
+ test('migration gate: a cross-boot equal-ticks legacy row is NOT migrated under the epoch fallback (no boot_id)', () => {
239
+ createNode(node('n1'));
240
+ setPidIdentity('n1', 111, LEGACY);
241
+ reconcileBootLiveness(ident(null, 1_000_000)); // seed a no-boot_id marker
242
+ // A transient current boot_id read failure: reconcile sees null on both sides,
243
+ // the epochs are within tolerance so no change is DETECTED, but same-boot is
244
+ // NOT PROVEN. A cross-boot pid+ticks collision must not be migrated.
245
+ const result = daemonBootStartup(ident(null, 1_000_000 + 5_000), () => `${BOOT_A}#53459`);
246
+ assert.deepEqual(result.migrated, [], 'epoch fallback → unproven → migration skipped');
247
+ assert.equal(getRow('n1')?.pi_pid_identity, LEGACY, 'not migrated on an unproven boot');
248
+ });
249
+ test('migration gate: a legacy row IS migrated when same-boot provenance is PROVEN (exact boot_id match)', () => {
250
+ createNode(node('n1'));
251
+ setPidIdentity('n1', 111, LEGACY);
252
+ reconcileBootLiveness(ident(BOOT_A, 1_000_000)); // seed an exact-boot_id marker
253
+ // Same boot proven (boot_id equal on both sides); pid 111 still names the same
254
+ // live process (matching ticks) — the in-place upgrade completes.
255
+ const result = daemonBootStartup(ident(BOOT_A, 1_000_000 + 3_000), (pid) => pid === 111 ? `${BOOT_A}#53459` : undefined);
256
+ assert.deepEqual(result.migrated, ['n1']);
257
+ assert.equal(getRow('n1')?.pi_pid_identity, `${BOOT_A}#53459`, 'proven same boot → migrated in place');
258
+ });
259
+ test('migrateLegacyPidIdentities CAS: a concurrent re-record between probe and update is not clobbered', () => {
260
+ createNode(node('n1'));
261
+ setPidIdentity('n1', 111, LEGACY);
262
+ // The capture models the old broker still probing as its live legacy self,
263
+ // but a concurrent revive atomically re-records a FRESH (pid, identity) pair
264
+ // before the sweep's compare-and-swap update lands.
265
+ const result = migrateLegacyPidIdentities((pid) => {
266
+ if (pid === 111) {
267
+ setPidIdentity('n1', 222, `${BOOT_A}#77777`); // concurrent revive wins the row
268
+ return `${BOOT_A}#53459`; // stale probe result for the OLD pid
269
+ }
270
+ return undefined;
271
+ });
272
+ assert.deepEqual(result.migrated, [], 'CAS matched zero rows — no migration counted');
273
+ assert.equal(getRow('n1')?.pi_pid, 222, 'the concurrent revive pid stands');
274
+ assert.equal(getRow('n1')?.pi_pid_identity, `${BOOT_A}#77777`, 'the fresh identity is NOT overwritten by the stale one');
275
+ });
@@ -24,7 +24,7 @@
24
24
  // when a base is NOT a boot_id UUID on either side, ticks decide alone.
25
25
  import { test } from 'node:test';
26
26
  import assert from 'node:assert/strict';
27
- import { identitiesMatch } from '../canvas/pid.js';
27
+ import { identitiesMatch, composeIdentity, makeBootIdCache } from '../canvas/pid.js';
28
28
  test('equal ticks with DIFFERENT lstart bases compare EQUAL (the btime-drift bug)', () => {
29
29
  // Same live process, same jiffies-since-boot; only the wall-clock-anchored
30
30
  // lstart drifted because btime shifted. Must NOT read as reuse.
@@ -71,6 +71,68 @@ test('migration: legacy lstart#ticks vs new bootId#ticks with equal ticks → ma
71
71
  assert.equal(identitiesMatch(`${BOOT_A}#53459`, 'Sat Jul 11 17:51:45 2026#53459'), true, 'order-independent');
72
72
  assert.equal(identitiesMatch('Sat Jul 11 17:51:45 2026#60536', `${BOOT_A}#53459`), false, 'legacy migration still discriminates on ticks');
73
73
  });
74
+ // --- composeIdentity composition (guards against a regression that emits the
75
+ // old wall-clock `lstart#ticks` on a boot_id-capable host) ---
76
+ test('composeIdentity: procfs + boot_id host emits <bootId>#<ticks> with the base equal to the kernel boot_id', () => {
77
+ // The exact production-guest shape. The `deps` seam injects the boot_id and
78
+ // ticks so the composition is provable off-Linux (real /proc reads return
79
+ // null there). If composeIdentity regressed to emitting lstart#ticks, the
80
+ // base here would be the lstart string, not BOOT_A — this asserts it is not.
81
+ const id = composeIdentity(1234, 'Sat Jul 11 17:51:45 2026', { bootId: BOOT_A, ticks: '53459' });
82
+ assert.equal(id, `${BOOT_A}#53459`, 'base must be the kernel boot_id, not the wall-clock lstart');
83
+ assert.equal(splitBase(id), BOOT_A);
84
+ });
85
+ test('composeIdentity: procfs but NO readable boot_id falls back to <lstart>#<ticks>', () => {
86
+ const id = composeIdentity(1234, 'Sat Jul 11 17:51:45 2026', { bootId: null, ticks: '53459' });
87
+ assert.equal(id, 'Sat Jul 11 17:51:45 2026#53459', 'no boot_id → prior lstart#ticks behavior');
88
+ });
89
+ test('composeIdentity: no procfs ticks emits the bare <lstart> (coarse, unchanged)', () => {
90
+ const id = composeIdentity(1234, 'Sat Jul 11 17:51:45 2026', { bootId: BOOT_A, ticks: null });
91
+ assert.equal(id, 'Sat Jul 11 17:51:45 2026', 'no ticks → bare lstart regardless of boot_id');
92
+ });
93
+ function splitBase(identity) {
94
+ const i = identity.indexOf('#');
95
+ return i === -1 ? identity : identity.slice(0, i);
96
+ }
97
+ // --- boot_id cache: a TRANSIENT read failure must be retried, never cached as
98
+ // permanent absence (would permanently downgrade a boot_id host to legacy
99
+ // lstart#ticks identities). This exercises the exact memoize used by
100
+ // bootIdBase. ---
101
+ test('makeBootIdCache: a null read is negative-cached, RETRIED only after the window, then a success is cached forever', () => {
102
+ let calls = 0;
103
+ const reads = [null, BOOT_A, BOOT_B];
104
+ let clock = 1_000;
105
+ const cache = makeBootIdCache(() => {
106
+ const v = calls < reads.length ? reads[calls] : BOOT_B;
107
+ calls += 1;
108
+ return v;
109
+ }, () => clock, 100);
110
+ assert.equal(cache(), null, 'first read failed → null (not cached)');
111
+ assert.equal(calls, 1);
112
+ assert.equal(cache(), null, 'within the negative window → NOT re-read');
113
+ assert.equal(calls, 1, 'the throwing read is rate-limited, not repeated every call');
114
+ clock += 100; // advance past the negative-cache deadline
115
+ assert.equal(cache(), BOOT_A, 'after the window the failure is retried → succeeds');
116
+ assert.equal(calls, 2);
117
+ clock += 10_000;
118
+ assert.equal(cache(), BOOT_A, 'success is now cached → reader NOT called again');
119
+ assert.equal(calls, 2, 'no further reads after a successful one');
120
+ });
121
+ test('makeBootIdCache: a permanent no-boot_id host reads null only once per negative window (bounded, not per-call)', () => {
122
+ let calls = 0;
123
+ let clock = 1_000;
124
+ const cache = makeBootIdCache(() => {
125
+ calls += 1;
126
+ return null;
127
+ }, () => clock, 100);
128
+ assert.equal(cache(), null);
129
+ assert.equal(cache(), null);
130
+ assert.equal(cache(), null);
131
+ assert.equal(calls, 1, 'repeated calls within one window do a SINGLE read — no synchronous read storm');
132
+ clock += 100;
133
+ assert.equal(cache(), null);
134
+ assert.equal(calls, 2, 'one read per window — still retryable for a transient miss, but bounded');
135
+ });
74
136
  test('malformed/empty ticks suffix is treated as ABSENT (base compare applies)', () => {
75
137
  // A garbage suffix must never be trusted as a fine discriminator; it falls
76
138
  // back to the base compare.