@crouton-kit/crouter 0.3.29 → 0.3.31

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,8 +1,16 @@
1
1
  import type { RootDef } from './core/command.js';
2
- /** Assemble the full crtr command tree. Root owns only the tagline; every
3
- * subtree declares its own root representation via its rootEntry, and every
4
- * branch assembles its child listing from the child defs (each child owns its
5
- * description/whenToUse/tier). No globals: -h is taught by each child's
6
- * whenToUse CTA and the capability-discovery rule in the root footer, so a
7
- * standalone "-h: print help" stub would be redundant. */
8
- export declare function buildRoot(): RootDef;
2
+ /** Every shipped subtree name. Cheap (no module loading) the front-door
3
+ * recursion guard and the dispatcher's first-token routing need only names. */
4
+ export declare const SUBTREE_NAMES: string[];
5
+ /** Build a root that contains only the subtree `first` dispatches into.
6
+ * Returns the FULL root when `first` is not a recognized subtree — bare `crtr`,
7
+ * `-h`/`--help`, `--version`, and any unknown leading token all need the
8
+ * complete tree (root -h lists every subtree; the unknown-path error names
9
+ * every valid child). This keeps every help/error surface byte-identical while
10
+ * the common leaf-dispatch path loads exactly one subtree. */
11
+ export declare function resolveRoot(first: string | undefined): Promise<RootDef>;
12
+ /** Assemble the full crtr command tree (all subtrees). Used for root -h, the
13
+ * unknown-path error, bare-root boot, and the listing-completeness test. Root
14
+ * owns only the tagline; every subtree declares its own root representation via
15
+ * its rootEntry. */
16
+ export declare function buildRoot(): Promise<RootDef>;
@@ -1,38 +1,50 @@
1
1
  import { defineRoot } from './core/command.js';
