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.
- package/dist/adapter-rest-boundary.test.js +34 -0
- package/dist/agent-model.e2e.test.js +2 -20
- package/dist/cli.js +50 -13
- package/dist/commands/channel-ref.js +16 -0
- package/dist/commands/diff.js +20 -85
- package/dist/commands/merge-worktree.js +5 -17
- package/dist/commands/new-worktree.js +5 -9
- package/dist/commands/permissions.js +77 -11
- package/dist/commands/resume.js +5 -9
- package/dist/commands/screenshare.js +295 -0
- package/dist/commands/session.js +6 -17
- package/dist/critique-utils.js +95 -0
- package/dist/diff-patch-plugin.js +314 -0
- package/dist/discord-bot.js +19 -14
- package/dist/discord-js-import-boundary.test.js +62 -0
- package/dist/discord-utils.js +44 -0
- package/dist/event-stream-real-capture.e2e.test.js +2 -20
- package/dist/gateway-proxy.e2e.test.js +2 -5
- package/dist/generated/cloudflare/browser.js +17 -0
- package/dist/generated/cloudflare/client.js +34 -0
- package/dist/generated/cloudflare/commonInputTypes.js +10 -0
- package/dist/generated/cloudflare/enums.js +48 -0
- package/dist/generated/cloudflare/internal/class.js +47 -0
- package/dist/generated/cloudflare/internal/prismaNamespace.js +252 -0
- package/dist/generated/cloudflare/internal/prismaNamespaceBrowser.js +222 -0
- package/dist/generated/cloudflare/internal/query_compiler_fast_bg.js +135 -0
- package/dist/generated/cloudflare/models/bot_api_keys.js +1 -0
- package/dist/generated/cloudflare/models/bot_tokens.js +1 -0
- package/dist/generated/cloudflare/models/channel_agents.js +1 -0
- package/dist/generated/cloudflare/models/channel_directories.js +1 -0
- package/dist/generated/cloudflare/models/channel_mention_mode.js +1 -0
- package/dist/generated/cloudflare/models/channel_models.js +1 -0
- package/dist/generated/cloudflare/models/channel_verbosity.js +1 -0
- package/dist/generated/cloudflare/models/channel_worktrees.js +1 -0
- package/dist/generated/cloudflare/models/forum_sync_configs.js +1 -0
- package/dist/generated/cloudflare/models/global_models.js +1 -0
- package/dist/generated/cloudflare/models/ipc_requests.js +1 -0
- package/dist/generated/cloudflare/models/part_messages.js +1 -0
- package/dist/generated/cloudflare/models/scheduled_tasks.js +1 -0
- package/dist/generated/cloudflare/models/session_agents.js +1 -0
- package/dist/generated/cloudflare/models/session_events.js +1 -0
- package/dist/generated/cloudflare/models/session_models.js +1 -0
- package/dist/generated/cloudflare/models/session_start_sources.js +1 -0
- package/dist/generated/cloudflare/models/thread_sessions.js +1 -0
- package/dist/generated/cloudflare/models/thread_worktrees.js +1 -0
- package/dist/generated/cloudflare/models.js +1 -0
- package/dist/generated/node/browser.js +17 -0
- package/dist/generated/node/client.js +37 -0
- package/dist/generated/node/commonInputTypes.js +10 -0
- package/dist/generated/node/enums.js +48 -0
- package/dist/generated/node/internal/class.js +49 -0
- package/dist/generated/node/internal/prismaNamespace.js +252 -0
- package/dist/generated/node/internal/prismaNamespaceBrowser.js +222 -0
- package/dist/generated/node/models/bot_api_keys.js +1 -0
- package/dist/generated/node/models/bot_tokens.js +1 -0
- package/dist/generated/node/models/channel_agents.js +1 -0
- package/dist/generated/node/models/channel_directories.js +1 -0
- package/dist/generated/node/models/channel_mention_mode.js +1 -0
- package/dist/generated/node/models/channel_models.js +1 -0
- package/dist/generated/node/models/channel_verbosity.js +1 -0
- package/dist/generated/node/models/channel_worktrees.js +1 -0
- package/dist/generated/node/models/forum_sync_configs.js +1 -0
- package/dist/generated/node/models/global_models.js +1 -0
- package/dist/generated/node/models/ipc_requests.js +1 -0
- package/dist/generated/node/models/part_messages.js +1 -0
- package/dist/generated/node/models/scheduled_tasks.js +1 -0
- package/dist/generated/node/models/session_agents.js +1 -0
- package/dist/generated/node/models/session_events.js +1 -0
- package/dist/generated/node/models/session_models.js +1 -0
- package/dist/generated/node/models/session_start_sources.js +1 -0
- package/dist/generated/node/models/thread_sessions.js +1 -0
- package/dist/generated/node/models/thread_worktrees.js +1 -0
- package/dist/generated/node/models.js +1 -0
- package/dist/interaction-handler.js +10 -0
- package/dist/kimaki-digital-twin.e2e.test.js +2 -20
- package/dist/message-flags-boundary.test.js +54 -0
- package/dist/message-formatting.js +3 -62
- package/dist/onboarding-tutorial-plugin.js +1 -1
- package/dist/opencode-command.js +129 -0
- package/dist/opencode-command.test.js +48 -0
- package/dist/opencode-interrupt-plugin.js +19 -1
- package/dist/opencode-interrupt-plugin.test.js +0 -5
- package/dist/opencode-plugin-loading.e2e.test.js +9 -20
- package/dist/opencode-plugin.js +4 -4
- package/dist/opencode.js +150 -27
- package/dist/patch-text-parser.js +97 -0
- package/dist/platform/components-v2.js +20 -0
- package/dist/platform/discord-adapter.js +1440 -0
- package/dist/platform/discord-routes.js +31 -0
- package/dist/platform/message-flags.js +8 -0
- package/dist/platform/platform-value.js +41 -0
- package/dist/platform/slack-adapter.js +872 -0
- package/dist/platform/slack-markdown.js +169 -0
- package/dist/platform/types.js +4 -0
- package/dist/queue-advanced-e2e-setup.js +265 -0
- package/dist/queue-advanced-footer.e2e.test.js +173 -0
- package/dist/queue-advanced-model-switch.e2e.test.js +299 -0
- package/dist/queue-advanced-permissions-typing.e2e.test.js +73 -1
- package/dist/runtime-lifecycle.e2e.test.js +2 -20
- package/dist/session-handler/event-stream-state.js +5 -0
- package/dist/session-handler/event-stream-state.test.js +6 -2
- package/dist/session-handler/thread-session-runtime.js +32 -2
- package/dist/system-message.js +26 -23
- package/dist/test-utils.js +16 -0
- package/dist/thread-message-queue.e2e.test.js +2 -20
- package/dist/utils.js +3 -1
- package/dist/voice-message.e2e.test.js +2 -20
- package/dist/voice.js +122 -9
- package/dist/voice.test.js +17 -2
- package/dist/websockify.js +69 -0
- package/dist/worktree-lifecycle.e2e.test.js +308 -0
- package/package.json +4 -2
- package/skills/critique/SKILL.md +17 -0
- package/skills/egaki/SKILL.md +35 -0
- package/skills/event-sourcing-state/SKILL.md +252 -0
- package/skills/goke/SKILL.md +1 -0
- package/skills/npm-package/SKILL.md +21 -2
- package/skills/playwriter/SKILL.md +1 -1
- package/skills/x-articles/SKILL.md +554 -0
- package/src/agent-model.e2e.test.ts +4 -19
- package/src/cli.ts +60 -13
- package/src/commands/diff.ts +25 -99
- package/src/commands/merge-worktree.ts +5 -21
- package/src/commands/new-worktree.ts +5 -11
- package/src/commands/permissions.ts +100 -15
- package/src/commands/resume.ts +5 -12
- package/src/commands/screenshare.ts +354 -0
- package/src/commands/session.ts +6 -23
- package/src/critique-utils.ts +139 -0
- package/src/discord-bot.ts +20 -15
- package/src/discord-utils.ts +53 -0
- package/src/event-stream-real-capture.e2e.test.ts +4 -20
- package/src/gateway-proxy.e2e.test.ts +2 -5
- package/src/interaction-handler.ts +15 -0
- package/src/kimaki-digital-twin.e2e.test.ts +2 -21
- package/src/message-formatting.ts +3 -68
- package/src/onboarding-tutorial-plugin.ts +1 -1
- package/src/opencode-command.test.ts +70 -0
- package/src/opencode-command.ts +188 -0
- package/src/opencode-interrupt-plugin.test.ts +0 -5
- package/src/opencode-interrupt-plugin.ts +34 -1
- package/src/opencode-plugin-loading.e2e.test.ts +25 -35
- package/src/opencode-plugin.ts +5 -4
- package/src/opencode.ts +199 -32
- package/src/patch-text-parser.ts +107 -0
- package/src/queue-advanced-e2e-setup.ts +273 -0
- package/src/queue-advanced-footer.e2e.test.ts +211 -0
- package/src/queue-advanced-model-switch.e2e.test.ts +383 -0
- package/src/queue-advanced-permissions-typing.e2e.test.ts +92 -0
- package/src/runtime-lifecycle.e2e.test.ts +4 -19
- package/src/session-handler/event-stream-state.test.ts +6 -2
- package/src/session-handler/event-stream-state.ts +5 -0
- package/src/session-handler/thread-session-runtime.ts +45 -2
- package/src/system-message.ts +26 -23
- package/src/test-utils.ts +17 -0
- package/src/thread-message-queue.e2e.test.ts +2 -20
- package/src/utils.ts +3 -1
- package/src/voice-message.e2e.test.ts +3 -20
- package/src/voice.test.ts +26 -2
- package/src/voice.ts +147 -9
- package/src/websockify.ts +101 -0
- 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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
237
|
+
const patchCounts = parsePatchFileCounts(patchText)
|
|
303
238
|
return [...patchCounts.entries()]
|
|
304
239
|
.map(([filePath, { additions, deletions }]) => {
|
|
305
240
|
const fileName = filePath.split('/').pop() || ''
|
|
@@ -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:
|
|
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 =
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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))
|