@crouton-kit/crouter 0.3.82 → 0.3.84

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 (94) hide show
  1. package/dist/builtin-pi-packages/pi-mode-switch/README.md +3 -3
  2. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +12 -4
  3. package/dist/clients/attach/__tests__/attach-keybindings.test.js +21 -4
  4. package/dist/clients/attach/__tests__/chat-view-snapshot-ordering.test.js +104 -0
  5. package/dist/clients/attach/__tests__/crtr-output-render.test.js +17 -44
  6. package/dist/clients/attach/attach-cmd.d.ts +2 -3
  7. package/dist/clients/attach/attach-cmd.js +689 -689
  8. package/dist/clients/attach/attach-def.d.ts +2 -0
  9. package/dist/clients/attach/attach-def.js +93 -0
  10. package/dist/clients/attach/canvas-panels.d.ts +10 -3
  11. package/dist/clients/attach/canvas-panels.js +42 -16
  12. package/dist/clients/attach/chat-view.d.ts +20 -1
  13. package/dist/clients/attach/chat-view.js +85 -2
  14. package/dist/commands/__tests__/human.test.js +7 -22
  15. package/dist/commands/attention.js +31 -30
  16. package/dist/commands/human/prompts.js +57 -38
  17. package/dist/commands/human/queue.d.ts +1 -28
  18. package/dist/commands/human/queue.js +156 -520
  19. package/dist/commands/human/shared.d.ts +28 -68
  20. package/dist/commands/human/shared.js +37 -120
  21. package/dist/commands/human.js +15 -17
  22. package/dist/commands/node.js +5 -2
  23. package/dist/commands/surface.js +1 -1
  24. package/dist/commands/sys/__tests__/setup-core.test.js +1 -1
  25. package/dist/commands/sys/__tests__/setup-front-door.test.js +85 -0
  26. package/dist/commands/sys/setup-core.d.ts +10 -0
  27. package/dist/commands/sys/setup-core.js +39 -1
  28. package/dist/commands/sys/setup.js +10 -1
  29. package/dist/core/__tests__/boot.test.js +141 -6
  30. package/dist/core/__tests__/human-deliver-e2e.test.js +95 -0
  31. package/dist/core/__tests__/human-deliver.test.js +332 -0
  32. package/dist/core/__tests__/human-node-not-supervised.test.js +5 -4
  33. package/dist/core/__tests__/pid-identity-match.test.js +145 -0
  34. package/dist/core/__tests__/tmux-surface.test.js +180 -2
  35. package/dist/core/canvas/__tests__/attention.test.d.ts +1 -0
  36. package/dist/core/canvas/__tests__/attention.test.js +126 -0
  37. package/dist/core/canvas/__tests__/remote-canvas-source.test.js +4 -4
  38. package/dist/core/canvas/__tests__/render-remote.test.js +1 -1
  39. package/dist/core/canvas/attention.d.ts +16 -14
  40. package/dist/core/canvas/attention.js +34 -29
  41. package/dist/core/canvas/boot.d.ts +45 -0
  42. package/dist/core/canvas/boot.js +36 -3
  43. package/dist/core/canvas/browse/app.js +3 -3
  44. package/dist/core/canvas/canvas.d.ts +40 -0
  45. package/dist/core/canvas/canvas.js +65 -1
  46. package/dist/core/canvas/pid.d.ts +86 -17
  47. package/dist/core/canvas/pid.js +152 -37
  48. package/dist/core/canvas/remote-canvas-source.d.ts +1 -1
  49. package/dist/core/canvas/remote-canvas-source.js +4 -2
  50. package/dist/core/canvas/render.d.ts +5 -5
  51. package/dist/core/canvas/render.js +14 -14
  52. package/dist/core/canvas/source.d.ts +2 -2
  53. package/dist/core/canvas/source.js +3 -3
  54. package/dist/core/keybindings/__tests__/inbox-affordance.test.d.ts +1 -0
  55. package/dist/core/keybindings/__tests__/inbox-affordance.test.js +38 -0
  56. package/dist/core/keybindings/__tests__/resolve.test.js +2 -1
  57. package/dist/core/keybindings/catalog.d.ts +2 -2
  58. package/dist/core/keybindings/catalog.js +4 -2
  59. package/dist/core/keybindings/inbox.d.ts +21 -0
  60. package/dist/core/keybindings/inbox.js +35 -0
  61. package/dist/core/keybindings/index.d.ts +1 -0
  62. package/dist/core/keybindings/index.js +1 -0
  63. package/dist/core/preview-registry.js +23 -29
  64. package/dist/core/profiles/select.js +4 -4
  65. package/dist/core/runtime/pi-vendored.js +15 -1
  66. package/dist/core/runtime/placement.d.ts +1 -10
  67. package/dist/core/runtime/placement.js +43 -26
  68. package/dist/core/runtime/session-list-cache.d.ts +22 -2
  69. package/dist/core/runtime/session-list-cache.js +92 -24
  70. package/dist/core/runtime/spawn.js +2 -1
  71. package/dist/core/runtime/tmux-chrome.d.ts +1 -1
  72. package/dist/core/runtime/tmux-chrome.js +1 -1
  73. package/dist/core/runtime/tmux.d.ts +1 -0
  74. package/dist/core/runtime/tmux.js +55 -1
  75. package/dist/core/spawn.d.ts +0 -59
  76. package/dist/core/spawn.js +5 -163
  77. package/dist/daemon/crtrd.js +36 -11
  78. package/dist/index.d.ts +2 -2
  79. package/dist/index.js +1 -1
  80. package/dist/web-client/assets/index-DZdKS-FB.js +78 -0
  81. package/dist/web-client/index.html +1 -1
  82. package/dist/web-client/sw.js +1 -1
  83. package/package.json +2 -2
  84. package/dist/core/__tests__/full/dead-pane-regression.test.js +0 -154
  85. package/dist/core/__tests__/full/human-new-window-regression.test.js +0 -104
  86. package/dist/core/__tests__/full/review-render-pane-regression.test.js +0 -133
  87. package/dist/core/__tests__/human-stranded-deliver.test.js +0 -145
  88. package/dist/core/__tests__/human-surface-target.test.js +0 -98
  89. package/dist/web-client/assets/index--md2ylfi.js +0 -78
  90. /package/dist/{core/__tests__/full/dead-pane-regression.test.d.ts → clients/attach/__tests__/chat-view-snapshot-ordering.test.d.ts} +0 -0
  91. /package/dist/{core/__tests__/full/human-new-window-regression.test.d.ts → commands/sys/__tests__/setup-front-door.test.d.ts} +0 -0
  92. /package/dist/core/__tests__/{full/review-render-pane-regression.test.d.ts → human-deliver-e2e.test.d.ts} +0 -0
  93. /package/dist/core/__tests__/{human-stranded-deliver.test.d.ts → human-deliver.test.d.ts} +0 -0
  94. /package/dist/core/__tests__/{human-surface-target.test.d.ts → pid-identity-match.test.d.ts} +0 -0
