@crouton-kit/crouter 0.3.11 → 0.3.12
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/bin/crtrd +2 -0
- package/dist/builtin-personas/design/base.md +9 -0
- package/dist/builtin-personas/design/orchestrator.md +10 -0
- package/dist/builtin-personas/developer/base.md +9 -0
- package/dist/builtin-personas/developer/orchestrator.md +12 -0
- package/dist/builtin-personas/explore/base.md +9 -0
- package/dist/builtin-personas/explore/orchestrator.md +9 -0
- package/dist/builtin-personas/general/base.md +5 -0
- package/dist/builtin-personas/general/orchestrator.md +7 -0
- package/dist/builtin-personas/orchestration-kernel.md +71 -0
- package/dist/builtin-personas/plan/base.md +7 -0
- package/dist/builtin-personas/plan/orchestrator.md +12 -0
- package/dist/builtin-personas/review/base.md +7 -0
- package/dist/builtin-personas/review/orchestrator.md +9 -0
- package/dist/builtin-personas/runtime-base.md +39 -0
- package/dist/builtin-personas/spec/base.md +7 -0
- package/dist/builtin-personas/spec/orchestrator.md +10 -0
- package/dist/builtin-skills/skills/design/SKILL.md +51 -0
- package/dist/builtin-skills/skills/development/SKILL.md +109 -0
- package/dist/builtin-skills/skills/planning/SKILL.md +59 -0
- package/dist/builtin-skills/skills/spec/SKILL.md +83 -0
- package/dist/cli.js +14 -6
- package/dist/commands/{mode.d.ts → attention.d.ts} +1 -1
- package/dist/commands/attention.js +152 -0
- package/dist/commands/canvas.d.ts +2 -0
- package/dist/commands/canvas.js +35 -0
- package/dist/commands/daemon.d.ts +2 -0
- package/dist/commands/daemon.js +111 -0
- package/dist/commands/dashboard.d.ts +2 -0
- package/dist/commands/dashboard.js +65 -0
- package/dist/commands/human/prompts.d.ts +5 -0
- package/dist/commands/human/prompts.js +269 -0
- package/dist/commands/human/queue.d.ts +3 -0
- package/dist/commands/human/queue.js +133 -0
- package/dist/commands/human/shared.d.ts +43 -0
- package/dist/commands/human/shared.js +107 -0
- package/dist/commands/human.js +10 -454
- package/dist/commands/node.d.ts +2 -0
- package/dist/commands/node.js +354 -0
- package/dist/commands/pkg/market-inspect.d.ts +1 -0
- package/dist/commands/pkg/market-inspect.js +157 -0
- package/dist/commands/pkg/market-manage.d.ts +1 -0
- package/dist/commands/pkg/market-manage.js +316 -0
- package/dist/commands/pkg/market.d.ts +1 -0
- package/dist/commands/pkg/market.js +16 -0
- package/dist/commands/pkg/plugin-inspect.d.ts +1 -0
- package/dist/commands/pkg/plugin-inspect.js +142 -0
- package/dist/commands/pkg/plugin-manage.d.ts +1 -0
- package/dist/commands/pkg/plugin-manage.js +294 -0
- package/dist/commands/pkg/plugin.d.ts +1 -0
- package/dist/commands/pkg/plugin.js +16 -0
- package/dist/commands/pkg/shared.d.ts +5 -0
- package/dist/commands/pkg/shared.js +61 -0
- package/dist/commands/pkg.js +3 -1004
- package/dist/commands/push.d.ts +3 -0
- package/dist/commands/push.js +159 -0
- package/dist/commands/revive.d.ts +2 -0
- package/dist/commands/revive.js +64 -0
- package/dist/commands/skill/author.d.ts +3 -0
- package/dist/commands/skill/author.js +147 -0
- package/dist/commands/skill/find.d.ts +4 -0
- package/dist/commands/skill/find.js +254 -0
- package/dist/commands/skill/read.d.ts +1 -0
- package/dist/commands/skill/read.js +89 -0
- package/dist/commands/skill/shared.d.ts +19 -0
- package/dist/commands/skill/shared.js +207 -0
- package/dist/commands/skill/state.d.ts +3 -0
- package/dist/commands/skill/state.js +69 -0
- package/dist/commands/skill.js +6 -691
- package/dist/commands/sys/config.d.ts +1 -0
- package/dist/commands/sys/config.js +186 -0
- package/dist/commands/sys/doctor.d.ts +1 -0
- package/dist/commands/sys/doctor.js +369 -0
- package/dist/commands/sys/shared.d.ts +3 -0
- package/dist/commands/sys/shared.js +24 -0
- package/dist/commands/sys/update.d.ts +2 -0
- package/dist/commands/sys/update.js +114 -0
- package/dist/commands/sys.js +4 -694
- package/dist/core/__tests__/argv-parser.test.js +19 -1
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +100 -0
- package/dist/core/__tests__/canvas.test.js +154 -0
- package/dist/core/__tests__/reset.test.js +105 -0
- package/dist/core/canvas/attention.d.ts +24 -0
- package/dist/core/canvas/attention.js +94 -0
- package/dist/core/canvas/canvas.d.ts +40 -0
- package/dist/core/canvas/canvas.js +210 -0
- package/dist/core/canvas/db.d.ts +7 -0
- package/dist/core/canvas/db.js +61 -0
- package/dist/core/canvas/index.d.ts +4 -0
- package/dist/core/canvas/index.js +6 -0
- package/dist/core/canvas/paths.d.ts +16 -0
- package/dist/core/canvas/paths.js +62 -0
- package/dist/core/canvas/render.d.ts +30 -0
- package/dist/core/canvas/render.js +186 -0
- package/dist/core/canvas/types.d.ts +87 -0
- package/dist/core/canvas/types.js +8 -0
- package/dist/core/command.d.ts +5 -0
- package/dist/core/command.js +35 -10
- package/dist/core/feed/feed.d.ts +43 -0
- package/dist/core/feed/feed.js +116 -0
- package/dist/core/feed/inbox.d.ts +50 -0
- package/dist/core/feed/inbox.js +124 -0
- package/dist/core/help.js +5 -3
- package/dist/core/io.d.ts +15 -1
- package/dist/core/io.js +56 -6
- package/dist/core/personas/index.d.ts +12 -0
- package/dist/core/personas/index.js +10 -0
- package/dist/core/personas/loader.d.ts +44 -0
- package/dist/core/personas/loader.js +157 -0
- package/dist/core/personas/resolve.d.ts +36 -0
- package/dist/core/personas/resolve.js +110 -0
- package/dist/core/render.d.ts +11 -0
- package/dist/core/render.js +126 -0
- package/dist/core/resolver.d.ts +10 -0
- package/dist/core/resolver.js +109 -1
- package/dist/core/runtime/front-door.d.ts +10 -0
- package/dist/core/runtime/front-door.js +97 -0
- package/dist/core/runtime/kickoff.d.ts +23 -0
- package/dist/core/runtime/kickoff.js +134 -0
- package/dist/core/runtime/launch.d.ts +34 -0
- package/dist/core/runtime/launch.js +85 -0
- package/dist/core/runtime/nodes.d.ts +38 -0
- package/dist/core/runtime/nodes.js +95 -0
- package/dist/core/runtime/presence.d.ts +38 -0
- package/dist/core/runtime/presence.js +152 -0
- package/dist/core/runtime/promote.d.ts +30 -0
- package/dist/core/runtime/promote.js +105 -0
- package/dist/core/runtime/reset.d.ts +13 -0
- package/dist/core/runtime/reset.js +97 -0
- package/dist/core/runtime/revive.d.ts +26 -0
- package/dist/core/runtime/revive.js +89 -0
- package/dist/core/runtime/roadmap.d.ts +12 -0
- package/dist/core/runtime/roadmap.js +52 -0
- package/dist/core/runtime/spawn.d.ts +33 -0
- package/dist/core/runtime/spawn.js +118 -0
- package/dist/core/runtime/stop-guard.d.ts +18 -0
- package/dist/core/runtime/stop-guard.js +33 -0
- package/dist/core/runtime/tmux.d.ts +88 -0
- package/dist/core/runtime/tmux.js +198 -0
- package/dist/core/spawn.d.ts +17 -197
- package/dist/core/spawn.js +16 -539
- package/dist/daemon/crtrd-cli.js +4 -0
- package/dist/daemon/crtrd.d.ts +20 -0
- package/dist/daemon/crtrd.js +200 -0
- package/dist/daemon/manage.d.ts +17 -0
- package/dist/daemon/manage.js +57 -0
- package/dist/pi-extensions/canvas-inbox-watcher.d.ts +16 -0
- package/dist/pi-extensions/canvas-inbox-watcher.js +229 -0
- package/dist/pi-extensions/canvas-nav.d.ts +32 -0
- package/dist/pi-extensions/canvas-nav.js +536 -0
- package/dist/pi-extensions/canvas-stophook.d.ts +17 -0
- package/dist/pi-extensions/canvas-stophook.js +373 -0
- package/package.json +6 -5
- package/dist/commands/agent.d.ts +0 -6
- package/dist/commands/agent.js +0 -585
- package/dist/commands/debug.d.ts +0 -3
- package/dist/commands/debug.js +0 -192
- package/dist/commands/job.d.ts +0 -11
- package/dist/commands/job.js +0 -384
- package/dist/commands/mode.js +0 -231
- package/dist/commands/plan.d.ts +0 -4
- package/dist/commands/plan.js +0 -322
- package/dist/commands/spec.d.ts +0 -3
- package/dist/commands/spec.js +0 -299
- package/dist/core/__tests__/flow-leaves.test.js +0 -248
- package/dist/core/__tests__/job.test.js +0 -310
- package/dist/core/__tests__/jobs.test.js +0 -98
- package/dist/core/__tests__/spawn.test.js +0 -138
- package/dist/core/__tests__/subagents.test.d.ts +0 -1
- package/dist/core/__tests__/subagents.test.js +0 -75
- package/dist/core/jobs.d.ts +0 -107
- package/dist/core/jobs.js +0 -565
- package/dist/core/subagents.d.ts +0 -18
- package/dist/core/subagents.js +0 -163
- package/dist/prompts/agent.d.ts +0 -27
- package/dist/prompts/agent.js +0 -184
- package/dist/prompts/debug.d.ts +0 -8
- package/dist/prompts/debug.js +0 -44
- /package/dist/core/__tests__/{flow-leaves.test.d.ts → canvas-inbox-watcher.test.d.ts} +0 -0
- /package/dist/core/__tests__/{job.test.d.ts → canvas.test.d.ts} +0 -0
- /package/dist/core/__tests__/{jobs.test.d.ts → reset.test.d.ts} +0 -0
- /package/dist/{core/__tests__/spawn.test.d.ts → daemon/crtrd-cli.d.ts} +0 -0
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
// canvas-nav.ts — pi extension for pi-native canvas agent nodes.
|
|
2
|
+
//
|
|
3
|
+
// Renders a navigable "spine" of the node graph as chrome around the editor.
|
|
4
|
+
// The editor itself is "you"; the three lanes are your neighbours, stacked so
|
|
5
|
+
// the spine reads top→bottom (managers · peers · you · reports):
|
|
6
|
+
//
|
|
7
|
+
// ABOVE EDITOR crtr-asks ⚑ N waiting (only when N > 0)
|
|
8
|
+
// ABOVE EDITOR crtr-managers ↑ managers <name> ● … (or ↑ (root))
|
|
9
|
+
// ABOVE EDITOR crtr-siblings ↔ peers <name> ○ … (omitted when none)
|
|
10
|
+
// ───────────── EDITOR (you) ─────────────
|
|
11
|
+
// BELOW EDITOR crtr-reports ↓ reports <name> ○ … · ctx <k>
|
|
12
|
+
//
|
|
13
|
+
// Navigation (only on an EMPTY editor, so composing is never disturbed):
|
|
14
|
+
// Alt+k → managers (up) Alt+j → reports (down)
|
|
15
|
+
// Alt+h / Alt+l → peers (left / right)
|
|
16
|
+
// ↵ focus the selected node · esc clears the selection
|
|
17
|
+
// Selection is shown by weight + a ▸ caret — NOT the status dot (which encodes
|
|
18
|
+
// active ● / idle ○ / done ✓ / dead ✗) and not colour alone, so it reads under
|
|
19
|
+
// NO_COLOR and on any background.
|
|
20
|
+
//
|
|
21
|
+
// INERT when CRTR_NODE_ID is absent (plain pi session or legacy job agent).
|
|
22
|
+
//
|
|
23
|
+
// Refresh triggers:
|
|
24
|
+
// • session_start — initial paint once we have a ctx.ui handle
|
|
25
|
+
// • turn_end — statuses may have changed during the turn
|
|
26
|
+
// • background timer (ASK_POLL_MS) — polls `crtr canvas attention count` and
|
|
27
|
+
// repaints whenever the count changes
|
|
28
|
+
//
|
|
29
|
+
// Double-timer prevention (copied from canvas-inbox-watcher):
|
|
30
|
+
// `liveTimer` is module-level. A /reload re-enters this factory and clears
|
|
31
|
+
// the previous interval before starting a new one — exactly one timer lives.
|
|
32
|
+
//
|
|
33
|
+
// Plain TS-with-types — no imports from @earendil-works/* so this compiles
|
|
34
|
+
// inside crouter's own tsc build without a dep on the pi packages.
|
|
35
|
+
import { execFile, execFileSync } from 'node:child_process';
|
|
36
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
37
|
+
import { join } from 'node:path';
|
|
38
|
+
import { getNode, subscribersOf, subscriptionsOf, jobDir } from '../core/canvas/index.js';
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Module-level state — persist across /reload to prevent stacking
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
/** The one live background timer. Cleared and replaced on every re-registration. */
|
|
43
|
+
let liveTimer;
|
|
44
|
+
/** The one live onTerminalInput unsubscribe. Cleared/replaced on /reload so
|
|
45
|
+
* exactly one key tap exists (mirrors the liveTimer double-guard). */
|
|
46
|
+
let liveUnsub;
|
|
47
|
+
/** Last-known ask count — cached across renders so the UI stays cheap. */
|
|
48
|
+
let cachedAskCount = 0;
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Tuning constants
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
const ASK_POLL_MS = 5_000; // how often to shell out for ask count
|
|
53
|
+
const RENDER_DEBOUNCE_MS = 150; // coalesce rapid turn_end bursts
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// ANSI styling. pi wraps widget string[] lines in Text components that render
|
|
56
|
+
// embedded escapes (the same path used internally for theme.fg(...)), and it
|
|
57
|
+
// measures width with an ANSI-aware visibleWidth — so raw escapes are safe here
|
|
58
|
+
// and need no pi-tui dependency. Selection uses theme-agnostic attributes
|
|
59
|
+
// (bold/dim weight + a ▸ caret) so it pops on any terminal; status uses the
|
|
60
|
+
// standard 8 colors on the dot, which read on both light and dark backgrounds.
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
const ESC = '\x1b[';
|
|
63
|
+
const RESET = `${ESC}0m`;
|
|
64
|
+
const BOLD = `${ESC}1m`;
|
|
65
|
+
const DIM = `${ESC}2m`;
|
|
66
|
+
const GREEN = `${ESC}32m`;
|
|
67
|
+
const RED = `${ESC}31m`;
|
|
68
|
+
const YELLOW = `${ESC}33m`;
|
|
69
|
+
const CYAN = `${ESC}36m`;
|
|
70
|
+
const GRAY = `${ESC}90m`;
|
|
71
|
+
/** Status glyph colored by state: active green, idle dim, done cyan, dead red. */
|
|
72
|
+
function coloredGlyph(node) {
|
|
73
|
+
if (node === null)
|
|
74
|
+
return '?';
|
|
75
|
+
switch (node.status) {
|
|
76
|
+
case 'active': return `${GREEN}●${RESET}`;
|
|
77
|
+
case 'idle': return `${GRAY}○${RESET}`;
|
|
78
|
+
case 'done': return `${CYAN}✓${RESET}`;
|
|
79
|
+
case 'dead': return `${RED}✗${RESET}`;
|
|
80
|
+
default: return '?';
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// ANSI-aware truncation — single-row, no pi-tui dep
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
87
|
+
/** Visible width, ignoring ANSI escapes. */
|
|
88
|
+
function visibleWidth(s) {
|
|
89
|
+
return s.replace(ANSI_RE, '').length;
|
|
90
|
+
}
|
|
91
|
+
/** Truncate to `max` VISIBLE columns: escape sequences are copied through
|
|
92
|
+
* verbatim (so a cut never lands mid-escape) and the result always ends in
|
|
93
|
+
* RESET, so a clipped style can't bleed into the editor below. */
|
|
94
|
+
function truncate(s, max = 180) {
|
|
95
|
+
if (visibleWidth(s) <= max)
|
|
96
|
+
return s;
|
|
97
|
+
let out = '';
|
|
98
|
+
let w = 0;
|
|
99
|
+
let i = 0;
|
|
100
|
+
while (i < s.length && w < max - 1) {
|
|
101
|
+
if (s[i] === '\x1b') {
|
|
102
|
+
const m = /^\x1b\[[0-9;]*m/.exec(s.slice(i));
|
|
103
|
+
if (m) {
|
|
104
|
+
out += m[0];
|
|
105
|
+
i += m[0].length;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
out += s[i];
|
|
110
|
+
w++;
|
|
111
|
+
i++;
|
|
112
|
+
}
|
|
113
|
+
return `${out}…${RESET}`;
|
|
114
|
+
}
|
|
115
|
+
function readTelemetry(nodeId) {
|
|
116
|
+
try {
|
|
117
|
+
const p = join(jobDir(nodeId), 'telemetry.json');
|
|
118
|
+
if (!existsSync(p))
|
|
119
|
+
return {};
|
|
120
|
+
return JSON.parse(readFileSync(p, 'utf8'));
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return {};
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function fmtTokens(n) {
|
|
127
|
+
return n < 1_000 ? `${n}` : `${Math.round(n / 1_000)}k`;
|
|
128
|
+
}
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// Ask count — shells out synchronously with a tight timeout so the timer
|
|
131
|
+
// callback is cheap (< 2 s). Result is cached; the UI reads only the cache.
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
function fetchAskCount(nodeId) {
|
|
134
|
+
try {
|
|
135
|
+
const raw = execFileSync('crtr', ['canvas', 'attention', 'count', '--node', nodeId], {
|
|
136
|
+
timeout: 2_000,
|
|
137
|
+
encoding: 'utf8',
|
|
138
|
+
});
|
|
139
|
+
const parsed = JSON.parse(raw.trim());
|
|
140
|
+
return typeof parsed.count === 'number' ? parsed.count : 0;
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return 0;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function toNeighbor(refId) {
|
|
147
|
+
const node = getNode(refId);
|
|
148
|
+
return { id: refId, name: node?.name ?? refId.slice(0, 8), node };
|
|
149
|
+
}
|
|
150
|
+
/** Managers — who this node reports up to (the UP direction). */
|
|
151
|
+
function managersOf(nodeId) {
|
|
152
|
+
try {
|
|
153
|
+
return subscribersOf(nodeId).map((ref) => toNeighbor(ref.node_id));
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return [];
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/** Live reports — children (the DOWN direction). Finished/dead workers fall
|
|
160
|
+
* off: a terminal agent that's done its job no longer needs a chrome slot. */
|
|
161
|
+
function reportsOf(nodeId) {
|
|
162
|
+
try {
|
|
163
|
+
return subscriptionsOf(nodeId)
|
|
164
|
+
.map((ref) => toNeighbor(ref.node_id))
|
|
165
|
+
.filter((n) => n.node?.status === 'active' || n.node?.status === 'idle');
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return [];
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/** Peers — other live reports of this node's managers (the SIDE direction):
|
|
172
|
+
* nodes that share a manager with us, minus ourselves. Deduped across multiple
|
|
173
|
+
* managers; like reports, only active/idle peers earn a chrome slot. */
|
|
174
|
+
function siblingsOf(nodeId) {
|
|
175
|
+
try {
|
|
176
|
+
const seen = new Set([nodeId]);
|
|
177
|
+
const out = [];
|
|
178
|
+
for (const mgr of subscribersOf(nodeId)) {
|
|
179
|
+
for (const ref of subscriptionsOf(mgr.node_id)) {
|
|
180
|
+
if (seen.has(ref.node_id))
|
|
181
|
+
continue;
|
|
182
|
+
seen.add(ref.node_id);
|
|
183
|
+
const nb = toNeighbor(ref.node_id);
|
|
184
|
+
if (nb.node?.status === 'active' || nb.node?.status === 'idle')
|
|
185
|
+
out.push(nb);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return [];
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const FOCUS_HINT = `${DIM} ↵ focus · esc cancel${RESET}`;
|
|
195
|
+
/** Dim, fixed-width lane label so the slot columns line up across rows. */
|
|
196
|
+
function laneLabel(glyph, word) {
|
|
197
|
+
return `${DIM}${glyph} ${word.padEnd(8)}${RESET}`;
|
|
198
|
+
}
|
|
199
|
+
/** One neighbor slot. Selection is carried by WEIGHT + a caret — never by the
|
|
200
|
+
* status dot (it already encodes active/idle/done/dead) and never by colour
|
|
201
|
+
* alone, so it reads under NO_COLOR and on any background:
|
|
202
|
+
* selected → `▸ name ●` bold, leading caret
|
|
203
|
+
* unselected → ` name ●` dim, caret column reserved (no horizontal jitter)
|
|
204
|
+
* The trailing glyph stays status-colored in both states. */
|
|
205
|
+
function slot(n, selected) {
|
|
206
|
+
const glyph = coloredGlyph(n.node);
|
|
207
|
+
if (selected)
|
|
208
|
+
return `${BOLD}▸ ${n.name}${RESET} ${glyph}`;
|
|
209
|
+
return `${DIM} ${n.name}${RESET} ${glyph}`;
|
|
210
|
+
}
|
|
211
|
+
/** Join one lane's slots, marking the selected index and emitting the focus
|
|
212
|
+
* hint only when this lane actually holds the selection. */
|
|
213
|
+
function laneSlots(neighbors, selIdx) {
|
|
214
|
+
const body = neighbors.map((n, i) => slot(n, i === selIdx)).join(' ');
|
|
215
|
+
return { body, hint: selIdx >= 0 ? FOCUS_HINT : '' };
|
|
216
|
+
}
|
|
217
|
+
/** ↑ managers <slots> (or ↑ (root) when this node reports to no one) */
|
|
218
|
+
function buildManagersLines(managers, cursor) {
|
|
219
|
+
if (managers.length === 0)
|
|
220
|
+
return [`${DIM}↑ ${'(root)'.padEnd(8)}${RESET}`];
|
|
221
|
+
const selIdx = cursor.lane === 'up' ? cursor.idx : -1;
|
|
222
|
+
const { body, hint } = laneSlots(managers, selIdx);
|
|
223
|
+
return [truncate(`${laneLabel('↑', 'managers')} ${body}${hint}`)];
|
|
224
|
+
}
|
|
225
|
+
/** ↔ peers <slots> (the whole row is omitted when this node has no peers) */
|
|
226
|
+
function buildSiblingsLines(siblings, cursor) {
|
|
227
|
+
if (siblings.length === 0)
|
|
228
|
+
return undefined;
|
|
229
|
+
const selIdx = cursor.lane === 'side' ? cursor.idx : -1;
|
|
230
|
+
const { body, hint } = laneSlots(siblings, selIdx);
|
|
231
|
+
return [truncate(`${laneLabel('↔', 'peers')} ${body}${hint}`)];
|
|
232
|
+
}
|
|
233
|
+
/** ↓ reports <slots> · ctx <k> (slots → (none) when this node has no reports) */
|
|
234
|
+
function buildReportsLines(nodeId, reports, cursor) {
|
|
235
|
+
const tel = readTelemetry(nodeId);
|
|
236
|
+
const ctx = tel.tokens_in != null && tel.tokens_in > 0 ? `${DIM}· ctx ${fmtTokens(tel.tokens_in)}${RESET}` : '';
|
|
237
|
+
const label = laneLabel('↓', 'reports');
|
|
238
|
+
if (reports.length === 0) {
|
|
239
|
+
return [truncate(`${label} ${DIM}(none)${RESET}${ctx !== '' ? ` ${ctx}` : ''}`)];
|
|
240
|
+
}
|
|
241
|
+
const selIdx = cursor.lane === 'down' ? cursor.idx : -1;
|
|
242
|
+
const { body, hint } = laneSlots(reports, selIdx);
|
|
243
|
+
const tail = ctx !== '' ? ` ${ctx}` : '';
|
|
244
|
+
return [truncate(`${label} ${body}${tail}${hint}`)];
|
|
245
|
+
}
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
// Key decoding — Alt+j / Alt+k reach us in different encodings depending on the
|
|
248
|
+
// terminal's active keyboard protocol. pi enables the kitty / modifyOtherKeys
|
|
249
|
+
// protocols, and a tmux with `extended-keys csi-u` then delivers a *modified*
|
|
250
|
+
// key as a CSI-u sequence — NOT the legacy ESC-prefix form. Comparing against a
|
|
251
|
+
// single literal ("\x1bj") silently fails on any such terminal, so we accept
|
|
252
|
+
// every encoding:
|
|
253
|
+
//
|
|
254
|
+
// legacy ESC j "\x1bj"
|
|
255
|
+
// kitty / csi-u ESC [ 106 ; 3 u "\x1b[106;3u" (mod 3 → alt)
|
|
256
|
+
// modifyOtherKeys ESC [ 27 ; 3 ; 106 ~ "\x1b[27;3;106~"
|
|
257
|
+
//
|
|
258
|
+
// The CSI-u modifier value is `mod-1` as a bitmask (shift 1, alt 2, ctrl 4,
|
|
259
|
+
// super 8, …); Alt-alone is bit 2 set with shift/ctrl/super/etc. all clear
|
|
260
|
+
// (lock bits ignored). Mirrors pi-tui's own parseKey, kept dependency-free.
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
const CSI_U_RE = /^\x1b\[(\d+)(?::\d*)?(?::\d+)?(?:;(\d+))?(?::\d+)?u$/;
|
|
263
|
+
const MOK_RE = /^\x1b\[27;(\d+);(\d+)~$/;
|
|
264
|
+
/** True when a decoded CSI-u modifier (already `mod-1`) is Alt and nothing else
|
|
265
|
+
* besides lock keys. */
|
|
266
|
+
function isAltOnly(mod) {
|
|
267
|
+
return (mod & 2) !== 0 && (mod & (1 | 4 | 8 | 16 | 32)) === 0;
|
|
268
|
+
}
|
|
269
|
+
/** Recognize Alt+<letter> across legacy, kitty/CSI-u and modifyOtherKeys. */
|
|
270
|
+
function isAltKey(data, letter) {
|
|
271
|
+
const code = letter.charCodeAt(0);
|
|
272
|
+
if (data === `\x1b${letter}`)
|
|
273
|
+
return true; // legacy ESC-prefix
|
|
274
|
+
const u = CSI_U_RE.exec(data);
|
|
275
|
+
if (u !== null) {
|
|
276
|
+
const mod = u[2] !== undefined ? parseInt(u[2], 10) - 1 : 0;
|
|
277
|
+
return parseInt(u[1], 10) === code && isAltOnly(mod);
|
|
278
|
+
}
|
|
279
|
+
const m = MOK_RE.exec(data);
|
|
280
|
+
if (m !== null) {
|
|
281
|
+
return parseInt(m[2], 10) === code && isAltOnly(parseInt(m[1], 10) - 1);
|
|
282
|
+
}
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
/** Plain Enter across legacy and kitty (ESC [ 13 u). */
|
|
286
|
+
function isEnterKey(data) {
|
|
287
|
+
return data === '\r' || data === '\n' || /^\x1b\[13(?:;1)?u$/.test(data);
|
|
288
|
+
}
|
|
289
|
+
/** Plain Escape across legacy and kitty (ESC [ 27 u). */
|
|
290
|
+
function isEscKey(data) {
|
|
291
|
+
return data === '\x1b' || /^\x1b\[27(?:;1)?u$/.test(data);
|
|
292
|
+
}
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
// Extension
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
/**
|
|
297
|
+
* Register the canvas nav chrome on `pi`.
|
|
298
|
+
*
|
|
299
|
+
* Returns immediately when CRTR_NODE_ID is absent — the extension is fully
|
|
300
|
+
* inert in a non-canvas pi session.
|
|
301
|
+
*/
|
|
302
|
+
export function registerCanvasNav(pi) {
|
|
303
|
+
const nodeId = process.env['CRTR_NODE_ID'];
|
|
304
|
+
if (nodeId === undefined || nodeId.trim() === '')
|
|
305
|
+
return; // not a canvas node
|
|
306
|
+
// Captured from session_start; used in every subsequent render.
|
|
307
|
+
let ui;
|
|
308
|
+
// Debounce flag — prevents stacked renders from rapid turn_end bursts.
|
|
309
|
+
let renderScheduled = false;
|
|
310
|
+
// Spine cursor across the three lanes (see Lane / Cursor above): which lane
|
|
311
|
+
// is active ('none' = nothing selected, chrome calm) and the index within it.
|
|
312
|
+
// Driven by the key tap below.
|
|
313
|
+
let cursor = { lane: 'none', idx: 0 };
|
|
314
|
+
// -------------------------------------------------------------------------
|
|
315
|
+
// Core render — pushes all three widgets in one pass
|
|
316
|
+
// -------------------------------------------------------------------------
|
|
317
|
+
// Re-clamp the cursor against the current lane's length (the graph may have
|
|
318
|
+
// shrunk since the last keypress); collapse to 'none' if the lane emptied.
|
|
319
|
+
const clampCursor = (managers, siblings, reports) => {
|
|
320
|
+
if (cursor.lane === 'none')
|
|
321
|
+
return;
|
|
322
|
+
const len = cursor.lane === 'up' ? managers.length :
|
|
323
|
+
cursor.lane === 'side' ? siblings.length :
|
|
324
|
+
reports.length;
|
|
325
|
+
if (len === 0) {
|
|
326
|
+
cursor = { lane: 'none', idx: 0 };
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
cursor.idx = Math.max(0, Math.min(len - 1, cursor.idx));
|
|
330
|
+
};
|
|
331
|
+
const render = () => {
|
|
332
|
+
if (ui === undefined)
|
|
333
|
+
return;
|
|
334
|
+
try {
|
|
335
|
+
const managers = managersOf(nodeId);
|
|
336
|
+
const siblings = siblingsOf(nodeId);
|
|
337
|
+
const reports = reportsOf(nodeId);
|
|
338
|
+
clampCursor(managers, siblings, reports);
|
|
339
|
+
// ⚑ pending asks — top of the stack, omitted entirely when count is 0.
|
|
340
|
+
ui.setWidget('crtr-asks', cachedAskCount > 0 ? [`${YELLOW}⚑ ${cachedAskCount} waiting${RESET}`] : undefined, { placement: 'aboveEditor' });
|
|
341
|
+
// ↑ managers, then ↔ peers directly above the editor — the spine reads
|
|
342
|
+
// top→bottom: managers · peers · [you] · reports. setWidget(…, undefined)
|
|
343
|
+
// drops the peers row entirely when this node has none.
|
|
344
|
+
ui.setWidget('crtr-managers', buildManagersLines(managers, cursor), { placement: 'aboveEditor' });
|
|
345
|
+
ui.setWidget('crtr-siblings', buildSiblingsLines(siblings, cursor), { placement: 'aboveEditor' });
|
|
346
|
+
// ↓ reports row, below the editor.
|
|
347
|
+
ui.setWidget('crtr-reports', buildReportsLines(nodeId, reports, cursor), { placement: 'belowEditor' });
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
/* render is best-effort; never throw out of a handler */
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
// Debounced render: coalesces rapid event bursts into one paint.
|
|
354
|
+
const scheduleRender = () => {
|
|
355
|
+
if (renderScheduled)
|
|
356
|
+
return;
|
|
357
|
+
renderScheduled = true;
|
|
358
|
+
setTimeout(() => {
|
|
359
|
+
renderScheduled = false;
|
|
360
|
+
render();
|
|
361
|
+
}, RENDER_DEBOUNCE_MS);
|
|
362
|
+
};
|
|
363
|
+
// Bring the selected node's window forefront. Reuses the `crtr node focus`
|
|
364
|
+
// CLI (which revives a dormant target first) via the same execFile pattern
|
|
365
|
+
// as the ask-count poll — keeps tmux/revive logic out of the extension.
|
|
366
|
+
const focusTarget = (id) => {
|
|
367
|
+
try {
|
|
368
|
+
execFile('crtr', ['node', 'focus', id], (err) => {
|
|
369
|
+
if (err != null && ui?.notify != null) {
|
|
370
|
+
try {
|
|
371
|
+
ui.notify(`focus failed: ${id.slice(0, 8)}`, 'error');
|
|
372
|
+
}
|
|
373
|
+
catch { /* best-effort */ }
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
/* best-effort */
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
// Pre-editor key tap. Only acts on an EMPTY editor so message composition
|
|
382
|
+
// (multi-line cursor moves, history, submit) is never disturbed. Vim-style
|
|
383
|
+
// Alt+h/j/k/l walk the spine — Alt+k UP (managers), Alt+j DOWN (reports),
|
|
384
|
+
// Alt+h/Alt+l LEFT/RIGHT (peers) — so the bare arrow keys stay bound to pi's
|
|
385
|
+
// normal history recall and never conflict with canvas nav.
|
|
386
|
+
const handleKey = (data) => {
|
|
387
|
+
try {
|
|
388
|
+
if (ui === undefined)
|
|
389
|
+
return undefined;
|
|
390
|
+
let editorEmpty = true;
|
|
391
|
+
try {
|
|
392
|
+
editorEmpty = (ui.getEditorText?.() ?? '').trim() === '';
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
editorEmpty = false;
|
|
396
|
+
}
|
|
397
|
+
if (!editorEmpty)
|
|
398
|
+
return undefined; // composing — leave every key alone
|
|
399
|
+
// Alt+h/j/k/l walk the spine — recognized across legacy ESC-prefix,
|
|
400
|
+
// kitty/CSI-u and modifyOtherKeys encodings (see isAltKey above) so nav
|
|
401
|
+
// works regardless of the terminal's keyboard protocol.
|
|
402
|
+
const isUp = isAltKey(data, 'k');
|
|
403
|
+
const isDown = isAltKey(data, 'j');
|
|
404
|
+
const isLeft = isAltKey(data, 'h');
|
|
405
|
+
const isRight = isAltKey(data, 'l');
|
|
406
|
+
const isEnter = isEnterKey(data);
|
|
407
|
+
const isEsc = isEscKey(data);
|
|
408
|
+
if (!isUp && !isDown && !isLeft && !isRight && !isEnter && !isEsc) {
|
|
409
|
+
// Any other key cancels an active selection, then passes through so the
|
|
410
|
+
// character lands in the editor as normal.
|
|
411
|
+
if (cursor.lane !== 'none') {
|
|
412
|
+
cursor = { lane: 'none', idx: 0 };
|
|
413
|
+
render();
|
|
414
|
+
}
|
|
415
|
+
return undefined;
|
|
416
|
+
}
|
|
417
|
+
const managers = managersOf(nodeId);
|
|
418
|
+
const siblings = siblingsOf(nodeId);
|
|
419
|
+
const reports = reportsOf(nodeId);
|
|
420
|
+
// Move within (or hop into) a lane, cycling with wrap. Entering a lane
|
|
421
|
+
// lands on the first slot for forward motion, the last for backward.
|
|
422
|
+
const step = (lane, count, dir) => {
|
|
423
|
+
if (count === 0)
|
|
424
|
+
return;
|
|
425
|
+
if (cursor.lane !== lane)
|
|
426
|
+
cursor = { lane, idx: dir === 1 ? 0 : count - 1 };
|
|
427
|
+
else
|
|
428
|
+
cursor = { lane, idx: (cursor.idx + dir + count) % count };
|
|
429
|
+
};
|
|
430
|
+
if (isUp) {
|
|
431
|
+
step('up', managers.length, +1);
|
|
432
|
+
render();
|
|
433
|
+
return { consume: true };
|
|
434
|
+
}
|
|
435
|
+
if (isDown) {
|
|
436
|
+
step('down', reports.length, +1);
|
|
437
|
+
render();
|
|
438
|
+
return { consume: true };
|
|
439
|
+
}
|
|
440
|
+
if (isRight) {
|
|
441
|
+
step('side', siblings.length, +1);
|
|
442
|
+
render();
|
|
443
|
+
return { consume: true };
|
|
444
|
+
}
|
|
445
|
+
if (isLeft) {
|
|
446
|
+
step('side', siblings.length, -1);
|
|
447
|
+
render();
|
|
448
|
+
return { consume: true };
|
|
449
|
+
}
|
|
450
|
+
if (isEsc) {
|
|
451
|
+
if (cursor.lane === 'none')
|
|
452
|
+
return undefined;
|
|
453
|
+
cursor = { lane: 'none', idx: 0 };
|
|
454
|
+
render();
|
|
455
|
+
return { consume: true };
|
|
456
|
+
}
|
|
457
|
+
// isEnter — focus the selected neighbor, if any; else normal submit.
|
|
458
|
+
if (cursor.lane === 'none')
|
|
459
|
+
return undefined; // nothing selected → submit
|
|
460
|
+
const lane = cursor.lane === 'up' ? managers : cursor.lane === 'side' ? siblings : reports;
|
|
461
|
+
const target = lane[cursor.idx];
|
|
462
|
+
if (target !== undefined)
|
|
463
|
+
focusTarget(target.id);
|
|
464
|
+
cursor = { lane: 'none', idx: 0 };
|
|
465
|
+
render();
|
|
466
|
+
return { consume: true };
|
|
467
|
+
}
|
|
468
|
+
catch {
|
|
469
|
+
return undefined;
|
|
470
|
+
}
|
|
471
|
+
};
|
|
472
|
+
// -------------------------------------------------------------------------
|
|
473
|
+
// Event handlers
|
|
474
|
+
// -------------------------------------------------------------------------
|
|
475
|
+
pi.on('session_start', (_event, ctx) => {
|
|
476
|
+
ui = ctx.ui;
|
|
477
|
+
// Register the spine-navigation key tap once. Double-guard against /reload
|
|
478
|
+
// stacking (mirrors liveTimer): clear any previous tap before adding ours.
|
|
479
|
+
if (liveUnsub !== undefined) {
|
|
480
|
+
try {
|
|
481
|
+
liveUnsub();
|
|
482
|
+
}
|
|
483
|
+
catch { /* ignore */ }
|
|
484
|
+
liveUnsub = undefined;
|
|
485
|
+
}
|
|
486
|
+
try {
|
|
487
|
+
if (typeof ctx.ui.onTerminalInput === 'function') {
|
|
488
|
+
liveUnsub = ctx.ui.onTerminalInput(handleKey);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
catch {
|
|
492
|
+
/* onTerminalInput unavailable (older pi / non-interactive) — chrome stays display-only */
|
|
493
|
+
}
|
|
494
|
+
scheduleRender();
|
|
495
|
+
});
|
|
496
|
+
pi.on('turn_end', (_event, _ctx) => {
|
|
497
|
+
scheduleRender();
|
|
498
|
+
});
|
|
499
|
+
// -------------------------------------------------------------------------
|
|
500
|
+
// Background timer — ask-count polling + periodic refresh
|
|
501
|
+
// -------------------------------------------------------------------------
|
|
502
|
+
if (liveTimer !== undefined)
|
|
503
|
+
clearInterval(liveTimer);
|
|
504
|
+
const timer = setInterval(() => {
|
|
505
|
+
try {
|
|
506
|
+
const fresh = fetchAskCount(nodeId);
|
|
507
|
+
// Only repaint when the count actually changed — avoids constant flicker.
|
|
508
|
+
if (fresh !== cachedAskCount) {
|
|
509
|
+
cachedAskCount = fresh;
|
|
510
|
+
scheduleRender();
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
catch {
|
|
514
|
+
/* timer is best-effort */
|
|
515
|
+
}
|
|
516
|
+
}, ASK_POLL_MS);
|
|
517
|
+
// unref() so the timer doesn't keep the Node process alive after everything
|
|
518
|
+
// else has finished — matches the inbox-watcher convention.
|
|
519
|
+
if (typeof timer.unref === 'function')
|
|
520
|
+
timer.unref();
|
|
521
|
+
liveTimer = timer;
|
|
522
|
+
// Clear on shutdown so a /reload never discovers a live sibling timer.
|
|
523
|
+
pi.on('session_shutdown', () => {
|
|
524
|
+
clearInterval(timer);
|
|
525
|
+
if (liveTimer === timer)
|
|
526
|
+
liveTimer = undefined;
|
|
527
|
+
if (liveUnsub !== undefined) {
|
|
528
|
+
try {
|
|
529
|
+
liveUnsub();
|
|
530
|
+
}
|
|
531
|
+
catch { /* ignore */ }
|
|
532
|
+
liveUnsub = undefined;
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
export default registerCanvasNav;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
type PiEvents = 'turn_end' | 'agent_end' | 'session_shutdown' | 'session_start';
|
|
2
|
+
interface PiLike {
|
|
3
|
+
on: (event: PiEvents, handler: (event: any, ctx: any) => void | Promise<void>) => void;
|
|
4
|
+
sendUserMessage: (content: string, options?: {
|
|
5
|
+
deliverAs?: 'steer' | 'followUp';
|
|
6
|
+
}) => void;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Register the canvas turn_end / agent_end handlers on `pi`.
|
|
10
|
+
*
|
|
11
|
+
* Returns immediately when CRTR_NODE_ID is absent — the extension is fully
|
|
12
|
+
* inert in a non-canvas pi session. Safe to call multiple times (each call
|
|
13
|
+
* re-registers on the same `pi` instance, so it should only be called once
|
|
14
|
+
* per node lifecycle, matching how pi loads extensions).
|
|
15
|
+
*/
|
|
16
|
+
export declare function registerCanvasStophook(pi: PiLike): void;
|
|
17
|
+
export default registerCanvasStophook;
|