kimaki 0.4.87 → 0.4.89
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/add-directory.e2e.test.js +101 -0
- package/dist/agent-model.e2e.test.js +2 -3
- package/dist/cli-send-thread.e2e.test.js +280 -0
- package/dist/cli.js +7 -1
- package/dist/commands/add-directory.js +67 -0
- package/dist/commands/user-command.js +10 -9
- package/dist/context-awareness-plugin.js +32 -18
- package/dist/context-awareness-plugin.test.js +57 -0
- package/dist/directory-permissions.js +38 -0
- package/dist/directory-permissions.test.js +37 -0
- package/dist/discord-bot.js +14 -0
- package/dist/generated/models/thread_allowed_directories.js +1 -0
- package/dist/kimaki-opencode-plugin.js +1 -0
- package/dist/markdown.test.js +0 -32
- package/dist/message-finish-field.e2e.test.js +164 -0
- package/dist/opencode.js +97 -35
- package/dist/queue-advanced-abort.e2e.test.js +0 -1
- package/dist/queue-advanced-footer.e2e.test.js +3 -40
- package/dist/queue-advanced-model-switch.e2e.test.js +0 -6
- package/dist/queue-advanced-permissions-typing.e2e.test.js +0 -1
- package/dist/queue-advanced-typing-interrupt.e2e.test.js +2 -8
- package/dist/runtime-lifecycle.e2e.test.js +1 -4
- package/dist/session-handler/event-stream-state.test.js +3 -0
- package/dist/session-handler/thread-session-runtime.js +11 -2
- package/dist/task-runner.js +6 -0
- package/dist/task-schedule.js +4 -0
- package/dist/thread-message-queue.e2e.test.js +4 -2
- package/dist/voice-message.e2e.test.js +1 -6
- package/package.json +8 -7
- package/src/agent-model.e2e.test.ts +2 -3
- package/src/cli-send-thread.e2e.test.ts +365 -0
- package/src/cli.ts +13 -1
- package/src/commands/user-command.ts +11 -11
- package/src/context-awareness-plugin.test.ts +66 -0
- package/src/context-awareness-plugin.ts +46 -26
- package/src/discord-bot.ts +15 -0
- package/src/kimaki-opencode-plugin.ts +1 -0
- package/src/markdown.test.ts +0 -32
- package/src/message-finish-field.e2e.test.ts +191 -0
- package/src/opencode.ts +111 -35
- package/src/queue-advanced-abort.e2e.test.ts +0 -1
- package/src/queue-advanced-footer.e2e.test.ts +3 -40
- package/src/queue-advanced-model-switch.e2e.test.ts +0 -6
- package/src/queue-advanced-permissions-typing.e2e.test.ts +0 -1
- package/src/queue-advanced-typing-interrupt.e2e.test.ts +2 -8
- package/src/runtime-lifecycle.e2e.test.ts +1 -4
- package/src/session-handler/event-stream-state.test.ts +3 -0
- package/src/session-handler/thread-runtime-state.ts +4 -0
- package/src/session-handler/thread-session-runtime.ts +13 -0
- package/src/system-message.ts +10 -1
- package/src/task-runner.ts +6 -0
- package/src/task-schedule.ts +6 -0
- package/src/thread-message-queue.e2e.test.ts +4 -2
- package/src/voice-message.e2e.test.ts +1 -6
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Tests for context-awareness directory switch reminders.
|
|
2
|
+
import { describe, expect, test } from 'vitest';
|
|
3
|
+
import { shouldInjectPwd } from './context-awareness-plugin.js';
|
|
4
|
+
describe('shouldInjectPwd', () => {
|
|
5
|
+
test('does not inject when current directory matches announced directory', () => {
|
|
6
|
+
const result = shouldInjectPwd({
|
|
7
|
+
currentDir: '/repo/worktree',
|
|
8
|
+
previousDir: '/repo/main',
|
|
9
|
+
announcedDir: '/repo/worktree',
|
|
10
|
+
});
|
|
11
|
+
expect(result).toMatchInlineSnapshot(`
|
|
12
|
+
{
|
|
13
|
+
"inject": false,
|
|
14
|
+
}
|
|
15
|
+
`);
|
|
16
|
+
});
|
|
17
|
+
test('does not inject without a previous directory to warn about', () => {
|
|
18
|
+
const result = shouldInjectPwd({
|
|
19
|
+
currentDir: '/repo/worktree',
|
|
20
|
+
previousDir: undefined,
|
|
21
|
+
announcedDir: undefined,
|
|
22
|
+
});
|
|
23
|
+
expect(result).toMatchInlineSnapshot(`
|
|
24
|
+
{
|
|
25
|
+
"inject": false,
|
|
26
|
+
}
|
|
27
|
+
`);
|
|
28
|
+
});
|
|
29
|
+
test('names previous and current directories in the correct order', () => {
|
|
30
|
+
const result = shouldInjectPwd({
|
|
31
|
+
currentDir: '/repo/worktree',
|
|
32
|
+
previousDir: '/repo/main',
|
|
33
|
+
announcedDir: undefined,
|
|
34
|
+
});
|
|
35
|
+
expect(result).toMatchInlineSnapshot(`
|
|
36
|
+
{
|
|
37
|
+
"inject": true,
|
|
38
|
+
"text": "
|
|
39
|
+
[working directory changed. Previous working directory: /repo/main. Current working directory: /repo/worktree. You MUST read, write, and edit files only under /repo/worktree. Do NOT read, write, or edit files under /repo/main.]",
|
|
40
|
+
}
|
|
41
|
+
`);
|
|
42
|
+
});
|
|
43
|
+
test('prefers the last announced directory as the previous directory', () => {
|
|
44
|
+
const result = shouldInjectPwd({
|
|
45
|
+
currentDir: '/repo/worktree-b',
|
|
46
|
+
previousDir: '/repo/main',
|
|
47
|
+
announcedDir: '/repo/worktree-a',
|
|
48
|
+
});
|
|
49
|
+
expect(result).toMatchInlineSnapshot(`
|
|
50
|
+
{
|
|
51
|
+
"inject": true,
|
|
52
|
+
"text": "
|
|
53
|
+
[working directory changed. Previous working directory: /repo/worktree-a. Current working directory: /repo/worktree-b. You MUST read, write, and edit files only under /repo/worktree-b. Do NOT read, write, or edit files under /repo/worktree-a.]",
|
|
54
|
+
}
|
|
55
|
+
`);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Directory permission helpers for one-shot external directory preapproval.
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
export function normalizeAllowedDirectoryPath({ input, workingDirectory, }) {
|
|
5
|
+
const trimmedInput = input.trim();
|
|
6
|
+
if (!trimmedInput) {
|
|
7
|
+
return new Error('Path cannot be empty');
|
|
8
|
+
}
|
|
9
|
+
const withoutTrailingGlob = trimmedInput.replace(/[\\/]\*+$/u, '');
|
|
10
|
+
if (!withoutTrailingGlob) {
|
|
11
|
+
return new Error('Path cannot be empty');
|
|
12
|
+
}
|
|
13
|
+
if (withoutTrailingGlob.includes('*') || withoutTrailingGlob.includes('?')) {
|
|
14
|
+
return new Error('Path must be a directory, not a glob pattern');
|
|
15
|
+
}
|
|
16
|
+
const expandedHomeDirectory = (() => {
|
|
17
|
+
if (withoutTrailingGlob === '~') {
|
|
18
|
+
return os.homedir();
|
|
19
|
+
}
|
|
20
|
+
if (withoutTrailingGlob.startsWith('~/')) {
|
|
21
|
+
return path.join(os.homedir(), withoutTrailingGlob.slice(2));
|
|
22
|
+
}
|
|
23
|
+
return withoutTrailingGlob;
|
|
24
|
+
})();
|
|
25
|
+
const absolutePath = path.isAbsolute(expandedHomeDirectory)
|
|
26
|
+
? expandedHomeDirectory
|
|
27
|
+
: path.resolve(workingDirectory, expandedHomeDirectory);
|
|
28
|
+
const normalizedPath = path.normalize(absolutePath);
|
|
29
|
+
const root = path.parse(normalizedPath).root;
|
|
30
|
+
const withoutTrailingSlash = normalizedPath.length > root.length
|
|
31
|
+
? normalizedPath.replace(/[\\/]+$/u, '')
|
|
32
|
+
: normalizedPath;
|
|
33
|
+
return withoutTrailingSlash.replaceAll('\\', '/');
|
|
34
|
+
}
|
|
35
|
+
export function buildAllowedDirectoryPatterns({ directory, }) {
|
|
36
|
+
const childPattern = directory.endsWith('/') ? `${directory}*` : `${directory}/*`;
|
|
37
|
+
return [directory, childPattern];
|
|
38
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Tests for one-shot directory permission path normalization helpers.
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { describe, expect, test } from 'vitest';
|
|
5
|
+
import { buildAllowedDirectoryPatterns, normalizeAllowedDirectoryPath, } from './directory-permissions.js';
|
|
6
|
+
describe('normalizeAllowedDirectoryPath', () => {
|
|
7
|
+
test('resolves relative paths from the working directory', () => {
|
|
8
|
+
const result = normalizeAllowedDirectoryPath({
|
|
9
|
+
input: '../shared/',
|
|
10
|
+
workingDirectory: '/repo/worktree/app',
|
|
11
|
+
});
|
|
12
|
+
expect(result).toBe('/repo/worktree/shared');
|
|
13
|
+
});
|
|
14
|
+
test('expands home directories and strips implicit trailing glob', () => {
|
|
15
|
+
const result = normalizeAllowedDirectoryPath({
|
|
16
|
+
input: '~/projects/*',
|
|
17
|
+
workingDirectory: '/repo/worktree/app',
|
|
18
|
+
});
|
|
19
|
+
expect(result).toBe(`${os.homedir().replaceAll('\\', '/')}/projects`);
|
|
20
|
+
});
|
|
21
|
+
test('rejects glob patterns in the middle of the path', () => {
|
|
22
|
+
const result = normalizeAllowedDirectoryPath({
|
|
23
|
+
input: 'src/*/nested',
|
|
24
|
+
workingDirectory: '/repo/worktree/app',
|
|
25
|
+
});
|
|
26
|
+
expect(result instanceof Error ? result.message : result).toBe('Path must be a directory, not a glob pattern');
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
describe('buildAllowedDirectoryPatterns', () => {
|
|
30
|
+
test('adds exact and child wildcard patterns for a directory', () => {
|
|
31
|
+
const directory = path.join('/repo', 'shared').replaceAll('\\', '/');
|
|
32
|
+
expect(buildAllowedDirectoryPatterns({ directory })).toEqual([
|
|
33
|
+
'/repo/shared',
|
|
34
|
+
'/repo/shared/*',
|
|
35
|
+
]);
|
|
36
|
+
});
|
|
37
|
+
});
|
package/dist/discord-bot.js
CHANGED
|
@@ -268,6 +268,9 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
268
268
|
const cliInjectedPermissions = isCliInjectedPrompt
|
|
269
269
|
? promptMarker?.permissions
|
|
270
270
|
: undefined;
|
|
271
|
+
const cliInjectedInjectionGuardPatterns = isCliInjectedPrompt
|
|
272
|
+
? promptMarker?.injectionGuardPatterns
|
|
273
|
+
: undefined;
|
|
271
274
|
// Always ignore our own messages (unless CLI-injected prompt above).
|
|
272
275
|
// Without this, assigning the Kimaki role to the bot itself would loop.
|
|
273
276
|
if (isSelfBotMessage && !isCliInjectedPrompt) {
|
|
@@ -488,6 +491,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
488
491
|
agent: cliInjectedAgent,
|
|
489
492
|
model: cliInjectedModel,
|
|
490
493
|
permissions: cliInjectedPermissions,
|
|
494
|
+
injectionGuardPatterns: cliInjectedInjectionGuardPatterns,
|
|
491
495
|
sessionStartSource: sessionStartSource
|
|
492
496
|
? {
|
|
493
497
|
scheduleKind: sessionStartSource.scheduleKind,
|
|
@@ -512,6 +516,15 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
512
516
|
}
|
|
513
517
|
}
|
|
514
518
|
if (channel.type === ChannelType.GuildText) {
|
|
519
|
+
// `kimaki send` posts a starter message with a `start` embed marker,
|
|
520
|
+
// then creates the thread via REST. The ThreadCreate handler picks up
|
|
521
|
+
// that thread and starts the session. If we don't skip here, this
|
|
522
|
+
// handler races the CLI to call startThread() on the same message,
|
|
523
|
+
// causing DiscordAPIError[160004] "A thread has already been created
|
|
524
|
+
// for this message".
|
|
525
|
+
if (promptMarker?.start) {
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
515
528
|
const textChannel = channel;
|
|
516
529
|
voiceLogger.log(`[GUILD_TEXT] Message in text channel #${textChannel.name} (${textChannel.id})`);
|
|
517
530
|
const channelConfig = await getChannelDirectory(textChannel.id);
|
|
@@ -750,6 +763,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
750
763
|
agent: marker.agent,
|
|
751
764
|
model: marker.model,
|
|
752
765
|
permissions: marker.permissions,
|
|
766
|
+
injectionGuardPatterns: marker.injectionGuardPatterns,
|
|
753
767
|
mode: 'opencode',
|
|
754
768
|
sessionStartSource: botThreadStartSource
|
|
755
769
|
? {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -13,3 +13,4 @@ export { contextAwarenessPlugin } from './context-awareness-plugin.js';
|
|
|
13
13
|
export { interruptOpencodeSessionOnUserMessage } from './opencode-interrupt-plugin.js';
|
|
14
14
|
export { anthropicAuthPlugin } from './anthropic-auth-plugin.js';
|
|
15
15
|
export { kittyGraphicsPlugin } from 'kitty-graphics-agent';
|
|
16
|
+
export { injectionGuardInternal as injectionGuard } from 'opencode-injection-guard';
|
package/dist/markdown.test.js
CHANGED
|
@@ -184,22 +184,6 @@ test('generate markdown with system info', async () => {
|
|
|
184
184
|
|
|
185
185
|
|
|
186
186
|
*Completed in Xs*
|
|
187
|
-
|
|
188
|
-
### 🤖 Assistant (deterministic-v2)
|
|
189
|
-
|
|
190
|
-
**Started using deterministic-provider/deterministic-v2**
|
|
191
|
-
|
|
192
|
-
Hello! This is a deterministic markdown test response.
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
*Completed in Xs*
|
|
196
|
-
|
|
197
|
-
### 🤖 Assistant (deterministic-v2)
|
|
198
|
-
|
|
199
|
-
**Started using deterministic-provider/deterministic-v2**
|
|
200
|
-
|
|
201
|
-
Hello! This is a deterministic markdown test response.
|
|
202
|
-
|
|
203
187
|
"
|
|
204
188
|
`);
|
|
205
189
|
});
|
|
@@ -235,22 +219,6 @@ test('generate markdown without system info', async () => {
|
|
|
235
219
|
|
|
236
220
|
|
|
237
221
|
*Completed in Xs*
|
|
238
|
-
|
|
239
|
-
### 🤖 Assistant (deterministic-v2)
|
|
240
|
-
|
|
241
|
-
**Started using deterministic-provider/deterministic-v2**
|
|
242
|
-
|
|
243
|
-
Hello! This is a deterministic markdown test response.
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
*Completed in Xs*
|
|
247
|
-
|
|
248
|
-
### 🤖 Assistant (deterministic-v2)
|
|
249
|
-
|
|
250
|
-
**Started using deterministic-provider/deterministic-v2**
|
|
251
|
-
|
|
252
|
-
Hello! This is a deterministic markdown test response.
|
|
253
|
-
|
|
254
222
|
"
|
|
255
223
|
`);
|
|
256
224
|
});
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// E2e test verifying that the opencode server populates the `finish` field
|
|
2
|
+
// on assistant messages. This field is critical for kimaki's footer logic:
|
|
3
|
+
// isAssistantMessageNaturalCompletion checks `message.finish !== 'tool-calls'`
|
|
4
|
+
// to suppress footers on intermediate tool-call steps.
|
|
5
|
+
// When `finish` is missing/null, every completed assistant message gets a
|
|
6
|
+
// spurious footer, breaking multi-step tool chains (16 test failures).
|
|
7
|
+
//
|
|
8
|
+
// Direct SDK test — no Discord layer needed since this is a server-level bug.
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import url from 'node:url';
|
|
12
|
+
import { test, expect, beforeAll, afterAll } from 'vitest';
|
|
13
|
+
import { buildDeterministicOpencodeConfig, } from 'opencode-deterministic-provider';
|
|
14
|
+
import { setDataDir } from './config.js';
|
|
15
|
+
import { initializeOpencodeForDirectory, stopOpencodeServer } from './opencode.js';
|
|
16
|
+
import { cleanupTestSessions } from './test-utils.js';
|
|
17
|
+
const ROOT = path.resolve(process.cwd(), 'tmp', 'finish-field-e2e');
|
|
18
|
+
function createRunDirectories() {
|
|
19
|
+
fs.mkdirSync(ROOT, { recursive: true });
|
|
20
|
+
const dataDir = fs.mkdtempSync(path.join(ROOT, 'data-'));
|
|
21
|
+
const projectDirectory = path.join(ROOT, 'project');
|
|
22
|
+
fs.mkdirSync(projectDirectory, { recursive: true });
|
|
23
|
+
return { dataDir, projectDirectory };
|
|
24
|
+
}
|
|
25
|
+
function createMatchers() {
|
|
26
|
+
// Tool-call step: finish="tool-calls"
|
|
27
|
+
const toolCallMatcher = {
|
|
28
|
+
id: 'finish-tool-call',
|
|
29
|
+
priority: 20,
|
|
30
|
+
when: {
|
|
31
|
+
lastMessageRole: 'user',
|
|
32
|
+
latestUserTextIncludes: 'FINISH_FIELD_TOOLCALL',
|
|
33
|
+
},
|
|
34
|
+
then: {
|
|
35
|
+
parts: [
|
|
36
|
+
{ type: 'stream-start', warnings: [] },
|
|
37
|
+
{ type: 'text-start', id: 'ft' },
|
|
38
|
+
{ type: 'text-delta', id: 'ft', delta: 'calling tool' },
|
|
39
|
+
{ type: 'text-end', id: 'ft' },
|
|
40
|
+
{
|
|
41
|
+
type: 'tool-call',
|
|
42
|
+
toolCallId: 'finish-bash',
|
|
43
|
+
toolName: 'bash',
|
|
44
|
+
input: JSON.stringify({ command: 'echo ok', description: 'test' }),
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
type: 'finish',
|
|
48
|
+
finishReason: 'tool-calls',
|
|
49
|
+
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
// Follow-up after tool result: finish="stop"
|
|
55
|
+
const followupMatcher = {
|
|
56
|
+
id: 'finish-followup',
|
|
57
|
+
priority: 21,
|
|
58
|
+
when: {
|
|
59
|
+
lastMessageRole: 'tool',
|
|
60
|
+
latestUserTextIncludes: 'FINISH_FIELD_TOOLCALL',
|
|
61
|
+
},
|
|
62
|
+
then: {
|
|
63
|
+
parts: [
|
|
64
|
+
{ type: 'stream-start', warnings: [] },
|
|
65
|
+
{ type: 'text-start', id: 'ff' },
|
|
66
|
+
{ type: 'text-delta', id: 'ff', delta: 'tool done' },
|
|
67
|
+
{ type: 'text-end', id: 'ff' },
|
|
68
|
+
{
|
|
69
|
+
type: 'finish',
|
|
70
|
+
finishReason: 'stop',
|
|
71
|
+
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
return [toolCallMatcher, followupMatcher];
|
|
77
|
+
}
|
|
78
|
+
let client;
|
|
79
|
+
let directories;
|
|
80
|
+
let testStartTime;
|
|
81
|
+
beforeAll(async () => {
|
|
82
|
+
testStartTime = Date.now();
|
|
83
|
+
directories = createRunDirectories();
|
|
84
|
+
setDataDir(directories.dataDir);
|
|
85
|
+
const providerNpm = url
|
|
86
|
+
.pathToFileURL(path.resolve(process.cwd(), '..', 'opencode-deterministic-provider', 'src', 'index.ts'))
|
|
87
|
+
.toString();
|
|
88
|
+
const opencodeConfig = buildDeterministicOpencodeConfig({
|
|
89
|
+
providerName: 'deterministic-provider',
|
|
90
|
+
providerNpm,
|
|
91
|
+
model: 'deterministic-v2',
|
|
92
|
+
smallModel: 'deterministic-v2',
|
|
93
|
+
settings: { strict: false, matchers: createMatchers() },
|
|
94
|
+
});
|
|
95
|
+
fs.writeFileSync(path.join(directories.projectDirectory, 'opencode.json'), JSON.stringify(opencodeConfig, null, 2));
|
|
96
|
+
const getClient = await initializeOpencodeForDirectory(directories.projectDirectory);
|
|
97
|
+
if (getClient instanceof Error) {
|
|
98
|
+
throw getClient;
|
|
99
|
+
}
|
|
100
|
+
client = getClient();
|
|
101
|
+
}, 60_000);
|
|
102
|
+
afterAll(async () => {
|
|
103
|
+
await cleanupTestSessions({
|
|
104
|
+
projectDirectory: directories.projectDirectory,
|
|
105
|
+
testStartTime,
|
|
106
|
+
});
|
|
107
|
+
await stopOpencodeServer();
|
|
108
|
+
}, 10_000);
|
|
109
|
+
test('tool-call step has finish="tool-calls", follow-up has finish="stop"', async () => {
|
|
110
|
+
const session = await client.session.create({
|
|
111
|
+
directory: directories.projectDirectory,
|
|
112
|
+
title: 'finish-field-test',
|
|
113
|
+
});
|
|
114
|
+
const sessionID = session.data.id;
|
|
115
|
+
await client.session.promptAsync({
|
|
116
|
+
sessionID,
|
|
117
|
+
directory: directories.projectDirectory,
|
|
118
|
+
parts: [{ type: 'text', text: 'FINISH_FIELD_TOOLCALL' }],
|
|
119
|
+
});
|
|
120
|
+
// Poll until we have 2 completed assistant messages (tool-call + follow-up)
|
|
121
|
+
const maxWait = 8_000;
|
|
122
|
+
const pollStart = Date.now();
|
|
123
|
+
let completedAssistants = [];
|
|
124
|
+
while (Date.now() - pollStart < maxWait) {
|
|
125
|
+
const msgs = await client.session.messages({ sessionID });
|
|
126
|
+
completedAssistants = (msgs.data || [])
|
|
127
|
+
.filter((m) => {
|
|
128
|
+
return m.info.role === 'assistant' && m.info.time.completed;
|
|
129
|
+
})
|
|
130
|
+
.map((m) => {
|
|
131
|
+
return {
|
|
132
|
+
finish: m.info.finish ?? null,
|
|
133
|
+
partTypes: m.parts.map((p) => { return p.type; }),
|
|
134
|
+
};
|
|
135
|
+
});
|
|
136
|
+
if (completedAssistants.length >= 2) {
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
await new Promise((resolve) => { setTimeout(resolve, 100); });
|
|
140
|
+
}
|
|
141
|
+
// Snapshot completed assistant messages — finish should NOT be null
|
|
142
|
+
expect(completedAssistants).toMatchInlineSnapshot(`
|
|
143
|
+
[
|
|
144
|
+
{
|
|
145
|
+
"finish": "tool-calls",
|
|
146
|
+
"partTypes": [
|
|
147
|
+
"step-start",
|
|
148
|
+
"text",
|
|
149
|
+
"step-finish",
|
|
150
|
+
],
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
"finish": "stop",
|
|
154
|
+
"partTypes": [
|
|
155
|
+
"step-start",
|
|
156
|
+
"text",
|
|
157
|
+
"step-finish",
|
|
158
|
+
],
|
|
159
|
+
},
|
|
160
|
+
]
|
|
161
|
+
`);
|
|
162
|
+
const finishes = completedAssistants.map((m) => { return m.finish; });
|
|
163
|
+
expect(finishes).toEqual(['tool-calls', 'stop']);
|
|
164
|
+
}, 15_000);
|
package/dist/opencode.js
CHANGED
|
@@ -378,6 +378,59 @@ async function startSingleServer() {
|
|
|
378
378
|
XDG_STATE_HOME: path.join(root, '.local', 'state'),
|
|
379
379
|
};
|
|
380
380
|
})();
|
|
381
|
+
// Write config to a file instead of passing via OPENCODE_CONFIG_CONTENT env var.
|
|
382
|
+
// OPENCODE_CONFIG (file path) is loaded before project config in opencode's
|
|
383
|
+
// priority chain, so project-level opencode.json can override kimaki defaults.
|
|
384
|
+
// OPENCODE_CONFIG_CONTENT was loaded last and overrode user project configs,
|
|
385
|
+
// causing issue #90 (project permissions not being respected).
|
|
386
|
+
const opencodeConfig = {
|
|
387
|
+
$schema: 'https://opencode.ai/config.json',
|
|
388
|
+
lsp: false,
|
|
389
|
+
formatter: false,
|
|
390
|
+
plugin: [new URL('../src/kimaki-opencode-plugin.ts', import.meta.url).href],
|
|
391
|
+
permission: {
|
|
392
|
+
edit: 'allow',
|
|
393
|
+
bash: 'allow',
|
|
394
|
+
external_directory: externalDirectoryPermissions,
|
|
395
|
+
webfetch: 'allow',
|
|
396
|
+
},
|
|
397
|
+
agent: {
|
|
398
|
+
explore: {
|
|
399
|
+
permission: {
|
|
400
|
+
'*': 'deny',
|
|
401
|
+
grep: 'allow',
|
|
402
|
+
glob: 'allow',
|
|
403
|
+
list: 'allow',
|
|
404
|
+
read: {
|
|
405
|
+
'*': 'allow',
|
|
406
|
+
'*.env': 'deny',
|
|
407
|
+
'*.env.*': 'deny',
|
|
408
|
+
'*.env.example': 'allow',
|
|
409
|
+
},
|
|
410
|
+
webfetch: 'allow',
|
|
411
|
+
websearch: 'allow',
|
|
412
|
+
codesearch: 'allow',
|
|
413
|
+
external_directory: externalDirectoryPermissions,
|
|
414
|
+
},
|
|
415
|
+
},
|
|
416
|
+
},
|
|
417
|
+
skills: {
|
|
418
|
+
paths: [path.resolve(__dirname, '..', 'skills')],
|
|
419
|
+
},
|
|
420
|
+
};
|
|
421
|
+
const opencodeConfigPath = path.join(getDataDir(), 'opencode-config.json');
|
|
422
|
+
const opencodeConfigJson = JSON.stringify(opencodeConfig, null, 2);
|
|
423
|
+
const existingContent = (() => {
|
|
424
|
+
try {
|
|
425
|
+
return fs.readFileSync(opencodeConfigPath, 'utf-8');
|
|
426
|
+
}
|
|
427
|
+
catch {
|
|
428
|
+
return '';
|
|
429
|
+
}
|
|
430
|
+
})();
|
|
431
|
+
if (existingContent !== opencodeConfigJson) {
|
|
432
|
+
fs.writeFileSync(opencodeConfigPath, opencodeConfigJson);
|
|
433
|
+
}
|
|
381
434
|
const serverProcess = spawn(spawnCommand, spawnArgs, {
|
|
382
435
|
stdio: 'pipe',
|
|
383
436
|
detached: false,
|
|
@@ -387,41 +440,7 @@ async function startSingleServer() {
|
|
|
387
440
|
cwd: os.homedir(),
|
|
388
441
|
env: {
|
|
389
442
|
...process.env,
|
|
390
|
-
|
|
391
|
-
$schema: 'https://opencode.ai/config.json',
|
|
392
|
-
lsp: false,
|
|
393
|
-
formatter: false,
|
|
394
|
-
plugin: [new URL('../src/kimaki-opencode-plugin.ts', import.meta.url).href],
|
|
395
|
-
permission: {
|
|
396
|
-
edit: 'allow',
|
|
397
|
-
bash: 'allow',
|
|
398
|
-
external_directory: externalDirectoryPermissions,
|
|
399
|
-
webfetch: 'allow',
|
|
400
|
-
},
|
|
401
|
-
agent: {
|
|
402
|
-
explore: {
|
|
403
|
-
permission: {
|
|
404
|
-
'*': 'deny',
|
|
405
|
-
grep: 'allow',
|
|
406
|
-
glob: 'allow',
|
|
407
|
-
list: 'allow',
|
|
408
|
-
read: {
|
|
409
|
-
'*': 'allow',
|
|
410
|
-
'*.env': 'deny',
|
|
411
|
-
'*.env.*': 'deny',
|
|
412
|
-
'*.env.example': 'allow',
|
|
413
|
-
},
|
|
414
|
-
webfetch: 'allow',
|
|
415
|
-
websearch: 'allow',
|
|
416
|
-
codesearch: 'allow',
|
|
417
|
-
external_directory: externalDirectoryPermissions,
|
|
418
|
-
},
|
|
419
|
-
},
|
|
420
|
-
},
|
|
421
|
-
skills: {
|
|
422
|
-
paths: [path.resolve(__dirname, '..', 'skills')],
|
|
423
|
-
},
|
|
424
|
-
}),
|
|
443
|
+
OPENCODE_CONFIG: opencodeConfigPath,
|
|
425
444
|
OPENCODE_PORT: port.toString(),
|
|
426
445
|
KIMAKI: '1',
|
|
427
446
|
KIMAKI_DATA_DIR: getDataDir(),
|
|
@@ -728,6 +747,49 @@ export function parsePermissionRules(raw) {
|
|
|
728
747
|
return [];
|
|
729
748
|
});
|
|
730
749
|
}
|
|
750
|
+
// ── Injection guard per-session config ───────────────────────────
|
|
751
|
+
// Per-session injection guard patterns are written as JSON files to a temp
|
|
752
|
+
// directory keyed by session ID. The injection guard plugin (running inside
|
|
753
|
+
// the opencode server process) checks for these files in tool.execute.after.
|
|
754
|
+
// This avoids needing env vars (which are per-process, not per-session).
|
|
755
|
+
const INJECTION_GUARD_DIR = path.join(os.tmpdir(), 'kimaki-injection-guard');
|
|
756
|
+
/**
|
|
757
|
+
* Write per-session injection guard config so the plugin picks it up.
|
|
758
|
+
* Only call this if injectionGuardPatterns is non-empty.
|
|
759
|
+
*/
|
|
760
|
+
export function writeInjectionGuardConfig({ sessionId, scanPatterns, }) {
|
|
761
|
+
try {
|
|
762
|
+
fs.mkdirSync(INJECTION_GUARD_DIR, { recursive: true });
|
|
763
|
+
fs.writeFileSync(path.join(INJECTION_GUARD_DIR, `${sessionId}.json`), JSON.stringify({ scanPatterns }));
|
|
764
|
+
}
|
|
765
|
+
catch {
|
|
766
|
+
// Best effort -- don't crash the bot if temp dir write fails
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* Remove per-session injection guard config file.
|
|
771
|
+
*/
|
|
772
|
+
export function removeInjectionGuardConfig({ sessionId }) {
|
|
773
|
+
try {
|
|
774
|
+
fs.unlinkSync(path.join(INJECTION_GUARD_DIR, `${sessionId}.json`));
|
|
775
|
+
}
|
|
776
|
+
catch {
|
|
777
|
+
// File may already be gone
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* Read per-session injection guard config. Used by the kimaki plugin
|
|
782
|
+
* inside the opencode server process.
|
|
783
|
+
*/
|
|
784
|
+
export function readInjectionGuardConfig({ sessionId }) {
|
|
785
|
+
try {
|
|
786
|
+
const raw = fs.readFileSync(path.join(INJECTION_GUARD_DIR, `${sessionId}.json`), 'utf-8');
|
|
787
|
+
return JSON.parse(raw);
|
|
788
|
+
}
|
|
789
|
+
catch {
|
|
790
|
+
return null;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
731
793
|
// ── Public helpers ───────────────────────────────────────────────
|
|
732
794
|
// These helpers expose the single shared server and directory-scoped clients.
|
|
733
795
|
export function getOpencodeServerPort(_directory) {
|
|
@@ -91,7 +91,6 @@ e2eTest('queue advanced: abort and retry', () => {
|
|
|
91
91
|
--- from: user (queue-advanced-tester)
|
|
92
92
|
Reply with exactly: papa
|
|
93
93
|
--- from: assistant (TestBot)
|
|
94
|
-
⬥ ok
|
|
95
94
|
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
96
95
|
`);
|
|
97
96
|
expect(afterBotMessages.length).toBeGreaterThanOrEqual(beforeBotCount + 1);
|
|
@@ -95,7 +95,6 @@ e2eTest('queue advanced: footer emission', () => {
|
|
|
95
95
|
Reply with exactly: footer-multi-second
|
|
96
96
|
--- from: assistant (TestBot)
|
|
97
97
|
⬥ ok
|
|
98
|
-
⬥ ok
|
|
99
98
|
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
100
99
|
`);
|
|
101
100
|
if (footerCount >= 2) {
|
|
@@ -191,14 +190,11 @@ e2eTest('queue advanced: footer emission', () => {
|
|
|
191
190
|
--- from: user (queue-advanced-tester)
|
|
192
191
|
PLUGIN_TIMEOUT_SLEEP_MARKER
|
|
193
192
|
--- from: assistant (TestBot)
|
|
194
|
-
⬥ ok
|
|
195
193
|
⬥ starting sleep 100
|
|
196
194
|
--- from: user (queue-advanced-tester)
|
|
197
195
|
Reply with exactly: interrupt-footer-followup
|
|
198
196
|
--- from: assistant (TestBot)
|
|
199
197
|
⬥ ok
|
|
200
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
201
|
-
⬥ ok
|
|
202
198
|
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
203
199
|
`);
|
|
204
200
|
expect(followupUserIdx).toBeGreaterThanOrEqual(0);
|
|
@@ -271,19 +267,15 @@ e2eTest('queue advanced: footer emission', () => {
|
|
|
271
267
|
--- from: assistant (TestBot)
|
|
272
268
|
⬥ ok
|
|
273
269
|
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
274
|
-
⬥ ok
|
|
275
270
|
--- from: user (queue-advanced-tester)
|
|
276
271
|
PLUGIN_TIMEOUT_SLEEP_MARKER
|
|
277
272
|
--- from: assistant (TestBot)
|
|
278
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
279
|
-
⬥ ok
|
|
280
273
|
⬥ starting sleep 100
|
|
281
274
|
--- from: user (queue-advanced-tester)
|
|
282
275
|
Reply with exactly: plugin-timeout-after
|
|
283
276
|
--- from: assistant (TestBot)
|
|
284
277
|
⬥ ok
|
|
285
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
286
|
-
⬥ ok"
|
|
278
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
287
279
|
`);
|
|
288
280
|
expect(afterIndex).toBeGreaterThanOrEqual(0);
|
|
289
281
|
const okReplyIndex = messagesWithFooter.findIndex((message, index) => {
|
|
@@ -363,10 +355,8 @@ e2eTest('queue advanced: footer emission', () => {
|
|
|
363
355
|
TOOL_CALL_FOOTER_MARKER
|
|
364
356
|
--- from: assistant (TestBot)
|
|
365
357
|
⬥ running tool
|
|
366
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
367
358
|
⬥ ok
|
|
368
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
369
|
-
⬥ ok"
|
|
359
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
370
360
|
`);
|
|
371
361
|
// Only ONE footer at the end — the tool-call step's footer is NOT
|
|
372
362
|
// emitted mid-turn. The final text follow-up gets the footer.
|
|
@@ -416,19 +406,6 @@ e2eTest('queue advanced: footer emission', () => {
|
|
|
416
406
|
MULTI_TOOL_FOOTER_MARKER
|
|
417
407
|
--- from: assistant (TestBot)
|
|
418
408
|
⬥ investigating the issue
|
|
419
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
420
|
-
⬥ all done, fixed 3 files
|
|
421
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
422
|
-
⬥ all done, fixed 3 files
|
|
423
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
424
|
-
⬥ all done, fixed 3 files
|
|
425
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
426
|
-
⬥ all done, fixed 3 files
|
|
427
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
428
|
-
⬥ all done, fixed 3 files
|
|
429
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
430
|
-
⬥ all done, fixed 3 files
|
|
431
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
432
409
|
⬥ all done, fixed 3 files
|
|
433
410
|
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
434
411
|
`);
|
|
@@ -482,24 +459,10 @@ e2eTest('queue advanced: footer emission', () => {
|
|
|
482
459
|
MULTI_STEP_CHAIN_MARKER
|
|
483
460
|
--- from: assistant (TestBot)
|
|
484
461
|
⬥ chain step 1: reading config
|
|
485
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
486
462
|
⬥ chain step 2: analyzing results
|
|
487
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
488
463
|
⬥ chain step 3: applying fix
|
|
489
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
490
|
-
⬥ chain complete: all 3 steps done
|
|
491
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
492
464
|
⬥ chain complete: all 3 steps done
|
|
493
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
494
|
-
⬥ chain complete: all 3 steps done
|
|
495
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
496
|
-
⬥ chain complete: all 3 steps done
|
|
497
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
498
|
-
⬥ chain complete: all 3 steps done
|
|
499
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
500
|
-
⬥ chain complete: all 3 steps done
|
|
501
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
502
|
-
⬥ chain complete: all 3 steps done"
|
|
465
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
503
466
|
`);
|
|
504
467
|
// The critical assertion: only 1 footer at the very end.
|
|
505
468
|
// With the naive "allow tool-calls as natural completion" fix,
|
|
@@ -252,20 +252,14 @@ describe('queue advanced: /model with interrupt recovery', () => {
|
|
|
252
252
|
--- from: assistant (TestBot)
|
|
253
253
|
⬥ ok
|
|
254
254
|
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
255
|
-
⬥ ok
|
|
256
255
|
Model set for this session:
|
|
257
256
|
**Deterministic Provider** / **deterministic-v3**
|
|
258
257
|
\`deterministic-provider/deterministic-v3\`
|
|
259
258
|
_Restarting current request with new model..._
|
|
260
259
|
_Tip: create [agent .md files](https://github.com/remorses/kimaki/blob/main/docs/model-switching.md) in .opencode/agent/ for one-command model switching_
|
|
261
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
262
|
-
⬥ ok
|
|
263
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
264
|
-
⬥ ok
|
|
265
260
|
--- from: user (queue-model-switch-tester)
|
|
266
261
|
PLUGIN_TIMEOUT_SLEEP_MARKER
|
|
267
262
|
--- from: assistant (TestBot)
|
|
268
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
269
263
|
⬥ ok
|
|
270
264
|
⬥ starting sleep 100
|
|
271
265
|
--- from: user (queue-model-switch-tester)
|