@crouton-kit/crouter 0.3.12 → 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/builtin-personas/runtime-base.md +2 -2
- 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 +239 -15
- 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 -1
- package/dist/core/runtime/presence.js +6 -4
- package/dist/core/runtime/promote.d.ts +4 -0
- package/dist/core/runtime/promote.js +21 -6
- package/dist/core/runtime/revive.js +6 -8
- package/dist/core/runtime/roadmap.d.ts +5 -4
- package/dist/core/runtime/roadmap.js +9 -16
- package/dist/core/runtime/spawn.d.ts +0 -2
- package/dist/core/runtime/spawn.js +26 -16
- package/dist/core/runtime/tmux.d.ts +18 -0
- package/dist/core/runtime/tmux.js +77 -0
- 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/dist/pi-extensions/canvas-stophook.js +42 -19
- package/package.json +1 -1
package/dist/core/command.js
CHANGED
|
@@ -23,7 +23,26 @@ export function defineLeaf(opts) {
|
|
|
23
23
|
render: opts.render,
|
|
24
24
|
};
|
|
25
25
|
}
|
|
26
|
+
/** Number of a node's own non-hidden subcommands (direct children). Leaves and
|
|
27
|
+
* childless branches return 0. */
|
|
28
|
+
function visibleSubCount(def) {
|
|
29
|
+
if (def.kind !== 'branch')
|
|
30
|
+
return 0;
|
|
31
|
+
return def.help.children.filter((c) => (c.tier ?? 'normal') !== 'hidden').length;
|
|
32
|
+
}
|
|
26
33
|
export function defineBranch(opts) {
|
|
34
|
+
// Enrich each help-child entry with the count of its own non-hidden
|
|
35
|
+
// subcommands so renderBranch can show "[+N subcommands]" when a branch child
|
|
36
|
+
// is listed without expanding it. Match help entries to child defs by name;
|
|
37
|
+
// entries without a matching def (or whose def has no subcommands) stay bare.
|
|
38
|
+
for (const hc of opts.help.children) {
|
|
39
|
+
const childDef = opts.children.find((c) => c.name === hc.name);
|
|
40
|
+
if (childDef !== undefined) {
|
|
41
|
+
const n = visibleSubCount(childDef);
|
|
42
|
+
if (n > 0)
|
|
43
|
+
hc.subCount = n;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
27
46
|
return {
|
|
28
47
|
kind: 'branch',
|
|
29
48
|
name: opts.name,
|
|
@@ -60,13 +79,27 @@ export function defineRoot(opts) {
|
|
|
60
79
|
// and dynamic block all travel with it.
|
|
61
80
|
const commands = opts.subtrees
|
|
62
81
|
.filter((s) => s.rootEntry !== undefined)
|
|
63
|
-
.map((s) =>
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
82
|
+
.map((s) => {
|
|
83
|
+
// Promote this subtree's common/important children into root, and count
|
|
84
|
+
// how many other (non-hidden) direct subcommands stay behind `<name> -h`.
|
|
85
|
+
const visible = s.help.children.filter((c) => (c.tier ?? 'normal') !== 'hidden');
|
|
86
|
+
const promoted = visible
|
|
87
|
+
.filter((c) => c.tier === 'common' || c.tier === 'important')
|
|
88
|
+
.map((c) => ({
|
|
89
|
+
path: `${s.name} ${c.name}`,
|
|
90
|
+
// important carries its shortform desc; common shows the bare path.
|
|
91
|
+
desc: c.tier === 'important' ? c.desc : undefined,
|
|
92
|
+
}));
|
|
93
|
+
return {
|
|
94
|
+
name: s.name,
|
|
95
|
+
concept: s.rootEntry.concept,
|
|
96
|
+
desc: s.rootEntry.desc,
|
|
97
|
+
useWhen: s.rootEntry.useWhen,
|
|
98
|
+
dynamicState: s.rootEntry.dynamicState,
|
|
99
|
+
subcommands: promoted.length > 0 ? promoted : undefined,
|
|
100
|
+
otherSubcommandCount: visible.length - promoted.length,
|
|
101
|
+
};
|
|
102
|
+
});
|
|
70
103
|
const help = {
|
|
71
104
|
tagline: opts.tagline,
|
|
72
105
|
commands,
|
package/dist/core/feed/feed.js
CHANGED
|
@@ -11,6 +11,7 @@ import { writeFileSync, renameSync, mkdirSync, existsSync } from 'node:fs';
|
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
import { reportsDir, subscribersOf, setStatus, updateNode, } from '../canvas/index.js';
|
|
13
13
|
import { appendInbox } from './inbox.js';
|
|
14
|
+
import { appendPassive } from './passive.js';
|
|
14
15
|
// ---------------------------------------------------------------------------
|
|
15
16
|
// Internal helpers
|
|
16
17
|
// ---------------------------------------------------------------------------
|
|
@@ -74,19 +75,20 @@ export async function push(nodeId, opts) {
|
|
|
74
75
|
const ts = compactTs(now);
|
|
75
76
|
// (a) Write the report.
|
|
76
77
|
const reportPath = writeReport(nodeId, kind, ts, body);
|
|
77
|
-
// (b) Fan out
|
|
78
|
-
//
|
|
78
|
+
// (b) Fan out a pointer to every subscriber. Active subscribers get it on
|
|
79
|
+
// inbox.jsonl (the inbox-watcher polls that → a wake). Passive subscribers
|
|
80
|
+
// get it on passive.jsonl instead — the watcher never polls that, so they
|
|
81
|
+
// are NOT woken; the pointer accumulates until the node is next messaged,
|
|
82
|
+
// when canvas-passive-context drains it as XML pre-text.
|
|
79
83
|
const subscribers = subscribersOf(nodeId);
|
|
80
84
|
const deliveredTo = [];
|
|
81
85
|
const label = firstLine(body);
|
|
82
86
|
for (const sub of subscribers) {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
label,
|
|
89
|
-
});
|
|
87
|
+
const entry = { from, tier: tierFor(kind), kind, ref: reportPath, label };
|
|
88
|
+
if (sub.active)
|
|
89
|
+
appendInbox(sub.node_id, entry);
|
|
90
|
+
else
|
|
91
|
+
appendPassive(sub.node_id, entry);
|
|
90
92
|
deliveredTo.push(sub.node_id);
|
|
91
93
|
}
|
|
92
94
|
// (c) Finalise node when kind === 'final'.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { InboxEntry } from './inbox.js';
|
|
2
|
+
/**
|
|
3
|
+
* Atomically append one entry to `nodes/<nodeId>/passive.jsonl`.
|
|
4
|
+
* Fills `ts` (current ISO time). Returns the completed entry.
|
|
5
|
+
*/
|
|
6
|
+
export declare function appendPassive(nodeId: string, entry: Omit<InboxEntry, 'ts'>): InboxEntry;
|
|
7
|
+
/** Return every accumulated passive entry (oldest first) without clearing. */
|
|
8
|
+
export declare function readPassive(nodeId: string): InboxEntry[];
|
|
9
|
+
/**
|
|
10
|
+
* Read AND clear the accumulator in one shot — the drain-on-message primitive.
|
|
11
|
+
*
|
|
12
|
+
* We rename the file aside before reading so a concurrent `appendPassive` (a
|
|
13
|
+
* publisher pushing at the same instant) starts a fresh file and is never lost
|
|
14
|
+
* to the truncate: at worst it lands in the next drain. The renamed snapshot is
|
|
15
|
+
* removed after a successful read. Returns the drained entries (oldest first).
|
|
16
|
+
*/
|
|
17
|
+
export declare function drainPassive(nodeId: string): InboxEntry[];
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Per-node passive-subscription accumulator for the pi-native canvas runtime.
|
|
2
|
+
//
|
|
3
|
+
// A PASSIVE subscription (the `active=false` flavor of a subscribes_to edge)
|
|
4
|
+
// must never WAKE its subscriber. So when `push` fans out, a passive
|
|
5
|
+
// subscriber's pointer is written here — to nodes/<id>/passive.jsonl — instead
|
|
6
|
+
// of inbox.jsonl. The inbox-watcher polls only inbox.jsonl, so nothing here
|
|
7
|
+
// triggers a turn.
|
|
8
|
+
//
|
|
9
|
+
// The accumulator is drained the moment the node is next MESSAGED: the
|
|
10
|
+
// canvas-passive-context extension reads + clears this file on pi's `input`
|
|
11
|
+
// event and injects every entry as timestamped XML pre-text before the message
|
|
12
|
+
// reaches the LLM. Until then entries simply pile up, oldest first.
|
|
13
|
+
//
|
|
14
|
+
// Same entry shape as the inbox (InboxEntry) so the two stores stay symmetric
|
|
15
|
+
// and a passive edge can be flipped active without reshaping data.
|
|
16
|
+
import { appendFileSync, existsSync, readFileSync, renameSync, rmSync, mkdirSync, } from 'node:fs';
|
|
17
|
+
import { dirname } from 'node:path';
|
|
18
|
+
import { passivePath } from '../canvas/index.js';
|
|
19
|
+
/**
|
|
20
|
+
* Atomically append one entry to `nodes/<nodeId>/passive.jsonl`.
|
|
21
|
+
* Fills `ts` (current ISO time). Returns the completed entry.
|
|
22
|
+
*/
|
|
23
|
+
export function appendPassive(nodeId, entry) {
|
|
24
|
+
const full = { ts: new Date().toISOString(), ...entry };
|
|
25
|
+
const line = JSON.stringify(full) + '\n';
|
|
26
|
+
const dir = dirname(passivePath(nodeId));
|
|
27
|
+
if (!existsSync(dir))
|
|
28
|
+
mkdirSync(dir, { recursive: true });
|
|
29
|
+
appendFileSync(passivePath(nodeId), line, { encoding: 'utf8', flag: 'a' });
|
|
30
|
+
return full;
|
|
31
|
+
}
|
|
32
|
+
/** Return every accumulated passive entry (oldest first) without clearing. */
|
|
33
|
+
export function readPassive(nodeId) {
|
|
34
|
+
const p = passivePath(nodeId);
|
|
35
|
+
if (!existsSync(p))
|
|
36
|
+
return [];
|
|
37
|
+
return readFileSync(p, 'utf8')
|
|
38
|
+
.split('\n')
|
|
39
|
+
.filter((l) => l.trim() !== '')
|
|
40
|
+
.map((l) => JSON.parse(l));
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Read AND clear the accumulator in one shot — the drain-on-message primitive.
|
|
44
|
+
*
|
|
45
|
+
* We rename the file aside before reading so a concurrent `appendPassive` (a
|
|
46
|
+
* publisher pushing at the same instant) starts a fresh file and is never lost
|
|
47
|
+
* to the truncate: at worst it lands in the next drain. The renamed snapshot is
|
|
48
|
+
* removed after a successful read. Returns the drained entries (oldest first).
|
|
49
|
+
*/
|
|
50
|
+
export function drainPassive(nodeId) {
|
|
51
|
+
const p = passivePath(nodeId);
|
|
52
|
+
if (!existsSync(p))
|
|
53
|
+
return [];
|
|
54
|
+
const snapshot = `${p}.draining`;
|
|
55
|
+
try {
|
|
56
|
+
renameSync(p, snapshot);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// Lost the race (file vanished) — nothing to drain.
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
let entries = [];
|
|
63
|
+
try {
|
|
64
|
+
entries = readFileSync(snapshot, 'utf8')
|
|
65
|
+
.split('\n')
|
|
66
|
+
.filter((l) => l.trim() !== '')
|
|
67
|
+
.map((l) => JSON.parse(l));
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
entries = [];
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
try {
|
|
74
|
+
rmSync(snapshot, { force: true });
|
|
75
|
+
}
|
|
76
|
+
catch { /* best-effort cleanup */ }
|
|
77
|
+
}
|
|
78
|
+
return entries;
|
|
79
|
+
}
|
package/dist/core/help.d.ts
CHANGED
|
@@ -47,6 +47,29 @@ export interface ContextFileParam {
|
|
|
47
47
|
shape?: string;
|
|
48
48
|
}
|
|
49
49
|
export type InputParam = PositionalParam | FlagParam | StdinParam | ContextFileParam;
|
|
50
|
+
/** How prominently a subcommand surfaces in ancestor (parent / root) -h
|
|
51
|
+
* listings. Set per child in the parent branch's `help.children`. Default
|
|
52
|
+
* 'normal'.
|
|
53
|
+
* - hidden — never listed anywhere, not even in this branch's own -h.
|
|
54
|
+
* You must already know it exists to invoke it.
|
|
55
|
+
* - normal — listed in this branch's own -h only (the default).
|
|
56
|
+
* - common — ALSO promoted into the parent's -h, as a bare qualified name.
|
|
57
|
+
* - important — ALSO promoted into the parent's -h, name + shortform desc. */
|
|
58
|
+
export type SubTier = 'hidden' | 'normal' | 'common' | 'important';
|
|
59
|
+
/** One child entry in a branch's -h listing. `desc`/`useWhen` are the shortform
|
|
60
|
+
* copy shown there; `tier` governs promotion into ancestor listings. */
|
|
61
|
+
export interface BranchChild {
|
|
62
|
+
name: string;
|
|
63
|
+
desc: string;
|
|
64
|
+
useWhen: string;
|
|
65
|
+
/** Visibility tier in ancestor listings (see SubTier). Default 'normal'. */
|
|
66
|
+
tier?: SubTier;
|
|
67
|
+
/** Computed at define time (defineBranch): how many non-hidden subcommands
|
|
68
|
+
* this child itself owns. Drives the "[+N subcommands]" affordance shown when
|
|
69
|
+
* a branch child is listed without expanding its own subcommands. Absent for
|
|
70
|
+
* leaves and childless branches. Do not author by hand. */
|
|
71
|
+
subCount?: number;
|
|
72
|
+
}
|
|
50
73
|
/** A subtree's self-description at the parent (root) level. Each subtree owns
|
|
51
74
|
* the content that represents it one level up: its vocabulary line, its
|
|
52
75
|
* selection rubric, and any bounded block it contributes to the parent's -h.
|
|
@@ -75,18 +98,32 @@ export interface RootHelp {
|
|
|
75
98
|
* root, carrying the subtree's concept, selection rubric, and any nested
|
|
76
99
|
* runtime-state block. Assembled from subtrees' RootEntry by defineRoot;
|
|
77
100
|
* root hardcodes none of it. */
|
|
78
|
-
commands:
|
|
79
|
-
name: string;
|
|
80
|
-
concept: string;
|
|
81
|
-
desc: string;
|
|
82
|
-
useWhen: string;
|
|
83
|
-
dynamicState?: () => string | null;
|
|
84
|
-
}[];
|
|
101
|
+
commands: RootCommand[];
|
|
85
102
|
globals: {
|
|
86
103
|
name: string;
|
|
87
104
|
desc: string;
|
|
88
105
|
}[];
|
|
89
106
|
}
|
|
107
|
+
/** A single command block at root. Most fields come from the subtree's
|
|
108
|
+
* RootEntry; `subcommands`/`otherSubcommandCount` are computed by defineRoot
|
|
109
|
+
* from the subtree's children tiers. */
|
|
110
|
+
export interface RootCommand {
|
|
111
|
+
name: string;
|
|
112
|
+
concept: string;
|
|
113
|
+
desc: string;
|
|
114
|
+
useWhen: string;
|
|
115
|
+
dynamicState?: () => string | null;
|
|
116
|
+
/** Promoted subcommands surfaced inline under this command at root, in
|
|
117
|
+
* declaration order. `desc` is present only for 'important' tier; 'common'
|
|
118
|
+
* tier carries the bare qualified path. */
|
|
119
|
+
subcommands?: {
|
|
120
|
+
path: string;
|
|
121
|
+
desc?: string;
|
|
122
|
+
}[];
|
|
123
|
+
/** How many of this command's other (non-hidden, not-promoted) direct
|
|
124
|
+
* subcommands are not shown. Drives the "[+N (other) subcommands]" line. */
|
|
125
|
+
otherSubcommandCount?: number;
|
|
126
|
+
}
|
|
90
127
|
export interface BranchHelp {
|
|
91
128
|
name: string;
|
|
92
129
|
summary: string;
|
|
@@ -96,11 +133,7 @@ export interface BranchHelp {
|
|
|
96
133
|
* it with stateBlock), e.g. `<skills count="42">…</skills>`. Renderer
|
|
97
134
|
* soft-fails to omission if this returns null or throws. */
|
|
98
135
|
dynamicState?: () => string | null;
|
|
99
|
-
children:
|
|
100
|
-
name: string;
|
|
101
|
-
desc: string;
|
|
102
|
-
useWhen: string;
|
|
103
|
-
}[];
|
|
136
|
+
children: BranchChild[];
|
|
104
137
|
}
|
|
105
138
|
export interface LeafHelp {
|
|
106
139
|
name: string;
|
package/dist/core/help.js
CHANGED
|
@@ -54,6 +54,31 @@ const IO_CONTRACT = 'I/O contract: flags and positional args on input; stdout is
|
|
|
54
54
|
const CAPABILITY_DISCOVERY = "If the user mentions or implies a crtr capability you don't fully understand, " +
|
|
55
55
|
'do not guess or assume it is unsupported — run `-h` on the relevant command ' +
|
|
56
56
|
'(append it anywhere along the path) to read the contract before acting.';
|
|
57
|
+
/** Lines for a command's subcommand affordance at root: any promoted
|
|
58
|
+
* (common/important) subcommands, then a remainder line naming how many other
|
|
59
|
+
* subcommands exist behind `crtr <name> -h`. Returns [] when the command has
|
|
60
|
+
* no listable subcommands at all. */
|
|
61
|
+
function rootSubcommandLines(c) {
|
|
62
|
+
const promoted = c.subcommands ?? [];
|
|
63
|
+
const other = c.otherSubcommandCount ?? 0;
|
|
64
|
+
if (promoted.length === 0 && other === 0)
|
|
65
|
+
return [];
|
|
66
|
+
const out = [];
|
|
67
|
+
if (promoted.length > 0) {
|
|
68
|
+
const labelW = maxLen(promoted.map((s) => s.path));
|
|
69
|
+
for (const s of promoted) {
|
|
70
|
+
// important → padded name + shortform desc; common → bare name.
|
|
71
|
+
out.push(s.desc !== undefined && s.desc !== ''
|
|
72
|
+
? ` ${pad(s.path, labelW)} ${s.desc}`
|
|
73
|
+
: ` ${s.path}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (other > 0) {
|
|
77
|
+
const word = promoted.length > 0 ? 'other subcommand' : 'subcommand';
|
|
78
|
+
out.push(` [+${other} ${word}${other === 1 ? '' : 's'} — \`crtr ${c.name} -h\`]`);
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
57
82
|
export function renderRoot(h) {
|
|
58
83
|
const lines = [];
|
|
59
84
|
lines.push(`${h.tagline}`);
|
|
@@ -71,6 +96,11 @@ export function renderRoot(h) {
|
|
|
71
96
|
lines.push(`<command name="${c.name}">`);
|
|
72
97
|
lines.push(c.concept);
|
|
73
98
|
lines.push(`use when ${c.useWhen}`);
|
|
99
|
+
// The command's subcommand surface: promoted (common/important) children
|
|
100
|
+
// inline, plus a "[+N other subcommands]" pointer to its own -h. Sits
|
|
101
|
+
// between the selection rubric and any live state block.
|
|
102
|
+
for (const l of rootSubcommandLines(c))
|
|
103
|
+
lines.push(l);
|
|
74
104
|
// dynamicState returns a complete self-named element (e.g.
|
|
75
105
|
// <skills count="42">…</skills>) — emit it as-is, nested in the command.
|
|
76
106
|
const state = evalDynamic(c.dynamicState);
|
|
@@ -113,10 +143,18 @@ export function renderBranch(h) {
|
|
|
113
143
|
}
|
|
114
144
|
lines.push('');
|
|
115
145
|
lines.push('Branches');
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
146
|
+
// 'hidden' children never appear in any listing — drop them here.
|
|
147
|
+
const visible = h.children.filter((c) => (c.tier ?? 'normal') !== 'hidden');
|
|
148
|
+
const nameW = maxLen(visible.map((c) => c.name));
|
|
149
|
+
const descW = maxLen(visible.map((c) => c.desc));
|
|
150
|
+
for (const c of visible) {
|
|
151
|
+
let line = ` ${pad(c.name, nameW)} ${pad(c.desc, descW)} | use when ${c.useWhen}`;
|
|
152
|
+
// A branch child is listed without its own subcommands expanded — flag how
|
|
153
|
+
// many it has so the agent knows there is more depth behind `<child> -h`.
|
|
154
|
+
if (c.subCount !== undefined && c.subCount > 0) {
|
|
155
|
+
line += ` [+${c.subCount} subcommand${c.subCount === 1 ? '' : 's'}]`;
|
|
156
|
+
}
|
|
157
|
+
lines.push(line);
|
|
120
158
|
}
|
|
121
159
|
return lines.join('\n');
|
|
122
160
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface DemoteResult {
|
|
2
|
+
/** True when the pane was recycled (a fresh root respawned in it). */
|
|
3
|
+
demoted: boolean;
|
|
4
|
+
/** True when a `final` report was pushed for the demoted node. */
|
|
5
|
+
finalized: boolean;
|
|
6
|
+
/** The fresh root node booted into the pane, or null on failure. */
|
|
7
|
+
newRoot: string | null;
|
|
8
|
+
/** Subscriber node ids that received the final report. */
|
|
9
|
+
delivered: string[];
|
|
10
|
+
}
|
|
11
|
+
/** Finish `nodeId` and recycle its pane into a fresh root. `callerPane` is the
|
|
12
|
+
* tmux pane the agent occupies (the Alt+C menu passes it as `#{pane_id}`).
|
|
13
|
+
* Best-effort; `demoted:false` when there is no pane to act on. */
|
|
14
|
+
export declare function demoteNode(nodeId: string, callerPane?: string): Promise<DemoteResult>;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// demote.ts — the "graduate this agent" action behind `crtr node demote`.
|
|
2
|
+
//
|
|
3
|
+
// Demote finishes the agent occupying a tmux pane and recycles that pane for
|
|
4
|
+
// fresh work, in three steps:
|
|
5
|
+
//
|
|
6
|
+
// 1. Finalize — push the agent's last surfaced message as a `final` report so
|
|
7
|
+
// every subscriber/manager waiting on it is unblocked, and mark it done.
|
|
8
|
+
// 2. Close — kill the agent's pi (respawn-pane -k tears it down in place).
|
|
9
|
+
// 3. Recycle — boot a fresh resident root in that same pane (a new `crtr`).
|
|
10
|
+
//
|
|
11
|
+
// The agent's real conversation lives inside pi (not on disk), so the final
|
|
12
|
+
// body is its newest report (which, on a natural stop, IS its last assistant
|
|
13
|
+
// message) — falling back to a short note when it never reported.
|
|
14
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { getNode, updateNode } from '../canvas/index.js';
|
|
17
|
+
import { reportsDir } from '../canvas/paths.js';
|
|
18
|
+
import { pushFinal } from '../feed/feed.js';
|
|
19
|
+
import { spawnNode } from './nodes.js';
|
|
20
|
+
import { buildLaunchSpec, buildPiArgv } from './launch.js';
|
|
21
|
+
import { respawnPane, piCommand, paneLocation, nodeSession } from './tmux.js';
|
|
22
|
+
import { FRONT_DOOR_ENV } from './front-door.js';
|
|
23
|
+
import { getFocus, setFocus } from './presence.js';
|
|
24
|
+
import { ensureDaemon } from '../../daemon/manage.js';
|
|
25
|
+
/** The agent's most recent surfaced message: the newest reports/*.md body with
|
|
26
|
+
* its YAML frontmatter stripped. Empty string when the node never reported. */
|
|
27
|
+
function lastReportBody(nodeId) {
|
|
28
|
+
try {
|
|
29
|
+
const dir = reportsDir(nodeId);
|
|
30
|
+
const files = readdirSync(dir).filter((f) => f.endsWith('.md'));
|
|
31
|
+
if (files.length === 0)
|
|
32
|
+
return '';
|
|
33
|
+
let newest = '';
|
|
34
|
+
let newestMs = -1;
|
|
35
|
+
for (const f of files) {
|
|
36
|
+
const ms = statSync(join(dir, f)).mtimeMs;
|
|
37
|
+
if (ms > newestMs) {
|
|
38
|
+
newestMs = ms;
|
|
39
|
+
newest = f;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const raw = readFileSync(join(dir, newest), 'utf8');
|
|
43
|
+
// Strip leading YAML frontmatter: ---\n …\n---\n<body>
|
|
44
|
+
const m = /^---\n[\s\S]*?\n---\n/.exec(raw);
|
|
45
|
+
return (m !== null ? raw.slice(m[0].length) : raw).trim();
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return '';
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Finish `nodeId` and recycle its pane into a fresh root. `callerPane` is the
|
|
52
|
+
* tmux pane the agent occupies (the Alt+C menu passes it as `#{pane_id}`).
|
|
53
|
+
* Best-effort; `demoted:false` when there is no pane to act on. */
|
|
54
|
+
export async function demoteNode(nodeId, callerPane) {
|
|
55
|
+
const meta = getNode(nodeId);
|
|
56
|
+
if (meta === null)
|
|
57
|
+
return { demoted: false, finalized: false, newRoot: null, delivered: [] };
|
|
58
|
+
const pane = callerPane ?? process.env['TMUX_PANE'];
|
|
59
|
+
if (pane === undefined || pane === '') {
|
|
60
|
+
return { demoted: false, finalized: false, newRoot: null, delivered: [] };
|
|
61
|
+
}
|
|
62
|
+
// 1. Finalize — fan the agent's last message out as a `final`, mark it done.
|
|
63
|
+
const body = lastReportBody(nodeId) ||
|
|
64
|
+
`Closed via demote — no final summary was authored by ${meta.name}.`;
|
|
65
|
+
let delivered = [];
|
|
66
|
+
let finalized = false;
|
|
67
|
+
try {
|
|
68
|
+
const res = await pushFinal(nodeId, body);
|
|
69
|
+
delivered = res.deliveredTo;
|
|
70
|
+
finalized = true;
|
|
71
|
+
}
|
|
72
|
+
catch { /* recycle the pane even if the report failed */ }
|
|
73
|
+
// The demoted node no longer holds a window — the pane is being reclaimed.
|
|
74
|
+
try {
|
|
75
|
+
updateNode(nodeId, { window: null, tmux_session: null });
|
|
76
|
+
}
|
|
77
|
+
catch { /* best-effort */ }
|
|
78
|
+
if (getFocus() === nodeId)
|
|
79
|
+
setFocus('');
|
|
80
|
+
// 2 + 3. Recycle — boot a fresh resident root in the SAME pane.
|
|
81
|
+
try {
|
|
82
|
+
ensureDaemon();
|
|
83
|
+
}
|
|
84
|
+
catch { /* daemon is best-effort */ }
|
|
85
|
+
const loc = paneLocation(pane);
|
|
86
|
+
const { launch } = buildLaunchSpec('general', 'base');
|
|
87
|
+
const root = spawnNode({
|
|
88
|
+
kind: 'general',
|
|
89
|
+
mode: 'base',
|
|
90
|
+
lifecycle: 'resident',
|
|
91
|
+
cwd: meta.cwd,
|
|
92
|
+
name: 'general',
|
|
93
|
+
parent: null,
|
|
94
|
+
launch,
|
|
95
|
+
});
|
|
96
|
+
if (loc !== null)
|
|
97
|
+
updateNode(root.node_id, { tmux_session: loc.session, window: loc.window });
|
|
98
|
+
const fresh = getNode(root.node_id);
|
|
99
|
+
const inv = buildPiArgv(fresh);
|
|
100
|
+
const env = { ...inv.env, CRTR_ROOT_SESSION: nodeSession(), [FRONT_DOOR_ENV]: '1' };
|
|
101
|
+
const ok = respawnPane({ pane, cwd: meta.cwd, env, command: piCommand(inv.argv) });
|
|
102
|
+
return { demoted: ok, finalized, newRoot: root.node_id, delivered };
|
|
103
|
+
}
|
|
@@ -6,6 +6,15 @@ export declare function readGoal(nodeId: string): string | null;
|
|
|
6
6
|
/** Persist the spawning prompt as the node's goal. No-op for an empty prompt
|
|
7
7
|
* (e.g. a bare root). Idempotent enough — call once at spawn/boot. */
|
|
8
8
|
export declare function writeGoal(nodeId: string, text: string): void;
|
|
9
|
+
/** Write the goal ONLY if the node has none yet. This is how a bare root (no
|
|
10
|
+
* spawn prompt) acquires its mandate: the first real user message becomes the
|
|
11
|
+
* goal. Returns true when it wrote one, false when a goal already existed or
|
|
12
|
+
* the text was empty. Guarded so a later message never clobbers the mandate. */
|
|
13
|
+
export declare function captureGoalIfAbsent(nodeId: string, text: string): boolean;
|
|
14
|
+
/** Sentinel opening the fresh-revive kickoff message (see buildReviveKickoff).
|
|
15
|
+
* The goal-capture extension skips any input starting with this so a kickoff
|
|
16
|
+
* prompt is never mistaken for a user's first mandate. */
|
|
17
|
+
export declare const REVIVE_KICKOFF_SENTINEL = "You have been revived fresh after a context refresh";
|
|
9
18
|
/** The yield-message file — a short note `crtr node yield` records for the next
|
|
10
19
|
* revive ("on wake, do X"). Consumed (deleted) when the revive reads it. */
|
|
11
20
|
export declare function yieldMessagePath(nodeId: string): string;
|
|
@@ -41,6 +41,24 @@ export function writeGoal(nodeId, text) {
|
|
|
41
41
|
mkdirSync(contextDir(nodeId), { recursive: true });
|
|
42
42
|
writeFileSync(goalPath(nodeId), body + '\n', 'utf8');
|
|
43
43
|
}
|
|
44
|
+
/** Write the goal ONLY if the node has none yet. This is how a bare root (no
|
|
45
|
+
* spawn prompt) acquires its mandate: the first real user message becomes the
|
|
46
|
+
* goal. Returns true when it wrote one, false when a goal already existed or
|
|
47
|
+
* the text was empty. Guarded so a later message never clobbers the mandate. */
|
|
48
|
+
export function captureGoalIfAbsent(nodeId, text) {
|
|
49
|
+
const existing = readGoal(nodeId);
|
|
50
|
+
if (existing !== null && existing.trim() !== '')
|
|
51
|
+
return false;
|
|
52
|
+
const body = text.trim();
|
|
53
|
+
if (body === '')
|
|
54
|
+
return false;
|
|
55
|
+
writeGoal(nodeId, body);
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
/** Sentinel opening the fresh-revive kickoff message (see buildReviveKickoff).
|
|
59
|
+
* The goal-capture extension skips any input starting with this so a kickoff
|
|
60
|
+
* prompt is never mistaken for a user's first mandate. */
|
|
61
|
+
export const REVIVE_KICKOFF_SENTINEL = 'You have been revived fresh after a context refresh';
|
|
44
62
|
/** The yield-message file — a short note `crtr node yield` records for the next
|
|
45
63
|
* revive ("on wake, do X"). Consumed (deleted) when the revive reads it. */
|
|
46
64
|
export function yieldMessagePath(nodeId) {
|
|
@@ -112,7 +130,7 @@ export function buildReviveKickoff(meta) {
|
|
|
112
130
|
// Consume the one-shot yield note first so it never shows in the dir listing.
|
|
113
131
|
const yieldMsg = consumeYieldMessage(nodeId);
|
|
114
132
|
const parts = [
|
|
115
|
-
|
|
133
|
+
`${REVIVE_KICKOFF_SENTINEL} — your previous in-memory ` +
|
|
116
134
|
'context is gone, by design. Everything below was just read from disk; it is your ' +
|
|
117
135
|
'full bearings. Rebuild from it and continue toward your goal.',
|
|
118
136
|
];
|
|
@@ -2,9 +2,15 @@ import type { NodeMeta, LaunchSpec, Mode } from '../canvas/index.js';
|
|
|
2
2
|
export declare const CANVAS_STOPHOOK_PATH: string;
|
|
3
3
|
export declare const CANVAS_INBOX_WATCHER_PATH: string;
|
|
4
4
|
export declare const CANVAS_NAV_PATH: string;
|
|
5
|
+
export declare const CANVAS_GOAL_CAPTURE_PATH: string;
|
|
6
|
+
export declare const CANVAS_PASSIVE_CONTEXT_PATH: string;
|
|
7
|
+
export declare const CANVAS_COMMANDS_PATH: string;
|
|
5
8
|
/** The canvas extensions every node loads, in order: stophook (routing +
|
|
6
9
|
* telemetry + session-id capture), inbox-watcher (wake), nav (in-editor
|
|
7
|
-
* graph chrome)
|
|
10
|
+
* graph chrome), goal-capture (persist the first user message as the goal),
|
|
11
|
+
* passive-context (drain passive backlog as pre-text on the next message),
|
|
12
|
+
* commands (the /promote slash-command). All self-gate on CRTR_NODE_ID.
|
|
13
|
+
* goal-capture precedes passive-context so it reads the raw user text. */
|
|
8
14
|
export declare const CANVAS_EXTENSIONS: string[];
|
|
9
15
|
/** Bare model aliases resolve to the anthropic provider under pi (avoids the
|
|
10
16
|
* bedrock default). Anything with a `/` or an unknown name passes through. */
|
|
@@ -24,6 +30,11 @@ export interface PiInvocation {
|
|
|
24
30
|
/** env to merge into the process. */
|
|
25
31
|
env: Record<string, string>;
|
|
26
32
|
}
|
|
33
|
+
/** The pi session display name — the editor label in the top-left. Shows the
|
|
34
|
+
* node's name plus its current mode so base vs orchestrator reads at a glance
|
|
35
|
+
* (e.g. `developer (orchestrator)`). Recomputed from `meta.mode` on every
|
|
36
|
+
* revive, so a base→orchestrator polymorph updates the label. */
|
|
37
|
+
export declare function editorLabel(meta: NodeMeta): string;
|
|
27
38
|
/** Construct the pi invocation for a node.
|
|
28
39
|
* - fresh start: pass `prompt` (the node's first user message), no resume.
|
|
29
40
|
* - revive idle/done: pass `resumeSessionId` to `--resume` (keeps conversation).
|
|
@@ -27,13 +27,22 @@ function resolveExtension(name) {
|
|
|
27
27
|
export const CANVAS_STOPHOOK_PATH = resolveExtension('canvas-stophook');
|
|
28
28
|
export const CANVAS_INBOX_WATCHER_PATH = resolveExtension('canvas-inbox-watcher');
|
|
29
29
|
export const CANVAS_NAV_PATH = resolveExtension('canvas-nav');
|
|
30
|
+
export const CANVAS_GOAL_CAPTURE_PATH = resolveExtension('canvas-goal-capture');
|
|
31
|
+
export const CANVAS_PASSIVE_CONTEXT_PATH = resolveExtension('canvas-passive-context');
|
|
32
|
+
export const CANVAS_COMMANDS_PATH = resolveExtension('canvas-commands');
|
|
30
33
|
/** The canvas extensions every node loads, in order: stophook (routing +
|
|
31
34
|
* telemetry + session-id capture), inbox-watcher (wake), nav (in-editor
|
|
32
|
-
* graph chrome)
|
|
35
|
+
* graph chrome), goal-capture (persist the first user message as the goal),
|
|
36
|
+
* passive-context (drain passive backlog as pre-text on the next message),
|
|
37
|
+
* commands (the /promote slash-command). All self-gate on CRTR_NODE_ID.
|
|
38
|
+
* goal-capture precedes passive-context so it reads the raw user text. */
|
|
33
39
|
export const CANVAS_EXTENSIONS = [
|
|
34
40
|
CANVAS_STOPHOOK_PATH,
|
|
35
41
|
CANVAS_INBOX_WATCHER_PATH,
|
|
36
42
|
CANVAS_NAV_PATH,
|
|
43
|
+
CANVAS_GOAL_CAPTURE_PATH,
|
|
44
|
+
CANVAS_PASSIVE_CONTEXT_PATH,
|
|
45
|
+
CANVAS_COMMANDS_PATH,
|
|
37
46
|
];
|
|
38
47
|
/** Bare model aliases resolve to the anthropic provider under pi (avoids the
|
|
39
48
|
* bedrock default). Anything with a `/` or an unknown name passes through. */
|
|
@@ -59,6 +68,13 @@ export function buildLaunchSpec(kind, mode, opts = {}) {
|
|
|
59
68
|
};
|
|
60
69
|
return { launch, lifecycle: p.lifecycle, skills: p.skills };
|
|
61
70
|
}
|
|
71
|
+
/** The pi session display name — the editor label in the top-left. Shows the
|
|
72
|
+
* node's name plus its current mode so base vs orchestrator reads at a glance
|
|
73
|
+
* (e.g. `developer (orchestrator)`). Recomputed from `meta.mode` on every
|
|
74
|
+
* revive, so a base→orchestrator polymorph updates the label. */
|
|
75
|
+
export function editorLabel(meta) {
|
|
76
|
+
return `${meta.name} (${meta.mode})`;
|
|
77
|
+
}
|
|
62
78
|
/** Construct the pi invocation for a node.
|
|
63
79
|
* - fresh start: pass `prompt` (the node's first user message), no resume.
|
|
64
80
|
* - revive idle/done: pass `resumeSessionId` to `--resume` (keeps conversation).
|
|
@@ -69,7 +85,7 @@ export function buildPiArgv(meta, opts = {}) {
|
|
|
69
85
|
for (const ext of spec?.extensions ?? CANVAS_EXTENSIONS) {
|
|
70
86
|
argv.push('-e', ext);
|
|
71
87
|
}
|
|
72
|
-
argv.push('-n', meta
|
|
88
|
+
argv.push('-n', editorLabel(meta));
|
|
73
89
|
if (opts.resumeSessionId !== undefined)
|
|
74
90
|
argv.push('--resume', opts.resumeSessionId);
|
|
75
91
|
if (spec?.model !== undefined)
|
|
@@ -31,7 +31,7 @@ export declare function focusNode(nodeId: string): {
|
|
|
31
31
|
*
|
|
32
32
|
* Falls back to window focus when there is no caller pane (not inside tmux) or
|
|
33
33
|
* the target pane can't be resolved. `inPlace` reports which path ran. */
|
|
34
|
-
export declare function focusNodeInPlace(nodeId: string, callerPane?: string): {
|
|
34
|
+
export declare function focusNodeInPlace(nodeId: string, callerPane?: string, callerNodeId?: string): {
|
|
35
35
|
focused: boolean;
|
|
36
36
|
session: string | null;
|
|
37
37
|
inPlace: boolean;
|
|
@@ -99,7 +99,7 @@ export function focusNode(nodeId) {
|
|
|
99
99
|
*
|
|
100
100
|
* Falls back to window focus when there is no caller pane (not inside tmux) or
|
|
101
101
|
* the target pane can't be resolved. `inPlace` reports which path ran. */
|
|
102
|
-
export function focusNodeInPlace(nodeId, callerPane) {
|
|
102
|
+
export function focusNodeInPlace(nodeId, callerPane, callerNodeId) {
|
|
103
103
|
const meta = getNode(nodeId);
|
|
104
104
|
// Always write the pointer so the dashboard reflects intent even on failure.
|
|
105
105
|
setFocus(nodeId);
|
|
@@ -140,10 +140,12 @@ export function focusNodeInPlace(nodeId, callerPane) {
|
|
|
140
140
|
catch { /* best-effort */ }
|
|
141
141
|
// The caller is the node running this focus (its pi process owns callerPane).
|
|
142
142
|
// Its pane moved to the target's old window, so re-point its window there.
|
|
143
|
-
|
|
144
|
-
|
|
143
|
+
// Prefer an explicit id (the `node cycle` tmux binding runs outside any pi,
|
|
144
|
+
// so CRTR_NODE_ID is unset there) and fall back to the env for `node focus`.
|
|
145
|
+
const cnid = callerNodeId ?? process.env['CRTR_NODE_ID'];
|
|
146
|
+
if (cnid !== undefined && cnid.trim() !== '' && cnid !== nodeId) {
|
|
145
147
|
try {
|
|
146
|
-
updateNode(
|
|
148
|
+
updateNode(cnid, { window });
|
|
147
149
|
}
|
|
148
150
|
catch { /* best-effort */ }
|
|
149
151
|
}
|
|
@@ -4,6 +4,10 @@ export interface PromoteResult {
|
|
|
4
4
|
/** Orchestration guidance to surface into the node's current context now. */
|
|
5
5
|
guidance: string;
|
|
6
6
|
roadmapWritten: boolean;
|
|
7
|
+
/** Absolute path to the node's roadmap doc (context/roadmap.md). */
|
|
8
|
+
roadmapPath: string;
|
|
9
|
+
/** Absolute path to the node's goal doc (context/initial-prompt.md). */
|
|
10
|
+
goalPath: string;
|
|
7
11
|
}
|
|
8
12
|
/** Promote a node to resident orchestrator, optionally specializing its kind
|
|
9
13
|
* (e.g. a `general` worker becoming a `developer.orchestrator`). Idempotent:
|