@crouton-kit/crouter 0.3.16 → 0.3.18

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 (104) hide show
  1. package/dist/builtin-personas/design/{base.md → PERSONA.md} +4 -0
  2. package/dist/builtin-personas/developer/{base.md → PERSONA.md} +4 -0
  3. package/dist/builtin-personas/explore/{base.md → PERSONA.md} +4 -0
  4. package/dist/builtin-personas/general/{base.md → PERSONA.md} +4 -0
  5. package/dist/builtin-personas/orchestration-kernel.md +6 -6
  6. package/dist/builtin-personas/plan/{base.md → PERSONA.md} +5 -1
  7. package/dist/builtin-personas/plan/reviewers/architecture-fit/{base.md → PERSONA.md} +1 -1
  8. package/dist/builtin-personas/plan/reviewers/code-smells/{base.md → PERSONA.md} +1 -1
  9. package/dist/builtin-personas/plan/reviewers/pattern-consistency/{base.md → PERSONA.md} +1 -1
  10. package/dist/builtin-personas/plan/reviewers/requirements-coverage/{base.md → PERSONA.md} +1 -1
  11. package/dist/builtin-personas/plan/reviewers/security/{base.md → PERSONA.md} +1 -1
  12. package/dist/builtin-personas/review/{base.md → PERSONA.md} +4 -0
  13. package/dist/builtin-personas/spec/{base.md → PERSONA.md} +5 -1
  14. package/dist/builtin-skills/skills/crouter-development/personas/SKILL.md +24 -14
  15. package/dist/builtin-skills/skills/crouter-development/personas/base-prompt/SKILL.md +4 -4
  16. package/dist/commands/canvas-browse.d.ts +2 -0
  17. package/dist/commands/canvas-browse.js +45 -0
  18. package/dist/commands/canvas-prune.js +11 -2
  19. package/dist/commands/canvas.js +3 -2
  20. package/dist/commands/daemon.js +1 -1
  21. package/dist/commands/human/prompts.js +3 -9
  22. package/dist/commands/human/shared.d.ts +26 -1
  23. package/dist/commands/human/shared.js +48 -10
  24. package/dist/commands/node.js +66 -4
  25. package/dist/commands/skill/author.js +2 -2
  26. package/dist/core/__tests__/cascade-close.test.js +199 -0
  27. package/dist/core/__tests__/daemon-boot.test.js +7 -0
  28. package/dist/core/__tests__/daemon-liveness.test.js +59 -4
  29. package/dist/core/__tests__/dead-pane-regression.test.js +151 -0
  30. package/dist/core/__tests__/fixtures/fake-pi-host.d.ts +2 -0
  31. package/dist/core/__tests__/fixtures/fake-pi-host.js +301 -0
  32. package/dist/core/__tests__/flagship-lifecycle.test.js +273 -0
  33. package/dist/core/__tests__/grace-clock.test.d.ts +1 -0
  34. package/dist/core/__tests__/grace-clock.test.js +115 -0
  35. package/dist/core/__tests__/helpers/harness.d.ts +78 -0
  36. package/dist/core/__tests__/helpers/harness.js +406 -0
  37. package/dist/core/__tests__/human-surface-target.test.d.ts +1 -0
  38. package/dist/core/__tests__/human-surface-target.test.js +98 -0
  39. package/dist/core/__tests__/lifecycle.test.js +6 -13
  40. package/dist/core/__tests__/live-mutation.test.d.ts +1 -0
  41. package/dist/core/__tests__/live-mutation.test.js +341 -0
  42. package/dist/core/__tests__/persona-subkind.test.js +18 -15
  43. package/dist/core/__tests__/placement-focus.test.js +53 -15
  44. package/dist/core/__tests__/relaunch.test.js +12 -12
  45. package/dist/core/__tests__/reset.test.js +11 -6
  46. package/dist/core/__tests__/spike-harness.test.d.ts +1 -0
  47. package/dist/core/__tests__/spike-harness.test.js +241 -0
  48. package/dist/core/__tests__/subscription-delivery.test.d.ts +1 -0
  49. package/dist/core/__tests__/subscription-delivery.test.js +233 -0
  50. package/dist/core/canvas/browse/__tests__/model.test.d.ts +1 -0
  51. package/dist/core/canvas/browse/__tests__/model.test.js +142 -0
  52. package/dist/core/canvas/browse/__tests__/render.test.d.ts +1 -0
  53. package/dist/core/canvas/browse/__tests__/render.test.js +102 -0
  54. package/dist/core/canvas/browse/app.d.ts +4 -0
  55. package/dist/core/canvas/browse/app.js +349 -0
  56. package/dist/core/canvas/browse/model.d.ts +97 -0
  57. package/dist/core/canvas/browse/model.js +258 -0
  58. package/dist/core/canvas/browse/render.d.ts +41 -0
  59. package/dist/core/canvas/browse/render.js +387 -0
  60. package/dist/core/canvas/browse/terminal.d.ts +23 -0
  61. package/dist/core/canvas/browse/terminal.js +100 -0
  62. package/dist/core/canvas/canvas.d.ts +9 -2
  63. package/dist/core/canvas/canvas.js +41 -3
  64. package/dist/core/canvas/paths.d.ts +4 -1
  65. package/dist/core/canvas/paths.js +10 -4
  66. package/dist/core/canvas/render.d.ts +10 -0
  67. package/dist/core/canvas/render.js +25 -1
  68. package/dist/core/canvas/types.js +2 -2
  69. package/dist/core/feed/inbox.d.ts +0 -3
  70. package/dist/core/feed/inbox.js +1 -5
  71. package/dist/core/help.d.ts +6 -0
  72. package/dist/core/help.js +7 -0
  73. package/dist/core/personas/index.d.ts +4 -3
  74. package/dist/core/personas/index.js +3 -2
  75. package/dist/core/personas/loader.d.ts +34 -16
  76. package/dist/core/personas/loader.js +102 -29
  77. package/dist/core/personas/resolve.d.ts +4 -4
  78. package/dist/core/personas/resolve.js +16 -14
  79. package/dist/core/runtime/busy.d.ts +8 -0
  80. package/dist/core/runtime/busy.js +46 -0
  81. package/dist/core/runtime/lifecycle.d.ts +1 -1
  82. package/dist/core/runtime/lifecycle.js +12 -4
  83. package/dist/core/runtime/naming.d.ts +3 -3
  84. package/dist/core/runtime/naming.js +6 -6
  85. package/dist/core/runtime/placement.d.ts +32 -5
  86. package/dist/core/runtime/placement.js +81 -14
  87. package/dist/core/runtime/reset.d.ts +11 -8
  88. package/dist/core/runtime/reset.js +23 -18
  89. package/dist/core/spawn.d.ts +20 -1
  90. package/dist/core/spawn.js +52 -5
  91. package/dist/daemon/crtrd.js +43 -21
  92. package/dist/pi-extensions/canvas-nav.js +106 -55
  93. package/dist/pi-extensions/canvas-resume.d.ts +0 -1
  94. package/dist/pi-extensions/canvas-resume.js +35 -126
  95. package/dist/pi-extensions/canvas-stophook.d.ts +1 -1
  96. package/dist/pi-extensions/canvas-stophook.js +16 -0
  97. package/dist/prompts/skill.js +6 -1
  98. package/package.json +1 -1
  99. package/dist/commands/__tests__/skill.test.js +0 -290
  100. package/dist/core/__tests__/pkg.test.js +0 -218
  101. package/dist/core/__tests__/sys.test.js +0 -208
  102. /package/dist/{commands/__tests__/skill.test.d.ts → core/__tests__/cascade-close.test.d.ts} +0 -0
  103. /package/dist/core/__tests__/{pkg.test.d.ts → dead-pane-regression.test.d.ts} +0 -0
  104. /package/dist/core/__tests__/{sys.test.d.ts → flagship-lifecycle.test.d.ts} +0 -0
