@crouton-kit/crouter 0.3.26 → 0.3.28
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-memory/internal/INDEX.md +1 -1
- package/dist/builtin-personas/runtime-base.md +3 -0
- package/dist/clients/attach/__tests__/reconnect-giveup.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/reconnect-giveup.test.js +30 -0
- package/dist/clients/attach/attach-cmd.js +187 -19
- package/dist/clients/attach/canvas-panels.d.ts +10 -0
- package/dist/clients/attach/canvas-panels.js +50 -0
- package/dist/clients/attach/graph-overlay.d.ts +34 -0
- package/dist/clients/attach/graph-overlay.js +266 -0
- package/dist/clients/attach/input-controller.d.ts +6 -0
- package/dist/clients/attach/input-controller.js +2 -0
- package/dist/clients/attach/slash-commands.d.ts +22 -1
- package/dist/clients/attach/slash-commands.js +160 -3
- package/dist/clients/attach/view-socket.d.ts +19 -1
- package/dist/clients/attach/view-socket.js +61 -6
- package/dist/commands/human/prompts.js +3 -3
- package/dist/commands/human/queue.d.ts +17 -0
- package/dist/commands/human/queue.js +111 -4
- package/dist/commands/memory/__tests__/lint-schema.test.js +24 -1
- package/dist/commands/memory/lint.d.ts +5 -4
- package/dist/commands/memory/lint.js +9 -5
- package/dist/commands/memory/write.js +19 -2
- package/dist/commands/memory.js +1 -1
- package/dist/commands/sys/feedback.d.ts +1 -0
- package/dist/commands/sys/feedback.js +163 -0
- package/dist/commands/sys.js +3 -2
- package/dist/core/__tests__/broker-snapshot-history.test.d.ts +1 -0
- package/dist/core/__tests__/broker-snapshot-history.test.js +105 -0
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +7 -0
- package/dist/core/__tests__/fixtures/fake-engine.js +10 -0
- package/dist/core/__tests__/full/placement-teardown.test.js +76 -0
- package/dist/core/__tests__/human-stranded-deliver.test.d.ts +1 -0
- package/dist/core/__tests__/human-stranded-deliver.test.js +108 -0
- package/dist/core/__tests__/on-read-dedup-resume.test.d.ts +1 -0
- package/dist/core/__tests__/on-read-dedup-resume.test.js +81 -0
- package/dist/core/canvas/nav-model.d.ts +162 -0
- package/dist/core/canvas/nav-model.js +486 -0
- package/dist/core/canvas/paths.d.ts +7 -0
- package/dist/core/canvas/paths.js +9 -0
- package/dist/core/runtime/broker-sdk.d.ts +0 -12
- package/dist/core/runtime/broker-sdk.js +77 -6
- package/dist/core/runtime/broker.d.ts +2 -1
- package/dist/core/runtime/broker.js +26 -1
- package/dist/core/runtime/front-door.js +23 -8
- package/dist/core/runtime/naming.d.ts +1 -5
- package/dist/core/runtime/naming.js +33 -49
- package/dist/core/runtime/placement.d.ts +7 -6
- package/dist/core/runtime/placement.js +24 -12
- package/dist/core/runtime/revive.js +9 -0
- package/dist/core/runtime/spawn.d.ts +5 -0
- package/dist/core/runtime/spawn.js +69 -11
- package/dist/core/runtime/tmux.d.ts +9 -0
- package/dist/core/runtime/tmux.js +12 -0
- package/dist/core/spawn.d.ts +14 -0
- package/dist/core/spawn.js +29 -9
- package/dist/core/substrate/index.d.ts +1 -1
- package/dist/core/substrate/index.js +6 -6
- package/dist/core/substrate/injected-store.d.ts +10 -0
- package/dist/core/substrate/injected-store.js +55 -0
- package/dist/core/substrate/schema.d.ts +6 -8
- package/dist/core/substrate/schema.js +26 -28
- package/dist/pi-extensions/canvas-doc-substrate.js +16 -7
- package/dist/pi-extensions/canvas-goal-capture.js +38 -22
- package/dist/pi-extensions/canvas-nav.js +30 -385
- package/dist/pi-extensions/canvas-stophook.d.ts +1 -1
- package/dist/pi-extensions/canvas-stophook.js +32 -2
- package/package.json +1 -1
- package/dist/builtin-memory/memory-authoring.md +0 -100
|
@@ -16,6 +16,6 @@ Open this dir whenever a task turns on understanding the runtime itself. Content
|
|
|
16
16
|
- **storage-tiers** — where every kind of state lives: the three tiers (scope root, per-cwd crouter root, canvas home) and their durability/ownership contracts.
|
|
17
17
|
- **examples/** — worked compositions of the primitives into complete systems (the analogue of pi's `examples/` dir), e.g. the iMessage assistant node.
|
|
18
18
|
|
|
19
|
-
Adjacent, outside this dir: authoring memory documents (
|
|
19
|
+
Adjacent, outside this dir: authoring memory documents (kind, rungs, gates, routing line, the asked-to-remember workflow) is owned by `crtr memory write -h` — the authoring guide lives on the help-gate so it surfaces exactly when you write; making a persona (a custom `--kind`) is owned by the builtin **crouter-development/personas** skills.
|
|
20
20
|
|
|
21
21
|
The individual files surface at `name` (their titles route; open the one the situation calls for); this index surfaces at `preview` so the dir announces when to come looking.
|
|
@@ -15,6 +15,9 @@ Don't stall and don't guess at a decision a person should make:
|
|
|
15
15
|
|
|
16
16
|
crtr human ask "<question>"
|
|
17
17
|
|
|
18
|
+
## When crtr itself misbehaves
|
|
19
|
+
A `crtr` command that errors unexpectedly, hangs, churns, double-spawns, or contradicts its own `-h` is a harness bug — don't silently work around it. Run `crtr sys feedback` to report it (`-h` for how), then continue.
|
|
20
|
+
|
|
18
21
|
## When your task is too big for one context window
|
|
19
22
|
If you discover the job is far larger than one node can hold — many phases, or work that won't fit before you run low on context — **promote yourself** instead of grinding it out:
|
|
20
23
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Regression: the `crtr attach` viewer used to ALWAYS teardown ("broker gone")
|
|
2
|
+
// on a socket close, so a resident broker node's yield→revive cycle dropped the
|
|
3
|
+
// viewer to a shell. The fix holds the pane and re-dials the SAME view.sock
|
|
4
|
+
// while the node is still alive, giving up only when it is genuinely gone. This
|
|
5
|
+
// locks the give-up/keep-reconnecting decision (the bug-prone core) without a
|
|
6
|
+
// socket or tmux. See src/clients/attach/{view-socket,attach-cmd}.ts.
|
|
7
|
+
import { test } from 'node:test';
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import { reconnectShouldGiveUp } from '../view-socket.js';
|
|
10
|
+
const rowWith = (status) => ({
|
|
11
|
+
node_id: 'n',
|
|
12
|
+
status,
|
|
13
|
+
host_kind: 'broker',
|
|
14
|
+
intent: null,
|
|
15
|
+
pi_pid: null,
|
|
16
|
+
});
|
|
17
|
+
test('keeps reconnecting while the node is still alive (active = mid yield/revive)', () => {
|
|
18
|
+
// A yield leaves status='active' (intent='refresh') — must NOT give up.
|
|
19
|
+
assert.equal(reconnectShouldGiveUp(rowWith('active')), false);
|
|
20
|
+
// idle-release revives on the next inbox wake — keep trying (bounded by the
|
|
21
|
+
// supervisor's own ~30s deadline), do not give up.
|
|
22
|
+
assert.equal(reconnectShouldGiveUp(rowWith('idle')), false);
|
|
23
|
+
});
|
|
24
|
+
test('gives up the instant the node is genuinely gone', () => {
|
|
25
|
+
assert.equal(reconnectShouldGiveUp(rowWith('done')), true);
|
|
26
|
+
assert.equal(reconnectShouldGiveUp(rowWith('dead')), true);
|
|
27
|
+
assert.equal(reconnectShouldGiveUp(rowWith('canceled')), true);
|
|
28
|
+
// A reaped row (null) → give up.
|
|
29
|
+
assert.equal(reconnectShouldGiveUp(null), true);
|
|
30
|
+
});
|
|
@@ -15,18 +15,40 @@
|
|
|
15
15
|
// `crtr attach <node>` form is not expressible without a framework change. attach
|
|
16
16
|
// is therefore a BRANCH wrapping a single leaf: `crtr attach to <node>`.
|
|
17
17
|
import { randomUUID } from 'node:crypto';
|
|
18
|
-
import {
|
|
18
|
+
import { execFile } from 'node:child_process';
|
|
19
|
+
import { CombinedAutocompleteProvider, Container, ProcessTerminal, Text, TUI, matchesKey, } from '@earendil-works/pi-tui';
|
|
19
20
|
import { CustomEditor, getSelectListTheme, } from '@earendil-works/pi-coding-agent';
|
|
20
21
|
import { defineBranch, defineLeaf } from '../../core/command.js';
|
|
21
22
|
import { InputError } from '../../core/io.js';
|
|
22
|
-
import { getNode } from '../../core/canvas/index.js';
|
|
23
|
+
import { getNode, getRow } from '../../core/canvas/index.js';
|
|
23
24
|
// tmux driver verbs only through placement (the §5.1 model-over-driver seam) —
|
|
24
25
|
// never `core/runtime/tmux.js` directly.
|
|
25
26
|
import { setPaneOption, currentTmux } from '../../core/runtime/placement.js';
|
|
27
|
+
import { climbRoot } from '../../core/canvas/nav-model.js';
|
|
26
28
|
import { ChatView } from './chat-view.js';
|
|
27
29
|
import { InputController } from './input-controller.js';
|
|
30
|
+
import { buildCanvasPanelLines } from './canvas-panels.js';
|
|
31
|
+
import { GraphOverlay } from './graph-overlay.js';
|
|
32
|
+
import { slashCommandList } from './slash-commands.js';
|
|
28
33
|
import { applyTheme, createKeybindingsManager } from './config-load.js';
|
|
29
|
-
import { BrokerUnavailableError, ViewSocketClient } from './view-socket.js';
|
|
34
|
+
import { BrokerUnavailableError, ViewSocketClient, reconnectShouldGiveUp } from './view-socket.js';
|
|
35
|
+
/** Async per-node ask-map fetch (NON-blocking — the viewer must never block its
|
|
36
|
+
* input pump on a shell-out, unlike canvas-nav's execFileSync). Buckets a whole
|
|
37
|
+
* sub-DAG's pending asks in one `crtr` process; on any error the callback is not
|
|
38
|
+
* invoked, so the caller keeps its last good map. */
|
|
39
|
+
function fetchAsksAsync(rootId, cb) {
|
|
40
|
+
execFile('crtr', ['canvas', 'attention', 'map', '--view', rootId, '--json'], { timeout: 2_500, maxBuffer: 4 * 1024 * 1024 }, (err, stdout) => {
|
|
41
|
+
if (err)
|
|
42
|
+
return;
|
|
43
|
+
try {
|
|
44
|
+
const parsed = JSON.parse(stdout.trim());
|
|
45
|
+
cb(parsed.counts ?? {});
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
/* malformed — keep last map */
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
30
52
|
/** Run the interactive attach session against `nodeId`. Resolves when the
|
|
31
53
|
* viewer detaches (ctrl+c/ctrl+d), the broker goes away, or a signal lands —
|
|
32
54
|
* always restoring the terminal first. */
|
|
@@ -101,12 +123,27 @@ async function runAttach(nodeId, observer) {
|
|
|
101
123
|
// otherwise (T5 contract). One KeybindingsManager feeds BOTH editor + input.
|
|
102
124
|
applyTheme({ cwd: meta.cwd });
|
|
103
125
|
const km = createKeybindingsManager();
|
|
104
|
-
// 2. TUI +
|
|
126
|
+
// 2. TUI + layout containers + editor. Top-to-bottom render order (set at
|
|
127
|
+
// step 8): chat · badge · managers (↑ subscriber) · editor · reports
|
|
128
|
+
// (↓ subscriptions) · footer. The badge sits in the BOTTOM-anchored chrome
|
|
129
|
+
// stack (directly above managers), NOT as the topmost child: pi-tui anchors
|
|
130
|
+
// the viewport to the cursor at the bottom, so a topmost badge scrolls off
|
|
131
|
+
// into scrollback the instant the chat exceeds one screen. The badge + the
|
|
132
|
+
// two canvas panels reproduce the pi-extension chrome natively (Unit Q).
|
|
105
133
|
const tui = new TUI(new ProcessTerminal());
|
|
134
|
+
const badge = new Container();
|
|
106
135
|
const chatContainer = new Container();
|
|
136
|
+
const managers = new Container();
|
|
137
|
+
const reports = new Container();
|
|
107
138
|
const footer = new Container();
|
|
108
139
|
const editorTheme = { borderColor: (s) => s, selectList: getSelectListTheme() };
|
|
109
140
|
const editor = new CustomEditor(tui, editorTheme, km, { paddingX: 1 });
|
|
141
|
+
// Slash-command autocomplete: builtins + native canvas commands now; enriched
|
|
142
|
+
// with the broker's engine/extension/skill commands on the get_commands ack.
|
|
143
|
+
const setCommands = (commands) => {
|
|
144
|
+
editor.setAutocompleteProvider(new CombinedAutocompleteProvider(slashCommandList(commands), meta.cwd));
|
|
145
|
+
};
|
|
146
|
+
setCommands();
|
|
110
147
|
// 3. Render layer (T5). ChatView owns the chat scroll + activity spinner; the
|
|
111
148
|
// footer/status line below is ours, so onFooterEvent is left unset (footer
|
|
112
149
|
// updates flow through handleBrokerFrame's single update path instead).
|
|
@@ -122,8 +159,6 @@ async function runAttach(nodeId, observer) {
|
|
|
122
159
|
bits.push(liveState.model);
|
|
123
160
|
if (liveState?.isStreaming)
|
|
124
161
|
bits.push('streaming');
|
|
125
|
-
if (liveState?.sessionName)
|
|
126
|
-
bits.push(liveState.sessionName);
|
|
127
162
|
if (liveState && liveState.pendingMessageCount > 0)
|
|
128
163
|
bits.push(`queued ${liveState.pendingMessageCount}`);
|
|
129
164
|
let line = `\x1b[2m${bits.join(' · ')}\x1b[22m`;
|
|
@@ -137,11 +172,47 @@ async function runAttach(nodeId, observer) {
|
|
|
137
172
|
notice = msg;
|
|
138
173
|
renderFooter();
|
|
139
174
|
};
|
|
140
|
-
//
|
|
175
|
+
// Name badge — the session name (= the canvas editor label `kind (mode)
|
|
176
|
+
// name`), relayed over the socket and patched live (Feature 1). Rendered just
|
|
177
|
+
// above the managers line in the bottom chrome so it stays in-viewport.
|
|
178
|
+
const renderBadge = () => {
|
|
179
|
+
badge.clear();
|
|
180
|
+
if (liveState?.sessionName)
|
|
181
|
+
badge.addChild(new Text(`\x1b[1m${liveState.sessionName}\x1b[22m`, 1, 0));
|
|
182
|
+
tui.requestRender();
|
|
183
|
+
};
|
|
184
|
+
// Subscribed-node panel — manager line above the editor, live reports below,
|
|
185
|
+
// read straight from canvas.db via nav-model (Feature 2). asksMap is refreshed
|
|
186
|
+
// by the low-rate poll below; beginFrame() inside buildCanvasPanelLines memoizes
|
|
187
|
+
// the db reads for each rebuild.
|
|
188
|
+
let asksMap = {};
|
|
189
|
+
const renderPanels = () => {
|
|
190
|
+
const { managers: mLines, reports: rLines } = buildCanvasPanelLines(nodeId, asksMap);
|
|
191
|
+
managers.clear();
|
|
192
|
+
for (const l of mLines)
|
|
193
|
+
managers.addChild(new Text(l, 1, 0));
|
|
194
|
+
reports.clear();
|
|
195
|
+
for (const l of rLines)
|
|
196
|
+
reports.addChild(new Text(l, 1, 0));
|
|
197
|
+
tui.requestRender();
|
|
198
|
+
};
|
|
199
|
+
// The alt+g GRAPH overlay (Feature 3) — a full-screen navigator over the same
|
|
200
|
+
// nav-model graph, reading the live asksMap.
|
|
201
|
+
const graphOverlay = new GraphOverlay(tui, nodeId, () => asksMap);
|
|
202
|
+
// Refresh all canvas chrome (panels + overlay if open) from canvas.db.
|
|
203
|
+
const refreshChrome = () => {
|
|
204
|
+
renderPanels();
|
|
205
|
+
graphOverlay.refresh();
|
|
206
|
+
};
|
|
207
|
+
// 5. Input layer (T6). onCommand/onDialogResponse → socket; onNotice → footer;
|
|
208
|
+
// nodeId/onGraph feed the slash context (/promote targets this node, /graph
|
|
209
|
+
// toggles the overlay).
|
|
141
210
|
const input = new InputController(tui, editor, km, {
|
|
142
211
|
onCommand: (frame) => socket.send(frame),
|
|
143
212
|
onDialogResponse: (resp) => socket.send(resp),
|
|
144
213
|
onNotice: (msg) => setNotice(msg),
|
|
214
|
+
nodeId,
|
|
215
|
+
onGraph: () => graphOverlay.toggle(),
|
|
145
216
|
});
|
|
146
217
|
// Feed fresh state into the input controller AND the footer. Steer-vs-prompt
|
|
147
218
|
// routing (T6) reads `state.isStreaming`, so this must stay current — the
|
|
@@ -151,6 +222,11 @@ async function runAttach(nodeId, observer) {
|
|
|
151
222
|
liveState = state;
|
|
152
223
|
input.setState(state);
|
|
153
224
|
renderFooter();
|
|
225
|
+
renderBadge();
|
|
226
|
+
// Panel liveness rides the relayed event stream (agent_start/agent_end/
|
|
227
|
+
// queue_update/session_info_changed all patch state through here) plus the
|
|
228
|
+
// low-rate poll below for changes that emit no event (child status flips).
|
|
229
|
+
refreshChrome();
|
|
154
230
|
};
|
|
155
231
|
const patchState = (patch) => {
|
|
156
232
|
if (liveState === undefined)
|
|
@@ -164,6 +240,9 @@ async function runAttach(nodeId, observer) {
|
|
|
164
240
|
role = frame.role;
|
|
165
241
|
chatView.applySnapshot(frame.snapshot);
|
|
166
242
|
setLiveState(frame.snapshot.state);
|
|
243
|
+
// Ask the broker for its merged command list (engine/extension/skill
|
|
244
|
+
// commands) to enrich slash autocomplete beyond the builtins+canvas set.
|
|
245
|
+
socket.send({ type: 'get_commands' });
|
|
167
246
|
if (frame.pending_dialog != null)
|
|
168
247
|
input.attachDialog(frame.pending_dialog);
|
|
169
248
|
break;
|
|
@@ -185,10 +264,19 @@ async function runAttach(nodeId, observer) {
|
|
|
185
264
|
break;
|
|
186
265
|
}
|
|
187
266
|
case 'ack': {
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
|
|
267
|
+
// get_commands replies with the broker's merged command list as JSON in
|
|
268
|
+
// `detail` — rebuild the autocomplete provider with it (slashCommandList
|
|
269
|
+
// re-appends the native canvas commands, deduped).
|
|
270
|
+
if (frame.for === 'get_commands' && frame.ok && frame.detail) {
|
|
271
|
+
try {
|
|
272
|
+
const list = JSON.parse(frame.detail);
|
|
273
|
+
setCommands(list);
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
/* malformed — keep the current provider */
|
|
277
|
+
}
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
192
280
|
if (!frame.ok) {
|
|
193
281
|
setNotice(`command failed: ${frame.for}${frame.detail ? ` — ${frame.detail}` : ''}`);
|
|
194
282
|
}
|
|
@@ -239,10 +327,14 @@ async function runAttach(nodeId, observer) {
|
|
|
239
327
|
const done = new Promise((r) => {
|
|
240
328
|
resolveDone = r;
|
|
241
329
|
});
|
|
330
|
+
let asksTimer;
|
|
242
331
|
const teardown = (reason) => {
|
|
243
332
|
if (tornDown)
|
|
244
333
|
return;
|
|
245
334
|
tornDown = true;
|
|
335
|
+
if (asksTimer !== undefined)
|
|
336
|
+
clearInterval(asksTimer);
|
|
337
|
+
graphOverlay.close();
|
|
246
338
|
clearPaneTag();
|
|
247
339
|
removeKeyListener();
|
|
248
340
|
// A clean detach tells the broker to drop this viewer (the engine runs on);
|
|
@@ -264,6 +356,53 @@ async function runAttach(nodeId, observer) {
|
|
|
264
356
|
}
|
|
265
357
|
resolveDone();
|
|
266
358
|
};
|
|
359
|
+
// The handshake the viewer sends on connect AND on every successful redial.
|
|
360
|
+
const sendHello = () => {
|
|
361
|
+
socket.send({
|
|
362
|
+
type: 'hello',
|
|
363
|
+
role: observer ? 'observer' : 'controller',
|
|
364
|
+
client_id: clientId,
|
|
365
|
+
term: { cols: process.stdout.columns ?? 80, rows: process.stdout.rows ?? 24 },
|
|
366
|
+
});
|
|
367
|
+
};
|
|
368
|
+
// Reconnect supervisor. A resident broker node that yields EXITS its broker
|
|
369
|
+
// (socket close) and is revived FRESH by the daemon on the SAME `view.sock`
|
|
370
|
+
// path within a few seconds. So on close we HOLD the pane and re-dial while
|
|
371
|
+
// the canvas-db row says the node is still alive (status active/idle), giving
|
|
372
|
+
// up only when it is genuinely terminal (done/dead/canceled or row gone) or a
|
|
373
|
+
// bounded ~30s of failed redials elapses (stale-row safety). A successful
|
|
374
|
+
// redial re-sends `hello`; the new broker's `welcome` runs applySnapshot,
|
|
375
|
+
// which resetChat()s the pane and rebuilds from the persisted .jsonl — a clean
|
|
376
|
+
// "clear + continuation" for free.
|
|
377
|
+
const RECONNECT_GIVEUP_MS = 30_000;
|
|
378
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
379
|
+
let reconnecting = false;
|
|
380
|
+
const attemptReconnect = async () => {
|
|
381
|
+
reconnecting = true;
|
|
382
|
+
setNotice('↻ node refreshing — reconnecting…');
|
|
383
|
+
const deadline = Date.now() + RECONNECT_GIVEUP_MS;
|
|
384
|
+
let delay = 200;
|
|
385
|
+
while (!tornDown && Date.now() < deadline) {
|
|
386
|
+
if (reconnectShouldGiveUp(getRow(nodeId))) {
|
|
387
|
+
break; // genuinely gone → fall through to broker-gone
|
|
388
|
+
}
|
|
389
|
+
try {
|
|
390
|
+
await socket.redial();
|
|
391
|
+
reconnecting = false;
|
|
392
|
+
sendHello();
|
|
393
|
+
setNotice('');
|
|
394
|
+
return; // reconnected; the welcome snapshot clears + continues the pane
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
// ENOENT (socket not recreated yet) / ECONNREFUSED (listen not ready) → retry.
|
|
398
|
+
await sleep(Math.min(delay, 1000));
|
|
399
|
+
delay *= 2;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
reconnecting = false;
|
|
403
|
+
if (!tornDown)
|
|
404
|
+
teardown('broker-gone');
|
|
405
|
+
};
|
|
267
406
|
// Permanent socket wiring (the connect-race listeners are gone).
|
|
268
407
|
socket.on('frame', handleBrokerFrame);
|
|
269
408
|
socket.on('error', (err) => {
|
|
@@ -271,7 +410,13 @@ async function runAttach(nodeId, observer) {
|
|
|
271
410
|
// broker-gone message prefers this over the generic text.
|
|
272
411
|
lastSocketError = err.message;
|
|
273
412
|
});
|
|
274
|
-
socket.on('close', () =>
|
|
413
|
+
socket.on('close', () => {
|
|
414
|
+
if (tornDown)
|
|
415
|
+
return; // a real detach/signal already won
|
|
416
|
+
if (reconnecting)
|
|
417
|
+
return; // the dead socket's own close during the redial loop
|
|
418
|
+
void attemptReconnect();
|
|
419
|
+
});
|
|
275
420
|
// ctrl+c / ctrl+d → detach (T6 left these unwired; lifecycle is ours). A
|
|
276
421
|
// global TUI input listener fires BEFORE the focused component (and before any
|
|
277
422
|
// dialog overlay), so detach is unconditional and consistent.
|
|
@@ -280,6 +425,13 @@ async function runAttach(nodeId, observer) {
|
|
|
280
425
|
teardown('detach');
|
|
281
426
|
return { consume: true };
|
|
282
427
|
}
|
|
428
|
+
// alt+g toggles the GRAPH overlay. The global listener fires BEFORE the
|
|
429
|
+
// focused component, so this works whether the editor or the overlay holds
|
|
430
|
+
// focus (closing it from inside the overlay too).
|
|
431
|
+
if (matchesKey(data, 'alt+g')) {
|
|
432
|
+
graphOverlay.toggle();
|
|
433
|
+
return { consume: true };
|
|
434
|
+
}
|
|
283
435
|
return undefined;
|
|
284
436
|
});
|
|
285
437
|
// Pane kill / daemon teardown arrives as a signal (raw mode swallows ctrl+c as
|
|
@@ -291,17 +443,33 @@ async function runAttach(nodeId, observer) {
|
|
|
291
443
|
// 8. Lay out, focus the editor, start, then handshake. Starting before the
|
|
292
444
|
// handshake means the welcome's applySnapshot renders into a live TUI.
|
|
293
445
|
tui.addChild(chatContainer);
|
|
294
|
-
tui.addChild(
|
|
446
|
+
tui.addChild(badge);
|
|
447
|
+
tui.addChild(managers);
|
|
295
448
|
tui.addChild(editor);
|
|
449
|
+
tui.addChild(reports);
|
|
450
|
+
tui.addChild(footer);
|
|
296
451
|
tui.setFocus(editor);
|
|
297
452
|
renderFooter();
|
|
453
|
+
renderBadge();
|
|
454
|
+
renderPanels();
|
|
298
455
|
tui.start();
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
456
|
+
sendHello();
|
|
457
|
+
// Low-rate canvas-chrome refresh: repaint panels/overlay from canvas.db (child
|
|
458
|
+
// status flips emit no broker event) and re-poll the ask map asynchronously.
|
|
459
|
+
const ASKS_POLL_MS = 5_000;
|
|
460
|
+
const pollChrome = () => {
|
|
461
|
+
refreshChrome();
|
|
462
|
+
fetchAsksAsync(climbRoot(nodeId), (counts) => {
|
|
463
|
+
if (JSON.stringify(counts) !== JSON.stringify(asksMap)) {
|
|
464
|
+
asksMap = counts;
|
|
465
|
+
refreshChrome();
|
|
466
|
+
}
|
|
467
|
+
});
|
|
468
|
+
};
|
|
469
|
+
pollChrome();
|
|
470
|
+
asksTimer = setInterval(pollChrome, ASKS_POLL_MS);
|
|
471
|
+
if (typeof asksTimer.unref === 'function')
|
|
472
|
+
asksTimer.unref();
|
|
305
473
|
await done;
|
|
306
474
|
}
|
|
307
475
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface CanvasPanelLines {
|
|
2
|
+
/** Above-editor manager line(s) — empty for a root node (no manager). */
|
|
3
|
+
managers: string[];
|
|
4
|
+
/** Below-editor report row(s) + self's trailing ⚑ asks line — empty when none. */
|
|
5
|
+
reports: string[];
|
|
6
|
+
}
|
|
7
|
+
/** Build the BASE panel line stacks for `nodeId` from canvas.db (ported verbatim
|
|
8
|
+
* from canvas-nav.ts renderBase). `asks` is the per-node pending-ask map the
|
|
9
|
+
* caller polls; pass `{}` to omit the ⚑ badges. */
|
|
10
|
+
export declare function buildCanvasPanelLines(nodeId: string, asks: Record<string, number>): CanvasPanelLines;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// canvas-panels.ts — the subscribed-node panel for `crtr attach` (Unit Q).
|
|
2
|
+
//
|
|
3
|
+
// Native reimplementation of canvas-nav.ts's BASE chrome (renderBase), reading
|
|
4
|
+
// the subscription graph DIRECTLY from canvas.db via the shared nav-model layer
|
|
5
|
+
// (no pi extension host, no broker frame). Produces the two line stacks the
|
|
6
|
+
// viewer paints into Containers around the editor:
|
|
7
|
+
// managers → ABOVE the editor: this node's manager (its first subscriber).
|
|
8
|
+
// reports → BELOW the editor: this node's live reports (active|idle
|
|
9
|
+
// subscriptions), plus a trailing ⚑ line for self's own asks.
|
|
10
|
+
//
|
|
11
|
+
// Pure: given a node id + the per-node ask map it returns ANSI strings; the
|
|
12
|
+
// caller owns the Containers and the refresh trigger. beginFrame() memoizes the
|
|
13
|
+
// canvas.db reads for this one build pass (mirrors canvas-nav's per-frame cache),
|
|
14
|
+
// so a whole panel rebuild is a single fan-out of disk/db reads.
|
|
15
|
+
import { beginFrame, cNode, managerOf, liveReports, climbRoot, computeSubtreeActivity, navLabel, coloredGlyph, truncate, tokensCell, cycleBadge, childBadge, liveBelowBadge, askBadge, activityCell, DIM, RESET, YELLOW, } from '../../core/canvas/nav-model.js';
|
|
16
|
+
/** Build the BASE panel line stacks for `nodeId` from canvas.db (ported verbatim
|
|
17
|
+
* from canvas-nav.ts renderBase). `asks` is the per-node pending-ask map the
|
|
18
|
+
* caller polls; pass `{}` to omit the ⚑ badges. */
|
|
19
|
+
export function buildCanvasPanelLines(nodeId, asks) {
|
|
20
|
+
// Fresh snapshot: drop the previous build's memoized reads so this pass sees
|
|
21
|
+
// current disk+db state, then read-once within it.
|
|
22
|
+
beginFrame();
|
|
23
|
+
// One subtree-activity pass (rooted at the ancestry root) feeds the ⇣N
|
|
24
|
+
// live-work-below badge on both the manager line and every report row.
|
|
25
|
+
const activity = computeSubtreeActivity(climbRoot(nodeId), nodeId);
|
|
26
|
+
const managers = [];
|
|
27
|
+
const mgr = managerOf(nodeId);
|
|
28
|
+
if (mgr !== undefined) {
|
|
29
|
+
const mn = cNode(mgr);
|
|
30
|
+
const name = navLabel(mn, mgr);
|
|
31
|
+
managers.push(truncate(`↑ ${name} ${coloredGlyph(mn)} ${DIM}${mn?.kind ?? ''}${RESET} ${DIM}${tokensCell(mgr)}${RESET}${cycleBadge(mn)}${childBadge(mn)}${liveBelowBadge(mn, activity.activeBelow)}${askBadge(mgr, asks)}${activityCell(mgr, mn)}`));
|
|
32
|
+
}
|
|
33
|
+
const reports = [];
|
|
34
|
+
const live = liveReports(nodeId);
|
|
35
|
+
if (live.length > 0) {
|
|
36
|
+
const nameW = Math.min(20, Math.max(...live.map((id) => navLabel(cNode(id), id).length)));
|
|
37
|
+
for (const id of live) {
|
|
38
|
+
const n = cNode(id);
|
|
39
|
+
const name = navLabel(n, id).padEnd(nameW);
|
|
40
|
+
const kind = `${DIM}${(n?.kind ?? '').padEnd(6)}${RESET}`;
|
|
41
|
+
const tokens = `${DIM}${tokensCell(id).padStart(5)}${RESET}`;
|
|
42
|
+
reports.push(truncate(` ${coloredGlyph(n)} ${name} ${kind} ${tokens}${cycleBadge(n)}${childBadge(n)}${liveBelowBadge(n, activity.activeBelow)}${askBadge(id, asks)}${activityCell(id, n)}`));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// Self's own pending asks (no self row in BASE) → a trailing inline line.
|
|
46
|
+
const selfAsks = asks[nodeId] ?? 0;
|
|
47
|
+
if (selfAsks > 0)
|
|
48
|
+
reports.push(`${YELLOW}⚑${selfAsks}${RESET}`);
|
|
49
|
+
return { managers, reports };
|
|
50
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type Component, type TUI } from '@earendil-works/pi-tui';
|
|
2
|
+
export declare class GraphOverlay implements Component {
|
|
3
|
+
private readonly tui;
|
|
4
|
+
private readonly self;
|
|
5
|
+
private readonly getAsks;
|
|
6
|
+
private handle;
|
|
7
|
+
/** Manual fold OVERRIDES (h collapses → userCollapsed, l expands → userExpanded);
|
|
8
|
+
* both override the default activity-driven policy and survive open/close. */
|
|
9
|
+
private readonly userExpanded;
|
|
10
|
+
private readonly userCollapsed;
|
|
11
|
+
private readonly folds;
|
|
12
|
+
/** Cursor as a node id (indices shift as topology changes). */
|
|
13
|
+
private cursorId;
|
|
14
|
+
/** Viewport scroll offset (row index of the top visible tree row). */
|
|
15
|
+
private scrollTop;
|
|
16
|
+
/** Transient y/n confirm gate (kill). */
|
|
17
|
+
private pendingConfirm;
|
|
18
|
+
constructor(tui: TUI, self: string, getAsks: () => Record<string, number>);
|
|
19
|
+
isOpen(): boolean;
|
|
20
|
+
open(): void;
|
|
21
|
+
close(): void;
|
|
22
|
+
toggle(): void;
|
|
23
|
+
/** Repaint if shown (the low-rate ask poll calls this). */
|
|
24
|
+
refresh(): void;
|
|
25
|
+
invalidate(): void;
|
|
26
|
+
render(width: number): string[];
|
|
27
|
+
handleInput(data: string): void;
|
|
28
|
+
private dispatch;
|
|
29
|
+
/** Swap `id` into THIS viewer's tmux pane (mirrors canvas browse's return-pane
|
|
30
|
+
* shell). Default --pane is the caller TMUX_PANE; we pass it explicitly when
|
|
31
|
+
* set so the focus lands here even if env resolution differs. */
|
|
32
|
+
private focusTarget;
|
|
33
|
+
private shellCrtr;
|
|
34
|
+
}
|