@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.
- package/dist/build-root.d.ts +15 -7
- package/dist/build-root.js +48 -36
- package/dist/cli.js +20 -15
- package/dist/clients/attach/__tests__/editor-newline.test.js +37 -12
- package/dist/clients/attach/attach-cmd.js +36 -6
- package/dist/clients/attach/config-load.d.ts +6 -0
- package/dist/clients/attach/config-load.js +33 -0
- package/dist/clients/attach/titled-editor.d.ts +18 -0
- package/dist/clients/attach/titled-editor.js +44 -13
- package/dist/commands/chord.js +3 -24
- package/dist/commands/revive.js +122 -11
- package/dist/core/__tests__/listing-completeness.test.js +2 -2
- package/dist/core/__tests__/revive-all.test.d.ts +1 -0
- package/dist/core/__tests__/revive-all.test.js +138 -0
- package/dist/core/help.d.ts +4 -0
- package/dist/core/help.js +2 -1
- package/dist/core/runtime/placement.d.ts +5 -5
- package/dist/core/runtime/placement.js +35 -24
- package/dist/core/runtime/revive-all.d.ts +42 -0
- package/dist/core/runtime/revive-all.js +78 -0
- package/dist/core/runtime/tmux.d.ts +2 -2
- package/dist/core/runtime/tmux.js +13 -23
- package/package.json +1 -1
package/dist/build-root.d.ts
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import type { RootDef } from './core/command.js';
|
|
2
|
-
/**
|
|
3
|
-
*
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
|
|
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>;
|
package/dist/build-root.js
CHANGED
|
@@ -1,38 +1,50 @@
|
|
|
1
1
|
import { defineRoot } from './core/command.js';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
import
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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 {
|
|
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
|
-
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
//
|
|
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
|
-
|
|
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
|
|
2
|
-
//
|
|
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
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// newLine
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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); //
|
|
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
|
-
|
|
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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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: [
|
|
20
|
-
low: [
|
|
21
|
-
medium: [
|
|
22
|
-
high: [
|
|
23
|
-
xhigh: [
|
|
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 /
|
package/dist/commands/chord.js
CHANGED
|
@@ -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
|
-
//
|
|
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,
|
|
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.
|
|
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' };
|
package/dist/commands/revive.js
CHANGED
|
@@ -1,29 +1,43 @@
|
|
|
1
1
|
// `crtr canvas revive` — explicit node revival.
|
|
2
2
|
//
|
|
3
|
-
// Bypasses the daemon: directly
|
|
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,
|
|
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: '
|
|
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:
|
|
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:
|
|
41
|
-
{ name: 'ready', type: 'boolean', required:
|
|
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
|
|
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
|
+
});
|
package/dist/core/help.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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 {
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
129
|
-
const
|
|
130
|
-
const
|
|
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)
|
|
139
|
-
if (Date.now() >= deadline)
|
|
140
|
-
setTimeout(attempt,
|
|
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(
|
|
148
|
+
socket.setTimeout(retryMs, () => finish(false));
|
|
151
149
|
}
|
|
152
150
|
attempt();
|
|
151
|
+
void parentPort;
|
|
153
152
|
`;
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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
|
|
182
|
-
* chords (graph
|
|
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
|
|
340
|
-
* chords (graph
|
|
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
|
|
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
|
-
//
|
|
386
|
-
//
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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.
|