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.
Files changed (54) hide show
  1. package/dist/add-directory.e2e.test.js +101 -0
  2. package/dist/agent-model.e2e.test.js +2 -3
  3. package/dist/cli-send-thread.e2e.test.js +280 -0
  4. package/dist/cli.js +7 -1
  5. package/dist/commands/add-directory.js +67 -0
  6. package/dist/commands/user-command.js +10 -9
  7. package/dist/context-awareness-plugin.js +32 -18
  8. package/dist/context-awareness-plugin.test.js +57 -0
  9. package/dist/directory-permissions.js +38 -0
  10. package/dist/directory-permissions.test.js +37 -0
  11. package/dist/discord-bot.js +14 -0
  12. package/dist/generated/models/thread_allowed_directories.js +1 -0
  13. package/dist/kimaki-opencode-plugin.js +1 -0
  14. package/dist/markdown.test.js +0 -32
  15. package/dist/message-finish-field.e2e.test.js +164 -0
  16. package/dist/opencode.js +97 -35
  17. package/dist/queue-advanced-abort.e2e.test.js +0 -1
  18. package/dist/queue-advanced-footer.e2e.test.js +3 -40
  19. package/dist/queue-advanced-model-switch.e2e.test.js +0 -6
  20. package/dist/queue-advanced-permissions-typing.e2e.test.js +0 -1
  21. package/dist/queue-advanced-typing-interrupt.e2e.test.js +2 -8
  22. package/dist/runtime-lifecycle.e2e.test.js +1 -4
  23. package/dist/session-handler/event-stream-state.test.js +3 -0
  24. package/dist/session-handler/thread-session-runtime.js +11 -2
  25. package/dist/task-runner.js +6 -0
  26. package/dist/task-schedule.js +4 -0
  27. package/dist/thread-message-queue.e2e.test.js +4 -2
  28. package/dist/voice-message.e2e.test.js +1 -6
  29. package/package.json +8 -7
  30. package/src/agent-model.e2e.test.ts +2 -3
  31. package/src/cli-send-thread.e2e.test.ts +365 -0
  32. package/src/cli.ts +13 -1
  33. package/src/commands/user-command.ts +11 -11
  34. package/src/context-awareness-plugin.test.ts +66 -0
  35. package/src/context-awareness-plugin.ts +46 -26
  36. package/src/discord-bot.ts +15 -0
  37. package/src/kimaki-opencode-plugin.ts +1 -0
  38. package/src/markdown.test.ts +0 -32
  39. package/src/message-finish-field.e2e.test.ts +191 -0
  40. package/src/opencode.ts +111 -35
  41. package/src/queue-advanced-abort.e2e.test.ts +0 -1
  42. package/src/queue-advanced-footer.e2e.test.ts +3 -40
  43. package/src/queue-advanced-model-switch.e2e.test.ts +0 -6
  44. package/src/queue-advanced-permissions-typing.e2e.test.ts +0 -1
  45. package/src/queue-advanced-typing-interrupt.e2e.test.ts +2 -8
  46. package/src/runtime-lifecycle.e2e.test.ts +1 -4
  47. package/src/session-handler/event-stream-state.test.ts +3 -0
  48. package/src/session-handler/thread-runtime-state.ts +4 -0
  49. package/src/session-handler/thread-session-runtime.ts +13 -0
  50. package/src/system-message.ts +10 -1
  51. package/src/task-runner.ts +6 -0
  52. package/src/task-schedule.ts +6 -0
  53. package/src/thread-message-queue.e2e.test.ts +4 -2
  54. 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
+ });
@@ -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
  ? {
@@ -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';
@@ -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
- OPENCODE_CONFIG_CONTENT: JSON.stringify({
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)