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
@@ -0,0 +1,48 @@
1
+ // Regression tests for Windows OpenCode command resolution and spawn args.
2
+ import { describe, expect, test } from 'vitest';
3
+ import { getSpawnCommandAndArgs, selectResolvedCommand, splitCommandLookupOutput, } from './opencode-command.js';
4
+ describe('splitCommandLookupOutput', () => {
5
+ test('splits windows command lookup output into trimmed lines', () => {
6
+ expect(splitCommandLookupOutput('C:\\Program Files\\nodejs\\opencode\r\nC:\\Program Files\\nodejs\\opencode.cmd\r\n')).toEqual([
7
+ 'C:\\Program Files\\nodejs\\opencode',
8
+ 'C:\\Program Files\\nodejs\\opencode.cmd',
9
+ ]);
10
+ });
11
+ });
12
+ describe('selectResolvedCommand', () => {
13
+ test('prefers npm cmd shims on windows', () => {
14
+ expect(selectResolvedCommand({
15
+ output: 'C:\\Program Files\\nodejs\\opencode\r\nC:\\Program Files\\nodejs\\opencode.cmd\r\n',
16
+ isWindows: true,
17
+ })).toBe('C:\\Program Files\\nodejs\\opencode.cmd');
18
+ });
19
+ test('keeps first result on non-windows platforms', () => {
20
+ expect(selectResolvedCommand({
21
+ output: '/usr/local/bin/opencode\n/opt/homebrew/bin/opencode\n',
22
+ isWindows: false,
23
+ })).toBe('/usr/local/bin/opencode');
24
+ });
25
+ });
26
+ describe('getSpawnCommandAndArgs', () => {
27
+ test('wraps windows cmd shims through cmd.exe without double-quoting by node', () => {
28
+ expect(getSpawnCommandAndArgs({
29
+ resolvedCommand: 'C:\\Program Files\\nodejs\\opencode.cmd',
30
+ baseArgs: ['serve', '--port', '4096'],
31
+ platform: 'win32',
32
+ })).toEqual({
33
+ command: 'cmd.exe',
34
+ args: ['/d', '/s', '/c', '"C:\\Program Files\\nodejs\\opencode.cmd"', 'serve', '--port', '4096'],
35
+ windowsVerbatimArguments: true,
36
+ });
37
+ });
38
+ test('leaves direct executables unchanged on windows', () => {
39
+ expect(getSpawnCommandAndArgs({
40
+ resolvedCommand: 'C:\\tools\\opencode.exe',
41
+ baseArgs: ['serve', '--port', '4096'],
42
+ platform: 'win32',
43
+ })).toEqual({
44
+ command: 'C:\\tools\\opencode.exe',
45
+ args: ['serve', '--port', '4096'],
46
+ });
47
+ });
48
+ });
@@ -63,6 +63,8 @@ const interruptOpencodeSessionOnUserMessage = async (ctx) => {
63
63
  started: false,
64
64
  timer,
65
65
  abortAfterStepMessageID: latestAssistantMessageIDBySession.get(sessionID),
66
+ agent: undefined,
67
+ model: undefined,
66
68
  });
67
69
  }
68
70
  function markStarted({ messageID }) {
@@ -127,9 +129,19 @@ const interruptOpencodeSessionOnUserMessage = async (ctx) => {
127
129
  clearPendingByMessageId({ messageID });
128
130
  return;
129
131
  }
132
+ // Keep the queued user message execution context across abort+resume.
133
+ // Without this, OpenCode re-resolves model defaults and can ignore
134
+ // /model session overrides (issue #77).
135
+ const resumeBody = { parts: [] };
136
+ if (currentPending.agent) {
137
+ resumeBody.agent = currentPending.agent;
138
+ }
139
+ if (currentPending.model) {
140
+ resumeBody.model = currentPending.model;
141
+ }
130
142
  await ctx.client.session.promptAsync({
131
143
  path: { id: sessionID },
132
- body: { parts: [] },
144
+ body: resumeBody,
133
145
  });
134
146
  clearPendingByMessageId({ messageID });
135
147
  const nextPending = getNextPendingMessage({ sessionID });
@@ -223,6 +235,12 @@ const interruptOpencodeSessionOnUserMessage = async (ctx) => {
223
235
  sessionID,
224
236
  delayMs: interruptStepTimeoutMs,
225
237
  });
