@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.
- package/dist/builtin-personas/runtime-base.md +3 -0
- package/dist/clients/attach/__tests__/reconnect-giveup.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/reconnect-giveup.test.js +30 -0
- package/dist/clients/attach/attach-cmd.js +187 -19
- package/dist/clients/attach/canvas-panels.d.ts +10 -0
- package/dist/clients/attach/canvas-panels.js +50 -0
- package/dist/clients/attach/graph-overlay.d.ts +34 -0
- package/dist/clients/attach/graph-overlay.js +266 -0
- package/dist/clients/attach/input-controller.d.ts +6 -0
- package/dist/clients/attach/input-controller.js +2 -0
- package/dist/clients/attach/slash-commands.d.ts +22 -1
- package/dist/clients/attach/slash-commands.js +160 -3
- package/dist/clients/attach/view-socket.d.ts +19 -1
- package/dist/clients/attach/view-socket.js +61 -6
- package/dist/commands/human/prompts.js +3 -3
- package/dist/commands/human/queue.d.ts +17 -0
- package/dist/commands/human/queue.js +111 -4
- package/dist/commands/memory/__tests__/lint-schema.test.js +24 -1
- package/dist/commands/memory/lint.d.ts +5 -4
- package/dist/commands/memory/lint.js +9 -5
- package/dist/commands/memory/write.js +12 -3
- package/dist/commands/sys/feedback.d.ts +1 -0
- package/dist/commands/sys/feedback.js +163 -0
- package/dist/commands/sys.js +3 -2
- package/dist/core/__tests__/broker-snapshot-history.test.d.ts +1 -0
- package/dist/core/__tests__/broker-snapshot-history.test.js +105 -0
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +7 -0
- package/dist/core/__tests__/fixtures/fake-engine.js +10 -0
- package/dist/core/__tests__/full/placement-teardown.test.js +76 -0
- package/dist/core/__tests__/human-stranded-deliver.test.d.ts +1 -0
- package/dist/core/__tests__/human-stranded-deliver.test.js +108 -0
- package/dist/core/__tests__/on-read-dedup-resume.test.d.ts +1 -0
- package/dist/core/__tests__/on-read-dedup-resume.test.js +81 -0
- package/dist/core/canvas/nav-model.d.ts +162 -0
- package/dist/core/canvas/nav-model.js +486 -0
- package/dist/core/canvas/paths.d.ts +7 -0
- package/dist/core/canvas/paths.js +9 -0
- package/dist/core/runtime/broker-sdk.d.ts +0 -12
- package/dist/core/runtime/broker-sdk.js +77 -6
- package/dist/core/runtime/broker.d.ts +2 -1
- package/dist/core/runtime/broker.js +26 -1
- package/dist/core/runtime/front-door.js +23 -8
- package/dist/core/runtime/placement.d.ts +7 -6
- package/dist/core/runtime/placement.js +24 -12
- package/dist/core/runtime/revive.js +9 -0
- package/dist/core/runtime/spawn.d.ts +5 -0
- package/dist/core/runtime/spawn.js +62 -1
- package/dist/core/runtime/tmux.d.ts +9 -0
- package/dist/core/runtime/tmux.js +12 -0
- package/dist/core/spawn.d.ts +14 -0
- package/dist/core/spawn.js +29 -9
- package/dist/core/substrate/index.d.ts +1 -1
- package/dist/core/substrate/index.js +6 -6
- package/dist/core/substrate/injected-store.d.ts +10 -0
- package/dist/core/substrate/injected-store.js +55 -0
- package/dist/core/substrate/schema.d.ts +6 -8
- package/dist/core/substrate/schema.js +26 -28
- package/dist/pi-extensions/canvas-doc-substrate.js +16 -7
- package/dist/pi-extensions/canvas-nav.js +30 -385
- package/dist/pi-extensions/canvas-stophook.d.ts +1 -1
- package/dist/pi-extensions/canvas-stophook.js +32 -2
- package/package.json +1 -1
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
// nav-model.ts — the pure model/render layer of the canvas graph-nav chrome.
|
|
2
|
+
//
|
|
3
|
+
// Extracted from src/pi-extensions/canvas-nav.ts so a SINGLE source feeds both
|
|
4
|
+
// the legacy pi extension (canvas-nav) and the headless `crtr attach` viewer.
|
|
5
|
+
// Everything here is pi-API-free: it reads the canvas (sqlite + meta.json + node
|
|
6
|
+
// telemetry) and produces ANSI-styled strings + a flattened graph model. The
|
|
7
|
+
// extension-host wiring (widgets, key taps, the ask-poll timer, run/focus
|
|
8
|
+
// shelling) stays in canvas-nav.ts and drives these functions.
|
|
9
|
+
//
|
|
10
|
+
// Mutable interaction state that the legacy extension held at module scope —
|
|
11
|
+
// the per-node ask counts and the manual fold overrides — is threaded through
|
|
12
|
+
// parameters here (see `asks` and `FoldState`) so this layer owns no singletons
|
|
13
|
+
// and each consumer keeps its own state.
|
|
14
|
+
import { execFileSync } from 'node:child_process';
|
|
15
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { getNode, subscribersOf, subscriptionsOf, jobDir, listFocuses } from './index.js';
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Tuning constants
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
/** pi's InteractiveMode.MAX_WIDGET_LINES — the hard cap on lines in a string
|
|
22
|
+
* array widget; anything beyond it pi truncates with its own "... (widget
|
|
23
|
+
* truncated)". Our GRAPH viewport stays at/under this and scrolls internally. */
|
|
24
|
+
export const PI_MAX_WIDGET_LINES = 10;
|
|
25
|
+
export const VIEWPORT_FALLBACK_ROWS = 30;
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// ANSI styling. pi renders embedded escapes in widget lines and measures width
|
|
28
|
+
// ANSI-aware, so raw escapes are safe and need no pi-tui dependency. The cursor
|
|
29
|
+
// (selected row) uses a theme-agnostic ATTRIBUTE (reverse), so it reads under
|
|
30
|
+
// NO_COLOR; the attached-row tint is a background COLOUR, while the dot glyph
|
|
31
|
+
// (●/○/✓/✗) carries the running signal independently, even where colour is
|
|
32
|
+
// stripped.
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
export const ESC = '\x1b[';
|
|
35
|
+
export const RESET = `${ESC}0m`;
|
|
36
|
+
export const BOLD = `${ESC}1m`;
|
|
37
|
+
export const DIM = `${ESC}2m`;
|
|
38
|
+
export const REVERSE = `${ESC}7m`;
|
|
39
|
+
/** Dark-green background bar marking an ATTACHED node (a human is currently
|
|
40
|
+
* watching it) — distinct from the cursor's reverse-video bar; chosen so
|
|
41
|
+
* default-fg text stays readable. */
|
|
42
|
+
export const BG_ATTACHED = `${ESC}48;5;22m`;
|
|
43
|
+
export const GREEN = `${ESC}32m`;
|
|
44
|
+
export const RED = `${ESC}31m`;
|
|
45
|
+
export const YELLOW = `${ESC}33m`;
|
|
46
|
+
export const CYAN = `${ESC}36m`;
|
|
47
|
+
export const GRAY = `${ESC}90m`;
|
|
48
|
+
/** Status glyph colored by state: active green, idle dim, done cyan, dead red. */
|
|
49
|
+
export function coloredGlyph(node) {
|
|
50
|
+
if (node === null)
|
|
51
|
+
return '?';
|
|
52
|
+
switch (node.status) {
|
|
53
|
+
case 'active': return `${GREEN}●${RESET}`;
|
|
54
|
+
case 'idle': return `${GRAY}○${RESET}`;
|
|
55
|
+
case 'done': return `${CYAN}✓${RESET}`;
|
|
56
|
+
case 'dead': return `${RED}✗${RESET}`;
|
|
57
|
+
case 'canceled': return `${YELLOW}⊘${RESET}`;
|
|
58
|
+
default: return '?';
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
62
|
+
/** Visible width, ignoring ANSI escapes. */
|
|
63
|
+
export function visibleWidth(s) {
|
|
64
|
+
return s.replace(ANSI_RE, '').length;
|
|
65
|
+
}
|
|
66
|
+
/** Truncate to `max` VISIBLE columns: escape sequences are copied through
|
|
67
|
+
* verbatim (so a cut never lands mid-escape) and the result always ends in
|
|
68
|
+
* RESET, so a clipped style can't bleed into the editor below. */
|
|
69
|
+
export function truncate(s, max = fillWidth()) {
|
|
70
|
+
if (visibleWidth(s) <= max)
|
|
71
|
+
return s;
|
|
72
|
+
let out = '';
|
|
73
|
+
let w = 0;
|
|
74
|
+
let i = 0;
|
|
75
|
+
while (i < s.length && w < max - 1) {
|
|
76
|
+
if (s[i] === '\x1b') {
|
|
77
|
+
const m = /^\x1b\[[0-9;]*m/.exec(s.slice(i));
|
|
78
|
+
if (m) {
|
|
79
|
+
out += m[0];
|
|
80
|
+
i += m[0].length;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
out += s[i];
|
|
85
|
+
w++;
|
|
86
|
+
i++;
|
|
87
|
+
}
|
|
88
|
+
return `${out}…${RESET}`;
|
|
89
|
+
}
|
|
90
|
+
/** Visible columns available to ONE widget line — the cap for every line we
|
|
91
|
+
* emit, and the width a full-width reverse-video SELF bar fills to.
|
|
92
|
+
*
|
|
93
|
+
* pi does NOT clip widget lines; it WRAPS them. Each string line is wrapped in
|
|
94
|
+
* a `Text(paddingX = 1)` inside a full-terminal-width container, so the usable
|
|
95
|
+
* content width is `columns - 2` (a 1-col margin on each side). A line wider
|
|
96
|
+
* than that wraps, and the overflow spills onto a second row as a stray
|
|
97
|
+
* reverse-video block (the bug this guards against). Clamp to `columns - 2`. */
|
|
98
|
+
export function fillWidth() {
|
|
99
|
+
return Math.max(20, Math.min((process.stdout.columns ?? 80) - 2, 180));
|
|
100
|
+
}
|
|
101
|
+
/** Wrap `content` in a full-width background bar opened by `open` (REVERSE for
|
|
102
|
+
* the cursor, BG_ATTACHED for a human-watched node). `open` is re-asserted after every
|
|
103
|
+
* embedded RESET so a coloured cell (the status dot) can't punch a hole in the
|
|
104
|
+
* bar; the visible width is padded out to `width`; the line closes with a real
|
|
105
|
+
* RESET so the style never bleeds into the editor below. */
|
|
106
|
+
export function fillBar(content, width, open) {
|
|
107
|
+
const clipped = truncate(content, width);
|
|
108
|
+
const reasserted = clipped.replace(/\x1b\[0m/g, `${RESET}${open}`);
|
|
109
|
+
const pad = Math.max(0, width - visibleWidth(clipped));
|
|
110
|
+
return `${open}${reasserted}${' '.repeat(pad)}${RESET}`;
|
|
111
|
+
}
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Per-frame read cache
|
|
114
|
+
//
|
|
115
|
+
// One render fans the SAME handful of nodes out 5-10× each: computeSubtreeActivity
|
|
116
|
+
// walks the whole tree (getNode + subscriptions per node), buildGraphModel walks it
|
|
117
|
+
// again (climbRoot, sortedChildIds), then every visible row re-reads getNode +
|
|
118
|
+
// telemetry + subscriptions for its badges. getNode alone is an fs.readFileSync of
|
|
119
|
+
// meta.json plus a sqlite row read, and readTelemetry another readFileSync — so on a
|
|
120
|
+
// modest graph a single 'j' keystroke fanned out to DOZENS of redundant disk reads.
|
|
121
|
+
// That redundancy is the GRAPH view's open/scroll lag.
|
|
122
|
+
//
|
|
123
|
+
// Each render is a synchronous snapshot, so we memoize the read primitives for the
|
|
124
|
+
// duration of one frame and clear at the top of render(): the next keystroke (or the
|
|
125
|
+
// ask-poll / inbox render) re-reads fresh, so a killed node, a new spawn, or a status
|
|
126
|
+
// flip all surface on the very next frame. Nothing here writes, so a stale entry can
|
|
127
|
+
// at worst be one frame old — and render() always reruns immediately after any action.
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
const frameNodes = new Map();
|
|
130
|
+
const frameTelem = new Map();
|
|
131
|
+
const frameSubs = new Map();
|
|
132
|
+
const frameMgrs = new Map();
|
|
133
|
+
export function beginFrame() {
|
|
134
|
+
frameNodes.clear();
|
|
135
|
+
frameTelem.clear();
|
|
136
|
+
frameSubs.clear();
|
|
137
|
+
frameMgrs.clear();
|
|
138
|
+
}
|
|
139
|
+
/** getNode memoized for the current frame (meta.json read + sqlite row). */
|
|
140
|
+
export function cNode(id) {
|
|
141
|
+
if (frameNodes.has(id))
|
|
142
|
+
return frameNodes.get(id) ?? null;
|
|
143
|
+
const v = getNode(id);
|
|
144
|
+
frameNodes.set(id, v);
|
|
145
|
+
return v;
|
|
146
|
+
}
|
|
147
|
+
/** subscriptionsOf (a node's reports) memoized for the current frame. */
|
|
148
|
+
export function cSubscriptions(id) {
|
|
149
|
+
const hit = frameSubs.get(id);
|
|
150
|
+
if (hit !== undefined)
|
|
151
|
+
return hit;
|
|
152
|
+
let v;
|
|
153
|
+
try {
|
|
154
|
+
v = subscriptionsOf(id);
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
v = [];
|
|
158
|
+
}
|
|
159
|
+
frameSubs.set(id, v);
|
|
160
|
+
return v;
|
|
161
|
+
}
|
|
162
|
+
/** subscribersOf (a node's managers) memoized for the current frame. */
|
|
163
|
+
export function cSubscribers(id) {
|
|
164
|
+
const hit = frameMgrs.get(id);
|
|
165
|
+
if (hit !== undefined)
|
|
166
|
+
return hit;
|
|
167
|
+
let v;
|
|
168
|
+
try {
|
|
169
|
+
v = subscribersOf(id);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
v = [];
|
|
173
|
+
}
|
|
174
|
+
frameMgrs.set(id, v);
|
|
175
|
+
return v;
|
|
176
|
+
}
|
|
177
|
+
export function readTelemetry(nodeId) {
|
|
178
|
+
const hit = frameTelem.get(nodeId);
|
|
179
|
+
if (hit !== undefined)
|
|
180
|
+
return hit;
|
|
181
|
+
let v;
|
|
182
|
+
try {
|
|
183
|
+
const p = join(jobDir(nodeId), 'telemetry.json');
|
|
184
|
+
v = existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) : {};
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
v = {};
|
|
188
|
+
}
|
|
189
|
+
frameTelem.set(nodeId, v);
|
|
190
|
+
return v;
|
|
191
|
+
}
|
|
192
|
+
export function fmtTokens(n) {
|
|
193
|
+
return n < 1_000 ? `${n}` : `${Math.round(n / 1_000)}k`;
|
|
194
|
+
}
|
|
195
|
+
/** The context-window cell — live window fill rounded to the nearest 1k (the same
|
|
196
|
+
* figure pi's footer shows), NOT a per-turn token delta. */
|
|
197
|
+
export function tokensCell(id) {
|
|
198
|
+
return fmtTokens(readTelemetry(id).context_tokens ?? 0);
|
|
199
|
+
}
|
|
200
|
+
/** Dimmed live "what is it doing" cue for an ACTIVE node — the last tool it ran.
|
|
201
|
+
* Empty for non-active rows (stale once a node parks) and when none is recorded. */
|
|
202
|
+
export function activityCell(id, node) {
|
|
203
|
+
if (node?.status !== 'active')
|
|
204
|
+
return '';
|
|
205
|
+
const a = (readTelemetry(id).last_activity ?? '').trim();
|
|
206
|
+
return a === '' ? '' : ` ${DIM}· ${a}${RESET}`;
|
|
207
|
+
}
|
|
208
|
+
/** Revive-count badge (meta.cycles). Hidden on the first cycle (0) to cut noise. */
|
|
209
|
+
export function cycleBadge(node) {
|
|
210
|
+
const c = node?.cycles ?? 0;
|
|
211
|
+
return c > 0 ? ` ${DIM}⟳${c}${RESET}` : '';
|
|
212
|
+
}
|
|
213
|
+
/** Short on-screen label: the explicit handle when set, else the pi-generated
|
|
214
|
+
* description, else the bare name. fullName joins BOTH (`handle description`);
|
|
215
|
+
* the nav chrome shows just the first so rows stay compact. */
|
|
216
|
+
export function navLabel(node, id) {
|
|
217
|
+
if (node === null)
|
|
218
|
+
return shortId(id);
|
|
219
|
+
const handle = node.name && node.name !== node.kind ? node.name : '';
|
|
220
|
+
if (handle !== '')
|
|
221
|
+
return handle;
|
|
222
|
+
const desc = (node.description ?? '').trim();
|
|
223
|
+
return desc !== '' ? desc : node.name;
|
|
224
|
+
}
|
|
225
|
+
export function shortId(id) {
|
|
226
|
+
return id.slice(0, 8);
|
|
227
|
+
}
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
// Attachment — is a human currently WATCHING a node? A separate axis from
|
|
230
|
+
// running (status 'active' = the engine is live on its host, which may be an
|
|
231
|
+
// unwatched backstage pane or a paneless broker). Two hosts, two signals:
|
|
232
|
+
// tmux — a `focuses` row points at the node (one cheap sqlite read per
|
|
233
|
+
// render pass; pane-existence alone is NOT the signal).
|
|
234
|
+
// broker — the broker persists its helloed-viewer count to job/attach.json
|
|
235
|
+
// on every viewer change (src/core/runtime/broker.ts). Trusted only
|
|
236
|
+
// while the node is 'active': a broker crash can leave a stale file.
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
/** Node ids currently shown in a tmux focus viewport. Built once per render. */
|
|
239
|
+
export function focusedNodeIds() {
|
|
240
|
+
try {
|
|
241
|
+
return new Set(listFocuses().map((f) => f.node_id));
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
return new Set();
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
/** True when a human is watching `id` right now (tmux focus or broker viewer). */
|
|
248
|
+
export function isAttached(id, node, focused) {
|
|
249
|
+
if (focused.has(id))
|
|
250
|
+
return true;
|
|
251
|
+
if (node?.status !== 'active')
|
|
252
|
+
return false; // stale attach.json from a crash
|
|
253
|
+
try {
|
|
254
|
+
const p = join(jobDir(id), 'attach.json');
|
|
255
|
+
if (!existsSync(p))
|
|
256
|
+
return false;
|
|
257
|
+
const rec = JSON.parse(readFileSync(p, 'utf8'));
|
|
258
|
+
return typeof rec.viewers === 'number' && rec.viewers > 0;
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
// Per-node ask counts — ONE shell-out per poll. `crtr canvas attention map`
|
|
266
|
+
// buckets a whole sub-DAG's pending asks by node in a single process, so the
|
|
267
|
+
// timer stays cheap (< 2 s) regardless of how many nodes are visible. --json
|
|
268
|
+
// gives a parseable {counts} blob (the default render is XML chrome).
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
export function fetchAsksMap(rootId) {
|
|
271
|
+
try {
|
|
272
|
+
const raw = execFileSync('crtr', ['canvas', 'attention', 'map', '--view', rootId, '--json'], {
|
|
273
|
+
timeout: 2_500,
|
|
274
|
+
encoding: 'utf8',
|
|
275
|
+
});
|
|
276
|
+
const parsed = JSON.parse(raw.trim());
|
|
277
|
+
return parsed.counts ?? {};
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
return {};
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
// Graph queries (dependency-free, straight off the canvas db)
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
/** First manager (by created) — the UP step for the ancestry spine. */
|
|
287
|
+
export function managerOf(id) {
|
|
288
|
+
return cSubscribers(id)[0]?.node_id;
|
|
289
|
+
}
|
|
290
|
+
/** A kind:'human' node is a control-plane ASK (a humanloop deck on the human's
|
|
291
|
+
* screen), NOT a pi conversation — it has no session, so focusing/reviving it
|
|
292
|
+
* boots a confused blank "you have been revived" pi. Its pending-ask signal
|
|
293
|
+
* already rides the ⚑ badge on the ASKING node (attention.ts attributes asks by
|
|
294
|
+
* source.nodeId, never to the human node), so the row carries no signal of its
|
|
295
|
+
* own. Drop it from every navigable list (the tree, BASE reports, child counts,
|
|
296
|
+
* subtree expansion) so it can never be selected. */
|
|
297
|
+
export function isHumanAsk(id) {
|
|
298
|
+
return cNode(id)?.kind === 'human';
|
|
299
|
+
}
|
|
300
|
+
/** A node's direct children that are navigable conversations — human-ask nodes
|
|
301
|
+
* dropped. The one place the nav chrome enumerates children. */
|
|
302
|
+
export function convoChildIds(id) {
|
|
303
|
+
return cSubscriptions(id).map((s) => s.node_id).filter((cid) => !isHumanAsk(cid));
|
|
304
|
+
}
|
|
305
|
+
/** Live reports (active|idle) of a node — the DOWN set in BASE. */
|
|
306
|
+
export function liveReports(id) {
|
|
307
|
+
return convoChildIds(id).filter((cid) => {
|
|
308
|
+
const st = cNode(cid)?.status;
|
|
309
|
+
return st === 'active' || st === 'idle';
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
/** Direct navigable children — used for the ⤳ badge and fold counts (human-ask
|
|
313
|
+
* nodes excluded, so the count matches what the tree actually shows). */
|
|
314
|
+
export function childCount(id) {
|
|
315
|
+
return convoChildIds(id).length;
|
|
316
|
+
}
|
|
317
|
+
/** Climb first-manager edges from `self` to the ancestry root (cycle-guarded). */
|
|
318
|
+
export function climbRoot(self) {
|
|
319
|
+
let cur = self;
|
|
320
|
+
const seen = new Set([cur]);
|
|
321
|
+
for (;;) {
|
|
322
|
+
const mgr = managerOf(cur);
|
|
323
|
+
if (mgr === undefined || seen.has(mgr))
|
|
324
|
+
break;
|
|
325
|
+
seen.add(mgr);
|
|
326
|
+
cur = mgr;
|
|
327
|
+
}
|
|
328
|
+
return cur;
|
|
329
|
+
}
|
|
330
|
+
/** Space-joined ids of a node's subtree (cursor-relative {subtree} var). */
|
|
331
|
+
export function subtreeIds(root) {
|
|
332
|
+
const out = [];
|
|
333
|
+
const seen = new Set([root]);
|
|
334
|
+
const q = convoChildIds(root);
|
|
335
|
+
while (q.length > 0) {
|
|
336
|
+
const id = q.shift();
|
|
337
|
+
if (seen.has(id))
|
|
338
|
+
continue;
|
|
339
|
+
seen.add(id);
|
|
340
|
+
out.push(id);
|
|
341
|
+
for (const cid of convoChildIds(id))
|
|
342
|
+
if (!seen.has(cid))
|
|
343
|
+
q.push(cid);
|
|
344
|
+
}
|
|
345
|
+
return out;
|
|
346
|
+
}
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
// Shared cell builders
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
/** ⤳M direct-children badge — only on orchestrator rows. */
|
|
351
|
+
export function childBadge(node) {
|
|
352
|
+
if (node === null || node.mode !== 'orchestrator')
|
|
353
|
+
return '';
|
|
354
|
+
const m = childCount(node.node_id);
|
|
355
|
+
return m > 0 ? ` ${DIM}⤳${m}${RESET}` : '';
|
|
356
|
+
}
|
|
357
|
+
/** ⚑K pending-asks badge for a node, read from the supplied per-node ask map. */
|
|
358
|
+
export function askBadge(id, asks) {
|
|
359
|
+
const k = asks[id] ?? 0;
|
|
360
|
+
return k > 0 ? ` ${YELLOW}⚑${k}${RESET}` : '';
|
|
361
|
+
}
|
|
362
|
+
/** "Live work below" badge — green ⇣N when this node is NOT itself active but has
|
|
363
|
+
* N active descendants. The one signal that an idle/parked node isn't dead: real
|
|
364
|
+
* work is still running beneath it. Count comes from the single subtree-activity
|
|
365
|
+
* pass (no per-row walk). Suppressed on active rows (their own ● already says so). */
|
|
366
|
+
export function liveBelowBadge(node, activeBelow) {
|
|
367
|
+
if (node === null || node.status === 'active')
|
|
368
|
+
return '';
|
|
369
|
+
const n = activeBelow.get(node.node_id) ?? 0;
|
|
370
|
+
return n > 0 ? ` ${GREEN}⇣${n}${RESET}` : '';
|
|
371
|
+
}
|
|
372
|
+
/** Sort rank for sibling ordering — live nodes (active, then idle) ahead of
|
|
373
|
+
* terminal ones, so sessions still running surface at the TOP of each child
|
|
374
|
+
* group instead of being buried under finished/failed ones. */
|
|
375
|
+
export function statusRank(id) {
|
|
376
|
+
switch (cNode(id)?.status) {
|
|
377
|
+
case 'active': return 0;
|
|
378
|
+
case 'idle': return 1;
|
|
379
|
+
case 'done': return 2;
|
|
380
|
+
case 'canceled': return 3;
|
|
381
|
+
case 'dead': return 4;
|
|
382
|
+
default: return 5;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
/** Direct children, live-first — the sibling order used both when flattening
|
|
386
|
+
* the tree and when stepping into a subtree (`l`). Array.sort is stable, so
|
|
387
|
+
* equal-status siblings keep their creation order. */
|
|
388
|
+
export function sortedChildIds(id) {
|
|
389
|
+
return convoChildIds(id).sort((a, b) => statusRank(a) - statusRank(b));
|
|
390
|
+
}
|
|
391
|
+
export function computeSubtreeActivity(root, self) {
|
|
392
|
+
const expand = new Set();
|
|
393
|
+
const activeBelow = new Map();
|
|
394
|
+
const seen = new Set();
|
|
395
|
+
// Returns the count of active nodes in subtree(id) INCLUDING id, and whether
|
|
396
|
+
// that subtree is worth revealing (holds an active node or self).
|
|
397
|
+
const visit = (id) => {
|
|
398
|
+
const selfActive = cNode(id)?.status === 'active';
|
|
399
|
+
if (seen.has(id))
|
|
400
|
+
return { active: selfActive ? 1 : 0, reveal: selfActive || id === self };
|
|
401
|
+
seen.add(id);
|
|
402
|
+
let below = 0;
|
|
403
|
+
let childReveal = false;
|
|
404
|
+
for (const c of convoChildIds(id)) {
|
|
405
|
+
const r = visit(c);
|
|
406
|
+
below += r.active;
|
|
407
|
+
if (r.reveal)
|
|
408
|
+
childReveal = true;
|
|
409
|
+
}
|
|
410
|
+
activeBelow.set(id, below);
|
|
411
|
+
if (childReveal)
|
|
412
|
+
expand.add(id); // a descendant is worth revealing → unfold id
|
|
413
|
+
return { active: below + (selfActive ? 1 : 0), reveal: childReveal || selfActive || id === self };
|
|
414
|
+
};
|
|
415
|
+
visit(root);
|
|
416
|
+
return { expand, activeBelow };
|
|
417
|
+
}
|
|
418
|
+
/** Fold policy alone — thin wrapper over computeSubtreeActivity for the keypress
|
|
419
|
+
* path (buildGraphModel without a precomputed set). The render path computes the
|
|
420
|
+
* activity once and threads `expand` in, so it never recomputes here. */
|
|
421
|
+
export function computeDefaultExpanded(root, self) {
|
|
422
|
+
return computeSubtreeActivity(root, self).expand;
|
|
423
|
+
}
|
|
424
|
+
export function buildGraphModel(self, folds, expand) {
|
|
425
|
+
const rootId = climbRoot(self);
|
|
426
|
+
const defaultExpanded = expand ?? computeDefaultExpanded(rootId, self);
|
|
427
|
+
// userExpanded / userCollapsed override the auto policy; absent → policy decides.
|
|
428
|
+
const isFolded = (id) => folds.userExpanded.has(id) ? false : folds.userCollapsed.has(id) ? true : !defaultExpanded.has(id);
|
|
429
|
+
const rows = [];
|
|
430
|
+
const visited = new Set();
|
|
431
|
+
const walk = (id, prefix, isRoot, isLast) => {
|
|
432
|
+
if (visited.has(id)) {
|
|
433
|
+
const connector = isRoot ? '' : isLast ? '└─ ' : '├─ ';
|
|
434
|
+
rows.push({ id, hasKids: false, isSelf: id === self, branch: prefix + connector, cycle: true, collapsed: false });
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
visited.add(id);
|
|
438
|
+
const kids = sortedChildIds(id);
|
|
439
|
+
const folded = isFolded(id);
|
|
440
|
+
const connector = isRoot ? '' : isLast ? '└─ ' : '├─ ';
|
|
441
|
+
rows.push({ id, hasKids: kids.length > 0, isSelf: id === self, branch: prefix + connector, cycle: false, collapsed: folded });
|
|
442
|
+
if (folded)
|
|
443
|
+
return; // folded — don't descend
|
|
444
|
+
const childPrefix = isRoot ? '' : prefix + (isLast ? ' ' : '│ ');
|
|
445
|
+
for (let i = 0; i < kids.length; i++)
|
|
446
|
+
walk(kids[i], childPrefix, false, i === kids.length - 1);
|
|
447
|
+
};
|
|
448
|
+
walk(rootId, '', true, true);
|
|
449
|
+
return rows;
|
|
450
|
+
}
|
|
451
|
+
/** Render one GRAPH row. CURSOR (selected) → reverse-video bar; an ATTACHED
|
|
452
|
+
* (human-watched) node → a coloured background bar; SELF → bold name. The
|
|
453
|
+
* cursor outranks the attached tint when both land on the same row. Running
|
|
454
|
+
* is signaled by the dot glyph alone (● green = active engine). */
|
|
455
|
+
export function renderGraphRow(r, isCursor, focused, activeBelow, asks) {
|
|
456
|
+
const wrap = (line, attached) => isCursor ? fillBar(line, fillWidth(), REVERSE)
|
|
457
|
+
: attached ? fillBar(line, fillWidth(), BG_ATTACHED)
|
|
458
|
+
: truncate(line);
|
|
459
|
+
if (r.cycle) {
|
|
460
|
+
const line = `${r.branch} ${DIM}↺ ${shortId(r.id)}${RESET}`;
|
|
461
|
+
return wrap(line, false);
|
|
462
|
+
}
|
|
463
|
+
const node = cNode(r.id);
|
|
464
|
+
const dot = coloredGlyph(node);
|
|
465
|
+
const rawName = navLabel(node, r.id);
|
|
466
|
+
const name = r.isSelf ? `${BOLD}${rawName}${RESET}` : rawName;
|
|
467
|
+
const kind = `${DIM}${node?.kind ?? ''}${RESET}`;
|
|
468
|
+
const tokens = `${DIM}${tokensCell(r.id)}${RESET}`;
|
|
469
|
+
// ▸ marks an expandable (collapsed-with-kids) row. The cursor row gets no
|
|
470
|
+
// caret — its reverse-video bar already distinguishes it — so the triangle
|
|
471
|
+
// reads purely as "this unfolds".
|
|
472
|
+
const expandable = r.hasKids && r.collapsed;
|
|
473
|
+
const caret = !isCursor && expandable ? `${DIM}▸${RESET} ` : ' ';
|
|
474
|
+
const fold = expandable ? ` ${DIM}[+${childCount(r.id)}]${RESET}` : '';
|
|
475
|
+
const line = `${r.branch}${caret}${dot} ${name} ${kind} ${tokens}${cycleBadge(node)}${childBadge(node)}${liveBelowBadge(node, activeBelow)}${fold}${askBadge(r.id, asks)}${activityCell(r.id, node)}`;
|
|
476
|
+
return wrap(line, isAttached(r.id, node, focused));
|
|
477
|
+
}
|
|
478
|
+
/** Total lines the GRAPH widget may emit. pi hard-caps extension widgets at
|
|
479
|
+
* MAX_WIDGET_LINES — anything past that pi truncates itself, eating our own
|
|
480
|
+
* scroll chrome — so never exceed it (and shrink on a very short terminal).
|
|
481
|
+
* The viewport scrolls WITHIN this cap as the cursor moves. */
|
|
482
|
+
export function graphWidgetBudget() {
|
|
483
|
+
const rows = process.stdout.rows ?? VIEWPORT_FALLBACK_ROWS;
|
|
484
|
+
return Math.max(4, Math.min(PI_MAX_WIDGET_LINES, rows - 4));
|
|
485
|
+
}
|
|
486
|
+
export const GRAPH_HINT = `${DIM}jk move · hl fold · ↵ focus · e expand · x kill · m mgr · esc${RESET}`;
|
|
@@ -17,6 +17,13 @@ export declare function inboxPath(nodeId: string): string;
|
|
|
17
17
|
export declare function passivePath(nodeId: string): string;
|
|
18
18
|
export declare function transcriptPath(nodeId: string): string;
|
|
19
19
|
export declare function sessionPtrPath(nodeId: string): string;
|
|
20
|
+
/** On-read injection dedup set — the doc realpaths already surfaced on-read in
|
|
21
|
+
* this node's CURRENT conversation transcript. Persisted so the once-per-
|
|
22
|
+
* transcript dedup survives a dormancy → revive(resume) cycle: a resume reuses
|
|
23
|
+
* the same .jsonl transcript in a NEW pi process, so the in-memory set would
|
|
24
|
+
* otherwise start empty and re-inject docs already present in the transcript.
|
|
25
|
+
* Deleted by the launch paths that start a FRESH transcript. */
|
|
26
|
+
export declare function injectedDocsPath(nodeId: string): string;
|
|
20
27
|
/** Create the full directory skeleton for a node. Idempotent. */
|
|
21
28
|
export declare function ensureNodeDirs(nodeId: string): void;
|
|
22
29
|
/** Ensure the canvas home exists. Idempotent. */
|
|
@@ -62,6 +62,15 @@ export function transcriptPath(nodeId) {
|
|
|
62
62
|
export function sessionPtrPath(nodeId) {
|
|
63
63
|
return join(nodeDir(nodeId), 'session.ptr');
|
|
64
64
|
}
|
|
65
|
+
/** On-read injection dedup set — the doc realpaths already surfaced on-read in
|
|
66
|
+
* this node's CURRENT conversation transcript. Persisted so the once-per-
|
|
67
|
+
* transcript dedup survives a dormancy → revive(resume) cycle: a resume reuses
|
|
68
|
+
* the same .jsonl transcript in a NEW pi process, so the in-memory set would
|
|
69
|
+
* otherwise start empty and re-inject docs already present in the transcript.
|
|
70
|
+
* Deleted by the launch paths that start a FRESH transcript. */
|
|
71
|
+
export function injectedDocsPath(nodeId) {
|
|
72
|
+
return join(nodeDir(nodeId), 'injected-docs.json');
|
|
73
|
+
}
|
|
65
74
|
/** Create the full directory skeleton for a node. Idempotent. */
|
|
66
75
|
export function ensureNodeDirs(nodeId) {
|
|
67
76
|
for (const d of [contextDir(nodeId), jobDir(nodeId), reportsDir(nodeId)]) {
|
|
@@ -33,16 +33,4 @@ export interface BrokerEngine {
|
|
|
33
33
|
* through here so the seam stays a single, auditable swap.
|
|
34
34
|
*/
|
|
35
35
|
export declare function loadBrokerEngine(): Promise<BrokerEngine>;
|
|
36
|
-
/**
|
|
37
|
-
* Boot-time tripwire: compare the imported SDK `VERSION` against the `pi` binary
|
|
38
|
-
* crouter forks elsewhere (`pi --version`). On mismatch (or if the binary can't be
|
|
39
|
-
* probed) it logs a LOUD warning to stderr and proceeds — it NEVER throws. The v3
|
|
40
|
-
* session format auto-migrates on load, so minor skew round-trips; a hard fail here
|
|
41
|
-
* would risk a grace-revive crash loop. Phase 5 may harden this to fail-fast once
|
|
42
|
-
* the broker is the only host.
|
|
43
|
-
*
|
|
44
|
-
* @param engineVersion the version actually loaded (defaults to the statically
|
|
45
|
-
* imported `VERSION`; pass `engine.VERSION` from `loadBrokerEngine` to assert the
|
|
46
|
-
* version the broker is really driving).
|
|
47
|
-
*/
|
|
48
36
|
export declare function assertEngineVersion(engineVersion?: string): void;
|
|
@@ -11,7 +11,10 @@
|
|
|
11
11
|
// Nothing else in the tree imports this yet; it compiles but stays inert so the
|
|
12
12
|
// lifecycle suite stays green until the broker is wired up.
|
|
13
13
|
import { execFileSync } from 'node:child_process';
|
|
14
|
+
import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { delimiter, join } from 'node:path';
|
|
14
16
|
import { createAgentSessionServices, createAgentSessionFromServices, createAgentSessionRuntime, SessionManager, VERSION, } from '@earendil-works/pi-coding-agent';
|
|
17
|
+
import { crtrHome } from '../canvas/paths.js';
|
|
15
18
|
// Static re-exports — the real SDK surface the broker drives in production.
|
|
16
19
|
export { createAgentSessionServices, createAgentSessionFromServices, createAgentSessionRuntime, SessionManager, VERSION, };
|
|
17
20
|
/**
|
|
@@ -53,15 +56,83 @@ export async function loadBrokerEngine() {
|
|
|
53
56
|
* imported `VERSION`; pass `engine.VERSION` from `loadBrokerEngine` to assert the
|
|
54
57
|
* version the broker is really driving).
|
|
55
58
|
*/
|
|
59
|
+
/** Resolve the `pi` executable on PATH without spawning anything — a plain walk
|
|
60
|
+
* of `$PATH` entries (`statSync` follows symlinks, so an npm/brew bin shim
|
|
61
|
+
* resolves to the real target). Returns the first match, or undefined. */
|
|
62
|
+
function resolvePiBinary() {
|
|
63
|
+
const path = process.env['PATH'];
|
|
64
|
+
if (path === undefined || path === '')
|
|
65
|
+
return undefined;
|
|
66
|
+
for (const dir of path.split(delimiter)) {
|
|
67
|
+
if (dir === '')
|
|
68
|
+
continue;
|
|
69
|
+
const candidate = join(dir, 'pi');
|
|
70
|
+
try {
|
|
71
|
+
if (existsSync(candidate))
|
|
72
|
+
return candidate;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
/* unreadable PATH entry — skip */
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
const PI_VERSION_CACHE = () => join(crtrHome(), 'pi-version.json');
|
|
81
|
+
/** Cached `pi --version`, keyed on the binary's path+mtime+size so a pi upgrade
|
|
82
|
+
* re-probes but the steady state never spawns. Returns undefined when the
|
|
83
|
+
* binary can't be resolved/stat'd or the cache misses — the caller then spawns
|
|
84
|
+
* once and back-fills via {@link writePiVersionCache}. */
|
|
85
|
+
function readPiVersionCache(binPath, mtimeMs, size) {
|
|
86
|
+
try {
|
|
87
|
+
const c = JSON.parse(readFileSync(PI_VERSION_CACHE(), 'utf8'));
|
|
88
|
+
if (c.path === binPath && c.mtimeMs === mtimeMs && c.size === size && typeof c.version === 'string') {
|
|
89
|
+
return c.version;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
/* missing/corrupt cache — treat as a miss */
|
|
94
|
+
}
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
function writePiVersionCache(entry) {
|
|
98
|
+
try {
|
|
99
|
+
writeFileSync(PI_VERSION_CACHE(), JSON.stringify(entry), 'utf8');
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
/* best-effort: a missing cache just means the next boot re-probes */
|
|
103
|
+
}
|
|
104
|
+
}
|
|
56
105
|
export function assertEngineVersion(engineVersion = VERSION) {
|
|
106
|
+
// Resolve + stat the binary (no spawn). When its path+mtime+size matches the
|
|
107
|
+
// cache, reuse the stored version and skip the ~0.4s `pi --version` boot — the
|
|
108
|
+
// common path on every broker start. A pi upgrade changes the stat and re-probes.
|
|
109
|
+
const binPath = resolvePiBinary();
|
|
110
|
+
let stat;
|
|
111
|
+
if (binPath !== undefined) {
|
|
112
|
+
try {
|
|
113
|
+
const s = statSync(binPath);
|
|
114
|
+
stat = { mtimeMs: s.mtimeMs, size: s.size };
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
stat = undefined;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
57
120
|
let binaryVersion;
|
|
58
|
-
|
|
59
|
-
binaryVersion =
|
|
121
|
+
if (binPath !== undefined && stat !== undefined) {
|
|
122
|
+
binaryVersion = readPiVersionCache(binPath, stat.mtimeMs, stat.size);
|
|
60
123
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
124
|
+
if (binaryVersion === undefined) {
|
|
125
|
+
try {
|
|
126
|
+
binaryVersion = execFileSync('pi', ['--version'], { encoding: 'utf8' }).trim();
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
process.stderr.write(`[broker] WARNING: could not run 'pi --version' to verify engine parity ` +
|
|
130
|
+
`(SDK ${engineVersion}): ${err.message}\n`);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (binPath !== undefined && stat !== undefined) {
|
|
134
|
+
writePiVersionCache({ path: binPath, mtimeMs: stat.mtimeMs, size: stat.size, version: binaryVersion });
|
|
135
|
+
}
|
|
65
136
|
}
|
|
66
137
|
if (binaryVersion !== engineVersion) {
|
|
67
138
|
process.stderr.write(`[broker] WARNING: pi SDK version mismatch — imported SDK is ${engineVersion} but ` +
|
|
@@ -2,7 +2,7 @@ import { type Socket } from 'node:net';
|
|
|
2
2
|
import { type AgentSessionRuntime, type AgentSessionServices, type CreateAgentSessionResult, type ExtensionUIContext } from '@earendil-works/pi-coding-agent';
|
|
3
3
|
import { type BrokerSdkConfig } from './launch.js';
|
|
4
4
|
import { type BrokerEngine } from './broker-sdk.js';
|
|
5
|
-
import { FrameDecoder, type ClientRole, type RpcExtensionUIRequest, type RpcExtensionUIResponse } from './broker-protocol.js';
|
|
5
|
+
import { FrameDecoder, type BrokerSnapshot, type ClientRole, type RpcExtensionUIRequest, type RpcExtensionUIResponse } from './broker-protocol.js';
|
|
6
6
|
interface BrokerClient {
|
|
7
7
|
id: string;
|
|
8
8
|
role: ClientRole;
|
|
@@ -33,6 +33,7 @@ interface PendingDialog {
|
|
|
33
33
|
* bash children. No-op before the session is built or after a clean dispose. */
|
|
34
34
|
export declare function disposeActiveSession(): void;
|
|
35
35
|
export declare function runBroker(nodeId: string): Promise<void>;
|
|
36
|
+
export declare function snapshotMessages(session: CreateAgentSessionResult['session']): BrokerSnapshot['messages'];
|
|
36
37
|
export declare function buildBrokerSession(engine: BrokerEngine, cfg: BrokerSdkConfig): Promise<{
|
|
37
38
|
session: CreateAgentSessionResult['session'];
|
|
38
39
|
services: AgentSessionServices;
|