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
@@ -3,7 +3,7 @@
3
3
  // (task, interruption, permission, action buttons, and question flows).
4
4
 
5
5
  import fs from 'node:fs'
6
- import net from 'node:net'
6
+
7
7
  import path from 'node:path'
8
8
  import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest'
9
9
  import { ChannelType, Client, GatewayIntentBits, Partials, type APIMessage } from 'discord.js'
@@ -22,7 +22,7 @@ import {
22
22
  type VerbosityLevel,
23
23
  } from './database.js'
24
24
  import { startHranaServer, stopHranaServer } from './hrana-server.js'
25
- import { cleanupTestSessions } from './test-utils.js'
25
+ import { chooseLockPort, cleanupTestSessions } from './test-utils.js'
26
26
  import { waitForBotMessageContaining, waitForBotReplyAfterUserMessage } from './test-utils.js'
27
27
  import { stopOpencodeServer } from './opencode.js'
28
28
  import { disposeRuntime, pendingPermissions } from './session-handler/thread-session-runtime.js'
@@ -69,23 +69,7 @@ function createRunDirectories() {
69
69
  }
70
70
  }
71
71
 
72
- function chooseLockPort(): Promise<number> {
73
- return new Promise((resolve, reject) => {
74
- const server = net.createServer()
75
- server.listen(0, () => {
76
- const address = server.address()
77
- if (!address || typeof address === 'string') {
78
- server.close()
79
- reject(new Error('Failed to resolve lock port'))
80
- return
81
- }
82
- const port = address.port
83
- server.close(() => {
84
- resolve(port)
85
- })
86
- })
87
- })
88
- }
72
+
89
73
 