@@ -5,10 +5,11 @@
5
5
  // at boot with "pi vehicle exited before the session came up".
6
6
  //
7
7
  // ROOT CAUSE: a kind:'human' row is NOT a broker-hosted agent node — it is a
8
- // bookkeeping row for the `crtr human` bridge. Its decision/review TUI runs in a
9
- // detached `crtr human _run` pane (NOT a pi engine), and the row's lifecycle is
10
- // driven entirely by that worker's `pushFinal` (or `human cancel`). So it never
11
- // records a `pi_pid` or `pi_session_id`. After the headless hard-cut made the
8
+ // bookkeeping row for the `crtr human` bridge. Nothing runs in it: the human
9
+ // answers from humanloop's inbox, and the row's lifecycle is driven entirely by
10
+ // humanloop's completion handler (`crtr human _deliver`) calling `pushFinal` (or
11
+ // `human cancel`). So it never records a `pi_pid` or `pi_session_id`. After the
12
+ // headless hard-cut made the
12
13
  // broker the universal node host, superviseTick supervised EVERY active|idle row
13
14
  // as a broker engine: it read the human row (pid==null, session==null) as a
14
15
  // never-booted broker, waited out the boot grace, then crash'd it and fired
@@ -0,0 +1,145 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/pid-identity-match.test.ts
2
+ //
3
+ // Regression cover for the uncapped-revive-loop bug on Blaxel unikraft guests
4
+ // (crouter-cloud "frozen on reconnecting", 2026-07-11): process identity is
5
+ // `lstart#ticks`, but `lstart` is derived from the wall-clock boot epoch
6
+ // (`/proc/stat` btime + starttime jiffies) and btime is UNSTABLE in the guest
7
+ // (clock correction / suspend-resume). A SINGLE live broker was observed with
8
+ // identical pid+ticks but `lstart` drifting +81s between two probes. The old
9
+ // `identitiesMatch` compared the `lstart` base FIRST and returned false on any
10
+ // base mismatch — so it read that still-live broker as reused/dead every
11
+ // cycle, wedging the daemon into an uncapped revive loop that rebound the
12
+ // node's view.sock and dropped the browser chat WS forever.
13
+ //
14
+ // The fix: when BOTH identities carry `#ticks`, ticks decide it ALONE (they
15
+ // are per-boot unique, strictly finer than lstart, and — unlike lstart — do
16
+ // not move when the wall clock does). The base is consulted only when a side
17
+ // lacks ticks (macOS / no procfs).
18
+ //
19
+ // Follow-up (cross-boot false match): ticks-alone is only safe WITHIN one
20
+ // boot — jiffies-since-boot reset and can repeat across boots. So the identity
21
+ // base is now BOOT-SCOPED on Linux-with-procfs: it is the kernel `boot_id`
22
+ // UUID (`<bootId>#<ticks>`), and a new-format match requires base AND ticks.
23
+ // A legacy `lstart#ticks` row (recorded before this) still migrates cleanly:
24
+ // when a base is NOT a boot_id UUID on either side, ticks decide alone.
25
+ import { test } from 'node:test';
26
+ import assert from 'node:assert/strict';
27
+ import { identitiesMatch, composeIdentity, makeBootIdCache } from '../canvas/pid.js';
28
+ test('equal ticks with DIFFERENT lstart bases compare EQUAL (the btime-drift bug)', () => {
29
+ // Same live process, same jiffies-since-boot; only the wall-clock-anchored
30
+ // lstart drifted because btime shifted. Must NOT read as reuse.
31
+ const a = 'Sat Jul 11 17:50:24 2026#53459';
32
+ const b = 'Sat Jul 11 17:51:45 2026#53459';
33
+ assert.equal(identitiesMatch(a, b), true, 'equal ticks must win over a drifting lstart base');
34
+ });
35
+ test('different ticks compare UNEQUAL even when lstart bases are identical (same-second reuse)', () => {
36
+ const a = 'Sat Jul 11 17:50:24 2026#53459';
37
+ const b = 'Sat Jul 11 17:50:24 2026#60536';
38
+ assert.equal(identitiesMatch(a, b), false, 'different ticks are proof of a reused pid');
39
+ });
40
+ test('identities WITHOUT ticks fall back to comparing the lstart base', () => {
41
+ assert.equal(identitiesMatch('Sat Jul 11 17:50:24 2026', 'Sat Jul 11 17:50:24 2026'), true, 'no ticks, equal base → match');
42
+ assert.equal(identitiesMatch('Sat Jul 11 17:50:24 2026', 'Sat Jul 11 17:51:45 2026'), false, 'no ticks, different base → reuse');
43
+ });
44
+ test('one side lacking ticks fails open to a base compare (transient /proc miss)', () => {
45
+ // A transient `/proc` read failure can record the SAME live process plain on
46
+ // one capture and with ticks on another. Bases agree → fail-open to match.
47
+ assert.equal(identitiesMatch('Sat Jul 11 17:50:24 2026', 'Sat Jul 11 17:50:24 2026#53459'), true, 'coarse-vs-fine with matching base → match');
48
+ assert.equal(identitiesMatch('Sat Jul 11 17:50:24 2026', 'Sat Jul 11 17:51:45 2026#53459'), false, 'coarse-vs-fine with different base → reuse');
49
+ });
50
+ // --- boot-scoped (new-format) identities ---
51
+ const BOOT_A = '4b0c1d2e-3f40-4152-8637-a1b2c3d4e5f6';
52
+ const BOOT_B = '9e8d7c6b-5a40-4938-9271-fedcba098765';
53
+ test('new-format: equal boot_id + equal ticks → match', () => {
54
+ assert.equal(identitiesMatch(`${BOOT_A}#53459`, `${BOOT_A}#53459`), true, 'same boot, same process');
55
+ });
56
+ test('new-format: equal ticks + DIFFERENT boot_id → NO match (cross-boot collision)', () => {
57
+ // Ticks reset per boot and can repeat; a different boot_id with the same
58
+ // ticks is a DIFFERENT process on a DIFFERENT boot. This is the exact false
59
+ // match the boot-scoped base closes.
60
+ assert.equal(identitiesMatch(`${BOOT_A}#53459`, `${BOOT_B}#53459`), false, 'equal ticks must NOT win across boots');
61
+ });
62
+ test('new-format: same boot_id + DIFFERENT ticks → NO match (in-boot reuse)', () => {
63
+ assert.equal(identitiesMatch(`${BOOT_A}#53459`, `${BOOT_A}#60536`), false, 'reused pid within one boot');
64
+ });
65
+ test('migration: legacy lstart#ticks vs new bootId#ticks with equal ticks → match', () => {
66
+ // An in-place upgrade compares an already-recorded `lstart#ticks` row
67
+ // against a freshly-read `bootId#ticks`. A blanket base-mismatch verdict
68
+ // would false-kill the live broker; the legacy base (not a UUID) routes to
69
+ // ticks-alone so the live broker survives the upgrade.
70
+ assert.equal(identitiesMatch('Sat Jul 11 17:51:45 2026#53459', `${BOOT_A}#53459`), true, 'legacy → new migration');
71
+ assert.equal(identitiesMatch(`${BOOT_A}#53459`, 'Sat Jul 11 17:51:45 2026#53459'), true, 'order-independent');
72
+ assert.equal(identitiesMatch('Sat Jul 11 17:51:45 2026#60536', `${BOOT_A}#53459`), false, 'legacy migration still discriminates on ticks');
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
+ });
136
+ test('malformed/empty ticks suffix is treated as ABSENT (base compare applies)', () => {
137
+ // A garbage suffix must never be trusted as a fine discriminator; it falls
138
+ // back to the base compare.
139
+ assert.equal(identitiesMatch('A#', 'B#'), false, 'empty suffix → absent ticks → bases differ');
140
+ assert.equal(identitiesMatch('A#', 'A#'), true, 'empty suffix → absent ticks → bases agree');
141
+ assert.equal(identitiesMatch('A#bad', 'B#bad'), false, 'non-numeric suffix → absent ticks → bases differ');
142
+ assert.equal(identitiesMatch('A#bad', 'A#other'), true, 'non-numeric suffix → absent ticks → bases agree');
143
+ assert.equal(identitiesMatch(`${BOOT_A}#12#34`, `${BOOT_A}#12#34`), true, 'multiple-# suffix is non-numeric → absent ticks → boot_id bases agree');
144
+ assert.equal(identitiesMatch(`${BOOT_A}#12#34`, `${BOOT_B}#12#34`), false, 'multiple-# suffix → absent ticks → boot_id bases differ');
145
+ });
@@ -17,11 +17,12 @@
17
17
  // importers are placement.ts + tmux-chrome.ts (tmux.ts itself excluded).
