@crouton-kit/crouter 0.3.27 → 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 (62) hide show
  1. package/dist/builtin-personas/runtime-base.md +3 -0
  2. package/dist/clients/attach/__tests__/reconnect-giveup.test.d.ts +1 -0
  3. package/dist/clients/attach/__tests__/reconnect-giveup.test.js +30 -0
  4. package/dist/clients/attach/attach-cmd.js +187 -19
  5. package/dist/clients/attach/canvas-panels.d.ts +10 -0
  6. package/dist/clients/attach/canvas-panels.js +50 -0
  7. package/dist/clients/attach/graph-overlay.d.ts +34 -0
  8. package/dist/clients/attach/graph-overlay.js +266 -0
  9. package/dist/clients/attach/input-controller.d.ts +6 -0
  10. package/dist/clients/attach/input-controller.js +2 -0
  11. package/dist/clients/attach/slash-commands.d.ts +22 -1
  12. package/dist/clients/attach/slash-commands.js +160 -3
  13. package/dist/clients/attach/view-socket.d.ts +19 -1
  14. package/dist/clients/attach/view-socket.js +61 -6
  15. package/dist/commands/human/prompts.js +3 -3
  16. package/dist/commands/human/queue.d.ts +17 -0
  17. package/dist/commands/human/queue.js +111 -4
  18. package/dist/commands/memory/__tests__/lint-schema.test.js +24 -1
  19. package/dist/commands/memory/lint.d.ts +5 -4
  20. package/dist/commands/memory/lint.js +9 -5
  21. package/dist/commands/memory/write.js +12 -3
  22. package/dist/commands/sys/feedback.d.ts +1 -0
  23. package/dist/commands/sys/feedback.js +163 -0
  24. package/dist/commands/sys.js +3 -2
  25. package/dist/core/__tests__/broker-snapshot-history.test.d.ts +1 -0
  26. package/dist/core/__tests__/broker-snapshot-history.test.js +105 -0
  27. package/dist/core/__tests__/fixtures/fake-engine.d.ts +7 -0
  28. package/dist/core/__tests__/fixtures/fake-engine.js +10 -0
  29. package/dist/core/__tests__/full/placement-teardown.test.js +76 -0
  30. package/dist/core/__tests__/human-stranded-deliver.test.d.ts +1 -0
  31. package/dist/core/__tests__/human-stranded-deliver.test.js +108 -0
  32. package/dist/core/__tests__/on-read-dedup-resume.test.d.ts +1 -0
  33. package/dist/core/__tests__/on-read-dedup-resume.test.js +81 -0
  34. package/dist/core/canvas/nav-model.d.ts +162 -0
  35. package/dist/core/canvas/nav-model.js +486 -0
  36. package/dist/core/canvas/paths.d.ts +7 -0
  37. package/dist/core/canvas/paths.js +9 -0
  38. package/dist/core/runtime/broker-sdk.d.ts +0 -12
  39. package/dist/core/runtime/broker-sdk.js +77 -6
  40. package/dist/core/runtime/broker.d.ts +2 -1
  41. package/dist/core/runtime/broker.js +26 -1
  42. package/dist/core/runtime/front-door.js +23 -8
  43. package/dist/core/runtime/placement.d.ts +7 -6
  44. package/dist/core/runtime/placement.js +24 -12
  45. package/dist/core/runtime/revive.js +9 -0
  46. package/dist/core/runtime/spawn.d.ts +5 -0
  47. package/dist/core/runtime/spawn.js +62 -1
  48. package/dist/core/runtime/tmux.d.ts +9 -0
  49. package/dist/core/runtime/tmux.js +12 -0
  50. package/dist/core/spawn.d.ts +14 -0
  51. package/dist/core/spawn.js +29 -9
  52. package/dist/core/substrate/index.d.ts +1 -1
  53. package/dist/core/substrate/index.js +6 -6
  54. package/dist/core/substrate/injected-store.d.ts +10 -0
  55. package/dist/core/substrate/injected-store.js +55 -0
  56. package/dist/core/substrate/schema.d.ts +6 -8
  57. package/dist/core/substrate/schema.js +26 -28
  58. package/dist/pi-extensions/canvas-doc-substrate.js +16 -7
  59. package/dist/pi-extensions/canvas-nav.js +30 -385
  60. package/dist/pi-extensions/canvas-stophook.d.ts +1 -1
  61. package/dist/pi-extensions/canvas-stophook.js +32 -2
  62. package/package.json +1 -1
@@ -1,9 +1,16 @@
1
1
  // schema.ts — the typed shape of a substrate document's frontmatter, the
