@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
@@ -3,13 +3,15 @@
3
3
  //
4
4
  // These tests exercise the leaf param schemas via parseArgv (framework) and
5
5
  // spot-check the leaf definitions directly — no subprocess spawning, no tmux.
6
- import { test, describe } from 'node:test';
6
+ import { test, describe, before, after, beforeEach } from 'node:test';
7
7
  import assert from 'node:assert/strict';
8
- import { writeFileSync, mkdirSync } from 'node:fs';
8
+ import { writeFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
9
9
  import { tmpdir } from 'node:os';
10
10
  import { join } from 'node:path';
11
11
  import { randomBytes } from 'node:crypto';
12
12
  import { parseArgv } from '../../core/command.js';
13
+ import { humanCancel } from '../human/queue.js';
14
+ import { createNode, getNode, closeDb } from '../../core/canvas/index.js';
13
15
  // ---------------------------------------------------------------------------
14
16
  // Helper: write a temp JSON file and return its path.
15
17
  // ---------------------------------------------------------------------------
@@ -170,6 +172,75 @@ describe('human ask: params', () => {
170
172
  });
171
173
  });
172
174
  // ---------------------------------------------------------------------------
175
+ // human cancel — positional job_id + optional --reason
176
+ // ---------------------------------------------------------------------------
177
+ describe('human cancel: params', () => {
178
+ const params = [
179
+ { kind: 'positional', name: 'job_id', type: 'string', required: true, constraint: 'Interaction node id.' },
180
+ { kind: 'flag', name: 'reason', type: 'string', required: false, constraint: 'Why it was retracted.' },
181
+ ];
182
+ test('parses positional job_id', async () => {
183
+ const result = await parseArgv(params, ['abc-1234']);
184
+ assert.equal(result['job_id'], 'abc-1234');
185
+ });
186
+ test('parses job_id + --reason', async () => {
187
+ const result = await parseArgv(params, ['abc-1234', '--reason', 'answered myself']);
188
+ assert.equal(result['job_id'], 'abc-1234');
189
+ assert.equal(result['reason'], 'answered myself');
190
+ });
191
+ test('missing job_id throws missing_parameter', async () => {
192
+ await assert.rejects(() => parseArgv(params, []), (err) => { assert.match(err.message, /required parameter is missing/); return true; });
193
+ });
194
+ });
195
+ // ---------------------------------------------------------------------------
196
+ // human cancel — run behavior (canvas-backed)
197
+ // ---------------------------------------------------------------------------
198
+ describe('human cancel: behavior', () => {
199
+ let home;
200
+ function humanNode(id, over = {}) {
201
+ return {
202
+ node_id: id,
203
+ name: 'human-ask',
204
+ created: new Date().toISOString(),
205
+ cwd: join(tmpdir(), `crtr-cancel-cwd-${randomBytes(3).toString('hex')}`),
206
+ kind: 'human',
207
+ mode: 'base',
208
+ lifecycle: 'terminal',
209
+ status: 'active',
210
+ ...over,
211
+ };
212
+ }
213
+ before(() => {
214
+ home = mkdtempSync(join(tmpdir(), 'crtr-cancel-home-'));
215
+ process.env['CRTR_HOME'] = home;
216
+ });
217
+ beforeEach(() => {
218
+ closeDb();
219
+ rmSync(home, { recursive: true, force: true });
220
+ });
221
+ after(() => {
222
+ closeDb();
223
+ rmSync(home, { recursive: true, force: true });
224
+ delete process.env['CRTR_HOME'];
225
+ });
226
+ test('unknown node throws not_found', async () => {
227
+ await assert.rejects(() => humanCancel.run({ job_id: 'no-such-node' }), (err) => { assert.match(err.message, /no interaction node/); return true; });
228
+ });
229
+ test('already-done node returns canceled:false / already_resolved', async () => {
230
+ createNode(humanNode('done-1', { status: 'done' }));
231
+ const r = await humanCancel.run({ job_id: 'done-1' });
232
+ assert.equal(r['canceled'], false);
233
+ assert.equal(r['reason'], 'already_resolved');
234
+ });
235
+ test('live node with no pane → retires the node (status done)', async () => {
236
+ createNode(humanNode('live-1', { status: 'active' }));
237
+ const r = await humanCancel.run({ job_id: 'live-1' });
238
+ assert.equal(r['canceled'], true);
239
+ assert.equal(r['job_id'], 'live-1');
240
+ assert.equal(getNode('live-1')?.status, 'done');
241
+ });
242
+ });
243
+ // ---------------------------------------------------------------------------
173
244
  // human list — --limit int + --cursor string
174
245
  // ---------------------------------------------------------------------------
