@crouton-kit/crouter 0.3.27 → 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 (62) hide show
  1. package/dist/builtin-personas/runtime-base.md +3 -0
  2. package/dist/clients/attach/__tests__/reconnect-giveup.test.d.ts +1 -0
  3. package/dist/clients/attach/__tests__/reconnect-giveup.test.js +30 -0
  4. package/dist/clients/attach/attach-cmd.js +187 -19
  5. package/dist/clients/attach/canvas-panels.d.ts +10 -0
  6. package/dist/clients/attach/canvas-panels.js +50 -0
  7. package/dist/clients/attach/graph-overlay.d.ts +34 -0
  8. package/dist/clients/attach/graph-overlay.js +266 -0
  9. package/dist/clients/attach/input-controller.d.ts +6 -0
  10. package/dist/clients/attach/input-controller.js +2 -0
  11. package/dist/clients/attach/slash-commands.d.ts +22 -1
  12. package/dist/clients/attach/slash-commands.js +160 -3
  13. package/dist/clients/attach/view-socket.d.ts +19 -1
  14. package/dist/clients/attach/view-socket.js +61 -6
  15. package/dist/commands/human/prompts.js +3 -3
  16. package/dist/commands/human/queue.d.ts +17 -0
  17. package/dist/commands/human/queue.js +111 -4
  18. package/dist/commands/memory/__tests__/lint-schema.test.js +24 -1
  19. package/dist/commands/memory/lint.d.ts +5 -4
  20. package/dist/commands/memory/lint.js +9 -5
  21. package/dist/commands/memory/write.js +12 -3
  22. package/dist/commands/sys/feedback.d.ts +1 -0
  23. package/dist/commands/sys/feedback.js +163 -0
  24. package/dist/commands/sys.js +3 -2
  25. package/dist/core/__tests__/broker-snapshot-history.test.d.ts +1 -0
  26. package/dist/core/__tests__/broker-snapshot-history.test.js +105 -0
  27. package/dist/core/__tests__/fixtures/fake-engine.d.ts +7 -0
  28. package/dist/core/__tests__/fixtures/fake-engine.js +10 -0
  29. package/dist/core/__tests__/full/placement-teardown.test.js +76 -0
  30. package/dist/core/__tests__/human-stranded-deliver.test.d.ts +1 -0
  31. package/dist/core/__tests__/human-stranded-deliver.test.js +108 -0
  32. package/dist/core/__tests__/on-read-dedup-resume.test.d.ts +1 -0
  33. package/dist/core/__tests__/on-read-dedup-resume.test.js +81 -0
  34. package/dist/core/canvas/nav-model.d.ts +162 -0
  35. package/dist/core/canvas/nav-model.js +486 -0
  36. package/dist/core/canvas/paths.d.ts +7 -0
  37. package/dist/core/canvas/paths.js +9 -0
  38. package/dist/core/runtime/broker-sdk.d.ts +0 -12
  39. package/dist/core/runtime/broker-sdk.js +77 -6
  40. package/dist/core/runtime/broker.d.ts +2 -1
  41. package/dist/core/runtime/broker.js +26 -1
  42. package/dist/core/runtime/front-door.js +23 -8
  43. package/dist/core/runtime/placement.d.ts +7 -6
  44. package/dist/core/runtime/placement.js +24 -12
  45. package/dist/core/runtime/revive.js +9 -0
  46. package/dist/core/runtime/spawn.d.ts +5 -0
  47. package/dist/core/runtime/spawn.js +62 -1
  48. package/dist/core/runtime/tmux.d.ts +9 -0
  49. package/dist/core/runtime/tmux.js +12 -0
  50. package/dist/core/spawn.d.ts +14 -0
  51. package/dist/core/spawn.js +29 -9
  52. package/dist/core/substrate/index.d.ts +1 -1
  53. package/dist/core/substrate/index.js +6 -6
  54. package/dist/core/substrate/injected-store.d.ts +10 -0
  55. package/dist/core/substrate/injected-store.js +55 -0
  56. package/dist/core/substrate/schema.d.ts +6 -8
  57. package/dist/core/substrate/schema.js +26 -28
  58. package/dist/pi-extensions/canvas-doc-substrate.js +16 -7
  59. package/dist/pi-extensions/canvas-nav.js +30 -385
  60. package/dist/pi-extensions/canvas-stophook.d.ts +1 -1
  61. package/dist/pi-extensions/canvas-stophook.js +32 -2
  62. package/package.json +1 -1