2
- import { registerMemory } from './commands/memory.js';
3
- import { registerPkg } from './commands/pkg.js';
4
- import { registerHuman } from './commands/human.js';
5
- import { registerSys } from './commands/sys.js';
6
- import { registerPush, registerFeed } from './commands/push.js';
7
- import { registerNode } from './commands/node.js';
8
- import { registerCanvas } from './commands/canvas.js';
9
- import { registerView } from './commands/view.js';
10
- import { registerAttach } from './clients/attach/attach-cmd.js';
11
- import { registerWorkspace } from './commands/workspace.js';
12
- import { registerWeb } from './clients/web/web-cmd.js';
13
- /** Assemble the full crtr command tree. Root owns only the tagline; every
14
- * subtree declares its own root representation via its rootEntry, and every
15
- * branch assembles its child listing from the child defs (each child owns its
16
- * description/whenToUse/tier). No globals: -h is taught by each child's
17
- * whenToUse CTA and the capability-discovery rule in the root footer, so a
18
- * standalone "-h: print help" stub would be redundant. */
19
- export function buildRoot() {
20
- return defineRoot({
21
- tagline: 'crtr: agentic planning runtime.',
22
- globals: [],
23
- subtrees: [
24
- registerMemory(),
25
- registerPkg(),
26
- registerHuman(),
27
- registerSys(),
28
- registerNode(),
29
- registerPush(),
30
- registerFeed(),
31
- registerCanvas(),
32
- registerView(),
33
- registerAttach(),
34
- registerWorkspace(),
35
- registerWeb(),
36
- ],
37
- });
2
+ const TAGLINE = 'crtr: agentic planning runtime.';
3
+ /** Lazy registry: subtree name → dynamic importer that builds its BranchDef.
4
+ * The whole point is that each `import()` only compiles that one subtree's
5
+ * module graph. The hot path (`crtr node focus …`, `crtr feed`, …) dispatches
6
+ * into a single leaf, so it must load ONE subtree — not the attach-TUI
7
+ * (babel/highlight.js), the web/vite command, and every other subtree it never
8
+ * touches. `crtr` cold-start is dominated by Node module loading; keeping the
9
+ * other 11 subtrees off the path is the biggest, lowest-risk win.
10
+ *
11
+ * Naming note: `push` and `feed` are two subtrees from one module — importing
12
+ * it once yields both registers. */
13
+ const SUBTREE_LOADERS = {
14
+ memory: async () => (await import('./commands/memory.js')).registerMemory(),
15
+ pkg: async () => (await import('./commands/pkg.js')).registerPkg(),
16
+ human: async () => (await import('./commands/human.js')).registerHuman(),
17
+ sys: async () => (await import('./commands/sys.js')).registerSys(),
18
+ node: async () => (await import('./commands/node.js')).registerNode(),
19
+ push: async () => (await import('./commands/push.js')).registerPush(),
20
+ feed: async () => (await import('./commands/push.js')).registerFeed(),
21
+ canvas: async () => (await import('./commands/canvas.js')).registerCanvas(),
22
+ view: async () => (await import('./commands/view.js')).registerView(),
23
+ attach: async () => (await import('./clients/attach/attach-cmd.js')).registerAttach(),
24
+ workspace: async () => (await import('./commands/workspace.js')).registerWorkspace(),
25
+ web: async () => (await import('./clients/web/web-cmd.js')).registerWeb(),
26
+ };
27
+ /** Every shipped subtree name. Cheap (no module loading) — the front-door
28
+ * recursion guard and the dispatcher's first-token routing need only names. */
29
+ export const SUBTREE_NAMES = Object.keys(SUBTREE_LOADERS);
30
+ /** Build a root that contains only the subtree `first` dispatches into.
31
+ * Returns the FULL root when `first` is not a recognized subtree — bare `crtr`,
32
+ * `-h`/`--help`, `--version`, and any unknown leading token all need the
33
+ * complete tree (root -h lists every subtree; the unknown-path error names
34
+ * every valid child). This keeps every help/error surface byte-identical while
35
+ * the common leaf-dispatch path loads exactly one subtree. */
36
+ export async function resolveRoot(first) {
37
+ const loader = first !== undefined ? SUBTREE_LOADERS[first] : undefined;
38
+ if (loader !== undefined) {
39
+ return defineRoot({ tagline: TAGLINE, globals: [], subtrees: [await loader()] });
40
+ }
41
+ return buildRoot();
42
+ }
43
+ /** Assemble the full crtr command tree (all subtrees). Used for root -h, the
44
+ * unknown-path error, bare-root boot, and the listing-completeness test. Root
45
+ * owns only the tagline; every subtree declares its own root representation via
46
+ * its rootEntry. */
47
+ export async function buildRoot() {
48
+ const subtrees = await Promise.all(SUBTREE_NAMES.map((n) => SUBTREE_LOADERS[n]()));
49
+ return defineRoot({ tagline: TAGLINE, globals: [], subtrees });
38
50
  }
package/dist/cli.js CHANGED
@@ -1,25 +1,30 @@
1
1
  #!/usr/bin/env node
2
2
  import { runCli } from './core/command.js';
3
- import { buildRoot } from './build-root.js';
3
+ import { resolveRoot } from './build-root.js';
4
4
  import { maybeBootRoot } from './core/runtime/front-door.js';
5
5
  import { maybeAutoUpdate } from './core/auto-update.js';
6
6
  import { ensureOfficialMarketplace, ensureProjectScope } from './core/bootstrap.js';
7
7
  import { provisionExports } from './core/skill-sync/export.js';
8
- // The full command tree is assembled in build-root.ts (shared with the
9
- // listing-completeness test). Root owns only the tagline; every subtree
10
- // declares its own representation.
11
- const root = buildRoot();
12
- // The front door: bare `crtr` (or `crtr [dir] ["prompt"]`) boots a resident
13
- // root node and execs pi in this terminal. Recognized subcommands fall through
14
- // to the normal dispatcher. Must run before anything that assumes a subcommand.
15
- if (maybeBootRoot(root, process.argv)) {
16
- // bootRoot exec'd pi inline and exited; unreachable.
8
+ async function main() {
9
+ // Lazy command tree: load only the subtree this invocation dispatches into.
10
+ // resolveRoot returns the full tree only for help/version/bare/unknown, where
11
+ // every subtree is genuinely needed (root -h, the unknown-path error). The
12
+ // hot leaf-dispatch path loads one subtree, keeping the other 11 (and their
13
+ // heavy deps the attach TUI, web/vite) off cold-start.
14
+ const root = await resolveRoot(process.argv[2]);
15
+ // The front door: bare `crtr` (or `crtr [dir] ["prompt"]`) boots a resident
16
+ // root node and execs pi in this terminal. Recognized subcommands fall through
17
+ // to the normal dispatcher. Must run before anything that assumes a subcommand.
18
+ if (maybeBootRoot(root, process.argv)) {
19
+ // bootRoot exec'd pi inline and exited; unreachable.
20
+ }
21
+ ensureOfficialMarketplace(process.argv);
22
+ provisionExports(root);
23
+ ensureProjectScope(process.argv);
24
+ maybeAutoUpdate(process.argv);
25
+ await runCli(root, process.argv);
17
26
  }
18
- ensureOfficialMarketplace(process.argv);
19
- provisionExports(root);
20
- ensureProjectScope(process.argv);
21
- maybeAutoUpdate(process.argv);
22
- runCli(root, process.argv).catch((err) => {
27
+ main().catch((err) => {
23
28
  const msg = err instanceof Error ? err.message : String(err);
24
29
  process.stderr.write(`crtr: ${msg}\n`);
25
30
  process.exit(1);
@@ -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 /
@@ -8,8 +8,7 @@
8
8
  // `crtr <argv>`. This keeps the menu static while the behaviour stays fully
9
9
  // config-driven (no per-node menu rebuild).
10
10
  //
11
- // Two special cases bypass the bind table:
12
- // • a digit key 1..9 → focus report N (the Nth live report of the pane node)
11
+ // One special case bypasses the bind table:
13
12
  // • a bind whose `run` is the sentinel `__graph__` → send-keys `/graph` into
14
13
  // the pane (toggles the in-pi GRAPH modal); the menu emits this directly so
15
14
  // the dispatcher only handles it defensively.
@@ -23,7 +22,7 @@ import { InputError } from '../core/io.js';
23
22
  import { readConfig } from '../core/config.js';
24
23
  import { sendKeysEnter } from '../core/runtime/tmux-chrome.js';
25
24
  import { nodeInPane } from './node.js';
26
- import { getNode, subscribersOf, subscriptionsOf, view, fullName, } from '../core/canvas/index.js';
25
+ import { getNode, subscribersOf, view, fullName, } from '../core/canvas/index.js';
27
26
  const pexec = promisify(execFile);
28
27
  /** Template vars available to a `run` string. Single-valued vars interpolate
29
28
  * in place (preserving spaces, e.g. a node name); `{subtree}` is multi-valued
@@ -76,7 +75,7 @@ export const chordLeaf = defineLeaf({
76
75
  name: 'key',
77
76
  type: 'string',
78
77
  required: true,
79
- constraint: 'The chord key pressed after alt+c (e.g. m, e, or a digit 1-9 for focus report N).',
78
+ constraint: 'The chord key pressed after alt+c (e.g. g or m).',
80
79
  },
81
80
  ],
82
81
  output: [
@@ -99,26 +98,6 @@ export const chordLeaf = defineLeaf({
99
98
  next: 'Run from inside an agent\'s pane, or pass --pane <pane-id>.',
100
99
  });
101
100
  }
102
- // Digit keys 1..9 → focus the Nth live report (generated, not a bind entry).
103
- if (/^[1-9]$/.test(key)) {
104
- const n = parseInt(key, 10);
105
- const reports = subscriptionsOf(selfId)
106
- .map((r) => r.node_id)
107
- .filter((id) => {
108
- const s = getNode(id)?.status;
109
- return s === 'active' || s === 'idle';
110
- });
111
- const target = reports[n - 1];
112
- if (target === undefined)
113
- return { ran: false, key, node_id: selfId, action: 'noop' };
114
- try {
115
- await pexec('crtr', ['node', 'focus', target], { timeout: 15_000 });
116
- }
117
- catch {
118
- /* best-effort */
119
- }
120
- return { ran: true, key, node_id: selfId, action: `node focus ${target}` };
121
- }
122
101
  const bind = readConfig('user').canvasNav.prefixBinds[key];
123
102
  if (bind === undefined)
124
103
  return { ran: false, key, node_id: selfId, action: 'noop' };
@@ -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
+ }
@@ -9,8 +9,8 @@
9
9
  import { test } from 'node:test';
10
10
  import assert from 'node:assert/strict';
11
11
  import { buildRoot } from '../../build-root.js';
12
- test('every non-hidden listing child declares description + whenToUse', () => {
13
- const root = buildRoot();
12
+ test('every non-hidden listing child declares description + whenToUse', async () => {
13
+ const root = await buildRoot();
14
14
  const missing = [];
15
15
  const walk = (branch, path) => {
16
16
  for (const child of branch.help.listing ?? []) {
@@ -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
+ });
@@ -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);
@@ -23,11 +23,11 @@ export declare function listFocuses(): FocusRow[];
23
23
  * targeting it. */
24
24
  export declare function graphSurfaceTarget(nodeId: string): FocusRow | null;
25
25
  /** Synchronously wait until a broker's view.sock accepts a connection. The spawn
26
- * and focus flows are sync (the command layer calls them directly), so the
27
- * readiness probe lives in a short child Node process that can use async net
28
- * events while this process blocks in `spawnSync`. Success proves more than file
29
- * existence: it is robust to a stale leftover socket the launching broker has
30
- * not unlinked yet, because only an accepting listener exits 0. */
26
+ * and focus flows are sync (the command layer calls them directly), so the async
27
+ * net poll runs in a worker THREAD while this thread blocks on `Atomics.wait`
28
+ * in-process, no child Node cold-start. Success proves more than file existence:
29
+ * it is robust to a stale leftover socket the launching broker has not unlinked
30
+ * yet, because only an accepting listener yields a `connect`. */
31
31
  export declare function waitForBrokerViewSocket(nodeId: string): boolean;
32
32
  /** A reviver: ensure a node's broker ENGINE is alive (idempotent → reviveNode →
33
33
  * headlessBrokerHost.launch). Injected so placement.ts need not import revive.ts
@@ -23,7 +23,7 @@
23
23
  // model-over-driver so the §5.1 import-lint ("only placement.ts /
24
24
  // tmux-chrome.ts import tmux.ts") holds — every other module reaches the
25
25
  // driver verbs through here.
26
- import { spawnSync } from 'node:child_process';
26
+ import { Worker } from 'node:worker_threads';
27
27
  import { join } from 'node:path';
28
28
  import { getNode, openFocusRow, closeFocusRow, getFocusByNode, getFocusByPane, getFocusById, listFocuses as listFocusRows, view, } from '../canvas/index.js';
29
29
  import { paneExists, paneLocation, ensureSession, openNodeWindow, splitWindow, closePane, currentTmux, switchClient, selectWindow, getPaneOption, } from './tmux.js';
@@ -115,19 +115,17 @@ export function graphSurfaceTarget(nodeId) {
115
115
  // ---------------------------------------------------------------------------
116
116
  const BROKER_FOCUS_SOCKET_WAIT_MS = 30_000;
117
117
  const BROKER_FOCUS_SOCKET_RETRY_MS = 100;
118
- /** Synchronously wait until a broker's view.sock accepts a connection. The spawn
119
- * and focus flows are sync (the command layer calls them directly), so the
120
- * readiness probe lives in a short child Node process that can use async net
121
- * events while this process blocks in `spawnSync`. Success proves more than file
122
- * existence: it is robust to a stale leftover socket the launching broker has
123
- * not unlinked yet, because only an accepting listener exits 0. */
124
- export function waitForBrokerViewSocket(nodeId) {
125
- const sockPath = join(nodeDir(nodeId), 'view.sock');
126
- const probe = `
118
+ // The probe runs inside a worker thread (see waitForBrokerViewSocket). Async net
119
+ // events drive the same poll loop; the worker reports its verdict into a shared
120
+ // Int32 (0 = pending, 1 = accepted, 2 = gave up) and Atomics.notify wakes the
121
+ // blocked main thread. eval:true CommonJS, so `require` and workerData apply.
122
+ const VIEW_SOCKET_PROBE_WORKER = `
127
123
  const net = require('node:net');
128
- const sockPath = process.argv[1];
129
- const deadline = Date.now() + Number(process.argv[2]);
130
- const delay = Number(process.argv[3]);
124
+ const { workerData, parentPort } = require('node:worker_threads');
125
+ const { buffer, sockPath, waitMs, retryMs } = workerData;
126
+ const result = new Int32Array(buffer);
127
+ const deadline = Date.now() + waitMs;
128
+ const report = (code) => { Atomics.store(result, 0, code); Atomics.notify(result, 0); };
131
129
  function attempt() {
132
130
  let socket;
133
131
  let settled = false;
@@ -135,9 +133,9 @@ function attempt() {
135
133
  if (settled) return;
136
134
  settled = true;
137
135
  if (socket !== undefined) socket.destroy();
138
- if (ok) process.exit(0);
139
- if (Date.now() >= deadline) process.exit(1);
140
- setTimeout(attempt, delay);
136
+ if (ok) { report(1); return; }
137
+ if (Date.now() >= deadline) { report(2); return; }
138
+ setTimeout(attempt, retryMs);
141
139
  };
142
140
  try {
143
141
  socket = net.createConnection(sockPath);
@@ -147,18 +145,31 @@ function attempt() {
147
145
  }
148
146
  socket.once('connect', () => finish(true));
149
147
  socket.once('error', () => finish(false));
150
- socket.setTimeout(delay, () => finish(false));
148
+ socket.setTimeout(retryMs, () => finish(false));
151
149
  }
152
150
  attempt();
151
+ void parentPort;
153
152
  `;
154
- const r = spawnSync(process.execPath, ['--input-type=commonjs', '-e', probe, sockPath, String(BROKER_FOCUS_SOCKET_WAIT_MS), String(BROKER_FOCUS_SOCKET_RETRY_MS)], {
155
- stdio: 'ignore',
156
- timeout: BROKER_FOCUS_SOCKET_WAIT_MS + 1_000,
157
- // Keep the probe deterministic: NODE_OPTIONS like --input-type=module or
158
- // --inspect-brk can break/hang a tiny `node -e` readiness check.
159
- env: { ...process.env, NODE_OPTIONS: '' },
153
+ /** Synchronously wait until a broker's view.sock accepts a connection. The spawn
154
+ * and focus flows are sync (the command layer calls them directly), so the async
155
+ * net poll runs in a worker THREAD while this thread blocks on `Atomics.wait` —
156
+ * in-process, no child Node cold-start. Success proves more than file existence:
157
+ * it is robust to a stale leftover socket the launching broker has not unlinked
158
+ * yet, because only an accepting listener yields a `connect`. */
159
+ export function waitForBrokerViewSocket(nodeId) {
160
+ const sockPath = join(nodeDir(nodeId), 'view.sock');
161
+ const buffer = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT);
162
+ const result = new Int32Array(buffer);
163
+ const worker = new Worker(VIEW_SOCKET_PROBE_WORKER, {
164
+ eval: true,
165
+ workerData: { buffer, sockPath, waitMs: BROKER_FOCUS_SOCKET_WAIT_MS, retryMs: BROKER_FOCUS_SOCKET_RETRY_MS },
160
166
  });
161
- return r.status === 0;
167
+ worker.unref();
168
+ // Block until the worker reports (or a hard timeout matching the old
169
+ // spawnSync). 'not-equal' covers the worker finishing before we wait.
170
+ Atomics.wait(result, 0, 0, BROKER_FOCUS_SOCKET_WAIT_MS + 1_000);
171
+ void worker.terminate();
172
+ return Atomics.load(result, 0) === 1;
162
173
  }
163
174
  function newFocusId() {
164
175
  return `f-${newNodeId()}`;
@@ -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
+ }
@@ -178,8 +178,8 @@ export declare function switchClient(session: string): boolean;
178
178
  * empty, same limitation as the menu's `/promote` item. Best-effort. */
179
179
  export declare function sendKeysEnter(pane: string, text: string): boolean;
180
180
  /** Bind Alt+C to the crouter action menu. Best-effort; false if tmux fails.
181
- * The built-in items (promote/demote/detach/close/browse) are static; the canvas-nav
182
- * chords (graph/manager/expand/report-N + any custom prefixBind) are appended
181
+ * The built-in items (promote/resume/demote/detach/close) are static; the canvas-nav
182
+ * chords (default g→graph, m→manager + any custom prefixBind) are appended
183
183
  * from `canvasNav.prefixBinds`, each routed through `crtr canvas chord` (or, for
184
184
  * the `__graph__` sentinel, a `send-keys '/graph'`) so the menu stays static
185
185
  * while behaviour is config-driven. */
@@ -336,8 +336,8 @@ export function sendKeysEnter(pane, text) {
336
336
  * `prefixBind` may not claim these (the built-in item wins). */
337
337
  const RESERVED_MENU_KEYS = new Set(['o', 'r', 'd', 'D', 'x']);
338
338
  /** Bind Alt+C to the crouter action menu. Best-effort; false if tmux fails.
339
- * The built-in items (promote/demote/detach/close/browse) are static; the canvas-nav
340
- * chords (graph/manager/expand/report-N + any custom prefixBind) are appended
339
+ * The built-in items (promote/resume/demote/detach/close) are static; the canvas-nav
340
+ * chords (default g→graph, m→manager + any custom prefixBind) are appended
341
341
  * from `canvasNav.prefixBinds`, each routed through `crtr canvas chord` (or, for
342
342
  * the `__graph__` sentinel, a `send-keys '/graph'`) so the menu stays static
343
343
  * while behaviour is config-driven. */
@@ -363,7 +363,7 @@ export function installMenuBinding() {
363
363
  // marks them canceled); revivable. Output discarded — the keypress just acts.
364
364
  { name: 'close agent + subtree', key: 'x', cmd: `run-shell "crtr node close --pane '#{pane_id}' >/dev/null 2>&1"` },
365
365
  ];
366
- // Canvas-nav chords from config (default: g→graph, m→manager, e→expand). The
366
+ // Canvas-nav chords from config (default: g→graph, m→manager). The
367
367
  // `__graph__` sentinel toggles the in-pi GRAPH modal via send-keys; every
368
368
  // other bind shells the chord dispatcher, which resolves the pane's node and
369
369
  // interpolates the bind at popup time. Keys colliding with the built-ins are
@@ -382,26 +382,16 @@ export function installMenuBinding() {
382
382
  : `run-shell "crtr canvas chord --pane '#{pane_id}' --key ${key} >/dev/null 2>&1"`;
383
383
  items.push({ name, key, cmd });
384
384
  }
385
- // Focus report N: nine generated chord items (1..9), each resolved by the
386
- // dispatcher to subscriptionsOf(self)[N-1] at popup time.
387
- for (let n = 1; n <= 9; n++) {
388
- items.push({
389
- name: `focus report ${n}`,
390
- key: `${n}`,
391
- cmd: `run-shell "crtr canvas chord --pane '#{pane_id}' --key ${n} >/dev/null 2>&1"`,
392
- });
393
- }
394
- // Alt+C TOGGLE: re-pressing Alt+C while the menu is open should DISMISS it.
395
- // display-menu is modal, so the root `M-c` binding is shadowed while the menu
396
- // is up; tmux instead assembles the re-pressed Esc+c into the meta key `M-c`
397
- // and delivers it to the menu's own key handler. An unhandled key is ignored
398
- // (the menu just stays open) and the trailing `c` never leaks to the editor
399
- // (verified empirically in tmux 3.6b: no native ESC-cancel + c-leak occurs for
400
- // an atomic keypress). We catch that `M-c` with a menu item keyed to it whose
401
- // command is empty — selecting it runs nothing and closes the menu. The row
402
- // doubles as the menu's self-documenting dismiss hint. Placed last so it reads
403
- // as chrome below the actions; `M-c` collides with no mnemonic above.
404
- items.push({ name: 'close menu', key: 'M-c', cmd: '' });
385
+ // Dismiss hint. A tmux display-menu always closes on its native cancel keys
386
+ // (Escape / q / C-c), encoding-independent. We do NOT try to catch a re-pressed
387
+ // Alt+C: under `extended-keys on` (common, and what pi negotiates) the second
388
+ // Alt+C reaches the overlay as a CSI-u key (`\033[99;3u`) that tmux's menu does
389
+ // NOT match against an `M-c` mnemonic item, so a "close menu" row keyed M-c
390
+ // never fires and the menu just sits open (verified in tmux 3.6b: legacy Esc+c
391
+ // closes, CSI-u does not). Instead, a disabled (`-` prefix dim, unselectable)
392
+ // last row tells the user the close keys that always work. Placed last so it
393
+ // reads as chrome below the actions.
394
+ items.push({ name: '-esc / q to close', key: '', cmd: '' });
405
395
  // tmux's -x sets the menu's LEFT edge. To sit the box INSIDE the pane's
406
396
  // top-right corner, shift x left by the box width (longest line + tmux chrome:
407
397
  // borders + padding + the right-aligned mnemonic-key column) via format math.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/crouter",
3
- "version": "0.3.29",
3
+ "version": "0.3.31",
4
4
  "description": "crtr — fast access to skills, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",