@crouton-kit/crouter 0.3.16 → 0.3.17

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 (72) hide show
  1. package/dist/builtin-personas/orchestration-kernel.md +6 -6
  2. package/dist/builtin-personas/plan/base.md +1 -1
  3. package/dist/builtin-personas/spec/base.md +1 -1
  4. package/dist/commands/canvas-browse.d.ts +2 -0
  5. package/dist/commands/canvas-browse.js +45 -0
  6. package/dist/commands/canvas-prune.js +11 -2
  7. package/dist/commands/canvas.js +3 -2
  8. package/dist/commands/node.js +13 -0
  9. package/dist/commands/skill/author.js +2 -2
  10. package/dist/core/__tests__/cascade-close.test.js +199 -0
  11. package/dist/core/__tests__/daemon-boot.test.js +7 -0
  12. package/dist/core/__tests__/daemon-liveness.test.js +59 -4
  13. package/dist/core/__tests__/dead-pane-regression.test.js +151 -0
  14. package/dist/core/__tests__/fixtures/fake-pi-host.d.ts +2 -0
  15. package/dist/core/__tests__/fixtures/fake-pi-host.js +301 -0
  16. package/dist/core/__tests__/flagship-lifecycle.test.js +273 -0
  17. package/dist/core/__tests__/grace-clock.test.d.ts +1 -0
  18. package/dist/core/__tests__/grace-clock.test.js +115 -0
  19. package/dist/core/__tests__/helpers/harness.d.ts +78 -0
  20. package/dist/core/__tests__/helpers/harness.js +406 -0
  21. package/dist/core/__tests__/lifecycle.test.js +6 -13
  22. package/dist/core/__tests__/live-mutation.test.d.ts +1 -0
  23. package/dist/core/__tests__/live-mutation.test.js +341 -0
  24. package/dist/core/__tests__/placement-focus.test.js +53 -15
  25. package/dist/core/__tests__/relaunch.test.js +12 -12
  26. package/dist/core/__tests__/reset.test.js +11 -6
  27. package/dist/core/__tests__/spike-harness.test.d.ts +1 -0
  28. package/dist/core/__tests__/spike-harness.test.js +241 -0
  29. package/dist/core/__tests__/subscription-delivery.test.d.ts +1 -0
  30. package/dist/core/__tests__/subscription-delivery.test.js +233 -0
  31. package/dist/core/canvas/browse/__tests__/model.test.d.ts +1 -0
  32. package/dist/core/canvas/browse/__tests__/model.test.js +142 -0
  33. package/dist/core/canvas/browse/__tests__/render.test.d.ts +1 -0
  34. package/dist/core/canvas/browse/__tests__/render.test.js +102 -0
  35. package/dist/core/canvas/browse/app.d.ts +4 -0
  36. package/dist/core/canvas/browse/app.js +349 -0
  37. package/dist/core/canvas/browse/model.d.ts +97 -0
  38. package/dist/core/canvas/browse/model.js +258 -0
  39. package/dist/core/canvas/browse/render.d.ts +41 -0
  40. package/dist/core/canvas/browse/render.js +387 -0
  41. package/dist/core/canvas/browse/terminal.d.ts +23 -0
  42. package/dist/core/canvas/browse/terminal.js +100 -0
  43. package/dist/core/canvas/canvas.d.ts +9 -2
  44. package/dist/core/canvas/canvas.js +41 -3
  45. package/dist/core/canvas/render.d.ts +10 -0
  46. package/dist/core/canvas/render.js +25 -1
  47. package/dist/core/feed/inbox.d.ts +0 -3
  48. package/dist/core/feed/inbox.js +1 -5
  49. package/dist/core/runtime/busy.d.ts +8 -0
  50. package/dist/core/runtime/busy.js +46 -0
  51. package/dist/core/runtime/lifecycle.d.ts +1 -1
  52. package/dist/core/runtime/lifecycle.js +12 -4
  53. package/dist/core/runtime/naming.d.ts +3 -3
  54. package/dist/core/runtime/naming.js +6 -6
  55. package/dist/core/runtime/placement.d.ts +22 -5
  56. package/dist/core/runtime/placement.js +44 -13
  57. package/dist/core/runtime/reset.d.ts +11 -8
  58. package/dist/core/runtime/reset.js +23 -18
  59. package/dist/daemon/crtrd.js +43 -21
  60. package/dist/pi-extensions/canvas-nav.js +29 -25
  61. package/dist/pi-extensions/canvas-resume.d.ts +0 -1
  62. package/dist/pi-extensions/canvas-resume.js +35 -126
  63. package/dist/pi-extensions/canvas-stophook.d.ts +1 -1
  64. package/dist/pi-extensions/canvas-stophook.js +16 -0
  65. package/dist/prompts/skill.js +6 -1
  66. package/package.json +1 -1
  67. package/dist/commands/__tests__/skill.test.js +0 -290
  68. package/dist/core/__tests__/pkg.test.js +0 -218
  69. package/dist/core/__tests__/sys.test.js +0 -208
  70. /package/dist/{commands/__tests__/skill.test.d.ts → core/__tests__/cascade-close.test.d.ts} +0 -0
  71. /package/dist/core/__tests__/{pkg.test.d.ts → dead-pane-regression.test.d.ts} +0 -0
  72. /package/dist/core/__tests__/{sys.test.d.ts → flagship-lifecycle.test.d.ts} +0 -0
