@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.
Files changed (68) hide show
  1. package/dist/builtin-memory/internal/INDEX.md +1 -1
  2. package/dist/builtin-personas/runtime-base.md +3 -0
  3. package/dist/clients/attach/__tests__/reconnect-giveup.test.d.ts +1 -0
  4. package/dist/clients/attach/__tests__/reconnect-giveup.test.js +30 -0
  5. package/dist/clients/attach/attach-cmd.js +187 -19
  6. package/dist/clients/attach/canvas-panels.d.ts +10 -0
  7. package/dist/clients/attach/canvas-panels.js +50 -0
  8. package/dist/clients/attach/graph-overlay.d.ts +34 -0
  9. package/dist/clients/attach/graph-overlay.js +266 -0
  10. package/dist/clients/attach/input-controller.d.ts +6 -0
  11. package/dist/clients/attach/input-controller.js +2 -0
  12. package/dist/clients/attach/slash-commands.d.ts +22 -1
  13. package/dist/clients/attach/slash-commands.js +160 -3
  14. package/dist/clients/attach/view-socket.d.ts +19 -1
  15. package/dist/clients/attach/view-socket.js +61 -6
  16. package/dist/commands/human/prompts.js +3 -3
  17. package/dist/commands/human/queue.d.ts +17 -0
  18. package/dist/commands/human/queue.js +111 -4
  19. package/dist/commands/memory/__tests__/lint-schema.test.js +24 -1
  20. package/dist/commands/memory/lint.d.ts +5 -4
  21. package/dist/commands/memory/lint.js +9 -5
  22. package/dist/commands/memory/write.js +19 -2
  23. package/dist/commands/memory.js +1 -1
  24. package/dist/commands/sys/feedback.d.ts +1 -0
  25. package/dist/commands/sys/feedback.js +163 -0
  26. package/dist/commands/sys.js +3 -2
  27. package/dist/core/__tests__/broker-snapshot-history.test.d.ts +1 -0
  28. package/dist/core/__tests__/broker-snapshot-history.test.js +105 -0
  29. package/dist/core/__tests__/fixtures/fake-engine.d.ts +7 -0
  30. package/dist/core/__tests__/fixtures/fake-engine.js +10 -0
  31. package/dist/core/__tests__/full/placement-teardown.test.js +76 -0
  32. package/dist/core/__tests__/human-stranded-deliver.test.d.ts +1 -0
  33. package/dist/core/__tests__/human-stranded-deliver.test.js +108 -0
  34. package/dist/core/__tests__/on-read-dedup-resume.test.d.ts +1 -0
  35. package/dist/core/__tests__/on-read-dedup-resume.test.js +81 -0
  36. package/dist/core/canvas/nav-model.d.ts +162 -0
  37. package/dist/core/canvas/nav-model.js +486 -0
  38. package/dist/core/canvas/paths.d.ts +7 -0
  39. package/dist/core/canvas/paths.js +9 -0
  40. package/dist/core/runtime/broker-sdk.d.ts +0 -12
  41. package/dist/core/runtime/broker-sdk.js +77 -6
  42. package/dist/core/runtime/broker.d.ts +2 -1
  43. package/dist/core/runtime/broker.js +26 -1
  44. package/dist/core/runtime/front-door.js +23 -8
  45. package/dist/core/runtime/naming.d.ts +1 -5
  46. package/dist/core/runtime/naming.js +33 -49
  47. package/dist/core/runtime/placement.d.ts +7 -6
  48. package/dist/core/runtime/placement.js +24 -12
  49. package/dist/core/runtime/revive.js +9 -0
  50. package/dist/core/runtime/spawn.d.ts +5 -0
  51. package/dist/core/runtime/spawn.js +69 -11
  52. package/dist/core/runtime/tmux.d.ts +9 -0
  53. package/dist/core/runtime/tmux.js +12 -0
  54. package/dist/core/spawn.d.ts +14 -0
  55. package/dist/core/spawn.js +29 -9
  56. package/dist/core/substrate/index.d.ts +1 -1
  57. package/dist/core/substrate/index.js +6 -6
  58. package/dist/core/substrate/injected-store.d.ts +10 -0
  59. package/dist/core/substrate/injected-store.js +55 -0
  60. package/dist/core/substrate/schema.d.ts +6 -8
  61. package/dist/core/substrate/schema.js +26 -28
  62. package/dist/pi-extensions/canvas-doc-substrate.js +16 -7
  63. package/dist/pi-extensions/canvas-goal-capture.js +38 -22
  64. package/dist/pi-extensions/canvas-nav.js +30 -385
  65. package/dist/pi-extensions/canvas-stophook.d.ts +1 -1
  66. package/dist/pi-extensions/canvas-stophook.js +32 -2
  67. package/package.json +1 -1
  68. 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 (frontmatter, rungs, gates, the asked-to-remember workflow) is owned by the builtin **memory-authoring** skill; making a persona (a custom `--kind`) is owned by the builtin **crouter-development/personas** skills.
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,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 { Container, ProcessTerminal, Text, TUI, matchesKey, } from '@earendil-works/pi-tui';
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 + chat container + editor.
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
- // 5. Input layer (T6). onCommand/onDialogResponse socket; onNotice footer.
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
- // Dynamic-command autocomplete (feeding get_commands into T6's palette)
189
- // is DEFERRED for Phase 4 (would require editing T6's input-controller —
190
- // out of my 3-file scope; the fall-through-to-prompt path already makes
191
- // extension commands functional). Surface only command failures here.
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', () => teardown('broker-gone'));
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(footer);
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
- socket.send({
300
- type: 'hello',
301
- role: observer ? 'observer' : 'controller',
302
- client_id: clientId,
303
- term: { cols: process.stdout.columns ?? 80, rows: process.stdout.rows ?? 24 },
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
+ }