opencode-dux 1.0.0

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 (302) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +452 -0
  3. package/dist/agents/descriptions.d.ts +6 -0
  4. package/dist/agents/designer.d.ts +2 -0
  5. package/dist/agents/explorer.d.ts +2 -0
  6. package/dist/agents/fixer.d.ts +2 -0
  7. package/dist/agents/index.d.ts +22 -0
  8. package/dist/agents/interpreter.d.ts +2 -0
  9. package/dist/agents/librarian.d.ts +2 -0
  10. package/dist/agents/oracle.d.ts +2 -0
  11. package/dist/agents/orchestrator.d.ts +27 -0
  12. package/dist/agents/overrides.d.ts +18 -0
  13. package/dist/agents/prompt-blocks.d.ts +97 -0
  14. package/dist/agents/steward.d.ts +3 -0
  15. package/dist/cli/config-io.d.ts +24 -0
  16. package/dist/cli/config-manager.d.ts +4 -0
  17. package/dist/cli/index.d.ts +2 -0
  18. package/dist/cli/index.js +1006 -0
  19. package/dist/cli/install.d.ts +2 -0
  20. package/dist/cli/mcps.d.ts +13 -0
  21. package/dist/cli/model-key-normalization.d.ts +1 -0
  22. package/dist/cli/paths.d.ts +35 -0
  23. package/dist/cli/providers.d.ts +137 -0
  24. package/dist/cli/skills.d.ts +22 -0
  25. package/dist/cli/system.d.ts +5 -0
  26. package/dist/cli/types.d.ts +38 -0
  27. package/dist/config/constants.d.ts +12 -0
  28. package/dist/config/index.d.ts +4 -0
  29. package/dist/config/loader.d.ts +40 -0
  30. package/dist/config/runtime-preset.d.ts +12 -0
  31. package/dist/config/schema.d.ts +281 -0
  32. package/dist/config/utils.d.ts +10 -0
  33. package/dist/discovery/local/types.d.ts +79 -0
  34. package/dist/discovery/local.d.ts +73 -0
  35. package/dist/discovery/mcp-servers.d.ts +88 -0
  36. package/dist/discovery/skills.d.ts +94 -0
  37. package/dist/hooks/apply-patch/codec.d.ts +7 -0
  38. package/dist/hooks/apply-patch/errors.d.ts +25 -0
  39. package/dist/hooks/apply-patch/execution-context.d.ts +27 -0
  40. package/dist/hooks/apply-patch/index.d.ts +15 -0
  41. package/dist/hooks/apply-patch/matching.d.ts +26 -0
  42. package/dist/hooks/apply-patch/operations.d.ts +3 -0
  43. package/dist/hooks/apply-patch/patch.d.ts +2 -0
  44. package/dist/hooks/apply-patch/prepared-changes.d.ts +17 -0
  45. package/dist/hooks/apply-patch/resolution.d.ts +19 -0
  46. package/dist/hooks/apply-patch/rewrite.d.ts +7 -0
  47. package/dist/hooks/apply-patch/test-helpers.d.ts +6 -0
  48. package/dist/hooks/apply-patch/types.d.ts +80 -0
  49. package/dist/hooks/auto-update-checker/cache.d.ts +11 -0
  50. package/dist/hooks/auto-update-checker/checker.d.ts +32 -0
  51. package/dist/hooks/auto-update-checker/constants.d.ts +11 -0
  52. package/dist/hooks/auto-update-checker/index.d.ts +18 -0
  53. package/dist/hooks/auto-update-checker/types.d.ts +22 -0
  54. package/dist/hooks/chat-headers.d.ts +16 -0
  55. package/dist/hooks/context-pressure-reminder/index.d.ts +33 -0
  56. package/dist/hooks/delegate-task-retry/guidance.d.ts +2 -0
  57. package/dist/hooks/delegate-task-retry/hook.d.ts +8 -0
  58. package/dist/hooks/delegate-task-retry/index.d.ts +4 -0
  59. package/dist/hooks/delegate-task-retry/patterns.d.ts +11 -0
  60. package/dist/hooks/filter-available-skills/index.d.ts +32 -0
  61. package/dist/hooks/foreground-fallback/index.d.ts +72 -0
  62. package/dist/hooks/image-hook.d.ts +5 -0
  63. package/dist/hooks/index.d.ts +14 -0
  64. package/dist/hooks/json-error-recovery/hook.d.ts +18 -0
  65. package/dist/hooks/json-error-recovery/index.d.ts +1 -0
  66. package/dist/hooks/phase-reminder/index.d.ts +26 -0
  67. package/dist/hooks/post-file-tool-nudge/index.d.ts +19 -0
  68. package/dist/hooks/task-session-manager/index.d.ts +52 -0
  69. package/dist/hooks/todo-continuation/index.d.ts +53 -0
  70. package/dist/hooks/todo-continuation/todo-hygiene.d.ts +35 -0
  71. package/dist/index.d.ts +5 -0
  72. package/dist/index.js +31782 -0
  73. package/dist/mcp/context7.d.ts +6 -0
  74. package/dist/mcp/grep-app.d.ts +6 -0
  75. package/dist/mcp/index.d.ts +13 -0
  76. package/dist/mcp/types.d.ts +12 -0
  77. package/dist/mcp/websearch.d.ts +9 -0
  78. package/dist/skills/registry.d.ts +29 -0
  79. package/dist/subscriptions/accounts-store.d.ts +57 -0
  80. package/dist/subscriptions/index.d.ts +13 -0
  81. package/dist/subscriptions/neuralwatt-scraper.d.ts +14 -0
  82. package/dist/subscriptions/opencode-go-scraper.d.ts +27 -0
  83. package/dist/subscriptions/types.d.ts +115 -0
  84. package/dist/subscriptions/usage-service.d.ts +74 -0
  85. package/dist/tools/ast-grep/cli.d.ts +15 -0
  86. package/dist/tools/ast-grep/constants.d.ts +25 -0
  87. package/dist/tools/ast-grep/downloader.d.ts +5 -0
  88. package/dist/tools/ast-grep/index.d.ts +10 -0
  89. package/dist/tools/ast-grep/tools.d.ts +3 -0
  90. package/dist/tools/ast-grep/types.d.ts +30 -0
  91. package/dist/tools/ast-grep/utils.d.ts +4 -0
  92. package/dist/tools/delegate.d.ts +14 -0
  93. package/dist/tools/index.d.ts +5 -0
  94. package/dist/tools/preset-manager.d.ts +27 -0
  95. package/dist/tools/smartfetch/binary.d.ts +3 -0
  96. package/dist/tools/smartfetch/cache.d.ts +6 -0
  97. package/dist/tools/smartfetch/constants.d.ts +12 -0
  98. package/dist/tools/smartfetch/index.d.ts +3 -0
  99. package/dist/tools/smartfetch/network.d.ts +38 -0
  100. package/dist/tools/smartfetch/secondary-model.d.ts +28 -0
  101. package/dist/tools/smartfetch/tool.d.ts +3 -0
  102. package/dist/tools/smartfetch/types.d.ts +122 -0
  103. package/dist/tools/smartfetch/utils.d.ts +18 -0
  104. package/dist/tui-state.d.ts +168 -0
  105. package/dist/tui.d.ts +37 -0
  106. package/dist/tui.js +1896 -0
  107. package/dist/utils/agent-variant.d.ts +63 -0
  108. package/dist/utils/compat.d.ts +30 -0
  109. package/dist/utils/env.d.ts +1 -0
  110. package/dist/utils/index.d.ts +9 -0
  111. package/dist/utils/internal-initiator.d.ts +6 -0
  112. package/dist/utils/logger.d.ts +8 -0
  113. package/dist/utils/polling.d.ts +21 -0
  114. package/dist/utils/session-manager.d.ts +55 -0
  115. package/dist/utils/session.d.ts +90 -0
  116. package/dist/utils/subagent-depth.d.ts +35 -0
  117. package/dist/utils/system-collapse.d.ts +6 -0
  118. package/dist/utils/task.d.ts +4 -0
  119. package/dist/utils/zip-extractor.d.ts +1 -0
  120. package/index.ts +1 -0
  121. package/opencode-dux.schema.json +634 -0
  122. package/package.json +103 -0
  123. package/src/agents/descriptions.ts +55 -0
  124. package/src/agents/designer.test.ts +86 -0
  125. package/src/agents/designer.ts +154 -0
  126. package/src/agents/display-name.test.ts +186 -0
  127. package/src/agents/explorer.test.ts +79 -0
  128. package/src/agents/explorer.ts +144 -0
  129. package/src/agents/fixer.test.ts +79 -0
  130. package/src/agents/fixer.ts +145 -0
  131. package/src/agents/index.test.ts +472 -0
  132. package/src/agents/index.ts +248 -0
  133. package/src/agents/interpreter.ts +136 -0
  134. package/src/agents/librarian.test.ts +80 -0
  135. package/src/agents/librarian.ts +145 -0
  136. package/src/agents/oracle.test.ts +89 -0
  137. package/src/agents/oracle.ts +184 -0
  138. package/src/agents/orchestrator.test.ts +116 -0
  139. package/src/agents/orchestrator.ts +574 -0
  140. package/src/agents/overrides.ts +95 -0
  141. package/src/agents/prompt-blocks.test.ts +114 -0
  142. package/src/agents/prompt-blocks.ts +640 -0
  143. package/src/agents/steward.ts +146 -0
  144. package/src/cli/config-io.test.ts +536 -0
  145. package/src/cli/config-io.ts +473 -0
  146. package/src/cli/config-manager.test.ts +141 -0
  147. package/src/cli/config-manager.ts +4 -0
  148. package/src/cli/index.ts +88 -0
  149. package/src/cli/install.ts +282 -0
  150. package/src/cli/mcps.test.ts +62 -0
  151. package/src/cli/mcps.ts +39 -0
  152. package/src/cli/model-key-normalization.test.ts +21 -0
  153. package/src/cli/model-key-normalization.ts +60 -0
  154. package/src/cli/paths.test.ts +167 -0
  155. package/src/cli/paths.ts +144 -0
  156. package/src/cli/providers.test.ts +118 -0
  157. package/src/cli/providers.ts +141 -0
  158. package/src/cli/skills.test.ts +111 -0
  159. package/src/cli/skills.ts +103 -0
  160. package/src/cli/system.test.ts +91 -0
  161. package/src/cli/system.ts +180 -0
  162. package/src/cli/types.ts +43 -0
  163. package/src/config/constants.ts +58 -0
  164. package/src/config/index.ts +4 -0
  165. package/src/config/loader.test.ts +1194 -0
  166. package/src/config/loader.ts +269 -0
  167. package/src/config/model-resolution.test.ts +176 -0
  168. package/src/config/runtime-preset.test.ts +61 -0
  169. package/src/config/runtime-preset.ts +37 -0
  170. package/src/config/schema.ts +248 -0
  171. package/src/config/utils.test.ts +41 -0
  172. package/src/config/utils.ts +23 -0
  173. package/src/discovery/local/types.ts +85 -0
  174. package/src/discovery/local.ts +322 -0
  175. package/src/discovery/mcp-servers.ts +804 -0
  176. package/src/discovery/skills.ts +959 -0
  177. package/src/hooks/apply-patch/codec.test.ts +184 -0
  178. package/src/hooks/apply-patch/codec.ts +352 -0
  179. package/src/hooks/apply-patch/errors.ts +117 -0
  180. package/src/hooks/apply-patch/execution-context.ts +432 -0
  181. package/src/hooks/apply-patch/hook.test.ts +768 -0
  182. package/src/hooks/apply-patch/index.ts +126 -0
  183. package/src/hooks/apply-patch/matching.test.ts +215 -0
  184. package/src/hooks/apply-patch/matching.ts +586 -0
  185. package/src/hooks/apply-patch/operations.test.ts +1535 -0
  186. package/src/hooks/apply-patch/operations.ts +3 -0
  187. package/src/hooks/apply-patch/patch.ts +9 -0
  188. package/src/hooks/apply-patch/prepared-changes.ts +400 -0
  189. package/src/hooks/apply-patch/resolution.test.ts +420 -0
  190. package/src/hooks/apply-patch/resolution.ts +437 -0
  191. package/src/hooks/apply-patch/rewrite.ts +496 -0
  192. package/src/hooks/apply-patch/test-helpers.ts +52 -0
  193. package/src/hooks/apply-patch/types.ts +111 -0
  194. package/src/hooks/auto-update-checker/cache.test.ts +179 -0
  195. package/src/hooks/auto-update-checker/cache.ts +188 -0
  196. package/src/hooks/auto-update-checker/checker.test.ts +159 -0
  197. package/src/hooks/auto-update-checker/checker.ts +308 -0
  198. package/src/hooks/auto-update-checker/constants.ts +33 -0
  199. package/src/hooks/auto-update-checker/index.test.ts +282 -0
  200. package/src/hooks/auto-update-checker/index.ts +225 -0
  201. package/src/hooks/auto-update-checker/types.ts +26 -0
  202. package/src/hooks/chat-headers.test.ts +236 -0
  203. package/src/hooks/chat-headers.ts +97 -0
  204. package/src/hooks/context-pressure-reminder/index.test.ts +179 -0
  205. package/src/hooks/context-pressure-reminder/index.ts +137 -0
  206. package/src/hooks/delegate-task-retry/guidance.ts +41 -0
  207. package/src/hooks/delegate-task-retry/hook.ts +23 -0
  208. package/src/hooks/delegate-task-retry/index.test.ts +38 -0
  209. package/src/hooks/delegate-task-retry/index.ts +7 -0
  210. package/src/hooks/delegate-task-retry/patterns.ts +79 -0
  211. package/src/hooks/filter-available-skills/index.test.ts +297 -0
  212. package/src/hooks/filter-available-skills/index.ts +160 -0
  213. package/src/hooks/foreground-fallback/index.test.ts +624 -0
  214. package/src/hooks/foreground-fallback/index.ts +374 -0
  215. package/src/hooks/image-hook.ts +6 -0
  216. package/src/hooks/index.ts +17 -0
  217. package/src/hooks/json-error-recovery/hook.ts +73 -0
  218. package/src/hooks/json-error-recovery/index.test.ts +111 -0
  219. package/src/hooks/json-error-recovery/index.ts +6 -0
  220. package/src/hooks/phase-reminder/index.test.ts +74 -0
  221. package/src/hooks/phase-reminder/index.ts +85 -0
  222. package/src/hooks/post-file-tool-nudge/index.test.ts +94 -0
  223. package/src/hooks/post-file-tool-nudge/index.ts +63 -0
  224. package/src/hooks/task-session-manager/index.test.ts +833 -0
  225. package/src/hooks/task-session-manager/index.ts +434 -0
  226. package/src/hooks/todo-continuation/index.test.ts +3026 -0
  227. package/src/hooks/todo-continuation/index.ts +878 -0
  228. package/src/hooks/todo-continuation/todo-hygiene.test.ts +204 -0
  229. package/src/hooks/todo-continuation/todo-hygiene.ts +207 -0
  230. package/src/index.ts +1672 -0
  231. package/src/mcp/context7.ts +14 -0
  232. package/src/mcp/grep-app.ts +11 -0
  233. package/src/mcp/index.test.ts +96 -0
  234. package/src/mcp/index.ts +66 -0
  235. package/src/mcp/types.ts +16 -0
  236. package/src/mcp/websearch.ts +47 -0
  237. package/src/skills/codemap/README.md +60 -0
  238. package/src/skills/codemap/SKILL.md +174 -0
  239. package/src/skills/codemap/scripts/codemap.mjs +483 -0
  240. package/src/skills/codemap/scripts/codemap.test.ts +129 -0
  241. package/src/skills/registry.ts +218 -0
  242. package/src/skills/simplify/README.md +19 -0
  243. package/src/skills/simplify/SKILL.md +138 -0
  244. package/src/subscriptions/accounts-store.test.ts +236 -0
  245. package/src/subscriptions/accounts-store.ts +184 -0
  246. package/src/subscriptions/index.ts +30 -0
  247. package/src/subscriptions/neuralwatt-scraper.ts +108 -0
  248. package/src/subscriptions/opencode-go-scraper.ts +301 -0
  249. package/src/subscriptions/types.ts +145 -0
  250. package/src/subscriptions/usage-service.test.ts +202 -0
  251. package/src/subscriptions/usage-service.ts +651 -0
  252. package/src/tools/ast-grep/cli.ts +257 -0
  253. package/src/tools/ast-grep/constants.ts +214 -0
  254. package/src/tools/ast-grep/downloader.ts +131 -0
  255. package/src/tools/ast-grep/index.ts +24 -0
  256. package/src/tools/ast-grep/tools.ts +117 -0
  257. package/src/tools/ast-grep/types.ts +51 -0
  258. package/src/tools/ast-grep/utils.ts +126 -0
  259. package/src/tools/delegate-handoff.test.ts +18 -0
  260. package/src/tools/delegate.ts +508 -0
  261. package/src/tools/index.ts +8 -0
  262. package/src/tools/preset-manager.test.ts +795 -0
  263. package/src/tools/preset-manager.ts +332 -0
  264. package/src/tools/smartfetch/binary.ts +58 -0
  265. package/src/tools/smartfetch/cache.test.ts +34 -0
  266. package/src/tools/smartfetch/cache.ts +112 -0
  267. package/src/tools/smartfetch/constants.ts +29 -0
  268. package/src/tools/smartfetch/index.ts +8 -0
  269. package/src/tools/smartfetch/network.test.ts +178 -0
  270. package/src/tools/smartfetch/network.ts +614 -0
  271. package/src/tools/smartfetch/secondary-model.test.ts +85 -0
  272. package/src/tools/smartfetch/secondary-model.ts +276 -0
  273. package/src/tools/smartfetch/tool.test.ts +60 -0
  274. package/src/tools/smartfetch/tool.ts +832 -0
  275. package/src/tools/smartfetch/types.ts +135 -0
  276. package/src/tools/smartfetch/utils.test.ts +24 -0
  277. package/src/tools/smartfetch/utils.ts +456 -0
  278. package/src/tui-state.test.ts +867 -0
  279. package/src/tui-state.ts +1255 -0
  280. package/src/tui.test.ts +336 -0
  281. package/src/tui.ts +1539 -0
  282. package/src/utils/agent-variant.test.ts +244 -0
  283. package/src/utils/agent-variant.ts +187 -0
  284. package/src/utils/compat.ts +91 -0
  285. package/src/utils/env.ts +12 -0
  286. package/src/utils/index.ts +9 -0
  287. package/src/utils/internal-initiator.ts +28 -0
  288. package/src/utils/logger.test.ts +220 -0
  289. package/src/utils/logger.ts +136 -0
  290. package/src/utils/polling.test.ts +191 -0
  291. package/src/utils/polling.ts +67 -0
  292. package/src/utils/session-manager.test.ts +173 -0
  293. package/src/utils/session-manager.ts +356 -0
  294. package/src/utils/session.test.ts +110 -0
  295. package/src/utils/session.ts +389 -0
  296. package/src/utils/subagent-depth.test.ts +170 -0
  297. package/src/utils/subagent-depth.ts +75 -0
  298. package/src/utils/system-collapse.test.ts +86 -0
  299. package/src/utils/system-collapse.ts +24 -0
  300. package/src/utils/task.test.ts +24 -0
  301. package/src/utils/task.ts +20 -0
  302. package/src/utils/zip-extractor.ts +102 -0