@@ -5,9 +5,10 @@
5
5
  * Resolution order (highest → lowest precedence): project > user > builtin.
6
6
  *
7
7
  * Layout on disk:
8
- * <root>/personas/<kind>/base.md
8
+ * <root>/personas/<kind>/PERSONA.md
9
9
  * <root>/personas/<kind>/orchestrator.md
10
10
  * <root>/personas/orchestration-kernel.md
11
+ * <root>/personas/<kind>/<...>/PERSONA.md (nested sub-personas)
11
12
  *
12
13
  * The builtin root is src/builtin-personas (or dist/builtin-personas in the
13
14
  * compiled build), resolved relative to this module — mirrors the pattern used
@@ -59,7 +60,7 @@ function personaSearchRoots() {
59
60
  // ---------------------------------------------------------------------------
60
61
  /**
61
62
  * Find the first existing file across the scope roots.
62
- * `relativePath` is relative to each root (e.g. 'general/base.md').
63
+ * `relativePath` is relative to each root (e.g. 'general/PERSONA.md').
63
64
  */
64
65
  function resolveFile(relativePath) {
65
66
  for (const root of personaSearchRoots()) {
@@ -92,6 +93,11 @@ function inlineIncludes(body) {
92
93
  return kernelBody.trim();
93
94
  });
94
95
  }
96
+ // ---------------------------------------------------------------------------
97
+ // Public API
98
+ // ---------------------------------------------------------------------------
99
+ /** The role-body filename for every kind/sub-persona (replaces legacy base.md). */
100
+ const PERSONA_FILE = 'PERSONA.md';
95
101
  /**
96
102
  * Load and parse a persona file for the given `kind` and `mode`.
97
103
  *
@@ -99,7 +105,8 @@ function inlineIncludes(body) {
99
105
  * On success, `@include` directives in the body are resolved and inlined.
100
106
  */
101
107
  export function loadPersona(kind, mode) {
102
- const relativePath = `${kind}/${mode}.md`;
108
+ // base PERSONA.md (the role body); orchestrator → its sibling orchestrator.md.
109
+ const relativePath = mode === 'orchestrator' ? `${kind}/orchestrator.md` : `${kind}/PERSONA.md`;
103
110
  const filePath = resolveFile(relativePath);
104
111
  if (!filePath)
105
112
  return null;
@@ -165,9 +172,11 @@ export function loadSpineFragment(hasManager) {
165
172
  return body.trim();
166
173
  }
167
174
  /**
168
- * Enumerate the kinds with at least one persona file (base.md or
175
+ * Enumerate the kinds with at least one persona file (PERSONA.md or
169
176
  * orchestrator.md) across all scope roots (project/user/builtin). Used to
170
- * validate a requested `--kind` and to list the valid choices.
177
+ * validate a requested `--kind` and to list the valid choices. Only the
178
+ * IMMEDIATE children of each root count — nested sub-personas never pollute
179
+ * the global kind list (see subPersonasFor).
171
180
  */
172
181
  export function availableKinds() {
173
182
  const kinds = new Set();
@@ -178,42 +187,106 @@ export function availableKinds() {
178
187
  if (!entry.isDirectory())
179
188
  continue;
180
189
  const dir = join(root, entry.name);
181
- if (existsSync(join(dir, 'base.md')) || existsSync(join(dir, 'orchestrator.md'))) {
190
+ if (existsSync(join(dir, PERSONA_FILE)) || existsSync(join(dir, 'orchestrator.md'))) {
182
191
  kinds.add(entry.name);
183
192
  }
184
193
  }
185
194
  }
186
195
  return [...kinds].sort();
187
196
  }
188
- const REVIEWERS_SUBDIR = 'reviewers';
189
197
  /**
190
- * Enumerate the reviewer sub-kinds owned by `parentKind` the specialist
191
- * personas at `<root>/<parentKind>/reviewers/<name>/base.md`, scanned across all
192
- * scope roots (project > user > builtin; highest precedence wins per name).
198
+ * The one-line "when to use this node type" gloss for `kind`, read from its
199
+ * `<kind>/PERSONA.md` `whenToUse` frontmatter (resolved project > user >
200
+ * builtin). Returns '' when the kind has no PERSONA.md or no `whenToUse`.
201
+ * Drives the dynamic kind list in `node new -h` / `node promote -h`.
202
+ */
203
+ export function kindWhenToUse(kind) {
204
+ const filePath = resolveFile(`${kind}/${PERSONA_FILE}`);
205
+ if (!filePath)
206
+ return '';
207
+ const { data } = parseFrontmatterGeneric(readFileSync(filePath, 'utf8'));
208
+ return data && typeof data['whenToUse'] === 'string' ? data['whenToUse'] : '';
209
+ }
210
+ /** Recursively yield every dir under `dir` (inclusive) that holds a PERSONA.md,
211
+ * with `relKind` = the dir's path relative to the scope root (slash-joined).
212
+ * Dirs WITHOUT a PERSONA.md (e.g. a `reviewers/` grouping namespace) are
213
+ * transparent — they yield nothing themselves but are still descended into,
214
+ * so `plan/reviewers/security` keeps that exact kind string. */
215
+ function* walkPersonaDirs(dir, relParts) {
216
+ const file = join(dir, PERSONA_FILE);
217
+ if (existsSync(file))
218
+ yield { relKind: relParts.join('/'), file };
219
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
220
+ if (entry.isDirectory())
221
+ yield* walkPersonaDirs(join(dir, entry.name), [...relParts, entry.name]);
222
+ }
223
+ }
224
+ /** Parse a sub-persona's `availableTo` frontmatter into its availability set.
225
+ * Returns the wildcard sentinel `'*'` for `"*"`/`"all"` (scalar or in an
226
+ * array), an explicit list of kind strings when present, else the default
227
+ * `[topKind]` (the top-level ancestor kind). */
228
+ function parseAvailableTo(data, topKind) {
229
+ const isWild = (s) => {
230
+ const t = s.trim().toLowerCase();
231
+ return t === '*' || t === 'all';
232
+ };
233
+ const v = data ? data['availableTo'] : undefined;
234
+ if (v === undefined)
235
+ return [topKind];
236
+ if (typeof v === 'string')
237
+ return isWild(v) ? '*' : [v];
238
+ if (Array.isArray(v)) {
239
+ const arr = v.filter((x) => typeof x === 'string');
240
+ if (arr.some(isWild))
241
+ return '*';
242
+ return arr.length > 0 ? arr : [topKind];
243
+ }
244
+ return [topKind];
245
+ }
246
+ /**
247
+ * Enumerate the sub-personas AVAILABLE TO `kind` — the nested specialist
248
+ * personas (e.g. `plan/reviewers/security`) a `kind` node may spawn, surfaced
249
+ * in its composed prompt (resolve.ts) and nowhere else.
193
250
  *
194
- * Sub-kinds are intentionally NOT global kinds: `availableKinds()` scans only the
195
- * immediate children of each persona root, so `<parentKind>/reviewers/*` never
196
- * leaks into the global list. A sub-kind is reachable only by its full kind
197
- * string and is surfaced only in its parent kind's composed prompt (resolve.ts).
198
- * Kind-parametric: any kind owns a roster simply by adding
199
- * `<kind>/reviewers/<name>/base.md` no code change.
251
+ * A sub-persona is any descendant dir (ANY depth) under a top-level kind dir
252
+ * that holds a PERSONA.md, EXCLUDING the top-level PERSONA.md itself. Its
253
+ * availability is its `availableTo` frontmatter: an explicit list of kind
254
+ * strings, or the wildcard `"*"`/`"all"` (visible to every kind); absent, it
255
+ * defaults to its own top-level ancestor kind. So the five `plan/reviewers/*`
256
+ * (no `availableTo`) are visible only to `plan`, while a sub-persona under
257
+ * `developer/` can declare `availableTo: [plan]` to surface in plan's menu —
258
+ * which is why ALL top-level kinds' descendants are scanned, not just `<kind>/`.
259
+ *
260
+ * Sub-personas are intentionally NOT global kinds: `availableKinds()` scans only
261
+ * the immediate children of each root, so a nested sub-persona never leaks into
262
+ * the global list; it is reachable only by its full kind string. Precedence is
263
+ * project > user > builtin keyed on the FULL kind string — the highest root that
264
+ * defines a given kind string wins (and owns its `availableTo`).
200
265
  */
201
- export function subKindsFor(parentKind) {
202
- const byName = new Map();
266
+ export function subPersonasFor(kind) {
267
+ const seen = new Set(); // full kind strings already resolved (higher root won)
268
+ const out = [];
203
269
  for (const root of personaSearchRoots()) {
204
- const dir = join(root, parentKind, REVIEWERS_SUBDIR);
205
- if (!existsSync(dir))
270
+ if (!existsSync(root))
206
271
  continue;
207
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
208
- if (!entry.isDirectory() || byName.has(entry.name))
209
- continue; // higher root already won
210
- const baseFile = join(dir, entry.name, 'base.md');
211
- if (!existsSync(baseFile))
272
+ for (const top of readdirSync(root, { withFileTypes: true })) {
273
+ if (!top.isDirectory())
212
274
  continue;
213
- const { data } = parseFrontmatterGeneric(readFileSync(baseFile, 'utf8'));
214
- const summary = data && typeof data['summary'] === 'string' ? data['summary'] : '';
215
- byName.set(entry.name, { kind: `${parentKind}/${REVIEWERS_SUBDIR}/${entry.name}`, name: entry.name, summary });
275
+ const topKind = top.name;
276
+ for (const { relKind, file } of walkPersonaDirs(join(root, topKind), [topKind])) {
277
+ if (relKind === topKind)
278
+ continue; // the top-level PERSONA.md is the kind itself, not a sub-persona
279
+ if (seen.has(relKind))
280
+ continue; // a higher root already resolved this kind string
281
+ seen.add(relKind);
282
+ const { data } = parseFrontmatterGeneric(readFileSync(file, 'utf8'));
283
+ const availableTo = parseAvailableTo(data, topKind);
284
+ if (availableTo !== '*' && !availableTo.includes(kind))
285
+ continue;
286
+ const whenToUse = data && typeof data['whenToUse'] === 'string' ? data['whenToUse'] : '';
287
+ out.push({ kind: relKind, name: relKind.split('/').pop(), whenToUse });
288
+ }
216
289
  }
217
290
  }
218
- return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
291
+ return out.sort((a, b) => a.kind.localeCompare(b.kind));
219
292
  }
@@ -4,17 +4,17 @@
4
4
  *
5
5
  * Composition rules:
6
6
  * mode==='base'
7
- * → load <kind>/base.md; if missing fall back to general defaults.
7
+ * → load <kind>/PERSONA.md; if missing fall back to general defaults.
8
8
  *
9
9
  * mode==='orchestrator'
10
10
  * → prefer <kind>/orchestrator.md (which must embed the kernel via
11
11
  * @include orchestration-kernel.md — inlined by the loader).
12
12
  * If no orchestrator.md exists for this kind, compose:
13
- * <kind>/base.md body + '\n\n' + kernel body
14
- * If even the base is missing, fall back to general defaults + kernel.
13
+ * <kind>/PERSONA.md body + '\n\n' + kernel body
14
+ * If even the PERSONA.md is missing, fall back to general defaults + kernel.
15
15
  *
16
16
  * Frontmatter from whichever file is the primary source (orchestrator.md >
17
- * base.md) supplies model/skills/extensions/tools. Lifecycle and spine position
17
+ * PERSONA.md) supplies model/skills/extensions/tools. Lifecycle and spine position
18
18
  * are INPUTS (the caller decides them — root/child, terminal/resident), not
19
19
  * derived here; they select the lifecycle/spine protocol fragments spliced
20
20
  * ahead of the persona body.
@@ -4,22 +4,22 @@
4
4
  *
5
5
  * Composition rules:
6
6
  * mode==='base'
7
- * → load <kind>/base.md; if missing fall back to general defaults.
7
+ * → load <kind>/PERSONA.md; if missing fall back to general defaults.
8
8
  *
9
9
  * mode==='orchestrator'
10
10
  * → prefer <kind>/orchestrator.md (which must embed the kernel via
11
11
  * @include orchestration-kernel.md — inlined by the loader).
12
12
  * If no orchestrator.md exists for this kind, compose:
13
- * <kind>/base.md body + '\n\n' + kernel body
14
- * If even the base is missing, fall back to general defaults + kernel.
13
+ * <kind>/PERSONA.md body + '\n\n' + kernel body
14
+ * If even the PERSONA.md is missing, fall back to general defaults + kernel.
15
15
  *
16
16
  * Frontmatter from whichever file is the primary source (orchestrator.md >
17
- * base.md) supplies model/skills/extensions/tools. Lifecycle and spine position
17
+ * PERSONA.md) supplies model/skills/extensions/tools. Lifecycle and spine position
18
18
  * are INPUTS (the caller decides them — root/child, terminal/resident), not
