@crouton-kit/crouter 0.3.18 → 0.3.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (289) hide show
  1. package/dist/build-root.js +6 -0
  2. package/dist/builtin-memory/crouter-development/marketplaces.md +164 -0
  3. package/dist/builtin-memory/crouter-development/personas/base-prompt.md +58 -0
  4. package/dist/builtin-memory/crouter-development/personas/orchestrator-prompt.md +60 -0
  5. package/dist/builtin-memory/crouter-development/personas.md +117 -0
  6. package/dist/builtin-memory/crouter-development/plugins.md +163 -0
  7. package/dist/builtin-memory/design.md +60 -0
  8. package/dist/builtin-memory/development.md +116 -0
  9. package/dist/builtin-memory/planning.md +66 -0
  10. package/dist/builtin-memory/spec.md +99 -0
  11. package/dist/builtin-personas/design/PERSONA.md +1 -0
  12. package/dist/builtin-personas/design/orchestrator.md +2 -1
  13. package/dist/builtin-personas/developer/PERSONA.md +1 -0
  14. package/dist/builtin-personas/developer/orchestrator.md +2 -1
  15. package/dist/builtin-personas/explore/PERSONA.md +1 -0
  16. package/dist/builtin-personas/explore/orchestrator.md +4 -0
  17. package/dist/builtin-personas/general/PERSONA.md +1 -0
  18. package/dist/builtin-personas/general/orchestrator.md +4 -0
  19. package/dist/builtin-personas/lifecycle/resident.md +1 -1
  20. package/dist/builtin-personas/lifecycle/terminal.md +5 -2
  21. package/dist/builtin-personas/orchestration-kernel.md +15 -24
  22. package/dist/builtin-personas/plan/PERSONA.md +1 -0
  23. package/dist/builtin-personas/plan/orchestrator.md +2 -1
  24. package/dist/builtin-personas/plan/reviewers/architecture-fit/PERSONA.md +1 -0
  25. package/dist/builtin-personas/plan/reviewers/code-smells/PERSONA.md +1 -0
  26. package/dist/builtin-personas/plan/reviewers/pattern-consistency/PERSONA.md +1 -0
  27. package/dist/builtin-personas/plan/reviewers/requirements-coverage/PERSONA.md +1 -0
  28. package/dist/builtin-personas/plan/reviewers/security/PERSONA.md +1 -0
  29. package/dist/builtin-personas/review/PERSONA.md +1 -0
  30. package/dist/builtin-personas/review/orchestrator.md +4 -0
  31. package/dist/builtin-personas/runtime-base.md +6 -3
  32. package/dist/builtin-personas/spec/PERSONA.md +1 -0
  33. package/dist/builtin-personas/spec/orchestrator.md +2 -1
  34. package/dist/builtin-personas/waiting.md +8 -0
  35. package/dist/builtin-skills/skills/crouter-development/personas/orchestrator-prompt/SKILL.md +1 -1
  36. package/dist/builtin-skills/skills/crouter-development/plugins/SKILL.md +2 -2
  37. package/dist/builtin-views/_lib/states.mjs +161 -0
  38. package/dist/builtin-views/canvas/client.mjs +303 -0
  39. package/dist/builtin-views/canvas/view.mjs +576 -0
  40. package/dist/builtin-views/git-pr/client.mjs +440 -0
  41. package/dist/builtin-views/git-pr/view.mjs +675 -0
  42. package/dist/builtin-views/inbox/_lib/render.mjs +177 -0
  43. package/dist/builtin-views/inbox/sources/gmail.mjs +965 -0
  44. package/dist/builtin-views/inbox/sources/linkedin.mjs +427 -0
  45. package/dist/builtin-views/inbox/view.mjs +889 -0
  46. package/dist/builtin-views/linkedin/client.mjs +610 -0
  47. package/dist/builtin-views/linkedin/view.mjs +1171 -0
  48. package/dist/clients/attach/attach-cmd.d.ts +2 -0
  49. package/dist/clients/attach/attach-cmd.js +354 -0
  50. package/dist/clients/attach/chat-view.d.ts +77 -0
  51. package/dist/clients/attach/chat-view.js +450 -0
  52. package/dist/clients/attach/clipboard-image.d.ts +16 -0
  53. package/dist/clients/attach/clipboard-image.js +113 -0
  54. package/dist/clients/attach/config-load.d.ts +31 -0
  55. package/dist/clients/attach/config-load.js +113 -0
  56. package/dist/clients/attach/extension-dialogs.d.ts +29 -0
  57. package/dist/clients/attach/extension-dialogs.js +101 -0
  58. package/dist/clients/attach/input-controller.d.ts +54 -0
  59. package/dist/clients/attach/input-controller.js +204 -0
  60. package/dist/clients/attach/slash-commands.d.ts +36 -0
  61. package/dist/clients/attach/slash-commands.js +200 -0
  62. package/dist/clients/attach/view-socket.d.ts +48 -0
  63. package/dist/clients/attach/view-socket.js +126 -0
  64. package/dist/commands/attention.js +3 -3
  65. package/dist/commands/canvas-prune.js +1 -1
  66. package/dist/commands/daemon.js +3 -2
  67. package/dist/commands/human/prompts.js +1 -1
  68. package/dist/commands/human/queue.js +54 -8
  69. package/dist/commands/human/shared.d.ts +2 -0
  70. package/dist/commands/memory/find.d.ts +1 -0
  71. package/dist/commands/memory/find.js +180 -0
  72. package/dist/commands/memory/lint.d.ts +1 -0
  73. package/dist/commands/memory/lint.js +140 -0
  74. package/dist/commands/memory/list.d.ts +1 -0
  75. package/dist/commands/memory/list.js +79 -0
  76. package/dist/commands/memory/read.js +103 -0
  77. package/dist/commands/memory/shared.d.ts +30 -0
  78. package/dist/commands/memory/shared.js +122 -0
  79. package/dist/commands/memory/write.d.ts +1 -0
  80. package/dist/commands/memory/write.js +85 -0
  81. package/dist/commands/memory.d.ts +2 -0
  82. package/dist/commands/memory.js +27 -0
  83. package/dist/commands/node.d.ts +3 -2
  84. package/dist/commands/node.js +610 -61
  85. package/dist/commands/pkg/market-manage.js +1 -1
  86. package/dist/commands/push.js +6 -6
  87. package/dist/commands/revive.js +1 -1
  88. package/dist/commands/skill/author.js +1 -1
  89. package/dist/commands/skill/shared.d.ts +1 -8
  90. package/dist/commands/skill/shared.js +2 -55
  91. package/dist/commands/skill.js +9 -14
  92. package/dist/commands/sys/doctor.js +1 -1
  93. package/dist/commands/sys/update.js +1 -1
  94. package/dist/commands/view-cycle.d.ts +2 -0
  95. package/dist/commands/view-cycle.js +125 -0
  96. package/dist/commands/view-list.d.ts +2 -0
  97. package/dist/commands/view-list.js +66 -0
  98. package/dist/commands/view-new.d.ts +2 -0
  99. package/dist/commands/view-new.js +70 -0
  100. package/dist/commands/view-pick.d.ts +2 -0
  101. package/dist/commands/view-pick.js +119 -0
  102. package/dist/commands/view-run.d.ts +2 -0
  103. package/dist/commands/view-run.js +191 -0
  104. package/dist/commands/view.d.ts +2 -0
  105. package/dist/commands/view.js +29 -0
  106. package/dist/core/__tests__/broker-attach-limits.test.d.ts +1 -0
  107. package/dist/core/__tests__/broker-attach-limits.test.js +157 -0
  108. package/dist/core/__tests__/broker-attach-stream.test.d.ts +1 -0
  109. package/dist/core/__tests__/broker-attach-stream.test.js +125 -0
  110. package/dist/core/__tests__/broker-crash-teardown.test.d.ts +1 -0
  111. package/dist/core/__tests__/broker-crash-teardown.test.js +116 -0
  112. package/dist/core/__tests__/broker-dialogs.test.d.ts +1 -0
  113. package/dist/core/__tests__/broker-dialogs.test.js +126 -0
  114. package/dist/core/__tests__/broker-dormant-wake.test.d.ts +1 -0
  115. package/dist/core/__tests__/broker-dormant-wake.test.js +51 -0
  116. package/dist/core/__tests__/broker-lifecycle.test.d.ts +1 -0
  117. package/dist/core/__tests__/broker-lifecycle.test.js +87 -0
  118. package/dist/core/__tests__/broker-sdk-wiring.test.d.ts +1 -0
  119. package/dist/core/__tests__/broker-sdk-wiring.test.js +166 -0
  120. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.d.ts +1 -0
  121. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +102 -0
  122. package/dist/core/__tests__/canvas-inbox-watcher.test.js +2 -36
  123. package/dist/core/__tests__/cascade-close.test.js +12 -2
  124. package/dist/core/__tests__/child-death-wake.test.d.ts +1 -0
  125. package/dist/core/__tests__/child-death-wake.test.js +245 -0
  126. package/dist/core/__tests__/context-intro.test.js +76 -62
  127. package/dist/core/__tests__/daemon-boot.test.js +14 -5
  128. package/dist/core/__tests__/daemon-liveness.test.js +34 -9
  129. package/dist/core/__tests__/detach-focus.test.d.ts +1 -0
  130. package/dist/core/__tests__/detach-focus.test.js +206 -0
  131. package/dist/core/__tests__/draw-style.test.d.ts +1 -0
  132. package/dist/core/__tests__/draw-style.test.js +258 -0
  133. package/dist/core/__tests__/fixtures/c3-custom-provider-ext.d.ts +2 -0
  134. package/dist/core/__tests__/fixtures/c3-custom-provider-ext.js +18 -0
  135. package/dist/core/__tests__/fixtures/fake-engine.d.ts +138 -0
  136. package/dist/core/__tests__/fixtures/fake-engine.js +614 -0
  137. package/dist/core/__tests__/fixtures/fake-pi-host.js +1 -0
  138. package/dist/core/__tests__/flagship-lifecycle.test.js +7 -1
  139. package/dist/core/__tests__/frame-decoder-perf.test.d.ts +1 -0
  140. package/dist/core/__tests__/frame-decoder-perf.test.js +198 -0
  141. package/dist/core/__tests__/helpers/broker-clients.d.ts +43 -0
  142. package/dist/core/__tests__/helpers/broker-clients.js +178 -0
  143. package/dist/core/__tests__/helpers/harness.d.ts +9 -0
  144. package/dist/core/__tests__/helpers/harness.js +111 -1
  145. package/dist/core/__tests__/home-session.test.js +41 -8
  146. package/dist/core/__tests__/human-new-window-regression.test.d.ts +1 -0
  147. package/dist/core/__tests__/human-new-window-regression.test.js +101 -0
  148. package/dist/core/__tests__/kickoff.test.js +37 -3
  149. package/dist/core/__tests__/live-mutation-verbs.test.d.ts +1 -0
  150. package/dist/core/__tests__/live-mutation-verbs.test.js +175 -0
  151. package/dist/core/__tests__/live-mutation.test.js +6 -132
  152. package/dist/core/__tests__/memory.test.js +23 -115
  153. package/dist/core/__tests__/placement-focus.test.js +5 -0
  154. package/dist/core/__tests__/placement-teardown.test.js +54 -11
  155. package/dist/core/__tests__/relaunch.test.js +4 -3
  156. package/dist/core/__tests__/review-render-pane-regression.test.d.ts +1 -0
  157. package/dist/core/__tests__/review-render-pane-regression.test.js +133 -0
  158. package/dist/core/__tests__/spawn-root.test.js +10 -0
  159. package/dist/core/__tests__/spike-harness.test.js +1 -0
  160. package/dist/core/__tests__/wake-bearings.test.d.ts +1 -0
  161. package/dist/core/__tests__/wake-bearings.test.js +156 -0
  162. package/dist/core/__tests__/wake-origin.test.d.ts +1 -0
  163. package/dist/core/__tests__/wake-origin.test.js +110 -0
  164. package/dist/core/bootstrap.js +1 -1
  165. package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
  166. package/dist/core/canvas/browse/app.js +24 -2
  167. package/dist/core/canvas/browse/model.d.ts +38 -2
  168. package/dist/core/canvas/browse/model.js +134 -10
  169. package/dist/core/canvas/browse/render.d.ts +6 -12
  170. package/dist/core/canvas/browse/render.js +72 -104
  171. package/dist/core/canvas/canvas.js +16 -23
  172. package/dist/core/canvas/db.js +46 -0
  173. package/dist/core/canvas/index.d.ts +1 -0
  174. package/dist/core/canvas/index.js +1 -0
  175. package/dist/core/canvas/pid.d.ts +4 -0
  176. package/dist/core/canvas/pid.js +23 -0
  177. package/dist/core/canvas/render.d.ts +11 -2
  178. package/dist/core/canvas/render.js +69 -0
  179. package/dist/core/canvas/types.d.ts +91 -3
  180. package/dist/core/canvas/wakeups.d.ts +76 -0
  181. package/dist/core/canvas/wakeups.js +185 -0
  182. package/dist/core/config.js +4 -1
  183. package/dist/core/frontmatter.js +37 -124
  184. package/dist/core/memory-resolver.d.ts +49 -0
  185. package/dist/core/memory-resolver.js +141 -0
  186. package/dist/core/personas/loader.d.ts +7 -0
  187. package/dist/core/personas/loader.js +36 -5
  188. package/dist/core/personas/resolve.js +13 -3
  189. package/dist/core/predicate.d.ts +63 -0
  190. package/dist/core/predicate.js +189 -0
  191. package/dist/core/resolver.js +26 -5
  192. package/dist/core/runtime/bearings.d.ts +53 -12
  193. package/dist/core/runtime/bearings.js +132 -59
  194. package/dist/core/runtime/broker-cli.d.ts +1 -0
  195. package/dist/core/runtime/broker-cli.js +46 -0
  196. package/dist/core/runtime/broker-protocol.d.ts +332 -0
  197. package/dist/core/runtime/broker-protocol.js +153 -0
  198. package/dist/core/runtime/broker-sdk.d.ts +48 -0
  199. package/dist/core/runtime/broker-sdk.js +72 -0
  200. package/dist/core/runtime/broker.d.ts +55 -0
  201. package/dist/core/runtime/broker.js +1128 -0
  202. package/dist/core/runtime/close.js +35 -6
  203. package/dist/core/runtime/host.d.ts +53 -0
  204. package/dist/core/runtime/host.js +186 -0
  205. package/dist/core/runtime/kickoff.d.ts +2 -1
  206. package/dist/core/runtime/kickoff.js +91 -5
  207. package/dist/core/runtime/launch.d.ts +54 -4
  208. package/dist/core/runtime/launch.js +85 -5
  209. package/dist/core/runtime/lifecycle.js +23 -6
  210. package/dist/core/runtime/memory.d.ts +2 -42
  211. package/dist/core/runtime/memory.js +11 -162
  212. package/dist/core/runtime/nodes.d.ts +37 -0
  213. package/dist/core/runtime/nodes.js +60 -1
  214. package/dist/core/runtime/persona.js +21 -11
  215. package/dist/core/runtime/pi-vendored.d.ts +18 -0
  216. package/dist/core/runtime/pi-vendored.js +49 -0
  217. package/dist/core/runtime/placement.d.ts +32 -14
  218. package/dist/core/runtime/placement.js +206 -52
  219. package/dist/core/runtime/promote.d.ts +0 -6
  220. package/dist/core/runtime/promote.js +4 -12
  221. package/dist/core/runtime/{demote.d.ts → recycle.d.ts} +5 -5
  222. package/dist/core/runtime/{demote.js → recycle.js} +27 -11
  223. package/dist/core/runtime/reset.js +11 -8
  224. package/dist/core/runtime/revive.d.ts +2 -0
  225. package/dist/core/runtime/revive.js +34 -34
  226. package/dist/core/runtime/spawn.d.ts +23 -0
  227. package/dist/core/runtime/spawn.js +77 -23
  228. package/dist/core/runtime/stop-guard.d.ts +1 -1
  229. package/dist/core/runtime/stop-guard.js +6 -1
  230. package/dist/core/runtime/tmux-chrome.d.ts +1 -1
  231. package/dist/core/runtime/tmux-chrome.js +1 -1
  232. package/dist/core/runtime/tmux.d.ts +28 -0
  233. package/dist/core/runtime/tmux.js +80 -6
  234. package/dist/core/scope.d.ts +11 -0
  235. package/dist/core/scope.js +39 -0
  236. package/dist/core/spawn.d.ts +5 -0
  237. package/dist/core/spawn.js +20 -2
  238. package/dist/core/substrate/gate.d.ts +13 -0
  239. package/dist/core/substrate/gate.js +21 -0
  240. package/dist/core/substrate/index.d.ts +7 -0
  241. package/dist/core/substrate/index.js +18 -0
  242. package/dist/core/substrate/on-read.d.ts +14 -0
  243. package/dist/core/substrate/on-read.js +292 -0
  244. package/dist/core/substrate/render.d.ts +25 -0
  245. package/dist/core/substrate/render.js +256 -0
  246. package/dist/core/substrate/schema.d.ts +76 -0
  247. package/dist/core/substrate/schema.js +124 -0
  248. package/dist/core/substrate/session-cache.d.ts +30 -0
  249. package/dist/core/substrate/session-cache.js +77 -0
  250. package/dist/core/substrate/subject.d.ts +41 -0
  251. package/dist/core/substrate/subject.js +54 -0
  252. package/dist/core/tui/contract.d.ts +83 -0
  253. package/dist/core/tui/contract.js +8 -0
  254. package/dist/core/tui/draw.d.ts +96 -0
  255. package/dist/core/tui/draw.js +339 -0
  256. package/dist/core/tui/host.d.ts +29 -0
  257. package/dist/core/tui/host.js +379 -0
  258. package/dist/core/tui/loader.d.ts +16 -0
  259. package/dist/core/tui/loader.js +94 -0
  260. package/dist/core/{canvas/browse → tui}/terminal.js +7 -6
  261. package/dist/core/wake.d.ts +86 -0
  262. package/dist/core/wake.js +308 -0
  263. package/dist/daemon/crtrd.d.ts +29 -4
  264. package/dist/daemon/crtrd.js +662 -46
  265. package/dist/pi-extensions/__tests__/canvas-context-intro.test.d.ts +1 -0
  266. package/dist/pi-extensions/__tests__/canvas-context-intro.test.js +171 -0
  267. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +19 -12
  268. package/dist/pi-extensions/canvas-commands.d.ts +3 -0
  269. package/dist/pi-extensions/canvas-commands.js +10 -0
  270. package/dist/pi-extensions/canvas-context-intro.d.ts +17 -0
  271. package/dist/pi-extensions/canvas-context-intro.js +55 -15
  272. package/dist/pi-extensions/canvas-doc-substrate.d.ts +44 -0
  273. package/dist/pi-extensions/canvas-doc-substrate.js +112 -0
  274. package/dist/pi-extensions/canvas-nav.d.ts +3 -0
  275. package/dist/pi-extensions/canvas-nav.js +87 -23
  276. package/dist/pi-extensions/canvas-stophook.js +17 -8
  277. package/dist/pi-extensions/canvas-view.d.ts +21 -0
  278. package/dist/pi-extensions/canvas-view.js +75 -0
  279. package/dist/prompts/skill.js +19 -26
  280. package/dist/prompts/view.d.ts +7 -0
  281. package/dist/prompts/view.js +101 -0
  282. package/dist/types.d.ts +4 -0
  283. package/dist/types.js +1 -0
  284. package/package.json +9 -4
  285. package/dist/commands/skill/find.d.ts +0 -4
  286. package/dist/commands/skill/find.js +0 -257
  287. package/dist/commands/skill/read.js +0 -91
  288. /package/dist/commands/{skill → memory}/read.d.ts +0 -0
  289. /package/dist/core/{canvas/browse → tui}/terminal.d.ts +0 -0
