@crouton-kit/crouter 0.3.70 → 0.3.78

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 (147) hide show
  1. package/README.md +1 -1
  2. package/dist/builtin-memory/00-runtime-base.md +3 -10
  3. package/dist/builtin-memory/04-base-worker.md +18 -0
  4. package/dist/builtin-memory/04-orchestration-kernel.md +1 -1
  5. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +13 -34
  6. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +622 -724
  7. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +231 -68
  8. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/statusline.ts +2 -9
  9. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +361 -90
  10. package/dist/builtin-pi-packages/pi-crtr-extensions/package-lock.json +2 -2
  11. package/dist/builtin-pi-packages/pi-mode-switch/README.md +11 -39
  12. package/dist/builtin-pi-packages/pi-mode-switch/extensions/index.ts +20 -15
  13. package/dist/builtin-pi-packages/pi-mode-switch/package.json +1 -1
  14. package/dist/builtin-views/canvas/tui.mjs +8 -9
  15. package/dist/builtin-views/chat/tui.mjs +12 -12
  16. package/dist/builtin-views/git-pr/tui.mjs +7 -8
  17. package/dist/builtin-views/inbox/tui.mjs +27 -41
  18. package/dist/builtin-views/linkedin/tui.mjs +23 -37
  19. package/dist/builtin-views/prompt-review/tui.mjs +11 -11
  20. package/dist/builtin-views/settings/tui.mjs +11 -11
  21. package/dist/builtin-views/workspace-sidebar/tui.mjs +8 -9
  22. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +3 -3
  23. package/dist/clients/attach/__tests__/crtr-output-render.test.js +1 -1
  24. package/dist/clients/attach/__tests__/mermaid-render.test.d.ts +1 -0
  25. package/dist/clients/attach/__tests__/mermaid-render.test.js +20 -0
  26. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.d.ts +1 -0
  27. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +110 -0
  28. package/dist/clients/attach/attach-cmd.d.ts +1 -1
  29. package/dist/clients/attach/attach-cmd.js +738 -735
  30. package/dist/clients/attach/canvas-panels.js +2 -3
  31. package/dist/clients/attach/chat-view.d.ts +7 -8
  32. package/dist/clients/attach/chat-view.js +119 -79
  33. package/dist/clients/attach/edit-diff-render.d.ts +6 -0
  34. package/dist/clients/attach/edit-diff-render.js +263 -0
  35. package/dist/clients/attach/input-controller.d.ts +28 -2
  36. package/dist/clients/attach/input-controller.js +38 -5
  37. package/dist/clients/attach/mermaid-render.js +5 -2
  38. package/dist/clients/attach/pickers.d.ts +8 -7
  39. package/dist/clients/attach/pickers.js +11 -17
  40. package/dist/clients/attach/slash-commands.d.ts +9 -0
  41. package/dist/clients/attach/slash-commands.js +127 -7
  42. package/dist/clients/attach/titled-editor.d.ts +12 -1
  43. package/dist/clients/attach/titled-editor.js +103 -8
  44. package/dist/commands/canvas-browse.js +2 -2
  45. package/dist/commands/memory/lint.js +39 -5
  46. package/dist/commands/memory/write.js +1 -0
  47. package/dist/commands/node.js +9 -2
  48. package/dist/commands/surface-tmux-spread.js +1 -3
  49. package/dist/commands/sys/__tests__/config-keybindings.test.d.ts +1 -0
  50. package/dist/commands/sys/__tests__/config-keybindings.test.js +55 -0
  51. package/dist/commands/sys/__tests__/config-model-ladders.test.d.ts +1 -0
  52. package/dist/commands/sys/__tests__/config-model-ladders.test.js +121 -0
  53. package/dist/commands/sys/config.js +18 -21
  54. package/dist/commands/sys/setup-core.d.ts +12 -1
  55. package/dist/commands/sys/setup-core.js +23 -3
  56. package/dist/commands/sys/setup.js +42 -12
  57. package/dist/core/__tests__/base-worker-prompt.test.d.ts +1 -0
  58. package/dist/core/__tests__/base-worker-prompt.test.js +24 -0
  59. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +232 -1
  60. package/dist/core/__tests__/fault-classifier.test.js +15 -0
  61. package/dist/core/__tests__/full/broker-dialogs.test.js +7 -2
  62. package/dist/core/__tests__/host-teardown-process-group.test.js +15 -4
  63. package/dist/core/__tests__/stream-watchdog.test.d.ts +1 -0
  64. package/dist/core/__tests__/stream-watchdog.test.js +70 -0
  65. package/dist/core/__tests__/tmux-surface.test.js +72 -0
  66. package/dist/core/canvas/browse/__tests__/model.test.js +23 -5
  67. package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
  68. package/dist/core/canvas/browse/app.js +164 -5
  69. package/dist/core/canvas/browse/model.d.ts +10 -6
  70. package/dist/core/canvas/browse/model.js +20 -12
  71. package/dist/core/canvas/browse/pins.d.ts +4 -0
  72. package/dist/core/canvas/browse/pins.js +29 -0
  73. package/dist/core/canvas/browse/render.d.ts +13 -1
  74. package/dist/core/canvas/browse/render.js +72 -15
  75. package/dist/core/canvas/nav-model.js +5 -11
  76. package/dist/core/canvas/paths.d.ts +5 -0
  77. package/dist/core/canvas/paths.js +7 -0
  78. package/dist/core/config.d.ts +53 -2
  79. package/dist/core/config.js +228 -110
  80. package/dist/core/fault-classifier.js +1 -1
  81. package/dist/core/fs-utils.d.ts +1 -0
  82. package/dist/core/fs-utils.js +15 -1
  83. package/dist/core/keybindings/__tests__/resolve.test.d.ts +1 -0
  84. package/dist/core/keybindings/__tests__/resolve.test.js +224 -0
  85. package/dist/core/keybindings/catalog.d.ts +14 -0
  86. package/dist/core/keybindings/catalog.js +256 -0
  87. package/dist/core/keybindings/index.d.ts +5 -0
  88. package/dist/core/keybindings/index.js +4 -0
  89. package/dist/core/keybindings/match.d.ts +29 -0
  90. package/dist/core/keybindings/match.js +99 -0
  91. package/dist/core/keybindings/persistence.d.ts +14 -0
  92. package/dist/core/keybindings/persistence.js +31 -0
  93. package/dist/core/keybindings/resolve.d.ts +14 -0
  94. package/dist/core/keybindings/resolve.js +305 -0
  95. package/dist/core/keybindings/types.d.ts +37 -0
  96. package/dist/core/keybindings/types.js +1 -0
  97. package/dist/core/memory-resolver.d.ts +1 -1
  98. package/dist/core/memory-resolver.js +10 -3
  99. package/dist/core/predicate.d.ts +5 -3
  100. package/dist/core/predicate.js +5 -3
  101. package/dist/core/runtime/broker-protocol.d.ts +33 -5
  102. package/dist/core/runtime/broker.js +163 -14
  103. package/dist/core/runtime/launch.d.ts +39 -6
  104. package/dist/core/runtime/launch.js +78 -20
  105. package/dist/core/runtime/naming.js +3 -3
  106. package/dist/core/runtime/pi-cli.d.ts +6 -0
  107. package/dist/core/runtime/pi-cli.js +16 -2
  108. package/dist/core/runtime/placement.d.ts +2 -2
  109. package/dist/core/runtime/placement.js +4 -1
  110. package/dist/core/runtime/promote.js +4 -0
  111. package/dist/core/runtime/recap.d.ts +3 -3
  112. package/dist/core/runtime/recap.js +47 -44
  113. package/dist/core/runtime/recycle.js +6 -1
  114. package/dist/core/runtime/reset.js +5 -0
  115. package/dist/core/runtime/session-list-cache.d.ts +23 -0
  116. package/dist/core/runtime/session-list-cache.js +270 -0
  117. package/dist/core/runtime/spawn.js +51 -32
  118. package/dist/core/runtime/stream-watchdog.d.ts +26 -0
  119. package/dist/core/runtime/stream-watchdog.js +75 -0
  120. package/dist/core/runtime/tmux-chrome.d.ts +1 -1
  121. package/dist/core/runtime/tmux-chrome.js +2 -2
  122. package/dist/core/runtime/tmux.d.ts +18 -15
  123. package/dist/core/runtime/tmux.js +216 -110
  124. package/dist/core/tui/__tests__/host-keybindings.test.d.ts +1 -0
  125. package/dist/core/tui/__tests__/host-keybindings.test.js +112 -0
  126. package/dist/core/tui/host.d.ts +30 -1
  127. package/dist/core/tui/host.js +148 -34
  128. package/dist/core/view/contract.d.ts +17 -3
  129. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +41 -0
  130. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.d.ts +1 -0
  131. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.js +121 -0
  132. package/dist/pi-extensions/canvas-inbox-watcher.d.ts +1 -1
  133. package/dist/pi-extensions/canvas-inbox-watcher.js +203 -25
  134. package/dist/pi-extensions/canvas-recap.d.ts +4 -0
  135. package/dist/pi-extensions/canvas-recap.js +38 -37
  136. package/dist/pi-extensions/canvas-stophook.d.ts +11 -0
  137. package/dist/pi-extensions/canvas-stophook.js +33 -4
  138. package/dist/types.d.ts +3 -13
  139. package/dist/types.js +2 -26
  140. package/dist/web-client/assets/{index-DiFuLcp6.js → index--md2ylfi.js} +1 -1
  141. package/dist/web-client/index.html +1 -1
  142. package/dist/web-client/sw.js +1 -1
  143. package/package.json +3 -3
  144. package/dist/pi-extensions/canvas-nav.d.ts +0 -43
  145. package/dist/pi-extensions/canvas-nav.js +0 -640
  146. package/dist/pi-extensions/widget-order-bus.d.ts +0 -6
  147. package/dist/pi-extensions/widget-order-bus.js +0 -34