@@ -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
@@ -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,7 +9,10 @@
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';
@@ -17,7 +20,7 @@ import { hasRoadmap, seedRoadmap } from './roadmap.js';
17
20
  import { buildIdentityAssertion, buildWakeBearings } from './bearings.js';
18
21
  import { installMenuBinding, installNavBindings, installViewNavBindings } from './tmux-chrome.js';
19
22
  import { setPresence, updateNode, getNode, fullName } from '../canvas/index.js';
20
- import { registerRootFocus, ensureSession, openNodeWindow, piCommand, currentTmux, inTmux, focusWindow, } from './placement.js';
23
+ import { registerRootFocus, ensureSession, openNodeWindow, piCommand, currentTmux, inTmux, focusWindow, waitForBrokerViewSocket, } from './placement.js';
21
24
  import { transition } from './lifecycle.js';
22
25
  import { headlessBrokerHost } from './host.js';
23
26
  import { ensureDaemon } from '../../daemon/manage.js';
@@ -31,6 +34,10 @@ export function bootRoot(opts) {
31
34
  }
32
35
  catch { /* daemon is best-effort */ }
33
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';
34
41
  // A born-resident root starts in base mode; it earns the orchestrator persona
35
42
  // the first time it delegates (or on promotion). Resident lifecycle either way.
36
43
  const { launch } = buildLaunchSpec(kind, 'base', { lifecycle: 'resident', hasManager: false });
@@ -45,6 +52,7 @@ export function bootRoot(opts) {
45
52
  cwd: opts.cwd,
46
53
  name: opts.name ?? kind,
47
54
  parent: null,
55
+ hostKind,
48
56
  launch,
49
57
  });
50
58
  // Persist the spawning prompt as the goal so a fresh revive can re-read its
@@ -70,6 +78,15 @@ export function bootRoot(opts) {
70
78
  }
71
79
  catch { /* best-effort */ }
72
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);
73
90
  // inline: the root's pi takes over THIS terminal, so its own window stays
74
91
  // where the user is (its tmux_session tracks that real pane so supervision
75
92
  // sees it alive). But its children spawn into the shared global session via
@@ -98,6 +115,50 @@ export function bootRoot(opts) {
98
115
  const r = spawnSync('pi', inv.argv, { stdio: 'inherit', env });
99
116
  process.exit(r.status ?? 0);
100
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
+ }
101
162
  /** Resolve a `--fork-from` value to the source pi gets as `--fork <path|id>`.
102
163
  * A live node id resolves to its captured session FILE (absolute, cwd-immune),
103
164
  * falling back to its bare session id; a path or partial uuid passes straight
@@ -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
@@ -3,13 +3,30 @@
3
3
  // A small set of tmux primitives used by the `human` command tree to put the
4
4
  // humanloop TUI in a detached pane: spawnAndDetach (open a pane running a given
5
5
  // command), countPanesInCurrentWindow (placement decision), plus shellQuote and
6
- // isInTmux. The canvas runtime has its own one-window-per-node machinery in
7
- // core/runtime/tmux.ts; this module is only the pane-split path the human TUI
8
- // needs.
6
+ // tmuxServerReachable. The canvas runtime has its own one-window-per-node
7
+ // machinery in core/runtime/tmux.ts; this module is only the pane-split path the
8
+ // human TUI needs.
9
9
  import { spawnSync } from 'node:child_process';
10
+ /** Does THIS process sit inside a tmux client (its own $TMUX is set)? NOT the
11
+ * gate for the human surface — see tmuxServerReachable. A headless-broker child
12
+ * strips TMUX/TMUX_PANE (broker.ts, anti-stophook-hijack) yet can still drive
13
+ * the canvas tmux server, so gating the surface on this wrongly starves broker
14
+ * nodes of human-in-the-loop. */
10
15
  export function isInTmux() {
11
16
  return Boolean(process.env.TMUX);
12
17
  }
