@crouton-kit/crouter 0.3.13 → 0.3.15

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 (233) hide show
  1. package/dist/build-root.d.ts +8 -0
  2. package/dist/build-root.js +30 -0
  3. package/dist/builtin-personas/design/base.md +3 -7
  4. package/dist/builtin-personas/design/orchestrator.md +4 -3
  5. package/dist/builtin-personas/developer/base.md +3 -7
  6. package/dist/builtin-personas/developer/orchestrator.md +5 -4
  7. package/dist/builtin-personas/explore/base.md +3 -7
  8. package/dist/builtin-personas/explore/orchestrator.md +1 -5
  9. package/dist/builtin-personas/general/base.md +2 -4
  10. package/dist/builtin-personas/general/orchestrator.md +2 -4
  11. package/dist/builtin-personas/lifecycle/resident.md +2 -0
  12. package/dist/builtin-personas/lifecycle/terminal.md +6 -0
  13. package/dist/builtin-personas/orchestration-kernel.md +42 -3
  14. package/dist/builtin-personas/plan/base.md +3 -5
  15. package/dist/builtin-personas/plan/orchestrator.md +5 -4
  16. package/dist/builtin-personas/plan/reviewers/architecture-fit/base.md +9 -0
  17. package/dist/builtin-personas/plan/reviewers/code-smells/base.md +9 -0
  18. package/dist/builtin-personas/plan/reviewers/pattern-consistency/base.md +9 -0
  19. package/dist/builtin-personas/plan/reviewers/requirements-coverage/base.md +9 -0
  20. package/dist/builtin-personas/plan/reviewers/security/base.md +9 -0
  21. package/dist/builtin-personas/review/base.md +3 -5
  22. package/dist/builtin-personas/review/orchestrator.md +2 -6
  23. package/dist/builtin-personas/runtime-base.md +3 -19
  24. package/dist/builtin-personas/spec/base.md +3 -5
  25. package/dist/builtin-personas/spec/orchestrator.md +4 -3
  26. package/dist/builtin-personas/spine/has-manager.md +10 -0
  27. package/dist/builtin-personas/spine/no-manager.md +2 -0
  28. package/dist/builtin-skills/skills/crouter-development/personas/SKILL.md +96 -0
  29. package/dist/builtin-skills/skills/crouter-development/personas/base-prompt/SKILL.md +49 -0
  30. package/dist/builtin-skills/skills/crouter-development/personas/orchestrator-prompt/SKILL.md +49 -0
  31. package/dist/builtin-skills/skills/planning/SKILL.md +1 -1
  32. package/dist/builtin-skills/skills/spec/SKILL.md +2 -2
  33. package/dist/cli.js +6 -29
  34. package/dist/commands/__tests__/human.test.js +73 -2
  35. package/dist/commands/attention.js +76 -7
  36. package/dist/commands/canvas-prune.d.ts +2 -0
  37. package/dist/commands/canvas-prune.js +66 -0
  38. package/dist/commands/canvas.js +5 -8
  39. package/dist/commands/chord.d.ts +2 -0
  40. package/dist/commands/chord.js +143 -0
  41. package/dist/commands/daemon.js +8 -5
  42. package/dist/commands/dashboard.js +2 -0
  43. package/dist/commands/human/prompts.js +28 -27
  44. package/dist/commands/human/queue.d.ts +1 -0
  45. package/dist/commands/human/queue.js +105 -2
  46. package/dist/commands/human/shared.d.ts +28 -18
  47. package/dist/commands/human/shared.js +53 -60
  48. package/dist/commands/human.js +6 -14
  49. package/dist/commands/node.d.ts +11 -0
  50. package/dist/commands/node.js +381 -87
  51. package/dist/commands/pkg/market-inspect.js +6 -4
  52. package/dist/commands/pkg/market-manage.js +10 -6
  53. package/dist/commands/pkg/market.js +2 -4
  54. package/dist/commands/pkg/plugin-inspect.js +6 -4
  55. package/dist/commands/pkg/plugin-manage.js +12 -7
  56. package/dist/commands/pkg/plugin.js +2 -4
  57. package/dist/commands/pkg.js +0 -4
  58. package/dist/commands/push.js +178 -15
  59. package/dist/commands/revive.js +5 -3
  60. package/dist/commands/skill/author.js +6 -4
  61. package/dist/commands/skill/find.js +8 -5
  62. package/dist/commands/skill/read.js +2 -0
  63. package/dist/commands/skill/state.js +6 -4
  64. package/dist/commands/skill.js +0 -6
  65. package/dist/commands/sys/config.js +21 -7
  66. package/dist/commands/sys/doctor.js +2 -0
  67. package/dist/commands/sys/update.js +4 -0
  68. package/dist/commands/sys.js +0 -6
  69. package/dist/commands/tmux-spread.d.ts +2 -0
  70. package/dist/commands/tmux-spread.js +130 -0
  71. package/dist/core/__tests__/canvas-inbox-watcher.test.js +25 -0
  72. package/dist/core/__tests__/child-followup.test.d.ts +1 -0
  73. package/dist/core/__tests__/child-followup.test.js +83 -0
  74. package/dist/core/__tests__/close.test.d.ts +1 -0
  75. package/dist/core/__tests__/close.test.js +148 -0
  76. package/dist/core/__tests__/context-intro.test.d.ts +1 -0
  77. package/dist/core/__tests__/context-intro.test.js +196 -0
  78. package/dist/core/__tests__/daemon-boot.test.d.ts +1 -0
  79. package/dist/core/__tests__/daemon-boot.test.js +93 -0
  80. package/dist/core/__tests__/daemon-liveness.test.d.ts +1 -0
  81. package/dist/core/__tests__/daemon-liveness.test.js +223 -0
  82. package/dist/core/__tests__/focuses.test.d.ts +1 -0
  83. package/dist/core/__tests__/focuses.test.js +259 -0
  84. package/dist/core/__tests__/fork.test.d.ts +1 -0
  85. package/dist/core/__tests__/fork.test.js +91 -0
  86. package/dist/core/__tests__/home-session.test.d.ts +1 -0
  87. package/dist/core/__tests__/home-session.test.js +153 -0
  88. package/dist/core/__tests__/human-cancel-guard.test.d.ts +1 -0
  89. package/dist/core/__tests__/human-cancel-guard.test.js +49 -0
  90. package/dist/core/__tests__/keystone.test.d.ts +1 -0
  91. package/dist/core/__tests__/keystone.test.js +185 -0
  92. package/dist/core/__tests__/kickoff.test.d.ts +1 -0
  93. package/dist/core/__tests__/kickoff.test.js +89 -0
  94. package/dist/core/__tests__/lifecycle.test.d.ts +1 -0
  95. package/dist/core/__tests__/lifecycle.test.js +178 -0
  96. package/dist/core/__tests__/listing-completeness.test.d.ts +1 -0
  97. package/dist/core/__tests__/listing-completeness.test.js +31 -0
  98. package/dist/core/__tests__/memory.test.d.ts +1 -0
  99. package/dist/core/__tests__/memory.test.js +152 -0
  100. package/dist/core/__tests__/migration.test.d.ts +1 -0
  101. package/dist/core/__tests__/migration.test.js +238 -0
  102. package/dist/core/__tests__/pane-column.test.d.ts +1 -0
  103. package/dist/core/__tests__/pane-column.test.js +153 -0
  104. package/dist/core/__tests__/passive-subscription.test.d.ts +1 -0
  105. package/dist/core/__tests__/passive-subscription.test.js +164 -0
  106. package/dist/core/__tests__/persona-compose.test.d.ts +1 -0
  107. package/dist/core/__tests__/persona-compose.test.js +53 -0
  108. package/dist/core/__tests__/persona-subkind.test.d.ts +1 -0
  109. package/dist/core/__tests__/persona-subkind.test.js +62 -0
  110. package/dist/core/__tests__/persona.test.d.ts +1 -0
  111. package/dist/core/__tests__/persona.test.js +107 -0
  112. package/dist/core/__tests__/placement-focus.test.d.ts +1 -0
  113. package/dist/core/__tests__/placement-focus.test.js +244 -0
  114. package/dist/core/__tests__/placement-reconcile.test.d.ts +1 -0
  115. package/dist/core/__tests__/placement-reconcile.test.js +212 -0
  116. package/dist/core/__tests__/placement-revive.test.d.ts +1 -0
  117. package/dist/core/__tests__/placement-revive.test.js +238 -0
  118. package/dist/core/__tests__/placement-teardown.test.d.ts +1 -0
  119. package/dist/core/__tests__/placement-teardown.test.js +183 -0
  120. package/dist/core/__tests__/prune.test.d.ts +1 -0
  121. package/dist/core/__tests__/prune.test.js +116 -0
  122. package/dist/core/__tests__/push-final-guard.test.d.ts +1 -0
  123. package/dist/core/__tests__/push-final-guard.test.js +71 -0
  124. package/dist/core/__tests__/relaunch.test.d.ts +1 -0
  125. package/dist/core/__tests__/relaunch.test.js +328 -0
  126. package/dist/core/__tests__/reset.test.js +26 -7
  127. package/dist/core/__tests__/revive.test.d.ts +1 -0
  128. package/dist/core/__tests__/revive.test.js +217 -0
  129. package/dist/core/__tests__/spawn-root.test.d.ts +1 -0
  130. package/dist/core/__tests__/spawn-root.test.js +73 -0
  131. package/dist/core/__tests__/steer-note.test.d.ts +1 -0
  132. package/dist/core/__tests__/steer-note.test.js +39 -0
  133. package/dist/core/__tests__/stop-guard.test.d.ts +1 -0
  134. package/dist/core/__tests__/stop-guard.test.js +82 -0
  135. package/dist/core/__tests__/subcommand-tier.test.d.ts +1 -0
  136. package/dist/core/__tests__/subcommand-tier.test.js +99 -0
  137. package/dist/core/__tests__/tmux-surface.test.d.ts +1 -0
  138. package/dist/core/__tests__/tmux-surface.test.js +106 -0
  139. package/dist/core/__tests__/unknown-path.test.js +8 -2
  140. package/dist/core/canvas/attention.d.ts +10 -0
  141. package/dist/core/canvas/attention.js +40 -0
  142. package/dist/core/canvas/canvas.d.ts +66 -7
  143. package/dist/core/canvas/canvas.js +209 -21
  144. package/dist/core/canvas/db.d.ts +8 -0
  145. package/dist/core/canvas/db.js +206 -4
  146. package/dist/core/canvas/focuses.d.ts +22 -0
  147. package/dist/core/canvas/focuses.js +80 -0
  148. package/dist/core/canvas/index.d.ts +3 -0
  149. package/dist/core/canvas/index.js +3 -0
  150. package/dist/core/canvas/labels.d.ts +27 -0
  151. package/dist/core/canvas/labels.js +36 -0
  152. package/dist/core/canvas/paths.d.ts +4 -0
  153. package/dist/core/canvas/paths.js +6 -0
  154. package/dist/core/canvas/render.js +25 -10
  155. package/dist/core/canvas/telemetry.d.ts +14 -0
  156. package/dist/core/canvas/telemetry.js +35 -0
  157. package/dist/core/canvas/types.d.ts +115 -12
  158. package/dist/core/command.d.ts +25 -1
  159. package/dist/core/command.js +48 -7
  160. package/dist/core/config.js +36 -2
  161. package/dist/core/feed/feed.js +14 -12
  162. package/dist/core/feed/inbox.d.ts +3 -1
  163. package/dist/core/feed/inbox.js +45 -5
  164. package/dist/core/feed/passive.d.ts +17 -0
  165. package/dist/core/feed/passive.js +92 -0
  166. package/dist/core/help.d.ts +59 -13
  167. package/dist/core/help.js +73 -28
  168. package/dist/core/personas/index.d.ts +1 -1
  169. package/dist/core/personas/index.js +1 -1
  170. package/dist/core/personas/loader.d.ts +40 -1
  171. package/dist/core/personas/loader.js +63 -1
  172. package/dist/core/personas/resolve.d.ts +13 -6
  173. package/dist/core/personas/resolve.js +46 -34
  174. package/dist/core/runtime/bearings.d.ts +20 -0
  175. package/dist/core/runtime/bearings.js +92 -0
  176. package/dist/core/runtime/close.d.ts +14 -0
  177. package/dist/core/runtime/close.js +151 -0
  178. package/dist/core/runtime/demote.d.ts +14 -0
  179. package/dist/core/runtime/demote.js +120 -0
  180. package/dist/core/runtime/front-door.js +1 -1
  181. package/dist/core/runtime/kickoff.d.ts +32 -6
  182. package/dist/core/runtime/kickoff.js +111 -37
  183. package/dist/core/runtime/launch.d.ts +29 -6
  184. package/dist/core/runtime/launch.js +85 -13
  185. package/dist/core/runtime/lifecycle.d.ts +13 -0
  186. package/dist/core/runtime/lifecycle.js +86 -0
  187. package/dist/core/runtime/memory.d.ts +43 -0
  188. package/dist/core/runtime/memory.js +165 -0
  189. package/dist/core/runtime/naming.d.ts +22 -0
  190. package/dist/core/runtime/naming.js +166 -0
  191. package/dist/core/runtime/nodes.d.ts +32 -1
  192. package/dist/core/runtime/nodes.js +60 -10
  193. package/dist/core/runtime/persona.d.ts +25 -0
  194. package/dist/core/runtime/persona.js +139 -0
  195. package/dist/core/runtime/placement.d.ts +287 -0
  196. package/dist/core/runtime/placement.js +663 -0
  197. package/dist/core/runtime/presence.d.ts +7 -32
  198. package/dist/core/runtime/presence.js +90 -110
  199. package/dist/core/runtime/promote.d.ts +18 -7
  200. package/dist/core/runtime/promote.js +70 -65
  201. package/dist/core/runtime/reset.d.ts +47 -4
  202. package/dist/core/runtime/reset.js +223 -52
  203. package/dist/core/runtime/revive.d.ts +26 -2
  204. package/dist/core/runtime/revive.js +166 -39
  205. package/dist/core/runtime/roadmap.d.ts +5 -4
  206. package/dist/core/runtime/roadmap.js +9 -16
  207. package/dist/core/runtime/spawn.d.ts +20 -5
  208. package/dist/core/runtime/spawn.js +169 -44
  209. package/dist/core/runtime/stop-guard.d.ts +1 -1
  210. package/dist/core/runtime/stop-guard.js +18 -8
  211. package/dist/core/runtime/tmux.d.ts +106 -21
  212. package/dist/core/runtime/tmux.js +249 -45
  213. package/dist/core/spawn.js +15 -0
  214. package/dist/daemon/crtrd.d.ts +12 -1
  215. package/dist/daemon/crtrd.js +152 -34
  216. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.d.ts +1 -0
  217. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +266 -0
  218. package/dist/pi-extensions/canvas-commands.d.ts +34 -0
  219. package/dist/pi-extensions/canvas-commands.js +103 -0
  220. package/dist/pi-extensions/canvas-context-intro.d.ts +70 -0
  221. package/dist/pi-extensions/canvas-context-intro.js +164 -0
  222. package/dist/pi-extensions/canvas-goal-capture.d.ts +21 -0
  223. package/dist/pi-extensions/canvas-goal-capture.js +67 -0
  224. package/dist/pi-extensions/canvas-inbox-watcher.js +11 -0
  225. package/dist/pi-extensions/canvas-nav.d.ts +12 -4
  226. package/dist/pi-extensions/canvas-nav.js +586 -262
  227. package/dist/pi-extensions/canvas-passive-context.d.ts +32 -0
  228. package/dist/pi-extensions/canvas-passive-context.js +114 -0
  229. package/dist/pi-extensions/canvas-stophook.d.ts +16 -0
  230. package/dist/pi-extensions/canvas-stophook.js +344 -228
  231. package/dist/types.d.ts +28 -0
  232. package/dist/types.js +16 -0
  233. package/package.json +1 -1
