@crouton-kit/crouter 0.3.24 → 0.3.26

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 (38) hide show
  1. package/dist/builtin-memory/internal/INDEX.md +21 -0
  2. package/dist/builtin-memory/internal/examples/INDEX.md +15 -0
  3. package/dist/builtin-memory/internal/examples/imessage-assistant.md +100 -0
  4. package/dist/builtin-memory/internal/nodes-and-canvas.md +49 -0
  5. package/dist/builtin-memory/internal/storage-tiers.md +32 -0
  6. package/dist/builtin-memory/memory-authoring.md +100 -0
  7. package/dist/builtin-personas/orchestration-kernel.md +4 -4
  8. package/dist/clients/attach/attach-cmd.js +26 -0
  9. package/dist/commands/node.d.ts +15 -6
  10. package/dist/commands/node.js +33 -9
  11. package/dist/commands/skill/shared.d.ts +0 -5
  12. package/dist/commands/skill/shared.js +0 -81
  13. package/dist/core/__tests__/context-intro.test.js +23 -20
  14. package/dist/core/__tests__/full/broker-pane-resolution.test.d.ts +1 -0
  15. package/dist/core/__tests__/full/broker-pane-resolution.test.js +84 -0
  16. package/dist/core/__tests__/memory-resolver.test.js +21 -2
  17. package/dist/core/memory-resolver.js +17 -12
  18. package/dist/core/runtime/bearings.d.ts +1 -1
  19. package/dist/core/runtime/bearings.js +2 -2
  20. package/dist/core/runtime/broker.js +4 -2
  21. package/dist/core/runtime/launch.d.ts +2 -2
  22. package/dist/core/runtime/launch.js +2 -2
  23. package/dist/core/runtime/placement.d.ts +13 -0
  24. package/dist/core/runtime/placement.js +23 -7
  25. package/dist/core/runtime/recycle.js +65 -10
  26. package/dist/core/substrate/ceiling.d.ts +0 -7
  27. package/dist/core/substrate/ceiling.js +0 -16
  28. package/dist/core/substrate/index.d.ts +2 -2
  29. package/dist/core/substrate/index.js +2 -2
  30. package/dist/core/substrate/render.d.ts +19 -20
  31. package/dist/core/substrate/render.js +271 -171
  32. package/dist/core/substrate/session-cache.d.ts +0 -13
  33. package/dist/core/substrate/session-cache.js +0 -12
  34. package/dist/index.d.ts +19 -1
  35. package/dist/index.js +26 -1
  36. package/dist/pi-extensions/canvas-context-intro.js +3 -3
  37. package/dist/pi-extensions/canvas-doc-substrate.js +13 -5
  38. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  // Tests for the <crtr-context> bearings preamble:
2
- // 1. Worker and orchestrator bearings carry the `## References` block
3
- // (substrate reference docs + node-local docs as `###` sub-sections).
2
+ // 1. Worker and orchestrator bearings carry the `<references>` block
3
+ // (substrate reference docs + node-local docs as a file tree).
4
4
  // Orchestrators add the across-cycles framing; promotion delivers that
5
5
  // same orchestrator context-dir framing to a node that spawned base.
6
6
  // 2. canvas-context-intro injects the block as its own session message at
@@ -34,10 +34,10 @@ after(() => {
34
34
  delete process.env['CRTR_HOME'];
35
35
  delete process.env['CRTR_NODE_ID'];
36
36
  });
