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,1255 @@
1
+ import * as fs from 'node:fs';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
4
+ import type {
5
+ SubscriptionProvider,
6
+ SubscriptionUsageEntry,
7
+ } from './subscriptions/types';
8
+
9
+ export type { SubscriptionUsageEntry };
10
+
11
+ /** Sidebar state for one OpenCode session (orchestrator or subagent). */
12
+ export interface SessionNode {
13
+ title: string;
14
+ agent: string;
15
+ model: string;
16
+ variant?: string;
17
+ parentId?: string;
18
+ childIds: string[];
19
+ status: 'busy' | 'idle' | 'retry';
20
+ mode?: 'blocking' | 'fire_forget';
21
+ createdAt: number;
22
+ finishedAt?: number;
23
+ usage?: SessionUsageEntry;
24
+ }
25
+
26
+ export interface SessionUsageEntry {
27
+ contextUsed: number;
28
+ contextLimit: number;
29
+ contextPct: number;
30
+ input: number;
31
+ output: number;
32
+ reasoning: number;
33
+ cacheRead: number;
34
+ cacheWrite: number;
35
+ updatedAt: number;
36
+ }
37
+
38
+ export interface OrchestrationSigmaAccum {
39
+ contextUsed: number;
40
+ input: number;
41
+ output: number;
42
+ cacheRead: number;
43
+ cacheWrite: number;
44
+ }
45
+
46
+ export interface SessionUsageDeltaBasis {
47
+ contextUsed: number;
48
+ input: number;
49
+ output: number;
50
+ cacheRead: number;
51
+ cacheWrite: number;
52
+ }
53
+
54
+ /** One OpenCode orchestration/session tree keyed by root session id. */
55
+ export interface TuiSessionBundle {
56
+ rootSessionId: string;
57
+ lastActivityAt: number;
58
+ projectPath?: string;
59
+ tree: Record<string, SessionNode>;
60
+ orchestrationSigmaAccum?: OrchestrationSigmaAccum;
61
+ orchestrationUsageLastSeen: Record<string, SessionUsageDeltaBasis>;
62
+ }
63
+
64
+ export interface TuiSnapshot {
65
+ version: 6;
66
+ updatedAt: number;
67
+ sessions: Record<string, TuiSessionBundle>;
68
+ subscriptionUsage: Record<string, SubscriptionUsageEntry>;
69
+ activeSubscriptionByProvider: Partial<Record<SubscriptionProvider, string>>;
70
+ }
71
+
72
+ export const sessionTreeStore: Record<string, SessionNode> = {};
73
+
74
+ export const SESSION_BUNDLE_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
75
+
76
+ function emptyBundle(rootSessionId: string): TuiSessionBundle {
77
+ return {
78
+ rootSessionId,
79
+ lastActivityAt: Date.now(),
80
+ tree: {},
81
+ orchestrationUsageLastSeen: {},
82
+ };
83
+ }
84
+
85
+ /** Normalized resolved directory for comparisons. */
86
+ export function normalizeProjectDirectory(raw: string): string {
87
+ return path.normalize(path.resolve(raw));
88
+ }
89
+
90
+ export function mergedSessionTree(
91
+ snapshot: TuiSnapshot,
92
+ ): Record<string, SessionNode> {
93
+ const out: Record<string, SessionNode> = {};
94
+ for (const bundle of Object.values(snapshot.sessions)) {
95
+ Object.assign(out, bundle.tree);
96
+ }
97
+ return out;
98
+ }
99
+
100
+ /** 0-100, from current context used ÷ limit (single source of truth for CTX %). */
101
+ export function deriveSessionContextPct(used: number, limit: number): number {
102
+ if (!(limit > 0)) return 0;
103
+ if (!(Number.isFinite(used) && Number.isFinite(limit))) return 0;
104
+ const safeUsed = Math.max(0, used);
105
+ return Math.max(0, Math.min(100, (safeUsed / limit) * 100));
106
+ }
107
+
108
+ function coerceSessionUsageEntry(
109
+ raw: Partial<SessionUsageEntry> | undefined,
110
+ ): SessionUsageEntry | undefined {
111
+ if (!raw || typeof raw !== 'object') return undefined;
112
+ return {
113
+ contextUsed:
114
+ typeof raw.contextUsed === 'number' ? Math.max(0, raw.contextUsed) : 0,
115
+ contextLimit:
116
+ typeof raw.contextLimit === 'number' ? Math.max(0, raw.contextLimit) : 0,
117
+ contextPct:
118
+ typeof raw.contextPct === 'number'
119
+ ? Math.max(0, Math.min(100, raw.contextPct))
120
+ : 0,
121
+ input: typeof raw.input === 'number' ? Math.max(0, raw.input) : 0,
122
+ output: typeof raw.output === 'number' ? Math.max(0, raw.output) : 0,
123
+ reasoning:
124
+ typeof raw.reasoning === 'number' ? Math.max(0, raw.reasoning) : 0,
125
+ cacheRead:
126
+ typeof raw.cacheRead === 'number' ? Math.max(0, raw.cacheRead) : 0,
127
+ cacheWrite:
128
+ typeof raw.cacheWrite === 'number' ? Math.max(0, raw.cacheWrite) : 0,
129
+ updatedAt:
130
+ typeof raw.updatedAt === 'number' ? Math.max(0, raw.updatedAt) : 0,
131
+ };
132
+ }
133
+
134
+ /** Token / model telemetry merged from nodes (see {@link SessionNode.usage}). */
135
+ export function mergedSessionUsage(
136
+ snapshot: TuiSnapshot,
137
+ ): Record<string, SessionUsageEntry> {
138
+ const out: Record<string, SessionUsageEntry> = {};
139
+ for (const bundle of Object.values(snapshot.sessions)) {
140
+ for (const [sid, node] of Object.entries(bundle.tree)) {
141
+ if (node.usage === undefined) continue;
142
+ const usage = coerceSessionUsageEntry(node.usage);
143
+ if (usage) out[sid] = usage;
144
+ }
145
+ }
146
+ return out;
147
+ }
148
+
149
+ export function mergedSessionModels(
150
+ snapshot: TuiSnapshot,
151
+ ): Record<string, string> {
152
+ const out: Record<string, string> = {};
153
+ const tree = mergedSessionTree(snapshot);
154
+ for (const [sid, node] of Object.entries(tree)) {
155
+ if (node.model) out[sid] = node.model;
156
+ }
157
+ return out;
158
+ }
159
+
160
+ export function mergedSessionVariants(
161
+ snapshot: TuiSnapshot,
162
+ ): Record<string, string> {
163
+ const out: Record<string, string> = {};
164
+ const tree = mergedSessionTree(snapshot);
165
+ for (const [sid, node] of Object.entries(tree)) {
166
+ if (typeof node.variant === 'string' && node.variant.length > 0) {
167
+ out[sid] = node.variant;
168
+ }
169
+ }
170
+ return out;
171
+ }
172
+
173
+ export function mergedOrchestrationUsageLastSeen(
174
+ snapshot: TuiSnapshot,
175
+ ): Record<string, SessionUsageDeltaBasis> {
176
+ const out: Record<string, SessionUsageDeltaBasis> = {};
177
+ for (const bundle of Object.values(snapshot.sessions)) {
178
+ Object.assign(out, bundle.orchestrationUsageLastSeen);
179
+ }
180
+ return out;
181
+ }
182
+
183
+ export function mergedOrchestrationSigmaAccum(
184
+ snapshot: TuiSnapshot,
185
+ ): Record<string, OrchestrationSigmaAccum> {
186
+ const out: Record<string, OrchestrationSigmaAccum> = {};
187
+ for (const [rootId, bundle] of Object.entries(snapshot.sessions)) {
188
+ if (bundle.orchestrationSigmaAccum) {
189
+ out[rootId] = bundle.orchestrationSigmaAccum;
190
+ }
191
+ }
192
+ return out;
193
+ }
194
+
195
+ function touchBundle(bundle: TuiSessionBundle): void {
196
+ bundle.lastActivityAt = Date.now();
197
+ }
198
+
199
+ function locateBundleForSession(
200
+ snapshot: TuiSnapshot,
201
+ sessionID: string,
202
+ ): { rootId: string; bundle: TuiSessionBundle } | undefined {
203
+ for (const [rootId, bundle] of Object.entries(snapshot.sessions)) {
204
+ if (bundle.tree[sessionID]) return { rootId, bundle };
205
+ }
206
+ return undefined;
207
+ }
208
+
209
+ export function mapOpenCodeStatusToTreeStatus(
210
+ raw: string,
211
+ ): 'busy' | 'idle' | 'retry' {
212
+ const t = raw.trim().toLowerCase();
213
+ if (t === 'idle') return 'idle';
214
+ if (t === 'retry') return 'retry';
215
+ if (t === 'busy') return 'busy';
216
+ return 'busy';
217
+ }
218
+
219
+ function applyOpenCodeSessionStatus(
220
+ snapshot: TuiSnapshot,
221
+ sessionID: string,
222
+ rawType: string,
223
+ ): void {
224
+ const mapped = mapOpenCodeStatusToTreeStatus(rawType);
225
+ const hit = locateBundleForSession(snapshot, sessionID);
226
+ if (hit) {
227
+ hit.bundle.tree[sessionID].status = mapped;
228
+ touchBundle(hit.bundle);
229
+ sessionTreeStore[sessionID] = hit.bundle.tree[sessionID];
230
+ return;
231
+ }
232
+ const store = sessionTreeStore[sessionID];
233
+ if (store) store.status = mapped;
234
+ }
235
+
236
+ export function syncOpenCodeStatusesIntoSessionTree(
237
+ snapshot: TuiSnapshot,
238
+ statuses: Record<string, { type: string }>,
239
+ ): void {
240
+ for (const [sid, row] of Object.entries(statuses)) {
241
+ applyOpenCodeSessionStatus(snapshot, sid, row.type);
242
+ }
243
+ }
244
+
245
+ function upwardRootFrom(
246
+ mergedTree: Record<string, SessionNode>,
247
+ startSessionId: string,
248
+ ): string {
249
+ let cur = startSessionId;
250
+ const visited = new Set<string>();
251
+ while (!visited.has(cur)) {
252
+ visited.add(cur);
253
+ const parent = mergedTree[cur]?.parentId;
254
+ if (!parent) break;
255
+ cur = parent;
256
+ }
257
+ return cur;
258
+ }
259
+
260
+ function resolveBundleRootForSession(
261
+ snapshot: TuiSnapshot,
262
+ sessionID: string,
263
+ explicitParentId?: string,
264
+ ): string {
265
+ const merged = mergedSessionTree(snapshot);
266
+ if (!explicitParentId) {
267
+ if (merged[sessionID]) return upwardRootFrom(merged, sessionID);
268
+ return sessionID;
269
+ }
270
+ return upwardRootFrom(merged, explicitParentId);
271
+ }
272
+
273
+ function ensureBundle(
274
+ snapshot: TuiSnapshot,
275
+ rootSessionId: string,
276
+ ): TuiSessionBundle {
277
+ let bundle = snapshot.sessions[rootSessionId];
278
+ if (!bundle) {
279
+ bundle = emptyBundle(rootSessionId);
280
+ snapshot.sessions[rootSessionId] = bundle;
281
+ touchBundle(bundle);
282
+ }
283
+ return bundle;
284
+ }
285
+
286
+ /** Ensure a writable node exists under this bundle and sync {@link sessionTreeStore}. */
287
+ function getOrCreateTreeNode(
288
+ bundle: TuiSessionBundle,
289
+ sessionID: string,
290
+ ): SessionNode {
291
+ const merged = sessionTreeStore[sessionID] ??
292
+ bundle.tree[sessionID] ?? {
293
+ title: '',
294
+ agent: '',
295
+ model: '',
296
+ childIds: [],
297
+ status: 'busy' as const,
298
+ createdAt: Date.now(),
299
+ };
300
+ bundle.tree[sessionID] = merged;
301
+ sessionTreeStore[sessionID] = merged;
302
+ return merged;
303
+ }
304
+
305
+ function deleteBundleCascade(
306
+ snapshot: TuiSnapshot,
307
+ rootSessionId: string,
308
+ ): Set<string> {
309
+ const bundle = snapshot.sessions[rootSessionId];
310
+ if (!bundle) return new Set();
311
+ const removedIds = new Set(Object.keys(bundle.tree));
312
+ delete snapshot.sessions[rootSessionId];
313
+ return removedIds;
314
+ }
315
+
316
+ function pruneSessionSidDataInBundle(
317
+ bundle: TuiSessionBundle,
318
+ sid: string,
319
+ ): void {
320
+ const node = bundle.tree[sid];
321
+ if (node) {
322
+ const needsFlashStart =
323
+ node.status !== 'idle' || node.finishedAt === undefined;
324
+ node.status = 'idle';
325
+ if (needsFlashStart) {
326
+ node.finishedAt = Date.now();
327
+ }
328
+ delete node.usage;
329
+ }
330
+ delete bundle.orchestrationUsageLastSeen[sid];
331
+ }
332
+
333
+ function normalizedBundleProjectForSession(
334
+ snapshot: TuiSnapshot,
335
+ sessionID: string,
336
+ ): string | undefined {
337
+ const hit = locateBundleForSession(snapshot, sessionID);
338
+ if (!hit?.bundle.projectPath) return undefined;
339
+ return normalizeProjectDirectory(hit.bundle.projectPath);
340
+ }
341
+
342
+ export function expandMissingSessionCascade(
343
+ mergedTree: Record<string, SessionNode>,
344
+ seeds: Iterable<string>,
345
+ ): Set<string> {
346
+ const ids = new Set(seeds);
347
+ let added = true;
348
+ while (added) {
349
+ added = false;
350
+ for (const [sid, node] of Object.entries(mergedTree)) {
351
+ if (ids.has(sid)) continue;
352
+ const parentId = node.parentId;
353
+ if (parentId && ids.has(parentId)) {
354
+ ids.add(sid);
355
+ added = true;
356
+ }
357
+ }
358
+ }
359
+ return ids;
360
+ }
361
+
362
+ /** True if `descendantCandidate` is not `ancestorId` and has `ancestorId` on its parent chain. */
363
+ function isStrictDescendantInMergedTree(
364
+ mergedTree: Record<string, SessionNode>,
365
+ ancestorId: string,
366
+ descendantCandidate: string,
367
+ ): boolean {
368
+ if (ancestorId === descendantCandidate) return false;
369
+ let cur: string | undefined = descendantCandidate;
370
+ const visited = new Set<string>();
371
+ while (cur && !visited.has(cur)) {
372
+ visited.add(cur);
373
+ if (cur === ancestorId) return true;
374
+ cur = mergedTree[cur]?.parentId;
375
+ }
376
+ return false;
377
+ }
378
+
379
+ function softPruneTargetHasPollDescendant(
380
+ mergedTree: Record<string, SessionNode>,
381
+ targetSid: string,
382
+ opencodeIds: ReadonlySet<string>,
383
+ ): boolean {
384
+ for (const pollId of opencodeIds) {
385
+ if (isStrictDescendantInMergedTree(mergedTree, targetSid, pollId)) {
386
+ return true;
387
+ }
388
+ }
389
+ return false;
390
+ }
391
+
392
+ /**
393
+ * Drop idle bundles (TTL, whole-tree gone from OpenCode) and soft-prune
394
+ * sessions missing from {@link input.opencodeIds}. Soft-prune skips any id
395
+ * still present in that set so incomplete polls cannot idle a busy child
396
+ * whose parent row was omitted. Ancestors are skipped while any polled id is
397
+ * still their descendant (avoids idling the orchestrator and clearing sigma
398
+ * when the poll omits the root). If incomplete polls persist, callers may add
399
+ * debouncing or skip soft-prune when poll cardinality collapses abruptly.
400
+ */
401
+ export function pruneStaleTuiSessionBundles(
402
+ snapshot: TuiSnapshot,
403
+ input: {
404
+ opencodeIds: ReadonlySet<string>;
405
+ currentProjectDir: string;
406
+ now: number;
407
+ },
408
+ ): Set<string> {
409
+ const strippedFromFile = new Set<string>();
410
+
411
+ for (const rootId of Object.keys(snapshot.sessions)) {
412
+ const bundle = snapshot.sessions[rootId];
413
+ if (!bundle) continue;
414
+ if (
415
+ bundle.lastActivityAt > 0 &&
416
+ input.now - bundle.lastActivityAt >= SESSION_BUNDLE_RETENTION_MS
417
+ ) {
418
+ for (const id of deleteBundleCascade(snapshot, rootId)) {
419
+ strippedFromFile.add(id);
420
+ }
421
+ }
422
+ }
423
+
424
+ const projectMatched = normalizeProjectDirectory(input.currentProjectDir);
425
+
426
+ // Only compare against OpenCode's id list when non-empty. Reconciliation
427
+ // skips when session.status is `{}` (cannot treat as authoritative).
428
+ if (input.opencodeIds.size > 0) {
429
+ for (const rootId of [...Object.keys(snapshot.sessions)]) {
430
+ const bundle = snapshot.sessions[rootId];
431
+ if (!bundle?.projectPath) continue;
432
+ if (normalizeProjectDirectory(bundle.projectPath) !== projectMatched) {
433
+ continue;
434
+ }
435
+ const treeIds = Object.keys(bundle.tree);
436
+ if (treeIds.length === 0) continue;
437
+ if (treeIds.every((id) => !input.opencodeIds.has(id))) {
438
+ for (const id of deleteBundleCascade(snapshot, rootId)) {
439
+ strippedFromFile.add(id);
440
+ }
441
+ }
442
+ }
443
+ }
444
+
445
+ const merged = mergedSessionTree(snapshot);
446
+ const missingSeeds =
447
+ input.opencodeIds.size > 0
448
+ ? Object.keys(merged).filter((id) => !input.opencodeIds.has(id))
449
+ : [];
450
+ const expandedMissing = expandMissingSessionCascade(merged, missingSeeds);
451
+
452
+ for (const sid of expandedMissing) {
453
+ // OpenCode still lists this session - never wipe it as "missing" just
454
+ // because a parent id was absent from the poll (expandMissingSessionCascade
455
+ // would otherwise include busy children).
456
+ if (input.opencodeIds.has(sid)) continue;
457
+ if (softPruneTargetHasPollDescendant(merged, sid, input.opencodeIds)) {
458
+ continue;
459
+ }
460
+
461
+ const projected = normalizedBundleProjectForSession(snapshot, sid);
462
+ if (projected === undefined || projected !== projectMatched) continue;
463
+
464
+ const located = locateBundleForSession(snapshot, sid);
465
+ if (!located) continue;
466
+
467
+ const { bundle, rootId } = located;
468
+ pruneSessionSidDataInBundle(bundle, sid);
469
+ strippedFromFile.add(sid);
470
+
471
+ if (located.bundle.tree[sid]?.agent === 'orchestrator' && sid === rootId) {
472
+ delete bundle.orchestrationSigmaAccum;
473
+ }
474
+ touchBundle(bundle);
475
+ }
476
+
477
+ return strippedFromFile;
478
+ }
479
+
480
+ const STATE_DIR = 'opencode-dux';
481
+ const STATE_FILE = 'tui-state.json';
482
+
483
+ function dataDir(): string {
484
+ return (
485
+ process.env.XDG_DATA_HOME ?? path.join(os.homedir(), '.local', 'share')
486
+ );
487
+ }
488
+
489
+ export function getTuiStatePath(): string {
490
+ return path.join(dataDir(), 'opencode', 'storage', STATE_DIR, STATE_FILE);
491
+ }
492
+
493
+ function emptySnapshot(): TuiSnapshot {
494
+ return {
495
+ version: 6,
496
+ updatedAt: Date.now(),
497
+ sessions: {},
498
+ subscriptionUsage: {},
499
+ activeSubscriptionByProvider: {},
500
+ };
501
+ }
502
+
503
+ function normalizeSubscriptionUsage(
504
+ usage: Record<string, SubscriptionUsageEntry>,
505
+ ): Record<string, SubscriptionUsageEntry> {
506
+ return usage;
507
+ }
508
+
509
+ function normalizeSigmaAccum(
510
+ value: Partial<OrchestrationSigmaAccum> | undefined,
511
+ ): OrchestrationSigmaAccum | undefined {
512
+ if (!value) return undefined;
513
+ return {
514
+ contextUsed:
515
+ typeof value.contextUsed === 'number'
516
+ ? Math.max(0, value.contextUsed)
517
+ : 0,
518
+ input: typeof value.input === 'number' ? Math.max(0, value.input) : 0,
519
+ output: typeof value.output === 'number' ? Math.max(0, value.output) : 0,
520
+ cacheRead:
521
+ typeof value.cacheRead === 'number' ? Math.max(0, value.cacheRead) : 0,
522
+ cacheWrite:
523
+ typeof value.cacheWrite === 'number' ? Math.max(0, value.cacheWrite) : 0,
524
+ };
525
+ }
526
+
527
+ function normalizeUsageLastSeen(
528
+ value: Record<string, Partial<SessionUsageDeltaBasis>>,
529
+ ): Record<string, SessionUsageDeltaBasis> {
530
+ const result: Record<string, SessionUsageDeltaBasis> = {};
531
+ for (const [sessionID, entry] of Object.entries(value)) {
532
+ if (!entry) continue;
533
+ result[sessionID] = {
534
+ contextUsed:
535
+ typeof entry.contextUsed === 'number'
536
+ ? Math.max(0, entry.contextUsed)
537
+ : 0,
538
+ input: typeof entry.input === 'number' ? Math.max(0, entry.input) : 0,
539
+ output: typeof entry.output === 'number' ? Math.max(0, entry.output) : 0,
540
+ cacheRead:
541
+ typeof entry.cacheRead === 'number' ? Math.max(0, entry.cacheRead) : 0,
542
+ cacheWrite:
543
+ typeof entry.cacheWrite === 'number'
544
+ ? Math.max(0, entry.cacheWrite)
545
+ : 0,
546
+ };
547
+ }
548
+ return result;
549
+ }
550
+
551
+ function hydrateTreeUsages(tree: Record<string, SessionNode>): void {
552
+ for (const node of Object.values(tree)) {
553
+ if (node.usage === undefined || node.usage === null) continue;
554
+ const u = coerceSessionUsageEntry(node.usage as Partial<SessionUsageEntry>);
555
+ if (u) node.usage = u;
556
+ else delete node.usage;
557
+ }
558
+ }
559
+
560
+ function parseSessionBundles(raw: unknown): Record<string, TuiSessionBundle> {
561
+ const out: Record<string, TuiSessionBundle> = {};
562
+ if (!raw || typeof raw !== 'object') return out;
563
+ const entries = Object.entries(raw as Record<string, unknown>);
564
+ for (const [rootId, value] of entries) {
565
+ if (!value || typeof value !== 'object') continue;
566
+ const v = value as Record<string, unknown>;
567
+ const tree =
568
+ v.tree && typeof v.tree === 'object'
569
+ ? (v.tree as Record<string, SessionNode>)
570
+ : {};
571
+ hydrateTreeUsages(tree);
572
+
573
+ const lastActivityAt =
574
+ typeof v.lastActivityAt === 'number' ? v.lastActivityAt : Date.now();
575
+ const projectPath =
576
+ typeof v.projectPath === 'string' && v.projectPath.length > 0
577
+ ? normalizeProjectDirectory(v.projectPath)
578
+ : undefined;
579
+
580
+ const bundle: TuiSessionBundle = {
581
+ rootSessionId:
582
+ typeof v.rootSessionId === 'string' && v.rootSessionId.length > 0
583
+ ? v.rootSessionId
584
+ : rootId,
585
+ lastActivityAt,
586
+ projectPath,
587
+ tree,
588
+ orchestrationSigmaAccum: normalizeSigmaAccum(
589
+ v.orchestrationSigmaAccum &&
590
+ typeof v.orchestrationSigmaAccum === 'object'
591
+ ? (v.orchestrationSigmaAccum as Partial<OrchestrationSigmaAccum>)
592
+ : undefined,
593
+ ),
594
+ orchestrationUsageLastSeen: normalizeUsageLastSeen(
595
+ typeof v.orchestrationUsageLastSeen === 'object' &&
596
+ v.orchestrationUsageLastSeen
597
+ ? (v.orchestrationUsageLastSeen as Record<
598
+ string,
599
+ Partial<SessionUsageDeltaBasis>
600
+ >)
601
+ : {},
602
+ ),
603
+ };
604
+ out[rootId] = bundle;
605
+ }
606
+ return out;
607
+ }
608
+
609
+ function parseSnapshot(value: string): TuiSnapshot | null {
610
+ try {
611
+ const parsed = JSON.parse(value) as Partial<TuiSnapshot> | undefined;
612
+ if (parsed?.version !== 6) return null;
613
+
614
+ const activeSubscriptionByProvider: Partial<
615
+ Record<SubscriptionProvider, string>
616
+ > = {};
617
+ if (parsed.activeSubscriptionByProvider) {
618
+ for (const provider of ['opencode-go', 'neuralwatt'] as const) {
619
+ const name = parsed.activeSubscriptionByProvider[provider];
620
+ if (typeof name === 'string' && name.length > 0) {
621
+ activeSubscriptionByProvider[provider] = name;
622
+ }
623
+ }
624
+ }
625
+
626
+ return {
627
+ version: 6,
628
+ updatedAt:
629
+ typeof parsed.updatedAt === 'number' ? parsed.updatedAt : Date.now(),
630
+ sessions: parseSessionBundles(parsed.sessions ?? {}),
631
+ subscriptionUsage: normalizeSubscriptionUsage(
632
+ typeof parsed.subscriptionUsage === 'object' && parsed.subscriptionUsage
633
+ ? (parsed.subscriptionUsage as Record<string, SubscriptionUsageEntry>)
634
+ : {},
635
+ ),
636
+ activeSubscriptionByProvider,
637
+ };
638
+ } catch {
639
+ return null;
640
+ }
641
+ }
642
+
643
+ function tryReadSnapshot(): {
644
+ snapshot: TuiSnapshot;
645
+ okForMutation: boolean;
646
+ } {
647
+ const filePath = getTuiStatePath();
648
+ try {
649
+ const parsed = parseSnapshot(fs.readFileSync(filePath, 'utf8'));
650
+ if (parsed) {
651
+ return { snapshot: parsed, okForMutation: true };
652
+ }
653
+ return { snapshot: emptySnapshot(), okForMutation: false };
654
+ } catch (error) {
655
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
656
+ return { snapshot: emptySnapshot(), okForMutation: true };
657
+ }
658
+ return { snapshot: emptySnapshot(), okForMutation: false };
659
+ }
660
+ }
661
+
662
+ export function readTuiSnapshot(): TuiSnapshot {
663
+ return tryReadSnapshot().snapshot;
664
+ }
665
+
666
+ export async function readTuiSnapshotAsync(): Promise<TuiSnapshot> {
667
+ try {
668
+ const parsed = parseSnapshot(
669
+ await fs.promises.readFile(getTuiStatePath(), 'utf8'),
670
+ );
671
+ return parsed ?? emptySnapshot();
672
+ } catch {
673
+ return emptySnapshot();
674
+ }
675
+ }
676
+
677
+ function writeTuiSnapshot(snapshot: TuiSnapshot): void {
678
+ try {
679
+ const filePath = getTuiStatePath();
680
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
681
+ fs.writeFileSync(filePath, `${JSON.stringify(snapshot)}\n`);
682
+ } catch {
683
+ // best-effort
684
+ }
685
+ }
686
+
687
+ /**
688
+ * Coalesces read-modify-write so overlapping callers never overwrite each
689
+ * other's in-flight edits; nested `updateSnapshot` shares one read+write
690
+ * within the same stack.
691
+ */
692
+ let isDrainingSnapshot = false;
693
+ const snapshotMutatorQueue: Array<(snapshot: TuiSnapshot) => void> = [];
694
+
695
+ export function updateSnapshot(mutator: (snapshot: TuiSnapshot) => void): void {
696
+ snapshotMutatorQueue.push(mutator);
697
+ if (isDrainingSnapshot) {
698
+ return;
699
+ }
700
+ isDrainingSnapshot = true;
701
+ try {
702
+ while (snapshotMutatorQueue.length > 0) {
703
+ try {
704
+ const { snapshot, okForMutation } = tryReadSnapshot();
705
+ if (!okForMutation) {
706
+ snapshotMutatorQueue.length = 0;
707
+ break;
708
+ }
709
+ while (snapshotMutatorQueue.length > 0) {
710
+ const m = snapshotMutatorQueue.shift();
711
+ if (m === undefined) {
712
+ break;
713
+ }
714
+ m(snapshot);
715
+ }
716
+ snapshot.updatedAt = Date.now();
717
+ writeTuiSnapshot(snapshot);
718
+ } catch {
719
+ snapshotMutatorQueue.length = 0;
720
+ break;
721
+ }
722
+ }
723
+ } finally {
724
+ isDrainingSnapshot = false;
725
+ }
726
+ }
727
+
728
+ /**
729
+ * Resolves after any pending synchronous `updateSnapshot` work on this thread
730
+ * has finished (writes are synchronous today).
731
+ */
732
+ export function flushTuiSnapshot(): Promise<void> {
733
+ return Promise.resolve();
734
+ }
735
+
736
+ export type RecordSessionUsageInput = {
737
+ sessionID: string;
738
+ contextUsed?: number;
739
+ contextLimit?: number;
740
+ contextPct?: number;
741
+ input: number;
742
+ output: number;
743
+ reasoning: number;
744
+ cacheRead: number;
745
+ cacheWrite: number;
746
+ };
747
+
748
+ function applyRecordSessionUsageToSnapshot(
749
+ snapshot: TuiSnapshot,
750
+ input: RecordSessionUsageInput,
751
+ ): void {
752
+ let bundle: TuiSessionBundle | undefined;
753
+
754
+ const located = locateBundleForSession(snapshot, input.sessionID);
755
+ if (located) bundle = located.bundle;
756
+ else {
757
+ const rootFallback = resolveBundleRootForSession(snapshot, input.sessionID);
758
+ bundle = ensureBundle(snapshot, rootFallback);
759
+ }
760
+
761
+ const node = getOrCreateTreeNode(bundle, input.sessionID);
762
+ const prev = coerceSessionUsageEntry(node.usage);
763
+
764
+ const nextContextUsed =
765
+ input.contextUsed !== undefined
766
+ ? Math.max(0, input.contextUsed)
767
+ : (prev?.contextUsed ?? 0);
768
+ const nextContextLimit =
769
+ input.contextLimit != null && input.contextLimit > 0
770
+ ? input.contextLimit
771
+ : (prev?.contextLimit ?? 0);
772
+
773
+ const next: SessionUsageEntry = {
774
+ contextUsed: nextContextUsed,
775
+ contextLimit: nextContextLimit,
776
+ contextPct: deriveSessionContextPct(nextContextUsed, nextContextLimit),
777
+ input:
778
+ input.input !== undefined
779
+ ? Math.max(prev?.input ?? 0, input.input)
780
+ : (prev?.input ?? 0),
781
+ output:
782
+ input.output !== undefined
783
+ ? Math.max(prev?.output ?? 0, input.output)
784
+ : (prev?.output ?? 0),
785
+ reasoning:
786
+ input.reasoning !== undefined
787
+ ? Math.max(prev?.reasoning ?? 0, input.reasoning)
788
+ : (prev?.reasoning ?? 0),
789
+ cacheRead:
790
+ input.cacheRead !== undefined
791
+ ? Math.max(prev?.cacheRead ?? 0, input.cacheRead)
792
+ : (prev?.cacheRead ?? 0),
793
+ cacheWrite:
794
+ input.cacheWrite !== undefined
795
+ ? Math.max(prev?.cacheWrite ?? 0, input.cacheWrite)
796
+ : (prev?.cacheWrite ?? 0),
797
+ updatedAt: Date.now(),
798
+ };
799
+ node.usage = next;
800
+ touchBundle(bundle);
801
+
802
+ const rootSessionID = resolveOrchestrationRootSessionID(
803
+ snapshot,
804
+ input.sessionID,
805
+ );
806
+ if (!rootSessionID) return;
807
+
808
+ const orchBundle = locateBundleForSession(snapshot, rootSessionID);
809
+ if (!orchBundle) return;
810
+
811
+ const previousSeen = orchBundle.bundle.orchestrationUsageLastSeen[
812
+ input.sessionID
813
+ ] ?? {
814
+ contextUsed: 0,
815
+ input: 0,
816
+ output: 0,
817
+ cacheRead: 0,
818
+ cacheWrite: 0,
819
+ };
820
+ const nextSeen: SessionUsageDeltaBasis = {
821
+ contextUsed: next.contextUsed,
822
+ input: next.input,
823
+ output: next.output,
824
+ cacheRead: next.cacheRead,
825
+ cacheWrite: next.cacheWrite,
826
+ };
827
+ const deltaContextUsed = Math.max(
828
+ 0,
829
+ nextSeen.contextUsed - previousSeen.contextUsed,
830
+ );
831
+ const deltaInput = Math.max(0, nextSeen.input - previousSeen.input);
832
+ const deltaOutput = Math.max(0, nextSeen.output - previousSeen.output);
833
+ const deltaCacheRead = Math.max(
834
+ 0,
835
+ nextSeen.cacheRead - previousSeen.cacheRead,
836
+ );
837
+ const deltaCacheWrite = Math.max(
838
+ 0,
839
+ nextSeen.cacheWrite - previousSeen.cacheWrite,
840
+ );
841
+ const prevAccum = orchBundle.bundle.orchestrationSigmaAccum ?? {
842
+ contextUsed: 0,
843
+ input: 0,
844
+ output: 0,
845
+ cacheRead: 0,
846
+ cacheWrite: 0,
847
+ };
848
+ orchBundle.bundle.orchestrationSigmaAccum = {
849
+ contextUsed: prevAccum.contextUsed + deltaContextUsed,
850
+ input: prevAccum.input + deltaInput,
851
+ output: prevAccum.output + deltaOutput,
852
+ cacheRead: prevAccum.cacheRead + deltaCacheRead,
853
+ cacheWrite: prevAccum.cacheWrite + deltaCacheWrite,
854
+ };
855
+ orchBundle.bundle.orchestrationUsageLastSeen[input.sessionID] = nextSeen;
856
+ touchBundle(orchBundle.bundle);
857
+ }
858
+
859
+ export function recordSessionUsagesBatch(
860
+ inputs: RecordSessionUsageInput[],
861
+ ): void {
862
+ if (inputs.length === 0) return;
863
+ updateSnapshot((snapshot) => {
864
+ for (const input of inputs) {
865
+ applyRecordSessionUsageToSnapshot(snapshot, input);
866
+ }
867
+ });
868
+ }
869
+
870
+ /**
871
+ * One persisted write for delegate-spawned subagent: tree node + parent
872
+ * `childIds` + {@link sessionTreeStore} parent link.
873
+ */
874
+ export function recordDelegatedSubagentSession(input: {
875
+ sessionID: string;
876
+ parentSessionId: string;
877
+ agent: string;
878
+ variant?: string;
879
+ mode?: 'blocking' | 'fire_forget';
880
+ }): void {
881
+ updateSnapshot((snapshot) => {
882
+ const rootId = resolveBundleRootForSession(
883
+ snapshot,
884
+ input.sessionID,
885
+ input.parentSessionId,
886
+ );
887
+ const bundle = ensureBundle(snapshot, rootId);
888
+
889
+ const existing = sessionTreeStore[input.sessionID] ??
890
+ bundle.tree[input.sessionID] ?? {
891
+ title: '',
892
+ agent: '',
893
+ model: '',
894
+ childIds: [],
895
+ status: 'busy' as const,
896
+ createdAt: Date.now(),
897
+ };
898
+ const node: SessionNode = {
899
+ ...existing,
900
+ title: existing.title,
901
+ agent: input.agent || existing.agent,
902
+ model: existing.model,
903
+ variant: input.variant !== undefined ? input.variant : existing.variant,
904
+ parentId: input.parentSessionId,
905
+ mode: input.mode !== undefined ? input.mode : existing.mode,
906
+ status: existing.status,
907
+ createdAt: existing.createdAt,
908
+ };
909
+ bundle.tree[input.sessionID] = node;
910
+ sessionTreeStore[input.sessionID] = node;
911
+ touchBundle(bundle);
912
+
913
+ for (const b of Object.values(snapshot.sessions)) {
914
+ const parent = b.tree[input.parentSessionId];
915
+ if (!parent) continue;
916
+ if (!parent.childIds.includes(input.sessionID)) {
917
+ parent.childIds.push(input.sessionID);
918
+ }
919
+ b.lastActivityAt = Date.now();
920
+ }
921
+ const storeParent = sessionTreeStore[input.parentSessionId];
922
+ if (storeParent && !storeParent.childIds.includes(input.sessionID)) {
923
+ storeParent.childIds.push(input.sessionID);
924
+ }
925
+ });
926
+ }
927
+
928
+ /**
929
+ * One persisted write for `session.created`: node, optional parent `childIds`,
930
+ * optional project path.
931
+ */
932
+ export function recordChildSessionSnapshot(input: {
933
+ sessionID: string;
934
+ title: string;
935
+ parentSessionId?: string;
936
+ projectPath?: string;
937
+ }): void {
938
+ updateSnapshot((snapshot) => {
939
+ const rootId = resolveBundleRootForSession(
940
+ snapshot,
941
+ input.sessionID,
942
+ input.parentSessionId,
943
+ );
944
+ const bundle = ensureBundle(snapshot, rootId);
945
+
946
+ const existing = sessionTreeStore[input.sessionID] ??
947
+ bundle.tree[input.sessionID] ?? {
948
+ title: '',
949
+ agent: '',
950
+ model: '',
951
+ childIds: [],
952
+ status: 'busy' as const,
953
+ createdAt: Date.now(),
954
+ };
955
+ const node: SessionNode = {
956
+ ...existing,
957
+ title: input.title ?? existing.title,
958
+ agent: existing.agent,
959
+ model: existing.model,
960
+ variant: existing.variant,
961
+ parentId:
962
+ input.parentSessionId !== undefined
963
+ ? input.parentSessionId
964
+ : existing.parentId,
965
+ mode: existing.mode,
966
+ status: existing.status,
967
+ createdAt: existing.createdAt,
968
+ };
969
+ bundle.tree[input.sessionID] = node;
970
+ sessionTreeStore[input.sessionID] = node;
971
+ touchBundle(bundle);
972
+
973
+ if (input.parentSessionId) {
974
+ for (const b of Object.values(snapshot.sessions)) {
975
+ const parent = b.tree[input.parentSessionId];
976
+ if (!parent) continue;
977
+ if (!parent.childIds.includes(input.sessionID)) {
978
+ parent.childIds.push(input.sessionID);
979
+ }
980
+ b.lastActivityAt = Date.now();
981
+ }
982
+ const storeParent = sessionTreeStore[input.parentSessionId];
983
+ if (storeParent && !storeParent.childIds.includes(input.sessionID)) {
984
+ storeParent.childIds.push(input.sessionID);
985
+ }
986
+ }
987
+
988
+ if (input.projectPath !== undefined && input.projectPath.length > 0) {
989
+ const normalized = normalizeProjectDirectory(input.projectPath);
990
+ const rootForProject = resolveBundleRootForSession(
991
+ snapshot,
992
+ input.sessionID,
993
+ );
994
+ const projectBundle = ensureBundle(snapshot, rootForProject);
995
+ projectBundle.projectPath = normalized;
996
+ touchBundle(projectBundle);
997
+ }
998
+ });
999
+ }
1000
+
1001
+ export function patchSessionTreeStatusFromOpenCode(
1002
+ sessionID: string,
1003
+ rawType: string,
1004
+ ): void {
1005
+ updateSnapshot((snapshot) => {
1006
+ applyOpenCodeSessionStatus(snapshot, sessionID, rawType);
1007
+ });
1008
+ }
1009
+
1010
+ export function recordSessionEnd(sessionID: string): void {
1011
+ updateSnapshot((snapshot) => {
1012
+ const located = locateBundleForSession(snapshot, sessionID);
1013
+ const node = located?.bundle.tree[sessionID] ?? sessionTreeStore[sessionID];
1014
+ if (node) delete node.usage;
1015
+ if (located) touchBundle(located.bundle);
1016
+ });
1017
+ }
1018
+
1019
+ export function recordSessionModel(input: {
1020
+ sessionID: string;
1021
+ model: string;
1022
+ }): void {
1023
+ updateSnapshot((snapshot) => {
1024
+ const rootId = resolveBundleRootForSession(snapshot, input.sessionID);
1025
+ const bundle = ensureBundle(snapshot, rootId);
1026
+ const node = getOrCreateTreeNode(bundle, input.sessionID);
1027
+ node.model = input.model;
1028
+ touchBundle(bundle);
1029
+ });
1030
+ }
1031
+
1032
+ export function recordSessionVariant(input: {
1033
+ sessionID: string;
1034
+ variant: string;
1035
+ }): void {
1036
+ updateSnapshot((snapshot) => {
1037
+ const rootId = resolveBundleRootForSession(snapshot, input.sessionID);
1038
+ const bundle = ensureBundle(snapshot, rootId);
1039
+ const node = getOrCreateTreeNode(bundle, input.sessionID);
1040
+ node.variant = input.variant;
1041
+ touchBundle(bundle);
1042
+ });
1043
+ }
1044
+
1045
+ export function recordSessionNode(input: {
1046
+ sessionID: string;
1047
+ /** Omit to keep the existing title (e.g. from `session.created`). Pass `''` to clear. */
1048
+ title?: string;
1049
+ agent: string;
1050
+ model?: string;
1051
+ variant?: string;
1052
+ parentId?: string;
1053
+ mode?: 'blocking' | 'fire_forget';
1054
+ status?: 'busy' | 'idle' | 'retry';
1055
+ }): void {
1056
+ updateSnapshot((snapshot) => {
1057
+ const rootId = resolveBundleRootForSession(
1058
+ snapshot,
1059
+ input.sessionID,
1060
+ input.parentId,
1061
+ );
1062
+ const bundle = ensureBundle(snapshot, rootId);
1063
+
1064
+ const existing = sessionTreeStore[input.sessionID] ??
1065
+ bundle.tree[input.sessionID] ?? {
1066
+ title: '',
1067
+ agent: '',
1068
+ model: '',
1069
+ childIds: [],
1070
+ status: 'busy' as const,
1071
+ createdAt: Date.now(),
1072
+ };
1073
+ const node = {
1074
+ ...existing,
1075
+ title: input.title !== undefined ? input.title : existing.title,
1076
+ agent: input.agent || existing.agent,
1077
+ model: input.model ?? existing.model,
1078
+ variant: input.variant !== undefined ? input.variant : existing.variant,
1079
+ parentId:
1080
+ input.parentId !== undefined ? input.parentId : existing.parentId,
1081
+ mode: input.mode !== undefined ? input.mode : existing.mode,
1082
+ status: input.status ?? existing.status,
1083
+ createdAt: existing.createdAt,
1084
+ };
1085
+ bundle.tree[input.sessionID] = node;
1086
+ sessionTreeStore[input.sessionID] = node;
1087
+ touchBundle(bundle);
1088
+ });
1089
+ }
1090
+
1091
+ /** Persist session title from OpenCode when the SDK reports a non-empty name. */
1092
+ export function recordSessionTitle(input: {
1093
+ sessionID: string;
1094
+ title: string;
1095
+ }): void {
1096
+ const trimmed = input.title.trim();
1097
+ if (!trimmed) return;
1098
+ updateSnapshot((snapshot) => {
1099
+ const hit = locateBundleForSession(snapshot, input.sessionID);
1100
+ if (hit) {
1101
+ const node = hit.bundle.tree[input.sessionID];
1102
+ if (node) {
1103
+ node.title = trimmed;
1104
+ sessionTreeStore[input.sessionID] = node;
1105
+ touchBundle(hit.bundle);
1106
+ }
1107
+ return;
1108
+ }
1109
+ const rootId = resolveBundleRootForSession(snapshot, input.sessionID);
1110
+ const bundle = ensureBundle(snapshot, rootId);
1111
+ const node = getOrCreateTreeNode(bundle, input.sessionID);
1112
+ node.title = trimmed;
1113
+ touchBundle(bundle);
1114
+ });
1115
+ }
1116
+
1117
+ export function recordSessionDone(sessionID: string): void {
1118
+ updateSnapshot((snapshot) => {
1119
+ const hit = locateBundleForSession(snapshot, sessionID);
1120
+ if (hit) {
1121
+ const node = hit.bundle.tree[sessionID];
1122
+ if (node) {
1123
+ node.status = 'idle';
1124
+ node.finishedAt = Date.now();
1125
+ }
1126
+ touchBundle(hit.bundle);
1127
+ }
1128
+ const storeNode = sessionTreeStore[sessionID];
1129
+ if (storeNode) {
1130
+ storeNode.status = 'idle';
1131
+ storeNode.finishedAt = Date.now();
1132
+ }
1133
+ });
1134
+ }
1135
+
1136
+ function resolveOrchestrationRootSessionID(
1137
+ snapshot: TuiSnapshot,
1138
+ sessionID: string,
1139
+ ): string | null {
1140
+ const merged = mergedSessionTree(snapshot);
1141
+ let currentID: string | undefined = sessionID;
1142
+ const visited = new Set<string>();
1143
+ while (currentID && !visited.has(currentID)) {
1144
+ visited.add(currentID);
1145
+ const treeNode: SessionNode | undefined = merged[currentID];
1146
+ if (!treeNode) return null;
1147
+ if (treeNode.agent === 'orchestrator') return currentID;
1148
+ currentID = treeNode.parentId;
1149
+ }
1150
+ return null;
1151
+ }
1152
+
1153
+ export function recordSessionUsage(input: RecordSessionUsageInput): void {
1154
+ updateSnapshot((snapshot) => {
1155
+ applyRecordSessionUsageToSnapshot(snapshot, input);
1156
+ });
1157
+ }
1158
+
1159
+ export function subscriptionUsageKey(
1160
+ provider: SubscriptionProvider,
1161
+ accountName: string,
1162
+ ): string {
1163
+ return `${provider}\u0000${accountName}`;
1164
+ }
1165
+
1166
+ export function recordSubscriptionUsage(usage: SubscriptionUsageEntry[]): void {
1167
+ updateSnapshot((snapshot) => {
1168
+ snapshot.subscriptionUsage = {};
1169
+ for (const entry of usage) {
1170
+ if (entry.accountName) {
1171
+ snapshot.subscriptionUsage[
1172
+ subscriptionUsageKey(entry.provider, entry.accountName)
1173
+ ] = entry;
1174
+ }
1175
+ }
1176
+ });
1177
+ }
1178
+
1179
+ export function removeSubscriptionUsageEntry(
1180
+ provider: SubscriptionProvider,
1181
+ name: string,
1182
+ ): void {
1183
+ updateSnapshot((snapshot) => {
1184
+ delete snapshot.subscriptionUsage[subscriptionUsageKey(provider, name)];
1185
+ });
1186
+ }
1187
+
1188
+ export function recordSessionProject(input: {
1189
+ sessionID: string;
1190
+ projectPath: string;
1191
+ }): void {
1192
+ const normalized = normalizeProjectDirectory(input.projectPath);
1193
+ updateSnapshot((snapshot) => {
1194
+ const rootId = resolveBundleRootForSession(snapshot, input.sessionID);
1195
+ const bundle = ensureBundle(snapshot, rootId);
1196
+ bundle.projectPath = normalized;
1197
+ touchBundle(bundle);
1198
+ });
1199
+ }
1200
+
1201
+ export function deleteSessionEntries(sessionID: string): void {
1202
+ updateSnapshot((snapshot) => {
1203
+ const located = locateBundleForSession(snapshot, sessionID);
1204
+ if (!located) return;
1205
+ const { bundle, rootId } = located;
1206
+
1207
+ delete bundle.orchestrationUsageLastSeen[sessionID];
1208
+
1209
+ // Root bundle key removed - drop entire orchestration snapshot for this tree.
1210
+ if (sessionID === rootId) {
1211
+ for (const id of deleteBundleCascade(snapshot, rootId)) {
1212
+ delete sessionTreeStore[id];
1213
+ }
1214
+ return;
1215
+ }
1216
+
1217
+ const node = bundle.tree[sessionID];
1218
+ const parentId = node?.parentId;
1219
+ delete bundle.tree[sessionID];
1220
+ delete sessionTreeStore[sessionID];
1221
+
1222
+ if (parentId) {
1223
+ const parent = bundle.tree[parentId];
1224
+ if (parent) {
1225
+ parent.childIds = parent.childIds.filter((c) => c !== sessionID);
1226
+ }
1227
+ const storeParent = sessionTreeStore[parentId];
1228
+ if (storeParent?.childIds) {
1229
+ storeParent.childIds = storeParent.childIds.filter(
1230
+ (c) => c !== sessionID,
1231
+ );
1232
+ }
1233
+ }
1234
+
1235
+ if (Object.keys(bundle.tree).length === 0) {
1236
+ delete snapshot.sessions[rootId];
1237
+ return;
1238
+ }
1239
+
1240
+ touchBundle(bundle);
1241
+ });
1242
+ }
1243
+
1244
+ export function recordActiveSubscriptionForProvider(
1245
+ provider: SubscriptionProvider,
1246
+ name: string | null,
1247
+ ): void {
1248
+ updateSnapshot((snapshot) => {
1249
+ if (name) {
1250
+ snapshot.activeSubscriptionByProvider[provider] = name;
1251
+ } else {
1252
+ delete snapshot.activeSubscriptionByProvider[provider];
1253
+ }
1254
+ });
1255
+ }