@crouton-kit/crouter 0.3.11 → 0.3.13

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 (182) hide show
  1. package/bin/crtrd +2 -0
  2. package/dist/builtin-personas/design/base.md +9 -0
  3. package/dist/builtin-personas/design/orchestrator.md +10 -0
  4. package/dist/builtin-personas/developer/base.md +9 -0
  5. package/dist/builtin-personas/developer/orchestrator.md +12 -0
  6. package/dist/builtin-personas/explore/base.md +9 -0
  7. package/dist/builtin-personas/explore/orchestrator.md +9 -0
  8. package/dist/builtin-personas/general/base.md +5 -0
  9. package/dist/builtin-personas/general/orchestrator.md +7 -0
  10. package/dist/builtin-personas/orchestration-kernel.md +71 -0
  11. package/dist/builtin-personas/plan/base.md +7 -0
  12. package/dist/builtin-personas/plan/orchestrator.md +12 -0
  13. package/dist/builtin-personas/review/base.md +7 -0
  14. package/dist/builtin-personas/review/orchestrator.md +9 -0
  15. package/dist/builtin-personas/runtime-base.md +39 -0
  16. package/dist/builtin-personas/spec/base.md +7 -0
  17. package/dist/builtin-personas/spec/orchestrator.md +10 -0
  18. package/dist/builtin-skills/skills/design/SKILL.md +51 -0
  19. package/dist/builtin-skills/skills/development/SKILL.md +109 -0
  20. package/dist/builtin-skills/skills/planning/SKILL.md +59 -0
  21. package/dist/builtin-skills/skills/spec/SKILL.md +83 -0
  22. package/dist/cli.js +14 -6
  23. package/dist/commands/{mode.d.ts → attention.d.ts} +1 -1
  24. package/dist/commands/attention.js +152 -0
  25. package/dist/commands/canvas.d.ts +2 -0
  26. package/dist/commands/canvas.js +35 -0
  27. package/dist/commands/daemon.d.ts +2 -0
  28. package/dist/commands/daemon.js +111 -0
  29. package/dist/commands/dashboard.d.ts +2 -0
  30. package/dist/commands/dashboard.js +65 -0
  31. package/dist/commands/human/prompts.d.ts +5 -0
  32. package/dist/commands/human/prompts.js +269 -0
  33. package/dist/commands/human/queue.d.ts +3 -0
  34. package/dist/commands/human/queue.js +133 -0
  35. package/dist/commands/human/shared.d.ts +43 -0
  36. package/dist/commands/human/shared.js +107 -0
  37. package/dist/commands/human.js +10 -454
  38. package/dist/commands/node.d.ts +2 -0
  39. package/dist/commands/node.js +407 -0
  40. package/dist/commands/pkg/market-inspect.d.ts +1 -0
  41. package/dist/commands/pkg/market-inspect.js +157 -0
  42. package/dist/commands/pkg/market-manage.d.ts +1 -0
  43. package/dist/commands/pkg/market-manage.js +316 -0
  44. package/dist/commands/pkg/market.d.ts +1 -0
  45. package/dist/commands/pkg/market.js +16 -0
  46. package/dist/commands/pkg/plugin-inspect.d.ts +1 -0
  47. package/dist/commands/pkg/plugin-inspect.js +142 -0
  48. package/dist/commands/pkg/plugin-manage.d.ts +1 -0
  49. package/dist/commands/pkg/plugin-manage.js +294 -0
  50. package/dist/commands/pkg/plugin.d.ts +1 -0
  51. package/dist/commands/pkg/plugin.js +16 -0
  52. package/dist/commands/pkg/shared.d.ts +5 -0
  53. package/dist/commands/pkg/shared.js +61 -0
  54. package/dist/commands/pkg.js +3 -1004
  55. package/dist/commands/push.d.ts +3 -0
  56. package/dist/commands/push.js +159 -0
  57. package/dist/commands/revive.d.ts +2 -0
  58. package/dist/commands/revive.js +64 -0
  59. package/dist/commands/skill/author.d.ts +3 -0
  60. package/dist/commands/skill/author.js +147 -0
  61. package/dist/commands/skill/find.d.ts +4 -0
  62. package/dist/commands/skill/find.js +254 -0
  63. package/dist/commands/skill/read.d.ts +1 -0
  64. package/dist/commands/skill/read.js +89 -0
  65. package/dist/commands/skill/shared.d.ts +19 -0
  66. package/dist/commands/skill/shared.js +207 -0
  67. package/dist/commands/skill/state.d.ts +3 -0
  68. package/dist/commands/skill/state.js +69 -0
  69. package/dist/commands/skill.js +6 -691
  70. package/dist/commands/sys/config.d.ts +1 -0
  71. package/dist/commands/sys/config.js +186 -0
  72. package/dist/commands/sys/doctor.d.ts +1 -0
  73. package/dist/commands/sys/doctor.js +369 -0
  74. package/dist/commands/sys/shared.d.ts +3 -0
  75. package/dist/commands/sys/shared.js +24 -0
  76. package/dist/commands/sys/update.d.ts +2 -0
  77. package/dist/commands/sys/update.js +114 -0
  78. package/dist/commands/sys.js +4 -694
  79. package/dist/core/__tests__/argv-parser.test.js +19 -1
  80. package/dist/core/__tests__/canvas-inbox-watcher.test.js +100 -0
  81. package/dist/core/__tests__/canvas.test.js +154 -0
  82. package/dist/core/__tests__/reset.test.js +105 -0
  83. package/dist/core/canvas/attention.d.ts +24 -0
  84. package/dist/core/canvas/attention.js +94 -0
  85. package/dist/core/canvas/canvas.d.ts +40 -0
  86. package/dist/core/canvas/canvas.js +210 -0
  87. package/dist/core/canvas/db.d.ts +7 -0
  88. package/dist/core/canvas/db.js +61 -0
  89. package/dist/core/canvas/index.d.ts +4 -0
  90. package/dist/core/canvas/index.js +6 -0
  91. package/dist/core/canvas/paths.d.ts +16 -0
  92. package/dist/core/canvas/paths.js +62 -0
  93. package/dist/core/canvas/render.d.ts +30 -0
  94. package/dist/core/canvas/render.js +186 -0
  95. package/dist/core/canvas/types.d.ts +87 -0
  96. package/dist/core/canvas/types.js +8 -0
  97. package/dist/core/command.d.ts +5 -0
  98. package/dist/core/command.js +35 -10
  99. package/dist/core/feed/feed.d.ts +43 -0
  100. package/dist/core/feed/feed.js +116 -0
  101. package/dist/core/feed/inbox.d.ts +50 -0
  102. package/dist/core/feed/inbox.js +124 -0
  103. package/dist/core/help.js +5 -3
  104. package/dist/core/io.d.ts +15 -1
  105. package/dist/core/io.js +56 -6
  106. package/dist/core/personas/index.d.ts +12 -0
  107. package/dist/core/personas/index.js +10 -0
  108. package/dist/core/personas/loader.d.ts +44 -0
  109. package/dist/core/personas/loader.js +157 -0
  110. package/dist/core/personas/resolve.d.ts +36 -0
  111. package/dist/core/personas/resolve.js +110 -0
  112. package/dist/core/render.d.ts +11 -0
  113. package/dist/core/render.js +126 -0
  114. package/dist/core/resolver.d.ts +10 -0
  115. package/dist/core/resolver.js +109 -1
  116. package/dist/core/runtime/front-door.d.ts +10 -0
  117. package/dist/core/runtime/front-door.js +97 -0
  118. package/dist/core/runtime/kickoff.d.ts +23 -0
  119. package/dist/core/runtime/kickoff.js +134 -0
  120. package/dist/core/runtime/launch.d.ts +34 -0
  121. package/dist/core/runtime/launch.js +85 -0
  122. package/dist/core/runtime/nodes.d.ts +38 -0
  123. package/dist/core/runtime/nodes.js +95 -0
  124. package/dist/core/runtime/presence.d.ts +55 -0
  125. package/dist/core/runtime/presence.js +198 -0
  126. package/dist/core/runtime/promote.d.ts +30 -0
  127. package/dist/core/runtime/promote.js +105 -0
  128. package/dist/core/runtime/reset.d.ts +13 -0
  129. package/dist/core/runtime/reset.js +97 -0
  130. package/dist/core/runtime/revive.d.ts +26 -0
  131. package/dist/core/runtime/revive.js +87 -0
  132. package/dist/core/runtime/roadmap.d.ts +12 -0
  133. package/dist/core/runtime/roadmap.js +52 -0
  134. package/dist/core/runtime/spawn.d.ts +31 -0
  135. package/dist/core/runtime/spawn.js +123 -0
  136. package/dist/core/runtime/stop-guard.d.ts +18 -0
  137. package/dist/core/runtime/stop-guard.js +33 -0
  138. package/dist/core/runtime/tmux.d.ts +107 -0
  139. package/dist/core/runtime/tmux.js +244 -0
  140. package/dist/core/spawn.d.ts +17 -197
  141. package/dist/core/spawn.js +16 -539
  142. package/dist/daemon/crtrd-cli.js +4 -0
  143. package/dist/daemon/crtrd.d.ts +20 -0
  144. package/dist/daemon/crtrd.js +200 -0
  145. package/dist/daemon/manage.d.ts +17 -0
  146. package/dist/daemon/manage.js +57 -0
  147. package/dist/pi-extensions/canvas-inbox-watcher.d.ts +16 -0
  148. package/dist/pi-extensions/canvas-inbox-watcher.js +229 -0
  149. package/dist/pi-extensions/canvas-nav.d.ts +32 -0
  150. package/dist/pi-extensions/canvas-nav.js +536 -0
  151. package/dist/pi-extensions/canvas-stophook.d.ts +17 -0
  152. package/dist/pi-extensions/canvas-stophook.js +396 -0
  153. package/package.json +6 -5
  154. package/dist/commands/agent.d.ts +0 -6
  155. package/dist/commands/agent.js +0 -585
  156. package/dist/commands/debug.d.ts +0 -3
  157. package/dist/commands/debug.js +0 -192
  158. package/dist/commands/job.d.ts +0 -11
  159. package/dist/commands/job.js +0 -384
  160. package/dist/commands/mode.js +0 -231
  161. package/dist/commands/plan.d.ts +0 -4
  162. package/dist/commands/plan.js +0 -322
  163. package/dist/commands/spec.d.ts +0 -3
  164. package/dist/commands/spec.js +0 -299
  165. package/dist/core/__tests__/flow-leaves.test.js +0 -248
  166. package/dist/core/__tests__/job.test.js +0 -310
  167. package/dist/core/__tests__/jobs.test.js +0 -98
  168. package/dist/core/__tests__/spawn.test.js +0 -138
  169. package/dist/core/__tests__/subagents.test.d.ts +0 -1
  170. package/dist/core/__tests__/subagents.test.js +0 -75
  171. package/dist/core/jobs.d.ts +0 -107
  172. package/dist/core/jobs.js +0 -565
  173. package/dist/core/subagents.d.ts +0 -18
  174. package/dist/core/subagents.js +0 -163
  175. package/dist/prompts/agent.d.ts +0 -27
  176. package/dist/prompts/agent.js +0 -184
  177. package/dist/prompts/debug.d.ts +0 -8
  178. package/dist/prompts/debug.js +0 -44
  179. /package/dist/core/__tests__/{flow-leaves.test.d.ts → canvas-inbox-watcher.test.d.ts} +0 -0
  180. /package/dist/core/__tests__/{job.test.d.ts → canvas.test.d.ts} +0 -0
  181. /package/dist/core/__tests__/{jobs.test.d.ts → reset.test.d.ts} +0 -0
  182. /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;