@crouton-kit/crouter 0.3.60 → 0.3.62

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 (120) hide show
  1. package/dist/builtin-views/canvas/core.mjs +2 -1
  2. package/dist/clients/attach/__tests__/context-message.test.d.ts +1 -0
  3. package/dist/clients/attach/__tests__/context-message.test.js +46 -0
  4. package/dist/clients/attach/__tests__/transport-relay.test.d.ts +1 -0
  5. package/dist/clients/attach/__tests__/transport-relay.test.js +176 -0
  6. package/dist/clients/attach/attach-cmd.js +747 -743
  7. package/dist/clients/attach/canvas-panels.d.ts +4 -4
  8. package/dist/clients/attach/canvas-panels.js +108 -37
  9. package/dist/clients/attach/chat-view.js +11 -0
  10. package/dist/clients/attach/context-message.d.ts +3 -1
  11. package/dist/clients/attach/context-message.js +30 -21
  12. package/dist/clients/attach/graph-overlay.d.ts +8 -1
  13. package/dist/clients/attach/graph-overlay.js +201 -22
  14. package/dist/clients/attach/transport-relay.d.ts +51 -0
  15. package/dist/clients/attach/transport-relay.js +196 -0
  16. package/dist/clients/attach/transport-socket.d.ts +29 -0
  17. package/dist/clients/attach/transport-socket.js +164 -0
  18. package/dist/clients/attach/transport.d.ts +32 -0
  19. package/dist/clients/attach/transport.js +26 -0
  20. package/dist/clients/attach/view-socket.d.ts +40 -44
  21. package/dist/clients/attach/view-socket.js +94 -197
  22. package/dist/clients/web/server.js +1 -0
  23. package/dist/commands/memory/delete.js +3 -3
  24. package/dist/commands/memory/origin.js +1 -1
  25. package/dist/commands/memory/read.js +3 -3
  26. package/dist/commands/memory/shared.d.ts +8 -6
  27. package/dist/commands/memory/shared.js +23 -9
  28. package/dist/commands/memory/write.js +3 -3
  29. package/dist/commands/node-worktree.js +9 -9
  30. package/dist/commands/node.js +71 -23
  31. package/dist/commands/sys/doctor.js +1 -0
  32. package/dist/core/__tests__/broker-fork-seam.test.js +17 -2
  33. package/dist/core/__tests__/broker-snapshot-history.test.js +12 -2
  34. package/dist/core/__tests__/canvas-inbox-watcher.test.js +79 -0
  35. package/dist/core/__tests__/context-intro.test.js +11 -0
  36. package/dist/core/__tests__/fixtures/fake-engine.d.ts +9 -4
  37. package/dist/core/__tests__/fixtures/fake-engine.js +12 -4
  38. package/dist/core/__tests__/kickoff.test.js +21 -0
  39. package/dist/core/__tests__/spawn-root.test.js +60 -0
  40. package/dist/core/__tests__/worktree.test.js +24 -1
  41. package/dist/core/canvas/browse/app.d.ts +2 -0
  42. package/dist/core/canvas/browse/app.js +83 -48
  43. package/dist/core/canvas/db.js +3 -2
  44. package/dist/core/canvas/index.d.ts +1 -0
  45. package/dist/core/canvas/index.js +1 -0
  46. package/dist/core/canvas/nav-model.d.ts +5 -3
  47. package/dist/core/canvas/nav-model.js +7 -6
  48. package/dist/core/canvas/paths.d.ts +8 -0
  49. package/dist/core/canvas/paths.js +10 -0
  50. package/dist/core/canvas/render.d.ts +6 -0
  51. package/dist/core/canvas/render.js +117 -20
  52. package/dist/core/canvas/source.d.ts +26 -0
  53. package/dist/core/canvas/source.js +29 -0
  54. package/dist/core/canvas/status-glyph.js +1 -0
  55. package/dist/core/canvas/types.d.ts +4 -3
  56. package/dist/core/config.d.ts +18 -2
  57. package/dist/core/config.js +88 -10
  58. package/dist/core/fault-classifier.d.ts +2 -2
  59. package/dist/core/memory-resolver.d.ts +9 -6
  60. package/dist/core/memory-resolver.js +28 -4
  61. package/dist/core/profiles/__tests__/root-profile.test.d.ts +1 -0
  62. package/dist/core/profiles/__tests__/root-profile.test.js +69 -0
  63. package/dist/core/profiles/manifest.d.ts +2 -0
  64. package/dist/core/profiles/manifest.js +40 -7
  65. package/dist/core/profiles/select.d.ts +4 -4
  66. package/dist/core/profiles/select.js +13 -13
  67. package/dist/core/runtime/__tests__/spawn-env.test.d.ts +1 -0
  68. package/dist/core/runtime/__tests__/spawn-env.test.js +268 -0
  69. package/dist/core/runtime/bearings.d.ts +5 -4
  70. package/dist/core/runtime/bearings.js +12 -6
  71. package/dist/core/runtime/broker.js +59 -7
  72. package/dist/core/runtime/fault-recovery.js +4 -0
  73. package/dist/core/runtime/front-door.js +1 -1
  74. package/dist/core/runtime/host.js +43 -3
  75. package/dist/core/runtime/kickoff.js +6 -0
  76. package/dist/core/runtime/launch.d.ts +6 -1
  77. package/dist/core/runtime/launch.js +7 -1
  78. package/dist/core/runtime/nodes.d.ts +7 -6
  79. package/dist/core/runtime/nodes.js +3 -3
  80. package/dist/core/runtime/situational-context.d.ts +12 -0
  81. package/dist/core/runtime/situational-context.js +48 -0
  82. package/dist/core/runtime/spawn-env.d.ts +27 -0
  83. package/dist/core/runtime/spawn-env.js +169 -0
  84. package/dist/core/runtime/spawn.d.ts +16 -7
  85. package/dist/core/runtime/spawn.js +8 -2
  86. package/dist/core/substrate/session-cache.js +7 -1
  87. package/dist/core/substrate/subject.d.ts +1 -1
  88. package/dist/core/view/remote-canvas-target.d.ts +3 -0
  89. package/dist/core/view/remote-canvas-target.js +25 -0
  90. package/dist/core/worktree.d.ts +4 -4
  91. package/dist/core/worktree.js +41 -19
  92. package/dist/daemon/crtrd.js +32 -0
  93. package/dist/daemon/predicate-eval.js +12 -1
  94. package/dist/index.d.ts +1 -0
  95. package/dist/index.js +4 -0
  96. package/dist/pi-extensions/canvas-bash-valve.d.ts +2 -0
  97. package/dist/pi-extensions/canvas-bash-valve.js +238 -0
  98. package/dist/pi-extensions/canvas-inbox-watcher.d.ts +12 -0
  99. package/dist/pi-extensions/canvas-inbox-watcher.js +41 -5
  100. package/dist/types.d.ts +32 -0
  101. package/dist/types.js +7 -0
  102. package/dist/web-client/apple-touch-icon.png +0 -0
  103. package/dist/web-client/assets/index-BnmSLNLa.css +2 -0
  104. package/dist/web-client/assets/index-BvzxXXGU.js +77 -0
  105. package/dist/web-client/assets/workbox-window.prod.es5-Bd17z0YL.js +2 -0
  106. package/dist/web-client/icon-192.png +0 -0
  107. package/dist/web-client/icon-512.png +0 -0
  108. package/dist/web-client/icon-maskable-512.png +0 -0
  109. package/dist/web-client/index.html +6 -3
  110. package/dist/web-client/manifest.webmanifest +1 -0
  111. package/dist/web-client/sw.js +1 -0
  112. package/docs/public-api.md +5 -3
  113. package/package.json +4 -3
  114. package/dist/web-client/assets/fraunces-latin-ext-wght-normal-Ca2vKHc0.woff2 +0 -0
  115. package/dist/web-client/assets/fraunces-latin-wght-normal-ukD16Tqj.woff2 +0 -0
  116. package/dist/web-client/assets/fraunces-vietnamese-wght-normal-CnvboYUG.woff2 +0 -0
  117. package/dist/web-client/assets/index-72YQdji_.js +0 -77
  118. package/dist/web-client/assets/index-bDhImjJy.css +0 -2
  119. package/dist/web-client/assets/instrument-sans-latin-ext-wght-normal-B5bTHO_g.woff2 +0 -0
  120. package/dist/web-client/assets/instrument-sans-latin-wght-normal-BbzFLZTg.woff2 +0 -0
