@crouton-kit/crouter 0.3.27 → 0.3.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/dist/builtin-personas/runtime-base.md +3 -0
  2. package/dist/clients/attach/__tests__/reconnect-giveup.test.d.ts +1 -0
  3. package/dist/clients/attach/__tests__/reconnect-giveup.test.js +30 -0
  4. package/dist/clients/attach/attach-cmd.js +187 -19
  5. package/dist/clients/attach/canvas-panels.d.ts +10 -0
  6. package/dist/clients/attach/canvas-panels.js +50 -0
  7. package/dist/clients/attach/graph-overlay.d.ts +34 -0
  8. package/dist/clients/attach/graph-overlay.js +266 -0
  9. package/dist/clients/attach/input-controller.d.ts +6 -0
  10. package/dist/clients/attach/input-controller.js +2 -0
  11. package/dist/clients/attach/slash-commands.d.ts +22 -1
  12. package/dist/clients/attach/slash-commands.js +160 -3
  13. package/dist/clients/attach/view-socket.d.ts +19 -1
  14. package/dist/clients/attach/view-socket.js +61 -6
  15. package/dist/commands/human/prompts.js +3 -3
  16. package/dist/commands/human/queue.d.ts +17 -0
  17. package/dist/commands/human/queue.js +111 -4
  18. package/dist/commands/memory/__tests__/lint-schema.test.js +24 -1
  19. package/dist/commands/memory/lint.d.ts +5 -4
  20. package/dist/commands/memory/lint.js +9 -5
  21. package/dist/commands/memory/write.js +12 -3
  22. package/dist/commands/sys/feedback.d.ts +1 -0
  23. package/dist/commands/sys/feedback.js +163 -0
  24. package/dist/commands/sys.js +3 -2
  25. package/dist/core/__tests__/broker-snapshot-history.test.d.ts +1 -0
  26. package/dist/core/__tests__/broker-snapshot-history.test.js +105 -0
  27. package/dist/core/__tests__/fixtures/fake-engine.d.ts +7 -0
  28. package/dist/core/__tests__/fixtures/fake-engine.js +10 -0
  29. package/dist/core/__tests__/full/placement-teardown.test.js +76 -0
  30. package/dist/core/__tests__/human-stranded-deliver.test.d.ts +1 -0
  31. package/dist/core/__tests__/human-stranded-deliver.test.js +108 -0
  32. package/dist/core/__tests__/on-read-dedup-resume.test.d.ts +1 -0
  33. package/dist/core/__tests__/on-read-dedup-resume.test.js +81 -0
  34. package/dist/core/canvas/nav-model.d.ts +162 -0
  35. package/dist/core/canvas/nav-model.js +486 -0
  36. package/dist/core/canvas/paths.d.ts +7 -0
  37. package/dist/core/canvas/paths.js +9 -0
  38. package/dist/core/runtime/broker-sdk.d.ts +0 -12
  39. package/dist/core/runtime/broker-sdk.js +77 -6
  40. package/dist/core/runtime/broker.d.ts +2 -1
  41. package/dist/core/runtime/broker.js +26 -1
  42. package/dist/core/runtime/front-door.js +23 -8
  43. package/dist/core/runtime/placement.d.ts +7 -6
  44. package/dist/core/runtime/placement.js +24 -12
  45. package/dist/core/runtime/revive.js +9 -0
  46. package/dist/core/runtime/spawn.d.ts +5 -0
  47. package/dist/core/runtime/spawn.js +62 -1
  48. package/dist/core/runtime/tmux.d.ts +9 -0
  49. package/dist/core/runtime/tmux.js +12 -0
  50. package/dist/core/spawn.d.ts +14 -0
  51. package/dist/core/spawn.js +29 -9
  52. package/dist/core/substrate/index.d.ts +1 -1
  53. package/dist/core/substrate/index.js +6 -6
  54. package/dist/core/substrate/injected-store.d.ts +10 -0
  55. package/dist/core/substrate/injected-store.js +55 -0
  56. package/dist/core/substrate/schema.d.ts +6 -8
  57. package/dist/core/substrate/schema.js +26 -28
  58. package/dist/pi-extensions/canvas-doc-substrate.js +16 -7
  59. package/dist/pi-extensions/canvas-nav.js +30 -385
  60. package/dist/pi-extensions/canvas-stophook.d.ts +1 -1
  61. package/dist/pi-extensions/canvas-stophook.js +32 -2
  62. package/package.json +1 -1