18
+ /** Is the canvas tmux SERVER reachable? The canvas runs on the DEFAULT tmux
19
+ * socket (no -L/-S in core/runtime/tmux.ts), so any `crtr` child — including a
20
+ * headless-broker child that has deleted its own $TMUX — can split panes into
21
+ * it. The human surface gates on THIS (can I reach the server to open a pane
22
+ * the user is watching?), not on the caller's $TMUX. `list-clients` exits 0
23
+ * whenever a server is up (even with zero attached clients) and non-zero when
24
+ * no server is running — the true "no tmux to surface into" case that should
25
+ * degrade to the inbox-drain follow-up. */
26
+ export function tmuxServerReachable() {
27
+ const r = spawnSync('tmux', ['list-clients', '-F', '#{client_name}'], { encoding: 'utf8' });
28
+ return r.status === 0;
29
+ }
13
30
  export function shellQuote(s) {
14
31
  return `'${s.replace(/'/g, "'\\''")}'`;
15
32
  }
@@ -34,7 +51,7 @@ export function countPanesInCurrentWindow() {
34
51
  * output on an unresolvable pane, so test for non-empty stdout, not just `.ok`.
35
52
  * False outside tmux / on error. */
36
53
  export function paneAlive(pane) {
37
- if (!isInTmux() || !/^%\d+$/.test(pane))
54
+ if (!/^%\d+$/.test(pane))
38
55
  return false;
39
56
  const r = spawnSync('tmux', ['display-message', '-p', '-t', pane, '#{pane_id}'], {
40
57
  encoding: 'utf8',
@@ -46,7 +63,7 @@ export function paneAlive(pane) {
46
63
  * specify pane here"); only split-window -t takes a pane. null outside tmux /
47
64
  * on a bad pane id / on error / empty. */
48
65
  export function paneWindowTarget(pane) {
49
- if (!isInTmux() || !/^%\d+$/.test(pane))
66
+ if (!/^%\d+$/.test(pane))
50
67
  return null;
51
68
  const r = spawnSync('tmux', ['display-message', '-p', '-t', pane, '#{session_name}:#{window_index}'], { encoding: 'utf8' });
52
69
  if (r.status !== 0)
@@ -59,8 +76,6 @@ export function paneWindowTarget(pane) {
59
76
  * to surface a human prompt in the user's view when nothing in the asking
60
77
  * node's graph is focused. null outside tmux / no client / on error. */
61
78
  export function attachedClientPane() {
62
- if (!isInTmux())
63
- return null;
64
79
  const clients = spawnSync('tmux', ['list-clients', '-F', '#{client_name}'], {
65
80
  encoding: 'utf8',
66
81
  });
@@ -102,10 +117,15 @@ export function scheduleKillCurrentPane(delaySeconds) {
102
117
  * as soon as the new pane is up; does NOT wait for the command to finish.
103
118
  */
104
119
  export function spawnAndDetach(opts) {
105
- if (!isInTmux()) {
120
+ // Gate on the CANVAS tmux server's reachability, NOT the caller's $TMUX. A
121
+ // headless-broker child strips its own $TMUX (broker.ts) but the canvas server
122
+ // is on the default socket, so `split-window -t <pane>` against a resolved
123
+ // target pane (resolveHumanTarget) works fine from it. Only a genuine
124
+ // no-server case degrades to the inbox-drain follow-up.
125
+ if (!tmuxServerReachable()) {
106
126
  return {
107
127
  status: 'not-in-tmux',
108
- message: 'handoff requires tmux (TMUX env var not set)',
128
+ message: 'handoff requires a reachable tmux server',
109
129
  };
110
130
  }
111
131
  const splitArgs = [];
@@ -1,4 +1,4 @@
1
- export { KINDS, isDocKind, RUNGS, rungRank, rungAtLeast, KIND_DEFAULT_RUNGS, parseSubstrateFrontmatter, parseSubstrateDoc, previewLine, } from './schema.js';
1
+ export { KINDS, isDocKind, RUNGS, rungRank, rungAtLeast, FALLBACK_RUNG, parseSubstrateFrontmatter, parseSubstrateDoc, previewLine, } from './schema.js';
2
2
  export type { DocKind, Rung, GatePredicate, SubstrateSchema, SubstrateDoc } from './schema.js';
3
3
  export { scopeForCwd, spineDepth, assembleNodeSubject } from './subject.js';
4
4
  export type { NodeConfigSubject } from './subject.js';
@@ -1,15 +1,15 @@
1
1
  // substrate/ — the unified document-substrate keystone: the frontmatter schema,
2
- // the 4-rung visibility ladder, the kind→default-rungs table, the node-config
3
- // subject assembly, and gate evaluation. Pure + well-typed; the shared base the
4
- // CLI verbs, boot render, on-read render, and migrator all build on. See
5
- // design-substrate.md §4/§9 + plan-substrate.md §2.
2
+ // the 4-rung visibility ladder, the node-config subject assembly, and gate
3
+ // evaluation. Pure + well-typed; the shared base the CLI verbs, boot render,
4
+ // on-read render, and migrator all build on. See design-substrate.md §4 +
5
+ // plan-substrate.md §2.
6
6
  export {
7
7
  // kinds
8
8
  KINDS, isDocKind,
9
9
  // ladder
10
10
  RUNGS, rungRank, rungAtLeast,
11
- // defaults
12
- KIND_DEFAULT_RUNGS,
11
+ // fallback floor
12
+ FALLBACK_RUNG,
13
13
  // parse + render-shared helpers
14
14
  parseSubstrateFrontmatter, parseSubstrateDoc, previewLine, } from './schema.js';
15
15
  export { scopeForCwd, spineDepth, assembleNodeSubject } from './subject.js';
@@ -0,0 +1,10 @@
1
+ /** Rehydrate a node's on-read dedup set from disk. Returns an empty set when the
2
+ * file is absent, unreadable, or malformed (a fresh transcript, or a node that
3
+ * has not yet surfaced any on-read doc). */
4
+ export declare function loadInjectedDocs(nodeId: string): Set<string>;
5
+ /** Persist a node's on-read dedup set. Called after each read that surfaced a
6
+ * new doc, so the grown set survives a later dormancy. */
7
+ export declare function saveInjectedDocs(nodeId: string, seen: Set<string>): void;
8
+ /** Drop a node's persisted dedup set. Called by the launch paths that start a
9
+ * FRESH transcript, so the new conversation surfaces docs from scratch. */
10
+ export declare function clearInjectedDocs(nodeId: string): void;
@@ -0,0 +1,55 @@
1
+ // injected-store.ts — durable per-node dedup set for on-read doc injection.
2
+ //
3
+ // The on-read substrate hook (canvas-doc-substrate.ts → renderOnReadDocs) dedups
4
+ // so a given doc surfaces at most once per conversation. That set USED to live
5
+ // only in the pi process heap, cleared on session_start. But a node's logical
6
+ // session — the .jsonl transcript — spans MULTIPLE pi processes: a dormancy →
7
+ // revive(resume) cycle exits the old process and launches a fresh `pi --session`
8
+ // that REUSES the same transcript. The fresh process started with an empty set,
9
+ // so any doc already injected before dormancy got injected AGAIN on the next
10
+ // read — the "fires a second time per session" bug.
11
+ //
12
+ // This module persists the set to `nodes/<id>/injected-docs.json` so the resumed
13
+ // process rehydrates it and skips docs already present in the transcript. The
14
+ // launch paths that begin a FRESH transcript (reviveNode resume=false,
15
+ // reviveInPlace, relaunchRootInPane — all in runtime/revive.ts) call
16
+ // clearInjectedDocs(), so a new conversation starts with an empty set.
17
+ //
18
+ // All ops are best-effort: a failed read/write degrades to a possible re-inject,
19
+ // never a crash — a dedup miss must never break a read or a revive.
20
+ import { readFileSync, writeFileSync, rmSync } from 'node:fs';
21
+ import { injectedDocsPath } from '../canvas/paths.js';
22
+ /** Rehydrate a node's on-read dedup set from disk. Returns an empty set when the
23
+ * file is absent, unreadable, or malformed (a fresh transcript, or a node that
24
+ * has not yet surfaced any on-read doc). */
25
+ export function loadInjectedDocs(nodeId) {
26
+ try {
27
+ const arr = JSON.parse(readFileSync(injectedDocsPath(nodeId), 'utf8'));
28
+ if (!Array.isArray(arr))
29
+ return new Set();
30
+ return new Set(arr.filter((x) => typeof x === 'string'));
31
+ }
32
+ catch {
33
+ return new Set();
34
+ }
35
+ }
36
+ /** Persist a node's on-read dedup set. Called after each read that surfaced a
37
+ * new doc, so the grown set survives a later dormancy. */
38
+ export function saveInjectedDocs(nodeId, seen) {
39
+ try {
40
+ writeFileSync(injectedDocsPath(nodeId), JSON.stringify([...seen]));
41
+ }
42
+ catch {
43
+ // best-effort — a failed persist only risks a re-inject, never a crash
44
+ }
45
+ }
46
+ /** Drop a node's persisted dedup set. Called by the launch paths that start a
47
+ * FRESH transcript, so the new conversation surfaces docs from scratch. */
48
+ export function clearInjectedDocs(nodeId) {
49
+ try {
50
+ rmSync(injectedDocsPath(nodeId), { force: true });
51
+ }
52
+ catch {
53
+ // ignore — absence is the desired state anyway
54
+ }
55
+ }
@@ -11,10 +11,7 @@ export declare function rungRank(r: Rung): number;
11
11
  /** Does rung `r` disclose at least as much as `min`? — e.g.
12
12
  * `rungAtLeast(doc.systemPromptVisibility, 'name')` ⇒ "shows at boot at all". */
13
13
  export declare function rungAtLeast(r: Rung, min: Rung): boolean;
14
- export declare const KIND_DEFAULT_RUNGS: Record<DocKind, {
15
- systemPrompt: Rung;
16
- fileRead: Rung;
17
- }>;
14
+ export declare const FALLBACK_RUNG: Rung;
18
15
  /** A gate predicate tree, evaluated by predicate.ts (`evalCondition`) against
19
16
  * the node-config subject. Typed loosely on purpose — the matcher engine owns
20
17
  * validation; structurally it is a field→matcher map with optional
@@ -63,10 +60,11 @@ export interface SubstrateDoc extends SubstrateSchema {
63
60
  /** Parse a raw frontmatter record (from `parseFrontmatterGeneric`, via the
64
61
  * resolver) into a typed schema with defaults applied. Returns `null` when the
65
62
  * record is absent or carries no valid `kind` — i.e. it is not a substrate
66
- * document and cannot be classified or defaulted. Tolerant of every other
67
- * imperfection (a missing `when-and-why-to-read` defaults to '', a bad rung
68
- * falls back to the kind default), so a renderer mapping over many docs never
69
- * throws. Authoring-time enforcement of the field lives in `crtr memory lint`. */
63
+ * document and cannot be classified. Tolerant of every other imperfection (a
64
+ * missing `when-and-why-to-read` defaults to '', a missing/bad rung falls back
65
+ * to the neutral floor `none`), so a renderer mapping over many docs never
66
+ * throws. Authoring-time enforcement of these fields lives in `crtr memory
67
+ * write` (on create) and `crtr memory lint`. */
70
68
  export declare function parseSubstrateFrontmatter(fm: Record<string, unknown> | null): SubstrateSchema | null;
71
69
  /** Parse a resolved MemoryDoc into a fully-typed SubstrateDoc (schema + the
72
70
  * resolver's name/scope/path/body). Returns `null` for a non-substrate doc