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,374 @@
1
+ /**
2
+ * Runtime model fallback for foreground (interactive) agent sessions.
3
+ *
4
+ * When OpenCode fires a session.error, message.updated, or session.status
5
+ * event containing a rate-limit signal, this manager:
6
+ * 1. Looks up the next untried model in the agent's configured chain
7
+ * 2. Aborts the rate-limited prompt via client.session.abort()
8
+ * 3. Re-queues the last user message via client.session.promptAsync()
9
+ * with the new model - promptAsync returns immediately so we never
10
+ * block the event handler waiting for a full LLM response.
11
+ *
12
+ * This mirrors the same fallback loop used for delegated sessions, but operates
13
+ * reactively through the event system instead of wrapping prompt() in a
14
+ * try/catch, which is not possible for interactive (foreground) sessions.
15
+ */
16
+
17
+ import type { PluginInput } from '@opencode-ai/plugin';
18
+ import { log } from '../../utils/logger';
19
+
20
+ type OpencodeClient = PluginInput['client'];
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Rate-limit detection
24
+ // ---------------------------------------------------------------------------
25
+
26
+ const RATE_LIMIT_PATTERNS = [
27
+ /\b429\b/,
28
+ /rate.?limit/i,
29
+ /too many requests/i,
30
+ /quota.?exceeded/i,
31
+ /usage.?exceeded/i,
32
+ /ExceededBudget/i,
33
+ /over.?budget/i,
34
+ /usage limit/i,
35
+ /overloaded/i,
36
+ /resource.?exhausted/i,
37
+ /insufficient.?quota/i,
38
+ /high concurrency/i,
39
+ /reduce concurrency/i,
40
+ ];
41
+
42
+ export function isRateLimitError(error: unknown): boolean {
43
+ if (!error || typeof error !== 'object') return false;
44
+ const err = error as {
45
+ message?: string;
46
+ data?: { statusCode?: number; message?: string; responseBody?: string };
47
+ };
48
+ const text = [
49
+ err.message ?? '',
50
+ String(err.data?.statusCode ?? ''),
51
+ err.data?.message ?? '',
52
+ err.data?.responseBody ?? '',
53
+ ].join(' ');
54
+ return RATE_LIMIT_PATTERNS.some((p) => p.test(text));
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Helpers
59
+ // ---------------------------------------------------------------------------
60
+
61
+ function parseModel(
62
+ model: string,
63
+ ): { providerID: string; modelID: string } | null {
64
+ const slash = model.indexOf('/');
65
+ if (slash <= 0 || slash >= model.length - 1) return null;
66
+ return { providerID: model.slice(0, slash), modelID: model.slice(slash + 1) };
67
+ }
68
+
69
+ /** Prevent re-triggering within this window for the same session. */
70
+ const DEDUP_WINDOW_MS = 5_000;
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Manager
74
+ // ---------------------------------------------------------------------------
75
+
76
+ /**
77
+ * Manages runtime model fallback for foreground agent sessions.
78
+ *
79
+ * Constructed at plugin init with the ordered fallback chains for each agent
80
+ * (built from _modelArray entries merged with fallback.chains config).
81
+ */
82
+ export class ForegroundFallbackManager {
83
+ /** sessionID → last observed model string ("providerID/modelID") */
84
+ private readonly sessionModel = new Map<string, string>();
85
+ /** sessionID → agent name (populated from message.updated info.agent field) */
86
+ private readonly sessionAgent = new Map<string, string>();
87
+ /** sessionID → set of models already attempted this session */
88
+ private readonly sessionTried = new Map<string, Set<string>>();
89
+ /** Sessions with an active fallback switch in flight */
90
+ private readonly inProgress = new Set<string>();
91
+ /** sessionID → timestamp of last trigger (for deduplication) */
92
+ private readonly lastTrigger = new Map<string, number>();
93
+
94
+ constructor(
95
+ private readonly client: OpencodeClient,
96
+ /**
97
+ * Ordered fallback chains per agent.
98
+ * e.g. { orchestrator: ['anthropic/claude-opus-4-5', 'openai/gpt-4o'] }
99
+ * The first model that hasn't been tried yet is selected on each fallback.
100
+ */
101
+ private readonly chains: Record<string, string[]>,
102
+ private readonly enabled: boolean,
103
+ ) {}
104
+
105
+ /**
106
+ * Process an OpenCode plugin event.
107
+ * Call this from the plugin's `event` hook for every event received.
108
+ */
109
+ async handleEvent(rawEvent: unknown): Promise<void> {
110
+ if (!this.enabled) return;
111
+ const event = rawEvent as { type: string; properties?: unknown };
112
+ if (!event?.type) return;
113
+
114
+ switch (event.type) {
115
+ case 'message.updated': {
116
+ const info = (
117
+ event.properties as { info?: Record<string, unknown> } | undefined
118
+ )?.info;
119
+ if (!info) break;
120
+ const sessionID = info.sessionID as string | undefined;
121
+ if (!sessionID) break;
122
+ // Capture agent name when available (OpenCode includes it on subagent messages)
123
+ if (typeof info.agent === 'string') {
124
+ this.sessionAgent.set(sessionID, info.agent);
125
+ }
126
+ // Track the model currently serving this session
127
+ if (
128
+ typeof info.providerID === 'string' &&
129
+ typeof info.modelID === 'string'
130
+ ) {
131
+ this.sessionModel.set(
132
+ sessionID,
133
+ `${info.providerID}/${info.modelID}`,
134
+ );
135
+ }
136
+ // Rate-limit on an individual message
137
+ if (info.error && isRateLimitError(info.error)) {
138
+ await this.tryFallback(sessionID);
139
+ }
140
+ break;
141
+ }
142
+
143
+ case 'session.error': {
144
+ const props = event.properties as
145
+ | { sessionID?: string; error?: unknown }
146
+ | undefined;
147
+ if (props?.sessionID && props.error && isRateLimitError(props.error)) {
148
+ await this.tryFallback(props.sessionID);
149
+ }
150
+ break;
151
+ }
152
+
153
+ case 'session.status': {
154
+ const props = event.properties as
155
+ | {
156
+ sessionID?: string;
157
+ status?: { type?: string; message?: string };
158
+ }
159
+ | undefined;
160
+ if (!props?.sessionID || props.status?.type !== 'retry') break;
161
+ const msg = props.status.message?.toLowerCase() ?? '';
162
+ if (
163
+ msg.includes('rate limit') ||
164
+ msg.includes('usage limit') ||
165
+ msg.includes('usage exceeded') ||
166
+ msg.includes('quota exceeded') ||
167
+ msg.includes('exceededbudget') ||
168
+ msg.includes('over budget') ||
169
+ msg.includes('high concurrency') ||
170
+ msg.includes('reduce concurrency')
171
+ ) {
172
+ await this.tryFallback(props.sessionID);
173
+ }
174
+ break;
175
+ }
176
+
177
+ case 'subagent.session.created': {
178
+ // Some builds of OpenCode include the agent name here.
179
+ const props = event.properties as
180
+ | { sessionID?: string; agentName?: unknown }
181
+ | undefined;
182
+ if (props?.sessionID && typeof props.agentName === 'string') {
183
+ this.sessionAgent.set(props.sessionID, props.agentName);
184
+ }
185
+ break;
186
+ }
187
+
188
+ case 'session.deleted': {
189
+ // Clean up all per-session state to prevent unbounded memory growth
190
+ // in long-running instances with many subagent sessions.
191
+ // OpenCode emits two shapes depending on context:
192
+ // { properties: { sessionID } } - subagent / task sessions
193
+ // { properties: { info: { id } } } - top-level session deletion
194
+ // Mirror the same dual-shape lookup used elsewhere in the plugin.
195
+ const props = event.properties as
196
+ | { sessionID?: string; info?: { id?: string } }
197
+ | undefined;
198
+ const id = props?.info?.id ?? props?.sessionID;
199
+ if (id) {
200
+ this.sessionModel.delete(id);
201
+ this.sessionAgent.delete(id);
202
+ this.sessionTried.delete(id);
203
+ this.inProgress.delete(id);
204
+ this.lastTrigger.delete(id);
205
+ }
206
+ break;
207
+ }
208
+ }
209
+ }
210
+
211
+ // ---------------------------------------------------------------------------
212
+ // Core fallback logic
213
+ // ---------------------------------------------------------------------------
214
+
215
+ private async tryFallback(sessionID: string): Promise<void> {
216
+ if (!sessionID) return;
217
+ if (this.inProgress.has(sessionID)) return;
218
+
219
+ // Deduplicate: multiple events can fire for a single rate-limit event.
220
+ const now = Date.now();
221
+ if (now - (this.lastTrigger.get(sessionID) ?? 0) < DEDUP_WINDOW_MS) return;
222
+ this.lastTrigger.set(sessionID, now);
223
+
224
+ this.inProgress.add(sessionID);
225
+ try {
226
+ const currentModel = this.sessionModel.get(sessionID);
227
+ const agentName = this.sessionAgent.get(sessionID);
228
+ const chain = this.resolveChain(agentName, currentModel);
229
+ if (!chain.length) {
230
+ log('[foreground-fallback] no chain configured', {
231
+ sessionID,
232
+ agentName,
233
+ });
234
+ return;
235
+ }
236
+
237
+ if (!this.sessionTried.has(sessionID)) {
238
+ this.sessionTried.set(sessionID, new Set());
239
+ }
240
+ // biome-ignore lint/style/noNonNullAssertion: We just set this above
241
+ const tried = this.sessionTried.get(sessionID)!;
242
+ if (currentModel) tried.add(currentModel);
243
+
244
+ const nextModel = chain.find((m) => !tried.has(m));
245
+ if (!nextModel) {
246
+ log('[foreground-fallback] fallback chain exhausted', {
247
+ sessionID,
248
+ agentName,
249
+ tried: [...tried],
250
+ });
251
+ return;
252
+ }
253
+ tried.add(nextModel);
254
+
255
+ const ref = parseModel(nextModel);
256
+ if (!ref) {
257
+ log('[foreground-fallback] invalid model format', {
258
+ sessionID,
259
+ nextModel,
260
+ });
261
+ return;
262
+ }
263
+
264
+ // Retrieve the last user message to re-submit with the fallback model.
265
+ const result = await this.client.session.messages({
266
+ path: { id: sessionID },
267
+ });
268
+ const messages = (result.data ?? []) as Array<{
269
+ info: { role: string };
270
+ parts: unknown[];
271
+ }>;
272
+ const lastUser = [...messages]
273
+ .reverse()
274
+ .find((m) => m.info.role === 'user');
275
+ if (!lastUser) {
276
+ log('[foreground-fallback] no user message found', { sessionID });
277
+ return;
278
+ }
279
+
280
+ // Abort the currently rate-limited prompt so the session becomes idle.
281
+ try {
282
+ await this.client.session.abort({ path: { id: sessionID } });
283
+ } catch {
284
+ // Session may already be idle; safe to ignore.
285
+ }
286
+
287
+ // Give the server a moment to finalise the abort before re-prompting.
288
+ await new Promise((r) => setTimeout(r, 500));
289
+
290
+ // promptAsync queues the prompt and returns immediately - this avoids
291
+ // blocking the event handler while waiting for a full LLM response.
292
+ // Cast required: promptAsync is not in the plugin TypeScript types for
293
+ // opencode-dux but IS present on the real OpenCode client at
294
+ // runtime (verified by opencode-rate-limit-fallback reference impl).
295
+ const sessionClient = this.client.session as unknown as {
296
+ promptAsync: (args: {
297
+ path: { id: string };
298
+ body: {
299
+ parts: unknown[];
300
+ model: { providerID: string; modelID: string };
301
+ };
302
+ }) => Promise<unknown>;
303
+ };
304
+ await sessionClient.promptAsync({
305
+ path: { id: sessionID },
306
+ body: { parts: lastUser.parts, model: ref },
307
+ });
308
+
309
+ this.sessionModel.set(sessionID, nextModel);
310
+ log('[foreground-fallback] switched to fallback model', {
311
+ sessionID,
312
+ agentName,
313
+ from: currentModel,
314
+ to: nextModel,
315
+ });
316
+ } catch (err) {
317
+ log('[foreground-fallback] fallback attempt failed', {
318
+ sessionID,
319
+ error: err instanceof Error ? err.message : String(err),
320
+ });
321
+ } finally {
322
+ this.inProgress.delete(sessionID);
323
+ }
324
+ }
325
+
326
+ // ---------------------------------------------------------------------------
327
+ // Chain resolution
328
+ // ---------------------------------------------------------------------------
329
+
330
+ /**
331
+ * Determine the fallback chain to use for a session.
332
+ *
333
+ * Priority:
334
+ * 1. Agent name known AND has a configured chain → return it directly
335
+ * 2. Agent name known but NO chain configured → return [] (no fallback;
336
+ * do NOT bleed into other agents' chains which would re-prompt the
337
+ * session with a model belonging to a completely different agent)
338
+ * 3. Agent name unknown, current model known → search all chains for
339
+ * the model to infer which chain to use
340
+ * 4. Nothing matches → flatten all chains as a last resort (only
341
+ * reached when both agent name and current model are unavailable)
342
+ */
343
+ private resolveChain(
344
+ agentName: string | undefined,
345
+ currentModel: string | undefined,
346
+ ): string[] {
347
+ if (agentName) {
348
+ // Agent is known: use its chain exactly, or no chain at all.
349
+ // Never fall through to cross-agent chains when the agent is identified.
350
+ return this.chains[agentName] ?? [];
351
+ }
352
+
353
+ // Agent unknown: try to infer from the current model.
354
+ if (currentModel) {
355
+ for (const chain of Object.values(this.chains)) {
356
+ if (chain.includes(currentModel)) return chain;
357
+ }
358
+ }
359
+
360
+ // Last resort: merged list across all agents preserving insertion order.
361
+ // Only reached when both agent name and current model are unavailable.
362
+ const all: string[] = [];
363
+ const seen = new Set<string>();
364
+ for (const chain of Object.values(this.chains)) {
365
+ for (const m of chain) {
366
+ if (!seen.has(m)) {
367
+ seen.add(m);
368
+ all.push(m);
369
+ }
370
+ }
371
+ }
372
+ return all;
373
+ }
374
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Image hook - currently a no-op.
3
+ * Images pass through naturally to the model.
4
+ */
5
+
6
+ export function processImageAttachments(): void {}
@@ -0,0 +1,17 @@
1
+ export { createApplyPatchHook } from './apply-patch';
2
+ export type { AutoUpdateCheckerOptions } from './auto-update-checker';
3
+ export { createAutoUpdateCheckerHook } from './auto-update-checker';
4
+ export { createChatHeadersHook } from './chat-headers';
5
+ export { createContextPressureReminderHook } from './context-pressure-reminder';
6
+ export { createDelegateTaskRetryHook } from './delegate-task-retry';
7
+ export { createFilterAvailableSkillsHook } from './filter-available-skills';
8
+ export {
9
+ ForegroundFallbackManager,
10
+ isRateLimitError,
11
+ } from './foreground-fallback';
12
+ export { processImageAttachments } from './image-hook';
13
+ export { createJsonErrorRecoveryHook } from './json-error-recovery';
14
+ export { createPhaseReminderHook } from './phase-reminder';
15
+ export { createPostFileToolNudgeHook } from './post-file-tool-nudge';
16
+ export { createTaskSessionManagerHook } from './task-session-manager';
17
+ export { createTodoContinuationHook } from './todo-continuation';
@@ -0,0 +1,73 @@
1
+ import type { PluginInput } from '@opencode-ai/plugin';
2
+
3
+ export const JSON_ERROR_TOOL_EXCLUDE_LIST = [
4
+ 'bash',
5
+ 'read',
6
+ 'glob',
7
+ 'webfetch',
8
+ 'grep_app_searchgithub',
9
+ 'websearch_web_search_exa',
10
+ ] as const;
11
+
12
+ export const JSON_ERROR_PATTERNS = [
13
+ /json parse error/i,
14
+ /failed to parse json/i,
15
+ /invalid json/i,
16
+ /malformed json/i,
17
+ /unexpected end of json input/i,
18
+ /syntaxerror:\s*unexpected token.*json/i,
19
+ /json[^\n]*expected '\}'/i,
20
+ /json[^\n]*unexpected eof/i,
21
+ ] as const;
22
+
23
+ const JSON_ERROR_REMINDER_MARKER =
24
+ '[JSON PARSE ERROR - IMMEDIATE ACTION REQUIRED]';
25
+ const JSON_ERROR_EXCLUDED_TOOLS = new Set<string>(JSON_ERROR_TOOL_EXCLUDE_LIST);
26
+
27
+ export const JSON_ERROR_REMINDER = `
28
+ [JSON PARSE ERROR - IMMEDIATE ACTION REQUIRED]
29
+
30
+ You sent invalid JSON arguments. The system could not parse your tool call.
31
+ STOP and do this NOW:
32
+
33
+ 1. LOOK at the error message above to see what was expected vs what you sent.
34
+ 2. CORRECT your JSON syntax (missing braces, unescaped quotes, trailing commas, etc).
35
+ 3. RETRY the tool call with valid JSON.
36
+
37
+ DO NOT repeat the exact same invalid call.
38
+ `;
39
+
40
+ interface ToolExecuteAfterInput {
41
+ tool: string;
42
+ sessionID: string;
43
+ callID: string;
44
+ }
45
+
46
+ interface ToolExecuteAfterOutput {
47
+ title: string;
48
+ output: unknown;
49
+ metadata: unknown;
50
+ }
51
+
52
+ export function createJsonErrorRecoveryHook(_ctx: PluginInput) {
53
+ return {
54
+ 'tool.execute.after': async (
55
+ input: ToolExecuteAfterInput,
56
+ output: ToolExecuteAfterOutput,
57
+ ): Promise<void> => {
58
+ if (JSON_ERROR_EXCLUDED_TOOLS.has(input.tool.toLowerCase())) return;
59
+ if (typeof output.output !== 'string') return;
60
+ if (output.output.includes(JSON_ERROR_REMINDER_MARKER)) return;
61
+
62
+ const outputText = output.output;
63
+
64
+ const hasJsonError = JSON_ERROR_PATTERNS.some((pattern) =>
65
+ pattern.test(outputText),
66
+ );
67
+
68
+ if (hasJsonError) {
69
+ output.output += `\n${JSON_ERROR_REMINDER}`;
70
+ }
71
+ },
72
+ };
73
+ }
@@ -0,0 +1,111 @@
1
+ import { beforeEach, describe, expect, test } from 'bun:test';
2
+ import type { PluginInput } from '@opencode-ai/plugin';
3
+ import {
4
+ createJsonErrorRecoveryHook,
5
+ JSON_ERROR_PATTERNS,
6
+ JSON_ERROR_REMINDER,
7
+ JSON_ERROR_TOOL_EXCLUDE_LIST,
8
+ } from './index';
9
+
10
+ describe('json-error-recovery hook', () => {
11
+ let hook: ReturnType<typeof createJsonErrorRecoveryHook>;
12
+
13
+ type ToolExecuteAfterHandler = NonNullable<
14
+ ReturnType<typeof createJsonErrorRecoveryHook>['tool.execute.after']
15
+ >;
16
+ type ToolExecuteAfterInput = Parameters<ToolExecuteAfterHandler>[0];
17
+ type ToolExecuteAfterOutput = Parameters<ToolExecuteAfterHandler>[1];
18
+
19
+ const createMockPluginInput = (): PluginInput => {
20
+ return {
21
+ client: {} as PluginInput['client'],
22
+ directory: '/tmp/test',
23
+ } as PluginInput;
24
+ };
25
+
26
+ beforeEach(() => {
27
+ hook = createJsonErrorRecoveryHook(createMockPluginInput());
28
+ });
29
+
30
+ const createInput = (tool = 'Edit'): ToolExecuteAfterInput => ({
31
+ tool,
32
+ sessionID: 'test-session',
33
+ callID: 'test-call-id',
34
+ });
35
+
36
+ const createOutput = (outputText: unknown): ToolExecuteAfterOutput => ({
37
+ title: 'Tool Error',
38
+ output: outputText,
39
+ metadata: {},
40
+ });
41
+
42
+ test('appends reminder when output includes JSON parse error', async () => {
43
+ const output = createOutput("JSON parse error: expected '}' in JSON body");
44
+
45
+ await hook['tool.execute.after'](createInput(), output);
46
+
47
+ expect(output.output).toContain(JSON_ERROR_REMINDER);
48
+ });
49
+
50
+ test('does not append reminder for normal output', async () => {
51
+ const output = createOutput('Task completed successfully');
52
+
53
+ await hook['tool.execute.after'](createInput(), output);
54
+
55
+ expect(output.output).toBe('Task completed successfully');
56
+ });
57
+
58
+ test('does not append reminder for excluded tools', async () => {
59
+ const output = createOutput(
60
+ 'JSON parse error: unexpected end of JSON input',
61
+ );
62
+
63
+ await hook['tool.execute.after'](createInput('Read'), output);
64
+
65
+ expect(output.output).toBe(
66
+ 'JSON parse error: unexpected end of JSON input',
67
+ );
68
+ });
69
+
70
+ test('does not append duplicate reminder on repeated execution', async () => {
71
+ const output = createOutput('JSON parse error: invalid JSON arguments');
72
+
73
+ await hook['tool.execute.after'](createInput(), output);
74
+ await hook['tool.execute.after'](createInput(), output);
75
+
76
+ const reminderCount =
77
+ String(output.output).split(
78
+ '[JSON PARSE ERROR - IMMEDIATE ACTION REQUIRED]',
79
+ ).length - 1;
80
+ expect(reminderCount).toBe(1);
81
+ });
82
+
83
+ test('ignores non-string output values', async () => {
84
+ const values: unknown[] = [42, null, undefined, { error: 'invalid json' }];
85
+
86
+ for (const value of values) {
87
+ const output = createOutput(value);
88
+ await hook['tool.execute.after'](createInput(), output);
89
+ expect(output.output).toBe(value);
90
+ }
91
+ });
92
+
93
+ test('pattern list detects known JSON parse errors', () => {
94
+ const output = 'JSON parse error: unexpected end of JSON input';
95
+ const isMatched = JSON_ERROR_PATTERNS.some((pattern) =>
96
+ pattern.test(output),
97
+ );
98
+ expect(isMatched).toBe(true);
99
+ });
100
+
101
+ test('exclude list contains content-heavy tools', () => {
102
+ const expectedExcludedTools: Array<
103
+ (typeof JSON_ERROR_TOOL_EXCLUDE_LIST)[number]
104
+ > = ['read', 'bash', 'webfetch'];
105
+
106
+ const allExpectedToolsIncluded = expectedExcludedTools.every((toolName) =>
107
+ JSON_ERROR_TOOL_EXCLUDE_LIST.includes(toolName),
108
+ );
109
+ expect(allExpectedToolsIncluded).toBe(true);
110
+ });
111
+ });
@@ -0,0 +1,6 @@
1
+ export {
2
+ createJsonErrorRecoveryHook,
3
+ JSON_ERROR_PATTERNS,
4
+ JSON_ERROR_REMINDER,
5
+ JSON_ERROR_TOOL_EXCLUDE_LIST,
6
+ } from './hook';
@@ -0,0 +1,74 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { SLIM_INTERNAL_INITIATOR_MARKER } from '../../utils';
3
+ import { createPhaseReminderHook, PHASE_REMINDER } from './index';
4
+
5
+ describe('createPhaseReminderHook', () => {
6
+ test('appends reminder for orchestrator sessions', async () => {
7
+ const hook = createPhaseReminderHook();
8
+ const output = {
9
+ messages: [
10
+ {
11
+ info: { role: 'user', agent: 'orchestrator' },
12
+ parts: [{ type: 'text', text: 'hello' }],
13
+ },
14
+ ],
15
+ };
16
+
17
+ await hook['experimental.chat.messages.transform']({}, output);
18
+
19
+ expect(output.messages[0].parts[0].text).toBe(
20
+ `hello\n\n---\n\n${PHASE_REMINDER}`,
21
+ );
22
+ });
23
+
24
+ test('skips non-orchestrator sessions', async () => {
25
+ const hook = createPhaseReminderHook();
26
+ const output = {
27
+ messages: [
28
+ {
29
+ info: { role: 'user', agent: 'explorer' },
30
+ parts: [{ type: 'text', text: 'hello' }],
31
+ },
32
+ ],
33
+ };
34
+
35
+ await hook['experimental.chat.messages.transform']({}, output);
36
+
37
+ expect(output.messages[0].parts[0].text).toBe('hello');
38
+ });
39
+
40
+ test('does not mutate internal notification turns', async () => {
41
+ const hook = createPhaseReminderHook();
42
+ const text = `[Background task "x" completed]\n${SLIM_INTERNAL_INITIATOR_MARKER}`;
43
+ const output = {
44
+ messages: [
45
+ {
46
+ info: { role: 'user' },
47
+ parts: [{ type: 'text', text }],
48
+ },
49
+ ],
50
+ };
51
+
52
+ await hook['experimental.chat.messages.transform']({}, output);
53
+
54
+ expect(output.messages[0].parts[0].text).toBe(text);
55
+ expect(output.messages[0].parts[0].text).not.toContain(PHASE_REMINDER);
56
+ });
57
+
58
+ test('does not append duplicate reminder', async () => {
59
+ const hook = createPhaseReminderHook();
60
+ const text = `hello\n\n---\n\n${PHASE_REMINDER}`;
61
+ const output = {
62
+ messages: [
63
+ {
64
+ info: { role: 'user', agent: 'orchestrator' },
65
+ parts: [{ type: 'text', text }],
66
+ },
67
+ ],
68
+ };
69
+
70
+ await hook['experimental.chat.messages.transform']({}, output);
71
+
72
+ expect(output.messages[0].parts[0].text).toBe(text);
73
+ });
74
+ });