@@ -6,15 +6,41 @@
6
6
  // the spine (`focus`/`msg`). The push/feed half lives under `crtr push`.
7
7
  import { defineLeaf, defineBranch } from '../core/command.js';
8
8
  import { InputError } from '../core/io.js';
9
- import { spawnChild, bootRoot } from '../core/runtime/spawn.js';
9
+ import { spawnChild } from '../core/runtime/spawn.js';
10
10
  import { promote, requestYield } from '../core/runtime/promote.js';
11
11
  import { writeYieldMessage } from '../core/runtime/kickoff.js';
12
12
  import { reviveNode } from '../core/runtime/revive.js';
13
- import { focusNodeInPlace, demoteNode } from '../core/runtime/presence.js';
13
+ import { demoteNode } from '../core/runtime/demote.js';
14
+ import { detachToBackground, focus as placementFocus } from '../core/runtime/placement.js';
15
+ import { buildLaunchSpec } from '../core/runtime/launch.js';
16
+ import { closeNode } from '../core/runtime/close.js';
14
17
  import { windowAlive, windowOfPane, currentTmux } from '../core/runtime/tmux.js';
15
18
  import { appendInbox } from '../core/feed/inbox.js';
16
19
  import { availableKinds } from '../core/personas/index.js';
17
- import { getNode, listNodes, subscriptionsOf, subscribersOf, } from '../core/canvas/index.js';
20
+ import { getNode, updateNode, listNodes, subscribe, unsubscribe, subscriptionsOf, subscribersOf, readContextTokens, } from '../core/canvas/index.js';
21
+ // Past this much context, an ORCHESTRATOR that spawns a managed child is better
22
+ // off yielding than holding its fat window open for the child's result: the
23
+ // child's finish revives it fresh against its roadmap, so a clean window absorbs
24
+ // the result instead of this bloated one. Below the steering bands (130k+) on
25
+ // purpose — catch it at spawn, before the window is critically full.
26
+ const YIELD_NUDGE_THRESHOLD = 100_000;
27
+ const STD_CHILD_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: continue other independent work right now, or stop and end your turn — the wake brings you back.";
28
+ /** Decision road sign for a managed (non-root) child. Normally STD_CHILD_FOLLOW_UP,
29
+ * but when the SPAWNER is an orchestrator whose context has already grown past
30
+ * YIELD_NUDGE_THRESHOLD, steer it to yield now and let its fresh revive handle
31
+ * the child's result. */
32
+ export function childFollowUp(spawnerId) {
33
+ if (spawnerId === undefined || spawnerId === '')
34
+ return STD_CHILD_FOLLOW_UP;
35
+ const spawner = getNode(spawnerId);
36
+ if (spawner === null || spawner.mode !== 'orchestrator')
37
+ return STD_CHILD_FOLLOW_UP;
38
+ const ctxTokens = readContextTokens(spawnerId);
39
+ if (ctxTokens === null || ctxTokens < YIELD_NUDGE_THRESHOLD)
40
+ return STD_CHILD_FOLLOW_UP;
41
+ const k = Math.round(ctxTokens / 1000);
42
+ return `Child spawned — you're auto-subscribed, so its finish wakes you on its own; never wait or poll. But you're an orchestrator already carrying ~${k}k of context: rather than hold this window open for the result, checkpoint context/roadmap.md and \`crtr node yield\` now. Yielding ends this turn, and the child's completion then revives you fresh against your roadmap — let that clean revive absorb the result instead of this bloated context.`;
43
+ }
18
44
  /** Validate a `--kind` against the installed personas; throws a listing InputError. */