@@ -0,0 +1,266 @@
1
+ // graph-overlay.ts — the alt+g GRAPH navigator overlay for `crtr attach` (Unit Q).
2
+ //
3
+ // Native reimplementation of canvas-nav.ts's GRAPH modal as a pi-tui OVERLAY
4
+ // (the viewer has no pi extension host). `tui.showOverlay(this, …)` mounts this
5
+ // Component full-screen and CAPTURES keyboard focus (mirrors extension-dialogs.ts);
6
+ // `OverlayHandle.hide()` tears it down and restores focus to the editor. While
7
+ // shown, every key routes to handleInput() — a fold-aware NERDTree of the local
8
+ // subscription graph with the canvas-nav keymap:
9
+ // j/k move · h/l fold/ascend-descend · g/G top/bottom · ↵ focus (swap into
10
+ // this pane) · m focus manager · x kill (y/n confirm) · esc close.
11
+ // `e` is a tmux-menu prefixBind, not a GRAPH key, so it is swallowed (per
12
+ // canvas-nav). All model/render comes from the shared nav-model layer; keys are
13
+ // decoded with pi-tui's canonical matchesKey (kitty/CSI-u aware).
14
+ //
15
+ // Enter/m/x SHELL `crtr` out-of-process (node focus / node close) — the viewer
16
+ // itself never spawns pi or opens a session (the §0 one-writer invariant); it
17
+ // runs inside a tmux pane, so `crtr node focus --pane` swaps the chosen node in.
18
+ import { execFile } from 'node:child_process';
19
+ import { matchesKey } from '@earendil-works/pi-tui';
20
+ import { fullName } from '../../core/canvas/index.js';
21
+ import { beginFrame, cNode, managerOf, sortedChildIds, climbRoot, computeSubtreeActivity, buildGraphModel, renderGraphRow, focusedNodeIds, shortId, visibleWidth, VIEWPORT_FALLBACK_ROWS, DIM, RESET, BOLD, YELLOW, } from '../../core/canvas/nav-model.js';
22
+ const OVERLAY_OPTIONS = { anchor: 'top-left', width: '100%', maxHeight: '100%' };
23
+ // Viewer-specific hint: nav-model's shared GRAPH_HINT advertises "e expand",
24
+ // but that key is the tmux pane-expand prefix-bind canvas-nav installs in its
25
+ // host pane — the attach overlay has no such bind and swallows `e`, so the key
26
+ // would do nothing here. Drop it.
27
+ const GRAPH_HINT = `${DIM}jk move · hl fold · ↵ focus · x kill · m mgr · esc${RESET}`;
28
+ /** Pad `s` out to `width` VISIBLE columns so the overlay line is opaque (no base
29
+ * content bleeds through the right edge). Lines already wider are left as-is. */
30
+ function padTo(s, width) {
31
+ const pad = width - visibleWidth(s);
32
+ return pad > 0 ? s + ' '.repeat(pad) : s;
33
+ }
34
+ export class GraphOverlay {
35
+ tui;
36
+ self;
37
+ getAsks;
38
+ handle;
39
+ /** Manual fold OVERRIDES (h collapses → userCollapsed, l expands → userExpanded);
40
+ * both override the default activity-driven policy and survive open/close. */
41
+ userExpanded = new Set();
42
+ userCollapsed = new Set();
43
+ folds = { userExpanded: this.userExpanded, userCollapsed: this.userCollapsed };
44
+ /** Cursor as a node id (indices shift as topology changes). */
45
+ cursorId;
46
+ /** Viewport scroll offset (row index of the top visible tree row). */
47
+ scrollTop = 0;
48
+ /** Transient y/n confirm gate (kill). */
49
+ pendingConfirm;
50
+ constructor(tui, self, getAsks) {
51
+ this.tui = tui;
52
+ this.self = self;
53
+ this.getAsks = getAsks;
54
+ }
55
+ isOpen() {
56
+ return this.handle !== undefined;
57
+ }
58
+ open() {
59
+ if (this.handle !== undefined)
60
+ return;
61
+ this.scrollTop = 0;
62
+ this.pendingConfirm = undefined;
63
+ if (this.cursorId === undefined || cNode(this.cursorId) === null)
64
+ this.cursorId = this.self;
65
+ this.handle = this.tui.showOverlay(this, OVERLAY_OPTIONS);
66
+ this.tui.requestRender();
67
+ }
68
+ close() {
69
+ if (this.handle === undefined)
70
+ return;
71
+ this.pendingConfirm = undefined;
72
+ this.handle.hide();
73
+ this.handle = undefined;
74
+ this.tui.requestRender();
75
+ }
76
+ toggle() {
77
+ if (this.handle === undefined)
78
+ this.open();
79
+ else
80
+ this.close();
81
+ }
82
+ /** Repaint if shown (the low-rate ask poll calls this). */
83
+ refresh() {
84
+ if (this.handle !== undefined)
85
+ this.tui.requestRender();
86
+ }
87
+ invalidate() {
88
+ /* no cached render state — rebuilt every render() */
89
+ }
90
+ // -------------------------------------------------------------------------
91
+ render(width) {
92
+ // Fresh snapshot per paint (mirror canvas-nav's per-frame cache).
93
+ beginFrame();
94
+ const asks = this.getAsks();
95
+ const activity = computeSubtreeActivity(climbRoot(this.self), this.self);
96
+ const rows = buildGraphModel(this.self, this.folds, activity.expand);
97
+ // Re-resolve the cursor id → row (it may have vanished under a fold/close).
98
+ let cursorIdx = rows.findIndex((r) => r.id === this.cursorId);
99
+ if (cursorIdx < 0) {
100
+ cursorIdx = rows.findIndex((r) => r.id === this.self);
101
+ if (cursorIdx < 0)
102
+ cursorIdx = 0;
103
+ }
104
+ this.cursorId = rows[cursorIdx]?.id ?? this.self;
105
+ // Full-screen budget: 1 title line + 1 footer hint, the rest for tree rows.
106
+ const totalH = Math.max(6, process.stdout.rows ?? VIEWPORT_FALLBACK_ROWS);
107
+ const treeArea = Math.max(2, totalH - 2);
108
+ // Track the cursor within treeArea, reserving ↑/↓ "more" indicators. Up to
109
+ // 4 passes converge the mutual dependency between window size and indicators
110
+ // (an indicator steals a tree row → can re-home scrollTop → toggles the other
111
+ // indicator); see canvas-nav.ts renderGraph for the rationale.
112
+ let viewportH = treeArea;
113
+ for (let pass = 0; pass < 4; pass++) {
114
+ if (cursorIdx < this.scrollTop)
115
+ this.scrollTop = cursorIdx;
116
+ if (cursorIdx >= this.scrollTop + viewportH)
117
+ this.scrollTop = cursorIdx - viewportH + 1;
118
+ this.scrollTop = Math.max(0, Math.min(this.scrollTop, Math.max(0, rows.length - viewportH)));
119
+ const fit = treeArea - (this.scrollTop > 0 ? 1 : 0) - (this.scrollTop + viewportH < rows.length ? 1 : 0);
120
+ if (fit === viewportH)
121
+ break;
122
+ viewportH = Math.max(1, fit);
123
+ }
124
+ const end = Math.min(rows.length, this.scrollTop + viewportH);
125
+ const focused = focusedNodeIds();
126
+ const body = [];
127
+ if (this.scrollTop > 0)
128
+ body.push(`${DIM} ↑ ${this.scrollTop} more${RESET}`);
129
+ for (let i = this.scrollTop; i < end; i++) {
130
+ body.push(renderGraphRow(rows[i], i === cursorIdx, focused, activity.activeBelow, asks));
131
+ }
132
+ if (end < rows.length)
133
+ body.push(`${DIM} ↓ ${rows.length - end} more${RESET}`);
134
+ while (body.length < treeArea)
135
+ body.push(''); // fill so the overlay is opaque
136
+ const title = `${BOLD}⌗ canvas graph${RESET} ${DIM}(${cursorIdx + 1}/${rows.length})${RESET}`;
137
+ const hint = this.pendingConfirm !== undefined
138
+ ? `${YELLOW}${this.pendingConfirm.label} ${BOLD}y/n${RESET}`
139
+ : GRAPH_HINT;
140
+ return [title, ...body, hint].map((line) => padTo(line, width));
141
+ }
142
+ // -------------------------------------------------------------------------
143
+ handleInput(data) {
144
+ try {
145
+ this.dispatch(data);
146
+ }
147
+ catch {
148
+ /* a key handler is best-effort; never throw out of the input pump */
149
+ }
150
+ }
151
+ dispatch(data) {
152
+ // y/n confirm gate takes precedence over everything.
153
+ if (this.pendingConfirm !== undefined) {
154
+ if (matchesKey(data, 'y')) {
155
+ const act = this.pendingConfirm.action;
156
+ this.pendingConfirm = undefined;
157
+ act();
158
+ }
159
+ else {
160
+ this.pendingConfirm = undefined; // any other key cancels
161
+ }
162
+ this.tui.requestRender();
163
+ return;
164
+ }
165
+ if (matchesKey(data, 'escape')) {
166
+ this.close();
167
+ return;
168
+ }
169
+ const rows = buildGraphModel(this.self, this.folds);
170
+ let idx = rows.findIndex((r) => r.id === this.cursorId);
171
+ if (idx < 0)
172
+ idx = Math.max(0, rows.findIndex((r) => r.id === this.self));
173
+ const cur = rows[idx];
174
+ if (matchesKey(data, 'j')) {
175
+ idx = Math.min(rows.length - 1, idx + 1);
176
+ this.cursorId = rows[idx]?.id ?? this.cursorId;
177
+ this.tui.requestRender();
178
+ return;
179
+ }
180
+ if (matchesKey(data, 'k')) {
181
+ idx = Math.max(0, idx - 1);
182
+ this.cursorId = rows[idx]?.id ?? this.cursorId;
183
+ this.tui.requestRender();
184
+ return;
185
+ }
186
+ if (matchesKey(data, 'g')) {
187
+ this.cursorId = rows[0]?.id ?? this.cursorId;
188
+ this.tui.requestRender();
189
+ return;
190
+ }
191
+ if (matchesKey(data, 'shift+g')) {
192
+ this.cursorId = rows[rows.length - 1]?.id ?? this.cursorId;
193
+ this.tui.requestRender();
194
+ return;
195
+ }
196
+ if (matchesKey(data, 'h')) {
197
+ if (cur !== undefined && cur.hasKids && !cur.collapsed) {
198
+ this.userCollapsed.add(cur.id);
199
+ this.userExpanded.delete(cur.id);
200
+ }
201
+ else {
202
+ const p = managerOf(this.cursorId ?? this.self);
203
+ if (p !== undefined && rows.some((r) => r.id === p))
204
+ this.cursorId = p;
205
+ }
206
+ this.tui.requestRender();
207
+ return;
208
+ }
209
+ if (matchesKey(data, 'l')) {
210
+ if (cur !== undefined && cur.collapsed && cur.hasKids) {
211
+ this.userExpanded.add(cur.id);
212
+ this.userCollapsed.delete(cur.id);
213
+ }
214
+ else if (cur !== undefined && cur.hasKids) {
215
+ const c = sortedChildIds(cur.id)[0];
216
+ if (c !== undefined)
217
+ this.cursorId = c;
218
+ }
219
+ this.tui.requestRender();
220
+ return;
221
+ }
222
+ if (matchesKey(data, 'enter')) {
223
+ if (this.cursorId !== undefined)
224
+ this.focusTarget(this.cursorId);
225
+ this.close();
226
+ return;
227
+ }
228
+ if (matchesKey(data, 'm')) {
229
+ const mgr = managerOf(this.self);
230
+ if (mgr !== undefined) {
231
+ this.focusTarget(mgr);
232
+ this.close();
233
+ }
234
+ return;
235
+ }
236
+ if (matchesKey(data, 'x')) {
237
+ const target = this.cursorId ?? this.self;
238
+ const n = cNode(target);
239
+ const nm = n !== null ? fullName(n) : shortId(target);
240
+ this.pendingConfirm = { label: `kill ${nm}?`, action: () => this.shellCrtr(['node', 'close', '--node', target]) };
241
+ this.tui.requestRender();
242
+ return;
243
+ }
244
+ // Modal: swallow everything else (incl. the `e` tmux-menu bind) so a stray
245
+ // key never reaches the editor underneath.
246
+ }
247
+ // -------------------------------------------------------------------------
248
+ /** Swap `id` into THIS viewer's tmux pane (mirrors canvas browse's return-pane
249
+ * shell). Default --pane is the caller TMUX_PANE; we pass it explicitly when
250
+ * set so the focus lands here even if env resolution differs. */
251
+ focusTarget(id) {
252
+ const argv = ['node', 'focus', id];
253
+ const pane = process.env['TMUX_PANE'];
254
+ if (pane !== undefined && pane !== '')
255
+ argv.push('--pane', pane);
256
+ this.shellCrtr(argv);
257
+ }
258
+ shellCrtr(argv) {
259
+ try {
260
+ execFile('crtr', argv, () => { });
261
+ }
262
+ catch {
263
+ /* best-effort */
264
+ }
265
+ }
266
+ }
@@ -11,6 +11,12 @@ export interface InputControllerHooks {
11
11
  * reports notices up the same way it reports commands. Absent → notices are
12
12
  * dropped silently. */
13
13
  onNotice?: (message: string) => void;
14
+ /** OPTIONAL: the canvas node this viewer is attached to — forwarded to the
15
+ * slash context so `/promote` targets it (Unit Q wires it from runAttach). */
16
+ nodeId?: string;
17
+ /** OPTIONAL: toggle the GRAPH overlay — forwarded to the slash context so
18
+ * `/graph` opens/closes it (Unit Q wires it from runAttach). */
19
+ onGraph?: () => void;
14
20
  }
