@crouton-kit/crouter 0.3.13 → 0.3.14
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/commands/__tests__/human.test.js +73 -2
- package/dist/commands/human/queue.d.ts +1 -0
- package/dist/commands/human/queue.js +89 -2
- package/dist/commands/human/shared.d.ts +5 -0
- package/dist/commands/human/shared.js +15 -0
- package/dist/commands/human.js +4 -2
- package/dist/commands/node.js +195 -24
- package/dist/core/__tests__/passive-subscription.test.d.ts +1 -0
- package/dist/core/__tests__/passive-subscription.test.js +141 -0
- package/dist/core/__tests__/subcommand-tier.test.d.ts +1 -0
- package/dist/core/__tests__/subcommand-tier.test.js +97 -0
- package/dist/core/canvas/paths.d.ts +4 -0
- package/dist/core/canvas/paths.js +6 -0
- package/dist/core/command.js +40 -7
- package/dist/core/feed/feed.js +11 -9
- package/dist/core/feed/passive.d.ts +17 -0
- package/dist/core/feed/passive.js +79 -0
- package/dist/core/help.d.ts +45 -12
- package/dist/core/help.js +42 -4
- package/dist/core/runtime/demote.d.ts +14 -0
- package/dist/core/runtime/demote.js +103 -0
- package/dist/core/runtime/kickoff.d.ts +9 -0
- package/dist/core/runtime/kickoff.js +19 -1
- package/dist/core/runtime/launch.d.ts +12 -1
- package/dist/core/runtime/launch.js +18 -2
- package/dist/core/runtime/presence.d.ts +1 -18
- package/dist/core/runtime/presence.js +7 -51
- package/dist/core/runtime/promote.d.ts +4 -0
- package/dist/core/runtime/promote.js +21 -6
- package/dist/core/runtime/roadmap.d.ts +5 -4
- package/dist/core/runtime/roadmap.js +9 -16
- package/dist/core/runtime/spawn.js +7 -2
- package/dist/core/runtime/tmux.d.ts +11 -12
- package/dist/core/runtime/tmux.js +57 -26
- package/dist/pi-extensions/canvas-commands.d.ts +34 -0
- package/dist/pi-extensions/canvas-commands.js +100 -0
- package/dist/pi-extensions/canvas-goal-capture.d.ts +18 -0
- package/dist/pi-extensions/canvas-goal-capture.js +53 -0
- package/dist/pi-extensions/canvas-passive-context.d.ts +32 -0
- package/dist/pi-extensions/canvas-passive-context.js +114 -0
- package/package.json +1 -1
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// End-to-end tests for passive subscriptions:
|
|
2
|
+
// 1. A passive subscriber's pushes land in passive.jsonl, NOT inbox.jsonl
|
|
3
|
+
// (so the inbox-watcher never wakes it); an active subscriber's land in
|
|
4
|
+
// inbox.jsonl as before.
|
|
5
|
+
// 2. drainPassive reads + clears the accumulator (surfaces exactly once).
|
|
6
|
+
// 3. canvas-passive-context formats drained entries as timestamped XML and
|
|
7
|
+
// transforms an `input` event into pre-text + the original message.
|
|
8
|
+
//
|
|
9
|
+
// Run: node --import tsx/esm --test src/core/__tests__/passive-subscription.test.ts
|
|
10
|
+
import { test, before, beforeEach, after } from 'node:test';
|
|
11
|
+
import assert from 'node:assert/strict';
|
|
12
|
+
import { mkdtempSync, rmSync, existsSync } from 'node:fs';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { createNode, subscribe } from '../canvas/canvas.js';
|
|
16
|
+
import { closeDb } from '../canvas/db.js';
|
|
17
|
+
import { inboxPath, passivePath } from '../canvas/paths.js';
|
|
18
|
+
import { push } from '../feed/feed.js';
|
|
19
|
+
import { appendPassive, readPassive, drainPassive } from '../feed/passive.js';
|
|
20
|
+
import { readInboxSince } from '../feed/inbox.js';
|
|
21
|
+
import registerCanvasPassiveContext, { formatPassive } from '../../pi-extensions/canvas-passive-context.js';
|
|
22
|
+
let home;
|
|
23
|
+
function node(id, over = {}) {
|
|
24
|
+
return {
|
|
25
|
+
node_id: id,
|
|
26
|
+
name: id,
|
|
27
|
+
created: new Date().toISOString(),
|
|
28
|
+
cwd: '/tmp/work',
|
|
29
|
+
kind: 'general',
|
|
30
|
+
mode: 'base',
|
|
31
|
+
lifecycle: 'terminal',
|
|
32
|
+
status: 'active',
|
|
33
|
+
...over,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
before(() => {
|
|
37
|
+
home = mkdtempSync(join(tmpdir(), 'crtr-passive-'));
|
|
38
|
+
process.env['CRTR_HOME'] = home;
|
|
39
|
+
});
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
closeDb();
|
|
42
|
+
rmSync(home, { recursive: true, force: true });
|
|
43
|
+
});
|
|
44
|
+
after(() => {
|
|
45
|
+
closeDb();
|
|
46
|
+
rmSync(home, { recursive: true, force: true });
|
|
47
|
+
delete process.env['CRTR_HOME'];
|
|
48
|
+
delete process.env['CRTR_NODE_ID'];
|
|
49
|
+
});
|
|
50
|
+
test('passive push accumulates in passive.jsonl, not inbox.jsonl', async () => {
|
|
51
|
+
createNode(node('pub'));
|
|
52
|
+
createNode(node('observer'));
|
|
53
|
+
subscribe('observer', 'pub', false); // PASSIVE
|
|
54
|
+
await push('pub', { kind: 'update', body: 'first observation\nmore detail' });
|
|
55
|
+
// No inbox entry → the inbox-watcher would never see it → no wake.
|
|
56
|
+
assert.equal(existsSync(inboxPath('observer')), false);
|
|
57
|
+
assert.equal(readInboxSince('observer').length, 0);
|
|
58
|
+
// It landed in the passive accumulator instead.
|
|
59
|
+
const acc = readPassive('observer');
|
|
60
|
+
assert.equal(acc.length, 1);
|
|
61
|
+
assert.equal(acc[0].from, 'pub');
|
|
62
|
+
assert.equal(acc[0].label, 'first observation');
|
|
63
|
+
assert.ok(acc[0].ref && acc[0].ref.endsWith('-update.md'));
|
|
64
|
+
});
|
|
65
|
+
test('active push still lands in inbox.jsonl (wakes)', async () => {
|
|
66
|
+
createNode(node('pub'));
|
|
67
|
+
createNode(node('worker-mgr'));
|
|
68
|
+
subscribe('worker-mgr', 'pub', true); // ACTIVE
|
|
69
|
+
await push('pub', { kind: 'update', body: 'active report' });
|
|
70
|
+
assert.equal(existsSync(passivePath('worker-mgr')), false);
|
|
71
|
+
const inbox = readInboxSince('worker-mgr');
|
|
72
|
+
assert.equal(inbox.length, 1);
|
|
73
|
+
assert.equal(inbox[0].from, 'pub');
|
|
74
|
+
});
|
|
75
|
+
test('mixed active + passive subscribers route to their own stores', async () => {
|
|
76
|
+
createNode(node('pub'));
|
|
77
|
+
createNode(node('active-sub'));
|
|
78
|
+
createNode(node('passive-sub'));
|
|
79
|
+
subscribe('active-sub', 'pub', true);
|
|
80
|
+
subscribe('passive-sub', 'pub', false);
|
|
81
|
+
const res = await push('pub', { kind: 'urgent', body: 'something happened' });
|
|
82
|
+
assert.deepEqual(new Set(res.deliveredTo), new Set(['active-sub', 'passive-sub']));
|
|
83
|
+
assert.equal(readInboxSince('active-sub').length, 1);
|
|
84
|
+
assert.equal(existsSync(passivePath('active-sub')), false);
|
|
85
|
+
assert.equal(readPassive('passive-sub').length, 1);
|
|
86
|
+
assert.equal(existsSync(inboxPath('passive-sub')), false);
|
|
87
|
+
});
|
|
88
|
+
test('drainPassive reads then clears (surfaces exactly once)', () => {
|
|
89
|
+
createNode(node('observer'));
|
|
90
|
+
appendPassive('observer', { from: 'a', tier: 'normal', kind: 'update', label: 'one' });
|
|
91
|
+
appendPassive('observer', { from: 'b', tier: 'normal', kind: 'update', label: 'two' });
|
|
92
|
+
const drained = drainPassive('observer');
|
|
93
|
+
assert.equal(drained.length, 2);
|
|
94
|
+
assert.deepEqual(drained.map((e) => e.label), ['one', 'two']); // oldest first
|
|
95
|
+
// Cleared — a second drain is empty.
|
|
96
|
+
assert.equal(drainPassive('observer').length, 0);
|
|
97
|
+
assert.equal(readPassive('observer').length, 0);
|
|
98
|
+
});
|
|
99
|
+
test('formatPassive renders timestamped XML update blocks', () => {
|
|
100
|
+
const entries = [
|
|
101
|
+
{ ts: '2026-06-03T12:00:00.000Z', from: 'pub-a', tier: 'normal', kind: 'update', label: 'alpha happened' },
|
|
102
|
+
{ ts: '2026-06-03T12:05:00.000Z', from: 'pub-b', tier: 'urgent', kind: 'final', label: 'beta done' },
|
|
103
|
+
];
|
|
104
|
+
const xml = formatPassive(entries);
|
|
105
|
+
assert.match(xml, /<passive-subscription-backlog count="2"/);
|
|
106
|
+
assert.match(xml, /<update from="pub-a" kind="update" at="2026-06-03T12:00:00.000Z">/);
|
|
107
|
+
assert.match(xml, /alpha happened/);
|
|
108
|
+
assert.match(xml, /<update from="pub-b" kind="final" at="2026-06-03T12:05:00.000Z">/);
|
|
109
|
+
assert.match(xml, /<\/passive-subscription-backlog>/);
|
|
110
|
+
});
|
|
111
|
+
function makeFakePi() {
|
|
112
|
+
return { on(e, h) { if (e === 'input')
|
|
113
|
+
this.handler = h; } };
|
|
114
|
+
}
|
|
115
|
+
test('input handler injects drained backlog as pre-text, then clears it', async () => {
|
|
116
|
+
createNode(node('pub'));
|
|
117
|
+
createNode(node('observer'));
|
|
118
|
+
subscribe('observer', 'pub', false);
|
|
119
|
+
await push('pub', { kind: 'update', body: 'the body of the report\nsecond line' });
|
|
120
|
+
process.env['CRTR_NODE_ID'] = 'observer';
|
|
121
|
+
const pi = makeFakePi();
|
|
122
|
+
registerCanvasPassiveContext(pi);
|
|
123
|
+
assert.ok(pi.handler, 'input handler registered');
|
|
124
|
+
// First message → backlog drains in as pre-text before the user's text.
|
|
125
|
+
const out = pi.handler({ type: 'input', text: 'hey what happened', source: 'interactive' });
|
|
126
|
+
assert.equal(out.action, 'transform');
|
|
127
|
+
assert.match(out.text, /<passive-subscription-backlog/);
|
|
128
|
+
assert.match(out.text, /the body of the report/); // dereferenced report body
|
|
129
|
+
assert.match(out.text, /hey what happened$/); // original message preserved at the end
|
|
130
|
+
// Second message → nothing accumulated → left untouched.
|
|
131
|
+
const out2 = pi.handler({ type: 'input', text: 'still there?', source: 'interactive' });
|
|
132
|
+
assert.ok(out2 === undefined || out2.action === 'continue');
|
|
133
|
+
});
|
|
134
|
+
test('input handler is inert when nothing is accumulated', () => {
|
|
135
|
+
createNode(node('observer'));
|
|
136
|
+
process.env['CRTR_NODE_ID'] = 'observer';
|
|
137
|
+
const pi = makeFakePi();
|
|
138
|
+
registerCanvasPassiveContext(pi);
|
|
139
|
+
const out = pi.handler({ type: 'input', text: 'plain message', source: 'interactive' });
|
|
140
|
+
assert.ok(out === undefined || out.action === 'continue');
|
|
141
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Tests for the subcommand visibility tier (hidden | normal | common | important)
|
|
2
|
+
// and the "[+N subcommands]" affordance.
|
|
3
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/subcommand-tier.test.ts
|
|
4
|
+
//
|
|
5
|
+
// Contract:
|
|
6
|
+
// - renderRoot promotes a subtree's `important` children (name + shortform
|
|
7
|
+
// desc) and `common` children (bare qualified path) into that command's
|
|
8
|
+
// block, then names how many other non-hidden subcommands stay behind
|
|
9
|
+
// `crtr <name> -h`.
|
|
10
|
+
// - `hidden` children never appear (not even in the subtree's own -h) and are
|
|
11
|
+
// not counted in any "[+N]" remainder.
|
|
12
|
+
// - renderBranch drops hidden children and flags branch children that own
|
|
13
|
+
// subcommands with "[+N subcommands]".
|
|
14
|
+
import { test, describe } from 'node:test';
|
|
15
|
+
import assert from 'node:assert/strict';
|
|
16
|
+
import { defineRoot, defineBranch, defineLeaf } from '../command.js';
|
|
17
|
+
import { renderRoot, renderBranch } from '../help.js';
|
|
18
|
+
const leaf = (name) => defineLeaf({
|
|
19
|
+
name,
|
|
20
|
+
help: { name, summary: name, output: [], outputKind: 'object', effects: ['None. Read-only.'] },
|
|
21
|
+
run: async () => ({}),
|
|
22
|
+
});
|
|
23
|
+
// A nested branch so we can assert the "[+N subcommands]" depth flag.
|
|
24
|
+
const inspect = defineBranch({
|
|
25
|
+
name: 'inspect',
|
|
26
|
+
help: {
|
|
27
|
+
name: 'thing inspect',
|
|
28
|
+
summary: 'inspect',
|
|
29
|
+
children: [
|
|
30
|
+
{ name: 'list', desc: 'list', useWhen: 'x' },
|
|
31
|
+
{ name: 'show', desc: 'show', useWhen: 'x' },
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
children: [leaf('list'), leaf('show')],
|
|
35
|
+
});
|
|
36
|
+
const thing = defineBranch({
|
|
37
|
+
name: 'thing',
|
|
38
|
+
rootEntry: { concept: 'a thing', desc: 'things', useWhen: 'doing things' },
|
|
39
|
+
help: {
|
|
40
|
+
name: 'thing',
|
|
41
|
+
summary: 'do things',
|
|
42
|
+
children: [
|
|
43
|
+
{ name: 'make', desc: 'make a thing', useWhen: 'x', tier: 'important' },
|
|
44
|
+
{ name: 'promote', desc: 'promote a thing', useWhen: 'x', tier: 'common' },
|
|
45
|
+
{ name: 'inspect', desc: 'inspect things', useWhen: 'x' },
|
|
46
|
+
{ name: 'secret', desc: 'secret op', useWhen: 'x', tier: 'hidden' },
|
|
47
|
+
{ name: 'plain', desc: 'plain op', useWhen: 'x' },
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
children: [leaf('make'), leaf('promote'), inspect, leaf('secret'), leaf('plain')],
|
|
51
|
+
});
|
|
52
|
+
const root = defineRoot({ tagline: 'test runtime', globals: [], subtrees: [thing] });
|
|
53
|
+
describe('renderRoot: subcommand promotion', () => {
|
|
54
|
+
const out = renderRoot(root.help);
|
|
55
|
+
test('important child surfaces with its shortform desc', () => {
|
|
56
|
+
assert.match(out, /\n {2}thing make {2,}make a thing\n/);
|
|
57
|
+
});
|
|
58
|
+
test('common child surfaces as a bare qualified path (no desc)', () => {
|
|
59
|
+
assert.match(out, /\n {2}thing promote\n/);
|
|
60
|
+
assert.doesNotMatch(out, /thing promote {2,}promote a thing/);
|
|
61
|
+
});
|
|
62
|
+
test('hidden child is never promoted and not counted', () => {
|
|
63
|
+
assert.doesNotMatch(out, /secret/);
|
|
64
|
+
// 5 children, 1 hidden => 4 listable, 2 promoted => 2 remaining.
|
|
65
|
+
assert.match(out, /\[\+2 other subcommands — `crtr thing -h`\]/);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
describe('renderRoot: commands with no promotions', () => {
|
|
69
|
+
test('still advertise their subcommand count', () => {
|
|
70
|
+
const bare = defineBranch({
|
|
71
|
+
name: 'bare',
|
|
72
|
+
rootEntry: { concept: 'bare', desc: 'bare', useWhen: 'x' },
|
|
73
|
+
help: { name: 'bare', summary: 'bare', children: [{ name: 'one', desc: 'one', useWhen: 'x' }] },
|
|
74
|
+
children: [leaf('one')],
|
|
75
|
+
});
|
|
76
|
+
const r = defineRoot({ tagline: 't', globals: [], subtrees: [bare] });
|
|
77
|
+
const out = renderRoot(r.help);
|
|
78
|
+
assert.match(out, /\[\+1 subcommand — `crtr bare -h`\]/); // singular, no "other"
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
describe('renderBranch: hidden filter + depth flag', () => {
|
|
82
|
+
const out = renderBranch(thing.help);
|
|
83
|
+
test('hidden child is dropped from the branch listing', () => {
|
|
84
|
+
assert.doesNotMatch(out, /secret/);
|
|
85
|
+
});
|
|
86
|
+
test('all non-hidden children are listed', () => {
|
|
87
|
+
for (const n of ['make', 'promote', 'inspect', 'plain']) {
|
|
88
|
+
assert.match(out, new RegExp(`\\n {2}${n} `));
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
test('a branch child flags how many subcommands it owns', () => {
|
|
92
|
+
assert.match(out, /inspect .* \[\+2 subcommands\]/);
|
|
93
|
+
});
|
|
94
|
+
test('leaf children carry no subcommand flag', () => {
|
|
95
|
+
assert.doesNotMatch(out, /make .* \[\+\d+ subcommands\]/);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -8,6 +8,10 @@ export declare function jobDir(nodeId: string): string;
|
|
|
8
8
|
export declare function reportsDir(nodeId: string): string;
|
|
9
9
|
export declare function nodeMetaPath(nodeId: string): string;
|
|
10
10
|
export declare function inboxPath(nodeId: string): string;
|
|
11
|
+
/** Passive-subscription accumulator. Pushes from publishers this node subscribes
|
|
12
|
+
* to PASSIVELY land here instead of inbox.jsonl — the inbox-watcher never polls
|
|
13
|
+
* it, so they never wake the node. Drained as XML pre-text on the next message. */
|
|
14
|
+
export declare function passivePath(nodeId: string): string;
|
|
11
15
|
export declare function transcriptPath(nodeId: string): string;
|
|
12
16
|
export declare function sessionPtrPath(nodeId: string): string;
|
|
13
17
|
/** Create the full directory skeleton for a node. Idempotent. */
|
|
@@ -44,6 +44,12 @@ export function nodeMetaPath(nodeId) {
|
|
|
44
44
|
export function inboxPath(nodeId) {
|
|
45
45
|
return join(nodeDir(nodeId), 'inbox.jsonl');
|
|
46
46
|
}
|
|
47
|
+
/** Passive-subscription accumulator. Pushes from publishers this node subscribes
|
|
48
|
+
* to PASSIVELY land here instead of inbox.jsonl — the inbox-watcher never polls
|
|
49
|
+
* it, so they never wake the node. Drained as XML pre-text on the next message. */
|
|
50
|
+
export function passivePath(nodeId) {
|
|
51
|
+
return join(nodeDir(nodeId), 'passive.jsonl');
|
|
52
|
+
}
|
|
47
53
|
export function transcriptPath(nodeId) {
|
|
48
54
|
return join(nodeDir(nodeId), 'transcript.jsonl');
|
|
49
55
|
}
|
package/dist/core/command.js
CHANGED
|
@@ -23,7 +23,26 @@ export function defineLeaf(opts) {
|
|
|
23
23
|
render: opts.render,
|
|
24
24
|
};
|
|
25
25
|
}
|
|
26
|
+
/** Number of a node's own non-hidden subcommands (direct children). Leaves and
|
|
27
|
+
* childless branches return 0. */
|
|
28
|
+
function visibleSubCount(def) {
|
|
29
|
+
if (def.kind !== 'branch')
|
|
30
|
+
return 0;
|
|
31
|
+
return def.help.children.filter((c) => (c.tier ?? 'normal') !== 'hidden').length;
|
|
32
|
+
}
|
|
26
33
|
export function defineBranch(opts) {
|
|
34
|
+
// Enrich each help-child entry with the count of its own non-hidden
|
|
35
|
+
// subcommands so renderBranch can show "[+N subcommands]" when a branch child
|
|
36
|
+
// is listed without expanding it. Match help entries to child defs by name;
|
|
37
|
+
// entries without a matching def (or whose def has no subcommands) stay bare.
|
|
38
|
+
for (const hc of opts.help.children) {
|
|
39
|
+
const childDef = opts.children.find((c) => c.name === hc.name);
|
|
40
|
+
if (childDef !== undefined) {
|
|
41
|
+
const n = visibleSubCount(childDef);
|
|
42
|
+
if (n > 0)
|
|
43
|
+
hc.subCount = n;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
27
46
|
return {
|
|
28
47
|
kind: 'branch',
|
|
29
48
|
name: opts.name,
|
|
@@ -60,13 +79,27 @@ export function defineRoot(opts) {
|
|
|
60
79
|
// and dynamic block all travel with it.
|
|
61
80
|
const commands = opts.subtrees
|
|
62
81
|
.filter((s) => s.rootEntry !== undefined)
|
|
63
|
-
.map((s) =>
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
82
|
+
.map((s) => {
|
|
83
|
+
// Promote this subtree's common/important children into root, and count
|
|
84
|
+
// how many other (non-hidden) direct subcommands stay behind `<name> -h`.
|
|
85
|
+
const visible = s.help.children.filter((c) => (c.tier ?? 'normal') !== 'hidden');
|
|
86
|
+
const promoted = visible
|
|
87
|
+
.filter((c) => c.tier === 'common' || c.tier === 'important')
|
|
88
|
+
.map((c) => ({
|
|
89
|
+
path: `${s.name} ${c.name}`,
|
|
90
|
+
// important carries its shortform desc; common shows the bare path.
|
|
91
|
+
desc: c.tier === 'important' ? c.desc : undefined,
|
|
92
|
+
}));
|
|
93
|
+
return {
|
|
94
|
+
name: s.name,
|
|
95
|
+
concept: s.rootEntry.concept,
|
|
96
|
+
desc: s.rootEntry.desc,
|
|
97
|
+
useWhen: s.rootEntry.useWhen,
|
|
98
|
+
dynamicState: s.rootEntry.dynamicState,
|
|
99
|
+
subcommands: promoted.length > 0 ? promoted : undefined,
|
|
100
|
+
otherSubcommandCount: visible.length - promoted.length,
|
|
101
|
+
};
|
|
102
|
+
});
|
|
70
103
|
const help = {
|
|
71
104
|
tagline: opts.tagline,
|
|
72
105
|
commands,
|
package/dist/core/feed/feed.js
CHANGED
|
@@ -11,6 +11,7 @@ import { writeFileSync, renameSync, mkdirSync, existsSync } from 'node:fs';
|
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
import { reportsDir, subscribersOf, setStatus, updateNode, } from '../canvas/index.js';
|
|
13
13
|
import { appendInbox } from './inbox.js';
|
|
14
|
+
import { appendPassive } from './passive.js';
|
|
14
15
|
// ---------------------------------------------------------------------------
|
|
15
16
|
// Internal helpers
|
|
16
17
|
// ---------------------------------------------------------------------------
|
|
@@ -74,19 +75,20 @@ export async function push(nodeId, opts) {
|
|
|
74
75
|
const ts = compactTs(now);
|
|
75
76
|
// (a) Write the report.
|
|
76
77
|
const reportPath = writeReport(nodeId, kind, ts, body);
|
|
77
|
-
// (b) Fan out
|
|
78
|
-
//
|
|
78
|
+
// (b) Fan out a pointer to every subscriber. Active subscribers get it on
|
|
79
|
+
// inbox.jsonl (the inbox-watcher polls that → a wake). Passive subscribers
|
|
80
|
+
// get it on passive.jsonl instead — the watcher never polls that, so they
|
|
81
|
+
// are NOT woken; the pointer accumulates until the node is next messaged,
|
|
82
|
+
// when canvas-passive-context drains it as XML pre-text.
|
|
79
83
|
const subscribers = subscribersOf(nodeId);
|
|
80
84
|
const deliveredTo = [];
|
|
81
85
|
const label = firstLine(body);
|
|
82
86
|
for (const sub of subscribers) {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
label,
|
|
89
|
-
});
|
|
87
|
+
const entry = { from, tier: tierFor(kind), kind, ref: reportPath, label };
|
|
88
|
+
if (sub.active)
|
|
89
|
+
appendInbox(sub.node_id, entry);
|
|
90
|
+
else
|
|
91
|
+
appendPassive(sub.node_id, entry);
|
|
90
92
|
deliveredTo.push(sub.node_id);
|
|
91
93
|
}
|
|
92
94
|
// (c) Finalise node when kind === 'final'.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { InboxEntry } from './inbox.js';
|
|
2
|
+
/**
|
|
3
|
+
* Atomically append one entry to `nodes/<nodeId>/passive.jsonl`.
|
|
4
|
+
* Fills `ts` (current ISO time). Returns the completed entry.
|
|
5
|
+
*/
|
|
6
|
+
export declare function appendPassive(nodeId: string, entry: Omit<InboxEntry, 'ts'>): InboxEntry;
|
|
7
|
+
/** Return every accumulated passive entry (oldest first) without clearing. */
|
|
8
|
+
export declare function readPassive(nodeId: string): InboxEntry[];
|
|
9
|
+
/**
|
|
10
|
+
* Read AND clear the accumulator in one shot — the drain-on-message primitive.
|
|
11
|
+
*
|
|
12
|
+
* We rename the file aside before reading so a concurrent `appendPassive` (a
|
|
13
|
+
* publisher pushing at the same instant) starts a fresh file and is never lost
|
|
14
|
+
* to the truncate: at worst it lands in the next drain. The renamed snapshot is
|
|
15
|
+
* removed after a successful read. Returns the drained entries (oldest first).
|
|
16
|
+
*/
|
|
17
|
+
export declare function drainPassive(nodeId: string): InboxEntry[];
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Per-node passive-subscription accumulator for the pi-native canvas runtime.
|
|
2
|
+
//
|
|
3
|
+
// A PASSIVE subscription (the `active=false` flavor of a subscribes_to edge)
|
|
4
|
+
// must never WAKE its subscriber. So when `push` fans out, a passive
|
|
5
|
+
// subscriber's pointer is written here — to nodes/<id>/passive.jsonl — instead
|
|
6
|
+
// of inbox.jsonl. The inbox-watcher polls only inbox.jsonl, so nothing here
|
|
7
|
+
// triggers a turn.
|
|
8
|
+
//
|
|
9
|
+
// The accumulator is drained the moment the node is next MESSAGED: the
|
|
10
|
+
// canvas-passive-context extension reads + clears this file on pi's `input`
|
|
11
|
+
// event and injects every entry as timestamped XML pre-text before the message
|
|
12
|
+
// reaches the LLM. Until then entries simply pile up, oldest first.
|
|
13
|
+
//
|
|
14
|
+
// Same entry shape as the inbox (InboxEntry) so the two stores stay symmetric
|
|
15
|
+
// and a passive edge can be flipped active without reshaping data.
|
|
16
|
+
import { appendFileSync, existsSync, readFileSync, renameSync, rmSync, mkdirSync, } from 'node:fs';
|
|
17
|
+
import { dirname } from 'node:path';
|
|
18
|
+
import { passivePath } from '../canvas/index.js';
|
|
19
|
+
/**
|
|
20
|
+
* Atomically append one entry to `nodes/<nodeId>/passive.jsonl`.
|
|
21
|
+
* Fills `ts` (current ISO time). Returns the completed entry.
|
|
22
|
+
*/
|
|
23
|
+
export function appendPassive(nodeId, entry) {
|
|
24
|
+
const full = { ts: new Date().toISOString(), ...entry };
|
|
25
|
+
const line = JSON.stringify(full) + '\n';
|
|
26
|
+
const dir = dirname(passivePath(nodeId));
|
|
27
|
+
if (!existsSync(dir))
|
|
28
|
+
mkdirSync(dir, { recursive: true });
|
|
29
|
+
appendFileSync(passivePath(nodeId), line, { encoding: 'utf8', flag: 'a' });
|
|
30
|
+
return full;
|
|
31
|
+
}
|
|
32
|
+
/** Return every accumulated passive entry (oldest first) without clearing. */
|
|
33
|
+
export function readPassive(nodeId) {
|
|
34
|
+
const p = passivePath(nodeId);
|
|
35
|
+
if (!existsSync(p))
|
|
36
|
+
return [];
|
|
37
|
+
return readFileSync(p, 'utf8')
|
|
38
|
+
.split('\n')
|
|
39
|
+
.filter((l) => l.trim() !== '')
|
|
40
|
+
.map((l) => JSON.parse(l));
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Read AND clear the accumulator in one shot — the drain-on-message primitive.
|
|
44
|
+
*
|
|
45
|
+
* We rename the file aside before reading so a concurrent `appendPassive` (a
|
|
46
|
+
* publisher pushing at the same instant) starts a fresh file and is never lost
|
|
47
|
+
* to the truncate: at worst it lands in the next drain. The renamed snapshot is
|
|
48
|
+
* removed after a successful read. Returns the drained entries (oldest first).
|
|
49
|
+
*/
|
|
50
|
+
export function drainPassive(nodeId) {
|
|
51
|
+
const p = passivePath(nodeId);
|
|
52
|
+
if (!existsSync(p))
|
|
53
|
+
return [];
|
|
54
|
+
const snapshot = `${p}.draining`;
|
|
55
|
+
try {
|
|
56
|
+
renameSync(p, snapshot);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// Lost the race (file vanished) — nothing to drain.
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
let entries = [];
|
|
63
|
+
try {
|
|
64
|
+
entries = readFileSync(snapshot, 'utf8')
|
|
65
|
+
.split('\n')
|
|
66
|
+
.filter((l) => l.trim() !== '')
|
|
67
|
+
.map((l) => JSON.parse(l));
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
entries = [];
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
try {
|
|
74
|
+
rmSync(snapshot, { force: true });
|
|
75
|
+
}
|
|
76
|
+
catch { /* best-effort cleanup */ }
|
|
77
|
+
}
|
|
78
|
+
return entries;
|
|
79
|
+
}
|
package/dist/core/help.d.ts
CHANGED
|
@@ -47,6 +47,29 @@ export interface ContextFileParam {
|
|
|
47
47
|
shape?: string;
|
|
48
48
|
}
|
|
49
49
|
export type InputParam = PositionalParam | FlagParam | StdinParam | ContextFileParam;
|
|
50
|
+
/** How prominently a subcommand surfaces in ancestor (parent / root) -h
|
|
51
|
+
* listings. Set per child in the parent branch's `help.children`. Default
|
|
52
|
+
* 'normal'.
|
|
53
|
+
* - hidden — never listed anywhere, not even in this branch's own -h.
|
|
54
|
+
* You must already know it exists to invoke it.
|
|
55
|
+
* - normal — listed in this branch's own -h only (the default).
|
|
56
|
+
* - common — ALSO promoted into the parent's -h, as a bare qualified name.
|
|
57
|
+
* - important — ALSO promoted into the parent's -h, name + shortform desc. */
|
|
58
|
+
export type SubTier = 'hidden' | 'normal' | 'common' | 'important';
|
|
59
|
+
/** One child entry in a branch's -h listing. `desc`/`useWhen` are the shortform
|
|
60
|
+
* copy shown there; `tier` governs promotion into ancestor listings. */
|
|
61
|
+
export interface BranchChild {
|
|
62
|
+
name: string;
|
|
63
|
+
desc: string;
|
|
64
|
+
useWhen: string;
|
|
65
|
+
/** Visibility tier in ancestor listings (see SubTier). Default 'normal'. */
|
|
66
|
+
tier?: SubTier;
|
|
67
|
+
/** Computed at define time (defineBranch): how many non-hidden subcommands
|
|
68
|
+
* this child itself owns. Drives the "[+N subcommands]" affordance shown when
|
|
69
|
+
* a branch child is listed without expanding its own subcommands. Absent for
|
|
70
|
+
* leaves and childless branches. Do not author by hand. */
|
|
71
|
+
subCount?: number;
|
|
72
|
+
}
|
|
50
73
|
/** A subtree's self-description at the parent (root) level. Each subtree owns
|
|
51
74
|
* the content that represents it one level up: its vocabulary line, its
|
|
52
75
|
* selection rubric, and any bounded block it contributes to the parent's -h.
|
|
@@ -75,18 +98,32 @@ export interface RootHelp {
|
|
|
75
98
|
* root, carrying the subtree's concept, selection rubric, and any nested
|
|
76
99
|
* runtime-state block. Assembled from subtrees' RootEntry by defineRoot;
|
|
77
100
|
* root hardcodes none of it. */
|
|
78
|
-
commands:
|
|
79
|
-
name: string;
|
|
80
|
-
concept: string;
|
|
81
|
-
desc: string;
|
|
82
|
-
useWhen: string;
|
|
83
|
-
dynamicState?: () => string | null;
|
|
84
|
-
}[];
|
|
101
|
+
commands: RootCommand[];
|
|
85
102
|
globals: {
|
|
86
103
|
name: string;
|
|
87
104
|
desc: string;
|
|
88
105
|
}[];
|
|
89
106
|
}
|
|
107
|
+
/** A single command block at root. Most fields come from the subtree's
|
|
108
|
+
* RootEntry; `subcommands`/`otherSubcommandCount` are computed by defineRoot
|
|
109
|
+
* from the subtree's children tiers. */
|
|
110
|
+
export interface RootCommand {
|
|
111
|
+
name: string;
|
|
112
|
+
concept: string;
|
|
113
|
+
desc: string;
|
|
114
|
+
useWhen: string;
|
|
115
|
+
dynamicState?: () => string | null;
|
|
116
|
+
/** Promoted subcommands surfaced inline under this command at root, in
|
|
117
|
+
* declaration order. `desc` is present only for 'important' tier; 'common'
|
|
118
|
+
* tier carries the bare qualified path. */
|
|
119
|
+
subcommands?: {
|
|
120
|
+
path: string;
|
|
121
|
+
desc?: string;
|
|
122
|
+
}[];
|
|
123
|
+
/** How many of this command's other (non-hidden, not-promoted) direct
|
|
124
|
+
* subcommands are not shown. Drives the "[+N (other) subcommands]" line. */
|
|
125
|
+
otherSubcommandCount?: number;
|
|
126
|
+
}
|
|
90
127
|
export interface BranchHelp {
|
|
91
128
|
name: string;
|
|
92
129
|
summary: string;
|
|
@@ -96,11 +133,7 @@ export interface BranchHelp {
|
|
|
96
133
|
* it with stateBlock), e.g. `<skills count="42">…</skills>`. Renderer
|
|
97
134
|
* soft-fails to omission if this returns null or throws. */
|
|
98
135
|
dynamicState?: () => string | null;
|
|
99
|
-
children:
|
|
100
|
-
name: string;
|
|
101
|
-
desc: string;
|
|
102
|
-
useWhen: string;
|
|
103
|
-
}[];
|
|
136
|
+
children: BranchChild[];
|
|
104
137
|
}
|
|
105
138
|
export interface LeafHelp {
|
|
106
139
|
name: string;
|
package/dist/core/help.js
CHANGED
|
@@ -54,6 +54,31 @@ const IO_CONTRACT = 'I/O contract: flags and positional args on input; stdout is
|
|
|
54
54
|
const CAPABILITY_DISCOVERY = "If the user mentions or implies a crtr capability you don't fully understand, " +
|
|
55
55
|
'do not guess or assume it is unsupported — run `-h` on the relevant command ' +
|
|
56
56
|
'(append it anywhere along the path) to read the contract before acting.';
|
|
57
|
+
/** Lines for a command's subcommand affordance at root: any promoted
|
|
58
|
+
* (common/important) subcommands, then a remainder line naming how many other
|
|
59
|
+
* subcommands exist behind `crtr <name> -h`. Returns [] when the command has
|
|
60
|
+
* no listable subcommands at all. */
|
|
61
|
+
function rootSubcommandLines(c) {
|
|
62
|
+
const promoted = c.subcommands ?? [];
|
|
63
|
+
const other = c.otherSubcommandCount ?? 0;
|
|
64
|
+
if (promoted.length === 0 && other === 0)
|
|
65
|
+
return [];
|
|
66
|
+
const out = [];
|
|
67
|
+
if (promoted.length > 0) {
|
|
68
|
+
const labelW = maxLen(promoted.map((s) => s.path));
|
|
69
|
+
for (const s of promoted) {
|
|
70
|
+
// important → padded name + shortform desc; common → bare name.
|
|
71
|
+
out.push(s.desc !== undefined && s.desc !== ''
|
|
72
|
+
? ` ${pad(s.path, labelW)} ${s.desc}`
|
|
73
|
+
: ` ${s.path}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (other > 0) {
|
|
77
|
+
const word = promoted.length > 0 ? 'other subcommand' : 'subcommand';
|
|
78
|
+
out.push(` [+${other} ${word}${other === 1 ? '' : 's'} — \`crtr ${c.name} -h\`]`);
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
57
82
|
export function renderRoot(h) {
|
|
58
83
|
const lines = [];
|
|
59
84
|
lines.push(`${h.tagline}`);
|
|
@@ -71,6 +96,11 @@ export function renderRoot(h) {
|
|
|
71
96
|
lines.push(`<command name="${c.name}">`);
|
|
72
97
|
lines.push(c.concept);
|
|
73
98
|
lines.push(`use when ${c.useWhen}`);
|
|
99
|
+
// The command's subcommand surface: promoted (common/important) children
|
|
100
|
+
// inline, plus a "[+N other subcommands]" pointer to its own -h. Sits
|
|
101
|
+
// between the selection rubric and any live state block.
|
|
102
|
+
for (const l of rootSubcommandLines(c))
|
|
103
|
+
lines.push(l);
|
|
74
104
|
// dynamicState returns a complete self-named element (e.g.
|
|
75
105
|
// <skills count="42">…</skills>) — emit it as-is, nested in the command.
|
|
76
106
|
const state = evalDynamic(c.dynamicState);
|
|
@@ -113,10 +143,18 @@ export function renderBranch(h) {
|
|
|
113
143
|
}
|
|
114
144
|
lines.push('');
|
|
115
145
|
lines.push('Branches');
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
146
|
+
// 'hidden' children never appear in any listing — drop them here.
|
|
147
|
+
const visible = h.children.filter((c) => (c.tier ?? 'normal') !== 'hidden');
|
|
148
|
+
const nameW = maxLen(visible.map((c) => c.name));
|
|
149
|
+
const descW = maxLen(visible.map((c) => c.desc));
|
|
150
|
+
for (const c of visible) {
|
|
151
|
+
let line = ` ${pad(c.name, nameW)} ${pad(c.desc, descW)} | use when ${c.useWhen}`;
|
|
152
|
+
// A branch child is listed without its own subcommands expanded — flag how
|
|
153
|
+
// many it has so the agent knows there is more depth behind `<child> -h`.
|
|
154
|
+
if (c.subCount !== undefined && c.subCount > 0) {
|
|
155
|
+
line += ` [+${c.subCount} subcommand${c.subCount === 1 ? '' : 's'}]`;
|
|
156
|
+
}
|
|
157
|
+
lines.push(line);
|
|
120
158
|
}
|
|
121
159
|
return lines.join('\n');
|
|
122
160
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface DemoteResult {
|
|
2
|
+
/** True when the pane was recycled (a fresh root respawned in it). */
|
|
3
|
+
demoted: boolean;
|
|
4
|
+
/** True when a `final` report was pushed for the demoted node. */
|
|
5
|
+
finalized: boolean;
|
|
6
|
+
/** The fresh root node booted into the pane, or null on failure. */
|
|
7
|
+
newRoot: string | null;
|
|
8
|
+
/** Subscriber node ids that received the final report. */
|
|
9
|
+
delivered: string[];
|
|
10
|
+
}
|
|
11
|
+
/** Finish `nodeId` and recycle its pane into a fresh root. `callerPane` is the
|
|
12
|
+
* tmux pane the agent occupies (the Alt+C menu passes it as `#{pane_id}`).
|
|
13
|
+
* Best-effort; `demoted:false` when there is no pane to act on. */
|
|
14
|
+
export declare function demoteNode(nodeId: string, callerPane?: string): Promise<DemoteResult>;
|