@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.
- package/dist/builtin-memory/internal/INDEX.md +21 -0
- package/dist/builtin-memory/internal/examples/INDEX.md +15 -0
- package/dist/builtin-memory/internal/examples/imessage-assistant.md +100 -0
- package/dist/builtin-memory/internal/nodes-and-canvas.md +49 -0
- package/dist/builtin-memory/internal/storage-tiers.md +32 -0
- package/dist/builtin-memory/memory-authoring.md +100 -0
- package/dist/builtin-personas/orchestration-kernel.md +4 -4
- package/dist/clients/attach/attach-cmd.js +26 -0
- package/dist/commands/node.d.ts +15 -6
- package/dist/commands/node.js +33 -9
- package/dist/commands/skill/shared.d.ts +0 -5
- package/dist/commands/skill/shared.js +0 -81
- package/dist/core/__tests__/context-intro.test.js +23 -20
- package/dist/core/__tests__/full/broker-pane-resolution.test.d.ts +1 -0
- package/dist/core/__tests__/full/broker-pane-resolution.test.js +84 -0
- package/dist/core/__tests__/memory-resolver.test.js +21 -2
- package/dist/core/memory-resolver.js +17 -12
- package/dist/core/runtime/bearings.d.ts +1 -1
- package/dist/core/runtime/bearings.js +2 -2
- package/dist/core/runtime/broker.js +4 -2
- package/dist/core/runtime/launch.d.ts +2 -2
- package/dist/core/runtime/launch.js +2 -2
- package/dist/core/runtime/placement.d.ts +13 -0
- package/dist/core/runtime/placement.js +23 -7
- package/dist/core/runtime/recycle.js +65 -10
- package/dist/core/substrate/ceiling.d.ts +0 -7
- package/dist/core/substrate/ceiling.js +0 -16
- package/dist/core/substrate/index.d.ts +2 -2
- package/dist/core/substrate/index.js +2 -2
- package/dist/core/substrate/render.d.ts +19 -20
- package/dist/core/substrate/render.js +271 -171
- package/dist/core/substrate/session-cache.d.ts +0 -13
- package/dist/core/substrate/session-cache.js +0 -12
- package/dist/index.d.ts +19 -1
- package/dist/index.js +26 -1
- package/dist/pi-extensions/canvas-context-intro.js +3 -3
- package/dist/pi-extensions/canvas-doc-substrate.js +13 -5
- 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
|
|
3
|
-
// (substrate reference docs + node-local docs as
|
|
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 +
|
|
37
|
+
test('worker bearings: base framing + <references> block, NO across-cycles framing', () => {
|
|
38
38
|
// Bug-regression: review finding M1 — buildContextBearings renders the
|
|
39
|
-
//
|
|
40
|
-
//
|
|
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
|
|
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,
|
|
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
|
|
62
|
-
// Bug-regression: review finding M1 — buildContextBearings renders
|
|
63
|
-
// Node-local substrate docs render as
|
|
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,
|
|
79
|
-
assert.match(block,
|
|
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
|
|
89
|
-
// Bug-regression: review findings M1 + M6 — the
|
|
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
|
|
93
|
-
// its bare
|
|
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
|
|
107
|
-
assert.match(block,
|
|
108
|
-
assert.ok(!block.includes('body that must not render'), 'none rung renders the
|
|
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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
-
//
|
|
14
|
-
//
|
|
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
|
|
115
|
-
//
|
|
116
|
-
//
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
750
|
-
|
|
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:
|
|
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:
|
|
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
|
|
78
|
-
//
|
|
79
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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,
|
|
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,
|
|
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';
|