@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.
- package/dist/builtin-views/canvas/core.mjs +2 -1
- package/dist/clients/attach/__tests__/context-message.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/context-message.test.js +46 -0
- package/dist/clients/attach/__tests__/transport-relay.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/transport-relay.test.js +176 -0
- package/dist/clients/attach/attach-cmd.js +747 -743
- package/dist/clients/attach/canvas-panels.d.ts +4 -4
- package/dist/clients/attach/canvas-panels.js +108 -37
- package/dist/clients/attach/chat-view.js +11 -0
- package/dist/clients/attach/context-message.d.ts +3 -1
- package/dist/clients/attach/context-message.js +30 -21
- package/dist/clients/attach/graph-overlay.d.ts +8 -1
- package/dist/clients/attach/graph-overlay.js +201 -22
- package/dist/clients/attach/transport-relay.d.ts +51 -0
- package/dist/clients/attach/transport-relay.js +196 -0
- package/dist/clients/attach/transport-socket.d.ts +29 -0
- package/dist/clients/attach/transport-socket.js +164 -0
- package/dist/clients/attach/transport.d.ts +32 -0
- package/dist/clients/attach/transport.js +26 -0
- package/dist/clients/attach/view-socket.d.ts +40 -44
- package/dist/clients/attach/view-socket.js +94 -197
- package/dist/clients/web/server.js +1 -0
- package/dist/commands/memory/delete.js +3 -3
- package/dist/commands/memory/origin.js +1 -1
- package/dist/commands/memory/read.js +3 -3
- package/dist/commands/memory/shared.d.ts +8 -6
- package/dist/commands/memory/shared.js +23 -9
- package/dist/commands/memory/write.js +3 -3
- package/dist/commands/node-worktree.js +9 -9
- package/dist/commands/node.js +71 -23
- package/dist/commands/sys/doctor.js +1 -0
- package/dist/core/__tests__/broker-fork-seam.test.js +17 -2
- package/dist/core/__tests__/broker-snapshot-history.test.js +12 -2
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +79 -0
- package/dist/core/__tests__/context-intro.test.js +11 -0
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +9 -4
- package/dist/core/__tests__/fixtures/fake-engine.js +12 -4
- package/dist/core/__tests__/kickoff.test.js +21 -0
- package/dist/core/__tests__/spawn-root.test.js +60 -0
- package/dist/core/__tests__/worktree.test.js +24 -1
- package/dist/core/canvas/browse/app.d.ts +2 -0
- package/dist/core/canvas/browse/app.js +83 -48
- package/dist/core/canvas/db.js +3 -2
- package/dist/core/canvas/index.d.ts +1 -0
- package/dist/core/canvas/index.js +1 -0
- package/dist/core/canvas/nav-model.d.ts +5 -3
- package/dist/core/canvas/nav-model.js +7 -6
- package/dist/core/canvas/paths.d.ts +8 -0
- package/dist/core/canvas/paths.js +10 -0
- package/dist/core/canvas/render.d.ts +6 -0
- package/dist/core/canvas/render.js +117 -20
- package/dist/core/canvas/source.d.ts +26 -0
- package/dist/core/canvas/source.js +29 -0
- package/dist/core/canvas/status-glyph.js +1 -0
- package/dist/core/canvas/types.d.ts +4 -3
- package/dist/core/config.d.ts +18 -2
- package/dist/core/config.js +88 -10
- package/dist/core/fault-classifier.d.ts +2 -2
- package/dist/core/memory-resolver.d.ts +9 -6
- package/dist/core/memory-resolver.js +28 -4
- package/dist/core/profiles/__tests__/root-profile.test.d.ts +1 -0
- package/dist/core/profiles/__tests__/root-profile.test.js +69 -0
- package/dist/core/profiles/manifest.d.ts +2 -0
- package/dist/core/profiles/manifest.js +40 -7
- package/dist/core/profiles/select.d.ts +4 -4
- package/dist/core/profiles/select.js +13 -13
- package/dist/core/runtime/__tests__/spawn-env.test.d.ts +1 -0
- package/dist/core/runtime/__tests__/spawn-env.test.js +268 -0
- package/dist/core/runtime/bearings.d.ts +5 -4
- package/dist/core/runtime/bearings.js +12 -6
- package/dist/core/runtime/broker.js +59 -7
- package/dist/core/runtime/fault-recovery.js +4 -0
- package/dist/core/runtime/front-door.js +1 -1
- package/dist/core/runtime/host.js +43 -3
- package/dist/core/runtime/kickoff.js +6 -0
- package/dist/core/runtime/launch.d.ts +6 -1
- package/dist/core/runtime/launch.js +7 -1
- package/dist/core/runtime/nodes.d.ts +7 -6
- package/dist/core/runtime/nodes.js +3 -3
- package/dist/core/runtime/situational-context.d.ts +12 -0
- package/dist/core/runtime/situational-context.js +48 -0
- package/dist/core/runtime/spawn-env.d.ts +27 -0
- package/dist/core/runtime/spawn-env.js +169 -0
- package/dist/core/runtime/spawn.d.ts +16 -7
- package/dist/core/runtime/spawn.js +8 -2
- package/dist/core/substrate/session-cache.js +7 -1
- package/dist/core/substrate/subject.d.ts +1 -1
- package/dist/core/view/remote-canvas-target.d.ts +3 -0
- package/dist/core/view/remote-canvas-target.js +25 -0
- package/dist/core/worktree.d.ts +4 -4
- package/dist/core/worktree.js +41 -19
- package/dist/daemon/crtrd.js +32 -0
- package/dist/daemon/predicate-eval.js +12 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +4 -0
- package/dist/pi-extensions/canvas-bash-valve.d.ts +2 -0
- package/dist/pi-extensions/canvas-bash-valve.js +238 -0
- package/dist/pi-extensions/canvas-inbox-watcher.d.ts +12 -0
- package/dist/pi-extensions/canvas-inbox-watcher.js +41 -5
- package/dist/types.d.ts +32 -0
- package/dist/types.js +7 -0
- package/dist/web-client/apple-touch-icon.png +0 -0
- package/dist/web-client/assets/index-BnmSLNLa.css +2 -0
- package/dist/web-client/assets/index-BvzxXXGU.js +77 -0
- package/dist/web-client/assets/workbox-window.prod.es5-Bd17z0YL.js +2 -0
- package/dist/web-client/icon-192.png +0 -0
- package/dist/web-client/icon-512.png +0 -0
- package/dist/web-client/icon-maskable-512.png +0 -0
- package/dist/web-client/index.html +6 -3
- package/dist/web-client/manifest.webmanifest +1 -0
- package/dist/web-client/sw.js +1 -0
- package/docs/public-api.md +5 -3
- package/package.json +4 -3
- package/dist/web-client/assets/fraunces-latin-ext-wght-normal-Ca2vKHc0.woff2 +0 -0
- package/dist/web-client/assets/fraunces-latin-wght-normal-ukD16Tqj.woff2 +0 -0
- package/dist/web-client/assets/fraunces-vietnamese-wght-normal-CnvboYUG.woff2 +0 -0
- package/dist/web-client/assets/index-72YQdji_.js +0 -77
- package/dist/web-client/assets/index-bDhImjJy.css +0 -2
- package/dist/web-client/assets/instrument-sans-latin-ext-wght-normal-B5bTHO_g.woff2 +0 -0
- 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
|
|
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).
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
+
}
|