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
|
@@ -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:
|
|
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 =
|
|
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
|
|
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({
|
package/dist/opencode-plugin.js
CHANGED
|
@@ -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
|
-
|
|
127
|
-
|
|
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.
|
|
211
|
+
const result = errore.try({
|
|
132
212
|
try: () => {
|
|
133
|
-
|
|
213
|
+
const commandOutput = execFileSync(whichCmd, ['opencode'], {
|
|
134
214
|
encoding: 'utf8',
|
|
135
215
|
timeout: 5000,
|
|
136
|
-
})
|
|
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(
|
|
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
|
-
|
|
559
|
-
|
|
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
|
-
|
|
681
|
+
server.process.kill('SIGTERM');
|
|
563
682
|
},
|
|
564
683
|
catch: (error) => {
|
|
565
|
-
return new 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
|
+
};
|