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,389 @@
1
+ /**
2
+ * Shared session utilities for background managers.
3
+ */
4
+
5
+ import path from 'node:path';
6
+ import { pathToFileURL } from 'node:url';
7
+ import type { PluginInput } from '@opencode-ai/plugin';
8
+ import { delay } from './polling';
9
+
10
+ type OpencodeClient = PluginInput['client'];
11
+
12
+ /** Poll interval while checking session status after `prompt`. */
13
+ const POST_PROMPT_IDLE_POLL_MS = 200;
14
+
15
+ /**
16
+ * Max time to poll session status after a delegate prompt. Keeps behavior bounded
17
+ * when the host never reports `idle` for child sessions or uses a different API shape.
18
+ */
19
+ const DELEGATE_POST_PROMPT_STATUS_MAX_MS = 45_000;
20
+
21
+ const SESSION_TERMINAL_STATUSES = new Set(['idle', 'completed', 'error']);
22
+ const SESSION_ACTIVE_STATUSES = new Set(['busy', 'retry']);
23
+
24
+ /** Retries if messages are not yet visible after idle. */
25
+ const POST_IDLE_EXTRACT_RETRY_DELAY_MS = 350;
26
+ const POST_IDLE_EXTRACT_MAX_RETRIES = 4;
27
+
28
+ type SessionPromptBody = NonNullable<
29
+ Parameters<OpencodeClient['session']['prompt']>[0]['body']
30
+ >;
31
+
32
+ /** Multimodal / text parts accepted by `session.prompt` */
33
+ export type PromptBodyPart = SessionPromptBody['parts'][number];
34
+
35
+ /** Prompt body including optional variant (supported by the host at runtime). */
36
+ export type PromptBody = SessionPromptBody & { variant?: string };
37
+
38
+ /**
39
+ * Extract the short model label from a "provider/model" string.
40
+ * E.g. "openai/gpt-5.4-mini" → "gpt-5.4-mini"
41
+ */
42
+ export function shortModelLabel(model: string): string {
43
+ return model.split('/').pop() ?? model;
44
+ }
45
+
46
+ /**
47
+ * Parse a model reference string into provider and model IDs.
48
+ * @param model - Model string in format "provider/model"
49
+ * @returns Object with providerID and modelID, or null if invalid
50
+ */
51
+ export function parseModelReference(
52
+ model: string,
53
+ ): { providerID: string; modelID: string } | null {
54
+ const slashIndex = model.indexOf('/');
55
+ if (slashIndex <= 0 || slashIndex >= model.length - 1) {
56
+ return null;
57
+ }
58
+ return {
59
+ providerID: model.slice(0, slashIndex),
60
+ modelID: model.slice(slashIndex + 1),
61
+ };
62
+ }
63
+
64
+ /**
65
+ * OpenCode stores pasted / attached screenshots as {@link FilePart} (`type: "file"`,
66
+ * `mime` starting with `image/`), not as `type: "image"`. Some stacks still emit
67
+ * legacy `image` parts - accept both.
68
+ *
69
+ * @see https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/session/message-v2.ts
70
+ */
71
+ export function isForwardableImagePart(part: Record<string, unknown>): boolean {
72
+ const t = part.type;
73
+ if (t === 'image') {
74
+ return true;
75
+ }
76
+ if (t === 'file') {
77
+ const mimeRaw = part.mime;
78
+ const mime =
79
+ typeof mimeRaw === 'string' ? mimeRaw.toLowerCase().trim() : '';
80
+ if (mime.startsWith('image/')) {
81
+ return true;
82
+ }
83
+ const fn = part.filename;
84
+ if (
85
+ typeof fn === 'string' &&
86
+ /\.(png|jpe?g|gif|webp|bmp|heic|avif)$/i.test(fn)
87
+ ) {
88
+ return true;
89
+ }
90
+ }
91
+ return false;
92
+ }
93
+
94
+ /**
95
+ * Non-text parts (e.g. images) from the latest user message in a session.
96
+ * Used when forwarding multimodal context to delegated agents such as @interpreter.
97
+ */
98
+ export async function extractLatestUserImageParts(
99
+ client: OpencodeClient,
100
+ sessionId: string,
101
+ directory: string,
102
+ ): Promise<PromptBodyPart[]> {
103
+ const messagesResult = await client.session.messages({
104
+ path: { id: sessionId },
105
+ query: { directory },
106
+ });
107
+ const messages = (messagesResult.data ?? []) as Array<{
108
+ info?: { role?: string };
109
+ parts?: Array<Record<string, unknown>>;
110
+ }>;
111
+ const userMessages = messages.filter((m) => m.info?.role === 'user');
112
+ const lastUser = userMessages[userMessages.length - 1];
113
+ if (!lastUser?.parts?.length) {
114
+ return [];
115
+ }
116
+ return lastUser.parts.filter(isForwardableImagePart) as PromptBodyPart[];
117
+ }
118
+
119
+ function fileUrlFromSource(
120
+ source: unknown,
121
+ workspaceDirectory: string | undefined,
122
+ ): string | undefined {
123
+ if (!source || typeof source !== 'object') return undefined;
124
+ const s = source as Record<string, unknown>;
125
+ if (s.type !== 'file') return undefined;
126
+ const filePath = s.path;
127
+ if (typeof filePath !== 'string' || !filePath.trim()) return undefined;
128
+ try {
129
+ const resolved = path.isAbsolute(filePath)
130
+ ? filePath
131
+ : workspaceDirectory
132
+ ? path.resolve(workspaceDirectory, filePath)
133
+ : filePath;
134
+ return pathToFileURL(resolved).href;
135
+ } catch {
136
+ return undefined;
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Stored {@link FilePart} rows include `sessionID` / `messageID` / etc. Child
142
+ * `session.prompt` expects {@link FilePartInput}-shaped drafts (`type`, `mime`,
143
+ * `url`, optional `filename`). Some attachments omit `url` but provide
144
+ * `source.path` - resolve that to a `file:` URL when possible.
145
+ */
146
+ export function normalizeImagePartsForChildPrompt(
147
+ parts: PromptBodyPart[],
148
+ workspaceDirectory?: string,
149
+ ): PromptBodyPart[] {
150
+ const out: PromptBodyPart[] = [];
151
+
152
+ for (const part of parts) {
153
+ const p = part as Record<string, unknown>;
154
+
155
+ if (p.type === 'file' && isForwardableImagePart(p)) {
156
+ let url = typeof p.url === 'string' && p.url.length > 0 ? p.url : '';
157
+ const mimeRaw =
158
+ typeof p.mime === 'string'
159
+ ? p.mime
160
+ : typeof p.mediaType === 'string'
161
+ ? (p.mediaType as string)
162
+ : 'application/octet-stream';
163
+
164
+ const filename = typeof p.filename === 'string' ? p.filename : undefined;
165
+
166
+ if (!url) {
167
+ url = fileUrlFromSource(p.source, workspaceDirectory) ?? '';
168
+ }
169
+ if (!url) continue;
170
+
171
+ const draft: Record<string, unknown> = {
172
+ type: 'file',
173
+ mime: mimeRaw,
174
+ url,
175
+ };
176
+ if (filename) draft.filename = filename;
177
+ out.push(draft as PromptBodyPart);
178
+ continue;
179
+ }
180
+
181
+ if (p.type === 'image') {
182
+ const raw = p.image ?? p.data ?? p.url;
183
+ const imageStr = typeof raw === 'string' ? raw : '';
184
+
185
+ if (imageStr.startsWith('data:')) {
186
+ const mimeMatch = imageStr.match(/^data:([^;]+);/);
187
+ const mime = mimeMatch?.[1] ?? 'image/png';
188
+ out.push({ type: 'file', mime, url: imageStr } as PromptBodyPart);
189
+ } else if (/^https?:\/\//i.test(imageStr)) {
190
+ out.push({
191
+ type: 'file',
192
+ mime: 'image/png',
193
+ url: imageStr,
194
+ } as PromptBodyPart);
195
+ }
196
+ }
197
+ }
198
+
199
+ return out;
200
+ }
201
+
202
+ /**
203
+ * Send a prompt to a session with optional timeout.
204
+ * If timeout is exceeded, the session is aborted and an error is thrown.
205
+ * @param client - OpenCode client instance
206
+ * @param args - Arguments for session.prompt()
207
+ * @param timeoutMs - Timeout in milliseconds (0 = no timeout)
208
+ * @throws Error if timeout is exceeded
209
+ */
210
+ export async function promptWithTimeout(
211
+ client: OpencodeClient,
212
+ args: Parameters<OpencodeClient['session']['prompt']>[0],
213
+ timeoutMs: number,
214
+ ): Promise<void> {
215
+ if (timeoutMs <= 0) {
216
+ await client.session.prompt(args);
217
+ return;
218
+ }
219
+
220
+ const sessionId = args.path.id;
221
+ let timer: ReturnType<typeof setTimeout> | undefined;
222
+
223
+ try {
224
+ const promptPromise = client.session.prompt(args);
225
+ promptPromise.catch(() => {});
226
+
227
+ await Promise.race([
228
+ promptPromise,
229
+ new Promise<never>((_, reject) => {
230
+ timer = setTimeout(() => {
231
+ client.session.abort({ path: { id: sessionId } }).catch(() => {});
232
+ reject(new Error(`Prompt timed out after ${timeoutMs}ms`));
233
+ }, timeoutMs);
234
+ }),
235
+ ]);
236
+ } finally {
237
+ clearTimeout(timer);
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Result of extracting session content.
243
+ * `empty` is true when the assistant produced zero text content -
244
+ * the provider returned an empty response (e.g. rate-limited silently).
245
+ */
246
+ export interface SessionExtractionResult {
247
+ text: string;
248
+ empty: boolean;
249
+ }
250
+
251
+ /**
252
+ * Extract the result text from a session.
253
+ * Collects all assistant messages and concatenates their text parts.
254
+ * @param client - OpenCode client instance
255
+ * @param sessionId - Session ID to extract from
256
+ * @param options - Optional: `includeReasoning` (default true) controls whether
257
+ * reasoning/chain-of-thought parts are included;
258
+ * `directory` scopes workspace for `session.messages` (child sessions).
259
+ * @returns Object with extracted text and an `empty` flag for zero-content detection
260
+ */
261
+ export async function extractSessionResult(
262
+ client: OpencodeClient,
263
+ sessionId: string,
264
+ options?: { includeReasoning?: boolean; directory?: string },
265
+ ): Promise<SessionExtractionResult> {
266
+ const includeReasoning = options?.includeReasoning ?? true;
267
+ const directory = options?.directory;
268
+
269
+ const messagesResult = await client.session.messages({
270
+ path: { id: sessionId },
271
+ ...(directory ? { query: { directory } } : {}),
272
+ });
273
+ const messages = (messagesResult.data ?? []) as Array<{
274
+ info?: { role: string };
275
+ parts?: Array<{ type: string; text?: string }>;
276
+ }>;
277
+ const assistantMessages = messages.filter(
278
+ (m) => m.info?.role === 'assistant',
279
+ );
280
+
281
+ const extractedContent: string[] = [];
282
+ for (const message of assistantMessages) {
283
+ for (const part of message.parts ?? []) {
284
+ const allowed = includeReasoning
285
+ ? part.type === 'text' || part.type === 'reasoning'
286
+ : part.type === 'text';
287
+ if (allowed && part.text) {
288
+ extractedContent.push(part.text);
289
+ }
290
+ }
291
+ }
292
+
293
+ const text = extractedContent.filter((t) => t.length > 0).join('\n\n');
294
+ return { text, empty: text.length === 0 };
295
+ }
296
+
297
+ async function readSessionStatusType(
298
+ client: OpencodeClient,
299
+ sessionId: string,
300
+ workspaceDirectory: string,
301
+ ): Promise<string | undefined> {
302
+ try {
303
+ const statusResult = await (
304
+ client.session.status as (
305
+ args: Record<string, unknown>,
306
+ ) => Promise<{ data?: unknown }>
307
+ )({
308
+ path: { id: sessionId },
309
+ query: { directory: workspaceDirectory },
310
+ });
311
+ const data = statusResult.data;
312
+ if (data && typeof data === 'object' && 'type' in data) {
313
+ const t = (data as { type: unknown }).type;
314
+ return typeof t === 'string' ? t : undefined;
315
+ }
316
+ } catch {
317
+ /* host may not support per-session status */
318
+ }
319
+ return undefined;
320
+ }
321
+
322
+ /**
323
+ * After `session.prompt`, optionally wait for a terminal session status.
324
+ * Returns immediately when per-session status is unavailable (common for some hosts),
325
+ * so we never block the orchestrator on a mismatched status API.
326
+ */
327
+ export async function waitUntilSessionIdle(
328
+ client: OpencodeClient,
329
+ sessionId: string,
330
+ workspaceDirectory: string,
331
+ ): Promise<void> {
332
+ const deadline = Date.now() + DELEGATE_POST_PROMPT_STATUS_MAX_MS;
333
+ while (Date.now() < deadline) {
334
+ const type = await readSessionStatusType(
335
+ client,
336
+ sessionId,
337
+ workspaceDirectory,
338
+ );
339
+ if (type === undefined) {
340
+ return;
341
+ }
342
+ if (SESSION_TERMINAL_STATUSES.has(type)) {
343
+ return;
344
+ }
345
+ if (SESSION_ACTIVE_STATUSES.has(type)) {
346
+ await delay(POST_PROMPT_IDLE_POLL_MS);
347
+ continue;
348
+ }
349
+ return;
350
+ }
351
+ }
352
+
353
+ /**
354
+ * After `session.prompt`, wait for idle then read assistant text. Retries if the
355
+ * message store lags; falls back to including reasoning parts if text is still empty.
356
+ */
357
+ export async function extractAssistantTextAfterPrompt(
358
+ client: OpencodeClient,
359
+ sessionId: string,
360
+ workspaceDirectory: string,
361
+ ): Promise<SessionExtractionResult> {
362
+ await waitUntilSessionIdle(client, sessionId, workspaceDirectory);
363
+
364
+ let result = await extractSessionResult(client, sessionId, {
365
+ includeReasoning: false,
366
+ directory: workspaceDirectory,
367
+ });
368
+
369
+ for (
370
+ let attempt = 0;
371
+ attempt < POST_IDLE_EXTRACT_MAX_RETRIES && result.empty;
372
+ attempt++
373
+ ) {
374
+ await delay(POST_IDLE_EXTRACT_RETRY_DELAY_MS);
375
+ result = await extractSessionResult(client, sessionId, {
376
+ includeReasoning: false,
377
+ directory: workspaceDirectory,
378
+ });
379
+ }
380
+
381
+ if (result.empty) {
382
+ result = await extractSessionResult(client, sessionId, {
383
+ includeReasoning: true,
384
+ directory: workspaceDirectory,
385
+ });
386
+ }
387
+
388
+ return result;
389
+ }
@@ -0,0 +1,170 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { SubagentDepthTracker } from './subagent-depth';
3
+
4
+ describe('SubagentDepthTracker', () => {
5
+ describe('constructor', () => {
6
+ test('uses DEFAULT_MAX_SUBAGENT_DEPTH (3) by default', () => {
7
+ const tracker = new SubagentDepthTracker();
8
+ expect(tracker).toBeDefined();
9
+ });
10
+
11
+ test('accepts custom max depth', () => {
12
+ const tracker = new SubagentDepthTracker(5);
13
+ expect(tracker).toBeDefined();
14
+ });
15
+ });
16
+
17
+ describe('getDepth', () => {
18
+ test('returns 0 for untracked sessions (root sessions)', () => {
19
+ const tracker = new SubagentDepthTracker();
20
+ expect(tracker.getDepth('root-session')).toBe(0);
21
+ expect(tracker.getDepth('untracked-session')).toBe(0);
22
+ });
23
+
24
+ test('returns tracked depth for registered sessions', () => {
25
+ const tracker = new SubagentDepthTracker();
26
+ tracker.registerChild('root-session', 'child-session');
27
+ expect(tracker.getDepth('child-session')).toBe(1);
28
+ });
29
+ });
30
+
31
+ describe('registerChild', () => {
32
+ test('tracks depth correctly (parent=0, child=1, grandchild=2)', () => {
33
+ const tracker = new SubagentDepthTracker();
34
+
35
+ expect(tracker.getDepth('root')).toBe(0);
36
+
37
+ const allowed1 = tracker.registerChild('root', 'child1');
38
+ expect(allowed1).toBe(true);
39
+ expect(tracker.getDepth('child1')).toBe(1);
40
+
41
+ const allowed2 = tracker.registerChild('child1', 'grandchild');
42
+ expect(allowed2).toBe(true);
43
+ expect(tracker.getDepth('grandchild')).toBe(2);
44
+ });
45
+
46
+ test('returns false when max depth exceeded (depth 4 > max 3)', () => {
47
+ const tracker = new SubagentDepthTracker(3);
48
+
49
+ const root = 'root';
50
+ const child1 = 'child1';
51
+ const child2 = 'child2';
52
+ const child3 = 'child3';
53
+ const child4 = 'child4';
54
+
55
+ expect(tracker.registerChild(root, child1)).toBe(true);
56
+ expect(tracker.registerChild(child1, child2)).toBe(true);
57
+ expect(tracker.registerChild(child2, child3)).toBe(true);
58
+ expect(tracker.registerChild(child3, child4)).toBe(false);
59
+ });
60
+
61
+ test('tracks across multiple branches independently', () => {
62
+ const tracker = new SubagentDepthTracker();
63
+
64
+ const root = 'root';
65
+ const branch1Child = 'branch1-child';
66
+ const branch2Child = 'branch2-child';
67
+ const branch1Grandchild = 'branch1-grandchild';
68
+ const branch2Grandchild = 'branch2-grandchild';
69
+
70
+ tracker.registerChild(root, branch1Child);
71
+ tracker.registerChild(branch1Child, branch1Grandchild);
72
+
73
+ tracker.registerChild(root, branch2Child);
74
+ tracker.registerChild(branch2Child, branch2Grandchild);
75
+
76
+ expect(tracker.getDepth(branch1Child)).toBe(1);
77
+ expect(tracker.getDepth(branch2Child)).toBe(1);
78
+ expect(tracker.getDepth(branch1Grandchild)).toBe(2);
79
+ expect(tracker.getDepth(branch2Grandchild)).toBe(2);
80
+ });
81
+
82
+ test('does not re-register existing session', () => {
83
+ const tracker = new SubagentDepthTracker();
84
+
85
+ const root = 'root';
86
+ const child = 'child';
87
+
88
+ tracker.registerChild(root, child);
89
+ expect(tracker.getDepth(child)).toBe(1);
90
+
91
+ tracker.registerChild(root, child);
92
+ expect(tracker.getDepth(child)).toBe(1);
93
+ });
94
+
95
+ test('updates depth if child is re-registered from different parent', () => {
96
+ const tracker = new SubagentDepthTracker();
97
+
98
+ const root = 'root';
99
+ const child1 = 'child1';
100
+ const child2 = 'child2';
101
+ const grandchild = 'grandchild';
102
+
103
+ tracker.registerChild(root, child1);
104
+ tracker.registerChild(child1, grandchild);
105
+ expect(tracker.getDepth(grandchild)).toBe(2);
106
+
107
+ tracker.registerChild(root, child2);
108
+ tracker.registerChild(child2, grandchild);
109
+ expect(tracker.getDepth(grandchild)).toBe(2);
110
+ });
111
+ });
112
+
113
+ describe('cleanup', () => {
114
+ test('removes a specific session', () => {
115
+ const tracker = new SubagentDepthTracker();
116
+
117
+ const root = 'root';
118
+ const child1 = 'child1';
119
+ const child2 = 'child2';
120
+
121
+ tracker.registerChild(root, child1);
122
+ tracker.registerChild(root, child2);
123
+
124
+ expect(tracker.getDepth(child1)).toBe(1);
125
+ expect(tracker.getDepth(child2)).toBe(1);
126
+
127
+ tracker.cleanup(child1);
128
+
129
+ expect(tracker.getDepth(child1)).toBe(0);
130
+ expect(tracker.getDepth(child2)).toBe(1);
131
+ });
132
+
133
+ test('does not throw when cleaning up untracked session', () => {
134
+ const tracker = new SubagentDepthTracker();
135
+
136
+ expect(() => tracker.cleanup('untracked')).not.toThrow();
137
+ });
138
+ });
139
+
140
+ describe('cleanupAll', () => {
141
+ test('removes all sessions', () => {
142
+ const tracker = new SubagentDepthTracker();
143
+
144
+ const root = 'root';
145
+ const child1 = 'child1';
146
+ const child2 = 'child2';
147
+ const grandchild = 'grandchild';
148
+
149
+ tracker.registerChild(root, child1);
150
+ tracker.registerChild(root, child2);
151
+ tracker.registerChild(child1, grandchild);
152
+
153
+ expect(tracker.getDepth(child1)).toBe(1);
154
+ expect(tracker.getDepth(child2)).toBe(1);
155
+ expect(tracker.getDepth(grandchild)).toBe(2);
156
+
157
+ tracker.cleanupAll();
158
+
159
+ expect(tracker.getDepth(child1)).toBe(0);
160
+ expect(tracker.getDepth(child2)).toBe(0);
161
+ expect(tracker.getDepth(grandchild)).toBe(0);
162
+ });
163
+
164
+ test('does not throw when called on empty tracker', () => {
165
+ const tracker = new SubagentDepthTracker();
166
+
167
+ expect(() => tracker.cleanupAll()).not.toThrow();
168
+ });
169
+ });
170
+ });
@@ -0,0 +1,75 @@
1
+ import { DEFAULT_MAX_SUBAGENT_DEPTH } from '../config';
2
+ import { log } from './logger';
3
+
4
+ /**
5
+ * Tracks subagent spawn depth to prevent excessive nesting.
6
+ *
7
+ * Depth 0 = root session (user's main conversation)
8
+ * Depth 1 = agent spawned by root (e.g., explorer)
9
+ * Depth 2 = agent spawned by depth-1 agent
10
+ * Depth 3 = agent spawned by depth-2 agent (max depth by default)
11
+ *
12
+ * When max depth is exceeded, the spawn is blocked.
13
+ */
14
+ export class SubagentDepthTracker {
15
+ private depthBySession = new Map<string, number>();
16
+ private readonly _maxDepth: number;
17
+
18
+ constructor(maxDepth: number = DEFAULT_MAX_SUBAGENT_DEPTH) {
19
+ this._maxDepth = maxDepth;
20
+ }
21
+
22
+ /** Maximum allowed depth. */
23
+ get maxDepth(): number {
24
+ return this._maxDepth;
25
+ }
26
+
27
+ /**
28
+ * Get the current depth of a session.
29
+ * Root sessions (not tracked) have depth 0.
30
+ */
31
+ getDepth(sessionId: string): number {
32
+ return this.depthBySession.get(sessionId) ?? 0;
33
+ }
34
+
35
+ /**
36
+ * Register a child session and check if the spawn is allowed.
37
+ * @returns true if allowed, false if max depth exceeded
38
+ */
39
+ registerChild(parentSessionId: string, childSessionId: string): boolean {
40
+ const parentDepth = this.getDepth(parentSessionId);
41
+ const childDepth = parentDepth + 1;
42
+
43
+ if (childDepth > this.maxDepth) {
44
+ log('[subagent-depth] spawn blocked: max depth exceeded', {
45
+ parentSessionId,
46
+ parentDepth,
47
+ childDepth,
48
+ maxDepth: this.maxDepth,
49
+ });
50
+ return false;
51
+ }
52
+
53
+ this.depthBySession.set(childSessionId, childDepth);
54
+ log('[subagent-depth] child registered', {
55
+ parentSessionId,
56
+ childSessionId,
57
+ childDepth,
58
+ });
59
+ return true;
60
+ }
61
+
62
+ /**
63
+ * Clean up session tracking when a session is deleted.
64
+ */
65
+ cleanup(sessionId: string): void {
66
+ this.depthBySession.delete(sessionId);
67
+ }
68
+
69
+ /**
70
+ * Clean up all tracking data.
71
+ */
72
+ cleanupAll(): void {
73
+ this.depthBySession.clear();
74
+ }
75
+ }