@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
@@ -0,0 +1,233 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/subscription-delivery.test.ts
2
+ //
3
+ // MULTI-LEVEL SUBSCRIPTION DELIVERY — the DELIVERY-vs-WAKE distinction across a
4
+ // graph >=2 levels deep, driven through the FAITHFUL integration harness (real
5
+ // CLI, real isolated tmux, real extension hooks in the fake-pi vehicle, real
6
+ // daemon decision pass via superviseTick). Every assertion reads the canvas
7
+ // data layer and is checked against the state-model ORACLE (§3b, §5, §6).
8
+ //
9
+ // This is the multi-level companion to the UNIT-level passive-subscription.test.ts
10
+ // (which pins push→inbox vs push→passive.jsonl routing in-process). What that test
11
+ // CANNOT show — and what is added here — is the runtime WAKE consequence of the
12
+ // split when BOTH an active and a passive subscriber sit on the SAME target and
13
+ // BOTH are DORMANT (terminal idle-released): the active one is daemon-REVIVED on
14
+ // its unseen inbox entry; the passive one is NOT (its pointer lands in the passive
15
+ // accumulator the daemon never reads), so it stays idle.
16
+ //
17
+ // THE ORACLE CONTRACT under test:
18
+ // • feed.push fans to subscribersOf(target): active → inbox.jsonl (wakes),
19
+ // passive → passive.jsonl (accumulates, NEVER wakes). (state-model §5; feed.ts)
20
+ // • The daemon's 2nd pass revives an idle-released node ONLY on an unseen INBOX
21
+ // entry (crtrd.ts) — so a passive subscriber's idle-released node never wakes.
22
+ // • hasActiveLiveSubscription excludes passive edges (canvas.ts active=1) — a
23
+ // passive tie does NOT legitimize idle-release (stop-guard §3b).
24
+ // • Wake is ONE HOP per push: a push fans to DIRECT subscribers only; an indirect
25
+ // ancestor hears nothing until a middle node explicitly re-pushes. (state-model §5)
26
+ //
27
+ // Graph (>=2 levels; T is two hops under A):
28
+ // A (resident root — the user's virtual front door)
29
+ // └── B (terminal) A→B active seed ── ACTIVE subscriber of T
30
+ // ├── T (terminal) B→T active seed ── THE TARGET (level 2)
31
+ // ├── K (terminal) B→K active seed ── keepalive (stays active)
32
+ // └── P (terminal) B→P active seed ── PASSIVE subscriber of T
33
+ // P→T passive (wired via `crtr node subscribe --passive`)
34
+ // P→K active (wired — the ONLY tie that legitimizes P's release;
35
+ // P→T passive is excluded from the stop-guard)
36
+ import { test } from 'node:test';
37
+ import assert from 'node:assert/strict';
38
+ import { spawnSync } from 'node:child_process';
39
+ import { createHarness, hasTmux } from './helpers/harness.js';
40
+ import { closeDb } from '../canvas/db.js';
41
+ import { readPassive } from '../feed/passive.js';
42
+ import { STALL_REPROMPT } from '../runtime/stop-guard.js';
43
+ function sessionExists(session) {
44
+ return spawnSync('tmux', ['has-session', '-t', session], { stdio: 'ignore' }).status === 0;
45
+ }
46
+ // LOCAL helper (candidate for harness consolidation — see report): read a node's
47
+ // PASSIVE accumulator (passive.jsonl) straight off the data layer, mirroring the
48
+ // harness's own `inbox()` reader. closeDb() keeps it consistent with the harness's
49
+ // cross-process WAL discipline (the push that wrote passive.jsonl ran in a `cli`
50
+ // subprocess). The harness exposes inbox() but NOT passive() — this fills that gap.
51
+ function passive(nodeId) {
52
+ closeDb();
53
+ return readPassive(nodeId);
54
+ }
55
+ // `{node_id, active}` arrays → a stable comparable set of `id:active|passive`.
56
+ function edgeSet(arr) {
57
+ return new Set(arr.map((e) => `${e.node_id}:${e.active ? 'active' : 'passive'}`));
58
+ }
59
+ test('multi-level subscription delivery: active subscriber WOKEN vs passive subscriber DELIVERED-not-woken, on the same dormant target', { skip: !hasTmux() ? 'tmux unavailable' : false, timeout: 180_000 }, async () => {
60
+ const h = await createHarness({ sessionPrefix: 'crtr-subdeliv' });
61
+ try {
62
+ // ===================================================================
63
+ // S1 — Build the graph. A (resident root, virtual) ► B ► {T,K,P}.
64
+ // Each spawn seeds an ACTIVE parent→child subscription (the spine).
65
+ // ===================================================================
66
+ const A = h.spawnRoot('front door');
67
+ const B = await h.spawnChild(A, 'mid manager', { kind: 'developer' });
68
+ const T = await h.spawnChild(B, 'the target worker');
69
+ const K = await h.spawnChild(B, 'a keepalive worker');
70
+ const P = await h.spawnChild(B, 'the passive observer');
71
+ // Spine seeds: A→B, B→T, B→K, B→P all ACTIVE. T's sole subscriber so far is B.
72
+ assert.deepEqual(edgeSet(h.subscribers(T)), new Set([`${B}:active`]), "T's only subscriber at birth is B (active spawn seed)");
73
+ assert.deepEqual(edgeSet(h.subscribers(B)), new Set([`${A}:active`]), 'A→B active seed');
74
+ // ===================================================================
75
+ // S2 — Wire the PASSIVE subscriber. P passively subscribes to T (the axis
76
+ // under test), and ACTIVELY subscribes to K (the only tie that will
77
+ // legitimize P's own idle-release — its passive tie to T does NOT count).
78
+ // ===================================================================
79
+ {
80
+ const passiveRes = h.cli(P, ['node', 'subscribe', T, '--passive']);
81
+ assert.equal(passiveRes.code, 0, `P→T passive subscribe exit 0\n${passiveRes.stderr}`);
82
+ assert.match(passiveRes.stdout, /mode="passive"/, 'P→T wired PASSIVE');
83
+ const activeRes = h.cli(P, ['node', 'subscribe', K]);
84
+ assert.equal(activeRes.code, 0, `P→K active subscribe exit 0\n${activeRes.stderr}`);
85
+ assert.match(activeRes.stdout, /mode="active"/, 'P→K wired ACTIVE');
86
+ }
87
+ // T now has BOTH an active (B) and a passive (P) subscriber — the crux.
88
+ assert.deepEqual(edgeSet(h.subscribers(T)), new Set([`${B}:active`, `${P}:passive`]), 'T has B(active) + P(passive) as subscribers — the same-target split');
89
+ assert.deepEqual(edgeSet(h.subscriptions(P)), new Set([`${T}:passive`, `${K}:active`]), 'P subscribes_to T(passive) + K(active)');
90
+ // ===================================================================
91
+ // S2b — PASSIVE-EXCLUSION, demonstrated directly (the stop-guard half of
92
+ // the active/passive split). X subscribes PASSIVE-ONLY to the very
93
+ // same live K that P subscribes to ACTIVELY. On stop, X holds NO
94
+ // active live subscription — hasActiveLiveSubscription filters
95
+ // active=1 (canvas.ts), so the passive K-tie is invisible to it →
96
+ // the stop-guard returns 'stalled', NOT 'awaiting': X is re-prompted
97
+ // (STALL_REPROMPT) and stays ACTIVE; it does NOT idle-release. Same
98
+ // publisher K, opposite outcome purely by edge mode — ACTIVE holds a
99
+ // node alive to await, PASSIVE does not (oracle §3b, §5). This is the
100
+ // discriminating negative the rest of the test relies on.
101
+ // ===================================================================
102
+ const X = await h.spawnChild(B, 'a passive-only observer');
103
+ {
104
+ const res = h.cli(X, ['node', 'subscribe', K, '--passive']);
105
+ assert.equal(res.code, 0, `X→K passive subscribe exit 0\n${res.stderr}`);
106
+ assert.match(res.stdout, /mode="passive"/, 'X→K wired PASSIVE-only (its ONLY subscription)');
107
+ }
108
+ assert.deepEqual(edgeSet(h.subscriptions(X)), new Set([`${K}:passive`]), 'X holds exactly one tie: K(passive) — no active live subscription');
109
+ const injBeforeStall = h.injected(X).length;
110
+ await h.stop(X);
111
+ // The PASSIVE tie does NOT legitimize a release → 'stalled' → re-prompt.
112
+ await h.waitFor(() => h.injected(X).slice(injBeforeStall).find((e) => e.content.includes(STALL_REPROMPT)), { timeoutMs: 15_000, label: 'X (passive-only) → STALL_REPROMPT, not awaiting' });
113
+ assert.equal(h.status(X), 'active', 'X stays ACTIVE — a PASSIVE-only tie does NOT hold it as awaiting');
114
+ assert.equal(h.node(X).intent ?? null, null, 'X intent untouched — it did NOT idle-release');
115
+ // ===================================================================
116
+ // S3 — P goes DORMANT. P stops while holding an ACTIVE live sub to K →
117
+ // stop-guard 'awaiting' → idle-release. (Its PASSIVE sub to T is
118
+ // EXCLUDED from hasActiveLiveSubscription — proven directly by S2b:
119
+ // a passive-only tie stalls; only the ACTIVE K-tie releases P here.)
120
+ // ===================================================================
121
+ await h.stop(P);
122
+ await h.waitForStatus(P, 'idle');
123
+ {
124
+ const p = h.node(P);
125
+ assert.equal(p.status, 'idle', 'P idle (released on the strength of its ACTIVE K-sub)');
126
+ assert.equal(p.intent, 'idle-release', 'P intent=idle-release');
127
+ }
128
+ await h.waitForPaneGone(P);
129
+ assert.equal(h.paneAlive(P), false, 'P unfocused → pane closed on idle-release (dormant)');
130
+ // ===================================================================
131
+ // S4 — B (the ACTIVE subscriber of T) goes DORMANT too. B awaits T,K
132
+ // (active) and P (now idle) → 'awaiting' → idle-release, pane closes.
133
+ // Now BOTH subscribers of T are dormant — one active, one passive.
134
+ // ===================================================================
135
+ await h.stop(B);
136
+ await h.waitForStatus(B, 'idle');
137
+ {
138
+ const b = h.node(B);
139
+ assert.equal(b.status, 'idle', 'B idle (released, awaiting its live children)');
140
+ assert.equal(b.intent, 'idle-release', 'B intent=idle-release');
141
+ }
142
+ await h.waitForPaneGone(B);
143
+ assert.equal(h.paneAlive(B), false, 'B unfocused → pane closed on idle-release');
144
+ // Pre-push baseline: the keepalive is up, the grandparent is untouched.
145
+ assert.equal(h.status(K), 'active', 'K still active (keepalive holds P legitimately dormant)');
146
+ assert.equal(h.status(A), 'active', 'A (resident root) untouched');
147
+ assert.equal(h.inbox(A).length, 0, 'A inbox empty before the push');
148
+ assert.equal(h.inbox(B).length, 0, 'B inbox empty before the push (nothing delivered yet)');
149
+ assert.equal(passive(P).length, 0, 'P passive accumulator empty before the push');
150
+ // ===================================================================
151
+ // S5 — T FINISHES (push final). feed.push fans the pointer to
152
+ // subscribersOf(T) = {B(active), P(passive)} ONLY, by the delivery
153
+ // split: B's lands in inbox.jsonl, P's in passive.jsonl. A (the
154
+ // grandparent, NOT a subscriber of T) gets NOTHING — one-hop fan-out.
155
+ // ===================================================================
156
+ const TARGET_FINAL = 'TARGET-FINAL-BODY: the worker completed';
157
+ await h.finish(T, TARGET_FINAL);
158
+ {
159
+ const t = h.node(T);
160
+ assert.equal(t.status, 'done', 'T done after push final');
161
+ assert.equal(t.intent, 'done', 'T intent=done');
162
+ assert.equal(h.paneAlive(T), false, 'T pane closed on done');
163
+ // ACTIVE delivery → B's INBOX (the wake channel).
164
+ const bInbox = h.inbox(B);
165
+ const bFinal = bInbox.find((e) => e.from === T && e.kind === 'final');
166
+ assert.ok(bFinal, 'ACTIVE subscriber B: T-final pointer DELIVERED to inbox.jsonl');
167
+ // PASSIVE delivery → P's ACCUMULATOR, NOT its inbox (no wake channel).
168
+ const pPassive = passive(P);
169
+ assert.equal(pPassive.length, 1, 'PASSIVE subscriber P: exactly one passive entry');
170
+ assert.equal(pPassive[0].from, T, "P's passive entry is from T");
171
+ assert.equal(pPassive[0].kind, 'final', "P's passive entry is the final");
172
+ assert.equal(h.inbox(P).length, 0, 'PASSIVE subscriber P: inbox.jsonl stays EMPTY (no wake channel)');
173
+ // ONE-HOP: the indirect ancestor and the non-subscriber sibling hear nothing.
174
+ assert.equal(h.inbox(A).length, 0, 'A (grandparent, not a subscriber of T) NOT delivered — one-hop');
175
+ assert.equal(h.inbox(K).length, 0, 'K (sibling, not a subscriber of T) NOT delivered');
176
+ }
177
+ // ===================================================================
178
+ // S6 — The DAEMON decision pass. Its 2nd pass revives an idle-released
179
+ // node ONLY on an unseen INBOX entry. B has one (active) → REVIVED
180
+ // (resume). P has none (its entry went to passive.jsonl) → stays idle.
181
+ // This is the WAKE half of delivery-vs-wake, decided in ONE tick.
182
+ // ===================================================================
183
+ const injBeforeWake = h.injected(B).length;
184
+ await h.tick();
185
+ // ACTIVE subscriber: WOKEN — status flips to active and a fresh pi resumes.
186
+ await h.waitForStatus(B, 'active');
187
+ {
188
+ const b = h.node(B);
189
+ assert.equal(b.status, 'active', 'ACTIVE subscriber B: daemon-REVIVED → active');
190
+ assert.equal(b.intent ?? null, null, "B intent cleared by revive");
191
+ }
192
+ await h.awaitBoot(B, { minCount: 2 }); // spawn boot + resume boot
193
+ // awaitWake THROWS on a 15s timeout unless an injected entry matching T's
194
+ // body arrives — that match-or-timeout IS the load-bearing oracle here. The
195
+ // resume-boot injects persona/bearings, never the report body, so only the
196
+ // real inbox-watcher delivery of T's final can satisfy the match.
197
+ await h.awaitWake(B, { sinceCount: injBeforeWake, match: /TARGET-FINAL-BODY/ });
198
+ // PASSIVE subscriber: NOT WOKEN — the synchronous tick that revived B did
199
+ // NOT revive P; P remains idle-released with its pane gone. (The tick's
200
+ // loop completed before tick() returned, so P's non-revive is settled.)
201
+ assert.equal(h.status(P), 'idle', 'PASSIVE subscriber P: STILL idle — daemon did NOT wake it');
202
+ assert.equal(h.node(P).intent, 'idle-release', 'P still intent=idle-release (untouched)');
203
+ assert.equal(h.paneAlive(P), false, 'P pane STILL gone — no resume happened');
204
+ assert.equal(passive(P).length, 1, "P's passive entry is still pending (drained only on its next message)");
205
+ assert.equal(h.inbox(P).length, 0, "P's inbox still empty");
206
+ // The grandparent is STILL untouched even though B woke — one-hop confirmed.
207
+ assert.equal(h.inbox(A).length, 0, 'A inbox STILL empty after B woke — wake did not propagate up');
208
+ assert.equal(h.status(A), 'active', 'A unchanged');
209
+ // ===================================================================
210
+ // S7 — SECOND SITE: one-hop fan-out on a re-push. The indirect ancestor A
211
+ // hears B ONLY when B explicitly re-pushes up its own spine. B (now
212
+ // active) pushes an UPDATE → it fans to subscribersOf(B) = {A} only.
213
+ // This corroborates the flagship one-hop finding at an independent site.
214
+ // ===================================================================
215
+ {
216
+ const res = h.cli(B, ['push', 'update', 'B re-pushing the rolled-up result']);
217
+ assert.equal(res.code, 0, `B push update exit 0\n${res.stderr}`);
218
+ const aInbox = h.inbox(A);
219
+ assert.equal(aInbox.length, 1, "A inbox now has exactly B's update (one hop, on B's explicit re-push)");
220
+ assert.equal(aInbox[0].from, B, "A's entry is from B");
221
+ assert.equal(aInbox[0].kind, 'update', "A's entry is the update");
222
+ }
223
+ // The re-push reached its DIRECT subscriber A only — siblings/observer untouched.
224
+ assert.equal(h.inbox(P).length, 0, "P inbox untouched by B's re-push (P does not subscribe to B)");
225
+ assert.equal(h.inbox(K).length, 0, "K inbox untouched by B's re-push");
226
+ assert.equal(passive(P).length, 1, "P's passive backlog unchanged by B's re-push");
227
+ }
228
+ finally {
229
+ const session = h.session;
230
+ await h.dispose();
231
+ assert.equal(sessionExists(session), false, 'isolated session killed — no stray');
232
+ }
233
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,142 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { buildTree, flatten, fuzzyMatch, tabPredicate, TABS } from '../model.js';
4
+ // ── Fixture canvas ───────────────────────────────────────────────────────────
5
+ // root1 (active) ← rank 0
6
+ // child-a (idle)
7
+ // grand-x (done)
8
+ // child-b (active, ⚑2)
9
+ // root2 (done) ← rank 2 (dormant root, but ancestor of a live node)
10
+ // child-c (active)
11
+ // lonely (idle) ← straggler: in rows, no edge reaches it, not a root
12
+ function row(node_id, name, status, asks = 0) {
13
+ return { node_id, name, status, kind: 'general', mode: 'base', ctx_tokens: 0, asks, cwd: '/tmp/proj', created: '2026-01-01T00:00:00.000Z' };
14
+ }
15
+ const ROWS = [
16
+ row('root1', 'root-one', 'active'),
17
+ row('child-a', 'child-a', 'idle'),
18
+ row('grand-x', 'grand-x', 'done'),
19
+ row('child-b', 'child-b', 'active', 2),
20
+ row('root2', 'root-two', 'done'),
21
+ row('child-c', 'child-c', 'active'),
22
+ row('lonely', 'lonely-node', 'idle'),
23
+ ];
24
+ // rootIds intentionally unsorted to prove buildTree sorts live-first.
25
+ const ROOT_IDS = ['root2', 'root1'];
26
+ const CHILDREN = {
27
+ root1: ['child-a', 'child-b'],
28
+ 'child-a': ['grand-x'],
29
+ root2: ['child-c'],
30
+ };
31
+ const childIdsOf = (id) => CHILDREN[id] ?? [];
32
+ function tree() {
33
+ return buildTree(ROWS, ROOT_IDS, childIdsOf);
34
+ }
35
+ // ── fuzzyMatch ───────────────────────────────────────────────────────────────
36
+ test('fuzzyMatch: empty query matches everything', () => {
37
+ assert.equal(fuzzyMatch('', 'whatever'), true);
38
+ });
39
+ test('fuzzyMatch: case-insensitive subsequence', () => {
40
+ assert.equal(fuzzyMatch('abc', 'aXbXc'), true);
41
+ assert.equal(fuzzyMatch('AB', 'xaYbz'), true);
42
+ assert.equal(fuzzyMatch('grandx', 'grand-x'), true);
43
+ });
44
+ test('fuzzyMatch: out-of-order is not a match', () => {
45
+ assert.equal(fuzzyMatch('abc', 'acb'), false);
46
+ assert.equal(fuzzyMatch('zz', 'z'), false);
47
+ });
48
+ // ── buildTree ────────────────────────────────────────────────────────────────
49
+ test('buildTree: roots sorted live-first, stragglers appended', () => {
50
+ const t = tree();
51
+ assert.deepEqual(t.roots, ['root1', 'root2', 'lonely']);
52
+ });
53
+ test('buildTree: depth, parentId, childIds', () => {
54
+ const t = tree();
55
+ assert.equal(t.nodes.size, 7);
56
+ assert.deepEqual(t.nodes.get('root1'), {
57
+ row: ROWS[0], depth: 0, parentId: null, childIds: ['child-a', 'child-b'],
58
+ });
59
+ assert.equal(t.nodes.get('child-a').depth, 1);
60
+ assert.equal(t.nodes.get('child-a').parentId, 'root1');
61
+ assert.deepEqual(t.nodes.get('child-a').childIds, ['grand-x']);
62
+ assert.equal(t.nodes.get('grand-x').depth, 2);
63
+ assert.equal(t.nodes.get('grand-x').parentId, 'child-a');
64
+ // straggler attaches at depth 0 with no parent
65
+ assert.equal(t.nodes.get('lonely').depth, 0);
66
+ assert.equal(t.nodes.get('lonely').parentId, null);
67
+ });
68
+ test('buildTree: unknown child ids are dropped (missing meta safe)', () => {
69
+ const t = buildTree(ROWS, ['root1'], (id) => (id === 'root1' ? ['child-a', 'ghost'] : childIdsOf(id)));
70
+ assert.deepEqual(t.nodes.get('root1').childIds, ['child-a']);
71
+ assert.equal(t.nodes.has('ghost'), false);
72
+ });
73
+ // ── tab predicates ───────────────────────────────────────────────────────────
74
+ test('tabPredicate: All / Live / Dormant / Flagged', () => {
75
+ assert.deepEqual([...TABS], ['All', 'Live', 'Dormant', 'Flagged']);
76
+ const active = ROWS[0]; // root1 active
77
+ const idle = ROWS[1]; // child-a idle
78
+ const done = ROWS[2]; // grand-x done
79
+ const flagged = ROWS[3]; // child-b active asks 2
80
+ assert.equal(tabPredicate('All', done), true);
81
+ assert.equal(tabPredicate('Live', active), true);
82
+ assert.equal(tabPredicate('Live', idle), true);
83
+ assert.equal(tabPredicate('Live', done), false);
84
+ assert.equal(tabPredicate('Dormant', done), true);
85
+ assert.equal(tabPredicate('Dormant', active), false);
86
+ assert.equal(tabPredicate('Flagged', flagged), true);
87
+ assert.equal(tabPredicate('Flagged', active), false);
88
+ });
89
+ // ── flatten: collapse / expand ───────────────────────────────────────────────
90
+ const allCollapsed = () => new Set(['root1', 'child-a', 'root2']); // every node with children
91
+ test('flatten: default-collapsed shows only top-level', () => {
92
+ const v = flatten(tree(), { collapsed: allCollapsed(), tab: 'All', query: '' });
93
+ assert.deepEqual(v.map((r) => r.id), ['root1', 'root2', 'lonely']);
94
+ // root1 has children and is collapsed → glyph state
95
+ assert.equal(v[0].hasChildren, true);
96
+ assert.equal(v[0].collapsed, true);
97
+ assert.equal(v[2].hasChildren, false); // lonely is a leaf
98
+ });
99
+ test('flatten: expanding a root reveals its (still-collapsed) children', () => {
100
+ const collapsed = allCollapsed();
101
+ collapsed.delete('root1');
102
+ const v = flatten(tree(), { collapsed, tab: 'All', query: '' });
103
+ assert.deepEqual(v.map((r) => r.id), ['root1', 'child-a', 'child-b', 'root2', 'lonely']);
104
+ // child-a still collapsed → grand-x hidden
105
+ assert.equal(v.find((r) => r.id === 'child-a').collapsed, true);
106
+ });
107
+ test('flatten: fully expanded shows the whole subtree', () => {
108
+ const collapsed = new Set(); // nothing collapsed
109
+ const v = flatten(tree(), { collapsed, tab: 'All', query: '' });
110
+ assert.deepEqual(v.map((r) => r.id), ['root1', 'child-a', 'grand-x', 'child-b', 'root2', 'child-c', 'lonely']);
111
+ });
112
+ // ── flatten: tab filtering + ancestor context ────────────────────────────────
113
+ test('flatten: Live tab dims dormant ancestors, keeps live matches', () => {
114
+ const v = flatten(tree(), { collapsed: allCollapsed(), tab: 'Live', query: '' });
115
+ assert.deepEqual(v.map((r) => r.id), ['root1', 'root2', 'lonely']);
116
+ const byId = Object.fromEntries(v.map((r) => [r.id, r]));
117
+ assert.equal(byId['root1'].matched, true); // active
118
+ assert.equal(byId['lonely'].matched, true); // idle
119
+ assert.equal(byId['root2'].matched, false); // dormant, shown only as ancestor of child-c
120
+ });
121
+ test('flatten: Dormant tab excludes live-only branches', () => {
122
+ const v = flatten(tree(), { collapsed: new Set(), tab: 'Dormant', query: '' });
123
+ const ids = v.map((r) => r.id);
124
+ assert.ok(ids.includes('grand-x')); // done
125
+ assert.ok(ids.includes('root2')); // done
126
+ assert.ok(!ids.includes('child-b')); // active → excluded
127
+ assert.ok(!ids.includes('lonely')); // idle → excluded
128
+ });
129
+ // ── flatten: query auto-expands ancestors of matches ─────────────────────────
130
+ test('flatten: query force-expands ancestors even when collapsed', () => {
131
+ // Everything collapsed; query targets a deep leaf. Its ancestors must appear.
132
+ const v = flatten(tree(), { collapsed: allCollapsed(), tab: 'All', query: 'grandx' });
133
+ assert.deepEqual(v.map((r) => r.id), ['root1', 'child-a', 'grand-x']);
134
+ const byId = Object.fromEntries(v.map((r) => [r.id, r]));
135
+ assert.equal(byId['grand-x'].matched, true); // the actual match
136
+ assert.equal(byId['child-a'].matched, false); // ancestor for context
137
+ assert.equal(byId['root1'].matched, false); // ancestor for context
138
+ });
139
+ test('flatten: query with no matches yields nothing', () => {
140
+ const v = flatten(tree(), { collapsed: allCollapsed(), tab: 'All', query: 'zzzznope' });
141
+ assert.deepEqual(v, []);
142
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,102 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { buildTree, flatten } from '../model.js';
4
+ import { renderFrame, detectColorCaps } from '../render.js';
5
+ // ── Fixture ───────────────────────────────────────────────────────────────────
6
+ function row(node_id, name, status, opts = {}) {
7
+ return { node_id, name, status, kind: 'general', mode: 'base', ctx_tokens: 0, asks: 0, cwd: '/tmp/proj', created: '2026-01-01T00:00:00.000Z', ...opts };
8
+ }
9
+ const ROWS = [
10
+ row('root1', 'alpha', 'active'),
11
+ row('idle1', 'bravo', 'idle'),
12
+ row('done1', 'charlie', 'done', { ctx_tokens: 120_000 }), // ≥100k → red ctx
13
+ row('dead1', 'delta', 'dead'),
14
+ row('canc1', 'echo', 'canceled', { asks: 3 }),
15
+ ];
16
+ const ROOT_IDS = ['root1', 'idle1', 'done1', 'dead1', 'canc1'];
17
+ const childIdsOf = () => [];
18
+ function tree() {
19
+ return buildTree(ROWS, ROOT_IDS, childIdsOf);
20
+ }
21
+ const SIZE = { cols: 100, rows: 24 };
22
+ const ON = { color: true, color256: true };
23
+ const OFF = { color: false, color256: false };
24
+ function state(over = {}) {
25
+ const t = tree();
26
+ const visible = flatten(t, { collapsed: new Set(), tab: 'All', query: over.query ?? '' });
27
+ return {
28
+ tree: t,
29
+ visible,
30
+ tab: 'All',
31
+ cursor: 0,
32
+ scrollOffset: 0,
33
+ query: '',
34
+ search: false,
35
+ totalNodes: t.nodes.size,
36
+ cwdScope: null,
37
+ sort: 'tree',
38
+ preview: false,
39
+ ...over,
40
+ };
41
+ }
42
+ const ESC = '\x1b[';
43
+ /** Any hue (color) SGR: fg 30–37/90–97, bg 40–47/100–107, or 256/truecolor sel. */
44
+ const HUE_RE = /\x1b\[(3\d|4\d|9\d|10\d|38;|48;)/;
45
+ // ── (a) color enabled → status hue present, glyph still there ──────────────────
46
+ test('color on: each status glyph carries its hue, glyph kept', () => {
47
+ // cursor off the rows we assert so lineBase doesn't wrap the glyph code.
48
+ const out = renderFrame(state({ cursor: -1 }), SIZE, ON);
49
+ assert.ok(out.includes(`${ESC}32m●`), 'active → green ●');
50
+ assert.ok(out.includes(`${ESC}33m○`), 'idle → yellow ○');
51
+ assert.ok(out.includes(`${ESC}36m✓`), 'done → cyan ✓');
52
+ assert.ok(out.includes(`${ESC}31m✗`), 'dead → red ✗');
53
+ assert.ok(out.includes(`${ESC}90m⊘`), 'canceled → gray ⊘');
54
+ });
55
+ test('color on: ctx ≥100k row is red; asks are bright-yellow', () => {
56
+ const out = renderFrame(state({ cursor: -1 }), SIZE, ON);
57
+ assert.ok(out.includes(`${ESC}31m120k`), 'ctx ≥100k → red');
58
+ assert.ok(out.includes(`${ESC}93m`), 'asks → bright-yellow');
59
+ assert.ok(out.includes('⚑3'), 'flag glyph kept');
60
+ });
61
+ test('color on: cursor row uses a 256-color background (not full reverse)', () => {
62
+ const out = renderFrame(state({ cursor: 0 }), SIZE, ON);
63
+ assert.ok(out.includes(`${ESC}48;5;236m`), 'subtle dark-gray cursor bg');
64
+ // and the status glyph hue still reads on the selected row
65
+ assert.ok(out.includes(`${ESC}32m●`), 'glyph hue survives on cursor row');
66
+ });
67
+ // ── (b) NO_COLOR → no hue SGR, but the glyphs (the real encoding) remain ───────
68
+ test('color off: no hue SGR emitted, but every status glyph remains', () => {
69
+ const out = renderFrame(state({ cursor: -1 }), SIZE, OFF);
70
+ assert.ok(!HUE_RE.test(out), `expected no color SGR, got: ${JSON.stringify(out.match(HUE_RE))}`);
71
+ for (const g of ['●', '○', '✓', '✗', '⊘']) {
72
+ assert.ok(out.includes(g), `glyph ${g} kept without color`);
73
+ }
74
+ assert.ok(out.includes('⚑3'), 'flag glyph kept without color');
75
+ // structural SGR is still allowed (dim separators/footer, reverse cursor).
76
+ assert.ok(out.includes(`${ESC}2m`), 'dim (structural) still used');
77
+ });
78
+ test('color off: cursor row falls back to reverse (structural, no hue)', () => {
79
+ const out = renderFrame(state({ cursor: 0 }), SIZE, OFF);
80
+ assert.ok(out.includes(`${ESC}7m`), 'reverse cursor fallback');
81
+ assert.ok(!HUE_RE.test(out), 'still no hue under no-color');
82
+ });
83
+ // ── (c) query → the matched substring is highlighted ───────────────────────────
84
+ test('color on: matched chars in a row name get the bright-cyan highlight', () => {
85
+ // query 'lph' matches 'alpha' (a-LPH-a). The matched run is highlighted.
86
+ const out = renderFrame(state({ query: 'lph', cursor: -1 }), SIZE, ON);
87
+ assert.ok(out.includes(`${ESC}96mlph`), 'matched substring → bright-cyan');
88
+ });
89
+ test('color off: match highlight degrades to bold (no hue), name intact', () => {
90
+ const out = renderFrame(state({ query: 'lph', cursor: -1 }), SIZE, OFF);
91
+ assert.ok(!HUE_RE.test(out), 'no hue under no-color even with a query');
92
+ assert.ok(out.includes(`${ESC}1mlph`), 'matched chars bold as the no-color affordance');
93
+ });
94
+ // ── detectColorCaps gate ───────────────────────────────────────────────────────
95
+ test('detectColorCaps: honors NO_COLOR, TERM=dumb, and non-TTY', () => {
96
+ assert.deepEqual(detectColorCaps({ isTTY: true }, { TERM: 'xterm-256color', COLORTERM: 'truecolor' }), { color: true, color256: true });
97
+ assert.equal(detectColorCaps({ isTTY: true }, { TERM: 'xterm-256color', NO_COLOR: '1' }).color, false);
98
+ assert.equal(detectColorCaps({ isTTY: true }, { TERM: 'dumb' }).color, false);
99
+ assert.equal(detectColorCaps({ isTTY: false }, { TERM: 'xterm-256color' }).color, false);
100
+ // basic color but no 256 → color on, color256 off (reverse cursor fallback).
101
+ assert.deepEqual(detectColorCaps({ isTTY: true }, { TERM: 'xterm' }), { color: true, color256: false });
102
+ });
@@ -0,0 +1,4 @@
1
+ export declare function runBrowse(opts?: {
2
+ returnPane?: string;
3
+ cwd?: string;
4
+ }): Promise<void>;