90
74
  function createDiscordJsClient({ restUrl }: { restUrl: string }) {
91
75
  return new Client({
@@ -327,7 +311,7 @@ describe('real event stream capture fixtures (cached provider)', () => {
327
311
 
328
312
  beforeAll(async () => {
329
313
  testStartTime = Date.now()
330
- lockPort = await chooseLockPort()
314
+ lockPort = chooseLockPort({ key: TEXT_CHANNEL_ID })
331
315
 
332
316
  listJsonlFiles(directories.sessionEventsDir).forEach((fileName) => {
333
317
  fs.rmSync(path.join(directories.sessionEventsDir, fileName), {
@@ -36,6 +36,7 @@ import { setDataDir } from './config.js'
36
36
  import type { VerbosityLevel } from './database.js'
37
37
  import { startDiscordBot } from './discord-bot.js'
38
38
  import {
39
+ chooseLockPort,
39
40
  cleanupTestSessions,
40
41
  waitForFooterMessage,
41
42
  } from './test-utils.js'
@@ -91,10 +92,6 @@ function createRunDirectories() {
91
92
  return { root, dataDir, projectDirectory }
92
93
  }
93
94
 
94
- function chooseLockPort(): Promise<number> {
95
- return getAvailablePort()
96
- }
97
-
98
95
  function createDiscordJsClient({ restUrl }: { restUrl: string }) {
99
96
  return new Client({
100
97
  intents: [
@@ -253,7 +250,7 @@ describeIf('gateway-proxy e2e', () => {
253
250
 
254
251
  beforeAll(async () => {
255
252
  testStartTime = Date.now()
256
- const lockPort = await chooseLockPort()
253
+ const lockPort = chooseLockPort({ key: CHANNEL_1_ID })
257
254
  directories = createRunDirectories()
258
255
  process.env['KIMAKI_LOCK_PORT'] = String(lockPort)
259
256
  process.env['KIMAKI_VITEST'] = '1'
@@ -90,6 +90,10 @@ import { handleContextUsageCommand } from './commands/context-usage.js'
90
90
  import { handleSessionIdCommand } from './commands/session-id.js'
91
91
  import { handleUpgradeAndRestartCommand } from './commands/upgrade.js'
92
92
  import { handleMcpCommand, handleMcpSelectMenu } from './commands/mcp.js'
93
+ import {
94
+ handleScreenshareCommand,
95
+ handleScreenshareStopCommand,
96
+ } from './commands/screenshare.js'
93
97
  import { handleModelVariantSelectMenu } from './commands/model.js'
94
98
  import {
95
99
  handleModelVariantCommand,
@@ -328,6 +332,17 @@ export function registerInteractionHandler({
328
332
  case 'mcp':
329
333
  await handleMcpCommand({ command: interaction, appId })
330
334
  return
335
+
336
+ case 'screenshare':
337
+ await handleScreenshareCommand({ command: interaction, appId })
338
+ return
339
+
340
+ case 'screenshare-stop':
341
+ await handleScreenshareStopCommand({
342
+ command: interaction,
343
+ appId,
344
+ })
345
+ return
331
346
  }
332
347
 
333
348
  // Handle quick agent commands (ending with -agent suffix, but not the base /agent command)
@@ -2,7 +2,6 @@
2
2
  // Verifies onboarding channel creation, message -> thread creation, and assistant reply.
3
3
 
4
4
  import fs from 'node:fs'
5
- import net from 'node:net'
6
5
  import path from 'node:path'
7
6
  import { expect, test } from 'vitest'
8
7
  import { ChannelType, Client, GatewayIntentBits, Partials } from 'discord.js'
@@ -17,7 +16,7 @@ import {
17
16
  setChannelDirectory,
18
17
  } from './database.js'
19
18
  import { startHranaServer, stopHranaServer } from './hrana-server.js'
20
- import { cleanupTestSessions } from './test-utils.js'
19
+ import { cleanupTestSessions, chooseLockPort } from './test-utils.js'
21
20
  import { stopOpencodeServer } from './opencode.js'
22
21
 
23
22
  const geminiApiKey =
@@ -44,24 +43,6 @@ function createRunDirectories() {
44
43
  }
45
44
  }
46
45
 
47
- function chooseLockPort(): Promise<number> {
48
- return new Promise((resolve, reject) => {
49
- const server = net.createServer()
50
- server.listen(0, () => {
51
- const address = server.address()
52
- if (!address || typeof address === 'string') {
53
- server.close()
54
- reject(new Error('Failed to resolve lock port'))
55
- return
56
- }
57
- const port = address.port
58
- server.close(() => {
59
- resolve(port)
60
- })
61
- })
62
- })
63
- }
64
-
65
46
  function createDiscordJsClient({ restUrl }: { restUrl: string }) {
66
47
  return new Client({
67
48
  intents: [
@@ -88,7 +69,7 @@ e2eTest(
88
69
  async () => {
89
70
  const testStartTime = Date.now()
90
71
  const directories = createRunDirectories()
91
- const lockPort = await chooseLockPort()
72
+ const lockPort = chooseLockPort({ key: 'kimaki-digital-twin-e2e' })
92
73
 
93
74
  process.env['KIMAKI_LOCK_PORT'] = String(lockPort)
94
75
  setDataDir(directories.dataDir)
@@ -13,6 +13,7 @@ import * as errore from 'errore'
13
13
  import { createLogger, LogPrefix } from './logger.js'
14
14
  import { FetchError } from './errors.js'
15
15
  import { processImage } from './image-utils.js'
16
+ import { parsePatchFileCounts } from './patch-text-parser.js'
16
17
 
17
18
  // Generic message type compatible with both v1 and v2 SDK
18
19
  type GenericSessionMessage = {
@@ -61,73 +62,7 @@ function escapeInlineMarkdown(text: string): string {
61
62
  return text.replace(/([*_~|`\\])/g, '\\$1')
62
63
  }
63
64
 
64
- /**
65
- * Parses a patchText string (apply_patch format) and counts additions/deletions per file.
66
- * Patch format uses `*** Add File:`, `*** Update File:`, `*** Delete File:` headers,
67
- * with diff lines prefixed by `+` (addition) or `-` (deletion) inside `@@` hunks.
68
- */
69
- function parsePatchCounts(
70
- patchText: string,
71
- ): Map<string, { additions: number; deletions: number }> {
72
- const counts = new Map<string, { additions: number; deletions: number }>()
73
- const lines = patchText.split('\n')
74
- let currentFile = ''
75
- let currentType = ''
76
- let inHunk = false
77
-
78
- for (const line of lines) {
79
- const addMatch = line.match(/^\*\*\* Add File:\s*(.+)/)
80
- const updateMatch = line.match(/^\*\*\* Update File:\s*(.+)/)
81
- const deleteMatch = line.match(/^\*\*\* Delete File:\s*(.+)/)
82
-
83
- if (addMatch || updateMatch || deleteMatch) {
84
- const match = addMatch || updateMatch || deleteMatch
85
- currentFile = (match?.[1] ?? '').trim()
86
- currentType = addMatch ? 'add' : updateMatch ? 'update' : 'delete'
87
- counts.set(currentFile, { additions: 0, deletions: 0 })
88
- inHunk = false
89
- continue
90
- }
91
-
92
- if (line.startsWith('@@')) {
93
- inHunk = true
94
- continue
95
- }
96
-
97
- if (line.startsWith('*** ')) {
98
- inHunk = false
99
- continue
100
- }
101
-
102
- if (!currentFile) {
103
- continue
104
- }
105
-
106
- const entry = counts.get(currentFile)
107
- if (!entry) {
108
- continue
109
- }
110
-
111
- if (currentType === 'add') {
112
- // all content lines in Add File are additions
113
- if (line.length > 0 && !line.startsWith('*** ')) {
114
- entry.additions++
115
- }
116
- } else if (currentType === 'delete') {
117
- // all content lines in Delete File are deletions
118
- if (line.length > 0 && !line.startsWith('*** ')) {
119
- entry.deletions++
120
- }
121
- } else if (inHunk) {
122
- if (line.startsWith('+')) {
123
- entry.additions++
124
- } else if (line.startsWith('-')) {
125
- entry.deletions++
126
- }
127
- }
128
- }
129
- return counts
130
- }
65
+ // parsePatchCounts → imported from patch-text-parser.ts as parsePatchFileCounts
131
66
 
132
67
  /**
133
68
  * Normalize whitespace: convert newlines to spaces and collapse consecutive spaces.
@@ -299,7 +234,7 @@ export function getToolSummaryText(part: Part): string {
299
234
  if (!patchText) {
300
235
  return ''
301
236
  }
302
- const patchCounts = parsePatchCounts(patchText)
237
+ const patchCounts = parsePatchFileCounts(patchText)
303
238
  return [...patchCounts.entries()]
304
239
  .map(([filePath, { additions, deletions }]) => {
305
240
  const fileName = filePath.split('/').pop() || ''
@@ -58,7 +58,7 @@ const onboardingTutorialPlugin: Plugin = async () => {
58
58
  }
59
59
 
60
60
  output.parts.push({
61
- id: crypto.randomUUID(),
61
+ id: `prt_${crypto.randomUUID()}`,
62
62
  sessionID,
63
63
  messageID: firstText.messageID,
64
64
  type: 'text' as const,
@@ -0,0 +1,70 @@
1
+ // Regression tests for Windows OpenCode command resolution and spawn args.
2
+
3
+ import { describe, expect, test } from 'vitest'
4
+ import {
5
+ getSpawnCommandAndArgs,
6
+ selectResolvedCommand,
7
+ splitCommandLookupOutput,
8
+ } from './opencode-command.js'
9
+
10
+ describe('splitCommandLookupOutput', () => {
11
+ test('splits windows command lookup output into trimmed lines', () => {
12
+ expect(
13
+ splitCommandLookupOutput(
14
+ 'C:\\Program Files\\nodejs\\opencode\r\nC:\\Program Files\\nodejs\\opencode.cmd\r\n',
15
+ ),
16
+ ).toEqual([
17
+ 'C:\\Program Files\\nodejs\\opencode',
18
+ 'C:\\Program Files\\nodejs\\opencode.cmd',
19
+ ])
20
+ })
21
+ })
22
+
23
+ describe('selectResolvedCommand', () => {
24
+ test('prefers npm cmd shims on windows', () => {
25
+ expect(
26
+ selectResolvedCommand({
27
+ output: 'C:\\Program Files\\nodejs\\opencode\r\nC:\\Program Files\\nodejs\\opencode.cmd\r\n',
28
+ isWindows: true,
29
+ }),
30
+ ).toBe('C:\\Program Files\\nodejs\\opencode.cmd')
31
+ })
32
+
33
+ test('keeps first result on non-windows platforms', () => {
34
+ expect(
35
+ selectResolvedCommand({
36
+ output: '/usr/local/bin/opencode\n/opt/homebrew/bin/opencode\n',
37
+ isWindows: false,
38
+ }),
39
+ ).toBe('/usr/local/bin/opencode')
40
+ })
41
+ })
42
+
43
+ describe('getSpawnCommandAndArgs', () => {
44
+ test('wraps windows cmd shims through cmd.exe without double-quoting by node', () => {
45
+ expect(
46
+ getSpawnCommandAndArgs({
47
+ resolvedCommand: 'C:\\Program Files\\nodejs\\opencode.cmd',
48
+ baseArgs: ['serve', '--port', '4096'],
49
+ platform: 'win32',
50
+ }),
51
+ ).toEqual({
52
+ command: 'cmd.exe',
53
+ args: ['/d', '/s', '/c', '"C:\\Program Files\\nodejs\\opencode.cmd"', 'serve', '--port', '4096'],
54
+ windowsVerbatimArguments: true,
55
+ })
56
+ })
57
+
58
+ test('leaves direct executables unchanged on windows', () => {
59
+ expect(
60
+ getSpawnCommandAndArgs({
61
+ resolvedCommand: 'C:\\tools\\opencode.exe',
62
+ baseArgs: ['serve', '--port', '4096'],
63
+ platform: 'win32',
64
+ }),
65
+ ).toEqual({
66
+ command: 'C:\\tools\\opencode.exe',
67
+ args: ['serve', '--port', '4096'],
68
+ })
69
+ })
70
+ })
@@ -0,0 +1,188 @@
1
+ // Shared OpenCode and Kimaki command resolution helpers.
2
+ // Normalizes `which`/`where` output across platforms, builds safe spawn
3
+ // arguments for Windows npm `.cmd` shims without relying on `shell: true`,
4
+ // and creates a stable `kimaki` shim for OpenCode child processes.
5
+
6
+ import fs from 'node:fs'
7
+ import path from 'node:path'
8
+
9
+ const WINDOWS_CMD_SHIM_REGEX = /\.(cmd|bat)$/i
10
+
11
+ function quotePosixShellSegment(value: string): string {
12
+ return `'${value.replaceAll("'", `'\\''`)}'`
13
+ }
14
+
15
+ export function splitCommandLookupOutput(output: string): string[] {
16
+ return output
17
+ .split(/\r?\n/g)
18
+ .map((line) => {
19
+ return line.trim()
20
+ })
21
+ .filter((line) => {
22
+ return line.length > 0
23
+ })
24
+ }
25
+
26
+ export function selectResolvedCommand({
27
+ output,
28
+ isWindows,
29
+ }: {
30
+ output: string
31
+ isWindows: boolean
32
+ }): string | null {
33
+ const lines = splitCommandLookupOutput(output)
34
+ if (lines.length === 0) {
35
+ return null
36
+ }
37
+ if (!isWindows) {
38
+ return lines[0] || null
39
+ }
40
+ const cmdShim = lines.find((line) => {
41
+ return WINDOWS_CMD_SHIM_REGEX.test(line)
42
+ })
43
+ return cmdShim || lines[0] || null
44
+ }
45
+
46
+ function quoteWindowsCommandSegment(value: string): string {
47
+ if (!/[\s"]/u.test(value)) {
48
+ return value
49
+ }
50
+ return `"${value.replaceAll('"', '\\"')}"`
51
+ }
52
+
53
+ export function getSpawnCommandAndArgs({
54
+ resolvedCommand,
55
+ baseArgs,
56
+ platform,
57
+ }: {
58
+ resolvedCommand: string
59
+ baseArgs: string[]
60
+ platform?: NodeJS.Platform
61
+ }): {
62
+ command: string
63
+ args: string[]
64
+ windowsVerbatimArguments?: boolean
65
+ } {
66
+ const effectivePlatform = platform || process.platform
67
+ if (effectivePlatform !== 'win32') {
68
+ return { command: resolvedCommand, args: baseArgs }
69
+ }
70
+
71
+ if (!WINDOWS_CMD_SHIM_REGEX.test(resolvedCommand)) {
72
+ return { command: resolvedCommand, args: baseArgs }
73
+ }
74
+
75
+ return {
76
+ command: 'cmd.exe',
77
+ args: [
78
+ '/d',
79
+ '/s',
80
+ '/c',
81
+ quoteWindowsCommandSegment(resolvedCommand),
82
+ ...baseArgs.map((arg) => {
83
+ return quoteWindowsCommandSegment(arg)
84
+ }),
85
+ ],
86
+ // Let cmd.exe receive the command line exactly as constructed above.
87
+ // Without this, Node re-quotes the executable segment and npm shim paths
88
+ // like `C:\Program Files\nodejs\opencode.cmd` break again.
89
+ windowsVerbatimArguments: true,
90
+ }
91
+ }
92
+
93
+ export function ensureKimakiCommandShim({
94
+ dataDir,
95
+ execPath,
96
+ execArgv,
97
+ entryScript,
98
+ platform,
99
+ }: {
100
+ dataDir: string
101
+ execPath: string
102
+ execArgv: string[]
103
+ entryScript: string
104
+ platform?: NodeJS.Platform
105
+ }): string | Error {
106
+ const effectivePlatform = platform || process.platform
107
+ const shimDirectory = path.join(dataDir, 'bin')
108
+
109
+ try {
110
+ fs.mkdirSync(shimDirectory, { recursive: true })
111
+ const launcherArgs = [...execArgv, entryScript]
112
+
113
+ if (effectivePlatform === 'win32') {
114
+ const shimPath = path.join(shimDirectory, 'kimaki.cmd')
115
+ const shimContent = [
116
+ '@echo off',
117
+ [execPath, ...launcherArgs].map((segment) => {
118
+ return `"${segment.replaceAll('"', '""')}"`
119
+ }).join(' ') + ' %*',
120
+ '',
121
+ ].join('\r\n')
122
+ writeShimIfNeeded({
123
+ shimPath,
124
+ shimContent,
125
+ })
126
+ return shimDirectory
127
+ }
128
+
129
+ const shimPath = path.join(shimDirectory, 'kimaki')
130
+ const shimContent = [
131
+ '#!/bin/sh',
132
+ `exec ${[execPath, ...launcherArgs].map((segment) => {
133
+ return quotePosixShellSegment(segment)
134
+ }).join(' ')} "$@"`,
135
+ '',
136
+ ].join('\n')
137
+ writeShimIfNeeded({
138
+ shimPath,
139
+ shimContent,
140
+ mode: 0o755,
141
+ })
142
+ return shimDirectory
143
+ } catch (cause) {
144
+ return new Error('Failed to create kimaki command shim', { cause })
145
+ }
146
+ }
147
+
148
+ export function prependPathEntry({
149
+ entry,
150
+ existingPath,
151
+ }: {
152
+ entry: string
153
+ existingPath?: string
154
+ }): string {
155
+ const pathEntries = (existingPath || '').split(path.delimiter).filter((segment) => {
156
+ return segment.length > 0
157
+ })
158
+ if (pathEntries.includes(entry)) {
159
+ return existingPath || entry
160
+ }
161
+ return [entry, ...pathEntries].join(path.delimiter)
162
+ }
163
+
164
+ export function getPathEnvKey(env: NodeJS.ProcessEnv): string {
165
+ return Object.keys(env).find((key) => {
166
+ return key.toLowerCase() === 'path'
167
+ }) || 'PATH'
168
+ }
169
+
170
+ function writeShimIfNeeded({
171
+ shimPath,
172
+ shimContent,
173
+ mode,
174
+ }: {
175
+ shimPath: string
176
+ shimContent: string
177
+ mode?: number
178
+ }): void {
179
+ const existingContent = fs.existsSync(shimPath)
180
+ ? fs.readFileSync(shimPath, 'utf8')
181
+ : null
182
+ if (existingContent !== shimContent) {
183
+ fs.writeFileSync(shimPath, shimContent, 'utf8')
184
+ }
185
+ if (mode !== undefined) {
186
+ fs.chmodSync(shimPath, mode)
187
+ }
188
+ }
@@ -121,11 +121,6 @@ function createChatOutput({
121
121
  sessionID,
122
122
  role: 'user',
123
123
  time: { created: Date.now() },
124
- agent: 'build',
125
- model: {
126
- providerID: 'deterministic-provider',
127
- modelID: 'deterministic-v2',
128
- },
129
124
  },
130
125
  parts: parts || [{ type: 'text', text: 'user message' }],
131
126
  } as InterruptChatOutput
@@ -13,6 +13,13 @@ type PendingMessage = {
13
13
  started: boolean
14
14
  timer: ReturnType<typeof setTimeout>
15
15
  abortAfterStepMessageID: string | undefined
16
+ agent: string | undefined
17
+ model:
18
+ | {
19
+ providerID: string
20
+ modelID: string
21
+ }
22
+ | undefined
16
23
  }
17
24
 
18
25
  type EventWaiter = {
@@ -100,6 +107,8 @@ const interruptOpencodeSessionOnUserMessage: Plugin = async (ctx) => {
100
107
  started: false,
101
108
  timer,
102
109
  abortAfterStepMessageID: latestAssistantMessageIDBySession.get(sessionID),
110
+ agent: undefined,
111
+ model: undefined,
103
112
  })
104
113
  }
105
114
 
@@ -176,9 +185,27 @@ const interruptOpencodeSessionOnUserMessage: Plugin = async (ctx) => {
176
185
  return
177
186
  }
178
187
 
188
+ // Keep the queued user message execution context across abort+resume.
189
+ // Without this, OpenCode re-resolves model defaults and can ignore
190
+ // /model session overrides (issue #77).
191
+ const resumeBody: {
192
+ parts: []
193
+ agent?: string
194
+ model?: {
195
+ providerID: string
196
+ modelID: string
197
+ }
198
+ } = { parts: [] }
199
+ if (currentPending.agent) {
200
+ resumeBody.agent = currentPending.agent
201
+ }
202
+ if (currentPending.model) {
203
+ resumeBody.model = currentPending.model
204
+ }
205
+
179
206
  await ctx.client.session.promptAsync({
180
207
  path: { id: sessionID },
181
- body: { parts: [] },
208
+ body: resumeBody,
182
209
  })
183
210
  clearPendingByMessageId({ messageID })
184
211
 
@@ -287,6 +314,12 @@ const interruptOpencodeSessionOnUserMessage: Plugin = async (ctx) => {
287
314
  sessionID,
288
315
  delayMs: interruptStepTimeoutMs,
289
316
  })
317
+ const pending = pendingByMessageId.get(messageID)
318
+ if (!pending) {
319
+ return
320
+ }
321
+ pending.agent = output.message.agent
322
+ pending.model = output.message.model
290
323
  },
291
324
  }
292
325
  }
@@ -5,31 +5,15 @@
5
5
 
6
6
  import { spawn, type ChildProcess } from 'node:child_process'
7
7
  import fs from 'node:fs'
8
- import net from 'node:net'
9
8
  import path from 'node:path'
10
9
  import { fileURLToPath } from 'node:url'
11
10
  import { test, expect } from 'vitest'
12
11
  import { resolveOpencodeCommand } from './opencode.js'
12
+ import { getSpawnCommandAndArgs } from './opencode-command.js'
13
+ import { chooseLockPort } from './test-utils.js'
13
14
 
14
15
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
15
16
 
16
- async function getOpenPort(): Promise<number> {
17
- return new Promise((resolve, reject) => {
18
- const server = net.createServer()
19
- server.listen(0, () => {
20
- const address = server.address()
21
- if (address && typeof address === 'object') {
22
- server.close(() => {
23
- resolve(address.port)
24
- })
25
- } else {
26
- reject(new Error('Failed to get port'))
27
- }
28
- })
29
- server.on('error', reject)
30
- })
31
- }
32
-
33
17
  async function waitForHealth({
34
18
  port,
35
19
  maxAttempts = 30,
@@ -59,27 +43,33 @@ test(
59
43
  const projectDir = path.resolve(process.cwd(), 'tmp', 'plugin-loading-e2e')
60
44
  fs.mkdirSync(projectDir, { recursive: true })
61
45
 
62
- const port = await getOpenPort()
46
+ const port = chooseLockPort({ key: 'opencode-plugin-loading-e2e' })
63
47
  const pluginPath = new URL('../src/opencode-plugin.ts', import.meta.url).href
64
48
  const stderrLines: string[] = []
65
49
 
66
- const serverProcess: ChildProcess = spawn(
67
- resolveOpencodeCommand(),
68
- ['serve', '--port', port.toString(), '--print-logs', '--log-level', 'DEBUG'],
69
- {
70
- stdio: 'pipe',
71
- cwd: projectDir,
72
- env: {
73
- ...process.env,
74
- OPENCODE_CONFIG_CONTENT: JSON.stringify({
75
- $schema: 'https://opencode.ai/config.json',
76
- lsp: false,
77
- formatter: false,
78
- plugin: [pluginPath],
79
- }),
80
- },
50
+ const {
51
+ command,
52
+ args,
53
+ windowsVerbatimArguments,
54
+ } = getSpawnCommandAndArgs({
55
+ resolvedCommand: resolveOpencodeCommand(),
56
+ baseArgs: ['serve', '--port', port.toString(), '--print-logs', '--log-level', 'DEBUG'],
57
+ })
58
+
59
+ const serverProcess: ChildProcess = spawn(command, args, {
60
+ stdio: 'pipe',
61
+ cwd: projectDir,
62
+ windowsVerbatimArguments,
63
+ env: {
64
+ ...process.env,
65
+ OPENCODE_CONFIG_CONTENT: JSON.stringify({
66
+ $schema: 'https://opencode.ai/config.json',
67
+ lsp: false,
68
+ formatter: false,
69
+ plugin: [pluginPath],
70
+ }),
81
71
  },
82
- )
72
+ })
83
73
 
84
74
  serverProcess.stderr?.on('data', (data) => {
85
75
  stderrLines.push(...data.toString().split('\n').filter(Boolean))