2
- // visibility ladder, the kind→default-rungs table, and the parse/validate
3
- // function that turns a resolved MemoryDoc into a fully-typed SubstrateDoc with
4
- // defaults applied. This is the keystone every downstream track (CLI verbs,
5
- // boot render, on-read render, migrator) builds against — pure and side-effect
6
- // free. See design-substrate.md §4 (schema) + §9 (defaults).
2
+ // visibility ladder, and the parse/validate function that turns a resolved
3
+ // MemoryDoc into a fully-typed SubstrateDoc. This is the keystone every
4
+ // downstream track (CLI verbs, boot render, on-read render, migrator) builds
5
+ // against — pure and side-effect free. See design-substrate.md §4 (schema).
6
+ //
7
+ // There is NO kind-based default for visibility: the right rung is a
8
+ // case-by-case authoring call, so both rungs are required at authoring time
9
+ // (enforced by `crtr memory write` on create and by `crtr memory lint`). The
10
+ // runtime parser is tolerant by contract (it maps over many docs and must
11
+ // never throw), so a doc missing/with an invalid rung falls back to the neutral
12
+ // floor `none` — a malformed doc renders invisible rather than crashing, and
13
+ // lint flags it. Valid docs never hit the fallback.
7
14
  // ---------------------------------------------------------------------------
8
15
  // Kinds — the three semantic kinds (design §3). `kind` is data, not a fork.
9
16
  // ---------------------------------------------------------------------------
@@ -28,44 +35,35 @@ export function rungAtLeast(r, min) {
28
35
  return rungRank(r) >= rungRank(min);
29
36
  }
30
37
  // ---------------------------------------------------------------------------
31
- // The kind→default-rungs table (design §9). When an author omits a visibility
32
- // field, its default is a function of `kind` chosen so the common case is
33
- // correct with no thought and the migration is behavior-preserving for skills
34
- // and preferences. One data row per kind, NOT a code fork.
35
- //
36
- // kind system-prompt-visibility file-read-visibility
37
- // skill name none
38
- // preference preview none
39
- // reference none preview
38
+ // The neutral fallback rung used only when a doc omits a visibility field or
39
+ // carries an invalid one. NOT a default an author may lean on authoring-time
40
+ // enforcement requires explicit rungs; this is purely the runtime parser's
41
+ // never-throw floor (a malformed doc renders invisible, and lint flags it).
40
42
  // ---------------------------------------------------------------------------
41
- export const KIND_DEFAULT_RUNGS = {
42
- skill: { systemPrompt: 'name', fileRead: 'none' },
43
- preference: { systemPrompt: 'preview', fileRead: 'none' },
44
- reference: { systemPrompt: 'none', fileRead: 'preview' },
45
- };
43
+ export const FALLBACK_RUNG = 'none';
46
44
  // ---------------------------------------------------------------------------
47
45
  // Parse / validate.
48
46
  // ---------------------------------------------------------------------------