@@ -5,13 +5,13 @@
5
5
  // Component as a BOUNDED, centered modal (~72% of the terminal, themed frame) and
6
6
  // CAPTURES keyboard focus (mirrors extension-dialogs.ts);
7
7
  // `OverlayHandle.hide()` tears it down and restores focus to the editor. While
8
- // shown, every key routes to handleInput() — a fold-aware NERDTree of the local
9
- // subscription graph with the canvas-nav keymap:
8
+ // shown, every key routes to handleInput() — a fold-aware NERDTree of the
9
+ // active CanvasSource subscription graph with the canvas-nav keymap:
10
10
  // j/k move · h/l fold/ascend-descend · g/G top/bottom · ↵ focus (swap into
11
11
  // this pane) · m focus manager · x kill (y/n confirm) · esc close.
12
12
  // `e` is a tmux-menu prefixBind, not a GRAPH key, so it is swallowed (per
13
- // canvas-nav). All model/render comes from the shared nav-model layer; keys are
14
- // decoded with pi-tui's canonical matchesKey (kitty/CSI-u aware).
13
+ // canvas-nav). Model/render uses the CanvasSource snapshot built for this
14
+ // overlay; keys are decoded with pi-tui's canonical matchesKey (kitty/CSI-u aware).
15
15
  //
16
16
  // Enter/m/x SHELL `crtr` out-of-process (node focus / node lifecycle close) — the viewer
17
17
  // itself never spawns pi or opens a session (the §0 one-writer invariant). It
