@crouton-kit/crouter 0.3.14 → 0.3.16

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 (224) hide show
  1. package/dist/build-root.d.ts +8 -0
  2. package/dist/build-root.js +30 -0
  3. package/dist/builtin-personas/design/base.md +3 -7
  4. package/dist/builtin-personas/design/orchestrator.md +4 -3
  5. package/dist/builtin-personas/developer/base.md +3 -7
  6. package/dist/builtin-personas/developer/orchestrator.md +5 -4
  7. package/dist/builtin-personas/explore/base.md +3 -7
  8. package/dist/builtin-personas/explore/orchestrator.md +1 -5
  9. package/dist/builtin-personas/general/base.md +2 -4
  10. package/dist/builtin-personas/general/orchestrator.md +2 -4
  11. package/dist/builtin-personas/lifecycle/resident.md +2 -0
  12. package/dist/builtin-personas/lifecycle/terminal.md +6 -0
  13. package/dist/builtin-personas/orchestration-kernel.md +42 -3
  14. package/dist/builtin-personas/plan/base.md +3 -5
  15. package/dist/builtin-personas/plan/orchestrator.md +5 -4
  16. package/dist/builtin-personas/plan/reviewers/architecture-fit/base.md +9 -0
  17. package/dist/builtin-personas/plan/reviewers/code-smells/base.md +9 -0
  18. package/dist/builtin-personas/plan/reviewers/pattern-consistency/base.md +9 -0
  19. package/dist/builtin-personas/plan/reviewers/requirements-coverage/base.md +9 -0
  20. package/dist/builtin-personas/plan/reviewers/security/base.md +9 -0
  21. package/dist/builtin-personas/review/base.md +3 -5
  22. package/dist/builtin-personas/review/orchestrator.md +2 -6
  23. package/dist/builtin-personas/runtime-base.md +3 -19
  24. package/dist/builtin-personas/spec/base.md +3 -5
  25. package/dist/builtin-personas/spec/orchestrator.md +4 -3
  26. package/dist/builtin-personas/spine/has-manager.md +10 -0
  27. package/dist/builtin-personas/spine/no-manager.md +2 -0
  28. package/dist/builtin-skills/skills/crouter-development/personas/SKILL.md +96 -0
  29. package/dist/builtin-skills/skills/crouter-development/personas/base-prompt/SKILL.md +49 -0
  30. package/dist/builtin-skills/skills/crouter-development/personas/orchestrator-prompt/SKILL.md +49 -0
  31. package/dist/builtin-skills/skills/planning/SKILL.md +1 -1
  32. package/dist/builtin-skills/skills/spec/SKILL.md +2 -2
  33. package/dist/cli.js +6 -29
  34. package/dist/commands/attention.js +76 -7
  35. package/dist/commands/canvas-prune.d.ts +2 -0
  36. package/dist/commands/canvas-prune.js +66 -0
  37. package/dist/commands/canvas.js +5 -8
  38. package/dist/commands/chord.d.ts +2 -0
  39. package/dist/commands/chord.js +143 -0
  40. package/dist/commands/daemon.js +8 -5
  41. package/dist/commands/dashboard.js +2 -0
  42. package/dist/commands/human/prompts.js +28 -27
  43. package/dist/commands/human/queue.js +30 -14
  44. package/dist/commands/human/shared.d.ts +26 -21
  45. package/dist/commands/human/shared.js +45 -67
  46. package/dist/commands/human.js +4 -14
  47. package/dist/commands/node.d.ts +11 -0
  48. package/dist/commands/node.js +221 -99
  49. package/dist/commands/pkg/market-inspect.js +6 -4
  50. package/dist/commands/pkg/market-manage.js +10 -6
  51. package/dist/commands/pkg/market.js +2 -4
  52. package/dist/commands/pkg/plugin-inspect.js +6 -4
  53. package/dist/commands/pkg/plugin-manage.js +12 -7
  54. package/dist/commands/pkg/plugin.js +2 -4
  55. package/dist/commands/pkg.js +0 -4
  56. package/dist/commands/push.js +178 -15
  57. package/dist/commands/revive.js +5 -3
  58. package/dist/commands/skill/author.js +6 -4
  59. package/dist/commands/skill/find.js +8 -5
  60. package/dist/commands/skill/read.js +2 -0
  61. package/dist/commands/skill/state.js +6 -4
  62. package/dist/commands/skill.js +0 -6
  63. package/dist/commands/sys/config.js +21 -7
  64. package/dist/commands/sys/doctor.js +2 -0
  65. package/dist/commands/sys/update.js +4 -0
  66. package/dist/commands/sys.js +0 -6
  67. package/dist/commands/tmux-spread.d.ts +2 -0
  68. package/dist/commands/tmux-spread.js +129 -0
  69. package/dist/core/__tests__/canvas-inbox-watcher.test.js +25 -0
  70. package/dist/core/__tests__/child-followup.test.d.ts +1 -0
  71. package/dist/core/__tests__/child-followup.test.js +83 -0
  72. package/dist/core/__tests__/close.test.d.ts +1 -0
  73. package/dist/core/__tests__/close.test.js +148 -0
  74. package/dist/core/__tests__/context-intro.test.d.ts +1 -0
  75. package/dist/core/__tests__/context-intro.test.js +196 -0
  76. package/dist/core/__tests__/daemon-boot.test.d.ts +1 -0
  77. package/dist/core/__tests__/daemon-boot.test.js +93 -0
  78. package/dist/core/__tests__/daemon-liveness.test.d.ts +1 -0
  79. package/dist/core/__tests__/daemon-liveness.test.js +223 -0
  80. package/dist/core/__tests__/focuses.test.d.ts +1 -0
  81. package/dist/core/__tests__/focuses.test.js +196 -0
  82. package/dist/core/__tests__/fork.test.d.ts +1 -0
  83. package/dist/core/__tests__/fork.test.js +91 -0
  84. package/dist/core/__tests__/home-session.test.d.ts +1 -0
  85. package/dist/core/__tests__/home-session.test.js +153 -0
  86. package/dist/core/__tests__/human-cancel-guard.test.d.ts +1 -0
  87. package/dist/core/__tests__/human-cancel-guard.test.js +49 -0
  88. package/dist/core/__tests__/keystone.test.d.ts +1 -0
  89. package/dist/core/__tests__/keystone.test.js +185 -0
  90. package/dist/core/__tests__/kickoff.test.d.ts +1 -0
  91. package/dist/core/__tests__/kickoff.test.js +89 -0
  92. package/dist/core/__tests__/lifecycle.test.d.ts +1 -0
  93. package/dist/core/__tests__/lifecycle.test.js +178 -0
  94. package/dist/core/__tests__/listing-completeness.test.d.ts +1 -0
  95. package/dist/core/__tests__/listing-completeness.test.js +31 -0
  96. package/dist/core/__tests__/memory.test.d.ts +1 -0
  97. package/dist/core/__tests__/memory.test.js +152 -0
  98. package/dist/core/__tests__/migration.test.d.ts +1 -0
  99. package/dist/core/__tests__/migration.test.js +238 -0
  100. package/dist/core/__tests__/pane-column.test.d.ts +1 -0
  101. package/dist/core/__tests__/pane-column.test.js +153 -0
  102. package/dist/core/__tests__/passive-subscription.test.js +24 -1
  103. package/dist/core/__tests__/persona-compose.test.d.ts +1 -0
  104. package/dist/core/__tests__/persona-compose.test.js +53 -0
  105. package/dist/core/__tests__/persona-subkind.test.d.ts +1 -0
  106. package/dist/core/__tests__/persona-subkind.test.js +62 -0
  107. package/dist/core/__tests__/persona.test.d.ts +1 -0
  108. package/dist/core/__tests__/persona.test.js +107 -0
  109. package/dist/core/__tests__/placement-focus.test.d.ts +1 -0
  110. package/dist/core/__tests__/placement-focus.test.js +266 -0
  111. package/dist/core/__tests__/placement-reconcile.test.d.ts +1 -0
  112. package/dist/core/__tests__/placement-reconcile.test.js +212 -0
  113. package/dist/core/__tests__/placement-revive.test.d.ts +1 -0
  114. package/dist/core/__tests__/placement-revive.test.js +238 -0
  115. package/dist/core/__tests__/placement-teardown.test.d.ts +1 -0
  116. package/dist/core/__tests__/placement-teardown.test.js +178 -0
  117. package/dist/core/__tests__/prune.test.d.ts +1 -0
  118. package/dist/core/__tests__/prune.test.js +116 -0
  119. package/dist/core/__tests__/push-final-guard.test.d.ts +1 -0
  120. package/dist/core/__tests__/push-final-guard.test.js +71 -0
  121. package/dist/core/__tests__/relaunch.test.d.ts +1 -0
  122. package/dist/core/__tests__/relaunch.test.js +334 -0
  123. package/dist/core/__tests__/reset.test.js +26 -7
  124. package/dist/core/__tests__/revive.test.d.ts +1 -0
  125. package/dist/core/__tests__/revive.test.js +217 -0
  126. package/dist/core/__tests__/spawn-root.test.d.ts +1 -0
  127. package/dist/core/__tests__/spawn-root.test.js +73 -0
  128. package/dist/core/__tests__/steer-note.test.d.ts +1 -0
  129. package/dist/core/__tests__/steer-note.test.js +39 -0
  130. package/dist/core/__tests__/stop-guard.test.d.ts +1 -0
  131. package/dist/core/__tests__/stop-guard.test.js +82 -0
  132. package/dist/core/__tests__/subcommand-tier.test.js +35 -33
  133. package/dist/core/__tests__/tmux-surface.test.d.ts +1 -0
  134. package/dist/core/__tests__/tmux-surface.test.js +105 -0
  135. package/dist/core/__tests__/unknown-path.test.js +8 -2
  136. package/dist/core/canvas/attention.d.ts +10 -0
  137. package/dist/core/canvas/attention.js +40 -0
  138. package/dist/core/canvas/canvas.d.ts +66 -7
  139. package/dist/core/canvas/canvas.js +209 -21
  140. package/dist/core/canvas/db.d.ts +8 -0
  141. package/dist/core/canvas/db.js +205 -4
  142. package/dist/core/canvas/focuses.d.ts +22 -0
  143. package/dist/core/canvas/focuses.js +81 -0
  144. package/dist/core/canvas/index.d.ts +3 -0
  145. package/dist/core/canvas/index.js +3 -0
  146. package/dist/core/canvas/labels.d.ts +27 -0
  147. package/dist/core/canvas/labels.js +36 -0
  148. package/dist/core/canvas/render.js +25 -10
  149. package/dist/core/canvas/telemetry.d.ts +14 -0
  150. package/dist/core/canvas/telemetry.js +35 -0
  151. package/dist/core/canvas/types.d.ts +115 -12
  152. package/dist/core/command.d.ts +25 -1
  153. package/dist/core/command.js +23 -15
  154. package/dist/core/config.js +36 -2
  155. package/dist/core/feed/feed.js +3 -3
  156. package/dist/core/feed/inbox.d.ts +3 -1
  157. package/dist/core/feed/inbox.js +45 -5
  158. package/dist/core/feed/passive.js +24 -11
  159. package/dist/core/help.d.ts +26 -13
  160. package/dist/core/help.js +44 -37
  161. package/dist/core/personas/index.d.ts +1 -1
  162. package/dist/core/personas/index.js +1 -1
  163. package/dist/core/personas/loader.d.ts +40 -1
  164. package/dist/core/personas/loader.js +63 -1
  165. package/dist/core/personas/resolve.d.ts +13 -6
  166. package/dist/core/personas/resolve.js +46 -34
  167. package/dist/core/runtime/bearings.d.ts +20 -0
  168. package/dist/core/runtime/bearings.js +92 -0
  169. package/dist/core/runtime/close.d.ts +14 -0
  170. package/dist/core/runtime/close.js +151 -0
  171. package/dist/core/runtime/demote.js +24 -12
  172. package/dist/core/runtime/front-door.js +1 -1
  173. package/dist/core/runtime/kickoff.d.ts +23 -6
  174. package/dist/core/runtime/kickoff.js +92 -36
  175. package/dist/core/runtime/launch.d.ts +26 -12
  176. package/dist/core/runtime/launch.js +78 -19
  177. package/dist/core/runtime/lifecycle.d.ts +13 -0
  178. package/dist/core/runtime/lifecycle.js +86 -0
  179. package/dist/core/runtime/memory.d.ts +43 -0
  180. package/dist/core/runtime/memory.js +165 -0
  181. package/dist/core/runtime/naming.d.ts +22 -0
  182. package/dist/core/runtime/naming.js +166 -0
  183. package/dist/core/runtime/nodes.d.ts +39 -1
  184. package/dist/core/runtime/nodes.js +69 -10
  185. package/dist/core/runtime/persona.d.ts +25 -0
  186. package/dist/core/runtime/persona.js +139 -0
  187. package/dist/core/runtime/placement.d.ts +299 -0
  188. package/dist/core/runtime/placement.js +688 -0
  189. package/dist/core/runtime/promote.d.ts +14 -7
  190. package/dist/core/runtime/promote.js +57 -67
  191. package/dist/core/runtime/reset.d.ts +47 -4
  192. package/dist/core/runtime/reset.js +223 -52
  193. package/dist/core/runtime/revive.d.ts +26 -2
  194. package/dist/core/runtime/revive.js +166 -39
  195. package/dist/core/runtime/spawn.d.ts +20 -5
  196. package/dist/core/runtime/spawn.js +163 -43
  197. package/dist/core/runtime/stop-guard.d.ts +1 -1
  198. package/dist/core/runtime/stop-guard.js +18 -8
  199. package/dist/core/runtime/tmux-chrome.d.ts +1 -0
  200. package/dist/core/runtime/tmux-chrome.js +4 -0
  201. package/dist/core/runtime/tmux.d.ts +113 -20
  202. package/dist/core/runtime/tmux.js +221 -39
  203. package/dist/core/spawn.js +15 -0
  204. package/dist/daemon/crtrd.d.ts +12 -1
  205. package/dist/daemon/crtrd.js +152 -34
  206. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.d.ts +1 -0
  207. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +266 -0
  208. package/dist/pi-extensions/canvas-commands.js +16 -13
  209. package/dist/pi-extensions/canvas-context-intro.d.ts +70 -0
  210. package/dist/pi-extensions/canvas-context-intro.js +164 -0
  211. package/dist/pi-extensions/canvas-goal-capture.d.ts +3 -0
  212. package/dist/pi-extensions/canvas-goal-capture.js +15 -1
  213. package/dist/pi-extensions/canvas-inbox-watcher.js +11 -0
  214. package/dist/pi-extensions/canvas-nav.d.ts +12 -4
  215. package/dist/pi-extensions/canvas-nav.js +594 -262
  216. package/dist/pi-extensions/canvas-resume.d.ts +22 -0
  217. package/dist/pi-extensions/canvas-resume.js +173 -0
  218. package/dist/pi-extensions/canvas-stophook.d.ts +16 -0
  219. package/dist/pi-extensions/canvas-stophook.js +340 -228
  220. package/dist/types.d.ts +28 -0
  221. package/dist/types.js +16 -0
  222. package/package.json +2 -2
  223. package/dist/core/runtime/presence.d.ts +0 -38
  224. package/dist/core/runtime/presence.js +0 -154
