@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.
Files changed (68) hide show
  1. package/dist/builtin-memory/internal/INDEX.md +1 -1
  2. package/dist/builtin-personas/runtime-base.md +3 -0
  3. package/dist/clients/attach/__tests__/reconnect-giveup.test.d.ts +1 -0
  4. package/dist/clients/attach/__tests__/reconnect-giveup.test.js +30 -0
  5. package/dist/clients/attach/attach-cmd.js +187 -19
  6. package/dist/clients/attach/canvas-panels.d.ts +10 -0
  7. package/dist/clients/attach/canvas-panels.js +50 -0
  8. package/dist/clients/attach/graph-overlay.d.ts +34 -0
  9. package/dist/clients/attach/graph-overlay.js +266 -0
  10. package/dist/clients/attach/input-controller.d.ts +6 -0
  11. package/dist/clients/attach/input-controller.js +2 -0
  12. package/dist/clients/attach/slash-commands.d.ts +22 -1
  13. package/dist/clients/attach/slash-commands.js +160 -3
  14. package/dist/clients/attach/view-socket.d.ts +19 -1
  15. package/dist/clients/attach/view-socket.js +61 -6
  16. package/dist/commands/human/prompts.js +3 -3
  17. package/dist/commands/human/queue.d.ts +17 -0
  18. package/dist/commands/human/queue.js +111 -4
  19. package/dist/commands/memory/__tests__/lint-schema.test.js +24 -1
  20. package/dist/commands/memory/lint.d.ts +5 -4
  21. package/dist/commands/memory/lint.js +9 -5
  22. package/dist/commands/memory/write.js +19 -2
  23. package/dist/commands/memory.js +1 -1
  24. package/dist/commands/sys/feedback.d.ts +1 -0
  25. package/dist/commands/sys/feedback.js +163 -0
  26. package/dist/commands/sys.js +3 -2
  27. package/dist/core/__tests__/broker-snapshot-history.test.d.ts +1 -0
  28. package/dist/core/__tests__/broker-snapshot-history.test.js +105 -0
  29. package/dist/core/__tests__/fixtures/fake-engine.d.ts +7 -0
  30. package/dist/core/__tests__/fixtures/fake-engine.js +10 -0
  31. package/dist/core/__tests__/full/placement-teardown.test.js +76 -0
  32. package/dist/core/__tests__/human-stranded-deliver.test.d.ts +1 -0
  33. package/dist/core/__tests__/human-stranded-deliver.test.js +108 -0
  34. package/dist/core/__tests__/on-read-dedup-resume.test.d.ts +1 -0
  35. package/dist/core/__tests__/on-read-dedup-resume.test.js +81 -0
  36. package/dist/core/canvas/nav-model.d.ts +162 -0
  37. package/dist/core/canvas/nav-model.js +486 -0
  38. package/dist/core/canvas/paths.d.ts +7 -0
  39. package/dist/core/canvas/paths.js +9 -0
  40. package/dist/core/runtime/broker-sdk.d.ts +0 -12
  41. package/dist/core/runtime/broker-sdk.js +77 -6
  42. package/dist/core/runtime/broker.d.ts +2 -1
  43. package/dist/core/runtime/broker.js +26 -1
  44. package/dist/core/runtime/front-door.js +23 -8
  45. package/dist/core/runtime/naming.d.ts +1 -5
  46. package/dist/core/runtime/naming.js +33 -49
  47. package/dist/core/runtime/placement.d.ts +7 -6
  48. package/dist/core/runtime/placement.js +24 -12
  49. package/dist/core/runtime/revive.js +9 -0
  50. package/dist/core/runtime/spawn.d.ts +5 -0
  51. package/dist/core/runtime/spawn.js +69 -11
  52. package/dist/core/runtime/tmux.d.ts +9 -0
  53. package/dist/core/runtime/tmux.js +12 -0
  54. package/dist/core/spawn.d.ts +14 -0
  55. package/dist/core/spawn.js +29 -9
  56. package/dist/core/substrate/index.d.ts +1 -1
  57. package/dist/core/substrate/index.js +6 -6
  58. package/dist/core/substrate/injected-store.d.ts +10 -0
  59. package/dist/core/substrate/injected-store.js +55 -0
  60. package/dist/core/substrate/schema.d.ts +6 -8
  61. package/dist/core/substrate/schema.js +26 -28
  62. package/dist/pi-extensions/canvas-doc-substrate.js +16 -7
  63. package/dist/pi-extensions/canvas-goal-capture.js +38 -22
  64. package/dist/pi-extensions/canvas-nav.js +30 -385
  65. package/dist/pi-extensions/canvas-stophook.d.ts +1 -1
  66. package/dist/pi-extensions/canvas-stophook.js +32 -2
  67. package/package.json +1 -1
  68. 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.messages,
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
- /** Parse `[dir] [prompt]` positionals + `--name`/`--kind` flags out of the
25
- * leftover tokens after the bare `crtr`. */
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
- // Anything else a bare word like `job`, or a leading flag — is treated as a
83
- // mistyped/removed subcommand and handed to the dispatcher, which errors with
84
- // "unknown subcommand: <token>". Booting pi for such tokens is what let the
85
- // renamed `agent`/`job` subcommands fork-bomb the front door.
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
- if (!looksLikePrompt && !looksLikeDir)
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 bare-root path passes a callback that calls
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
- // Two entry points:
10
- // generateSessionName — synchronous (spawnSync). For the CLI spawn paths
11
- // (spawnChild / bootRoot) that run outside any pi event loop, where a brief
12
- // block before launching the worker is fine and lets the FIRST pi session
13
- // already carry the name.
14
- // generateAndPersistName async (execFile, non-blocking). For the bare-root
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
- // Both are best-effort: a failed/slow/garbled pi call falls back to a local slug
20
- // of the prompt, so a node always gets a sane name.
21
- import { spawnSync, execFile } from 'node:child_process';
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
- /** Synchronously ask pi for a 3-8 word kebab name for `prompt`. Blocks up to
101
- * NAME_TIMEOUT_MS; on any failure (non-zero exit, timeout, empty/garbled
102
- * output) falls back to a local slug. Returns '' only for an empty prompt. */
103
- export function generateSessionName(prompt) {
104
- const body = (prompt ?? '').trim();
105
- if (body === '')
106
- return '';
107
- try {
108
- const r = spawnSync('pi', nameArgs(body), {
109
- encoding: 'utf8',
110
- timeout: NAME_TIMEOUT_MS,
111
- // Don't inherit a TUI; capture stdout only.
112
- stdio: ['ignore', 'pipe', 'ignore'],
113
- });
114
- if (r.status === 0 && typeof r.stdout === 'string') {
115
- const name = sanitizeSessionName(r.stdout);
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
- catch {
121
- // fall through to slug
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 bare-root path passes a callback that calls
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
- try {
154
- execFile('pi', nameArgs(body), { encoding: 'utf8', timeout: NAME_TIMEOUT_MS }, (err, stdout) => {
155
- if (err || typeof stdout !== 'string') {
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
- * - LIVE manager (pi alive in the backstage, the normal multi-child state):
312
- * the daemon never revives it (it only respawns dead-pi nodes), so we must
313
- * bring it into the viewport SYNCHRONOUSLY here swap its backstage pane
314
- * into the focus slot (MAJOR 1). Otherwise the manager runs off-screen
315
- * forever while %m sits orphaned in the viewport and the focus row lies
316
- * about LOCATION.
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
- * - LIVE manager (pi alive in the backstage, the normal multi-child state):
812
- * the daemon never revives it (it only respawns dead-pi nodes), so we must
813
- * bring it into the viewport SYNCHRONOUSLY here swap its backstage pane
814
- * into the focus slot (MAJOR 1). Otherwise the manager runs off-screen
815
- * forever while %m sits orphaned in the viewport and the focus row lies
816
- * about LOCATION.
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 — LIVE backstage manager → swap it into the focus slot NOW. The
844
- // daemon never revives a live node (it only respawns dead-pi nodes), so this
845
- // synchronous swap is the ONLY way a live manager claims the frozen %m. Commit
846
- // the occupant repoint only here, on a path that genuinely takes the pane.
847
- if (mgr.pane != null && isNodePaneAlive(mgr) && isPidAlive(mgr.pi_pid) && f.pane != null) {
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
- // A root opened WITH a prompt gets its editor name now (so the first pi
39
- // session already carries it). A bare root has no prompt yet the
40
- // goal-capture extension names it from the first message (async, next cycle).
41
- const description = opts.prompt !== undefined && opts.prompt.trim() !== ''
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
- // Name the worker from its task now, so its first editor label carries it.
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,
@@ -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