@@ -0,0 +1,867 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
+ import * as fs from 'node:fs';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import {
6
+ deleteSessionEntries,
7
+ flushTuiSnapshot,
8
+ getTuiStatePath,
9
+ mergedSessionTree,
10
+ mergedSessionUsage,
11
+ normalizeProjectDirectory,
12
+ pruneStaleTuiSessionBundles,
13
+ readTuiSnapshot,
14
+ recordActiveSubscriptionForProvider,
15
+ recordSessionDone,
16
+ recordSessionNode,
17
+ recordSessionProject,
18
+ recordSessionTitle,
19
+ recordSessionUsage,
20
+ recordSessionUsagesBatch,
21
+ recordSubscriptionUsage,
22
+ removeSubscriptionUsageEntry,
23
+ subscriptionUsageKey,
24
+ syncOpenCodeStatusesIntoSessionTree,
25
+ updateSnapshot,
26
+ } from './tui-state';
27
+
28
+ let previousXdgDataHome: string | undefined;
29
+ let tempDir: string;
30
+
31
+ beforeEach(() => {
32
+ previousXdgDataHome = process.env.XDG_DATA_HOME;
33
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'omos-tui-state-'));
34
+ process.env.XDG_DATA_HOME = tempDir;
35
+ });
36
+
37
+ afterEach(() => {
38
+ if (previousXdgDataHome === undefined) {
39
+ delete process.env.XDG_DATA_HOME;
40
+ } else {
41
+ process.env.XDG_DATA_HOME = previousXdgDataHome;
42
+ }
43
+
44
+ fs.rmSync(tempDir, { recursive: true, force: true });
45
+ });
46
+
47
+ describe('subscriptionUsage', () => {
48
+ test('recordSubscriptionUsage uses provider-scoped keys', () => {
49
+ recordSubscriptionUsage([
50
+ {
51
+ provider: 'opencode-go',
52
+ accountName: 'personal',
53
+ workspaceId: 'wrk_123',
54
+ fetchedAt: Date.now(),
55
+ },
56
+ {
57
+ provider: 'neuralwatt',
58
+ accountName: 'personal',
59
+ snapshot_at: '',
60
+ balance: {
61
+ credits_remaining_usd: 0,
62
+ total_credits_usd: 0,
63
+ credits_used_usd: 0,
64
+ accounting_method: 'energy',
65
+ },
66
+ usage: {
67
+ lifetime: { cost_usd: 0, requests: 0, tokens: 0, energy_kwh: 0 },
68
+ current_month: { cost_usd: 0, requests: 0, tokens: 0, energy_kwh: 0 },
69
+ },
70
+ subscription: null,
71
+ fetchedAt: Date.now(),
72
+ },
73
+ ]);
74
+
75
+ const snapshot = readTuiSnapshot();
76
+ expect(snapshot.subscriptionUsage).toHaveProperty(
77
+ subscriptionUsageKey('opencode-go', 'personal'),
78
+ );
79
+ expect(snapshot.subscriptionUsage).toHaveProperty(
80
+ subscriptionUsageKey('neuralwatt', 'personal'),
81
+ );
82
+ });
83
+
84
+ test('recordSubscriptionUsage clears stale entries', () => {
85
+ // First write two entries
86
+ recordSubscriptionUsage([
87
+ {
88
+ provider: 'opencode-go',
89
+ accountName: 'personal',
90
+ workspaceId: 'wrk_123',
91
+ fetchedAt: Date.now(),
92
+ error: undefined,
93
+ },
94
+ {
95
+ provider: 'opencode-go',
96
+ accountName: 'work',
97
+ workspaceId: 'wrk_456',
98
+ fetchedAt: Date.now(),
99
+ error: undefined,
100
+ },
101
+ ]);
102
+
103
+ expect(readTuiSnapshot().subscriptionUsage).toHaveProperty(
104
+ subscriptionUsageKey('opencode-go', 'personal'),
105
+ );
106
+ expect(readTuiSnapshot().subscriptionUsage).toHaveProperty(
107
+ subscriptionUsageKey('opencode-go', 'work'),
108
+ );
109
+
110
+ // Now write only one entry - the other should be gone
111
+ recordSubscriptionUsage([
112
+ {
113
+ provider: 'opencode-go',
114
+ accountName: 'personal',
115
+ workspaceId: 'wrk_123',
116
+ fetchedAt: Date.now(),
117
+ error: undefined,
118
+ },
119
+ ]);
120
+
121
+ const snapshot = readTuiSnapshot();
122
+ expect(snapshot.subscriptionUsage).toHaveProperty(
123
+ subscriptionUsageKey('opencode-go', 'personal'),
124
+ );
125
+ expect(snapshot.subscriptionUsage).not.toHaveProperty(
126
+ subscriptionUsageKey('opencode-go', 'work'),
127
+ );
128
+ });
129
+
130
+ test('recordSubscriptionUsage handles empty array (clears all)', () => {
131
+ recordSubscriptionUsage([
132
+ {
133
+ provider: 'opencode-go',
134
+ accountName: 'personal',
135
+ workspaceId: 'wrk_123',
136
+ fetchedAt: Date.now(),
137
+ error: undefined,
138
+ },
139
+ ]);
140
+ expect(readTuiSnapshot().subscriptionUsage).toHaveProperty(
141
+ subscriptionUsageKey('opencode-go', 'personal'),
142
+ );
143
+
144
+ // Empty array should clear everything
145
+ recordSubscriptionUsage([]);
146
+ expect(readTuiSnapshot().subscriptionUsage).toEqual({});
147
+ });
148
+
149
+ test('removeSubscriptionUsageEntry deletes a specific entry', () => {
150
+ recordSubscriptionUsage([
151
+ {
152
+ provider: 'opencode-go',
153
+ accountName: 'personal',
154
+ workspaceId: 'wrk_123',
155
+ fetchedAt: Date.now(),
156
+ error: undefined,
157
+ },
158
+ {
159
+ provider: 'opencode-go',
160
+ accountName: 'work',
161
+ workspaceId: 'wrk_456',
162
+ fetchedAt: Date.now(),
163
+ error: undefined,
164
+ },
165
+ ]);
166
+
167
+ removeSubscriptionUsageEntry('opencode-go', 'personal');
168
+
169
+ const snapshot = readTuiSnapshot();
170
+ expect(snapshot.subscriptionUsage).not.toHaveProperty(
171
+ subscriptionUsageKey('opencode-go', 'personal'),
172
+ );
173
+ expect(snapshot.subscriptionUsage).toHaveProperty(
174
+ subscriptionUsageKey('opencode-go', 'work'),
175
+ );
176
+ });
177
+
178
+ test('removeSubscriptionUsageEntry is idempotent for unknown names', () => {
179
+ recordSubscriptionUsage([
180
+ {
181
+ provider: 'opencode-go',
182
+ accountName: 'personal',
183
+ workspaceId: 'wrk_123',
184
+ fetchedAt: Date.now(),
185
+ error: undefined,
186
+ },
187
+ ]);
188
+
189
+ // Removing a name that doesn't exist should not throw
190
+ expect(() =>
191
+ removeSubscriptionUsageEntry('opencode-go', 'nonexistent'),
192
+ ).not.toThrow();
193
+ expect(readTuiSnapshot().subscriptionUsage).toHaveProperty(
194
+ subscriptionUsageKey('opencode-go', 'personal'),
195
+ );
196
+ });
197
+ });
198
+
199
+ describe('activeSubscriptionByProvider', () => {
200
+ test('recordActiveSubscriptionForProvider sets the field', () => {
201
+ recordActiveSubscriptionForProvider('opencode-go', 'personal');
202
+ expect(readTuiSnapshot().activeSubscriptionByProvider['opencode-go']).toBe(
203
+ 'personal',
204
+ );
205
+ });
206
+
207
+ test('recordActiveSubscriptionForProvider clears with null', () => {
208
+ recordActiveSubscriptionForProvider('opencode-go', 'personal');
209
+ recordActiveSubscriptionForProvider('opencode-go', null);
210
+ expect(readTuiSnapshot().activeSubscriptionByProvider['opencode-go']).toBe(
211
+ undefined,
212
+ );
213
+ });
214
+
215
+ test('recordActiveSubscriptionForProvider survives other snapshot updates', () => {
216
+ recordActiveSubscriptionForProvider('opencode-go', 'personal');
217
+ // Write some other data - shouldn't affect active provider selection
218
+ recordSessionNode({
219
+ sessionID: 'sess-x',
220
+ title: '',
221
+ agent: 'explorer',
222
+ status: 'busy',
223
+ });
224
+ expect(readTuiSnapshot().activeSubscriptionByProvider['opencode-go']).toBe(
225
+ 'personal',
226
+ );
227
+ });
228
+ });
229
+
230
+ describe('recordSessionTitle', () => {
231
+ test('sets tree title when SDK reports a non-empty name', () => {
232
+ recordSessionNode({
233
+ sessionID: 'orch-1',
234
+ title: '',
235
+ agent: 'orchestrator',
236
+ status: 'busy',
237
+ });
238
+ recordSessionTitle({ sessionID: 'orch-1', title: ' My task ' });
239
+ expect(mergedSessionTree(readTuiSnapshot())['orch-1']?.title).toBe(
240
+ 'My task',
241
+ );
242
+ });
243
+
244
+ test('ignores empty title string', () => {
245
+ recordSessionNode({
246
+ sessionID: 'orch-2',
247
+ title: 'kept',
248
+ agent: 'orchestrator',
249
+ status: 'busy',
250
+ });
251
+ recordSessionTitle({ sessionID: 'orch-2', title: ' ' });
252
+ expect(mergedSessionTree(readTuiSnapshot())['orch-2']?.title).toBe('kept');
253
+ });
254
+ });
255
+
256
+ describe('sessionUsage', () => {
257
+ test('recordSessionUsage persists token telemetry per session', () => {
258
+ recordSessionUsage({
259
+ sessionID: 'session-123',
260
+ contextUsed: 150_000,
261
+ contextLimit: 400_000,
262
+ contextPct: 37.5,
263
+ input: 8_000,
264
+ output: 900,
265
+ reasoning: 200,
266
+ cacheRead: 2_500,
267
+ cacheWrite: 700,
268
+ });
269
+
270
+ const usage = mergedSessionUsage(readTuiSnapshot())['session-123'];
271
+ expect(usage).toBeDefined();
272
+ expect(usage?.contextUsed).toBe(150_000);
273
+ expect(usage?.contextLimit).toBe(400_000);
274
+ expect(usage?.contextPct).toBe(37.5);
275
+ expect(usage?.input).toBe(8_000);
276
+ expect(usage?.output).toBe(900);
277
+ expect(usage?.reasoning).toBe(200);
278
+ expect(usage?.cacheRead).toBe(2_500);
279
+ expect(usage?.cacheWrite).toBe(700);
280
+ });
281
+
282
+ test('recomputes contextPct from used/limit on model switch (stale pct is ignored)', () => {
283
+ recordSessionUsage({
284
+ sessionID: 'session-model-switch',
285
+ contextUsed: 170_000,
286
+ contextLimit: 1_000_000,
287
+ contextPct: 17,
288
+ input: 10_000,
289
+ output: 500,
290
+ reasoning: 0,
291
+ cacheRead: 100,
292
+ cacheWrite: 50,
293
+ });
294
+ recordSessionUsage({
295
+ sessionID: 'session-model-switch',
296
+ contextUsed: 228_700,
297
+ contextLimit: 262_144,
298
+ contextPct: 17,
299
+ input: 12_000,
300
+ output: 600,
301
+ reasoning: 0,
302
+ cacheRead: 200,
303
+ cacheWrite: 60,
304
+ });
305
+ const usage = mergedSessionUsage(readTuiSnapshot())['session-model-switch'];
306
+ expect(usage?.contextUsed).toBe(228_700);
307
+ expect(usage?.contextLimit).toBe(262_144);
308
+ expect(usage?.contextPct).toBeCloseTo((228_700 / 262_144) * 100, 5);
309
+ });
310
+
311
+ test('context usage may decrease after compact; sigma context adds only non-negative deltas', () => {
312
+ recordSessionNode({
313
+ sessionID: 'orch-compact',
314
+ title: 'orch',
315
+ agent: 'orchestrator',
316
+ status: 'busy',
317
+ });
318
+ recordSessionNode({
319
+ sessionID: 'solo',
320
+ title: 'solo',
321
+ agent: 'explorer',
322
+ parentId: 'orch-compact',
323
+ status: 'busy',
324
+ });
325
+ recordSessionUsage({
326
+ sessionID: 'solo',
327
+ contextUsed: 50_000,
328
+ contextLimit: 1_000_000,
329
+ input: 10_000,
330
+ output: 500,
331
+ reasoning: 0,
332
+ cacheRead: 1_000,
333
+ cacheWrite: 100,
334
+ });
335
+ recordSessionUsage({
336
+ sessionID: 'solo',
337
+ contextUsed: 8_000,
338
+ contextLimit: 262_144,
339
+ input: 12_000,
340
+ output: 600,
341
+ reasoning: 0,
342
+ cacheRead: 1_100,
343
+ cacheWrite: 100,
344
+ });
345
+ const snap = readTuiSnapshot();
346
+ expect(mergedSessionUsage(snap).solo?.contextUsed).toBe(8_000);
347
+ expect(mergedSessionUsage(snap).solo?.contextLimit).toBe(262_144);
348
+ expect(snap.sessions['orch-compact']?.orchestrationSigmaAccum).toEqual({
349
+ contextUsed: 50_000,
350
+ input: 12_000,
351
+ output: 600,
352
+ cacheRead: 1_100,
353
+ cacheWrite: 100,
354
+ });
355
+ });
356
+
357
+ test('accumulates orchestration sigma from per-session deltas and persists across idle', () => {
358
+ recordSessionNode({
359
+ sessionID: 'orch',
360
+ title: 'orch',
361
+ agent: 'orchestrator',
362
+ status: 'busy',
363
+ });
364
+ recordSessionNode({
365
+ sessionID: 'child-1',
366
+ title: 'child-1',
367
+ agent: 'explorer',
368
+ parentId: 'orch',
369
+ status: 'busy',
370
+ });
371
+ recordSessionNode({
372
+ sessionID: 'child-2',
373
+ title: 'child-2',
374
+ agent: 'fixer',
375
+ parentId: 'orch',
376
+ status: 'busy',
377
+ });
378
+
379
+ recordSessionUsage({
380
+ sessionID: 'child-1',
381
+ contextUsed: 21_120,
382
+ input: 20_000,
383
+ output: 100,
384
+ reasoning: 20,
385
+ cacheRead: 1_000,
386
+ cacheWrite: 400,
387
+ });
388
+ recordSessionUsage({
389
+ sessionID: 'child-2',
390
+ contextUsed: 20_730,
391
+ input: 20_000,
392
+ output: 200,
393
+ reasoning: 30,
394
+ cacheRead: 500,
395
+ cacheWrite: 100,
396
+ });
397
+ recordSessionUsage({
398
+ sessionID: 'child-1',
399
+ contextUsed: 26_500,
400
+ input: 25_000,
401
+ output: 150,
402
+ reasoning: 50,
403
+ cacheRead: 1_300,
404
+ cacheWrite: 500,
405
+ });
406
+
407
+ // Simulate the orchestration becoming idle/completed.
408
+ recordSessionDone('child-1');
409
+ recordSessionDone('child-2');
410
+ recordSessionDone('orch');
411
+
412
+ // New child under the same orchestrator session should keep accumulating.
413
+ recordSessionNode({
414
+ sessionID: 'child-3',
415
+ title: 'child-3',
416
+ agent: 'oracle',
417
+ parentId: 'orch',
418
+ status: 'busy',
419
+ });
420
+ recordSessionUsage({
421
+ sessionID: 'child-3',
422
+ contextUsed: 20_810,
423
+ input: 20_000,
424
+ output: 100,
425
+ reasoning: 10,
426
+ cacheRead: 700,
427
+ cacheWrite: 200,
428
+ });
429
+
430
+ const snapshot = readTuiSnapshot();
431
+ expect(snapshot.sessions.orch?.orchestrationSigmaAccum).toEqual({
432
+ contextUsed: 68_040,
433
+ input: 65_000,
434
+ output: 450,
435
+ cacheRead: 2_500,
436
+ cacheWrite: 800,
437
+ });
438
+ expect(
439
+ snapshot.sessions.orch?.orchestrationUsageLastSeen['child-1'],
440
+ ).toEqual({
441
+ contextUsed: 26_500,
442
+ input: 25_000,
443
+ output: 150,
444
+ cacheRead: 1_300,
445
+ cacheWrite: 500,
446
+ });
447
+ });
448
+
449
+ test('deleteSessionEntries removes child nodes from tree and cascades bundle on root delete', () => {
450
+ recordSessionNode({
451
+ sessionID: 'orch',
452
+ title: 'orch',
453
+ agent: 'orchestrator',
454
+ status: 'busy',
455
+ });
456
+ recordSessionNode({
457
+ sessionID: 'child-1',
458
+ title: 'child-1',
459
+ agent: 'explorer',
460
+ parentId: 'orch',
461
+ status: 'busy',
462
+ });
463
+ recordSessionUsage({
464
+ sessionID: 'child-1',
465
+ contextUsed: 1_065,
466
+ input: 1_000,
467
+ output: 10,
468
+ reasoning: 5,
469
+ cacheRead: 50,
470
+ cacheWrite: 10,
471
+ });
472
+ expect(
473
+ readTuiSnapshot().sessions.orch?.orchestrationSigmaAccum,
474
+ ).toBeDefined();
475
+
476
+ deleteSessionEntries('child-1');
477
+ expect(
478
+ readTuiSnapshot().sessions.orch?.orchestrationUsageLastSeen['child-1'],
479
+ ).toBeUndefined();
480
+ expect(readTuiSnapshot().sessions.orch?.tree['child-1']).toBeUndefined();
481
+ expect(readTuiSnapshot().sessions.orch?.tree.orch?.childIds).toEqual([]);
482
+ expect(
483
+ readTuiSnapshot().sessions.orch?.orchestrationSigmaAccum,
484
+ ).toBeDefined();
485
+
486
+ deleteSessionEntries('orch');
487
+ expect(readTuiSnapshot().sessions.orch).toBeUndefined();
488
+ });
489
+ });
490
+
491
+ describe('pruneStaleTuiSessionBundles', () => {
492
+ test('removes bundle when every tree id is absent from OpenCode and project matches', () => {
493
+ const projectDir = normalizeProjectDirectory(tempDir);
494
+ recordSessionProject({ sessionID: 'root-del', projectPath: tempDir });
495
+ recordSessionNode({
496
+ sessionID: 'root-del',
497
+ title: '',
498
+ agent: 'orchestrator',
499
+ status: 'idle',
500
+ });
501
+ recordSessionNode({
502
+ sessionID: 'child-del',
503
+ title: '',
504
+ agent: 'explorer',
505
+ parentId: 'root-del',
506
+ status: 'idle',
507
+ });
508
+
509
+ expect(readTuiSnapshot().sessions['root-del']).toBeDefined();
510
+
511
+ updateSnapshot((s) => {
512
+ pruneStaleTuiSessionBundles(s, {
513
+ opencodeIds: new Set(['ses_still_live']),
514
+ currentProjectDir: projectDir,
515
+ now: Date.now(),
516
+ });
517
+ });
518
+
519
+ expect(readTuiSnapshot().sessions['root-del']).toBeUndefined();
520
+ });
521
+
522
+ test('keeps bundle when project path does not match workspace', () => {
523
+ const otherDir = path.join(tempDir, 'other-ws');
524
+ fs.mkdirSync(otherDir, { recursive: true });
525
+ const workspaceDir = normalizeProjectDirectory(tempDir);
526
+ recordSessionProject({ sessionID: 'root-x', projectPath: otherDir });
527
+ recordSessionNode({
528
+ sessionID: 'root-x',
529
+ title: '',
530
+ agent: 'orchestrator',
531
+ status: 'idle',
532
+ });
533
+
534
+ updateSnapshot((s) => {
535
+ pruneStaleTuiSessionBundles(s, {
536
+ opencodeIds: new Set(['nostale']),
537
+ currentProjectDir: workspaceDir,
538
+ now: Date.now(),
539
+ });
540
+ });
541
+
542
+ expect(readTuiSnapshot().sessions['root-x']).toBeDefined();
543
+ });
544
+
545
+ test('does not remove bundles when opencodeIds is empty', () => {
546
+ const projectDir = normalizeProjectDirectory(tempDir);
547
+ recordSessionProject({ sessionID: 'root-k', projectPath: tempDir });
548
+ recordSessionNode({
549
+ sessionID: 'root-k',
550
+ title: '',
551
+ agent: 'orchestrator',
552
+ status: 'idle',
553
+ });
554
+
555
+ updateSnapshot((s) => {
556
+ pruneStaleTuiSessionBundles(s, {
557
+ opencodeIds: new Set(),
558
+ currentProjectDir: projectDir,
559
+ now: Date.now(),
560
+ });
561
+ });
562
+
563
+ expect(readTuiSnapshot().sessions['root-k']).toBeDefined();
564
+ });
565
+
566
+ test('soft-prunes subtree when only some ids are missing from OpenCode', () => {
567
+ const projectDir = normalizeProjectDirectory(tempDir);
568
+ recordSessionProject({ sessionID: 'root-p', projectPath: tempDir });
569
+ recordSessionNode({
570
+ sessionID: 'root-p',
571
+ title: '',
572
+ agent: 'orchestrator',
573
+ status: 'busy',
574
+ });
575
+ recordSessionNode({
576
+ sessionID: 'gone-child',
577
+ title: '',
578
+ agent: 'explorer',
579
+ parentId: 'root-p',
580
+ status: 'busy',
581
+ });
582
+ recordSessionUsage({
583
+ sessionID: 'gone-child',
584
+ contextUsed: 100,
585
+ contextLimit: 400,
586
+ contextPct: 25,
587
+ input: 10,
588
+ output: 5,
589
+ reasoning: 0,
590
+ cacheRead: 0,
591
+ cacheWrite: 0,
592
+ });
593
+
594
+ updateSnapshot((s) => {
595
+ pruneStaleTuiSessionBundles(s, {
596
+ opencodeIds: new Set(['root-p']),
597
+ currentProjectDir: projectDir,
598
+ now: Date.now(),
599
+ });
600
+ });
601
+
602
+ const snap = readTuiSnapshot();
603
+ expect(snap.sessions['root-p']).toBeDefined();
604
+ const gone = snap.sessions['root-p']?.tree['gone-child'];
605
+ expect(gone?.status).toBe('idle');
606
+ expect(gone?.usage).toBeUndefined();
607
+ });
608
+
609
+ test('second soft-prune does not bump finishedAt on already-idle ghost child', () => {
610
+ const projectDir = normalizeProjectDirectory(tempDir);
611
+ recordSessionProject({ sessionID: 'root-ghost2', projectPath: tempDir });
612
+ recordSessionNode({
613
+ sessionID: 'root-ghost2',
614
+ title: '',
615
+ agent: 'orchestrator',
616
+ status: 'busy',
617
+ });
618
+ recordSessionNode({
619
+ sessionID: 'ghost-child-2',
620
+ title: '',
621
+ agent: 'explorer',
622
+ parentId: 'root-ghost2',
623
+ status: 'busy',
624
+ });
625
+
626
+ const opencode = new Set(['root-ghost2']);
627
+
628
+ updateSnapshot((s) => {
629
+ pruneStaleTuiSessionBundles(s, {
630
+ opencodeIds: opencode,
631
+ currentProjectDir: projectDir,
632
+ now: Date.now(),
633
+ });
634
+ });
635
+
636
+ const afterFirst =
637
+ readTuiSnapshot().sessions['root-ghost2']?.tree['ghost-child-2'];
638
+ expect(afterFirst?.status).toBe('idle');
639
+ const t1 = afterFirst?.finishedAt;
640
+ expect(t1).toBeDefined();
641
+
642
+ updateSnapshot((s) => {
643
+ pruneStaleTuiSessionBundles(s, {
644
+ opencodeIds: opencode,
645
+ currentProjectDir: projectDir,
646
+ now: Date.now(),
647
+ });
648
+ });
649
+
650
+ const afterSecond =
651
+ readTuiSnapshot().sessions['root-ghost2']?.tree['ghost-child-2'];
652
+ expect(afterSecond?.finishedAt).toBe(t1);
653
+ });
654
+
655
+ test('does not soft-prune child still listed in OpenCode when parent missing from poll', () => {
656
+ const projectDir = normalizeProjectDirectory(tempDir);
657
+ recordSessionProject({ sessionID: 'root-flicker', projectPath: tempDir });
658
+ recordSessionNode({
659
+ sessionID: 'root-flicker',
660
+ title: '',
661
+ agent: 'orchestrator',
662
+ status: 'busy',
663
+ });
664
+ recordSessionNode({
665
+ sessionID: 'child-flicker',
666
+ title: '',
667
+ agent: 'explorer',
668
+ parentId: 'root-flicker',
669
+ status: 'busy',
670
+ });
671
+
672
+ updateSnapshot((s) => {
673
+ syncOpenCodeStatusesIntoSessionTree(s, {
674
+ 'child-flicker': { type: 'busy' },
675
+ });
676
+ pruneStaleTuiSessionBundles(s, {
677
+ opencodeIds: new Set(['child-flicker']),
678
+ currentProjectDir: projectDir,
679
+ now: Date.now(),
680
+ });
681
+ });
682
+
683
+ const snap = readTuiSnapshot();
684
+ expect(snap.sessions['root-flicker']?.tree['child-flicker']?.status).toBe(
685
+ 'busy',
686
+ );
687
+ const parent = snap.sessions['root-flicker']?.tree['root-flicker'];
688
+ expect(parent?.status).toBe('busy');
689
+ expect(parent?.finishedAt).toBeUndefined();
690
+ });
691
+
692
+ test('keeps orchestration sigma when orchestrator row missing from poll but child listed', () => {
693
+ const projectDir = normalizeProjectDirectory(tempDir);
694
+ recordSessionProject({ sessionID: 'root-sigma', projectPath: tempDir });
695
+ recordSessionNode({
696
+ sessionID: 'root-sigma',
697
+ title: '',
698
+ agent: 'orchestrator',
699
+ status: 'busy',
700
+ });
701
+ recordSessionNode({
702
+ sessionID: 'child-sigma',
703
+ title: '',
704
+ agent: 'explorer',
705
+ parentId: 'root-sigma',
706
+ status: 'busy',
707
+ });
708
+ recordSessionUsage({
709
+ sessionID: 'child-sigma',
710
+ contextUsed: 100,
711
+ contextLimit: 400,
712
+ contextPct: 25,
713
+ input: 50,
714
+ output: 20,
715
+ reasoning: 0,
716
+ cacheRead: 0,
717
+ cacheWrite: 0,
718
+ });
719
+
720
+ expect(
721
+ readTuiSnapshot().sessions['root-sigma']?.orchestrationSigmaAccum?.input,
722
+ ).toBe(50);
723
+
724
+ updateSnapshot((s) => {
725
+ syncOpenCodeStatusesIntoSessionTree(s, {
726
+ 'child-sigma': { type: 'busy' },
727
+ });
728
+ pruneStaleTuiSessionBundles(s, {
729
+ opencodeIds: new Set(['child-sigma']),
730
+ currentProjectDir: projectDir,
731
+ now: Date.now(),
732
+ });
733
+ });
734
+
735
+ const snap = readTuiSnapshot();
736
+ expect(snap.sessions['root-sigma']?.orchestrationSigmaAccum?.input).toBe(
737
+ 50,
738
+ );
739
+ expect(snap.sessions['root-sigma']?.tree['root-sigma']?.status).toBe(
740
+ 'busy',
741
+ );
742
+ });
743
+ });
744
+
745
+ describe('tui-state concurrent persistence', () => {
746
+ test('microtask storm of recordSessionUsage retains all sessions and sigma', async () => {
747
+ recordSessionNode({
748
+ sessionID: 'storm-orch',
749
+ title: '',
750
+ agent: 'orchestrator',
751
+ status: 'busy',
752
+ });
753
+ const n = 12;
754
+ for (let i = 0; i < n; i++) {
755
+ recordSessionNode({
756
+ sessionID: `storm-child-${i}`,
757
+ title: '',
758
+ agent: 'explorer',
759
+ parentId: 'storm-orch',
760
+ status: 'busy',
761
+ });
762
+ }
763
+
764
+ await Promise.all(
765
+ Array.from({ length: n }, (_, i) =>
766
+ Promise.resolve().then(() =>
767
+ recordSessionUsage({
768
+ sessionID: `storm-child-${i}`,
769
+ contextUsed: 50 + i,
770
+ contextLimit: 200,
771
+ contextPct: 25,
772
+ input: 10 + i,
773
+ output: 5,
774
+ reasoning: 0,
775
+ cacheRead: 0,
776
+ cacheWrite: 0,
777
+ }),
778
+ ),
779
+ ),
780
+ );
781
+ await flushTuiSnapshot();
782
+
783
+ const snap = readTuiSnapshot();
784
+ const bundle = snap.sessions['storm-orch'];
785
+ expect(bundle).toBeDefined();
786
+
787
+ let expectedInput = 0;
788
+ for (let i = 0; i < n; i++) {
789
+ const node = bundle?.tree[`storm-child-${i}`];
790
+ expect(node).toBeDefined();
791
+ expect(node?.usage?.input).toBe(10 + i);
792
+ expectedInput += 10 + i;
793
+ }
794
+
795
+ expect(bundle?.orchestrationSigmaAccum?.input).toBe(expectedInput);
796
+ });
797
+
798
+ test('recordSessionUsagesBatch applies all rows in one write', () => {
799
+ recordSessionNode({
800
+ sessionID: 'orch-b',
801
+ title: '',
802
+ agent: 'orchestrator',
803
+ status: 'busy',
804
+ });
805
+ recordSessionNode({
806
+ sessionID: 'b1',
807
+ title: '',
808
+ agent: 'explorer',
809
+ parentId: 'orch-b',
810
+ status: 'busy',
811
+ });
812
+ recordSessionNode({
813
+ sessionID: 'b2',
814
+ title: '',
815
+ agent: 'librarian',
816
+ parentId: 'orch-b',
817
+ status: 'busy',
818
+ });
819
+
820
+ recordSessionUsagesBatch([
821
+ {
822
+ sessionID: 'b1',
823
+ contextUsed: 40,
824
+ contextLimit: 200,
825
+ contextPct: 20,
826
+ input: 20,
827
+ output: 10,
828
+ reasoning: 0,
829
+ cacheRead: 0,
830
+ cacheWrite: 0,
831
+ },
832
+ {
833
+ sessionID: 'b2',
834
+ contextUsed: 60,
835
+ contextLimit: 200,
836
+ contextPct: 30,
837
+ input: 30,
838
+ output: 15,
839
+ reasoning: 0,
840
+ cacheRead: 0,
841
+ cacheWrite: 0,
842
+ },
843
+ ]);
844
+
845
+ const snap = readTuiSnapshot();
846
+ expect(mergedSessionUsage(snap).b1?.input).toBe(20);
847
+ expect(mergedSessionUsage(snap).b2?.input).toBe(30);
848
+ expect(snap.sessions['orch-b']?.orchestrationSigmaAccum?.input).toBe(50);
849
+ });
850
+ });
851
+
852
+ describe('tui-state file safety', () => {
853
+ test('does not clobber existing file when state json is malformed', () => {
854
+ const filePath = getTuiStatePath();
855
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
856
+ fs.writeFileSync(filePath, '{ malformed json');
857
+
858
+ recordSessionNode({
859
+ sessionID: 'sess-x',
860
+ title: '',
861
+ agent: 'explorer',
862
+ status: 'busy',
863
+ });
864
+
865
+ expect(fs.readFileSync(filePath, 'utf8')).toBe('{ malformed json');
866
+ });
867
+ });