@@ -0,0 +1,23 @@
1
+ export interface Key {
2
+ upArrow: boolean;
3
+ downArrow: boolean;
4
+ leftArrow: boolean;
5
+ rightArrow: boolean;
6
+ return: boolean;
7
+ escape: boolean;
8
+ ctrl: boolean;
9
+ meta: boolean;
10
+ tab: boolean;
11
+ shiftTab: boolean;
12
+ backspace: boolean;
13
+ }
14
+ export declare function parseKeypress(data: Buffer): {
15
+ input: string;
16
+ key: Key;
17
+ };
18
+ export declare function setupTerminal(): void;
19
+ export declare function restoreTerminal(): void;
20
+ export declare function getTerminalSize(): {
21
+ cols: number;
22
+ rows: number;
23
+ };
@@ -0,0 +1,100 @@
1
+ // terminal.ts — raw-mode helpers for the `crtr canvas browse` TUI.
2
+ //
3
+ // Hand-rolled (no deps), mirroring humanloop's src/tui/terminal.ts. Extends its
4
+ // key parsing beyond up/down/return/escape/tab/backspace/ctrl + printable input:
5
+ // adds leftArrow/rightArrow (cursor keys) and shiftTab (`\x1b[Z`) so the browser
6
+ // can drive tree expand/collapse and tab cycling.
7
+ function emptyKey() {
8
+ return {
9
+ upArrow: false,
10
+ downArrow: false,
11
+ leftArrow: false,
12
+ rightArrow: false,
13
+ return: false,
14
+ escape: false,
15
+ ctrl: false,
16
+ meta: false,
17
+ tab: false,
18
+ shiftTab: false,
19
+ backspace: false,
20
+ };
21
+ }
22
+ export function parseKeypress(data) {
23
+ const str = data.toString('utf8');
24
+ const key = emptyKey();
25
+ if (str === '\x1b[A') {
26
+ key.upArrow = true;
27
+ return { input: '', key };
28
+ }
29
+ if (str === '\x1b[B') {
30
+ key.downArrow = true;
31
+ return { input: '', key };
32
+ }
33
+ if (str === '\x1b[C') {
34
+ key.rightArrow = true;
35
+ return { input: '', key };
36
+ }
37
+ if (str === '\x1b[D') {
38
+ key.leftArrow = true;
39
+ return { input: '', key };
40
+ }
41
+ if (str === '\x1b[Z') {
42
+ key.shiftTab = true;
43
+ return { input: '', key };
44
+ }
45
+ if (str === '\r' || str === '\n') {
46
+ key.return = true;
47
+ return { input: '', key };
48
+ }
49
+ // Alt+Backspace: terminals send ESC followed by DEL/BS. Must precede the
50
+ // bare-ESC check so the two-byte sequence isn't swallowed as plain escape.
51
+ if (str === '\x1b\x7f' || str === '\x1b\b') {
52
+ key.meta = true;
53
+ key.backspace = true;
54
+ return { input: '', key };
55
+ }
56
+ if (str === '\x1b') {
57
+ key.escape = true;
58
+ return { input: '', key };
59
+ }
60
+ if (str === '\t') {
61
+ key.tab = true;
62
+ return { input: '', key };
63
+ }
64
+ if (str === '\x7f' || str === '\b') {
65
+ key.backspace = true;
66
+ return { input: '', key };
67
+ }
68
+ if (str.length === 1 && str.charCodeAt(0) < 32) {
69
+ key.ctrl = true;
70
+ const ch = String.fromCharCode(str.charCodeAt(0) + 64).toLowerCase();
71
+ return { input: ch, key };
72
+ }
73
+ // Multi-byte chunks (paste, multi-byte UTF-8, unknown escape sequences) are
74
+ // returned as-is in `input`; the input-mode handler sanitises them before
75
+ // appending to its buffer. Top-level handlers ignore strings of length > 1.
76
+ return { input: str, key };
77
+ }
78
+ export function setupTerminal() {
79
+ if (!process.stdin.isTTY) {
80
+ throw new Error('crtr canvas browse requires an interactive terminal (TTY)');
81
+ }
82
+ process.stdin.setRawMode(true);
83
+ process.stdin.resume();
84
+ process.stdin.setEncoding('utf8');
85
+ process.stdout.write('\x1b[?25l'); // hide cursor
86
+ process.stdout.write('\x1b[?1049h'); // alt screen
87
+ process.stdout.write('\x1b[2J\x1b[H'); // clear
88
+ }
89
+ export function restoreTerminal() {
90
+ process.stdout.write('\x1b[?25h'); // show cursor
91
+ process.stdout.write('\x1b[?1049l'); // restore screen
92
+ process.stdin.setRawMode(false);
93
+ process.stdin.pause();
94
+ }
95
+ export function getTerminalSize() {
96
+ return {
97
+ cols: process.stdout.columns || 80,
98
+ rows: process.stdout.rows || 24,
99
+ };
100
+ }
@@ -86,8 +86,14 @@ export interface PruneResult {
86
86
  * `created` is older than `ttlDays`, bounding the otherwise-unbounded growth of
87
87
  * node rows + dirs. The edges→nodes FK (`ON DELETE CASCADE`, migration v4) GCs
88
88
  * each pruned node's edges automatically; the on-disk `nodes/<id>/` dir is
89
- * removed too. Live nodes are NEVER touched: active | idle are the daemon's
90
- * domain, a DISJOINT status set, so prune and supervision can't interfere.
89
+ * removed too.
90
+ *
91
+ * With `includeStale`, ALSO prunes nominally-live (active | idle) nodes past the
92
+ * TTL whose process is provably gone — `pi_pid` is NULL or no longer alive. This
93
+ * reaps stale roots (a bare `crtr` whose pi died without the row transitioning),
94
+ * which the daemon's supervision never reconciled. A genuinely-running node keeps
95
+ * a live `pi_pid`, so it is protected, as is the CALLER ($CRTR_NODE_ID). Without
96
+ * the flag, active | idle are NEVER touched (the daemon's domain).
91
97
  *
92
98
  * The row deletes run in ONE transaction (so the sweep is all-or-nothing); the
93
99
  * dir removals follow after COMMIT — the fs isn't transactional, and by then the
@@ -96,4 +102,5 @@ export interface PruneResult {
96
102
  export declare function pruneNodes(opts: {
97
103
  ttlDays: number;
98
104
  dryRun?: boolean;
105
+ includeStale?: boolean;
99
106
  }): PruneResult;
@@ -352,12 +352,29 @@ export function rebuildIndex() {
352
352
  recordSpawn(meta.node_id, prov);
353
353
  }
354
354
  }
355
+ /** Is `pid` a live process? `kill(pid, 0)` sends no signal — it only probes
356
+ * existence/permission. ESRCH ⇒ gone; EPERM ⇒ alive but not ours (still alive). */
357
+ function pidAlive(pid) {
358
+ try {
359
+ process.kill(pid, 0);
360
+ return true;
361
+ }
362
+ catch (e) {
363
+ return e.code === 'EPERM';
364
+ }
365
+ }
355
366
  /** Retention sweep: remove TERMINAL nodes (status dead | done | canceled) whose
356
367
  * `created` is older than `ttlDays`, bounding the otherwise-unbounded growth of
357
368
  * node rows + dirs. The edges→nodes FK (`ON DELETE CASCADE`, migration v4) GCs
358
369
  * each pruned node's edges automatically; the on-disk `nodes/<id>/` dir is
359
- * removed too. Live nodes are NEVER touched: active | idle are the daemon's
360
- * domain, a DISJOINT status set, so prune and supervision can't interfere.
370
+ * removed too.
371
+ *
372
+ * With `includeStale`, ALSO prunes nominally-live (active | idle) nodes past the
373
+ * TTL whose process is provably gone — `pi_pid` is NULL or no longer alive. This
374
+ * reaps stale roots (a bare `crtr` whose pi died without the row transitioning),
375
+ * which the daemon's supervision never reconciled. A genuinely-running node keeps
376
+ * a live `pi_pid`, so it is protected, as is the CALLER ($CRTR_NODE_ID). Without
377
+ * the flag, active | idle are NEVER touched (the daemon's domain).
361
378
  *
362
379
  * The row deletes run in ONE transaction (so the sweep is all-or-nothing); the
363
380
  * dir removals follow after COMMIT — the fs isn't transactional, and by then the
@@ -365,9 +382,11 @@ export function rebuildIndex() {
365
382
  * reports the candidate set and deletes NOTHING. */
366
383
  export function pruneNodes(opts) {
367
384
  const dryRun = opts.dryRun ?? false;
385
+ const includeStale = opts.includeStale ?? false;
368
386
  const cutoff = new Date(Date.now() - opts.ttlDays * 86_400_000).toISOString();
387
+ const selfId = process.env['CRTR_NODE_ID'] ?? '';
369
388
  const db = openDb();
370
- const candidates = db
389
+ const terminal = db
371
390
  .prepare(`SELECT node_id, status, created FROM nodes
372
391
  WHERE status IN ('dead', 'done', 'canceled') AND created < ?
373
392
  ORDER BY created`)
@@ -376,6 +395,25 @@ export function pruneNodes(opts) {
376
395
  status: r['status'],
377
396
  created: r['created'],
378
397
  }));
398
+ // Stale non-terminal sweep (opt-in): active | idle past the TTL whose process
399
+ // is provably gone (pi_pid NULL or not alive). Never the caller itself.
400
+ const stale = !includeStale ? [] : db
401
+ .prepare(`SELECT node_id, status, created, pi_pid FROM nodes
402
+ WHERE status IN ('active', 'idle') AND created < ?
403
+ ORDER BY created`)
404
+ .all(cutoff)
405
+ .filter((r) => {
406
+ if (r['node_id'] === selfId)
407
+ return false;
408
+ const pid = r['pi_pid'];
409
+ return pid === null || !pidAlive(pid);
410
+ })
411
+ .map((r) => ({
412
+ node_id: r['node_id'],
413
+ status: r['status'],
414
+ created: r['created'],
415
+ }));
416
+ const candidates = [...terminal, ...stale];
379
417
  if (dryRun || candidates.length === 0)
380
418
  return { pruned: candidates, dryRun };
381
419
  // One transactioned sweep — delete the rows; the FK cascades their edges.
@@ -23,6 +23,16 @@ export interface DashboardRow {
23
23
  mode: string;
24
24
  ctx_tokens: number;
25
25
  asks: number;
26
+ /** The dir the node is pinned to (its cwd). Drives the browser's cwd-scope
27
+ * filter + the All-dirs basename cue. */
28
+ cwd: string;
29
+ /** ISO 8601 birth timestamp — drives the recency sort + the relative-age cue. */
30
+ created: string;
31
+ /** The node's spawn prompt (context/initial-prompt.md), trimmed + capped. Only
32
+ * populated by dashboardRowsAll (the browser snapshot) — the dashboard leaf
33
+ * leaves it undefined to avoid a file read per node. Indexed by super-search
34
+ * and shown in the preview panel. */
35
+ goal?: string;
26
36
  }
27
37
  /** One row per node visible in the sub-DAG of `rootId` (including root). */
28
38
  export declare function dashboardRows(rootId: string): DashboardRow[];
@@ -16,7 +16,7 @@ import { existsSync, readFileSync } from 'node:fs';
16
16
  import { join } from 'node:path';
17
17
  import { getNode, listNodes, subscriptionsOf, view } from './canvas.js';
18
18
  import { fullName } from './labels.js';
19
- import { jobDir } from './paths.js';
19
+ import { jobDir, contextDir } from './paths.js';
20
20
  import { countAsks } from './attention.js';
21
21
  // ---------------------------------------------------------------------------
22
22
  // Glyphs
@@ -162,6 +162,25 @@ export function renderForest() {
162
162
  }
163
163
  return parts.join('\n\n');
164
164
  }
