@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.
Files changed (45) hide show
  1. package/dist/builtin-personas/runtime-base.md +2 -2
  2. package/dist/commands/__tests__/human.test.js +73 -2
  3. package/dist/commands/human/queue.d.ts +1 -0
  4. package/dist/commands/human/queue.js +89 -2
  5. package/dist/commands/human/shared.d.ts +5 -0
  6. package/dist/commands/human/shared.js +15 -0
  7. package/dist/commands/human.js +4 -2
  8. package/dist/commands/node.js +239 -15
  9. package/dist/core/__tests__/passive-subscription.test.d.ts +1 -0
  10. package/dist/core/__tests__/passive-subscription.test.js +141 -0
  11. package/dist/core/__tests__/subcommand-tier.test.d.ts +1 -0
  12. package/dist/core/__tests__/subcommand-tier.test.js +97 -0
  13. package/dist/core/canvas/paths.d.ts +4 -0
  14. package/dist/core/canvas/paths.js +6 -0
  15. package/dist/core/command.js +40 -7
  16. package/dist/core/feed/feed.js +11 -9
  17. package/dist/core/feed/passive.d.ts +17 -0
  18. package/dist/core/feed/passive.js +79 -0
  19. package/dist/core/help.d.ts +45 -12
  20. package/dist/core/help.js +42 -4
  21. package/dist/core/runtime/demote.d.ts +14 -0
  22. package/dist/core/runtime/demote.js +103 -0
  23. package/dist/core/runtime/kickoff.d.ts +9 -0
  24. package/dist/core/runtime/kickoff.js +19 -1
  25. package/dist/core/runtime/launch.d.ts +12 -1
  26. package/dist/core/runtime/launch.js +18 -2
  27. package/dist/core/runtime/presence.d.ts +1 -1
  28. package/dist/core/runtime/presence.js +6 -4
  29. package/dist/core/runtime/promote.d.ts +4 -0
  30. package/dist/core/runtime/promote.js +21 -6
  31. package/dist/core/runtime/revive.js +6 -8
  32. package/dist/core/runtime/roadmap.d.ts +5 -4
  33. package/dist/core/runtime/roadmap.js +9 -16
  34. package/dist/core/runtime/spawn.d.ts +0 -2
  35. package/dist/core/runtime/spawn.js +26 -16
  36. package/dist/core/runtime/tmux.d.ts +18 -0
  37. package/dist/core/runtime/tmux.js +77 -0
  38. package/dist/pi-extensions/canvas-commands.d.ts +34 -0
  39. package/dist/pi-extensions/canvas-commands.js +100 -0
  40. package/dist/pi-extensions/canvas-goal-capture.d.ts +18 -0
  41. package/dist/pi-extensions/canvas-goal-capture.js +53 -0
  42. package/dist/pi-extensions/canvas-passive-context.d.ts +32 -0
  43. package/dist/pi-extensions/canvas-passive-context.js +114 -0
  44. package/dist/pi-extensions/canvas-stophook.js +42 -19
  45. package/package.json +1 -1
@@ -23,7 +23,26 @@ export function defineLeaf(opts) {
23
23
  render: opts.render,
24
24
  };
25
25
  }
