@crouton-kit/crouter 0.3.81 → 0.3.83

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 (84) 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 +7 -3
  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 +16 -44
  6. package/dist/clients/attach/attach-cmd.d.ts +1 -2
  7. package/dist/clients/attach/attach-cmd.js +960 -961
  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 +51 -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 +13 -17
  22. package/dist/commands/node.js +4 -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 +98 -0
  26. package/dist/commands/sys/setup-core.d.ts +22 -0
  27. package/dist/commands/sys/setup-core.js +62 -0
  28. package/dist/commands/sys/setup.js +8 -1
  29. package/dist/core/__tests__/human-deliver-e2e.test.js +95 -0
  30. package/dist/core/__tests__/human-deliver.test.js +332 -0
  31. package/dist/core/__tests__/human-node-not-supervised.test.js +5 -4
  32. package/dist/core/__tests__/pid-identity-match.test.js +83 -0
  33. package/dist/core/canvas/__tests__/attention.test.d.ts +1 -0
  34. package/dist/core/canvas/__tests__/attention.test.js +126 -0
  35. package/dist/core/canvas/__tests__/remote-canvas-source.test.js +4 -4
  36. package/dist/core/canvas/__tests__/render-remote.test.js +1 -1
  37. package/dist/core/canvas/attention.d.ts +16 -14
  38. package/dist/core/canvas/attention.js +34 -29
  39. package/dist/core/canvas/boot.d.ts +1 -0
  40. package/dist/core/canvas/boot.js +1 -1
  41. package/dist/core/canvas/browse/app.js +3 -3
  42. package/dist/core/canvas/pid.d.ts +43 -17
  43. package/dist/core/canvas/pid.js +104 -35
  44. package/dist/core/canvas/remote-canvas-source.d.ts +1 -1
  45. package/dist/core/canvas/remote-canvas-source.js +4 -2
  46. package/dist/core/canvas/render.d.ts +5 -5
  47. package/dist/core/canvas/render.js +14 -14
  48. package/dist/core/canvas/source.d.ts +2 -2
  49. package/dist/core/canvas/source.js +3 -3
  50. package/dist/core/keybindings/__tests__/resolve.test.js +1 -1
  51. package/dist/core/keybindings/catalog.d.ts +2 -2
  52. package/dist/core/keybindings/catalog.js +3 -2
  53. package/dist/core/preview-registry.js +22 -29
  54. package/dist/core/profiles/select.js +4 -4
  55. package/dist/core/runtime/front-door.js +19 -6
  56. package/dist/core/runtime/package-health.d.ts +13 -0
  57. package/dist/core/runtime/package-health.js +18 -0
  58. package/dist/core/runtime/pi-vendored.js +15 -1
  59. package/dist/core/runtime/placement.d.ts +1 -10
  60. package/dist/core/runtime/placement.js +43 -26
  61. package/dist/core/runtime/resume.js +72 -4
  62. package/dist/core/runtime/session-list-cache.d.ts +22 -2
  63. package/dist/core/runtime/session-list-cache.js +92 -24
  64. package/dist/core/runtime/spawn.js +9 -0
  65. package/dist/core/spawn.d.ts +0 -59
  66. package/dist/core/spawn.js +5 -163
  67. package/dist/daemon/crtrd.js +7 -6
  68. package/dist/index.d.ts +2 -2
  69. package/dist/index.js +1 -1
  70. package/dist/web-client/assets/index-DZdKS-FB.js +78 -0
  71. package/dist/web-client/index.html +1 -1
  72. package/dist/web-client/sw.js +1 -1
  73. package/package.json +2 -2
  74. package/dist/core/__tests__/full/dead-pane-regression.test.js +0 -154
  75. package/dist/core/__tests__/full/human-new-window-regression.test.js +0 -104
  76. package/dist/core/__tests__/full/review-render-pane-regression.test.js +0 -133
  77. package/dist/core/__tests__/human-stranded-deliver.test.js +0 -145
  78. package/dist/core/__tests__/human-surface-target.test.js +0 -98
  79. package/dist/web-client/assets/index--md2ylfi.js +0 -78
  80. /package/dist/{core/__tests__/full/dead-pane-regression.test.d.ts → clients/attach/__tests__/chat-view-snapshot-ordering.test.d.ts} +0 -0
  81. /package/dist/{core/__tests__/full/human-new-window-regression.test.d.ts → commands/sys/__tests__/setup-front-door.test.d.ts} +0 -0
  82. /package/dist/core/__tests__/{full/review-render-pane-regression.test.d.ts → human-deliver-e2e.test.d.ts} +0 -0
  83. /package/dist/core/__tests__/{human-stranded-deliver.test.d.ts → human-deliver.test.d.ts} +0 -0
  84. /package/dist/core/__tests__/{human-surface-target.test.d.ts → pid-identity-match.test.d.ts} +0 -0
@@ -4,6 +4,19 @@ export interface MissingPackage {
4
4
  /** Absolute path it resolved to (which does not exist). */
5
5
  resolved: string;
6
6
  }
