@crouton-kit/crouter 0.3.18 → 0.3.20

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 (289) hide show
  1. package/dist/build-root.js +6 -0
  2. package/dist/builtin-memory/crouter-development/marketplaces.md +164 -0
  3. package/dist/builtin-memory/crouter-development/personas/base-prompt.md +58 -0
  4. package/dist/builtin-memory/crouter-development/personas/orchestrator-prompt.md +60 -0
  5. package/dist/builtin-memory/crouter-development/personas.md +117 -0
  6. package/dist/builtin-memory/crouter-development/plugins.md +163 -0
  7. package/dist/builtin-memory/design.md +60 -0
  8. package/dist/builtin-memory/development.md +116 -0
  9. package/dist/builtin-memory/planning.md +66 -0
  10. package/dist/builtin-memory/spec.md +99 -0
  11. package/dist/builtin-personas/design/PERSONA.md +1 -0
  12. package/dist/builtin-personas/design/orchestrator.md +2 -1
  13. package/dist/builtin-personas/developer/PERSONA.md +1 -0
  14. package/dist/builtin-personas/developer/orchestrator.md +2 -1
  15. package/dist/builtin-personas/explore/PERSONA.md +1 -0
  16. package/dist/builtin-personas/explore/orchestrator.md +4 -0
  17. package/dist/builtin-personas/general/PERSONA.md +1 -0
  18. package/dist/builtin-personas/general/orchestrator.md +4 -0
  19. package/dist/builtin-personas/lifecycle/resident.md +1 -1
  20. package/dist/builtin-personas/lifecycle/terminal.md +5 -2
  21. package/dist/builtin-personas/orchestration-kernel.md +15 -24
  22. package/dist/builtin-personas/plan/PERSONA.md +1 -0
  23. package/dist/builtin-personas/plan/orchestrator.md +2 -1
  24. package/dist/builtin-personas/plan/reviewers/architecture-fit/PERSONA.md +1 -0
  25. package/dist/builtin-personas/plan/reviewers/code-smells/PERSONA.md +1 -0
  26. package/dist/builtin-personas/plan/reviewers/pattern-consistency/PERSONA.md +1 -0
  27. package/dist/builtin-personas/plan/reviewers/requirements-coverage/PERSONA.md +1 -0
  28. package/dist/builtin-personas/plan/reviewers/security/PERSONA.md +1 -0
  29. package/dist/builtin-personas/review/PERSONA.md +1 -0
  30. package/dist/builtin-personas/review/orchestrator.md +4 -0
  31. package/dist/builtin-personas/runtime-base.md +6 -3
  32. package/dist/builtin-personas/spec/PERSONA.md +1 -0
  33. package/dist/builtin-personas/spec/orchestrator.md +2 -1
  34. package/dist/builtin-personas/waiting.md +8 -0
  35. package/dist/builtin-skills/skills/crouter-development/personas/orchestrator-prompt/SKILL.md +1 -1
  36. package/dist/builtin-skills/skills/crouter-development/plugins/SKILL.md +2 -2
  37. package/dist/builtin-views/_lib/states.mjs +161 -0
  38. package/dist/builtin-views/canvas/client.mjs +303 -0
  39. package/dist/builtin-views/canvas/view.mjs +576 -0
  40. package/dist/builtin-views/git-pr/client.mjs +440 -0
  41. package/dist/builtin-views/git-pr/view.mjs +675 -0
  42. package/dist/builtin-views/inbox/_lib/render.mjs +177 -0
  43. package/dist/builtin-views/inbox/sources/gmail.mjs +965 -0
  44. package/dist/builtin-views/inbox/sources/linkedin.mjs +427 -0
  45. package/dist/builtin-views/inbox/view.mjs +889 -0
  46. package/dist/builtin-views/linkedin/client.mjs +610 -0
  47. package/dist/builtin-views/linkedin/view.mjs +1171 -0
  48. package/dist/clients/attach/attach-cmd.d.ts +2 -0
  49. package/dist/clients/attach/attach-cmd.js +354 -0
  50. package/dist/clients/attach/chat-view.d.ts +77 -0
  51. package/dist/clients/attach/chat-view.js +450 -0
  52. package/dist/clients/attach/clipboard-image.d.ts +16 -0
  53. package/dist/clients/attach/clipboard-image.js +113 -0
  54. package/dist/clients/attach/config-load.d.ts +31 -0
  55. package/dist/clients/attach/config-load.js +113 -0
  56. package/dist/clients/attach/extension-dialogs.d.ts +29 -0
  57. package/dist/clients/attach/extension-dialogs.js +101 -0
  58. package/dist/clients/attach/input-controller.d.ts +54 -0
  59. package/dist/clients/attach/input-controller.js +204 -0
  60. package/dist/clients/attach/slash-commands.d.ts +36 -0
  61. package/dist/clients/attach/slash-commands.js +200 -0
  62. package/dist/clients/attach/view-socket.d.ts +48 -0
  63. package/dist/clients/attach/view-socket.js +126 -0
  64. package/dist/commands/attention.js +3 -3
  65. package/dist/commands/canvas-prune.js +1 -1
  66. package/dist/commands/daemon.js +3 -2
  67. package/dist/commands/human/prompts.js +1 -1
  68. package/dist/commands/human/queue.js +54 -8
  69. package/dist/commands/human/shared.d.ts +2 -0
  70. package/dist/commands/memory/find.d.ts +1 -0
  71. package/dist/commands/memory/find.js +180 -0
  72. package/dist/commands/memory/lint.d.ts +1 -0
  73. package/dist/commands/memory/lint.js +140 -0
  74. package/dist/commands/memory/list.d.ts +1 -0
  75. package/dist/commands/memory/list.js +79 -0
  76. package/dist/commands/memory/read.js +103 -0
  77. package/dist/commands/memory/shared.d.ts +30 -0
  78. package/dist/commands/memory/shared.js +122 -0
  79. package/dist/commands/memory/write.d.ts +1 -0
  80. package/dist/commands/memory/write.js +85 -0
  81. package/dist/commands/memory.d.ts +2 -0
  82. package/dist/commands/memory.js +27 -0
  83. package/dist/commands/node.d.ts +3 -2
  84. package/dist/commands/node.js +610 -61
  85. package/dist/commands/pkg/market-manage.js +1 -1
  86. package/dist/commands/push.js +6 -6
  87. package/dist/commands/revive.js +1 -1
  88. package/dist/commands/skill/author.js +1 -1
  89. package/dist/commands/skill/shared.d.ts +1 -8
  90. package/dist/commands/skill/shared.js +2 -55
  91. package/dist/commands/skill.js +9 -14
  92. package/dist/commands/sys/doctor.js +1 -1
  93. package/dist/commands/sys/update.js +1 -1
  94. package/dist/commands/view-cycle.d.ts +2 -0
  95. package/dist/commands/view-cycle.js +125 -0
  96. package/dist/commands/view-list.d.ts +2 -0
  97. package/dist/commands/view-list.js +66 -0
  98. package/dist/commands/view-new.d.ts +2 -0
  99. package/dist/commands/view-new.js +70 -0
  100. package/dist/commands/view-pick.d.ts +2 -0
  101. package/dist/commands/view-pick.js +119 -0
  102. package/dist/commands/view-run.d.ts +2 -0
  103. package/dist/commands/view-run.js +191 -0
  104. package/dist/commands/view.d.ts +2 -0
  105. package/dist/commands/view.js +29 -0
  106. package/dist/core/__tests__/broker-attach-limits.test.d.ts +1 -0
  107. package/dist/core/__tests__/broker-attach-limits.test.js +157 -0
  108. package/dist/core/__tests__/broker-attach-stream.test.d.ts +1 -0
  109. package/dist/core/__tests__/broker-attach-stream.test.js +125 -0
  110. package/dist/core/__tests__/broker-crash-teardown.test.d.ts +1 -0
  111. package/dist/core/__tests__/broker-crash-teardown.test.js +116 -0
  112. package/dist/core/__tests__/broker-dialogs.test.d.ts +1 -0
  113. package/dist/core/__tests__/broker-dialogs.test.js +126 -0
  114. package/dist/core/__tests__/broker-dormant-wake.test.d.ts +1 -0
  115. package/dist/core/__tests__/broker-dormant-wake.test.js +51 -0
  116. package/dist/core/__tests__/broker-lifecycle.test.d.ts +1 -0
  117. package/dist/core/__tests__/broker-lifecycle.test.js +87 -0
  118. package/dist/core/__tests__/broker-sdk-wiring.test.d.ts +1 -0
  119. package/dist/core/__tests__/broker-sdk-wiring.test.js +166 -0
  120. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.d.ts +1 -0
  121. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +102 -0
  122. package/dist/core/__tests__/canvas-inbox-watcher.test.js +2 -36
  123. package/dist/core/__tests__/cascade-close.test.js +12 -2
  124. package/dist/core/__tests__/child-death-wake.test.d.ts +1 -0
  125. package/dist/core/__tests__/child-death-wake.test.js +245 -0
  126. package/dist/core/__tests__/context-intro.test.js +76 -62
  127. package/dist/core/__tests__/daemon-boot.test.js +14 -5
  128. package/dist/core/__tests__/daemon-liveness.test.js +34 -9
  129. package/dist/core/__tests__/detach-focus.test.d.ts +1 -0
  130. package/dist/core/__tests__/detach-focus.test.js +206 -0
  131. package/dist/core/__tests__/draw-style.test.d.ts +1 -0
  132. package/dist/core/__tests__/draw-style.test.js +258 -0
  133. package/dist/core/__tests__/fixtures/c3-custom-provider-ext.d.ts +2 -0
  134. package/dist/core/__tests__/fixtures/c3-custom-provider-ext.js +18 -0
  135. package/dist/core/__tests__/fixtures/fake-engine.d.ts +138 -0
  136. package/dist/core/__tests__/fixtures/fake-engine.js +614 -0
  137. package/dist/core/__tests__/fixtures/fake-pi-host.js +1 -0
  138. package/dist/core/__tests__/flagship-lifecycle.test.js +7 -1
  139. package/dist/core/__tests__/frame-decoder-perf.test.d.ts +1 -0
  140. package/dist/core/__tests__/frame-decoder-perf.test.js +198 -0
  141. package/dist/core/__tests__/helpers/broker-clients.d.ts +43 -0
  142. package/dist/core/__tests__/helpers/broker-clients.js +178 -0
  143. package/dist/core/__tests__/helpers/harness.d.ts +9 -0
  144. package/dist/core/__tests__/helpers/harness.js +111 -1
  145. package/dist/core/__tests__/home-session.test.js +41 -8
  146. package/dist/core/__tests__/human-new-window-regression.test.d.ts +1 -0
  147. package/dist/core/__tests__/human-new-window-regression.test.js +101 -0
  148. package/dist/core/__tests__/kickoff.test.js +37 -3
  149. package/dist/core/__tests__/live-mutation-verbs.test.d.ts +1 -0
  150. package/dist/core/__tests__/live-mutation-verbs.test.js +175 -0
  151. package/dist/core/__tests__/live-mutation.test.js +6 -132
  152. package/dist/core/__tests__/memory.test.js +23 -115
  153. package/dist/core/__tests__/placement-focus.test.js +5 -0
  154. package/dist/core/__tests__/placement-teardown.test.js +54 -11
  155. package/dist/core/__tests__/relaunch.test.js +4 -3
  156. package/dist/core/__tests__/review-render-pane-regression.test.d.ts +1 -0
  157. package/dist/core/__tests__/review-render-pane-regression.test.js +133 -0
  158. package/dist/core/__tests__/spawn-root.test.js +10 -0
  159. package/dist/core/__tests__/spike-harness.test.js +1 -0
  160. package/dist/core/__tests__/wake-bearings.test.d.ts +1 -0
  161. package/dist/core/__tests__/wake-bearings.test.js +156 -0
  162. package/dist/core/__tests__/wake-origin.test.d.ts +1 -0
  163. package/dist/core/__tests__/wake-origin.test.js +110 -0
  164. package/dist/core/bootstrap.js +1 -1
  165. package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
  166. package/dist/core/canvas/browse/app.js +24 -2
  167. package/dist/core/canvas/browse/model.d.ts +38 -2
  168. package/dist/core/canvas/browse/model.js +134 -10
  169. package/dist/core/canvas/browse/render.d.ts +6 -12
  170. package/dist/core/canvas/browse/render.js +72 -104
  171. package/dist/core/canvas/canvas.js +16 -23
  172. package/dist/core/canvas/db.js +46 -0
  173. package/dist/core/canvas/index.d.ts +1 -0
  174. package/dist/core/canvas/index.js +1 -0
  175. package/dist/core/canvas/pid.d.ts +4 -0
  176. package/dist/core/canvas/pid.js +23 -0
  177. package/dist/core/canvas/render.d.ts +11 -2
  178. package/dist/core/canvas/render.js +69 -0
  179. package/dist/core/canvas/types.d.ts +91 -3
  180. package/dist/core/canvas/wakeups.d.ts +76 -0
  181. package/dist/core/canvas/wakeups.js +185 -0
  182. package/dist/core/config.js +4 -1
  183. package/dist/core/frontmatter.js +37 -124
  184. package/dist/core/memory-resolver.d.ts +49 -0
  185. package/dist/core/memory-resolver.js +141 -0
  186. package/dist/core/personas/loader.d.ts +7 -0
  187. package/dist/core/personas/loader.js +36 -5
  188. package/dist/core/personas/resolve.js +13 -3
  189. package/dist/core/predicate.d.ts +63 -0
  190. package/dist/core/predicate.js +189 -0
  191. package/dist/core/resolver.js +26 -5
  192. package/dist/core/runtime/bearings.d.ts +53 -12
  193. package/dist/core/runtime/bearings.js +132 -59
  194. package/dist/core/runtime/broker-cli.d.ts +1 -0
  195. package/dist/core/runtime/broker-cli.js +46 -0
  196. package/dist/core/runtime/broker-protocol.d.ts +332 -0
  197. package/dist/core/runtime/broker-protocol.js +153 -0
  198. package/dist/core/runtime/broker-sdk.d.ts +48 -0
  199. package/dist/core/runtime/broker-sdk.js +72 -0
  200. package/dist/core/runtime/broker.d.ts +55 -0
  201. package/dist/core/runtime/broker.js +1128 -0
  202. package/dist/core/runtime/close.js +35 -6
  203. package/dist/core/runtime/host.d.ts +53 -0
  204. package/dist/core/runtime/host.js +186 -0
  205. package/dist/core/runtime/kickoff.d.ts +2 -1
  206. package/dist/core/runtime/kickoff.js +91 -5
  207. package/dist/core/runtime/launch.d.ts +54 -4
  208. package/dist/core/runtime/launch.js +85 -5
  209. package/dist/core/runtime/lifecycle.js +23 -6
  210. package/dist/core/runtime/memory.d.ts +2 -42
  211. package/dist/core/runtime/memory.js +11 -162
  212. package/dist/core/runtime/nodes.d.ts +37 -0
  213. package/dist/core/runtime/nodes.js +60 -1
  214. package/dist/core/runtime/persona.js +21 -11
  215. package/dist/core/runtime/pi-vendored.d.ts +18 -0
  216. package/dist/core/runtime/pi-vendored.js +49 -0
  217. package/dist/core/runtime/placement.d.ts +32 -14
  218. package/dist/core/runtime/placement.js +206 -52
  219. package/dist/core/runtime/promote.d.ts +0 -6
  220. package/dist/core/runtime/promote.js +4 -12
  221. package/dist/core/runtime/{demote.d.ts → recycle.d.ts} +5 -5
  222. package/dist/core/runtime/{demote.js → recycle.js} +27 -11
  223. package/dist/core/runtime/reset.js +11 -8
  224. package/dist/core/runtime/revive.d.ts +2 -0
  225. package/dist/core/runtime/revive.js +34 -34
  226. package/dist/core/runtime/spawn.d.ts +23 -0
  227. package/dist/core/runtime/spawn.js +77 -23
  228. package/dist/core/runtime/stop-guard.d.ts +1 -1
  229. package/dist/core/runtime/stop-guard.js +6 -1
  230. package/dist/core/runtime/tmux-chrome.d.ts +1 -1
  231. package/dist/core/runtime/tmux-chrome.js +1 -1
  232. package/dist/core/runtime/tmux.d.ts +28 -0
  233. package/dist/core/runtime/tmux.js +80 -6
  234. package/dist/core/scope.d.ts +11 -0
  235. package/dist/core/scope.js +39 -0
  236. package/dist/core/spawn.d.ts +5 -0
  237. package/dist/core/spawn.js +20 -2
  238. package/dist/core/substrate/gate.d.ts +13 -0
  239. package/dist/core/substrate/gate.js +21 -0
  240. package/dist/core/substrate/index.d.ts +7 -0
  241. package/dist/core/substrate/index.js +18 -0
  242. package/dist/core/substrate/on-read.d.ts +14 -0
  243. package/dist/core/substrate/on-read.js +292 -0
  244. package/dist/core/substrate/render.d.ts +25 -0
  245. package/dist/core/substrate/render.js +256 -0
  246. package/dist/core/substrate/schema.d.ts +76 -0
  247. package/dist/core/substrate/schema.js +124 -0
  248. package/dist/core/substrate/session-cache.d.ts +30 -0
  249. package/dist/core/substrate/session-cache.js +77 -0
  250. package/dist/core/substrate/subject.d.ts +41 -0
  251. package/dist/core/substrate/subject.js +54 -0
  252. package/dist/core/tui/contract.d.ts +83 -0
  253. package/dist/core/tui/contract.js +8 -0
  254. package/dist/core/tui/draw.d.ts +96 -0
  255. package/dist/core/tui/draw.js +339 -0
  256. package/dist/core/tui/host.d.ts +29 -0
  257. package/dist/core/tui/host.js +379 -0
  258. package/dist/core/tui/loader.d.ts +16 -0
  259. package/dist/core/tui/loader.js +94 -0
  260. package/dist/core/{canvas/browse → tui}/terminal.js +7 -6
  261. package/dist/core/wake.d.ts +86 -0
  262. package/dist/core/wake.js +308 -0
  263. package/dist/daemon/crtrd.d.ts +29 -4
  264. package/dist/daemon/crtrd.js +662 -46
  265. package/dist/pi-extensions/__tests__/canvas-context-intro.test.d.ts +1 -0
  266. package/dist/pi-extensions/__tests__/canvas-context-intro.test.js +171 -0
  267. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +19 -12
  268. package/dist/pi-extensions/canvas-commands.d.ts +3 -0
  269. package/dist/pi-extensions/canvas-commands.js +10 -0
  270. package/dist/pi-extensions/canvas-context-intro.d.ts +17 -0
  271. package/dist/pi-extensions/canvas-context-intro.js +55 -15
  272. package/dist/pi-extensions/canvas-doc-substrate.d.ts +44 -0
  273. package/dist/pi-extensions/canvas-doc-substrate.js +112 -0
  274. package/dist/pi-extensions/canvas-nav.d.ts +3 -0
  275. package/dist/pi-extensions/canvas-nav.js +87 -23
  276. package/dist/pi-extensions/canvas-stophook.js +17 -8
  277. package/dist/pi-extensions/canvas-view.d.ts +21 -0
  278. package/dist/pi-extensions/canvas-view.js +75 -0
  279. package/dist/prompts/skill.js +19 -26
  280. package/dist/prompts/view.d.ts +7 -0
  281. package/dist/prompts/view.js +101 -0
  282. package/dist/types.d.ts +4 -0
  283. package/dist/types.js +1 -0
  284. package/package.json +9 -4
  285. package/dist/commands/skill/find.d.ts +0 -4
  286. package/dist/commands/skill/find.js +0 -257
  287. package/dist/commands/skill/read.js +0 -91
  288. /package/dist/commands/{skill → memory}/read.d.ts +0 -0
  289. /package/dist/core/{canvas/browse → tui}/terminal.d.ts +0 -0
