@brianli/kimaki 0.4.72-brianli.1

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 (328) hide show
  1. package/bin.js +2 -0
  2. package/dist/ai-tool-to-genai.js +233 -0
  3. package/dist/ai-tool-to-genai.test.js +267 -0
  4. package/dist/ai-tool.js +6 -0
  5. package/dist/bin.js +87 -0
  6. package/dist/bot-token.js +121 -0
  7. package/dist/bot-token.test.js +134 -0
  8. package/dist/channel-management.js +101 -0
  9. package/dist/cli-parsing.test.js +89 -0
  10. package/dist/cli.js +2529 -0
  11. package/dist/commands/abort.js +82 -0
  12. package/dist/commands/action-buttons.js +257 -0
  13. package/dist/commands/add-project.js +114 -0
  14. package/dist/commands/agent.js +291 -0
  15. package/dist/commands/ask-question.js +223 -0
  16. package/dist/commands/compact.js +120 -0
  17. package/dist/commands/context-usage.js +140 -0
  18. package/dist/commands/create-new-project.js +118 -0
  19. package/dist/commands/diff.js +128 -0
  20. package/dist/commands/file-upload.js +275 -0
  21. package/dist/commands/fork.js +217 -0
  22. package/dist/commands/gemini-apikey.js +70 -0
  23. package/dist/commands/login.js +490 -0
  24. package/dist/commands/mention-mode.js +51 -0
  25. package/dist/commands/merge-worktree.js +124 -0
  26. package/dist/commands/model.js +694 -0
  27. package/dist/commands/permissions.js +163 -0
  28. package/dist/commands/queue.js +217 -0
  29. package/dist/commands/remove-project.js +115 -0
  30. package/dist/commands/restart-opencode-server.js +116 -0
  31. package/dist/commands/resume.js +159 -0
  32. package/dist/commands/run-command.js +79 -0
  33. package/dist/commands/session-id.js +78 -0
  34. package/dist/commands/session.js +192 -0
  35. package/dist/commands/share.js +80 -0
  36. package/dist/commands/types.js +2 -0
  37. package/dist/commands/undo-redo.js +159 -0
  38. package/dist/commands/unset-model.js +152 -0
  39. package/dist/commands/upgrade.js +42 -0
  40. package/dist/commands/user-command.js +148 -0
  41. package/dist/commands/verbosity.js +60 -0
  42. package/dist/commands/worktree-settings.js +50 -0
  43. package/dist/commands/worktree.js +299 -0
  44. package/dist/condense-memory.js +33 -0
  45. package/dist/config.js +110 -0
  46. package/dist/database.js +1050 -0
  47. package/dist/db.js +159 -0
  48. package/dist/db.test.js +49 -0
  49. package/dist/discord-api.js +28 -0
  50. package/dist/discord-auth.js +231 -0
  51. package/dist/discord-auth.test.js +80 -0
  52. package/dist/discord-bot.js +997 -0
  53. package/dist/discord-utils.js +560 -0
  54. package/dist/discord-utils.test.js +115 -0
  55. package/dist/errors.js +167 -0
  56. package/dist/escape-backticks.test.js +429 -0
  57. package/dist/format-tables.js +122 -0
  58. package/dist/format-tables.test.js +199 -0
  59. package/dist/forum-sync/config.js +79 -0
  60. package/dist/forum-sync/discord-operations.js +154 -0
  61. package/dist/forum-sync/index.js +5 -0
  62. package/dist/forum-sync/markdown.js +117 -0
  63. package/dist/forum-sync/sync-to-discord.js +417 -0
  64. package/dist/forum-sync/sync-to-files.js +190 -0
  65. package/dist/forum-sync/types.js +53 -0
  66. package/dist/forum-sync/watchers.js +307 -0
  67. package/dist/gateway-consumer.js +232 -0
  68. package/dist/gateway-consumer.test.js +18 -0
  69. package/dist/genai-worker-wrapper.js +111 -0
  70. package/dist/genai-worker.js +311 -0
  71. package/dist/genai.js +232 -0
  72. package/dist/generated/browser.js +17 -0
  73. package/dist/generated/client.js +35 -0
  74. package/dist/generated/commonInputTypes.js +10 -0
  75. package/dist/generated/enums.js +30 -0
  76. package/dist/generated/internal/class.js +41 -0
  77. package/dist/generated/internal/prismaNamespace.js +239 -0
  78. package/dist/generated/internal/prismaNamespaceBrowser.js +209 -0
  79. package/dist/generated/models/bot_api_keys.js +1 -0
  80. package/dist/generated/models/bot_tokens.js +1 -0
  81. package/dist/generated/models/channel_agents.js +1 -0
  82. package/dist/generated/models/channel_directories.js +1 -0
  83. package/dist/generated/models/channel_mention_mode.js +1 -0
  84. package/dist/generated/models/channel_models.js +1 -0
  85. package/dist/generated/models/channel_verbosity.js +1 -0
  86. package/dist/generated/models/channel_worktrees.js +1 -0
  87. package/dist/generated/models/forum_sync_configs.js +1 -0
  88. package/dist/generated/models/global_models.js +1 -0
  89. package/dist/generated/models/ipc_requests.js +1 -0
  90. package/dist/generated/models/part_messages.js +1 -0
  91. package/dist/generated/models/scheduled_tasks.js +1 -0
  92. package/dist/generated/models/session_agents.js +1 -0
  93. package/dist/generated/models/session_models.js +1 -0
  94. package/dist/generated/models/session_start_sources.js +1 -0
  95. package/dist/generated/models/thread_sessions.js +1 -0
  96. package/dist/generated/models/thread_worktrees.js +1 -0
  97. package/dist/generated/models.js +1 -0
  98. package/dist/heap-monitor.js +95 -0
  99. package/dist/hrana-server.js +416 -0
  100. package/dist/hrana-server.test.js +368 -0
  101. package/dist/image-utils.js +112 -0
  102. package/dist/interaction-handler.js +327 -0
  103. package/dist/ipc-polling.js +251 -0
  104. package/dist/kimaki-digital-twin.e2e.test.js +165 -0
  105. package/dist/limit-heading-depth.js +25 -0
  106. package/dist/limit-heading-depth.test.js +105 -0
  107. package/dist/logger.js +160 -0
  108. package/dist/markdown.js +342 -0
  109. package/dist/markdown.test.js +253 -0
  110. package/dist/message-formatting.js +433 -0
  111. package/dist/message-formatting.test.js +73 -0
  112. package/dist/openai-realtime.js +228 -0
  113. package/dist/opencode-plugin-loading.e2e.test.js +91 -0
  114. package/dist/opencode-plugin.js +536 -0
  115. package/dist/opencode-plugin.test.js +98 -0
  116. package/dist/opencode.js +409 -0
  117. package/dist/privacy-sanitizer.js +105 -0
  118. package/dist/runtime-mode.js +51 -0
  119. package/dist/runtime-mode.test.js +115 -0
  120. package/dist/sentry.js +127 -0
  121. package/dist/session-handler/state.js +151 -0
  122. package/dist/session-handler.js +1874 -0
  123. package/dist/session-search.js +100 -0
  124. package/dist/session-search.test.js +40 -0
  125. package/dist/startup-service.js +153 -0
  126. package/dist/system-message.js +499 -0
  127. package/dist/task-runner.js +282 -0
  128. package/dist/task-schedule.js +191 -0
  129. package/dist/task-schedule.test.js +71 -0
  130. package/dist/thinking-utils.js +35 -0
  131. package/dist/thread-message-queue.e2e.test.js +781 -0
  132. package/dist/tools.js +359 -0
  133. package/dist/unnest-code-blocks.js +136 -0
  134. package/dist/unnest-code-blocks.test.js +641 -0
  135. package/dist/upgrade.js +114 -0
  136. package/dist/utils.js +109 -0
  137. package/dist/voice-handler.js +606 -0
  138. package/dist/voice.js +304 -0
  139. package/dist/voice.test.js +187 -0
  140. package/dist/wait-session.js +94 -0
  141. package/dist/worker-types.js +4 -0
  142. package/dist/worktree-utils.js +727 -0
  143. package/dist/xml.js +92 -0
  144. package/dist/xml.test.js +32 -0
  145. package/package.json +82 -0
  146. package/schema.prisma +246 -0
  147. package/skills/batch/SKILL.md +87 -0
  148. package/skills/critique/SKILL.md +129 -0
  149. package/skills/errore/SKILL.md +589 -0
  150. package/skills/goke/.prettierrc +5 -0
  151. package/skills/goke/CHANGELOG.md +40 -0
  152. package/skills/goke/LICENSE +21 -0
  153. package/skills/goke/README.md +666 -0
  154. package/skills/goke/SKILL.md +458 -0
  155. package/skills/goke/package.json +43 -0
  156. package/skills/goke/src/__test__/coerce.test.ts +411 -0
  157. package/skills/goke/src/__test__/index.test.ts +1798 -0
  158. package/skills/goke/src/__test__/types.test-d.ts +111 -0
  159. package/skills/goke/src/coerce.ts +547 -0
  160. package/skills/goke/src/goke.ts +1362 -0
  161. package/skills/goke/src/index.ts +16 -0
  162. package/skills/goke/src/mri.ts +164 -0
  163. package/skills/goke/tsconfig.json +15 -0
  164. package/skills/jitter/EDITOR.md +219 -0
  165. package/skills/jitter/EXPORT-INTERNALS.md +309 -0
  166. package/skills/jitter/SKILL.md +158 -0
  167. package/skills/jitter/jitter-clipboard.json +1042 -0
  168. package/skills/jitter/package.json +14 -0
  169. package/skills/jitter/tsconfig.json +15 -0
  170. package/skills/jitter/utils/actions.ts +212 -0
  171. package/skills/jitter/utils/export.ts +114 -0
  172. package/skills/jitter/utils/index.ts +141 -0
  173. package/skills/jitter/utils/snapshot.ts +154 -0
  174. package/skills/jitter/utils/traverse.ts +246 -0
  175. package/skills/jitter/utils/types.ts +279 -0
  176. package/skills/jitter/utils/wait.ts +133 -0
  177. package/skills/playwriter/SKILL.md +31 -0
  178. package/skills/security-review/SKILL.md +208 -0
  179. package/skills/simplify/SKILL.md +58 -0
  180. package/skills/termcast/SKILL.md +945 -0
  181. package/skills/tuistory/SKILL.md +250 -0
  182. package/skills/zustand-centralized-state/SKILL.md +582 -0
  183. package/src/__snapshots__/compact-session-context-no-system.md +35 -0
  184. package/src/__snapshots__/compact-session-context.md +41 -0
  185. package/src/__snapshots__/first-session-no-info.md +17 -0
  186. package/src/__snapshots__/first-session-with-info.md +23 -0
  187. package/src/__snapshots__/session-1.md +17 -0
  188. package/src/__snapshots__/session-2.md +5871 -0
  189. package/src/__snapshots__/session-3.md +17 -0
  190. package/src/__snapshots__/session-with-tools.md +5871 -0
  191. package/src/ai-tool-to-genai.test.ts +296 -0
  192. package/src/ai-tool-to-genai.ts +282 -0
  193. package/src/ai-tool.ts +39 -0
  194. package/src/bin.ts +108 -0
  195. package/src/bot-token.test.ts +171 -0
  196. package/src/bot-token.ts +159 -0
  197. package/src/channel-management.ts +172 -0
  198. package/src/cli-parsing.test.ts +132 -0
  199. package/src/cli.ts +3605 -0
  200. package/src/commands/abort.ts +112 -0
  201. package/src/commands/action-buttons.ts +376 -0
  202. package/src/commands/add-project.ts +152 -0
  203. package/src/commands/agent.ts +404 -0
  204. package/src/commands/ask-question.ts +330 -0
  205. package/src/commands/compact.ts +157 -0
  206. package/src/commands/context-usage.ts +199 -0
  207. package/src/commands/create-new-project.ts +179 -0
  208. package/src/commands/diff.ts +165 -0
  209. package/src/commands/file-upload.ts +389 -0
  210. package/src/commands/fork.ts +320 -0
  211. package/src/commands/gemini-apikey.ts +104 -0
  212. package/src/commands/login.ts +634 -0
  213. package/src/commands/mention-mode.ts +77 -0
  214. package/src/commands/merge-worktree.ts +177 -0
  215. package/src/commands/model.ts +961 -0
  216. package/src/commands/permissions.ts +261 -0
  217. package/src/commands/queue.ts +296 -0
  218. package/src/commands/remove-project.ts +155 -0
  219. package/src/commands/restart-opencode-server.ts +162 -0
  220. package/src/commands/resume.ts +242 -0
  221. package/src/commands/run-command.ts +123 -0
  222. package/src/commands/session-id.ts +109 -0
  223. package/src/commands/session.ts +250 -0
  224. package/src/commands/share.ts +106 -0
  225. package/src/commands/types.ts +25 -0
  226. package/src/commands/undo-redo.ts +221 -0
  227. package/src/commands/unset-model.ts +189 -0
  228. package/src/commands/upgrade.ts +52 -0
  229. package/src/commands/user-command.ts +193 -0
  230. package/src/commands/verbosity.ts +88 -0
  231. package/src/commands/worktree-settings.ts +79 -0
  232. package/src/commands/worktree.ts +431 -0
  233. package/src/condense-memory.ts +36 -0
  234. package/src/config.ts +148 -0
  235. package/src/database.ts +1530 -0
  236. package/src/db.test.ts +60 -0
  237. package/src/db.ts +190 -0
  238. package/src/discord-api.ts +35 -0
  239. package/src/discord-bot.ts +1316 -0
  240. package/src/discord-utils.test.ts +132 -0
  241. package/src/discord-utils.ts +767 -0
  242. package/src/errors.ts +213 -0
  243. package/src/escape-backticks.test.ts +469 -0
  244. package/src/format-tables.test.ts +223 -0
  245. package/src/format-tables.ts +145 -0
  246. package/src/forum-sync/config.ts +92 -0
  247. package/src/forum-sync/discord-operations.ts +241 -0
  248. package/src/forum-sync/index.ts +9 -0
  249. package/src/forum-sync/markdown.ts +176 -0
  250. package/src/forum-sync/sync-to-discord.ts +595 -0
  251. package/src/forum-sync/sync-to-files.ts +294 -0
  252. package/src/forum-sync/types.ts +175 -0
  253. package/src/forum-sync/watchers.ts +454 -0
  254. package/src/genai-worker-wrapper.ts +164 -0
  255. package/src/genai-worker.ts +386 -0
  256. package/src/genai.ts +321 -0
  257. package/src/generated/browser.ts +109 -0
  258. package/src/generated/client.ts +131 -0
  259. package/src/generated/commonInputTypes.ts +512 -0
  260. package/src/generated/enums.ts +46 -0
  261. package/src/generated/internal/class.ts +362 -0
  262. package/src/generated/internal/prismaNamespace.ts +2251 -0
  263. package/src/generated/internal/prismaNamespaceBrowser.ts +308 -0
  264. package/src/generated/models/bot_api_keys.ts +1288 -0
  265. package/src/generated/models/bot_tokens.ts +1577 -0
  266. package/src/generated/models/channel_agents.ts +1256 -0
  267. package/src/generated/models/channel_directories.ts +2104 -0
  268. package/src/generated/models/channel_mention_mode.ts +1300 -0
  269. package/src/generated/models/channel_models.ts +1288 -0
  270. package/src/generated/models/channel_verbosity.ts +1224 -0
  271. package/src/generated/models/channel_worktrees.ts +1308 -0
  272. package/src/generated/models/forum_sync_configs.ts +1452 -0
  273. package/src/generated/models/global_models.ts +1288 -0
  274. package/src/generated/models/ipc_requests.ts +1485 -0
  275. package/src/generated/models/part_messages.ts +1302 -0
  276. package/src/generated/models/scheduled_tasks.ts +2320 -0
  277. package/src/generated/models/session_agents.ts +1086 -0
  278. package/src/generated/models/session_models.ts +1114 -0
  279. package/src/generated/models/session_start_sources.ts +1408 -0
  280. package/src/generated/models/thread_sessions.ts +1599 -0
  281. package/src/generated/models/thread_worktrees.ts +1352 -0
  282. package/src/generated/models.ts +29 -0
  283. package/src/heap-monitor.ts +121 -0
  284. package/src/hrana-server.test.ts +428 -0
  285. package/src/hrana-server.ts +547 -0
  286. package/src/image-utils.ts +149 -0
  287. package/src/interaction-handler.ts +461 -0
  288. package/src/ipc-polling.ts +325 -0
  289. package/src/kimaki-digital-twin.e2e.test.ts +201 -0
  290. package/src/limit-heading-depth.test.ts +116 -0
  291. package/src/limit-heading-depth.ts +26 -0
  292. package/src/logger.ts +203 -0
  293. package/src/markdown.test.ts +360 -0
  294. package/src/markdown.ts +410 -0
  295. package/src/message-formatting.test.ts +81 -0
  296. package/src/message-formatting.ts +549 -0
  297. package/src/openai-realtime.ts +362 -0
  298. package/src/opencode-plugin-loading.e2e.test.ts +112 -0
  299. package/src/opencode-plugin.test.ts +108 -0
  300. package/src/opencode-plugin.ts +652 -0
  301. package/src/opencode.ts +554 -0
  302. package/src/privacy-sanitizer.ts +142 -0
  303. package/src/schema.sql +158 -0
  304. package/src/sentry.ts +137 -0
  305. package/src/session-handler/state.ts +232 -0
  306. package/src/session-handler.ts +2668 -0
  307. package/src/session-search.test.ts +50 -0
  308. package/src/session-search.ts +148 -0
  309. package/src/startup-service.ts +200 -0
  310. package/src/system-message.ts +568 -0
  311. package/src/task-runner.ts +425 -0
  312. package/src/task-schedule.test.ts +84 -0
  313. package/src/task-schedule.ts +287 -0
  314. package/src/thinking-utils.ts +61 -0
  315. package/src/thread-message-queue.e2e.test.ts +997 -0
  316. package/src/tools.ts +432 -0
  317. package/src/unnest-code-blocks.test.ts +679 -0
  318. package/src/unnest-code-blocks.ts +168 -0
  319. package/src/upgrade.ts +127 -0
  320. package/src/utils.ts +145 -0
  321. package/src/voice-handler.ts +852 -0
  322. package/src/voice.test.ts +219 -0
  323. package/src/voice.ts +444 -0
  324. package/src/wait-session.ts +147 -0
  325. package/src/worker-types.ts +64 -0
  326. package/src/worktree-utils.ts +988 -0
  327. package/src/xml.test.ts +38 -0
  328. package/src/xml.ts +121 -0