@@ -2,16 +2,55 @@
2
2
  // window. Used by both the supervisor daemon (on crash/refresh detection) and
3
3
  // the explicit `crtr canvas revive` command.
4
4
  //
5
- // A revive always opens a NEW window: the old one is gone (crashed, or the
6
- // node exited with intent=refresh). The node's persisted LaunchSpec and cwd
7
- // are the canonical recipe; reviveNode replays them faithfully.
5
+ // A revive replays the node's persisted LaunchSpec + cwd (the canonical recipe)
6
+ // and routes PLACEMENT through reviveIntoPlacement (§1.4): a non-focused node
7
+ // opens a fresh background window in its home_session (the backstage `crtr` for
8
+ // a child — NEVER a user session); a node that occupies a LIVE focus resumes IN
9
+ // PLACE in that focus pane (respawn-pane -k, no new window). reviveNode never
10
+ // targets meta.tmux_session, so a background revive can no longer open an
11
+ // unbidden window in the user's session.
8
12
  //
9
- // resume=true → `pi --resume <pi_session_id>` — wakes the saved conversation.
13
+ // resume=true → `pi --session <path|id>` — wakes the saved conversation,
14
+ // preferring the absolute session-file path (cwd-immune) over
15
+ // the bare session id.
10
16
  // resume=false → fresh pi invocation — the node re-reads its roadmap/context dir.