@@ -6,18 +6,22 @@
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 { readConfig } from '../core/config.js';
9
10
  import { spawnChild } from '../core/runtime/spawn.js';
10
11
  import { promote, requestYield } from '../core/runtime/promote.js';
11
- import { writeYieldMessage } from '../core/runtime/kickoff.js';
12
+ import { writeYieldMessage, readGoal } from '../core/runtime/kickoff.js';
12
13
  import { reviveNode } from '../core/runtime/revive.js';
13
- import { demoteNode } from '../core/runtime/demote.js';
14
+ import { newNodeId } from '../core/runtime/nodes.js';
15
+ import { readRoadmap } from '../core/runtime/roadmap.js';
16
+ import { parseWhen, parseCadence, cadenceDisplay } from '../core/wake.js';
17
+ import { recycleNode } from '../core/runtime/recycle.js';
14
18
  import { detachToBackground, focus as placementFocus, windowAlive, windowOfPane, currentTmux } from '../core/runtime/placement.js';
15
19
  import { buildLaunchSpec } from '../core/runtime/launch.js';
16
20
  import { closeNode } from '../core/runtime/close.js';
17
21
  import { appendInbox } from '../core/feed/inbox.js';
18
22
  import { availableKinds, kindWhenToUse, subPersonasFor } from '../core/personas/index.js';
