@crouton-kit/crouter 0.3.30 → 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/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/revive.js +122 -11
- 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/revive-all.d.ts +42 -0
- package/dist/core/runtime/revive-all.js +78 -0
- package/package.json +1 -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/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
|
+
}
|
|
@@ -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);
|
|
@@ -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
|
+
}
|