@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
|
@@ -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}'], {
|
package/dist/commands/human.js
CHANGED
|
@@ -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
|
});
|
package/dist/commands/node.js
CHANGED
|
@@ -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
|
|
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: '
|
|
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: "
|
|
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: '
|
|
210
|
+
summary: 'finish the agent in your current pane and recycle the pane — push 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
|
|
203
|
-
{ kind: 'flag', name: 'pane', type: 'string', required: false, constraint: 'tmux pane id to
|
|
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
|
|
207
|
-
{ name: 'node_id', type: 'string', required: false, constraint: 'The
|
|
208
|
-
{ name: '
|
|
209
|
-
{ name: '
|
|
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: ['
|
|
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
|
-
|
|
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
|
|
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,
|
|
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']}"
|
|
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: '
|
|
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: '
|
|
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 {};
|