@crouton-kit/crouter 0.3.68 → 0.3.78
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/README.md +1 -1
- package/dist/builtin-memory/00-runtime-base.md +3 -10
- package/dist/builtin-memory/04-base-worker.md +18 -0
- package/dist/builtin-memory/04-orchestration-kernel.md +1 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +13 -34
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +639 -741
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +241 -58
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/statusline.ts +2 -9
- package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +365 -94
- package/dist/builtin-pi-packages/pi-crtr-extensions/package-lock.json +2 -2
- package/dist/builtin-pi-packages/pi-mode-switch/README.md +11 -39
- package/dist/builtin-pi-packages/pi-mode-switch/extensions/index.ts +20 -15
- package/dist/builtin-pi-packages/pi-mode-switch/package.json +1 -1
- package/dist/builtin-views/canvas/tui.mjs +8 -9
- package/dist/builtin-views/chat/tui.mjs +12 -12
- package/dist/builtin-views/git-pr/tui.mjs +7 -8
- package/dist/builtin-views/inbox/tui.mjs +27 -41
- package/dist/builtin-views/linkedin/tui.mjs +23 -37
- package/dist/builtin-views/prompt-review/tui.mjs +11 -11
- package/dist/builtin-views/settings/tui.mjs +11 -11
- package/dist/builtin-views/workspace-sidebar/tui.mjs +8 -9
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +1 -0
- package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +11 -7
- package/dist/clients/attach/__tests__/crtr-output-render.test.js +1 -1
- package/dist/clients/attach/__tests__/mermaid-render.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/mermaid-render.test.js +20 -0
- package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +110 -0
- package/dist/clients/attach/attach-cmd.d.ts +1 -1
- package/dist/clients/attach/attach-cmd.js +733 -730
- package/dist/clients/attach/auth-pickers.d.ts +12 -0
- package/dist/clients/attach/auth-pickers.js +11 -0
- package/dist/clients/attach/canvas-panels.js +2 -3
- package/dist/clients/attach/chat-view.d.ts +7 -8
- package/dist/clients/attach/chat-view.js +119 -79
- package/dist/clients/attach/edit-diff-render.d.ts +6 -0
- package/dist/clients/attach/edit-diff-render.js +263 -0
- package/dist/clients/attach/input-controller.d.ts +28 -2
- package/dist/clients/attach/input-controller.js +38 -5
- package/dist/clients/attach/mermaid-render.js +5 -2
- package/dist/clients/attach/pickers.d.ts +8 -7
- package/dist/clients/attach/pickers.js +13 -17
- package/dist/clients/attach/slash-commands.d.ts +9 -0
- package/dist/clients/attach/slash-commands.js +128 -7
- package/dist/clients/attach/titled-editor.d.ts +12 -1
- package/dist/clients/attach/titled-editor.js +103 -8
- package/dist/commands/canvas-browse.js +2 -2
- package/dist/commands/memory/lint.js +39 -5
- package/dist/commands/memory/write.js +1 -0
- package/dist/commands/node.js +12 -5
- package/dist/commands/profile/new.js +30 -5
- package/dist/commands/surface-tmux-spread.js +1 -3
- package/dist/commands/sys/__tests__/config-keybindings.test.d.ts +1 -0
- package/dist/commands/sys/__tests__/config-keybindings.test.js +55 -0
- package/dist/commands/sys/__tests__/config-model-ladders.test.d.ts +1 -0
- package/dist/commands/sys/__tests__/config-model-ladders.test.js +121 -0
- package/dist/commands/sys/config.js +18 -21
- package/dist/commands/sys/setup-core.d.ts +12 -1
- package/dist/commands/sys/setup-core.js +25 -3
- package/dist/commands/sys/setup.js +166 -20
- package/dist/core/__tests__/base-worker-prompt.test.d.ts +1 -0
- package/dist/core/__tests__/base-worker-prompt.test.js +24 -0
- package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +232 -1
- package/dist/core/__tests__/fault-classifier.test.js +15 -0
- package/dist/core/__tests__/full/broker-dialogs.test.js +7 -2
- package/dist/core/__tests__/full/broker-pane-resolution.test.js +9 -6
- package/dist/core/__tests__/full/cascade-close.test.js +5 -2
- package/dist/core/__tests__/full/dead-pane-regression.test.js +6 -3
- package/dist/core/__tests__/full/detach-focus.test.js +10 -5
- package/dist/core/__tests__/full/human-new-window-regression.test.js +6 -3
- package/dist/core/__tests__/full/review-render-pane-regression.test.js +2 -2
- package/dist/core/__tests__/helpers/harness.d.ts +1 -0
- package/dist/core/__tests__/helpers/harness.js +28 -4
- package/dist/core/__tests__/host-teardown-process-group.test.js +15 -4
- package/dist/core/__tests__/live-mutation-verbs.test.js +5 -4
- package/dist/core/__tests__/review-model-floor.test.js +1 -1
- package/dist/core/__tests__/stream-watchdog.test.d.ts +1 -0
- package/dist/core/__tests__/stream-watchdog.test.js +70 -0
- package/dist/core/__tests__/tmux-surface.test.js +72 -0
- package/dist/core/canvas/__tests__/remote-event-stream.test.js +11 -9
- package/dist/core/canvas/__tests__/render-remote.test.js +7 -4
- package/dist/core/canvas/browse/__tests__/model.test.js +23 -5
- package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.js +30 -13
- package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
- package/dist/core/canvas/browse/app.js +164 -5
- package/dist/core/canvas/browse/model.d.ts +10 -6
- package/dist/core/canvas/browse/model.js +20 -12
- package/dist/core/canvas/browse/pins.d.ts +4 -0
- package/dist/core/canvas/browse/pins.js +29 -0
- package/dist/core/canvas/browse/render.d.ts +13 -1
- package/dist/core/canvas/browse/render.js +72 -15
- package/dist/core/canvas/nav-model.js +5 -11
- package/dist/core/canvas/paths.d.ts +5 -0
- package/dist/core/canvas/paths.js +7 -0
- package/dist/core/canvas/pid.d.ts +14 -10
- package/dist/core/canvas/pid.js +14 -10
- package/dist/core/config.d.ts +53 -2
- package/dist/core/config.js +228 -110
- package/dist/core/fault-classifier.js +1 -1
- package/dist/core/fs-utils.d.ts +1 -0
- package/dist/core/fs-utils.js +15 -1
- package/dist/core/keybindings/__tests__/resolve.test.d.ts +1 -0
- package/dist/core/keybindings/__tests__/resolve.test.js +224 -0
- package/dist/core/keybindings/catalog.d.ts +14 -0
- package/dist/core/keybindings/catalog.js +256 -0
- package/dist/core/keybindings/index.d.ts +5 -0
- package/dist/core/keybindings/index.js +4 -0
- package/dist/core/keybindings/match.d.ts +29 -0
- package/dist/core/keybindings/match.js +99 -0
- package/dist/core/keybindings/persistence.d.ts +14 -0
- package/dist/core/keybindings/persistence.js +31 -0
- package/dist/core/keybindings/resolve.d.ts +14 -0
- package/dist/core/keybindings/resolve.js +305 -0
- package/dist/core/keybindings/types.d.ts +37 -0
- package/dist/core/keybindings/types.js +1 -0
- package/dist/core/memory-resolver.d.ts +1 -1
- package/dist/core/memory-resolver.js +10 -3
- package/dist/core/predicate.d.ts +5 -3
- package/dist/core/predicate.js +5 -3
- package/dist/core/runtime/broker-protocol.d.ts +33 -5
- package/dist/core/runtime/broker.js +165 -14
- package/dist/core/runtime/launch.d.ts +39 -6
- package/dist/core/runtime/launch.js +79 -21
- package/dist/core/runtime/naming.js +3 -3
- package/dist/core/runtime/pi-cli.d.ts +6 -0
- package/dist/core/runtime/pi-cli.js +16 -2
- package/dist/core/runtime/placement.d.ts +2 -2
- package/dist/core/runtime/placement.js +4 -1
- package/dist/core/runtime/promote.js +4 -0
- package/dist/core/runtime/recap.d.ts +3 -3
- package/dist/core/runtime/recap.js +47 -44
- package/dist/core/runtime/recycle.js +6 -1
- package/dist/core/runtime/reset.js +5 -0
- package/dist/core/runtime/revive.js +28 -18
- package/dist/core/runtime/session-list-cache.d.ts +23 -0
- package/dist/core/runtime/session-list-cache.js +270 -0
- package/dist/core/runtime/spawn.js +51 -32
- package/dist/core/runtime/stream-watchdog.d.ts +26 -0
- package/dist/core/runtime/stream-watchdog.js +75 -0
- package/dist/core/runtime/tmux-chrome.d.ts +1 -1
- package/dist/core/runtime/tmux-chrome.js +2 -2
- package/dist/core/runtime/tmux.d.ts +18 -15
- package/dist/core/runtime/tmux.js +216 -110
- package/dist/core/tui/__tests__/host-keybindings.test.d.ts +1 -0
- package/dist/core/tui/__tests__/host-keybindings.test.js +112 -0
- package/dist/core/tui/host.d.ts +30 -1
- package/dist/core/tui/host.js +148 -34
- package/dist/core/view/__tests__/transport-remote.test.js +9 -6
- package/dist/core/view/contract.d.ts +17 -3
- package/dist/daemon/crtrd-cli.d.ts +1 -1
- package/dist/daemon/crtrd-cli.js +1 -0
- package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +41 -0
- package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.d.ts +1 -0
- package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.js +121 -0
- package/dist/pi-extensions/canvas-inbox-watcher.d.ts +1 -1
- package/dist/pi-extensions/canvas-inbox-watcher.js +203 -25
- package/dist/pi-extensions/canvas-recap.d.ts +4 -0
- package/dist/pi-extensions/canvas-recap.js +38 -37
- package/dist/pi-extensions/canvas-stophook.d.ts +11 -0
- package/dist/pi-extensions/canvas-stophook.js +33 -4
- package/dist/suppress-experimental-warnings.d.ts +1 -0
- package/dist/suppress-experimental-warnings.js +15 -0
- package/dist/types.d.ts +3 -13
- package/dist/types.js +12 -36
- package/dist/web-client/assets/{index-CnF5r8ky.js → index--md2ylfi.js} +18 -17
- package/dist/web-client/assets/index-BUdm9s9s.css +2 -0
- package/dist/web-client/index.html +3 -3
- package/dist/web-client/sw.js +1 -1
- package/package.json +11 -8
- package/scripts/postinstall.mjs +1 -1
- package/dist/pi-extensions/canvas-nav.d.ts +0 -43
- package/dist/pi-extensions/canvas-nav.js +0 -640
- package/dist/pi-extensions/widget-order-bus.d.ts +0 -6
- package/dist/pi-extensions/widget-order-bus.js +0 -34
- package/dist/web-client/assets/index-BnmSLNLa.css +0 -2
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type FocusRow } from '../canvas/index.js';
|
|
2
2
|
export type { FocusRow };
|
|
3
|
-
export { splitWindow, breakPane, selectLayout, selectPane, focusWindow, selectWindow, switchClient, openNodeWindow, ensureSession, paneLocation, paneExists, paneRunning, paneCurrentPath, currentTmux, inTmux, installMenuBinding, setPaneOption, getPaneOption, piCommand, shellQuote, respawnPaneSync, closePane, windowOfPane, renameWindow, paneOfWindow, windowAlive, listLivePanes, } from './tmux.js';
|
|
4
|
-
export type { RespawnPaneOpts } from './tmux.js';
|
|
3
|
+
export { splitWindow, breakPane, selectLayout, selectPane, focusWindow, selectWindow, switchClient, openNodeWindow, ensureSession, paneLocation, paneExists, paneRunning, paneCurrentPath, currentTmux, inTmux, installTmuxBindings, installTmuxBindings as installMenuBinding, setPaneOption, getPaneOption, piCommand, shellQuote, respawnPaneSync, closePane, windowOfPane, renameWindow, paneOfWindow, windowAlive, listLivePanes, } from './tmux.js';
|
|
4
|
+
export type { RespawnPaneOpts, TmuxInstalledPair, TmuxInstallDiagnostic, TmuxInstallResult } from './tmux.js';
|
|
5
5
|
/** The viewer a node has on screen, or null. UNIQUE(node_id) ⇒ at most one. */
|
|
6
6
|
export declare function focusOf(nodeId: string): FocusRow | null;
|
|
7
7
|
/** Does this node have a live viewer on screen? */
|
|
@@ -48,7 +48,10 @@ import { isPidAlive } from '../canvas/pid.js';
|
|
|
48
48
|
// re-exported for the same reason — callers building the seam's expected
|
|
49
49
|
// bundled-pi command string (e.g. the M1 seam test) need the identical quoting
|
|
50
50
|
// piCommand uses internally, without importing tmux.ts directly.
|
|
51
|
-
export { splitWindow, breakPane, selectLayout, selectPane, focusWindow, selectWindow, switchClient, openNodeWindow, ensureSession, paneLocation, paneExists, paneRunning, paneCurrentPath, currentTmux, inTmux,
|
|
51
|
+
export { splitWindow, breakPane, selectLayout, selectPane, focusWindow, selectWindow, switchClient, openNodeWindow, ensureSession, paneLocation, paneExists, paneRunning, paneCurrentPath, currentTmux, inTmux, installTmuxBindings,
|
|
52
|
+
// Compile seam for attach-cmd.ts, which still calls installMenuBinding();
|
|
53
|
+
// deleted when the attach unit imports installTmuxBindings directly.
|
|
54
|
+
installTmuxBindings as installMenuBinding, setPaneOption, getPaneOption, piCommand, shellQuote, respawnPaneSync, closePane, windowOfPane, renameWindow, paneOfWindow, windowAlive, listLivePanes, } from './tmux.js';
|
|
52
55
|
// ---------------------------------------------------------------------------
|
|
53
56
|
// Viewer registry reads — COMPOSE over the canvas focuses table (§2.3/§4).
|
|
54
57
|
//
|
|
@@ -55,6 +55,10 @@ export function promote(nodeId, opts = {}) {
|
|
|
55
55
|
// across the polymorph (the persona default is recomputed fresh for
|
|
56
56
|
// targetKind).
|
|
57
57
|
model: targetModel,
|
|
58
|
+
// Resolve kind/ladder config against the NODE's own cwd + profile, not
|
|
59
|
+
// this process's ambient scope.
|
|
60
|
+
cwd: node.cwd,
|
|
61
|
+
profileId: node.profile_id,
|
|
58
62
|
});
|
|
59
63
|
// Seed a barebones roadmap scaffold if absent so the file exists for a
|
|
60
64
|
// refresh. Pre-fill its Goal from the node's goal doc when present (set at
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
/** Ask pi headlessly for a
|
|
2
|
-
* `onRecap` with the parsed
|
|
1
|
+
/** Ask pi headlessly for a concise briefing of `conversation`, async. Invokes
|
|
2
|
+
* `onRecap` with the parsed string on success, or never (silent) on any
|
|
3
3
|
* failure — non-zero exit, timeout, empty/garbled output. Owns the subprocess
|
|
4
4
|
* mechanics — crucially it hands pi an immediate stdin EOF: `pi -p` reads stdin,
|
|
5
5
|
* and execFile's default stdin is an OPEN pipe that never closes, so without
|
|
6
6
|
* this pi blocks waiting for EOF and the call exits non-zero (the same gotcha
|
|
7
7
|
* naming.ts documents). */
|
|
8
|
-
export declare function generateRecap(conversation: string, onRecap: (
|
|
8
|
+
export declare function generateRecap(conversation: string, onRecap: (briefing: string) => void, liveModel?: string): void;
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
// Inactivity recap — turn a dormant node's conversation into
|
|
2
|
-
//
|
|
1
|
+
// Inactivity recap — turn a dormant node's conversation into one concise
|
|
2
|
+
// returning-developer briefing for the recap widget.
|
|
3
3
|
//
|
|
4
4
|
// One entry point: generateRecap — async (execFile, non-blocking), mirroring
|
|
5
|
-
// naming.ts's headless-`pi -p` pattern exactly. It runs
|
|
6
|
-
// back-and-forth (the caller concatenates user
|
|
5
|
+
// naming.ts's headless-`pi -p` pattern exactly. It runs the live conversation's
|
|
6
|
+
// working model over the literal back-and-forth (the caller concatenates user and
|
|
7
|
+
// assistant text) with a stripped-
|
|
7
8
|
// down pi invocation (no tools/session/context/extensions/skills), so it's fast
|
|
8
9
|
// and side-effect free and can never recurse into another spawn.
|
|
9
10
|
//
|
|
@@ -15,37 +16,48 @@
|
|
|
15
16
|
// Plain TS-with-types — no imports from @earendil-works/* so it compiles inside
|
|
16
17
|
// crouter's own tsc build without a dep on the pi packages.
|
|
17
18
|
import { execFile } from 'node:child_process';
|
|
18
|
-
import { resolveBundledPiCliPath } from './pi-cli.js';
|
|
19
|
+
import { bundledPiSubprocessEnv, resolveBundledPiCliPath } from './pi-cli.js';
|
|
19
20
|
/** Wall-clock budget for the headless pi call before we give up (no recap). */
|
|
20
21
|
const RECAP_TIMEOUT_MS = 25_000;
|
|
21
|
-
/**
|
|
22
|
-
* the
|
|
23
|
-
*
|
|
22
|
+
/** Fallback model when the live session has not resolved one yet. Ordinarily the
|
|
23
|
+
* caller passes the provider/model that just completed the conversation's last
|
|
24
|
+
* turn, avoiding a dead hard-pinned provider. CRTR_RECAP_MODEL overrides both. */
|
|
24
25
|
const DEFAULT_RECAP_MODEL = 'anthropic/claude-haiku-4-5';
|
|
25
|
-
const RECAP_SYSTEM_PROMPT = 'You
|
|
26
|
-
'
|
|
27
|
-
'
|
|
28
|
-
'
|
|
29
|
-
'
|
|
30
|
-
'
|
|
31
|
-
'
|
|
32
|
-
'
|
|
33
|
-
'
|
|
34
|
-
'
|
|
35
|
-
'
|
|
36
|
-
'
|
|
26
|
+
const RECAP_SYSTEM_PROMPT = 'You brief a developer returning to a paused coding-agent conversation. They run ' +
|
|
27
|
+
'many agent sessions in parallel and have lost this one\'s thread; the brief is ' +
|
|
28
|
+
'pinned above their input box and must re-orient them in seconds, without ' +
|
|
29
|
+
're-reading the transcript. You are given the literal user/agent back-and-forth ' +
|
|
30
|
+
'(tool output omitted, most recent last).\n' +
|
|
31
|
+
'\n' +
|
|
32
|
+
'Output EXACTLY one concise plain-text briefing: no labels, numbering, bullets, ' +
|
|
33
|
+
'or line breaks. In one or two sentences, explain the overall goal, what has ' +
|
|
34
|
+
'actually happened, and the current next move or blocker. If the agent is ' +
|
|
35
|
+
'waiting on the developer, make the needed answer clear. Be concrete: use the ' +
|
|
36
|
+
'actual file names, commands, branches, option labels, and error messages from ' +
|
|
37
|
+
'the conversation — a developer re-orients on specifics, never abstractions. ' +
|
|
38
|
+
'Weight the end of the conversation, never narrate ("the user asked…"), hedge, ' +
|
|
39
|
+
'or pad. If the conversation has barely started, still emit your best briefing. ' +
|
|
40
|
+
'Output JUST the briefing.';
|
|
37
41
|
/** Put the conversation FIRST in a delimited block, then the instruction, so the
|
|
38
42
|
* model reads the content before being told what to do. */
|
|
39
43
|
function recapUserPrompt(conversation) {
|
|
40
|
-
return `<conversation>\n${conversation}\n</conversation>\n\nWrite the
|
|
44
|
+
return `<conversation>\n${conversation}\n</conversation>\n\nWrite the concise returning-developer briefing for the conversation above. Output JUST the briefing.`;
|
|
41
45
|
}
|
|
42
46
|
/** The pi argv for a headless recap request. Stripped down (no tools, session,
|
|
43
47
|
* context files, extensions, skills, templates, themes) so it's fast and
|
|
44
|
-
* side-effect free.
|
|
45
|
-
*
|
|
46
|
-
function recapArgs(conversation) {
|
|
48
|
+
* side-effect free. Thinking stays off; model precedence is explicit env
|
|
49
|
+
* override → the conversation's live working model → the small fallback. */
|
|
50
|
+
function recapArgs(conversation, liveModel) {
|
|
47
51
|
const override = process.env['CRTR_RECAP_MODEL'];
|
|
48
|
-
|
|
52
|
+
// An explicit operator override wins. Otherwise prefer the live conversation's
|
|
53
|
+
// current model: it just completed a successful turn, while a hard-pinned
|
|
54
|
+
// provider (the old Haiku-only path) may be rate-limited and silently produce
|
|
55
|
+
// no brief. The small default remains for sessions with no resolved model yet.
|
|
56
|
+
const model = override !== undefined && override.trim() !== ''
|
|
57
|
+
? override.trim()
|
|
58
|
+
: liveModel !== undefined && liveModel.trim() !== ''
|
|
59
|
+
? liveModel.trim()
|
|
60
|
+
: DEFAULT_RECAP_MODEL;
|
|
49
61
|
const argv = [
|
|
50
62
|
'-p',
|
|
51
63
|
'--no-session',
|
|
@@ -63,40 +75,31 @@ function recapArgs(conversation) {
|
|
|
63
75
|
];
|
|
64
76
|
return argv;
|
|
65
77
|
}
|
|
66
|
-
/**
|
|
67
|
-
*
|
|
68
|
-
* drops empties, and hard-caps line length so a runaway line can't blow up the
|
|
69
|
-
* widget. Returns [] when nothing usable survives (→ no recap). */
|
|
78
|
+
/** Normalize model stdout to one display string. Newlines are whitespace rather
|
|
79
|
+
* than separate fields; the viewer wraps this string to its available width. */
|
|
70
80
|
function parseRecap(stdout) {
|
|
71
|
-
|
|
72
|
-
const lines = stdout
|
|
73
|
-
.split('\n')
|
|
74
|
-
.map((l) => l.trim())
|
|
75
|
-
// Strip a leading `goal:` / `now:` / `next:` / `- ` / `1.` style prefix.
|
|
76
|
-
.map((l) => l.replace(/^(?:[-*•]\s*)?(?:\d+[.)]\s*)?(?:goal|now|doing(?:[\s-]now)?|next)\s*[:\-]\s*/i, '').trim())
|
|
77
|
-
.filter((l) => l !== '');
|
|
78
|
-
return lines.slice(0, 3).map((l) => (l.length > LINE_CAP ? l.slice(0, LINE_CAP) : l));
|
|
81
|
+
return stdout.replace(/\s+/g, ' ').trim();
|
|
79
82
|
}
|
|
80
|
-
/** Ask pi headlessly for a
|
|
81
|
-
* `onRecap` with the parsed
|
|
83
|
+
/** Ask pi headlessly for a concise briefing of `conversation`, async. Invokes
|
|
84
|
+
* `onRecap` with the parsed string on success, or never (silent) on any
|
|
82
85
|
* failure — non-zero exit, timeout, empty/garbled output. Owns the subprocess
|
|
83
86
|
* mechanics — crucially it hands pi an immediate stdin EOF: `pi -p` reads stdin,
|
|
84
87
|
* and execFile's default stdin is an OPEN pipe that never closes, so without
|
|
85
88
|
* this pi blocks waiting for EOF and the call exits non-zero (the same gotcha
|
|
86
89
|
* naming.ts documents). */
|
|
87
|
-
export function generateRecap(conversation, onRecap) {
|
|
90
|
+
export function generateRecap(conversation, onRecap, liveModel) {
|
|
88
91
|
const body = (conversation ?? '').trim();
|
|
89
92
|
if (body === '')
|
|
90
93
|
return;
|
|
91
94
|
try {
|
|
92
|
-
const child = execFile(process.execPath, [resolveBundledPiCliPath(), ...recapArgs(body)], { encoding: 'utf8', timeout: RECAP_TIMEOUT_MS }, (err, stdout) => {
|
|
95
|
+
const child = execFile(process.execPath, [resolveBundledPiCliPath(), ...recapArgs(body, liveModel)], { encoding: 'utf8', timeout: RECAP_TIMEOUT_MS, env: bundledPiSubprocessEnv() }, (err, stdout) => {
|
|
93
96
|
if (err || typeof stdout !== 'string')
|
|
94
97
|
return; // silent — no recap
|
|
95
|
-
const
|
|
96
|
-
if (
|
|
98
|
+
const briefing = parseRecap(stdout);
|
|
99
|
+
if (briefing === '')
|
|
97
100
|
return;
|
|
98
101
|
try {
|
|
99
|
-
onRecap(
|
|
102
|
+
onRecap(briefing);
|
|
100
103
|
}
|
|
101
104
|
catch { /* best-effort */ }
|
|
102
105
|
});
|
|
@@ -97,7 +97,11 @@ export async function recycleNode(nodeId, callerPane) {
|
|
|
97
97
|
ensureDaemon();
|
|
98
98
|
}
|
|
99
99
|
catch { /* daemon is best-effort */ }
|
|
100
|
-
|
|
100
|
+
// Resolve against the RECYCLED node's cwd + profile (not this process's
|
|
101
|
+
// ambient scope) and carry the profile onto the fresh root — it is the same
|
|
102
|
+
// person's seat, so profile-scope defaults (e.g. a persisted /model default)
|
|
103
|
+
// must keep applying.
|
|
104
|
+
const { launch } = buildLaunchSpec('general', 'base', { lifecycle: 'resident', hasManager: false, cwd: meta.cwd, profileId: meta.profile_id });
|
|
101
105
|
const root = spawnNode({
|
|
102
106
|
kind: 'general',
|
|
103
107
|
mode: 'base',
|
|
@@ -105,6 +109,7 @@ export async function recycleNode(nodeId, callerPane) {
|
|
|
105
109
|
cwd: meta.cwd,
|
|
106
110
|
name: 'general',
|
|
107
111
|
parent: null,
|
|
112
|
+
profile_id: meta.profile_id,
|
|
108
113
|
launch,
|
|
109
114
|
});
|
|
110
115
|
// Hand the viewport to the fresh root: reuse M's focus row over the SAME pane
|
|
@@ -86,6 +86,10 @@ export async function relaunchRoot(oldId, deps = {}) {
|
|
|
86
86
|
lifecycle: 'resident',
|
|
87
87
|
hasManager: false,
|
|
88
88
|
model: old.model_override ?? undefined,
|
|
89
|
+
// The OLD root's cwd + profile, not this process's ambient scope — the
|
|
90
|
+
// relaunch is the same seat, so profile-scope kind defaults keep applying.
|
|
91
|
+
cwd: old.cwd,
|
|
92
|
+
profileId: old.profile_id,
|
|
89
93
|
});
|
|
90
94
|
const newMeta = spawnNode({
|
|
91
95
|
kind: old.kind,
|
|
@@ -94,6 +98,7 @@ export async function relaunchRoot(oldId, deps = {}) {
|
|
|
94
98
|
cwd: old.cwd,
|
|
95
99
|
name: old.kind,
|
|
96
100
|
parent: null,
|
|
101
|
+
profile_id: old.profile_id,
|
|
97
102
|
launch,
|
|
98
103
|
modelOverride: old.model_override ?? undefined,
|
|
99
104
|
});
|
|
@@ -29,7 +29,7 @@ import { fanDoctrineWake } from './close.js';
|
|
|
29
29
|
import { buildPiArgv } from './launch.js';
|
|
30
30
|
import { buildReviveKickoff, drainBearings } from './kickoff.js';
|
|
31
31
|
import { headlessBrokerHost } from './host.js';
|
|
32
|
-
import {
|
|
32
|
+
import { isRecordedPidAlive } from '../canvas/pid.js';
|
|
33
33
|
import { reconcileBootLiveness } from '../canvas/boot.js';
|
|
34
34
|
import { clearFault } from './fault.js';
|
|
35
35
|
import { clearInjectedDocs } from '../substrate/injected-store.js';
|
|
@@ -118,24 +118,34 @@ export function reviveNode(nodeId, opts) {
|
|
|
118
118
|
const db = openDb();
|
|
119
119
|
db.exec('BEGIN IMMEDIATE');
|
|
120
120
|
try {
|
|
121
|
-
// Double-revive guard: identity-aware liveness on pi_pid
|
|
122
|
-
// CONFIRMED
|
|
123
|
-
// whose broker pid is
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
121
|
+
// Double-revive guard: identity-aware liveness on pi_pid. Only a
|
|
122
|
+
// CONFIRMED-dead read proceeds to relaunch; `alive` AND `indeterminate`
|
|
123
|
+
// both no-op. A node whose broker pid is still running as that same broker
|
|
124
|
+
// was already revived by another path — re-launching would put a SECOND
|
|
125
|
+
// broker on the same session file.
|
|
126
|
+
//
|
|
127
|
+
// Why `indeterminate` must NOT relaunch (live incident, 2026-07-10, 4GB
|
|
128
|
+
// Hearth home): the `dead` verdict comes from the fork-free signal-0
|
|
129
|
+
// syscall (`isPidAlive`), which cannot fail under resource pressure — a
|
|
130
|
+
// truly dead broker ALWAYS reads `dead`. `indeterminate` therefore means
|
|
131
|
+
// "the pid EXISTS but the `ps` identity probe failed" — and the probe is a
|
|
132
|
+
// fork+exec, so it fails exactly when the host is out of memory (ENOMEM)
|
|
133
|
+
// or thrashing (>2s timeout). Those failures are CORRELATED and
|
|
134
|
+
// persistent, not transient: under sustained OOM every guard read went
|
|
135
|
+
// `indeterminate`, every daemon grace window relaunched a broker whose
|
|
136
|
+
// predecessor was still alive, and `recordPid` below overwrote the row's
|
|
137
|
+
// pid — leaking each older broker untracked. That compounded to 7 live
|
|
138
|
+
// engines on one node (35 total on the home), which itself sustained the
|
|
139
|
+
// OOM that kept the probes failing. Relaunching on uncertainty is
|
|
140
|
+
// self-amplifying in precisely the regime where uncertainty occurs.
|
|
141
|
+
//
|
|
142
|
+
// The cost of no-opping on `indeterminate` is bounded and small: the only
|
|
143
|
+
// dead-but-indeterminate cases are a zombie or a reused pid whose `ps`
|
|
144
|
+
// probe failed — both re-read next daemon tick, and read `dead` the
|
|
145
|
+
// moment a probe succeeds. `isRecordedPidAlive` is the exact adapter for
|
|
146
|
+
// this direction (false ONLY on confirmed `dead`).
|
|
137
147
|
const live = getNode(nodeId) ?? meta;
|
|
138
|
-
if (
|
|
148
|
+
if (isRecordedPidAlive(live.pi_pid, live.pi_pid_identity)) {
|
|
139
149
|
return {
|
|
140
150
|
window: null,
|
|
141
151
|
session: live.tmux_session ?? null,
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { WireSessionInfo } from './broker-protocol.js';
|
|
2
|
+
export declare function compactSessionSearchText(text: string): string;
|
|
3
|
+
/** In-memory + file-backed cache keyed by absolute `.jsonl` path. One instance
|
|
4
|
+
* per broker; the file is shared across brokers (last-writer-wins via atomic
|
|
5
|
+
* rename — a lost update just costs one file's rebuild next open, never
|
|
6
|
+
* corruption). */
|
|
7
|
+
export declare class SessionListCache {
|
|
8
|
+
private readonly cacheFile;
|
|
9
|
+
private cache;
|
|
10
|
+
private loaded;
|
|
11
|
+
private persisting;
|
|
12
|
+
private persistAgain;
|
|
13
|
+
constructor(cacheFile: string);
|
|
14
|
+
/** Sessions for one cwd's session dir (the picker's default `cwd` scope). */
|
|
15
|
+
listDir(sessionDir: string): Promise<WireSessionInfo[]>;
|
|
16
|
+
/** Sessions across every cwd's dir under the sessions root (`all` scope). */
|
|
17
|
+
listAll(sessionsRoot: string): Promise<WireSessionInfo[]>;
|
|
18
|
+
private build;
|
|
19
|
+
private ensureLoaded;
|
|
20
|
+
/** Atomic write (temp + rename) so a concurrent reader never sees a partial
|
|
21
|
+
* file. Coalesces overlapping writes into one trailing flush. */
|
|
22
|
+
private persist;
|
|
23
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// Broker-side cache for the `/resume` session picker's `list_sessions` reply.
|
|
2
|
+
//
|
|
3
|
+
// pi's `SessionManager.list()` reads EVERY `.jsonl` in a cwd's session dir in
|
|
4
|
+
// full, line-by-line, to build each session's `allMessagesText` search field.
|
|
5
|
+
// On a large corpus (3.3k files / 1.1 GiB here) that is ~3.6s of CPU-bound
|
|
6
|
+
// JSON.parse on EVERY picker open — pi exposes no per-file entry point and no
|
|
7
|
+
// cache. This module keeps a `(mtimeMs, size)`-keyed cache of the already-wire-
|
|
8
|
+
// shaped, search-text-bounded `WireSessionInfo`: on each open it stats every
|
|
9
|
+
// file (cheap — ~260ms for 3.3k) and rebuilds info ONLY for new/changed files,
|
|
10
|
+
// reusing cached entries for the rest and dropping deleted ones. The cache is
|
|
11
|
+
// persisted to one global JSON file so the first open after a broker restart is
|
|
12
|
+
// also fast.
|
|
13
|
+
//
|
|
14
|
+
// `buildWireSessionInfo` below is a faithful mirror of pi's private
|
|
15
|
+
// `buildSessionInfo` (node_modules/@earendil-works/pi-coding-agent/dist/core/
|
|
16
|
+
// session-manager.js) — it is not exported, so the only way to rebuild a single
|
|
17
|
+
// file's info without re-reading the whole corpus is to reproduce its parse.
|
|
18
|
+
// Keep it in sync with pi's session-file format (CURRENT_SESSION_VERSION).
|
|
19
|
+
import { createReadStream } from 'node:fs';
|
|
20
|
+
import { readFile, readdir, rename, stat, writeFile } from 'node:fs/promises';
|
|
21
|
+
import { createInterface } from 'node:readline';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
/** Search-text bound: `allMessagesText` is searched on every query edit; pi's
|
|
24
|
+
* unbounded value retains gigabytes in the viewer and turns key-repeat into GC
|
|
25
|
+
* jitter. The opening and latest text keep search useful. */
|
|
26
|
+
const SESSION_SEARCH_TEXT_CHARS = 1024;
|
|
27
|
+
export function compactSessionSearchText(text) {
|
|
28
|
+
if (text.length <= SESSION_SEARCH_TEXT_CHARS)
|
|
29
|
+
return text;
|
|
30
|
+
const half = SESSION_SEARCH_TEXT_CHARS / 2;
|
|
31
|
+
return `${text.slice(0, half)} … ${text.slice(-half)}`;
|
|
32
|
+
}
|
|
33
|
+
const MAX_CONCURRENT_INFO_LOADS = 10;
|
|
34
|
+
// ---- faithful mirror of pi's private buildSessionInfo -----------------------
|
|
35
|
+
function parseLine(line) {
|
|
36
|
+
if (!line.trim())
|
|
37
|
+
return null;
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(line);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function extractTextContent(message) {
|
|
46
|
+
const content = message['content'];
|
|
47
|
+
if (typeof content === 'string')
|
|
48
|
+
return content;
|
|
49
|
+
if (!Array.isArray(content))
|
|
50
|
+
return '';
|
|
51
|
+
return content
|
|
52
|
+
.filter((b) => b.type === 'text')
|
|
53
|
+
.map((b) => b.text)
|
|
54
|
+
.join(' ');
|
|
55
|
+
}
|
|
56
|
+
function messageActivityTime(entry) {
|
|
57
|
+
const message = entry['message'];
|
|
58
|
+
if (!message || !('content' in message))
|
|
59
|
+
return undefined;
|
|
60
|
+
const role = message['role'];
|
|
61
|
+
if (role !== 'user' && role !== 'assistant')
|
|
62
|
+
return undefined;
|
|
63
|
+
const ts = message['timestamp'];
|
|
64
|
+
if (typeof ts === 'number')
|
|
65
|
+
return ts;
|
|
66
|
+
const t = new Date(entry['timestamp']).getTime();
|
|
67
|
+
return Number.isNaN(t) ? undefined : t;
|
|
68
|
+
}
|
|
69
|
+
/** Build one session's wire info by streaming the file. Returns null for a
|
|
70
|
+
* malformed/headerless file (matches pi, which drops those from the list). */
|
|
71
|
+
async function buildWireSessionInfo(filePath) {
|
|
72
|
+
try {
|
|
73
|
+
const stats = await stat(filePath);
|
|
74
|
+
let header = null;
|
|
75
|
+
let messageCount = 0;
|
|
76
|
+
let firstMessage = '';
|
|
77
|
+
const allMessages = [];
|
|
78
|
+
let name;
|
|
79
|
+
let lastActivityTime;
|
|
80
|
+
const rl = createInterface({
|
|
81
|
+
input: createReadStream(filePath, { encoding: 'utf8' }),
|
|
82
|
+
crlfDelay: Infinity,
|
|
83
|
+
});
|
|
84
|
+
for await (const line of rl) {
|
|
85
|
+
const entry = parseLine(line);
|
|
86
|
+
if (!entry)
|
|
87
|
+
continue;
|
|
88
|
+
if (!header) {
|
|
89
|
+
if (entry['type'] !== 'session')
|
|
90
|
+
return null;
|
|
91
|
+
header = entry;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (entry['type'] === 'session_info') {
|
|
95
|
+
name = entry['name']?.trim() || undefined;
|
|
96
|
+
}
|
|
97
|
+
if (entry['type'] !== 'message')
|
|
98
|
+
continue;
|
|
99
|
+
messageCount++;
|
|
100
|
+
const activityTime = messageActivityTime(entry);
|
|
101
|
+
if (typeof activityTime === 'number') {
|
|
102
|
+
lastActivityTime = Math.max(lastActivityTime ?? 0, activityTime);
|
|
103
|
+
}
|
|
104
|
+
const message = entry['message'];
|
|
105
|
+
if (!message || !('content' in message))
|
|
106
|
+
continue;
|
|
107
|
+
const role = message['role'];
|
|
108
|
+
if (role !== 'user' && role !== 'assistant')
|
|
109
|
+
continue;
|
|
110
|
+
const textContent = extractTextContent(message);
|
|
111
|
+
if (!textContent)
|
|
112
|
+
continue;
|
|
113
|
+
allMessages.push(textContent);
|
|
114
|
+
if (!firstMessage && role === 'user')
|
|
115
|
+
firstMessage = textContent;
|
|
116
|
+
}
|
|
117
|
+
if (!header)
|
|
118
|
+
return null;
|
|
119
|
+
const cwd = typeof header['cwd'] === 'string' ? header['cwd'] : '';
|
|
120
|
+
const headerTime = typeof header['timestamp'] === 'string' ? new Date(header['timestamp']).getTime() : NaN;
|
|
121
|
+
const modified = typeof lastActivityTime === 'number' && lastActivityTime > 0
|
|
122
|
+
? new Date(lastActivityTime)
|
|
123
|
+
: !Number.isNaN(headerTime)
|
|
124
|
+
? new Date(headerTime)
|
|
125
|
+
: stats.mtime;
|
|
126
|
+
return {
|
|
127
|
+
path: filePath,
|
|
128
|
+
id: header['id'],
|
|
129
|
+
cwd,
|
|
130
|
+
name,
|
|
131
|
+
parentSessionPath: header['parentSession'],
|
|
132
|
+
created: new Date(header['timestamp']).toISOString(),
|
|
133
|
+
modified: modified.toISOString(),
|
|
134
|
+
messageCount,
|
|
135
|
+
firstMessage: firstMessage || '(no messages)',
|
|
136
|
+
// Cache the already-bounded wire form (the selector searches this field).
|
|
137
|
+
allMessagesText: compactSessionSearchText(allMessages.join(' ')),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// ---- the cache ---------------------------------------------------------------
|
|
145
|
+
/** In-memory + file-backed cache keyed by absolute `.jsonl` path. One instance
|
|
146
|
+
* per broker; the file is shared across brokers (last-writer-wins via atomic
|
|
147
|
+
* rename — a lost update just costs one file's rebuild next open, never
|
|
148
|
+
* corruption). */
|
|
149
|
+
export class SessionListCache {
|
|
150
|
+
cacheFile;
|
|
151
|
+
cache = new Map();
|
|
152
|
+
loaded = false;
|
|
153
|
+
persisting = false;
|
|
154
|
+
persistAgain = false;
|
|
155
|
+
constructor(cacheFile) {
|
|
156
|
+
this.cacheFile = cacheFile;
|
|
157
|
+
}
|
|
158
|
+
/** Sessions for one cwd's session dir (the picker's default `cwd` scope). */
|
|
159
|
+
async listDir(sessionDir) {
|
|
160
|
+
return this.build(await jsonlFiles(sessionDir));
|
|
161
|
+
}
|
|
162
|
+
/** Sessions across every cwd's dir under the sessions root (`all` scope). */
|
|
163
|
+
async listAll(sessionsRoot) {
|
|
164
|
+
let dirEntries;
|
|
165
|
+
try {
|
|
166
|
+
dirEntries = (await readdir(sessionsRoot, { withFileTypes: true }))
|
|
167
|
+
.filter((e) => e.isDirectory())
|
|
168
|
+
.map((e) => join(sessionsRoot, e.name));
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return [];
|
|
172
|
+
}
|
|
173
|
+
const files = [];
|
|
174
|
+
for (const dir of dirEntries)
|
|
175
|
+
files.push(...(await jsonlFiles(dir)));
|
|
176
|
+
return this.build(files);
|
|
177
|
+
}
|
|
178
|
+
async build(files) {
|
|
179
|
+
await this.ensureLoaded();
|
|
180
|
+
const present = new Set(files);
|
|
181
|
+
for (const key of this.cache.keys()) {
|
|
182
|
+
if (!present.has(key))
|
|
183
|
+
this.cache.delete(key);
|
|
184
|
+
}
|
|
185
|
+
const infos = [];
|
|
186
|
+
let changed = false;
|
|
187
|
+
let next = 0;
|
|
188
|
+
const worker = async () => {
|
|
189
|
+
while (next < files.length) {
|
|
190
|
+
const file = files[next++];
|
|
191
|
+
if (file === undefined)
|
|
192
|
+
return;
|
|
193
|
+
const st = await stat(file).catch(() => null);
|
|
194
|
+
if (!st) {
|
|
195
|
+
this.cache.delete(file);
|
|
196
|
+
changed = true;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
const cached = this.cache.get(file);
|
|
200
|
+
if (cached && cached.mtimeMs === st.mtimeMs && cached.size === st.size) {
|
|
201
|
+
infos.push(cached.info);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
const info = await buildWireSessionInfo(file);
|
|
205
|
+
changed = true;
|
|
206
|
+
if (!info) {
|
|
207
|
+
this.cache.delete(file);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
this.cache.set(file, { mtimeMs: st.mtimeMs, size: st.size, info });
|
|
211
|
+
infos.push(info);
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
await Promise.all(Array.from({ length: Math.min(MAX_CONCURRENT_INFO_LOADS, files.length) }, () => worker()));
|
|
215
|
+
if (changed)
|
|
216
|
+
void this.persist();
|
|
217
|
+
infos.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime());
|
|
218
|
+
return infos;
|
|
219
|
+
}
|
|
220
|
+
async ensureLoaded() {
|
|
221
|
+
if (this.loaded)
|
|
222
|
+
return;
|
|
223
|
+
this.loaded = true;
|
|
224
|
+
try {
|
|
225
|
+
const raw = JSON.parse(await readFile(this.cacheFile, 'utf8'));
|
|
226
|
+
for (const [path, entry] of Object.entries(raw))
|
|
227
|
+
this.cache.set(path, entry);
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
// Missing or corrupt cache file → start empty and rebuild. No lenient
|
|
231
|
+
// multi-path recovery: the cache is derivable, so a bad file is just
|
|
232
|
+
// overwritten on the next persist.
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/** Atomic write (temp + rename) so a concurrent reader never sees a partial
|
|
236
|
+
* file. Coalesces overlapping writes into one trailing flush. */
|
|
237
|
+
async persist() {
|
|
238
|
+
if (this.persisting) {
|
|
239
|
+
this.persistAgain = true;
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
this.persisting = true;
|
|
243
|
+
try {
|
|
244
|
+
const obj = {};
|
|
245
|
+
for (const [path, entry] of this.cache)
|
|
246
|
+
obj[path] = entry;
|
|
247
|
+
const tmp = `${this.cacheFile}.${process.pid}.tmp`;
|
|
248
|
+
await writeFile(tmp, JSON.stringify(obj));
|
|
249
|
+
await rename(tmp, this.cacheFile);
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
// Best-effort: a failed persist only costs a rebuild next open.
|
|
253
|
+
}
|
|
254
|
+
finally {
|
|
255
|
+
this.persisting = false;
|
|
256
|
+
if (this.persistAgain) {
|
|
257
|
+
this.persistAgain = false;
|
|
258
|
+
void this.persist();
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
async function jsonlFiles(dir) {
|
|
264
|
+
try {
|
|
265
|
+
return (await readdir(dir)).filter((f) => f.endsWith('.jsonl')).map((f) => join(dir, f));
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
return [];
|
|
269
|
+
}
|
|
270
|
+
}
|