175
246
  describe('human list: params', () => {
@@ -1,3 +1,4 @@
1
1
  export declare const humanInbox: import("../../core/command.js").LeafDef;
2
2
  export declare const humanList: import("../../core/command.js").LeafDef;
3
+ export declare const humanCancel: import("../../core/command.js").LeafDef;
3
4
  export declare const humanRun: import("../../core/command.js").LeafDef;
@@ -1,9 +1,14 @@
1
1
  import { defineLeaf } from '../../core/command.js';
2
+ import { InputError } from '../../core/io.js';
2
3
  import { pushFinal } from '../../core/feed/feed.js';
3
- import { interactionsRoot } from '../../core/artifact.js';
4
+ import { interactionsRoot, interactionDir } from '../../core/artifact.js';
4
5
  import { paginate } from '../../core/pagination.js';
6
+ import { getNode, setStatus, updateNode, subscribersOf } from '../../core/canvas/index.js';
7
+ import { appendInbox } from '../../core/feed/inbox.js';
8
+ import { existsSync } from 'node:fs';
5
9
  import { join } from 'node:path';
6
- import { inbox, scanInbox, parseDeck, deckPath, ask, launchReview, readJson, } from '@crouton-kit/humanloop';
10
+ import { inbox, scanInbox, parseDeck, deckPath, responsePath, isResolved, atomicWriteJson, ask, launchReview, readJson, } from '@crouton-kit/humanloop';
11
+ import { killPane } from './shared.js';
7
12
  // ---------------------------------------------------------------------------
8
13
  // inbox (human-invoked, blocking)
9
14
  // ---------------------------------------------------------------------------
@@ -71,6 +76,88 @@ export const humanList = defineLeaf({
71
76
  },
72
77
  });
73
78
  // ---------------------------------------------------------------------------
79
+ // cancel — retract a pending ask/approve/review
80
+ // ---------------------------------------------------------------------------
81
+ export const humanCancel = defineLeaf({
82
+ name: 'cancel',
83
+ help: {
84
+ name: 'human cancel',
85
+ summary: 'retract a pending ask/approve/review you posed — kills its TUI pane, drops it from the human queue, and retires the node. Reach for this the moment a question goes stale (you answered it yourself, the situation changed) so a human is not left resolving a prompt whose answer no longer matters',
86
+ guide: 'Pass the job_id returned by `human ask`/`approve`/`review`. Best-effort and idempotent: if the human already answered, or it was already canceled, it reports canceled:false with reason "already_resolved" and changes nothing. Subscribers (you and the asking node) get an inbox note that no answer is coming, so nobody waits on it. A blocking `human review` caller unblocks with status "closed" when its pane is killed.',
87
+ params: [
88
+ { kind: 'positional', name: 'job_id', type: 'string', required: true, constraint: 'Node id of the interaction to cancel — the job_id returned by ask/approve/review.' },
89
+ { kind: 'flag', name: 'reason', type: 'string', required: false, constraint: 'Optional short note delivered to subscribers explaining why it was retracted.' },
90
+ ],
91
+ output: [
92
+ { name: 'canceled', type: 'boolean', required: true, constraint: 'True when the interaction was retracted; false when there was nothing live to cancel (already answered/canceled).' },
93
+ { name: 'job_id', type: 'string', required: true, constraint: 'The interaction node id.' },
94
+ { name: 'reason', type: 'string', required: false, constraint: 'Why nothing was canceled (e.g. "already_resolved"), present when canceled is false.' },
95
+ ],
96
+ outputKind: 'object',
97
+ effects: [
98
+ "Kills the detached TUI pane (if any) so the prompt leaves the human's screen.",
99
+ 'Writes a canceled response.json so the interaction drops out of `human list`/`inbox`.',
100
+ 'Marks the node done and notifies its subscribers that no answer is coming.',
101
+ ],
102
+ },
103
+ run: async (input) => {
104
+ const jobId = input['job_id'];
105
+ const reason = input['reason'];
106
+ const node = getNode(jobId);
107
+ if (node === null) {
108
+ throw new InputError({
109
+ error: 'not_found',
110
+ message: `no interaction node: ${jobId}`,
111
+ field: 'job_id',
112
+ next: 'Pass the job_id from human ask/approve/review, or list pending with `crtr human list`.',
113
+ });
114
+ }
115
+ // Resolve the interaction dir from the node's RECORDED cwd: interaction dirs
116
+ // are keyed by the asking process's cwd, which may differ from the caller's.
117
+ const idir = interactionDir(jobId, node.cwd);
118
+ // Nothing live to cancel: the human already answered, or it was retired.
119
+ if (node.status === 'done' || node.status === 'dead' || isResolved(idir)) {
120
+ return { canceled: false, job_id: jobId, reason: 'already_resolved' };
121
+ }
122
+ // (1) Kill the detached TUI pane so the prompt leaves the human's screen and
123
+ // any blocking `human review` caller unblocks (pane death → 'closed').
124
+ const rc = readJson(join(idir, 'run.json'));
125
+ if (rc?.pane_id !== undefined)
126
+ killPane(rc.pane_id);
127
+ // (2) Drop it from the human queue: a response.json marks the dir resolved,
128
+ // so scanInbox (human list/inbox) skips it.
129
+ if (existsSync(idir)) {
130
+ atomicWriteJson(responsePath(idir), {
131
+ canceled: true,
132
+ canceledAt: new Date().toISOString(),
133
+ ...(reason !== undefined && reason !== '' ? { reason } : {}),
134
+ });
135
+ }
136
+ // (3) Retire the node and tell its subscribers no answer is coming. We do
137
+ // NOT push a -final.md report: a blocking `human review` caller must see
138
+ // the pane-death 'closed', not a phantom 'done' result.
139
+ setStatus(jobId, 'done');
140
+ updateNode(jobId, { intent: 'done' });
141
+ const caller = process.env['CRTR_NODE_ID'] ?? 'human';
142
+ const note = reason !== undefined && reason !== '' ? ` — ${reason}` : '';
143
+ for (const sub of subscribersOf(jobId)) {
144
+ if (sub.node_id === caller)
145
+ continue; // don't ping whoever issued the cancel
146
+ appendInbox(sub.node_id, {
147
+ from: caller,
148
+ tier: 'normal',
149
+ kind: 'message',
150
+ label: `human interaction ${jobId} canceled — no answer is coming${note}`,
151
+ data: { body: `The human interaction ${jobId} was canceled${note}. No response will arrive.` },
152
+ });
153
+ }
154
+ return { canceled: true, job_id: jobId };
155
+ },
156
+ render: (r) => r['canceled'] === true
157
+ ? `<canceled job_id="${r['job_id']}"/>`
158
+ : `<cancel-noop job_id="${r['job_id']}">${r['reason'] ?? 'nothing to cancel'}</cancel-noop>`,
159
+ });
160
+ // ---------------------------------------------------------------------------
74
161
  // _run (hidden worker; not listed in branch help)
75
162
  // ---------------------------------------------------------------------------
76
163
  export const humanRun = defineLeaf({
@@ -5,6 +5,8 @@ export interface RunRecord {
5
5
  approve_iid?: string;
6
6
  file?: string;
7
7
  output?: string;
8
+ /** tmux pane id of the detached TUI, recorded so `human cancel` can kill it. */
9
+ pane_id?: string;
8
10
  }
9
11
  export declare function resolveMaxPanes(): number;
10
12
  export declare function pickPlacement(): 'split-h' | 'new-window';
@@ -28,6 +30,9 @@ export declare function spawnHumanJob(jobId: string, idir: string, cwd: string):
28
30
  follow_up: string;
29
31
  paneId?: string;
30
32
  };
33
+ /** Best-effort kill of a detached TUI pane. No-op (and never throws) when the
34
+ * pane is already gone or tmux is unavailable — `human cancel` is best-effort. */
35
+ export declare function killPane(paneId: string): boolean;
31
36
  export interface HumanResult {
32
37
  status: string;
33
38
  result?: unknown;
@@ -2,6 +2,7 @@ import { readConfig } from '../../core/config.js';
2
2
  import { spawnSync } from 'node:child_process';
3
3
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
+ import { atomicWriteJson, readJson } from '@crouton-kit/humanloop';
5
6
  import { countPanesInCurrentWindow, spawnAndDetach, shellQuote } from '../../core/spawn.js';
6
7
  import { reportsDir } from '../../core/canvas/paths.js';
7
8
  export const DECK_SCHEMA_HINT = 'Deck must match the humanloop deck schema: {title?, ' +
@@ -48,12 +49,26 @@ export function spawnHumanJob(jobId, idir, cwd) {
48
49
  if (spawn.status !== 'spawned') {
49
50
  return { spawned: false, follow_up: followUpDrain(jobId) };
50
51
  }
52
+ // Record the pane id on run.json so `human cancel` can later kill the TUI.
53
+ // run.json was already written by the caller; merge the pane id in place.
54
+ if (spawn.paneId !== undefined) {
55
+ const rcPath = join(idir, 'run.json');
56
+ const rc = readJson(rcPath);
57
+ if (rc !== null)
58
+ atomicWriteJson(rcPath, { ...rc, pane_id: spawn.paneId });
59
+ }
51
60
  return {
52
61
  spawned: true,
53
62
  follow_up: followUpResult(jobId),
54
63
  ...(spawn.paneId !== undefined ? { paneId: spawn.paneId } : {}),
55
64
  };
56
65
  }
66
+ /** Best-effort kill of a detached TUI pane. No-op (and never throws) when the
67
+ * pane is already gone or tmux is unavailable — `human cancel` is best-effort. */
68
+ export function killPane(paneId) {
69
+ const r = spawnSync('tmux', ['kill-pane', '-t', paneId], { encoding: 'utf8' });
70
+ return r.status === 0;
71
+ }
57
72
  /** True when a tmux pane is still alive. */
58
73
  function paneAlive(paneId) {
59
74
  const r = spawnSync('tmux', ['display-message', '-p', '-t', paneId, '#{pane_id}'], {
@@ -13,13 +13,13 @@
13
13
  // run.json, never stdin.
14
14
  import { defineBranch } from '../core/command.js';
15
15
  import { humanAsk, humanApprove, humanReview, humanNotify, humanShow } from './human/prompts.js';
16
- import { humanInbox, humanList, humanRun } from './human/queue.js';
16
+ import { humanInbox, humanList, humanCancel, humanRun } from './human/queue.js';
17
17
  export function registerHuman() {
18
18
  return defineBranch({
19
19
  name: 'human',
20
20
  rootEntry: {
21
21
  concept: 'human-in-the-loop decisions, document review, and live display: ask puts a structured choice to a person, approve gates a handoff on a Yes/No sign-off, review collects anchored comments on a plan or spec, notify informs without blocking, show puts a file live on screen',
22
- desc: 'ask, approve, review, notify, show, inbox, list',
22
+ desc: 'ask, approve, review, notify, show, cancel, inbox, list',
23
23
  useWhen: 'you have a question for the user or want their feedback — always reach for human instead of guessing or assuming when a person can decide',
24
24
  },
25
25
  help: {
@@ -32,6 +32,7 @@ export function registerHuman() {
32
32
  { name: 'review', desc: 'anchored-comment review of a .md', useWhen: 'a human should comment on a plan or spec' },
33
33
  { name: 'notify', desc: 'fire-and-forget acknowledgement', useWhen: 'informing a person without blocking' },
34
34
  { name: 'show', desc: 'put a file live on screen', useWhen: 'displaying a doc while a human comments' },
35
+ { name: 'cancel', desc: 'retract a pending ask/approve/review', useWhen: 'a question went stale before the human answered' },
35
36
  { name: 'inbox', desc: 'interactively drain pending interactions', useWhen: 'a human clears the queue at their terminal' },
36
37
  { name: 'list', desc: 'enumerate pending interactions', useWhen: 'discovering what is blocked on a human' },
37
38
  ],
@@ -44,6 +45,7 @@ export function registerHuman() {
44
45
  humanShow,
45
46
  humanInbox,
46
47
  humanList,
48
+ humanCancel,
47
49
  humanRun,
48
50
  ],
49
51
  });
@@ -10,11 +10,12 @@ import { spawnChild, bootRoot } from '../core/runtime/spawn.js';
10
10
  import { promote, requestYield } from '../core/runtime/promote.js';
11
11
  import { writeYieldMessage } from '../core/runtime/kickoff.js';
12
12
  import { reviveNode } from '../core/runtime/revive.js';
13
- import { focusNodeInPlace, demoteNode } from '../core/runtime/presence.js';
13
+ import { focusNodeInPlace } from '../core/runtime/presence.js';
14
+ import { demoteNode } from '../core/runtime/demote.js';
14
15
  import { windowAlive, windowOfPane, currentTmux } from '../core/runtime/tmux.js';
15
16
  import { appendInbox } from '../core/feed/inbox.js';
16
17
  import { availableKinds } from '../core/personas/index.js';
17
- import { getNode, listNodes, subscriptionsOf, subscribersOf, } from '../core/canvas/index.js';
18
+ import { getNode, listNodes, subscribe, unsubscribe, subscriptionsOf, subscribersOf, } from '../core/canvas/index.js';
18
19
  /** Validate a `--kind` against the installed personas; throws a listing InputError. */
19
20
  function assertKind(kind) {
20
21
  const kinds = availableKinds();
@@ -44,7 +45,7 @@ const nodeNew = defineLeaf({
44
45
  { name: 'window', type: 'string', required: false, constraint: 'tmux window id of the background window.' },
45
46
  { name: 'session', type: 'string', required: true, constraint: 'The shared crtr tmux session the node was placed in.' },
46
47
  { name: 'status', type: 'string', required: true, constraint: 'Always "active" on spawn.' },
47
- { name: 'follow_up', type: 'string', required: true, constraint: 'A notification to the caller about the spawn: the child runs independently and its finish wakes you automatically, so treat it as fire-and-forget. Read it, then act.' },
48
+ { name: 'follow_up', type: 'string', required: true, constraint: 'Decision road sign for the caller: the child runs independently and its finish wakes you on its own, so never wait or poll on it either pick up other work now or end your turn. Read it, then act.' },
48
49
  ],
49
50
  outputKind: 'object',
50
51
  effects: [
@@ -70,7 +71,7 @@ const nodeNew = defineLeaf({
70
71
  window: res.window ?? undefined,
71
72
  session: res.session,
72
73
  status: res.node.status,
73
- follow_up: "Notification only — you're auto-subscribed, so the child's finish wakes you automatically; treat it as fire-and-forget. Carry on with other independent work now, or stop and end your turn. On wake: `crtr feed read`.",
74
+ follow_up: "Do not wait or poll on this child there is no result to await and stopping will not strand you. You're auto-subscribed, so its finish wakes you on its own. Two moves only: pick up other independent work right now, or stop and end your turn the wake brings you back. Sitting idle to watch it is wasted; pick one and act.",
74
75
  };
75
76
  },
76
77
  render: (r) => `<spawned name="${r['name']}" id="${r['node_id']}" status="${r['status']}">\n${r['follow_up']}\n</spawned>`,
@@ -193,47 +194,141 @@ function nodeByWindow(win) {
193
194
  }
194
195
  return undefined;
195
196
  }
197
+ /** The live node occupying a tmux pane (pane → window → node), or undefined.
198
+ * Defaults to $TMUX_PANE / the caller's current pane when `pane` is omitted —
199
+ * shared by `node demote` and `node cycle`, both of which act on "the agent in
200
+ * front of you". */
201
+ function nodeInPane(pane) {
202
+ const resolvePane = pane ?? process.env['TMUX_PANE'] ?? currentTmux()?.pane;
203
+ const win = resolvePane !== undefined && resolvePane !== '' ? windowOfPane(resolvePane) : null;
204
+ return win !== null ? nodeByWindow(win) : undefined;
205
+ }
196
206
  const nodeDemote = defineLeaf({
197
207
  name: 'demote',
198
208
  help: {
199
209
  name: 'node demote',
200
- summary: 'detach the agent in your current pane to the backgroundswap a fresh terminal into its place and relocate its running pi to a window in the shared crtr session (the inverse of focus; reattach later with `node focus`)',
210
+ summary: 'finish the agent in your current pane and recycle the panepush its last message as a final report to everyone waiting on it, mark it done, then boot a fresh crtr root in the same pane',
201
211
  params: [
202
- { kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Node to demote. Defaults to the node occupying --pane (or your current pane).' },
203
- { kind: 'flag', name: 'pane', type: 'string', required: false, constraint: 'tmux pane id to demote out of. Defaults to $TMUX_PANE / your current pane. The Alt+C menu passes this for you.' },
212
+ { kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Node to finish. Defaults to the node occupying --pane (or your current pane).' },
213
+ { kind: 'flag', name: 'pane', type: 'string', required: false, constraint: 'tmux pane id to recycle. Defaults to $TMUX_PANE / your current pane. The Alt+C menu passes this for you.' },
204
214
  ],
205
215
  output: [
206
- { name: 'demoted', type: 'boolean', required: true, constraint: 'True when the agent was swapped out to the background.' },
207
- { name: 'node_id', type: 'string', required: false, constraint: 'The demoted node.' },
208
- { name: 'session', type: 'string', required: false, constraint: 'The shared session the agent now lives in.' },
209
- { name: 'window', type: 'string', required: false, constraint: 'The agent\'s new background window id.' },
216
+ { name: 'demoted', type: 'boolean', required: true, constraint: 'True when the pane was recycled into a fresh root.' },
217
+ { name: 'node_id', type: 'string', required: false, constraint: 'The finished node.' },
218
+ { name: 'finalized', type: 'boolean', required: false, constraint: 'True when a final report was pushed to its subscribers.' },
219
+ { name: 'delivered', type: 'number', required: false, constraint: 'How many subscribers/managers received the final report.' },
220
+ { name: 'new_root', type: 'string', required: false, constraint: 'The fresh root node booted into the pane.' },
210
221
  ],
211
222
  outputKind: 'object',
212
- effects: ['Swaps a fresh shell into the caller pane (tmux swap-pane) and relocates the node\'s pi window into the shared crtr session.', 'Clears the focus pointer if the demoted node held it. The pi keeps running — nothing is killed.'],
223
+ effects: ['Pushes a final report from the node (fans out to all subscribers) and marks it done.', 'Kills the agent\'s pi and respawns a fresh resident root in the same tmux pane.'],
213
224
  },
214
225
  run: async (input) => {
215
226
  const pane = input['pane'] ?? process.env['TMUX_PANE'];
216
227
  let id = input['node'];
217
228
  if (id === undefined || id === '') {
218
229
  // Derive the node from the pane: which node's window holds it?
219
- const resolvePane = pane ?? currentTmux()?.pane;
220
- const win = resolvePane !== undefined ? windowOfPane(resolvePane) : null;
221
- id = win !== null ? nodeByWindow(win) : undefined;
230
+ id = nodeInPane(pane);
222
231
  }
223
232
  if (id === undefined || id === '') {
224
- throw new InputError({ error: 'no_node', message: 'no node found in this pane to demote', next: 'Pass --node <id>, or run from inside a focused node\'s pane.' });
233
+ throw new InputError({ error: 'no_node', message: 'no node found in this pane to finish', next: 'Pass --node <id>, or run from inside the agent\'s pane.' });
225
234
  }
226
235
  if (getNode(id) === null) {
227
236
  throw new InputError({ error: 'not_found', message: `no node: ${id}`, next: 'List nodes with `crtr node inspect list`.' });
228
237
  }
229
- const res = demoteNode(id, pane);
230
- return { demoted: res.demoted, node_id: id, session: res.session ?? undefined, window: res.window ?? undefined };
238
+ const res = await demoteNode(id, pane);
239
+ return { demoted: res.demoted, node_id: id, finalized: res.finalized, delivered: res.delivered.length, new_root: res.newRoot ?? undefined };
231
240
  },
232
241
  render: (r) => r['demoted'] === true
233
- ? `<demoted id="${r['node_id']}" session="${r['session'] ?? ''}"/>`
242
+ ? `<demoted id="${r['node_id']}" finalized="${r['finalized']}" delivered="${r['delivered']}" new_root="${r['new_root'] ?? ''}"/>`
234
243
  : `<demote-failed id="${r['node_id'] ?? ''}">not in tmux, or no agent in this pane</demote-failed>`,
235
244
  });
236
245
  // ---------------------------------------------------------------------------
246
+ // node cycle — DFS-walk the canvas one window at a time (Alt+] / Alt+[)
247
+ // ---------------------------------------------------------------------------
248
+ /** Every live node in DFS pre-order across the whole forest. The spawn tree is
249
+ * the `parent` field; children inherit their parent's row order (created), so
250
+ * the walk descends into a node's children before moving to its siblings —
251
+ * exactly "next in pre-order is your first child". Roots are live nodes with no
252
+ * live parent (a done/dead parent orphans its live children up to the top).
253
+ * Cycle-safe: a final pass appends any node a cycle kept from being reached. */
254
+ function liveDfsOrder() {
255
+ const rows = listNodes({ status: ['active', 'idle'] }); // ORDER BY created
256
+ const liveIds = new Set(rows.map((r) => r.node_id));
257
+ const childrenOf = new Map();
258
+ for (const r of rows) {
259
+ const p = r.parent;
260
+ if (p != null && liveIds.has(p)) {
261
+ const arr = childrenOf.get(p) ?? [];
262
+ arr.push(r.node_id);
263
+ childrenOf.set(p, arr);
264
+ }
265
+ }
266
+ const out = [];
267
+ const seen = new Set();
268
+ const visit = (id) => {
269
+ if (seen.has(id))
270
+ return;
271
+ seen.add(id);
272
+ out.push(id);
273
+ for (const c of childrenOf.get(id) ?? [])
274
+ visit(c);
275
+ };
276
+ for (const r of rows)
277
+ if (r.parent == null || !liveIds.has(r.parent))
278
+ visit(r.node_id);
279
+ for (const r of rows)
280
+ visit(r.node_id); // stragglers (parent cycles)
281
+ return out;
282
+ }
283
+ const nodeCycle = defineLeaf({
284
+ name: 'cycle',
285
+ help: {
286
+ name: 'node cycle',
287
+ summary: 'focus the next/previous live node in DFS pre-order — the canvas walked one window at a time, descending into a node\'s children before its siblings (bound to Alt+] forward / Alt+[ back)',
288
+ params: [
289
+ { kind: 'flag', name: 'dir', type: 'enum', choices: ['next', 'prev'], required: false, default: 'next', constraint: 'Direction along the pre-order: next (Alt+], rightward/deeper into children) or prev (Alt+[, back). Wraps at the ends.' },
290
+ { kind: 'flag', name: 'pane', type: 'string', required: false, constraint: 'tmux pane to cycle FROM. Defaults to $TMUX_PANE / your current pane. The Alt+] / Alt+[ bindings pass this for you.' },
291
+ ],
292
+ output: [
293
+ { name: 'focused', type: 'boolean', required: true, constraint: 'True when the neighbor was brought into view.' },
294
+ { name: 'node_id', type: 'string', required: false, constraint: 'The node now in front of you.' },
295
+ { name: 'name', type: 'string', required: false, constraint: 'Its display name.' },
296
+ { name: 'from', type: 'string', required: false, constraint: 'The node you cycled away from.' },
297
+ ],
298
+ outputKind: 'object',
299
+ effects: ['Swaps the neighbor\'s pane into the caller pane (like `node focus`); the node you were viewing drops to the background.', 'Revives the neighbor first if its window was released.'],
300
+ },
301
+ run: async (input) => {
302
+ const pane = input['pane'] ?? process.env['TMUX_PANE'] ?? currentTmux()?.pane ?? undefined;
303
+ const dir = (input['dir'] ?? 'next');
304
+ const fromId = nodeInPane(pane);
305
+ if (fromId === undefined)
306
+ return { focused: false };
307
+ const order = liveDfsOrder();
308
+ const i = order.indexOf(fromId);
309
+ if (i === -1 || order.length < 2)
310
+ return { focused: false, node_id: fromId, from: fromId };
311
+ const step = dir === 'next' ? 1 : -1;
312
+ const targetId = order[(i + step + order.length) % order.length];
313
+ const target = getNode(targetId);
314
+ if (target === null)
315
+ return { focused: false, from: fromId };
316
+ // A live node may have had its window released — revive (resume) so there is
317
+ // a window to swap in, mirroring `node focus`.
318
+ if (!windowAlive(target.tmux_session, target.window)) {
319
+ try {
320
+ reviveNode(targetId, { resume: true });
321
+ }
322
+ catch { /* fall through */ }
323
+ }
324
+ const res = focusNodeInPlace(targetId, pane, fromId);
325
+ return { focused: res.focused, node_id: targetId, name: target.name, from: fromId };
326
+ },
327
+ render: (r) => r['focused'] === true
328
+ ? `<cycled to="${r['node_id']}" name="${r['name'] ?? ''}" from="${r['from'] ?? ''}"/>`
329
+ : `<cycle-noop>no other live node to focus</cycle-noop>`,
330
+ });
331
+ // ---------------------------------------------------------------------------
237
332
  // node session — boot a NEW root in its own tmux session (the explicit form)
238
333
  // ---------------------------------------------------------------------------
239
334
  const nodeSession = defineLeaf({
@@ -310,6 +405,77 @@ const nodeMsg = defineLeaf({
310
405
  },
311
406
  });
312
407
  // ---------------------------------------------------------------------------
408
+ // node subscribe / unsubscribe — wire the subscribes_to spine between any pair
409
+ // ---------------------------------------------------------------------------
410
+ /** Resolve the subscriber: explicit --subscriber wins, else the calling node. */
411
+ function resolveSubscriber(input) {
412
+ const sub = input['subscriber'] ?? process.env['CRTR_NODE_ID'];
413
+ if (sub === undefined || sub === '') {
414
+ throw new InputError({ error: 'no_subscriber', message: 'no subscriber (set CRTR_NODE_ID or pass --subscriber)', field: 'subscriber', next: 'Run from inside a node, or pass --subscriber <id>.' });
415
+ }
416
+ return sub;
417
+ }
418
+ const nodeSubscribe = defineLeaf({
419
+ name: 'subscribe',
420
+ help: {
421
+ name: 'node subscribe',
422
+ summary: 'wire a subscribes_to edge so one node receives another\'s pushes — the subscriber can be you (default) or, with --subscriber, ANY node, to ANY publisher. Re-running flips an existing edge\'s active/passive mode.',
423
+ params: [
424
+ { kind: 'positional', name: 'publisher', required: true, constraint: 'The node to subscribe TO — whose pushes get delivered to the subscriber.' },
425
+ { kind: 'flag', name: 'subscriber', type: 'string', required: false, constraint: 'Who receives the pushes. Defaults to the calling node (CRTR_NODE_ID). Pass any node id to wire a third party.' },
426
+ { kind: 'flag', name: 'passive', type: 'bool', required: false, constraint: 'Passive subscription: pushes ACCUMULATE without waking the subscriber, then auto-inject as timestamped XML pre-text on its next message. Omit for an active (wake-on-push) subscription.' },
427
+ ],
428
+ output: [
429
+ { name: 'subscribed', type: 'boolean', required: true, constraint: 'True when the edge was created/updated.' },
430
+ { name: 'subscriber', type: 'string', required: true, constraint: 'The receiving node.' },
431
+ { name: 'publisher', type: 'string', required: true, constraint: 'The node being subscribed to.' },
432
+ { name: 'mode', type: 'string', required: true, constraint: '"active" (wakes on push) or "passive" (accumulates, no wake).' },
433
+ ],
434
+ outputKind: 'object',
435
+ effects: ['Upserts a subscribes_to edge in canvas.db (active flag set from --passive).', 'Passive edges never wake the subscriber and do not hold it alive (excluded from the stop-guard).'],
436
+ },
437
+ run: async (input) => {
438
+ const publisher = input['publisher'];
439
+ const subscriber = resolveSubscriber(input);
440
+ const passive = input['passive'] === true;
441
+ if (subscriber === publisher) {
442
+ throw new InputError({ error: 'self_subscribe', message: 'a node cannot subscribe to itself', next: 'Pick a different publisher.' });
443
+ }
444
+ if (getNode(subscriber) === null)
445
+ throw new InputError({ error: 'not_found', message: `no node: ${subscriber}`, field: 'subscriber', next: 'List nodes with `crtr node inspect list`.' });
446
+ if (getNode(publisher) === null)
447
+ throw new InputError({ error: 'not_found', message: `no node: ${publisher}`, field: 'publisher', next: 'List nodes with `crtr node inspect list`.' });
448
+ subscribe(subscriber, publisher, !passive);
449
+ return { subscribed: true, subscriber, publisher, mode: passive ? 'passive' : 'active' };
450
+ },
451
+ render: (r) => `<subscribed subscriber="${r['subscriber']}" publisher="${r['publisher']}" mode="${r['mode']}"/>`,
452
+ });
453
+ const nodeUnsubscribe = defineLeaf({
454
+ name: 'unsubscribe',
455
+ help: {
456
+ name: 'node unsubscribe',
457
+ summary: 'drop a subscribes_to edge — the subscriber (you by default, or any node via --subscriber) stops receiving the publisher\'s pushes.',
458
+ params: [
459
+ { kind: 'positional', name: 'publisher', required: true, constraint: 'The node to stop subscribing to.' },
460
+ { kind: 'flag', name: 'subscriber', type: 'string', required: false, constraint: 'Who to detach. Defaults to the calling node (CRTR_NODE_ID).' },
461
+ ],
462
+ output: [
463
+ { name: 'unsubscribed', type: 'boolean', required: true, constraint: 'True when the edge was removed (idempotent — also true if none existed).' },
464
+ { name: 'subscriber', type: 'string', required: true, constraint: 'The detached node.' },
465
+ { name: 'publisher', type: 'string', required: true, constraint: 'The node it stopped subscribing to.' },
466
+ ],
467
+ outputKind: 'object',
468
+ effects: ['Deletes the subscribes_to edge from canvas.db.'],
469
+ },
470
+ run: async (input) => {
471
+ const publisher = input['publisher'];
472
+ const subscriber = resolveSubscriber(input);
473
+ unsubscribe(subscriber, publisher);
474
+ return { unsubscribed: true, subscriber, publisher };
475
+ },
476
+ render: (r) => `<unsubscribed subscriber="${r['subscriber']}" publisher="${r['publisher']}"/>`,
477
+ });
478
+ // ---------------------------------------------------------------------------
313
479
  // node promote — become a resident orchestrator (terminal → resident polymorph)
314
480
  // ---------------------------------------------------------------------------
315
481
  const nodePromote = defineLeaf({
@@ -326,6 +492,8 @@ const nodePromote = defineLeaf({
326
492
  { name: 'kind', type: 'string', required: true, constraint: 'The kind it now orchestrates as.' },
327
493
  { name: 'mode', type: 'string', required: true, constraint: 'Now "orchestrator".' },
328
494
  { name: 'roadmap_written', type: 'boolean', required: true, constraint: 'True if a roadmap scaffold was seeded by this call.' },
495
+ { name: 'roadmap_path', type: 'string', required: true, constraint: 'Absolute path to your roadmap doc (context/roadmap.md) — edit it to author your plan.' },
496
+ { name: 'goal_path', type: 'string', required: true, constraint: 'Absolute path to your goal doc (context/initial-prompt.md) — the mandate you were spawned with.' },
329
497
  { name: 'guidance', type: 'string', required: true, constraint: 'Instructions for your new role — read and act on them this turn.' },
330
498
  ],
331
499
  outputKind: 'object',
@@ -339,7 +507,7 @@ const nodePromote = defineLeaf({
339
507
  if (kind !== undefined)
340
508
  assertKind(kind);
341
509
  const res = promote(id, kind !== undefined ? { kind } : {});
342
- return { node_id: res.meta.node_id, kind: res.meta.kind, mode: res.meta.mode, roadmap_written: res.roadmapWritten, guidance: res.guidance };
510
+ return { node_id: res.meta.node_id, kind: res.meta.kind, mode: res.meta.mode, roadmap_written: res.roadmapWritten, roadmap_path: res.roadmapPath, goal_path: res.goalPath, guidance: res.guidance };
343
511
  },
344
512
  });
345
513
  // ---------------------------------------------------------------------------
@@ -392,16 +560,19 @@ export function registerNode() {
392
560
  'HOW: `crtr node new "<task>" --kind <kind>` returns a node id immediately and runs the worker in a background window. Match the kind to the work (see `node new -h`). You are woken when a child finishes; absorb what your children reported with `crtr feed read` (coalesced pointers — dereference the report paths that matter, don\'t act on a one-line summary). Integrate, then either delegate the next units or finish.\n\n' +
393
561
  'FINISH: a worker ends its own work with `crtr push final "<result>"` (writes the canonical result, marks done, closes the window) — stopping without it is not finishing. For a job too big for one context window, `node promote` to a resident orchestrator (holds a roadmap, delegates phases); when context fills, `node yield` to refresh against that roadmap.',
394
562
  children: [
395
- { name: 'new', desc: 'spawn a terminal worker as a background window', useWhen: 'delegating a self-contained unit of work' },
563
+ { name: 'new', desc: 'spawn a terminal worker as a background window', useWhen: 'delegating a self-contained unit of work', tier: 'important' },
396
564
  { name: 'inspect', desc: 'read the graph (list nodes / show one)', useWhen: 'surveying the canvas or inspecting a node' },
397
565
  { name: 'focus', desc: 'bring a node window forefront', useWhen: 'jumping to a node to watch or steer it' },
398
- { name: 'demote', desc: 'detach the agent in your pane to the background', useWhen: 'parking the agent in front of you and getting your terminal back (Alt+C → d)' },
566
+ { name: 'cycle', desc: 'DFS-walk to the next/prev live node in place', useWhen: 'sweeping the canvas one window at a time (Alt+] forward / Alt+[ back)' },
567
+ { name: 'demote', desc: 'finish the agent in your pane + recycle it into a fresh root', useWhen: 'wrapping up the agent in front of you and starting fresh (Alt+C → d)' },
399
568
  { name: 'session', desc: 'open a fresh root in its own tmux session', useWhen: 'starting a new top-level session from inside a node' },
400
569
  { name: 'msg', desc: 'direct-message any node at a wake tier', useWhen: 'steering or pinging a specific node (wakes it)' },
401
- { name: 'promote', desc: 'become a resident orchestrator of a chosen kind', useWhen: 'your task is bigger than one context window and you must delegate + persist' },
570
+ { name: 'subscribe', desc: 'wire a subscribes_to edge between any pair (active or --passive)', useWhen: 'making a node (you or another) receive another node\'s pushes' },
571
+ { name: 'unsubscribe', desc: 'drop a subscribes_to edge', useWhen: 'detaching a subscriber from a publisher' },
572
+ { name: 'promote', desc: 'become a resident orchestrator of a chosen kind', useWhen: 'your task is bigger than one context window and you must delegate + persist', tier: 'important' },
402
573
  { name: 'yield', desc: 'refresh your context against your roadmap', useWhen: 'your context window is filling up' },
403
574
  ],
404
575
  },
405
- children: [nodeNew, nodeInspect, nodeFocus, nodeDemote, nodeSession, nodeMsg, nodePromote, nodeYield],
576
+ children: [nodeNew, nodeInspect, nodeFocus, nodeCycle, nodeDemote, nodeSession, nodeMsg, nodeSubscribe, nodeUnsubscribe, nodePromote, nodeYield],
406
577
  });
407
578
  }
@@ -0,0 +1 @@
1
+ export {};