@@ -21,7 +21,11 @@
21
21
  import { execFile } from 'node:child_process';
22
22
  import { matchesKey, truncateToWidth } from '@earendil-works/pi-tui';
23
23
  import { fullName } from '../../core/canvas/index.js';
24
- import { beginFrame, cNode, managerOf, sortedChildIds, climbRoot, computeSubtreeActivity, buildGraphModel, renderGraphRow, focusedNodeIds, shortId, visibleWidth, VIEWPORT_FALLBACK_ROWS, DIM, RESET, BOLD, YELLOW, } from '../../core/canvas/nav-model.js';
24
+ import { activeFaultForDisplay } from '../../core/canvas/render.js';
25
+ import { readFault } from '../../core/runtime/fault.js';
26
+ import { faultSummary } from '../../core/canvas/status-glyph.js';
27
+ import { localCanvasSource } from '../../core/canvas/source.js';
28
+ import { beginFrame, navLabel, nodeGlyph, tokensCell, cycleBadge, askBadge, activityCell, focusedNodeIds, shortId, visibleWidth, fillBar, fillWidth, truncate, isAttached, VIEWPORT_FALLBACK_ROWS, DIM, RESET, BOLD, YELLOW, REVERSE, BG_ATTACHED, GREEN, } from '../../core/canvas/nav-model.js';
25
29
  // A BOUNDED, centered modal (CTO ruling: not full-bleed). Width/height are capped
26
30
  // at ~72% of the terminal; the overlay clips our lines to maxHeight, and render()
27
31
  // budgets the panel to the SAME fraction (HEIGHT_PCT) so the framed box is never