@@ -62,7 +62,7 @@
62
62
  import { test, before, after } from 'node:test';
63
63
  import assert from 'node:assert/strict';
64
64
  import { spawn } from 'node:child_process';
65
- import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
65
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
66
66
  import { tmpdir } from 'node:os';
67
67
  import { join } from 'node:path';
68
68
  import { createHarness } from './helpers/harness.js';
@@ -180,9 +180,20 @@ async function spawnBrokerWithSharedGroupDescendant(tag, descendantScript = 'sle
180
180
  if (leader.pid == null)
181
181
  throw new Error('failed to spawn fake broker leader');
182
182
  trackPid(leader.pid);
183
- await h.waitFor(() => existsSync(pidFile), { label: `${tag}: descendant pid file written` });
184
- const descendantPid = Number.parseInt(readFileSync(pidFile, 'utf8').trim(), 10);
185
- assert.ok(Number.isInteger(descendantPid) && descendantPid > 0, `${tag}: parsed a real descendant pid`);
183
+ const descendantPid = await h.waitFor(() => {
184
+ try {
185
+ const record = readFileSync(pidFile, 'utf8');
186
+ if (!/^\d+\n$/.test(record))
187
+ return false;
188
+ const pid = Number(record.slice(0, -1));
189
+ return Number.isSafeInteger(pid) && pid > 0 ? pid : false;
190
+ }
191
+ catch (error) {
192
+ if (error.code === 'ENOENT')
193
+ return false;
194
+ throw error;
195
+ }
196
+ }, { label: `${tag}: valid descendant pid written` });
186
197
  trackPid(descendantPid);
187
198
  await h.waitFor(() => isPidAlive(descendantPid), { label: `${tag}: descendant alive before teardown` });
188
199
  return { leaderPid: leader.pid, descendantPid };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,70 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/stream-watchdog.test.ts
2
+ //
3
+ // Unit-level contract test for StreamWatchdog (see stream-watchdog.ts): a
4
+ // single-timer dead-man's switch the broker pokes while a turn awaits the
5
+ // provider (disarmed during tool execution) and clears at agent_end. This
6
+ // locks the class's arm/clear/single-fire/token contract directly — no broker
7
+ // or fake-engine harness needed (that integration surface is deliberately out
8
+ // of scope; see
9
+ // the roadmap's Fix 3 test-scope note).
10
+ import { test } from 'node:test';
11
+ import assert from 'node:assert/strict';
12
+ import { StreamWatchdog } from '../runtime/stream-watchdog.js';
13
+ const MS = 30;
14
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
15
+ test('poke() arms the watchdog, firing once after ms with no further poke', async () => {
16
+ let calls = 0;
17
+ const wd = new StreamWatchdog(() => calls++, MS);
18
+ wd.poke(0);
19
+ assert.equal(calls, 0, 'must not fire before ms elapses');
20
+ await sleep(MS * 2);
21
+ assert.equal(calls, 1, 'must fire exactly once after ms with no further poke');
22
+ });
23
+ test('repeated pokes rearm the deadline from the LAST poke, not the first', async () => {
24
+ // A cancel-without-reschedule bug (clear the old timer but never arm a new
25
+ // one) would also "never fire while poked", so proving no-fire mid-stream is
26
+ // not enough. Prove the deadline actually REARMS: no fire around the original
27
+ // deadline, then exactly one fire measured from the FINAL poke.
28
+ let calls = 0;
29
+ const wd = new StreamWatchdog(() => calls++, MS);
30
+ wd.poke(0);
31
+ // Re-poke well inside the window, several times, always resetting the clock.
32
+ // The last poke lands at ~t=2.5*MS, so the original t=MS deadline must pass
33
+ // with no fire.
34
+ for (let i = 0; i < 5; i++) {
35
+ await sleep(MS / 2);
36
+ wd.poke(0);
37
+ }
38
+ assert.equal(calls, 0, 'no fire around the original deadline — each poke rearmed it');
39
+ // From the final poke the timer must still be armed and fire exactly once.
40
+ await sleep(MS * 2);
41
+ assert.equal(calls, 1, 'exactly one fire, measured from the final poke — proves rearming');
42
+ });
43
+ test('clear() after poke prevents firing', async () => {
44
+ let calls = 0;
45
+ const wd = new StreamWatchdog(() => calls++, MS);
46
+ wd.poke(0);
47
+ wd.clear();
48
+ await sleep(MS * 2);
49
+ assert.equal(calls, 0, 'a cleared watchdog must never fire');
50
+ });
51
+ test('fires exactly once even if left armed well past ms', async () => {
52
+ let calls = 0;
53
+ const wd = new StreamWatchdog(() => calls++, MS);
54
+ wd.poke(0);
55
+ await sleep(MS * 5);
56
+ assert.equal(calls, 1, 'must fire only once, not repeatedly, past the deadline');
57
+ });
58
+ test('onStall receives the token of the arm that fired (generation binding)', async () => {
59
+ // The broker stamps each poke with the current session generation and no-ops
60
+ // a stall whose token is stale. This pins that the token travels with the
61
+ // arm: a re-poke supersedes the prior token, so a fire after a rebind carries
62
+ // the NEW generation — and an old timer that somehow survived would deliver
63
+ // the OLD one, which the broker guard rejects.
64
+ const tokens = [];
65
+ const wd = new StreamWatchdog((token) => tokens.push(token), MS);
66
+ wd.poke(1);
67
+ wd.poke(2); // supersedes generation 1 before it can fire
68
+ await sleep(MS * 2);
69
+ assert.deepEqual(tokens, [2], 'exactly one fire, carrying the most-recent arm\'s token');
70
+ });
@@ -18,8 +18,11 @@
18
18
  import { test } from 'node:test';
19
19
  import assert from 'node:assert/strict';
20
20
  import { readFileSync, readdirSync, statSync } from 'node:fs';
21
+ import { spawnSync } from 'node:child_process';
21
22
  import { fileURLToPath } from 'node:url';
22
23
  import { dirname, join, basename } from 'node:path';
24
+ import { installTmuxBindings } from '../runtime/tmux-chrome.js';
25
+ import { resolveKeybindings } from '../keybindings/index.js';
23
26
  const __dirname = dirname(fileURLToPath(import.meta.url));
24
27
  const SRC_ROOT = join(__dirname, '..', '..'); // .../src
25
28
  const TMUX_TS = join(SRC_ROOT, 'core', 'runtime', 'tmux.ts');
@@ -113,3 +116,72 @@ test('§5.1 lint: only placement.ts / tmux-chrome.ts import the tmux driver', ()
113
116
  .map((f) => f.slice(SRC_ROOT.length + 1));
114
117
  assert.deepEqual(offenders, [], `modules importing tmux.ts directly: ${offenders.join(', ')}`);
115
118
  });