@@ -0,0 +1,206 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/detach-focus.test.ts
2
+ //
3
+ // GAP CLOSE (flagship-lifecycle.test.ts coverage boundary): "node demote
4
+ // --detach (A3: orphaned-focus-row hazard)". An END-TO-END test that drives the
5
+ // REAL `crtr node demote --detach` verb (subprocess, AS the node) — the actual
6
+ // Alt+C → D menu path — against a live fake-pi node genuinely FOCUSED in a user
7
+ // viewport, and
8
+ // proves the three things the A3 hazard is about:
9
+ // (a) lifecycle flips terminal IN PLACE,
10
+ // (b) the still-running pi's pane is RELOCATED to the backstage crtr session
11
+ // (the pi keeps generating off-screen, not killed),
12
+ // (c) the focus row it occupied is CLOSED — no orphaned/phantom viewport row
13
+ // lingering on the relocated %pane_id (Invariant P / Invariant F5).
14
+ //
15
+ // This is the harness-driven counterpart to placement-focus.test.ts's
16
+ // `detachToBackground` unit (which calls the function directly): here the FLIP
17
+ // and the detach both go through the real CLI leaf (node.ts nodeDemote →
18
+ // setLifecycle), so the whole `crtr node demote --detach` wiring — the Alt+C → D
19
+ // menu path — is exercised. Modeled on live-mutation.test.ts (harness +
20
+ // `crtr node demote` + firstPaneOf) and
21
+ // placement-focus.test.ts (user/back sessions + focus-row asserts).
22
+ //
23
+ // NOTE — the SIBLING focused-finish→manager-TAKEOVER path is NOT added here: the
24
+ // harness mints its root via in-process createNode (never a real bootRoot — see
25
+ // flagship S1), so a manager is always a paneless, never-booted row. Driving B
26
+ // through agent_end's done-branch (h.finish) with such a manager would only ever
27
+ // hit handFocusToManager's "live-but-paneless inline root → false" guard
28
+ // (closeFocusToShell), never a genuine takeover; a LIVE-backstage or dormant
29
+ // idle-release manager swap is unreachable through the harness. That swap is
30
+ // unit-covered in placement-teardown.test.ts (MAJOR 1 + the dormant idle-release
31
+ // case), so it is deliberately not re-driven here.
32
+ import { test } from 'node:test';
33
+ import assert from 'node:assert/strict';
34
+ import { spawnSync } from 'node:child_process';
35
+ import { createHarness, hasTmux } from './helpers/harness.js';
36
+ import { setPresence, getNode } from '../canvas/canvas.js';
37
+ import { openFocusRow, getFocusByNode, getFocusByPane, listFocuses } from '../canvas/focuses.js';
38
+ import { closeDb } from '../canvas/db.js';
39
+ import { markBusy } from '../runtime/busy.js';
40
+ const SKIP = !hasTmux() ? 'tmux unavailable' : false;
41
+ function sessionExists(session) {
42
+ return spawnSync('tmux', ['has-session', '-t', session], { stdio: 'ignore' }).status === 0;
43
+ }
44
+ function tmuxOut(args) {
45
+ return (spawnSync('tmux', args, { encoding: 'utf8' }).stdout ?? '').trim();
46
+ }
47
+ /** The first %pane_id of a tmux window (spawn records window+session, not pane). */
48
+ function firstPaneOf(window) {
49
+ const r = spawnSync('tmux', ['list-panes', '-t', window, '-F', '#{pane_id}'], { encoding: 'utf8' });
50
+ if (r.status !== 0)
51
+ return null;
52
+ return r.stdout.split('\n').map((s) => s.trim()).filter(Boolean)[0] ?? null;
53
+ }
54
+ /** A pane's CURRENT session/window (display-message on its durable %id). */
55
+ function paneLoc(pane) {
56
+ const out = tmuxOut(['display-message', '-p', '-t', pane, '#{session_name}\t#{window_id}']);
57
+ const [session, window] = out.split('\t');
58
+ if (!session || !window)
59
+ return null;
60
+ return { session, window };
61
+ }
62
+ function paneSessionReal(pane) {
63
+ return tmuxOut(['display-message', '-p', '-t', pane, '#{session_name}']);
64
+ }
65
+ function paneExistsReal(pane) {
66
+ return tmuxOut(['display-message', '-p', '-t', pane, '#{pane_id}']) === pane;
67
+ }
68
+ // ===========================================================================
69
+ // `crtr node demote --detach` on a FOCUSED node (A3 gap close; Alt+C → D).
70
+ // ===========================================================================
71
+ test('node demote --detach on a FOCUSED node: flips terminal, relocates the pane to the backstage, CLOSES the focus row (A3 — no orphaned focus row)', { skip: SKIP, timeout: 120_000 }, async () => {
72
+ const h = await createHarness({ sessionPrefix: 'crtr-detach-focus' });
73
+ const user = `crtr-detach-user-${process.pid}-${Date.now().toString(36)}`;
74
+ try {
75
+ // A resident root + a live terminal child B (born into the backstage =
76
+ // h.session, the harness's CRTR_NODE_SESSION). B holds an active live sub
77
+ // to nothing extra — it just needs to be a live, focusable terminal node.
78
+ const A = h.spawnRoot('resident root');
79
+ const B = await h.spawnChild(A, 'do the work', { kind: 'developer' });
80
+ const b0 = h.node(B);
81
+ assert.equal(b0.lifecycle, 'terminal', 'B born terminal');
82
+ assert.equal(b0.status, 'active', 'B active after boot');
83
+ // Resolve B's live %pane_id from its window (the spawn path records only
84
+ // window+session; pane is null until a reconcile/focus).
85
+ const bPane = firstPaneOf(b0.window);
86
+ assert.ok(typeof bPane === 'string' && bPane !== '', 'B has a live pane');
87
+ // --- Put B into a USER viewport, FOCUSED. Create a separate user session
88
+ // and break B's live pi pane out into it (the pi keeps running; the
89
+ // %id survives the break), then anchor its presence + open a focus row.
90
+ // This is the genuine "focused in a user pane" precondition — distinct
91
+ // from the backstage (h.session) the detach will relocate it back to.
92
+ spawnSync('tmux', ['new-session', '-d', '-s', user, '-c', '/tmp', 'sleep 600'], { stdio: 'ignore' });
93
+ assert.equal(spawnSync('tmux', ['break-pane', '-d', '-a', '-s', bPane, '-t', `${user}:`]).status, 0, 'B pane broke out into the user viewport (pi kept alive)');
94
+ const moved = paneLoc(bPane);
95
+ assert.equal(moved?.session, user, 'B pane physically in the user session now');
96
+ assert.equal(paneExistsReal(bPane), true, "B's pi is still alive after the move");
97
+ closeDb();
98
+ setPresence(B, { pane: bPane, tmux_session: user, window: moved.window });
99
+ openFocusRow('f-detach', bPane, user, B);
100
+ assert.equal(getFocusByNode(B)?.focus_id, 'f-detach', 'precondition: B is FOCUSED in the user viewport');
101
+ // B must read as GENERATING for the backstage-park path: isGenerating now
102
+ // gates the break-pane (Bug 1 / Invariant P) on the mid-turn busy marker
103
+ // AND a live pi_pid. The boot only recorded pi_pid (session_start); no turn
104
+ // fired, so set the busy marker to model an agent genuinely mid-turn —
105
+ // otherwise the new gate would RELEASE B (the non-generating path below).
106
+ markBusy(B);
107
+ // --- Drive the REAL verb on the FOCUSED node. The subprocess inherits
108
+ // CRTR_NODE_SESSION = h.session (the backstage), so detachToBackground
109
+ // breaks the pane back into h.session.
110
+ const res = h.cli(B, ['node', 'demote', '--node', B, '--pane', bPane, '--detach']);
111
+ assert.equal(res.code, 0, `node demote --detach exit 0\n${res.stderr}`);
112
+ assert.match(res.stdout, /detached="true"/, `the agent was detached\n${res.stdout}`);
113
+ closeDb();
114
+ const b = getNode(B);
115
+ // (a) lifecycle flipped to terminal IN PLACE.
116
+ assert.equal(b.lifecycle, 'terminal', '(a) B lifecycle=terminal after the flip');
117
+ assert.equal(b.status, 'active', "(a) detach does NOT end the pi — B stays active");
118
+ // (b) the still-running pane was RELOCATED to the backstage crtr session
119
+ // (NOT killed): the pi keeps generating off-screen.
120
+ assert.equal(paneExistsReal(bPane), true, "(b) B's pi keeps generating (pane alive, relocated not killed)");
121
+ assert.equal(paneSessionReal(bPane), h.session, '(b) B pane relocated to the backstage crtr session');
122
+ assert.equal(b.tmux_session, h.session, "(b) B's LOCATION followed the pane to the backstage");
123
+ // (c) the focus row is CLOSED — no orphaned/phantom viewport (A3 hazard).
124
+ assert.equal(getFocusByNode(B), null, '(c) B no longer occupies any focus (row CLOSED — Invariant P)');
125
+ assert.equal(getFocusByPane(bPane), null, '(c) NO phantom focus resolves on the relocated %id');
126
+ assert.equal(listFocuses().length, 0, '(c) no dangling focus rows remain');
127
+ }
128
+ finally {
129
+ spawnSync('tmux', ['kill-session', '-t', user], { stdio: 'ignore' });
130
+ const session = h.session;
131
+ await h.dispose();
132
+ assert.equal(sessionExists(session), false, 'isolated session killed — no stray');
133
+ }
134
+ });
135
+ // ===========================================================================
136
+ // Bug 1 (Invariant P / detach-idle-node): `crtr node demote --detach` on a
137
+ // NON-GENERATING focused node must NOT park it alive in the backstage — it must
138
+ // be RELEASED to dormant and its pane reaped.
139
+ //
140
+ // The backstage holds ONLY generating-but-unfocused nodes (Invariant P).
141
+ // detachToBackground used to break-pane to the backstage UNCONDITIONALLY, with no
142
+ // generating-ness check: a resident root parked idle between turns (pi alive,
143
+ // idle, NOT mid-turn) detached via Alt+C → D became unfocused + pi-alive-idle +
144
+ // holding a backstage pane forever — nothing re-enters agent_end to release it
145
+ // and, with no live subscription, nothing wakes it → a leaked live-idle pi that
146
+ // never reaps. The fix gates the break-pane on isGenerating() and RELEASES a
147
+ // non-generating node to dormant instead (mirrors retargetFocus's parked-viewer
148
+ // release on focus-away).
149
+ //
150
+ // This drives the SAME real verb as the happy-path test above, but on a node
151
+ // with NO busy marker (not mid-turn). isGenerating() = busy && pidAlive, so a
152
+ // freshly-booted node (pi_pid recorded at session_start, no turn fired) reads as
153
+ // NOT generating.
154
+ // ===========================================================================
155
+ test('node demote --detach on a NON-GENERATING focused node: RELEASES to dormant (idle + idle-release), reaps the pane, closes the focus row — NOT parked in the backstage (Bug 1 — Invariant P)', { skip: SKIP, timeout: 120_000 }, async () => {
156
+ const h = await createHarness({ sessionPrefix: 'crtr-detach-idle' });
157
+ const user = `crtr-detach-idle-user-${process.pid}-${Date.now().toString(36)}`;
158
+ try {
159
+ // A resident root + a live terminal child B. B is freshly booted: pi alive
160
+ // (pi_pid recorded at session_start), status active, and — critically — NO
161
+ // busy marker, since no turn ever fired. It is parked idle, not mid-turn:
162
+ // exactly the non-generating occupant Bug 1 is about.
163
+ const A = h.spawnRoot('resident root');
164
+ const B = await h.spawnChild(A, 'do the work', { kind: 'developer' });
165
+ const b0 = h.node(B);
166
+ assert.equal(b0.status, 'active', 'B active after boot');
167
+ const bPane = firstPaneOf(b0.window);
168
+ assert.ok(typeof bPane === 'string' && bPane !== '', 'B has a live pane');
169
+ // --- Put B into a USER viewport, FOCUSED (same precondition as the happy
170
+ // path), but DO NOT mark it busy: it stays NOT generating.
171
+ spawnSync('tmux', ['new-session', '-d', '-s', user, '-c', '/tmp', 'sleep 600'], { stdio: 'ignore' });
172
+ assert.equal(spawnSync('tmux', ['break-pane', '-d', '-a', '-s', bPane, '-t', `${user}:`]).status, 0, 'B pane broke out into the user viewport (pi kept alive)');
173
+ const moved = paneLoc(bPane);
174
+ assert.equal(moved?.session, user, 'B pane physically in the user session now');
175
+ assert.equal(paneExistsReal(bPane), true, "B's pi is alive (parked idle, not mid-turn)");
176
+ closeDb();
177
+ setPresence(B, { pane: bPane, tmux_session: user, window: moved.window });
178
+ openFocusRow('f-detach-idle', bPane, user, B);
179
+ assert.equal(getFocusByNode(B)?.focus_id, 'f-detach-idle', 'precondition: B is FOCUSED, NON-generating');
180
+ // --- Drive the REAL verb on the non-generating focused node.
181
+ const res = h.cli(B, ['node', 'demote', '--node', B, '--pane', bPane, '--detach']);
182
+ assert.equal(res.code, 0, `node demote --detach exit 0\n${res.stderr}`);
183
+ assert.match(res.stdout, /detached="true"/, `the agent was detached\n${res.stdout}`);
184
+ closeDb();
185
+ const b = getNode(B);
186
+ // (b) RELEASED to dormant — idle + intent='idle-release' (revivable via inbox
187
+ // / re-focus), NOT left active in the backstage.
188
+ assert.equal(b.status, 'idle', '(b) B released to dormant (status idle)');
189
+ assert.equal(b.intent, 'idle-release', "(b) B's intent is idle-release (daemon revives on inbox)");
190
+ // (c) the pane is REAPED / presence nulled — NOT relocated to the backstage
191
+ // crtr session. The leaked live-idle pi must be gone.
192
+ assert.equal(paneExistsReal(bPane), false, '(c) B pane reaped (NOT parked alive in the backstage)');
193
+ assert.equal(b.pane, null, "(c) B's presence pane is nulled");
194
+ assert.equal(b.tmux_session, null, "(c) B's presence session is nulled (NOT the backstage crtr session)");
195
+ // (a) the focus row is CLOSED — no orphaned/phantom viewport.
196
+ assert.equal(getFocusByNode(B), null, '(a) B no longer occupies any focus (row CLOSED — Invariant P)');
197
+ assert.equal(getFocusByPane(bPane), null, '(a) NO phantom focus resolves on the reaped %id');
198
+ assert.equal(listFocuses().length, 0, '(a) no dangling focus rows remain');
199
+ }
200
+ finally {
201
+ spawnSync('tmux', ['kill-session', '-t', user], { stdio: 'ignore' });
202
+ const session = h.session;
203
+ await h.dispose();
204
+ assert.equal(sessionExists(session), false, 'isolated session killed — no stray');
205
+ }
206
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,258 @@
1
+ // Bug-regression test for the crtr TUI Draw style encoding.
2
+ //
3
+ // Observed bug (canvas + linkedin views, 2026-06-07): view code set Style.fg to a
4
+ // color NAME ('green'/'cyan'/'red') instead of an SGR parameter ('32'/'36'/'31').
5
+ // With color ON, styleSpan emitted `\x1b[greenm` — an invalid CSI the terminal
6
+ // eats as a control op, printing the tail ('reenm') as literal garbage. The
7
+ // monitor's status glyphs/flags rendered as garbage on any color terminal (the
8
+ // non-TTY/NO_COLOR path was unaffected because fg is gated behind `color`).
9
+ //
10
+ // Root-cause fix: styleSpan now only emits fg/bg when the value is a valid SGR
11
+ // parameter string (digits + semicolons); a bad value degrades to no-color
12
+ // instead of emitting a broken escape. These tests lock that guarantee.
13
+ //
14
+ // Run: node --import tsx/esm --test src/core/__tests__/draw-style.test.ts
15
+ import { test, describe } from 'node:test';
16
+ import assert from 'node:assert/strict';
17
+ import { styleSpan, isSgrParams, createDraw } from '../tui/draw.js';
18
+ import { drawChrome } from '../tui/host.js';
19
+ const ESC = '\x1b[';
20
+ const ON = { color: true, color256: true };
21
+ const OFF = { color: false, color256: false };
22
+ const SIZE = { cols: 80, rows: 24 };
23
+ /** Strip the harmless framing CSIs the serializer emits (cursor home / clear),
24
+ * so any REMAINING `\x1b[` not followed by an SGR param digit/semicolon is a
25
+ * broken SGR escape — the exact bug class (`\x1b[greenm`). */
26
+ function hasInvalidCsi(frame) {
27
+ const stripped = frame.replace(/\x1b\[[HJK]/g, '').replace(/[\r\n]/g, '');
28
+ return /\x1b\[[^0-9;]/.test(stripped);
29
+ }
30
+ /** Any 8-bit color SGR (fg 30-37/90-97, or 256-color bg) present in the frame. */
31
+ function hasColorSgr(frame) {
32
+ return /\x1b\[(3[0-9]|9[0-7])m/.test(frame) || /\x1b\[48;5;/.test(frame);
33
+ }
34
+ function baseChrome(over = {}) {
35
+ return { status: null, banner: null, busy: false, loaded: true, lastRefresh: Date.now(), tick: 0, subtitle: null, mode: null, ...over };
36
+ }
37
+ const MANIFEST = {
38
+ id: 't', title: 'Test View', subtitle: '3 unread', description: 'd',
39
+ keymap: [
40
+ { keys: 'j/k', label: 'move' }, { keys: 'enter', label: 'open' },
41
+ { keys: 'r', label: 'reply' }, { keys: 'g', label: 'refresh' }, { keys: 'q', label: 'quit' },
42
+ ],
43
+ };
44
+ describe('styleSpan: SGR encoding (bug-regression)', () => {
45
+ test('a valid numeric fg emits a well-formed CSI', () => {
46
+ const out = styleSpan('●', { fg: '32', bold: true }, true, '');
47
+ assert.ok(out.includes(`${ESC}32m`), 'should contain \\x1b[32m');
48
+ assert.ok(out.includes(`${ESC}1m`), 'bold still emitted');
49
+ });
50
+ test('a color NAME fg degrades to no-color — never emits a broken escape', () => {
51
+ const out = styleSpan('●', { fg: 'green' }, true, '');
52
+ // The bug: \x1b[greenm. Assert no CSI is followed by a non-digit/semicolon.
53
+ assert.ok(!/\x1b\[[^0-9;]/.test(out), 'no invalid CSI introducer');
54
+ assert.ok(!out.includes('green'), 'the bad token never leaks into output');
55
+ });
56
+ test('a color NAME bg degrades to no-color', () => {
57
+ const out = styleSpan('x', { bg: 'red' }, true, '');
58
+ assert.ok(!/\x1b\[[^0-9;]/.test(out), 'no invalid CSI introducer');
59
+ assert.ok(!out.includes('red'), 'the bad token never leaks into output');
60
+ });
61
+ test('a valid 256-color bg index emits 48;5;N', () => {
62
+ const out = styleSpan('x', { bg: '236' }, true, '');
63
+ assert.ok(out.includes(`${ESC}48;5;236m`), 'should contain \\x1b[48;5;236m');
64
+ });
65
+ test('compound SGR params (e.g. "1;36") are valid', () => {
66
+ assert.equal(isSgrParams('1;36'), true);
67
+ assert.equal(isSgrParams('236'), true);
68
+ assert.equal(isSgrParams('green'), false);
69
+ assert.equal(isSgrParams(''), false);
70
+ });
71
+ test('structural styles (bold/dim/reverse) are unaffected by color gating', () => {
72
+ const out = styleSpan('x', { fg: 'green', dim: true }, true, '');
73
+ assert.ok(out.includes(`${ESC}2m`), 'dim still emitted even when fg is rejected');
74
+ });
75
+ });
76
+ // ── New Draw primitives — same bug class (no invalid CSI; bad value degrades) ──
77
+ describe('spansRight: SGR encoding (bug-regression)', () => {
78
+ test('valid numeric fg → well-formed CSI, no broken escape', () => {
79
+ const { draw, frame } = createDraw(SIZE, ON);
80
+ draw.spansRight(0, 80, [{ text: 'ready', style: { fg: '32', bold: true } }]);
81
+ const f = frame();
82
+ assert.ok(f.includes(`${ESC}32m`), 'emits \\x1b[32m');
83
+ assert.ok(!hasInvalidCsi(f), 'no invalid CSI');
84
+ });
85
+ test('a color NAME fg degrades — no broken escape, name not leaked', () => {
86
+ const { draw, frame } = createDraw(SIZE, ON);
87
+ draw.spansRight(0, 80, [{ text: 'x', style: { fg: 'green' } }]);
88
+ const f = frame();
89
+ assert.ok(!hasInvalidCsi(f), 'no invalid CSI introducer');
90
+ assert.ok(!f.includes('green'), 'the bad token never leaks');
91
+ });
92
+ test('over-maxWidth groups left-clip with a leading … (no overflow, no garbage)', () => {
93
+ const { draw, frame } = createDraw(SIZE, ON);
94
+ draw.spansRight(0, 80, [{ text: 'abcdefghij', style: { dim: true } }], 4);
95
+ const f = frame();
96
+ assert.ok(f.includes('…'), 'leading ellipsis present');
97
+ assert.ok(!hasInvalidCsi(f), 'no invalid CSI');
98
+ });
99
+ });
100
+ describe('vline: SGR encoding + mono fallback (bug-regression)', () => {
101
+ test('renders a dim rule with no invalid CSI (color on)', () => {
102
+ const { draw, frame } = createDraw(SIZE, ON);
103
+ draw.vline(40, 0, 10);
104
+ const f = frame();
105
+ assert.ok(f.includes('│'), 'default │ glyph present');
106
+ assert.ok(f.includes(`${ESC}2m`), 'dim emitted');
107
+ assert.ok(!hasInvalidCsi(f), 'no invalid CSI');
108
+ });
109
+ test('ASCII fallback ch and mono caps both stay well-formed', () => {
110
+ const { draw, frame } = createDraw(SIZE, OFF);
111
+ draw.vline(40, 0, 10, '|');
112
+ const f = frame();
113
+ assert.ok(f.includes('|'), 'ASCII fallback | present');
114
+ assert.ok(!hasColorSgr(f), 'no color SGR when caps.color is off');
115
+ assert.ok(!hasInvalidCsi(f), 'no invalid CSI');
116
+ });
117
+ });
118
+ describe('ListItemRow.right: SGR encoding (bug-regression)', () => {
119
+ test('right group + cursor-row base merge → no invalid CSI', () => {
120
+ const { draw, frame } = createDraw(SIZE, ON);
121
+ /** @type {ListItemRow[]} */
122
+ const items = [
123
+ { spans: [{ text: 'Ada Lovelace', style: { bold: true } }], right: [{ text: '2h', style: { fg: '90' } }] },
124
+ { spans: [{ text: 'Grace Hopper' }], right: [{ text: '4h', style: { fg: '90' } }] },
125
+ ];
126
+ const res = draw.list({ row: 2, col: 0, width: 80, height: 5 }, items, 0, 0);
127
+ const f = frame();
128
+ assert.equal(res.scroll, 0);
129
+ assert.ok(f.includes('2h') && f.includes('4h'), 'right-flushed timestamps drawn');
130
+ assert.ok(f.includes(`${ESC}48;5;236m`), 'cursor-row 236-bg highlight present');
131
+ assert.ok(!hasInvalidCsi(f), 'no invalid CSI');
132
+ });
133
+ test('a color NAME fg in the right group degrades, not leaks', () => {
134
+ const { draw, frame } = createDraw(SIZE, ON);
135
+ const items = [{ spans: [{ text: 'x' }], right: [{ text: 'y', style: { fg: 'cyan' } }] }];
136
+ draw.list({ row: 0, col: 0, width: 80, height: 2 }, items, 0, 0);
137
+ const f = frame();
138
+ assert.ok(!hasInvalidCsi(f), 'no invalid CSI');
139
+ assert.ok(!f.includes('cyan'), 'the bad token never leaks');
140
+ });
141
+ });
142
+ // ── Chrome — every new element survives the bug class + has a mono fallback ──
143
+ describe('drawChrome: SGR encoding + mono fallback (bug-regression)', () => {
144
+ const states = [
145
+ ['working', baseChrome({ busy: true, status: 'Loading…' })],
146
+ ['blocked', baseChrome({ banner: { msg: 'Send rejected', level: 'error' } })],
147
+ ['attention', baseChrome({ banner: { msg: 'Log in, then press r', level: 'action' } })],
148
+ ['ready', baseChrome({ status: '5 conversations' })],
149
+ ['idle', baseChrome({ loaded: false, lastRefresh: 0 })],
150
+ ['info-banner', baseChrome({ banner: { msg: 'Throttling — waiting', level: 'info' } })],
151
+ ];
152
+ for (const [name, chrome] of states) {
153
+ test(`state "${name}" never emits a broken escape (color on)`, () => {
154
+ const { draw, frame } = createDraw(SIZE, ON);
155
+ drawChrome(draw, SIZE, MANIFEST, chrome);
156
+ assert.ok(!hasInvalidCsi(frame()), 'no invalid CSI');
157
+ });
158
+ }
159
+ test('mono fallback: no color SGR when caps.color is off, glyphs/words still render', () => {
160
+ const { draw, frame } = createDraw(SIZE, OFF);
161
+ drawChrome(draw, SIZE, MANIFEST, baseChrome({ busy: true, status: 'Loading…' }));
162
+ const f = frame();
163
+ assert.ok(!hasColorSgr(f), 'no color SGR emitted in mono');
164
+ assert.ok(f.includes('▎'), 'state rail glyph always drawn');
165
+ assert.ok(f.includes('working'), 'state WORD carries meaning in mono');
166
+ assert.ok(f.includes(`${ESC}1m`), 'structural bold still emitted');
167
+ assert.ok(!hasInvalidCsi(f), 'no invalid CSI');
168
+ });
169
+ test('every banner level renders its glyph with no broken escape', () => {
170
+ for (const [level, glyph] of [['info', 'ℹ'], ['action', '▸'], ['error', '✗']]) {
171
+ const { draw, frame } = createDraw(SIZE, ON);
172
+ drawChrome(draw, SIZE, MANIFEST, baseChrome({ banner: { msg: 'm', level } }));
173
+ const f = frame();
174
+ assert.ok(f.includes(glyph), `${level} banner glyph ${glyph} present`);
175
+ assert.ok(!hasInvalidCsi(f), `${level} banner: no invalid CSI`);
176
+ }
177
+ });
178
+ test('keymap overflow degrades to keys-only on a narrow screen, still well-formed', () => {
179
+ const narrow = { cols: 28, rows: 24 };
180
+ const { draw, frame } = createDraw(narrow, ON);
181
+ drawChrome(draw, narrow, MANIFEST, baseChrome({ status: 'a very long status line here' }));
182
+ const f = frame();
183
+ assert.ok(f.includes('q'), 'q quit hint survives overflow');
184
+ assert.ok(!hasInvalidCsi(f), 'no invalid CSI under overflow');
185
+ });
186
+ });
187
+ // ── Dynamic subtitle (host.setSubtitle) — same bug class + mono fallback ──
188
+ describe('drawChrome subtitle: SGR encoding + mono fallback (bug-regression)', () => {
189
+ test('a dynamic subtitle overrides manifest.subtitle, dim, no broken escape', () => {
190
+ const { draw, frame } = createDraw(SIZE, ON);
191
+ drawChrome(draw, SIZE, MANIFEST, baseChrome({ subtitle: '7 unread' }));
192
+ const f = frame();
193
+ assert.ok(f.includes('7 unread'), 'dynamic subtitle rendered');
194
+ assert.ok(!f.includes('3 unread'), 'dynamic value overrides the static manifest subtitle');
195
+ assert.ok(f.includes(`${ESC}2m`), 'subtitle is dim (structural)');
196
+ assert.ok(!hasInvalidCsi(f), 'no invalid CSI');
197
+ });
198
+ test('null subtitle falls back to the manifest default', () => {
199
+ const { draw, frame } = createDraw(SIZE, ON);
200
+ drawChrome(draw, SIZE, MANIFEST, baseChrome({ subtitle: null }));
201
+ const f = frame();
202
+ assert.ok(f.includes('3 unread'), 'manifest.subtitle shown when dynamic is null');
203
+ assert.ok(!hasInvalidCsi(f), 'no invalid CSI');
204
+ });
205
+ test('mono fallback: subtitle text renders dim with no color SGR', () => {
206
+ const { draw, frame } = createDraw(SIZE, OFF);
207
+ drawChrome(draw, SIZE, MANIFEST, baseChrome({ subtitle: '7 unread' }));
208
+ const f = frame();
209
+ assert.ok(f.includes('7 unread'), 'subtitle text still renders in mono');
210
+ assert.ok(!hasColorSgr(f), 'no color SGR emitted for the subtitle in mono');
211
+ assert.ok(!hasInvalidCsi(f), 'no invalid CSI');
212
+ });
213
+ });
214
+ // ── Mode chip (host.setMode) — same bug class, mono fallback, precedence ──
215
+ describe('drawChrome mode chip: SGR encoding + mono fallback (bug-regression)', () => {
216
+ test('compose mode → ✎ compose in yellow (33), no broken escape', () => {
217
+ const { draw, frame } = createDraw(SIZE, ON);
218
+ drawChrome(draw, SIZE, MANIFEST, baseChrome({ mode: 'compose' }));
219
+ const f = frame();
220
+ assert.ok(f.includes('✎') && f.includes('compose'), 'mode glyph + word present');
221
+ assert.ok(f.includes(`${ESC}33m`), 'compose-accent yellow 33 emitted');
222
+ assert.ok(!hasInvalidCsi(f), 'no invalid CSI');
223
+ });
224
+ test('react mode → ☺ react reuses the compose-accent yellow (33)', () => {
225
+ const { draw, frame } = createDraw(SIZE, ON);
226
+ drawChrome(draw, SIZE, MANIFEST, baseChrome({ mode: 'react' }));
227
+ const f = frame();
228
+ assert.ok(f.includes('☺') && f.includes('react'), 'react glyph + word present');
229
+ assert.ok(f.includes(`${ESC}33m`), 'react reuses yellow 33');
230
+ assert.ok(!hasInvalidCsi(f), 'no invalid CSI');
231
+ });
232
+ test('mono fallback: mode chip is glyph + bold word, no color SGR', () => {
233
+ const { draw, frame } = createDraw(SIZE, OFF);
234
+ drawChrome(draw, SIZE, MANIFEST, baseChrome({ mode: 'compose' }));
235
+ const f = frame();
236
+ assert.ok(f.includes('✎'), 'glyph carries the mode in mono');
237
+ assert.ok(f.includes('compose'), 'mode word carries meaning in mono');
238
+ assert.ok(f.includes(`${ESC}1m`), 'bold still emitted (mono carrier)');
239
+ assert.ok(!hasColorSgr(f), 'no color SGR emitted in mono');
240
+ assert.ok(!hasInvalidCsi(f), 'no invalid CSI');
241
+ });
242
+ test('precedence: an explicit mode WINS the chip over the derived busy/working state', () => {
243
+ const { draw, frame } = createDraw(SIZE, ON);
244
+ // busy would derive `working`; the explicit mode must override it.
245
+ drawChrome(draw, SIZE, MANIFEST, baseChrome({ busy: true, mode: 'compose' }));
246
+ const f = frame();
247
+ assert.ok(f.includes('compose'), 'mode word shown');
248
+ assert.ok(!f.includes('working'), 'derived working chip suppressed by the mode override');
249
+ assert.ok(!hasInvalidCsi(f), 'no invalid CSI');
250
+ });
251
+ test('null mode returns to the derived chip (ready when loaded)', () => {
252
+ const { draw, frame } = createDraw(SIZE, ON);
253
+ drawChrome(draw, SIZE, MANIFEST, baseChrome({ mode: null }));
254
+ const f = frame();
255
+ assert.ok(f.includes('ready'), 'derived ready chip shown when no mode set');
256
+ assert.ok(!hasInvalidCsi(f), 'no invalid CSI');
257
+ });
258
+ });
@@ -0,0 +1,2 @@
1
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
+ export default function (pi: ExtensionAPI): void;
@@ -0,0 +1,18 @@
1
+ export default function (pi) {
2
+ pi.registerProvider('c3prov', {
3
+ baseUrl: 'https://example.invalid',
4
+ apiKey: 'c3-test-key',
5
+ api: 'anthropic-messages',
6
+ models: [
7
+ {
8
+ id: 'c3model',
9
+ name: 'C3 Custom Model',
10
+ reasoning: false,
11
+ input: ['text'],
12
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
13
+ contextWindow: 1000,
14
+ maxTokens: 100,
15
+ },
16
+ ],
17
+ });
18
+ }
@@ -0,0 +1,138 @@
1
+ /** Test seam (M-1 regression): a FRESH-start kickoff prompt carrying this token
2
+ * makes the fake engine THROW inside bindExtensions BEFORE it fires
3
+ * session_start — the exact failure mode that records no pid and no session, so
4
+ * the broker exits(1) and the daemon must surface a boot failure rather than
5
+ * strand the node. Carried on the prompt (not a global env) so it is per-spawn. */
6
+ export declare const FAIL_BEFORE_SESSION_START = "__FAIL_BEFORE_SESSION_START__";
7
+ export declare class SessionManager {
8
+ readonly sessionId: string;
9
+ readonly sessionFile: string;
10
+ readonly resumed: boolean;
11
+ private constructor();
12
+ /** Fresh start: a brand-new session whose .jsonl lives under the node dir
13
+ * (created empty if missing, like fake-pi-host). */
14
+ static create(_cwd: string): SessionManager;
15
+ /** Resume: round-trip the .jsonl PATH the revive passed via `--session`. */
16
+ static open(path: string): SessionManager;
17
+ getSessionId(): string;
18
+ getSessionFile(): string;
19
+ }
20
+ type Handler = (ev: unknown, ctx: unknown) => void | Promise<void>;
21
+ interface BindExtensionsOpts {
22
+ uiContext: BrokerUiContext;
23
+ mode: string;
24
+ shutdownHandler?: () => void;
25
+ }
26
+ /** The slice of the broker's ExtensionUIContext we exercise (the only blocking
27
+ * dialog scenario 7 drives). */
28
+ interface BrokerUiContext {
29
+ confirm?: (title: string, message: string, opts?: {
30
+ signal?: AbortSignal;
31
+ timeout?: number;
32
+ }) => Promise<boolean>;
33
+ }
34
+ /** The slice of a loaded SDK extension we drive: its path + the handler map the
35
+ * SDK's createExtensionAPI populated as the extension called pi.on(event, …)
36
+ * during the loader's reload(). */
37
+ interface LoadedExtension {
38
+ path: string;
39
+ handlers: Map<string, Handler[]>;
40
+ }
41
+ /** The shared extension runtime the SDK's loader built (createExtensionRuntime).
42
+ * Its action methods start as `notInitialized` throwers; the REAL session's
43
+ * bindCore swaps them for working impls bound to the live session — we replicate
44
+ * the minimal slice (sendUserMessage/sendMessage/setSessionName) so the already-
45
+ * registered handlers (e.g. the inbox-watcher) deliver instead of throwing. */
46
+ interface ExtRuntime {
47
+ assertActive: () => void;
48
+ sendUserMessage: (content: string, options?: {
49
+ deliverAs?: string;
50
+ }) => void;
51
+ sendMessage: (message: unknown, options?: {
52
+ deliverAs?: string;
53
+ }) => void;
54
+ setSessionName: (name: string) => void;
55
+ [k: string]: unknown;
56
+ }
57
+ /** The minimal slice of the REAL DefaultResourceLoader the broker hands to
58
+ * createAgentSession: getExtensions() returns the already-loaded (jiti) canvas
59
+ * extensions + their shared runtime. */
60
+ interface ResourceLoader {
61
+ getExtensions(): {
62
+ extensions: LoadedExtension[];
63
+ errors?: unknown[];
64
+ runtime: ExtRuntime;
65
+ };
66
+ }
67
+ declare class FakeSession {
68
+ private readonly sm;
69
+ private readonly loader;
70
+ private readonly dir;
71
+ private extensions;
72
+ private readonly injected;
73
+ private uiContext;
74
+ private shutdownHandler;
75
+ private streaming;
76
+ private disposed;
77
+ private eventSeq;
78
+ private timer;
79
+ private readonly listeners;
80
+ private readonly messageLog;
81
+ constructor(sm: SessionManager, loader: ResourceLoader | undefined);
82
+ get messages(): unknown[];
83
+ get sessionId(): string;
84
+ get sessionFile(): string | undefined;
85
+ get model(): {
86
+ id: string;
87
+ } | undefined;
88
+ get isStreaming(): boolean;
89
+ getSessionStats(): Record<string, unknown>;
90
+ setSessionName(_name: string): void;
91
+ prompt(text: string, _options?: unknown): Promise<void>;
92
+ steer(_text: string): Promise<void>;
93
+ followUp(_text: string): Promise<void>;
94
+ abort(): Promise<void>;
95
+ subscribe(listener: (event: unknown) => void): () => void;
96
+ /** Fan one typed AgentSessionEvent out to every broker subscriber. A throwing
97
+ * listener is recorded, never propagated (mirrors the broker's m7 try/catch). */
98
+ private emit;
99
+ /** Emit a realistic streaming assistant turn on the subscribe channel:
100
+ * agent_start → turn_start → message_start → message_update×N →
101
+ * tool_execution_start/update/end → message_end → turn_end → agent_end. Drives
102
+ * G1 (controller prompt relay), G3 (produce-while-detached, accrues messageLog),
103
+ * and G8 (a fast event stream — `updates`/`padBytes` size the flood that sheds a
104
+ * stalled viewer at the broker HWM). A per-update setImmediate yield lets a
105
+ * fast viewer drain between frames so only the stalled one trips the HWM. */
106
+ private emitTurn;
107
+ dispose(): void;
108
+ bindExtensions(opts: BindExtensionsOpts): Promise<void>;
109
+ private recordInjected;
110
+ private buildCtx;
111
+ private recordEvent;
112
+ private fire;
113
+ private step;
114
+ private dispatch;
115
+ private runDialog;
116
+ }
117
+ interface FakeServices {
118
+ resourceLoader: ResourceLoader;
119
+ modelRegistry: {
120
+ find: (provider: string, id: string) => undefined;
121
+ };
122
+ }
123
+ export declare function createAgentSessionServices(options: {
124
+ cwd: string;
125
+ agentDir?: string;
126
+ resourceLoaderOptions?: {
127
+ additionalExtensionPaths?: string[];
128
+ appendSystemPrompt?: string[];
129
+ };
130
+ }): Promise<FakeServices>;
131
+ export declare function createAgentSessionFromServices(options: {
132
+ services: FakeServices;
133
+ sessionManager: SessionManager;
134
+ }): Promise<{
135
+ session: FakeSession;
136
+ }>;
137
+ export declare const VERSION = "0.78.1";
138
+ export {};