11
- import { getNode, updateNode, } from '../canvas/index.js';
17
+ import { getNode, updateNode, setPresence, clearPid, fullName, } from '../canvas/index.js';
18
+ import { transition } from './lifecycle.js';
12
19
  import { buildPiArgv } from './launch.js';
13
- import { buildReviveKickoff } from './kickoff.js';
14
- import { ensureSession, openNodeWindow, piCommand, respawnPane, nodeSession, } from './tmux.js';
20
+ import { buildReviveKickoff, drainBearings } from './kickoff.js';
21
+ import { FRONT_DOOR_ENV } from './front-door.js';
22
+ import { reviveIntoPlacement, reconcile, isNodePaneAlive, homeSessionOf, piCommand, respawnPane, } from './placement.js';
23
+ import { nodeSession } from './nodes.js';
24
+ /** signal-0 liveness probe for a pi pid (mirrors the daemon's isPidAlive). A
25
+ * null pid (legacy / never-booted) reads dead. */
26
+ function pidAlive(pid) {
27
+ if (pid == null)
28
+ return false;
29
+ try {
30
+ process.kill(pid, 0);
31
+ return true;
32
+ }
33
+ catch (e) {
34
+ return e.code === 'EPERM';
35
+ }
36
+ }
37
+ // ---------------------------------------------------------------------------
38
+ // resumeArgs — which session source a revive resumes from
39
+ // ---------------------------------------------------------------------------
40
+ /** Pick the `--session` source for a revive. resume=true prefers the absolute
41
+ * session-file path (immune to cwd; pi opens it directly) and keeps the bare
42
+ * session id as the fallback for older nodes booted before pi_session_file was
43
+ * captured. buildPiArgv prefers the path when both are present. resume=false (a
44
+ * refresh-yield) selects neither — the node re-reads its roadmap fresh. Pure so
45
+ * the path-vs-id selection is unit-testable without tmux. */
46
+ export function resumeArgs(meta, resume) {
47
+ if (!resume)
48
+ return {};
49
+ return {
50
+ resumeSessionId: meta.pi_session_id ?? undefined,
51
+ resumeSessionPath: meta.pi_session_file ?? undefined,
52
+ };
53
+ }
15
54
  // ---------------------------------------------------------------------------
16
55
  // reviveNode
17
56
  // ---------------------------------------------------------------------------
