kimaki 0.4.76 → 0.4.78

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 (162) hide show
  1. package/dist/adapter-rest-boundary.test.js +34 -0
  2. package/dist/agent-model.e2e.test.js +2 -20
  3. package/dist/cli.js +50 -13
  4. package/dist/commands/channel-ref.js +16 -0
  5. package/dist/commands/diff.js +20 -85
  6. package/dist/commands/merge-worktree.js +5 -17
  7. package/dist/commands/new-worktree.js +5 -9
  8. package/dist/commands/permissions.js +77 -11
  9. package/dist/commands/resume.js +5 -9
  10. package/dist/commands/screenshare.js +295 -0
  11. package/dist/commands/session.js +6 -17
  12. package/dist/critique-utils.js +95 -0
  13. package/dist/diff-patch-plugin.js +314 -0
  14. package/dist/discord-bot.js +19 -14
  15. package/dist/discord-js-import-boundary.test.js +62 -0
  16. package/dist/discord-utils.js +44 -0
  17. package/dist/event-stream-real-capture.e2e.test.js +2 -20
  18. package/dist/gateway-proxy.e2e.test.js +2 -5
  19. package/dist/generated/cloudflare/browser.js +17 -0
  20. package/dist/generated/cloudflare/client.js +34 -0
  21. package/dist/generated/cloudflare/commonInputTypes.js +10 -0
  22. package/dist/generated/cloudflare/enums.js +48 -0
  23. package/dist/generated/cloudflare/internal/class.js +47 -0
  24. package/dist/generated/cloudflare/internal/prismaNamespace.js +252 -0
  25. package/dist/generated/cloudflare/internal/prismaNamespaceBrowser.js +222 -0
  26. package/dist/generated/cloudflare/internal/query_compiler_fast_bg.js +135 -0
  27. package/dist/generated/cloudflare/models/bot_api_keys.js +1 -0
  28. package/dist/generated/cloudflare/models/bot_tokens.js +1 -0
  29. package/dist/generated/cloudflare/models/channel_agents.js +1 -0
  30. package/dist/generated/cloudflare/models/channel_directories.js +1 -0
  31. package/dist/generated/cloudflare/models/channel_mention_mode.js +1 -0
  32. package/dist/generated/cloudflare/models/channel_models.js +1 -0
  33. package/dist/generated/cloudflare/models/channel_verbosity.js +1 -0
  34. package/dist/generated/cloudflare/models/channel_worktrees.js +1 -0
  35. package/dist/generated/cloudflare/models/forum_sync_configs.js +1 -0
  36. package/dist/generated/cloudflare/models/global_models.js +1 -0
  37. package/dist/generated/cloudflare/models/ipc_requests.js +1 -0
  38. package/dist/generated/cloudflare/models/part_messages.js +1 -0
  39. package/dist/generated/cloudflare/models/scheduled_tasks.js +1 -0
  40. package/dist/generated/cloudflare/models/session_agents.js +1 -0
  41. package/dist/generated/cloudflare/models/session_events.js +1 -0
  42. package/dist/generated/cloudflare/models/session_models.js +1 -0
  43. package/dist/generated/cloudflare/models/session_start_sources.js +1 -0
  44. package/dist/generated/cloudflare/models/thread_sessions.js +1 -0
  45. package/dist/generated/cloudflare/models/thread_worktrees.js +1 -0
  46. package/dist/generated/cloudflare/models.js +1 -0
  47. package/dist/generated/node/browser.js +17 -0
  48. package/dist/generated/node/client.js +37 -0
  49. package/dist/generated/node/commonInputTypes.js +10 -0
  50. package/dist/generated/node/enums.js +48 -0
  51. package/dist/generated/node/internal/class.js +49 -0
  52. package/dist/generated/node/internal/prismaNamespace.js +252 -0
  53. package/dist/generated/node/internal/prismaNamespaceBrowser.js +222 -0
  54. package/dist/generated/node/models/bot_api_keys.js +1 -0
  55. package/dist/generated/node/models/bot_tokens.js +1 -0
  56. package/dist/generated/node/models/channel_agents.js +1 -0
  57. package/dist/generated/node/models/channel_directories.js +1 -0
  58. package/dist/generated/node/models/channel_mention_mode.js +1 -0
  59. package/dist/generated/node/models/channel_models.js +1 -0
  60. package/dist/generated/node/models/channel_verbosity.js +1 -0
  61. package/dist/generated/node/models/channel_worktrees.js +1 -0
  62. package/dist/generated/node/models/forum_sync_configs.js +1 -0
  63. package/dist/generated/node/models/global_models.js +1 -0
  64. package/dist/generated/node/models/ipc_requests.js +1 -0
  65. package/dist/generated/node/models/part_messages.js +1 -0
  66. package/dist/generated/node/models/scheduled_tasks.js +1 -0
  67. package/dist/generated/node/models/session_agents.js +1 -0
  68. package/dist/generated/node/models/session_events.js +1 -0
  69. package/dist/generated/node/models/session_models.js +1 -0
  70. package/dist/generated/node/models/session_start_sources.js +1 -0
  71. package/dist/generated/node/models/thread_sessions.js +1 -0
  72. package/dist/generated/node/models/thread_worktrees.js +1 -0
  73. package/dist/generated/node/models.js +1 -0
  74. package/dist/interaction-handler.js +10 -0
  75. package/dist/kimaki-digital-twin.e2e.test.js +2 -20
  76. package/dist/message-flags-boundary.test.js +54 -0
  77. package/dist/message-formatting.js +3 -62
  78. package/dist/onboarding-tutorial-plugin.js +1 -1
  79. package/dist/opencode-command.js +129 -0
  80. package/dist/opencode-command.test.js +48 -0
  81. package/dist/opencode-interrupt-plugin.js +19 -1
  82. package/dist/opencode-interrupt-plugin.test.js +0 -5
  83. package/dist/opencode-plugin-loading.e2e.test.js +9 -20
  84. package/dist/opencode-plugin.js +4 -4
  85. package/dist/opencode.js +150 -27
  86. package/dist/patch-text-parser.js +97 -0
  87. package/dist/platform/components-v2.js +20 -0
  88. package/dist/platform/discord-adapter.js +1440 -0
  89. package/dist/platform/discord-routes.js +31 -0
  90. package/dist/platform/message-flags.js +8 -0
  91. package/dist/platform/platform-value.js +41 -0
  92. package/dist/platform/slack-adapter.js +872 -0
  93. package/dist/platform/slack-markdown.js +169 -0
  94. package/dist/platform/types.js +4 -0
  95. package/dist/queue-advanced-e2e-setup.js +265 -0
  96. package/dist/queue-advanced-footer.e2e.test.js +173 -0
  97. package/dist/queue-advanced-model-switch.e2e.test.js +299 -0
  98. package/dist/queue-advanced-permissions-typing.e2e.test.js +73 -1
  99. package/dist/runtime-lifecycle.e2e.test.js +2 -20
  100. package/dist/session-handler/event-stream-state.js +5 -0
  101. package/dist/session-handler/event-stream-state.test.js +6 -2
  102. package/dist/session-handler/thread-session-runtime.js +32 -2
  103. package/dist/system-message.js +26 -23
  104. package/dist/test-utils.js +16 -0
  105. package/dist/thread-message-queue.e2e.test.js +2 -20
  106. package/dist/utils.js +3 -1
  107. package/dist/voice-message.e2e.test.js +2 -20
  108. package/dist/voice.js +122 -9
  109. package/dist/voice.test.js +17 -2
  110. package/dist/websockify.js +69 -0
  111. package/dist/worktree-lifecycle.e2e.test.js +308 -0
  112. package/package.json +4 -2
  113. package/skills/critique/SKILL.md +17 -0
  114. package/skills/egaki/SKILL.md +35 -0
  115. package/skills/event-sourcing-state/SKILL.md +252 -0
  116. package/skills/goke/SKILL.md +1 -0
  117. package/skills/npm-package/SKILL.md +21 -2
  118. package/skills/playwriter/SKILL.md +1 -1
  119. package/skills/x-articles/SKILL.md +554 -0
  120. package/src/agent-model.e2e.test.ts +4 -19
  121. package/src/cli.ts +60 -13
  122. package/src/commands/diff.ts +25 -99
  123. package/src/commands/merge-worktree.ts +5 -21
  124. package/src/commands/new-worktree.ts +5 -11
  125. package/src/commands/permissions.ts +100 -15
  126. package/src/commands/resume.ts +5 -12
  127. package/src/commands/screenshare.ts +354 -0
  128. package/src/commands/session.ts +6 -23
  129. package/src/critique-utils.ts +139 -0
  130. package/src/discord-bot.ts +20 -15
  131. package/src/discord-utils.ts +53 -0
  132. package/src/event-stream-real-capture.e2e.test.ts +4 -20
  133. package/src/gateway-proxy.e2e.test.ts +2 -5
  134. package/src/interaction-handler.ts +15 -0
  135. package/src/kimaki-digital-twin.e2e.test.ts +2 -21
  136. package/src/message-formatting.ts +3 -68
  137. package/src/onboarding-tutorial-plugin.ts +1 -1
  138. package/src/opencode-command.test.ts +70 -0
  139. package/src/opencode-command.ts +188 -0
  140. package/src/opencode-interrupt-plugin.test.ts +0 -5
  141. package/src/opencode-interrupt-plugin.ts +34 -1
  142. package/src/opencode-plugin-loading.e2e.test.ts +25 -35
  143. package/src/opencode-plugin.ts +5 -4
  144. package/src/opencode.ts +199 -32
  145. package/src/patch-text-parser.ts +107 -0
  146. package/src/queue-advanced-e2e-setup.ts +273 -0
  147. package/src/queue-advanced-footer.e2e.test.ts +211 -0
  148. package/src/queue-advanced-model-switch.e2e.test.ts +383 -0
  149. package/src/queue-advanced-permissions-typing.e2e.test.ts +92 -0
  150. package/src/runtime-lifecycle.e2e.test.ts +4 -19
  151. package/src/session-handler/event-stream-state.test.ts +6 -2
  152. package/src/session-handler/event-stream-state.ts +5 -0
  153. package/src/session-handler/thread-session-runtime.ts +45 -2
  154. package/src/system-message.ts +26 -23
  155. package/src/test-utils.ts +17 -0
  156. package/src/thread-message-queue.e2e.test.ts +2 -20
  157. package/src/utils.ts +3 -1
  158. package/src/voice-message.e2e.test.ts +3 -20
  159. package/src/voice.test.ts +26 -2
  160. package/src/voice.ts +147 -9
  161. package/src/websockify.ts +101 -0
  162. package/src/worktree-lifecycle.e2e.test.ts +391 -0
