@crouton-kit/crouter 0.3.30 → 0.3.32

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.
@@ -1,15 +1,19 @@
1
- // Regression: in the attach viewer, Alt+Enter did NOTHING not a newline, not a
2
- // submit. The editor was SWALLOWING it. Root cause: pi-coding-agent's
1
+ // Regression (#11): in the attach viewer, Alt+Enter did NOTHING on an INSTALLED
2
+ // (non-deduped) crtr not a newline, not a submit. Root cause: pi-coding-agent's
3
3
  // CustomEditor resolves `@earendil-works/pi-tui` from its OWN node_modules, a
4
- // SEPARATE module instance from the one `config-load` imports (a non-deduped
5
- // install). config-load registered the KeybindingsManager only on its copy, but
6
- // the editor's super reads `getKeybindings()` from ITS instance — stuck at the
7
- // default `tui.input.newLine = shift+enter`. So the attach `alt+enter → newLine`
8
- // binding was invisible to the editor: Alt+Enter (`\x1b[13;3u`) matched neither
9
- // newLine nor submit and fell through. `mirrorKeybindingsToEditor` registers the
10
- // SAME manager on the editor's pi-tui instance; this test feeds the exact
11
- // Alt+Enter / Enter byte sequences a terminal sends and asserts the behavior.
12
- // See mirrorKeybindingsToEditor in src/clients/attach/config-load.ts.
4
+ // SEPARATE module instance from the one `config-load` imports. The base Editor
5
+ // reads `getKeybindings()` from THAT instance stuck at the default
6
+ // `tui.input.newLine = shift+enter` unless the `mirrorKeybindingsToEditor` shim
7
+ // (best-effort, `import.meta.resolve`-based) succeeds in registering crtr's
8
+ // manager on it. On install layouts where that mirror silently fails, the
9
+ // `alt+enter → newLine` override never reached the editor and Alt+Enter
10
+ // (`\x1b[13;3u`) matched neither newLine nor submit and fell through.
11
+ //
12
+ // The fix makes the chord self-contained: `TitledEditor.handleInput` matches
13
+ // newLine against crtr's OWN KeybindingsManager (the one CustomEditor already
14
+ // uses for `app.*`) and inserts the newline directly, so it no longer depends on
15
+ // the cross-instance mirror. The third test below builds the editor WITHOUT the
16
+ // mirror and is the real guard for the installed-layout failure.
13
17
  import { test } from 'node:test';
14
18
  import assert from 'node:assert/strict';
15
19
  import { mkdtempSync } from 'node:fs';