19
45
  function assertKind(kind) {
20
46
  const kinds = availableKinds();
@@ -27,30 +53,35 @@ function assertKind(kind) {
27
53
  // ---------------------------------------------------------------------------
28
54
  const nodeNew = defineLeaf({
29
55
  name: 'new',
56
+ description: 'spawn a node — a managed child (default), or an independent root with --root',
57
+ whenToUse: 'you have a self-contained unit of work — reach for this instead of doing it inline, so the reading and the tokens land in a fresh window and only the conclusion comes back: mapping an unfamiliar part of the codebase, writing a spec, designing an approach, breaking a job into a plan, implementing a change, or running a review. Match `--kind` to the work (explore/spec/design/plan/developer/review/general) and fan independent units out as concurrent children. Default is a managed child you auto-subscribe to, so its finish wakes you; pass `--mode orchestrator` when the unit is itself too big for one window (e.g. a large multi-area review) so it boots as a sub-orchestrator with its own roadmap instead of a base worker you hope promotes itself; pass `--root` to hand off an INDEPENDENT node you neither manage nor get woken by (e.g. one a human will sit and drive), not for ordinary delegation',
58
+ tier: 'important',
30
59
  help: {
31
60
  name: 'node new',
32
61
  summary: 'spawn a terminal worker onto the canvas as a background window — returns its node id',
33
62
  params: [
34
63
  { kind: 'stdin', name: 'prompt', required: true, constraint: 'First user message for the spawned node. Piped on stdin or passed as a positional.' },
35
64
  { kind: 'flag', name: 'kind', type: 'string', required: false, default: 'general', constraint: 'Persona kind — match the work: explore (map/investigate a codebase), spec (write a spec), design (architect a solution), plan (break work into steps), developer (implement a change), review (validate/critique), general (anything else).' },
36
- { kind: 'flag', name: 'mode', type: 'enum', choices: ['base', 'orchestrator'], required: false, default: 'base', constraint: 'Persona mode. Almost always base; orchestrator is reserved for promoted/resident nodes.' },
65
+ { kind: 'flag', name: 'mode', type: 'enum', choices: ['base', 'orchestrator'], required: false, default: 'base', constraint: 'Persona mode. base for a worker that finishes in one window; orchestrator to create the child directly as a sub-orchestrator (it boots with the orchestrator persona + a seeded roadmap and fans its scope out) — use it when the unit is too large for one window, e.g. a big review, instead of spawning a base worker and counting on it to promote itself.' },
37
66
  { kind: 'flag', name: 'cwd', type: 'path', required: false, constraint: 'Dir the node is pinned to. Defaults to the caller cwd.' },
38
67
  { kind: 'flag', name: 'name', type: 'string', required: false, constraint: 'Display name (tmux window + resume picker). Defaults to the kind.' },
39
68
  { kind: 'flag', name: 'parent', type: 'string', required: false, constraint: 'Parent node id. Defaults to the calling node (CRTR_NODE_ID).' },
69
+ { kind: 'flag', name: 'root', type: 'bool', required: false, constraint: 'Spawn an INDEPENDENT root instead of a managed child: no parent (top-level on the canvas), NO subscription back to you (you are NOT woken by it), resident lifecycle. It records spawned_by=you for provenance and is brought forefront so it can be driven directly. Use for a node you hand off and do not manage (e.g. a sub-orchestrator a human will discuss with).' },
70
+ { kind: 'flag', name: 'fork-from', type: 'string', required: false, constraint: 'FORK the new node from an existing pi conversation instead of starting it fresh: pass a node id (forks from that node\'s session), an absolute session `.jsonl` path, or a partial pi session uuid. pi copies that whole history into a NEW session for the child (the source is untouched), then the prompt is delivered as the next message — i.e. the child wakes up as a continuation of that conversation. Use to branch exploratory work off a node that already built up the context you need, instead of re-deriving it. One-shot at birth: the fork resumes its own session thereafter.' },
40
71
  ],
41
72
  output: [
42
73
  { name: 'node_id', type: 'string', required: true, constraint: 'The new node id.' },
43
74
  { name: 'name', type: 'string', required: true, constraint: 'Display name.' },
44
75
  { name: 'window', type: 'string', required: false, constraint: 'tmux window id of the background window.' },
45
- { name: 'session', type: 'string', required: true, constraint: 'The shared crtr tmux session the node was placed in.' },
76
+ { name: 'session', type: 'string', required: true, constraint: 'The tmux session the node was placed in — the shared crtr session for a child; your current session for an in-tmux --root.' },
46
77
  { name: 'status', type: 'string', required: true, constraint: 'Always "active" on spawn.' },
47
- { name: 'follow_up', type: 'string', required: true, constraint: 'A notification to the caller about the spawn: the child runs independently and its finish wakes you automatically, so treat it as fire-and-forget. Read it, then act.' },
78
+ { 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. If you are an orchestrator already deep in context (>100k), it instead steers you to `crtr node yield` now so your fresh revive absorbs the child\'s result. Read it, then act.' },
48
79
  ],
49
80
  outputKind: 'object',
50
81
  effects: [
51
82
  'Creates a node under ~/.crtr/nodes/<id>/ and indexes it in canvas.db.',
52
- 'Parent auto-subscribes (active) to the child so it is woken on the child\'s pushes.',
53
- 'Opens a background (non-focus-stealing) tmux window running pi.',
83
+ 'Default (managed child): parent auto-subscribes (active) and is woken on the child\'s pushes. With --root: no subscription — records a spawned_by edge for provenance only.',
84
+ 'Opens a tmux window running pi: a background (non-focus-stealing) window in the shared crtr session for a child; with --root, a new window in your current session (in-tmux) or the shared session (outside tmux), with the client switched to it.',
54
85
  ],
55
86
  },
56
87
  run: async (input) => {
@@ -63,14 +94,18 @@ const nodeNew = defineLeaf({
63
94
  const cwd = input['cwd'] ?? process.cwd();
64
95
  const name = input['name'];
65
96
  const parent = input['parent'];
66
- const res = spawnChild({ kind, mode, cwd, name, prompt, parent });
97
+ const root = input['root'] === true;
98
+ const forkFrom = input['forkFrom'];
99
+ const res = spawnChild({ kind, mode, cwd, name, prompt, parent, root, forkFrom });
67
100
  return {
68
101
  node_id: res.node.node_id,
69
102
  name: res.node.name,
70
103
  window: res.window ?? undefined,
71
104
  session: res.session,
72
105
  status: res.node.status,
73
- follow_up: "Notification only — you're auto-subscribed, so the child's finish wakes you automatically; treat it as fire-and-forget. Carry on with other independent work now, or stop and end your turn. On wake: `crtr feed read`.",
106
+ follow_up: root
107
+ ? "Independent root spawned — it is NOT under you. You are not subscribed, so its finish will NOT wake you and it does not hold you alive; it carries spawned_by=you for lineage only. It opened in its own window and the client was switched to it so it can be driven directly. Hand it off and move on — you will not be notified of its progress."
108
+ : childFollowUp(parent ?? process.env['CRTR_NODE_ID']),
74
109
  };
75
110
  },
76
111
  render: (r) => `<spawned name="${r['name']}" id="${r['node_id']}" status="${r['status']}">\n${r['follow_up']}\n</spawned>`,
@@ -80,11 +115,13 @@ const nodeNew = defineLeaf({
80
115
  // ---------------------------------------------------------------------------
81
116
  const nodeList = defineLeaf({
82
117
  name: 'list',
118
+ description: 'list nodes on the canvas',
119
+ whenToUse: 'you want a flat roster of the nodes on the canvas, optionally sliced by status (active/idle/done/dead/canceled): a quick read of what exists and what is still running. Use `node inspect show` instead to drill into one node and its spine neighbors, `canvas dashboard` for the tree SHAPE, and `canvas attention` to find which nodes are blocked on a human',
83
120
  help: {
84
121
  name: 'node inspect list',
85
122
  summary: 'list nodes on the canvas, optionally by status',
86
123
  params: [
87
- { kind: 'flag', name: 'status', type: 'string', required: false, constraint: 'Filter: active | idle | done | dead. Comma-separated for several.' },
124
+ { kind: 'flag', name: 'status', type: 'string', required: false, constraint: 'Filter: active | idle | done | dead | canceled. Comma-separated for several.' },
88
125
  ],
89
126
  output: [
90
127
  { name: 'nodes', type: 'object[]', required: true, constraint: 'Rows: {node_id, name, kind, mode, lifecycle, status, cwd, parent, created}.' },
@@ -104,6 +141,8 @@ const nodeList = defineLeaf({
104
141
  // ---------------------------------------------------------------------------
105
142
  const nodeShow = defineLeaf({
106
143
  name: 'show',
144
+ description: 'show a node + its spine neighbors',
145
+ whenToUse: 'you want one node in depth: its meta plus its spine neighbors — who it reports to (subscriptions) and who manages it (subscribers). Use `node inspect list` instead for the flat roster of every node, or `canvas dashboard` to see the whole-tree shape',
107
146
  help: {
108
147
  name: 'node inspect show',
109
148
  summary: 'show a node\'s meta plus its subscriptions (reports) and subscribers (managers)',
@@ -132,13 +171,11 @@ const nodeShow = defineLeaf({
132
171
  // ---------------------------------------------------------------------------
133
172
  const nodeInspect = defineBranch({
134
173
  name: 'inspect',
174
+ description: 'read the graph (list nodes / show one)',
175
+ whenToUse: 'reading the graph: enumerate the nodes on the canvas (`list`) or drill into one and its spine neighbors (`show`). Use `canvas dashboard` instead to render the tree SHAPE, or `canvas attention` to find which nodes are blocked on a human',
135
176
  help: {
136
177
  name: 'node inspect',
137
178
  summary: 'read the canvas graph — enumerate nodes or inspect one with its spine neighbors',
138
- children: [
139
- { name: 'list', desc: 'list nodes on the canvas', useWhen: 'surveying what exists' },
140
- { name: 'show', desc: 'show a node + its spine neighbors', useWhen: 'inspecting one node' },
141
- ],
142
179
  },
143
180
  children: [nodeList, nodeShow],
144
181
  });
@@ -147,11 +184,14 @@ const nodeInspect = defineBranch({
147
184
  // ---------------------------------------------------------------------------
148
185
  const nodeFocus = defineLeaf({
149
186
  name: 'focus',
187
+ description: 'bring a node window forefront',
188
+ whenToUse: 'you want to bring a specific node into view — swapped into your current pane — to watch or steer it directly, reviving it first if dormant. Use `node cycle` instead to walk neighbors one window at a time rather than jump to a named node, and `node msg` to steer a node without leaving where you are',
150
189
  help: {
151
190
  name: 'node focus',
152
191
  summary: 'bring a node into your CURRENT pane in place (swap-pane) — the agent appears where you are instead of navigating you to its window',
153
192
  params: [
154
193
  { kind: 'positional', name: 'node', required: true, constraint: 'Node id to focus.' },
194
+ { kind: 'flag', name: 'new-pane', type: 'bool', required: false, constraint: 'Open the node in a NEW viewport SIDE-BY-SIDE with your current pane (a second focus) instead of swapping it into your pane. Two agents on screen at once (F4).' },
155
195
  ],
156
196
  output: [
157
197
  { name: 'focused', type: 'boolean', required: true, constraint: 'True when the node was brought into view.' },
@@ -160,25 +200,23 @@ const nodeFocus = defineLeaf({
160
200
  { name: 'in_place', type: 'boolean', required: true, constraint: 'True when the node was swapped into the caller pane; false when it fell back to window focus (no caller pane).' },
161
201
  ],
162
202
  outputKind: 'object',
163
- effects: ['Swaps the node\'s pane into the caller\'s current pane (tmux swap-pane -d) and updates the focus pointer.', 'Falls back to select-window (+ switch-client across roots) when there is no caller pane.', 'Revives a dormant node (resume) if it has no live window, then focuses it.'],
203
+ effects: ['Swaps the node\'s pane into the caller\'s current pane (tmux swap-pane -d) and retargets the caller\'s focus to it (focus pointer updated).', 'With --new-pane: splits a new viewport beside the caller (a second live focus) instead of swapping in place.', 'Revives a dormant node (resume) into the backstage if it has no live pane, then swaps it into the focus.'],
164
204
  },
165
205
  run: async (input) => {
166
206
  const id = input['node'];
167
207
  const node = getNode(id);
168
208
  if (node === null)
169
209
  throw new InputError({ error: 'not_found', message: `no node: ${id}`, next: 'List nodes with `crtr node inspect list`.' });
170
- // A dormant node (done/dead/window released) has no live window revive it
171
- // (resume the saved conversation) so there is something to focus.
172
- let revived = false;
173
- if (!windowAlive(node.tmux_session, node.window)) {
174
- try {
175
- reviveNode(id, { resume: true });
176
- revived = true;
177
- }
178
- catch { /* fall through; focus reports focused:false */ }
179
- }
180
- const res = focusNodeInPlace(id);
181
- return { focused: res.focused, session: res.session, revived, in_place: res.inPlace };
210
+ // Placement owns the whole act (§2.3): resolve the caller's focus (or open a
211
+ // new viewport with --new-pane), revive the target into the backstage if it
212
+ // is dormant, then hot-swap it onto the focus. The reviver is injected so
213
+ // placement need not import revive.ts.
214
+ const res = placementFocus(id, {
215
+ newPane: input['newPane'] === true,
216
+ callerNode: process.env['CRTR_NODE_ID'],
217
+ revive: (nid) => { reviveNode(nid, { resume: true }); },
218
+ });
219
+ return { focused: res.focused, session: res.session, revived: res.revived, in_place: res.inPlace };
182
220
  },
183
221
  });
184
222
  // ---------------------------------------------------------------------------
@@ -193,82 +231,197 @@ function nodeByWindow(win) {
193
231
  }
194
232
  return undefined;
195
233
  }
234
+ /** The live node occupying a tmux pane (pane → window → node), or undefined.
235
+ * Defaults to $TMUX_PANE / the caller's current pane when `pane` is omitted —
236
+ * shared by `node demote` and `node cycle`, both of which act on "the agent in
237
+ * front of you". Exported for the `canvas chord` / `canvas tmux-spread` leaves,
238
+ * which resolve the active pane's node the same way. */
239
+ export function nodeInPane(pane) {
240
+ const resolvePane = pane ?? process.env['TMUX_PANE'] ?? currentTmux()?.pane;
241
+ const win = resolvePane !== undefined && resolvePane !== '' ? windowOfPane(resolvePane) : null;
242
+ return win !== null ? nodeByWindow(win) : undefined;
243
+ }
196
244
  const nodeDemote = defineLeaf({
197
245
  name: 'demote',
246
+ description: 'finish the agent in your pane + recycle it into a fresh root',
247
+ whenToUse: 'you are at an agent\'s pane and done with it: finish it cleanly and recycle the pane in one move — 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 to keep working. The human-driver way to end an agent and immediately start over in place. Use `node close` instead to tear a node and its subtree down WITHOUT finishing (no report, revivable), and `push final` when the agent should finish ITSELF from inside its own turn',
198
248
  help: {
199
249
  name: 'node demote',
200
- summary: 'detach the agent in your current pane to the backgroundswap a fresh terminal into its place and relocate its running pi to a window in the shared crtr session (the inverse of focus; reattach later with `node focus`)',
250
+ summary: 'finish the agent in your current pane and recycle the panepush its last message as a final report to everyone waiting on it, mark it done, then boot a fresh crtr root in the same pane',
201
251
  params: [
202
- { kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Node to demote. Defaults to the node occupying --pane (or your current pane).' },
203
- { kind: 'flag', name: 'pane', type: 'string', required: false, constraint: 'tmux pane id to demote out of. Defaults to $TMUX_PANE / your current pane. The Alt+C menu passes this for you.' },
252
+ { kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Node to finish. Defaults to the node occupying --pane (or your current pane).' },
253
+ { 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
254
  ],
205
255
  output: [
206
- { name: 'demoted', type: 'boolean', required: true, constraint: 'True when the agent was swapped out to the background.' },
207
- { name: 'node_id', type: 'string', required: false, constraint: 'The demoted node.' },
208
- { name: 'session', type: 'string', required: false, constraint: 'The shared session the agent now lives in.' },
209
- { name: 'window', type: 'string', required: false, constraint: 'The agent\'s new background window id.' },
256
+ { name: 'demoted', type: 'boolean', required: true, constraint: 'True when the pane was recycled into a fresh root.' },
257
+ { name: 'node_id', type: 'string', required: false, constraint: 'The finished node.' },
258
+ { name: 'finalized', type: 'boolean', required: false, constraint: 'True when a final report was pushed to its subscribers.' },
259
+ { name: 'delivered', type: 'number', required: false, constraint: 'How many subscribers/managers received the final report.' },
260
+ { name: 'new_root', type: 'string', required: false, constraint: 'The fresh root node booted into the pane.' },
210
261
  ],
211
262
  outputKind: 'object',
212
- effects: ['Swaps a fresh shell into the caller pane (tmux swap-pane) and relocates the node\'s pi window into the shared crtr session.', 'Clears the focus pointer if the demoted node held it. The pi keeps running — nothing is killed.'],
263
+ 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
264
  },
214
265
  run: async (input) => {
215
266
  const pane = input['pane'] ?? process.env['TMUX_PANE'];
216
267
  let id = input['node'];
217
268
  if (id === undefined || id === '') {
218
269
  // Derive the node from the pane: which node's window holds it?
219
- const resolvePane = pane ?? currentTmux()?.pane;
220
- const win = resolvePane !== undefined ? windowOfPane(resolvePane) : null;
221
- id = win !== null ? nodeByWindow(win) : undefined;
270
+ id = nodeInPane(pane);
222
271
  }
223
272
  if (id === undefined || id === '') {
224
- throw new InputError({ error: 'no_node', message: 'no node found in this pane to demote', next: 'Pass --node <id>, or run from inside a focused node\'s pane.' });
273
+ 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
274
  }
226
275
  if (getNode(id) === null) {
227
276
  throw new InputError({ error: 'not_found', message: `no node: ${id}`, next: 'List nodes with `crtr node inspect list`.' });
228
277
  }
229
- const res = demoteNode(id, pane);
230
- return { demoted: res.demoted, node_id: id, session: res.session ?? undefined, window: res.window ?? undefined };
278
+ const res = await demoteNode(id, pane);
279
+ return { demoted: res.demoted, node_id: id, finalized: res.finalized, delivered: res.delivered.length, new_root: res.newRoot ?? undefined };
231
280
  },
232
281
  render: (r) => r['demoted'] === true
233
- ? `<demoted id="${r['node_id']}" session="${r['session'] ?? ''}"/>`
282
+ ? `<demoted id="${r['node_id']}" finalized="${r['finalized']}" delivered="${r['delivered']}" new_root="${r['new_root'] ?? ''}"/>`
234
283
  : `<demote-failed id="${r['node_id'] ?? ''}">not in tmux, or no agent in this pane</demote-failed>`,
235
284
  });
236
285
  // ---------------------------------------------------------------------------
237
- // node sessionboot a NEW root in its own tmux session (the explicit form)
286
+ // node closeclose a node + cascade-cancel its exclusive subtree (Alt+C x)
238
287
  // ---------------------------------------------------------------------------
239
- const nodeSession = defineLeaf({
240
- name: 'session',
288
+ const nodeClose = defineLeaf({
289
+ name: 'close',
290
+ description: 'close a node + cascade-cancel its exclusive subtree (revivable)',
291
+ whenToUse: 'you want to tear a node down WITHOUT finishing it, cascade-cancelling every descendant it exclusively owns: abandoning a line of work, killing a stuck or wrong-turn subtree, clearing a branch you no longer need. Windows die but nothing is deleted — each closed node keeps its pi session and can be revived later (`canvas revive`). Use `node demote` instead to FINISH the agent in your pane with a final report, and `push final` when a worker should end its own work normally (Alt+C → x)',
241
292
  help: {
242
- name: 'node session',
243
- summary: 'start a fresh root node as its own window in the shared crtr session (use from inside a node to start a new root without taking your pane)',
293
+ name: 'node close',
294
+ summary: 'close a node and cascade-cancel its subtree — kill its tmux window plus those of every descendant it EXCLUSIVELY owns (down the subscribes_to spine), mark them all canceled, and leave each a notice it reads on resume. A descendant still subscribed to by a manager outside the subtree is left running. Nothing is deleted: every closed node keeps its pi session and can be revived later (`crtr canvas revive`)',
244
295
  params: [
245
- { kind: 'stdin', name: 'prompt', required: false, constraint: 'Optional starter prompt; a root needs none.' },
246
- { kind: 'flag', name: 'kind', type: 'string', required: false, default: 'general', constraint: 'Persona kind for the root.' },
247
- { kind: 'flag', name: 'cwd', type: 'path', required: false, constraint: 'Dir to pin the root to. Defaults to the caller cwd.' },
248
- { kind: 'flag', name: 'name', type: 'string', required: false, constraint: 'Display name.' },
296
+ { kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Node to close. Defaults to the node occupying --pane (or your current pane).' },
297
+ { kind: 'flag', name: 'pane', type: 'string', required: false, constraint: 'tmux pane id whose node to close. Defaults to $TMUX_PANE / your current pane. The Alt+C menu passes this for you.' },
249
298
  ],
250
299
  output: [
251
- { name: 'node_id', type: 'string', required: true, constraint: 'The root node id.' },
252
- { name: 'session', type: 'string', required: true, constraint: 'The shared crtr tmux session this root\'s window was placed in.' },
253
- { name: 'window', type: 'string', required: false, constraint: 'The root node\'s window id.' },
300
+ { name: 'closed', type: 'boolean', required: true, constraint: 'True when the node (and its exclusive subtree) was closed.' },
301
+ { name: 'node_id', type: 'string', required: false, constraint: 'The node that was closed the cascade root.' },
302
+ { name: 'count', type: 'number', required: false, constraint: 'How many nodes were closed (root + cascaded descendants).' },
303
+ { name: 'closed_ids', type: 'string[]', required: false, constraint: 'All closed node ids, kill order (leaves first, root last).' },
304
+ { name: 'spared', type: 'string[]', required: false, constraint: 'Descendants left alive because a manager outside the subtree still subscribes to them.' },
254
305
  ],
255
306
  outputKind: 'object',
256
- effects: ['Opens a detached window in the shared crtr session and runs pi in it as a resident root node.'],
307
+ effects: [
308
+ 'Marks the node and its exclusive descendants `canceled` and clears intent (the daemon never revives a canceled node).',
309
+ 'Kills each closed node\'s tmux window; their pi sessions and canvas edges persist for a later revive.',
310
+ 'Appends a cancellation notice to each closed node\'s inbox, surfaced on its next resume.',
311
+ ],
257
312
  },
258
313
  run: async (input) => {
259
- const prompt = input['prompt'];
260
- const kind = input['kind'] ?? 'general';
261
- const cwd = input['cwd'] ?? process.cwd();
262
- const name = input['name'];
263
- const meta = bootRoot({ cwd, kind, name, prompt, placement: 'session' });
264
- return { node_id: meta.node_id, session: meta.tmux_session ?? '', window: meta.window ?? undefined };
314
+ const pane = input['pane'] ?? process.env['TMUX_PANE'];
315
+ let id = input['node'];
316
+ if (id === undefined || id === '')
317
+ id = nodeInPane(pane);
318
+ if (id === undefined || id === '') {
319
+ throw new InputError({ error: 'no_node', message: 'no node found in this pane to close', next: 'Pass --node <id>, or run from inside the agent\'s pane.' });
320
+ }
321
+ if (getNode(id) === null) {
322
+ throw new InputError({ error: 'not_found', message: `no node: ${id}`, next: 'List nodes with `crtr node inspect list`.' });
323
+ }
324
+ const res = closeNode(id);
325
+ return { closed: true, node_id: res.root, count: res.closed.length, closed_ids: res.closed, spared: res.spared };
265
326
  },
327
+ render: (r) => r['closed'] === true
328
+ ? `<closed id="${r['node_id']}" count="${r['count']}" spared="${r['spared']?.length ?? 0}"/>`
329
+ : `<close-failed/>`,
330
+ });
331
+ // ---------------------------------------------------------------------------
332
+ // node cycle — DFS-walk the canvas one window at a time (Alt+] / Alt+[)
333
+ // ---------------------------------------------------------------------------
334
+ /** Every live node in DFS pre-order across the whole forest. The spawn tree is
335
+ * the `parent` field; children inherit their parent's row order (created), so
336
+ * the walk descends into a node's children before moving to its siblings —
337
+ * exactly "next in pre-order is your first child". Roots are live nodes with no
338
+ * live parent (a done/dead parent orphans its live children up to the top).
339
+ * Cycle-safe: a final pass appends any node a cycle kept from being reached. */
340
+ function liveDfsOrder() {
341
+ const rows = listNodes({ status: ['active', 'idle'] }); // ORDER BY created
342
+ const liveIds = new Set(rows.map((r) => r.node_id));
343
+ const childrenOf = new Map();
344
+ for (const r of rows) {
345
+ const p = r.parent;
346
+ if (p != null && liveIds.has(p)) {
347
+ const arr = childrenOf.get(p) ?? [];
348
+ arr.push(r.node_id);
349
+ childrenOf.set(p, arr);
350
+ }
351
+ }
352
+ const out = [];
353
+ const seen = new Set();
354
+ const visit = (id) => {
355
+ if (seen.has(id))
356
+ return;
357
+ seen.add(id);
358
+ out.push(id);
359
+ for (const c of childrenOf.get(id) ?? [])
360
+ visit(c);
361
+ };
362
+ for (const r of rows)
363
+ if (r.parent == null || !liveIds.has(r.parent))
364
+ visit(r.node_id);
365
+ for (const r of rows)
366
+ visit(r.node_id); // stragglers (parent cycles)
367
+ return out;
368
+ }
369
+ const nodeCycle = defineLeaf({
370
+ name: 'cycle',
371
+ description: 'DFS-walk to the next/prev live node in place',
372
+ whenToUse: 'sweeping the canvas one window at a time, descending into children before siblings (bound to Alt+] forward / Alt+[ back). Use `node focus` instead to jump straight to a named node',
373
+ help: {
374
+ name: 'node cycle',
375
+ 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)',
376
+ params: [
377
+ { 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.' },
378
+ { 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.' },
379
+ ],
380
+ output: [
381
+ { name: 'focused', type: 'boolean', required: true, constraint: 'True when the neighbor was brought into view.' },
382
+ { name: 'node_id', type: 'string', required: false, constraint: 'The node now in front of you.' },
383
+ { name: 'name', type: 'string', required: false, constraint: 'Its display name.' },
384
+ { name: 'from', type: 'string', required: false, constraint: 'The node you cycled away from.' },
385
+ ],
386
+ outputKind: 'object',
387
+ 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.'],
388
+ },
389
+ run: async (input) => {
390
+ const pane = input['pane'] ?? process.env['TMUX_PANE'] ?? currentTmux()?.pane ?? undefined;
391
+ const dir = (input['dir'] ?? 'next');
392
+ const fromId = nodeInPane(pane);
393
+ if (fromId === undefined)
394
+ return { focused: false };
395
+ const order = liveDfsOrder();
396
+ const i = order.indexOf(fromId);
397
+ if (i === -1 || order.length < 2)
398
+ return { focused: false, node_id: fromId, from: fromId };
399
+ const step = dir === 'next' ? 1 : -1;
400
+ const targetId = order[(i + step + order.length) % order.length];
401
+ const target = getNode(targetId);
402
+ if (target === null)
403
+ return { focused: false, from: fromId };
404
+ // Placement retargets the caller pane's focus to the neighbor (§2.3),
405
+ // reviving it into the backstage first if its pane was released. callerNode
406
+ // is the node we cycled AWAY from — the current occupant of the caller pane.
407
+ const res = placementFocus(targetId, {
408
+ pane,
409
+ callerNode: fromId,
410
+ revive: (nid) => { reviveNode(nid, { resume: true }); },
411
+ });
412
+ return { focused: res.focused, node_id: targetId, name: target.name, from: fromId };
413
+ },
414
+ render: (r) => r['focused'] === true
415
+ ? `<cycled to="${r['node_id']}" name="${r['name'] ?? ''}" from="${r['from'] ?? ''}"/>`
416
+ : `<cycle-noop>no other live node to focus</cycle-noop>`,
266
417
  });
267
418
  // ---------------------------------------------------------------------------
268
419
  // node msg — direct-address any node at a wake tier (wakes a dormant target)
269
420
  // ---------------------------------------------------------------------------
270
421
  const nodeMsg = defineLeaf({
271
422
  name: 'msg',
423
+ description: 'direct-message any node at a wake tier',
424
+ whenToUse: 'you want to address a specific node directly — steer it mid-flight, hand it a correction, ping it, or pass it new information — and have it land regardless of subscriptions, reviving a dormant target. Set `--tier` by urgency: critical interrupts with a new turn, urgent steers mid-turn, normal is a follow-up, deferred waits for its next cycle. Use `node subscribe` instead to wire ongoing push delivery rather than send a one-off, and `push` to report UP your own spine',
272
425
  help: {
273
426
  name: 'node msg',
274
427
  summary: 'send a direct message to any node\'s inbox at a wake tier — a direct message wakes the node regardless of subscriptions (reviving it if dormant)',
@@ -310,26 +463,110 @@ const nodeMsg = defineLeaf({
310
463
  },
311
464
  });
312
465
  // ---------------------------------------------------------------------------
313
- // node promotebecome a resident orchestrator (terminal resident polymorph)
466
+ // node subscribe / unsubscribe wire the subscribes_to spine between any pair
467
+ // ---------------------------------------------------------------------------
468
+ /** Resolve the subscriber: explicit --subscriber wins, else the calling node. */
469
+ function resolveSubscriber(input) {
470
+ const sub = input['subscriber'] ?? process.env['CRTR_NODE_ID'];
471
+ if (sub === undefined || sub === '') {
472
+ 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>.' });
473
+ }
474
+ return sub;
475
+ }
476
+ const nodeSubscribe = defineLeaf({
477
+ name: 'subscribe',
478
+ description: 'wire a subscribes_to edge between any pair (active or --passive)',
479
+ whenToUse: 'you want to wire who-wakes-whom on the graph: make a node receive the pushes another node emits — yourself by default, or any node to any publisher via `--subscriber` (e.g. point a manager at a `--root` worker you spawned, or fan a reviewer to a second orchestrator). Active by default, so a push WAKES the subscriber; pass `--passive` to have pushes accumulate and auto-inject on its next message without waking it. You already auto-subscribe to any child you spawn, so reach for this for edges spawn did not create. Inverse is `node unsubscribe`',
480
+ help: {
481
+ name: 'node subscribe',
482
+ 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.',
483
+ params: [
484
+ { kind: 'positional', name: 'publisher', required: true, constraint: 'The node to subscribe TO — whose pushes get delivered to the subscriber.' },
485
+ { 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.' },
486
+ { 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.' },
487
+ ],
488
+ output: [
489
+ { name: 'subscribed', type: 'boolean', required: true, constraint: 'True when the edge was created/updated.' },
490
+ { name: 'subscriber', type: 'string', required: true, constraint: 'The receiving node.' },
491
+ { name: 'publisher', type: 'string', required: true, constraint: 'The node being subscribed to.' },
492
+ { name: 'mode', type: 'string', required: true, constraint: '"active" (wakes on push) or "passive" (accumulates, no wake).' },
493
+ ],
494
+ outputKind: 'object',
495
+ 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).'],
496
+ },
497
+ run: async (input) => {
498
+ const publisher = input['publisher'];
499
+ const subscriber = resolveSubscriber(input);
500
+ const passive = input['passive'] === true;
501
+ if (subscriber === publisher) {
502
+ throw new InputError({ error: 'self_subscribe', message: 'a node cannot subscribe to itself', next: 'Pick a different publisher.' });
503
+ }
504
+ if (getNode(subscriber) === null)
505
+ throw new InputError({ error: 'not_found', message: `no node: ${subscriber}`, field: 'subscriber', next: 'List nodes with `crtr node inspect list`.' });
506
+ if (getNode(publisher) === null)
507
+ throw new InputError({ error: 'not_found', message: `no node: ${publisher}`, field: 'publisher', next: 'List nodes with `crtr node inspect list`.' });
508
+ subscribe(subscriber, publisher, !passive);
509
+ return { subscribed: true, subscriber, publisher, mode: passive ? 'passive' : 'active' };
510
+ },
511
+ render: (r) => `<subscribed subscriber="${r['subscriber']}" publisher="${r['publisher']}" mode="${r['mode']}"/>`,
512
+ });
513
+ const nodeUnsubscribe = defineLeaf({
514
+ name: 'unsubscribe',
515
+ description: 'drop a subscribes_to edge',
516
+ whenToUse: 'you want to stop a node receiving another\'s pushes: detach yourself (default) or any node via `--subscriber` from a publisher — quiet a feed you no longer track, or cut a manager loose from a finished worker. Idempotent. The inverse of `node subscribe`',
517
+ help: {
518
+ name: 'node unsubscribe',
519
+ summary: 'drop a subscribes_to edge — the subscriber (you by default, or any node via --subscriber) stops receiving the publisher\'s pushes.',
520
+ params: [
521
+ { kind: 'positional', name: 'publisher', required: true, constraint: 'The node to stop subscribing to.' },
522
+ { kind: 'flag', name: 'subscriber', type: 'string', required: false, constraint: 'Who to detach. Defaults to the calling node (CRTR_NODE_ID).' },
523
+ ],
524
+ output: [
525
+ { name: 'unsubscribed', type: 'boolean', required: true, constraint: 'True when the edge was removed (idempotent — also true if none existed).' },
526
+ { name: 'subscriber', type: 'string', required: true, constraint: 'The detached node.' },
527
+ { name: 'publisher', type: 'string', required: true, constraint: 'The node it stopped subscribing to.' },
528
+ ],
529
+ outputKind: 'object',
530
+ effects: ['Deletes the subscribes_to edge from canvas.db.'],
531
+ },
532
+ run: async (input) => {
533
+ const publisher = input['publisher'];
534
+ const subscriber = resolveSubscriber(input);
535
+ unsubscribe(subscriber, publisher);
536
+ return { unsubscribed: true, subscriber, publisher };
537
+ },
538
+ render: (r) => `<unsubscribed subscriber="${r['subscriber']}" publisher="${r['publisher']}"/>`,
539
+ });
540
+ // ---------------------------------------------------------------------------
541
+ // node promote — become an orchestrator (the worker→orchestrator polymorph)
314
542
  // ---------------------------------------------------------------------------
315
543
  const nodePromote = defineLeaf({
316
544
  name: 'promote',
545
+ description: 'become an orchestrator of a chosen kind',
546
+ whenToUse: 'your task has outgrown a single context window — many phases to delegate and persist across refreshes — so become an orchestrator: a long-lived, roadmap-holding node that fans work out to children and survives context refreshes (`node yield`). Choose `--kind` to specialize (developer/review/spec/design/plan/explore/general). Pass `--resident` to ALSO make it interactable (stays dormant, woken by inbox/human, never forced to submit a final); without it you stay terminal/orchestrator — still reporting a final up the spine and reaping when done. Do NOT reach for this for work that fits one window, or merely because you spawned a child — a base worker that spawns a helper and ends with `push final` never needs to promote',
547
+ tier: 'important',
317
548
  help: {
318
549
  name: 'node promote',
319
- summary: 'promote yourself to a resident orchestrator — do this when your task outgrows one context window (many phases to delegate and persist across refreshes); not for work that fits one window, and not merely because you spawned a child',
550
+ summary: 'promote yourself to an orchestrator — do this when your task outgrows one context window (many phases to delegate and persist across refreshes); not for work that fits one window, and not merely because you spawned a child. Mode only — lifecycle stays as-is unless you pass --resident',
320
551
  params: [
321
552
  { kind: 'flag', name: 'kind', type: 'string', required: false, constraint: 'Specialize as this kind of orchestrator: developer (own feature delivery), review, spec, design, plan, explore, general. Defaults to your current kind. Promoting from a generic kind? CHOOSE a concrete one — it sets the orchestrator persona you revive into.' },
553
+ { kind: 'flag', name: 'resident', type: 'bool', required: false, constraint: 'ALSO flip lifecycle→resident: make the node interactable — it stays dormant, woken by inbox/human, and is never forced to submit a final. Omit to stay terminal/orchestrator (delegates + holds a roadmap, but still owes a final up the spine and reaps when done).' },
322
554
  { kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Node to promote. Defaults to the caller (CRTR_NODE_ID).' },
323
555
  ],
324
556
  output: [
325
557
  { name: 'node_id', type: 'string', required: true, constraint: 'The promoted node.' },
326
558
  { name: 'kind', type: 'string', required: true, constraint: 'The kind it now orchestrates as.' },
327
559
  { name: 'mode', type: 'string', required: true, constraint: 'Now "orchestrator".' },
560
+ { name: 'lifecycle', type: 'string', required: true, constraint: '"resident" if you passed --resident, else unchanged (typically "terminal").' },
328
561
  { name: 'roadmap_written', type: 'boolean', required: true, constraint: 'True if a roadmap scaffold was seeded by this call.' },
329
- { name: 'guidance', type: 'string', required: true, constraint: 'Instructions for your new roleread and act on them this turn.' },
562
+ { name: 'roadmap_path', type: 'string', required: true, constraint: 'Absolute path to your roadmap doc (context/roadmap.md) edit it to author your plan.' },
563
+ { name: 'goal_path', type: 'string', required: true, constraint: 'Absolute path to your goal doc (context/initial-prompt.md) — the mandate you were spawned with.' },
564
+ { name: 'memory_path', type: 'string', required: true, constraint: 'Absolute path to your NODE-LOCAL memory index (context/memory/MEMORY.md) — facts specific to this goal; dies with this node.' },
565
+ { name: 'user_memory_path', type: 'string', required: true, constraint: 'Absolute path to your USER-GLOBAL memory index (<crtrHome>/memory/MEMORY.md) — who the human is, how they like to work; loaded into every orchestrator everywhere.' },
566
+ { name: 'project_memory_path', type: 'string', required: true, constraint: 'Absolute path to your PROJECT memory index (<crtrHome>/projects/<key>/memory/MEMORY.md) — facts bound to this repo; loaded into every orchestrator working here.' },
330
567
  ],
331
568
  outputKind: 'object',
332
- effects: ['Flips lifecycle→resident, mode→orchestrator, kind→chosen; rewrites the launch spec to that kind\'s orchestrator persona; seeds context/roadmap.md scaffold if absent.'],
569
+ effects: ['Flips mode→orchestrator + kind→chosen (lifecycle unchanged unless --resident, which also flips lifecycle→resident); rewrites the launch spec to that kind\'s orchestrator persona; seeds context/roadmap.md scaffold + all three scoped memory stores (user-global, project, node-local) if absent.', 'Your new-role guidance is injected automatically at the turn boundary by the persona injector — the command no longer returns it.'],
333
570
  },
334
571
  run: async (input) => {
335
572
  const id = input['node'] ?? process.env['CRTR_NODE_ID'];
@@ -338,25 +575,90 @@ const nodePromote = defineLeaf({
338
575
  const kind = input['kind'];
339
576
  if (kind !== undefined)
340
577
  assertKind(kind);
341
- 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 };
578
+ const resident = input['resident'] === true;
579
+ const res = promote(id, { ...(kind !== undefined ? { kind } : {}), ...(resident ? { resident: true } : {}) });
580
+ return { node_id: res.meta.node_id, kind: res.meta.kind, mode: res.meta.mode, lifecycle: res.meta.lifecycle, roadmap_written: res.roadmapWritten, roadmap_path: res.roadmapPath, goal_path: res.goalPath, memory_path: res.memoryPath, user_memory_path: res.userMemoryPath, project_memory_path: res.projectMemoryPath };
343
581
  },
344
582
  });
345
583
  // ---------------------------------------------------------------------------
584
+ // node lifecycle — flip the lifecycle axis (terminal ↔ resident), independent
585
+ // of mode. The persona injector delivers the transition guidance.
586
+ // ---------------------------------------------------------------------------
587
+ const nodeLifecycle = defineLeaf({
588
+ name: 'lifecycle',
589
+ description: 'switch a node between terminal and resident',
590
+ whenToUse: 'you want to flip a node\'s LIFECYCLE independent of its mode: make a node RESIDENT so it becomes interactable — it stays dormant, wakes on inbox/human, and is never forced to submit a final; or make a node TERMINAL so it owes a final result up the spine and reaps when done. Orthogonal to `node promote`, which changes MODE (base↔orchestrator), not lifecycle. The new-state guidance is injected automatically at the next turn boundary. Pass `--detach` to ALSO send a still-running agent to the background crtr session, freeing your pane while it finishes — the human-driver demote (Alt+C → d demotes in place) and detach (Alt+C → D demotes + backgrounds)',
591
+ help: {
592
+ name: 'node lifecycle',
593
+ summary: 'set a node\'s lifecycle axis — terminal (owes a final up the spine, reaps when done) or resident (interactable, stays dormant, woken by inbox/human, never forced to submit). Orthogonal to mode; promotion does not touch it. `--detach` also relocates a live agent to the background crtr session',
594
+ params: [
595
+ { kind: 'positional', name: 'lifecycle', required: true, constraint: 'terminal | resident.' },
596
+ { kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Node to change. Defaults to the node in --pane, else the caller (CRTR_NODE_ID).' },
597
+ { kind: 'flag', name: 'pane', type: 'string', required: false, constraint: 'tmux pane id whose node to change, when --node is omitted. Defaults to $TMUX_PANE. The Alt+C menu passes this for you.' },
598
+ { kind: 'flag', name: 'detach', type: 'bool', required: false, constraint: 'After flipping lifecycle, send the still-running agent to the background crtr session (break its pane out of the foreground). The pi keeps generating and — now terminal — pushes a final up the spine when done. The human-driver "I am done foregrounding this" move (Alt+C → D).' },
599
+ ],
600
+ output: [
601
+ { name: 'node_id', type: 'string', required: true, constraint: 'The node.' },
602
+ { name: 'lifecycle', type: 'string', required: true, constraint: 'Its new lifecycle (terminal | resident).' },
603
+ { name: 'detached', type: 'boolean', required: false, constraint: 'True when --detach relocated the agent to the background crtr session.' },
604
+ ],
605
+ outputKind: 'object',
606
+ effects: ['Sets lifecycle on the node meta and rebuilds its launch spec so a future revive boots with the new lifecycle\'s prompt baked in.', 'The persona injector delivers the transition guidance at the next turn boundary (or on the node\'s next revive if it is dormant).', 'With --detach: relocates the agent\'s live pane to the background crtr session (break-pane) WITHOUT killing the pi — it keeps generating in the background.'],
607
+ },
608
+ run: async (input) => {
609
+ const value = input['lifecycle']?.trim().toLowerCase();
610
+ if (value !== 'terminal' && value !== 'resident') {
611
+ throw new InputError({ error: 'bad_lifecycle', message: `invalid lifecycle: ${value ?? ''}`, field: 'lifecycle', next: 'Pass `terminal` or `resident`.' });
612
+ }
613
+ // Resolve the node: explicit --node, else the node occupying --pane (the
614
+ // Alt+C menu passes #{pane_id}), else the caller (CRTR_NODE_ID).
615
+ const pane = input['pane'] ?? process.env['TMUX_PANE'];
616
+ let id = input['node'];
617
+ if (id === undefined || id === '')
618
+ id = nodeInPane(pane);
619
+ if (id === undefined || id === '')
620
+ id = process.env['CRTR_NODE_ID'];
621
+ if (id === undefined || id === '')
622
+ throw new InputError({ error: 'no_node', message: 'no node (set CRTR_NODE_ID, pass --node, or run from the agent\'s pane)', next: 'Run from inside a node, pass --node <id>, or --pane <pane>.' });
623
+ if (getNode(id) === null)
624
+ throw new InputError({ error: 'not_found', message: `no node: ${id}`, next: 'List nodes with `crtr node inspect list`.' });
625
+ // Rebuild the launch spec so a future revive comes back with the new
626
+ // lifecycle's prompt baked in (the live session is steered by the persona
627
+ // injector; this fixes the static prompt the daemon replays). Spine is fixed
628
+ // by parent-ness, so it carries through unchanged.
629
+ const target = getNode(id);
630
+ const { launch } = buildLaunchSpec(target.kind, target.mode, {
631
+ lifecycle: value,
632
+ hasManager: target.parent !== null,
633
+ });
634
+ const meta = updateNode(id, { lifecycle: value, launch });
635
+ // --detach: shove the still-running agent into the background crtr session,
636
+ // freeing the foreground pane. The pi is untouched (it keeps generating); now
637
+ // terminal, it pushes a final up the spine when it finishes.
638
+ let detached = false;
639
+ if (input['detach'] === true)
640
+ detached = detachToBackground(id, pane);
641
+ return { node_id: meta.node_id, lifecycle: meta.lifecycle, detached };
642
+ },
643
+ render: (r) => `<lifecycle node="${r['node_id']}" set="${r['lifecycle']}"${r['detached'] === true ? ' detached="true"' : ''}/>`,
644
+ });
645
+ // ---------------------------------------------------------------------------
346
646
  // node yield — refresh: discard context, revive fresh against the roadmap
347
647
  // ---------------------------------------------------------------------------
348
648
  const nodeYield = defineLeaf({
349
649
  name: 'yield',
650
+ description: 'refresh your context against your roadmap',
651
+ whenToUse: 'your context window is filling up but the mandate is unfinished: request a refresh — end your turn and revive fresh against your roadmap, leaving a note to your future self for the moment you wake. A base node auto-promotes to orchestrator first (a yield needs a roadmap to refresh against). Use `node promote` instead when you need to BECOME an orchestrator with no refresh pending',
350
652
  help: {
351
653
  name: 'node yield',
352
- summary: 'request a context refresh — you will be respawned fresh against your roadmap on your next stop (a terminal node auto-promotes to resident first)',
654
+ summary: 'request a context refresh — you will be respawned fresh against your roadmap on your next stop (a base node auto-promotes to orchestrator first)',
353
655
  params: [
354
- { kind: 'flag', name: 'kind', type: 'string', required: false, constraint: 'If this yield auto-promotes a terminal node, specialize it as this kind of orchestrator (developer, review, spec, design, plan, explore, general). Defaults to your current kind.' },
355
- { kind: 'stdin', name: 'message', required: false, constraint: 'A short note to your future self — what to do (if anything) the moment you wake fresh. Surfaced as <yield-message> in the next revive. Pass as a positional or pipe via heredoc.' },
656
+ { kind: 'flag', name: 'kind', type: 'string', required: false, constraint: 'If this yield auto-promotes a base node, specialize it as this kind of orchestrator (developer, review, spec, design, plan, explore, general). Defaults to your current kind.' },
657
+ { kind: 'stdin', name: 'message', required: true, constraint: 'A note to your future self — what to do the moment you wake fresh. Surfaced as <yield-message> in the next revive. Pass as a positional or pipe via heredoc.' },
356
658
  ],
357
659
  output: [
358
660
  { name: 'node_id', type: 'string', required: true, constraint: 'The yielding node.' },
359
- { name: 'promoted', type: 'boolean', required: true, constraint: 'True if this yield promoted a terminal node to resident.' },
661
+ { name: 'promoted', type: 'boolean', required: true, constraint: 'True if this yield promoted a base node to orchestrator.' },
360
662
  { name: 'will_refresh', type: 'boolean', required: true, constraint: 'Always true: end your turn and you will revive fresh against your roadmap.' },
361
663
  ],
362
664
  outputKind: 'object',
@@ -370,8 +672,10 @@ const nodeYield = defineLeaf({
370
672
  if (kind !== undefined)
371
673
  assertKind(kind);
372
674
  const message = typeof input['message'] === 'string' ? input['message'].trim() : '';
373
- if (message !== '')
374
- writeYieldMessage(id, message);
675
+ if (message === '') {
676
+ throw new InputError({ error: 'empty_message', message: 'a yield message is required (stdin or positional)', next: 'Pass a note to your future self as an argument or pipe it on stdin.' });
677
+ }
678
+ writeYieldMessage(id, message);
375
679
  const res = requestYield(id, kind !== undefined ? { kind } : {});
376
680
  return { node_id: res.meta.node_id, promoted: res.promoted, will_refresh: res.willRefresh };
377
681
  },
@@ -389,19 +693,9 @@ export function registerNode() {
389
693
  summary: 'spawn and navigate canvas nodes',
390
694
  model: 'Every agent is a node in one directed graph (the canvas); `subscribes_to` is the spine — spawn a child and you auto-subscribe (active) to it, so its finish wakes you.\n\n' +
391
695
  'WHEN TO DELEGATE: hand any self-contained unit of work to a child instead of doing it inline — it keeps your own context window (your scarce resource) free for steering. Spawn independent units in PARALLEL; a wake with idle workers is wasted. Serialize only true dependencies, and never let two live children edit the same files.\n\n' +
392
- '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
- '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
- children: [
395
- { name: 'new', desc: 'spawn a terminal worker as a background window', useWhen: 'delegating a self-contained unit of work' },
396
- { name: 'inspect', desc: 'read the graph (list nodes / show one)', useWhen: 'surveying the canvas or inspecting a node' },
397
- { name: 'focus', desc: 'bring a node window forefront', useWhen: 'jumping to a node to watch or steer it' },
398
- { name: 'demote', desc: 'detach the agent in your pane to the background', useWhen: 'parking the agent in front of you and getting your terminal back (Alt+C → d)' },
399
- { name: 'session', desc: 'open a fresh root in its own tmux session', useWhen: 'starting a new top-level session from inside a node' },
400
- { name: 'msg', desc: 'direct-message any node at a wake tier', useWhen: 'steering or pinging a specific node (wakes it)' },
401
- { name: 'promote', desc: 'become a resident orchestrator of a chosen kind', useWhen: 'your task is bigger than one context window and you must delegate + persist' },
402
- { name: 'yield', desc: 'refresh your context against your roadmap', useWhen: 'your context window is filling up' },
403
- ],
696
+ '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 the wake message ALREADY IS the coalesced digest (the watcher drains your inbox to wake you), so don\'t re-run `crtr feed read` to "open" it (it would read empty, the cursor already advanced); instead dereference the report paths in that digest that matter, don\'t act on a one-line label. (`crtr feed read` is for proactively polling before a wake, or inspecting a child\'s inbox via `--node`; `--all` re-reads history with full message bodies.) Integrate, then either delegate the next units or finish.\n\n' +
697
+ '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 an orchestrator (holds a roadmap, delegates phases); when context fills, `node yield` to refresh against that roadmap.',
404
698
  },
405
- children: [nodeNew, nodeInspect, nodeFocus, nodeDemote, nodeSession, nodeMsg, nodePromote, nodeYield],
699
+ children: [nodeNew, nodeInspect, nodeFocus, nodeCycle, nodeDemote, nodeClose, nodeMsg, nodeSubscribe, nodeUnsubscribe, nodePromote, nodeLifecycle, nodeYield],
406
700
  });
407
701
  }