19
23
  import { stateBlock } from '../core/help.js';
20
- import { getNode, updateNode, listNodes, subscribe, unsubscribe, subscriptionsOf, subscribersOf, readContextTokens, } from '../core/canvas/index.js';
24
+ import { getNode, updateNode, listNodes, subscribe, unsubscribe, subscriptionsOf, subscribersOf, readContextTokens, view, armWake, listWakes, cancelWake, WakeArmError, } from '../core/canvas/index.js';
21
25
  // Past this much context, an ORCHESTRATOR that spawns a managed child is better
22
26
  // off yielding than holding its fat window open for the child's result: the
23
27
  // child's finish revives it fresh against its roadmap, so a clean window absorbs
@@ -100,13 +104,13 @@ function callerSubPersonasBlock() {
100
104
  const nodeNew = defineLeaf({
101
105
  name: 'new',
102
106
  description: 'spawn a node — a managed child (default), or an independent root with --root',
103
- 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',
107
+ 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. Fan independent units out as concurrent children. Most spawns are managed children whose finish wakes you; reach here too when a unit is itself too big for one window (boot it directly as its own sub-orchestrator rather than hope a base worker promotes itself) or when you are handing off an INDEPENDENT node you will neither manage nor be woken by, e.g. one a human will sit and drive',
104
108
  tier: 'important',
105
109
  help: {
106
110
  name: 'node new',
107
111
  summary: 'spawn a terminal worker onto the canvas as a background window — returns its node id',
108
112
  params: [
109
- { kind: 'stdin', name: 'prompt', required: true, constraint: 'First user message for the spawned node. Piped on stdin or passed as a positional.' },
113
+ { kind: 'stdin', name: 'prompt', required: true, constraint: 'First user message for the spawned node. Deliver it on stdin from a quoted heredoc (`<<\'EOF\'`) or a file (`< prompt.md`).' },
110
114
  { kind: 'flag', name: 'kind', type: 'string', required: false, default: 'general', constraint: 'Persona kind — match the work to the kind. The <kinds> list below names every installable kind and when to use each; the <sub-personas> block (when present) names the specialist sub-personas available to YOU, each spawnable by its full kind string (e.g. plan/reviewers/security).' },
111
115
  { 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.' },
112
116
  { kind: 'flag', name: 'cwd', type: 'path', required: false, constraint: 'Dir the node is pinned to. Defaults to the caller cwd.' },
@@ -114,6 +118,8 @@ const nodeNew = defineLeaf({
114
118
  { kind: 'flag', name: 'parent', type: 'string', required: false, constraint: 'Parent node id. Defaults to the calling node (CRTR_NODE_ID).' },
115
119
  { 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).' },
116
120
  { 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.' },
121
+ { kind: 'flag', name: 'headless', type: 'bool', required: false, constraint: 'Spawn the node on the HEADLESS broker host (no tmux pane) instead of a tmux window. Overrides the `headless` config default for this spawn; omit to use that default (which itself defaults to a tmux pane).' },
122
+ { kind: 'flag', name: 'model', type: 'enum', choices: ['ultra', 'strong', 'medium', 'light'], required: false, constraint: 'Override the model the node runs on, by capability TIER: ultra (frontier), strong (opus), medium (sonnet), light (haiku). Omit to use the kind\'s persona default (explore=light, every other kind=strong). The override is durable — it survives revives and polymorphs (promote/demote).' },
117
123
  ],
118
124
  output: [
119
125
  { name: 'node_id', type: 'string', required: true, constraint: 'The new node id.' },
@@ -143,7 +149,10 @@ const nodeNew = defineLeaf({
143
149
  const parent = input['parent'];
144
150
  const root = input['root'] === true;
145
151
  const forkFrom = input['forkFrom'];
146
- const res = spawnChild({ kind, mode, cwd, name, prompt, parent, root, forkFrom });
152
+ const model = input['model'];
153
+ // Host precedence: explicit --headless flag > config `headless` default > tmux.
154
+ const hostKind = input['headless'] === true ? 'broker' : readConfig('user').headless === true ? 'broker' : 'tmux';
155
+ const res = spawnChild({ kind, mode, cwd, name, prompt, parent, root, forkFrom, hostKind, model });
147
156
  return {
148
157
  node_id: res.node.node_id,
149
158
  name: res.node.name,
@@ -163,7 +172,7 @@ const nodeNew = defineLeaf({
163
172
  const nodeList = defineLeaf({
164
173
  name: 'list',
165
174
  description: 'list nodes on the canvas',
166
- 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',
175
+ whenToUse: 'you want a flat roster of the nodes on the canvas, optionally sliced by status: 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',
167
176
  help: {
168
177
  name: 'node inspect list',
169
178
  summary: 'list nodes on the canvas, optionally by status',
@@ -280,7 +289,11 @@ const nodeFocus = defineLeaf({
280
289
  },
281
290
  });
282
291
  // ---------------------------------------------------------------------------
283
- // node demotedetach the agent in your pane to the background session
292
+ // node recycleFINALIZE the agent in your pane + recycle it into a fresh root
293
+ // (push its last report as a `final` → mark it done, then boot a fresh resident
294
+ // `crtr` root in the SAME pane; see recycleNode in runtime/recycle.ts). NOT
295
+ // bound to any Alt+C key/menu — d/D there route to `node demote` (±--detach),
296
+ // the flip-to-terminal-IN-PLACE action that keeps the agent running.
284
297
  // ---------------------------------------------------------------------------
285
298
  /** First live node whose window id is `win` (each node owns one window). The
286
299
  * queryable row projection omits `window`, so resolve full meta per candidate. */
@@ -293,27 +306,28 @@ function nodeByWindow(win) {
293
306
  }
294
307
  /** The live node occupying a tmux pane (pane → window → node), or undefined.
295
308
  * Defaults to $TMUX_PANE / the caller's current pane when `pane` is omitted —
296
- * shared by `node demote` and `node cycle`, both of which act on "the agent in
297
- * front of you". Exported for the `canvas chord` / `canvas tmux-spread` leaves,
309
+ * shared by `node recycle` / `node demote` / `node lifecycle` / `node close` /
310
+ * `node cycle`, all of which act on "the agent in front of you". Exported for
311
+ * the `canvas chord` / `canvas tmux-spread` leaves,
298
312
  * which resolve the active pane's node the same way. */
299
313
  export function nodeInPane(pane) {
300
314
  const resolvePane = pane ?? process.env['TMUX_PANE'] ?? currentTmux()?.pane;
301
315
  const win = resolvePane !== undefined && resolvePane !== '' ? windowOfPane(resolvePane) : null;
302
316
  return win !== null ? nodeByWindow(win) : undefined;
303
317
  }
304
- const nodeDemote = defineLeaf({
305
- name: 'demote',
318
+ const nodeRecycle = defineLeaf({
319
+ name: 'recycle',
306
320
  description: 'finish the agent in your pane + recycle it into a fresh root',
307
- 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',
321
+ 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 demote` instead to put it on a finishing track IN PLACE (flip terminal, keep it running) without ending it now, `node close` 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',
308
322
  help: {
309
- name: 'node demote',
323
+ name: 'node recycle',
310
324
  summary: 'finish the agent in your current pane and recycle the pane — push its last message as a final report to everyone waiting on it, mark it done, then boot a fresh crtr root in the same pane',
311
325
  params: [
312
326
  { kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Node to finish. Defaults to the node occupying --pane (or your current pane).' },
313
327
  { 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.' },
314
328
  ],
315
329
  output: [
316
- { name: 'demoted', type: 'boolean', required: true, constraint: 'True when the pane was recycled into a fresh root.' },
330
+ { name: 'recycled', type: 'boolean', required: true, constraint: 'True when the pane was recycled into a fresh root.' },
317
331
  { name: 'node_id', type: 'string', required: false, constraint: 'The finished node.' },
318
332
  { name: 'finalized', type: 'boolean', required: false, constraint: 'True when a final report was pushed to its subscribers.' },
319
333
  { name: 'delivered', type: 'number', required: false, constraint: 'How many subscribers/managers received the final report.' },
@@ -335,12 +349,12 @@ const nodeDemote = defineLeaf({
335
349
  if (getNode(id) === null) {
336
350
  throw new InputError({ error: 'not_found', message: `no node: ${id}`, next: 'List nodes with `crtr node inspect list`.' });
337
351
  }
338
- const res = await demoteNode(id, pane);
339
- return { demoted: res.demoted, node_id: id, finalized: res.finalized, delivered: res.delivered.length, new_root: res.newRoot ?? undefined };
352
+ const res = await recycleNode(id, pane);
353
+ return { recycled: res.recycled, node_id: id, finalized: res.finalized, delivered: res.delivered.length, new_root: res.newRoot ?? undefined };
340
354
  },
341
- render: (r) => r['demoted'] === true
342
- ? `<demoted id="${r['node_id']}" finalized="${r['finalized']}" delivered="${r['delivered']}" new_root="${r['new_root'] ?? ''}"/>`
343
- : `<demote-failed id="${r['node_id'] ?? ''}">not in tmux, or no agent in this pane</demote-failed>`,
355
+ render: (r) => r['recycled'] === true
356
+ ? `<recycled id="${r['node_id']}" finalized="${r['finalized']}" delivered="${r['delivered']}" new_root="${r['new_root'] ?? ''}"/>`
357
+ : `<recycle-failed id="${r['node_id'] ?? ''}">not in tmux, or no agent in this pane</recycle-failed>`,
344
358
  });
345
359
  // ---------------------------------------------------------------------------
346
360
  // node close — close a node + cascade-cancel its exclusive subtree (Alt+C → x)
@@ -348,7 +362,7 @@ const nodeDemote = defineLeaf({
348
362
  const nodeClose = defineLeaf({
349
363
  name: 'close',
350
364
  description: 'close a node + cascade-cancel its exclusive subtree (revivable)',
351
- 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)',
365
+ 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 recycle` 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)',
352
366
  help: {
353
367
  name: 'node close',
354
368
  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`)',
@@ -481,7 +495,7 @@ const nodeCycle = defineLeaf({
481
495
  const nodeMsg = defineLeaf({
482
496
  name: 'msg',
483
497
  description: 'direct-message any node at a wake tier',
484
- 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',
498
+ 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. You set how urgently it lands, from an immediate interrupt to a note read on 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',
485
499
  help: {
486
500
  name: 'node msg',
487
501
  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)',
@@ -536,7 +550,7 @@ function resolveSubscriber(input) {
536
550
  const nodeSubscribe = defineLeaf({
537
551
  name: 'subscribe',
538
552
  description: 'wire a subscribes_to edge between any pair (active or --passive)',
539
- 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`',
553
+ 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 (e.g. point a manager at an independent worker you spawned, or fan a reviewer to a second orchestrator), choosing whether each push wakes the subscriber or just accumulates for its next turn. You already auto-subscribe to any child you spawn, so reach for this for edges spawn did not create. Inverse is `node unsubscribe`',
540
554
  help: {
541
555
  name: 'node subscribe',
542
556
  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.',
@@ -573,7 +587,7 @@ const nodeSubscribe = defineLeaf({
573
587
  const nodeUnsubscribe = defineLeaf({
574
588
  name: 'unsubscribe',
575
589
  description: 'drop a subscribes_to edge',
576
- 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`',
590
+ whenToUse: 'you want to stop a node receiving another\'s pushes: detach yourself, or any node, 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`',
577
591
  help: {
578
592
  name: 'node unsubscribe',
579
593
  summary: 'drop a subscribes_to edge — the subscriber (you by default, or any node via --subscriber) stops receiving the publisher\'s pushes.',
@@ -603,7 +617,7 @@ const nodeUnsubscribe = defineLeaf({
603
617
  const nodePromote = defineLeaf({
604
618
  name: 'promote',
605
619
  description: 'become an orchestrator of a chosen kind',
606
- 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',
620
+ 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`). Specialize it to the kind of work it now steers, and optionally make it interactable so it stays dormant between inbox/human pings instead of owing a final. 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',
607
621
  tier: 'important',
608
622
  help: {
609
623
  name: 'node promote',
@@ -621,13 +635,10 @@ const nodePromote = defineLeaf({
621
635
  { name: 'roadmap_written', type: 'boolean', required: true, constraint: 'True if a roadmap scaffold was seeded by this call.' },
622
636
  { name: 'roadmap_path', type: 'string', required: true, constraint: 'Absolute path to your roadmap doc (context/roadmap.md) — edit it to author your plan.' },
623
637
  { name: 'goal_path', type: 'string', required: true, constraint: 'Absolute path to your goal doc (context/initial-prompt.md) — the mandate you were spawned with.' },
624
- { 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.' },
625
- { 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.' },
626
- { 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.' },
627
638
  ],
628
639
  dynamicState: () => kindsStateBlock(),
629
640
  outputKind: 'object',
630
- 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.'],
641
+ 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 if absent.', 'Your new-role guidance is injected automatically at the turn boundary by the persona injector — the command no longer returns it.'],
631
642
  },
632
643
  run: async (input) => {
633
644
  const id = input['node'] ?? process.env['CRTR_NODE_ID'];
@@ -638,17 +649,95 @@ const nodePromote = defineLeaf({
638
649
  assertKind(kind);
639
650
  const resident = input['resident'] === true;
640
651
  const res = promote(id, { ...(kind !== undefined ? { kind } : {}), ...(resident ? { resident: true } : {}) });
641
- 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 };
652
+ 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 };
642
653
  },
643
654
  });
644
655
  // ---------------------------------------------------------------------------
656
+ // Shared lifecycle plumbing — the single implementation behind BOTH `node
657
+ // demote` (the friendly terminal-only verb that pairs with `node promote`) and
658
+ // `node lifecycle` (the low-level orthogonal flip, which also does resident).
659
+ // ---------------------------------------------------------------------------
660
+ /** Resolve the node a demote/lifecycle command acts on: explicit --node, else
661
+ * the node occupying --pane (the Alt+C menu passes #{pane_id}), else the caller
662
+ * (CRTR_NODE_ID). Throws a rendered error when none resolves or it is unknown. */
663
+ function resolveLifecycleNode(input, pane) {
664
+ let id = input['node'];
665
+ if (id === undefined || id === '')
666
+ id = nodeInPane(pane);
667
+ if (id === undefined || id === '')
668
+ id = process.env['CRTR_NODE_ID'];
669
+ if (id === undefined || id === '')
670
+ 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>.' });
671
+ if (getNode(id) === null)
672
+ throw new InputError({ error: 'not_found', message: `no node: ${id}`, next: 'List nodes with `crtr node inspect list`.' });
673
+ return id;
674
+ }
675
+ /** Set a node's lifecycle axis and, with `detach`, relocate its still-running
676
+ * pane to the background crtr session. Rebuilds the launch spec so a future
677
+ * revive comes back with the new lifecycle's prompt baked in (the live session
678
+ * is steered by the persona injector; this fixes the static prompt the daemon
679
+ * replays). Spine is fixed by parent-ness, so it carries through unchanged.
680
+ * The ONE implementation `node demote` (always terminal) and `node lifecycle`
681
+ * (the passed value) both call — no duplication. */
682
+ function setLifecycle(id, value, opts) {
683
+ const target = getNode(id);
684
+ const { launch } = buildLaunchSpec(target.kind, target.mode, {
685
+ lifecycle: value,
686
+ hasManager: target.parent !== null,
687
+ model: target.model_override ?? undefined,
688
+ });
689
+ const meta = updateNode(id, { lifecycle: value, launch });
690
+ // --detach: shove the still-running agent into the background crtr session,
691
+ // freeing the foreground pane. The pi is untouched (it keeps generating); now
692
+ // terminal, it pushes a final up the spine when it finishes.
693
+ let detached = false;
694
+ if (opts.detach === true)
695
+ detached = detachToBackground(id, opts.pane);
696
+ return { node_id: meta.node_id, lifecycle: meta.lifecycle, detached };
697
+ }
698
+ // ---------------------------------------------------------------------------
699
+ // node demote — flip a node to TERMINAL in place (the friendly half of the
700
+ // promote/demote pair; bound to Alt+C → d, and → D with --detach). It stays
701
+ // focused and running but now owes a final up the spine. A terminal-only skin
702
+ // over the shared setLifecycle plumbing; `node lifecycle` is the orthogonal
703
+ // low-level command (it also does resident). See vision F5.
704
+ // ---------------------------------------------------------------------------
705
+ const nodeDemote = defineLeaf({
706
+ name: 'demote',
707
+ description: 'demote a node to terminal in place; it stays focused and running but now owes a final; --detach also sends it to the backstage crtr session',
708
+ whenToUse: 'you are watching a resident/interactive node and want to put it on a finishing track WITHOUT disturbing it: flip it terminal IN PLACE — it keeps its pane and your focus, keeps running, but now owes a final report up the spine and reaps when done. The friendly counterpart to `node promote`. You can also let go entirely and send the still-running agent off-screen to finish in the background. Use `node recycle` instead to FINISH it now and reboot a fresh root in its pane, and `node lifecycle` for the orthogonal low-level flip (incl. terminal→resident)',
709
+ help: {
710
+ name: 'node demote',
711
+ summary: 'demote a node to terminal IN PLACE — it stays focused and running but now owes a final up the spine and reaps when done. Pairs with `node promote` (mode↑); `--detach` also relocates the still-running agent to the background crtr session',
712
+ params: [
713
+ { kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Node to demote. Defaults to the node in --pane, else the caller (CRTR_NODE_ID).' },
714
+ { kind: 'flag', name: 'pane', type: 'string', required: false, constraint: 'tmux pane id whose node to demote, when --node is omitted. Defaults to $TMUX_PANE. The Alt+C menu passes this for you.' },
715
+ { kind: 'flag', name: 'detach', type: 'bool', required: false, constraint: 'After flipping terminal, 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 Alt+C → D move.' },
716
+ ],
717
+ output: [
718
+ { name: 'node_id', type: 'string', required: true, constraint: 'The demoted node.' },
719
+ { name: 'lifecycle', type: 'string', required: true, constraint: 'Always "terminal" after a demote.' },
720
+ { name: 'detached', type: 'boolean', required: false, constraint: 'True when --detach relocated the agent to the background crtr session.' },
721
+ ],
722
+ outputKind: 'object',
723
+ effects: ['Flips the node\'s lifecycle→terminal and rebuilds its launch spec so a future revive boots terminal — it stays focused and running, now owing a final up the spine.', '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 off-screen.'],
724
+ },
725
+ run: async (input) => {
726
+ const pane = input['pane'] ?? process.env['TMUX_PANE'];
727
+ const id = resolveLifecycleNode(input, pane);
728
+ const res = setLifecycle(id, 'terminal', { pane, detach: input['detach'] === true });
729
+ return { node_id: res.node_id, lifecycle: res.lifecycle, detached: res.detached };
730
+ },
731
+ render: (r) => `<demoted node="${r['node_id']}" lifecycle="${r['lifecycle']}"${r['detached'] === true ? ' detached="true"' : ''}/>`,
732
+ });
733
+ // ---------------------------------------------------------------------------
645
734
  // node lifecycle — flip the lifecycle axis (terminal ↔ resident), independent
646
735
  // of mode. The persona injector delivers the transition guidance.
647
736
  // ---------------------------------------------------------------------------
648
737
  const nodeLifecycle = defineLeaf({
649
738
  name: 'lifecycle',
650
739
  description: 'switch a node between terminal and resident',
651
- 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)',
740
+ 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. You can also let go of a still-running agent and send it off-screen to finish in the background. For the human-driver flip-to-terminal pair reach for `node demote` (the friendly terminal-only skin over this)',
652
741
  help: {
653
742
  name: 'node lifecycle',
654
743
  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',
@@ -671,35 +760,10 @@ const nodeLifecycle = defineLeaf({
671
760
  if (value !== 'terminal' && value !== 'resident') {
672
761
  throw new InputError({ error: 'bad_lifecycle', message: `invalid lifecycle: ${value ?? ''}`, field: 'lifecycle', next: 'Pass `terminal` or `resident`.' });
673
762
  }
674
- // Resolve the node: explicit --node, else the node occupying --pane (the
675
- // Alt+C menu passes #{pane_id}), else the caller (CRTR_NODE_ID).
676
763
  const pane = input['pane'] ?? process.env['TMUX_PANE'];
677
- let id = input['node'];
678
- if (id === undefined || id === '')
679
- id = nodeInPane(pane);
680
- if (id === undefined || id === '')
681
- id = process.env['CRTR_NODE_ID'];
682
- if (id === undefined || id === '')
683
- 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>.' });
684
- if (getNode(id) === null)
685
- throw new InputError({ error: 'not_found', message: `no node: ${id}`, next: 'List nodes with `crtr node inspect list`.' });
686
- // Rebuild the launch spec so a future revive comes back with the new
687
- // lifecycle's prompt baked in (the live session is steered by the persona
688
- // injector; this fixes the static prompt the daemon replays). Spine is fixed
689
- // by parent-ness, so it carries through unchanged.
690
- const target = getNode(id);
691
- const { launch } = buildLaunchSpec(target.kind, target.mode, {
692
- lifecycle: value,
693
- hasManager: target.parent !== null,
694
- });
695
- const meta = updateNode(id, { lifecycle: value, launch });
696
- // --detach: shove the still-running agent into the background crtr session,
697
- // freeing the foreground pane. The pi is untouched (it keeps generating); now
698
- // terminal, it pushes a final up the spine when it finishes.
699
- let detached = false;
700
- if (input['detach'] === true)
701
- detached = detachToBackground(id, pane);
702
- return { node_id: meta.node_id, lifecycle: meta.lifecycle, detached };
764
+ const id = resolveLifecycleNode(input, pane);
765
+ const res = setLifecycle(id, value, { pane, detach: input['detach'] === true });
766
+ return { node_id: res.node_id, lifecycle: res.lifecycle, detached: res.detached };
703
767
  },
704
768
  render: (r) => `<lifecycle node="${r['node_id']}" set="${r['lifecycle']}"${r['detached'] === true ? ' detached="true"' : ''}/>`,
705
769
  });
@@ -741,13 +805,498 @@ const nodeYield = defineLeaf({
741
805
  return { node_id: res.meta.node_id, promoted: res.promoted, will_refresh: res.willRefresh };
742
806
  },
743
807
  });
808
+ // ---------------------------------------------------------------------------
809
+ // node wake — scheduled wakeups (arm / list / cancel). The agent-facing skin
810
+ // over the wakeups data-access layer (armWake/listWakes/cancelWake) + the time
811
+ // grammar (parseWhen/parseCadence). The surface ONLY arms a durable row and
812
+ // introspects/cancels — it never spawns pi, drives a transition, or fires a wake
813
+ // (those are the daemon's at fire time). T2's armWake carries only integrity
814
+ // backstops (empty body / recur-on-deadline / unknown kind); the target-
815
+ // resolvability, bare-recoverable-state, and per-owner cap checks live HERE
816
+ // (Min-6), so every armer must route through this surface.
817
+ // ---------------------------------------------------------------------------
818
+ /** Max pending wakes a single owner may hold (AC-N4). */
819
+ const WAKE_CAP = 100;
820
+ /** Default timeout body for a note-less `until`, so the deadline row is never
821
+ * empty (T2 rejects an empty body) and Maj-8's rendered timeout signal has text. */
822
+ const DEFAULT_DEADLINE_BODY = 'Deadline reached — no report arrived; reassess / chase / escalate.';
823
+ /** Resolve the calling node (the armer/owner). CRTR_NODE_ID is mandatory — a
824
+ * wake is owned by the node that arms it (owner_id). */
825
+ function armerId() {
826
+ const id = process.env['CRTR_NODE_ID'];
827
+ if (id === undefined || id === '') {
828
+ throw new InputError({ error: 'no_node', message: 'no node to arm a wake (CRTR_NODE_ID unset)', next: 'Run from inside a node.' });
829
+ }
830
+ return id;
831
+ }
832
+ /** Build ParseOpts, including `tz` only when actually provided. */
833
+ function parseOpts(now, tz) {
834
+ return tz !== undefined && tz.trim() !== '' ? { tz, now } : { now };
835
+ }
836
+ /** Map a T3 typed time-grammar error to the rendered AC-N3/N4 error block. */
837
+ function throwWakeError(e) {
838
+ const next = {
839
+ wake_in_past: 'Pick a future instant — a positive duration ("5m","2h") or an ISO time later than now.',
840
+ bad_when: 'Use a duration ("5m","1h30m"), a zoned ISO ("2026-06-07T09:00:00Z"), or a bare ISO ("2026-06-07T09:00").',
841
+ bad_cadence: 'Use a duration ("6h"), a 5-field cron ("0 9 * * *"), or an @alias ("@daily").',
842
+ unknown_zone: 'Pass --tz with an IANA zone name (e.g. "America/New_York").',
843
+ cadence_too_fast: 'Use a cadence of at least 60s (e.g. "1m","5m","1h").',
844
+ };
845
+ throw new InputError({ error: e.code, message: e.message, received: e.received, next: next[e.code] ?? 'Fix the time value and retry.' });
846
+ }
847
+ /** Run armWake, mapping its thrown integrity backstop (WakeArmError) to a
848
+ * rendered error block. The surface validates these cases up front, so a throw
849
+ * here is a backstop, not the primary path. */
850
+ function armOrThrow(spec) {
851
+ try {
852
+ return armWake(spec);
853
+ }
854
+ catch (e) {
855
+ if (e instanceof WakeArmError) {
856
+ const next = {
857
+ empty_note: 'Provide a real --note, or omit it for a bare wake.',
858
+ deadline_cannot_recur: 'Drop --every. For a recurring self-alarm use `crtr node wake at --every <cadence>`.',
859
+ bad_kind: 'This is a crtr bug — report it.',
860
+ };
861
+ throw new InputError({ error: e.code, message: e.message, next: next[e.code] ?? 'Fix the wake spec and retry.' });
862
+ }
863
+ throw e;
864
+ }
865
+ }
866
+ /** Reject an arm that would push this owner past the pending-wakes cap (AC-N4),
867
+ * counted via the {owner} listWakes variant. */
868
+ function assertUnderCap(ownerId) {
869
+ const pending = listWakes({ owner: ownerId }).length;
870
+ if (pending >= WAKE_CAP) {
871
+ throw new InputError({
872
+ error: 'cap_exceeded',
873
+ message: `you hold ${pending} pending wakes (cap ${WAKE_CAP}).`,
874
+ next: 'Reap stale wakes with `crtr node wake cancel <id>` (see `crtr node wake list`) before arming more.',
875
+ });
876
+ }
877
+ }
878
+ /** A bare wake resumes a fresh window with no memory beyond disk, so it needs
879
+ * durable state to wake INTO. Accept it only when the target has a goal
880
+ * (initial-prompt.md) or roadmap (roadmap.md) on disk — located via the
881
+ * codebase's own writeGoal/roadmap convention, not a new file (AC-N3). */
882
+ function hasRecoverableState(nodeId) {
883
+ const goal = readGoal(nodeId);
884
+ if (goal !== null && goal.trim() !== '')
885
+ return true;
886
+ const roadmap = readRoadmap(nodeId);
887
+ return roadmap !== null && roadmap.trim() !== '';
888
+ }
889
+ /** True when a stored recur is a fixed interval (vs a calendar cron). */
890
+ function isFixedInterval(recur) {
891
+ try {
892
+ return typeof JSON.parse(recur).every === 'string';
893
+ }
894
+ catch {
895
+ return false;
896
+ }
897
+ }
898
+ /** A human ETA hint like " (~5m)" from a fire-at ISO relative to now. '' if past. */
899
+ function etaHint(fireAt, now) {
900
+ const ms = new Date(fireAt).getTime() - now.getTime();
901
+ if (!Number.isFinite(ms) || ms <= 0)
902
+ return '';
903
+ const mins = Math.round(ms / 60_000);
904
+ if (mins < 60)
905
+ return ` (~${mins}m)`;
906
+ const hrs = Math.round(ms / 3_600_000);
907
+ if (hrs < 48)
908
+ return ` (~${hrs}h)`;
909
+ return ` (~${Math.round(ms / 86_400_000)}d)`;
910
+ }
911
+ /** Escape a value for a rendered XML attribute (list rows carry free-text notes). */
912
+ function xmlAttr(s) {
913
+ return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
914
+ }
915
+ // node wake at ---------------------------------------------------------------
916
+ const nodeWakeAt = defineLeaf({
917
+ name: 'at',
918
+ description: 'arm a self-alarm — wake yourself (or another node) at a future time',
919
+ whenToUse: 'you have a standing or recurring task on a CLOCK, or nothing to do until a known future time — run a job each morning, loop a health check on a cadence (--every), re-check an EXTERNAL poll (CI, a deploy, a rate-limit window) after a backoff, or hand your future self a timed reminder. Time-driven work ONLY: do NOT arm a timer to watch your own agents — you auto-subscribe to every child you spawn, so its finish already wakes you; setting a wake to "check if it is done yet" just burns a window the subscription would have woken anyway. The bare form is the time-triggered twin of `node yield`: a fresh window re-reading your roadmap, right for standing/recurring work; the noted form instead wakes you into your saved conversation with a pointer this moment needs. Use `node wake until` to put a single DEADLINE backstop on an inbox-wait (still event-first, not polling); `node wake spawn` to defer spawning a NEW node; `node yield` to refresh NOW rather than at a future T',
920
+ help: {
921
+ name: 'node wake at',
922
+ summary: 'arm a self-alarm: wake yourself (default) or another node (--node) at a future time. Bare ⇒ fresh window re-reading roadmap/disk; --note ⇒ saved conversation with the note as new context; --every ⇒ a declarative recurrence the runtime keeps firing even across your crash/finalize',
923
+ params: [
924
+ { kind: 'positional', name: 'when', required: false, constraint: 'Fire time — a duration ("5m","1h30m"), a zoned ISO ("2026-06-07T09:00:00Z"), or a bare ISO ("2026-06-07T09:00", host-local). Required UNLESS --every is given (the cadence then sets the first fire).' },
925
+ { kind: 'flag', name: 'note', type: 'string', required: false, constraint: 'A non-empty note delivered into the woken context. Present ⇒ NOTED wake (resume your saved conversation, note as new context). Absent ⇒ BARE wake (fresh window re-reading roadmap/disk). A bare wake on a node with no goal/roadmap on disk is rejected.' },
926
+ { kind: 'flag', name: 'every', type: 'string', required: false, constraint: 'Make it a declarative recurrence the runtime fires on schedule even if you crash/finalize (node-anchored revive-cron): a duration ("6h","30m") or a 5-field cron / @alias ("0 9 * * *","@daily"). A fixed-interval --every WITH <when> uses <when> as the first fire; a cron --every ignores <when>. Min cadence 60s.' },
927
+ { kind: 'flag', name: 'tz', type: 'string', required: false, constraint: 'IANA zone for a calendar --every or a bare-ISO <when> (default: host-local).' },
928
+ { kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Arm the wake for ANOTHER existing node (a parent waking a child / a timed message to it). Default: self (CRTR_NODE_ID).' },
929
+ ],
930
+ output: [
931
+ { name: 'id', type: 'string', required: true, constraint: 'The new wakeup id (cancel/list by it).' },
932
+ { name: 'kind', type: 'string', required: true, constraint: '"bare" or "noted".' },
933
+ { name: 'fires_at', type: 'string', required: true, constraint: 'Absolute UTC fire time (the first fire for a recurrence).' },
934
+ { name: 'recur', type: 'string', required: true, constraint: '"none", or the cadence (every 6h / cron `0 9 * * *` <zone>).' },
935
+ { name: 'target', type: 'string', required: true, constraint: '"self" or the --node id.' },
936
+ { name: 'guidance', type: 'string', required: true, constraint: 'What to do now — end your turn to go dormant; do not push final.' },
937
+ ],
938
+ outputKind: 'object',
939
+ effects: [
940
+ 'Inserts one wakeups row (kind bare/noted); nothing fires before fire_at.',
941
+ 'No pi spawn, no transition — arming is a pure durable side-effect. End your turn separately to go dormant.',
942
+ ],
943
+ },
944
+ run: async (input) => {
945
+ const ownerId = armerId();
946
+ const targetId = (input['node'] ?? '').trim() || ownerId;
947
+ if (getNode(targetId) === null) {
948
+ throw new InputError({ error: 'not_found', message: `no node: ${targetId}`, field: 'node', next: 'List nodes with `crtr node inspect list`.' });
949
+ }
950
+ const when = input['when']?.trim();
951
+ const every = input['every']?.trim();
952
+ const tz = input['tz'];
953
+ const noteRaw = input['note'];
954
+ const hasNote = noteRaw !== undefined;
955
+ if ((when === undefined || when === '') && (every === undefined || every === '')) {
956
+ throw new InputError({ error: 'bad_when', message: 'a <when> time is required (or --every for a recurrence).', next: 'Pass a duration ("5m"), an ISO time, or --every <cadence>.' });
957
+ }
958
+ if (hasNote && noteRaw.trim() === '') {
959
+ throw new InputError({ error: 'empty_note', message: '--note must be non-empty for a noted wake.', received: noteRaw, next: 'Provide a real note ("re-check CI #4821; deploy was pending"), or omit --note for a bare wake.' });
960
+ }
961
+ const kind = hasNote ? 'noted' : 'bare';
962
+ if (kind === 'bare' && !hasRecoverableState(targetId)) {
963
+ throw new InputError({ error: 'bare_no_recoverable_state', message: `node ${targetId} has no goal/roadmap on disk — a bare wake would resume amnesiac.`, next: 'Pass --note "<why this moment matters>" so the woken context carries a pointer, or arm the bare wake only once the node has a roadmap/goal.' });
964
+ }
965
+ const now = new Date();
966
+ let recur;
967
+ let fireAt;
968
+ if (every !== undefined && every !== '') {
969
+ const cad = parseCadence(every, parseOpts(now, tz));
970
+ if ('error' in cad)
971
+ throwWakeError(cad.error);
972
+ recur = cad.recur;
973
+ fireAt = cad.firstFireAt;
974
+ // BOTH <when> and a FIXED-interval --every: <when> overrides the first fire
975
+ // (Min-12). A cron --every ignores <when> by design.
976
+ if (when !== undefined && when !== '' && isFixedInterval(cad.recur)) {
977
+ const w = parseWhen(when, parseOpts(now, tz));
978
+ if ('error' in w)
979
+ throwWakeError(w.error);
980
+ fireAt = w.fireAt;
981
+ }
982
+ }
983
+ else {
984
+ const w = parseWhen(when, parseOpts(now, tz));
985
+ if ('error' in w)
986
+ throwWakeError(w.error);
987
+ fireAt = w.fireAt;
988
+ }
989
+ assertUnderCap(ownerId);
990
+ let payload = null;
991
+ if (kind === 'noted') {
992
+ const body = noteRaw;
993
+ payload = { body, label: body.split('\n')[0].slice(0, 120) };
994
+ }
995
+ const id = `wk-${newNodeId()}`;
996
+ armOrThrow({
997
+ wakeup_id: id,
998
+ node_id: targetId,
999
+ owner_id: ownerId,
1000
+ fire_at: fireAt,
1001
+ kind,
1002
+ ...(recur !== undefined ? { recur } : {}),
1003
+ payload,
1004
+ });
1005
+ const target = targetId === ownerId ? 'self' : targetId;
1006
+ const eta = etaHint(fireAt, now);
1007
+ const guidance = recur !== undefined
1008
+ ? `${kind === 'noted' ? 'Noted' : 'Bare'} recurrence armed (${cadenceDisplay(recur)}); first fire ${fireAt}${eta}. End your turn to go dormant; do not push final. The runtime keeps firing it even across your crash/finalize — cancel with \`crtr node wake cancel ${id}\`.`
1009
+ : kind === 'noted'
1010
+ ? `Noted wake armed. You wake into your saved conversation at ${fireAt}${eta} with your note. End your turn now to go dormant; do not push final.`
1011
+ : `Bare self-alarm armed. You wake in a fresh window at ${fireAt}${eta}. End your turn now to go dormant; do not push final. The wake re-reads your roadmap.`;
1012
+ return { id, kind, fires_at: fireAt, recur: cadenceDisplay(recur), target, guidance };
1013
+ },
1014
+ render: (r) => `<wake-armed id="${r['id']}" kind="${r['kind']}" fires-at="${r['fires_at']}" recur="${r['recur']}" target="${r['target']}">\n${r['guidance']}\n</wake-armed>`,
1015
+ });
1016
+ // node wake until ------------------------------------------------------------
1017
+ const nodeWakeUntil = defineLeaf({
1018
+ name: 'until',
1019
+ description: 'bind a deadline to your current inbox-wait (self only)',
1020
+ whenToUse: 'rarely — only to put a deadline on an inbox-wait whose event the runtime genuinely cannot guarantee to deliver: you are open to a message AND backstopping an UNPUSHABLE external (a human who may never reply, an outside system with no spine-push). NOT for delegates — a child finishing, crashing, or being closed already wakes you on its own, so a deadline to "chase" a delegate is exactly the belt-and-suspenders the runtime makes redundant. Use `node wake at` instead for an unconditional timed wake unrelated to an inbox event',
1021
+ help: {
1022
+ name: 'node wake until',
1023
+ summary: 'arm a deadline on your current dormancy: you wake on the first inbox message, or at <when> if none arrives — whichever fires first wins, the loser is canceled. Self only; ≤1 deadline per node (a new `until` replaces the prior); cancel-on-wake (any genuine revive drops it)',
1024
+ params: [
1025
+ { kind: 'positional', name: 'when', required: true, constraint: 'Deadline time — a duration ("30m"), a zoned ISO, or a bare ISO (host-local).' },
1026
+ { kind: 'flag', name: 'note', type: 'string', required: false, constraint: 'Your timeout playbook — what to do if you woke because the wait expired (delivered with a timeout marker so you can tell a timeout from a real report). Omit and a default timeout note is supplied.' },
1027
+ { kind: 'flag', name: 'every', type: 'string', required: false, constraint: 'NOT ALLOWED — a deadline cannot recur. Passing it is rejected (deadline_cannot_recur); use `crtr node wake at --every` for a recurring self-alarm.' },
1028
+ ],
1029
+ output: [
1030
+ { name: 'id', type: 'string', required: true, constraint: 'The new deadline wakeup id.' },
1031
+ { name: 'fires_at', type: 'string', required: true, constraint: 'Absolute UTC deadline.' },
1032
+ { name: 'target', type: 'string', required: true, constraint: 'Always "self".' },
1033
+ { name: 'guidance', type: 'string', required: true, constraint: 'What to do now — delegate / end your turn to go dormant.' },
1034
+ ],
1035
+ outputKind: 'object',
1036
+ effects: [
1037
+ "Upserts the node's single deadline wakeups row (replacing any prior).",
1038
+ 'No pi spawn, no transition — arm, then end your turn to go dormant. A genuine revive (an inbox event or any other wake) cancels it.',
1039
+ ],
1040
+ },
1041
+ run: async (input) => {
1042
+ const ownerId = armerId();
1043
+ const every = input['every']?.trim();
1044
+ if (every !== undefined && every !== '') {
1045
+ throw new InputError({ error: 'deadline_cannot_recur', message: 'a deadline cannot recur.', next: 'Drop --every. For a recurring self-alarm use `crtr node wake at --every <cadence>`.' });
1046
+ }
1047
+ const when = input['when'].trim();
1048
+ const noteRaw = input['note'];
1049
+ if (noteRaw !== undefined && noteRaw.trim() === '') {
1050
+ throw new InputError({ error: 'empty_note', message: '--note must be non-empty when given.', received: noteRaw, next: 'Provide a real timeout playbook, or omit --note for the default timeout note.' });
1051
+ }
1052
+ const now = new Date();
1053
+ const w = parseWhen(when, { now });
1054
+ if ('error' in w)
1055
+ throwWakeError(w.error);
1056
+ assertUnderCap(ownerId);
1057
+ const body = noteRaw !== undefined && noteRaw.trim() !== '' ? noteRaw : DEFAULT_DEADLINE_BODY;
1058
+ const label = body.split('\n')[0].slice(0, 120);
1059
+ const payload = { body, timeout: true, label };
1060
+ const id = `wk-${newNodeId()}`;
1061
+ armOrThrow({ wakeup_id: id, node_id: ownerId, owner_id: ownerId, fire_at: w.fireAt, kind: 'deadline', payload });
1062
+ const eta = etaHint(w.fireAt, now);
1063
+ const guidance = `Deadline armed. You wake on the first inbox message, or at ${w.fireAt}${eta} if none arrives. Now delegate / end your turn to go dormant — whichever fires first cancels the other.`;
1064
+ return { id, fires_at: w.fireAt, target: 'self', guidance };
1065
+ },
1066
+ render: (r) => `<deadline-armed id="${r['id']}" fires-at="${r['fires_at']}" target="${r['target']}">\n${r['guidance']}\n</deadline-armed>`,
1067
+ });
1068
+ // node wake spawn -----------------------------------------------------------
1069
+ const nodeWakeSpawn = defineLeaf({
1070
+ name: 'spawn',
1071
+ description: 'defer or recur the BIRTH of a new node',
1072
+ whenToUse: 'you want a node spawned LATER or on a repeating cadence rather than right now: a one-shot deferred birth at a future time (--at), or a spawn-cron that re-births a fresh node every interval/cron even across your crash/finalize (--every) — standing work like a nightly review, a periodic health check, a morning digest, or a timed reminder that acts. Use `node new` instead to spawn immediately, and `node wake at` to re-wake an EXISTING node on a timer rather than birth a new one',
1073
+ help: {
1074
+ name: 'node wake spawn',
1075
+ summary: 'defer or recur a node birth — arm a wake that spawns a fresh node at a future time (--at) or on a repeating cadence (--every), instead of `node new` spawning it now. Returns a wakeup id, not a node id; the full spawn recipe is stored on the wake and re-derived live at fire time',
1076
+ params: [
1077
+ { kind: 'stdin', name: 'prompt', required: true, constraint: 'First user message for the node to be born. Deliver it on stdin from a quoted heredoc (`<<\'EOF\'`) or a file (`< prompt.md`).' },
1078
+ { kind: 'flag', name: 'at', type: 'string', required: false, constraint: 'DEFER a one-shot birth at <when> — a duration ("5m"), a zoned ISO, or a bare ISO (host-local, or in --tz). Mutually exclusive with --every; exactly one of --at/--every is required.' },
1079
+ { kind: 'flag', name: 'every', type: 'string', required: false, constraint: 'SPAWN-CRON: re-birth a fresh node on this declarative cadence even after a prior instance reaped itself or crashed — a duration ("6h") or a 5-field cron / @alias ("0 9 * * *","@daily"). Canvas-anchored (survives your crash/finalize), reaped by your deliberate close or `node wake cancel`. Min cadence 60s. Mutually exclusive with --at.' },
1080
+ { kind: 'flag', name: 'tz', type: 'string', required: false, constraint: 'IANA zone for a calendar --every (or a bare-ISO --at); default host-local. Makes "every 9am" mean 9am there, DST-correct.' },
1081
+ { kind: 'flag', name: 'kind', type: 'string', required: false, default: 'general', constraint: 'Persona kind for the node to be born — match the work to the kind (the <kinds> list below names each).' },
1082
+ { kind: 'flag', name: 'mode', type: 'enum', choices: ['base', 'orchestrator'], required: false, default: 'base', constraint: 'Persona mode: base (finishes in one window) or orchestrator (boots with a seeded roadmap and fans its scope out).' },
1083
+ { kind: 'flag', name: 'cwd', type: 'path', required: false, constraint: 'Dir the born node is pinned to. Defaults to the caller cwd, resolved NOW — it must still exist at fire time or the spawn fails loud.' },
1084
+ { kind: 'flag', name: 'name', type: 'string', required: false, constraint: 'Display name for the born node (tmux window + resume picker). Defaults to the kind.' },
1085
+ { kind: 'flag', name: 'parent', type: 'string', required: false, constraint: 'Parent node id for the born node. Defaults to the calling node (CRTR_NODE_ID).' },
1086
+ { kind: 'flag', name: 'root', type: 'bool', required: false, constraint: 'Birth an INDEPENDENT root instead of a managed child: no parent on the spine, NO subscription back to you, resident lifecycle (records spawned_by=you for provenance).' },
1087
+ { kind: 'flag', name: 'fork-from', type: 'string', required: false, constraint: 'Fork the born node from an existing pi conversation instead of starting it fresh: a node id, an absolute session `.jsonl` path, or a partial pi session uuid. pi copies that history into a NEW session, then the prompt is the next message. One-shot at birth.' },
1088
+ ],
1089
+ output: [
1090
+ { name: 'id', type: 'string', required: true, constraint: 'The wakeup id (inspect/cancel by it via `node wake list`/`cancel`).' },
1091
+ { name: 'kind', type: 'string', required: true, constraint: 'The persona kind of the deferred node.' },
1092
+ { name: 'fires_at', type: 'string', required: true, constraint: 'Absolute UTC birth time (the first fire for a cron).' },
1093
+ { name: 'recur', type: 'string', required: true, constraint: '"none" (one-shot --at) or the cadence (every 6h / cron `0 9 * * *` <zone>).' },
1094
+ { name: 'guidance', type: 'string', required: true, constraint: 'What to do now — no node exists yet; pick up other work or end your turn.' },
1095
+ ],
1096
+ dynamicState: () => kindsStateBlock(),
1097
+ outputKind: 'object',
1098
+ effects: [
1099
+ 'Inserts one detached `spawn` wakeups row (node_id NULL, owner=you, parent/cwd resolved now); NO node and NO window exist until fire time.',
1100
+ 'At fire time the daemon spawns the node from the stored recipe, re-deriving the launch spec live (persona prose is never stale). Best-effort: if the cwd/parent is gone at fire it fails LOUD (an urgent push to you, or a daemon-log line if you are gone).',
1101
+ ],
1102
+ },
1103
+ run: async (input) => {
1104
+ const prompt = input['prompt'] ?? '';
1105
+ if (prompt.trim() === '') {
1106
+ throw new InputError({ error: 'empty_prompt', message: 'a prompt is required (stdin or positional)', next: 'Pipe a task on stdin or pass it as an argument.' });
1107
+ }
1108
+ const at = input['at']?.trim();
1109
+ const every = input['every']?.trim();
1110
+ const tz = input['tz'];
1111
+ const hasAt = at !== undefined && at !== '';
1112
+ const hasEvery = every !== undefined && every !== '';
1113
+ if (!hasAt && !hasEvery) {
1114
+ throw new InputError({ error: 'no_schedule', message: 'node wake spawn needs a schedule: --at <when> or --every <cadence>.', next: 'Pass --at for a one-shot deferred spawn, --every for a spawn-cron, or use `crtr node new` to spawn now.' });
1115
+ }
1116
+ if (hasAt && hasEvery) {
1117
+ throw new InputError({ error: 'at_and_every', message: '--at and --every are mutually exclusive.', next: 'Use --at for a one-shot deferred spawn, or --every for a spawn-cron.' });
1118
+ }
1119
+ const kind = input['kind'] ?? 'general';
1120
+ const mode = (input['mode'] ?? 'base');
1121
+ const cwd = input['cwd'] ?? process.cwd();
1122
+ const name = input['name'];
1123
+ const parent = input['parent'];
1124
+ const root = input['root'] === true;
1125
+ const forkFrom = input['forkFrom'];
1126
+ const ownerId = armerId();
1127
+ // The recipe's `parent` is the resolved armer — NON-NULL on EVERY payload,
1128
+ // INCLUDING --root: spawnChild throws at fire time on a null parent (the
1129
+ // daemon has no CRTR_NODE_ID); for a root it internally nulls the spine
1130
+ // parent while keeping `spawner` for provenance, so a non-null value is right.
1131
+ const recipeParent = parent ?? ownerId;
1132
+ const recipe = {
1133
+ kind,
1134
+ mode,
1135
+ cwd,
1136
+ prompt,
1137
+ parent: recipeParent,
1138
+ ...(name !== undefined ? { name } : {}),
1139
+ ...(root ? { root: true } : {}),
1140
+ ...(forkFrom !== undefined ? { forkFrom } : {}),
1141
+ };
1142
+ const now = new Date();
1143
+ let recur;
1144
+ let fireAt;
1145
+ if (hasEvery) {
1146
+ const cad = parseCadence(every, parseOpts(now, tz));
1147
+ if ('error' in cad)
1148
+ throwWakeError(cad.error);
1149
+ recur = cad.recur;
1150
+ fireAt = cad.firstFireAt;
1151
+ }
1152
+ else {
1153
+ const w = parseWhen(at, parseOpts(now, tz));
1154
+ if ('error' in w)
1155
+ throwWakeError(w.error);
1156
+ fireAt = w.fireAt;
1157
+ }
1158
+ assertUnderCap(ownerId);
1159
+ const id = `wk-${newNodeId()}`;
1160
+ armOrThrow({ wakeup_id: id, node_id: null, owner_id: ownerId, fire_at: fireAt, kind: 'spawn', ...(recur !== undefined ? { recur } : {}), payload: recipe });
1161
+ const eta = etaHint(fireAt, now);
1162
+ const guidance = recur !== undefined
1163
+ ? `Spawn-cron armed (${cadenceDisplay(recur)}): a fresh ${kind} node is born each fire, first at ${fireAt}${eta}. No node exists yet; the runtime keeps spawning even across your crash/finalize — inspect/cancel via \`crtr node wake list\` / \`crtr node wake cancel ${id}\`.`
1164
+ : `Deferred spawn armed: a fresh ${kind} node is born at ${fireAt}${eta}. No node exists yet — inspect/cancel via \`crtr node wake list\` / \`crtr node wake cancel ${id}\`. Pick up other work or end your turn.`;
1165
+ return { id, kind, fires_at: fireAt, recur: cadenceDisplay(recur), guidance };
1166
+ },
1167
+ render: (r) => `<spawn-deferred id="${r['id']}" kind="${r['kind']}" fires-at="${r['fires_at']}" recur="${r['recur']}">\n${r['guidance']}\n</spawn-deferred>`,
1168
+ });
1169
+ // node wake list -------------------------------------------------------------
1170
+ const nodeWakeList = defineLeaf({
1171
+ name: 'list',
1172
+ description: 'list pending wakes for a scope (default self)',
1173
+ whenToUse: 'before re-arming or finishing, to see what you already have armed and reap stale ones. Defaults to your own wakes, with scopes for another node, the whole canvas, or a node and its descendants',
1174
+ help: {
1175
+ name: 'node wake list',
1176
+ summary: 'list pending wakes (all kinds, incl. deferred spawns) for a scope — id, kind, next fire, cadence, target, owner, note. Fired one-shots are gone; a recurrence shows its NEXT fire, not past slots',
1177
+ params: [
1178
+ { kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'List the wakes anchored to this node. Mutually exclusive with --canvas/--subtree.' },
1179
+ { kind: 'flag', name: 'canvas', type: 'bool', required: false, constraint: 'List EVERY wake on the canvas. Mutually exclusive with --node/--subtree.' },
1180
+ { kind: 'flag', name: 'subtree', type: 'string', required: false, constraint: 'List the wakes anchored to this node AND its descendants (the subscription sub-DAG). Mutually exclusive with --node/--canvas.' },
1181
+ ],
1182
+ output: [
1183
+ { name: 'scope', type: 'string', required: true, constraint: 'The scope listed (self / <id> / canvas / subtree:<id>).' },
1184
+ { name: 'wakes', type: 'object[]', required: true, constraint: 'Rows: {id, kind, next, recur, target, owner, note}.' },
1185
+ ],
1186
+ outputKind: 'object',
1187
+ effects: ['Read-only: queries the wakeups table.'],
1188
+ },
1189
+ run: async (input) => {
1190
+ const nodeFlag = input['node']?.trim();
1191
+ const canvas = input['canvas'] === true;
1192
+ const subtree = input['subtree']?.trim();
1193
+ const chosen = [nodeFlag !== undefined && nodeFlag !== '', canvas, subtree !== undefined && subtree !== ''].filter(Boolean).length;
1194
+ if (chosen > 1) {
1195
+ throw new InputError({ error: 'bad_scope', message: 'choose at most one of --node, --canvas, --subtree.', next: 'Pass a single scope flag, or none for your own wakes.' });
1196
+ }
1197
+ const viewer = process.env['CRTR_NODE_ID'];
1198
+ let scope;
1199
+ let scopeLabel;
1200
+ if (canvas) {
1201
+ scope = { canvas: true };
1202
+ scopeLabel = 'canvas';
1203
+ }
1204
+ else if (subtree !== undefined && subtree !== '') {
1205
+ if (getNode(subtree) === null)
1206
+ throw new InputError({ error: 'not_found', message: `no node: ${subtree}`, field: 'subtree', next: 'List nodes with `crtr node inspect list`.' });
1207
+ scope = { subtree: [subtree, ...view(subtree)] };
1208
+ scopeLabel = `subtree:${subtree}`;
1209
+ }
1210
+ else if (nodeFlag !== undefined && nodeFlag !== '') {
1211
+ scope = { node: nodeFlag };
1212
+ scopeLabel = viewer !== undefined && nodeFlag === viewer ? 'self' : nodeFlag;
1213
+ }
1214
+ else {
1215
+ // Default "self" scope is OWNER-based, not node-anchored: it is "what YOU
1216
+ // armed" (§3.5 whenToUse), so the detached spawn wakes you own via
1217
+ // `node new --at/--every` (node_id NULL, owner_id self) DO surface here
1218
+ // with their `spawn:<kind>@<cwd>` target — §3.5/§3.7 require that. (Use
1219
+ // --node <id> for the node-ANCHORED view of a target's wakes.)
1220
+ scope = { owner: armerId() };
1221
+ scopeLabel = 'self';
1222
+ }
1223
+ const rel = (id) => {
1224
+ if (id === null)
1225
+ return '';
1226
+ return viewer !== undefined && viewer !== '' && id === viewer ? 'self' : id;
1227
+ };
1228
+ const wakes = listWakes(scope).map((w) => {
1229
+ let target;
1230
+ if (w.node_id !== null) {
1231
+ target = rel(w.node_id);
1232
+ }
1233
+ else if (w.kind === 'spawn' && w.payload !== null) {
1234
+ const recipe = w.payload;
1235
+ target = `spawn:${recipe.kind}@${recipe.cwd}`;
1236
+ }
1237
+ else {
1238
+ target = 'detached';
1239
+ }
1240
+ const note = (w.kind === 'noted' || w.kind === 'deadline') && w.payload !== null
1241
+ ? (w.payload.label ?? '')
1242
+ : '';
1243
+ return { id: w.wakeup_id, kind: w.kind, next: w.fire_at, recur: cadenceDisplay(w.recur), target, owner: rel(w.owner_id), note };
1244
+ });
1245
+ return { scope: scopeLabel, wakes };
1246
+ },
1247
+ render: (r) => {
1248
+ const wakes = r['wakes'];
1249
+ const rows = wakes.map((w) => ` <wake id="${w['id']}" kind="${w['kind']}" next="${w['next']}" recur="${xmlAttr(String(w['recur']))}" target="${xmlAttr(String(w['target']))}" owner="${xmlAttr(String(w['owner']))}" note="${xmlAttr(String(w['note']))}"/>`);
1250
+ const body = rows.length > 0 ? '\n' + rows.join('\n') + '\n' : '\n';
1251
+ return (`<wakes scope="${xmlAttr(String(r['scope']))}" count="${wakes.length}">${body}</wakes>\n` +
1252
+ '<follow_up>Cancel one with `crtr node wake cancel <id>`. Fired one-shots are gone; a recurrence shows its NEXT fire, not past slots.</follow_up>');
1253
+ },
1254
+ });
1255
+ // node wake cancel -----------------------------------------------------------
1256
+ const nodeWakeCancel = defineLeaf({
1257
+ name: 'cancel',
1258
+ description: 'cancel a pending wake by id (idempotent)',
1259
+ whenToUse: 'a wait you no longer need — a poll whose goal is met, a deferred spawn you reconsidered, a deadline you are replacing. Idempotent: canceling an already-fired or already-canceled id is a no-op. Closing/reaping a node already reaps its own wakes and the detached ones it armed; reach for this to drop a single wake, or to reap a detached spawn/cron a finished or crashed node left running',
1260
+ help: {
1261
+ name: 'node wake cancel',
1262
+ summary: 'cancel a pending wake by id — it never fires and leaves the list. Idempotent (canceling an already-gone id is a no-op, not an error)',
1263
+ params: [
1264
+ { kind: 'positional', name: 'wakeup-id', required: true, constraint: 'The wakeup id to cancel (from `node wake list`).' },
1265
+ ],
1266
+ output: [
1267
+ { name: 'id', type: 'string', required: true, constraint: 'The canceled wakeup id.' },
1268
+ { name: 'was_pending', type: 'boolean', required: true, constraint: 'True when a pending row was removed; false when it was already gone.' },
1269
+ ],
1270
+ outputKind: 'object',
1271
+ effects: ['Deletes the wakeup row (idempotent — no error if it was already gone).'],
1272
+ },
1273
+ run: async (input) => {
1274
+ const id = input['wakeupId'].trim();
1275
+ const wasPending = listWakes({ canvas: true }).some((w) => w.wakeup_id === id);
1276
+ cancelWake(id);
1277
+ return { id, was_pending: wasPending };
1278
+ },
1279
+ render: (r) => `<wake-canceled id="${r['id']}" was-pending="${r['was_pending']}"/>`,
1280
+ });
1281
+ // node wake (branch) ---------------------------------------------------------
1282
+ const nodeWake = defineBranch({
1283
+ name: 'wake',
1284
+ description: 'arm/list/cancel scheduled wakeups — the second trigger that stirs a dormant node: time',
1285
+ whenToUse: 'you want to schedule work on a CLOCK — a long-horizon or recurring task that fires at a future time or loops on a cadence: a standing job, a recurring cron, a deferred or repeating node birth, or a poll against EXTERNAL state (CI, a deploy, a clock) after a backoff. This is the TIME trigger, NOT a way to watch your own agents — to monitor a child you arm NOTHING: you auto-subscribe on spawn, so its finish, crash, or close wakes you on its own; a deadline to chase a delegate is the belt-and-suspenders the runtime makes redundant. `at` arms a self-alarm (the timed twin of `node yield`), `until` bounds an inbox-wait with a deadline, `spawn` defers or repeats the BIRTH of a new node, `list`/`cancel` inspect and reap',
1286
+ help: {
1287
+ name: 'node wake',
1288
+ summary: 'the pending-wakeups namespace — arm a time trigger on the dormant state, then inspect/cancel it',
1289
+ model: 'Time is the second trigger that stirs a dormant node — the first is an inbox message, and a scheduled wake is just a future delivery on that same channel. At the moment you set, the runtime brings you (or, for a deferred birth, a fresh node) back through the ordinary revive path: no new window, nothing the focus model learns. Reach for it to schedule CLOCK-driven work — a standing job, a recurring loop (--every), a deferred birth, or a poll against EXTERNAL state after a backoff. It is NOT how you watch your own agents: you auto-subscribe to every child you spawn, so its finish already wakes you — arm nothing and stay dormant rather than set a timer to "check if it is done" — a child finishing, crashing, or being closed all wake you on their own. (`until` is a narrow exception: a deadline backstop on an inbox-wait the runtime CANNOT guarantee to deliver — an unpushable external, never a delegate, whose outcome already wakes you.) Use this to wait CHEAPLY — end your turn, go dormant (free: no window, no compute), and be woken only when it is worth acting again. `at` arms a self-alarm; `until` bounds an inbox-wait with a deadline; `spawn` defers or repeats the BIRTH of a new node (the full recipe stored on the wake); `list`/`cancel` inspect and reap. Arming is a pure side-effect: it writes a durable row, it does NOT end your turn — end your turn separately to go dormant.',
1290
+ },
1291
+ children: [nodeWakeAt, nodeWakeUntil, nodeWakeSpawn, nodeWakeList, nodeWakeCancel],
1292
+ });
744
1293
  export function registerNode() {
745
1294
  return defineBranch({
746
1295
  name: 'node',
747
1296
  rootEntry: {
748
1297
  concept: 'a unit of the agent runtime — an agent on the canvas with its own context dir and pi vehicle. Spawning nodes is how work gets done here, not an optimization you reach for late',
749
1298
  desc: 'spawn, inspect, and navigate nodes on the canvas',
750
- useWhen: 'delegating work, or walking the subscription graph. CRITICAL, and your default reflex — spawning a child is the first move on almost any task, not a fallback. Your own context window is the scarce resource: before you read files into it to understand, implement, debug, or change code you do not already know, hand that to a fresh node and get back just the conclusion (`crtr node new "<task>" --kind <explore|spec|design|plan|developer|review>`). Keep inline only a fact you already know. Fan out independent units as CONCURRENT children — a wake with idle workers is wasted; serialize only true dependencies, and never let two live children edit the same files. Once you delegate a unit, do not also run it yourself: you auto-subscribe on spawn, so its finish wakes you. Spawn + collect mechanics: `crtr node -h`.',
1299
+ useWhen: 'delegating work, or walking the subscription graph. CRITICAL, and your default reflex — spawning a child is the first move on almost any task, not a fallback. Your own context window is the scarce resource: before you read files into it to understand, implement, debug, or change code you do not already know, hand that to a fresh node and get back just the conclusion, matching the node kind to the work. Keep inline only a fact you already know. Fan out independent units as CONCURRENT children — a wake with idle workers is wasted; serialize only true dependencies, and never let two live children edit the same files. Once you delegate a unit, do not also run it yourself: you auto-subscribe on spawn, so its finish wakes you. Spawn + collect mechanics: `crtr node -h`.',
751
1300
  },
752
1301
  help: {
753
1302
  name: 'node',
@@ -757,6 +1306,6 @@ export function registerNode() {
757
1306
  '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' +
758
1307
  '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.',
759
1308
  },
760
- children: [nodeNew, nodeInspect, nodeFocus, nodeCycle, nodeDemote, nodeClose, nodeMsg, nodeSubscribe, nodeUnsubscribe, nodePromote, nodeLifecycle, nodeYield],
1309
+ children: [nodeNew, nodeInspect, nodeFocus, nodeCycle, nodeRecycle, nodeClose, nodeMsg, nodeSubscribe, nodeUnsubscribe, nodePromote, nodeDemote, nodeLifecycle, nodeYield, nodeWake],
761
1310
  });
762
1311
  }