@crouton-kit/crouter 0.3.26 → 0.3.28
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/builtin-memory/internal/INDEX.md +1 -1
- package/dist/builtin-personas/runtime-base.md +3 -0
- package/dist/clients/attach/__tests__/reconnect-giveup.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/reconnect-giveup.test.js +30 -0
- package/dist/clients/attach/attach-cmd.js +187 -19
- package/dist/clients/attach/canvas-panels.d.ts +10 -0
- package/dist/clients/attach/canvas-panels.js +50 -0
- package/dist/clients/attach/graph-overlay.d.ts +34 -0
- package/dist/clients/attach/graph-overlay.js +266 -0
- package/dist/clients/attach/input-controller.d.ts +6 -0
- package/dist/clients/attach/input-controller.js +2 -0
- package/dist/clients/attach/slash-commands.d.ts +22 -1
- package/dist/clients/attach/slash-commands.js +160 -3
- package/dist/clients/attach/view-socket.d.ts +19 -1
- package/dist/clients/attach/view-socket.js +61 -6
- package/dist/commands/human/prompts.js +3 -3
- package/dist/commands/human/queue.d.ts +17 -0
- package/dist/commands/human/queue.js +111 -4
- package/dist/commands/memory/__tests__/lint-schema.test.js +24 -1
- package/dist/commands/memory/lint.d.ts +5 -4
- package/dist/commands/memory/lint.js +9 -5
- package/dist/commands/memory/write.js +19 -2
- package/dist/commands/memory.js +1 -1
- package/dist/commands/sys/feedback.d.ts +1 -0
- package/dist/commands/sys/feedback.js +163 -0
- package/dist/commands/sys.js +3 -2
- package/dist/core/__tests__/broker-snapshot-history.test.d.ts +1 -0
- package/dist/core/__tests__/broker-snapshot-history.test.js +105 -0
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +7 -0
- package/dist/core/__tests__/fixtures/fake-engine.js +10 -0
- package/dist/core/__tests__/full/placement-teardown.test.js +76 -0
- package/dist/core/__tests__/human-stranded-deliver.test.d.ts +1 -0
- package/dist/core/__tests__/human-stranded-deliver.test.js +108 -0
- package/dist/core/__tests__/on-read-dedup-resume.test.d.ts +1 -0
- package/dist/core/__tests__/on-read-dedup-resume.test.js +81 -0
- package/dist/core/canvas/nav-model.d.ts +162 -0
- package/dist/core/canvas/nav-model.js +486 -0
- package/dist/core/canvas/paths.d.ts +7 -0
- package/dist/core/canvas/paths.js +9 -0
- package/dist/core/runtime/broker-sdk.d.ts +0 -12
- package/dist/core/runtime/broker-sdk.js +77 -6
- package/dist/core/runtime/broker.d.ts +2 -1
- package/dist/core/runtime/broker.js +26 -1
- package/dist/core/runtime/front-door.js +23 -8
- package/dist/core/runtime/naming.d.ts +1 -5
- package/dist/core/runtime/naming.js +33 -49
- package/dist/core/runtime/placement.d.ts +7 -6
- package/dist/core/runtime/placement.js +24 -12
- package/dist/core/runtime/revive.js +9 -0
- package/dist/core/runtime/spawn.d.ts +5 -0
- package/dist/core/runtime/spawn.js +69 -11
- package/dist/core/runtime/tmux.d.ts +9 -0
- package/dist/core/runtime/tmux.js +12 -0
- package/dist/core/spawn.d.ts +14 -0
- package/dist/core/spawn.js +29 -9
- package/dist/core/substrate/index.d.ts +1 -1
- package/dist/core/substrate/index.js +6 -6
- package/dist/core/substrate/injected-store.d.ts +10 -0
- package/dist/core/substrate/injected-store.js +55 -0
- package/dist/core/substrate/schema.d.ts +6 -8
- package/dist/core/substrate/schema.js +26 -28
- package/dist/pi-extensions/canvas-doc-substrate.js +16 -7
- package/dist/pi-extensions/canvas-goal-capture.js +38 -22
- package/dist/pi-extensions/canvas-nav.js +30 -385
- package/dist/pi-extensions/canvas-stophook.d.ts +1 -1
- package/dist/pi-extensions/canvas-stophook.js +32 -2
- package/package.json +1 -1
- package/dist/builtin-memory/memory-authoring.md +0 -100
|
@@ -310,7 +310,7 @@ export async function runBroker(nodeId) {
|
|
|
310
310
|
broadcast({ type: 'control_changed', controller_id: controllerId });
|
|
311
311
|
};
|
|
312
312
|
const buildSnapshot = () => ({
|
|
313
|
-
messages: session
|
|
313
|
+
messages: snapshotMessages(session),
|
|
314
314
|
stats: session.getSessionStats(),
|
|
315
315
|
state: {
|
|
316
316
|
sessionId: session.sessionId,
|
|
@@ -907,6 +907,31 @@ export async function runBroker(nodeId) {
|
|
|
907
907
|
}
|
|
908
908
|
}
|
|
909
909
|
// ---------------------------------------------------------------------------
|
|
910
|
+
// snapshotMessages — the catch-up snapshot's ordered message history.
|
|
911
|
+
//
|
|
912
|
+
// The broker is the SOLE writer of the node's session `.jsonl`, so the session
|
|
913
|
+
// manager's persisted tree (`buildSessionContext`) is the canonical, complete,
|
|
914
|
+
// ordered history — byte-identical to what a dormant reader (crouter-web's static
|
|
915
|
+
// normalizer) reconstructs from the same file. We serve THAT, NOT the agent's
|
|
916
|
+
// live `session.messages` (`agent.state.messages`), because pi's recovery paths
|
|
917
|
+
// mutate the live array away from the persisted history: auto-retry, context-
|
|
918
|
+
// overflow recovery, and compaction each SLICE the errored/superseded assistant
|
|
919
|
+
// message out of `state.messages` while DELIBERATELY keeping it on disk ("keep in
|
|
920
|
+
// session for history", agent-session.js). So `session.messages` can OMIT — or, via
|
|
921
|
+
// branch/compaction reshaping, reorder — a turn the `.jsonl` still holds. Pre-
|
|
922
|
+
// close that drift is invisible (the live stream and the live array agree); after
|
|
923
|
+
// a revive the welcome snapshot built from the reloaded array would diverge from
|
|
924
|
+
// the on-disk history a dormant viewer just saw. Reconstructing from the session
|
|
925
|
+
// manager makes the live snapshot == the persisted history (single source of
|
|
926
|
+
// truth); the relayed live stream then continues from there.
|
|
927
|
+
//
|
|
928
|
+
// `AgentSession.sessionManager` is a public readonly field of the real pi SDK; the
|
|
929
|
+
// fake-engine test fixture mirrors the same `sessionManager.buildSessionContext()`
|
|
930
|
+
// surface, so this is a single path with no engine-capability fallback.
|
|
931
|
+
export function snapshotMessages(session) {
|
|
932
|
+
return session.sessionManager.buildSessionContext().messages;
|
|
933
|
+
}
|
|
934
|
+
// ---------------------------------------------------------------------------
|
|
910
935
|
// buildBrokerSession (plan T4 steps 2–4) — turn the launch recipe into a live
|
|
911
936
|
// engine session via the pi SDK SERVICES path. Exported so the C3/C4 real-SDK
|
|
912
937
|
// regression tests can drive the EXACT production wiring (not the mock).
|
|
@@ -21,12 +21,19 @@ function isDir(p) {
|
|
|
21
21
|
return false;
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
|
-
/**
|
|
25
|
-
*
|
|
24
|
+
/** The `--`flags the front door OWNS (everything else after a bare `crtr` is a
|
|
25
|
+
* positional dir/prompt). A leading token in this set still boots a root —
|
|
26
|
+
* without it, `crtr --headless` / `crtr --name X` would fall through to the
|
|
27
|
+
* dispatcher and error as an unknown subcommand. */
|
|
28
|
+
const FRONT_DOOR_FLAGS = new Set(['--name', '--kind', '--headless', '--no-headless']);
|
|
29
|
+
/** Parse `[dir] [prompt]` positionals + the front-door flags out of the leftover
|
|
30
|
+
* tokens after the bare `crtr`. `headless` is tri-state: `true`/`false` from an
|
|
31
|
+
* explicit `--headless`/`--no-headless`, `undefined` to defer to the config. */
|
|
26
32
|
function parseRootArgs(tokens) {
|
|
27
33
|
let cwd = process.cwd();
|
|
28
34
|
let name;
|
|
29
35
|
let kind;
|
|
36
|
+
let headless;
|
|
30
37
|
const positionals = [];
|
|
31
38
|
for (let i = 0; i < tokens.length; i++) {
|
|
32
39
|
const t = tokens[i];
|
|
@@ -36,6 +43,12 @@ function parseRootArgs(tokens) {
|
|
|
36
43
|
else if (t === '--kind') {
|
|
37
44
|
kind = tokens[++i];
|
|
38
45
|
}
|
|
46
|
+
else if (t === '--headless') {
|
|
47
|
+
headless = true;
|
|
48
|
+
}
|
|
49
|
+
else if (t === '--no-headless') {
|
|
50
|
+
headless = false;
|
|
51
|
+
}
|
|
39
52
|
else if (t.startsWith('--')) {
|
|
40
53
|
// ignore unknown flags for the front door
|
|
41
54
|
}
|
|
@@ -48,7 +61,7 @@ function parseRootArgs(tokens) {
|
|
|
48
61
|
cwd = resolvePath(positionals.shift());
|
|
49
62
|
}
|
|
50
63
|
const prompt = positionals.length > 0 ? positionals.join(' ') : undefined;
|
|
51
|
-
return { cwd, prompt, name, kind };
|
|
64
|
+
return { cwd, prompt, name, kind, headless };
|
|
52
65
|
}
|
|
53
66
|
/** Env marker set on every pi the front door boots. Its presence means we are
|
|
54
67
|
* already inside a front-door-booted root, so a nested front-door launch must
|
|
@@ -79,14 +92,16 @@ export function maybeBootRoot(root, argv) {
|
|
|
79
92
|
// • bare `crtr` (no tokens)
|
|
80
93
|
// • `crtr <dir> [prompt]` (first positional is an existing dir)
|
|
81
94
|
// • `crtr "multi word prompt"` (first token contains whitespace)
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
95
|
+
// • `crtr --headless` / `crtr --name X` (a leading front-door flag)
|
|
96
|
+
// Anything else — a bare word like `job`, or an UNKNOWN leading flag — is
|
|
97
|
+
// treated as a mistyped/removed subcommand and handed to the dispatcher, which
|
|
98
|
+
// errors with "unknown subcommand: <token>". Booting pi for such tokens is what
|
|
99
|
+
// let the renamed `agent`/`job` subcommands fork-bomb the front door.
|
|
86
100
|
if (first !== undefined) {
|
|
87
101
|
const looksLikePrompt = /\s/.test(first);
|
|
88
102
|
const looksLikeDir = !first.startsWith('-') && isDir(resolvePath(first));
|
|
89
|
-
|
|
103
|
+
const looksLikeFrontDoorFlag = FRONT_DOOR_FLAGS.has(first);
|
|
104
|
+
if (!looksLikePrompt && !looksLikeDir && !looksLikeFrontDoorFlag)
|
|
90
105
|
return false;
|
|
91
106
|
}
|
|
92
107
|
// Unambiguous front-door launch → boot a resident root inline (exec pi in
|
|
@@ -6,17 +6,13 @@ export declare function sanitizeSessionName(raw: string): string;
|
|
|
6
6
|
/** Local fallback: derive a name straight from the prompt (no pi call). Drops
|
|
7
7
|
* stop-words, takes the first few content words. */
|
|
8
8
|
export declare function slugFromPrompt(prompt: string): string;
|
|
9
|
-
/** Synchronously ask pi for a 3-8 word kebab name for `prompt`. Blocks up to
|
|
10
|
-
* NAME_TIMEOUT_MS; on any failure (non-zero exit, timeout, empty/garbled
|
|
11
|
-
* output) falls back to a local slug. Returns '' only for an empty prompt. */
|
|
12
|
-
export declare function generateSessionName(prompt: string): string;
|
|
13
9
|
/** Asynchronously generate a name for `prompt` and persist it to the node's
|
|
14
10
|
* meta as `description` — only if the node has none yet (so a later message
|
|
15
11
|
* never clobbers it). Non-blocking: safe to call from inside a live pi event
|
|
16
12
|
* loop. Best-effort; swallows all errors.
|
|
17
13
|
*
|
|
18
14
|
* `onNamed` (optional) fires with the freshly-persisted meta the moment the
|
|
19
|
-
* name lands — the
|
|
15
|
+
* name lands — the canvas-goal-capture naming hook passes a callback that calls
|
|
20
16
|
* pi.setSessionName(editorLabel(meta)) so the LIVE editor label updates in the
|
|
21
17
|
* same session, instead of waiting for the next revive/cycle. */
|
|
22
18
|
export declare function generateAndPersistName(nodeId: string, prompt: string, onNamed?: (meta: NodeMeta) => void): void;
|
|
@@ -6,19 +6,16 @@
|
|
|
6
6
|
// the first prompt by asking pi headlessly (`pi -p`), persisted on the node's
|
|
7
7
|
// meta so it survives revives and shows in every cycle.
|
|
8
8
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// case where the prompt only arrives as the first interactive message inside
|
|
16
|
-
// a live pi process; it must never block the event loop. Persists the name
|
|
17
|
-
// to meta so the label picks it up on the next cycle.
|
|
9
|
+
// One entry point: generateAndPersistName — async (execFile, non-blocking).
|
|
10
|
+
// Naming happens INSIDE the named node's own pi process, off the first real
|
|
11
|
+
// message (the kickoff task or a human's first line), never on the spawn path:
|
|
12
|
+
// blocking spawn on an LLM round-trip used to freeze the caller's terminal for
|
|
13
|
+
// 2-3s on every `crtr node new`. The headless namer runs with --no-extensions,
|
|
14
|
+
// so it loads no canvas hooks and can never recurse into another spawn/name.
|
|
18
15
|
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
import {
|
|
16
|
+
// Best-effort: a failed/slow/garbled pi call falls back to a local slug of the
|
|
17
|
+
// prompt, so a node always gets a sane name.
|
|
18
|
+
import { execFile } from 'node:child_process';
|
|
22
19
|
import { getNode, updateNode } from '../canvas/index.js';
|
|
23
20
|
/** Cap on prompt text fed to the namer — a name needs only the gist. */
|
|
24
21
|
const PROMPT_CAP = 2000;
|
|
@@ -97,30 +94,27 @@ function nameArgs(prompt) {
|
|
|
97
94
|
argv.push(nameUserPrompt(prompt));
|
|
98
95
|
return argv;
|
|
99
96
|
}
|
|
100
|
-
/**
|
|
101
|
-
*
|
|
102
|
-
* output)
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
timeout: NAME_TIMEOUT_MS,
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
if (name !== '')
|
|
117
|
-
return name;
|
|
97
|
+
/** Ask pi headlessly for a kebab-case name for `body`, async. Resolves to the
|
|
98
|
+
* sanitized name, or '' on any failure (non-zero exit, timeout, empty/garbled
|
|
99
|
+
* output) so the caller can fall back to a local slug. Owns the subprocess
|
|
100
|
+
* mechanics — crucially it hands pi an immediate stdin EOF: `pi -p` reads
|
|
101
|
+
* stdin, and execFile's default stdin is an OPEN pipe that never closes, so
|
|
102
|
+
* without this pi blocks waiting for EOF and the call exits non-zero (the
|
|
103
|
+
* regression that silently lost every LLM name to the slug fallback). */
|
|
104
|
+
function headlessName(body) {
|
|
105
|
+
return new Promise((resolve) => {
|
|
106
|
+
try {
|
|
107
|
+
const child = execFile('pi', nameArgs(body), { encoding: 'utf8', timeout: NAME_TIMEOUT_MS }, (err, stdout) => {
|
|
108
|
+
if (err || typeof stdout !== 'string')
|
|
109
|
+
return resolve('');
|
|
110
|
+
resolve(sanitizeSessionName(stdout));
|
|
111
|
+
});
|
|
112
|
+
child.stdin?.end(); // immediate EOF — see the doc above
|
|
118
113
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
}
|
|
123
|
-
return slugFromPrompt(body);
|
|
114
|
+
catch {
|
|
115
|
+
resolve('');
|
|
116
|
+
}
|
|
117
|
+
});
|
|
124
118
|
}
|
|
125
119
|
/** Asynchronously generate a name for `prompt` and persist it to the node's
|
|
126
120
|
* meta as `description` — only if the node has none yet (so a later message
|
|
@@ -128,7 +122,7 @@ export function generateSessionName(prompt) {
|
|
|
128
122
|
* loop. Best-effort; swallows all errors.
|
|
129
123
|
*
|
|
130
124
|
* `onNamed` (optional) fires with the freshly-persisted meta the moment the
|
|
131
|
-
* name lands — the
|
|
125
|
+
* name lands — the canvas-goal-capture naming hook passes a callback that calls
|
|
132
126
|
* pi.setSessionName(editorLabel(meta)) so the LIVE editor label updates in the
|
|
133
127
|
* same session, instead of waiting for the next revive/cycle. */
|
|
134
128
|
export function generateAndPersistName(nodeId, prompt, onNamed) {
|
|
@@ -150,17 +144,7 @@ export function generateAndPersistName(nodeId, prompt, onNamed) {
|
|
|
150
144
|
// best-effort
|
|
151
145
|
}
|
|
152
146
|
};
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
persist(slugFromPrompt(body));
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
|
-
const name = sanitizeSessionName(stdout);
|
|
160
|
-
persist(name !== '' ? name : slugFromPrompt(body));
|
|
161
|
-
});
|
|
162
|
-
}
|
|
163
|
-
catch {
|
|
164
|
-
persist(slugFromPrompt(body));
|
|
165
|
-
}
|
|
147
|
+
void headlessName(body).then((name) => {
|
|
148
|
+
persist(name !== '' ? name : slugFromPrompt(body));
|
|
149
|
+
});
|
|
166
150
|
}
|
|
@@ -308,12 +308,13 @@ export declare function recycleFocusPane(nodeId: string, pane: string, launch: R
|
|
|
308
308
|
* manager that is NOT idle-release (done/dead/canceled, or idle with another
|
|
309
309
|
* intent) is one the daemon will NEVER revive, so this returns false WITHOUT
|
|
310
310
|
* repointing — the caller then disarms the freeze (see below).
|
|
311
|
-
* -
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
311
|
+
* - RUNNING backstage pane (a live pi — the normal multi-child state — OR a
|
|
312
|
+
* pi still BOOTING because the daemon already revived the manager off the
|
|
313
|
+
* `push final` before this handoff ran): the daemon never (re)revives it,
|
|
314
|
+
* so we must bring it into the viewport SYNCHRONOUSLY here — swap its
|
|
315
|
+
* backstage pane into the focus slot (MAJOR 1). Otherwise the manager runs
|
|
316
|
+
* off-screen forever while %m sits orphaned in the viewport and the focus
|
|
317
|
+
* row lies about LOCATION.
|
|
317
318
|
* Returns false — the caller closes the focus (Q1: closeFocusToShell disarms
|
|
318
319
|
* remain-on-exit so the pane REAPS on exit) — whenever NO successor will claim
|
|
319
320
|
* the frozen pane: no manager, the manager IS this node, the manager already
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
import { spawnSync } from 'node:child_process';
|
|
29
29
|
import { join } from 'node:path';
|
|
30
30
|
import { getRow, getRowByPane, getNode, setPresence, openDb, openFocusRow, setFocusOccupant, closeFocusRow, getFocusByNode, getFocusByPane, getFocusById, setFocusPane, listFocuses as listFocusRows, view, } from '../canvas/index.js';
|
|
31
|
-
import { paneExists, paneLocation, paneOfWindow, windowAlive, windowOfPane, ensureSession, openNodeWindow, respawnPaneSync, respawnPaneDetached, breakPaneToSession, splitWindow, swapPaneInPlace, setRemainOnExit, closePane, currentTmux, joinPane, selectLayout, setWindowOption, switchClient, selectWindow, } from './tmux.js';
|
|
31
|
+
import { paneExists, paneRunning, paneLocation, paneOfWindow, windowAlive, windowOfPane, ensureSession, openNodeWindow, respawnPaneSync, respawnPaneDetached, breakPaneToSession, splitWindow, swapPaneInPlace, setRemainOnExit, closePane, currentTmux, joinPane, selectLayout, setWindowOption, switchClient, selectWindow, } from './tmux.js';
|
|
32
32
|
import { homeSessionOf, childBackstageOf, nodeSession, newNodeId, rootOfSpine } from './nodes.js';
|
|
33
33
|
import { nodeDir } from '../canvas/paths.js';
|
|
34
34
|
import { isBusy } from './busy.js';
|
|
@@ -808,12 +808,13 @@ export function recycleFocusPane(nodeId, pane, launch) {
|
|
|
808
808
|
* manager that is NOT idle-release (done/dead/canceled, or idle with another
|
|
809
809
|
* intent) is one the daemon will NEVER revive, so this returns false WITHOUT
|
|
810
810
|
* repointing — the caller then disarms the freeze (see below).
|
|
811
|
-
* -
|
|
812
|
-
*
|
|
813
|
-
*
|
|
814
|
-
*
|
|
815
|
-
*
|
|
816
|
-
*
|
|
811
|
+
* - RUNNING backstage pane (a live pi — the normal multi-child state — OR a
|
|
812
|
+
* pi still BOOTING because the daemon already revived the manager off the
|
|
813
|
+
* `push final` before this handoff ran): the daemon never (re)revives it,
|
|
814
|
+
* so we must bring it into the viewport SYNCHRONOUSLY here — swap its
|
|
815
|
+
* backstage pane into the focus slot (MAJOR 1). Otherwise the manager runs
|
|
816
|
+
* off-screen forever while %m sits orphaned in the viewport and the focus
|
|
817
|
+
* row lies about LOCATION.
|
|
817
818
|
* Returns false — the caller closes the focus (Q1: closeFocusToShell disarms
|
|
818
819
|
* remain-on-exit so the pane REAPS on exit) — whenever NO successor will claim
|
|
819
820
|
* the frozen pane: no manager, the manager IS this node, the manager already
|
|
@@ -840,11 +841,22 @@ export function handFocusToManager(focusId, managerId) {
|
|
|
840
841
|
const mgr = getRow(managerId);
|
|
841
842
|
if (mgr === null)
|
|
842
843
|
return false; // no row to hand to
|
|
843
|
-
// MAJOR 1 —
|
|
844
|
-
// daemon never revives a live node (it only respawns dead-pi
|
|
845
|
-
// synchronous swap is the ONLY way
|
|
846
|
-
// the occupant repoint only here, on a path that genuinely takes the
|
|
847
|
-
|
|
844
|
+
// MAJOR 1 — manager with a RUNNING backstage pane → swap it into the focus
|
|
845
|
+
// slot NOW. The daemon never revives a live node (it only respawns dead-pi
|
|
846
|
+
// nodes), so this synchronous swap is the ONLY way it claims the frozen %m.
|
|
847
|
+
// Commit the occupant repoint only here, on a path that genuinely takes the
|
|
848
|
+
// pane. The gate is the PANE (`paneRunning`: exists + command running, i.e.
|
|
849
|
+
// not a remain-on-exit corpse), NOT `isPidAlive(mgr.pi_pid)` — the recorded
|
|
850
|
+
// pid is a STALE proxy in the lost-pane race: the finishing child's `push
|
|
851
|
+
// final` seeds the manager's inbox mid-turn, the daemon's second pass revives
|
|
852
|
+
// the still-unfocused manager BACKSTAGE before this agent_end runs, and the
|
|
853
|
+
// fresh pi records its pid only at session_start, seconds into boot. Gating on
|
|
854
|
+
// the old dead pid made this branch miss the booting manager, the dormant
|
|
855
|
+
// branch miss it too (revive already flipped it active), and the caller reap
|
|
856
|
+
// the user's viewport while the manager ran on invisibly backstage. A booting
|
|
857
|
+
// pane is as swappable as a live one — swap it in; only a frozen corpse
|
|
858
|
+
// (pane_dead=1) is excluded, preserving the dead-focus-pane guarantees.
|
|
859
|
+
if (mgr.pane != null && paneRunning(mgr.pane) && f.pane != null) {
|
|
848
860
|
setFocusOccupant(focusId, managerId);
|
|
849
861
|
const focusLoc = paneLocation(f.pane); // F2's window/session — the slot mgr swaps INTO (%m is currently there)
|
|
850
862
|
if (swapPaneInPlace(mgr.pane, f.pane) && focusLoc !== null) {
|
|
@@ -23,6 +23,7 @@ import { reconcile, piCommand, respawnPane, } from './placement.js';
|
|
|
23
23
|
import { hostFor } from './host.js';
|
|
24
24
|
import { nodeSession, childBackstageOf, rootOfSpine } from './nodes.js';
|
|
25
25
|
import { isPidAlive } from '../canvas/pid.js';
|
|
26
|
+
import { clearInjectedDocs } from '../substrate/injected-store.js';
|
|
26
27
|
// ---------------------------------------------------------------------------
|
|
27
28
|
// resumeArgs — which session source a revive resumes from
|
|
28
29
|
// ---------------------------------------------------------------------------
|
|
@@ -93,6 +94,10 @@ export function reviveNode(nodeId, opts) {
|
|
|
93
94
|
// woke you"); every other reviveNode caller passes nothing → no block.
|
|
94
95
|
const bearings = drainBearings(meta);
|
|
95
96
|
inv = buildPiArgv(meta, { prompt: buildReviveKickoff(meta, bearings, opts.wakeReason) });
|
|
97
|
+
// Fresh (no-resume) revive starts a NEW transcript — reset the on-read doc
|
|
98
|
+
// dedup so the new conversation surfaces docs from scratch (a resume below
|
|
99
|
+
// would instead KEEP the persisted set, continuing the same transcript).
|
|
100
|
+
clearInjectedDocs(nodeId);
|
|
96
101
|
}
|
|
97
102
|
// Placement owns WHERE this revive lands (§1.4): resume into a live focus pane
|
|
98
103
|
// if the node occupies one, else a fresh window in its home_session (the
|
|
@@ -140,6 +145,8 @@ export function reviveInPlace(nodeId, pane, respawn = respawnPane) {
|
|
|
140
145
|
// A refresh-yield is a cycle too — advance the label's trailing N.
|
|
141
146
|
meta.cycles = (meta.cycles ?? 0) + 1;
|
|
142
147
|
updateNode(nodeId, { cycles: meta.cycles });
|
|
148
|
+
// Fresh re-exec → new transcript: reset the on-read doc dedup.
|
|
149
|
+
clearInjectedDocs(nodeId);
|
|
143
150
|
// The node's LOCATION — the session its pane physically lives in. The re-exec
|
|
144
151
|
// is IN PLACE (the pane never moves), so this is preserved unchanged below.
|
|
145
152
|
const session = meta.tmux_session ?? nodeSession();
|
|
@@ -198,6 +205,8 @@ export function relaunchRootInPane(nodeId, pane) {
|
|
|
198
205
|
throw new Error(`relaunchRootInPane: unknown node ${nodeId}`);
|
|
199
206
|
}
|
|
200
207
|
// No prompt, no resume → a brand-new root conversation at cycle 0.
|
|
208
|
+
// Brand-new transcript: reset the on-read doc dedup.
|
|
209
|
+
clearInjectedDocs(nodeId);
|
|
201
210
|
const inv = buildPiArgv(meta, {});
|
|
202
211
|
// Source CRTR_ROOT_SESSION from childBackstageOf, the same backstage rule as
|
|
203
212
|
// reviveInPlace. relaunchRootInPane runs only on a root, whose children must
|
|
@@ -6,6 +6,11 @@ export interface BootRootOpts {
|
|
|
6
6
|
name?: string;
|
|
7
7
|
/** Optional starter prompt (bare `crtr` requires none). */
|
|
8
8
|
prompt?: string;
|
|
9
|
+
/** Tri-state host selection: `true`/`false` from an explicit
|
|
10
|
+
* `--headless`/`--no-headless`, `undefined` to defer to the `headless`
|
|
11
|
+
* config default. When it resolves to broker, the front door boots a
|
|
12
|
+
* DETACHED broker root and auto-attaches a viewer inline in this pane. */
|
|
13
|
+
headless?: boolean;
|
|
9
14
|
}
|
|
10
15
|
/** Create a root node and bring up its pi. Returns the node; for 'inline' this
|
|
11
16
|
* only returns after pi exits (it took over the terminal). */
|
|
@@ -9,16 +9,18 @@
|
|
|
9
9
|
// INDEPENDENT resident root (no subscription back to the spawner,
|
|
10
10
|
// provenance via spawned_by) brought forefront for direct driving.
|
|
11
11
|
import { spawnSync } from 'node:child_process';
|
|
12
|
+
import { join } from 'node:path';
|
|
12
13
|
import { FRONT_DOOR_ENV } from './front-door.js';
|
|
14
|
+
import { readConfig } from '../config.js';
|
|
15
|
+
import { jobDir } from '../canvas/paths.js';
|
|
13
16
|
import { spawnNode, currentNodeContext, resolveBirthSession, nodeSession, rootOfSpine } from './nodes.js';
|
|
14
17
|
import { buildLaunchSpec, buildPiArgv } from './launch.js';
|
|
15
18
|
import { writeGoal } from './kickoff.js';
|
|
16
19
|
import { hasRoadmap, seedRoadmap } from './roadmap.js';
|
|
17
|
-
import { generateSessionName } from './naming.js';
|
|
18
20
|
import { buildIdentityAssertion, buildWakeBearings } from './bearings.js';
|
|
19
21
|
import { installMenuBinding, installNavBindings, installViewNavBindings } from './tmux-chrome.js';
|
|
20
22
|
import { setPresence, updateNode, getNode, fullName } from '../canvas/index.js';
|
|
21
|
-
import { registerRootFocus, ensureSession, openNodeWindow, piCommand, currentTmux, inTmux, focusWindow, } from './placement.js';
|
|
23
|
+
import { registerRootFocus, ensureSession, openNodeWindow, piCommand, currentTmux, inTmux, focusWindow, waitForBrokerViewSocket, } from './placement.js';
|
|
22
24
|
import { transition } from './lifecycle.js';
|
|
23
25
|
import { headlessBrokerHost } from './host.js';
|
|
24
26
|
import { ensureDaemon } from '../../daemon/manage.js';
|
|
@@ -32,23 +34,25 @@ export function bootRoot(opts) {
|
|
|
32
34
|
}
|
|
33
35
|
catch { /* daemon is best-effort */ }
|
|
34
36
|
const kind = opts.kind ?? 'general';
|
|
37
|
+
// Host precedence: explicit --headless/--no-headless flag > config `headless`
|
|
38
|
+
// default > tmux. Mirrors `crtr node new`'s resolution (commands/node.ts).
|
|
39
|
+
const headless = opts.headless ?? readConfig('user').headless === true;
|
|
40
|
+
const hostKind = headless ? 'broker' : 'tmux';
|
|
35
41
|
// A born-resident root starts in base mode; it earns the orchestrator persona
|
|
36
42
|
// the first time it delegates (or on promotion). Resident lifecycle either way.
|
|
37
43
|
const { launch } = buildLaunchSpec(kind, 'base', { lifecycle: 'resident', hasManager: false });
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
? generateSessionName(opts.prompt)
|
|
43
|
-
: undefined;
|
|
44
|
+
// Born WITHOUT a name. Naming is async + event-driven: the canvas-goal-capture
|
|
45
|
+
// extension names the node from its FIRST real message (the kickoff prompt or
|
|
46
|
+
// a human's first line) inside its own pi process, via a headless `pi -p`.
|
|
47
|
+
// Never block the front door on an LLM round-trip.
|
|
44
48
|
const meta = spawnNode({
|
|
45
49
|
kind,
|
|
46
50
|
mode: 'base',
|
|
47
51
|
lifecycle: 'resident',
|
|
48
52
|
cwd: opts.cwd,
|
|
49
53
|
name: opts.name ?? kind,
|
|
50
|
-
description,
|
|
51
54
|
parent: null,
|
|
55
|
+
hostKind,
|
|
52
56
|
launch,
|
|
53
57
|
});
|
|
54
58
|
// Persist the spawning prompt as the goal so a fresh revive can re-read its
|
|
@@ -74,6 +78,15 @@ export function bootRoot(opts) {
|
|
|
74
78
|
}
|
|
75
79
|
catch { /* best-effort */ }
|
|
76
80
|
}
|
|
81
|
+
// HEADLESS front door: a broker root has NO engine pane — headlessBrokerHost
|
|
82
|
+
// launches a DETACHED broker that hosts the single engine + binds view.sock,
|
|
83
|
+
// opening no tmux window. So instead of exec'ing pi inline we boot the broker,
|
|
84
|
+
// wait for its socket, and auto-attach a VIEWER into THIS pane (the same
|
|
85
|
+
// `crtr attach` viewer focusBroker splits beside a caller). Presence stays
|
|
86
|
+
// null and NO focus row is registered — the viewer pane self-tags @crtr_node
|
|
87
|
+
// on connect, the handle node cycle/close/lifecycle resolve a broker by.
|
|
88
|
+
if (meta.host_kind === 'broker')
|
|
89
|
+
return bootRootBroker(meta, session, opts);
|
|
77
90
|
// inline: the root's pi takes over THIS terminal, so its own window stays
|
|
78
91
|
// where the user is (its tmux_session tracks that real pane so supervision
|
|
79
92
|
// sees it alive). But its children spawn into the shared global session via
|
|
@@ -102,6 +115,50 @@ export function bootRoot(opts) {
|
|
|
102
115
|
const r = spawnSync('pi', inv.argv, { stdio: 'inherit', env });
|
|
103
116
|
process.exit(r.status ?? 0);
|
|
104
117
|
}
|
|
118
|
+
/** The headless front door (host_kind==='broker'): launch a detached broker for
|
|
119
|
+
* this root, wait for its view.sock, then auto-attach a viewer INLINE in this
|
|
120
|
+
* pane. Mirrors placement.focusBroker (ensure-broker → wait-socket → viewer)
|
|
121
|
+
* but the viewer runs in the CURRENT pane (the front door owns it) rather than
|
|
122
|
+
* a split beside a caller. Never returns — it exits with the viewer's status. */
|
|
123
|
+
function bootRootBroker(meta, session, opts) {
|
|
124
|
+
// The shared backstage this subtree's descendants flow into (CRTR_ROOT_SESSION
|
|
125
|
+
// below). Mirrors the inline path; for a broker it is also the (moot) revive
|
|
126
|
+
// home — broker revive launches a detached process, ignoring the session.
|
|
127
|
+
updateNode(meta.node_id, { home_session: session });
|
|
128
|
+
const withSession = getNode(meta.node_id);
|
|
129
|
+
const inv = buildPiArgv(withSession, { prompt: opts.prompt });
|
|
130
|
+
// Authoritative backstage routing on the ENGINE's env (the broker host merges
|
|
131
|
+
// inv.env into the detached broker process), mirroring the inline pi env.
|
|
132
|
+
inv.env = { ...inv.env, CRTR_ROOT_SESSION: session, CRTR_SUBTREE: rootOfSpine(meta.node_id) };
|
|
133
|
+
// Launch the detached broker (it records its own pid as pi_pid via the
|
|
134
|
+
// stophook and binds view.sock). Returns placement all-null — no tmux window.
|
|
135
|
+
headlessBrokerHost.launch(meta.node_id, inv, { cwd: meta.cwd, name: fullName(meta), resuming: false });
|
|
136
|
+
// Close the cold-start race: the broker records its pid during extension bind,
|
|
137
|
+
// then opens view.sock LATER; attach connects once and exits "no broker" if we
|
|
138
|
+
// run it before listen(). Probe for an ACCEPTING socket before attaching. A
|
|
139
|
+
// timeout here does NOT mean the broker died — the engine is a resident root
|
|
140
|
+
// that keeps booting; we just can't attach yet, so point the user at re-focus.
|
|
141
|
+
if (!waitForBrokerViewSocket(meta.node_id)) {
|
|
142
|
+
process.stderr.write(`crtr: ${meta.node_id} is still starting its broker — it is running, but its viewer socket did not come up in time. ` +
|
|
143
|
+
`Re-focus the node to attach (\`crtr node focus ${meta.node_id}\`); see ${join(jobDir(meta.node_id), 'broker.log')} if it never appears.\n`);
|
|
144
|
+
process.exit(1);
|
|
145
|
+
}
|
|
146
|
+
// Auto-attach the VIEWER inline: re-invoke OUR OWN cli (process.execPath +
|
|
147
|
+
// its own execArgv + argv[1]) as `attach to <id>` so it needs no PATH `crtr`
|
|
148
|
+
// AND survives a loader-based launch (dev `tsx`/`--import` flags ride
|
|
149
|
+
// execArgv); stdio:'inherit' hands this pane to the viewer's raw-mode TUI.
|
|
150
|
+
// runAttach self-tags the pane @crtr_node on connect; ctrl+c/ctrl+d detaches
|
|
151
|
+
// and the broker engine runs on.
|
|
152
|
+
const entry = process.argv[1];
|
|
153
|
+
if (entry === undefined) {
|
|
154
|
+
process.stderr.write(`crtr: cannot resolve the crtr entrypoint to auto-attach; run \`crtr node focus ${meta.node_id}\`.\n`);
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
const r = spawnSync(process.execPath, [...process.execArgv, entry, 'attach', 'to', meta.node_id], {
|
|
158
|
+
stdio: 'inherit',
|
|
159
|
+
});
|
|
160
|
+
process.exit(r.status ?? 0);
|
|
161
|
+
}
|
|
105
162
|
/** Resolve a `--fork-from` value to the source pi gets as `--fork <path|id>`.
|
|
106
163
|
* A live node id resolves to its captured session FILE (absolute, cwd-immune),
|
|
107
164
|
* falling back to its bare session id; a path or partial uuid passes straight
|
|
@@ -162,14 +219,15 @@ export function spawnChild(opts) {
|
|
|
162
219
|
// independent root sits top-of-spine with nobody to push to. Mirrors the
|
|
163
220
|
// `parent` set below (root ? null : spawner), so hasManager === parent!==null.
|
|
164
221
|
const { launch } = buildLaunchSpec(opts.kind, mode, { lifecycle, hasManager: !root, model: opts.model });
|
|
165
|
-
//
|
|
222
|
+
// Born WITHOUT a name — the canvas-goal-capture extension names it async from
|
|
223
|
+
// its first message (the kickoff task) inside its own pi process, so spawn
|
|
224
|
+
// never blocks on the LLM naming round-trip (the 2-3s freeze it used to cost).
|
|
166
225
|
const meta = spawnNode({
|
|
167
226
|
kind: opts.kind,
|
|
168
227
|
mode,
|
|
169
228
|
lifecycle,
|
|
170
229
|
cwd: opts.cwd,
|
|
171
230
|
name: opts.name ?? opts.kind,
|
|
172
|
-
description: generateSessionName(opts.prompt),
|
|
173
231
|
// A root has no spine parent (top-level, nobody subscribes); it still
|
|
174
232
|
// records spawned_by=spawner when a node (not a human shell) spawned it.
|
|
175
233
|
// A child's parent IS its manager.
|
|
@@ -96,6 +96,15 @@ export declare function paneLocation(pane: string): {
|
|
|
96
96
|
* liveness. We therefore require the echoed `#{pane_id}` to equal the requested
|
|
97
97
|
* pane: a live pane echoes its own id, a gone/bogus one yields empty. */
|
|
98
98
|
export declare function paneExists(pane: string): boolean;
|
|
99
|
+
/** Does this pane exist AND have its command still RUNNING (`#{pane_dead}` = 0)?
|
|
100
|
+
* Distinguishes a pane genuinely hosting a process — a live pi, or a pi still
|
|
101
|
+
* BOOTING whose pid hasn't been recorded yet — from a remain-on-exit corpse
|
|
102
|
+
* frozen after exit (`pane_dead` = 1). Node panes run pi as the pane command
|
|
103
|
+
* (openNodeWindow / respawn-pane), never a shell, so pane-running ⟹ a pi (or
|
|
104
|
+
* its respawn) occupies it. The probe handFocusToManager's live-swap gates on:
|
|
105
|
+
* the recorded `pi_pid` is a stale proxy in the just-revived window (the new pi
|
|
106
|
+
* records its pid only at session_start), but the pane itself never lies. */
|
|
107
|
+
export declare function paneRunning(pane: string): boolean;
|
|
99
108
|
/** Every live pane id on the server (`list-panes -a`), as a Set for membership
|
|
100
109
|
* probes. Returns null when tmux is unreachable (no server / transient failure)
|
|
101
110
|
* so callers can tell "no panes" apart from "can't tell" — a GC pass must skip,
|
|
@@ -191,6 +191,18 @@ export function paneExists(pane) {
|
|
|
191
191
|
const r = tmux(['display-message', '-p', '-t', pane, '#{pane_id}']);
|
|
192
192
|
return r.ok && r.stdout === pane;
|
|
193
193
|
}
|
|
194
|
+
/** Does this pane exist AND have its command still RUNNING (`#{pane_dead}` = 0)?
|
|
195
|
+
* Distinguishes a pane genuinely hosting a process — a live pi, or a pi still
|
|
196
|
+
* BOOTING whose pid hasn't been recorded yet — from a remain-on-exit corpse
|
|
197
|
+
* frozen after exit (`pane_dead` = 1). Node panes run pi as the pane command
|
|
198
|
+
* (openNodeWindow / respawn-pane), never a shell, so pane-running ⟹ a pi (or
|
|
199
|
+
* its respawn) occupies it. The probe handFocusToManager's live-swap gates on:
|
|
200
|
+
* the recorded `pi_pid` is a stale proxy in the just-revived window (the new pi
|
|
201
|
+
* records its pid only at session_start), but the pane itself never lies. */
|
|
202
|
+
export function paneRunning(pane) {
|
|
203
|
+
const r = tmux(['display-message', '-p', '-t', pane, '#{pane_id}\t#{pane_dead}']);
|
|
204
|
+
return r.ok && r.stdout === `${pane}\t0`;
|
|
205
|
+
}
|
|
194
206
|
/** Every live pane id on the server (`list-panes -a`), as a Set for membership
|
|
195
207
|
* probes. Returns null when tmux is unreachable (no server / transient failure)
|
|
196
208
|
* so callers can tell "no panes" apart from "can't tell" — a GC pass must skip,
|
package/dist/core/spawn.d.ts
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
|
+
/** Does THIS process sit inside a tmux client (its own $TMUX is set)? NOT the
|
|
2
|
+
* gate for the human surface — see tmuxServerReachable. A headless-broker child
|
|
3
|
+
* strips TMUX/TMUX_PANE (broker.ts, anti-stophook-hijack) yet can still drive
|
|
4
|
+
* the canvas tmux server, so gating the surface on this wrongly starves broker
|
|
5
|
+
* nodes of human-in-the-loop. */
|
|
1
6
|
export declare function isInTmux(): boolean;
|
|
7
|
+
/** Is the canvas tmux SERVER reachable? The canvas runs on the DEFAULT tmux
|
|
8
|
+
* socket (no -L/-S in core/runtime/tmux.ts), so any `crtr` child — including a
|
|
9
|
+
* headless-broker child that has deleted its own $TMUX — can split panes into
|
|
10
|
+
* it. The human surface gates on THIS (can I reach the server to open a pane
|
|
11
|
+
* the user is watching?), not on the caller's $TMUX. `list-clients` exits 0
|
|
12
|
+
* whenever a server is up (even with zero attached clients) and non-zero when
|
|
13
|
+
* no server is running — the true "no tmux to surface into" case that should
|
|
14
|
+
* degrade to the inbox-drain follow-up. */
|
|
15
|
+
export declare function tmuxServerReachable(): boolean;
|
|
2
16
|
export declare function shellQuote(s: string): string;
|
|
3
17
|
/** Count panes in a tmux window (0 outside tmux / on error). With `targetPane`,
|
|
4
18
|
* counts the window THAT pane lives in (the placement decision must reflect the
|