18
18
  import { test } from 'node:test';
19
19
  import assert from 'node:assert/strict';
20
- import { readFileSync, readdirSync, statSync } from 'node:fs';
20
+ import { readFileSync, readdirSync, statSync, mkdtempSync, mkdirSync, writeFileSync, existsSync } from 'node:fs';
21
21
  import { spawnSync } from 'node:child_process';
22
+ import { tmpdir } from 'node:os';
22
23
  import { fileURLToPath } from 'node:url';
23
24
  import { dirname, join, basename } from 'node:path';
24
- import { installTmuxBindings } from '../runtime/tmux-chrome.js';
25
+ import { installTmuxBindings, legacyInboxKeyFile } from '../runtime/tmux-chrome.js';
25
26
  import { resolveKeybindings } from '../keybindings/index.js';
26
27
  const __dirname = dirname(fileURLToPath(import.meta.url));
27
28
  const SRC_ROOT = join(__dirname, '..', '..'); // .../src
@@ -149,6 +150,8 @@ test('tmux installer remaps owned pairs and preserves occupied user bindings', (
149
150
  assert.ok(first.installed.some((pair) => pair.table === 'root' && pair.key === 'M-]'));
150
151
  assert.match(tmuxBinding(socket, 'root', 'M-c') ?? '', /display-menu/);
151
152
  assert.match(tmuxBinding(socket, 'root', 'M-v') ?? '', /switch-client/);
153
+ // The inbox toggle installs at its default key (alt+i → M-i) in the same sweep.
154
+ assert.match(tmuxBinding(socket, 'root', 'M-i') ?? '', /hl inbox toggle/);
152
155
  const remapped = installTmuxBindings(resolveKeybindings({
153
156
  'crtr.tmux.menu.open': ['alt+shift+z'],
154
157
  'crtr.tmux.menu.graph': ['shift+z'],
@@ -185,3 +188,178 @@ test('tmux installer remaps owned pairs and preserves occupied user bindings', (
185
188
  isolatedTmux(socket, ['kill-server']);
186
189
  }
187
190
  });
191
+ test('tmux installer rebinds/disables the inbox toggle and adopts the legacy hl-owned M-i', (t) => {
192
+ if (spawnSync('tmux', ['-V']).status !== 0) {
193
+ t.skip('tmux is unavailable');
194
+ return;
195
+ }
196
+ const name = `crtr-inbox-${process.pid}-${Date.now()}`;
197
+ const boot = spawnSync('tmux', ['-L', name, 'new-session', '-d', '-s', 'test'], { encoding: 'utf8' });
198
+ assert.equal(boot.status, 0, boot.stderr);
199
+ const socket = spawnSync('tmux', ['-L', name, 'display-message', '-p', '#{socket_path}'], { encoding: 'utf8' }).stdout.trim();
200
+ assert.notEqual(socket, '');
201
+ const priorTmux = process.env['TMUX'];
202
+ const priorState = process.env['XDG_STATE_HOME'];
203
+ const stateHome = mkdtempSync(join(tmpdir(), 'crtr-inbox-state-'));
204
+ process.env['TMUX'] = `${socket},1,0`;
205
+ process.env['XDG_STATE_HOME'] = stateHome;
206
+ try {
207
+ // Fixture: a surviving hl-owned M-i binding plus its flat inbox-key file —
208
+ // the arrangement crouter's key ownership replaces — with no crouter
209
+ // manifest yet. First reconciliation must take over M-i (crouter's inbox
210
+ // toggle, tracked in the manifest) rather than report it foreign.
211
+ isolatedTmux(socket, ['bind-key', '-T', 'root', 'M-i', 'run-shell', '-b', 'hl inbox toggle --quiet --tmux-socket "#{socket_path}"']);
212
+ const legacyKeyDir = join(stateHome, 'humanloop');
213
+ mkdirSync(legacyKeyDir, { recursive: true });
214
+ const legacyKeyFile = join(legacyKeyDir, 'inbox-key');
215
+ writeFileSync(legacyKeyFile, 'M-i\n');
216
+ const first = installTmuxBindings(resolveKeybindings());
217
+ assert.equal(first.ok, true, JSON.stringify(first.diagnostics));
218
+ // Adopted, not foreign: M-i is now crouter's inbox toggle, no occupied diag.
219
+ assert.ok(!first.diagnostics.some((d) => d.bindingId === 'crtr.tmux.inbox.toggle'), JSON.stringify(first.diagnostics));
220
+ assert.match(tmuxBinding(socket, 'root', 'M-i') ?? '', /hl inbox toggle/);
221
+ assert.ok(first.installed.some((pair) => pair.table === 'root' && pair.key === 'M-i'));
222
+ // The obsolete flat key file is deleted by the cleanup.
223
+ assert.equal(existsSync(legacyKeyFile), false, 'legacy inbox-key file should be removed');
224
+ // Rebind to a custom key: the old M-i (crouter-owned via the manifest) is
225
+ // dropped and the new key carries the same inbox command.
226
+ const rebound = installTmuxBindings(resolveKeybindings({ 'crtr.tmux.inbox.toggle': ['alt+j'] }));
227
+ assert.equal(rebound.ok, true, JSON.stringify(rebound.diagnostics));
228
+ assert.equal(tmuxBinding(socket, 'root', 'M-i'), null);
229
+ assert.match(tmuxBinding(socket, 'root', 'M-j') ?? '', /hl inbox toggle/);
230
+ // Disable via empty gesture list: no inbox key installed at all, no error.
231
+ const disabled = installTmuxBindings(resolveKeybindings({ 'crtr.tmux.inbox.toggle': [] }));
232
+ assert.equal(disabled.ok, true, JSON.stringify(disabled.diagnostics));
233
+ assert.equal(tmuxBinding(socket, 'root', 'M-i'), null);
234
+ assert.equal(tmuxBinding(socket, 'root', 'M-j'), null);
235
+ }
236
+ finally {
237
+ if (priorTmux === undefined)
238
+ delete process.env['TMUX'];
239
+ else
240
+ process.env['TMUX'] = priorTmux;
241
+ if (priorState === undefined)
242
+ delete process.env['XDG_STATE_HOME'];
243
+ else
244
+ process.env['XDG_STATE_HOME'] = priorState;
245
+ isolatedTmux(socket, ['kill-server']);
246
+ }
247
+ });
248
+ test('tmux installer adopts the legacy hl-owned inbox key when a crouter manifest already exists', (t) => {
249
+ if (spawnSync('tmux', ['-V']).status !== 0) {
250
+ t.skip('tmux is unavailable');
251
+ return;
252
+ }
253
+ const name = `crtr-inbox-upgrade-${process.pid}-${Date.now()}`;
254
+ const boot = spawnSync('tmux', ['-L', name, 'new-session', '-d', '-s', 'test'], { encoding: 'utf8' });
255
+ assert.equal(boot.status, 0, boot.stderr);
256
+ const socket = spawnSync('tmux', ['-L', name, 'display-message', '-p', '#{socket_path}'], { encoding: 'utf8' }).stdout.trim();
257
+ assert.notEqual(socket, '');
258
+ const priorTmux = process.env['TMUX'];
259
+ const priorState = process.env['XDG_STATE_HOME'];
260
+ const stateHome = mkdtempSync(join(tmpdir(), 'crtr-inbox-state-'));
261
+ process.env['TMUX'] = `${socket},1,0`;
262
+ process.env['XDG_STATE_HOME'] = stateHome;
263
+ try {
264
+ // Contract: an hl-owned inbox binding not tracked in the manifest must be
265
+ // adopted even when a crouter manifest already exists. Force
266
+ // `manifest.existed = true` with a non-empty option, then seed an untracked
267
+ // hl-owned M-i binding + its key file.
268
+ isolatedTmux(socket, ['set-option', '-s', '@crtr_keybindings_manifest', '[]']);
269
+ isolatedTmux(socket, ['bind-key', '-T', 'root', 'M-i', 'run-shell', '-b', 'hl inbox toggle --quiet --tmux-socket "#{socket_path}"']);
270
+ const legacyKeyDir = join(stateHome, 'humanloop');
271
+ mkdirSync(legacyKeyDir, { recursive: true });
272
+ const legacyKeyFile = join(legacyKeyDir, 'inbox-key');
273
+ writeFileSync(legacyKeyFile, 'M-i\n');
274
+ const result = installTmuxBindings(resolveKeybindings());
275
+ assert.equal(result.ok, true, JSON.stringify(result.diagnostics));
276
+ // Cleanup ran despite the existing manifest: M-i is crouter's tracked inbox
277
+ // toggle, no occupied/foreign diagnostic, and the key file is gone.
278
+ assert.ok(!result.diagnostics.some((d) => d.bindingId === 'crtr.tmux.inbox.toggle'), JSON.stringify(result.diagnostics));
279
+ assert.match(tmuxBinding(socket, 'root', 'M-i') ?? '', /hl inbox toggle/);
280
+ assert.ok(result.installed.some((pair) => pair.table === 'root' && pair.key === 'M-i'));
281
+ assert.equal(existsSync(legacyKeyFile), false, 'legacy inbox-key file should be removed');
282
+ }
283
+ finally {
284
+ if (priorTmux === undefined)
285
+ delete process.env['TMUX'];
286
+ else
287
+ process.env['TMUX'] = priorTmux;
288
+ if (priorState === undefined)
289
+ delete process.env['XDG_STATE_HOME'];
290
+ else
291
+ process.env['XDG_STATE_HOME'] = priorState;
292
+ isolatedTmux(socket, ['kill-server']);
293
+ }
294
+ });
295
+ test('tmux installer removes a legacy custom inbox key recorded in the state file', (t) => {
296
+ if (spawnSync('tmux', ['-V']).status !== 0) {
297
+ t.skip('tmux is unavailable');
298
+ return;
299
+ }
300
+ const name = `crtr-inbox-custom-${process.pid}-${Date.now()}`;
301
+ const boot = spawnSync('tmux', ['-L', name, 'new-session', '-d', '-s', 'test'], { encoding: 'utf8' });
302
+ assert.equal(boot.status, 0, boot.stderr);
303
+ const socket = spawnSync('tmux', ['-L', name, 'display-message', '-p', '#{socket_path}'], { encoding: 'utf8' }).stdout.trim();
304
+ assert.notEqual(socket, '');
305
+ const priorTmux = process.env['TMUX'];
306
+ const priorState = process.env['XDG_STATE_HOME'];
307
+ const stateHome = mkdtempSync(join(tmpdir(), 'crtr-inbox-state-'));
308
+ process.env['TMUX'] = `${socket},1,0`;
309
+ process.env['XDG_STATE_HOME'] = stateHome;
310
+ try {
311
+ // Contract: an hl-owned binding at a non-default key named by the state
312
+ // file must be located via that file and unbound (its value is never
313
+ // imported as a crouter gesture), leaving crouter's configured M-i the only
314
+ // installed inbox key even when a crouter manifest already exists.
315
+ isolatedTmux(socket, ['set-option', '-s', '@crtr_keybindings_manifest', '[]']);
316
+ isolatedTmux(socket, ['bind-key', '-T', 'root', 'M-z', 'run-shell', '-b', 'hl inbox toggle --quiet --tmux-socket "#{socket_path}"']);
317
+ const legacyKeyDir = join(stateHome, 'humanloop');
318
+ mkdirSync(legacyKeyDir, { recursive: true });
319
+ const legacyKeyFile = join(legacyKeyDir, 'inbox-key');
320
+ writeFileSync(legacyKeyFile, 'M-z\n');
321
+ const result = installTmuxBindings(resolveKeybindings());
322
+ assert.equal(result.ok, true, JSON.stringify(result.diagnostics));
323
+ // The orphaned custom-key binding is gone — not left as an untracked live
324
+ // shortcut — and was not imported into crouter's config (M-z stays unbound).
325
+ assert.equal(tmuxBinding(socket, 'root', 'M-z'), null, 'legacy custom key must be unbound');
326
+ assert.match(tmuxBinding(socket, 'root', 'M-i') ?? '', /hl inbox toggle/);
327
+ assert.equal(existsSync(legacyKeyFile), false, 'legacy inbox-key file should be removed');
328
+ }
329
+ finally {
330
+ if (priorTmux === undefined)
331
+ delete process.env['TMUX'];
332
+ else
333
+ process.env['TMUX'] = priorTmux;
334
+ if (priorState === undefined)
335
+ delete process.env['XDG_STATE_HOME'];
336
+ else
337
+ process.env['XDG_STATE_HOME'] = priorState;
338
+ isolatedTmux(socket, ['kill-server']);
339
+ }
340
+ });
341
+ test('legacyInboxKeyFile resolves under the OS home when HOME and XDG_STATE_HOME are unset', () => {
342
+ const priorHome = process.env['HOME'];
343
+ const priorState = process.env['XDG_STATE_HOME'];
344
+ delete process.env['HOME'];
345
+ delete process.env['XDG_STATE_HOME'];
346
+ try {
347
+ const resolved = legacyInboxKeyFile();
348
+ // Must be an absolute OS-home path (via os.homedir()), never cwd-relative —
349
+ // otherwise cleanup would miss the real file and could delete an unrelated
350
+ // file under the current directory.
351
+ assert.ok(resolved.startsWith('/'), `expected an absolute path, got ${resolved}`);
352
+ assert.ok(resolved.endsWith(join('.local', 'state', 'humanloop', 'inbox-key')), resolved);
353
+ assert.ok(!resolved.startsWith(join(process.cwd(), '.local')), `must not resolve relative to cwd: ${resolved}`);
354
+ }
355
+ finally {
356
+ if (priorHome === undefined)
357
+ delete process.env['HOME'];
358
+ else
359
+ process.env['HOME'] = priorHome;
360
+ if (priorState === undefined)
361
+ delete process.env['XDG_STATE_HOME'];
362
+ else
363
+ process.env['XDG_STATE_HOME'] = priorState;
364
+ }
365
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,126 @@
1
+ // attention.test.ts — the C2 ticket-attribution contract for `attention.ts`.
2
+ //
3
+ // The humanloop cutover generalized crouter's per-node attention counter from
4
+ // "pending asks" to "all pending human tickets": a count now folds in decks
5
+ // (asks), notify decks, AND anchored reviews, all bucketed by the
6
+ // `source.nodeId` stamp humanloop puts on every crouter-originated submission.
7
+ // This exercises the nuanced part that regressions hide in:
8
+ // - a cwd shared by several nodes is scanned ONCE and its tickets split by
9
+ // stamp, never double-counted;
10
+ // - decks, notify decks, and reviews all count;
11
+ // - a bare-shell ticket (no nodeId stamp) belongs to no node and is excluded
12
+ // from the per-node map;
13
+ // - every requested id is present (0 when it has none).
14
+ import { test, before, beforeEach, after } from 'node:test';
15
+ import assert from 'node:assert/strict';
16
+ import { mkdtempSync, rmSync, mkdirSync } from 'node:fs';
17
+ import { tmpdir } from 'node:os';
18
+ import { join } from 'node:path';
19
+ import { atomicWriteJson, deckPath } from '@crouton-kit/humanloop';
20
+ import { createNode } from '../canvas.js';
21
+ import { closeDb } from '../db.js';
22
+ import { interactionsRoot } from '../../artifact.js';
23
+ import { countTickets, ticketCountsForNodes, pendingTicketsForView } from '../attention.js';
24
+ function meta(id, cwd, extra) {
25
+ return {
26
+ node_id: id,
27
+ name: id,
28
+ created: new Date().toISOString(),
29
+ cwd,
30
+ kind: 'general',
31
+ mode: 'base',
32
+ lifecycle: 'terminal',
33
+ status: 'active',
34
+ ...extra,
35
+ };
36
+ }
37
+ /** Seed a deck ticket (notify is just a durable deck) under `cwd`'s
38
+ * interactions root, stamped with `nodeId` when given (omit for a bare-shell
39
+ * ticket with no canvas owner). */
40
+ function seedDeck(cwd, id, nodeId) {
41
+ const dir = join(interactionsRoot(cwd), id);
42
+ mkdirSync(dir, { recursive: true });
43
+ atomicWriteJson(deckPath(dir), {
44
+ source: nodeId === undefined ? {} : { nodeId },
45
+ interactions: [{ id: 'q', title: id, options: [] }],
46
+ });
47
+ }
48
+ /** Seed an anchored review ticket (review.json) under `cwd`, stamped `nodeId`. */
49
+ function seedReview(cwd, id, nodeId) {
50
+ const dir = join(interactionsRoot(cwd), id);
51
+ mkdirSync(dir, { recursive: true });
52
+ atomicWriteJson(join(dir, 'review.json'), {
53
+ schema: 'humanloop.review/v1',
54
+ file: join(cwd, 'src.md'),
55
+ output: join(dir, 'feedback.json'),
56
+ title: id,
57
+ source: { nodeId },
58
+ blockedSince: new Date().toISOString(),
59
+ });
60
+ }
61
+ let home;
62
+ let sharedCwd;
63
+ let soloCwd;
64
+ before(() => {
65
+ home = mkdtempSync(join(tmpdir(), 'crtr-attention-'));
66
+ sharedCwd = mkdtempSync(join(tmpdir(), 'crtr-attention-shared-'));
67
+ soloCwd = mkdtempSync(join(tmpdir(), 'crtr-attention-solo-'));
68
+ process.env['CRTR_HOME'] = home;
69
+ });
70
+ beforeEach(() => {
71
+ closeDb();
72
+ rmSync(home, { recursive: true, force: true });
73
+ rmSync(interactionsRoot(sharedCwd), { recursive: true, force: true });
74
+ rmSync(interactionsRoot(soloCwd), { recursive: true, force: true });
75
+ });
76
+ after(() => {
77
+ closeDb();
78
+ for (const d of [home, sharedCwd, soloCwd])
79
+ rmSync(d, { recursive: true, force: true });
80
+ delete process.env['CRTR_HOME'];
81
+ });
82
+ test('ticketCountsForNodes buckets deck/review/notify by node across a shared cwd and drops bare-shell tickets', () => {
83
+ // Nodes a and b share one cwd; c has its own.
84
+ createNode(meta('a', sharedCwd));
85
+ createNode(meta('b', sharedCwd));
86
+ createNode(meta('c', soloCwd));
87
+ // Shared cwd: two tickets for a (an ask + a notify deck), one review for b,
88
+ // and one unattributed bare-shell deck that belongs to no node.
89
+ seedDeck(sharedCwd, 'a-ask', 'a');
90
+ seedDeck(sharedCwd, 'a-notify', 'a');
91
+ seedReview(sharedCwd, 'b-review', 'b');
92
+ seedDeck(sharedCwd, 'bare', undefined);
93
+ // Solo cwd: one ask for c.
94
+ seedDeck(soloCwd, 'c-ask', 'c');
95
+ const counts = ticketCountsForNodes(['a', 'b', 'c']);
96
+ // a: ask + notify = 2; b: review = 1; c: ask = 1. The bare-shell ticket is
97
+ // excluded from every per-node bucket even though it lives in a's cwd.
98
+ assert.deepEqual(counts, { a: 2, b: 1, c: 1 });
99
+ });
100
+ test('countTickets counts only the requesting node in a shared cwd', () => {
101
+ createNode(meta('a', sharedCwd));
102
+ createNode(meta('b', sharedCwd));
103
+ seedDeck(sharedCwd, 'a-ask', 'a');
104
+ seedReview(sharedCwd, 'b-review', 'b');
105
+ seedDeck(sharedCwd, 'bare', undefined);
106
+ assert.equal(countTickets('a'), 1);
107
+ assert.equal(countTickets('b'), 1);
108
+ });
109
+ test('every requested id is present with 0 when it has no tickets', () => {
110
+ createNode(meta('a', sharedCwd));
111
+ createNode(meta('c', soloCwd));
112
+ seedDeck(sharedCwd, 'a-ask', 'a');
113
+ assert.deepEqual(ticketCountsForNodes(['a', 'c']), { a: 1, c: 0 });
114
+ });
115
+ test('pendingTicketsForView de-dups a shared cwd to one entry summing all its tickets', () => {
116
+ // a is the view root; b shares a's cwd.
117
+ createNode(meta('a', sharedCwd));
118
+ createNode(meta('b', sharedCwd));
119
+ seedDeck(sharedCwd, 'a-ask', 'a');
120
+ seedReview(sharedCwd, 'b-review', 'b');
121
+ const items = pendingTicketsForView('a');
122
+ // One cwd → one entry; its count is every pending ticket in that cwd (2),
123
+ // not a per-node split, so the shared dir is never counted twice.
124
+ assert.equal(items.length, 1);
125
+ assert.equal(items[0].count, 2);
126
+ });
@@ -207,8 +207,8 @@ test('a 500 response, a non-JSON body, and a non-zero exitCode all degrade grace
207
207
  const source = new RemoteCanvasSource(server.url, 'tok');
208
208
  assert.equal(await source.getNode('http500'), null);
209
209
  assert.deepEqual(await source.listNodes(), []);
210
- assert.deepEqual(await source.askCountsForView('badjson'), {});
211
- assert.deepEqual(await source.askCountsForView('nonzero-exit'), {});
210
+ assert.deepEqual(await source.ticketCountsForView('badjson'), {});
211
+ assert.deepEqual(await source.ticketCountsForView('nonzero-exit'), {});
212
212
  });
213
213
  test('malformed success JSON (exit 0, wrong shape) degrades cleanly instead of returning a shape a consumer would crash on', async (t) => {
214
214
  const server = await startMockCrtrServer((args) => {
@@ -247,8 +247,8 @@ test('malformed success JSON (exit 0, wrong shape) degrades cleanly instead of r
247
247
  // `listNodes()` with a non-array `nodes` field must degrade to [], never
248
248
  // throw or return the malformed value.
249
249
  assert.deepEqual(await source.listNodes(), []);
250
- // `askCountsForView()` with a non-object `counts` field must degrade to {}.
251
- assert.deepEqual(await source.askCountsForView('root'), {});
250
+ // `ticketCountsForView()` with a non-object `counts` field must degrade to {}.
251
+ assert.deepEqual(await source.ticketCountsForView('root'), {});
252
252
  });
253
253
  function fullNodeRow(id, over = {}) {
254
254
  return {
@@ -110,7 +110,7 @@ after(() => {
110
110
  * poisoned session-file path so the mocked `node inspect show` response can
111
111
  * point `pi_session_file` at it (the vector a regression would follow). */
112
112
  function seedPoisonedLocalState() {
113
- // A local node row + a pending ask under its cwd — poisons `asksForNodes`.
113
+ // A local node row + a pending ticket under its cwd — poisons `ticketCountsForNodes`.
114
114
  createNode(meta('a', { cwd: localCwd }));
115
115
  const askDir = join(interactionsRoot(localCwd), 'poison-ask');
116
116
  mkdirSync(askDir, { recursive: true });
@@ -1,34 +1,36 @@
1
- export interface AskEntry {
1
+ export interface TicketEntry {
2
2
  node_id: string;
3
3
  name: string;
4
4
  cwd: string;
5
5
  count: number;
6
6
  }
7
7
  /**
8
- * Count pending asks for the cwd of a single node.
8
+ * Count pending tickets for the cwd of a single node.
9
9
  * Returns 0 when the node is unknown or humanloop is unavailable.
10
10
  */
11
- export declare function countAsks(nodeId: string): number;
11
+ export declare function countTickets(nodeId: string): number;
12
12
  /**
13
- * Pending asks for all nodes reachable in the subscription sub-DAG from
13
+ * Pending tickets for all nodes reachable in the subscription sub-DAG from
14
14
  * `rootId` (including root itself). De-duped by cwd: when multiple nodes
15
15
  * share a cwd the first one encountered claims the entry.
16
16
  *
17
17
  * Returns only entries with count > 0.
18
18
  */
19
- export declare function pendingAsksForView(rootId: string): AskEntry[];
19
+ export declare function pendingTicketsForView(rootId: string): TicketEntry[];
20
20
  /**
21
- * Per-node pending ask counts for an explicit set of node ids — the batched
22
- * counterpart to `countAsks`, used by the nav chrome to label every visible
21
+ * Per-node pending ticket counts for an explicit set of node ids — the batched
22
+ * counterpart to `countTickets`, used by the nav chrome to label every visible
23
23
  * node in ONE pass. Groups ids by their cwd so each distinct interactions dir
24
- * is scanned exactly once, then buckets the decks by the `source.nodeId` stamp
25
- * (same attribution as `countForCwd(cwd, nodeId)`). Asks with no node stamp are
26
- * not attributable to any node and are excluded. Every requested id appears in
27
- * the result (0 when it has none). Never throws.
24
+ * is scanned exactly once, de-duplicates by canonical ticket dir (scanInbox
25
+ * already returns one summary per dir), then buckets the tickets by the
26
+ * `source.nodeId` stamp (same attribution as `countForCwd(cwd, nodeId)`).
27
+ * Tickets with no node stamp (bare-shell / unattributed) belong to no node and
28
+ * are excluded from the map. Every requested id appears in the result (0 when
29
+ * it has none). Never throws.
28
30
  */
29
- export declare function asksForNodes(ids: string[]): Record<string, number>;
31
+ export declare function ticketCountsForNodes(ids: string[]): Record<string, number>;
30
32
  /**
31
- * Pending asks across the entire canvas — every distinct cwd among all known
33
+ * Pending tickets across the entire canvas — every distinct cwd among all known
32
34
  * nodes. Returns only entries with count > 0.
33
35
  */
34
- export declare function asksAcrossCanvas(): AskEntry[];
36
+ export declare function ticketsAcrossCanvas(): TicketEntry[];