19
19
  * derived here; they select the lifecycle/spine protocol fragments spliced
20
20
  * ahead of the persona body.
21
21
  */
22
- import { loadPersona, loadKernel, loadRuntimeBase, loadSpineFragment, loadLifecycleFragment, subKindsFor } from './loader.js';
22
+ import { loadPersona, loadKernel, loadRuntimeBase, loadSpineFragment, loadLifecycleFragment, subPersonasFor } from './loader.js';
23
23
  // ---------------------------------------------------------------------------
24
24
  // Helpers
25
25
  // ---------------------------------------------------------------------------
@@ -40,24 +40,26 @@ function fallbackBasePrompt(kind) {
40
40
  * fragment (report-up vs. silent, keyed on whether the node has a manager),
41
41
  * then the lifecycle fragment (finish-with-`push final` vs. dormant/wake). The
42
42
  * kind×mode persona body follows after a rule. Empty fragments drop out. */
43
- /** Render the "sub-kinds you may spawn" menu for a kind that owns a roster.
44
- * Returns '' when the kind owns none. Data-driven: one line per sub-kind, its
45
- * spawn string + its `summary`. Adding a roster file makes it appear here. */
46
- function renderSubKindMenu(kind) {
47
- const subs = subKindsFor(kind);
43
+ /** Render the "sub-personas you may spawn" menu for a kind that has any
44
+ * available to it. Returns '' when none are. Data-driven: one line per
45
+ * sub-persona, its spawn string + its `whenToUse`. A sub-persona surfaces here
46
+ * for a kind when its `availableTo` includes that kind (default: its own
47
+ * top-level ancestor) or is the wildcard. */
48
+ function renderSubPersonaMenu(kind) {
49
+ const subs = subPersonasFor(kind);
48
50
  if (subs.length === 0)
49
51
  return '';
50
- const lines = subs.map((s) => `- \`${s.kind}\` — ${s.summary}`);
52
+ const lines = subs.map((s) => `- \`${s.kind}\` — ${s.whenToUse}`);
51
53
  return [
52
- '## Reviewer sub-kinds you may spawn',
54
+ '## Sub-personas you may spawn',
53
55
  '',
54
- `These specialist reviewers exist only in the ${kind} kind's world — no other kind sees them. Spawn one with \`crtr node new --kind <sub-kind> "<scope>"\`, giving it only its scope, never your suspicions: a reviewer handed a hint anchors on it instead of finding problems independently.`,
56
+ `These specialist sub-personas are available to the ${kind} kind. Spawn one with \`crtr node new --kind <sub> "<scope>"\`, giving it only its scope, never your suspicions: a reviewer handed a hint anchors on it instead of finding problems independently.`,
55
57
  '',
56
58
  ...lines,
57
59
  ].join('\n');
58
60
  }
59
61
  function composeProtocol(personaPrompt, kind, lifecycle, hasManager) {
60
- const menu = renderSubKindMenu(kind);
62
+ const menu = renderSubPersonaMenu(kind);
61
63
  const body = menu ? `${personaPrompt}\n\n${menu}` : personaPrompt;
62
64
  const protocol = [
63
65
  loadRuntimeBase(),
@@ -0,0 +1,8 @@
1
+ /** Mark a node mid-turn (pi entered a turn). Best-effort. */
2
+ export declare function markBusy(nodeId: string): void;
3
+ /** Clear the mid-turn marker (the turn ended, however it routed). Best-effort. */
4
+ export declare function clearBusy(nodeId: string): void;
5
+ /** Is the node currently inside a turn? AND this with `pidAlive` at the call
6
+ * site — a stale marker from a crashed pi is harmless because the dead pid
7
+ * fails the AND. */
8
+ export declare function isBusy(nodeId: string): boolean;
@@ -0,0 +1,46 @@
1
+ // busy.ts — the "is pi actually mid-turn" signal (a marker file, no db column).
2
+ //
3
+ // The disposition of a focus's OUTGOING node on a hot-swap (placement.ts
4
+ // `outgoingDisposition`) must distinguish a terminal worker that is GENUINELY
5
+ // mid-turn (keep it running off-screen, Invariant F2) from one merely PARKED at
6
+ // its prompt with a live pi (a viewer revived for inspection — despawn it back to
7
+ // dormant on focus-away). A live pid is NOT that signal: a parked node has a live
8
+ // pid too. This marker is.
9
+ //
10
+ // `<jobDir>/busy` exists for exactly the span pi is inside a turn: the stophook
11
+ // touches it on `agent_start` and unlinks it at the top of `agent_end` (and
12
+ // defensively on `session_shutdown`). It is always AND-ed with `pidAlive` at the
13
+ // read site, so a stale marker (process crashed mid-turn without firing
14
+ // agent_end) is harmless — the dead pid fails the AND and the node is reaped.
15
+ // No db migration, atomic touch/unlink, best-effort (never throws).
16
+ import { existsSync, writeFileSync, rmSync, mkdirSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ import { jobDir } from '../canvas/index.js';
19
+ function busyPath(nodeId) {
20
+ return join(jobDir(nodeId), 'busy');
21
+ }
22
+ /** Mark a node mid-turn (pi entered a turn). Best-effort. */
23
+ export function markBusy(nodeId) {
24
+ try {
25
+ mkdirSync(jobDir(nodeId), { recursive: true });
26
+ writeFileSync(busyPath(nodeId), '');
27
+ }
28
+ catch {
29
+ /* best-effort */
30
+ }
31
+ }
32
+ /** Clear the mid-turn marker (the turn ended, however it routed). Best-effort. */
33
+ export function clearBusy(nodeId) {
34
+ try {
35
+ rmSync(busyPath(nodeId), { force: true });
36
+ }
37
+ catch {
38
+ /* best-effort */
39
+ }
40
+ }
41
+ /** Is the node currently inside a turn? AND this with `pidAlive` at the call
42
+ * site — a stale marker from a crashed pi is harmless because the dead pid
43
+ * fails the AND. */
44
+ export function isBusy(nodeId) {
45
+ return existsSync(busyPath(nodeId));
46
+ }
@@ -2,7 +2,7 @@ import type { NodeMeta } from '../canvas/types.js';
2
2
  /** The lifecycle events — the only vocabulary for moving a node's status/intent.
3
3
  * Each maps (in the table below) to a target status and/or intent plus the set
4
4
  * of from-statuses it is legal from. */
5
- export type LifecycleEvent = 'finalize' | 'reap' | 'cancel' | 'crash' | 'yield' | 'release' | 'revive' | 'boot';
5
+ export type LifecycleEvent = 'finalize' | 'cancel' | 'crash' | 'yield' | 'release' | 'revive' | 'boot';
6
6
  /** Enact a lifecycle event on a node: validate the from-status against the
7
7
  * table, then write status+intent in ONE atomic statement (so they can never
8
8
  * disagree). Returns the hydrated node view after the write.
@@ -17,9 +17,17 @@
17
17
  // "flip status to a non-supervised value + clear intent BEFORE killing the
18
18
  // window" — the daemon only ever revives active|idle nodes, so a teardown must
19
19
  // leave the node done/canceled first to close the revive race. That invariant is
20
- // now the DEFINITION of the `reap`/`cancel` events: callers flip via transition()
20
+ // now the DEFINITION of the `cancel` event: callers flip via transition()
21
21
  // and only THEN kill the window.
22
22
  //
23
+ // Unification (A5, human-confirmed 2026-06-06): an externally-reaped node — torn
24
+ // down because the user moved on (close cascade) OR because a root reset/relaunch
25
+ // superseded it — ends `canceled`, NOT `done`. `done` is reserved for a node that
26
+ // finished its OWN work (finalize). The old `reap` event (→ done) was identical to
27
+ // `cancel` in every field and side effect once unified on status, so it was
28
+ // COLLAPSED into `cancel`; reset.ts's reapDescendants + relaunchRoot park-old now
29
+ // route through `cancel`.
30
+ //
23
31
  // Layering note: lifecycle.ts is runtime, but it is the canvas write surface's
24
32
  // `transition` verb (the only writer of status+intent), so it owns its atomic
25
33
  // row UPDATE directly via openDb — the one sanctioned exception to "only
@@ -35,9 +43,9 @@ const LIVE = ['active', 'idle'];
35
43
  const TRANSITIONS = {
36
44
  // feed.push(final) · queue.cancelJob · markCleanExitDone (clean quit).
37
45
  finalize: { status: 'done', intent: 'done', from: LIVE },
38
- // reapDescendants · relaunchRoot park-old. Forced teardown → done, intent cleared.
39
- reap: { status: 'done', intent: null, from: ANY },
40
- // closeNode cascade. Forced teardown canceled, intent cleared.
46
+ // closeNode cascade · reapDescendants · relaunchRoot park-old. Forced teardown
47
+ // of a node that did NOT finish its own work → canceled, intent cleared. (A5:
48
+ // done is reserved for finalize; every external reap unifies on canceled.)
41
49
  cancel: { status: 'canceled', intent: null, from: ANY },
42
50
  // daemon superviseTick: window gone with no yield/release intent. Intent KEPT
43
51
  // (the dead log line still reports it).
@@ -1,12 +1,12 @@
1
1
  import type { NodeMeta } from '../canvas/index.js';
2
- /** Coerce arbitrary text into a 3-5 word kebab-case name, or '' if nothing
2
+ /** Coerce arbitrary text into a 3-8 word kebab-case name, or '' if nothing
3
3
  * usable survives. Lowercases, keeps [a-z0-9], collapses everything else to a
4
- * single hyphen, and clamps to the first 5 words. */
4
+ * single hyphen, and clamps to the first 8 words. */
5
5
  export declare function sanitizeSessionName(raw: string): string;
6
6
  /** Local fallback: derive a name straight from the prompt (no pi call). Drops
7
7
  * stop-words, takes the first few content words. */
8
8
  export declare function slugFromPrompt(prompt: string): string;
9
- /** Synchronously ask pi for a 2-4 word kebab name for `prompt`. Blocks up to
9
+ /** Synchronously ask pi for a 3-8 word kebab name for `prompt`. Blocks up to
10
10
  * NAME_TIMEOUT_MS; on any failure (non-zero exit, timeout, empty/garbled
11
11
  * output) falls back to a local slug. Returns '' only for an empty prompt. */
12
12
  export declare function generateSessionName(prompt: string): string;
@@ -2,7 +2,7 @@
2
2
  // handle for the editor label.
3
3
  //
4
4
  // A node's editor label is `<kind> (<mode>) <name> <cycle>` (see editorLabel in
5
- // launch.ts). The `<name>` is a 3-5 word kebab-case "description" derived from
5
+ // launch.ts). The `<name>` is a 3-8 word kebab-case "description" derived from
6
6
  // the first prompt by asking pi headlessly (`pi -p`), persisted on the node's
7
7
  // meta so it survives revives and shows in every cycle.
8
8
  //
@@ -26,7 +26,7 @@ const PROMPT_CAP = 2000;
26
26
  const NAME_TIMEOUT_MS = 20_000;
27
27
  const NAME_SYSTEM_PROMPT = 'You name coding-agent work sessions. This name is a label used to identify the ' +
28
28
  'session at a glance among many other concurrent programming sessions, so it must ' +
29
- 'describe what the task is about. Reply with ONLY a concise 3-5 word name in ' +
29
+ 'describe what the task is about. Reply with ONLY a concise 3-8 word name in ' +
30
30
  'kebab-case: lowercase words joined by single hyphens (e.g. `refactor-auth-token-flow`, ' +
31
31
  '`add-csv-export-endpoint`). No punctuation, quotes, prose, or trailing text. ' +
32
32
  'Output JUST the name, nothing else.';
@@ -43,9 +43,9 @@ const STOPWORDS = new Set([
43
43
  'is', 'are', 'be', 'this', 'that', 'it', 'as', 'at', 'by', 'from', 'into',
44
44
  'please', 'can', 'you', 'i', 'we', 'my', 'our', 'me', 'so', 'then',
45
45
  ]);
46
- /** Coerce arbitrary text into a 3-5 word kebab-case name, or '' if nothing
46
+ /** Coerce arbitrary text into a 3-8 word kebab-case name, or '' if nothing
47
47
  * usable survives. Lowercases, keeps [a-z0-9], collapses everything else to a
48
- * single hyphen, and clamps to the first 5 words. */
48
+ * single hyphen, and clamps to the first 8 words. */
49
49
  export function sanitizeSessionName(raw) {
50
50
  const firstLine = (raw ?? '').split('\n').map((l) => l.trim()).find((l) => l !== '') ?? '';
51
51
  const words = firstLine
@@ -53,7 +53,7 @@ export function sanitizeSessionName(raw) {
53
53
  .replace(/[^a-z0-9]+/g, '-')
54
54
  .split('-')
55
55
  .filter((w) => w !== '');
56
- return words.slice(0, 5).join('-');
56
+ return words.slice(0, 8).join('-');
57
57
  }
58
58
  /** Local fallback: derive a name straight from the prompt (no pi call). Drops
59
59
  * stop-words, takes the first few content words. */
@@ -97,7 +97,7 @@ function nameArgs(prompt) {
97
97
  argv.push(nameUserPrompt(prompt));
98
98
  return argv;
99
99
  }
100
- /** Synchronously ask pi for a 2-4 word kebab name for `prompt`. Blocks up to
100
+ /** Synchronously ask pi for a 3-8 word kebab name for `prompt`. Blocks up to
101
101
  * NAME_TIMEOUT_MS; on any failure (non-zero exit, timeout, empty/garbled
102
102
  * output) falls back to a local slug. Returns '' only for an empty prompt. */
103
103
  export function generateSessionName(prompt) {
@@ -15,6 +15,16 @@ export declare function focusByPane(pane: string): FocusRow | null;
15
15
  export declare function focusedNodes(): Set<string>;
16
16
  /** Every focus row (every live viewport). */
17
17
  export declare function listFocuses(): FocusRow[];
18
+ /** The on-screen viewport a human-in-the-loop prompt raised by `nodeId` should
19
+ * surface into: the HIGHEST FOCUSED node of nodeId's graph — the focused node
20
+ * closest to the graph root, i.e. the session/window the user is actually
21
+ * watching this work in. Walks nodeId's spine to its root, enumerates the whole
22
+ * tree root-first (`view` is BFS ⇒ shallowest first), and returns the focus row
23
+ * of the first node that occupies a viewport. null when nothing in the graph is
24
+ * on screen — the caller then surfaces in the user's attached pane rather than
25
+ * the backstage node session. PURE (db reads only): no tmux probe, so the pane
26
+ * may be stale; the caller liveness-checks before targeting it. */
27
+ export declare function graphSurfaceTarget(nodeId: string): FocusRow | null;
18
28
  /** The cached LOCATION as stored on a node row: the authoritative `pane` handle
19
29
  * plus its derived window/session cache. */
20
30
  export interface CachedLocation {
@@ -171,16 +181,32 @@ export interface FocusResult {
171
181
  revived: boolean;
172
182
  }
173
183
  /** PURE disposition of a focus's outgoing occupant after a retarget swap (§2.5/
174
- * §1.3): a still-generating node moves to backstage (F2); a holder pane or a
175
- * done/dormant node has its (now-backstage) pane reaped (Invariant P: a
176
- * not-focused + not-generating node has NO pane). Unit-testable in isolation. */
184
+ * §1.3). Four signals decide one of three fates; unit-testable in isolation:
185
+ * - `kill` — a holder pane (no row) or a done/dead/canceled node: reap the
186
+ * (now-backstage) pane (Invariant P: not-focused + not-live
187
+ * no pane).
188
+ * - `backstage`— a human-driven RESIDENT node (editor/root/orchestrator — NEVER
189
+ * despawned on focus-away), or a terminal worker that is
190
+ * genuinely MID-TURN (Invariant F2 — keeps running off-screen).
191
+ * - `release` — a PARKED terminal viewer (live but not mid-turn): a node
192
+ * revived only for inspection. Despawn it back to dormant
193
+ * (transition `release` → idle/idle-release) and reap its pane;
194
+ * the daemon revives it on its inbox, or the user re-focuses.
195
+ * This is the bug fix: such a node was misclassified as
196
+ * generating and left stuck active forever.
197
+ * Order matters: `resident` is checked BEFORE `generating` so a resident node is
198
+ * always kept warm regardless of whether it happens to be mid-turn. */
177
199
  export type OutgoingAction = {
178
200
  kind: 'backstage';
179
201
  } | {
180
202
  kind: 'kill';
203
+ } | {
204
+ kind: 'release';
181
205
  };
182
206
  export declare function outgoingDisposition(o: {
183
207
  exists: boolean;
208
+ live: boolean;
209
+ resident: boolean;
184
210
  generating: boolean;
185
211
  }): OutgoingAction;
186
212
  /** Open a NEW viewport (§2.3, F4) — the ONLY path a new pane appears in a user
@@ -210,8 +236,9 @@ export declare function registerRootFocus(nodeId: string, pane: string, session:
210
236
  * - `swapPaneInPlace(pin, focusPane)`: incoming → the viewport slot; the
211
237
  * outgoing occupant → incoming's old (backstage) slot, %ids preserved
212
238
  * (cross-session swap confirmed by the spike).
213
- * - outgoing still generating → backstage (F2); else reap its now-backstage
214
- * pane (Invariant P). A holder occupant (no node row) is always reaped.
239
+ * - outgoing resident OR still mid-turn → backstage (kept warm / F2); a parked
240
+ * terminal viewer RELEASE (status idle, pane reaped); a holder or
241
+ * done/dormant occupant → reap its now-backstage pane (Invariant P).
215
242
  * Arms remain-on-exit on the viewport (F3); the focus row is the record. */
216
243
  export declare function retargetFocus(focusId: string, incoming: string, revive: Reviver): FocusResult;
217
244
  /** The front door for `node focus` / `node cycle` (§2.3): resolve which focus the