165
+ /** The spawn prompt, read straight off disk (canvas-home state) and capped so a
166
+ * giant initial-prompt.md can't bloat the snapshot. Mirrors how telemetry is
167
+ * read here directly rather than via the runtime layer (which would invert the
168
+ * canvas→runtime dependency). Never throws. */
169
+ const GOAL_CAP = 4096;
170
+ function readGoalText(nodeId) {
171
+ try {
172
+ const p = join(contextDir(nodeId), 'initial-prompt.md');
173
+ if (!existsSync(p))
174
+ return undefined;
175
+ const body = readFileSync(p, 'utf8').trim();
176
+ if (body === '')
177
+ return undefined;
178
+ return body.length > GOAL_CAP ? body.slice(0, GOAL_CAP) : body;
179
+ }
180
+ catch {
181
+ return undefined;
182
+ }
183
+ }
165
184
  /** One row per node visible in the sub-DAG of `rootId` (including root). */
166
185
  export function dashboardRows(rootId) {
167
186
  const ids = [rootId, ...view(rootId)];
@@ -178,6 +197,8 @@ export function dashboardRows(rootId) {
178
197
  mode: node.mode,
179
198
  ctx_tokens: tel.tokens_in ?? 0,
180
199
  asks: countAsks(id),
200
+ cwd: node.cwd,
201
+ created: node.created,
181
202
  }];