26
+ /** Number of a node's own non-hidden subcommands (direct children). Leaves and
27
+ * childless branches return 0. */
28
+ function visibleSubCount(def) {
29
+ if (def.kind !== 'branch')
30
+ return 0;
31
+ return def.help.children.filter((c) => (c.tier ?? 'normal') !== 'hidden').length;
32
+ }
26
33
  export function defineBranch(opts) {
34
+ // Enrich each help-child entry with the count of its own non-hidden
35
+ // subcommands so renderBranch can show "[+N subcommands]" when a branch child
36
+ // is listed without expanding it. Match help entries to child defs by name;
37
+ // entries without a matching def (or whose def has no subcommands) stay bare.
38
+ for (const hc of opts.help.children) {
39
+ const childDef = opts.children.find((c) => c.name === hc.name);
40
+ if (childDef !== undefined) {
41
+ const n = visibleSubCount(childDef);
42
+ if (n > 0)
43
+ hc.subCount = n;
44
+ }
45
+ }
27
46
  return {
28
47
  kind: 'branch',
29
48
  name: opts.name,
@@ -60,13 +79,27 @@ export function defineRoot(opts) {
60
79
  // and dynamic block all travel with it.
61
80
  const commands = opts.subtrees
62
81
  .filter((s) => s.rootEntry !== undefined)
63
- .map((s) => ({
64
- name: s.name,
65
- concept: s.rootEntry.concept,
66
- desc: s.rootEntry.desc,
67
- useWhen: s.rootEntry.useWhen,
68
- dynamicState: s.rootEntry.dynamicState,
69
- }));
82
+ .map((s) => {
83
+ // Promote this subtree's common/important children into root, and count
84
+ // how many other (non-hidden) direct subcommands stay behind `<name> -h`.
85
+ const visible = s.help.children.filter((c) => (c.tier ?? 'normal') !== 'hidden');
86
+ const promoted = visible
87
+ .filter((c) => c.tier === 'common' || c.tier === 'important')
88
+ .map((c) => ({
89
+ path: `${s.name} ${c.name}`,
90
+ // important carries its shortform desc; common shows the bare path.
91
+ desc: c.tier === 'important' ? c.desc : undefined,
92
+ }));
93
+ return {
94
+ name: s.name,
95
+ concept: s.rootEntry.concept,
96
+ desc: s.rootEntry.desc,
97
+ useWhen: s.rootEntry.useWhen,
98
+ dynamicState: s.rootEntry.dynamicState,
99
+ subcommands: promoted.length > 0 ? promoted : undefined,
100
+ otherSubcommandCount: visible.length - promoted.length,
101
+ };
102
+ });
70
103
  const help = {
71
104
  tagline: opts.tagline,
72
105
  commands,
@@ -11,6 +11,7 @@ import { writeFileSync, renameSync, mkdirSync, existsSync } from 'node:fs';
11
11
  import { join } from 'node:path';
12
12
  import { reportsDir, subscribersOf, setStatus, updateNode, } from '../canvas/index.js';
13
13
  import { appendInbox } from './inbox.js';
14
+ import { appendPassive } from './passive.js';
14
15
  // ---------------------------------------------------------------------------
15
16
  // Internal helpers
16
17
  // ---------------------------------------------------------------------------
@@ -74,19 +75,20 @@ export async function push(nodeId, opts) {
74
75
  const ts = compactTs(now);
75
76
  // (a) Write the report.
76
77
  const reportPath = writeReport(nodeId, kind, ts, body);
77
- // (b) Fan out inbox pointers to every subscriber (active and passive both
78
- // receive the pointer; the daemon decides whether to wake active ones).
78
+ // (b) Fan out a pointer to every subscriber. Active subscribers get it on
79
+ // inbox.jsonl (the inbox-watcher polls that a wake). Passive subscribers
80
+ // get it on passive.jsonl instead — the watcher never polls that, so they
81
+ // are NOT woken; the pointer accumulates until the node is next messaged,
82
+ // when canvas-passive-context drains it as XML pre-text.
79
83
  const subscribers = subscribersOf(nodeId);
80
84
  const deliveredTo = [];
81
85
  const label = firstLine(body);
82
86
  for (const sub of subscribers) {
83
- appendInbox(sub.node_id, {
84
- from,
85
- tier: tierFor(kind),
86
- kind,
87
- ref: reportPath,
88
- label,
89
- });
87
+ const entry = { from, tier: tierFor(kind), kind, ref: reportPath, label };
88
+ if (sub.active)
89
+ appendInbox(sub.node_id, entry);
90
+ else
91
+ appendPassive(sub.node_id, entry);
90
92
  deliveredTo.push(sub.node_id);
91
93
  }
92
94
  // (c) Finalise node when kind === 'final'.
@@ -0,0 +1,17 @@
1
+ import type { InboxEntry } from './inbox.js';
2
+ /**
3
+ * Atomically append one entry to `nodes/<nodeId>/passive.jsonl`.
4
+ * Fills `ts` (current ISO time). Returns the completed entry.
5
+ */
6
+ export declare function appendPassive(nodeId: string, entry: Omit<InboxEntry, 'ts'>): InboxEntry;
7
+ /** Return every accumulated passive entry (oldest first) without clearing. */
8
+ export declare function readPassive(nodeId: string): InboxEntry[];
9
+ /**
10
+ * Read AND clear the accumulator in one shot — the drain-on-message primitive.
11
+ *
12
+ * We rename the file aside before reading so a concurrent `appendPassive` (a
13
+ * publisher pushing at the same instant) starts a fresh file and is never lost
14
+ * to the truncate: at worst it lands in the next drain. The renamed snapshot is
15
+ * removed after a successful read. Returns the drained entries (oldest first).
16
+ */
17
+ export declare function drainPassive(nodeId: string): InboxEntry[];
@@ -0,0 +1,79 @@
1
+ // Per-node passive-subscription accumulator for the pi-native canvas runtime.
2
+ //
3
+ // A PASSIVE subscription (the `active=false` flavor of a subscribes_to edge)
4
+ // must never WAKE its subscriber. So when `push` fans out, a passive
5
+ // subscriber's pointer is written here — to nodes/<id>/passive.jsonl — instead
6
+ // of inbox.jsonl. The inbox-watcher polls only inbox.jsonl, so nothing here
7
+ // triggers a turn.
8
+ //
9
+ // The accumulator is drained the moment the node is next MESSAGED: the
10
+ // canvas-passive-context extension reads + clears this file on pi's `input`
11
+ // event and injects every entry as timestamped XML pre-text before the message
12
+ // reaches the LLM. Until then entries simply pile up, oldest first.
13
+ //
14
+ // Same entry shape as the inbox (InboxEntry) so the two stores stay symmetric
15
+ // and a passive edge can be flipped active without reshaping data.
16
+ import { appendFileSync, existsSync, readFileSync, renameSync, rmSync, mkdirSync, } from 'node:fs';
17
+ import { dirname } from 'node:path';
18
+ import { passivePath } from '../canvas/index.js';
19
+ /**
20
+ * Atomically append one entry to `nodes/<nodeId>/passive.jsonl`.
21
+ * Fills `ts` (current ISO time). Returns the completed entry.
22
+ */
23
+ export function appendPassive(nodeId, entry) {
24
+ const full = { ts: new Date().toISOString(), ...entry };
25
+ const line = JSON.stringify(full) + '\n';
26
+ const dir = dirname(passivePath(nodeId));
27
+ if (!existsSync(dir))
28
+ mkdirSync(dir, { recursive: true });
29
+ appendFileSync(passivePath(nodeId), line, { encoding: 'utf8', flag: 'a' });
30
+ return full;
31
+ }
32
+ /** Return every accumulated passive entry (oldest first) without clearing. */
33
+ export function readPassive(nodeId) {
34
+ const p = passivePath(nodeId);
35
+ if (!existsSync(p))
36
+ return [];
37
+ return readFileSync(p, 'utf8')
38
+ .split('\n')
39
+ .filter((l) => l.trim() !== '')
40
+ .map((l) => JSON.parse(l));
41
+ }
42
+ /**
43
+ * Read AND clear the accumulator in one shot — the drain-on-message primitive.
44
+ *
45
+ * We rename the file aside before reading so a concurrent `appendPassive` (a
46
+ * publisher pushing at the same instant) starts a fresh file and is never lost
47
+ * to the truncate: at worst it lands in the next drain. The renamed snapshot is
48
+ * removed after a successful read. Returns the drained entries (oldest first).
49
+ */
50
+ export function drainPassive(nodeId) {
51
+ const p = passivePath(nodeId);
52
+ if (!existsSync(p))
53
+ return [];
54
+ const snapshot = `${p}.draining`;
55
+ try {
56
+ renameSync(p, snapshot);
57
+ }
58
+ catch {
59
+ // Lost the race (file vanished) — nothing to drain.
60
+ return [];
61
+ }
62
+ let entries = [];
63
+ try {
64
+ entries = readFileSync(snapshot, 'utf8')
65
+ .split('\n')
66
+ .filter((l) => l.trim() !== '')
67
+ .map((l) => JSON.parse(l));
68
+ }
69
+ catch {
70
+ entries = [];
71
+ }
72
+ finally {
73
+ try {
74
+ rmSync(snapshot, { force: true });
75
+ }
76
+ catch { /* best-effort cleanup */ }
77
+ }
78
+ return entries;
79
+ }
@@ -47,6 +47,29 @@ export interface ContextFileParam {
47
47
  shape?: string;
48
48
  }
49
49
  export type InputParam = PositionalParam | FlagParam | StdinParam | ContextFileParam;
50
+ /** How prominently a subcommand surfaces in ancestor (parent / root) -h
51
+ * listings. Set per child in the parent branch's `help.children`. Default
52
+ * 'normal'.
53
+ * - hidden — never listed anywhere, not even in this branch's own -h.
54
+ * You must already know it exists to invoke it.
55
+ * - normal — listed in this branch's own -h only (the default).
56
+ * - common — ALSO promoted into the parent's -h, as a bare qualified name.
57
+ * - important — ALSO promoted into the parent's -h, name + shortform desc. */
58
+ export type SubTier = 'hidden' | 'normal' | 'common' | 'important';
59
+ /** One child entry in a branch's -h listing. `desc`/`useWhen` are the shortform
60
+ * copy shown there; `tier` governs promotion into ancestor listings. */
61
+ export interface BranchChild {
62
+ name: string;
63
+ desc: string;
64
+ useWhen: string;
65
+ /** Visibility tier in ancestor listings (see SubTier). Default 'normal'. */
66
+ tier?: SubTier;
67
+ /** Computed at define time (defineBranch): how many non-hidden subcommands
68
+ * this child itself owns. Drives the "[+N subcommands]" affordance shown when
69
+ * a branch child is listed without expanding its own subcommands. Absent for
70
+ * leaves and childless branches. Do not author by hand. */
71
+ subCount?: number;
72
+ }
50
73
  /** A subtree's self-description at the parent (root) level. Each subtree owns
51
74
  * the content that represents it one level up: its vocabulary line, its
52
75
  * selection rubric, and any bounded block it contributes to the parent's -h.
@@ -75,18 +98,32 @@ export interface RootHelp {
75
98
  * root, carrying the subtree's concept, selection rubric, and any nested
76
99
  * runtime-state block. Assembled from subtrees' RootEntry by defineRoot;
77
100
  * root hardcodes none of it. */
78
- commands: {
79
- name: string;
80
- concept: string;
81
- desc: string;
82
- useWhen: string;
83
- dynamicState?: () => string | null;
84
- }[];
101
+ commands: RootCommand[];
85
102
  globals: {
86
103
  name: string;
87
104
  desc: string;
88
105
  }[];
89
106
  }
107
+ /** A single command block at root. Most fields come from the subtree's
108
+ * RootEntry; `subcommands`/`otherSubcommandCount` are computed by defineRoot
109
+ * from the subtree's children tiers. */
110
+ export interface RootCommand {
111
+ name: string;
112
+ concept: string;
113
+ desc: string;
114
+ useWhen: string;
115
+ dynamicState?: () => string | null;
116
+ /** Promoted subcommands surfaced inline under this command at root, in
117
+ * declaration order. `desc` is present only for 'important' tier; 'common'
118
+ * tier carries the bare qualified path. */
119
+ subcommands?: {
120
+ path: string;
121
+ desc?: string;
122
+ }[];
123
+ /** How many of this command's other (non-hidden, not-promoted) direct
124
+ * subcommands are not shown. Drives the "[+N (other) subcommands]" line. */
125
+ otherSubcommandCount?: number;
126
+ }
90
127
  export interface BranchHelp {
91
128
  name: string;
92
129
  summary: string;
@@ -96,11 +133,7 @@ export interface BranchHelp {
96
133
  * it with stateBlock), e.g. `<skills count="42">…</skills>`. Renderer
97
134
  * soft-fails to omission if this returns null or throws. */
98
135
  dynamicState?: () => string | null;
99
- children: {
100
- name: string;
101
- desc: string;
102
- useWhen: string;
103
- }[];
136
+ children: BranchChild[];
104
137
  }
105
138
  export interface LeafHelp {
106
139
  name: string;
package/dist/core/help.js CHANGED
@@ -54,6 +54,31 @@ const IO_CONTRACT = 'I/O contract: flags and positional args on input; stdout is
54
54
  const CAPABILITY_DISCOVERY = "If the user mentions or implies a crtr capability you don't fully understand, " +
55
55
  'do not guess or assume it is unsupported — run `-h` on the relevant command ' +
56
56
  '(append it anywhere along the path) to read the contract before acting.';
57
+ /** Lines for a command's subcommand affordance at root: any promoted
58
+ * (common/important) subcommands, then a remainder line naming how many other
59
+ * subcommands exist behind `crtr <name> -h`. Returns [] when the command has
60
+ * no listable subcommands at all. */
61
+ function rootSubcommandLines(c) {
62
+ const promoted = c.subcommands ?? [];
63
+ const other = c.otherSubcommandCount ?? 0;
64
+ if (promoted.length === 0 && other === 0)
65
+ return [];
66
+ const out = [];
67
+ if (promoted.length > 0) {
68
+ const labelW = maxLen(promoted.map((s) => s.path));
69
+ for (const s of promoted) {
70
+ // important → padded name + shortform desc; common → bare name.
71
+ out.push(s.desc !== undefined && s.desc !== ''
72
+ ? ` ${pad(s.path, labelW)} ${s.desc}`
73
+ : ` ${s.path}`);
74
+ }
75
+ }
76
+ if (other > 0) {
77
+ const word = promoted.length > 0 ? 'other subcommand' : 'subcommand';
78
+ out.push(` [+${other} ${word}${other === 1 ? '' : 's'} — \`crtr ${c.name} -h\`]`);
79
+ }
80
+ return out;
81
+ }
57
82
  export function renderRoot(h) {
58
83
  const lines = [];
59
84
  lines.push(`${h.tagline}`);
@@ -71,6 +96,11 @@ export function renderRoot(h) {
71
96
  lines.push(`<command name="${c.name}">`);
72
97
  lines.push(c.concept);
73
98
  lines.push(`use when ${c.useWhen}`);
99
+ // The command's subcommand surface: promoted (common/important) children
100
+ // inline, plus a "[+N other subcommands]" pointer to its own -h. Sits
101
+ // between the selection rubric and any live state block.
102
+ for (const l of rootSubcommandLines(c))
103
+ lines.push(l);
74
104
  // dynamicState returns a complete self-named element (e.g.
75
105
  // <skills count="42">…</skills>) — emit it as-is, nested in the command.
76
106
  const state = evalDynamic(c.dynamicState);
@@ -113,10 +143,18 @@ export function renderBranch(h) {
113
143
  }
114
144
  lines.push('');
115
145
  lines.push('Branches');
116
- const nameW = maxLen(h.children.map((c) => c.name));
117
- const descW = maxLen(h.children.map((c) => c.desc));
118
- for (const c of h.children) {
119
- lines.push(` ${pad(c.name, nameW)} ${pad(c.desc, descW)} | use when ${c.useWhen}`);
146
+ // 'hidden' children never appear in any listing — drop them here.
147
+ const visible = h.children.filter((c) => (c.tier ?? 'normal') !== 'hidden');
148
+ const nameW = maxLen(visible.map((c) => c.name));
149
+ const descW = maxLen(visible.map((c) => c.desc));
150
+ for (const c of visible) {
151
+ let line = ` ${pad(c.name, nameW)} ${pad(c.desc, descW)} | use when ${c.useWhen}`;
152
+ // A branch child is listed without its own subcommands expanded — flag how
153
+ // many it has so the agent knows there is more depth behind `<child> -h`.
154
+ if (c.subCount !== undefined && c.subCount > 0) {
155
+ line += ` [+${c.subCount} subcommand${c.subCount === 1 ? '' : 's'}]`;
156
+ }
157
+ lines.push(line);
120
158
  }
121
159
  return lines.join('\n');
122
160
  }
@@ -0,0 +1,14 @@
1
+ export interface DemoteResult {
2
+ /** True when the pane was recycled (a fresh root respawned in it). */
3
+ demoted: boolean;
4
+ /** True when a `final` report was pushed for the demoted node. */
5
+ finalized: boolean;
6
+ /** The fresh root node booted into the pane, or null on failure. */
7
+ newRoot: string | null;
8
+ /** Subscriber node ids that received the final report. */
9
+ delivered: string[];
10
+ }
11
+ /** Finish `nodeId` and recycle its pane into a fresh root. `callerPane` is the
12
+ * tmux pane the agent occupies (the Alt+C menu passes it as `#{pane_id}`).
13
+ * Best-effort; `demoted:false` when there is no pane to act on. */
14
+ export declare function demoteNode(nodeId: string, callerPane?: string): Promise<DemoteResult>;
@@ -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
- 'You have been revived fresh after a context refresh — your previous in-memory ' +
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). All self-gate on CRTR_NODE_ID. */
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). All self-gate on CRTR_NODE_ID. */
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.name);
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
- const callerNodeId = process.env['CRTR_NODE_ID'];
144
- if (callerNodeId !== undefined && callerNodeId.trim() !== '' && callerNodeId !== nodeId) {
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(callerNodeId, { window });
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: