@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
@@ -167,6 +167,16 @@ class FakeSession {
167
167
  get messages() {
168
168
  return this.messageLog;
169
169
  }
170
+ // The broker's catch-up snapshot reconstructs history from the persisted
171
+ // session tree (sessionManager.buildSessionContext) — the single source of
172
+ // truth, since `session.messages` can be sliced by pi's retry/overflow/
173
+ // compaction recovery. Mirror that surface so the fake's snapshot still serves
174
+ // the accrued history (G2/G3). The fake's persisted analog IS messageLog.
175
+ get sessionManager() {
176
+ return {
177
+ buildSessionContext: () => ({ messages: this.messageLog, thinkingLevel: 'off', model: null }),
178
+ };
179
+ }
170
180
  get sessionId() {
171
181
  return this.sm.getSessionId();
172
182
  }
@@ -145,6 +145,82 @@ test('handFocusToManager: LIVE backstage manager → swaps its pane INTO the foc
145
145
  spawnSync('tmux', ['kill-session', '-t', back], { stdio: 'ignore' });
146
146
  }
147
147
  });
148
+ // ---------------------------------------------------------------------------
149
+ // BUG REGRESSION (lost focus pane on the push-final race): a focused child's
150
+ // `crtr push final` seeds its manager's inbox MID-TURN; the daemon's second pass
151
+ // can revive the dormant manager BACKSTAGE (the focus row still points at the
152
+ // child) before the child's agent_end runs this handoff. At that point the
153
+ // manager is status='active' with a real backstage pane whose pi is still
154
+ // BOOTING — its recorded pi_pid is the OLD dead pid (the fresh pi records its
155
+ // pid only at session_start). Pre-fix, MAJOR 1 gated on isPidAlive(pi_pid), so
156
+ // it missed the booting manager; the dormant branch missed it too (no longer
157
+ // idle-release) → handFocusToManager returned false → the stophook disarmed the
158
+ // freeze and the USER'S VIEWPORT PANE REAPED while the manager ran on invisibly
159
+ // backstage — the "pane gets lost" bug. The gate is now the PANE itself
160
+ // (paneRunning: exists + command running), which a booting pi satisfies.
161
+ // ---------------------------------------------------------------------------
162
+ test('handFocusToManager (BUG REGRESSION): just-revived manager (RUNNING pane, STALE dead pi_pid) → swap, not reap', { skip: !hasTmux() }, () => {
163
+ const user = `crtr-hfm-race-user-${process.pid}`;
164
+ const back = `crtr-hfm-race-back-${process.pid}`;
165
+ spawnSync('tmux', ['new-session', '-d', '-s', user, '-c', '/tmp', 'sleep 600']);
166
+ spawnSync('tmux', ['new-session', '-d', '-s', back, '-c', '/tmp', 'sleep 600']);
167
+ try {
168
+ const userWindow = tmuxOut(['list-windows', '-t', user, '-F', '#{window_id}']).split('\n')[0];
169
+ const backWindow = tmuxOut(['list-windows', '-t', back, '-F', '#{window_id}']).split('\n')[0];
170
+ const focusPane = tmuxOut(['display-message', '-p', '-t', `${user}:${userWindow}`, '#{pane_id}']);
171
+ // The manager's backstage pane: a RUNNING command (the booting pi stand-in).
172
+ const mgrPane = tmuxOut(['split-window', '-d', '-P', '-F', '#{pane_id}', '-t', `${back}:${backWindow}`, 'sleep 600']);
173
+ createNode(node('M', { pane: focusPane, tmux_session: user, window: userWindow, status: 'done' }));
174
+ // The just-revived state: active (transition 'revive'), intent cleared, but
175
+ // pi_pid NOT yet re-recorded — dead/stale (revive deliberately leaves it).
176
+ createNode(node('mgr', { pane: mgrPane, tmux_session: back, window: backWindow, status: 'active', pi_pid: null }));
177
+ openFocusRow('f', focusPane, user, 'M');
178
+ assert.equal(handFocusToManager('f', 'mgr'), true, 'a booting just-revived manager takes the focus — the pane is what matters, not the stale pid');
179
+ assert.equal(getFocusByNode('mgr')?.focus_id, 'f', 'the manager occupies the focus row');
180
+ assert.equal(getFocusById('f')?.pane, mgrPane, 'focus row re-anchored to the manager pane (swap happened)');
181
+ assert.equal(paneSession(mgrPane), user, 'the booting manager pane physically moved into the user viewport');
182
+ // Non-vacuous: the pre-fix isPidAlive(mgr.pi_pid) gate fails on pi_pid=null →
183
+ // returns false without repointing → every assert above fails, and the caller
184
+ // would have reaped the user's pane.
185
+ }
186
+ finally {
187
+ spawnSync('tmux', ['kill-session', '-t', user], { stdio: 'ignore' });
188
+ spawnSync('tmux', ['kill-session', '-t', back], { stdio: 'ignore' });
189
+ }
190
+ });
191
+ test('handFocusToManager: manager pane is a FROZEN remain-on-exit corpse (pane_dead=1) → false, no swap (dead-focus-pane guarantee holds under the pane gate)', { skip: !hasTmux() }, async () => {
192
+ const user = `crtr-hfm-corpse-user-${process.pid}`;
193
+ const back = `crtr-hfm-corpse-back-${process.pid}`;
194
+ spawnSync('tmux', ['new-session', '-d', '-s', user, '-c', '/tmp', 'sleep 600']);
195
+ spawnSync('tmux', ['new-session', '-d', '-s', back, '-c', '/tmp', 'sleep 600']);
196
+ try {
197
+ const userWindow = tmuxOut(['list-windows', '-t', user, '-F', '#{window_id}']).split('\n')[0];
198
+ const backWindow = tmuxOut(['list-windows', '-t', back, '-F', '#{window_id}']).split('\n')[0];
199
+ const focusPane = tmuxOut(['display-message', '-p', '-t', `${user}:${userWindow}`, '#{pane_id}']);
200
+ // A short-lived command frozen by remain-on-exit → pane EXISTS but pane_dead=1.
201
+ const mgrPane = tmuxOut(['split-window', '-d', '-P', '-F', '#{pane_id}', '-t', `${back}:${backWindow}`, 'sleep 0.2']);
202
+ spawnSync('tmux', ['set-option', '-p', '-t', mgrPane, 'remain-on-exit', 'on'], { stdio: 'ignore' });
203
+ // Wait until tmux reports the pane dead (frozen corpse), bounded.
204
+ for (let i = 0; i < 50; i++) {
205
+ if (tmuxOut(['display-message', '-p', '-t', mgrPane, '#{pane_dead}']) === '1')
206
+ break;
207
+ await new Promise((r) => setTimeout(r, 100));
208
+ }
209
+ assert.equal(tmuxOut(['display-message', '-p', '-t', mgrPane, '#{pane_dead}']), '1', 'precondition: the manager pane is a frozen corpse');
210
+ createNode(node('M', { pane: focusPane, tmux_session: user, window: userWindow, status: 'done' }));
211
+ createNode(node('mgr', { pane: mgrPane, tmux_session: back, window: backWindow, status: 'active', pi_pid: null }));
212
+ openFocusRow('f', focusPane, user, 'M');
213
+ assert.equal(handFocusToManager('f', 'mgr'), false, 'a corpse pane never claims the viewport — caller disarms + reaps');
214
+ assert.equal(getFocusByNode('mgr'), null, 'occupant NOT repointed (no stranded frozen focus)');
215
+ assert.equal(getFocusById('f')?.node_id, 'M', 'M still occupies the focus → caller runs closeFocusToShell');
216
+ // Non-vacuous: a gate of bare paneExists (existence without pane_dead) would
217
+ // swap the corpse into the viewport and return true — both asserts fail.
218
+ }
219
+ finally {
220
+ spawnSync('tmux', ['kill-session', '-t', user], { stdio: 'ignore' });
221
+ spawnSync('tmux', ['kill-session', '-t', back], { stdio: 'ignore' });
222
+ }
223
+ });
148
224
  test('handFocusToManager: DORMANT idle-release manager (dead pane) → occupant repointed, NO swap, focus pane UNCHANGED', () => {
149
225
  openFocusRow('f', '%focus', 'Suser', 'M');
150
226
  // The manager is dormant + idle-release (the ONLY dormant manager the daemon
@@ -0,0 +1,108 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/human-stranded-deliver.test.ts
2
+ //
3
+ // BUG REGRESSION (human-ask broker round-trip): a headless-broker asker strips
4
+ // $TMUX, so the detached `crtr human _run` worker — whose `pushFinal` is the SOLE
5
+ // deliver-back + reap step — never spawned. Worse, the `crtr human inbox` drain
6
+ // path writes response.json directly (humanloop's inbox()) and ALSO never calls
7
+ // pushFinal, so an answered deck strands on disk: the asker never learns the
8
+ // answer and the bridge node leaks active forever.
9
+ //
10
+ // This locks the SECONDARY heal: finalizeResolvedInteraction must, for a still-
11
+ // LIVE bridge node whose interaction is resolved on disk, deliver the answer to
12
+ // the asker (parent auto-subscribed) and reap the bridge node — exactly what the
13
+ // _run worker's pushFinal would have done. Idempotent (no double-deliver once
14
+ // done); a canceled-on-disk response reaps without delivering a result.
15
+ import { test, before, after, beforeEach } from 'node:test';
16
+ import assert from 'node:assert/strict';
17
+ import { mkdtempSync, rmSync, mkdirSync } from 'node:fs';
18
+ import { tmpdir } from 'node:os';
19
+ import { join } from 'node:path';
20
+ import { createNode, subscribe, getNode } from '../canvas/canvas.js';
21
+ import { closeDb } from '../canvas/db.js';
22
+ import { readInboxSince } from '../feed/inbox.js';
23
+ import { interactionDir, interactionsRoot } from '../artifact.js';
24
+ import { atomicWriteJson, deckPath } from '@crouton-kit/humanloop';
25
+ import { finalizeResolvedInteraction } from '../../commands/human/queue.js';
26
+ let home;
27
+ // A unique cwd so interactionDir (keyed off the REAL homedir + mangled cwd, NOT
28
+ // CRTR_HOME) lands in an isolated, cleanable subtree.
29
+ let workCwd;
30
+ function node(id, parent) {
31
+ return {
32
+ node_id: id,
33
+ name: id,
34
+ created: new Date().toISOString(),
35
+ cwd: workCwd,
36
+ kind: 'developer',
37
+ mode: 'base',
38
+ lifecycle: 'terminal',
39
+ status: 'active',
40
+ parent,
41
+ };
42
+ }
43
+ /** Seed an interaction dir on disk the way the kickoff + a resolution would. */
44
+ function seed(jobId, run, response) {
45
+ const idir = interactionDir(jobId, workCwd);
46
+ mkdirSync(idir, { recursive: true });
47
+ atomicWriteJson(deckPath(idir), { interactions: [] });
48
+ atomicWriteJson(join(idir, 'run.json'), run);
49
+ atomicWriteJson(join(idir, 'response.json'), response);
50
+ }
51
+ before(() => {
52
+ home = mkdtempSync(join(tmpdir(), 'crtr-stranded-'));
53
+ process.env['CRTR_HOME'] = home;
54
+ workCwd = mkdtempSync(join(tmpdir(), 'crtr-stranded-cwd-'));
55
+ });
56
+ beforeEach(() => {
57
+ closeDb();
58
+ rmSync(home, { recursive: true, force: true });
59
+ rmSync(interactionsRoot(workCwd), { recursive: true, force: true });
60
+ });
61
+ after(() => {
62
+ closeDb();
63
+ rmSync(home, { recursive: true, force: true });
64
+ rmSync(interactionsRoot(workCwd), { recursive: true, force: true });
65
+ rmSync(workCwd, { recursive: true, force: true });
66
+ delete process.env['CRTR_HOME'];
67
+ });
68
+ test('answered-but-undelivered ask delivers back to the asker and reaps the bridge', async () => {
69
+ // Asker A, bridge B under A (parent auto-subscribes to its child bridge).
70
+ createNode(node('A', null));
71
+ createNode(node('B', 'A'));
72
+ subscribe('A', 'B');
73
+ seed('B', { mode: 'ask', job_id: 'B' }, { responses: [{ id: 'q', selectedOptionId: 'resync', freetext: 'go ahead' }], completedAt: '2026-06-10T22:27:07.000Z' });
74
+ const acted = await finalizeResolvedInteraction('B');
75
+ assert.equal(acted, true, 'should have delivered + reaped');
76
+ // Bridge reaped.
77
+ assert.equal(getNode('B')?.status, 'done', 'bridge node must be reaped to done');
78
+ // Answer fanned into the asker's inbox, carrying the choice.
79
+ const inbox = readInboxSince('A');
80
+ assert.equal(inbox.length >= 1, true, 'asker inbox must receive the answer pointer');
81
+ const blob = JSON.stringify(inbox);
82
+ assert.match(blob, /resync/, 'delivered answer should carry the human selection');
83
+ // Idempotent: a second sweep is a no-op (node already done — no double-deliver).
84
+ const again = await finalizeResolvedInteraction('B');
85
+ assert.equal(again, false, 'must not re-deliver an already-finalized interaction');
86
+ });
87
+ test('a canceled-on-disk response reaps the bridge without delivering a result', async () => {
88
+ createNode(node('A', null));
89
+ createNode(node('B', 'A'));
90
+ subscribe('A', 'B');
91
+ seed('B', { mode: 'ask', job_id: 'B' }, { canceled: true, canceledAt: '2026-06-10T22:42:04.000Z', reason: 'stale' });
92
+ const acted = await finalizeResolvedInteraction('B');
93
+ assert.equal(acted, true);
94
+ assert.equal(getNode('B')?.status, 'done', 'a canceled stranded bridge must still be reaped');
95
+ // The asker gets a quiet "no answer is coming" note, not a result.
96
+ const blob = JSON.stringify(readInboxSince('A'));
97
+ assert.match(blob, /no answer is coming/);
98
+ });
99
+ test('an unresolved (no response.json) live bridge is left untouched', async () => {
100
+ createNode(node('A', null));
101
+ createNode(node('B', 'A'));
102
+ subscribe('A', 'B');
103
+ const idir = interactionDir('B', workCwd);
104
+ mkdirSync(idir, { recursive: true });
105
+ atomicWriteJson(join(idir, 'run.json'), { mode: 'ask', job_id: 'B' });
106
+ assert.equal(await finalizeResolvedInteraction('B'), false);
107
+ assert.equal(getNode('B')?.status, 'active', 'still-pending bridge must stay live');
108
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,81 @@
1
+ // Bug-regression: on-read doc injection "fires a second time per session".
2
+ //
3
+ // The on-read substrate hook dedups so a doc surfaces at most once per
4
+ // conversation. That dedup set USED to live only in the pi process heap (cleared
5
+ // on session_start). But a node's logical session — the .jsonl transcript —
6
+ // spans MULTIPLE pi processes: a dormancy → revive(resume) cycle exits the old
7
+ // process and launches a fresh `pi --session` that REUSES the same transcript.
8
+ // The fresh process started with an empty set, so any doc already injected
9
+ // before dormancy got injected AGAIN on the next read.
10
+ //
11
+ // The fix persists the set to nodes/<id>/injected-docs.json: a resume rehydrates
12
+ // it (dedup holds), and only the FRESH-transcript launch paths clear it. These
13
+ // tests lock in both halves: the store round-trip, and the dedup surviving a
14
+ // simulated revive(resume) while re-surfacing after a fresh launch.
15
+ //
16
+ // Run: node --import tsx/esm --test src/core/__tests__/on-read-dedup-resume.test.ts
17
+ import { test, before, beforeEach, after } from 'node:test';
18
+ import assert from 'node:assert/strict';
19
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from 'node:fs';
20
+ import { tmpdir } from 'node:os';
21
+ import { join } from 'node:path';
22
+ import { closeDb } from '../canvas/db.js';
23
+ import { injectedDocsPath } from '../canvas/paths.js';
24
+ import { spawnNode } from '../runtime/nodes.js';
25
+ import { renderOnReadDocs } from '../substrate/on-read.js';
26
+ import { loadInjectedDocs, saveInjectedDocs, clearInjectedDocs, } from '../substrate/injected-store.js';
27
+ const FIXTURE_BODY = 'Fixture body that must surface exactly once.';
28
+ let home;
29
+ let work;
30
+ before(() => {
31
+ home = mkdtempSync(join(tmpdir(), 'crtr-onread-home-'));
32
+ process.env['CRTR_HOME'] = home;
33
+ });
34
+ beforeEach(() => {
35
+ closeDb();
36
+ rmSync(home, { recursive: true, force: true });
37
+ work = mkdtempSync(join(tmpdir(), 'crtr-onread-work-'));
38
+ });
39
+ after(() => {
40
+ closeDb();
41
+ rmSync(home, { recursive: true, force: true });
42
+ rmSync(work, { recursive: true, force: true });
43
+ delete process.env['CRTR_HOME'];
44
+ });
45
+ test('injected-store round-trips and clears', () => {
46
+ const node = spawnNode({ kind: 'general', cwd: work, parent: null }).node_id;
47
+ assert.deepEqual([...loadInjectedDocs(node)], [], 'cold load is empty');
48
+ saveInjectedDocs(node, new Set(['/a', '/b']));
49
+ assert.deepEqual([...loadInjectedDocs(node)].sort(), ['/a', '/b'], 'save then load round-trips');
50
+ clearInjectedDocs(node);
51
+ assert.ok(!existsSync(injectedDocsPath(node)), 'clear removes the file');
52
+ assert.deepEqual([...loadInjectedDocs(node)], [], 'load after clear is empty');
53
+ });
54
+ test('on-read doc surfaces once, stays deduped across a revive(resume), re-surfaces after a fresh launch', () => {
55
+ const node = spawnNode({ kind: 'general', cwd: work, parent: null }).node_id;
56
+ // A positional substrate doc in a `.crouter/memory/` ancestor of the read file.
57
+ const memDir = join(work, '.crouter', 'memory');
58
+ mkdirSync(memDir, { recursive: true });
59
+ writeFileSync(join(memDir, 'onread-fixture.md'), '---\nkind: reference\n' +
60
+ 'when-and-why-to-read: When reading work files, this reference should be read because it is the on-read regression fixture\n' +
61
+ 'file-read-visibility: content\n---\n' +
62
+ `${FIXTURE_BODY}\n`);
63
+ const readFile = join(work, 'src', 'file.ts');
64
+ mkdirSync(join(work, 'src'), { recursive: true });
65
+ writeFileSync(readFile, 'export const x = 1;\n');
66
+ // --- pi process #1: cold load; first read surfaces the doc ---
67
+ const seen = loadInjectedDocs(node);
68
+ const first = renderOnReadDocs(node, readFile, seen);
69
+ assert.match(first, new RegExp(FIXTURE_BODY.replace(/[.]/g, '\\.')), 'first read surfaces the doc body');
70
+ saveInjectedDocs(node, seen);
71
+ // Same process, repeat read of the same file → deduped in-memory.
72
+ assert.ok(!renderOnReadDocs(node, readFile, seen).includes(FIXTURE_BODY), 'same-process repeat read is deduped');
73
+ // --- dormancy → revive(resume): NEW process rehydrates the set from disk ---
74
+ const seenAfterRevive = loadInjectedDocs(node);
75
+ assert.deepEqual(seenAfterRevive, seen, 'revive(resume) rehydrates the persisted dedup set');
76
+ assert.ok(!renderOnReadDocs(node, readFile, seenAfterRevive).includes(FIXTURE_BODY), 'resumed process does NOT re-inject the doc (the bug)');
77
+ // --- fresh launch (revive resume=false / reviveInPlace) clears the set ---
78
+ clearInjectedDocs(node);
79
+ const seenFresh = loadInjectedDocs(node);
80
+ assert.ok(renderOnReadDocs(node, readFile, seenFresh).includes(FIXTURE_BODY), 'a fresh transcript surfaces the doc again');
81
+ });
@@ -0,0 +1,162 @@
1
+ import type { NodeMeta, SubscriptionRef } from './index.js';
2
+ /** pi's InteractiveMode.MAX_WIDGET_LINES — the hard cap on lines in a string
3
+ * array widget; anything beyond it pi truncates with its own "... (widget
4
+ * truncated)". Our GRAPH viewport stays at/under this and scrolls internally. */
5
+ export declare const PI_MAX_WIDGET_LINES = 10;
6
+ export declare const VIEWPORT_FALLBACK_ROWS = 30;
7
+ export declare const ESC = "\u001B[";
8
+ export declare const RESET = "\u001B[0m";
9
+ export declare const BOLD = "\u001B[1m";
10
+ export declare const DIM = "\u001B[2m";
11
+ export declare const REVERSE = "\u001B[7m";
12
+ /** Dark-green background bar marking an ATTACHED node (a human is currently
13
+ * watching it) — distinct from the cursor's reverse-video bar; chosen so
14
+ * default-fg text stays readable. */
15
+ export declare const BG_ATTACHED = "\u001B[48;5;22m";
16
+ export declare const GREEN = "\u001B[32m";
17
+ export declare const RED = "\u001B[31m";
18
+ export declare const YELLOW = "\u001B[33m";
19
+ export declare const CYAN = "\u001B[36m";
20
+ export declare const GRAY = "\u001B[90m";
21
+ /** Status glyph colored by state: active green, idle dim, done cyan, dead red. */
22
+ export declare function coloredGlyph(node: NodeMeta | null): string;
23
+ /** Visible width, ignoring ANSI escapes. */
24
+ export declare function visibleWidth(s: string): number;
25
+ /** Truncate to `max` VISIBLE columns: escape sequences are copied through
26
+ * verbatim (so a cut never lands mid-escape) and the result always ends in
27
+ * RESET, so a clipped style can't bleed into the editor below. */
28
+ export declare function truncate(s: string, max?: number): string;
29
+ /** Visible columns available to ONE widget line — the cap for every line we
30
+ * emit, and the width a full-width reverse-video SELF bar fills to.
31
+ *
32
+ * pi does NOT clip widget lines; it WRAPS them. Each string line is wrapped in
33
+ * a `Text(paddingX = 1)` inside a full-terminal-width container, so the usable
34
+ * content width is `columns - 2` (a 1-col margin on each side). A line wider
35
+ * than that wraps, and the overflow spills onto a second row as a stray
36
+ * reverse-video block (the bug this guards against). Clamp to `columns - 2`. */
37
+ export declare function fillWidth(): number;
38
+ /** Wrap `content` in a full-width background bar opened by `open` (REVERSE for
39
+ * the cursor, BG_ATTACHED for a human-watched node). `open` is re-asserted after every
40
+ * embedded RESET so a coloured cell (the status dot) can't punch a hole in the
41
+ * bar; the visible width is padded out to `width`; the line closes with a real
42
+ * RESET so the style never bleeds into the editor below. */
43
+ export declare function fillBar(content: string, width: number, open: string): string;
44
+ export declare function beginFrame(): void;
45
+ /** getNode memoized for the current frame (meta.json read + sqlite row). */
46
+ export declare function cNode(id: string): NodeMeta | null;
47
+ /** subscriptionsOf (a node's reports) memoized for the current frame. */
48
+ export declare function cSubscriptions(id: string): SubscriptionRef[];
49
+ /** subscribersOf (a node's managers) memoized for the current frame. */
50
+ export declare function cSubscribers(id: string): SubscriptionRef[];
51
+ export interface Telemetry {
52
+ /** Live context-window size (pi's getContextUsage) — the figure pi's own footer
53
+ * shows. This is the node's window fill, NOT a per-turn delta. */
54
+ context_tokens?: number;
55
+ /** One-line "what is it doing" cue (`tool: detail`), written on every tool start. */
56
+ last_activity?: string;
57
+ }
58
+ export declare function readTelemetry(nodeId: string): Telemetry;
59
+ export declare function fmtTokens(n: number): string;
60
+ /** The context-window cell — live window fill rounded to the nearest 1k (the same
61
+ * figure pi's footer shows), NOT a per-turn token delta. */
62
+ export declare function tokensCell(id: string): string;
63
+ /** Dimmed live "what is it doing" cue for an ACTIVE node — the last tool it ran.
64
+ * Empty for non-active rows (stale once a node parks) and when none is recorded. */
65
+ export declare function activityCell(id: string, node: NodeMeta | null): string;
66
+ /** Revive-count badge (meta.cycles). Hidden on the first cycle (0) to cut noise. */
67
+ export declare function cycleBadge(node: NodeMeta | null): string;
68
+ /** Short on-screen label: the explicit handle when set, else the pi-generated
69
+ * description, else the bare name. fullName joins BOTH (`handle description`);
70
+ * the nav chrome shows just the first so rows stay compact. */
71
+ export declare function navLabel(node: NodeMeta | null, id: string): string;
72
+ export declare function shortId(id: string): string;
73
+ /** Node ids currently shown in a tmux focus viewport. Built once per render. */
74
+ export declare function focusedNodeIds(): Set<string>;
75
+ /** True when a human is watching `id` right now (tmux focus or broker viewer). */
76
+ export declare function isAttached(id: string, node: NodeMeta | null, focused: ReadonlySet<string>): boolean;
77
+ export declare function fetchAsksMap(rootId: string): Record<string, number>;
78
+ /** First manager (by created) — the UP step for the ancestry spine. */
79
+ export declare function managerOf(id: string): string | undefined;
80
+ /** A kind:'human' node is a control-plane ASK (a humanloop deck on the human's
81
+ * screen), NOT a pi conversation — it has no session, so focusing/reviving it
82
+ * boots a confused blank "you have been revived" pi. Its pending-ask signal
83
+ * already rides the ⚑ badge on the ASKING node (attention.ts attributes asks by
84
+ * source.nodeId, never to the human node), so the row carries no signal of its
85
+ * own. Drop it from every navigable list (the tree, BASE reports, child counts,
86
+ * subtree expansion) so it can never be selected. */
87
+ export declare function isHumanAsk(id: string): boolean;
88
+ /** A node's direct children that are navigable conversations — human-ask nodes
89
+ * dropped. The one place the nav chrome enumerates children. */
90
+ export declare function convoChildIds(id: string): string[];
91
+ /** Live reports (active|idle) of a node — the DOWN set in BASE. */
92
+ export declare function liveReports(id: string): string[];
93
+ /** Direct navigable children — used for the ⤳ badge and fold counts (human-ask
94
+ * nodes excluded, so the count matches what the tree actually shows). */
95
+ export declare function childCount(id: string): number;
96
+ /** Climb first-manager edges from `self` to the ancestry root (cycle-guarded). */
97
+ export declare function climbRoot(self: string): string;
98
+ /** Space-joined ids of a node's subtree (cursor-relative {subtree} var). */
99
+ export declare function subtreeIds(root: string): string[];
100
+ /** ⤳M direct-children badge — only on orchestrator rows. */
101
+ export declare function childBadge(node: NodeMeta | null): string;
102
+ /** ⚑K pending-asks badge for a node, read from the supplied per-node ask map. */
103
+ export declare function askBadge(id: string, asks: Record<string, number>): string;
104
+ /** "Live work below" badge — green ⇣N when this node is NOT itself active but has
105
+ * N active descendants. The one signal that an idle/parked node isn't dead: real
106
+ * work is still running beneath it. Count comes from the single subtree-activity
107
+ * pass (no per-row walk). Suppressed on active rows (their own ● already says so). */
108
+ export declare function liveBelowBadge(node: NodeMeta | null, activeBelow: ReadonlyMap<string, number>): string;
109
+ /** Sort rank for sibling ordering — live nodes (active, then idle) ahead of
110
+ * terminal ones, so sessions still running surface at the TOP of each child
111
+ * group instead of being buried under finished/failed ones. */
112
+ export declare function statusRank(id: string): number;
113
+ /** Direct children, live-first — the sibling order used both when flattening
114
+ * the tree and when stepping into a subtree (`l`). Array.sort is stable, so
115
+ * equal-status siblings keep their creation order. */
116
+ export declare function sortedChildIds(id: string): string[];
117
+ export interface FlatRow {
118
+ id: string;
119
+ hasKids: boolean;
120
+ isSelf: boolean;
121
+ branch: string;
122
+ cycle: boolean;
123
+ collapsed: boolean;
124
+ }
125
+ /** Manual fold OVERRIDES, keyed by id. `userCollapsed` forces a fold, `userExpanded`
126
+ * forces an unfold; both override the default activity-driven policy. Held by the
127
+ * consumer (the extension / viewer) and threaded into buildGraphModel so this layer
128
+ * carries no mutable singletons. */
129
+ export interface FoldState {
130
+ userExpanded: ReadonlySet<string>;
131
+ userCollapsed: ReadonlySet<string>;
132
+ }
133
+ /** One bottom-up O(N) pass over the local graph, computing TWO things at once so
134
+ * the render path never walks twice:
135
+ * - `expand`: which nodes auto-UNFOLD — a node expands when a child subtree holds
136
+ * a running ('active') agent or self, so the path to any live agent (and to
137
+ * you) is revealed while quiescent branches stay folded.
138
+ * - `activeBelow`: per node, the count of ACTIVE descendants (excluding itself) —
139
+ * drives the ⇣N "live work below" badge on idle/parked nodes.
140
+ * Cycle-guarded (the graph is declared acyclic; a re-seen id contributes only
141
+ * its own status, never its subtree again, so counts can't double or loop). */
142
+ export interface SubtreeActivity {
143
+ expand: Set<string>;
144
+ activeBelow: Map<string, number>;
145
+ }
146
+ export declare function computeSubtreeActivity(root: string, self: string): SubtreeActivity;
147
+ /** Fold policy alone — thin wrapper over computeSubtreeActivity for the keypress
148
+ * path (buildGraphModel without a precomputed set). The render path computes the
149
+ * activity once and threads `expand` in, so it never recomputes here. */
150
+ export declare function computeDefaultExpanded(root: string, self: string): Set<string>;
151
+ export declare function buildGraphModel(self: string, folds: FoldState, expand?: ReadonlySet<string>): FlatRow[];
152
+ /** Render one GRAPH row. CURSOR (selected) → reverse-video bar; an ATTACHED
153
+ * (human-watched) node → a coloured background bar; SELF → bold name. The
154
+ * cursor outranks the attached tint when both land on the same row. Running
155
+ * is signaled by the dot glyph alone (● green = active engine). */
156
+ export declare function renderGraphRow(r: FlatRow, isCursor: boolean, focused: ReadonlySet<string>, activeBelow: ReadonlyMap<string, number>, asks: Record<string, number>): string;
157
+ /** Total lines the GRAPH widget may emit. pi hard-caps extension widgets at
158
+ * MAX_WIDGET_LINES — anything past that pi truncates itself, eating our own
159
+ * scroll chrome — so never exceed it (and shrink on a very short terminal).
160
+ * The viewport scrolls WITHIN this cap as the cursor moves. */
161
+ export declare function graphWidgetBudget(): number;
162
+ export declare const GRAPH_HINT = "\u001B[2mjk move \u00B7 hl fold \u00B7 \u21B5 focus \u00B7 e expand \u00B7 x kill \u00B7 m mgr \u00B7 esc\u001B[0m";