@@ -0,0 +1,554 @@
1
+ // OpenCode server process manager.
2
+ // Spawns and maintains OpenCode API servers per project directory,
3
+ // handles automatic restarts on failure, and provides typed SDK clients.
4
+ // Uses errore for type-safe error handling.
5
+
6
+ import { spawn, type ChildProcess } from 'node:child_process'
7
+ import fs from 'node:fs'
8
+ import net from 'node:net'
9
+ import os from 'node:os'
10
+ import path from 'node:path'
11
+ import { fileURLToPath } from 'node:url'
12
+
13
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
14
+ import {
15
+ createOpencodeClient,
16
+ type OpencodeClient,
17
+ type Config as SdkConfig,
18
+ } from '@opencode-ai/sdk/v2'
19
+ import { getBotToken } from './bot-token.js'
20
+ import {
21
+ getDataDir,
22
+ getLockPort,
23
+ getVerboseOpencodeServer,
24
+ } from './config.js'
25
+ import { getHranaUrl } from './hrana-server.js'
26
+
27
+ // SDK Config type is simplified; opencode accepts nested permission objects with path patterns
28
+ type PermissionAction = 'ask' | 'allow' | 'deny'
29
+ type PermissionRule = PermissionAction | Record<string, PermissionAction>
30
+ type Config = Omit<SdkConfig, 'permission'> & {
31
+ permission?: {
32
+ edit?: PermissionRule
33
+ bash?: PermissionRule
34
+ external_directory?: PermissionRule
35
+ webfetch?: PermissionRule
36
+ [key: string]: PermissionRule | undefined
37
+ }
38
+ }
39
+ import * as errore from 'errore'
40
+ import { createLogger, LogPrefix } from './logger.js'
41
+ import { notifyError } from './sentry.js'
42
+ import {
43
+ DirectoryNotAccessibleError,
44
+ ServerStartError,
45
+ ServerNotReadyError,
46
+ FetchError,
47
+ type OpenCodeErrors,
48
+ } from './errors.js'
49
+
50
+ const opencodeLogger = createLogger(LogPrefix.OPENCODE)
51
+
52
+ const STARTUP_STDERR_TAIL_LIMIT = 30
53
+ const STARTUP_STDERR_LINE_MAX_LENGTH = 120
54
+ const STARTUP_ERROR_REASON_MAX_LENGTH = 1500
55
+ const ANSI_ESCAPE_REGEX =
56
+ /[\u001B\u009B][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[a-zA-Z\d]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g
57
+
58
+ function truncateWithEllipsis({
59
+ value,
60
+ maxLength,
61
+ }: {
62
+ value: string
63
+ maxLength: number
64
+ }): string {
65
+ if (maxLength <= 3) {
66
+ return value.slice(0, maxLength)
67
+ }
68
+ if (value.length <= maxLength) {
69
+ return value
70
+ }
71
+ return `${value.slice(0, maxLength - 3)}...`
72
+ }
73
+
74
+ function stripAnsiCodes(value: string): string {
75
+ return value.replaceAll(ANSI_ESCAPE_REGEX, '')
76
+ }
77
+
78
+ function splitOutputChunkLines(chunk: string): string[] {
79
+ return chunk
80
+ .split(/\r?\n/g)
81
+ .map((line) => stripAnsiCodes(line).trim())
82
+ .filter((line) => line.length > 0)
83
+ }
84
+
85
+ function sanitizeForCodeFence(line: string): string {
86
+ return line.replaceAll('```', '`\u200b``')
87
+ }
88
+
89
+ function pushStartupStderrTail({
90
+ stderrTail,
91
+ chunk,
92
+ }: {
93
+ stderrTail: string[]
94
+ chunk: string
95
+ }): void {
96
+ const incomingLines = splitOutputChunkLines(chunk)
97
+ const truncatedLines = incomingLines.map((line) => {
98
+ const sanitizedLine = sanitizeForCodeFence(line)
99
+ return truncateWithEllipsis({
100
+ value: sanitizedLine,
101
+ maxLength: STARTUP_STDERR_LINE_MAX_LENGTH,
102
+ })
103
+ })
104
+ stderrTail.push(...truncatedLines)
105
+ if (stderrTail.length > STARTUP_STDERR_TAIL_LIMIT) {
106
+ stderrTail.splice(0, stderrTail.length - STARTUP_STDERR_TAIL_LIMIT)
107
+ }
108
+ }
109
+
110
+ function buildStartupTimeoutReason({
111
+ maxAttempts,
112
+ stderrTail,
113
+ }: {
114
+ maxAttempts: number
115
+ stderrTail: string[]
116
+ }): string {
117
+ const baseReason = `Server did not start after ${maxAttempts} seconds`
118
+ if (stderrTail.length === 0) {
119
+ return baseReason
120
+ }
121
+
122
+ const formatReason = ({
123
+ lines,
124
+ omitted,
125
+ }: {
126
+ lines: string[]
127
+ omitted: number
128
+ }): string => {
129
+ const omittedLine =
130
+ omitted > 0
131
+ ? `[... ${omitted} older stderr lines omitted to fit Discord ...]\n`
132
+ : ''
133
+ const stderrCodeBlock = `${omittedLine}${lines.join('\n')}`
134
+ return `${baseReason}\nLast opencode stderr lines:\n\`\`\`text\n${stderrCodeBlock}\n\`\`\``
135
+ }
136
+
137
+ let lines = [...stderrTail]
138
+ let omitted = 0
139
+ let formattedReason = formatReason({ lines, omitted })
140
+
141
+ while (
142
+ formattedReason.length > STARTUP_ERROR_REASON_MAX_LENGTH &&
143
+ lines.length > 0
144
+ ) {
145
+ lines = lines.slice(1)
146
+ omitted += 1
147
+ formattedReason = formatReason({ lines, omitted })
148
+ }
149
+
150
+ return truncateWithEllipsis({
151
+ value: formattedReason,
152
+ maxLength: STARTUP_ERROR_REASON_MAX_LENGTH,
153
+ })
154
+ }
155
+
156
+ type ServerInitOptions = { originalRepoDirectory?: string; channelId?: string }
157
+
158
+ const opencodeServers = new Map<
159
+ string,
160
+ {
161
+ process: ChildProcess
162
+ client: OpencodeClient
163
+ port: number
164
+ /** Original options used to spawn this server, reused on auto-restart */
165
+ initOptions?: ServerInitOptions
166
+ }
167
+ >()
168
+
169
+ const serverRetryCount = new Map<string, number>()
170
+
171
+ async function getOpenPort(): Promise<number> {
172
+ return new Promise((resolve, reject) => {
173
+ const server = net.createServer()
174
+ server.listen(0, () => {
175
+ const address = server.address()
176
+ if (address && typeof address === 'object') {
177
+ const port = address.port
178
+ server.close(() => {
179
+ resolve(port)
180
+ })
181
+ } else {
182
+ reject(new Error('Failed to get port'))
183
+ }
184
+ })
185
+ server.on('error', reject)
186
+ })
187
+ }
188
+
189
+ async function waitForServer({
190
+ port,
191
+ maxAttempts = 30,
192
+ startupStderrTail,
193
+ }: {
194
+ port: number
195
+ maxAttempts?: number
196
+ startupStderrTail: string[]
197
+ }): Promise<ServerStartError | true> {
198
+ const endpoint = `http://127.0.0.1:${port}/api/health`
199
+ for (let i = 0; i < maxAttempts; i++) {
200
+ const response = await errore.tryAsync({
201
+ try: () => fetch(endpoint),
202
+ catch: (e) => new FetchError({ url: endpoint, cause: e }),
203
+ })
204
+ if (response instanceof Error) {
205
+ // Connection refused or other transient errors - continue polling
206
+ await new Promise((resolve) => setTimeout(resolve, 1000))
207
+ continue
208
+ }
209
+ if (response.status < 500) {
210
+ return true
211
+ }
212
+ const body = await response.text()
213
+ // Fatal errors that won't resolve with retrying
214
+ if (body.includes('BunInstallFailedError')) {
215
+ return new ServerStartError({ port, reason: body.slice(0, 200) })
216
+ }
217
+ await new Promise((resolve) => setTimeout(resolve, 1000))
218
+ }
219
+ return new ServerStartError({
220
+ port,
221
+ reason: buildStartupTimeoutReason({
222
+ maxAttempts,
223
+ stderrTail: startupStderrTail,
224
+ }),
225
+ })
226
+ }
227
+
228
+ /**
229
+ * Initialize OpenCode server for a directory.
230
+ * @param directory - The directory to run the server in (cwd)
231
+ * @param options.originalRepoDirectory - For worktrees: the original repo directory to allow access to
232
+ */
233
+ export async function initializeOpencodeForDirectory(
234
+ directory: string,
235
+ options?: { originalRepoDirectory?: string; channelId?: string },
236
+ ): Promise<OpenCodeErrors | (() => OpencodeClient)> {
237
+ const existing = opencodeServers.get(directory)
238
+ if (existing && !existing.process.killed) {
239
+ opencodeLogger.log(
240
+ `Reusing existing server on port ${existing.port} for directory: ${directory}`,
241
+ )
242
+ return () => {
243
+ const entry = opencodeServers.get(directory)
244
+ if (!entry?.client) {
245
+ throw new ServerNotReadyError({ directory })
246
+ }
247
+ return entry.client
248
+ }
249
+ }
250
+
251
+ // Verify directory exists and is accessible before spawning
252
+ const accessCheck = errore.tryFn({
253
+ try: () => {
254
+ fs.accessSync(directory, fs.constants.R_OK | fs.constants.X_OK)
255
+ },
256
+ catch: () => new DirectoryNotAccessibleError({ directory }),
257
+ })
258
+ if (accessCheck instanceof Error) {
259
+ return accessCheck
260
+ }
261
+
262
+ const port = await getOpenPort()
263
+
264
+ const opencodeCommand = process.env.OPENCODE_PATH || 'opencode'
265
+
266
+ // Normalize path separators for cross-platform compatibility (Windows uses backslashes)
267
+ const tmpdir = os.tmpdir().replaceAll('\\', '/')
268
+ const originalRepo = options?.originalRepoDirectory?.replaceAll('\\', '/')
269
+ const normalizedDirectory = directory.replaceAll('\\', '/')
270
+
271
+ // Build external_directory permissions, optionally including original repo for worktrees.
272
+ const externalDirectoryPermissions: Record<string, PermissionAction> = {
273
+ '*': 'ask',
274
+ '/tmp': 'allow',
275
+ '/tmp/*': 'allow',
276
+ '/private/tmp': 'allow',
277
+ '/private/tmp/*': 'allow',
278
+ [tmpdir]: 'allow',
279
+ [`${tmpdir}/*`]: 'allow',
280
+ [normalizedDirectory]: 'allow',
281
+ [`${normalizedDirectory}/*`]: 'allow',
282
+ }
283
+ // Allow ~/.config/opencode so the agent doesn't get permission prompts when
284
+ // it tries to read the global AGENTS.md or opencode config (the path is
285
+ // visible in the system prompt, so models sometimes try to read it).
286
+ const opencodeConfigDir = path
287
+ .join(os.homedir(), '.config', 'opencode')
288
+ .replaceAll('\\', '/')
289
+ externalDirectoryPermissions[opencodeConfigDir] = 'allow'
290
+ externalDirectoryPermissions[`${opencodeConfigDir}/*`] = 'allow'
291
+
292
+ if (originalRepo) {
293
+ externalDirectoryPermissions[originalRepo] = 'allow'
294
+ externalDirectoryPermissions[`${originalRepo}/*`] = 'allow'
295
+ }
296
+
297
+ // Get bot token for plugin to use Discord API (env first, DB fallback)
298
+ const kimakiBotToken = getBotToken()?.token
299
+
300
+ const serveArgs = ['serve', '--port', port.toString()]
301
+ if (getVerboseOpencodeServer()) {
302
+ serveArgs.push('--print-logs', '--log-level', 'DEBUG')
303
+ }
304
+
305
+ const serverProcess = spawn(
306
+ opencodeCommand,
307
+ serveArgs,
308
+ {
309
+ stdio: 'pipe',
310
+ detached: false,
311
+ cwd: directory,
312
+ shell: true, // Required for .cmd files on Windows
313
+ env: {
314
+ ...process.env,
315
+ OPENCODE_CONFIG_CONTENT: JSON.stringify({
316
+ $schema: 'https://opencode.ai/config.json',
317
+ lsp: false,
318
+ formatter: false,
319
+ plugin: [new URL('../src/opencode-plugin.ts', import.meta.url).href],
320
+ permission: {
321
+ edit: 'allow',
322
+ bash: 'allow',
323
+ external_directory: externalDirectoryPermissions,
324
+ webfetch: 'allow',
325
+ },
326
+ agent: {
327
+ explore: {
328
+ permission: {
329
+ '*': 'deny',
330
+ grep: 'allow',
331
+ glob: 'allow',
332
+ list: 'allow',
333
+ read: {
334
+ '*': 'allow',
335
+ '*.env': 'deny',
336
+ '*.env.*': 'deny',
337
+ '*.env.example': 'allow',
338
+ },
339
+ webfetch: 'allow',
340
+ websearch: 'allow',
341
+ codesearch: 'allow',
342
+ external_directory: externalDirectoryPermissions,
343
+ },
344
+ },
345
+ },
346
+ skills: {
347
+ paths: [path.resolve(__dirname, '..', 'skills')],
348
+ },
349
+ } satisfies Config),
350
+ OPENCODE_PORT: port.toString(),
351
+ KIMAKI_DATA_DIR: getDataDir(),
352
+ KIMAKI_LOCK_PORT: getLockPort().toString(),
353
+ ...(kimakiBotToken && { KIMAKI_BOT_TOKEN: kimakiBotToken }),
354
+
355
+ ...(getHranaUrl() && { KIMAKI_DB_URL: getHranaUrl()! }),
356
+ ...(process.env.KIMAKI_SENTRY_DSN && {
357
+ KIMAKI_SENTRY_DSN: process.env.KIMAKI_SENTRY_DSN,
358
+ }),
359
+ },
360
+ },
361
+ )
362
+
363
+ // Buffer logs until we know if server started successfully.
364
+ // Once ready, switch to forwarding if --verbose-opencode-server is set.
365
+ const logBuffer: string[] = []
366
+ const startupStderrTail: string[] = []
367
+ let serverReady = false
368
+ const shortDir = path.basename(directory)
369
+
370
+ logBuffer.push(
371
+ `Spawned opencode serve --port ${port} in ${directory} (pid: ${serverProcess.pid})`,
372
+ )
373
+
374
+ serverProcess.stdout?.on('data', (data) => {
375
+ try {
376
+ const chunk = data.toString()
377
+ const lines = splitOutputChunkLines(chunk)
378
+ if (!serverReady) {
379
+ logBuffer.push(...lines.map((line) => `[stdout] ${line}`))
380
+ return
381
+ }
382
+ if (getVerboseOpencodeServer()) {
383
+ for (const line of lines) {
384
+ opencodeLogger.log(`[${shortDir}:${port}] ${line}`)
385
+ }
386
+ }
387
+ } catch (error) {
388
+ logBuffer.push(`Failed to process stdout startup logs: ${error}`)
389
+ }
390
+ })
391
+
392
+ serverProcess.stderr?.on('data', (data) => {
393
+ try {
394
+ const chunk = data.toString()
395
+ const lines = splitOutputChunkLines(chunk)
396
+ if (!serverReady) {
397
+ logBuffer.push(...lines.map((line) => `[stderr] ${line}`))
398
+ pushStartupStderrTail({ stderrTail: startupStderrTail, chunk })
399
+ return
400
+ }
401
+ if (getVerboseOpencodeServer()) {
402
+ for (const line of lines) {
403
+ opencodeLogger.error(`[${shortDir}:${port}] ${line}`)
404
+ }
405
+ }
406
+ } catch (error) {
407
+ logBuffer.push(`Failed to process stderr startup logs: ${error}`)
408
+ }
409
+ })
410
+
411
+ serverProcess.on('error', (error) => {
412
+ logBuffer.push(`Failed to start server on port ${port}: ${error}`)
413
+ })
414
+
415
+ serverProcess.on('exit', (code) => {
416
+ opencodeLogger.log(
417
+ `Opencode server on ${directory} exited with code:`,
418
+ code,
419
+ )
420
+ // Capture init options before deleting the entry so auto-restart preserves
421
+ // worktree repo access.
422
+ const storedInitOptions = opencodeServers.get(directory)?.initOptions
423
+ opencodeServers.delete(directory)
424
+ if (code !== 0) {
425
+ const retryCount = serverRetryCount.get(directory) || 0
426
+ if (retryCount < 5) {
427
+ serverRetryCount.set(directory, retryCount + 1)
428
+ opencodeLogger.log(
429
+ `Restarting server for directory: ${directory} (attempt ${retryCount + 1}/5)`,
430
+ )
431
+ initializeOpencodeForDirectory(directory, storedInitOptions).then(
432
+ (result) => {
433
+ if (result instanceof Error) {
434
+ opencodeLogger.error(`Failed to restart opencode server:`, result)
435
+ void notifyError(result, `OpenCode server restart failed for ${directory}`)
436
+ }
437
+ },
438
+ )
439
+ } else {
440
+ const crashError = new Error(
441
+ `Server for ${directory} crashed too many times (5), not restarting`,
442
+ )
443
+ opencodeLogger.error(crashError.message)
444
+ void notifyError(crashError, `OpenCode server crash loop exhausted`)
445
+ }
446
+ } else {
447
+ serverRetryCount.delete(directory)
448
+ }
449
+ })
450
+
451
+ const waitResult = await waitForServer({
452
+ port,
453
+ startupStderrTail,
454
+ })
455
+ if (waitResult instanceof Error) {
456
+ // Dump buffered logs on failure
457
+ opencodeLogger.error(`Server failed to start for ${directory}:`)
458
+ for (const line of logBuffer) {
459
+ opencodeLogger.error(` ${line}`)
460
+ }
461
+ return waitResult
462
+ }
463
+ serverReady = true
464
+ opencodeLogger.log(`Server ready on port ${port}`)
465
+
466
+ // When verbose mode is enabled, also dump startup logs so plugin loading
467
+ // errors and other startup output are visible in kimaki.log.
468
+ if (getVerboseOpencodeServer()) {
469
+ for (const line of logBuffer) {
470
+ opencodeLogger.log(`[${shortDir}:${port}:startup] ${line}`)
471
+ }
472
+ }
473
+
474
+ const baseUrl = `http://127.0.0.1:${port}`
475
+ const fetchWithTimeout = (request: Request) =>
476
+ fetch(request, {
477
+ // @ts-ignore
478
+ timeout: false,
479
+ })
480
+
481
+ const client = createOpencodeClient({
482
+ baseUrl,
483
+ fetch: fetchWithTimeout as typeof fetch,
484
+ })
485
+
486
+ opencodeServers.set(directory, {
487
+ process: serverProcess,
488
+ client,
489
+ port,
490
+ initOptions: options,
491
+ })
492
+
493
+ return () => {
494
+ const entry = opencodeServers.get(directory)
495
+ if (!entry?.client) {
496
+ throw new ServerNotReadyError({ directory })
497
+ }
498
+ return entry.client
499
+ }
500
+ }
501
+
502
+ export function getOpencodeServers() {
503
+ return opencodeServers
504
+ }
505
+
506
+ export function getOpencodeServerPort(directory: string): number | null {
507
+ const entry = opencodeServers.get(directory)
508
+ return entry?.port ?? null
509
+ }
510
+
511
+ export function getOpencodeClient(directory: string): OpencodeClient | null {
512
+ const entry = opencodeServers.get(directory)
513
+ return entry?.client ?? null
514
+ }
515
+
516
+ /**
517
+ * Restart the opencode server for a directory.
518
+ * Kills the existing process and reinitializes a new one.
519
+ * Used for resolving opencode state issues, refreshing auth, plugins, etc.
520
+ */
521
+ export async function restartOpencodeServer(
522
+ directory: string,
523
+ ): Promise<OpenCodeErrors | true> {
524
+ const existing = opencodeServers.get(directory)
525
+ // Preserve init options (originalRepoDirectory) so the restarted
526
+ // server retains worktree access.
527
+ const storedInitOptions = existing?.initOptions
528
+
529
+ if (existing) {
530
+ opencodeLogger.log(
531
+ `Killing existing server for directory: ${directory} (pid: ${existing.process.pid})`,
532
+ )
533
+ // Reset retry count so the exit handler doesn't auto-restart
534
+ serverRetryCount.set(directory, 999)
535
+ existing.process.kill('SIGTERM')
536
+ opencodeServers.delete(directory)
537
+ // Give the process time to fully terminate
538
+ await new Promise((resolve) => {
539
+ setTimeout(resolve, 1000)
540
+ })
541
+ }
542
+
543
+ // Reset retry count for the fresh start
544
+ serverRetryCount.delete(directory)
545
+
546
+ const result = await initializeOpencodeForDirectory(
547
+ directory,
548
+ storedInitOptions,
549
+ )
550
+ if (result instanceof Error) {
551
+ return result
552
+ }
553
+ return true
554
+ }
@@ -0,0 +1,142 @@
1
+ // Sensitive data redaction helpers for logs and telemetry payloads.
2
+ // Redacts common secrets, identifiers, emails, and can optionally redact paths.
3
+
4
+ const CORE_SENSITIVE_REPLACEMENTS: Array<{
5
+ pattern: RegExp
6
+ replacement: string
7
+ }> = [
8
+ {
9
+ pattern: /\bBearer\s+[A-Za-z0-9._-]{10,}\b/gi,
10
+ replacement: 'Bearer [REDACTED]',
11
+ },
12
+ {
13
+ pattern: /\bsk-[A-Za-z0-9]{16,}\b/g,
14
+ replacement: '[REDACTED_OPENAI_KEY]',
15
+ },
16
+ {
17
+ pattern: /\bAIza[0-9A-Za-z_-]{20,}\b/g,
18
+ replacement: '[REDACTED_GOOGLE_KEY]',
19
+ },
20
+ {
21
+ pattern: /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g,
22
+ replacement: '[REDACTED_GITHUB_TOKEN]',
23
+ },
24
+ {
25
+ pattern:
26
+ /([?&](?:token|api[_-]?key|key|secret|password|authorization)=)[^&\s]+/gi,
27
+ replacement: '$1[REDACTED]',
28
+ },
29
+ {
30
+ pattern:
31
+ /(\b(?:token|api[_-]?key|secret|password|authorization)\b\s*[:=]\s*")([^"]+)(")/gi,
32
+ replacement: '$1[REDACTED]$3',
33
+ },
34
+ {
35
+ pattern:
36
+ /(\b(?:token|api[_-]?key|secret|password|authorization)\b\s*[:=]\s*)([^\s,;]+)/gi,
37
+ replacement: '$1[REDACTED]',
38
+ },
39
+ ]
40
+
41
+ const PATH_REPLACEMENTS: Array<{
42
+ pattern: RegExp
43
+ replacement: string
44
+ }> = [
45
+ {
46
+ pattern: /\/(?:Users|home)\/[^/\s]+\/[^\s'"`)]*/g,
47
+ replacement: '[REDACTED_PATH]',
48
+ },
49
+ {
50
+ pattern: /[A-Za-z]:\\[^\s'"`)]*/g,
51
+ replacement: '[REDACTED_PATH]',
52
+ },
53
+ ]
54
+
55
+ export function sanitizeSensitiveText(
56
+ value: string,
57
+ { redactPaths = false }: { redactPaths?: boolean } = {},
58
+ ): string {
59
+ const replacements = redactPaths
60
+ ? [...CORE_SENSITIVE_REPLACEMENTS, ...PATH_REPLACEMENTS]
61
+ : CORE_SENSITIVE_REPLACEMENTS
62
+ return replacements.reduce((current, entry) => {
63
+ return current.replace(entry.pattern, entry.replacement)
64
+ }, value)
65
+ }
66
+
67
+ export function sanitizeUnknownValue(
68
+ value: unknown,
69
+ {
70
+ depth = 0,
71
+ seen = new WeakSet<object>(),
72
+ redactPaths = false,
73
+ }: {
74
+ depth?: number
75
+ seen?: WeakSet<object>
76
+ redactPaths?: boolean
77
+ } = {},
78
+ ): unknown {
79
+ if (depth > 8) {
80
+ return '[REDACTED_DEPTH_LIMIT]'
81
+ }
82
+
83
+ if (typeof value === 'string') {
84
+ return sanitizeSensitiveText(value, { redactPaths })
85
+ }
86
+
87
+ if (
88
+ typeof value === 'number' ||
89
+ typeof value === 'boolean' ||
90
+ value === null ||
91
+ value === undefined
92
+ ) {
93
+ return value
94
+ }
95
+
96
+ if (value instanceof Date) {
97
+ return value.toISOString()
98
+ }
99
+
100
+ if (value instanceof Error) {
101
+ const sanitizedStack = value.stack
102
+ ? sanitizeSensitiveText(value.stack, { redactPaths })
103
+ : undefined
104
+ return {
105
+ name: value.name,
106
+ message: sanitizeSensitiveText(value.message, { redactPaths }),
107
+ stack: sanitizedStack,
108
+ cause: sanitizeUnknownValue(value.cause, {
109
+ depth: depth + 1,
110
+ seen,
111
+ redactPaths,
112
+ }),
113
+ }
114
+ }
115
+
116
+ if (Array.isArray(value)) {
117
+ return value.map((item) => {
118
+ return sanitizeUnknownValue(item, { depth: depth + 1, seen, redactPaths })
119
+ })
120
+ }
121
+
122
+ if (typeof value === 'object') {
123
+ if (seen.has(value)) {
124
+ return '[REDACTED_CIRCULAR]'
125
+ }
126
+ seen.add(value)
127
+
128
+ const sanitizedEntries = Object.entries(value).map(([key, entryValue]) => {
129
+ return [
130
+ key,
131
+ sanitizeUnknownValue(entryValue, {
132
+ depth: depth + 1,
133
+ seen,
134
+ redactPaths,
135
+ }),
136
+ ]
137
+ })
138
+ return Object.fromEntries(sanitizedEntries)
139
+ }
140
+
141
+ return sanitizeSensitiveText(String(value), { redactPaths })
142
+ }