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
package/src/index.ts ADDED
@@ -0,0 +1,1672 @@
1
+ import { existsSync, readdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import type { Plugin } from '@opencode-ai/plugin';
4
+ import { createAgents, getAgentConfigs } from './agents';
5
+ import { buildOrchestratorPrompt } from './agents/orchestrator';
6
+ import {
7
+ type AgentOverrideConfig,
8
+ ALL_AGENT_NAMES,
9
+ DEFAULT_MODELS,
10
+ deepMerge,
11
+ loadPluginConfig,
12
+ } from './config';
13
+ import { AGENT_ALIASES } from './config/constants';
14
+ import {
15
+ getActiveRuntimePreset,
16
+ getPreviousRuntimePreset,
17
+ setActiveRuntimePreset,
18
+ } from './config/runtime-preset';
19
+ import { getLocalDiscovery } from './discovery/local';
20
+ import { createDiscoverMcpServersTool } from './discovery/mcp-servers';
21
+ import { createDiscoverSkillsTool } from './discovery/skills';
22
+ import {
23
+ createApplyPatchHook,
24
+ createAutoUpdateCheckerHook,
25
+ createChatHeadersHook,
26
+ createContextPressureReminderHook,
27
+ createDelegateTaskRetryHook,
28
+ createFilterAvailableSkillsHook,
29
+ createJsonErrorRecoveryHook,
30
+ createPhaseReminderHook,
31
+ createPostFileToolNudgeHook,
32
+ createTaskSessionManagerHook,
33
+ createTodoContinuationHook,
34
+ ForegroundFallbackManager,
35
+ } from './hooks';
36
+ import { processImageAttachments } from './hooks/image-hook';
37
+ import { createBuiltinMcps } from './mcp';
38
+ import { discoverSkills } from './skills/registry';
39
+ import type { UsageService } from './subscriptions';
40
+ import { createUsageService } from './subscriptions';
41
+ import {
42
+ ast_grep_replace,
43
+ ast_grep_search,
44
+ createDelegateTools,
45
+ createPresetManager,
46
+ createWebfetchTool,
47
+ } from './tools';
48
+ import {
49
+ deleteSessionEntries,
50
+ expandMissingSessionCascade,
51
+ mergedSessionModels,
52
+ mergedSessionTree,
53
+ normalizeProjectDirectory,
54
+ patchSessionTreeStatusFromOpenCode,
55
+ pruneStaleTuiSessionBundles,
56
+ type RecordSessionUsageInput,
57
+ readTuiSnapshot,
58
+ recordChildSessionSnapshot,
59
+ recordSessionDone,
60
+ recordSessionEnd,
61
+ recordSessionModel,
62
+ recordSessionNode,
63
+ recordSessionProject,
64
+ recordSessionTitle,
65
+ recordSessionUsage,
66
+ recordSessionUsagesBatch,
67
+ recordSessionVariant,
68
+ sessionTreeStore,
69
+ syncOpenCodeStatusesIntoSessionTree,
70
+ updateSnapshot,
71
+ } from './tui-state';
72
+ import {
73
+ createDisplayNameMentionRewriter,
74
+ resolveRuntimeAgentName,
75
+ } from './utils';
76
+ import { initLogger, log } from './utils/logger';
77
+ import { SubagentDepthTracker } from './utils/subagent-depth';
78
+ import { collapseSystemInPlace } from './utils/system-collapse';
79
+
80
+ /**
81
+ * Best-effort log to opencode's app logger.
82
+ * Wrapped in try/catch to avoid deadlocking on opencode v1.4.8-v1.4.9
83
+ * where client.app.log() during init triggers a middleware cycle.
84
+ */
85
+ async function appLog(
86
+ ctx: Parameters<Plugin>[0],
87
+ level: 'error' | 'warn' | 'info',
88
+ message: string,
89
+ ): Promise<void> {
90
+ try {
91
+ await ctx.client.app.log({
92
+ body: { service: 'opencode-dux', level, message },
93
+ });
94
+ } catch {
95
+ // client.app.log may deadlock or be unavailable; stderr is the
96
+ // fallback
97
+ const prefix =
98
+ level === 'error' ? 'ERROR' : level === 'warn' ? 'WARN' : 'INFO';
99
+ console.error(`[opencode-dux] ${prefix}: ${message}`);
100
+ }
101
+ }
102
+
103
+ /** Minimum expected registrations for a healthy plugin load. */
104
+ const HEALTH_CHECK = {
105
+ minAgents: 5,
106
+ minTools: 5,
107
+ minMcps: 1,
108
+ } as const;
109
+
110
+ function asNumber(value: unknown): number | null {
111
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
112
+ }
113
+
114
+ function readTokenTelemetry(message: unknown): {
115
+ input: number;
116
+ output: number;
117
+ reasoning: number;
118
+ cacheRead: number;
119
+ cacheWrite: number;
120
+ contextLimit: number;
121
+ } | null {
122
+ const msg = message as {
123
+ info?: {
124
+ role?: string;
125
+ tokens?: {
126
+ input?: unknown;
127
+ output?: unknown;
128
+ reasoning?: unknown;
129
+ cache?: { read?: unknown; write?: unknown };
130
+ };
131
+ model?: {
132
+ limit?: { context?: unknown; input?: unknown };
133
+ };
134
+ };
135
+ };
136
+ if (msg.info?.role !== 'assistant') return null;
137
+
138
+ const input = asNumber(msg.info?.tokens?.input) ?? 0;
139
+ const output = asNumber(msg.info?.tokens?.output) ?? 0;
140
+ const reasoning = asNumber(msg.info?.tokens?.reasoning) ?? 0;
141
+ const cacheRead = asNumber(msg.info?.tokens?.cache?.read) ?? 0;
142
+ const cacheWrite = asNumber(msg.info?.tokens?.cache?.write) ?? 0;
143
+
144
+ const contextLimit =
145
+ asNumber(msg.info?.model?.limit?.context) ??
146
+ asNumber(msg.info?.model?.limit?.input) ??
147
+ 0;
148
+
149
+ if (
150
+ input <= 0 &&
151
+ output <= 0 &&
152
+ reasoning <= 0 &&
153
+ cacheRead <= 0 &&
154
+ cacheWrite <= 0
155
+ ) {
156
+ return null;
157
+ }
158
+
159
+ return {
160
+ input,
161
+ output,
162
+ reasoning,
163
+ cacheRead,
164
+ cacheWrite,
165
+ contextLimit,
166
+ };
167
+ }
168
+
169
+ // Model context limit cache: key = "providerID/modelID", value = context
170
+ // limit. Populated lazily via ensureModelContextLimits().
171
+ const _modelContextLimitCache = new Map<string, number>();
172
+ let _modelLimitFetchPromise: Promise<void> | null = null;
173
+
174
+ async function ensureModelContextLimits(client: {
175
+ provider: {
176
+ list: () => Promise<{
177
+ data?: { all?: Array<Record<string, unknown>> };
178
+ }>;
179
+ };
180
+ }): Promise<void> {
181
+ if (_modelContextLimitCache.size > 0 || _modelLimitFetchPromise) {
182
+ await _modelLimitFetchPromise;
183
+ return;
184
+ }
185
+
186
+ _modelLimitFetchPromise = (async () => {
187
+ try {
188
+ const result = await client.provider.list();
189
+ const providers =
190
+ (result.data?.all as
191
+ | Array<{
192
+ id?: string;
193
+ models?: Record<
194
+ string,
195
+ { id?: string; limit?: { context?: number } }
196
+ >;
197
+ }>
198
+ | undefined) ?? [];
199
+ for (const provider of providers) {
200
+ if (!provider.models) continue;
201
+ for (const model of Object.values(provider.models)) {
202
+ if (
203
+ typeof model?.limit?.context === 'number' &&
204
+ model.limit.context > 0 &&
205
+ provider.id &&
206
+ model.id
207
+ ) {
208
+ _modelContextLimitCache.set(
209
+ `${provider.id}/${model.id}`,
210
+ model.limit.context,
211
+ );
212
+ }
213
+ }
214
+ }
215
+ } catch {
216
+ // Non-critical - cache stays empty, percentage shows 0
217
+ }
218
+ })();
219
+
220
+ return _modelLimitFetchPromise;
221
+ }
222
+
223
+ /**
224
+ * Compute usage telemetry for one session (messages fetch). Used by
225
+ * reconciliation; persists via {@link recordSessionUsagesBatch} so we do not
226
+ * N-compete on tui-state.json.
227
+ */
228
+ async function computeSessionUsageForReconcile(
229
+ ctx: Parameters<Plugin>[0],
230
+ sessionID: string,
231
+ ): Promise<RecordSessionUsageInput | null> {
232
+ try {
233
+ const messagesResult = await ctx.client.session.messages({
234
+ path: { id: sessionID },
235
+ });
236
+ const allMessages = Array.isArray(messagesResult.data)
237
+ ? messagesResult.data
238
+ : [];
239
+ const assistantMsgs = allMessages.filter(
240
+ (m) => (m as { info?: { role?: string } }).info?.role === 'assistant',
241
+ );
242
+
243
+ // Extract tokens from last assistant message only (SDK supplies cumulative values)
244
+ let totalInput = 0;
245
+ let totalOutput = 0;
246
+ let totalReasoning = 0;
247
+ let totalCacheRead = 0;
248
+ let totalCacheWrite = 0;
249
+ let contextLimit = 0;
250
+ let contextUsed = 0;
251
+ let contextPct = 0;
252
+
253
+ // Ensure context limit cache is populated before recording usage
254
+ await ensureModelContextLimits(ctx.client).catch(() => {});
255
+
256
+ const lastTokenMsg = [...assistantMsgs]
257
+ .reverse()
258
+ .find((m) => readTokenTelemetry(m));
259
+ if (lastTokenMsg) {
260
+ const telemetry = readTokenTelemetry(lastTokenMsg);
261
+ if (telemetry) {
262
+ totalInput = telemetry.input;
263
+ totalOutput = telemetry.output;
264
+ totalReasoning = telemetry.reasoning;
265
+ totalCacheRead = telemetry.cacheRead;
266
+ totalCacheWrite = telemetry.cacheWrite;
267
+ contextLimit = telemetry.contextLimit;
268
+ // Sidebar expects CTX used to match Input + Output tokens.
269
+ // Input row = input + cacheRead
270
+ // Output row = output + reasoning
271
+ contextUsed =
272
+ telemetry.input +
273
+ telemetry.cacheRead +
274
+ telemetry.output +
275
+ telemetry.reasoning;
276
+ contextPct = contextLimit > 0 ? (contextUsed / contextLimit) * 100 : 0;
277
+ }
278
+ }
279
+
280
+ // Fallback: if message didn't provide context limit, look up from cache
281
+ if (contextLimit === 0 && contextUsed > 0) {
282
+ const model = mergedSessionModels(readTuiSnapshot())[sessionID];
283
+ const cachedLimit = model
284
+ ? _modelContextLimitCache.get(model)
285
+ : undefined;
286
+ if (cachedLimit && cachedLimit > 0) {
287
+ contextLimit = cachedLimit;
288
+ contextPct = (contextUsed / contextLimit) * 100;
289
+ }
290
+ }
291
+
292
+ if (contextUsed > 0 || totalInput > 0 || totalOutput > 0) {
293
+ return {
294
+ sessionID,
295
+ contextUsed,
296
+ contextLimit,
297
+ contextPct,
298
+ input: totalInput,
299
+ output: totalOutput,
300
+ reasoning: totalReasoning,
301
+ cacheRead: totalCacheRead,
302
+ cacheWrite: totalCacheWrite,
303
+ };
304
+ }
305
+ return null;
306
+ } catch {
307
+ return null;
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Probe jsdom at init time so the first webfetch call doesn't fail
313
+ * silently. Logs a warning if jsdom can't be imported or instantiated,
314
+ * but does not throw; the plugin works without webfetch.
315
+ */
316
+ async function probeJSDOM(): Promise<string | null> {
317
+ try {
318
+ const { JSDOM } = await import('jsdom');
319
+ new JSDOM('<!DOCTYPE html><html><body>test</body></html>');
320
+ return null;
321
+ } catch (err) {
322
+ return String(err);
323
+ }
324
+ }
325
+
326
+ // Module-level runtime preset tracking. Survives plugin re-inits triggered
327
+ // by client.config.update() → Instance.dispose(). When the plugin function
328
+ // re-runs, it checks this variable and applies the runtime preset instead
329
+ // of the config file's preset. State lives in config/runtime-preset.ts.
330
+
331
+ // Guards to ensure startup logs fire only once across plugin re-inits
332
+ let didLogVerboseInit = false;
333
+ let didLogStartupSummary = false;
334
+
335
+ const OhMyOpenCodeLite: Plugin = async (ctx) => {
336
+ const sessionId = new Date().toISOString().replace(/[-:]/g, '').slice(0, 15);
337
+ initLogger(sessionId);
338
+
339
+ // Declare variables that must survive the try/catch for the return
340
+ // closure. These are set inside the try block.
341
+ let config: ReturnType<typeof loadPluginConfig>;
342
+ let agentDefs: Awaited<ReturnType<typeof createAgents>>;
343
+ let agents: Awaited<ReturnType<typeof getAgentConfigs>>;
344
+ let builtinMcps: ReturnType<typeof createBuiltinMcps>;
345
+ let modelArrayMap: Record<string, Array<{ id: string; variant?: string }>>;
346
+ let runtimeChains: Record<string, string[]>;
347
+ let depthTracker: SubagentDepthTracker;
348
+ let autoUpdateChecker: ReturnType<typeof createAutoUpdateCheckerHook>;
349
+ let phaseReminderHook: ReturnType<typeof createPhaseReminderHook>;
350
+ let filterAvailableSkillsHook: ReturnType<
351
+ typeof createFilterAvailableSkillsHook
352
+ >;
353
+ let sessionAgentMap: Map<string, string>;
354
+ let deletingSessions: Set<string>;
355
+ let reconcileSessions!: () => Promise<void>;
356
+ let postFileToolNudgeHook: ReturnType<typeof createPostFileToolNudgeHook>;
357
+ let chatHeadersHook: ReturnType<typeof createChatHeadersHook>;
358
+ let delegateTaskRetryHook: ReturnType<typeof createDelegateTaskRetryHook>;
359
+ let applyPatchHook: ReturnType<typeof createApplyPatchHook>;
360
+ let jsonErrorRecoveryHook: ReturnType<typeof createJsonErrorRecoveryHook>;
361
+ let foregroundFallback: ForegroundFallbackManager;
362
+ let todoContinuationHook: ReturnType<typeof createTodoContinuationHook>;
363
+ let taskSessionManagerHook: ReturnType<typeof createTaskSessionManagerHook>;
364
+ let contextPressureReminderHook: ReturnType<
365
+ typeof createContextPressureReminderHook
366
+ >;
367
+ let presetManager: ReturnType<typeof createPresetManager>;
368
+ let usageService: UsageService | null;
369
+ let webfetch: ReturnType<typeof createWebfetchTool>;
370
+ let delegateTools: Record<string, unknown>;
371
+ let discoverMcpTool:
372
+ | ReturnType<typeof createDiscoverMcpServersTool>
373
+ | undefined;
374
+ let discoverSkillTool:
375
+ | ReturnType<typeof createDiscoverSkillsTool>
376
+ | undefined;
377
+ let rewriteDisplayNameMentions: ReturnType<
378
+ typeof createDisplayNameMentionRewriter
379
+ >;
380
+
381
+ // Counters for post-init health check (set inside try, checked outside)
382
+ let toolCount = 0;
383
+
384
+ try {
385
+ const isFirstInit = !didLogVerboseInit;
386
+
387
+ if (isFirstInit) console.log('\u{2699}\u{FE0F} Initializing opencode-dux...');
388
+
389
+ config = loadPluginConfig(ctx.directory);
390
+
391
+ // Safety net: if a runtime preset was set via /preset command and
392
+ // OpenCode ever fully re-runs the plugin function (not just the
393
+ // config() hook), override config.preset so agents are created with
394
+ // the correct models. Currently only the config() hook re-runs after
395
+ // Instance.dispose(), so this is a defensive guard.
396
+ const runtimePreset = getActiveRuntimePreset();
397
+ if (runtimePreset && config.presets?.[runtimePreset]) {
398
+ config.preset = runtimePreset;
399
+ // Re-merge runtime preset into config.agents (loadPluginConfig
400
+ // already merged the config-file preset, not the runtime one).
401
+ // Runtime preset is override so it wins over config-file preset.
402
+ const presetAgents = config.presets[runtimePreset];
403
+ config.agents = deepMerge(config.agents, presetAgents);
404
+ } else if (runtimePreset) {
405
+ // Preset was deleted from config since last switch - clear stale state
406
+ setActiveRuntimePreset(null);
407
+ }
408
+
409
+ // Validate all agents have models configured
410
+ for (const agentName of ALL_AGENT_NAMES) {
411
+ const override = config?.agents?.[agentName]?.model;
412
+ const defaultModel =
413
+ DEFAULT_MODELS[agentName as keyof typeof DEFAULT_MODELS];
414
+ const effectiveModel = override ?? defaultModel;
415
+ if (!effectiveModel) {
416
+ ctx.client.tui
417
+ .showToast({
418
+ body: {
419
+ title: `Agent "${agentName}" has no model configured`,
420
+ message:
421
+ `Set "agents.${agentName}.model" in your config or ` +
422
+ 'a default will be used.',
423
+ variant: 'info',
424
+ duration: 5000,
425
+ },
426
+ })
427
+ .catch(() => {});
428
+ }
429
+ }
430
+
431
+ if (isFirstInit) console.log(' \u{1F4C1} plugin config: loaded');
432
+
433
+ rewriteDisplayNameMentions = createDisplayNameMentionRewriter(config);
434
+ agentDefs = await createAgents(config);
435
+ agents = await getAgentConfigs(config);
436
+
437
+ if (isFirstInit) console.log(` \u{1F916} agents: ${Object.keys(agents).join(', ')}`);
438
+
439
+ // Build a map of agent name → priority model array for runtime
440
+ // fallback. Populated when the user configures model as an array in
441
+ // their plugin config.
442
+ modelArrayMap = {} as Record<
443
+ string,
444
+ Array<{ id: string; variant?: string }>
445
+ >;
446
+ for (const agentDef of agentDefs) {
447
+ if (agentDef._modelArray && agentDef._modelArray.length > 0) {
448
+ modelArrayMap[agentDef.name] = agentDef._modelArray;
449
+ }
450
+ }
451
+ // Build runtime fallback chains for all foreground agents. Each chain
452
+ // is an ordered list of model strings to try when the current model is
453
+ // rate-limited. Seeds from _modelArray entries (when the user
454
+ // configures model as an array), then appends fallback.chains entries.
455
+ runtimeChains = {} as Record<string, string[]>;
456
+ for (const agentDef of agentDefs) {
457
+ if (agentDef._modelArray?.length) {
458
+ runtimeChains[agentDef.name] = agentDef._modelArray.map((m) => m.id);
459
+ }
460
+ }
461
+ if (config.fallback?.enabled !== false) {
462
+ const chains =
463
+ (config.fallback?.chains as Record<string, string[] | undefined>) ?? {};
464
+ for (const [agentName, chainModels] of Object.entries(chains)) {
465
+ if (!chainModels?.length) continue;
466
+ const existing = runtimeChains[agentName] ?? [];
467
+ const seen = new Set(existing);
468
+ for (const m of chainModels) {
469
+ if (!seen.has(m)) {
470
+ seen.add(m);
471
+ existing.push(m);
472
+ }
473
+ }
474
+ runtimeChains[agentName] = existing;
475
+ }
476
+ }
477
+
478
+ depthTracker = new SubagentDepthTracker();
479
+
480
+ // Initialize delegate tools for orchestrator variant-based subagent spawning
481
+ delegateTools = createDelegateTools(
482
+ ctx,
483
+ config,
484
+ depthTracker,
485
+ );
486
+
487
+ builtinMcps = createBuiltinMcps(undefined, config.websearch);
488
+
489
+ if (isFirstInit) console.log(` \u{1F50C} MCPs: ${Object.keys(builtinMcps).join(', ')}`);
490
+
491
+ // Warm the local discovery cache asynchronously (non-blocking init).
492
+ // Subsequent hooks/tools will read from cache on first use.
493
+ getLocalDiscovery(ctx).catch(() => {});
494
+
495
+ webfetch = createWebfetchTool(ctx);
496
+
497
+ // Initialize online discovery tools (graceful degradation on failure)
498
+ const toolsOnline: string[] = [];
499
+ try {
500
+ discoverMcpTool = createDiscoverMcpServersTool(ctx);
501
+ toolsOnline.push('discover_mcp_servers');
502
+ } catch (err) {
503
+ log('[plugin] failed to create discover_mcp_servers tool', String(err));
504
+ discoverMcpTool = undefined;
505
+ }
506
+ try {
507
+ discoverSkillTool = createDiscoverSkillsTool(ctx);
508
+ toolsOnline.push('discover_skills_online');
509
+ } catch (err) {
510
+ log('[plugin] failed to create discover_skills_online tool', String(err));
511
+ discoverSkillTool = undefined;
512
+ }
513
+ if (isFirstInit) console.log(` \u{1F527} tools: webfetch, ast_grep_search, ast_grep_replace${toolsOnline.length ? `, ${toolsOnline.join(', ')}` : ''}`);
514
+
515
+ // Initialize auto-update checker hook
516
+ autoUpdateChecker = createAutoUpdateCheckerHook(ctx, {
517
+ autoUpdate: config.autoUpdate ?? true,
518
+ });
519
+
520
+ // Initialize phase reminder hook for workflow compliance
521
+ phaseReminderHook = createPhaseReminderHook();
522
+
523
+ // Initialize available skills filter hook
524
+ filterAvailableSkillsHook = createFilterAvailableSkillsHook(ctx, config);
525
+
526
+ // Track session → agent mapping for serve-mode system prompt injection
527
+ sessionAgentMap = new Map<string, string>();
528
+ deletingSessions = new Set<string>();
529
+
530
+ // Sync tui-state with OpenCode's session snapshot. Prefer running after the
531
+ // user submits (`experimental.chat.messages.transform`) rather than plugin
532
+ // init so session.status tends to enumerate active sessions reliably.
533
+ reconcileSessions = async (): Promise<void> => {
534
+ try {
535
+ const result = await ctx.client.session.status({});
536
+ const statuses = result.data as
537
+ | Record<string, { type: string }>
538
+ | undefined;
539
+ if (!statuses) return;
540
+ // An empty status map cannot distinguish "nothing running" from
541
+ // transient/incomplete enumeration. Treating {} as authoritative
542
+ // cleared every snapshot bundle for this cwd and tore down the
543
+ // entire sessionTreeStore (see instanceSeeds below).
544
+ if (Object.keys(statuses).length === 0) return;
545
+
546
+ const opencodeIds = new Set(Object.keys(statuses));
547
+ const currentProjectDir = normalizeProjectDirectory(ctx.directory);
548
+
549
+ // Sync polled OpenCode statuses into tree nodes. Bundles are removed
550
+ // when every session id in the tree is absent from OpenCode (same
551
+ // project only), by 7d idle TTL, or soft-pruned for partial gaps.
552
+ updateSnapshot((s) => {
553
+ syncOpenCodeStatusesIntoSessionTree(
554
+ s,
555
+ statuses as Record<string, { type: string }>,
556
+ );
557
+ pruneStaleTuiSessionBundles(s, {
558
+ opencodeIds,
559
+ currentProjectDir,
560
+ now: Date.now(),
561
+ });
562
+ });
563
+
564
+ const snap = readTuiSnapshot();
565
+ const mergedForMemory = {
566
+ ...mergedSessionTree(snap),
567
+ ...sessionTreeStore,
568
+ };
569
+
570
+ const instanceSeeds = Object.keys(sessionTreeStore).filter(
571
+ (sid) => !opencodeIds.has(sid),
572
+ );
573
+ const instanceExpanded = expandMissingSessionCascade(
574
+ mergedForMemory,
575
+ instanceSeeds,
576
+ );
577
+
578
+ for (const sid of instanceExpanded) {
579
+ sessionAgentMap.delete(sid);
580
+ delete sessionTreeStore[sid];
581
+ if (depthTracker) depthTracker.cleanup(sid);
582
+ deletingSessions.delete(sid);
583
+ }
584
+
585
+ const activeIds = Object.keys(statuses);
586
+ const usageResults = await Promise.allSettled(
587
+ activeIds.map((sid) => computeSessionUsageForReconcile(ctx, sid)),
588
+ );
589
+ const usageBatch: RecordSessionUsageInput[] = [];
590
+ for (const r of usageResults) {
591
+ if (r.status === 'fulfilled' && r.value) {
592
+ usageBatch.push(r.value);
593
+ }
594
+ }
595
+ recordSessionUsagesBatch(usageBatch);
596
+
597
+ usageService?.refresh(false).catch(() => {});
598
+ } catch {
599
+ // best-effort - silent
600
+ }
601
+ };
602
+
603
+ // Initialize post-file-tool nudge hook
604
+ postFileToolNudgeHook = createPostFileToolNudgeHook({
605
+ shouldInject: (sessionID) =>
606
+ sessionAgentMap.get(sessionID) === 'orchestrator',
607
+ });
608
+
609
+ chatHeadersHook = createChatHeadersHook(ctx);
610
+
611
+ // Initialize delegate-task retry guidance hook
612
+ delegateTaskRetryHook = createDelegateTaskRetryHook(ctx);
613
+
614
+ applyPatchHook = createApplyPatchHook(ctx);
615
+ // Initialize JSON parse error recovery hook
616
+ jsonErrorRecoveryHook = createJsonErrorRecoveryHook(ctx);
617
+
618
+ // Initialize foreground fallback manager for runtime model switching
619
+ foregroundFallback = new ForegroundFallbackManager(
620
+ ctx.client,
621
+ runtimeChains,
622
+ config.fallback?.enabled !== false &&
623
+ Object.keys(runtimeChains).length > 0,
624
+ );
625
+
626
+ // Initialize todo-continuation hook (opt-in auto-continue for
627
+ // incomplete todos)
628
+ todoContinuationHook = createTodoContinuationHook(ctx, {
629
+ maxContinuations: config.todoContinuation?.maxContinuations ?? 5,
630
+ cooldownMs: config.todoContinuation?.cooldownMs ?? 3000,
631
+ autoEnable: config.todoContinuation?.autoEnable ?? false,
632
+ autoEnableThreshold: config.todoContinuation?.autoEnableThreshold ?? 4,
633
+ });
634
+ taskSessionManagerHook = createTaskSessionManagerHook(ctx, {
635
+ maxSessionsPerAgent: config.sessionManager?.maxSessionsPerAgent ?? 2,
636
+ readContextMinLines: config.sessionManager?.readContextMinLines ?? 10,
637
+ readContextMaxFiles: config.sessionManager?.readContextMaxFiles ?? 8,
638
+ shouldManageSession: (sessionID) =>
639
+ sessionAgentMap.get(sessionID) === 'orchestrator',
640
+ });
641
+ contextPressureReminderHook = createContextPressureReminderHook({
642
+ enabled: config.contextPressure?.enabled ?? true,
643
+ warnThresholdPct: config.contextPressure?.warnThresholdPct ?? 75,
644
+ });
645
+ presetManager = createPresetManager(ctx, config);
646
+ usageService = createUsageService(ctx.client);
647
+ usageService.syncActiveAccounts();
648
+
649
+ if (isFirstInit) console.log(' \u{1F517} hooks: auto-update, phase-reminder, skills-filter, apply-patch, json-recovery, fallback, todo-continuation, session-manager, pressure-reminder');
650
+
651
+ toolCount =
652
+ Object.keys(delegateTools).length +
653
+ Object.keys(todoContinuationHook.tool).length +
654
+ 1 + // webfetch
655
+ 2 + // ast_grep_search, ast_grep_replace
656
+ (discoverMcpTool ? 1 : 0) + // discover_mcp_servers
657
+ (discoverSkillTool ? 1 : 0); // discover_skills_online
658
+
659
+ if (isFirstInit) {
660
+ console.log(`\u{2705} opencode-dux initialized (${Object.keys(agents).length} agents, ${toolCount} tools, ${Object.keys(builtinMcps).length} MCPs)`);
661
+ didLogVerboseInit = true;
662
+ }
663
+ } catch (err) {
664
+ // Plugin init failed: log visibly before re-throwing so the user
665
+ // sees something actionable instead of a silent "loaded but empty".
666
+ log('[plugin] FATAL: init failed', String(err));
667
+ await appLog(
668
+ ctx,
669
+ 'error',
670
+ `INIT FAILED: ${String(err)}. Report at github.com/bakhtiar-personal-work/opencode-dux/issues`,
671
+ );
672
+ throw err;
673
+ }
674
+
675
+ // ── Health check: validate registrations ────────────────────────────
676
+ const agentCount = Object.keys(agents).length;
677
+ const mcpCount = Object.keys(builtinMcps).length;
678
+ const mcpThreshold = HEALTH_CHECK.minMcps;
679
+
680
+ if (
681
+ agentCount < HEALTH_CHECK.minAgents ||
682
+ toolCount < HEALTH_CHECK.minTools ||
683
+ mcpCount < mcpThreshold
684
+ ) {
685
+ const msg = [
686
+ 'Health check: registrations suspiciously low.',
687
+ ` agents: ${agentCount} (expected >=${HEALTH_CHECK.minAgents})`,
688
+ ` tools: ${toolCount} (expected >=${HEALTH_CHECK.minTools})`,
689
+ ` mcps: ${mcpCount} (expected >=${mcpThreshold})`,
690
+ 'This usually means a dependency failed to resolve (jsdom, etc).',
691
+ ].join('\n');
692
+ log(`[plugin] WARN: ${msg}`);
693
+ await appLog(ctx, 'warn', msg);
694
+ } else {
695
+ log('[plugin] health check passed', {
696
+ agents: agentCount,
697
+ tools: toolCount,
698
+ mcps: mcpCount,
699
+ });
700
+ }
701
+
702
+ // ── Probe jsdom (async, non-blocking) ───────────────────────────────
703
+ // Don't await this; we don't want to block init. The warning will
704
+ // appear shortly after startup if jsdom is broken.
705
+ probeJSDOM().then((err) => {
706
+ if (err) {
707
+ const msg = `jsdom probe failed; webfetch tool will not work: ${err}`;
708
+ log(`[plugin] WARN: ${msg}`);
709
+ appLog(ctx, 'warn', msg).catch(() => {});
710
+ }
711
+ });
712
+
713
+ return {
714
+ name: 'opencode-dux',
715
+
716
+ agent: agents,
717
+
718
+ tool: {
719
+ ...delegateTools,
720
+ webfetch,
721
+ ...todoContinuationHook.tool,
722
+ ast_grep_search,
723
+ ast_grep_replace,
724
+ ...(discoverMcpTool ? { discover_mcp_servers: discoverMcpTool } : {}),
725
+ ...(discoverSkillTool
726
+ ? { discover_skills_online: discoverSkillTool }
727
+ : {}),
728
+ },
729
+
730
+ mcp: builtinMcps,
731
+
732
+ config: async (opencodeConfig: Record<string, unknown>) => {
733
+ // Only set default_agent if not already configured by the user
734
+ // and the plugin config doesn't explicitly disable this behavior
735
+ if (
736
+ config.setDefaultAgent !== false &&
737
+ !(opencodeConfig as { default_agent?: string }).default_agent
738
+ ) {
739
+ (opencodeConfig as { default_agent?: string }).default_agent =
740
+ 'orchestrator';
741
+ }
742
+
743
+ // Merge Agent configs - per-agent shallow merge to preserve
744
+ // user-supplied fields (e.g. tools, permission) from opencode.json
745
+ if (!opencodeConfig.agent) {
746
+ opencodeConfig.agent = { ...agents };
747
+ } else {
748
+ for (const [name, pluginAgent] of Object.entries(agents)) {
749
+ const existing = (opencodeConfig.agent as Record<string, unknown>)[
750
+ name
751
+ ] as Record<string, unknown> | undefined;
752
+ if (existing) {
753
+ // Shallow merge: plugin defaults first, user overrides win
754
+ (opencodeConfig.agent as Record<string, unknown>)[name] = {
755
+ ...pluginAgent,
756
+ ...existing,
757
+ };
758
+ } else {
759
+ (opencodeConfig.agent as Record<string, unknown>)[name] = {
760
+ ...pluginAgent,
761
+ };
762
+ }
763
+ }
764
+ }
765
+ const configAgent = opencodeConfig.agent as Record<string, unknown>;
766
+
767
+ // Model resolution for foreground agents: combine _modelArray
768
+ // entries with fallback.chains config, then pick the first model in
769
+ // the effective array for startup-time selection.
770
+ //
771
+ // Runtime failover on API errors (e.g. rate limits
772
+ // mid-conversation) is handled separately by
773
+ // ForegroundFallbackManager via the event hook.
774
+ const fallbackChainsEnabled = config.fallback?.enabled !== false;
775
+ const fallbackChains = fallbackChainsEnabled
776
+ ? ((config.fallback?.chains as Record<string, string[] | undefined>) ??
777
+ {})
778
+ : {};
779
+
780
+ // Build effective model arrays: seed from _modelArray, then append
781
+ // fallback.chains entries so the resolver considers the full chain
782
+ // when picking the best available provider at startup.
783
+ const effectiveArrays: Record<
784
+ string,
785
+ Array<{ id: string; variant?: string }>
786
+ > = {};
787
+
788
+ for (const [agentName, models] of Object.entries(modelArrayMap)) {
789
+ effectiveArrays[agentName] = [...models];
790
+ }
791
+
792
+ for (const [agentName, chainModels] of Object.entries(fallbackChains)) {
793
+ if (!chainModels || chainModels.length === 0) continue;
794
+
795
+ if (!effectiveArrays[agentName]) {
796
+ // Agent has no _modelArray - seed from its current string model
797
+ // so the fallback chain appends after it rather than replacing
798
+ // it.
799
+ const entry = configAgent[agentName] as
800
+ | Record<string, unknown>
801
+ | undefined;
802
+ const currentModel =
803
+ typeof entry?.model === 'string' ? entry.model : undefined;
804
+ effectiveArrays[agentName] = currentModel
805
+ ? [{ id: currentModel }]
806
+ : [];
807
+ }
808
+
809
+ const seen = new Set(effectiveArrays[agentName].map((m) => m.id));
810
+ for (const chainModel of chainModels) {
811
+ if (!seen.has(chainModel)) {
812
+ seen.add(chainModel);
813
+ effectiveArrays[agentName].push({ id: chainModel });
814
+ }
815
+ }
816
+ }
817
+
818
+ if (Object.keys(effectiveArrays).length > 0) {
819
+ for (const [agentName, modelArray] of Object.entries(effectiveArrays)) {
820
+ if (modelArray.length === 0) continue;
821
+
822
+ // Use the first model in the effective array. Not all providers
823
+ // require entries in opencodeConfig.provider - some are loaded
824
+ // automatically by opencode (e.g. github-copilot, openrouter).
825
+ // We cannot distinguish these from truly unconfigured providers
826
+ // at config-hook time, so we cannot gate on the provider config
827
+ // keys. Runtime failover is handled separately by
828
+ // ForegroundFallbackManager.
829
+ const chosen = modelArray[0];
830
+ const entry = configAgent[agentName] as
831
+ | Record<string, unknown>
832
+ | undefined;
833
+ if (entry) {
834
+ entry.model = chosen.id;
835
+ if (chosen.variant) {
836
+ entry.variant = chosen.variant;
837
+ }
838
+ } else {
839
+ // Agent exists in slim but not in opencodeConfig.agent -
840
+ // create entry
841
+ (configAgent as Record<string, unknown>)[agentName] = {
842
+ model: chosen.id,
843
+ ...(chosen.variant ? { variant: chosen.variant } : {}),
844
+ };
845
+ }
846
+ log('[plugin] resolved model from array', {
847
+ agent: agentName,
848
+ model: chosen.id,
849
+ variant: chosen.variant,
850
+ });
851
+ }
852
+ }
853
+
854
+ // Runtime preset override: if /preset switched to a runtime preset,
855
+ // override the model/variant/temperature from the preset's agent
856
+ // config. This runs after the normal model resolution because the
857
+ // config() hook re-runs with stale modelArrayMap after dispose(),
858
+ // but the runtime preset data is in the captured `config` closure.
859
+ const runtimePresetName = getActiveRuntimePreset();
860
+ if (runtimePresetName && config.presets?.[runtimePresetName]) {
861
+ const runtimePreset = config.presets[runtimePresetName];
862
+ for (const [agentName, override] of Object.entries(runtimePreset)) {
863
+ // Resolve legacy alias keys (e.g. "explore" → "explorer")
864
+ // so presets using aliases work in this path.
865
+ const resolvedName = AGENT_ALIASES[agentName] ?? agentName;
866
+ const entry = configAgent[resolvedName] as
867
+ | Record<string, unknown>
868
+ | undefined;
869
+ if (!entry) continue;
870
+
871
+ if (typeof override.model === 'string') {
872
+ entry.model = override.model;
873
+ } else if (
874
+ Array.isArray(override.model) &&
875
+ override.model.length > 0
876
+ ) {
877
+ const first = override.model[0];
878
+ entry.model = typeof first === 'string' ? first : first.id;
879
+ // Extract inline variant from array-form model entry
880
+ if (typeof first !== 'string' && first.variant) {
881
+ entry.variant = first.variant;
882
+ }
883
+ }
884
+ // Explicitly set or clear scalar fields so switching from
885
+ // Preset A (which sets a field) to Preset B (which doesn't)
886
+ // doesn't leave stale values behind.
887
+ if (typeof override.variant === 'string') {
888
+ entry.variant = override.variant;
889
+ } else if ('variant' in override) {
890
+ delete entry.variant;
891
+ }
892
+ if (typeof override.temperature === 'number') {
893
+ entry.temperature = override.temperature;
894
+ } else if ('temperature' in override) {
895
+ delete entry.temperature;
896
+ }
897
+ if (
898
+ override.options &&
899
+ typeof override.options === 'object' &&
900
+ !Array.isArray(override.options)
901
+ ) {
902
+ entry.options = override.options;
903
+ } else if ('options' in override) {
904
+ delete entry.options;
905
+ }
906
+ log('[plugin] runtime preset override', {
907
+ preset: runtimePresetName,
908
+ agent: agentName,
909
+ model: entry.model as string,
910
+ });
911
+ }
912
+
913
+ // Reset agents from the previous preset that aren't in the new one.
914
+ // The stale model resolution above overwrites the reset values sent
915
+ // by preset-manager, so we re-apply them here from config-file
916
+ // baseline.
917
+ const prevPresetName = getPreviousRuntimePreset();
918
+ if (prevPresetName && config.presets?.[prevPresetName]) {
919
+ const prevPreset = config.presets[prevPresetName];
920
+ // Build resolved key set from new preset for correct comparison
921
+ // (handles alias keys like "explore" → "explorer")
922
+ const newPresetResolved = new Set(
923
+ Object.keys(runtimePreset).map((k) => AGENT_ALIASES[k] ?? k),
924
+ );
925
+ for (const agentName of Object.keys(prevPreset)) {
926
+ const resolvedName = AGENT_ALIASES[agentName] ?? agentName;
927
+ if (newPresetResolved.has(resolvedName)) continue; // new preset handles it
928
+ const entry = configAgent[resolvedName] as
929
+ | Record<string, unknown>
930
+ | undefined;
931
+ if (!entry) continue;
932
+ // Reset to config-file baseline. Use the previous preset's
933
+ // override to identify which fields to clear even when the
934
+ // baseline doesn't define them.
935
+ const baseline = config.agents?.[resolvedName];
936
+ const prevOverride = prevPreset[agentName] as
937
+ | AgentOverrideConfig
938
+ | undefined;
939
+ if (typeof baseline?.model === 'string') {
940
+ entry.model = baseline.model;
941
+ }
942
+ if (typeof baseline?.variant === 'string') {
943
+ entry.variant = baseline.variant;
944
+ } else if (prevOverride && 'variant' in prevOverride) {
945
+ delete entry.variant;
946
+ }
947
+ if (typeof baseline?.temperature === 'number') {
948
+ entry.temperature = baseline.temperature;
949
+ } else if (prevOverride && 'temperature' in prevOverride) {
950
+ delete entry.temperature;
951
+ }
952
+ if (
953
+ baseline?.options &&
954
+ typeof baseline.options === 'object' &&
955
+ !Array.isArray(baseline.options)
956
+ ) {
957
+ entry.options = baseline.options;
958
+ } else if (prevOverride && 'options' in prevOverride) {
959
+ delete entry.options;
960
+ }
961
+ log('[plugin] runtime preset reset from previous', {
962
+ previousPreset: prevPresetName,
963
+ agent: resolvedName,
964
+ model: entry.model as string,
965
+ });
966
+ }
967
+ }
968
+ }
969
+
970
+ // Merge MCP configs
971
+ const configMcp = opencodeConfig.mcp as
972
+ | Record<string, unknown>
973
+ | undefined;
974
+ if (!configMcp) {
975
+ opencodeConfig.mcp = { ...builtinMcps };
976
+ } else {
977
+ Object.assign(configMcp, builtinMcps);
978
+ }
979
+
980
+ // Register /auto-continue command so OpenCode recognizes it.
981
+ // Actual handling is done by command.execute.before hook below
982
+ // (no LLM round-trip - injected directly into output.parts).
983
+ const configCommand = opencodeConfig.command as
984
+ | Record<string, unknown>
985
+ | undefined;
986
+ if (!configCommand?.['auto-continue']) {
987
+ if (!opencodeConfig.command) {
988
+ opencodeConfig.command = {};
989
+ }
990
+ (opencodeConfig.command as Record<string, unknown>)['auto-continue'] = {
991
+ template: 'Call the auto_continue tool with enabled=true',
992
+ description:
993
+ 'Enable auto-continuation - orchestrator keeps working through incomplete todos',
994
+ };
995
+ }
996
+
997
+ presetManager.registerCommand(opencodeConfig);
998
+ usageService?.registerCommand(opencodeConfig);
999
+
1000
+ // One-time startup summary: log bundled skills, installed skills, and MCPs
1001
+ if (!didLogStartupSummary) {
1002
+ didLogStartupSummary = true;
1003
+
1004
+ const bundledSkillsDir = join(ctx.directory, 'src', 'skills');
1005
+ if (existsSync(bundledSkillsDir)) {
1006
+ try {
1007
+ const skills = readdirSync(bundledSkillsDir, { withFileTypes: true })
1008
+ .filter((d) => d.isDirectory())
1009
+ .map((d) => d.name);
1010
+ if (skills.length > 0) {
1011
+ console.log(`\u{1F4E6} Bundled skills available: ${skills.join(', ')}`);
1012
+ }
1013
+ } catch {
1014
+ // Silently ignore scan failures
1015
+ }
1016
+ }
1017
+
1018
+ const installedSkills = await discoverSkills(ctx.directory);
1019
+ if (installedSkills.length > 0) {
1020
+ console.log(
1021
+ `\u{1F4A1} Auto-discovered ${installedSkills.length} skill(s): ${installedSkills.map((s) => s.name).join(', ')}`,
1022
+ );
1023
+ }
1024
+
1025
+ const mcpKeys = Object.keys(opencodeConfig.mcp as Record<string, unknown> ?? builtinMcps);
1026
+ if (mcpKeys.length > 0) {
1027
+ console.log(`\u{1F50C} MCP servers: ${mcpKeys.join(', ')}`);
1028
+ }
1029
+ }
1030
+ },
1031
+
1032
+ event: async (input) => {
1033
+ const event = input.event as {
1034
+ type: string;
1035
+ properties?: {
1036
+ info?: {
1037
+ id?: string;
1038
+ parentID?: string;
1039
+ title?: string;
1040
+ agent?: string;
1041
+ providerID?: string;
1042
+ modelID?: string;
1043
+ variant?: string;
1044
+ sessionID?: string;
1045
+ directory?: string;
1046
+ };
1047
+ sessionID?: string;
1048
+ error?: { name?: string };
1049
+ status?: { type: string };
1050
+ part?: {
1051
+ type?: string;
1052
+ sessionID?: string;
1053
+ tokens?: {
1054
+ input?: number;
1055
+ output?: number;
1056
+ reasoning?: number;
1057
+ cache?: { read?: number; write?: number };
1058
+ };
1059
+ };
1060
+ providerID?: string;
1061
+ modelID?: string;
1062
+ };
1063
+ };
1064
+
1065
+ // Handle streaming token updates from step-finish parts
1066
+ if (event.type === 'message.part.updated') {
1067
+ const part = event.properties?.part as
1068
+ | {
1069
+ type?: string;
1070
+ sessionID?: string;
1071
+ tokens?: {
1072
+ input?: number;
1073
+ output?: number;
1074
+ reasoning?: number;
1075
+ cache?: { read?: number; write?: number };
1076
+ };
1077
+ }
1078
+ | undefined;
1079
+
1080
+ if (part?.type === 'step-finish' && part?.sessionID && part?.tokens) {
1081
+ const input = part.tokens.input ?? 0;
1082
+ const output = part.tokens.output ?? 0;
1083
+ const reasoning = part.tokens.reasoning ?? 0;
1084
+ const cacheRead = part.tokens.cache?.read ?? 0;
1085
+ // Don't record cache tokens during streaming - they're cumulative
1086
+ // per message and will be correctly summed by the message.updated
1087
+ // handler
1088
+
1089
+ if (input > 0 || output > 0 || reasoning > 0 || cacheRead > 0) {
1090
+ // Calculate contextUsed from the same components as
1091
+ // the sidebar Input + Output rows.
1092
+ const streamContextUsed = input + cacheRead + output + reasoning;
1093
+
1094
+ // Look up contextLimit from cache using session's model
1095
+ let streamContextLimit = 0;
1096
+ const sessionModel = mergedSessionModels(readTuiSnapshot())[
1097
+ part.sessionID
1098
+ ];
1099
+ if (sessionModel) {
1100
+ streamContextLimit =
1101
+ _modelContextLimitCache.get(sessionModel) ?? 0;
1102
+ }
1103
+
1104
+ const streamContextPct =
1105
+ streamContextLimit > 0
1106
+ ? (streamContextUsed / streamContextLimit) * 100
1107
+ : 0;
1108
+
1109
+ recordSessionUsage({
1110
+ sessionID: part.sessionID,
1111
+ contextUsed: streamContextUsed,
1112
+ contextLimit: streamContextLimit,
1113
+ contextPct: streamContextPct,
1114
+ input,
1115
+ output,
1116
+ reasoning,
1117
+ cacheRead,
1118
+ cacheWrite: part.tokens.cache?.write ?? 0,
1119
+ });
1120
+ }
1121
+ }
1122
+ }
1123
+
1124
+ if (event.type === 'message.updated') {
1125
+ const info = event.properties?.info;
1126
+ const sessionIDForTitle =
1127
+ (info && typeof info.sessionID === 'string' && info.sessionID) ||
1128
+ (typeof event.properties?.sessionID === 'string'
1129
+ ? event.properties.sessionID
1130
+ : undefined);
1131
+ if (
1132
+ sessionIDForTitle &&
1133
+ info &&
1134
+ typeof info.title === 'string' &&
1135
+ info.title.trim().length > 0
1136
+ ) {
1137
+ recordSessionTitle({
1138
+ sessionID: sessionIDForTitle,
1139
+ title: info.title,
1140
+ });
1141
+ }
1142
+ if (info) {
1143
+ const sessionID = info.sessionID ?? event.properties?.sessionID;
1144
+ if (sessionID) {
1145
+ if (
1146
+ typeof info.providerID === 'string' &&
1147
+ typeof info.modelID === 'string'
1148
+ ) {
1149
+ recordSessionModel({
1150
+ sessionID,
1151
+ model: `${info.providerID}/${info.modelID}`,
1152
+ });
1153
+ }
1154
+ if (typeof info.variant === 'string' && info.variant.trim()) {
1155
+ recordSessionVariant({
1156
+ sessionID,
1157
+ variant: info.variant.trim(),
1158
+ });
1159
+ }
1160
+ }
1161
+ }
1162
+
1163
+ const sessionID = info?.sessionID ?? event.properties?.sessionID;
1164
+ if (sessionID) {
1165
+ try {
1166
+ // Fetch messages and extract tokens from last assistant message only
1167
+ const messagesResult = await ctx.client.session.messages({
1168
+ path: { id: sessionID },
1169
+ });
1170
+ const allMessages = Array.isArray(messagesResult.data)
1171
+ ? messagesResult.data
1172
+ : [];
1173
+ const assistantMsgs = allMessages.filter(
1174
+ (m) =>
1175
+ (m as { info?: { role?: string } }).info?.role === 'assistant',
1176
+ );
1177
+
1178
+ // Extract tokens from last assistant message only (SDK supplies cumulative values)
1179
+ let totalInput = 0;
1180
+ let totalOutput = 0;
1181
+ let totalReasoning = 0;
1182
+ let totalCacheRead = 0;
1183
+ let totalCacheWrite = 0;
1184
+ let contextLimit = 0;
1185
+ let contextUsed = 0;
1186
+ let contextPct = 0;
1187
+
1188
+ // Ensure context limit cache is populated before recording usage
1189
+ await ensureModelContextLimits(ctx.client).catch(() => {});
1190
+
1191
+ const lastTokenMsg = [...assistantMsgs]
1192
+ .reverse()
1193
+ .find((m) => readTokenTelemetry(m));
1194
+ if (lastTokenMsg) {
1195
+ const telemetry = readTokenTelemetry(lastTokenMsg);
1196
+ if (telemetry) {
1197
+ totalInput = telemetry.input;
1198
+ totalOutput = telemetry.output;
1199
+ totalReasoning = telemetry.reasoning;
1200
+ totalCacheRead = telemetry.cacheRead;
1201
+ totalCacheWrite = telemetry.cacheWrite;
1202
+ contextLimit = telemetry.contextLimit;
1203
+ // Sidebar expects CTX used to match Input + Output tokens.
1204
+ // Input row = input + cacheRead
1205
+ // Output row = output + reasoning
1206
+ contextUsed =
1207
+ telemetry.input +
1208
+ telemetry.cacheRead +
1209
+ telemetry.output +
1210
+ telemetry.reasoning;
1211
+ contextPct =
1212
+ contextLimit > 0 ? (contextUsed / contextLimit) * 100 : 0;
1213
+ }
1214
+ }
1215
+
1216
+ // Fallback: if message didn't provide context limit, look up from
1217
+ // cache using the model associated with this session.
1218
+ if (contextLimit === 0) {
1219
+ const model = mergedSessionModels(readTuiSnapshot())[sessionID];
1220
+ const cachedLimit = model
1221
+ ? _modelContextLimitCache.get(model)
1222
+ : undefined;
1223
+ if (cachedLimit && cachedLimit > 0) {
1224
+ contextLimit = cachedLimit;
1225
+ contextPct = (contextUsed / contextLimit) * 100;
1226
+ }
1227
+ }
1228
+
1229
+ if (contextUsed > 0 || totalInput > 0 || totalOutput > 0) {
1230
+ recordSessionUsage({
1231
+ sessionID,
1232
+ contextUsed,
1233
+ contextLimit,
1234
+ contextPct,
1235
+ input: totalInput,
1236
+ output: totalOutput,
1237
+ reasoning: totalReasoning,
1238
+ cacheRead: totalCacheRead,
1239
+ cacheWrite: totalCacheWrite,
1240
+ });
1241
+ }
1242
+ } catch {
1243
+ // Usage telemetry is best-effort for sidebar display.
1244
+ }
1245
+ }
1246
+ }
1247
+
1248
+ if (event.type === 'session.created') {
1249
+ const childSessionId = event.properties?.info?.id;
1250
+ const parentSessionId = event.properties?.info?.parentID;
1251
+ const title = event.properties?.info?.title;
1252
+ const directory = event.properties?.info?.directory ?? ctx.directory;
1253
+ if (depthTracker && childSessionId && parentSessionId) {
1254
+ depthTracker.registerChild(parentSessionId, childSessionId);
1255
+ }
1256
+ if (childSessionId) {
1257
+ recordChildSessionSnapshot({
1258
+ sessionID: childSessionId,
1259
+ title: title ?? '',
1260
+ parentSessionId:
1261
+ typeof parentSessionId === 'string' ? parentSessionId : undefined,
1262
+ projectPath: directory ? directory : undefined,
1263
+ });
1264
+ }
1265
+ }
1266
+
1267
+ if (event.type === 'session.updated') {
1268
+ const info = event.properties?.info;
1269
+ const sid =
1270
+ (typeof info?.id === 'string' && info.id) ||
1271
+ (typeof info?.sessionID === 'string' && info.sessionID) ||
1272
+ (typeof event.properties?.sessionID === 'string'
1273
+ ? event.properties.sessionID
1274
+ : undefined);
1275
+ if (
1276
+ sid &&
1277
+ info &&
1278
+ typeof info.title === 'string' &&
1279
+ info.title.trim().length > 0
1280
+ ) {
1281
+ recordSessionTitle({ sessionID: sid, title: info.title });
1282
+ }
1283
+ if (
1284
+ sid &&
1285
+ info &&
1286
+ typeof info.variant === 'string' &&
1287
+ info.variant.trim().length > 0
1288
+ ) {
1289
+ recordSessionVariant({
1290
+ sessionID: sid,
1291
+ variant: info.variant.trim(),
1292
+ });
1293
+ }
1294
+ }
1295
+
1296
+ // Runtime model fallback for foreground agents (rate-limit detection)
1297
+ await foregroundFallback.handleEvent(input.event);
1298
+
1299
+ // Todo-continuation: auto-continue orchestrator on incomplete todos
1300
+ await todoContinuationHook.handleEvent(input);
1301
+
1302
+ // Handle auto-update checking
1303
+ await autoUpdateChecker.event(input);
1304
+
1305
+ // Track session.status to update sidebar status display and
1306
+ // active session counts. Non-orchestrator idle means done.
1307
+ if (event.type === 'session.status') {
1308
+ const statusType = event.properties?.status?.type;
1309
+ const sessionID = event.properties?.sessionID;
1310
+ if (sessionID && statusType) {
1311
+ patchSessionTreeStatusFromOpenCode(sessionID, statusType);
1312
+ }
1313
+ if (sessionID && statusType === 'idle') {
1314
+ if (sessionAgentMap.get(sessionID) === 'orchestrator') {
1315
+ // Cascade abort: stop any still-running blocking children
1316
+ const snapshot = readTuiSnapshot();
1317
+ for (const [childId, child] of Object.entries(
1318
+ mergedSessionTree(snapshot),
1319
+ )) {
1320
+ if (
1321
+ child.parentId === sessionID &&
1322
+ child.status === 'busy' &&
1323
+ child.mode !== 'fire_forget'
1324
+ ) {
1325
+ ctx.client.session
1326
+ .abort({ path: { id: childId } })
1327
+ .catch(() => {});
1328
+ }
1329
+ }
1330
+ recordSessionNode({
1331
+ sessionID,
1332
+ agent: 'orchestrator',
1333
+ status: 'idle',
1334
+ });
1335
+ // Trigger OpenCode Go usage data refresh
1336
+ usageService?.onOrchestratorIdle();
1337
+ // Set finishedAt with a 3-second buffer so the orchestrator's
1338
+ // flash timer starts AFTER children have cleared from the tree.
1339
+ // Children were just marked idle (recordSessionDone) and need
1340
+ // FLASH_DURATION_MS+1s to flash out. The orchestrator shows a
1341
+ // spinner while children are visible, then flashes after they clear.
1342
+ updateSnapshot((s) => {
1343
+ for (const bundle of Object.values(s.sessions)) {
1344
+ const node = bundle.tree[sessionID];
1345
+ if (node) {
1346
+ node.finishedAt = Date.now() + 3000;
1347
+ bundle.lastActivityAt = Date.now();
1348
+ }
1349
+ }
1350
+ });
1351
+ const storeNode = sessionTreeStore[sessionID];
1352
+ if (storeNode) storeNode.finishedAt = Date.now() + 3000;
1353
+ } else {
1354
+ recordSessionEnd(sessionID);
1355
+ recordSessionDone(sessionID);
1356
+ }
1357
+ }
1358
+ }
1359
+
1360
+ await taskSessionManagerHook.event(
1361
+ input as {
1362
+ event: {
1363
+ type: string;
1364
+ properties?: { info?: { id?: string }; sessionID?: string };
1365
+ };
1366
+ },
1367
+ );
1368
+
1369
+ if (event.type === 'session.deleted') {
1370
+ const sessionID =
1371
+ event.properties?.info?.id ?? event.properties?.sessionID;
1372
+ if (sessionID) {
1373
+ recordSessionEnd(sessionID);
1374
+ recordSessionDone(sessionID);
1375
+ deleteSessionEntries(sessionID);
1376
+ if (depthTracker) depthTracker.cleanup(sessionID);
1377
+ deletingSessions.delete(sessionID);
1378
+ }
1379
+ }
1380
+ },
1381
+
1382
+ // Best-effort rescue only for stale apply_patch input before native
1383
+ // execution
1384
+ 'tool.execute.before': async (input, output) => {
1385
+ await applyPatchHook['tool.execute.before'](
1386
+ input as {
1387
+ tool: string;
1388
+ directory?: string;
1389
+ },
1390
+ output as {
1391
+ args?: { patchText?: unknown; [key: string]: unknown };
1392
+ },
1393
+ );
1394
+
1395
+ await taskSessionManagerHook['tool.execute.before'](
1396
+ input as {
1397
+ tool: string;
1398
+ sessionID?: string;
1399
+ callID?: string;
1400
+ },
1401
+ output as { args?: unknown },
1402
+ );
1403
+ },
1404
+
1405
+ // Direct interception of /auto-continue command - bypasses LLM
1406
+ // round-trip
1407
+ 'command.execute.before': async (input, output) => {
1408
+ await todoContinuationHook.handleCommandExecuteBefore(
1409
+ input as {
1410
+ command: string;
1411
+ sessionID: string;
1412
+ arguments: string;
1413
+ },
1414
+ output as { parts: Array<{ type: string; text?: string }> },
1415
+ );
1416
+
1417
+ await presetManager.handleCommandExecuteBefore(
1418
+ input as {
1419
+ command: string;
1420
+ sessionID: string;
1421
+ arguments: string;
1422
+ },
1423
+ output as { parts: Array<{ type: string; text?: string }> },
1424
+ );
1425
+
1426
+ await usageService?.handleCommandExecuteBefore(
1427
+ input as {
1428
+ command: string;
1429
+ sessionID: string;
1430
+ arguments: string;
1431
+ },
1432
+ output as { parts: Array<{ type: string; text?: string }> },
1433
+ );
1434
+ },
1435
+
1436
+ 'chat.headers': chatHeadersHook['chat.headers'],
1437
+
1438
+ // Track which agent each session uses (needed for serve-mode prompt
1439
+ // injection)
1440
+ 'chat.message': async (
1441
+ input: {
1442
+ sessionID: string;
1443
+ agent?: string;
1444
+ model?: { providerID: string; modelID: string };
1445
+ variant?: string;
1446
+ },
1447
+ output?: { message?: { agent?: string } },
1448
+ ) => {
1449
+ const rawAgent = input.agent ?? output?.message?.agent;
1450
+ const agent = rawAgent
1451
+ ? resolveRuntimeAgentName(config, rawAgent)
1452
+ : undefined;
1453
+
1454
+ if (
1455
+ agent &&
1456
+ output?.message &&
1457
+ typeof output.message.agent === 'string'
1458
+ ) {
1459
+ output.message.agent = agent;
1460
+ }
1461
+
1462
+ if (agent) {
1463
+ sessionAgentMap.set(input.sessionID, agent);
1464
+ recordSessionProject({
1465
+ sessionID: input.sessionID,
1466
+ projectPath: ctx.directory,
1467
+ });
1468
+ if (input.model) {
1469
+ recordSessionModel({
1470
+ sessionID: input.sessionID,
1471
+ model: `${input.model.providerID}/${input.model.modelID}`,
1472
+ });
1473
+ }
1474
+ if (typeof input.variant === 'string') {
1475
+ recordSessionVariant({
1476
+ sessionID: input.sessionID,
1477
+ variant: input.variant,
1478
+ });
1479
+ }
1480
+ if (agent) {
1481
+ recordSessionNode({
1482
+ sessionID: input.sessionID,
1483
+ agent,
1484
+ model: input.model
1485
+ ? `${input.model.providerID}/${input.model.modelID}`
1486
+ : undefined,
1487
+ variant: input.variant,
1488
+ status: 'busy',
1489
+ });
1490
+ }
1491
+ }
1492
+ todoContinuationHook.handleChatMessage({
1493
+ sessionID: input.sessionID,
1494
+ agent,
1495
+ });
1496
+ },
1497
+
1498
+ // Inject orchestrator system prompt for serve-mode sessions. In serve
1499
+ // mode, the agent's prompt field may be absent from the agents
1500
+ // registry (built before plugin config hooks run). This hook injects
1501
+ // it at LLM call time. Uses the already-resolved prompt from
1502
+ // agentDefs (which has custom replacement or append prompts applied)
1503
+ // instead of rebuilding the default.
1504
+ 'experimental.chat.system.transform': async (
1505
+ input: { sessionID?: string },
1506
+ output: { system: string[] },
1507
+ ): Promise<void> => {
1508
+ const agentName = input.sessionID
1509
+ ? sessionAgentMap.get(input.sessionID)
1510
+ : undefined;
1511
+ if (agentName === 'orchestrator') {
1512
+ const alreadyInjected = output.system.some(
1513
+ (s) =>
1514
+ typeof s === 'string' &&
1515
+ s.includes('<Role>') &&
1516
+ s.includes('orchestrator'),
1517
+ );
1518
+ if (!alreadyInjected) {
1519
+ // Prepend the orchestrator prompt to the system array. Use the
1520
+ // resolved prompt from the orchestrator agent definition (which
1521
+ // includes any custom replacement or append from orchestrator.md
1522
+ // / orchestrator_append.md) Fall back to
1523
+ // buildOrchestratorPrompt only if the resolved prompt is
1524
+ // missing.
1525
+ const orchestratorDef = agentDefs.find(
1526
+ (a) => a.name === 'orchestrator',
1527
+ );
1528
+ const orchestratorPrompt =
1529
+ typeof orchestratorDef?.config?.prompt === 'string'
1530
+ ? orchestratorDef.config.prompt
1531
+ : buildOrchestratorPrompt();
1532
+ output.system[0] =
1533
+ orchestratorPrompt +
1534
+ (output.system[0] ? `\n\n${output.system[0]}` : '');
1535
+ }
1536
+ }
1537
+
1538
+ // Collapse to single system message for provider compatibility.
1539
+ // Some providers (e.g. Qwen via VLLM/DashScope) reject multiple
1540
+ // system messages. Sub-hooks above may push additional entries; join
1541
+ // them back into one element so OpenCode emits a single system
1542
+ // message.
1543
+ collapseSystemInPlace(output.system);
1544
+ },
1545
+
1546
+ // Inject phase reminder and filter available skills before sending to
1547
+ // API (doesn't show in UI)
1548
+ 'experimental.chat.messages.transform': async (
1549
+ input: Record<string, never>,
1550
+ output: { messages: unknown[] },
1551
+ ): Promise<void> => {
1552
+ // Type assertion since we know the structure matches
1553
+ // MessageWithParts[]
1554
+ const typedOutput = output as {
1555
+ messages: Array<{
1556
+ info: { role: string; agent?: string; sessionID?: string };
1557
+ parts: Array<{
1558
+ type: string;
1559
+ text?: string;
1560
+ [key: string]: unknown;
1561
+ }>;
1562
+ }>;
1563
+ };
1564
+
1565
+ const hasUserTurn = typedOutput.messages.some(
1566
+ (message) => message.info.role === 'user',
1567
+ );
1568
+ if (hasUserTurn) {
1569
+ // After the user submits, session.status reliably reflects OpenCode -
1570
+ // better than reconciling once at startup (empty/partial snapshots).
1571
+ // Await so context telemetry is fresh for hooks (e.g. /compact reminder).
1572
+ await reconcileSessions();
1573
+ }
1574
+
1575
+ for (const message of typedOutput.messages) {
1576
+ if (message.info.role !== 'user') {
1577
+ continue;
1578
+ }
1579
+ for (const part of message.parts) {
1580
+ if (part.type !== 'text' || typeof part.text !== 'string') {
1581
+ continue;
1582
+ }
1583
+ part.text = rewriteDisplayNameMentions(part.text);
1584
+ }
1585
+ }
1586
+
1587
+ processImageAttachments();
1588
+
1589
+ await todoContinuationHook.handleMessagesTransform({
1590
+ messages: typedOutput.messages,
1591
+ });
1592
+ await taskSessionManagerHook['experimental.chat.messages.transform'](
1593
+ input,
1594
+ typedOutput,
1595
+ );
1596
+ await contextPressureReminderHook['experimental.chat.messages.transform'](
1597
+ input,
1598
+ typedOutput,
1599
+ );
1600
+ await phaseReminderHook['experimental.chat.messages.transform'](
1601
+ input,
1602
+ typedOutput,
1603
+ );
1604
+ await filterAvailableSkillsHook['experimental.chat.messages.transform'](
1605
+ input,
1606
+ typedOutput,
1607
+ );
1608
+ },
1609
+
1610
+ // Post-tool hooks: retry guidance for delegation errors + file-tool
1611
+ // nudge
1612
+ 'tool.execute.after': async (input, output) => {
1613
+ await delegateTaskRetryHook['tool.execute.after'](
1614
+ input as { tool: string },
1615
+ output as { output: unknown },
1616
+ );
1617
+
1618
+ await jsonErrorRecoveryHook['tool.execute.after'](
1619
+ input as {
1620
+ tool: string;
1621
+ sessionID: string;
1622
+ callID: string;
1623
+ },
1624
+ output as {
1625
+ title: string;
1626
+ output: unknown;
1627
+ metadata: unknown;
1628
+ },
1629
+ );
1630
+
1631
+ await todoContinuationHook.handleToolExecuteAfter(
1632
+ input as {
1633
+ tool: string;
1634
+ sessionID?: string;
1635
+ },
1636
+ output as { output?: unknown },
1637
+ );
1638
+
1639
+ await postFileToolNudgeHook['tool.execute.after'](
1640
+ input as {
1641
+ tool: string;
1642
+ sessionID?: string;
1643
+ callID?: string;
1644
+ },
1645
+ output as {
1646
+ title: string;
1647
+ output: string;
1648
+ metadata: Record<string, unknown>;
1649
+ },
1650
+ );
1651
+
1652
+ await taskSessionManagerHook['tool.execute.after'](
1653
+ input as {
1654
+ tool: string;
1655
+ sessionID?: string;
1656
+ callID?: string;
1657
+ },
1658
+ output as { output: unknown },
1659
+ );
1660
+ },
1661
+ };
1662
+ };
1663
+
1664
+ export default OhMyOpenCodeLite;
1665
+
1666
+ export type {
1667
+ AgentName,
1668
+ AgentOverrideConfig,
1669
+ McpName,
1670
+ PluginConfig,
1671
+ } from './config';
1672
+ export type { RemoteMcpConfig } from './mcp';