7
+ /**
8
+ * The pi agent config dir (`~/.pi/agent`, or `$PI_CODING_AGENT_DIR`) — a local
9
+ * reimplementation of pi's `getAgentDir()`. Reimplemented (not imported) ON
10
+ * PURPOSE: `getAgentDir` lives in `@earendil-works/pi-coding-agent`, and
11
+ * importing that package pulls in the ENTIRE ~360ms pi engine module graph. The
12
+ * front door calls this on every bare-`crtr` boot BEFORE spawning the broker, so
13
+ * a static/dynamic pi import here adds ~316ms of serialized dead-time in the
14
+ * foreground before the broker (which loads pi itself) even starts. This mirrors
15
+ * pi's contract exactly: `$PI_CODING_AGENT_DIR` (tilde-expanded) else
16
+ * `~/.pi/agent`. Same precedent as `bearings.ts` (keep the pi engine out of the
17
+ * hot/foreground path). If pi ever rebrands its configDir/env, update here.
18
+ */
19
+ export declare function piAgentDir(homeDir?: string): string;
7
20
  /**
8
21
  * Return every local-path package in `<agentDir>/settings.json` whose resolved
9
22
  * path is missing. Empty when settings is absent/unreadable or all paths exist.
@@ -21,6 +21,24 @@ function expandTilde(pathValue, homeDir) {
21
21
  return join(homeDir, pathValue.slice(2));
22
22
  return pathValue;
23
23
  }
24
+ /**
25
+ * The pi agent config dir (`~/.pi/agent`, or `$PI_CODING_AGENT_DIR`) — a local
26
+ * reimplementation of pi's `getAgentDir()`. Reimplemented (not imported) ON
27
+ * PURPOSE: `getAgentDir` lives in `@earendil-works/pi-coding-agent`, and
28
+ * importing that package pulls in the ENTIRE ~360ms pi engine module graph. The
29
+ * front door calls this on every bare-`crtr` boot BEFORE spawning the broker, so
30
+ * a static/dynamic pi import here adds ~316ms of serialized dead-time in the
31
+ * foreground before the broker (which loads pi itself) even starts. This mirrors
32
+ * pi's contract exactly: `$PI_CODING_AGENT_DIR` (tilde-expanded) else
33
+ * `~/.pi/agent`. Same precedent as `bearings.ts` (keep the pi engine out of the
34
+ * hot/foreground path). If pi ever rebrands its configDir/env, update here.
35
+ */
36
+ export function piAgentDir(homeDir = homedir()) {
37
+ const envDir = process.env['PI_CODING_AGENT_DIR'];
38
+ if (envDir !== undefined && envDir !== '')
39
+ return expandTilde(envDir, homeDir);
40
+ return join(homeDir, '.pi', 'agent');
41
+ }
24
42
  /** A `packages[]` entry is a LOCAL PATH (not an npm ref) — the only kind that
25
43
  * can go stale on a repo move. `npm:...` and bare registry names resolve
26
44
  * through node/npm, not the filesystem, so they are out of scope here. */
@@ -21,9 +21,23 @@ import { join } from 'node:path';
21
21
  * the sessions dir — same vendoring rationale as the slash-command list below. */
22
22
  export function piSessionsRoot() {
23
23
  const env = process.env['PI_CODING_AGENT_DIR'];
24
- const agentDir = env !== undefined && env !== '' ? env : join(homedir(), '.pi', 'agent');
24
+ const agentDir = env !== undefined && env !== '' ? expandTilde(env) : join(homedir(), '.pi', 'agent');
25
25
  return join(agentDir, 'sessions');
26
26
  }
27
+ /** Mirror pi's `expandTildePath` (= `normalizePath` with the default expandTilde
28
+ * on): a bare `~` is the home dir, and a leading `~/` (or `~\` on win32) joins
29
+ * the remainder under home. pi's `getAgentDir()` runs `PI_CODING_AGENT_DIR`
30
+ * through this, so the vendored root must too or `~/custom-pi` yields a broken
31
+ * relative path. Other forms (absolute, relative, `~user`) pass through as pi
32
+ * leaves them. */
33
+ function expandTilde(p) {
34
+ if (p === '~')
35
+ return homedir();
36
+ if (p.startsWith('~/') || (process.platform === 'win32' && p.startsWith('~\\'))) {
37
+ return join(homedir(), p.slice(2));
38
+ }
39
+ return p;
40
+ }
27
41
  /**
28
42
  * pi's builtin slash commands — vendored verbatim from pi `core/slash-commands.js`
29
43
  * `BUILTIN_SLASH_COMMANDS` (review C1). **21 entries at 0.78.1** (review n1 — NOT 23).
@@ -12,16 +12,6 @@ export declare function focusByPane(pane: string): FocusRow | null;
12
12
  export declare function focusedNodes(): Set<string>;
13
13
  /** Every live viewer row (GC'ing any whose pane has gone). */
14
14
  export declare function listFocuses(): FocusRow[];