238
+ const pending = pendingByMessageId.get(messageID);
239
+ if (!pending) {
240
+ return;
241
+ }
242
+ pending.agent = output.message.agent;
243
+ pending.model = output.message.model;
226
244
  },
227
245
  };
228
246
  };
@@ -90,11 +90,6 @@ function createChatOutput({ sessionID, messageID, parts, }) {
90
90
  sessionID,
91
91
  role: 'user',
92
92
  time: { created: Date.now() },
93
- agent: 'build',
94
- model: {
95
- providerID: 'deterministic-provider',
96
- modelID: 'deterministic-v2',
97
- },
98
93
  },
99
94
  parts: parts || [{ type: 'text', text: 'user message' }],
100
95
  };
@@ -4,29 +4,13 @@
4
4
  // No Discord infrastructure needed — just the OpenCode server process.
5
5
  import { spawn } from 'node:child_process';
6
6
  import fs from 'node:fs';
7
- import net from 'node:net';
8
7
  import path from 'node:path';
9
8
  import { fileURLToPath } from 'node:url';
10
9
  import { test, expect } from 'vitest';
11
10
  import { resolveOpencodeCommand } from './opencode.js';
11
+ import { getSpawnCommandAndArgs } from './opencode-command.js';
12
+ import { chooseLockPort } from './test-utils.js';
12
13
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
- async function getOpenPort() {
14
- return new Promise((resolve, reject) => {
15
- const server = net.createServer();
16
- server.listen(0, () => {
17
- const address = server.address();
18
- if (address && typeof address === 'object') {
19
- server.close(() => {
20
- resolve(address.port);
21
- });
22
- }
23
- else {
24
- reject(new Error('Failed to get port'));
25
- }
26
- });
27
- server.on('error', reject);
28
- });
29
- }
30
14
  async function waitForHealth({ port, maxAttempts = 30, }) {
31
15
  for (let i = 0; i < maxAttempts; i++) {
32
16
  try {
@@ -47,12 +31,17 @@ async function waitForHealth({ port, maxAttempts = 30, }) {
47
31
  test('opencode server loads plugin without errors', async () => {
48
32
  const projectDir = path.resolve(process.cwd(), 'tmp', 'plugin-loading-e2e');
49
33
  fs.mkdirSync(projectDir, { recursive: true });
50
- const port = await getOpenPort();
34
+ const port = chooseLockPort({ key: 'opencode-plugin-loading-e2e' });
51
35
  const pluginPath = new URL('../src/opencode-plugin.ts', import.meta.url).href;
52
36
  const stderrLines = [];
53
- const serverProcess = spawn(resolveOpencodeCommand(), ['serve', '--port', port.toString(), '--print-logs', '--log-level', 'DEBUG'], {
37
+ const { command, args, windowsVerbatimArguments, } = getSpawnCommandAndArgs({
38
+ resolvedCommand: resolveOpencodeCommand(),
39
+ baseArgs: ['serve', '--port', port.toString(), '--print-logs', '--log-level', 'DEBUG'],
40
+ });
41
+ const serverProcess = spawn(command, args, {
54
42
  stdio: 'pipe',
55
43
  cwd: projectDir,
44
+ windowsVerbatimArguments,
56
45
  env: {
57
46
  ...process.env,
58
47
  OPENCODE_CONFIG_CONTENT: JSON.stringify({
@@ -267,7 +267,7 @@ const kimakiPlugin = async ({ directory }) => {
267
267
  if (memoryContent) {
268
268
  const condensed = condenseMemoryMd(memoryContent);
269
269
  output.parts.push({
270
- id: crypto.randomUUID(),
270
+ id: `prt_${crypto.randomUUID()}`,
271
271
  sessionID,
272
272
  messageID,
273
273
  type: 'text',
@@ -304,7 +304,7 @@ const kimakiPlugin = async ({ directory }) => {
304
304
  hour12: false,
305
305
  });
306
306
  output.parts.push({
307
- id: crypto.randomUUID(),
307
+ id: `prt_${crypto.randomUUID()}`,
308
308
  sessionID,
309
309
  messageID,
310
310
  type: 'text',
@@ -315,7 +315,7 @@ const kimakiPlugin = async ({ directory }) => {
315
315
  // When the user comes back after a long break, remind the model
316
316
  // to save any important context from the previous conversation.
317
317
  output.parts.push({
318
- id: crypto.randomUUID(),
318
+ id: `prt_${crypto.randomUUID()}`,
319
319
  sessionID,
320
320
  messageID,
321
321
  type: 'text',
@@ -340,7 +340,7 @@ const kimakiPlugin = async ({ directory }) => {
340
340
  })();
341
341
  sessionGitStates.set(sessionID, gitState);
342
342
  output.parts.push({
343
- id: crypto.randomUUID(),
343
+ id: `prt_${crypto.randomUUID()}`,
344
344
  sessionID,
345
345
  messageID,
346
346
  type: 'text',
package/dist/opencode.js CHANGED
@@ -27,6 +27,7 @@ import * as errore from 'errore';
27
27
  import { createLogger, LogPrefix } from './logger.js';
28
28
  import { notifyError } from './sentry.js';
29
29
  import { DirectoryNotAccessibleError, ServerStartError, ServerNotReadyError, FetchError, } from './errors.js';
30
+ import { ensureKimakiCommandShim, getPathEnvKey, getSpawnCommandAndArgs, prependPathEntry, selectResolvedCommand, } from './opencode-command.js';
30
31
  const opencodeLogger = createLogger(LogPrefix.OPENCODE);
31
32
  const STARTUP_STDERR_TAIL_LIMIT = 30;
32
33
  const STARTUP_STDERR_LINE_MAX_LENGTH = 120;
@@ -96,6 +97,8 @@ function buildStartupTimeoutReason({ maxAttempts, stderrTail, }) {
96
97
  let singleServer = null;
97
98
  let serverRetryCount = 0;
98
99
  const serverLifecycleListeners = new Set();
100
+ let processCleanupHandlersRegistered = false;
101
+ let startingServerProcess = null;
99
102
  // Cached SDK clients per directory. Each client has a fixed
100
103
  // x-opencode-directory header pointing to its project directory.
101
104
  const clientCache = new Map();
@@ -110,6 +113,77 @@ export function subscribeOpencodeServerLifecycle(listener) {
110
113
  serverLifecycleListeners.delete(listener);
111
114
  };
112
115
  }
116
+ function killSingleServerProcessNow({ reason, }) {
117
+ if (!singleServer) {
118
+ return;
119
+ }
120
+ const serverProcess = singleServer.process;
121
+ const pid = serverProcess.pid;
122
+ if (!pid || serverProcess.killed) {
123
+ return;
124
+ }
125
+ const killResult = errore.try({
126
+ try: () => {
127
+ serverProcess.kill('SIGTERM');
128
+ },
129
+ catch: (error) => {
130
+ return new Error('Failed to send SIGTERM to opencode server', {
131
+ cause: error,
132
+ });
133
+ },
134
+ });
135
+ if (killResult instanceof Error) {
136
+ opencodeLogger.warn(`[cleanup:${reason}] ${killResult.message} (pid: ${pid}, port: ${singleServer.port})`);
137
+ return;
138
+ }
139
+ opencodeLogger.log(`[cleanup:${reason}] Sent SIGTERM to opencode server (pid: ${pid}, port: ${singleServer.port})`);
140
+ }
141
+ function killStartingServerProcessNow({ reason, }) {
142
+ const serverProcess = startingServerProcess;
143
+ if (!serverProcess) {
144
+ return;
145
+ }
146
+ const pid = serverProcess.pid;
147
+ if (!pid || serverProcess.killed) {
148
+ return;
149
+ }
150
+ const killResult = errore.try({
151
+ try: () => {
152
+ serverProcess.kill('SIGTERM');
153
+ },
154
+ catch: (error) => {
155
+ return new Error('Failed to send SIGTERM to starting opencode server', {
156
+ cause: error,
157
+ });
158
+ },
159
+ });
160
+ if (killResult instanceof Error) {
161
+ opencodeLogger.warn(`[cleanup:${reason}] ${killResult.message} (pid: ${pid})`);
162
+ return;
163
+ }
164
+ opencodeLogger.log(`[cleanup:${reason}] Sent SIGTERM to starting opencode server (pid: ${pid})`);
165
+ }
166
+ function ensureProcessCleanupHandlersRegistered() {
167
+ if (processCleanupHandlersRegistered) {
168
+ return;
169
+ }
170
+ processCleanupHandlersRegistered = true;
171
+ opencodeLogger.log('Registering process cleanup handlers for opencode server');
172
+ process.on('exit', () => {
173
+ killSingleServerProcessNow({ reason: 'process-exit' });
174
+ killStartingServerProcessNow({ reason: 'process-exit' });
175
+ });
176
+ // Fallback for short-lived CLI subcommands that call process.exit without
177
+ // running discord-bot.ts shutdown handlers.
178
+ process.on('SIGINT', () => {
179
+ killSingleServerProcessNow({ reason: 'sigint' });
180
+ killStartingServerProcessNow({ reason: 'sigint' });
181
+ });
182
+ process.on('SIGTERM', () => {
183
+ killSingleServerProcessNow({ reason: 'sigterm' });
184
+ killStartingServerProcessNow({ reason: 'sigterm' });
185
+ });
186
+ }
113
187
  // ── Resolve opencode binary ──────────────────────────────────────
114
188
  // Resolve the full path to the opencode binary so we can spawn without
115
189
  // shell: true. Using shell: true creates an intermediate sh process — when
@@ -123,17 +197,31 @@ export function resolveOpencodeCommand() {
123
197
  }
124
198
  const envPath = process.env.OPENCODE_PATH;
125
199
  if (envPath) {
126
- resolvedOpencodeCommand = envPath;
127
- return envPath;
200
+ const resolvedFromEnv = selectResolvedCommand({
201
+ output: envPath,
202
+ isWindows: process.platform === 'win32',
203
+ });
204
+ if (resolvedFromEnv) {
205
+ resolvedOpencodeCommand = resolvedFromEnv;
206
+ return resolvedFromEnv;
207
+ }
128
208
  }
129
209
  const isWindows = process.platform === 'win32';
130
210
  const whichCmd = isWindows ? 'where' : 'which';
131
- const result = errore.tryFn({
211
+ const result = errore.try({
132
212
  try: () => {
133
- return execFileSync(whichCmd, ['opencode'], {
213
+ const commandOutput = execFileSync(whichCmd, ['opencode'], {
134
214
  encoding: 'utf8',
135
215
  timeout: 5000,
136
- }).trim().split('\n')[0].trim();
216
+ });
217
+ const resolved = selectResolvedCommand({
218
+ output: commandOutput,
219
+ isWindows,
220
+ });
221
+ if (resolved) {
222
+ return resolved;
223
+ }
224
+ throw new Error('opencode not found in PATH');
137
225
  },
138
226
  catch: () => new Error('opencode not found in PATH'),
139
227
  });
@@ -147,23 +235,6 @@ export function resolveOpencodeCommand() {
147
235
  opencodeLogger.log(`Resolved opencode binary: ${result}`);
148
236
  return result;
149
237
  }
150
- /**
151
- * Build the spawn command and args, handling Windows .cmd shims.
152
- * On Windows, .cmd/.bat files can't be spawned directly without a shell —
153
- * we wrap them with cmd.exe /d /s /c instead of using shell: true
154
- * (which creates an intermediate sh process that eats SIGTERM).
155
- */
156
- function getSpawnCommandAndArgs(baseArgs) {
157
- const resolved = resolveOpencodeCommand();
158
- if (process.platform !== 'win32') {
159
- return { command: resolved, args: baseArgs };
160
- }
161
- const lower = resolved.toLowerCase();
162
- if (lower.endsWith('.cmd') || lower.endsWith('.bat')) {
163
- return { command: 'cmd.exe', args: ['/d', '/s', '/c', `"${resolved}"`, ...baseArgs] };
164
- }
165
- return { command: resolved, args: baseArgs };
166
- }
167
238
  async function getOpenPort() {
168
239
  return new Promise((resolve, reject) => {
169
240
  const server = net.createServer();
@@ -235,12 +306,16 @@ async function ensureSingleServer() {
235
306
  }
236
307
  }
237
308
  async function startSingleServer() {
309
+ ensureProcessCleanupHandlersRegistered();
238
310
  const port = await getOpenPort();
239
311
  const serveArgs = ['serve', '--port', port.toString()];
240
312
  if (store.getState().verboseOpencodeServer) {
241
313
  serveArgs.push('--print-logs', '--log-level', 'DEBUG');
242
314
  }
243
- const { command: spawnCommand, args: spawnArgs } = getSpawnCommandAndArgs(serveArgs);
315
+ const { command: spawnCommand, args: spawnArgs, windowsVerbatimArguments, } = getSpawnCommandAndArgs({
316
+ resolvedCommand: resolveOpencodeCommand(),
317
+ baseArgs: serveArgs,
318
+ });
244
319
  // Server config uses permissive defaults. Per-directory external_directory
245
320
  // permissions are set at session creation time via session.create({ permission }).
246
321
  // Common directories (tmpdir, ~/.config/opencode, ~/.kimaki) are pre-allowed
@@ -266,9 +341,26 @@ async function startSingleServer() {
266
341
  [kimakiDataDir]: 'allow',
267
342
  [`${kimakiDataDir}/*`]: 'allow',
268
343
  };
344
+ const kimakiShimDirectory = ensureKimakiCommandShim({
345
+ dataDir: getDataDir(),
346
+ execPath: process.execPath,
347
+ execArgv: process.execArgv,
348
+ entryScript: process.argv[1] || fileURLToPath(new URL('../bin.js', import.meta.url)),
349
+ });
350
+ const pathEnvKey = getPathEnvKey(process.env);
351
+ const pathEnv = kimakiShimDirectory instanceof Error
352
+ ? process.env[pathEnvKey]
353
+ : prependPathEntry({
354
+ entry: kimakiShimDirectory,
355
+ existingPath: process.env[pathEnvKey],
356
+ });
357
+ if (kimakiShimDirectory instanceof Error) {
358
+ opencodeLogger.warn(kimakiShimDirectory.message);
359
+ }
269
360
  const serverProcess = spawn(spawnCommand, spawnArgs, {
270
361
  stdio: 'pipe',
271
362
  detached: false,
363
+ windowsVerbatimArguments,
272
364
  // No project-specific cwd — the server handles all directories via
273
365
  // x-opencode-directory header. Use home dir as a neutral working dir.
274
366
  cwd: os.homedir(),
@@ -319,8 +411,10 @@ async function startSingleServer() {
319
411
  ...(process.env.KIMAKI_SENTRY_DSN && {
320
412
  KIMAKI_SENTRY_DSN: process.env.KIMAKI_SENTRY_DSN,
321
413
  }),
414
+ ...(pathEnv && { [pathEnvKey]: pathEnv }),
322
415
  },
323
416
  });
417
+ startingServerProcess = serverProcess;
324
418
  // Buffer logs until we know if server started successfully.
325
419
  // Once ready, switch to forwarding if --verbose-opencode-server is set.
326
420
  const logBuffer = [];
@@ -368,6 +462,9 @@ async function startSingleServer() {
368
462
  logBuffer.push(`Failed to start server on port ${port}: ${error}`);
369
463
  });
370
464
  serverProcess.on('exit', (code, signal) => {
465
+ if (startingServerProcess === serverProcess) {
466
+ startingServerProcess = null;
467
+ }
371
468
  opencodeLogger.log(`Opencode server exited with code: ${code}, signal: ${signal}`);
372
469
  singleServer = null;
373
470
  clientCache.clear();
@@ -405,6 +502,10 @@ async function startSingleServer() {
405
502
  startupStderrTail,
406
503
  });
407
504
  if (waitResult instanceof Error) {
505
+ killStartingServerProcessNow({ reason: 'startup-failed' });
506
+ if (startingServerProcess === serverProcess) {
507
+ startingServerProcess = null;
508
+ }
408
509
  // Dump buffered logs on failure
409
510
  opencodeLogger.error(`Server failed to start:`);
410
511
  for (const line of logBuffer) {
@@ -426,6 +527,9 @@ async function startSingleServer() {
426
527
  port,
427
528
  baseUrl: `http://127.0.0.1:${port}`,
428
529
  };
530
+ if (startingServerProcess === serverProcess) {
531
+ startingServerProcess = null;
532
+ }
429
533
  singleServer = server;
430
534
  notifyServerLifecycle({ type: 'started', port });
431
535
  return server;
@@ -527,6 +631,20 @@ export function buildSessionPermissions({ directory, originalRepoDirectory, }) {
527
631
  .join(os.homedir(), '.kimaki')
528
632
  .replaceAll('\\', '/');
529
633
  rules.push({ permission: 'external_directory', pattern: kimakiDataDir, action: 'allow' }, { permission: 'external_directory', pattern: `${kimakiDataDir}/*`, action: 'allow' });
634
+ // Allow opencode tool output artifacts under XDG data so agents can inspect
635
+ // prior tool outputs without interactive permission prompts.
636
+ const opencodeToolOutputDir = path
637
+ .join(os.homedir(), '.local', 'share', 'opencode', 'tool-output')
638
+ .replaceAll('\\', '/');
639
+ rules.push({
640
+ permission: 'external_directory',
641
+ pattern: opencodeToolOutputDir,
642
+ action: 'allow',
643
+ }, {
644
+ permission: 'external_directory',
645
+ pattern: `${opencodeToolOutputDir}/*`,
646
+ action: 'allow',
647
+ });
530
648
  // For worktrees: allow access to the original repository directory
531
649
  if (originalRepo) {
532
650
  rules.push({ permission: 'external_directory', pattern: originalRepo, action: 'allow' }, { permission: 'external_directory', pattern: `${originalRepo}/*`, action: 'allow' });
@@ -555,20 +673,25 @@ export async function stopOpencodeServer() {
555
673
  if (!singleServer) {
556
674
  return false;
557
675
  }
558
- opencodeLogger.log(`Stopping opencode server (pid: ${singleServer.process.pid}, port: ${singleServer.port})`);
559
- if (!singleServer.process.killed) {
676
+ const server = singleServer;
677
+ opencodeLogger.log(`Stopping opencode server (pid: ${server.process.pid}, port: ${server.port})`);
678
+ if (!server.process.killed) {
560
679
  const killResult = errore.try({
561
680
  try: () => {
562
- singleServer.process.kill('SIGTERM');
681
+ server.process.kill('SIGTERM');
563
682
  },
564
683
  catch: (error) => {
565
- return new Error(`Failed to send SIGTERM to opencode server`, { cause: error });
684
+ return new Error('Failed to send SIGTERM to opencode server', {
685
+ cause: error,
686
+ });
566
687
  },
567
688
  });
568
689
  if (killResult instanceof Error) {
569
690
  opencodeLogger.warn(killResult.message);
570
691
  }
571
692
  }
693
+ killStartingServerProcessNow({ reason: 'stop-opencode-server' });
694
+ startingServerProcess = null;
572
695
  singleServer = null;
573
696
  clientCache.clear();
574
697
  serverRetryCount = 0;
@@ -0,0 +1,97 @@
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
+ * Extract all file paths referenced in a patchText string.
13
+ * Handles custom apply_patch headers, move targets, and unified diff headers.
14
+ * Returns deduplicated paths.
15
+ */
16
+ export function extractPatchFilePaths(patchText) {
17
+ const custom = [
18
+ ...patchText.matchAll(/^\*\*\* (?:Add|Update|Delete) File:\s+(.+)$/gm),
19
+ ].map((m) => {
20
+ return (m[1] ?? '').trim();
21
+ });
22
+ const moved = [
23
+ ...patchText.matchAll(/^\*\*\* Move to:\s+(.+)$/gm),
24
+ ].map((m) => {
25
+ return (m[1] ?? '').trim();
26
+ });
27
+ const unified = [
28
+ ...patchText.matchAll(/^(?:---|\+\+\+) [ab]\/(.+)$/gm),
29
+ ].map((m) => {
30
+ return (m[1] ?? '').trim();
31
+ });
32
+ const all = [...custom, ...moved, ...unified].filter(Boolean);
33
+ return all.filter((v, i, a) => {
34
+ return a.indexOf(v) === i;
35
+ });
36
+ }
37
+ /**
38
+ * Parse a patchText string and count additions/deletions per file.
39
+ * Patch format uses `*** Add File:`, `*** Update File:`, `*** Delete File:` headers,
40
+ * with diff lines prefixed by `+` (addition) or `-` (deletion) inside `@@` hunks.
41
+ */
42
+ export function parsePatchFileCounts(patchText) {
43
+ const counts = new Map();
44
+ const lines = patchText.split('\n');
45
+ let currentFile = '';
46
+ let currentType = '';
47
+ let inHunk = false;
48
+ for (const line of lines) {
49
+ const addMatch = line.match(/^\*\*\* Add File:\s*(.+)/);
50
+ const updateMatch = line.match(/^\*\*\* Update File:\s*(.+)/);
51
+ const deleteMatch = line.match(/^\*\*\* Delete File:\s*(.+)/);
52
+ if (addMatch || updateMatch || deleteMatch) {
53
+ const match = addMatch || updateMatch || deleteMatch;
54
+ currentFile = (match?.[1] ?? '').trim();
55
+ currentType = addMatch ? 'add' : updateMatch ? 'update' : 'delete';
56
+ counts.set(currentFile, { additions: 0, deletions: 0 });
57
+ inHunk = false;
58
+ continue;
59
+ }
60
+ if (line.startsWith('@@')) {
61
+ inHunk = true;
62
+ continue;
63
+ }
64
+ if (line.startsWith('*** ')) {
65
+ inHunk = false;
66
+ continue;
67
+ }
68
+ if (!currentFile) {
69
+ continue;
70
+ }
71
+ const entry = counts.get(currentFile);
72
+ if (!entry) {
73
+ continue;
74
+ }
75
+ if (currentType === 'add') {
76
+ // all content lines in Add File are additions
77
+ if (line.length > 0 && !line.startsWith('*** ')) {
78
+ entry.additions++;
79
+ }
80
+ }
81
+ else if (currentType === 'delete') {
82
+ // all content lines in Delete File are deletions
83
+ if (line.length > 0 && !line.startsWith('*** ')) {
84
+ entry.deletions++;
85
+ }
86
+ }
87
+ else if (inHunk) {
88
+ if (line.startsWith('+')) {
89
+ entry.additions++;
90
+ }
91
+ else if (line.startsWith('-')) {
92
+ entry.deletions++;
93
+ }
94
+ }
95
+ }
96
+ return counts;
97
+ }
@@ -0,0 +1,20 @@
1
+ // Platform-level Discord Components V2 constants and structural types.
2
+ // Keeps Discord numeric protocol details out of command/render modules.
3
+ export const PLATFORM_COMPONENT_TYPE = {
4
+ ACTION_ROW: 1,
5
+ BUTTON: 2,
6
+ TEXT_DISPLAY: 10,
7
+ SEPARATOR: 14,
8
+ CONTAINER: 17,
9
+ };
10
+ export const PLATFORM_BUTTON_STYLE = {
11
+ PRIMARY: 1,
12
+ SECONDARY: 2,
13
+ SUCCESS: 3,
14
+ DANGER: 4,
15
+ LINK: 5,
16
+ };
17
+ export const PLATFORM_SEPARATOR_SPACING = {
18
+ SMALL: 1,
19
+ LARGE: 2,
20
+ };