182
203
  });
183
204
  }
@@ -196,6 +217,9 @@ export function dashboardRowsAll() {
196
217
  mode: row.mode,
197
218
  ctx_tokens: tel.tokens_in ?? 0,
198
219
  asks: countAsks(row.node_id),
220
+ cwd: row.cwd,
221
+ created: row.created,
222
+ goal: readGoalText(row.node_id),
199
223
  }];
200
224
  });
201
225
  }
@@ -45,8 +45,5 @@ export declare function writeCursor(nodeId: string, iso: string): void;
45
45
  * [<kind>] ← ref-less msg: full body inlined
46
46
  * <body line>
47
47
  * …
48
- *
49
- * A header line announces the total count and instructs the receiver to
50
- * dereference only what matters.
51
48
  */
52
49
  export declare function coalesce(entries: InboxEntry[]): string;
@@ -139,14 +139,10 @@ function renderEntry(e) {
139
139
  * [<kind>] ← ref-less msg: full body inlined
140
140
  * <body line>
141
141
  * …
142
- *
143
- * A header line announces the total count and instructs the receiver to
144
- * dereference only what matters.
145
142
  */
146
143
  export function coalesce(entries) {
147
144
  if (entries.length === 0)
148
145
  return '(inbox empty)';
149
- const header = `${entries.length} update${entries.length === 1 ? '' : 's'} since last read — dereference what matters.\n`;
150
146
  // Group by `from` (null → 'system').
151
147
  const groups = new Map();
152
148
  for (const e of entries) {
@@ -160,5 +156,5 @@ export function coalesce(entries) {
160
156
  const lines = items.map(renderEntry);
161
157
  sections.push(`From ${sender} — ${items.length} update${items.length === 1 ? '' : 's'}:\n${lines.join('\n')}`);
162
158
  }
163
- return header + sections.join('\n\n');
159
+ return sections.join('\n\n');
164
160
  }
@@ -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) {
@@ -171,16 +171,32 @@ export interface FocusResult {
171
171
  revived: boolean;
172
172
  }
173
173
  /** 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. */
174
+ * §1.3). Four signals decide one of three fates; unit-testable in isolation:
175
+ * - `kill` — a holder pane (no row) or a done/dead/canceled node: reap the
176
+ * (now-backstage) pane (Invariant P: not-focused + not-live
177
+ * no pane).
178
+ * - `backstage`— a human-driven RESIDENT node (editor/root/orchestrator — NEVER
179
+ * despawned on focus-away), or a terminal worker that is
180
+ * genuinely MID-TURN (Invariant F2 — keeps running off-screen).
181
+ * - `release` — a PARKED terminal viewer (live but not mid-turn): a node
182
+ * revived only for inspection. Despawn it back to dormant
183
+ * (transition `release` → idle/idle-release) and reap its pane;
184
+ * the daemon revives it on its inbox, or the user re-focuses.
185
+ * This is the bug fix: such a node was misclassified as
186
+ * generating and left stuck active forever.
187
+ * Order matters: `resident` is checked BEFORE `generating` so a resident node is
188
+ * always kept warm regardless of whether it happens to be mid-turn. */
177
189
  export type OutgoingAction = {
178
190
  kind: 'backstage';
179
191
  } | {
180
192
  kind: 'kill';
193
+ } | {
194
+ kind: 'release';
181
195
  };
182
196
  export declare function outgoingDisposition(o: {
183
197
  exists: boolean;
198
+ live: boolean;
199
+ resident: boolean;
184
200
  generating: boolean;
185
201
  }): OutgoingAction;
186
202
  /** Open a NEW viewport (§2.3, F4) — the ONLY path a new pane appears in a user
@@ -210,8 +226,9 @@ export declare function registerRootFocus(nodeId: string, pane: string, session:
210
226
  * - `swapPaneInPlace(pin, focusPane)`: incoming → the viewport slot; the
211
227
  * outgoing occupant → incoming's old (backstage) slot, %ids preserved
212
228
  * (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.
229
+ * - outgoing resident OR still mid-turn → backstage (kept warm / F2); a parked
230
+ * terminal viewer RELEASE (status idle, pane reaped); a holder or
231
+ * done/dormant occupant → reap its now-backstage pane (Invariant P).
215
232
  * Arms remain-on-exit on the viewport (F3); the focus row is the record. */
216
233
  export declare function retargetFocus(focusId: string, incoming: string, revive: Reviver): FocusResult;
217
234
  /** The front door for `node focus` / `node cycle` (§2.3): resolve which focus the
@@ -28,6 +28,8 @@
28
28
  import { getRow, getRowByPane, getNode, setPresence, openDb, openFocusRow, setFocusOccupant, closeFocusRow, getFocusByNode, getFocusByPane, getFocusById, setFocusPane, listFocuses as listFocusRows, } from '../canvas/index.js';
29
29
  import { paneExists, paneLocation, paneOfWindow, windowAlive, windowOfPane, ensureSession, openNodeWindow, respawnPaneSync, respawnPaneDetached, breakPaneToSession, splitWindow, swapPaneInPlace, setRemainOnExit, closePane, currentTmux, joinPane, selectLayout, setWindowOption, switchClient, selectWindow, } from './tmux.js';
30
30
  import { homeSessionOf, nodeSession, newNodeId } from './nodes.js';
31
+ import { isBusy } from './busy.js';
32
+ import { transition } from './lifecycle.js';
31
33
  // Re-export the durable REVIVE-HOME read so placement is the one front door for
32
34
  // "where does this node live."
33
35
  export { homeSessionOf };
@@ -314,22 +316,37 @@ function pidAlive(pid) {
314
316
  return e.code === 'EPERM';
315
317
  }
316
318
  }
317
- /** Is a focus's OUTGOING occupant still GENERATING (a live pi doing work)? A
318
- * still-generating node is moved to backstage by a retarget (F2 — it keeps
319
- * running off-screen); a holder / done / dormant node has its pane reaped
320
- * (Invariant P). A holder or vanished node (row null) is never generating. */
319
+ /** Is a focus's OUTGOING occupant still GENERATING (a live pi actually MID-TURN)?
320
+ * A still-generating node is moved to backstage by a retarget (F2 — it keeps
321
+ * running off-screen); a holder / done / dormant / merely-parked node has its
322
+ * pane reaped or released (Invariant P). A holder or vanished node (row null) is
323
+ * never generating.
324
+ *
325
+ * The signal is the mid-turn `busy` marker AND a live pid — NOT pid-alive
326
+ * alone. A node revived only for VIEWING is parked at its prompt with a live
327
+ * pid between turns; pid-alive would misclassify it as "doing work" and leave
328
+ * it stuck backstaged-active forever. `isBusy` is true only inside a turn, so a
329
+ * parked viewer reads as not-generating and is released to dormant on
330
+ * focus-away. The AND with `pidAlive` makes a stale marker (a pi that crashed
331
+ * mid-turn) harmless. */
321
332
  function isGenerating(nodeId) {
322
333
  const row = getRow(nodeId);
323
334
  if (row === null)
324
335
  return false;
325
336
  if (row.status !== 'active' && row.status !== 'idle')
326
337
  return false;
327
- return pidAlive(row.pi_pid);
338
+ return isBusy(nodeId) && pidAlive(row.pi_pid);
328
339
  }
329
340
  export function outgoingDisposition(o) {
330
341
  if (!o.exists)
331
- return { kind: 'kill' };
332
- return o.generating ? { kind: 'backstage' } : { kind: 'kill' };
342
+ return { kind: 'kill' }; // holder pane
343
+ if (!o.live)
344
+ return { kind: 'kill' }; // done/dead/canceled — reap the pane
345
+ if (o.resident)
346
+ return { kind: 'backstage' }; // human-driven node: keep warm
347
+ if (o.generating)
348
+ return { kind: 'backstage' }; // mid-turn terminal worker (F2)
349
+ return { kind: 'release' }; // parked terminal viewer → dormant
333
350
  }
334
351
  /** The node's pane iff it is a LIVE pane (a generating-unfocused backstage pane,
335
352
  * or a still-live focus pane), else null. The retarget swaps THIS pane into the
@@ -403,8 +420,9 @@ export function registerRootFocus(nodeId, pane, session, window) {
403
420
  * - `swapPaneInPlace(pin, focusPane)`: incoming → the viewport slot; the
404
421
  * outgoing occupant → incoming's old (backstage) slot, %ids preserved
405
422
  * (cross-session swap confirmed by the spike).
406
- * - outgoing still generating → backstage (F2); else reap its now-backstage
407
- * pane (Invariant P). A holder occupant (no node row) is always reaped.
423
+ * - outgoing resident OR still mid-turn → backstage (kept warm / F2); a parked
424
+ * terminal viewer RELEASE (status idle, pane reaped); a holder or
425
+ * done/dormant occupant → reap its now-backstage pane (Invariant P).
408
426
  * Arms remain-on-exit on the viewport (F3); the focus row is the record. */
409
427
  export function retargetFocus(focusId, incoming, revive) {
410
428
  let f = getFocusById(focusId);
@@ -455,11 +473,23 @@ export function retargetFocus(focusId, incoming, revive) {
455
473
  }
456
474
  const pinLoc = paneLocation(pin); // now the viewport
457
475
  const outLoc = paneLocation(focusPane); // now backstage (outgoing's new home)
458
- const action = outgoingDisposition({ exists: getRow(outgoing) !== null, generating: isGenerating(outgoing) });
476
+ const oRow = getRow(outgoing);
477
+ const action = outgoingDisposition({
478
+ exists: oRow !== null,
479
+ live: oRow?.status === 'active' || oRow?.status === 'idle',
480
+ resident: oRow?.lifecycle === 'resident',
481
+ generating: isGenerating(outgoing),
482
+ });
459
483
  commitFocusTxn(f.focus_id, incoming, pin, pinLoc, outgoing, action, outLoc, focusPane);
460
- // Reap the outgoing/holder pane (now backstage) when not generating — AFTER
461
- // commit (a tmux side effect, outside the txn).
462
- if (action.kind === 'kill')
484
+ // Crash-safety: flip a released (parked terminal viewer) node to dormant
485
+ // (idle + intent='idle-release') BEFORE reaping its pane, so the daemon never
486
+ // sees a window-gone live node and races to revive it. Then reap the
487
+ // outgoing/holder pane (now backstage) for both kill (done/dormant/holder) and
488
+ // release (parked viewer) — AFTER commit (a tmux side effect, outside the txn).
489
+ // A still-generating worker or a resident node is backstaged, untouched here.
490
+ if (action.kind === 'release')
491
+ transition(outgoing, 'release');
492
+ if (action.kind === 'kill' || action.kind === 'release')
463
493
  closePane(focusPane);
464
494
  armRemainOnExit(pinLoc?.window);
465
495
  return { focused: true, session: pinLoc?.session ?? f.session, inPlace: true, revived };
@@ -481,6 +511,7 @@ function commitFocusTxn(focusId, incoming, pin, pinLoc, outgoing, action, outLoc
481
511
  setPresence(outgoing, { pane: outgoingPane, tmux_session: outLoc?.session ?? null, window: outLoc?.window ?? null });
482
512
  }
483
513
  else {
514
+ // kill | release — the pane is reaped by the caller; null the LOCATION.
484
515
  setPresence(outgoing, { pane: null, tmux_session: null, window: null });
485
516
  }
486
517
  }