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,220 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
+ import * as fs from 'node:fs';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import {
6
+ flushLoggerForTesting,
7
+ getLogDir,
8
+ initLogger,
9
+ log,
10
+ resetLogger,
11
+ } from './logger';
12
+
13
+ describe('logger', () => {
14
+ let tmpDir: string;
15
+ let origLogDir: string | undefined;
16
+
17
+ beforeEach(() => {
18
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'logger-test-'));
19
+ origLogDir = process.env.OPENCODE_LOG_DIR;
20
+ process.env.OPENCODE_LOG_DIR = tmpDir;
21
+ resetLogger();
22
+ });
23
+
24
+ afterEach(async () => {
25
+ await flushLoggerForTesting();
26
+ if (origLogDir === undefined) {
27
+ delete process.env.OPENCODE_LOG_DIR;
28
+ } else {
29
+ process.env.OPENCODE_LOG_DIR = origLogDir;
30
+ }
31
+ fs.rmSync(tmpDir, { recursive: true, force: true });
32
+ });
33
+
34
+ test('log() silently no-ops before initLogger()', () => {
35
+ log('should not crash');
36
+ expect(fs.readdirSync(tmpDir).length).toBe(0);
37
+ });
38
+
39
+ test('initLogger creates per-session log file', () => {
40
+ initLogger('20260416T143052');
41
+ log('test message');
42
+
43
+ const files = fs.readdirSync(tmpDir);
44
+ expect(files).toEqual(['opencode-dux.20260416T143052.log']);
45
+ });
46
+
47
+ test('writes log message with timestamp', async () => {
48
+ initLogger('session1');
49
+ log('timestamped message');
50
+ await flushLoggerForTesting();
51
+
52
+ const logPath = path.join(tmpDir, 'opencode-dux.session1.log');
53
+ const content = fs.readFileSync(logPath, 'utf-8');
54
+ expect(content).toMatch(/\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\]/);
55
+ expect(content).toContain('timestamped message');
56
+ });
57
+
58
+ test('logs message with data object', async () => {
59
+ initLogger('session1');
60
+ log('message with data', { key: 'value', number: 42 });
61
+ await flushLoggerForTesting();
62
+
63
+ const logPath = path.join(tmpDir, 'opencode-dux.session1.log');
64
+ const content = fs.readFileSync(logPath, 'utf-8');
65
+ expect(content).toContain('"key":"value"');
66
+ expect(content).toContain('"number":42');
67
+ });
68
+
69
+ test('logs message without extra JSON when no data', async () => {
70
+ initLogger('session1');
71
+ log('message without data');
72
+ await flushLoggerForTesting();
73
+
74
+ const logPath = path.join(tmpDir, 'opencode-dux.session1.log');
75
+ const content = fs.readFileSync(logPath, 'utf-8');
76
+ expect(content.trim()).toMatch(/message without data\s*$/);
77
+ });
78
+
79
+ test('appends multiple log entries', async () => {
80
+ initLogger('session1');
81
+ log('first');
82
+ log('second');
83
+ log('third');
84
+ await flushLoggerForTesting();
85
+
86
+ const logPath = path.join(tmpDir, 'opencode-dux.session1.log');
87
+ const lines = fs.readFileSync(logPath, 'utf-8').trim().split('\n');
88
+ expect(lines.length).toBe(3);
89
+ expect(lines[0]).toContain('first');
90
+ expect(lines[1]).toContain('second');
91
+ expect(lines[2]).toContain('third');
92
+ });
93
+
94
+ test('initLogger called twice uses second session file', async () => {
95
+ initLogger('session1');
96
+ log('from session1');
97
+ initLogger('session2');
98
+ log('from session2');
99
+ await flushLoggerForTesting();
100
+
101
+ const files = fs.readdirSync(tmpDir).sort();
102
+ expect(files).toEqual([
103
+ 'opencode-dux.session1.log',
104
+ 'opencode-dux.session2.log',
105
+ ]);
106
+
107
+ const content1 = fs.readFileSync(path.join(tmpDir, files[0]), 'utf-8');
108
+ const content2 = fs.readFileSync(path.join(tmpDir, files[1]), 'utf-8');
109
+ expect(content1).toContain('from session1');
110
+ expect(content1).not.toContain('from session2');
111
+ expect(content2).toContain('from session2');
112
+ });
113
+
114
+ test('cleanup keeps only the latest 10 log files', () => {
115
+ const baseTime = Date.now() - 50_000;
116
+ for (let i = 0; i < 10; i++) {
117
+ const fileName = `opencode-dux.seed-${i}.log`;
118
+ const filePath = path.join(tmpDir, fileName);
119
+ fs.writeFileSync(filePath, `seed-${i}\n`);
120
+ const mtime = baseTime + i * 1_000;
121
+ fs.utimesSync(filePath, new Date(mtime), new Date(mtime));
122
+ }
123
+
124
+ initLogger('current');
125
+ log('init');
126
+
127
+ const files = fs
128
+ .readdirSync(tmpDir)
129
+ .filter(
130
+ (file) =>
131
+ file.startsWith('opencode-dux.') && file.endsWith('.log'),
132
+ )
133
+ .sort();
134
+
135
+ expect(files.length).toBe(10);
136
+ expect(files).toContain('opencode-dux.current.log');
137
+ expect(files).not.toContain('opencode-dux.seed-0.log');
138
+ expect(files).toContain('opencode-dux.seed-9.log');
139
+ });
140
+
141
+ test('cleanup does not delete current session log when overflowing', () => {
142
+ const baseTime = Date.now() - 100_000;
143
+ for (let i = 0; i < 25; i++) {
144
+ const fileName = `opencode-dux.old-${i}.log`;
145
+ const filePath = path.join(tmpDir, fileName);
146
+ fs.writeFileSync(filePath, `old-${i}\n`);
147
+ const mtime = baseTime + i * 1_000;
148
+ fs.utimesSync(filePath, new Date(mtime), new Date(mtime));
149
+ }
150
+
151
+ initLogger('current');
152
+
153
+ const files = fs
154
+ .readdirSync(tmpDir)
155
+ .filter(
156
+ (file) =>
157
+ file.startsWith('opencode-dux.') && file.endsWith('.log'),
158
+ );
159
+
160
+ expect(files.length).toBe(10);
161
+ expect(files).toContain('opencode-dux.current.log');
162
+ });
163
+
164
+ test('cleanup with no existing files does not crash', () => {
165
+ expect(() => initLogger('fresh')).not.toThrow();
166
+ log('init');
167
+ const files = fs.readdirSync(tmpDir);
168
+ expect(files.find((f) => f.includes('fresh'))).toBeDefined();
169
+ });
170
+
171
+ test('handles circular references in data', async () => {
172
+ initLogger('session1');
173
+ const circular: any = { name: 'test' };
174
+ circular.self = circular;
175
+
176
+ expect(() => log('circular data', circular)).not.toThrow();
177
+ await flushLoggerForTesting();
178
+
179
+ const logPath = path.join(tmpDir, 'opencode-dux.session1.log');
180
+ const content = fs.readFileSync(logPath, 'utf-8');
181
+ expect(content).toContain('circular data');
182
+ expect(content).toContain('[unserializable]');
183
+ });
184
+
185
+ test('getLogDir returns OPENCODE_LOG_DIR when set', () => {
186
+ expect(getLogDir()).toBe(tmpDir);
187
+ });
188
+
189
+ test('getLogDir falls back to os.homedir when env not set', () => {
190
+ delete process.env.OPENCODE_LOG_DIR;
191
+ try {
192
+ expect(getLogDir()).toBe(
193
+ path.join(os.homedir(), '.local/share/opencode'),
194
+ );
195
+ } finally {
196
+ if (origLogDir === undefined) {
197
+ delete process.env.OPENCODE_LOG_DIR;
198
+ } else {
199
+ process.env.OPENCODE_LOG_DIR = origLogDir;
200
+ }
201
+ }
202
+ });
203
+
204
+ test('handles complex data structures', async () => {
205
+ initLogger('session1');
206
+ log('complex data', {
207
+ nested: { deep: { value: 'test' } },
208
+ array: [1, 2, 3],
209
+ boolean: true,
210
+ null: null,
211
+ });
212
+ await flushLoggerForTesting();
213
+
214
+ const logPath = path.join(tmpDir, 'opencode-dux.session1.log');
215
+ const content = fs.readFileSync(logPath, 'utf-8');
216
+ expect(content).toContain('"nested":');
217
+ expect(content).toContain('"array":[1,2,3]');
218
+ expect(content).toContain('"boolean":true');
219
+ });
220
+ });
@@ -0,0 +1,136 @@
1
+ import * as fs from 'node:fs';
2
+ import { appendFile } from 'node:fs/promises';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+
6
+ const LOG_PREFIX = 'opencode-dux.';
7
+ const LOG_SUFFIX = '.log';
8
+ const MAX_LOG_FILES = 10;
9
+ const MAX_BG_TASK_FILES = 10;
10
+
11
+ let logFile: string | null = null;
12
+ let writeChain: Promise<void> = Promise.resolve();
13
+
14
+ function getLogDir(): string {
15
+ return (
16
+ process.env.OPENCODE_LOG_DIR ??
17
+ path.join(os.homedir(), '.local/share/opencode')
18
+ );
19
+ }
20
+
21
+ function trimByCount(
22
+ filePaths: string[],
23
+ maxFiles: number,
24
+ preservePath?: string,
25
+ ): void {
26
+ if (filePaths.length <= maxFiles) return;
27
+
28
+ const sortedByMtime = filePaths
29
+ .map((filePath) => {
30
+ try {
31
+ return { filePath, mtimeMs: fs.statSync(filePath).mtimeMs };
32
+ } catch {
33
+ return null;
34
+ }
35
+ })
36
+ .filter((entry): entry is { filePath: string; mtimeMs: number } =>
37
+ Boolean(entry),
38
+ )
39
+ .sort((a, b) => a.mtimeMs - b.mtimeMs);
40
+
41
+ const overflow = sortedByMtime.length - maxFiles;
42
+ if (overflow <= 0) return;
43
+
44
+ const candidates = preservePath
45
+ ? sortedByMtime.filter((entry) => entry.filePath !== preservePath)
46
+ : sortedByMtime;
47
+
48
+ for (const entry of candidates.slice(0, overflow)) {
49
+ try {
50
+ fs.unlinkSync(entry.filePath);
51
+ } catch {
52
+ // Skip individual file errors
53
+ }
54
+ }
55
+ }
56
+
57
+ function cleanupOldLogs(logDir: string, preservePath?: string): void {
58
+ try {
59
+ const entries = fs.readdirSync(logDir);
60
+ const logFiles = entries
61
+ .filter(
62
+ (entry) => entry.startsWith(LOG_PREFIX) && entry.endsWith(LOG_SUFFIX),
63
+ )
64
+ .map((entry) => path.join(logDir, entry));
65
+
66
+ trimByCount(logFiles, MAX_LOG_FILES, preservePath);
67
+ } catch {
68
+ // Directory may not exist yet - that's fine
69
+ }
70
+
71
+ // Apply the same count-based retention to persisted background task files
72
+ try {
73
+ const bgTaskDir = path.join(logDir, 'bg-tasks');
74
+ const taskFiles = fs
75
+ .readdirSync(bgTaskDir)
76
+ .filter((entry) => entry.endsWith('.json'))
77
+ .map((entry) => path.join(bgTaskDir, entry));
78
+
79
+ trimByCount(taskFiles, MAX_BG_TASK_FILES);
80
+ } catch {
81
+ // bg-tasks dir may not exist yet - that's fine
82
+ }
83
+ }
84
+
85
+ export function initLogger(sessionId: string): void {
86
+ const dir = getLogDir();
87
+ try {
88
+ fs.mkdirSync(dir, { recursive: true });
89
+ } catch {
90
+ // Directory creation failed - logging will silently fail
91
+ }
92
+ logFile = path.join(dir, `${LOG_PREFIX}${sessionId}${LOG_SUFFIX}`);
93
+ try {
94
+ fs.closeSync(fs.openSync(logFile, 'a'));
95
+ } catch {
96
+ // File creation failed - later writes will silently fail
97
+ }
98
+ cleanupOldLogs(dir, logFile);
99
+ }
100
+
101
+ /** @internal Reset logger state for testing */
102
+ export function resetLogger(): void {
103
+ logFile = null;
104
+ writeChain = Promise.resolve();
105
+ }
106
+
107
+ /** @internal Wait for queued log writes in tests. */
108
+ export async function flushLoggerForTesting(): Promise<void> {
109
+ await writeChain;
110
+ }
111
+
112
+ export { getLogDir };
113
+
114
+ export function log(message: string, data?: unknown): void {
115
+ const target = logFile;
116
+ if (!target) return; // Uninitialized - silently no-op
117
+ try {
118
+ const timestamp = new Date().toISOString();
119
+ let dataStr = '';
120
+ if (data !== undefined) {
121
+ try {
122
+ dataStr = JSON.stringify(data);
123
+ } catch {
124
+ dataStr = '[unserializable]';
125
+ }
126
+ }
127
+ const logEntry = `[${timestamp}] ${message} ${dataStr}\n`;
128
+ writeChain = writeChain
129
+ .then(() => appendFile(target, logEntry))
130
+ .catch(() => {
131
+ // Silently ignore logging errors and keep future writes alive
132
+ });
133
+ } catch {
134
+ // Silently ignore logging errors
135
+ }
136
+ }
@@ -0,0 +1,191 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { delay, pollUntilStable } from './polling';
3
+
4
+ describe('pollUntilStable', () => {
5
+ test('returns success when condition becomes stable', async () => {
6
+ let callCount = 0;
7
+ const fetchFn = async () => {
8
+ callCount++;
9
+ return callCount >= 3 ? 'stable' : 'changing';
10
+ };
11
+
12
+ const isStable = (current: string, previous: string | null) => {
13
+ return current === 'stable' && previous === 'stable';
14
+ };
15
+
16
+ const result = await pollUntilStable(fetchFn, isStable, {
17
+ pollInterval: 10,
18
+ maxPollTime: 1000,
19
+ stableThreshold: 2,
20
+ });
21
+
22
+ expect(result.success).toBe(true);
23
+ expect(result.data).toBe('stable');
24
+ expect(result.timedOut).toBeUndefined();
25
+ expect(result.aborted).toBeUndefined();
26
+ });
27
+
28
+ test('returns timeout when max poll time exceeded', async () => {
29
+ const fetchFn = async () => 'always-changing';
30
+ const isStable = () => false; // Never stable
31
+
32
+ const result = await pollUntilStable(fetchFn, isStable, {
33
+ pollInterval: 10,
34
+ maxPollTime: 50, // Very short timeout
35
+ stableThreshold: 2,
36
+ });
37
+
38
+ expect(result.success).toBe(false);
39
+ expect(result.timedOut).toBe(true);
40
+ expect(result.data).toBe('always-changing');
41
+ });
42
+
43
+ test('returns aborted when signal is aborted', async () => {
44
+ const controller = new AbortController();
45
+ const fetchFn = async () => {
46
+ // Abort after first call
47
+ controller.abort();
48
+ return 'data';
49
+ };
50
+
51
+ const isStable = () => false;
52
+
53
+ const result = await pollUntilStable(fetchFn, isStable, {
54
+ pollInterval: 10,
55
+ maxPollTime: 1000,
56
+ signal: controller.signal,
57
+ });
58
+
59
+ expect(result.success).toBe(false);
60
+ expect(result.aborted).toBe(true);
61
+ });
62
+
63
+ test('respects custom stability threshold', async () => {
64
+ let callCount = 0;
65
+ const fetchFn = async () => {
66
+ callCount++;
67
+ return callCount >= 2 ? 'stable' : 'changing';
68
+ };
69
+
70
+ const isStable = (
71
+ current: string,
72
+ previous: string | null,
73
+ _stableCount: number,
74
+ ) => {
75
+ return current === 'stable' && previous === 'stable';
76
+ };
77
+
78
+ const result = await pollUntilStable(fetchFn, isStable, {
79
+ pollInterval: 10,
80
+ maxPollTime: 1000,
81
+ stableThreshold: 3, // Require 3 stable polls
82
+ });
83
+
84
+ expect(result.success).toBe(true);
85
+ expect(callCount).toBeGreaterThanOrEqual(5); // At least 2 changing + 3 stable
86
+ });
87
+
88
+ test('resets stable count when condition becomes unstable', async () => {
89
+ let callCount = 0;
90
+ const values = ['a', 'a', 'b', 'b', 'b', 'b']; // Unstable, then stable
91
+ const fetchFn = async () => values[callCount++] || 'b';
92
+
93
+ const isStable = (current: string, previous: string | null) => {
94
+ return current === previous && current === 'b';
95
+ };
96
+
97
+ const result = await pollUntilStable(fetchFn, isStable, {
98
+ pollInterval: 10,
99
+ maxPollTime: 1000,
100
+ stableThreshold: 3,
101
+ });
102
+
103
+ expect(result.success).toBe(true);
104
+ expect(result.data).toBe('b');
105
+ });
106
+
107
+ test('uses default options when not provided', async () => {
108
+ let callCount = 0;
109
+ const fetchFn = async () => {
110
+ callCount++;
111
+ return callCount >= 2 ? 'stable' : 'changing';
112
+ };
113
+
114
+ const isStable = (current: string, previous: string | null) => {
115
+ return current === 'stable' && previous === 'stable';
116
+ };
117
+
118
+ const result = await pollUntilStable(fetchFn, isStable);
119
+
120
+ expect(result.success).toBe(true);
121
+ expect(result.data).toBe('stable');
122
+ });
123
+
124
+ test('handles fetchFn that throws errors', async () => {
125
+ const fetchFn = async () => {
126
+ throw new Error('Fetch failed');
127
+ };
128
+
129
+ const isStable = () => false;
130
+
131
+ await expect(
132
+ pollUntilStable(fetchFn, isStable, {
133
+ pollInterval: 10,
134
+ maxPollTime: 100,
135
+ }),
136
+ ).rejects.toThrow('Fetch failed');
137
+ });
138
+
139
+ test('passes stable count to isStable function', async () => {
140
+ let _callCount = 0;
141
+ const fetchFn = async () => {
142
+ _callCount++;
143
+ return 'data';
144
+ };
145
+
146
+ let maxStableCount = 0;
147
+ const isStable = (
148
+ current: string,
149
+ previous: string | null,
150
+ stableCount: number,
151
+ ) => {
152
+ maxStableCount = Math.max(maxStableCount, stableCount);
153
+ // Check if data is actually stable (same as previous)
154
+ return current === previous && current === 'data';
155
+ };
156
+
157
+ const result = await pollUntilStable(fetchFn, isStable, {
158
+ pollInterval: 10,
159
+ maxPollTime: 1000,
160
+ stableThreshold: 3,
161
+ });
162
+
163
+ expect(result.success).toBe(true);
164
+ expect(maxStableCount).toBeGreaterThanOrEqual(2);
165
+ });
166
+ });
167
+
168
+ describe('delay', () => {
169
+ test('delays for specified milliseconds', async () => {
170
+ const start = Date.now();
171
+ await delay(50);
172
+ const elapsed = Date.now() - start;
173
+
174
+ // Allow some tolerance for timing
175
+ expect(elapsed).toBeGreaterThanOrEqual(45);
176
+ expect(elapsed).toBeLessThan(100);
177
+ });
178
+
179
+ test('resolves without value', async () => {
180
+ const result = await delay(10);
181
+ expect(result).toBeUndefined();
182
+ });
183
+
184
+ test('can be used in promise chains', async () => {
185
+ const result = await Promise.resolve('test')
186
+ .then((val) => delay(10).then(() => val))
187
+ .then((val) => val.toUpperCase());
188
+
189
+ expect(result).toBe('TEST');
190
+ });
191
+ });
@@ -0,0 +1,67 @@
1
+ import {
2
+ MAX_POLL_TIME_MS,
3
+ POLL_INTERVAL_MS,
4
+ STABLE_POLLS_THRESHOLD,
5
+ } from '../config';
6
+
7
+ export interface PollOptions {
8
+ pollInterval?: number;
9
+ maxPollTime?: number;
10
+ stableThreshold?: number;
11
+ signal?: AbortSignal;
12
+ }
13
+
14
+ export interface PollResult<T> {
15
+ success: boolean;
16
+ data?: T;
17
+ timedOut?: boolean;
18
+ aborted?: boolean;
19
+ }
20
+
21
+ /**
22
+ * Generic polling utility that waits for a condition to be met.
23
+ * Returns when the condition is satisfied or timeout/abort occurs.
24
+ */
25
+ export async function pollUntilStable<T>(
26
+ fetchFn: () => Promise<T>,
27
+ isStable: (current: T, previous: T | null, stableCount: number) => boolean,
28
+ opts: PollOptions = {},
29
+ ): Promise<PollResult<T>> {
30
+ const pollInterval = opts.pollInterval ?? POLL_INTERVAL_MS;
31
+ const maxPollTime = opts.maxPollTime ?? MAX_POLL_TIME_MS;
32
+ const stableThreshold = opts.stableThreshold ?? STABLE_POLLS_THRESHOLD;
33
+
34
+ const pollStart = Date.now();
35
+ let previousData: T | null = null;
36
+ let stablePolls = 0;
37
+
38
+ while (Date.now() - pollStart < maxPollTime) {
39
+ if (opts.signal?.aborted) {
40
+ return { success: false, aborted: true };
41
+ }
42
+
43
+ await new Promise((r) => setTimeout(r, pollInterval));
44
+
45
+ const currentData = await fetchFn();
46
+
47
+ if (isStable(currentData, previousData, stablePolls)) {
48
+ stablePolls++;
49
+ if (stablePolls >= stableThreshold) {
50
+ return { success: true, data: currentData };
51
+ }
52
+ } else {
53
+ stablePolls = 0;
54
+ }
55
+
56
+ previousData = currentData;
57
+ }
58
+
59
+ return { success: false, timedOut: true, data: previousData ?? undefined };
60
+ }
61
+
62
+ /**
63
+ * Simple delay utility
64
+ */
65
+ export function delay(ms: number): Promise<void> {
66
+ return new Promise((r) => setTimeout(r, ms));
67
+ }