15
21
  export declare class InputController {
16
22
  private readonly tui;
@@ -90,6 +90,8 @@ export class InputController {
90
90
  notify: (message) => this.notify(message),
91
91
  state: this.state,
92
92
  cwd: process.cwd(),
93
+ nodeId: this.hooks.nodeId,
94
+ onGraph: this.hooks.onGraph,
93
95
  };
94
96
  }
95
97
  handleSubmit(text) {
@@ -12,7 +12,27 @@ export interface SlashContext {
12
12
  state?: BrokerSnapshot['state'];
13
13
  /** cwd for the default `/export` path; defaults to `process.cwd()`. */
14
14
  cwd?: string;
15
+ /** The canvas node this viewer is attached to — used by `/promote` (passed
16
+ * as `--node`). Falls back to `CRTR_NODE_ID` when absent; if neither is set
17
+ * `/promote` notifies and no-ops. (Unit Q wires this from `runAttach`.) */
18
+ nodeId?: string;
19
+ /** Toggle the in-process GRAPH overlay (the local subscription-tree view).
20
+ * `/graph` is inherently an in-viewer overlay, not a tmux popup, so this unit
21
+ * registers `/graph` as a stub that calls this hook. Unit C/Q owns the overlay
22
+ * itself and populates this; when absent `/graph` notifies it isn't wired yet. */
23
+ onGraph?: () => void;
15
24
  }
25
+ /** Canvas slash-commands — reimplemented NATIVE in the viewer (the canvas chrome
26
+ * pi-extensions cannot load here; see findings-chrome-parity.md Feature 3). Each
27
+ * ports the action of its extension: `/promote` ← canvas-commands.ts, `/resume-node`
28
+ * ← canvas-resume.ts, `/view` ← canvas-view.ts, `/graph` ← canvas-nav.ts. Three of
29
+ * them shell a `crtr` subcommand via `tmux display-popup` (the viewer runs inside a
30
+ * tmux pane, so this works exactly as in the extensions); `/graph` is a stub for the
31
+ * overlay (Unit C/Q). Exported so Unit Q can fold these into the autocomplete list. */
32
+ export declare const CANVAS_SLASH_COMMANDS: ReadonlyArray<{
33
+ name: string;
34
+ description: string;
35
+ }>;
16
36
  /** True if `text` is a leading-slash command (vs. a normal prompt). */
17
37
  export declare function isSlashCommand(text: string): boolean;
18
38
  /**
@@ -24,7 +44,8 @@ export declare function isSlashCommand(text: string): boolean;
24
44
  export declare function dispatchSlashCommand(text: string, ctx: SlashContext): boolean;
25
45
  /** Build slash-command autocomplete entries from the merged command list (the
26
46
  * broker's `get_commands` result, which T7 may inject) — defaults to the
27
- * vendored builtins. Shaped for pi-tui's `CombinedAutocompleteProvider`. */
47
+ * vendored builtins. The native canvas commands are always appended. Shaped for
48
+ * pi-tui's `CombinedAutocompleteProvider`. */
28
49
  export declare function slashCommandList(commands?: ReadonlyArray<{
29
50
  name: string;
30
51
  description?: string;
@@ -22,7 +22,22 @@
22
22
  //
23
23
  // SCOPED OUT (review m2 — no engine method): `/login`, `/logout`, `/share`,
24
24
  // `/trust` — a brief "not supported in attach" notice, never a frame.
25
+ import { execFile } from 'node:child_process';
25
26
  import { BUILTIN_SLASH_COMMANDS } from '../../core/runtime/pi-vendored.js';
27
+ /** Canvas slash-commands — reimplemented NATIVE in the viewer (the canvas chrome
28
+ * pi-extensions cannot load here; see findings-chrome-parity.md Feature 3). Each
29
+ * ports the action of its extension: `/promote` ← canvas-commands.ts, `/resume-node`
30
+ * ← canvas-resume.ts, `/view` ← canvas-view.ts, `/graph` ← canvas-nav.ts. Three of
31
+ * them shell a `crtr` subcommand via `tmux display-popup` (the viewer runs inside a
32
+ * tmux pane, so this works exactly as in the extensions); `/graph` is a stub for the
33
+ * overlay (Unit C/Q). Exported so Unit Q can fold these into the autocomplete list. */
34
+ export const CANVAS_SLASH_COMMANDS = [
35
+ { name: 'graph', description: 'Toggle the canvas GRAPH view (your local subscription tree)' },
36
+ { name: 'promote', description: 'Promote this node to an orchestrator — /promote, or /promote <kind> to specialize' },
37
+ { name: 'resume-node', description: 'Open the canvas navigator (search/scope/sort/tree) and resume the chosen node' },
38
+ { name: 'view', description: 'Open a view in a popup — bare for the picker, or /view <name> to open that view directly' },
39
+ ];
40
+ const CANVAS_NAMES = new Set(CANVAS_SLASH_COMMANDS.map((c) => c.name));
26
41
  /** Builtins with no Phase-4 engine method — scoped out (review m2). */
27
42
  const SCOPED_OUT = new Set(['login', 'logout', 'share', 'trust']);
28
43
  /** Valid `/settings thinking` levels — the `SetThinkingLevelFrame['level']`
@@ -60,6 +75,10 @@ export function dispatchSlashCommand(text, ctx) {
60
75
  ctx.notify(`/${name} is not supported in attach (Phase 4)`);
61
76
  return true;
62
77
  }
78
+ // Canvas commands (reimplemented native in the viewer) — handled before the
79
+ // builtin gate since they are neither pi builtins nor engine-extension commands.
80
+ if (CANVAS_NAMES.has(name))
81
+ return dispatchCanvasCommand(name, arg, ctx);
63
82
  // Unknown to us AND not a builtin → let the engine's extension runner have it.
64
83
  if (!BUILTIN_NAMES.has(name))
65
84
  return false;
@@ -140,15 +159,153 @@ export function dispatchSlashCommand(text, ctx) {
140
159
  return true;
141
160
  }
142
161
  }
162
+ /** Append the native canvas commands to a command list, skipping any already
163
+ * present (so they survive whatever list Unit Q feeds in — builtins, or the
164
+ * broker's `get_commands` result). The four canvas commands are viewer-owned,
165
+ * so they are always surfaced in autocomplete regardless of the source list. */
166
+ function withCanvasCommands(commands) {
167
+ const seen = new Set(commands.map((c) => c.name));
168
+ return [...commands, ...CANVAS_SLASH_COMMANDS.filter((c) => !seen.has(c.name))];
169
+ }
143
170
  /** Build slash-command autocomplete entries from the merged command list (the
144
171
  * broker's `get_commands` result, which T7 may inject) — defaults to the
145
- * vendored builtins. Shaped for pi-tui's `CombinedAutocompleteProvider`. */
172
+ * vendored builtins. The native canvas commands are always appended. Shaped for
173
+ * pi-tui's `CombinedAutocompleteProvider`. */
146
174
  export function slashCommandList(commands = BUILTIN_SLASH_COMMANDS) {
147
- return commands.map((c) => ({ name: c.name, description: c.description }));
175
+ return withCanvasCommands(commands).map((c) => ({ name: c.name, description: c.description }));
148
176
  }
149
177
  /** Same list as flat autocomplete items (value/label/description). */
150
178
  export function commandAutocompleteItems(commands = BUILTIN_SLASH_COMMANDS) {
151
- return commands.map((c) => ({ value: `/${c.name}`, label: `/${c.name}`, description: c.description }));
179
+ return withCanvasCommands(commands).map((c) => ({ value: `/${c.name}`, label: `/${c.name}`, description: c.description }));
180
+ }
181
+ // ---------------------------------------------------------------------------
182
+ // Canvas commands — native reimplementations of the canvas chrome extensions.
183
+ // ---------------------------------------------------------------------------
184
+ /** Dispatch one of the four native canvas commands. Always returns `true` (the
185
+ * command is viewer-owned — never forwarded to the engine as a prompt). */
186
+ function dispatchCanvasCommand(name, arg, ctx) {
187
+ switch (name) {
188
+ case 'graph':
189
+ // `/graph` is inherently the in-process GRAPH overlay (canvas-nav.ts
190
+ // `toggleGraph`), NOT a tmux popup. The overlay is Unit C/Q's surface; this
191
+ // unit registers the command + autocomplete entry and triggers the overlay
192
+ // via the `onGraph` hook the integration unit wires in.
193
+ if (ctx.onGraph)
194
+ ctx.onGraph();
195
+ else
196
+ ctx.notify('The GRAPH overlay is not wired into the viewer yet');
197
+ return true;
198
+ case 'promote':
199
+ promoteNode(arg, ctx);
200
+ return true;
201
+ case 'resume-node':
202
+ // ← canvas-resume.ts: open the full-screen canvas navigator as a popup; on
203
+ // Enter it focuses the chosen node back into THIS pane via `crtr node focus`.
204
+ return resumeNode(ctx);
205
+ case 'view':
206
+ // ← canvas-view.ts: bare `/view` opens the picker, `/view <name>` opens that
207
+ // view directly — each as a self-contained popup (no node-focus, no return-pane).
208
+ return openView(arg, ctx);
209
+ default:
210
+ ctx.notify(`/${name} is not available in attach`);
211
+ return true;
212
+ }
213
+ }
214
+ /** `/promote [kind]` ← canvas-commands.ts. Shells `crtr node promote --json` for
215
+ * this node (out-of-process), notifies the result, then triggers a turn so the
216
+ * persona injector steers in the new-role guidance at the turn boundary — exactly
217
+ * as the node would by running the command itself. */
218
+ function promoteNode(arg, ctx) {
219
+ const nodeId = (ctx.nodeId ?? process.env['CRTR_NODE_ID'] ?? '').trim();
220
+ if (nodeId === '') {
221
+ ctx.notify('/promote: no node to promote (viewer has no node id)');
222
+ return;
223
+ }
224
+ const kind = arg.trim().toLowerCase();
225
+ ctx.notify(kind ? `Promoting → ${kind}…` : 'Promoting…');
226
+ const argv = ['node', 'promote', '--node', nodeId, '--json'];
227
+ if (kind !== '')
228
+ argv.push('--kind', kind);
229
+ execFile('crtr', argv, { timeout: 15_000, maxBuffer: 4 * 1024 * 1024 }, (err, stdout) => {
230
+ // On a non-zero exit crtr still prints the structured error to stdout, so
231
+ // prefer its `message` over the raw throw.
232
+ let result = null;
233
+ let errMsg = null;
234
+ try {
235
+ result = JSON.parse(stdout);
236
+ }
237
+ catch {
238
+ result = null;
239
+ }
240
+ if (err && result === null) {
241
+ const out = typeof err.stdout === 'string' ? err.stdout : '';
242
+ try {
243
+ const payload = JSON.parse(out);
244
+ errMsg = typeof payload.message === 'string' ? payload.message : null;
245
+ }
246
+ catch {
247
+ errMsg = null;
248
+ }
249
+ if (errMsg === null)
250
+ errMsg = err.message;
251
+ }
252
+ if (result === null) {
253
+ ctx.notify(`promote failed: ${errMsg ?? 'unknown error'}`);
254
+ return;
255
+ }
256
+ const rmPath = (result.roadmap_path ?? '').trim();
257
+ ctx.notify(`Promoted to ${result.kind ?? 'orchestrator'} orchestrator — authoring roadmap${rmPath !== '' ? ` (${rmPath})` : ''}.`);
258
+ // Trigger a turn so the persona injector (canvas-stophook turn_end) fires and
259
+ // delivers the new-role guidance — the viewer's only turn-trigger is `prompt`.
260
+ ctx.send({
261
+ type: 'prompt',
262
+ text: 'You have just been promoted to an orchestrator. Your new-role guidance is arriving — read it, author your roadmap, and start delegating.',
263
+ });
264
+ });
265
+ }
266
+ /** `/resume-node` ← canvas-resume.ts. Opens `crtr canvas browse` as a tmux popup,
267
+ * scoped to this node's cwd; on Enter it focuses the chosen node back into this
268
+ * pane via `crtr node focus --pane` (which `canvas browse` shells). */
269
+ function resumeNode(ctx) {
270
+ const origPane = process.env['TMUX_PANE'];
271
+ if (process.env['TMUX'] === undefined || origPane === undefined || origPane === '') {
272
+ ctx.notify('/resume-node needs tmux');
273
+ return true;
274
+ }
275
+ const cwd = shellQuote(ctx.cwd ?? process.cwd());
276
+ const cmd = `crtr canvas browse --return-pane ${origPane} --cwd ${cwd}`;
277
+ popup(cmd);
278
+ return true;
279
+ }
280
+ /** `/view [name]` ← canvas-view.ts. Bare opens the picker (`crtr view pick`);
281
+ * `/view <name>` opens that view directly (`crtr view run <name>`). Self-contained
282
+ * popup — no node-focus, no return-pane. */
283
+ function openView(arg, ctx) {
284
+ if (process.env['TMUX'] === undefined) {
285
+ ctx.notify('/view needs tmux');
286
+ return true;
287
+ }
288
+ const name = arg.trim();
289
+ const cmd = name === '' ? 'crtr view pick' : `crtr view run ${shellQuote(name)}`;
290
+ popup(cmd);
291
+ return true;
292
+ }
293
+ /** Open `cmd` as a fire-and-forget tmux display-popup (same geometry as the canvas
294
+ * extensions). tmux runs the trailing string through `sh -c`; the popup owns its
295
+ * screen and closes itself when the command exits, dropping back to this pane. */
296
+ function popup(cmd) {
297
+ try {
298
+ execFile('tmux', ['display-popup', '-E', '-w', '90%', '-h', '85%', cmd], () => {
299
+ /* best-effort: popup is self-contained */
300
+ });
301
+ }
302
+ catch {
303
+ /* best-effort */
304
+ }
305
+ }
306
+ /** Single-quote a string for safe interpolation into a `sh -c` command line. */
307
+ function shellQuote(s) {
308
+ return `'${s.replace(/'/g, `'\\''`)}'`;
152
309
  }
153
310
  // ---------------------------------------------------------------------------
154
311
  function usage(ctx, form) {
@@ -1,8 +1,18 @@
1
1
  import { EventEmitter } from 'node:events';
2
+ import type { NodeRow } from '../../core/canvas/types.js';
2
3
  import { type BrokerToClient, type ClientToBroker } from '../../core/runtime/broker-protocol.js';
3
4
  /** Surfaced when the node has no reachable broker at connect time (no socket
4
5
  * file, or a stale socket with nothing listening). The command catches this to
5
6
  * exit non-zero with a focus/revive hint. */
7
+ /** The reconnect supervisor's give-up predicate (extracted pure so it is
8
+ * testable without a socket or TUI). After a broker close the viewer KEEPS
9
+ * re-dialing the same `view.sock` while the node is still alive — a yield
10
+ * leaves `status='active'` (intent='refresh') and the daemon revives a fresh
11
+ * broker on the same path. It gives up only when the node is genuinely gone:
12
+ * a terminal status (done/dead/canceled) or a reaped row (null). `idle` is NOT
13
+ * terminal — an idle-release node revives on its next inbox wake, so keep
14
+ * trying (the supervisor's own ~30s bound caps an indefinite wait). */
15
+ export declare function reconnectShouldGiveUp(row: NodeRow | null): boolean;
6
16
  export declare class BrokerUnavailableError extends Error {
7
17
  readonly nodeId: string;
8
18
  constructor(nodeId: string);
@@ -24,7 +34,7 @@ export interface ViewSocketClient {
24
34
  export declare class ViewSocketClient extends EventEmitter {
25
35
  private readonly nodeId;
26
36
  private socket;
27
- private readonly decoder;
37
+ private decoder;
28
38
  private closeEmitted;
29
39
  constructor(nodeId: string);
30
40
  /** The broker binds `join(nodeDir(id), 'view.sock')` — resolve it the same way. */
@@ -33,6 +43,14 @@ export declare class ViewSocketClient extends EventEmitter {
33
43
  * `error` (a {@link BrokerUnavailableError} for ECONNREFUSED/ENOENT) on
34
44
  * failure. Idempotent guard is the caller's job — call once. */
35
45
  connect(): void;
46
+ /** Re-establish the socket after a broker exit (a yield→revive cycle), on the
47
+ * SAME stable `view.sock` path. Installs a FRESH FrameDecoder (a half-frame
48
+ * from the dead stream must not corrupt the new one) and resets the close
49
+ * guard, then re-dials. Resolves on `connect` (caller re-sends `hello`);
50
+ * rejects on the dial's `error` (ECONNREFUSED while the new broker is
51
+ * mid-boot, or ENOENT before it re-binds the socket) — both retryable. A
52
+ * post-connect error flows through the normal `onError`→`close` path. */
53
+ redial(): Promise<void>;
36
54
  /** Encode + write one client→broker frame. No-op on a dead/absent socket
37
55
  * (a `close` event drives teardown); never throws. */
38
56
  send(frame: ClientToBroker): void;
@@ -9,12 +9,14 @@
9
9
  // what keeps a broker alive, not attach). It also never uses pi's `RpcClient`
10
10
  // (which would spawn its own engine).
11
11
  //
12
- // No reconnect: a dropped broker means the engine's turn-cycle settled and the
13
- // broker disposed+exited; a fresh focus re-runs attach. On EOF/close → emit
14
- // `close` (the caller renders "broker gone" + exits). On a connect-time
15
- // ECONNREFUSED/ENOENT → a `BrokerUnavailableError` so the command exits non-zero
16
- // with a clear message. Decode is bounded by `CLIENT_READ_CAPS`; a broker that
17
- // somehow sends an oversized frame surfaces as a clean error+exit, not a crash.
12
+ // Reconnect: a dropped broker emits `close`; the caller's reconnect supervisor
13
+ // decides whether to re-dial (a yield/revive keeps the SAME `view.sock` path
14
+ // `redial()` re-establishes the socket on it) or give up ("broker gone"). On a
15
+ // connect-time ECONNREFUSED/ENOENT → a `BrokerUnavailableError` so the FIRST
16
+ // connect exits non-zero with a clear message; the same codes during a redial
17
+ // reject the redial promise as retryable. Decode is bounded by
18
+ // `CLIENT_READ_CAPS`; a broker that somehow sends an oversized frame surfaces as
19
+ // a clean error+exit, not a crash.
18
20
  import { EventEmitter } from 'node:events';
19
21
  import { createConnection } from 'node:net';
20
22
  import { join } from 'node:path';
@@ -23,6 +25,19 @@ import { CLIENT_READ_CAPS, encodeFrame, FrameDecoder, FrameOverflowError, } from
23
25
  /** Surfaced when the node has no reachable broker at connect time (no socket
24
26
  * file, or a stale socket with nothing listening). The command catches this to
25
27
  * exit non-zero with a focus/revive hint. */
28
+ /** The reconnect supervisor's give-up predicate (extracted pure so it is
29
+ * testable without a socket or TUI). After a broker close the viewer KEEPS
30
+ * re-dialing the same `view.sock` while the node is still alive — a yield
31
+ * leaves `status='active'` (intent='refresh') and the daemon revives a fresh
32
+ * broker on the same path. It gives up only when the node is genuinely gone:
33
+ * a terminal status (done/dead/canceled) or a reaped row (null). `idle` is NOT
34
+ * terminal — an idle-release node revives on its next inbox wake, so keep
35
+ * trying (the supervisor's own ~30s bound caps an indefinite wait). */
36
+ export function reconnectShouldGiveUp(row) {
37
+ if (row === null)
38
+ return true;
39
+ return row.status === 'done' || row.status === 'dead' || row.status === 'canceled';
40
+ }
26
41
  export class BrokerUnavailableError extends Error {
27
42
  nodeId;
28
43
  constructor(nodeId) {
@@ -55,6 +70,46 @@ export class ViewSocketClient extends EventEmitter {
55
70
  socket.on('error', (err) => this.onError(err));
56
71
  socket.on('close', () => this.onClose());
57
72
  }
73
+ /** Re-establish the socket after a broker exit (a yield→revive cycle), on the
74
+ * SAME stable `view.sock` path. Installs a FRESH FrameDecoder (a half-frame
75
+ * from the dead stream must not corrupt the new one) and resets the close
76
+ * guard, then re-dials. Resolves on `connect` (caller re-sends `hello`);
77
+ * rejects on the dial's `error` (ECONNREFUSED while the new broker is
78
+ * mid-boot, or ENOENT before it re-binds the socket) — both retryable. A
79
+ * post-connect error flows through the normal `onError`→`close` path. */
80
+ redial() {
81
+ this.destroy();
82
+ this.decoder = new FrameDecoder(CLIENT_READ_CAPS);
83
+ this.closeEmitted = false;
84
+ return new Promise((resolve, reject) => {
85
+ const socket = createConnection(this.socketPath);
86
+ this.socket = socket;
87
+ let settled = false;
88
+ socket.on('connect', () => {
89
+ if (settled)
90
+ return;
91
+ settled = true;
92
+ this.emit('connect');
93
+ resolve();
94
+ });
95
+ socket.on('data', (chunk) => this.onData(chunk));
96
+ socket.on('error', (err) => {
97
+ if (settled) {
98
+ this.onError(err);
99
+ return;
100
+ }
101
+ settled = true;
102
+ try {
103
+ socket.destroy();
104
+ }
105
+ catch {
106
+ /* ignore */
107
+ }
108
+ reject(err);
109
+ });
110
+ socket.on('close', () => this.onClose());
111
+ });
112
+ }
58
113
  /** Encode + write one client→broker frame. No-op on a dead/absent socket
59
114
  * (a `close` event drives teardown); never throws. */
60
115
  send(frame) {