119
+ function isolatedTmux(socket, args) {
120
+ const result = spawnSync('tmux', ['-S', socket, ...args], { encoding: 'utf8' });
121
+ return { status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? '' };
122
+ }
123
+ function tmuxBinding(socket, table, key) {
124
+ const result = isolatedTmux(socket, ['list-keys', '-T', table]);
125
+ if (result.status !== 0)
126
+ return null;
127
+ return result.stdout.split('\n').find((line) => {
128
+ const match = line.match(/^bind-key\s+(?:-r\s+)?-T\s+\S+\s+(\S+)\s+/);
129
+ return match?.[1] === key;
130
+ }) ?? null;
131
+ }
132
+ test('tmux installer remaps owned pairs and preserves occupied user bindings', (t) => {
133
+ if (spawnSync('tmux', ['-V']).status !== 0) {
134
+ t.skip('tmux is unavailable');
135
+ return;
136
+ }
137
+ const name = `crtr-keybindings-${process.pid}-${Date.now()}`;
138
+ const boot = spawnSync('tmux', ['-L', name, 'new-session', '-d', '-s', 'test'], { encoding: 'utf8' });
139
+ assert.equal(boot.status, 0, boot.stderr);
140
+ const socketResult = spawnSync('tmux', ['-L', name, 'display-message', '-p', '#{socket_path}'], { encoding: 'utf8' });
141
+ const socket = socketResult.stdout.trim();
142
+ assert.notEqual(socket, '');
143
+ const priorTmux = process.env['TMUX'];
144
+ process.env['TMUX'] = `${socket},1,0`;
145
+ try {
146
+ isolatedTmux(socket, ['bind-key', '-T', 'root', 'M-]', 'run-shell', `crtr node cycle --dir next --pane '#{pane_id}' >/dev/null 2>&1`]);
147
+ const first = installTmuxBindings(resolveKeybindings());
148
+ assert.equal(first.ok, true, JSON.stringify(first.diagnostics));
149
+ assert.ok(first.installed.some((pair) => pair.table === 'root' && pair.key === 'M-]'));
150
+ assert.match(tmuxBinding(socket, 'root', 'M-c') ?? '', /display-menu/);
151
+ assert.match(tmuxBinding(socket, 'root', 'M-v') ?? '', /switch-client/);
152
+ const remapped = installTmuxBindings(resolveKeybindings({
153
+ 'crtr.tmux.menu.open': ['alt+shift+z'],
154
+ 'crtr.tmux.menu.graph': ['shift+z'],
155
+ 'crtr.tmux.node.next': [],
156
+ 'crtr.tmux.view.next': ['alt+z > n'],
157
+ 'crtr.tmux.view.previous': ['alt+z > p'],
158
+ }));
159
+ assert.equal(remapped.ok, true, JSON.stringify(remapped.diagnostics));
160
+ assert.equal(tmuxBinding(socket, 'root', 'M-c'), null);
161
+ assert.equal(tmuxBinding(socket, 'root', 'M-]'), null);
162
+ const menu = tmuxBinding(socket, 'root', 'M-Z') ?? '';
163
+ assert.match(menu, /graph view/);
164
+ assert.match(menu, /Z/);
165
+ const prefix = tmuxBinding(socket, 'root', 'M-z') ?? '';
166
+ assert.match(prefix, /switch-client/);
167
+ const again = installTmuxBindings(resolveKeybindings({
168
+ 'crtr.tmux.menu.open': ['alt+shift+y'],
169
+ 'crtr.tmux.node.next': [],
170
+ 'crtr.tmux.view.next': ['alt+z > n'],
171
+ 'crtr.tmux.view.previous': ['alt+z > p'],
172
+ }));
173
+ assert.equal(again.ok, true, JSON.stringify(again.diagnostics));
174
+ isolatedTmux(socket, ['bind-key', '-T', 'root', 'M-Z', 'display-message', 'user-owned']);
175
+ const occupied = installTmuxBindings(resolveKeybindings({ 'crtr.tmux.menu.open': ['alt+shift+z'] }));
176
+ assert.equal(occupied.ok, true);
177
+ assert.ok(occupied.diagnostics.some((diagnostic) => diagnostic.code === 'occupied' && diagnostic.bindingId === 'crtr.tmux.menu.open'));
178
+ assert.match(tmuxBinding(socket, 'root', 'M-Z') ?? '', /user-owned/);
179
+ }
180
+ finally {
181
+ if (priorTmux === undefined)
182
+ delete process.env['TMUX'];
183
+ else
184
+ process.env['TMUX'] = priorTmux;
185
+ isolatedTmux(socket, ['kill-server']);
186
+ }
187
+ });
@@ -108,20 +108,38 @@ test('pruneNode: absent id is a no-op', () => {
108
108
  assert.equal(t.nodes.size, before);
109
109
  });