49
47
  /** Parse a raw frontmatter record (from `parseFrontmatterGeneric`, via the
50
48
  * resolver) into a typed schema with defaults applied. Returns `null` when the
51
49
  * record is absent or carries no valid `kind` — i.e. it is not a substrate
52
- * document and cannot be classified or defaulted. Tolerant of every other
53
- * imperfection (a missing `when-and-why-to-read` defaults to '', a bad rung
54
- * falls back to the kind default), so a renderer mapping over many docs never
55
- * throws. Authoring-time enforcement of the field lives in `crtr memory lint`. */
50
+ * document and cannot be classified. Tolerant of every other imperfection (a
51
+ * missing `when-and-why-to-read` defaults to '', a missing/bad rung falls back
52
+ * to the neutral floor `none`), so a renderer mapping over many docs never
53
+ * throws. Authoring-time enforcement of these fields lives in `crtr memory
54
+ * write` (on create) and `crtr memory lint`. */
56
55
  export function parseSubstrateFrontmatter(fm) {
57
56
  if (fm === null)
58
57
  return null;
59
58
  if (!isDocKind(fm.kind))
60
59
  return null;
61
60
  const kind = fm.kind;
62
- const defaults = KIND_DEFAULT_RUNGS[kind];
63
61
  return {
64
62
  kind,
65
63
  whenAndWhyToRead: strField(fm['when-and-why-to-read']),
66
64
  shortForm: strField(fm['short-form']),
67
- systemPromptVisibility: parseRung(fm['system-prompt-visibility'], defaults.systemPrompt),
68
- fileReadVisibility: parseRung(fm['file-read-visibility'], defaults.fileRead),
65
+ systemPromptVisibility: parseRung(fm['system-prompt-visibility'], FALLBACK_RUNG),
66
+ fileReadVisibility: parseRung(fm['file-read-visibility'], FALLBACK_RUNG),
69
67
  gate: parseGate(fm.gate),
70
68
  appliesTo: parseAppliesTo(fm['applies-to']),
71
69
  };
@@ -95,8 +93,8 @@ export function previewLine(doc) {
95
93
  function strField(v) {
96
94
  return typeof v === 'string' ? v : '';
97
95
  }
98
- /** Resolve a visibility field to a ladder rung, falling back to the kind
99
- * default when absent/invalid. */
96
+ /** Resolve a visibility field to a ladder rung, falling back to the neutral
97
+ * floor when absent/invalid. */
100
98
  function parseRung(v, fallback) {
101
99
  return typeof v === 'string' && RUNGS.includes(v)
102
100
  ? v
@@ -36,6 +36,7 @@ import { homedir } from 'node:os';
36
36
  import { join, resolve } from 'node:path';
37
37
  import { renderMemoryGuidance, renderOnReadDocs, renderPreferencesSection, renderSkillsSection, } from '../core/substrate/index.js';
38
38
  import { clearSessionCache } from '../core/substrate/session-cache.js';
39
+ import { loadInjectedDocs, saveInjectedDocs } from '../core/substrate/injected-store.js';
39
40
  // ---------------------------------------------------------------------------
40
41
  // Extension
41
42
  // ---------------------------------------------------------------------------
@@ -55,14 +56,19 @@ export function registerCanvasDocSubstrate(pi) {
55
56
  const nodeId = process.env['CRTR_NODE_ID'];
56
57
  if (nodeId === undefined || nodeId.trim() === '')
57
58
  return; // not a canvas node — inert
58
- // Per-session set of injected doc realpaths → a doc surfaces at most once per
59
- // session across repeated reads. Cleared on every session_start (so a resume
60
- // starts fresh, matching the on-read precedent).
61
- // Also clears the per-session substrate parse cache so the corpus is re-scanned
62
- // fresh for each new session (avoids stale docs after a skill/memory write).
63
- const injectedDocs = new Set();
59
+ // Per-TRANSCRIPT set of injected doc realpaths → a doc surfaces at most once
60
+ // across the node's conversation, EVEN across a dormancy revive(resume)
61
+ // cycle. A resume reuses the same .jsonl transcript in a NEW pi process, so
62
+ // the set is REHYDRATED from disk at process start (not started empty) and is
63
+ // NOT cleared on session_start a resume continues the transcript, so the
64
+ // dedup must carry forward. The launch paths that begin a FRESH transcript
65
+ // (revive resume=false, reviveInPlace, relaunchRootInPane) delete the file, so
66
+ // a new conversation rehydrates empty here. See core/substrate/injected-store.ts.
67
+ const injectedDocs = loadInjectedDocs(nodeId);
64
68
  pi.on('session_start', () => {
65
- injectedDocs.clear();
69
+ // Only the per-session substrate PARSE cache resets each session (so the
70
+ // corpus is re-scanned, picking up skill/memory writes). injectedDocs is
71
+ // transcript-scoped, not session-scoped — deliberately NOT cleared here.
66
72
  clearSessionCache();
67
73
  });
68
74
  // 1. BOOT system-prompt half — splice `<skills>` + `<preferences>` +
@@ -109,6 +115,9 @@ export function registerCanvasDocSubstrate(pi) {
109
115
  const injected = renderOnReadDocs(nodeId, absFile, injectedDocs);
110
116
  if (injected === '')
111
117
  return; // nothing surfaced — pass the read through unchanged
118
+ // A doc surfaced → renderOnReadDocs grew injectedDocs; persist the set so
119
+ // the dedup survives a later dormancy → revive(resume).
120
+ saveInjectedDocs(nodeId, injectedDocs);
112
121
  // Prepend the surfacing docs ahead of the file contents.
113
122
  return { content: [{ type: 'text', text: injected }, ...event.content] };
114
123
  }
@@ -43,10 +43,9 @@
43
43
  //
44
44
  // Plain TS-with-types — no imports from @earendil-works/* so this compiles
45
45
  // inside crouter's own tsc build without a dep on the pi packages.
46
- import { execFile, execFileSync } from 'node:child_process';
47
- import { existsSync, readFileSync } from 'node:fs';
48
- import { join } from 'node:path';
49
- import { getNode, subscribersOf, subscriptionsOf, jobDir, fullName, listFocuses } from '../core/canvas/index.js';
46
+ import { execFile } from 'node:child_process';
47
+ import { fullName } from '../core/canvas/index.js';
48
+ import { beginFrame, cNode, managerOf, liveReports, sortedChildIds, subtreeIds, climbRoot, computeSubtreeActivity, buildGraphModel, renderGraphRow, navLabel, coloredGlyph, truncate, tokensCell, cycleBadge, childBadge, liveBelowBadge, askBadge, activityCell, focusedNodeIds, graphWidgetBudget, fetchAsksMap, shortId, DIM, RESET, BOLD, YELLOW, GRAPH_HINT, } from '../core/canvas/nav-model.js';
50
49
  import { readConfig } from '../core/config.js';
51
50
  // ---------------------------------------------------------------------------
52
51
  // Module-level state — persists across /reload so guards don't stack and fold
@@ -65,6 +64,10 @@ let view = 'base';
65
64
  * userExpanded. Both survive renders AND BASE↔GRAPH toggles. */
66
65
  const userCollapsed = new Set();
67
66
  const userExpanded = new Set();
67
+ /** A live view of the manual fold overrides for the pure nav-model layer; the
68
+ * Sets above are mutated in place, so this reference stays current across
69
+ * renders and BASE↔GRAPH toggles. */
70
+ const folds = { userExpanded, userCollapsed };
68
71
  /** GRAPH cursor (a node id, not an index — indices shift as topology changes). */
69
72
  let cursorId;
70
73
  /** GRAPH viewport scroll offset (row index of the top visible row). */
@@ -78,366 +81,6 @@ let asksMap = {};
78
81
  // ---------------------------------------------------------------------------
79
82
  const ASK_POLL_MS = 5_000;
80
83
  const RENDER_DEBOUNCE_MS = 150;
81
- /** pi's InteractiveMode.MAX_WIDGET_LINES — the hard cap on lines in a string
82
- * array widget; anything beyond it pi truncates with its own "... (widget
83
- * truncated)". Our GRAPH viewport stays at/under this and scrolls internally. */
84
- const PI_MAX_WIDGET_LINES = 10;
85
- const VIEWPORT_FALLBACK_ROWS = 30;
86
- // ---------------------------------------------------------------------------
87
- // ANSI styling. pi renders embedded escapes in widget lines and measures width
88
- // ANSI-aware, so raw escapes are safe and need no pi-tui dependency. The cursor
89
- // (selected row) uses a theme-agnostic ATTRIBUTE (reverse), so it reads under
90
- // NO_COLOR; the attached-row tint is a background COLOUR, while the dot glyph
91
- // (●/○/✓/✗) carries the running signal independently, even where colour is
92
- // stripped.
93
- // ---------------------------------------------------------------------------
94
- const ESC = '\x1b[';
95
- const RESET = `${ESC}0m`;
96
- const BOLD = `${ESC}1m`;
97
- const DIM = `${ESC}2m`;
98
- const REVERSE = `${ESC}7m`;
99
- /** Dark-green background bar marking an ATTACHED node (a human is currently
100
- * watching it) — distinct from the cursor's reverse-video bar; chosen so
101
- * default-fg text stays readable. */
102
- const BG_ATTACHED = `${ESC}48;5;22m`;
103
- const GREEN = `${ESC}32m`;
104
- const RED = `${ESC}31m`;
105
- const YELLOW = `${ESC}33m`;
106
- const CYAN = `${ESC}36m`;
107
- const GRAY = `${ESC}90m`;
108
- /** Status glyph colored by state: active green, idle dim, done cyan, dead red. */
109
- function coloredGlyph(node) {
110
- if (node === null)
111
- return '?';
112
- switch (node.status) {
113
- case 'active': return `${GREEN}●${RESET}`;
114
- case 'idle': return `${GRAY}○${RESET}`;
115
- case 'done': return `${CYAN}✓${RESET}`;
116
- case 'dead': return `${RED}✗${RESET}`;
117
- case 'canceled': return `${YELLOW}⊘${RESET}`;
118
- default: return '?';
119
- }
120
- }
121
- const ANSI_RE = /\x1b\[[0-9;]*m/g;
122
- /** Visible width, ignoring ANSI escapes. */
123
- function visibleWidth(s) {
124
- return s.replace(ANSI_RE, '').length;
125
- }
126
- /** Truncate to `max` VISIBLE columns: escape sequences are copied through
127
- * verbatim (so a cut never lands mid-escape) and the result always ends in
128
- * RESET, so a clipped style can't bleed into the editor below. */
129
- function truncate(s, max = fillWidth()) {
130
- if (visibleWidth(s) <= max)
131
- return s;
132
- let out = '';
133
- let w = 0;
134
- let i = 0;
135
- while (i < s.length && w < max - 1) {
136
- if (s[i] === '\x1b') {
137
- const m = /^\x1b\[[0-9;]*m/.exec(s.slice(i));
138
- if (m) {
139
- out += m[0];
140
- i += m[0].length;
141
- continue;
142
- }
143
- }
144
- out += s[i];
145
- w++;
146
- i++;
147
- }
148
- return `${out}…${RESET}`;
149
- }
150
- /** Visible columns available to ONE widget line — the cap for every line we
151
- * emit, and the width a full-width reverse-video SELF bar fills to.
152
- *
153
- * pi does NOT clip widget lines; it WRAPS them. Each string line is wrapped in
154
- * a `Text(paddingX = 1)` inside a full-terminal-width container, so the usable
155
- * content width is `columns - 2` (a 1-col margin on each side). A line wider
156
- * than that wraps, and the overflow spills onto a second row as a stray
157
- * reverse-video block (the bug this guards against). Clamp to `columns - 2`. */
158
- function fillWidth() {
159
- return Math.max(20, Math.min((process.stdout.columns ?? 80) - 2, 180));
160
- }
161
- /** Wrap `content` in a full-width background bar opened by `open` (REVERSE for
162
- * the cursor, BG_ATTACHED for a human-watched node). `open` is re-asserted after every
163
- * embedded RESET so a coloured cell (the status dot) can't punch a hole in the
164
- * bar; the visible width is padded out to `width`; the line closes with a real
165
- * RESET so the style never bleeds into the editor below. */
166
- function fillBar(content, width, open) {
167
- const clipped = truncate(content, width);
168
- const reasserted = clipped.replace(/\x1b\[0m/g, `${RESET}${open}`);
169
- const pad = Math.max(0, width - visibleWidth(clipped));
170
- return `${open}${reasserted}${' '.repeat(pad)}${RESET}`;
171
- }
172
- function readTelemetry(nodeId) {
173
- try {
174
- const p = join(jobDir(nodeId), 'telemetry.json');
175
- if (!existsSync(p))
176
- return {};
177
- return JSON.parse(readFileSync(p, 'utf8'));
178
- }
179
- catch {
180
- return {};
181
- }
182
- }
183
- function fmtTokens(n) {
184
- return n < 1_000 ? `${n}` : `${Math.round(n / 1_000)}k`;
185
- }
186
- function tokensCell(id) {
187
- return fmtTokens(readTelemetry(id).tokens_in ?? 0);
188
- }
189
- function shortId(id) {
190
- return id.slice(0, 8);
191
- }
192
- // ---------------------------------------------------------------------------
193
- // Attachment — is a human currently WATCHING a node? A separate axis from
194
- // running (status 'active' = the engine is live on its host, which may be an
195
- // unwatched backstage pane or a paneless broker). Two hosts, two signals:
196
- // tmux — a `focuses` row points at the node (one cheap sqlite read per
197
- // render pass; pane-existence alone is NOT the signal).
198
- // broker — the broker persists its helloed-viewer count to job/attach.json
199
- // on every viewer change (src/core/runtime/broker.ts). Trusted only
200
- // while the node is 'active': a broker crash can leave a stale file.
201
- // ---------------------------------------------------------------------------
202
- /** Node ids currently shown in a tmux focus viewport. Built once per render. */
203
- function focusedNodeIds() {
204
- try {
205
- return new Set(listFocuses().map((f) => f.node_id));
206
- }
207
- catch {
208
- return new Set();
209
- }
210
- }
211
- /** True when a human is watching `id` right now (tmux focus or broker viewer). */
212
- function isAttached(id, node, focused) {
213
- if (focused.has(id))
214
- return true;
215
- if (node?.status !== 'active')
216
- return false; // stale attach.json from a crash
217
- try {
218
- const p = join(jobDir(id), 'attach.json');
219
- if (!existsSync(p))
220
- return false;
221
- const rec = JSON.parse(readFileSync(p, 'utf8'));
222
- return typeof rec.viewers === 'number' && rec.viewers > 0;
223
- }
224
- catch {
225
- return false;
226
- }
227
- }
228
- // ---------------------------------------------------------------------------
229
- // Per-node ask counts — ONE shell-out per poll. `crtr canvas attention map`
230
- // buckets a whole sub-DAG's pending asks by node in a single process, so the
231
- // timer stays cheap (< 2 s) regardless of how many nodes are visible. --json
232
- // gives a parseable {counts} blob (the default render is XML chrome).
233
- // ---------------------------------------------------------------------------
234
- function fetchAsksMap(rootId) {
235
- try {
236
- const raw = execFileSync('crtr', ['canvas', 'attention', 'map', '--view', rootId, '--json'], {
237
- timeout: 2_500,
238
- encoding: 'utf8',
239
- });
240
- const parsed = JSON.parse(raw.trim());
241
- return parsed.counts ?? {};
242
- }
243
- catch {
244
- return {};
245
- }
246
- }
247
- // ---------------------------------------------------------------------------
248
- // Graph queries (dependency-free, straight off the canvas db)
249
- // ---------------------------------------------------------------------------
250
- /** First manager (by created) — the UP step for the ancestry spine. */
251
- function managerOf(id) {
252
- try {
253
- return subscribersOf(id)[0]?.node_id;
254
- }
255
- catch {
256
- return undefined;
257
- }
258
- }
259
- /** A kind:'human' node is a control-plane ASK (a humanloop deck on the human's
260
- * screen), NOT a pi conversation — it has no session, so focusing/reviving it
261
- * boots a confused blank "you have been revived" pi. Its pending-ask signal
262
- * already rides the ⚑ badge on the ASKING node (attention.ts attributes asks by
263
- * source.nodeId, never to the human node), so the row carries no signal of its
264
- * own. Drop it from every navigable list (the tree, BASE reports, child counts,
265
- * subtree expansion) so it can never be selected. */
266
- function isHumanAsk(id) {
267
- return getNode(id)?.kind === 'human';
268
- }
269
- /** A node's direct children that are navigable conversations — human-ask nodes
270
- * dropped. The one place the nav chrome enumerates children. */
271
- function convoChildIds(id) {
272
- try {
273
- return subscriptionsOf(id).map((s) => s.node_id).filter((cid) => !isHumanAsk(cid));
274
- }
275
- catch {
276
- return [];
277
- }
278
- }
279
- /** Live reports (active|idle) of a node — the DOWN set in BASE. */
280
- function liveReports(id) {
281
- return convoChildIds(id).filter((cid) => {
282
- const st = getNode(cid)?.status;
283
- return st === 'active' || st === 'idle';
284
- });
285
- }
286
- /** Direct navigable children — used for the ⤳ badge and fold counts (human-ask
287
- * nodes excluded, so the count matches what the tree actually shows). */
288
- function childCount(id) {
289
- return convoChildIds(id).length;
290
- }
291
- /** Climb first-manager edges from `self` to the ancestry root (cycle-guarded). */
292
- function climbRoot(self) {
293
- let cur = self;
294
- const seen = new Set([cur]);
295
- for (;;) {
296
- const mgr = managerOf(cur);
297
- if (mgr === undefined || seen.has(mgr))
298
- break;
299
- seen.add(mgr);
300
- cur = mgr;
301
- }
302
- return cur;
303
- }
304
- /** Space-joined ids of a node's subtree (cursor-relative {subtree} var). */
305
- function subtreeIds(root) {
306
- const out = [];
307
- const seen = new Set([root]);
308
- const q = convoChildIds(root);
309
- while (q.length > 0) {
310
- const id = q.shift();
311
- if (seen.has(id))
312
- continue;
313
- seen.add(id);
314
- out.push(id);
315
- for (const cid of convoChildIds(id))
316
- if (!seen.has(cid))
317
- q.push(cid);
318
- }
319
- return out;
320
- }
321
- // ---------------------------------------------------------------------------
322
- // Shared cell builders
323
- // ---------------------------------------------------------------------------
324
- /** ⤳M direct-children badge — only on orchestrator rows. */
325
- function childBadge(node) {
326
- if (node === null || node.mode !== 'orchestrator')
327
- return '';
328
- const m = childCount(node.node_id);
329
- return m > 0 ? ` ${DIM}⤳${m}${RESET}` : '';
330
- }
331
- /** ⚑K pending-asks badge for a node, read from the cached map. */
332
- function askBadge(id) {
333
- const k = asksMap[id] ?? 0;
334
- return k > 0 ? ` ${YELLOW}⚑${k}${RESET}` : '';
335
- }
336
- /** Sort rank for sibling ordering — live nodes (active, then idle) ahead of
337
- * terminal ones, so sessions still running surface at the TOP of each child
338
- * group instead of being buried under finished/failed ones. */
339
- function statusRank(id) {
340
- switch (getNode(id)?.status) {
341
- case 'active': return 0;
342
- case 'idle': return 1;
343
- case 'done': return 2;
344
- case 'canceled': return 3;
345
- case 'dead': return 4;
346
- default: return 5;
347
- }
348
- }
349
- /** Direct children, live-first — the sibling order used both when flattening
350
- * the tree and when stepping into a subtree (`l`). Array.sort is stable, so
351
- * equal-status siblings keep their creation order. */
352
- function sortedChildIds(id) {
353
- return convoChildIds(id).sort((a, b) => statusRank(a) - statusRank(b));
354
- }
355
- /** Default fold policy: which nodes auto-EXPAND. A node expands only when one
356
- * of its child subtrees holds a running ('active') agent or self — so the path
357
- * to any live agent (and to you) is revealed while quiescent branches stay
358
- * folded. One bottom-up O(N) pass from the ancestry root; cycle-guarded. */
359
- function computeDefaultExpanded(root, self) {
360
- const expand = new Set();
361
- const seen = new Set();
362
- // Returns whether subtree(id), INCLUDING id, holds an active node or self.
363
- const visit = (id) => {
364
- if (seen.has(id))
365
- return id === self || getNode(id)?.status === 'active';
366
- seen.add(id);
367
- let childRevealing = false;
368
- for (const c of convoChildIds(id))
369
- if (visit(c))
370
- childRevealing = true;
371
- if (childRevealing)
372
- expand.add(id); // a descendant is worth revealing → unfold id
373
- return childRevealing || id === self || getNode(id)?.status === 'active';
374
- };
375
- visit(root);
376
- return expand;
377
- }
378
- function buildGraphModel(self) {
379
- const rootId = climbRoot(self);
380
- const defaultExpanded = computeDefaultExpanded(rootId, self);
381
- // userExpanded / userCollapsed override the auto policy; absent → policy decides.
382
- const isFolded = (id) => userExpanded.has(id) ? false : userCollapsed.has(id) ? true : !defaultExpanded.has(id);
383
- const rows = [];
384
- const visited = new Set();
385
- const walk = (id, prefix, isRoot, isLast) => {
386
- if (visited.has(id)) {
387
- const connector = isRoot ? '' : isLast ? '└─ ' : '├─ ';
388
- rows.push({ id, hasKids: false, isSelf: id === self, branch: prefix + connector, cycle: true, collapsed: false });
389
- return;
390
- }
391
- visited.add(id);
392
- const kids = sortedChildIds(id);
393
- const folded = isFolded(id);
394
- const connector = isRoot ? '' : isLast ? '└─ ' : '├─ ';
395
- rows.push({ id, hasKids: kids.length > 0, isSelf: id === self, branch: prefix + connector, cycle: false, collapsed: folded });
396
- if (folded)
397
- return; // folded — don't descend
398
- const childPrefix = isRoot ? '' : prefix + (isLast ? ' ' : '│ ');
399
- for (let i = 0; i < kids.length; i++)
400
- walk(kids[i], childPrefix, false, i === kids.length - 1);
401
- };
402
- walk(rootId, '', true, true);
403
- return rows;
404
- }
405
- /** Render one GRAPH row. CURSOR (selected) → reverse-video bar; an ATTACHED
406
- * (human-watched) node → a coloured background bar; SELF → bold name. The
407
- * cursor outranks the attached tint when both land on the same row. Running
408
- * is signaled by the dot glyph alone (● green = active engine). */
409
- function renderGraphRow(r, isCursor, focused) {
410
- const wrap = (line, attached) => isCursor ? fillBar(line, fillWidth(), REVERSE)
411
- : attached ? fillBar(line, fillWidth(), BG_ATTACHED)
412
- : truncate(line);
413
- if (r.cycle) {
414
- const line = `${r.branch} ${DIM}↺ ${shortId(r.id)}${RESET}`;
415
- return wrap(line, false);
416
- }
417
- const node = getNode(r.id);
418
- const dot = coloredGlyph(node);
419
- const rawName = node !== null ? fullName(node) : shortId(r.id);
420
- const name = r.isSelf ? `${BOLD}${rawName}${RESET}` : rawName;
421
- const kind = `${DIM}${node?.kind ?? ''}${RESET}`;
422
- const tokens = `${DIM}${tokensCell(r.id)}${RESET}`;
423
- // ▸ marks an expandable (collapsed-with-kids) row. The cursor row gets no
424
- // caret — its reverse-video bar already distinguishes it — so the triangle
425
- // reads purely as "this unfolds".
426
- const expandable = r.hasKids && r.collapsed;
427
- const caret = !isCursor && expandable ? `${DIM}▸${RESET} ` : ' ';
428
- const fold = expandable ? ` ${DIM}[+${childCount(r.id)}]${RESET}` : '';
429
- const line = `${r.branch}${caret}${dot} ${name} ${kind} ${tokens}${childBadge(node)}${fold}${askBadge(r.id)}`;
430
- return wrap(line, isAttached(r.id, node, focused));
431
- }
432
- /** Total lines the GRAPH widget may emit. pi hard-caps extension widgets at
433
- * MAX_WIDGET_LINES — anything past that pi truncates itself, eating our own
434
- * scroll chrome — so never exceed it (and shrink on a very short terminal).
435
- * The viewport scrolls WITHIN this cap as the cursor moves. */
436
- function graphWidgetBudget() {
437
- const rows = process.stdout.rows ?? VIEWPORT_FALLBACK_ROWS;
438
- return Math.max(4, Math.min(PI_MAX_WIDGET_LINES, rows - 4));
439
- }
440
- const GRAPH_HINT = `${DIM}jk move · hl fold · ↵ focus · e expand · x kill · m mgr · esc${RESET}`;
441
84
  // ---------------------------------------------------------------------------
442
85
  // Key decoding — recognizers tolerant of legacy, kitty/CSI-u and
443
86
  // modifyOtherKeys encodings (pi enables the kitty / modifyOtherKeys protocols,
@@ -552,31 +195,31 @@ export function registerCanvasNav(pi) {
552
195
  const renderBase = () => {
553
196
  if (ui === undefined)
554
197
  return;
198
+ // One subtree-activity pass (rooted at the ancestry root) feeds the ⇣N
199
+ // live-work-below badge on both the manager line and every report row.
200
+ const activity = computeSubtreeActivity(climbRoot(nodeId), nodeId);
555
201
  const mgr = managerOf(nodeId);
556
202
  if (mgr === undefined) {
557
203
  // Root node: no manager → drop the widget rather than show "↑ (root)" chrome.
558
204
  ui.setWidget('crtr-managers', undefined, { placement: 'aboveEditor' });
559
205
  }
560
206
  else {
561
- const mn = getNode(mgr);
562
- const name = mn !== null ? fullName(mn) : shortId(mgr);
563
- const mgrLine = truncate(`↑ ${name} ${coloredGlyph(mn)} ${DIM}${mn?.kind ?? ''}${RESET} ${DIM}${tokensCell(mgr)}${RESET}${childBadge(mn)}${askBadge(mgr)}`);
207
+ const mn = cNode(mgr);
208
+ const name = navLabel(mn, mgr);
209
+ const mgrLine = truncate(`↑ ${name} ${coloredGlyph(mn)} ${DIM}${mn?.kind ?? ''}${RESET} ${DIM}${tokensCell(mgr)}${RESET}${cycleBadge(mn)}${childBadge(mn)}${liveBelowBadge(mn, activity.activeBelow)}${askBadge(mgr, asksMap)}${activityCell(mgr, mn)}`);
564
210
  ui.setWidget('crtr-managers', [mgrLine], { placement: 'aboveEditor' });
565
211
  }
566
212
  const reports = liveReports(nodeId);
567
213
  const lines = [];
568
214
  // Report rows only — no "↓ reports (N)" header (the label carries no signal).
569
215
  if (reports.length > 0) {
570
- const nameW = Math.min(20, Math.max(...reports.map((id) => {
571
- const n = getNode(id);
572
- return (n !== null ? fullName(n) : shortId(id)).length;
573
- })));
216
+ const nameW = Math.min(20, Math.max(...reports.map((id) => navLabel(cNode(id), id).length)));
574
217
  for (const id of reports) {
575
- const n = getNode(id);
576
- const name = (n !== null ? fullName(n) : shortId(id)).padEnd(nameW);
218
+ const n = cNode(id);
219
+ const name = navLabel(n, id).padEnd(nameW);
577
220
  const kind = `${DIM}${(n?.kind ?? '').padEnd(6)}${RESET}`;
578
221
  const tokens = `${DIM}${tokensCell(id).padStart(5)}${RESET}`;
579
- lines.push(truncate(` ${coloredGlyph(n)} ${name} ${kind} ${tokens}${childBadge(n)}${askBadge(id)}`));
222
+ lines.push(truncate(` ${coloredGlyph(n)} ${name} ${kind} ${tokens}${cycleBadge(n)}${childBadge(n)}${liveBelowBadge(n, activity.activeBelow)}${askBadge(id, asksMap)}${activityCell(id, n)}`));
580
223
  }
581
224
  }
582
225
  // Self's own pending asks (no self row in BASE) → a trailing inline line.
@@ -592,7 +235,10 @@ export function registerCanvasNav(pi) {
592
235
  const renderGraph = () => {
593
236
  if (ui === undefined)
594
237
  return;
595
- const rows = buildGraphModel(nodeId);
238
+ // One subtree-activity pass feeds BOTH the fold policy (which rows show) and
239
+ // the ⇣N live-work-below badge — computed once here, never re-walked per row.
240
+ const activity = computeSubtreeActivity(climbRoot(nodeId), nodeId);
241
+ const rows = buildGraphModel(nodeId, folds, activity.expand);
596
242
  // Re-resolve the cursor id → row (it may have vanished under a fold or a
597
243
  // close); clamp to nearest visible row.
598
244
  let cursorIdx = rows.findIndex((r) => r.id === cursorId);
@@ -634,7 +280,7 @@ export function registerCanvasNav(pi) {
634
280
  if (scrollTop > 0)
635
281
  lines.push(`${DIM} ↑ ${scrollTop} more${RESET}`);
636
282
  for (let i = scrollTop; i < end; i++)
637
- lines.push(renderGraphRow(rows[i], i === cursorIdx, focused));
283
+ lines.push(renderGraphRow(rows[i], i === cursorIdx, focused, activity.activeBelow, asksMap));
638
284
  if (end < rows.length)
639
285
  lines.push(`${DIM} ↓ ${rows.length - end} more${RESET}`);
640
286
  const hint = pendingConfirm !== undefined
@@ -649,6 +295,9 @@ export function registerCanvasNav(pi) {
649
295
  const render = () => {
650
296
  if (ui === undefined)
651
297
  return;
298
+ // Fresh snapshot per render: drop last frame's memoized node/telemetry/edge
299
+ // reads so this paint reflects current disk+db state, then read-once within it.
300
+ beginFrame();
652
301
  try {
653
302
  if (view === 'graph')
654
303
  renderGraph();
@@ -697,7 +346,7 @@ export function registerCanvasNav(pi) {
697
346
  view = 'graph';
698
347
  pendingConfirm = undefined;
699
348
  scrollTop = 0;
700
- if (cursorId === undefined || getNode(cursorId) === null)
349
+ if (cursorId === undefined || cNode(cursorId) === null)
701
350
  cursorId = nodeId;
702
351
  render();
703
352
  };
@@ -714,7 +363,7 @@ export function registerCanvasNav(pi) {
714
363
  };
715
364
  /** Template vars for a graphBind, resolved against the CURSOR node. */
716
365
  const graphVars = (cur) => {
717
- const cn = getNode(cur);
366
+ const cn = cNode(cur);
718
367
  return {
719
368
  id: cur,
720
369
  self: nodeId,
@@ -749,7 +398,7 @@ export function registerCanvasNav(pi) {
749
398
  exitGraph();
750
399
  return { consume: true };
751
400
  }
752
- const rows = buildGraphModel(nodeId);
401
+ const rows = buildGraphModel(nodeId, folds);
753
402
  let idx = rows.findIndex((r) => r.id === cursorId);
754
403
  if (idx < 0)
755
404
  idx = Math.max(0, rows.findIndex((r) => r.id === nodeId));
@@ -815,13 +464,9 @@ export function registerCanvasNav(pi) {
815
464
  render();
816
465
  return { consume: true };
817
466
  }
818
- if (isPlain(data, 'e')) {
819
- shellCrtr(['canvas', 'tmux-spread', nodeId]);
820
- return { consume: true };
821
- }
822
467
  if (isPlain(data, 'x')) {
823
468
  const target = cursorId ?? nodeId;
824
- const n = getNode(target);
469
+ const n = cNode(target);
825
470
  const nm = n !== null ? fullName(n) : shortId(target);
826
471
  pendingConfirm = { label: `kill ${nm}?`, action: () => shellCrtr(['node', 'close', '--node', target], render) };
827
472
  render();
@@ -838,7 +483,7 @@ export function registerCanvasNav(pi) {
838
483
  if (argv.length === 0)
839
484
  return { consume: true };
840
485
  if (bind.confirm === true) {
841
- const n = getNode(target);
486
+ const n = cNode(target);
842
487
  const nm = n !== null ? fullName(n) : shortId(target);
843
488
  pendingConfirm = { label: `${bind.desc ?? bind.run} ${nm}?`, action: () => shellCrtr(argv, render) };
844
489
  }
@@ -1,4 +1,4 @@
1
- type PiEvents = 'agent_start' | 'turn_end' | 'agent_end' | 'session_shutdown' | 'session_start';
1
+ type PiEvents = 'agent_start' | 'turn_end' | 'agent_end' | 'session_shutdown' | 'session_start' | 'tool_execution_start';
2
2
  interface PiLike {
3
3
  on: (event: PiEvents, handler: (event: any, ctx: any) => void | Promise<void>) => void;
4
4
  sendUserMessage: (content: string, options?: {