@@ -26,7 +30,7 @@ async function buildEditor() {
26
30
  // attach DEFAULT (`ATTACH_KEYBINDING_OVERRIDES` adds alt+enter to newLine).
27
31
  const agentDir = mkdtempSync(join(tmpdir(), 'crtr-kb-'));
28
32
  const km = createKeybindingsManager(agentDir);
29
- await mirrorKeybindingsToEditor(km); // the fix
33
+ await mirrorKeybindingsToEditor(km); // mirror still runs for general tui.* parity
30
34
  const tui = new TUI(new ProcessTerminal());
31
35
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
32
36
  return new TitledEditor(tui, { borderColor: (s) => s, selectList: {} }, km, {
@@ -55,3 +59,24 @@ test('attach editor still submits on plain Enter', async () => {
55
59
  assert.equal(submitted, 'hello', 'plain Enter must submit');
56
60
  assert.equal(editor.getText(), '', 'editor clears after submit');
57
61
  });
62
+ test('attach editor inserts a newline on Alt+Enter even WITHOUT the cross-instance mirror (#11)', async () => {
63
+ // Reproduces the installed-layout failure: the editor's pi-tui instance never
64
+ // received crtr's alt+enter override (mirror not run). TitledEditor.handleInput
65
+ // must still newline by matching crtr's own km directly.
66
+ const agentDir = mkdtempSync(join(tmpdir(), 'crtr-kb-'));
67
+ const km = createKeybindingsManager(agentDir);
68
+ // NOTE: deliberately NOT calling mirrorKeybindingsToEditor.
69
+ const tui = new TUI(new ProcessTerminal());
70
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
71
+ const editor = new TitledEditor(tui, { borderColor: (s) => s, selectList: {} }, km, {
72
+ paddingX: 1,
73
+ });
74
+ let submitted = null;
75
+ editor.onSubmit = (t) => {
76
+ submitted = t;
77
+ };
78
+ editor.setText('hello');
79
+ editor.handleInput(ALT_ENTER);
80
+ assert.equal(editor.getText(), 'hello\n', 'Alt+Enter must newline without relying on the mirror');
81
+ assert.equal(submitted, null, 'Alt+Enter must not submit');
82
+ });
@@ -31,7 +31,7 @@ import { writeClipboardText } from './clipboard-text.js';
31
31
  import { buildCanvasPanelLines } from './canvas-panels.js';
32
32
  import { GraphOverlay } from './graph-overlay.js';
33
33
  import { slashCommandList } from './slash-commands.js';
34
- import { applyTheme, attachPalette, createKeybindingsManager, defaultAgentDir, mirrorKeybindingsToEditor, mirrorKittyProtocolToEditor, } from './config-load.js';
34
+ import { applyTheme, attachPalette, createKeybindingsManager, defaultAgentDir, mirrorKeybindingsToEditor, mirrorKittyProtocolToEditor, resolveFdPath, } from './config-load.js';
35
35
  import { buildLoginPicker, buildLogoutPicker } from './auth-pickers.js';
36
36
  import { TitledEditor, thinkingBorderColor, thinkingTitleStyle, defaultTitleStyle } from './titled-editor.js';
37
37
  import { fetchGitInfo } from './git-info.js';
@@ -134,6 +134,11 @@ async function runAttach(nodeId, observer) {
134
134
  // deduped install. Awaited before tui.start so the editor never handles a key
135
135
  // against stale defaults.
136
136
  await mirrorKeybindingsToEditor(km);
137
+ // Resolve `fd` once so the editor's @-mention picker can list files. pi-tui's
138
+ // CombinedAutocompleteProvider yields zero file suggestions without it (issue
139
+ // #6); `null` on a host with no fd (and no download) just leaves @-mention
140
+ // file-less, matching pi's own degraded behavior.
141
+ const fdPath = await resolveFdPath();
137
142
  // The front door installs the Alt+C menu binding before it ever loads a theme,
138
143
  // so it lands unstyled. Now that this viewer process HAS themed (applyTheme
139
144
  // above populated the live-theme global), re-install it so the menu picks up
@@ -170,7 +175,7 @@ async function runAttach(nodeId, observer) {
170
175
  // Slash-command autocomplete: builtins + native canvas commands now; enriched
171
176
  // with the broker's engine/extension/skill commands on the get_commands ack.
172
177
  const setCommands = (commands) => {
173
- editor.setAutocompleteProvider(new CombinedAutocompleteProvider(slashCommandList(commands), meta.cwd));
178
+ editor.setAutocompleteProvider(new CombinedAutocompleteProvider(slashCommandList(commands), meta.cwd, fdPath));
174
179
  };
175
180
  setCommands();
176
181
  // 3. Render layer (T5). ChatView owns the chat scroll + activity spinner; the
@@ -182,6 +187,9 @@ async function runAttach(nodeId, observer) {
182
187
  let role = observer ? 'observer' : 'controller';
183
188
  let brokerAgentDir;
184
189
  let liveState;
190
+ // Shell/bash mode: the editor text leads with `!`. Tracks pi's interactive
191
+ // `isBashMode`, which recolors the editor border green as a visual cue (#7).
192
+ let bashMode = false;
185
193
  let notice = '';
186
194
  // Live context occupancy for the footer: `contextTokens` is the prompt-token
187
195
  // count of the most recent assistant turn (input + cacheRead + cacheWrite =
@@ -365,16 +373,38 @@ async function runAttach(nodeId, observer) {
365
373
  // routing (T6) reads `state.isStreaming`, so this must stay current — the
366
374
  // broker only ships full state in `welcome`, so we patch isStreaming/name/etc
367
375
  // from the relayed event stream below.
376
+ // Paint the editor border + title chip. Shell/bash mode (leading `!`) wins and
377
+ // forces the whole frame green (#7); otherwise the hue tracks the thinking
378
+ // level. One place so the onChange bash toggle and live-state updates agree.
379
+ const paintEditorFrame = () => {
380
+ if (bashMode) {
381
+ editor.borderColor = pal.bashMode;
382
+ editor.titleStyle = (s) => `\x1b[42m\x1b[97m\x1b[1m ${s} \x1b[0m`;
383
+ }
384
+ else {
385
+ editor.borderColor = thinkingBorderColor(liveState?.thinkingLevel, pal.border);
386
+ // Title chip background = the same thinking-level color (bold white text),
387
+ // so the name chip and the border rule read as one hue.
388
+ editor.titleStyle = thinkingTitleStyle(liveState?.thinkingLevel, defaultTitleStyle);
389
+ }
390
+ };
391
+ // Recolor the editor when `!` enters/leaves the front of the input (#7). pi's
392
+ // interactive mode does the same off `editor.onChange`.
393
+ editor.onChange = (text) => {
394
+ const next = text.trimStart().startsWith('!');
395
+ if (next === bashMode)
396
+ return;
397
+ bashMode = next;
398
+ paintEditorFrame();
399
+ tui.requestRender();
400
+ };
368
401
  const setLiveState = (state) => {
369
402
  liveState = state;
370
403
  input.setState(state);
371
404
  // The session name lives in the editor's top border (solid chip); the border
372
405
  // color tracks the agent's thinking level. Both follow live state.
373
406
  editor.title = state.sessionName ? `⬢ ${state.sessionName}` : '';
374
- editor.borderColor = thinkingBorderColor(state.thinkingLevel, pal.border);
375
- // Title chip background = the same thinking-level color (bold white text), so
376
- // the name chip and the border rule read as one hue.
377
- editor.titleStyle = thinkingTitleStyle(state.thinkingLevel, defaultTitleStyle);
407
+ paintEditorFrame();
378
408
  renderFooter();
379
409
  // Panel liveness rides the relayed event stream (agent_start/agent_end/
380
410
  // queue_update/session_info_changed all patch state through here) plus the
@@ -16,6 +16,7 @@ export declare function mirrorKeybindingsToEditor(km: KeybindingsManager): Promi
16
16
  * instance (ProcessTerminal sets it only on OUR copy). Call after the terminal
17
17
  * has negotiated, i.e. after `tui.start()`. */
18
18
  export declare function mirrorKittyProtocolToEditor(active: boolean): Promise<void>;
19
+ export declare function resolveFdPath(): Promise<string | null>;
19
20
  /**
20
21
  * The user's theme name from pi settings — project (`<cwd>/.pi/settings.json`)
21
22
  * overrides global (`~/.pi/agent/settings.json`). `undefined` → pi's default.
@@ -62,6 +63,11 @@ export interface AttachPalette {
62
63
  error: (s: string) => string;
63
64
  /** Warning / transient notices. Same constraint as `error` → ANSI yellow. */
64
65
  warning: (s: string) => string;
66
+ /** Editor border when the input is in shell/bash mode (leading `!`). pi paints
67
+ * this from its `bashMode` theme color (default `green`), which pi does NOT
68
+ * re-export through its public surface — so, like `error`/`warning`, this is
69
+ * the semantically-correct ANSI green, not an ad-hoc hardcode. */
70
+ bashMode: (s: string) => string;
65
71
  }
66
72
  /**
67
73
  * Build the attach chrome's color palette from the LIVE theme. Call AFTER
@@ -144,6 +144,37 @@ export async function mirrorKeybindingsToEditor(km) {
144
144
  export async function mirrorKittyProtocolToEditor(active) {
145
145
  (await editorPiTui())?.setKittyProtocolActive(active);
146
146
  }
147
+ /**
148
+ * Resolve the `fd` binary path the @-mention file picker needs. pi-tui's
149
+ * `CombinedAutocompleteProvider` only returns file suggestions when handed an
150
+ * `fdPath` (its third ctor arg) — without it, typing `@` triggers the picker but
151
+ * `getFuzzyFileSuggestions` returns `[]`, so the picker never appears (issue #6).
152
+ * pi's own interactive mode resolves this via `ensureTool('fd')` from its
153
+ * internal `utils/tools-manager` (checks pi's managed bin dir + system PATH,
154
+ * downloading the binary if missing). That module is NOT on pi-coding-agent's
155
+ * `.`-only export map, so we reach it the same way we reach the editor's pi-tui
156
+ * instance: resolve the package entry, then import the sibling file by URL
157
+ * (file URLs bypass the bare-specifier exports gate). Best-effort: any
158
+ * resolution/import failure (or no fd available) → `null`, and the picker stays
159
+ * file-less rather than crashing.
160
+ */
161
+ let fdPathPromise;
162
+ export function resolveFdPath() {
163
+ if (!fdPathPromise) {
164
+ fdPathPromise = (async () => {
165
+ try {
166
+ const pcaEntry = import.meta.resolve('@earendil-works/pi-coding-agent');
167
+ const toolsManagerUrl = new URL('./utils/tools-manager.js', pcaEntry).href;
168
+ const toolsManager = (await import(toolsManagerUrl));
169
+ return (await toolsManager.ensureTool('fd', true)) ?? null;
170
+ }
171
+ catch {
172
+ return null;
173
+ }
174
+ })();
175
+ }
176
+ return fdPathPromise;
177
+ }
147
178
  /**
148
179
  * The user's theme name from pi settings — project (`<cwd>/.pi/settings.json`)
149
180
  * overrides global (`~/.pi/agent/settings.json`). `undefined` → pi's default.
@@ -174,6 +205,7 @@ export function applyTheme(opts) {
174
205
  const FAINT = (s) => `\x1b[2m${s}\x1b[22m`;
175
206
  const RED = (s) => `\x1b[31m${s}\x1b[39m`;
176
207
  const YELLOW = (s) => `\x1b[33m${s}\x1b[39m`;
208
+ const GREEN = (s) => `\x1b[32m${s}\x1b[39m`;
177
209
  /**
178
210
  * Build the attach chrome's color palette from the LIVE theme. Call AFTER
179
211
  * `applyTheme()` — the colors are pulled from pi's `getMarkdownTheme()` /
@@ -204,6 +236,7 @@ export function attachPalette() {
204
236
  bold: md.bold,
205
237
  error: RED,
206
238
  warning: YELLOW,
239
+ bashMode: GREEN,
207
240
  surface: (line) => `${bgOn}${line.replace(/\x1b\[0m/g, `\x1b[0m${bgOn}`)}\x1b[49m`,
208
241
  };
209
242
  }
@@ -1,4 +1,5 @@
1
1
  import { CustomEditor } from '@earendil-works/pi-coding-agent';
2
+ import { type EditorOptions, type EditorTheme, type TUI } from '@earendil-works/pi-tui';
2
3
  /** Thinking levels pi cycles through (shift+tab), lowest → highest budget. */
3
4
  export type ThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
4
5
  /** The default (thinking `off`) title chip: reverse video + a space of padding
@@ -20,6 +21,23 @@ export declare function thinkingTitleStyle(level: string | undefined, fallback:
20
21
  * regression test. */
21
22
  export declare function composeTopBorder(width: number, title: string, info: string, titleStyle: (s: string) => string, borderColor: (s: string) => string): string;
22
23
  export declare class TitledEditor extends CustomEditor {
24
+ /** crtr's OWN keybindings manager — the same one CustomEditor matches `app.*`
25
+ * against. We keep a reference so `handleInput` can resolve the newline chord
26
+ * here, independent of the editor's (possibly separate) pi-tui module global. */
27
+ private readonly km;
28
+ constructor(tui: TUI, theme: EditorTheme, keybindings: ConstructorParameters<typeof CustomEditor>[2], options?: EditorOptions);
29
+ /** Insert a newline on the newLine chord (Alt+Enter / Shift+Enter) using crtr's
30
+ * OWN keybindings manager, then defer everything else to the stock editor.
31
+ *
32
+ * The base `Editor` resolves newLine against `getKeybindings()` on ITS pi-tui
33
+ * instance, which in a non-deduped install is a DIFFERENT module than the one
34
+ * carrying crtr's `alt+enter` override — so the override can silently never
35
+ * reach it and Alt+Enter falls through to submit (issue #11). crtr's `km` (the
36
+ * same manager CustomEditor matches `app.*` against) always carries the
37
+ * override, so matching the chord here makes the newline behavior independent
38
+ * of cross-instance keybinding mirroring. Skipped while the autocomplete popup
39
+ * is open so Enter-to-confirm keeps working. */
40
+ handleInput(data: string): void;
23
41
  /** Session-name chip painted into the LEFT of the top border. Empty → plain. */
24
42
  title: string;
25
43
  /** Pre-styled context string painted into the RIGHT of the top border (cwd /
@@ -6,21 +6,23 @@
6
6
  // on each state update), mirroring pi's `getThinkingBorderColor`.
7
7
  // Both are pure render-layer chrome; nothing here touches the socket or session.
8
8
  import { CustomEditor } from '@earendil-works/pi-coding-agent';
9
- import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
10
- /** Per-level border/chip color as a 24-bit RGB triple ONE blue-violet hue at
11
- * CONSTANT perceived lightness (luma 120) whose SATURATION alone climbs with
12
- * the budget: a near-gray blue at `minimal` deepening to a vivid violet at
13
- * `xhigh`, never getting lighter or darker only more colorful. (pi paints its
14
- * own `thinking*` theme colors off its public palette surface, so we own this
15
- * ramp directly truecolor, not the 16-color ANSI hues, so the saturation
16
- * climb actually reads.) `off` falls back to the caller's default border color. */
9
+ import { truncateToWidth, visibleWidth, } from '@earendil-works/pi-tui';
10
+ /** Per-level border/chip color as a 24-bit RGB triple. Each level ROTATES HUE by
11
+ * a large step (~35–45°) along a "heat" ramp indigo → violet → magenta → pink
12
+ * red-orange so adjacent levels are unmistakably different at a glance (a
13
+ * constant-hue saturation climb read as nearly identical; see #8). All sit at
14
+ * roughly constant, high-ish lightness so each is vivid on a dark terminal, and
15
+ * the cool→hot direction maps intuitively onto a climbing thinking budget. (pi
16
+ * paints its own `thinking*` theme colors off its public palette surface, so we
17
+ * own this ramp directly — truecolor, not the 16-color ANSI hues, so the hue
18
+ * sweep actually reads.) `off` falls back to the caller's default border color. */
17
19
  const THINKING_RGB = {
18
20
  off: undefined, // default border color
19
- minimal: [125, 115, 135], // near-gray blue-violet
20
- low: [129, 109, 151], // muted blue-violet
21
- medium: [134, 103, 168], // violet
22
- high: [139, 98, 184], // deeper violet
23
- xhigh: [144, 92, 200], // vivid violet
21
+ minimal: [96, 132, 220], // indigo / cornflower blue
22
+ low: [150, 110, 214], // violet
23
+ medium: [200, 98, 196], // magenta
24
+ high: [224, 102, 142], // pink-red
25
+ xhigh: [228, 120, 84], // red-orange
24
26
  };
25
27
  /** The default (thinking `off`) title chip: reverse video + a space of padding
26
28
  * each side, so the name reads as a label sitting on the border rule. Used as
@@ -63,6 +65,35 @@ export function composeTopBorder(width, title, info, titleStyle, borderColor) {
63
65
  return chip + borderColor('─'.repeat(fill)) + infoChip;
64
66
  }
65
67
  export class TitledEditor extends CustomEditor {
68
+ /** crtr's OWN keybindings manager — the same one CustomEditor matches `app.*`
69
+ * against. We keep a reference so `handleInput` can resolve the newline chord
70
+ * here, independent of the editor's (possibly separate) pi-tui module global. */
71
+ km;
72
+ // CustomEditor's ctor signature; captured so we own the newline chord (see
73
+ // handleInput). The keybindings param is typed as pi's app-subclass manager,
74
+ // but the attach viewer feeds it pi-tui's base manager — hence the cast.
75
+ constructor(tui, theme, keybindings, options) {
76
+ super(tui, theme, keybindings, options);
77
+ this.km = keybindings;
78
+ }
79
+ /** Insert a newline on the newLine chord (Alt+Enter / Shift+Enter) using crtr's
80
+ * OWN keybindings manager, then defer everything else to the stock editor.
81
+ *
82
+ * The base `Editor` resolves newLine against `getKeybindings()` on ITS pi-tui
83
+ * instance, which in a non-deduped install is a DIFFERENT module than the one
84
+ * carrying crtr's `alt+enter` override — so the override can silently never
85
+ * reach it and Alt+Enter falls through to submit (issue #11). crtr's `km` (the
86
+ * same manager CustomEditor matches `app.*` against) always carries the
87
+ * override, so matching the chord here makes the newline behavior independent
88
+ * of cross-instance keybinding mirroring. Skipped while the autocomplete popup
89
+ * is open so Enter-to-confirm keeps working. */
90
+ handleInput(data) {
91
+ if (!this.isShowingAutocomplete() && this.km.matches(data, 'tui.input.newLine')) {
92
+ this.insertTextAtCursor('\n');
93
+ return;
94
+ }
95
+ super.handleInput(data);
96
+ }
66
97
  /** Session-name chip painted into the LEFT of the top border. Empty → plain. */
67
98
  title = '';
68
99
  /** Pre-styled context string painted into the RIGHT of the top border (cwd /
@@ -1,29 +1,43 @@
1
1
  // `crtr canvas revive` — explicit node revival.
2
2
  //
3
- // Bypasses the daemon: directly opens a fresh tmux window for a node that is
3
+ // Bypasses the daemon: directly relaunches the broker engine for a node that is
4
4
  // done, idle, or dead. Default behavior resumes the saved pi conversation
5
5
  // (--session <id>); pass --fresh to start a clean pi session against the context dir.
6
+ //
7
+ // `--all` sweeps EVERY disconnected node (engine not running, resumable session)
8
+ // in one shot — the recovery for a reboot / mass-crash / daemon-down-a-while
9
+ // event. It is a two-step gate: run it once to PREVIEW the candidates (nothing is
10
+ // launched), then confirm to actually revive them.
6
11
  import { defineLeaf } from '../core/command.js';
7
12
  import { InputError } from '../core/io.js';
8
13
  import { reviveNode } from '../core/runtime/revive.js';
14
+ import { listDisconnected, reviveAll } from '../core/runtime/revive-all.js';
9
15
  import { waitForBrokerViewSocket } from '../core/runtime/placement.js';
10
- import { getNode } from '../core/canvas/index.js';
16
+ import { getNode, fullName } from '../core/canvas/index.js';
11
17
  // ---------------------------------------------------------------------------
12
18
  // revive node
13
19
  // ---------------------------------------------------------------------------
14
20
  export const reviveLeaf = defineLeaf({
15
21
  name: 'revive',
16
- description: 'reopen a window for a done/idle/dead/canceled node',
17
- whenToUse: 'you want to bring a dormant node back yourself — reopen a window for one that is done, idle, dead, or canceled: resume a node you closed with `node close`, reopen a finished worker for a follow-up, or restart a crashed one now instead of waiting. It resumes the saved conversation by default, or can restart the node clean. You rarely need this for crashes — the daemon auto-revives those; reach for it to bring a node back on demand, or to revive a canceled node the daemon will never touch on its own',
22
+ description: 'reopen a window for a done/idle/dead/canceled node, or --all disconnected nodes',
23
+ whenToUse: 'you want to bring a dormant node back yourself — reopen a window for one that is done, idle, dead, or canceled: resume a node you closed with `node close`, reopen a finished worker for a follow-up, or restart a crashed one now instead of waiting. It resumes the saved conversation by default, or can restart the node clean. Pass `--all` instead of a node id to bring back EVERY disconnected node at once (engine not running but a saved session intact) — the recovery after a reboot, a killed login/tmux session, a mass crash, or the daemon being down a while. You rarely need this for crashes — the daemon auto-revives those; reach for it to bring a node back on demand, to revive a canceled node the daemon will never touch on its own, or to mass-reconnect survivors after a disconnect event',
18
24
  help: {
19
25
  name: 'canvas revive',
20
- summary: 'open a fresh tmux window for a node, optionally resuming its saved pi conversation',
26
+ summary: 'relaunch a node\'s broker engine (resuming its saved conversation), or --all disconnected nodes',
21
27
  params: [
22
28
  {
23
29
  kind: 'positional',
24
30
  name: 'node',
25
- required: true,
26
- constraint: 'Node id to revive.',
31
+ required: false,
32
+ constraint: 'Node id to revive. Omit when using --all.',
33
+ },
34
+ {
35
+ kind: 'flag',
36
+ name: 'all',
37
+ type: 'bool',
38
+ required: false,
39
+ default: false,
40
+ constraint: 'Revive EVERY disconnected node (engine not running, resumable saved session) — excludes done/canceled (terminal-by-choice). Run it once to PREVIEW the candidates; nothing is launched until you confirm. Mutually exclusive with a node id.',
27
41
  },
28
42
  {
29
43
  kind: 'flag',
@@ -31,24 +45,63 @@ export const reviveLeaf = defineLeaf({
31
45
  type: 'bool',
32
46
  required: false,
33
47
  default: false,
34
- constraint: 'When set, start a clean pi session (no --session). Default: resume the saved conversation.',
48
+ constraint: 'When set, start a clean pi session (no --session). Default: resume the saved conversation. Ignored with --all (which always resumes).',
49
+ },
50
+ // --force is a DELIBERATELY UNDOCUMENTED confirmation gate for --all (hidden
51
+ // from -h; see FlagParam.hidden). Silas's intent (2026-06-13): a mass revive
52
+ // can have large, unforeseen consequences, so the agent must NOT be able to
53
+ // discover a one-shot bypass by reading the schema. The unforced --all path
54
+ // PREVIEWS the candidates and instructs the agent to double-check with the
55
+ // USER before re-running; only then, having been told the flag, does it pass
56
+ // --force. Documenting it here would defeat that gate.
57
+ {
58
+ kind: 'flag',
59
+ name: 'force',
60
+ type: 'bool',
61
+ required: false,
62
+ default: false,
63
+ hidden: true,
64
+ constraint: 'Confirm a --all sweep (internal gate; see code comment).',
35
65
  },
36
66
  ],
37
67
  output: [
38
68
  { name: 'window', type: 'string', required: false, constraint: 'Always null — the revived broker is headless and opens no tmux window. Kept for caller back-compat.' },
39
69
  { name: 'session', type: 'string', required: false, constraint: 'The node\'s last live location session, or null — the headless broker has no tmux session of its own.' },
40
- { name: 'resumed', type: 'boolean', required: true, constraint: 'True when pi was told to --session the saved conversation.' },
41
- { name: 'ready', type: 'boolean', required: true, constraint: 'True when the revived broker\'s view.sock accepted a connection before return — the node is immediately attachable/drivable.' },
70
+ { name: 'resumed', type: 'boolean', required: false, constraint: 'True when pi was told to --session the saved conversation. Single-node revive only.' },
71
+ { name: 'ready', type: 'boolean', required: false, constraint: 'True when the revived broker\'s view.sock accepted a connection before return — the node is immediately attachable/drivable. Single-node revive only.' },
72
+ { name: 'mode', type: 'string', required: false, constraint: '"preview" (the --all candidate list, nothing launched), "revived" (the --all sweep ran), or absent for a single-node revive.' },
73
+ { name: 'candidates', type: 'string', required: false, constraint: '--all preview: the node ids that WOULD be revived (newline-joined). Empty when none are disconnected.' },
74
+ { name: 'revived', type: 'string', required: false, constraint: '--all sweep: the node ids whose broker engine was relaunched (newline-joined).' },
75
+ { name: 'failed', type: 'string', required: false, constraint: '--all sweep: node ids whose relaunch threw, with the error (newline-joined). Absent when none failed.' },
42
76
  ],
43
77
  outputKind: 'object',
44
78
  effects: [
45
79
  'Launches the node\'s detached headless broker engine (no tmux window).',
46
- 'Updates the node\'s canvas record: status=active, intent=null, window=<new>.',
80
+ 'Updates the node\'s canvas record: status=active, intent=null.',
47
81
  'Blocks until the broker\'s view.sock accepts a connection (up to ~30s), so a caller can attach/dial immediately on return.',
82
+ '--all without confirmation launches NOTHING — it only previews the disconnected candidates.',
48
83
  ],
49
84
  },
50
85
  run: async (input) => {
86
+ const all = input['all'] ?? false;
51
87
  const nodeId = input['node'];
88
+ if (all) {
89
+ if (nodeId !== undefined) {
90
+ throw new InputError({
91
+ error: 'conflicting_args',
92
+ message: '`--all` reconnects every disconnected node; do not also pass a node id.',
93
+ next: 'Run `crtr canvas revive --all` (no node id), or `crtr canvas revive <node>` for one.',
94
+ });
95
+ }
96
+ return runReviveAll(input);
97
+ }
98
+ if (nodeId === undefined) {
99
+ throw new InputError({
100
+ error: 'missing_parameter',
101
+ message: 'pass a node id to revive, or `--all` to reconnect every disconnected node.',
102
+ next: 'Run `crtr canvas revive <node>` or `crtr canvas revive --all`. List nodes with `crtr node inspect list`.',
103
+ });
104
+ }
52
105
  const fresh = input['fresh'] ?? false;
53
106
  // Validate the node exists before attempting revival.
54
107
  const meta = getNode(nodeId);
@@ -72,3 +125,61 @@ export const reviveLeaf = defineLeaf({
72
125
  };
73
126
  },
74
127
  });
128
+ // ---------------------------------------------------------------------------
129
+ // revive --all
130
+ // ---------------------------------------------------------------------------
131
+ /** One line per candidate: `<id> <full name> [<status>]`. */
132
+ function describe(ids) {
133
+ return ids
134
+ .map((m) => {
135
+ const meta = getNode(m.node_id);
136
+ const name = meta !== null ? fullName(meta) : m.node_id;
137
+ const status = meta !== null ? meta.status : '?';
138
+ return ` ${m.node_id} ${name} [${status}]`;
139
+ })
140
+ .join('\n');
141
+ }
142
+ /** The --all sweep, gated on confirmation. Without `--force` it PREVIEWS the
143
+ * disconnected candidates and returns WITHOUT launching anything, telling the
144
+ * agent to double-check with the user first (a mass revive can have large,
145
+ * unforeseen consequences). With `--force` it actually resumes them all. */
146
+ function runReviveAll(input) {
147
+ const force = input['force'] ?? false;
148
+ const candidates = listDisconnected();
149
+ if (candidates.length === 0) {
150
+ return {
151
+ mode: 'preview',
152
+ candidates: '',
153
+ message: 'No disconnected nodes — every node with a resumable session already has a running engine. Nothing to revive.',
154
+ };
155
+ }
156
+ if (!force) {
157
+ const list = describe(candidates);
158
+ return {
159
+ mode: 'preview',
160
+ candidates: candidates.map((m) => m.node_id).join('\n'),
161
+ // Directive for the AGENT (not a documented flag): this is a preview, and a
162
+ // mass revive can have large, unforeseen consequences — so confirm with the
163
+ // USER before proceeding, then re-run to actually revive.
164
+ message: `${candidates.length} disconnected node(s) WOULD be revived (RESUME). Nothing has been launched yet:\n\n` +
165
+ `${list}\n\n` +
166
+ `Reviving all of these at once can have large, unforeseen consequences (a flood of resumed engines, ` +
167
+ `cost, side effects from each node continuing its work). This is a preview only — DOUBLE-CHECK WITH THE ` +
168
+ `USER that they want every node above brought back before you proceed. Once they confirm, re-run the ` +
169
+ `revive with confirmation to actually relaunch them.`,
170
+ };
171
+ }
172
+ const res = reviveAll();
173
+ const out = {
174
+ mode: 'revived',
175
+ revived: res.revived.join('\n'),
176
+ message: `Revived ${res.revived.length} disconnected node(s) (RESUME).`,
177
+ };
178
+ if (res.failed.length > 0) {
179
+ out['failed'] = res.failed.map((f) => `${f.node_id}: ${f.error}`).join('\n');
180
+ out['message'] =
181
+ `Revived ${res.revived.length} of ${res.revived.length + res.failed.length} disconnected node(s); ` +
182
+ `${res.failed.length} failed to relaunch (see failed).`;
183
+ }
184
+ return out;
185
+ }
@@ -200,7 +200,13 @@ export async function createHarness(opts = {}) {
200
200
  }
201
201
  function nodeDirs() {
202
202
  try {
203
- return readdirSync(join(home, 'nodes'));
203
+ // Node ids are minted by newNodeId() (runtime/nodes.ts) as `<base36-ts>-<8
204
+ // hex>`. Scope the scan to that shape so a stray non-node sibling under
205
+ // nodes/ is never miscounted as a new node dir — e.g. a `reports/` dir
206
+ // created for an empty node id (the rare CI flake on full-tier run
207
+ // 27459304455 that broke the spawn counters with
208
+ // `expected exactly 1 new node dir, got [<id>, reports]`).
209
+ return readdirSync(join(home, 'nodes')).filter((d) => /^[0-9a-z]+-[0-9a-f]{8}$/.test(d));
204
210
  }
205
211
  catch {
206
212
  return [];
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,138 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/revive-all.test.ts
2
+ //
3
+ // REGRESSION LOCK — gh issue #9 (revive-all over the canvas). The heart of the
4
+ // feature is the SELECTION predicate `isDisconnected`: which nodes a one-shot
5
+ // "resume everything" sweep relaunches. Getting that wrong is the whole risk —
6
+ // too narrow strands survivors, too broad resurrects finished/closed
7
+ // conversations nobody asked to reopen, or double-spawns a live node.
8
+ //
9
+ // (1) WHY PURE, NOT TMUX — isDisconnected is a total function of (meta, isAlive
10
+ // probe, scope): zero process, zero tmux, instant. The liveness probe is
11
+ // injected so the alive/dead branch is exercised without a real pid. The
12
+ // reviveAll orchestration over it is a thin select-then-reviveNode loop;
13
+ // reviveNode is covered (double-spawn guard) by revive.test.ts.
14
+ //
15
+ // (2) THE INVARIANTS LOCKED:
16
+ // - engine running (live pid) → NOT disconnected (never double-spawn).
17
+ // - no resumable saved session (no pi_session_id AND no pi_session_file) →
18
+ // NOT disconnected (nothing to resume).
19
+ // - dead/crashed with a session → disconnected (swept by default).
20
+ // - done/canceled (terminal-by-choice) → always excluded (no opt-in).
21
+ // - kind:'human' (the human bridge, never a pi engine) → never disconnected.
22
+ //
23
+ // (3) HOW IT FAILS IF THE LOGIC REGRESSES — drop the terminal-by-choice guard
24
+ // and the done/canceled exclusion asserts go RED; drop the session
25
+ // requirement and the no-session assert goes RED; invert the liveness branch
26
+ // and the live-pid assert goes RED.
27
+ import { test, before, after, beforeEach } from 'node:test';
28
+ import assert from 'node:assert/strict';
29
+ import { mkdtempSync, rmSync } from 'node:fs';
30
+ import { tmpdir } from 'node:os';
31
+ import { join } from 'node:path';
32
+ import { spawnSync } from 'node:child_process';
33
+ import { isDisconnected, TERMINAL_BY_CHOICE, listDisconnected } from '../runtime/revive-all.js';
34
+ import { createNode } from '../canvas/canvas.js';
35
+ import { closeDb } from '../canvas/db.js';
36
+ const ALIVE = () => true;
37
+ const DEAD = () => false;
38
+ function meta(over = {}) {
39
+ return {
40
+ node_id: 'n',
41
+ name: 'n',
42
+ created: new Date().toISOString(),
43
+ cwd: '/tmp/work',
44
+ kind: 'developer',
45
+ mode: 'base',
46
+ lifecycle: 'terminal',
47
+ status: 'active',
48
+ pi_pid: 1234,
49
+ pi_session_id: 'uuid-1',
50
+ pi_session_file: '/abs/sess.jsonl',
51
+ ...over,
52
+ };
53
+ }
54
+ // --- engine liveness branch -------------------------------------------------
55
+ test('a node whose engine is RUNNING (live pid) is NOT disconnected — never double-spawn', () => {
56
+ assert.equal(isDisconnected(meta({ pi_pid: 1234 }), ALIVE), false);
57
+ });
58
+ test('a node whose engine is DEAD but has a saved session IS disconnected', () => {
59
+ assert.equal(isDisconnected(meta({ pi_pid: 1234 }), DEAD), true);
60
+ });
61
+ test('a node with a null pid (never/ no longer recorded) and a session IS disconnected', () => {
62
+ assert.equal(isDisconnected(meta({ pi_pid: null }), DEAD), true);
63
+ });
64
+ // --- resumable-session requirement -----------------------------------------
65
+ test('a dead node with NO resumable saved session is NOT disconnected', () => {
66
+ assert.equal(isDisconnected(meta({ pi_session_id: null, pi_session_file: null }), DEAD), false);
67
+ });
68
+ test('a session FILE alone (older node, no id) is enough to be disconnected', () => {
69
+ assert.equal(isDisconnected(meta({ pi_session_id: null }), DEAD), true);
70
+ });
71
+ // --- terminal-by-choice scope ----------------------------------------------
72
+ for (const status of TERMINAL_BY_CHOICE) {
73
+ test(`a dead-engine '${status}' node is EXCLUDED (terminal-by-choice, no opt-in)`, () => {
74
+ assert.equal(isDisconnected(meta({ status: status }), DEAD), false);
75
+ });
76
+ }
77
+ test("a crashed 'dead' node is swept by default (a crash is involuntary, not terminal-by-choice)", () => {
78
+ assert.equal(isDisconnected(meta({ status: 'dead' }), DEAD), true);
79
+ });
80
+ test("dormant 'idle' nodes with a saved session are swept by default", () => {
81
+ assert.equal(isDisconnected(meta({ status: 'idle', intent: 'idle-release' }), DEAD), true);
82
+ });
83
+ // --- human-bridge carve-out -------------------------------------------------
84
+ test("kind:'human' rows (the human bridge, never a pi engine) are never disconnected", () => {
85
+ assert.equal(isDisconnected(meta({ kind: 'human', pi_pid: null }), DEAD), false);
86
+ });
87
+ // --- listDisconnected over a live canvas (the sweep's selection) -------------
88
+ //
89
+ // Locks that the canvas-wide select returns EXACTLY the disconnected set against
90
+ // the REAL isPidAlive probe — a live-engine node (this process's pid) is kept
91
+ // out, a crashed one with a session is in, a done one is excluded. No broker is
92
+ // launched: listDisconnected is the side-effect-free preview the command gates on.
93
+ let home;
94
+ function deadPid() {
95
+ const r = spawnSync('true', [], { stdio: 'ignore' });
96
+ return r.pid ?? 0x7ffffffe;
97
+ }
98
+ function fabricate(id, over) {
99
+ createNode({
100
+ node_id: id,
101
+ name: id,
102
+ created: new Date().toISOString(),
103
+ cwd: '/tmp/work',
104
+ host_kind: 'broker',
105
+ kind: 'developer',
106
+ mode: 'base',
107
+ lifecycle: 'terminal',
108
+ status: 'active',
109
+ pi_session_id: 'uuid-' + id,
110
+ ...over,
111
+ });
112
+ closeDb();
113
+ }
114
+ before(() => {
115
+ home = mkdtempSync(join(tmpdir(), 'crtr-revive-all-'));
116
+ process.env['CRTR_HOME'] = home;
117
+ });
118
+ beforeEach(() => {
119
+ closeDb();
120
+ rmSync(home, { recursive: true, force: true });
121
+ });
122
+ after(() => {
123
+ closeDb();
124
+ rmSync(home, { recursive: true, force: true });
125
+ delete process.env['CRTR_HOME'];
126
+ });
127
+ test('listDisconnected returns EXACTLY the disconnected nodes (crashed-with-session), excluding live + done', () => {
128
+ // disconnected: dead pid + a saved session.
129
+ fabricate('crashed', { status: 'active', pi_pid: deadPid() });
130
+ // connected: a LIVE engine pid (this process — read-only here) → excluded.
131
+ fabricate('live', { status: 'active', pi_pid: process.pid });
132
+ // terminal-by-choice: done + dead pid + session → excluded.
133
+ fabricate('finished', { status: 'done', pi_pid: deadPid() });
134
+ // no resumable session → excluded.
135
+ fabricate('sessionless', { status: 'active', pi_pid: deadPid(), pi_session_id: null });
136
+ const ids = listDisconnected().map((m) => m.node_id).sort();
137
+ assert.deepEqual(ids, ['crashed'], 'only the crashed-with-session node is swept');
138
+ });
@@ -73,6 +73,12 @@ export function injectedDocsPath(nodeId) {
73
73
  }
74
74
  /** Create the full directory skeleton for a node. Idempotent. */
75
75
  export function ensureNodeDirs(nodeId) {
76
+ // Refuse an empty nodeId: it would mint stray `nodes/context`, `nodes/job`,
77
+ // `nodes/reports` siblings (non-node entries the test harness's node-dir
78
+ // scan would miscount). A node skeleton must belong to a real node id.
79
+ if (!nodeId) {
80
+ throw new Error('ensureNodeDirs: empty nodeId — refusing to create stray node-skeleton dirs under nodes/');
81
+ }
76
82
  for (const d of [contextDir(nodeId), jobDir(nodeId), reportsDir(nodeId)]) {
77
83
  mkdirSync(d, { recursive: true });
78
84
  }
@@ -0,0 +1,45 @@
1
+ // Regression: a report/skeleton write for an EMPTY node id used to resolve
2
+ // reportsDir('') → `nodes/reports` and silently mint a stray non-node sibling
3
+ // under nodes/. That stray entry broke the full-tier test harness's node-dir
4
+ // counter (`expected exactly 1 new node dir, got [<id>, reports]` — the rare
5
+ // flake on CI run 27459304455). The boundary now refuses an empty node id loudly
6
+ // instead of writing garbage. This locks that in.
7
+ import { test } from 'node:test';
8
+ import assert from 'node:assert/strict';
9
+ import { mkdtempSync, existsSync, rmSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { tmpdir } from 'node:os';
12
+ import { push } from '../feed.js';
13
+ import { ensureNodeDirs, nodesRoot } from '../../canvas/paths.js';
14
+ test('push() rejects an empty nodeId and creates no stray nodes/reports dir', async () => {
15
+ const home = mkdtempSync(join(tmpdir(), 'crtr-emptyguard-'));
16
+ const orig = process.env['CRTR_HOME'];
17
+ process.env['CRTR_HOME'] = home;
18
+ try {
19
+ await assert.rejects(() => push('', { kind: 'update', body: 'orphan report' }), /empty nodeId/);
20
+ assert.equal(existsSync(join(nodesRoot(), 'reports')), false, 'no stray nodes/reports dir was created');
21
+ }
22
+ finally {
23
+ if (orig === undefined)
24
+ delete process.env['CRTR_HOME'];
25
+ else
26
+ process.env['CRTR_HOME'] = orig;
27
+ rmSync(home, { recursive: true, force: true });
28
+ }
29
+ });
30
+ test('ensureNodeDirs() rejects an empty nodeId and creates no stray skeleton dirs', () => {
31
+ const home = mkdtempSync(join(tmpdir(), 'crtr-emptyguard-'));
32
+ const orig = process.env['CRTR_HOME'];
33
+ process.env['CRTR_HOME'] = home;
34
+ try {
35
+ assert.throws(() => ensureNodeDirs(''), /empty nodeId/);
36
+ assert.equal(existsSync(join(nodesRoot(), 'reports')), false, 'no stray nodes/reports dir was created');
37
+ }
38
+ finally {
39
+ if (orig === undefined)
40
+ delete process.env['CRTR_HOME'];
41
+ else
42
+ process.env['CRTR_HOME'] = orig;
43
+ rmSync(home, { recursive: true, force: true });
44
+ }
45
+ });
@@ -70,6 +70,14 @@ function tierFor(kind) {
70
70
  * (c) If kind === 'final', mark the node done.
71
71
  */
72
72
  export async function push(nodeId, opts) {
73
+ // A report MUST belong to a node. An empty nodeId would resolve reportsDir('')
74
+ // to `nodes/reports` and silently mint a stray non-node sibling under nodes/
75
+ // (the latent corruption behind the full-tier flake on CI run 27459304455).
76
+ // Refuse it loudly at the boundary so a bad caller surfaces with a stack
77
+ // instead of writing garbage.
78
+ if (!nodeId) {
79
+ throw new Error(`push: empty nodeId (kind=${opts.kind}) — a report must belong to a node`);
80
+ }
73
81
  const { kind, body } = opts;
74
82
  const from = opts.from ?? nodeId;
75
83
  const now = new Date();
@@ -29,6 +29,10 @@ export interface FlagParam {
29
29
  /** When true, the flag may appear multiple times; values accumulate into an
30
30
  * array. TODO: no current leaf needs this — implement when first leaf migrates. */
31
31
  repeatable?: boolean;
32
+ /** When true, the flag is PARSED normally but never rendered in `-h`. For a
33
+ * deliberately-undiscoverable confirmation gate the agent must be TOLD about
34
+ * in the command's own output (not by reading the schema), never advertised. */
35
+ hidden?: boolean;
32
36
  }
33
37
  /** Raw stdin content blob (piped text, not parsed as JSON). */
34
38
  export interface StdinParam {
package/dist/core/help.js CHANGED
@@ -213,7 +213,8 @@ export function renderLeafArgv(h) {
213
213
  lines.push(h.guide);
214
214
  }
215
215
  lines.push('');
216
- const params = h.params ?? [];
216
+ // Hidden flags are parsed but never advertised in -h (see FlagParam.hidden).
217
+ const params = (h.params ?? []).filter((p) => !(p.kind === 'flag' && p.hidden === true));
217
218
  if (params.length > 0) {
218
219
  lines.push('Input');
219
220
  const labels = params.map(paramLabel);
@@ -0,0 +1,42 @@
1
+ import type { NodeMeta, NodeStatus } from '../canvas/types.js';
2
+ /** Statuses a node lands in DELIBERATELY: a worker that finished its own work
3
+ * (`done`) or a node a human closed/reaped (`canceled`). The daemon never
4
+ * auto-revives these (see reapDeadResidue in crtrd.ts), and revive-all leaves
5
+ * them alone too — resurrecting finished and closed conversations en masse would
6
+ * flood the canvas with work nobody asked to reopen. `dead` is NOT here: a crash
7
+ * is involuntary, so it is swept. (Silas, 2026-06-13: no opt-in to include these
8
+ * — the exclusion is unconditional.) */
9
+ export declare const TERMINAL_BY_CHOICE: readonly NodeStatus[];
10
+ /** True when `meta`'s engine is NOT running but it has a resumable saved session
11
+ * — the precise "disconnected" predicate (gh #9).
12
+ *
13
+ * - engine not running: no live `pi_pid` (a dead/absent broker pid).
14
+ * - resumable: a captured pi session (`pi_session_file` or `pi_session_id`) —
15
+ * without one there is nothing to resume.
16
+ * - `human`-kind rows are the `crtr human` bridge, never a pi engine, so they
17
+ * are never "disconnected" (mirrors the daemon's superviseTick carve-out).
18
+ * - terminal-by-choice (`done`/`canceled`) is always excluded.
19
+ *
20
+ * Pure: the liveness probe is injected so scope is unit-testable without real
21
+ * processes. */
22
+ export declare function isDisconnected(meta: NodeMeta, isAlive: (pid: number | null | undefined) => boolean): boolean;
23
+ /** Every disconnected node on the canvas — the set a revive-all WOULD relaunch.
24
+ * This is the PREVIEW: it has no side effects, so the command can list the
25
+ * candidates and gate on confirmation before any engine is launched. */
26
+ export declare function listDisconnected(): NodeMeta[];
27
+ export interface ReviveAllResult {
28
+ /** Node ids whose broker engine reviveAll relaunched (RESUME). */
29
+ revived: string[];
30
+ /** Per-node failures — reviveNode threw (its launch failed). One bad node
31
+ * never aborts the sweep. */
32
+ failed: {
33
+ node_id: string;
34
+ error: string;
35
+ }[];
36
+ }
37
+ /** RESUME every disconnected node. reviveNode is the ONLY sanctioned launcher
38
+ * and self-guards the double-spawn (a node whose broker pid is already live is a
39
+ * no-op), so this is a thin orchestrator over `listDisconnected`: revive each
40
+ * with resume:true. One failing node never aborts the sweep — its error is
41
+ * collected and the rest proceed. */
42
+ export declare function reviveAll(): ReviveAllResult;
@@ -0,0 +1,78 @@
1
+ // revive-all — resume EVERY disconnected node in one shot (gh issue #9).
2
+ //
3
+ // After a mass-disconnect event (a reboot, a killed login/tmux session, a mass
4
+ // crash, or the daemon being down a while) many nodes end up with their
5
+ // canvas.db row + saved conversation intact but NO broker engine running. The
6
+ // daemon recovers some on its own (active|idle rows with a dead pid grace-revive
7
+ // on its next tick), but it deliberately never touches terminal/dormant states,
8
+ // so the operator is left reviving survivors one id at a time.
9
+ //
10
+ // This module is the single sweep: `listDisconnected` selects every DISCONNECTED
11
+ // node (engine not running, but a resumable saved session exists), and
12
+ // `reviveAll` RESUMEs each via reviveNode — the only sanctioned launcher, which
13
+ // self-guards the double-spawn. The selection predicate (`isDisconnected`) is
14
+ // pure so the scope rules are unit-testable without booting a single process.
15
+ import { listNodes, getNode } from '../canvas/index.js';
16
+ import { isPidAlive } from '../canvas/pid.js';
17
+ import { reviveNode } from './revive.js';
18
+ /** Statuses a node lands in DELIBERATELY: a worker that finished its own work
19
+ * (`done`) or a node a human closed/reaped (`canceled`). The daemon never
20
+ * auto-revives these (see reapDeadResidue in crtrd.ts), and revive-all leaves
21
+ * them alone too — resurrecting finished and closed conversations en masse would
22
+ * flood the canvas with work nobody asked to reopen. `dead` is NOT here: a crash
23
+ * is involuntary, so it is swept. (Silas, 2026-06-13: no opt-in to include these
24
+ * — the exclusion is unconditional.) */
25
+ export const TERMINAL_BY_CHOICE = ['done', 'canceled'];
26
+ /** True when `meta`'s engine is NOT running but it has a resumable saved session
27
+ * — the precise "disconnected" predicate (gh #9).
28
+ *
29
+ * - engine not running: no live `pi_pid` (a dead/absent broker pid).
30
+ * - resumable: a captured pi session (`pi_session_file` or `pi_session_id`) —
31
+ * without one there is nothing to resume.
32
+ * - `human`-kind rows are the `crtr human` bridge, never a pi engine, so they
33
+ * are never "disconnected" (mirrors the daemon's superviseTick carve-out).
34
+ * - terminal-by-choice (`done`/`canceled`) is always excluded.
35
+ *
36
+ * Pure: the liveness probe is injected so scope is unit-testable without real
37
+ * processes. */
38
+ export function isDisconnected(meta, isAlive) {
39
+ if (meta.kind === 'human')
40
+ return false;
41
+ if (isAlive(meta.pi_pid))
42
+ return false; // engine running → connected
43
+ if (meta.pi_session_id == null && meta.pi_session_file == null)
44
+ return false; // nothing to resume
45
+ if (TERMINAL_BY_CHOICE.includes(meta.status))
46
+ return false;
47
+ return true;
48
+ }
49
+ /** Every disconnected node on the canvas — the set a revive-all WOULD relaunch.
50
+ * This is the PREVIEW: it has no side effects, so the command can list the
51
+ * candidates and gate on confirmation before any engine is launched. */
52
+ export function listDisconnected() {
53
+ const out = [];
54
+ for (const row of listNodes()) {
55
+ const meta = getNode(row.node_id);
56
+ if (meta !== null && isDisconnected(meta, isPidAlive))
57
+ out.push(meta);
58
+ }
59
+ return out;
60
+ }
61
+ /** RESUME every disconnected node. reviveNode is the ONLY sanctioned launcher
62
+ * and self-guards the double-spawn (a node whose broker pid is already live is a
63
+ * no-op), so this is a thin orchestrator over `listDisconnected`: revive each
64
+ * with resume:true. One failing node never aborts the sweep — its error is
65
+ * collected and the rest proceed. */
66
+ export function reviveAll() {
67
+ const result = { revived: [], failed: [] };
68
+ for (const meta of listDisconnected()) {
69
+ try {
70
+ reviveNode(meta.node_id, { resume: true });
71
+ result.revived.push(meta.node_id);
72
+ }
73
+ catch (err) {
74
+ result.failed.push({ node_id: meta.node_id, error: err.message });
75
+ }
76
+ }
77
+ return result;
78
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/crouter",
3
- "version": "0.3.30",
3
+ "version": "0.3.32",
4
4
  "description": "crtr — fast access to skills, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",