@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.
Files changed (41) hide show
  1. package/dist/commands/__tests__/human.test.js +73 -2
  2. package/dist/commands/human/queue.d.ts +1 -0
  3. package/dist/commands/human/queue.js +89 -2
  4. package/dist/commands/human/shared.d.ts +5 -0
  5. package/dist/commands/human/shared.js +15 -0
  6. package/dist/commands/human.js +4 -2
  7. package/dist/commands/node.js +195 -24
  8. package/dist/core/__tests__/passive-subscription.test.d.ts +1 -0
  9. package/dist/core/__tests__/passive-subscription.test.js +141 -0
  10. package/dist/core/__tests__/subcommand-tier.test.d.ts +1 -0
  11. package/dist/core/__tests__/subcommand-tier.test.js +97 -0
  12. package/dist/core/canvas/paths.d.ts +4 -0
  13. package/dist/core/canvas/paths.js +6 -0
  14. package/dist/core/command.js +40 -7
  15. package/dist/core/feed/feed.js +11 -9
  16. package/dist/core/feed/passive.d.ts +17 -0
  17. package/dist/core/feed/passive.js +79 -0
  18. package/dist/core/help.d.ts +45 -12
  19. package/dist/core/help.js +42 -4
  20. package/dist/core/runtime/demote.d.ts +14 -0
  21. package/dist/core/runtime/demote.js +103 -0
  22. package/dist/core/runtime/kickoff.d.ts +9 -0
  23. package/dist/core/runtime/kickoff.js +19 -1
  24. package/dist/core/runtime/launch.d.ts +12 -1
  25. package/dist/core/runtime/launch.js +18 -2
  26. package/dist/core/runtime/presence.d.ts +1 -18
  27. package/dist/core/runtime/presence.js +7 -51
  28. package/dist/core/runtime/promote.d.ts +4 -0
  29. package/dist/core/runtime/promote.js +21 -6
  30. package/dist/core/runtime/roadmap.d.ts +5 -4
  31. package/dist/core/runtime/roadmap.js +9 -16
  32. package/dist/core/runtime/spawn.js +7 -2
  33. package/dist/core/runtime/tmux.d.ts +11 -12
  34. package/dist/core/runtime/tmux.js +57 -26
  35. package/dist/pi-extensions/canvas-commands.d.ts +34 -0
  36. package/dist/pi-extensions/canvas-commands.js +100 -0
  37. package/dist/pi-extensions/canvas-goal-capture.d.ts +18 -0
  38. package/dist/pi-extensions/canvas-goal-capture.js +53 -0
  39. package/dist/pi-extensions/canvas-passive-context.d.ts +32 -0
  40. package/dist/pi-extensions/canvas-passive-context.js +114 -0
  41. 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
  }
@@ -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
- name: s.name,
65
- concept: s.rootEntry.concept,
66
- desc: s.rootEntry.desc,
67
- useWhen: s.rootEntry.useWhen,
68
- dynamicState: s.rootEntry.dynamicState,
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,
@@ -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 inbox pointers to every subscriber (active and passive both
78
- // receive the pointer; the daemon decides whether to wake active ones).
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
- appendInbox(sub.node_id, {
84
- from,
85
- tier: tierFor(kind),
86
- kind,
87
- ref: reportPath,
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
+ }
@@ -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
- const nameW = maxLen(h.children.map((c) => c.name));
117
- const descW = maxLen(h.children.map((c) => c.desc));
118
- for (const c of h.children) {
119
- lines.push(` ${pad(c.name, nameW)} ${pad(c.desc, descW)} | use when ${c.useWhen}`);
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>;