37
- test('worker bearings: base framing + ## References block, NO across-cycles framing', () => {
37
+ test('worker bearings: base framing + <references> block, NO across-cycles framing', () => {
38
38
  // Bug-regression: review finding M1 — buildContextBearings renders the
39
- // ## References block (renderReferencesBlock): ## References + ### <name>
40
- // sub-sections. This test locks in that contract.
39
+ // <references> block (renderReferencesBlock): a kind-wrapped file tree of
40
+ // reference docs. This test locks in that contract.
41
41
  const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
42
42
  // Seed a node-local substrate doc so the ## References block is non-empty.
43
43
  const dir = memoryDir(meta.node_id);
@@ -46,21 +46,25 @@ test('worker bearings: base framing + ## References block, NO across-cycles fram
46
46
  const block = buildContextIntro(meta.node_id);
47
47
  assert.match(block, new RegExp(`<crtr-context dir="${contextDir(meta.node_id)}">`));
48
48
  assert.match(block, /shared document store, not a task tracker/, 'base = shared docs, not tasks');
49
- // Reference content renders ONLY as ## References + ### sub-sections.
49
+ // Reference content renders ONLY as the <references> file-tree block.
50
50
  assert.doesNotMatch(block, /<memory>/, 'no <memory> block');
51
51
  // Per-store stanza headers (label · dir) never appear.
52
52
  assert.doesNotMatch(block, /user-global · /, 'no user-global label·dir stanza');
53
53
  assert.doesNotMatch(block, /node-local · /, 'no node-local label·dir stanza');
54
54
  // No (empty) placeholder marker.
55
55
  assert.doesNotMatch(block, /\(empty\)/, 'no (empty) placeholder');
56
- assert.match(block, /## References\n\n###/, '## References followed by ### sub-sections');
56
+ assert.match(block, /<references>/, '<references> block present');
57
+ assert.match(block, /\nreferences\n/, 'tree headed by the `references` root label');
58
+ // The node-local preview-rung doc renders as a tree entry with a `# read when:`
59
+ // routing line (verbatim previewLine output).
60
+ assert.match(block, /test-ref {2}# read when: When testing, this reference should be read because it is a regression fixture\./, 'node-local preview doc renders its routing line');
57
61
  // A terminal worker must NOT carry the orchestrator across-cycles framing.
58
62
  assert.doesNotMatch(block, /refresh cycles/, 'no across-cycles framing for a terminal worker');
59
63
  assert.match(block, /<\/crtr-context>/);
60
64
  });
61
- test('orchestrator bearings: across-cycles framing + node-local substrate docs ride into ## References; a non-substrate .md file is never inlined', () => {
62
- // Bug-regression: review finding M1 — buildContextBearings renders ## References.
63
- // Node-local substrate docs render as ### sub-sections at their rung; a
65
+ test('orchestrator bearings: across-cycles framing + node-local substrate docs ride into <references>; a non-substrate .md file is never inlined', () => {
66
+ // Bug-regression: review finding M1 — buildContextBearings renders <references>.
67
+ // Node-local substrate docs render as tree entries at their rung; a
64
68
  // non-substrate .md file (no frontmatter `kind`) never surfaces.
65
69
  const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
66
70
  promote(meta.node_id); // flip to orchestrator mode — the across-cycles gate
@@ -75,9 +79,8 @@ test('orchestrator bearings: across-cycles framing + node-local substrate docs r
75
79
  assert.match(block, /shared document store, not a task tracker/, 'still carries the base framing');
76
80
  assert.match(block, /refresh cycles/, 'orchestrator gets the across-cycles framing');
77
81
  assert.doesNotMatch(block, /<memory>/, 'no <memory> block');
78
- assert.match(block, /## References\n/, 'references block present');
79
- assert.match(block, /### flaky-build\n/, 'node-local doc gets its ### sub-section');
80
- assert.match(block, /When the build flakes, this reference should be read because the first run fails\./, 'preview rung renders the routing line');
82
+ assert.match(block, /<references>/, 'references block present');
83
+ assert.match(block, /flaky-build {2}# read when: When the build flakes, this reference should be read because the first run fails\./, 'node-local doc renders as a tree entry with its routing line');
81
84
  // The non-substrate file never renders: no header line, no pointer line, no path.
82
85
  assert.ok(!block.includes('# memory index'), 'the index header comment is NOT inlined');
83
86
  assert.ok(!block.includes('- [Flaky build](flaky-build.md)'), 'index pointer lines are NOT inlined');
@@ -85,12 +88,12 @@ test('orchestrator bearings: across-cycles framing + node-local substrate docs r
85
88
  assert.ok(!block.includes('node-local · '), 'no label·dir stanza header');
86
89
  assert.match(block, /<\/crtr-context>/);
87
90
  });
88
- test('orchestrator bearings: no per-store stanzas or (empty) markers; a rung-none node-local doc still surfaces as a ### title stub', () => {
89
- // Bug-regression: review findings M1 + M6 — the ## References block carries no
91
+ test('orchestrator bearings: no per-store stanzas or (empty) markers; a rung-none node-local doc still surfaces as a bare-name tree entry', () => {
92
+ // Bug-regression: review findings M1 + M6 — the <references> block carries no
90
93
  // per-store `label · dir` stanzas or (empty) markers, and node-local docs are
91
94
  // NOT filtered on rung: a migrated node-local reference defaults
92
- // system-prompt-visibility: none and must still ride into ## References as
93
- // its bare title (never its body).
95
+ // system-prompt-visibility: none and must still ride into <references> as
96
+ // its bare name (floored to the `name` rung; never its body).
94
97
  const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
95
98
  promote(meta.node_id); // flip to orchestrator mode
96
99
  const dir = memoryDir(meta.node_id);
@@ -103,9 +106,9 @@ test('orchestrator bearings: no per-store stanzas or (empty) markers; a rung-non
103
106
  assert.ok(!block.includes('project · '), 'no project stanza header');
104
107
  assert.doesNotMatch(block, /\(empty\)/, 'no (empty) markers');
105
108
  assert.ok(!block.includes('MEMORY.md'), 'no MEMORY.md path in the block');
106
- // M6: rung-none node-local doc surfaces as its title stub only.
107
- assert.match(block, /### rung-none-fact\s*\n/, 'rung-none node-local doc surfaces as its title stub');
108
- assert.ok(!block.includes('body that must not render'), 'none rung renders the title only, not the body');
109
+ // M6: rung-none node-local doc surfaces as a bare-name tree entry only.
110
+ assert.match(block, /─ rung-none-fact\n/, 'rung-none node-local doc surfaces as a bare-name tree entry');
111
+ assert.ok(!block.includes('body that must not render'), 'none rung renders the name only, not the body');
109
112
  });
110
113
  test('promotion guidance delivers the orchestrator context-dir framing', () => {
111
114
  // A node spawns as a base worker (no orchestrator bearings). On promotion the
@@ -0,0 +1,84 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/full/broker-pane-resolution.test.ts
2
+ //
3
+ // REGRESSION — broker-hosted nodes were second-class in the tmux command
4
+ // surface (audit: findings-headless-default.md, Gaps 1 + 2). Two real bugs,
5
+ // both observable only with a REAL tmux pane, so this is a FULL-tier test.
6
+ //
7
+ // (1) Gap 1 — `nodeInPane()` (src/commands/node.ts) resolved a pane via
8
+ // window→node ONLY. A broker engine runs DETACHED with window=null, and its
9
+ // on-screen presence is a `crtr attach` VIEWER pane, so the window lookup
10
+ // never found it: `node cycle`/`recycle`/`close`/`demote`/`lifecycle` all
11
+ // broke when the current pane was a broker viewer. The fix has attach
12
+ // self-tag its pane `@crtr_node=<id>` and nodeInPane resolve that tag first.
13
+ // LOCKED: an untagged pane over a broker resolves undefined (the bug); the
14
+ // SAME pane, once tagged, resolves the broker (the fix).
15
+ //
16
+ // (2) Gap 2 — `recycleNode()` (src/core/runtime/recycle.ts) always respawned the
17
+ // pane into a fresh TMUX pi root, even for a broker node — silently turning
18
+ // the viewer pane into a tmux engine and dropping out of the broker host
19
+ // model. The fix preserves host_kind: recycling a broker node finalizes it,
20
+ // tears the broker down, and boots a fresh BROKER root the pane re-attaches
21
+ // to. LOCKED: the recycled node is finalized (done) and the fresh root is
22
+ // broker-hosted, not tmux.
23
+ import { test, before, after } from 'node:test';
24
+ import assert from 'node:assert/strict';
25
+ import { spawnSync } from 'node:child_process';
26
+ import { createHarness, hasTmux } from '../helpers/harness.js';
27
+ import { closeDb } from '../../canvas/db.js';
28
+ import { nodeInPane } from '../../../commands/node.js';
29
+ import { recycleNode } from '../../runtime/recycle.js';
30
+ let h;
31
+ let root;
32
+ before(async () => {
33
+ h = await createHarness({ sessionPrefix: 'crtr-brkpane' });
34
+ root = h.spawnRoot('host root');
35
+ });
36
+ after(async () => {
37
+ if (h !== undefined)
38
+ await h.dispose();
39
+ });
40
+ /** Split a throwaway pane in the harness session; return its %id. */
41
+ function makePane() {
42
+ const r = spawnSync('tmux', ['split-window', '-d', '-t', h.session, '-P', '-F', '#{pane_id}', 'sleep 100000'], { encoding: 'utf8' });
43
+ const pane = (r.stdout ?? '').trim();
44
+ assert.ok(pane.startsWith('%'), `expected a %pane_id, got "${pane}" (stderr: ${r.stderr})`);
45
+ return pane;
46
+ }
47
+ function tagPane(pane, value) {
48
+ spawnSync('tmux', ['set-option', '-p', '-t', pane, '@crtr_node', value], { stdio: 'ignore' });
49
+ }
50
+ test('Gap 1 — nodeInPane resolves a broker node from its tagged viewer pane (undefined when untagged)', async (t) => {
51
+ if (!hasTmux())
52
+ return t.skip('tmux unavailable');
53
+ const broker = await h.spawnHeadlessChild(root, 'broker worker');
54
+ closeDb();
55
+ assert.equal(h.node(broker).host_kind, 'broker', 'spawned a broker-hosted node');
56
+ assert.equal(h.node(broker).window ?? null, null, 'broker engine has window=null (paneless)');
57
+ const viewer = makePane();
58
+ // BUG REPRODUCTION: an untagged pane over a broker is invisible — no node owns
59
+ // this pane's window, and a broker has no window to find, so resolution fails.
60
+ closeDb();
61
+ assert.equal(nodeInPane(viewer), undefined, 'untagged viewer pane → broker is invisible (the bug)');
62
+ // FIX: the viewer self-tag (`@crtr_node`) is the broker's pane handle.
63
+ tagPane(viewer, broker);
64
+ closeDb();
65
+ assert.equal(nodeInPane(viewer), broker, 'tagged viewer pane → resolves the broker (the fix)');
66
+ // A stale tag pointing at a DONE node is ignored (must be live: active/idle).
67
+ spawnSync('tmux', ['kill-pane', '-t', viewer], { stdio: 'ignore' });
68
+ });
69
+ test('Gap 2 — recycle preserves the broker host: finalizes the node, boots a fresh BROKER root', async (t) => {
70
+ if (!hasTmux())
71
+ return t.skip('tmux unavailable');
72
+ const broker = await h.spawnHeadlessChild(root, 'broker to recycle');
73
+ const viewer = makePane();
74
+ tagPane(viewer, broker);
75
+ closeDb();
76
+ assert.equal(nodeInPane(viewer), broker, 'precondition: viewer resolves the broker');
77
+ const res = await recycleNode(broker, viewer);
78
+ closeDb();
79
+ assert.equal(h.node(broker).status, 'done', 'recycled broker node is finalized (done)');
80
+ assert.ok(res.newRoot, 'a fresh root was spawned');
81
+ const fresh = h.node(res.newRoot);
82
+ assert.equal(fresh.host_kind, 'broker', 'fresh root PRESERVES the broker host (not a tmux pi root)');
83
+ assert.equal(fresh.parent ?? null, null, 'fresh root is a root (parent=null)');
84
+ });
@@ -10,8 +10,15 @@
10
10
  // resolved. The fix taught findMemoryMatches to resolve `<plugin>/<rest>` against
11
11
  // each enabled plugin's pluginMemoryDir, native-before-plugin precedence kept.
12
12
  //
13
- // This test FAILS on the pre-fix code (the two fully-qualified reads throw
14
- // notFound) and PASSES on the current code.
13
+ // Follow-up bug (same seat): the `<plugin>/<rest>` branch required a non-empty
14
+ // rest (`if (slash <= 0) continue`), so a BARE plugin name never tried the
15
+ // plugin-root INDEX.md — `read claude-godot-prompter` / `read ai` returned
16
+ // not_found even though `<plugin>/INDEX` and `<plugin>/<child>` resolved. The
17
+ // fix resolves a bare plugin name to `<plugin>/memory/INDEX.md`, mirroring the
18
+ // native bare-dir-name -> INDEX.md contract, native-before-plugin kept.
19
+ //
20
+ // This test FAILS on the pre-fix code (the fully-qualified and bare-plugin-name
21
+ // reads throw notFound) and PASSES on the current code.
15
22
  //
16
23
  // Run: node --import tsx/esm --test src/core/__tests__/memory-resolver.test.ts
17
24
  import { test, before, after } from 'node:test';
@@ -48,6 +55,9 @@ before(() => {
48
55
  writeFileSync(join(pluginMem, 'widget.md'), doc('plugin-widget'));
49
56
  writeFileSync(join(pluginMem, 'area', 'zone.md'), doc('plugin-zone'));
50
57
  writeFileSync(join(pluginMem, 'shared.md'), doc('plugin-shared'));
58
+ // Plugin-root INDEX.md: a BARE plugin name must resolve this (mirrors the
59
+ // native bare-dir-name -> INDEX.md contract for the plugin mount root).
60
+ writeFileSync(join(pluginMem, 'INDEX.md'), doc('plugin-root-index'));
51
61
  // Explicitly enable the plugin in config (default is enabled, but be robust).
52
62
  writeFileSync(join(scopeRoot, 'config.json'), JSON.stringify({ plugins: { [PLUGIN]: { enabled: true } } }));
53
63
  // Point the scope resolver at the fixture: project scope is found by walking
@@ -78,6 +88,15 @@ test('the bare leaf still resolves via last-segment fallback', () => {
78
88
  const d = resolveMemoryDoc('widget');
79
89
  assert.equal(d.name, `${PLUGIN}/widget`);
80
90
  });
91
+ test('bare plugin name resolves the plugin-root INDEX.md', () => {
92
+ // Regression: findMemoryMatches skipped the plugin branch for a bare name
93
+ // (`if (slash <= 0) continue`), so a bare plugin name never tried the
94
+ // plugin-root INDEX.md and returned not_found — even though `<plugin>/INDEX`
95
+ // resolved. The fix resolves the bare mount root to <plugin>/memory/INDEX.md.
96
+ const d = resolveMemoryDoc(PLUGIN);
97
+ assert.equal(d.name, PLUGIN);
98
+ assert.ok(d.path.endsWith(join('plugins', PLUGIN, 'memory', 'INDEX.md')));
99
+ });
81
100
  test('native doc wins over a plugin doc of the same canonical name', () => {
82
101
  // native-before-plugin precedence: findMemoryMatches checks scopeMemoryDir
83
102
  // before pluginMemoryDir, so the native shadow at memory/fixplug/shared.md
@@ -111,24 +111,29 @@ function findMemoryMatches(name, scope) {
111
111
  continue;
112
112
  }
113
113
  }
114
- // Plugin memory dir: a fully-qualified `<plugin>/<rest>` name resolves
115
- // against that enabled plugin's memory/ tree (the `<pluginName>/` mount
116
- // that listAllMemoryDocs enumerates — `read` must resolve what `list` shows).
114
+ // Plugin memory dir: a `<plugin>/<rest>` name resolves against that enabled
115
+ // plugin's memory/ tree (the `<pluginName>/` mount that listAllMemoryDocs
116
+ // enumerates — `read` must resolve what `list` shows). A BARE plugin name
117
+ // (no slash) resolves the plugin-root INDEX.md, mirroring the native
118
+ // bare-dir-name -> INDEX.md contract for the plugin mount root.
117
119
  const slash = name.indexOf('/');
118
- if (slash <= 0)
119
- continue;
120
- const pluginName = name.slice(0, slash);
121
- const rest = name.slice(slash + 1);
120
+ const pluginName = slash > 0 ? name.slice(0, slash) : name;
121
+ const rest = slash > 0 ? name.slice(slash + 1) : '';
122
122
  for (const p of listInstalledPlugins(s)) {
123
123
  if (!p.enabled || p.name !== pluginName)
124
124
  continue;
125
125
  const pdir = pluginMemoryDir(p);
126
- const ppath = join(pdir, ...rest.split('/')) + '.md';
127
- if (pathExists(ppath)) {
128
- matches.push(loadMemoryDoc(name, s, ppath));
129
- break;
126
+ if (rest) {
127
+ const ppath = join(pdir, ...rest.split('/')) + '.md';
128
+ if (pathExists(ppath)) {
129
+ matches.push(loadMemoryDoc(name, s, ppath));
130
+ break;
131
+ }
130
132
  }
131
- const pindex = join(pdir, ...rest.split('/'), 'INDEX.md');
133
+ // Bare name -> <plugin>/memory/INDEX.md; slashed name -> dir INDEX.
134
+ const pindex = rest
135
+ ? join(pdir, ...rest.split('/'), 'INDEX.md')
136
+ : join(pdir, 'INDEX.md');
132
137
  if (pathExists(pindex)) {
133
138
  matches.push(loadMemoryDoc(name, s, pindex));
134
139
  break;
@@ -56,6 +56,6 @@ export declare function buildWakeBearings(origin: WakeOrigin): string;
56
56
  * any copied-in persona) followed by the `<crtr-context>` bearings block. Base
57
57
  * framing rides for EVERY node; the across-cycles context-dir note is added
58
58
  * ONLY for an orchestrator (by mode) — the one node whose dir a future cycle
59
- * resumes from. The `## References` block carries the substrate reference docs
59
+ * resumes from. The `<references>` block carries the substrate reference docs
60
60
  * + node-local docs. */
61
61
  export declare function buildContextBearings(nodeId: string): string;
@@ -19,7 +19,7 @@
19
19
  // one place other nodes on the canvas can read from, so it is for documents
20
20
  // worth a shared reference, NOT a task tracker, and NOT a "future memory-wiped
21
21
  // you" stash (a terminal worker has no future cycle — that framing only makes
22
- // sense once a node is a resident orchestrator). The `## References` block
22
+ // sense once a node is a resident orchestrator). The `<references>` block
23
23
  // (substrate reference docs + node-local docs) rides into the context message.
24
24
  //
25
25
  // Orchestrator addendum (gated on orchestrator MODE): the dir ALSO survives
@@ -145,7 +145,7 @@ export function buildWakeBearings(origin) {
145
145
  * any copied-in persona) followed by the `<crtr-context>` bearings block. Base
146
146
  * framing rides for EVERY node; the across-cycles context-dir note is added
147
147
  * ONLY for an orchestrator (by mode) — the one node whose dir a future cycle
148
- * resumes from. The `## References` block carries the substrate reference docs
148
+ * resumes from. The `<references>` block carries the substrate reference docs
149
149
  * + node-local docs. */
150
150
  export function buildContextBearings(nodeId) {
151
151
  const identity = buildIdentityAssertion(nodeId);
@@ -746,8 +746,10 @@ export async function runBroker(nodeId) {
746
746
  break;
747
747
  }
748
748
  case 'get_commands': {
749
- if (notController(client, 'list commands'))
750
- break;
749
+ // OBSERVERS may call this: the merged command inventory is static
750
+ // (extensions + templates + skills + builtins) and drives nothing in the
751
+ // engine, so it is not a controller-gated op. The web bridge fetches it on
752
+ // its observer-by-default upstream connection to populate the palette.
751
753
  // The merged command list rides in ack.detail as JSON (the foundation's
752
754
  // AckFrame.detail field) — the viewer (T6) JSON.parses it when for ===
753
755
  // 'get_commands'. Keeps every command op a uniform ack reply.
@@ -15,8 +15,8 @@ export declare const CANVAS_VIEW_PATH: string;
15
15
  * passive-context (drain passive backlog as pre-text on the next message),
16
16
  * context-intro (inject the <crtr-context> bearings block as its own session
17
17
  * message, once per brand-new chat), doc-substrate (the unified document
18
- * substrate's two hooks: ## Skills + ## Preferences at boot, on-read context
19
- * injection), commands (the /promote slash-command), resume (the /resume-node
18
+ * substrate's two hooks: <skills> + <preferences> + <memory-guidance> at boot,
19
+ * on-read context injection), commands (the /promote slash-command), resume (the /resume-node
20
20
  * whole-canvas picker → `crtr node focus`), view (the /view popup → `crtr
21
21
  * view pick` / `crtr view run <name>`).
22
22
  * All self-gate on CRTR_NODE_ID. goal-capture precedes passive-context so it
@@ -42,8 +42,8 @@ export const CANVAS_VIEW_PATH = resolveExtension('canvas-view');
42
42
  * passive-context (drain passive backlog as pre-text on the next message),
43
43
  * context-intro (inject the <crtr-context> bearings block as its own session
44
44
  * message, once per brand-new chat), doc-substrate (the unified document
45
- * substrate's two hooks: ## Skills + ## Preferences at boot, on-read context
46
- * injection), commands (the /promote slash-command), resume (the /resume-node
45
+ * substrate's two hooks: <skills> + <preferences> + <memory-guidance> at boot,
46
+ * on-read context injection), commands (the /promote slash-command), resume (the /resume-node
47
47
  * whole-canvas picker → `crtr node focus`), view (the /view popup → `crtr
48
48
  * view pick` / `crtr view run <name>`).
49
49
  * All self-gate on CRTR_NODE_ID. goal-capture precedes passive-context so it
@@ -266,6 +266,19 @@ export declare function focus(nodeId: string, opts: {
266
266
  callerNode?: string;
267
267
  revive: Reviver;
268
268
  }): FocusResult;
269
+ /** Synchronously wait until a broker's view.sock accepts a connection. `focus()`
270
+ * is sync today (the command layer calls it directly), so the readiness probe
271
+ * lives in a short child Node process that can use async net events while this
272
+ * process blocks in `spawnSync`. Success proves more than file existence: it is
273
+ * robust to a stale leftover socket that the launching broker has not unlinked
274
+ * yet, because only an accepting listener exits 0. */
275
+ export declare function waitForBrokerViewSocket(nodeId: string): boolean;
276
+ /** Env for a `crtr attach` VIEWER pane (the focusBroker split AND recycle's
277
+ * re-attach respawn): propagate CRTR_HOME so the viewer resolves the SAME canvas
278
+ * home — and thus the right view.sock — under a non-default override; otherwise
279
+ * an empty env (the pane inherits the tmux server env, like every other crtr
280
+ * chrome pane). One helper so the two viewer-pane sites can't drift. */
281
+ export declare function viewerSplitEnv(): Record<string, string>;
269
282
  /** Tear a node off its placement (close/reset teardown, §2.3, flow (e)).
270
283
  * Reconcile first (follow a manual move / backfill a legacy pane), close the
271
284
  * focus row it occupies (if any), kill its pane (pane-keyed via the durable
@@ -307,6 +307,18 @@ export function detachToBackground(nodeId, pane) {
307
307
  const row = getRow(nodeId);
308
308
  if (row === null)
309
309
  return false;
310
+ // A broker node has NO engine pane — it is ALREADY headless, so the only thing
311
+ // on screen is a `crtr attach` VIEWER pane. "Detach" (the Alt+C → D let-go)
312
+ // therefore means "stop foregrounding it": close the viewer pane and leave the
313
+ // broker running untouched. The engine-in-pane relocate/release below would
314
+ // mis-anchor the live engine row to the viewer pane and (when idle) flip a
315
+ // LIVE broker to idle-release — corrupting state. Never reach it for a broker.
316
+ if (row.host_kind === 'broker') {
317
+ const viewer = pane ?? null;
318
+ if (viewer !== null && paneExists(viewer))
319
+ return closePane(viewer);
320
+ return false;
321
+ }
310
322
  const target = pane ?? row.pane;
311
323
  if (target === null || !paneExists(target))
312
324
  return false;
@@ -632,7 +644,7 @@ const BROKER_FOCUS_SOCKET_RETRY_MS = 100;
632
644
  * process blocks in `spawnSync`. Success proves more than file existence: it is
633
645
  * robust to a stale leftover socket that the launching broker has not unlinked
634
646
  * yet, because only an accepting listener exits 0. */
635
- function waitForBrokerViewSocket(nodeId) {
647
+ export function waitForBrokerViewSocket(nodeId) {
636
648
  const sockPath = join(nodeDir(nodeId), 'view.sock');
637
649
  const probe = `
638
650
  const net = require('node:net');
@@ -712,17 +724,21 @@ function focusBroker(nodeId, meta, opts) {
712
724
  if (!waitForBrokerViewSocket(nodeId)) {
713
725
  return { focused: false, session: null, inPlace: false, revived };
714
726
  }
715
- // Propagate CRTR_HOME so the viewer resolves the SAME canvas home (and thus
716
- // the right view.sock) under a non-default override; otherwise an empty env
717
- // (the split inherits the tmux server env, like every other crtr chrome pane).
718
- const crtrHome = process.env['CRTR_HOME'];
719
- const env = crtrHome !== undefined ? { CRTR_HOME: crtrHome } : {};
720
727
  // Node ids are shell-safe identifiers (base36-ts + hex); no quoting needed.
721
- const pane = splitWindow(callerPane, { cwd: meta.cwd, env, command: `crtr attach to ${nodeId}` });
728
+ const pane = splitWindow(callerPane, { cwd: meta.cwd, env: viewerSplitEnv(), command: `crtr attach to ${nodeId}` });
722
729
  if (pane === null)
723
730
  return { focused: false, session: null, inPlace: false, revived };
724
731
  return { focused: true, session: paneLocation(pane)?.session ?? null, inPlace: false, revived };
725
732
  }
733
+ /** Env for a `crtr attach` VIEWER pane (the focusBroker split AND recycle's
734
+ * re-attach respawn): propagate CRTR_HOME so the viewer resolves the SAME canvas
735
+ * home — and thus the right view.sock — under a non-default override; otherwise
736
+ * an empty env (the pane inherits the tmux server env, like every other crtr
737
+ * chrome pane). One helper so the two viewer-pane sites can't drift. */
738
+ export function viewerSplitEnv() {
739
+ const crtrHome = process.env['CRTR_HOME'];
740
+ return crtrHome !== undefined ? { CRTR_HOME: crtrHome } : {};
741
+ }
726
742
  /** Register the caller's CURRENT pane as a focus so a `node focus`/`cycle` from a
727
743
  * pane that isn't yet a viewport retargets IN PLACE. Occupied by whatever node
728
744
  * sits in the pane now (`callerNode`, else resolved by pane→row), or a HOLDER
@@ -23,7 +23,7 @@ import { pushFinal } from '../feed/feed.js';
23
23
  import { spawnNode, nodeSession, rootOfSpine } from './nodes.js';
24
24
  import { buildLaunchSpec, buildPiArgv } from './launch.js';
25
25
  import { FRONT_DOOR_ENV } from './front-door.js';
26
- import { focusOf, recycleFocusPane, piCommand, paneLocation } from './placement.js';
26
+ import { focusOf, recycleFocusPane, piCommand, paneLocation, respawnPaneSync, setPaneOption, waitForBrokerViewSocket, viewerSplitEnv } from './placement.js';
27
27
  import { hostFor } from './host.js';
28
28
  import { ensureDaemon } from '../../daemon/manage.js';
29
29
  /** The agent's most recent surfaced message: the newest reports/*.md body with
@@ -74,12 +74,14 @@ export async function recycleNode(nodeId, callerPane) {
74
74
  finalized = true;
75
75
  }
76
76
  catch { /* recycle the pane even if the report failed */ }
77
- // A broker node has NO tmux pane, so recycleFocusPane below (respawn-pane -k)
78
- // never kills its engine route its teardown through the Host seam so the
79
- // broker PROCESS exits and releases the sole .jsonl writer (mirrors the T12
77
+ // A broker node's pane is a VIEWER (`crtr attach`), not its engine: the engine
78
+ // is the detached broker process, so respawn-pane -k below would only kill the
79
+ // viewer, never the engine. Route teardown through the Host seam so the broker
80
+ // PROCESS exits and releases the sole .jsonl writer (mirrors the T12
80
81
  // close.ts/reset.ts fix; review reuse MINOR-3). Status is already flipped done
81
82
  // by pushFinal above (crash-safe order: the daemon won't revive a done node).
82
- if (meta.host_kind === 'broker') {
83
+ const isBroker = meta.host_kind === 'broker';
84
+ if (isBroker) {
83
85
  try {
84
86
  hostFor(meta).teardown(nodeId);
85
87
  }
@@ -93,7 +95,12 @@ export async function recycleNode(nodeId, callerPane) {
93
95
  setPresence(nodeId, { pane: null, window: null, tmux_session: null });
94
96
  }
95
97
  catch { /* best-effort */ }
96
- // 2 + 3. Recycle — boot a fresh resident root in the SAME pane.
98
+ // 2 + 3. Recycle — boot a fresh resident root for the SAME pane, PRESERVING
99
+ // the recycled node's host model: a tmux node recycles into a tmux root in the
100
+ // pane; a broker node recycles into a fresh BROKER root the pane re-attaches to
101
+ // (broker-is-the-host — the viewer pane stays a viewer, never becomes an engine
102
+ // pane). Without preserving host_kind, recycling a broker node from its viewer
103
+ // would silently respawn the viewer into a tmux pi root.
97
104
  try {
98
105
  ensureDaemon();
99
106
  }
@@ -108,6 +115,7 @@ export async function recycleNode(nodeId, callerPane) {
108
115
  name: 'general',
109
116
  parent: null,
110
117
  launch,
118
+ hostKind: isBroker ? 'broker' : 'tmux',
111
119
  });
112
120
  // REVIVE-HOME: a recycled root's durable revive target is the session
113
121
  // of the pane it was recycled into (the one place home_session is rewritten
@@ -123,9 +131,56 @@ export async function recycleNode(nodeId, callerPane) {
123
131
  }
124
132
  const fresh = getNode(root.node_id);
125
133
  const inv = buildPiArgv(fresh);
126
- const env = { ...inv.env, CRTR_ROOT_SESSION: nodeSession(), CRTR_SUBTREE: rootOfSpine(root.node_id), [FRONT_DOOR_ENV]: '1' };
127
- const ok = recycleFocusPane(root.node_id, pane, {
128
- command: piCommand(inv.argv), env, cwd: meta.cwd, name: fullName(fresh), resuming: false,
129
- });
134
+ // CRTR_ROOT_SESSION/CRTR_SUBTREE route the fresh root's children to the
135
+ // backstage (both consumers below read this one authoritative env). FRONT_DOOR
136
+ // is added per-consumer: the tmux command const adds it; the broker host sets
137
+ // it itself, so the broker branch omits it.
138
+ inv.env = { ...inv.env, CRTR_ROOT_SESSION: nodeSession(), CRTR_SUBTREE: rootOfSpine(root.node_id) };
139
+ const ok = isBroker
140
+ ? recycleBrokerViewer(fresh, pane, inv)
141
+ : recycleTmuxRoot(root.node_id, pane, {
142
+ command: piCommand(inv.argv),
143
+ env: { ...inv.env, [FRONT_DOOR_ENV]: '1' },
144
+ cwd: meta.cwd,
145
+ name: fullName(fresh),
146
+ });
130
147
  return { recycled: ok, finalized, newRoot: root.node_id, delivered };
131
148
  }
149
+ /** Recycle a TMUX root into `pane`: respawn-pane -k boots the fresh pi engine in
150
+ * place. Clears any stale `@crtr_node` viewer tag first — a prior `crtr attach`
151
+ * in this pane may have left one (tmux pane options survive respawn-pane), and
152
+ * since nodeInPane checks the tag BEFORE the window, a stale tag would shadow
153
+ * the real window→node lookup for the fresh tmux engine now in the pane. */
154
+ function recycleTmuxRoot(nodeId, pane, launch) {
155
+ try {
156
+ setPaneOption(pane, '@crtr_node', '');
157
+ }
158
+ catch { /* best-effort */ }
159
+ return recycleFocusPane(nodeId, pane, { ...launch, resuming: false });
160
+ }
161
+ /** Recycle a BROKER root into `pane`: the fresh root is broker-hosted, so its
162
+ * engine runs in a DETACHED broker, not the pane. Birth-launch that broker via
163
+ * the Host seam (mirrors spawnChild's birth path — the host records its pid),
164
+ * wait for its view.sock to accept, then respawn the pane in place to the VIEWER
165
+ * `crtr attach to <root>`. The pane stays a viewer (attach self-tags it
166
+ * `@crtr_node`); it never hosts the engine. Returns false (recycle reports the
167
+ * pane was not respawned) when the broker never serves — the fresh root row
168
+ * still exists, broker-hosted, for the daemon to revive. */
169
+ function recycleBrokerViewer(fresh, pane, inv) {
170
+ hostFor(fresh).launch(fresh.node_id, inv, { cwd: fresh.cwd, name: fullName(fresh), resuming: false });
171
+ if (!waitForBrokerViewSocket(fresh.node_id))
172
+ return false;
173
+ // Clear the finalized node's stale `@crtr_node` tag before respawn (symmetry
174
+ // with recycleTmuxRoot) so the tag never names a done node during the gap
175
+ // before the new `crtr attach` re-tags on connect. Node ids are shell-safe
176
+ // identifiers; no quoting needed.
177
+ try {
178
+ setPaneOption(pane, '@crtr_node', '');
179
+ }
180
+ catch { /* best-effort */ }
181
+ // SYNC respawn: the recycle command runs as a SEPARATE CLI process (the viewer
182
+ // runs `crtr attach`, a TUI — it never execs recycle), so we are NOT respawning
183
+ // our own pane and can confirm the respawn actually landed (the honest exit
184
+ // status), unlike the tmux path which often recycles the caller's own pane.
185
+ return respawnPaneSync({ pane, cwd: fresh.cwd, env: viewerSplitEnv(), command: `crtr attach to ${fresh.node_id}` });
186
+ }
@@ -19,10 +19,3 @@ export declare function buildCeilingIndex(docs: SubstrateDoc[]): Map<string, Sub
19
19
  * ceiling: min(own rung, each ancestor INDEX's rung). A `none` ancestor INDEX
20
20
  * hides the whole subtree (min with none = none). */
21
21
  export declare function effectiveRung(doc: SubstrateDoc, ceil: Map<string, SubstrateDoc>, surface: Surface): Rung;
22
- /** Apply ceilings to a doc list for a surface: each doc gets its effective rung
23
- * on `surface` written back into that field, and INDEX docs are renamed to
24
- * their dir entry. Returns NEW objects only where something changed (cheap,
25
- * preserves identity for untouched docs). Does NOT drop `none`-rung docs — the
26
- * caller filters per surface (boot drops them; node-local intentionally keeps
27
- * them). */
28
- export declare function applyCeilings(docs: SubstrateDoc[], surface: Surface): SubstrateDoc[];
@@ -69,19 +69,3 @@ export function effectiveRung(doc, ceil, surface) {
69
69
  }
70
70
  return rung;
71
71
  }
72
- /** Apply ceilings to a doc list for a surface: each doc gets its effective rung
73
- * on `surface` written back into that field, and INDEX docs are renamed to
74
- * their dir entry. Returns NEW objects only where something changed (cheap,
75
- * preserves identity for untouched docs). Does NOT drop `none`-rung docs — the
76
- * caller filters per surface (boot drops them; node-local intentionally keeps
77
- * them). */
78
- export function applyCeilings(docs, surface) {
79
- const ceil = buildCeilingIndex(docs);
80
- return docs.map((d) => {
81
- const rung = effectiveRung(d, ceil, surface);
82
- const name = displayName(d.name);
83
- if (rung === d[surface] && name === d.name)
84
- return d;
85
- return { ...d, [surface]: rung, name };
86
- });
87
- }
@@ -3,7 +3,7 @@ export type { DocKind, Rung, GatePredicate, SubstrateSchema, SubstrateDoc } from
3
3
  export { scopeForCwd, spineDepth, assembleNodeSubject } from './subject.js';
4
4
  export type { NodeConfigSubject } from './subject.js';
5
5
  export { gatePasses } from './gate.js';
6
- export { INDEX_NAME, isIndexName, indexDirOf, displayName, buildCeilingIndex, effectiveRung, applyCeilings, } from './ceiling.js';
6
+ export { INDEX_NAME, isIndexName, indexDirOf, displayName, buildCeilingIndex, effectiveRung, } from './ceiling.js';
7
7
  export type { Surface } from './ceiling.js';
8
- export { renderSkillsSection, renderPreferencesSection, renderReferencesBlock } from './render.js';
8
+ export { renderSkillsSection, renderPreferencesSection, renderReferencesBlock, renderMemoryGuidance, } from './render.js';
9
9
  export { renderOnReadDocs } from './on-read.js';
@@ -14,6 +14,6 @@ KIND_DEFAULT_RUNGS,
14
14
  parseSubstrateFrontmatter, parseSubstrateDoc, previewLine, } from './schema.js';
15
15
  export { scopeForCwd, spineDepth, assembleNodeSubject } from './subject.js';
16
16
  export { gatePasses } from './gate.js';
17
- export { INDEX_NAME, isIndexName, indexDirOf, displayName, buildCeilingIndex, effectiveRung, applyCeilings, } from './ceiling.js';
18
- export { renderSkillsSection, renderPreferencesSection, renderReferencesBlock } from './render.js';
17
+ export { INDEX_NAME, isIndexName, indexDirOf, displayName, buildCeilingIndex, effectiveRung, } from './ceiling.js';
18
+ export { renderSkillsSection, renderPreferencesSection, renderReferencesBlock, renderMemoryGuidance, } from './render.js';
19
19
  export { renderOnReadDocs } from './on-read.js';