@@ -344,7 +344,7 @@ const kimakiPlugin: Plugin = async ({ directory }) => {
344
344
  if (memoryContent) {
345
345
  const condensed = condenseMemoryMd(memoryContent)
346
346
  output.parts.push({
347
- id: crypto.randomUUID(),
347
+ id: `prt_${crypto.randomUUID()}`,
348
348
  sessionID,
349
349
  messageID,
350
350
  type: 'text' as const,
@@ -386,7 +386,7 @@ const kimakiPlugin: Plugin = async ({ directory }) => {
386
386
  })
387
387
 
388
388
  output.parts.push({
389
- id: crypto.randomUUID(),
389
+ id: `prt_${crypto.randomUUID()}`,
390
390
  sessionID,
391
391
  messageID,
392
392
  type: 'text' as const,
@@ -398,7 +398,7 @@ const kimakiPlugin: Plugin = async ({ directory }) => {
398
398
  // When the user comes back after a long break, remind the model
399
399
  // to save any important context from the previous conversation.
400
400
  output.parts.push({
401
- id: crypto.randomUUID(),
401
+ id: `prt_${crypto.randomUUID()}`,
402
402
  sessionID,
403
403
  messageID,
404
404
  type: 'text' as const,
@@ -425,7 +425,7 @@ const kimakiPlugin: Plugin = async ({ directory }) => {
425
425
 
426
426
  sessionGitStates.set(sessionID, gitState)
427
427
  output.parts.push({
428
- id: crypto.randomUUID(),
428
+ id: `prt_${crypto.randomUUID()}`,
429
429
  sessionID,
430
430
  messageID,
431
431
  type: 'text' as const,
@@ -481,3 +481,4 @@ const kimakiPlugin: Plugin = async ({ directory }) => {
481
481
  export { kimakiPlugin }
482
482
  export { interruptOpencodeSessionOnUserMessage } from './opencode-interrupt-plugin.js'
483
483
  export { onboardingTutorialPlugin } from './onboarding-tutorial-plugin.js'
484
+
package/src/opencode.ts CHANGED
@@ -57,6 +57,13 @@ import {
57
57
  FetchError,
58
58
  type OpenCodeErrors,
59
59
  } from './errors.js'
60
+ import {
61
+ ensureKimakiCommandShim,
62
+ getPathEnvKey,
63
+ getSpawnCommandAndArgs,
64
+ prependPathEntry,
65
+ selectResolvedCommand,
66
+ } from './opencode-command.js'
60
67
 
61
68
  const opencodeLogger = createLogger(LogPrefix.OPENCODE)
62
69
 
@@ -181,6 +188,8 @@ type ServerLifecycleEvent =
181
188
  let singleServer: SingleServer | null = null
182
189
  let serverRetryCount = 0
183
190
  const serverLifecycleListeners = new Set<(event: ServerLifecycleEvent) => void>()
191
+ let processCleanupHandlersRegistered = false
192
+ let startingServerProcess: ChildProcess | null = null
184
193
 
185
194
  // Cached SDK clients per directory. Each client has a fixed
186
195
  // x-opencode-directory header pointing to its project directory.
@@ -201,6 +210,107 @@ export function subscribeOpencodeServerLifecycle(
201
210
  }
202
211
  }
203
212
 
213
+ function killSingleServerProcessNow({
214
+ reason,
215
+ }: {
216
+ reason: string
217
+ }): void {
218
+ if (!singleServer) {
219
+ return
220
+ }
221
+
222
+ const serverProcess = singleServer.process
223
+ const pid = serverProcess.pid
224
+ if (!pid || serverProcess.killed) {
225
+ return
226
+ }
227
+
228
+ const killResult = errore.try({
229
+ try: () => {
230
+ serverProcess.kill('SIGTERM')
231
+ },
232
+ catch: (error) => {
233
+ return new Error('Failed to send SIGTERM to opencode server', {
234
+ cause: error,
235
+ })
236
+ },
237
+ })
238
+
239
+ if (killResult instanceof Error) {
240
+ opencodeLogger.warn(
241
+ `[cleanup:${reason}] ${killResult.message} (pid: ${pid}, port: ${singleServer.port})`,
242
+ )
243
+ return
244
+ }
245
+
246
+ opencodeLogger.log(
247
+ `[cleanup:${reason}] Sent SIGTERM to opencode server (pid: ${pid}, port: ${singleServer.port})`,
248
+ )
249
+ }
250
+
251
+ function killStartingServerProcessNow({
252
+ reason,
253
+ }: {
254
+ reason: string
255
+ }): void {
256
+ const serverProcess = startingServerProcess
257
+ if (!serverProcess) {
258
+ return
259
+ }
260
+
261
+ const pid = serverProcess.pid
262
+ if (!pid || serverProcess.killed) {
263
+ return
264
+ }
265
+
266
+ const killResult = errore.try({
267
+ try: () => {
268
+ serverProcess.kill('SIGTERM')
269
+ },
270
+ catch: (error) => {
271
+ return new Error('Failed to send SIGTERM to starting opencode server', {
272
+ cause: error,
273
+ })
274
+ },
275
+ })
276
+
277
+ if (killResult instanceof Error) {
278
+ opencodeLogger.warn(
279
+ `[cleanup:${reason}] ${killResult.message} (pid: ${pid})`,
280
+ )
281
+ return
282
+ }
283
+
284
+ opencodeLogger.log(
285
+ `[cleanup:${reason}] Sent SIGTERM to starting opencode server (pid: ${pid})`,
286
+ )
287
+ }
288
+
289
+ function ensureProcessCleanupHandlersRegistered(): void {
290
+ if (processCleanupHandlersRegistered) {
291
+ return
292
+ }
293
+ processCleanupHandlersRegistered = true
294
+
295
+ opencodeLogger.log('Registering process cleanup handlers for opencode server')
296
+
297
+ process.on('exit', () => {
298
+ killSingleServerProcessNow({ reason: 'process-exit' })
299
+ killStartingServerProcessNow({ reason: 'process-exit' })
300
+ })
301
+
302
+ // Fallback for short-lived CLI subcommands that call process.exit without
303
+ // running discord-bot.ts shutdown handlers.
304
+ process.on('SIGINT', () => {
305
+ killSingleServerProcessNow({ reason: 'sigint' })
306
+ killStartingServerProcessNow({ reason: 'sigint' })
307
+ })
308
+ process.on('SIGTERM', () => {
309
+ killSingleServerProcessNow({ reason: 'sigterm' })
310
+ killStartingServerProcessNow({ reason: 'sigterm' })
311
+ })
312
+ }
313
+
204
314
  // ── Resolve opencode binary ──────────────────────────────────────
205
315
  // Resolve the full path to the opencode binary so we can spawn without
206
316
  // shell: true. Using shell: true creates an intermediate sh process — when
@@ -216,18 +326,32 @@ export function resolveOpencodeCommand(): string {
216
326
 
217
327
  const envPath = process.env.OPENCODE_PATH
218
328
  if (envPath) {
219
- resolvedOpencodeCommand = envPath
220
- return envPath
329
+ const resolvedFromEnv = selectResolvedCommand({
330
+ output: envPath,
331
+ isWindows: process.platform === 'win32',
332
+ })
333
+ if (resolvedFromEnv) {
334
+ resolvedOpencodeCommand = resolvedFromEnv
335
+ return resolvedFromEnv
336
+ }
221
337
  }
222
338
 
223
339
  const isWindows = process.platform === 'win32'
224
340
  const whichCmd = isWindows ? 'where' : 'which'
225
- const result = errore.tryFn({
341
+ const result = errore.try({
226
342
  try: () => {
227
- return execFileSync(whichCmd, ['opencode'], {
343
+ const commandOutput = execFileSync(whichCmd, ['opencode'], {
228
344
  encoding: 'utf8',
229
345
  timeout: 5000,
230
- }).trim().split('\n')[0]!.trim()
346
+ })
347
+ const resolved = selectResolvedCommand({
348
+ output: commandOutput,
349
+ isWindows,
350
+ })
351
+ if (resolved) {
352
+ return resolved
353
+ }
354
+ throw new Error('opencode not found in PATH')
231
355
  },
232
356
  catch: () => new Error('opencode not found in PATH'),
233
357
  })
@@ -243,25 +367,6 @@ export function resolveOpencodeCommand(): string {
243
367
  opencodeLogger.log(`Resolved opencode binary: ${result}`)
244
368
  return result
245
369
  }
246
-
247
- /**
248
- * Build the spawn command and args, handling Windows .cmd shims.
249
- * On Windows, .cmd/.bat files can't be spawned directly without a shell —
250
- * we wrap them with cmd.exe /d /s /c instead of using shell: true
251
- * (which creates an intermediate sh process that eats SIGTERM).
252
- */
253
- function getSpawnCommandAndArgs(baseArgs: string[]): { command: string; args: string[] } {
254
- const resolved = resolveOpencodeCommand()
255
- if (process.platform !== 'win32') {
256
- return { command: resolved, args: baseArgs }
257
- }
258
- const lower = resolved.toLowerCase()
259
- if (lower.endsWith('.cmd') || lower.endsWith('.bat')) {
260
- return { command: 'cmd.exe', args: ['/d', '/s', '/c', `"${resolved}"`, ...baseArgs] }
261
- }
262
- return { command: resolved, args: baseArgs }
263
- }
264
-
265
370
  async function getOpenPort(): Promise<number> {
266
371
  return new Promise((resolve, reject) => {
267
372
  const server = net.createServer()
@@ -346,6 +451,8 @@ async function ensureSingleServer(): Promise<ServerStartError | SingleServer> {
346
451
  }
347
452
 
348
453
  async function startSingleServer(): Promise<ServerStartError | SingleServer> {
454
+ ensureProcessCleanupHandlersRegistered()
455
+
349
456
  const port = await getOpenPort()
350
457
 
351
458
  const serveArgs = ['serve', '--port', port.toString()]
@@ -353,7 +460,14 @@ async function startSingleServer(): Promise<ServerStartError | SingleServer> {
353
460
  serveArgs.push('--print-logs', '--log-level', 'DEBUG')
354
461
  }
355
462
 
356
- const { command: spawnCommand, args: spawnArgs } = getSpawnCommandAndArgs(serveArgs)
463
+ const {
464
+ command: spawnCommand,
465
+ args: spawnArgs,
466
+ windowsVerbatimArguments,
467
+ } = getSpawnCommandAndArgs({
468
+ resolvedCommand: resolveOpencodeCommand(),
469
+ baseArgs: serveArgs,
470
+ })
357
471
 
358
472
  // Server config uses permissive defaults. Per-directory external_directory
359
473
  // permissions are set at session creation time via session.create({ permission }).
@@ -380,6 +494,22 @@ async function startSingleServer(): Promise<ServerStartError | SingleServer> {
380
494
  [kimakiDataDir]: 'allow',
381
495
  [`${kimakiDataDir}/*`]: 'allow',
382
496
  }
497
+ const kimakiShimDirectory = ensureKimakiCommandShim({
498
+ dataDir: getDataDir(),
499
+ execPath: process.execPath,
500
+ execArgv: process.execArgv,
501
+ entryScript: process.argv[1] || fileURLToPath(new URL('../bin.js', import.meta.url)),
502
+ })
503
+ const pathEnvKey = getPathEnvKey(process.env)
504
+ const pathEnv = kimakiShimDirectory instanceof Error
505
+ ? process.env[pathEnvKey]
506
+ : prependPathEntry({
507
+ entry: kimakiShimDirectory,
508
+ existingPath: process.env[pathEnvKey],
509
+ })
510
+ if (kimakiShimDirectory instanceof Error) {
511
+ opencodeLogger.warn(kimakiShimDirectory.message)
512
+ }
383
513
 
384
514
  const serverProcess = spawn(
385
515
  spawnCommand,
@@ -387,6 +517,7 @@ async function startSingleServer(): Promise<ServerStartError | SingleServer> {
387
517
  {
388
518
  stdio: 'pipe',
389
519
  detached: false,
520
+ windowsVerbatimArguments,
390
521
  // No project-specific cwd — the server handles all directories via
391
522
  // x-opencode-directory header. Use home dir as a neutral working dir.
392
523
  cwd: os.homedir(),
@@ -437,10 +568,13 @@ async function startSingleServer(): Promise<ServerStartError | SingleServer> {
437
568
  ...(process.env.KIMAKI_SENTRY_DSN && {
438
569
  KIMAKI_SENTRY_DSN: process.env.KIMAKI_SENTRY_DSN,
439
570
  }),
571
+ ...(pathEnv && { [pathEnvKey]: pathEnv }),
440
572
  },
441
573
  },
442
574
  )
443
575
 
576
+ startingServerProcess = serverProcess
577
+
444
578
  // Buffer logs until we know if server started successfully.
445
579
  // Once ready, switch to forwarding if --verbose-opencode-server is set.
446
580
  const logBuffer: string[] = []
@@ -493,6 +627,10 @@ async function startSingleServer(): Promise<ServerStartError | SingleServer> {
493
627
  })
494
628
 
495
629
  serverProcess.on('exit', (code, signal) => {
630
+ if (startingServerProcess === serverProcess) {
631
+ startingServerProcess = null
632
+ }
633
+
496
634
  opencodeLogger.log(
497
635
  `Opencode server exited with code: ${code}, signal: ${signal}`,
498
636
  )
@@ -538,6 +676,11 @@ async function startSingleServer(): Promise<ServerStartError | SingleServer> {
538
676
  startupStderrTail,
539
677
  })
540
678
  if (waitResult instanceof Error) {
679
+ killStartingServerProcessNow({ reason: 'startup-failed' })
680
+ if (startingServerProcess === serverProcess) {
681
+ startingServerProcess = null
682
+ }
683
+
541
684
  // Dump buffered logs on failure
542
685
  opencodeLogger.error(`Server failed to start:`)
543
686
  for (const line of logBuffer) {
@@ -561,6 +704,9 @@ async function startSingleServer(): Promise<ServerStartError | SingleServer> {
561
704
  port,
562
705
  baseUrl: `http://127.0.0.1:${port}`,
563
706
  }
707
+ if (startingServerProcess === serverProcess) {
708
+ startingServerProcess = null
709
+ }
564
710
  singleServer = server
565
711
  notifyServerLifecycle({ type: 'started', port })
566
712
  return server
@@ -700,6 +846,24 @@ export function buildSessionPermissions({
700
846
  { permission: 'external_directory', pattern: `${kimakiDataDir}/*`, action: 'allow' },
701
847
  )
702
848
 
849
+ // Allow opencode tool output artifacts under XDG data so agents can inspect
850
+ // prior tool outputs without interactive permission prompts.
851
+ const opencodeToolOutputDir = path
852
+ .join(os.homedir(), '.local', 'share', 'opencode', 'tool-output')
853
+ .replaceAll('\\', '/')
854
+ rules.push(
855
+ {
856
+ permission: 'external_directory',
857
+ pattern: opencodeToolOutputDir,
858
+ action: 'allow',
859
+ },
860
+ {
861
+ permission: 'external_directory',
862
+ pattern: `${opencodeToolOutputDir}/*`,
863
+ action: 'allow',
864
+ },
865
+ )
866
+
703
867
  // For worktrees: allow access to the original repository directory
704
868
  if (originalRepo) {
705
869
  rules.push(
@@ -737,19 +901,19 @@ export async function stopOpencodeServer(): Promise<boolean> {
737
901
  return false
738
902
  }
739
903
 
904
+ const server = singleServer
740
905
  opencodeLogger.log(
741
- `Stopping opencode server (pid: ${singleServer.process.pid}, port: ${singleServer.port})`,
906
+ `Stopping opencode server (pid: ${server.process.pid}, port: ${server.port})`,
742
907
  )
743
- if (!singleServer.process.killed) {
908
+ if (!server.process.killed) {
744
909
  const killResult = errore.try({
745
910
  try: () => {
746
- singleServer!.process.kill('SIGTERM')
911
+ server.process.kill('SIGTERM')
747
912
  },
748
913
  catch: (error) => {
749
- return new Error(
750
- `Failed to send SIGTERM to opencode server`,
751
- { cause: error },
752
- )
914
+ return new Error('Failed to send SIGTERM to opencode server', {
915
+ cause: error,
916
+ })
753
917
  },
754
918
  })
755
919
  if (killResult instanceof Error) {
@@ -757,6 +921,9 @@ export async function stopOpencodeServer(): Promise<boolean> {
757
921
  }
758
922
  }
759
923
 
924
+ killStartingServerProcessNow({ reason: 'stop-opencode-server' })
925
+ startingServerProcess = null
926
+
760
927
  singleServer = null
761
928
  clientCache.clear()
762
929
  serverRetryCount = 0
@@ -0,0 +1,107 @@
1
+ // Shared apply_patch text parsing utilities.
2
+ // Used by diff-patch-plugin.ts (file path extraction for snapshots) and
3
+ // message-formatting.ts (per-file addition/deletion counts for Discord display).
4
+ //
5
+ // The apply_patch tool uses three path header formats:
6
+ // *** Add File: path — new file
7
+ // *** Update File: path — existing file edit
8
+ // *** Delete File: path — file removal
9
+ // *** Move to: path — rename destination
10
+ // --- a/path / +++ b/path — unified diff headers (fallback)
11
+
12
+ /**
13
+ * Extract all file paths referenced in a patchText string.
14
+ * Handles custom apply_patch headers, move targets, and unified diff headers.
15
+ * Returns deduplicated paths.
16
+ */
17
+ export function extractPatchFilePaths(patchText: string): string[] {
18
+ const custom = [
19
+ ...patchText.matchAll(
20
+ /^\*\*\* (?:Add|Update|Delete) File:\s+(.+)$/gm,
21
+ ),
22
+ ].map((m) => {
23
+ return (m[1] ?? '').trim()
24
+ })
25
+ const moved = [
26
+ ...patchText.matchAll(/^\*\*\* Move to:\s+(.+)$/gm),
27
+ ].map((m) => {
28
+ return (m[1] ?? '').trim()
29
+ })
30
+ const unified = [
31
+ ...patchText.matchAll(/^(?:---|\+\+\+) [ab]\/(.+)$/gm),
32
+ ].map((m) => {
33
+ return (m[1] ?? '').trim()
34
+ })
35
+ const all = [...custom, ...moved, ...unified].filter(Boolean)
36
+ return all.filter((v, i, a) => {
37
+ return a.indexOf(v) === i
38
+ })
39
+ }
40
+
41
+ /**
42
+ * Parse a patchText string and count additions/deletions per file.
43
+ * Patch format uses `*** Add File:`, `*** Update File:`, `*** Delete File:` headers,
44
+ * with diff lines prefixed by `+` (addition) or `-` (deletion) inside `@@` hunks.
45
+ */
46
+ export function parsePatchFileCounts(
47
+ patchText: string,
48
+ ): Map<string, { additions: number; deletions: number }> {
49
+ const counts = new Map<string, { additions: number; deletions: number }>()
50
+ const lines = patchText.split('\n')
51
+ let currentFile = ''
52
+ let currentType = ''
53
+ let inHunk = false
54
+
55
+ for (const line of lines) {
56
+ const addMatch = line.match(/^\*\*\* Add File:\s*(.+)/)
57
+ const updateMatch = line.match(/^\*\*\* Update File:\s*(.+)/)
58
+ const deleteMatch = line.match(/^\*\*\* Delete File:\s*(.+)/)
59
+
60
+ if (addMatch || updateMatch || deleteMatch) {
61
+ const match = addMatch || updateMatch || deleteMatch
62
+ currentFile = (match?.[1] ?? '').trim()
63
+ currentType = addMatch ? 'add' : updateMatch ? 'update' : 'delete'
64
+ counts.set(currentFile, { additions: 0, deletions: 0 })
65
+ inHunk = false
66
+ continue
67
+ }
68
+
69
+ if (line.startsWith('@@')) {
70
+ inHunk = true
71
+ continue
72
+ }
73
+
74
+ if (line.startsWith('*** ')) {
75
+ inHunk = false
76
+ continue
77
+ }
78
+
79
+ if (!currentFile) {
80
+ continue
81
+ }
82
+
83
+ const entry = counts.get(currentFile)
84
+ if (!entry) {
85
+ continue
86
+ }
87
+
88
+ if (currentType === 'add') {
89
+ // all content lines in Add File are additions
90
+ if (line.length > 0 && !line.startsWith('*** ')) {
91
+ entry.additions++
92
+ }
93
+ } else if (currentType === 'delete') {
94
+ // all content lines in Delete File are deletions
95
+ if (line.length > 0 && !line.startsWith('*** ')) {
96
+ entry.deletions++
97
+ }
98
+ } else if (inHunk) {
99
+ if (line.startsWith('+')) {
100
+ entry.additions++
101
+ } else if (line.startsWith('-')) {
102
+ entry.deletions++
103
+ }
104
+ }
105
+ }
106
+ return counts
107
+ }