@@ -25,35 +64,63 @@ export function reviveNode(nodeId, opts) {
25
64
  if (meta === null) {
26
65
  throw new Error(`reviveNode: unknown node ${nodeId}`);
27
66
  }
28
- // The node lives in the shared global session. Prefer its stored session
29
- // (an inline root tracks its own real terminal session); fall back to the
30
- // shared node session.
31
- const session = meta.tmux_session ?? nodeSession();
32
- ensureSession(session, meta.cwd);
33
- // Decide whether to wake the saved pi conversation or start fresh.
34
- const resumeId = opts.resume && meta.pi_session_id != null
35
- ? meta.pi_session_id
36
- : undefined;
67
+ // Double-revive guard (pane-keyed, §2.4): reconcile FIRST so a user-moved pane
68
+ // isn't misread as "not yet revived", then probe pane-existence. A node whose
69
+ // pane is alive AND whose pi is still RUNNING was already revived by another
70
+ // path; re-launching would put a SECOND pi on the same session file — no-op.
71
+ // A FROZEN focus pane (remain-on-exit, F3) is pane-alive but pi-DEAD: that is
72
+ // the resume-into-focus case and MUST proceed (respawn-pane -k back into the
73
+ // frozen pane), so the guard gates on pi liveness too, not pane-existence alone.
74
+ reconcile(nodeId);
75
+ const live = getNode(nodeId) ?? meta;
76
+ if (isNodePaneAlive(nodeId) && pidAlive(live.pi_pid)) {
77
+ return {
78
+ window: live.window ?? null,
79
+ session: live.tmux_session ?? nodeSession(),
80
+ resumed: false,
81
+ };
82
+ }
83
+ // Every (re)launch is a new cycle — bump the counter so the editor label's
84
+ // trailing N advances. Mutate the in-memory meta too so buildPiArgv below
85
+ // builds the label with the incremented count.
86
+ meta.cycles = (meta.cycles ?? 0) + 1;
87
+ updateNode(nodeId, { cycles: meta.cycles });
88
+ // Decide whether to wake the saved pi conversation or start fresh. Prefer the
89
+ // absolute session-file path (cwd-immune); fall back to the bare id.
90
+ const resume = resumeArgs(meta, opts.resume);
91
+ const resuming = resume.resumeSessionPath !== undefined || resume.resumeSessionId !== undefined;
37
92
  // A fresh revive (no resume) gets a kickoff prompt so it re-reads its roadmap
38
- // and continues; resuming a saved conversation needs none.
39
- const inv = resumeId !== undefined
40
- ? buildPiArgv(meta, { resumeSessionId: resumeId })
41
- : buildPiArgv(meta, { prompt: buildReviveKickoff(meta) });
42
- const env = { ...inv.env, CRTR_ROOT_SESSION: session };
43
- const window = openNodeWindow({
44
- session,
45
- name: meta.name,
46
- cwd: meta.cwd,
47
- env,
93
+ // and continues; resuming a saved conversation needs none. drainBearings is the
94
+ // one-shot consuming step (yield note + feed cursor + persona ack); the builder
95
+ // is then pure.
96
+ let inv;
97
+ if (resuming) {
98
+ inv = buildPiArgv(meta, resume);
99
+ }
100
+ else {
101
+ const bearings = drainBearings(meta);
102
+ inv = buildPiArgv(meta, { prompt: buildReviveKickoff(meta, bearings) });
103
+ }
104
+ // Placement owns WHERE this revive lands (§1.4): resume into a live focus pane
105
+ // if the node occupies one, else a fresh window in its home_session (the
106
+ // backstage `crtr` for a child — NEVER a user session). reviveIntoPlacement
107
+ // performs the one atomic setPresence; reviveNode keeps transition+clearPid
108
+ // around it (the crash-safety ordering, unchanged). THIS is the bug-kill: a
109
+ // non-focused background revive can no longer new-window into a user session.
110
+ transition(nodeId, 'revive');
111
+ const placed = reviveIntoPlacement(nodeId, {
48
112
  command: piCommand(inv.argv),
113
+ env: inv.env,
114
+ cwd: meta.cwd,
115
+ name: fullName(meta),
116
+ resuming,
49
117
  });
50
- updateNode(nodeId, {
51
- status: 'active',
52
- intent: null,
53
- window,
54
- tmux_session: session,
55
- });
56
- return { window, session, resumed: resumeId !== undefined };
118
+ // Window-backed launch: clear the stale pid so the daemon won't re-fire on
119
+ // it during the new pi's boot. The fresh pi re-records its pid on
120
+ // session_start; if it never boots, this window closes and the window-gone
121
+ // pass reaps it.
122
+ clearPid(nodeId);
123
+ return { window: placed.window, session: placed.session, resumed: resuming };
57
124
  }
58
125
  // ---------------------------------------------------------------------------
59
126
  // reviveInPlace — refresh-yield without churning the window
@@ -67,21 +134,81 @@ export function reviveNode(nodeId, opts) {
67
134
  * `pane` is the target pane id (the yielding node reads it from $TMUX_PANE).
68
135
  * Throws on unknown node or when the respawn could not be dispatched, so the
69
136
  * caller can fall back to a plain shutdown (daemon revives in a new window). */
70
- export function reviveInPlace(nodeId, pane) {
137
+ export function reviveInPlace(nodeId, pane, respawn = respawnPane) {
71
138
  const meta = getNode(nodeId);
72
139
  if (meta === null) {
73
140
  throw new Error(`reviveInPlace: unknown node ${nodeId}`);
74
141
  }
142
+ // A refresh-yield is a cycle too — advance the label's trailing N.
143
+ meta.cycles = (meta.cycles ?? 0) + 1;
144
+ updateNode(nodeId, { cycles: meta.cycles });
145
+ // The node's LOCATION — the session its pane physically lives in. The re-exec
146
+ // is IN PLACE (the pane never moves), so this is preserved unchanged below.
75
147
  const session = meta.tmux_session ?? nodeSession();
76
148
  // Fresh re-exec: same recipe as a no-resume reviveNode, with the kickoff so
77
- // the node rebuilds its bearings from disk.
78
- const inv = buildPiArgv(meta, { prompt: buildReviveKickoff(meta) });
79
- const env = { ...inv.env, CRTR_ROOT_SESSION: session };
80
- const ok = respawnPane({ pane, cwd: meta.cwd, env, command: piCommand(inv.argv) });
149
+ // the node rebuilds its bearings from disk. Drain the one-shot bearings first,
150
+ // then build purely.
151
+ const bearings = drainBearings(meta);
152
+ const inv = buildPiArgv(meta, { prompt: buildReviveKickoff(meta, bearings) });
153
+ // CRTR_ROOT_SESSION is the backstage this node's CHILDREN spawn into — it must
154
+ // be the durable REVIVE-HOME (home_session), NOT the pane's live `session`. A
155
+ // FOCUSED child's pane is in a USER session (focus taints meta.tmux_session),
156
+ // so sourcing it from `session` would land any child it spawns in the user's
157
+ // session, re-tainting that child's home_session (A-MAJOR-1). home_session is
158
+ // the taint-immune backstage `crtr` for a child; for a root it equals its own
159
+ // session, so this is behavior-preserving there.
160
+ const env = { ...inv.env, CRTR_ROOT_SESSION: homeSessionOf(nodeId) };
161
+ const ok = respawn({ pane, cwd: meta.cwd, env, command: piCommand(inv.argv) });
81
162
  if (!ok) {
82
163
  throw new Error(`reviveInPlace: respawn-pane dispatch failed for ${nodeId}`);
83
164
  }
84
- updateNode(nodeId, { status: 'active', intent: null, tmux_session: session });
165
+ // Deliberately DO NOT clear intent here, and DO NOT touch pi_pid. The detached
166
+ // respawn-pane can't confirm it actually replaced the pi (it kills this very
167
+ // process mid-flight), so clearing intent optimistically is how a failed
168
+ // refresh became a silent death: the fresh pi never boots, yet meta says the
169
+ // refresh completed. Instead we leave intent='refresh' (the fresh pi clears it
170
+ // on boot — the only proof the respawn worked) and leave pi_pid as the OLD
171
+ // pid. If the respawn succeeds, the old pi dies and the fresh one overwrites
172
+ // pid+intent within the daemon's grace window; if it fails, the old pid stays
173
+ // dead and the daemon's pi-liveness pass revives the node.
174
+ transition(nodeId, 'boot');
175
+ // tmux_session may have resolved to the shared session; window is unchanged
176
+ // (we re-execed in place), so preserve it explicitly.
177
+ setPresence(nodeId, { tmux_session: session, window: meta.window ?? null });
85
178
  // Window is unchanged (we re-execed in place); report the existing one.
86
179
  return { window: meta.window ?? null, session, resumed: false };
87
180
  }
181
+ // ---------------------------------------------------------------------------
182
+ // relaunchRootInPane — boot a CLEAN fresh root in the current pane (option C)
183
+ // ---------------------------------------------------------------------------
184
+ /** Re-exec a FRESH pi for `nodeId` in EXISTING `pane` (respawn-pane -k), with
185
+ * NO prompt and NO resume — a clean root conversation (goal-capture /
186
+ * context-intro handle the first message + bearings, exactly like bare
187
+ * `crtr`). Unlike reviveInPlace: no buildReviveKickoff prompt, no cycles bump,
188
+ * and it sets CRTR_FRONT_DOOR=1 (REQUIRED — src/core/runtime/CLAUDE.md: any
189
+ * path that boots a pi must guard against a removed/renamed subcommand
190
+ * fork-bombing). Throws if the respawn could not be dispatched.
191
+ *
192
+ * Used by relaunchRoot (reset.ts) for the `/new`-in-a-root relaunch. Kept
193
+ * SEPARATE from reviveInPlace so the refresh-yield path's exact semantics
194
+ * (kickoff + cycle bump) are untouched. */
195
+ export function relaunchRootInPane(nodeId, pane) {
196
+ const meta = getNode(nodeId);
197
+ if (meta === null) {
198
+ throw new Error(`relaunchRootInPane: unknown node ${nodeId}`);
199
+ }
200
+ // No prompt, no resume → a brand-new root conversation at cycle 0.
201
+ const inv = buildPiArgv(meta, {});
202
+ // Source CRTR_ROOT_SESSION from the durable REVIVE-HOME (home_session), the
203
+ // same taint-immunity rule as reviveInPlace. relaunchRootInPane runs only on a
204
+ // root, whose home_session IS its own session, so this is behavior-preserving
205
+ // — it keeps both in-pane revive paths sourced identically.
206
+ const env = { ...inv.env, CRTR_ROOT_SESSION: homeSessionOf(nodeId), [FRONT_DOOR_ENV]: '1' };
207
+ const ok = respawnPane({ pane, cwd: meta.cwd, env, command: piCommand(inv.argv) });
208
+ if (!ok) {
209
+ throw new Error(`relaunchRootInPane: respawn-pane dispatch failed for ${nodeId}`);
210
+ }
211
+ // Do NOT clear intent/pi_pid here — the fresh pi clears intent='refresh' on
212
+ // its session_start boot (the only proof the respawn worked), same dance as
213
+ // reviveInPlace.
214
+ }
@@ -5,9 +5,6 @@ export interface BootRootOpts {
5
5
  name?: string;
6
6
  /** Optional starter prompt (bare `crtr` requires none). */
7
7
  prompt?: string;
8
- /** 'inline' — exec pi in the current terminal (bare `crtr`).
9
- * 'session' — create a dedicated tmux session and run pi there (`session new`). */
10
- placement: 'inline' | 'session';
11
8
  }
12
9
  /** Create a root node and bring up its pi. Returns the node; for 'inline' this
13
10
  * only returns after pi exits (it took over the terminal). */
@@ -20,12 +17,30 @@ export interface SpawnChildOpts {
20
17
  prompt: string;
21
18
  /** Override the parent (defaults to the calling node from env). */
22
19
  parent?: string;
20
+ /** Spawn an INDEPENDENT root instead of a managed child: parent=null, no
21
+ * subscription back to the spawner, resident lifecycle, spawned_by=spawner.
22
+ * Brought forefront on spawn so a human can drive it directly. */
23
+ root?: boolean;
24
+ /** Fork the new node from an existing pi conversation instead of starting it
25
+ * fresh: a node id (resolved to that node's session file), an absolute
26
+ * `.jsonl` path, or a partial pi session uuid. pi COPIES that history into a
27
+ * new session for the child — the source is untouched — then `prompt` is the
28
+ * next message. A one-shot at birth; the child resumes its own session after. */
29
+ forkFrom?: string;
23
30
  }
31
+ /** Resolve a `--fork-from` value to the source pi gets as `--fork <path|id>`.
32
+ * A live node id resolves to its captured session FILE (absolute, cwd-immune),
33
+ * falling back to its bare session id; a path or partial uuid passes straight
34
+ * through to pi. Throws when a known node has no session to fork yet. */
35
+ export declare function resolveForkSource(value: string): string;
24
36
  export interface SpawnChildResult {
25
37
  node: NodeMeta;
26
38
  window: string | null;
27
39
  session: string;
28
40
  }
29
- /** Spawn a terminal worker as a background window under the root session.
30
- * The parent auto-subscribes (active) to it via spawnNode. */
41
+ /** Spawn a node from a live node. By default a managed terminal worker in a
42
+ * background window, with the spawner auto-subscribed (active) via spawnNode.
43
+ * With `root`: an independent resident root — parent=null, NO subscription back
44
+ * to the spawner (it carries spawned_by=spawner for provenance only), brought
45
+ * forefront so a human can pick up the conversation directly. */
31
46
  export declare function spawnChild(opts: SpawnChildOpts): SpawnChildResult;
@@ -2,17 +2,24 @@
2
2
  // a running pi process on the canvas. Composes canvas (birth + spine), persona
3
3
  // (resolve), launch (pi argv), and tmux (placement).
4
4
  //
5
- // bootRoot — a user-opened entry point (bare `crtr`).
6
- // Resident. Runs pi in the foreground (inline) or its own session.
7
- // spawnChild — a background worker spawned by a live node (`crtr node new`).
8
- // Terminal. Opens a non-focus-stealing window under the root.
5
+ // bootRoot — the user-opened front door (bare `crtr`). Resident; runs pi
6
+ // inline, taking over the current terminal.
7
+ // spawnChild — a node spawned by a live node (`crtr node new`). A managed,
8
+ // terminal background worker by default; with `root`, an
9
+ // INDEPENDENT resident root (no subscription back to the spawner,
10
+ // provenance via spawned_by) brought forefront for direct driving.
9
11
  import { spawnSync } from 'node:child_process';
10
12
  import { FRONT_DOOR_ENV } from './front-door.js';
11
- import { spawnNode, currentNodeContext } from './nodes.js';
13
+ import { spawnNode, currentNodeContext, resolveBirthSession, nodeSession } from './nodes.js';
12
14
  import { buildLaunchSpec, buildPiArgv } from './launch.js';
13
15
  import { writeGoal } from './kickoff.js';
14
- import { ensureSession, openNodeWindow, piCommand, currentTmux, inTmux, nodeSession, installMenuBinding, installNavBindings, } from './tmux.js';
15
- import { updateNode, getNode } from '../canvas/index.js';
16
+ import { hasRoadmap, seedRoadmap } from './roadmap.js';
17
+ import { seedMemory, seedUserMemory, seedProjectMemory } from './memory.js';
18
+ import { generateSessionName } from './naming.js';
19
+ import { installMenuBinding, installNavBindings } from './tmux-chrome.js';
20
+ import { setPresence, updateNode, getNode, fullName } from '../canvas/index.js';
21
+ import { registerRootFocus, ensureSession, openNodeWindow, piCommand, currentTmux, inTmux, focusWindow, } from './placement.js';
22
+ import { transition } from './lifecycle.js';
16
23
  import { ensureDaemon } from '../../daemon/manage.js';
17
24
  /** Create a root node and bring up its pi. Returns the node; for 'inline' this
18
25
  * only returns after pi exits (it took over the terminal). */
@@ -26,13 +33,20 @@ export function bootRoot(opts) {
26
33
  const kind = opts.kind ?? 'general';
27
34
  // A born-resident root starts in base mode; it earns the orchestrator persona
28
35
  // the first time it delegates (or on promotion). Resident lifecycle either way.
29
- const { launch } = buildLaunchSpec(kind, 'base');
36
+ const { launch } = buildLaunchSpec(kind, 'base', { lifecycle: 'resident', hasManager: false });
37
+ // A root opened WITH a prompt gets its editor name now (so the first pi
38
+ // session already carries it). A bare root has no prompt yet — the
39
+ // goal-capture extension names it from the first message (async, next cycle).
40
+ const description = opts.prompt !== undefined && opts.prompt.trim() !== ''
41
+ ? generateSessionName(opts.prompt)
42
+ : undefined;
30
43
  const meta = spawnNode({
31
44
  kind,
32
45
  mode: 'base',
33
46
  lifecycle: 'resident',
34
47
  cwd: opts.cwd,
35
48
  name: opts.name ?? kind,
49
+ description,
36
50
  parent: null,
37
51
  launch,
38
52
  });
@@ -55,74 +69,180 @@ export function bootRoot(opts) {
55
69
  }
56
70
  catch { /* best-effort */ }
57
71
  }
58
- if (opts.placement === 'session') {
59
- updateNode(meta.node_id, { tmux_session: session });
60
- const withSession = getNode(meta.node_id);
61
- const inv = buildPiArgv(withSession, { prompt: opts.prompt });
62
- const env = { ...inv.env, CRTR_ROOT_SESSION: session, [FRONT_DOOR_ENV]: '1' };
63
- const win = openNodeWindow({
64
- session,
65
- name: meta.name,
66
- cwd: opts.cwd,
67
- env,
68
- command: piCommand(inv.argv),
69
- });
70
- updateNode(meta.node_id, { window: win });
71
- return getNode(meta.node_id);
72
- }
73
72
  // inline: the root's pi takes over THIS terminal, so its own window stays
74
73
  // where the user is (its tmux_session tracks that real pane so supervision
75
74
  // sees it alive). But its children spawn into the shared global session via
76
75
  // CRTR_ROOT_SESSION — they never clutter the user's working session.
77
76
  const here = currentTmux();
78
- const adopted = here?.session ?? session;
79
- updateNode(meta.node_id, { tmux_session: adopted, window: here?.window ?? null });
77
+ const adopted = resolveBirthSession({ adoptCaller: true, here, rootSession: undefined });
78
+ setPresence(meta.node_id, { tmux_session: adopted, window: here?.window ?? null, pane: here?.pane ?? null });
79
+ // REVIVE-HOME: the inline root's durable revive target is the session it
80
+ // adopts (the caller's when inside tmux, else the shared backstage). Set once
81
+ // at birth, alongside the live LOCATION above.
82
+ updateNode(meta.node_id, { home_session: adopted });
83
+ // Root boot registers focus #1 (§2.6): the FOREGROUND inline root owns the
84
+ // user's viewport, so its OWN pane becomes a durable focus (remain-on-exit so
85
+ // a clean exit freezes rather than detaching the terminal). A background
86
+ // `--root` (spawnChild) does NOT — it stays a plain window until the user
87
+ // focuses it (§6). Only possible inside tmux (a pane to anchor on).
88
+ if (here) {
89
+ try {
90
+ registerRootFocus(meta.node_id, here.pane, adopted, here.window);
91
+ }
92
+ catch { /* best-effort */ }
93
+ }
80
94
  const withSession = getNode(meta.node_id);
81
95
  const inv = buildPiArgv(withSession, { prompt: opts.prompt });
82
96
  const env = { ...process.env, ...inv.env, CRTR_ROOT_SESSION: session, [FRONT_DOOR_ENV]: '1' };
83
97
  const r = spawnSync('pi', inv.argv, { stdio: 'inherit', env });
84
98
  process.exit(r.status ?? 0);
85
99
  }
86
- /** Spawn a terminal worker as a background window under the root session.
87
- * The parent auto-subscribes (active) to it via spawnNode. */
100
+ /** Resolve a `--fork-from` value to the source pi gets as `--fork <path|id>`.
101
+ * A live node id resolves to its captured session FILE (absolute, cwd-immune),
102
+ * falling back to its bare session id; a path or partial uuid passes straight
103
+ * through to pi. Throws when a known node has no session to fork yet. */
104
+ export function resolveForkSource(value) {
105
+ const v = value.trim();
106
+ if (v === '')
107
+ throw new Error('--fork-from requires a node id, session file, or session uuid.');
108
+ // A path (contains `/` or ends `.jsonl`) is a session file — hand it to pi as-is.
109
+ if (v.includes('/') || v.endsWith('.jsonl'))
110
+ return v;
111
+ // A live node id — fork from the conversation it has accumulated.
112
+ const n = getNode(v);
113
+ if (n !== null) {
114
+ const src = n.pi_session_file ?? n.pi_session_id;
115
+ if (src === undefined || src === null || src === '') {
116
+ throw new Error(`node ${v} has no pi session yet — it has not started a conversation to fork from.`);
117
+ }
118
+ return src;
119
+ }
120
+ // Not a known node — treat as a bare/partial pi session id for pi to resolve.
121
+ return v;
122
+ }
123
+ /** Spawn a node from a live node. By default a managed terminal worker in a
124
+ * background window, with the spawner auto-subscribed (active) via spawnNode.
125
+ * With `root`: an independent resident root — parent=null, NO subscription back
126
+ * to the spawner (it carries spawned_by=spawner for provenance only), brought
127
+ * forefront so a human can pick up the conversation directly. */
88
128
  export function spawnChild(opts) {
89
129
  try {
90
130
  ensureDaemon();
91
131
  }
92
132
  catch { /* daemon is best-effort */ }
93
133
  const ctx = currentNodeContext();
94
- const parent = opts.parent ?? ctx.nodeId;
95
- if (parent === null || parent === undefined) {
134
+ const spawner = opts.parent ?? ctx.nodeId;
135
+ if (spawner === null || spawner === undefined) {
96
136
  throw new Error('spawnChild requires a calling node (CRTR_NODE_ID) or an explicit parent');
97
137
  }
138
+ const root = opts.root === true;
98
139
  const mode = opts.mode ?? 'base';
99
- const { launch } = buildLaunchSpec(opts.kind, mode);
140
+ // Lifecycle keys on ROOT-ness only, independent of mode: an independent root
141
+ // (or `--root`) is resident (a conversation that persists, woken by inbox/
142
+ // human); every spawned child is terminal — it owes a final up the spine and
143
+ // reaps when done. A child born as an orchestrator is terminal/orchestrator
144
+ // (delegates + holds a roadmap, but still reports up), NOT resident.
145
+ const lifecycle = root ? 'resident' : 'terminal';
146
+ // Spine: a managed child reports up to its spawner (has a manager); an
147
+ // independent root sits top-of-spine with nobody to push to. Mirrors the
148
+ // `parent` set below (root ? null : spawner), so hasManager === parent!==null.
149
+ const { launch } = buildLaunchSpec(opts.kind, mode, { lifecycle, hasManager: !root });
150
+ // Name the worker from its task now, so its first editor label carries it.
100
151
  const meta = spawnNode({
101
152
  kind: opts.kind,
102
153
  mode,
103
- lifecycle: 'terminal',
154
+ lifecycle,
104
155
  cwd: opts.cwd,
105
156
  name: opts.name ?? opts.kind,
106
- parent,
157
+ description: generateSessionName(opts.prompt),
158
+ // A root has no spine parent (top-level, nobody subscribes); it still
159
+ // records spawned_by=spawner. A child's parent IS its manager.
160
+ parent: root ? null : spawner,
161
+ spawnedBy: root ? spawner : undefined,
107
162
  launch,
108
163
  });
109
164
  // Persist the task as the child's goal for a fresh revive to re-read.
110
165
  writeGoal(meta.node_id, opts.prompt);
111
- // Children always land in the shared global session: inherited from the
112
- // parent's CRTR_ROOT_SESSION, else the default node session.
113
- let session = process.env['CRTR_ROOT_SESSION'];
114
- if (session === undefined || session === '')
115
- session = nodeSession();
166
+ // A fork copies an existing conversation into this child's first session
167
+ // (resolved to an absolute file path when forking from a node). Resolved here
168
+ // not in buildPiArgv — so a bad reference fails the spawn loudly before any
169
+ // window opens, rather than after pi is already booting.
170
+ const forkFrom = opts.forkFrom !== undefined ? resolveForkSource(opts.forkFrom) : undefined;
171
+ // A child created DIRECTLY as an orchestrator (mode='orchestrator') boots
172
+ // with the orchestrator persona but bypasses promote(), which is where a
173
+ // roadmap scaffold would normally be seeded. Lay one down here (goal
174
+ // pre-filled from the task) so the orchestrator has its memory artifact from
175
+ // birth, instead of waking memory-less. Guarded so it never clobbers.
176
+ if (mode === 'orchestrator' && !hasRoadmap(meta.node_id)) {
177
+ seedRoadmap(meta.node_id, { goal: opts.prompt.trim() });
178
+ }
179
+ // Born an orchestrator ⇒ also lay down its three scoped long-term memory
180
+ // stores, the companions to the roadmap: user-global (key-less), project
181
+ // (keyed off the child's cwd), and node-local. Each guarded against clobber.
182
+ if (mode === 'orchestrator') {
183
+ seedUserMemory();
184
+ seedProjectMemory(opts.cwd);
185
+ seedMemory(meta.node_id);
186
+ }
187
+ // A managed CHILD lands in the shared global session: inherited from the
188
+ // parent's CRTR_ROOT_SESSION, else the default node session. A --root spawned
189
+ // from inside tmux instead opens its window in the CALLER'S CURRENT session,
190
+ // so it appears where the spawner is working rather than exiled to a separate
191
+ // crtr session. Either way the root's OWN descendants still flow to the shared
192
+ // session (childSession) via CRTR_ROOT_SESSION, to keep the subtree from
193
+ // cluttering the user's session.
194
+ const rootSessionEnv = process.env['CRTR_ROOT_SESSION'];
195
+ const here = root ? currentTmux() : null;
196
+ // The shared backstage the whole subtree flows into (this child's own
197
+ // CRTR_ROOT_SESSION): the inherited root session, else the default `crtr`.
198
+ const childSession = resolveBirthSession({ adoptCaller: false, here, rootSession: rootSessionEnv });
199
+ // Where THIS node's window opens — and its durable REVIVE-HOME. A managed
200
+ // child lands in the backstage; a --root adopts the caller's current session
201
+ // when inside tmux, so it appears where the spawner is working.
202
+ const session = resolveBirthSession({ adoptCaller: root, here, rootSession: rootSessionEnv });
116
203
  ensureSession(session, opts.cwd);
117
- const inv = buildPiArgv(meta, { prompt: opts.prompt });
118
- const env = { ...inv.env, CRTR_ROOT_SESSION: session };
119
- const window = openNodeWindow({
204
+ // REVIVE-HOME set once at birth: a managed child's revive target is the
205
+ // backstage, never a user session — this is what keeps a background revive
206
+ // off the user's screen (the focus taint cannot reach it).
207
+ updateNode(meta.node_id, { home_session: session });
208
+ const inv = buildPiArgv(meta, { prompt: opts.prompt, forkFrom });
209
+ const env = { ...inv.env, CRTR_ROOT_SESSION: childSession, [FRONT_DOOR_ENV]: '1' };
210
+ const command = piCommand(inv.argv);
211
+ // openNodeWindow now returns {window, pane}; pane is unused until the
212
+ // placement layer lands, so destructure the window and proceed unchanged.
213
+ const opened = openNodeWindow({
120
214
  session,
121
- name: meta.name,
215
+ name: fullName(meta),
122
216
  cwd: opts.cwd,
123
217
  env,
124
- command: piCommand(inv.argv),
218
+ command,
125
219
  });
126
- const saved = updateNode(meta.node_id, { tmux_session: session, window });
220
+ const window = opened?.window ?? null;
221
+ // Two-stage failure model. Opening the window is instant and definitive, so a
222
+ // failure here is reported SYNCHRONOUSLY: crash the node (so it isn't a zombie
223
+ // 'active' the daemon can't reap — it has no window to watch) and throw so
224
+ // `crtr node new` exits non-zero with a clear message for the caller. The node
225
+ // is still 'active' from spawnNode, so transition('crash') is a legal from-LIVE
226
+ // move — the last scattered node-status write, now through the lifecycle machine.
227
+ //
228
+ // pi BOOTING inside the window, by contrast, is inherently slow (and slower
229
+ // under load), so we stay optimistic and return status='active' the instant
230
+ // the window exists. A vehicle that then dies before its first session_start
231
+ // is caught by the daemon — it surfaces the boot failure up the spine rather
232
+ // than letting the node die silently (see crtrd.ts surfaceBootFailure).
233
+ if (window === null) {
234
+ transition(meta.node_id, 'crash');
235
+ throw new Error(`failed to open a tmux window for ${meta.node_id} (${meta.name}) in session '${session}' — the node was not started.`);
236
+ }
237
+ setPresence(meta.node_id, { tmux_session: session, window });
238
+ const saved = getNode(meta.node_id);
239
+ // A root is spawned to be driven directly — bring it forefront so whoever
240
+ // asked for it picks up the conversation. A child stays a background window.
241
+ if (root) {
242
+ try {
243
+ focusWindow(session, window);
244
+ }
245
+ catch { /* best-effort */ }
246
+ }
127
247
  return { node: saved, window, session };
128
248
  }
@@ -6,7 +6,7 @@ export interface StopSignals {
6
6
  }
7
7
  export type StopAction = {
8
8
  action: 'allow';
9
- reason: 'awaiting' | 'finished' | 'escalated' | 'attended';
9
+ reason: 'awaiting' | 'finished' | 'escalated' | 'dormant';
10
10
  } | {
11
11
  action: 'reprompt';
12
12
  reason: 'stalled';
@@ -5,12 +5,17 @@
5
5
  // subscription to a node that's still live (active|idle) — something that can
6
6
  // actually wake it. (A passive sub won't wake you, so it doesn't count.)
7
7
  //
8
- // • waiting stopping is correct; it's a dormant orchestrator awaiting
9
- // its workers. Let it sleep; a child's push wakes it.
8
+ // • resident an interactable / human-driven node is NEVER forced to
9
+ // submit a final: stopping to go dormant is always
10
+ // legitimate (woken by inbox/human). Keyed on the LIFECYCLE
11
+ // value, not on parent/mode — what matters is residency.
12
+ // • waiting → a TERMINAL node holding an active live subscription is a
13
+ // dormant orchestrator awaiting its workers. Let it sleep;
14
+ // a child's push wakes it (and idle-releases its window).
10
15
  // • finished/asked → it pushed --final (done) or called `crtr ask` this turn.
11
16
  // Also fine.
12
- // • otherwise → it has nothing live to wait for and hasn't resolved.
13
- // Re-prompt it to finish or escalate. Stalls are impossible.
17
+ // • otherwise → a TERMINAL node with nothing live to wait for and no
18
+ // final pushed. Re-prompt it to finish or escalate.
14
19
  import { hasActiveLiveSubscription, getNode } from '../canvas/index.js';
15
20
  export const STALL_REPROMPT = "You've stopped but you're not waiting on anyone and haven't finished. " +
16
21
  'Run `crtr push final "<result>"` if the work is done, or `crtr human ask` if you are blocked or need the user.';
@@ -21,13 +26,18 @@ export function evaluateStop(nodeId, signals) {
21
26
  return { action: 'allow', reason: 'finished' };
22
27
  if (signals.askedHuman)
23
28
  return { action: 'allow', reason: 'escalated' };
24
- // A user-opened root (no parent) is human-attended: the human is its wake
25
- // source, so stopping to await input is always legitimate never nag it.
29
+ // A RESIDENT node is interactable / human-driven and is never forced to submit
30
+ // a final: stopping to go dormant is always legitimate (the inbox or the human
31
+ // wakes it). Keyed on lifecycle, not parent — whether it has a parent doesn't
32
+ // matter, only whether it's resident. Roots are resident by birth default, so
33
+ // this still covers "don't nag the human's root" while generalizing it.
26
34
  const node = getNode(nodeId);
27
- if (node !== null && (node.parent === null || node.parent === undefined)) {
28
- return { action: 'allow', reason: 'attended' };
35
+ if (node !== null && node.lifecycle === 'resident') {
36
+ return { action: 'allow', reason: 'dormant' };
29
37
  }
38
+ // A terminal node holding something live to wake it is legitimately awaiting.
30
39
  if (hasActiveLiveSubscription(nodeId))
31
40
  return { action: 'allow', reason: 'awaiting' };
41
+ // A terminal node with nothing live and no final pushed has stalled.
32
42
  return { action: 'reprompt', reason: 'stalled', message: STALL_REPROMPT };
33
43
  }
@@ -0,0 +1 @@
1
+ export { installMenuBinding, installNavBindings, sendKeysEnter } from './tmux.js';
@@ -0,0 +1,4 @@
1
+ // tmux-chrome.ts — chrome seam (§2.1): stateless keybind/input verbs.
2
+ // The ONLY non-placement module allowed to import the tmux driver, per the
3
+ // §5.1 lint. Re-exports the menu/nav/send-keys verbs callers (spawn, chord) need.
4
+ export { installMenuBinding, installNavBindings, sendKeysEnter } from './tmux.js';