@@ -38,6 +42,7 @@ export class GraphOverlay {
38
42
  self;
39
43
  getAsks;
40
44
  palette;
45
+ source;
41
46
  handle;
42
47
  /** Manual fold OVERRIDES (h collapses → userCollapsed, l expands → userExpanded);
43
48
  * both override the default activity-driven policy and survive open/close. */
@@ -50,11 +55,14 @@ export class GraphOverlay {
50
55
  scrollTop = 0;
51
56
  /** Transient y/n confirm gate (kill). */
52
57
  pendingConfirm;
53
- constructor(tui, self, getAsks, palette) {
58
+ snapshot;
59
+ snapshotSeq = 0;
60
+ constructor(tui, self, getAsks, palette, source = localCanvasSource) {
54
61
  this.tui = tui;
55
62
  this.self = self;
56
63
  this.getAsks = getAsks;
57
64
  this.palette = palette;
65
+ this.source = source;
58
66
  }
59
67
  isOpen() {
60
68
  return this.handle !== undefined;
@@ -68,7 +76,7 @@ export class GraphOverlay {
68
76
  // it was left on the last open — the overlay should always default to self.
69
77
  this.cursorId = this.self;
70
78
  this.handle = this.tui.showOverlay(this, OVERLAY_OPTIONS);
71
- this.tui.requestRender();
79
+ this.refresh();
72
80
  }
73
81
  close() {
74
82
  if (this.handle === undefined)
@@ -86,19 +94,182 @@ export class GraphOverlay {
86
94
  }
87
95
  /** Repaint if shown (the low-rate ask poll calls this). */
88
96
  refresh() {
89
- if (this.handle !== undefined)
97
+ if (this.handle === undefined)
98
+ return;
99
+ const seq = ++this.snapshotSeq;
100
+ void this.buildSnapshot().then((snapshot) => {
101
+ if (seq !== this.snapshotSeq)
102
+ return;
103
+ this.snapshot = snapshot;
104
+ this.tui.requestRender();
105
+ }).catch((err) => {
106
+ this.logAsyncFailure('graph overlay refresh failed', err);
90
107
  this.tui.requestRender();
108
+ });
91
109
  }
92
110
  invalidate() {
93
- /* no cached render state — rebuilt every render() */
111
+ this.refresh();
94
112
  }
95
113
  // -------------------------------------------------------------------------
114
+ async buildSnapshot() {
115
+ beginFrame();
116
+ const nodes = new Map();
117
+ const childIds = new Map();
118
+ const sortedChildIds = new Map();
119
+ const managerOf = new Map();
120
+ const activeBelow = new Map();
121
+ const node = async (id) => {
122
+ if (nodes.has(id))
123
+ return nodes.get(id) ?? null;
124
+ let v;
125
+ try {
126
+ v = await this.source.getNode(id);
127
+ }
128
+ catch {
129
+ v = null;
130
+ }
131
+ nodes.set(id, v);
132
+ return v;
133
+ };
134
+ const children = async (id) => {
135
+ const hit = childIds.get(id);
136
+ if (hit !== undefined)
137
+ return hit;
138
+ let refs;
139
+ try {
140
+ refs = await this.source.subscriptionsOf(id);
141
+ }
142
+ catch {
143
+ refs = [];
144
+ }
145
+ const out = [];
146
+ for (const ref of refs) {
147
+ if ((await node(ref.node_id))?.kind !== 'human')
148
+ out.push(ref.node_id);
149
+ }
150
+ childIds.set(id, out);
151
+ return out;
152
+ };
153
+ const manager = async (id) => {
154
+ if (managerOf.has(id))
155
+ return managerOf.get(id);
156
+ let mgr;
157
+ try {
158
+ mgr = (await this.source.subscribersOf(id))[0]?.node_id;
159
+ }
160
+ catch {
161
+ mgr = undefined;
162
+ }
163
+ managerOf.set(id, mgr);
164
+ return mgr;
165
+ };
166
+ const climbRoot = async (self) => {
167
+ let cur = self;
168
+ const seen = new Set([cur]);
169
+ for (;;) {
170
+ const mgr = await manager(cur);
171
+ if (mgr === undefined || seen.has(mgr))
172
+ break;
173
+ seen.add(mgr);
174
+ cur = mgr;
175
+ }
176
+ return cur;
177
+ };
178
+ const rank = async (id) => {
179
+ switch ((await node(id))?.status) {
180
+ case 'active': return 0;
181
+ case 'idle': return 1;
182
+ case 'done': return 2;
183
+ case 'canceled': return 3;
184
+ case 'dead': return 4;
185
+ default: return 5;
186
+ }
187
+ };
188
+ const sortedChildren = async (id) => {
189
+ const cached = sortedChildIds.get(id);
190
+ if (cached !== undefined)
191
+ return cached;
192
+ const ids = [...await children(id)];
193
+ const ranks = new Map();
194
+ await Promise.all(ids.map(async (cid) => { ranks.set(cid, await rank(cid)); }));
195
+ ids.sort((a, b) => (ranks.get(a) ?? 99) - (ranks.get(b) ?? 99));
196
+ sortedChildIds.set(id, ids);
197
+ return ids;
198
+ };
199
+ const expand = new Set();
200
+ const seenActivity = new Set();
201
+ const visitActivity = async (id) => {
202
+ const selfActive = (await node(id))?.status === 'active';
203
+ if (seenActivity.has(id))
204
+ return { active: selfActive ? 1 : 0, reveal: selfActive || id === this.self };
205
+ seenActivity.add(id);
206
+ let below = 0;
207
+ let childReveal = false;
208
+ for (const c of await children(id)) {
209
+ const r = await visitActivity(c);
210
+ below += r.active;
211
+ if (r.reveal)
212
+ childReveal = true;
213
+ }
214
+ activeBelow.set(id, below);
215
+ if (childReveal)
216
+ expand.add(id);
217
+ return { active: below + (selfActive ? 1 : 0), reveal: childReveal || selfActive || id === this.self };
218
+ };
219
+ await visitActivity(await climbRoot(this.self));
220
+ const rows = [];
221
+ const visited = new Set();
222
+ const walk = async (id, prefix, isRoot, isLast) => {
223
+ if (visited.has(id)) {
224
+ const connector = isRoot ? '' : isLast ? '└─ ' : '├─ ';
225
+ rows.push({ id, hasKids: false, isSelf: id === this.self, branch: prefix + connector, cycle: true, collapsed: false });
226
+ return;
227
+ }
228
+ visited.add(id);
229
+ const kids = await sortedChildren(id);
230
+ const folded = this.folds.userExpanded.has(id) ? false : this.folds.userCollapsed.has(id) ? true : !expand.has(id);
231
+ const connector = isRoot ? '' : isLast ? '└─ ' : '├─ ';
232
+ rows.push({ id, hasKids: kids.length > 0, isSelf: id === this.self, branch: prefix + connector, cycle: false, collapsed: folded });
233
+ if (folded)
234
+ return;
235
+ const childPrefix = isRoot ? '' : prefix + (isLast ? ' ' : '│ ');
236
+ for (let i = 0; i < kids.length; i++)
237
+ await walk(kids[i], childPrefix, false, i === kids.length - 1);
238
+ };
239
+ await walk(await climbRoot(this.self), '', true, true);
240
+ return { rows, activeBelow, nodes, childIds, sortedChildIds, managerOf, focused: focusedNodeIds() };
241
+ }
242
+ renderSnapshotRow(snapshot, r, isCursor, asks) {
243
+ const wrap = (line, attached) => isCursor ? fillBar(line, fillWidth(), REVERSE)
244
+ : attached ? fillBar(line, fillWidth(), BG_ATTACHED)
245
+ : truncate(line);
246
+ if (r.cycle) {
247
+ const line = `${r.branch} ${DIM}↺ ${shortId(r.id)}${RESET}`;
248
+ return wrap(line, false);
249
+ }
250
+ const node = snapshot.nodes.get(r.id) ?? null;
251
+ const dot = nodeGlyph(node);
252
+ const rawName = navLabel(node, r.id);
253
+ const name = r.isSelf ? `${BOLD}${rawName}${RESET}` : rawName;
254
+ const kind = `${DIM}${node?.kind ?? ''}${RESET}`;
255
+ const tokens = `${DIM}${tokensCell(r.id)}${RESET}`;
256
+ const expandable = r.hasKids && r.collapsed;
257
+ const caret = !isCursor && expandable ? `${DIM}▸${RESET} ` : ' ';
258
+ const cycle = cycleBadge(node);
259
+ const childCount = snapshot.childIds.get(r.id)?.length ?? 0;
260
+ const child = node !== null && childCount > 0 ? ` ${DIM}⤳${childCount}${RESET}` : '';
261
+ const below = node !== null && node.status !== 'active' && (snapshot.activeBelow.get(node.node_id) ?? 0) > 0
262
+ ? ` ${GREEN}⇣${snapshot.activeBelow.get(node.node_id) ?? 0}${RESET}`
263
+ : '';
264
+ const fault = node !== null ? activeFaultForDisplay(node, readFault(r.id)) : null;
265
+ const faultSuffix = fault !== null ? ` · ${faultSummary(fault)}` : '';
266
+ const line = `${r.branch}${caret}${dot} ${name} ${kind} ${tokens}${cycle}${child}${below}${askBadge(r.id, asks)}${activityCell(r.id, node)}${faultSuffix}`;
267
+ return wrap(line, isAttached(r.id, node, snapshot.focused));
268
+ }
96
269
  render(width) {
97
- // Fresh snapshot per paint (mirror canvas-nav's per-frame cache).
98
270
  beginFrame();
99
271
  const asks = this.getAsks();
100
- const activity = computeSubtreeActivity(climbRoot(this.self), this.self);
101
- const rows = buildGraphModel(this.self, this.folds, activity.expand);
272
+ const rows = this.snapshot?.rows ?? [];
102
273
  // Re-resolve the cursor id → row (it may have vanished under a fold/close).
103
274
  let cursorIdx = rows.findIndex((r) => r.id === this.cursorId);
104
275
  if (cursorIdx < 0) {
@@ -133,18 +304,20 @@ export class GraphOverlay {
133
304
  viewportH = Math.max(1, fit);
134
305
  }
135
306
  const end = Math.min(rows.length, this.scrollTop + viewportH);
136
- const focused = focusedNodeIds();
137
307
  const body = [];
138
308
  if (this.scrollTop > 0)
139
309
  body.push(`${DIM} ↑ ${this.scrollTop} more${RESET}`);
140
310
  for (let i = this.scrollTop; i < end; i++) {
141
- body.push(renderGraphRow(rows[i], i === cursorIdx, focused, activity.activeBelow, asks));
311
+ const snapshot = this.snapshot;
312
+ body.push(snapshot !== undefined ? this.renderSnapshotRow(snapshot, rows[i], i === cursorIdx, asks) : '');
142
313
  }
143
314
  if (end < rows.length)
144
315
  body.push(`${DIM} ↓ ${rows.length - end} more${RESET}`);
145
316
  while (body.length < treeArea)
146
317
  body.push(''); // fill so the overlay is opaque
147
- const title = `${BOLD}⌗ canvas graph${RESET} ${DIM}(${cursorIdx + 1}/${rows.length})${RESET}`;
318
+ const title = rows.length > 0
319
+ ? `${BOLD}⌗ canvas graph${RESET} ${DIM}(${cursorIdx + 1}/${rows.length})${RESET}`
320
+ : `${BOLD}⌗ canvas graph${RESET} ${DIM}(loading)${RESET}`;
148
321
  const hint = this.pendingConfirm !== undefined
149
322
  ? `${YELLOW}${this.pendingConfirm.label} ${BOLD}y/n${RESET}`
150
323
  : GRAPH_HINT;
@@ -205,7 +378,8 @@ export class GraphOverlay {
205
378
  this.close();
206
379
  return;
207
380
  }
208
- const rows = buildGraphModel(this.self, this.folds);
381
+ const snapshot = this.snapshot;
382
+ const rows = snapshot?.rows ?? [];
209
383
  let idx = rows.findIndex((r) => r.id === this.cursorId);
210
384
  if (idx < 0)
211
385
  idx = Math.max(0, rows.findIndex((r) => r.id === this.self));
@@ -236,26 +410,28 @@ export class GraphOverlay {
236
410
  if (cur !== undefined && cur.hasKids && !cur.collapsed) {
237
411
  this.userCollapsed.add(cur.id);
238
412
  this.userExpanded.delete(cur.id);
413
+ this.refresh();
239
414
  }
240
415
  else {
241
- const p = managerOf(this.cursorId ?? this.self);
416
+ const p = snapshot?.managerOf.get(this.cursorId ?? this.self);
242
417
  if (p !== undefined && rows.some((r) => r.id === p))
243
418
  this.cursorId = p;
419
+ this.tui.requestRender();
244
420
  }
245
- this.tui.requestRender();
246
421
  return;
247
422
  }
248
423
  if (matchesKey(data, 'l')) {
249
424
  if (cur !== undefined && cur.collapsed && cur.hasKids) {
250
425
  this.userExpanded.add(cur.id);
251
426
  this.userCollapsed.delete(cur.id);
427
+ this.refresh();
252
428
  }
253
429
  else if (cur !== undefined && cur.hasKids) {
254
- const c = sortedChildIds(cur.id)[0];
430
+ const c = snapshot?.sortedChildIds.get(cur.id)?.[0];
255
431
  if (c !== undefined)
256
432
  this.cursorId = c;
433
+ this.tui.requestRender();
257
434
  }
258
- this.tui.requestRender();
259
435
  return;
260
436
  }
261
437
  if (matchesKey(data, 'enter')) {
@@ -265,7 +441,7 @@ export class GraphOverlay {
265
441
  return;
266
442
  }
267
443
  if (matchesKey(data, 'm')) {
268
- const mgr = managerOf(this.self);
444
+ const mgr = snapshot?.managerOf.get(this.self);
269
445
  if (mgr !== undefined) {
270
446
  this.focusTarget(mgr);
271
447
  this.close();
@@ -274,7 +450,7 @@ export class GraphOverlay {
274
450
  }
275
451
  if (matchesKey(data, 'x')) {
276
452
  const target = this.cursorId ?? this.self;
277
- const n = cNode(target);
453
+ const n = snapshot?.nodes.get(target) ?? null;
278
454
  const nm = n !== null ? fullName(n) : shortId(target);
279
455
  this.pendingConfirm = { label: `kill ${nm}?`, action: () => this.shellCrtr(['node', 'lifecycle', 'close', '--node', target]) };
280
456
  this.tui.requestRender();
@@ -289,6 +465,9 @@ export class GraphOverlay {
289
465
  * `id` has no viewer yet a fresh one splits beside this pane; if it already has
290
466
  * one, that viewer is navigated in place (this pane is left as-is). We pass
291
467
  * TMUX_PANE explicitly so the anchor is THIS pane even if env resolution differs. */
468
+ logAsyncFailure(label, err) {
469
+ process.stderr.write(`[crtr attach] ${label}: ${String(err?.message ?? err)}\n`);
470
+ }
292
471
  focusTarget(id) {
293
472
  const argv = ['node', 'focus', id];
294
473
  const pane = process.env['TMUX_PANE'];
@@ -0,0 +1,51 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import type { ClientToBroker } from '../../core/runtime/broker-protocol.js';
3
+ import { type Transport } from './transport.js';
4
+ /** A resolved remote canvas target — see `remote-canvas-target.ts` for how
5
+ * this is assembled from config + the token's env var, never from argv. */
6
+ export interface RelayTarget {
7
+ /** The preview endpoint origin, e.g. `https://<hash>.preview.bl.run`. */
8
+ previewEndpoint: string;
9
+ /** The raw relay/bearer token. NEVER logged, NEVER echoed. */
10
+ relayToken: string;
11
+ }
12
+ export declare class RelayTransport extends EventEmitter implements Transport {
13
+ private readonly nodeId;
14
+ private readonly target;
15
+ private ws;
16
+ private closeEmitted;
17
+ /** One redial fault line per reconnect EPISODE, not per attempt — mirrors
18
+ * SocketTransport. */
19
+ private redialFaultRecorded;
20
+ constructor(nodeId: string, target: RelayTarget);
21
+ private get url();
22
+ private recordRelayFault;
23
+ private dial;
24
+ connect(): void;
25
+ /** Re-dial the SAME `wss://<previewEndpoint>/node/<id>` — the preview
26
+ * endpoint is the stable anchor remotely, as `view.sock` is locally. */
27
+ redial(): Promise<void>;
28
+ send(frame: ClientToBroker): void;
29
+ destroy(): void;
30
+ private onMessage;
31
+ private onError;
32
+ /** WS close codes mirror the SAME semantics the browser relay client
33
+ * (`clients/web/web-client/broker-client.ts`'s `onclose` / `src/web/
34
+ * transport-stream.ts`'s `classifyClose`): 1008 invalid id, or a 1011 whose
35
+ * REASON starts with "no running broker"/"no node " (the relay's exact
36
+ * wording — `src/clients/web/server.ts`), are the terminal "gone" signals —
37
+ * surfaced as BrokerUnavailableError so the reconnect supervisor's normal
38
+ * give-up path applies uniformly across transports. `fault-classifier.ts`'s
39
+ * `classify` is consulted only for the 1008/protocol case (parity with the
40
+ * browser client), NOT for 1011 — `classifyWsClose`'s 1011 case answers "is
41
+ * this reason connection-shaped", the OPPOSITE question from "is the
42
+ * broker/node gone", so branching on its `kind` here previously inverted
43
+ * the gone/not-gone decision (a real bug caught by the mock-relay test, see
44
+ * `transport-relay.test.ts`). Everything else (1006 abnormal, 1000 normal,
45
+ * or an unrecognized 1011 reason) is a transient/expected drop the caller
46
+ * may redial. */
47
+ private onClose;
48
+ /** Emit `error` only when a listener exists — a bare EventEmitter `error`
49
+ * with no listener throws, and this transport must never throw uncaught. */
50
+ private emitError;
51
+ }
@@ -0,0 +1,196 @@
1
+ // transport-relay.ts — the REMOTE Transport: a relayed WebSocket straight to
2
+ // `wss://<previewEndpoint>/node/<id>`, through the in-guest model-auth-front
3
+ // (bearer-gated on the home's relay token), to the SAME `/node/<id>` handler
4
+ // `crtr surface web serve` already exposes for the browser client
5
+ // (`src/clients/web/server.ts`). One JSON frame per WS message, both
6
+ // directions — no FrameDecoder needed (WS preserves message boundaries).
7
+ //
8
+ // Deliberately NOT the Hearth CP relay: the CP hard-pins its WS upstream to
9
+ // the home's single `home_agent_target`, so it can only ever reach the home
10
+ // node (findings-remote-attach-feasibility.md D1). model-auth-front does not
11
+ // pin — it forwards path verbatim — so this is the only route that reaches an
12
+ // arbitrary node id, which is what makes multi-node remote attach possible.
13
+ //
14
+ // The relay token is the SAME in-guest credential model-auth-front already
15
+ // gates on; holding it is the in-guest trust tier, not a new one. Callers
16
+ // must keep it off argv/shell history (see `remote-canvas-target.ts`) — this
17
+ // transport only ever sends it as a request header, never logs it.
18
+ import { EventEmitter } from 'node:events';
19
+ import { WebSocket } from 'ws';
20
+ import { classify } from '../../core/fault-classifier.js';
21
+ import { clearFault, recordFault } from '../../core/runtime/fault.js';
22
+ import { BrokerUnavailableError } from './transport.js';
23
+ function relayUrl(target, nodeId) {
24
+ const url = new URL(target.previewEndpoint);
25
+ url.protocol = url.protocol === 'http:' ? 'ws:' : 'wss:';
26
+ url.pathname = `/node/${encodeURIComponent(nodeId)}`;
27
+ url.search = '';
28
+ url.hash = '';
29
+ return url.toString();
30
+ }
31
+ export class RelayTransport extends EventEmitter {
32
+ nodeId;
33
+ target;
34
+ ws;
35
+ closeEmitted = false;
36
+ /** One redial fault line per reconnect EPISODE, not per attempt — mirrors
37
+ * SocketTransport. */
38
+ redialFaultRecorded = false;
39
+ constructor(nodeId, target) {
40
+ super();
41
+ this.nodeId = nodeId;
42
+ this.target = target;
43
+ }
44
+ get url() {
45
+ return relayUrl(this.target, this.nodeId);
46
+ }
47
+ recordRelayFault(op, err) {
48
+ if (op === 'redial') {
49
+ if (this.redialFaultRecorded)
50
+ return;
51
+ this.redialFaultRecorded = true;
52
+ }
53
+ const classified = classify('viewer↔broker', err);
54
+ recordFault(this.nodeId, {
55
+ link: 'viewer↔broker',
56
+ op,
57
+ kind: classified.kind,
58
+ retry: op === 'redial'
59
+ ? { disposition: 'auto', by: 'client' }
60
+ : { disposition: 'manual' },
61
+ message: err instanceof Error ? err.message : String(err),
62
+ });
63
+ }
64
+ dial() {
65
+ const ws = new WebSocket(this.url, {
66
+ headers: { authorization: `Bearer ${this.target.relayToken}` },
67
+ perMessageDeflate: false,
68
+ });
69
+ this.ws = ws;
70
+ return ws;
71
+ }
72
+ connect() {
73
+ const ws = this.dial();
74
+ ws.on('open', () => {
75
+ this.redialFaultRecorded = false;
76
+ clearFault(this.nodeId, { link: 'viewer↔broker' });
77
+ this.emit('connect');
78
+ });
79
+ ws.on('message', (data) => this.onMessage(data));
80
+ ws.on('error', (err) => this.onError(err, 'connect'));
81
+ ws.on('close', (code, reason) => this.onClose(code, reason.toString()));
82
+ }
83
+ /** Re-dial the SAME `wss://<previewEndpoint>/node/<id>` — the preview
84
+ * endpoint is the stable anchor remotely, as `view.sock` is locally. */
85
+ redial() {
86
+ this.destroy();
87
+ this.closeEmitted = false;
88
+ return new Promise((resolve, reject) => {
89
+ const ws = this.dial();
90
+ let settled = false;
91
+ ws.on('open', () => {
92
+ if (settled)
93
+ return;
94
+ settled = true;
95
+ this.redialFaultRecorded = false;
96
+ clearFault(this.nodeId, { link: 'viewer↔broker' });
97
+ this.emit('connect');
98
+ resolve();
99
+ });
100
+ ws.on('message', (data) => this.onMessage(data));
101
+ ws.on('error', (err) => {
102
+ if (settled) {
103
+ this.onError(err, 'redial');
104
+ return;
105
+ }
106
+ settled = true;
107
+ this.recordRelayFault('redial', err);
108
+ try {
109
+ ws.terminate();
110
+ }
111
+ catch {
112
+ /* ignore */
113
+ }
114
+ reject(err);
115
+ });
116
+ ws.on('close', (code, reason) => {
117
+ if (!settled)
118
+ return; // the 'error' handler above already rejected
119
+ this.onClose(code, reason.toString());
120
+ });
121
+ });
122
+ }
123
+ send(frame) {
124
+ const ws = this.ws;
125
+ if (ws === undefined || ws.readyState !== WebSocket.OPEN)
126
+ return;
127
+ try {
128
+ ws.send(JSON.stringify(frame));
129
+ }
130
+ catch {
131
+ /* dead socket — 'close' will fire and drive teardown */
132
+ }
133
+ }
134
+ destroy() {
135
+ const ws = this.ws;
136
+ if (ws !== undefined && ws.readyState <= WebSocket.OPEN) {
137
+ try {
138
+ ws.close();
139
+ }
140
+ catch {
141
+ /* ignore */
142
+ }
143
+ }
144
+ }
145
+ onMessage(data) {
146
+ let frame;
147
+ try {
148
+ frame = JSON.parse(data.toString());
149
+ }
150
+ catch {
151
+ // A malformed frame must never crash the client — drop it (parity with
152
+ // the local FrameDecoder's own bad-JSON handling).
153
+ return;
154
+ }
155
+ this.emit('frame', frame);
156
+ }
157
+ onError(err, op) {
158
+ this.recordRelayFault(op, err);
159
+ this.emitError(new Error(`relay transport error: ${err.message}`));
160
+ // 'close' follows 'error'; teardown converges there.
161
+ }
162
+ /** WS close codes mirror the SAME semantics the browser relay client
163
+ * (`clients/web/web-client/broker-client.ts`'s `onclose` / `src/web/
164
+ * transport-stream.ts`'s `classifyClose`): 1008 invalid id, or a 1011 whose
165
+ * REASON starts with "no running broker"/"no node " (the relay's exact
166
+ * wording — `src/clients/web/server.ts`), are the terminal "gone" signals —
167
+ * surfaced as BrokerUnavailableError so the reconnect supervisor's normal
168
+ * give-up path applies uniformly across transports. `fault-classifier.ts`'s
169
+ * `classify` is consulted only for the 1008/protocol case (parity with the
170
+ * browser client), NOT for 1011 — `classifyWsClose`'s 1011 case answers "is
171
+ * this reason connection-shaped", the OPPOSITE question from "is the
172
+ * broker/node gone", so branching on its `kind` here previously inverted
173
+ * the gone/not-gone decision (a real bug caught by the mock-relay test, see
174
+ * `transport-relay.test.ts`). Everything else (1006 abnormal, 1000 normal,
175
+ * or an unrecognized 1011 reason) is a transient/expected drop the caller
176
+ * may redial. */
177
+ onClose(code, reason) {
178
+ if (this.closeEmitted)
179
+ return;
180
+ this.closeEmitted = true;
181
+ const classified = classify('viewer↔broker', { code, reason });
182
+ const gone = code === 1008 ||
183
+ classified.kind === 'protocol' ||
184
+ (code === 1011 && (/^no running broker/.test(reason) || /^no node /.test(reason)));
185
+ if (gone) {
186
+ this.emitError(new BrokerUnavailableError(this.nodeId));
187
+ }
188
+ this.emit('close');
189
+ }
190
+ /** Emit `error` only when a listener exists — a bare EventEmitter `error`
191
+ * with no listener throws, and this transport must never throw uncaught. */
192
+ emitError(err) {
193
+ if (this.listenerCount('error') > 0)
194
+ this.emit('error', err);
195
+ }
196
+ }
@@ -0,0 +1,29 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { type ClientToBroker } from '../../core/runtime/broker-protocol.js';
3
+ import { type Transport } from './transport.js';
4
+ export declare class SocketTransport extends EventEmitter implements Transport {
5
+ private readonly nodeId;
6
+ private socket;
7
+ private decoder;
8
+ private closeEmitted;
9
+ /** One redial fault line per reconnect EPISODE, not per attempt — see
10
+ * {@link recordSocketFault}. */
11
+ private redialFaultRecorded;
12
+ constructor(nodeId: string);
13
+ /** The broker binds `viewSocketPath(id)` — resolve it the same way. */
14
+ get socketPath(): string;
15
+ private recordSocketFault;
16
+ connect(): void;
17
+ /** Re-establish the socket after a broker exit, on the SAME stable
18
+ * `view.sock` path. Installs a FRESH FrameDecoder (a half-frame from the
19
+ * dead stream must not corrupt the new one). */
20
+ redial(): Promise<void>;
21
+ send(frame: ClientToBroker): void;
22
+ destroy(): void;
23
+ private onData;
24
+ private onError;
25
+ private onClose;
26
+ /** Emit `error` only when a listener exists — a bare EventEmitter `error`
27
+ * with no listener throws, and this transport must never throw uncaught. */
28
+ private emitError;
29
+ }