15
- /** The on-screen viewer a human-in-the-loop prompt raised by `nodeId` should
16
- * surface into: the HIGHEST node of nodeId's graph that has a viewer — the
17
- * viewer closest to the graph root, i.e. the session/window the user is actually
18
- * watching this work in. Walks nodeId's spine to its root, enumerates the whole
19
- * tree root-first (`view` is BFS ⇒ shallowest first), and returns the viewer row
20
- * of the first node that has one. null when nothing in the graph is on screen —
21
- * the caller then surfaces in the user's attached pane. PURE (db reads only): no
22
- * tmux probe, so the pane may be stale; the caller liveness-checks before
23
- * targeting it. */
24
- export declare function graphSurfaceTarget(nodeId: string): FocusRow | null;
25
15
  /** Wait until a broker's view.sock accepts a connection. The launching caller
26
16
  * stays async, so the broker's real exit signal can fail fast the wait instead
27
17
  * of being masked by a zombie pid. Success proves more than file existence: it
@@ -84,6 +74,7 @@ export declare function openViewerWindow(nodeId: string, session: string, opts?:
84
74
  export declare function focus(nodeId: string, opts: {
85
75
  pane?: string;
86
76
  newPane?: boolean;
77
+ inPlace?: boolean;
87
78
  revive: Reviver;
88
79
  }): Promise<FocusResult>;
89
80
  /** Tear a node off its viewer (close/reset/cancel teardown). The broker engine
@@ -24,11 +24,11 @@
24
24
  // tmux-chrome.ts import tmux.ts") holds — every other module reaches the
25
25
  // driver verbs through here.
26
26
  import { createConnection } from 'node:net';
27
- import { getNode, deleteNode, listNodes, openFocusRow, closeFocusRow, getFocusByNode, getFocusByPane, getFocusById, listFocuses as listFocusRows, view, } from '../canvas/index.js';
27
+ import { getNode, fullName, deleteNode, listNodes, openFocusRow, closeFocusRow, getFocusByNode, getFocusByPane, getFocusById, listFocuses as listFocusRows, } from '../canvas/index.js';
28
28
  import { nodeHasAssistantMessage, isNodeStreaming } from '../canvas/render.js';
29
29
  import { headlessBrokerHost } from './host.js';
30
- import { paneExists, paneLocation, ensureSession, openNodeWindow, splitWindow, closePane, currentTmux, switchClient, selectWindow, selectLayout, selectPane, getPaneOption, } from './tmux.js';
31
- import { newNodeId, rootOfSpine } from './nodes.js';
30
+ import { paneExists, paneLocation, ensureSession, openNodeWindow, splitWindow, closePane, currentTmux, switchClient, selectWindow, selectLayout, selectPane, getPaneOption, setPaneOption, respawnPaneSync, windowOfPane, renameWindow, } from './tmux.js';
31
+ import { newNodeId } from './nodes.js';
32
32
  import { viewSocketPath } from '../canvas/paths.js';
33
33
  import { isPidAlive } from '../canvas/pid.js';
34
34
  // Placement is the sanctioned model-over-driver (§2.1): non-placement runtime /
@@ -61,8 +61,7 @@ installTmuxBindings as installMenuBinding, setPaneOption, getPaneOption, piComma
61
61
  //
62
62
  // GC is lazy on read: a row whose viewer pane no longer exists (the user closed
63
63
  // the window, the attach client died) is pruned the next time it is read, so the
64
- // registry self-heals without a sweeper. (graphSurfaceTarget stays pure — no
65
- // tmux probe — by design; its caller liveness-checks the pane it returns.)
64
+ // registry self-heals without a sweeper.
66
65
  // ---------------------------------------------------------------------------
67
66
  /** Prune a viewer row whose pane is gone; return it iff its pane is still live. */