110
110
  // ── tab predicates ───────────────────────────────────────────────────────────
111
- test('tabPredicate: All / Live / Dormant / Flagged', () => {
112
- assert.deepEqual([...TABS], ['All', 'Live', 'Dormant', 'Flagged']);
111
+ test('tabPredicate: All / Live / Dormant / Attention / Pinned', () => {
112
+ assert.deepEqual([...TABS], ['All', 'Live', 'Dormant', 'Attention', 'Pinned']);
113
113
  const active = ROWS[0]; // root1 active
114
114
  const idle = ROWS[1]; // child-a idle
115
115
  const done = ROWS[2]; // grand-x done
116
- const flagged = ROWS[3]; // child-b active asks 2
116
+ const attention = ROWS[3]; // child-b active asks 2
117
117
  assert.equal(tabPredicate('All', done), true);
118
118
  assert.equal(tabPredicate('Live', active), true);
119
119
  assert.equal(tabPredicate('Live', idle), true);
120
120
  assert.equal(tabPredicate('Live', done), false);
121
121
  assert.equal(tabPredicate('Dormant', done), true);
122
122
  assert.equal(tabPredicate('Dormant', active), false);
123
- assert.equal(tabPredicate('Flagged', flagged), true);
124
- assert.equal(tabPredicate('Flagged', active), false);
123
+ // Attention = pending asks OR a fault.
124
+ assert.equal(tabPredicate('Attention', attention), true);
125
+ assert.equal(tabPredicate('Attention', active), false);
126
+ assert.equal(tabPredicate('Attention', { ...active, asks: 0, hanging: { kind: 'provider' } }), true);
127
+ // Pinned reads the isPinned arg, not the row.
128
+ assert.equal(tabPredicate('Pinned', active), false);
129
+ assert.equal(tabPredicate('Pinned', active, true), true);
130
+ });
131
+ // ── flatten: pins ────────────────────────────────────────────────────────────
132
+ test('flatten: Pinned tab shows only pinned rows', () => {
133
+ const pinned = new Set(['child-c']);
134
+ const v = flatten(tree(), { collapsed: new Set(), tab: 'Pinned', query: '', pinned });
135
+ // child-c is directly matched; root2 rides along as its ancestor (context).
136
+ const matched = v.filter((r) => r.matched).map((r) => r.id);
137
+ assert.deepEqual(matched, ['child-c']);
138
+ });
139
+ test('flatten: pinned rows float to the top in a flat sort', () => {
140
+ const pinned = new Set(['lonely']); // an idle straggler that would otherwise sort late
141
+ const v = flatten(tree(), { collapsed: new Set(), tab: 'All', query: '', sort: 'recency', pinned });
142
+ assert.equal(v[0].id, 'lonely');
125
143
  });
126
144
  // ── flatten: collapse / expand ───────────────────────────────────────────────
127
145
  const allCollapsed = () => new Set(['root1', 'child-a', 'root2']); // every node with children
@@ -37,6 +37,7 @@ function state(over = {}) {
37
37
  sort: 'tree',
38
38
  preview: false,
39
39
  residentsOnly: false,
40
+ pinned: new Set(),
40
41
  ...over,
41
42
  };
42
43
  }
@@ -15,6 +15,7 @@ import { RemoteCanvasSource } from '../remote-canvas-source.js';
15
15
  import { closeNode } from '../../runtime/close.js';
16
16
  import { setupTerminal, restoreTerminal, getTerminalSize, parseKeypress, } from '../../tui/terminal.js';
17
17
  import { buildTree, flatten, pruneNode, TABS } from './model.js';
18
+ import { loadPins, savePins } from './pins.js';
18
19
  import { createCoalescedRebuilder } from './rebuild-coalescer.js';
19
20
  // Sort cycle for the `s` key. Starts on the default `attention` ordering; one `s`
20
21
  // press restores the structural `tree` view, then relevance/recency, then back.
@@ -23,10 +24,10 @@ import { renderFrame, detectColorCaps, headerHeight, PREVIEW_HEIGHT } from './re
23
24
  /** Viewport (body) height = total rows minus the header renderFrame draws (see
24
25
  * render.ts headerHeight), the footer, and the preview panel when shown. Kept
25
26
  * in lockstep with render.ts via the shared headerHeight/PREVIEW_HEIGHT. */
26
- function viewportHeight(rowsTotal, search, previewOn) {
27
+ function viewportHeight(rowsTotal, inputLine, previewOn) {
27
28
  const rows = Math.max(8, rowsTotal);
28
29
  const previewH = previewOn ? PREVIEW_HEIGHT : 0;
29
- return Math.max(1, rows - headerHeight(search) - 1 /* footer */ - previewH);
30
+ return Math.max(1, rows - headerHeight(inputLine) - 1 /* footer */ - previewH);
30
31
  }
31
32
  /** Wires a RemoteEventStream's `nodes`/`inbox` events through the coalescer
32
33
  * (`rebuild-coalescer.ts`) so a burst of SSE events collapses to ONE rebuild
@@ -103,6 +104,14 @@ export async function runBrowse(opts = {}) {
103
104
  preview: true, // default ON (decision)
104
105
  residentsOnly: true, // default ON: hide one-shot workers (decision)
105
106
  pendingClose: null,
107
+ pinned: loadPins(),
108
+ help: false,
109
+ rename: false,
110
+ renameBuf: '',
111
+ renameTarget: null,
112
+ message: false,
113
+ messageBuf: '',
114
+ messageTarget: null,
106
115
  };
107
116
  let visible = [];
108
117
  // The DashboardRow ref the tree holds for a visible id. Enrichment mutates this
@@ -193,6 +202,7 @@ export async function runBrowse(opts = {}) {
193
202
  cwdScope: state.cwdScope,
194
203
  residentsOnly: state.residentsOnly,
195
204
  sort: state.sort,
205
+ pinned: state.pinned,
196
206
  });
197
207
  if (keepId !== undefined) {
198
208
  const idx = visible.findIndex((v) => v.id === keepId);
@@ -207,7 +217,8 @@ export async function runBrowse(opts = {}) {
207
217
  const flush = async () => {
208
218
  const size = getTerminalSize();
209
219
  const previewOn = state.preview && visible.length > 0;
210
- const viewport = viewportHeight(size.rows, state.search, previewOn);
220
+ const inputMode = state.search || state.rename || state.message;
221
+ const viewport = viewportHeight(size.rows, inputMode, previewOn);
211
222
  // Keep the cursor inside the viewport window.
212
223
  if (state.cursor < state.scrollOffset)
213
224
  state.scrollOffset = state.cursor;
@@ -238,6 +249,9 @@ export async function runBrowse(opts = {}) {
238
249
  query: state.query, search: state.search, totalNodes,
239
250
  cwdScope: state.cwdScope, sort: state.sort, preview: state.preview,
240
251
  residentsOnly: state.residentsOnly, pendingClose: state.pendingClose,
252
+ pinned: state.pinned, help: state.help,
253
+ rename: state.rename, renameBuf: state.renameBuf,
254
+ message: state.message, messageBuf: state.messageBuf,
241
255
  readOnly: isRemote,
242
256
  }, size, caps);
243
257
  process.stdout.write(frame);
@@ -419,6 +433,102 @@ export async function runBrowse(opts = {}) {
419
433
  scheduleSearchRepaint();
420
434
  }
421
435
  };
436
+ // Recenter the cursor on the launching node (CRTR_NODE_ID), expanding its
437
+ // ancestors so it is actually visible. No-op outside a node or when it's filtered
438
+ // out of the current tab/scope. Same move the boot performs, on demand (`.`).
439
+ const recenterOnSelf = () => {
440
+ const selfId = process.env['CRTR_NODE_ID'];
441
+ if (selfId === undefined || selfId === '' || !tree.nodes.has(selfId))
442
+ return;
443
+ for (let p = tree.nodes.get(selfId)?.parentId ?? null; p !== null; p = tree.nodes.get(p)?.parentId ?? null) {
444
+ state.collapsed.delete(p);
445
+ }
446
+ recompute(selfId);
447
+ };
448
+ // Toggle the pin on a node: mutate the in-memory set, persist best-effort, and
449
+ // recompute so it floats (flat sorts) / (dis)appears from the Pinned tab.
450
+ const togglePin = (id) => {
451
+ if (state.pinned.has(id))
452
+ state.pinned.delete(id);
453
+ else
454
+ state.pinned.add(id);
455
+ savePins(state.pinned);
456
+ recompute(id);
457
+ };
458
+ // Commit an inline rename: `crtr node config <id> --name <name>` (also renames a
459
+ // live viewer window), then reflect the new name into the snapshot in place.
460
+ const commitRename = () => {
461
+ const id = state.renameTarget;
462
+ const name = state.renameBuf.trim();
463
+ state.rename = false;
464
+ if (id !== null && name !== '') {
465
+ try {
466
+ execFileSync('crtr', ['node', 'config', id, '--name', name], { stdio: 'ignore' });
467
+ }
468
+ catch {
469
+ /* best-effort — a rename failure never wedges the picker */
470
+ }
471
+ const n = tree.nodes.get(id);
472
+ if (n !== undefined)
473
+ n.row.name = name;
474
+ }
475
+ recompute(state.renameTarget ?? undefined);
476
+ scheduleFlush();
477
+ };
478
+ // Commit an inline message: `crtr node msg --to <id> <body>` (immediate inbox
479
+ // delivery, best-effort revive if dormant). Fire-and-forget — browse stays put.
480
+ const commitMessage = () => {
481
+ const id = state.messageTarget;
482
+ const body = state.messageBuf.trim();
483
+ state.message = false;
484
+ if (id !== null && body !== '') {
485
+ try {
486
+ execFileSync('crtr', ['node', 'msg', '--to', id, body], { stdio: 'ignore' });
487
+ }
488
+ catch {
489
+ /* best-effort — delivery failure never wedges the picker */
490
+ }
491
+ }
492
+ scheduleFlush();
493
+ };
494
+ // Shared text-input keystroke handling for the rename/message sub-modes: Esc
495
+ // cancels, Enter commits, Backspace deletes, Ctrl+C quits, printable chars append.
496
+ const onKeyText = (input, key, get, set, commit, cancel) => {
497
+ if (key.escape) {
498
+ cancel();
499
+ return;
500
+ }
501
+ if (key.return) {
502
+ commit();
503
+ return;
504
+ }
505
+ if (key.backspace) {
506
+ set(get().slice(0, -1));
507
+ scheduleFlush();
508
+ return;
509
+ }
510
+ if (key.ctrl) {
511
+ if (input === 'c')
512
+ quit();
513
+ return;
514
+ }
515
+ const printable = [...input].filter((c) => c >= ' ').join('');
516
+ if (printable.length > 0) {
517
+ set(get() + printable);
518
+ scheduleFlush();
519
+ }
520
+ };
521
+ const onKeyRename = (input, key) => onKeyText(input, key, () => state.renameBuf, (s) => { state.renameBuf = s; }, commitRename, () => { state.rename = false; scheduleFlush(); });
522
+ const onKeyMessage = (input, key) => onKeyText(input, key, () => state.messageBuf, (s) => { state.messageBuf = s; }, commitMessage, () => { state.message = false; scheduleFlush(); });
523
+ // Help overlay: any key dismisses (Ctrl+C still quits outright).
524
+ const onKeyHelp = (input, key) => {
525
+ if (key.ctrl && input === 'c') {
526
+ quit();
527
+ return;
528
+ }
529
+ state.help = false;
530
+ scheduleFlush();
531
+ };
422
532
  const onKeyNav = (input, key) => {
423
533
  // Ctrl-combos first: only Ctrl+C is meaningful (quit); swallow the rest so
424
534
  // Ctrl+L / Ctrl+J / Ctrl+Q etc. don't masquerade as l/j/q commands.
@@ -519,7 +629,7 @@ export async function runBrowse(opts = {}) {
519
629
  scheduleFlush();
520
630
  return;
521
631
  }
522
- if (input >= '1' && input <= '4') {
632
+ if (input >= '1' && input <= '5') {
523
633
  const idx = Number(input) - 1;
524
634
  if (idx < TABS.length) {
525
635
  state.tab = TABS[idx];
@@ -569,6 +679,49 @@ export async function runBrowse(opts = {}) {
569
679
  scheduleFlush();
570
680
  return;
571
681
  }
682
+ // Help overlay.
683
+ if (input === '?') {
684
+ state.help = true;
685
+ scheduleFlush();
686
+ return;
687
+ }
688
+ // Pin / unpin (local, view-only — works against a remote canvas too).
689
+ if (input === 'f') {
690
+ if (row !== undefined)
691
+ togglePin(row.id);
692
+ scheduleFlush();
693
+ return;
694
+ }
695
+ // Recenter on the launching node.
696
+ if (input === '.') {
697
+ recenterOnSelf();
698
+ scheduleFlush();
699
+ return;
700
+ }
701
+ // Rename the node inline (mutation — no-op on a remote canvas).
702
+ if (input === 'R') {
703
+ if (isRemote)
704
+ return;
705
+ if (row !== undefined) {
706
+ state.rename = true;
707
+ state.renameTarget = row.id;
708
+ state.renameBuf = rowOf(row.id)?.name ?? '';
709
+ }
710
+ scheduleFlush();
711
+ return;
712
+ }
713
+ // Message the node without resuming it (mutation — no-op on a remote canvas).
714
+ if (input === 'm') {
715
+ if (isRemote)
716
+ return;
717
+ if (row !== undefined) {
718
+ state.message = true;
719
+ state.messageTarget = row.id;
720
+ state.messageBuf = '';
721
+ }
722
+ scheduleFlush();
723
+ return;
724
+ }
572
725
  // Search. Starting a search ranks by relevance (decision) so the best prompt/
573
726
  // name match floats to the top as you type.
574
727
  if (input === '/') {
@@ -664,8 +817,14 @@ export async function runBrowse(opts = {}) {
664
817
  const onData = (data) => {
665
818
  try {
666
819
  const { input, key } = parseKeypress(data);
667
- if (state.search)
820
+ if (state.help)
821
+ onKeyHelp(input, key);
822
+ else if (state.search)
668
823
  onKeySearch(input, key);
824
+ else if (state.rename)
825
+ onKeyRename(input, key);
826
+ else if (state.message)
827
+ onKeyMessage(input, key);
669
828
  else
670
829
  onKeyNav(input, key);
671
830
  }
@@ -1,6 +1,6 @@
1
1
  import type { DashboardRow } from '../render.js';
2
2
  import type { NodeStatus } from '../types.js';
3
- export type Tab = 'All' | 'Live' | 'Dormant' | 'Flagged';
3
+ export type Tab = 'All' | 'Live' | 'Dormant' | 'Attention' | 'Pinned';
4
4
  export declare const TABS: readonly Tab[];
5
5
  /** How the visible rows are ordered.
6
6
  * tree — spanning-tree order, ancestors shown for context (the default).
@@ -11,11 +11,12 @@ export declare const TABS: readonly Tab[];
11
11
  export type SortMode = 'tree' | 'attention' | 'relevance' | 'recency';
12
12
  export declare const SORTS: readonly SortMode[];
13
13
  /** Does a node belong to this tab's slice?
14
- * All — every node.
15
- * Live — active | idle.
16
- * Dormant — done | dead | canceled.
17
- * Flagged has > 0 pending human asks. */
18
- export declare function tabPredicate(tab: Tab, row: DashboardRow): boolean;
14
+ * All — every node.
15
+ * Live — active | idle.
16
+ * Dormant — done | dead | canceled.
17
+ * Attention needs a human: has pending asks OR is parked on a fault.
18
+ * Pinned — user-starred (a local, view-only preference; `isPinned`). */
19
+ export declare function tabPredicate(tab: Tab, row: DashboardRow, isPinned?: boolean): boolean;
19
20
  export interface TreeNode {
20
21
  row: DashboardRow;
21
22
  depth: number;
@@ -138,6 +139,9 @@ export interface FlattenOpts {
138
139
  /** Ordering. `tree` keeps the spanning tree + ancestor context; `relevance` /
139
140
  * `recency` produce a FLAT ranked list of directly-matched rows. */
140
141
  sort?: SortMode;
142
+ /** User-starred node ids (local, view-only). Gates the Pinned tab, and floats
143
+ * pinned rows to the top of every FLAT sort mode. undefined = none pinned. */
144
+ pinned?: Set<string>;
141
145
  }
142
146
  /** Is this row inside the active cwd scope? No scope (null/undefined) = All dirs. */
143
147
  export declare function cwdMatch(scope: string | null | undefined, row: DashboardRow): boolean;
@@ -4,19 +4,21 @@
4
4
  // it is exhaustively unit-testable. The app layer (app.ts) wires the canvas data
5
5
  // access (dashboardRowsAll / listNodes / subscriptionsOf) into buildTree, then
6
6
  // drives flatten() on each keystroke.
7
- export const TABS = ['All', 'Live', 'Dormant', 'Flagged'];
7
+ export const TABS = ['All', 'Live', 'Dormant', 'Attention', 'Pinned'];
8
8
  export const SORTS = ['tree', 'attention', 'relevance', 'recency'];
9
9
  /** Does a node belong to this tab's slice?
10
- * All — every node.
11
- * Live — active | idle.
12
- * Dormant — done | dead | canceled.
13
- * Flagged has > 0 pending human asks. */
14
- export function tabPredicate(tab, row) {
10
+ * All — every node.
11
+ * Live — active | idle.
12
+ * Dormant — done | dead | canceled.
13
+ * Attention needs a human: has pending asks OR is parked on a fault.
14
+ * Pinned — user-starred (a local, view-only preference; `isPinned`). */
15
+ export function tabPredicate(tab, row, isPinned = false) {
15
16
  switch (tab) {
16
17
  case 'All': return true;
17
18
  case 'Live': return row.status === 'active' || row.status === 'idle';
18
19
  case 'Dormant': return row.status === 'done' || row.status === 'dead' || row.status === 'canceled';
19
- case 'Flagged': return row.asks > 0;
20
+ case 'Attention': return row.asks > 0 || row.hanging != null;
21
+ case 'Pinned': return isPinned;
20
22
  }
21
23
  }
22
24
  /** Sort rank for roots/stragglers — live first (active, then idle), dormant
@@ -383,7 +385,8 @@ export function lifecycleMatch(residentsOnly, row) {
383
385
  * always reachable.
384
386
  */
385
387
  export function flatten(tree, opts) {
386
- const { collapsed, tab, query, cwdScope, residentsOnly, sort = 'tree' } = opts;
388
+ const { collapsed, tab, query, cwdScope, residentsOnly, sort = 'tree', pinned } = opts;
389
+ const isPinned = (id) => pinned?.has(id) === true;
387
390
  // 1. Directly-matched nodes: tab predicate AND cwd scope AND lifecycle AND query.
388
391
  // `matched` honors the residents-only filter (drives top-level rows, ancestor
389
392
  // context, force-expand, and the flat search results below). `matchedAll` is
@@ -394,7 +397,7 @@ export function flatten(tree, opts) {
394
397
  const matched = new Set();
395
398
  const matchedAll = new Set();
396
399
  for (const [id, node] of tree.nodes) {
397
- if (tabPredicate(tab, node.row) && cwdMatch(cwdScope, node.row) && queryMatch(query, node.row)) {
400
+ if (tabPredicate(tab, node.row, isPinned(id)) && cwdMatch(cwdScope, node.row) && queryMatch(query, node.row)) {
398
401
  matchedAll.add(id);
399
402
  if (lifecycleMatch(residentsOnly, node.row))
400
403
  matched.add(id);
@@ -407,21 +410,26 @@ export function flatten(tree, opts) {
407
410
  const ids = [...matched];
408
411
  const createdOf = (id) => tree.nodes.get(id)?.row.created ?? '';
409
412
  const byRecency = (a, b) => createdOf(b).localeCompare(createdOf(a));
413
+ // Pinned rows float above everything in every flat mode — "keep this at the
414
+ // top of my list" is the whole point of a pin. Ordering WITHIN each pin tier
415
+ // still follows the active sort.
416
+ const pinRank = (id) => (isPinned(id) ? 0 : 1);
410
417
  if (sort === 'attention') {
411
418
  // Tiered (attached/streaming/live first), newest-message-first within tier.
412
419
  const rowOf = (id) => tree.nodes.get(id).row;
413
- ids.sort((a, b) => (attentionTier(rowOf(a)) - attentionTier(rowOf(b))) ||
420
+ ids.sort((a, b) => (pinRank(a) - pinRank(b)) ||
421
+ (attentionTier(rowOf(a)) - attentionTier(rowOf(b))) ||
414
422
  (attentionMtime(rowOf(b)) - attentionMtime(rowOf(a))) ||
415
423
  byRecency(a, b));
416
424
  }
417
425
  else if (sort === 'recency' || query === '') {
418
- ids.sort(byRecency);
426
+ ids.sort((a, b) => (pinRank(a) - pinRank(b)) || byRecency(a, b));
419
427
  }
420
428
  else {
421
429
  const score = new Map();
422
430
  for (const id of ids)
423
431
  score.set(id, scoreRow(query, tree.nodes.get(id).row));
424
- ids.sort((a, b) => (score.get(b) - score.get(a)) || byRecency(a, b));
432
+ ids.sort((a, b) => (pinRank(a) - pinRank(b)) || (score.get(b) - score.get(a)) || byRecency(a, b));
425
433
  }
426
434
  return ids.map((id) => ({ id, depth: 0, hasChildren: false, collapsed: false, matched: true }));
427
435
  }
@@ -0,0 +1,4 @@
1
+ /** Load the pinned-id set. Missing/garbage file → empty set (never throws). */
2
+ export declare function loadPins(): Set<string>;
3
+ /** Persist the pinned-id set. Best-effort — a write failure never wedges the TUI. */
4
+ export declare function savePins(pins: Set<string>): void;
@@ -0,0 +1,29 @@
1
+ // pins.ts — the durable per-user "pinned" set for the canvas browser.
2
+ //
3
+ // A pin is a LOCAL, view-only preference, NOT a canvas property: starring a node
4
+ // keeps it at the top of YOUR navigator across sessions and machines, and it works
5
+ // against a remote canvas too (you are pinning in your own view, mutating nothing
6
+ // on the canvas). Stored as a flat id list at ~/.crouter/browse-pins.json so every
7
+ // `canvas browse` invocation shares it regardless of cwd/scope. Read once at boot,
8
+ // mutated in memory, written best-effort on each toggle.
9
+ import { join } from 'node:path';
10
+ import { userScopeRoot } from '../../scope.js';
11
+ import { readJsonIfExists, atomicWriteJson } from '../../fs-utils.js';
12
+ const PINS_FILE = 'browse-pins.json';
13
+ function pinsPath() {
14
+ return join(userScopeRoot(), PINS_FILE);
15
+ }
16
+ /** Load the pinned-id set. Missing/garbage file → empty set (never throws). */
17
+ export function loadPins() {
18
+ const raw = readJsonIfExists(pinsPath());
19
+ return new Set(Array.isArray(raw) ? raw.filter((x) => typeof x === 'string') : []);
20
+ }
21
+ /** Persist the pinned-id set. Best-effort — a write failure never wedges the TUI. */
22
+ export function savePins(pins) {
23
+ try {
24
+ atomicWriteJson(pinsPath(), [...pins]);
25
+ }
26
+ catch {
27
+ /* best-effort: a pin that doesn't survive the session is a minor loss */
28
+ }
29
+ }