68
67
  function liveOrPrune(f) {
@@ -100,27 +99,6 @@ export function listFocuses() {
100
99
  return live;
101
100
  }
102
101
  // ---------------------------------------------------------------------------
103
- // Graph → viewer routing (for surfacing human-in-the-loop prompts)
104
- // ---------------------------------------------------------------------------
105
- /** The on-screen viewer a human-in-the-loop prompt raised by `nodeId` should
106
- * surface into: the HIGHEST node of nodeId's graph that has a viewer — the
107
- * viewer closest to the graph root, i.e. the session/window the user is actually
108
- * watching this work in. Walks nodeId's spine to its root, enumerates the whole
109
- * tree root-first (`view` is BFS ⇒ shallowest first), and returns the viewer row
110
- * of the first node that has one. null when nothing in the graph is on screen —
111
- * the caller then surfaces in the user's attached pane. PURE (db reads only): no
112
- * tmux probe, so the pane may be stale; the caller liveness-checks before
113
- * targeting it. */
114
- export function graphSurfaceTarget(nodeId) {
115
- const root = rootOfSpine(nodeId);
116
- for (const id of [root, ...view(root)]) {
117
- const f = getFocusByNode(id);
118
- if (f !== null && f.pane !== null)
119
- return f;
120
- }
121
- return null;
122
- }
123
- // ---------------------------------------------------------------------------
124
102
  // view.sock readiness — the one shared broker-cold-start primitive
125
103
  // ---------------------------------------------------------------------------
126
104
  const BROKER_FOCUS_SOCKET_WAIT_MS = 30_000;
@@ -311,6 +289,45 @@ export async function focus(nodeId, opts) {
311
289
  // split pane's location — the caller's own session.
312
290
  const callerSession = paneLocation(callerPane)?.session ?? null;
313
291
  const fallbackSession = callerSession ?? '';
292
+ // (b0) --in-place → the CALLER pane BECOMES this node's viewer, replacing
293
+ // whatever it showed (the attach subagent scroll+swap: ↓ into the reports
294
+ // roster, Enter dives into that child right here, no new split). This
295
+ // re-execs the caller pane via `respawn-pane -k`, so it MUST be driven
296
+ // from OUTSIDE that pane's process tree (the viewer shells this through
297
+ // `tmux run-shell`, never a direct child) — else the respawn kills its own
298
+ // driver mid-call. Move the child's viewer here if it had one elsewhere
299
+ // (UNIQUE one-viewer-per-node) and drop the caller pane's prior viewer row
300
+ // (that node's broker keeps running headless, reconnectable by a later
301
+ // focus). The respawned `crtr surface attach` replays scrollback from the
302
+ // welcome snapshot and self-tags the pane `@crtr_node`.
303
+ if (opts.inPlace === true) {
304
+ const prior = focusOf(nodeId);
305
+ if (prior !== null) {
306
+ if (prior.pane !== null && prior.pane !== callerPane && paneExists(prior.pane))
307
+ closePane(prior.pane);
308
+ closeFocusRow(prior.focus_id);
309
+ }
310
+ const here = getFocusByPane(callerPane);
311
+ if (here !== null && here.node_id !== nodeId)
312
+ closeFocusRow(here.focus_id);
313
+ // Clear the stale tag before respawn so it never names the wrong node during
314
+ // the gap before the fresh attach re-tags on connect.
315
+ try {
316
+ setPaneOption(callerPane, '@crtr_node', '');
317
+ }
318
+ catch { /* best-effort */ }
319
+ const window = windowOfPane(callerPane);
320
+ // Continuity: the fresh viewer re-enters the inline roster on the node just
321
+ // swapped to (self), so the user keeps scrolling from where they were rather
322
+ // than being dropped back into the text box.
323
+ const ok = respawnPaneSync({ pane: callerPane, cwd: meta.cwd, env: { ...viewerSplitEnv(), CRTR_ATTACH_ROSTER: '1' }, command: `crtr surface attach to ${nodeId}` });
324
+ if (ok) {
325
+ if (window !== null)
326
+ renameWindow(window, fullName(meta));
327
+ registerViewerFocus(nodeId, callerPane, callerSession, window);
328
+ }
329
+ return { focused: ok, session: callerSession, inPlace: true, revived };
330
+ }
314
331
  // (b) --new-pane → always a fresh viewer beside the caller. Drop any prior
315
332
  // viewer this node holds elsewhere (UNIQUE(node_id): one viewer per node).
316
333
  if (opts.newPane === true) {
@@ -15,7 +15,9 @@
15
15
  import { spawn } from 'node:child_process';
16
16
  import { emitKeypressEvents } from 'node:readline';
17
17
  import { getNode, listResumableRoots } from '../canvas/index.js';
18
+ import { navLabel } from '../canvas/nav-model.js';
18
19
  import { isPidAlive } from '../canvas/pid.js';
20
+ import { readGoal } from './kickoff.js';
19
21
  import { reviveNode } from './revive.js';
20
22
  import { installTmuxBindings } from './tmux-chrome.js';
21
23
  import { registerViewerFocus, waitForBrokerViewSocket, viewerSplitEnv, currentTmux, inTmux, } from './placement.js';
@@ -109,17 +111,81 @@ async function attachInline(nodeId, cwd) {
109
111
  const HILITE = accent('\u25b8');
110
112
  const cursorUp = (n) => (n > 0 ? `\u001b[${n}A\r` : '');
111
113
  const CLEAR_DOWN = '\u001b[0J';
114
+ const ANSI_RE = /\u001b\[[0-9;]*m/g;
115
+ /** Terminal rows a just-written block occupies — counts each '\n' AND the extra
116
+ * rows a long line WRAPS into at the current terminal width (ANSI color codes
117
+ * stripped so they don't inflate the width). A plain newline count undershoots
118
+ * in a narrow pane, so redraws stack instead of clearing. Mirrors the
119
+ * profile-menu blockRows. */
120
+ function blockRows(block) {
121
+ const cols = process.stdout.columns ?? 80;
122
+ const segs = block.split('\n');
123
+ const newlines = segs.length - 1; // block ends with '\n' → last seg is ''
124
+ let extra = 0;
125
+ for (let i = 0; i < newlines; i++) {
126
+ const w = segs[i].replace(ANSI_RE, '').length;
127
+ if (cols > 0 && w > cols)
128
+ extra += Math.ceil(w / cols) - 1;
129
+ }
130
+ return newlines + extra;
131
+ }
132
+ /** Truncate to a visible-column budget, preserving ANSI escapes (they cost no
133
+ * width). Appends '…' when it cuts. Keeps a styled row on one terminal line so
134
+ * nothing wraps. */
135
+ function fitWidth(s, width) {
136
+ if (width <= 0)
137
+ return s;
138
+ let out = '';
139
+ let vis = 0;
140
+ for (let i = 0; i < s.length;) {
141
+ if (s[i] === '\u001b') {
142
+ const m = /^\u001b\[[0-9;]*m/.exec(s.slice(i));
143
+ if (m !== null) {
144
+ out += m[0];
145
+ i += m[0].length;
146
+ continue;
147
+ }
148
+ }
149
+ if (vis >= width - 1 && s.replace(ANSI_RE, '').length > width) {
150
+ out += '\u2026';
151
+ break;
152
+ }
153
+ out += s[i];
154
+ vis++;
155
+ i++;
156
+ }
157
+ return out;
158
+ }
112
159
  function label(r) {
113
160
  // Liveness is the broker pid, not the row status: a dormant resident root
114
161
  // keeps status='active' while its engine is down.
115
162
  const live = isPidAlive(r.pi_pid ?? null) ? accent('\u25cf live') : dim('\u25cb dormant');
116
- const name = r.name && r.name !== r.kind ? r.name : `${r.kind} root`;
163
+ // The SAME handle the Alt-G graph shows: navLabel = name description → kind.
164
+ // getNode carries `description` (NodeRow from the list query does not).
165
+ const node = getNode(r.node_id);
166
+ let name = navLabel(node, r.node_id);
167
+ // navLabel falls back to the bare kind for a never-named root — useless in a
168
+ // roster where every root is 'general'. Rescue it with the goal's first line
169
+ // (the spawning prompt), so unnamed convos are still tellable apart.
170
+ if (node !== null && name === node.kind) {
171
+ const goal = firstLine(readGoal(r.node_id));
172
+ if (goal !== '')
173
+ name = goal;
174
+ }
117
175
  return `${name} ${live}`;
118
176
  }
177
+ /** First non-empty line of a goal body, trimmed and length-capped. */
178
+ function firstLine(text) {
179
+ if (text === null)
180
+ return '';
181
+ const line = text.split('\n').map((l) => l.trim()).find((l) => l !== '') ?? '';
182
+ return line.length > 72 ? line.slice(0, 71) + '\u2026' : line;
183
+ }
119
184
  function detail(r, cwd) {
120
185
  const when = ago(r.created);
121
186
  const where = r.cwd === cwd ? '' : ` ${r.cwd}`;
122
- return `${r.node_id} ${when}${where}`;
187
+ // Short id keeps the row on one line; the full id is rarely needed to pick.
188
+ return `${when}${where} ${r.node_id.slice(0, 8)}`;
123
189
  }
124
190
  function ago(iso) {
125
191
  const ms = Date.now() - new Date(iso).getTime();
@@ -147,11 +213,13 @@ function pickRoot(roots, cwd) {
147
213
  let sel = 0;
148
214
  let prevLines = 0;
149
215
  const render = () => {
216
+ const cols = process.stdout.columns ?? 80;
150
217
  let s = `\n ${bold(title)}\n ${dim(subtitle)}\n\n`;
151
218
  roots.forEach((r, i) => {
152
219
  const mark = i === sel ? HILITE : ' ';
153
220
  const slot = i < 9 ? key(`${i + 1}`) : ' ';
154
- s += ` ${mark} ${slot} ${label(r)} ${dim(detail(r, cwd))}\n`;
221
+ const row = ` ${mark} ${slot} ${label(r)} ${dim(detail(r, cwd))}`;
222
+ s += fitWidth(row, cols) + '\n';
155
223
  });
156
224
  s += `\n ${dim('↑/↓ move enter resume q quit')}\n`;
157
225
  return s;
@@ -159,7 +227,7 @@ function pickRoot(roots, cwd) {
159
227
  const draw = () => {
160
228
  const block = render();
161
229
  out.write((prevLines > 0 ? cursorUp(prevLines) + CLEAR_DOWN : '') + block);
162
- prevLines = block.split('\n').length - 1;
230
+ prevLines = blockRows(block);
163
231
  };
164
232
  emitKeypressEvents(input);
165
233
  const wasRaw = input.isRaw ?? false;
@@ -7,7 +7,13 @@ export declare function compactSessionSearchText(text: string): string;
7
7
  export declare class SessionListCache {
8
8
  private readonly cacheFile;
9
9
  private cache;
10
- private loaded;
10
+ /** In-flight cwd scans keyed by normalized session dir, so an immediate
11
+ * /resume that races the background prewarm (or a second identical open)
12
+ * shares the one running jsonlFiles/stat/build pass instead of queueing a
13
+ * redundant one behind it. */
14
+ private inflightDir;
15
+ private loadPromise;
16
+ private buildChain;
11
17
  private persisting;
12
18
  private persistAgain;
13
19
  private persistTimer;
@@ -18,12 +24,26 @@ export declare class SessionListCache {
18
24
  * broker loop — pre-warming beats worker threads here. Errors are swallowed
19
25
  * by the same paths that guard a normal open. */
20
26
  prewarm(sessionDir: string): void;
21
- /** Sessions for one cwd's session dir (the picker's default `cwd` scope). */
27
+ /** Sessions for one cwd's session dir (the picker's default `cwd` scope).
28
+ * Single-flight: an identical concurrent cwd open (prewarm ⇄ immediate
29
+ * /resume) shares the in-flight promise rather than repeating the scan.
30
+ * Incompatible cwd/all builds still serialize through the build chain. */
22
31
  listDir(sessionDir: string): Promise<WireSessionInfo[]>;
32
+ private buildDir;
23
33
  /** Sessions across every cwd's dir under the sessions root (`all` scope). */
24
34
  listAll(sessionsRoot: string): Promise<WireSessionInfo[]>;
35
+ /** Serialize builds through a single chain: they mutate the shared cache map
36
+ * and, cold, scan the same large corpus. Identical cwd opens are already
37
+ * collapsed upstream by `listDir`'s in-flight sharing; chaining serializes
38
+ * the remaining incompatible builds (a cwd scan vs an `all` scan) so they
39
+ * never race map mutation or launch overlapping cold scans. */
25
40
  private build;
41
+ private buildLocked;
42
+ /** Load the persisted cache exactly once, single-flight. Returns a shared
43
+ * promise so a build that starts while the read is still in flight awaits the
44
+ * same load rather than proceeding against a not-yet-populated map. */
26
45
  private ensureLoaded;
46
+ private loadFromDisk;
27
47
  /** Coalesce persists onto a single trailing timer so the tens-of-MB
28
48
  * `JSON.stringify` never runs on the reply path and rapid opens write once. */
29
49
  private schedulePersist;
@@ -19,7 +19,7 @@
19
19
  import { createReadStream } from 'node:fs';
20
20
  import { readFile, readdir, rename, stat, unlink, writeFile } from 'node:fs/promises';
21
21
  import { createInterface } from 'node:readline';
22
- import { dirname, join } from 'node:path';
22
+ import { dirname, join, normalize } from 'node:path';
23
23
  /** Search-text bound: `allMessagesText` is searched on every query edit; pi's
24
24
  * unbounded value retains gigabytes in the viewer and turns key-repeat into GC
25
25
  * jitter. The opening and latest text keep search useful. */
@@ -41,18 +41,41 @@ const CACHE_SCHEMA_VERSION = 1;
41
41
  * are coalesced onto this trailing timer so rapid opens write at most once per
42
42
  * window, off the hot path. */
43
43
  const PERSIST_DEBOUNCE_MS = 2000;
44
+ function isFiniteNonNeg(v) {
45
+ return typeof v === 'number' && Number.isFinite(v) && v >= 0;
46
+ }
47
+ /** An optional field is valid iff absent/undefined or a string. */
48
+ function isOptionalString(v) {
49
+ return v === undefined || typeof v === 'string';
50
+ }
51
+ /** Validate the full persisted `WireSessionInfo` shape. A same-version file whose
52
+ * entry drifts from the current wire schema (any required field missing or
53
+ * mistyped, any optional field the wrong type, non-finite/negative metadata)
54
+ * must discard the whole cache and rebuild rather than feed a malformed entry to
55
+ * the viewer or the sort. */
56
+ function isValidWireInfo(v) {
57
+ if (typeof v !== 'object' || v === null)
58
+ return false;
59
+ const i = v;
60
+ return (typeof i['path'] === 'string' &&
61
+ typeof i['id'] === 'string' &&
62
+ typeof i['cwd'] === 'string' &&
63
+ typeof i['created'] === 'string' &&
64
+ typeof i['modified'] === 'string' &&
65
+ isFiniteNonNeg(i['messageCount']) &&
66
+ typeof i['firstMessage'] === 'string' &&
67
+ typeof i['allMessagesText'] === 'string' &&
68
+ isOptionalString(i['name']) &&
69
+ isOptionalString(i['parentSessionPath']));
70
+ }
44
71
  /** Shape-check a loaded entry — a well-formed-JSON file with the right version
45
- * but a stale entry shape (e.g. missing `info`) would otherwise push undefined
46
- * into the sort and throw. Any failure discards the whole file (rebuild). */
72
+ * but a stale entry shape would otherwise feed a malformed `WireSessionInfo` to
73
+ * the viewer/sort. Any failure discards the whole file (rebuild). */
47
74
  function isValidEntry(e) {
48
75
  if (typeof e !== 'object' || e === null)
49
76
  return false;
50
77
  const c = e;
51
- return (typeof c['mtimeMs'] === 'number' &&
52
- typeof c['size'] === 'number' &&
53
- typeof c['info'] === 'object' &&
54
- c['info'] !== null &&
55
- typeof c['info']['modified'] === 'string');
78
+ return isFiniteNonNeg(c['mtimeMs']) && isFiniteNonNeg(c['size']) && isValidWireInfo(c['info']);
56
79
  }
57
80
  // ---- faithful mirror of pi's private buildSessionInfo -----------------------
58
81
  function parseLine(line) {
@@ -172,7 +195,13 @@ async function buildWireSessionInfo(filePath) {
172
195
  export class SessionListCache {
173
196
  cacheFile;
174
197
  cache = new Map();
175
- loaded = false;
198
+ /** In-flight cwd scans keyed by normalized session dir, so an immediate
199
+ * /resume that races the background prewarm (or a second identical open)
200
+ * shares the one running jsonlFiles/stat/build pass instead of queueing a
201
+ * redundant one behind it. */
202
+ inflightDir = new Map();
203
+ loadPromise = null;
204
+ buildChain = Promise.resolve();
176
205
  persisting = false;
177
206
  persistAgain = false;
178
207
  persistTimer = null;
@@ -187,9 +216,32 @@ export class SessionListCache {
187
216
  prewarm(sessionDir) {
188
217
  void this.listDir(sessionDir);
189
218
  }
190
- /** Sessions for one cwd's session dir (the picker's default `cwd` scope). */
191
- async listDir(sessionDir) {
192
- return this.build(await jsonlFiles(sessionDir), new Set([sessionDir]));
219
+ /** Sessions for one cwd's session dir (the picker's default `cwd` scope).
220
+ * Single-flight: an identical concurrent cwd open (prewarm ⇄ immediate
221
+ * /resume) shares the in-flight promise rather than repeating the scan.
222
+ * Incompatible cwd/all builds still serialize through the build chain. */
223
+ listDir(sessionDir) {
224
+ const key = normalize(sessionDir);
225
+ const existing = this.inflightDir.get(key);
226
+ if (existing)
227
+ return existing;
228
+ const op = this.buildDir(key);
229
+ this.inflightDir.set(key, op);
230
+ // Attach both settlement handlers so the slot is cleared on success OR
231
+ // rejection, a rejected scan can never poison the next open, and a
232
+ // discarded prewarm rejection never becomes unhandled.
233
+ const clear = () => {
234
+ if (this.inflightDir.get(key) === op)
235
+ this.inflightDir.delete(key);
236
+ };
237
+ op.then(clear, clear);
238
+ return op;
239
+ }
240
+ buildDir(sessionDir) {
241
+ // Prune only keys directly in this cwd's dir. A cwd-scope open must not evict
242
+ // other cwds' cached entries: they weren't rescanned, so their absence from
243
+ // `files` says nothing about whether they still exist on disk.
244
+ return jsonlFiles(sessionDir).then((files) => this.build(files, (key) => dirname(key) === sessionDir));
193
245
  }
194
246
  /** Sessions across every cwd's dir under the sessions root (`all` scope). */
195
247
  async listAll(sessionsRoot) {
@@ -205,19 +257,30 @@ export class SessionListCache {
205
257
  const files = [];
206
258
  for (const dir of dirEntries)
207
259
  files.push(...(await jsonlFiles(dir)));
208
- return this.build(files, new Set(dirEntries));
260
+ // Prune every cached key that is a direct grandchild of the sessions root
261
+ // (`<root>/<cwd-dir>/<file>.jsonl`). Using the path boundary rather than the
262
+ // set of dirs that still exist means a wholly-deleted cwd dir — absent from
263
+ // this scan — also has its stale entries evicted, so the persisted file never
264
+ // accumulates bloat from removed cwds.
265
+ return this.build(files, (key) => dirname(dirname(key)) === sessionsRoot);
266
+ }
267
+ /** Serialize builds through a single chain: they mutate the shared cache map
268
+ * and, cold, scan the same large corpus. Identical cwd opens are already
269
+ * collapsed upstream by `listDir`'s in-flight sharing; chaining serializes
270
+ * the remaining incompatible builds (a cwd scan vs an `all` scan) so they
271
+ * never race map mutation or launch overlapping cold scans. */
272
+ build(files, shouldPrune) {
273
+ const run = this.buildChain.then(() => this.buildLocked(files, shouldPrune));
274
+ // Keep the chain alive past a rejected build without leaking the rejection.
275
+ this.buildChain = run.then(() => { }, () => { });
276
+ return run;
209
277
  }
210
- async build(files, scannedDirs) {
278
+ async buildLocked(files, shouldPrune) {
211
279
  await this.ensureLoaded();
212
280
  const present = new Set(files);
213
281
  let changed = false;
214
- // Prune ONLY keys under the dirs we actually scanned. A cwd-scope open must
215
- // not evict other cwds' cached entries: they weren't rescanned, so their
216
- // absence from `files` says nothing about whether they still exist on disk.
217
- // (Pruning the whole map here would self-destruct the global/`all` cache on
218
- // every cwd open.)
219
282
  for (const key of this.cache.keys()) {
220
- if (!present.has(key) && scannedDirs.has(dirname(key))) {
283
+ if (!present.has(key) && shouldPrune(key)) {
221
284
  this.cache.delete(key);
222
285
  changed = true;
223
286
  }
@@ -262,10 +325,15 @@ export class SessionListCache {
262
325
  result.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime());
263
326
  return result;
264
327
  }
265
- async ensureLoaded() {
266
- if (this.loaded)
267
- return;
268
- this.loaded = true;
328
+ /** Load the persisted cache exactly once, single-flight. Returns a shared
329
+ * promise so a build that starts while the read is still in flight awaits the
330
+ * same load rather than proceeding against a not-yet-populated map. */
331
+ ensureLoaded() {
332
+ if (!this.loadPromise)
333
+ this.loadPromise = this.loadFromDisk();
334
+ return this.loadPromise;
335
+ }
336
+ async loadFromDisk() {
269
337
  try {
270
338
  const raw = JSON.parse(await readFile(this.cacheFile, 'utf8'));
271
339
  // Version mismatch or shape drift → discard and rebuild. No lenient
@@ -24,6 +24,7 @@ import { appendSituationalContext } from './situational-context.js';
24
24
  import { hasRoadmap, seedRoadmap } from './roadmap.js';
25
25
  import { buildIdentityAssertion, buildWakeBearings } from './bearings.js';
26
26
  import { installTmuxBindings } from './tmux-chrome.js';
27
+ import { installInboxBinding } from '@crouton-kit/humanloop';
27
28
  import { getNode, fullName, recordPid } from '../canvas/index.js';
28
29
  import { registerViewerFocus, openViewerWindow, waitForBrokerViewSocket, viewerSplitEnv, windowOfPane, currentTmux, inTmux, focusWindow, } from './placement.js';
29
30
  import { transition } from './lifecycle.js';
@@ -102,6 +103,14 @@ export async function bootRoot(opts) {
102
103
  installTmuxBindings();
103
104
  }
104
105
  catch { /* best-effort */ }
106
+ // Reconcile humanloop's collision-safe M-i inbox popup binding on the same
107
+ // server so the inbox is reachable from any window. Crouter calls humanloop's
108
+ // installer — it never authors or parses its own tmux command; the installer
109
+ // is idempotent and leaves any pre-existing M-i binding untouched.
110
+ try {
111
+ installInboxBinding();
112
+ }
113
+ catch { /* best-effort */ }
105
114
  // This terminal becomes the root's attach VIEWER in the user's CURRENT session,
106
115
  // so its window stays where the user already is. The node row carries NO
107
116
  // presence — a broker node's pane/window/tmux_session stay NULL; the viewer
@@ -14,62 +14,3 @@ export declare function isInTmux(): boolean;
14
14
  * degrade to the inbox-drain follow-up. */
15
15
  export declare function tmuxServerReachable(): boolean;
16
16
  export declare function shellQuote(s: string): string;
17
- /** Count panes in a tmux window (0 outside tmux / on error). With `targetPane`,
18
- * counts the window THAT pane lives in (the placement decision must reflect the
19
- * window the new pane will actually open into, not the caller's backstage one);
20
- * without it, the caller's current window. */
21
- export declare function countPanesInWindow(targetPane?: string): number;
22
- /** Back-compat alias: panes in the caller's current window. */
23
- export declare function countPanesInCurrentWindow(): number;
24
- /** Does this tmux pane id still exist? `display-message` EXITS 0 with EMPTY
25
- * output on an unresolvable pane, so test for non-empty stdout, not just `.ok`.
26
- * False outside tmux / on error. */
27
- export declare function paneAlive(pane: string): boolean;
28
- /** Resolve a tmux pane id to its `session:window_index` — the target form
29
- * `new-window -t` accepts. tmux REJECTS a pane id for new-window ("can't
30
- * specify pane here"); only split-window -t takes a pane. null outside tmux /
31
- * on a bad pane id / on error / empty. */
32
- export declare function paneWindowTarget(pane: string): string | null;
33
- /** The active pane of the user's attached tmux client — where they are looking
34
- * right now. `list-clients` first attached client, then its current pane. Used
35
- * to surface a human prompt in the user's view when nothing in the asking
36
- * node's graph is focused. null outside tmux / no client / on error. */
37
- export declare function attachedClientPane(): string | null;
38
- /**
39
- * Schedule a kill-pane on the *current* tmux pane after `delaySeconds`, detached
40
- * so the caller can return normally before the pane dies. No-op outside tmux,
41
- * when TMUX_PANE is unset, or when delaySeconds <= 0.
42
- */
43
- export declare function scheduleKillCurrentPane(delaySeconds: number): boolean;
44
- export interface DetachOptions {
45
- /** Inner command to run in the new pane. */
46
- command: string;
47
- cwd: string;
48
- /** Optional id injected as the CRTR_JOB_ID env var in the pane. */
49
- jobId?: string;
50
- /** Where to open the new pane. */
51
- placement: 'split-h' | 'split-v' | 'new-window';
52
- /** Seconds before killing the originating pane so the caller can finish. */
53
- killAfterSeconds: number;
54
- /** Pin the new pane to this tmux pane: split-window splits it; new-window is
55
- * inserted immediately after its window (-a -t <pane>). Without this, tmux
56
- * uses the attached client's currently-focused pane — which drifts if the
57
- * user switches windows between kickoff and spawn. */
58
- targetPane?: string;
59
- /** Pass tmux `-d` to new-window so CREATING the window never switches the
60
- * attached client to it (split-window already leaves the client's view put).
61
- * The prompt lands in the target session/window without jumping the user out
62
- * of what they are looking at. No effect on split-h/split-v. */
63
- detached?: boolean;
64
- }
65
- export interface DetachResult {
66
- status: 'spawned' | 'spawn-failed' | 'not-in-tmux';
67
- paneId?: string;
68
- message: string;
69
- }
70
- /**
71
- * Fire-and-forget: launch `opts.command` in a new pane (or window), then
72
- * schedule the originating pane to be killed after `killAfterSeconds`. Returns
73
- * as soon as the new pane is up; does NOT wait for the command to finish.
74
- */
75
- export declare function spawnAndDetach(opts: DetachOptions): DetachResult;