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,101 @@
1
+ // E2e tests for thread-scoped external directory preapproval via /add-directory.
2
+ import { describe, expect, test } from 'vitest';
3
+ import { setupQueueAdvancedSuite, TEST_USER_ID, } from './queue-advanced-e2e-setup.js';
4
+ import { waitForBotMessageContaining, waitForFooterMessage, } from './test-utils.js';
5
+ const TEXT_CHANNEL_ID = '200000000000001014';
6
+ describe('/add-directory', () => {
7
+ const ctx = setupQueueAdvancedSuite({
8
+ channelId: TEXT_CHANNEL_ID,
9
+ channelName: 'add-directory-e2e',
10
+ dirName: 'add-directory-e2e',
11
+ username: 'add-directory-tester',
12
+ });
13
+ test('preapproves external directory access for the current thread', async () => {
14
+ await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
15
+ content: 'Reply with exactly: add-directory-setup',
16
+ });
17
+ const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
18
+ timeout: 4_000,
19
+ predicate: (candidate) => {
20
+ return candidate.name === 'Reply with exactly: add-directory-setup';
21
+ },
22
+ });
23
+ const th = ctx.discord.thread(thread.id);
24
+ await th.waitForBotReply({ timeout: 4_000 });
25
+ await waitForFooterMessage({
26
+ discord: ctx.discord,
27
+ threadId: thread.id,
28
+ timeout: 4_000,
29
+ });
30
+ const slashCommand = await th.user(TEST_USER_ID).runSlashCommand({
31
+ name: 'add-directory',
32
+ options: [{ name: 'path', type: 3, value: '/Users/morse' }],
33
+ });
34
+ await th.waitForInteractionAck({
35
+ interactionId: slashCommand.id,
36
+ timeout: 4_000,
37
+ });
38
+ await th.user(TEST_USER_ID).sendMessage({
39
+ content: 'PERMISSION_TYPING_MARKER add-directory-flow first',
40
+ });
41
+ await waitForBotMessageContaining({
42
+ discord: ctx.discord,
43
+ threadId: thread.id,
44
+ userId: TEST_USER_ID,
45
+ text: 'permission-flow-done',
46
+ timeout: 8_000,
47
+ });
48
+ await waitForFooterMessage({
49
+ discord: ctx.discord,
50
+ threadId: thread.id,
51
+ timeout: 12_000,
52
+ afterMessageIncludes: 'permission-flow-done',
53
+ afterAuthorId: ctx.discord.botUserId,
54
+ });
55
+ for (let attempt = 0; attempt < 10; attempt++) {
56
+ const messages = await th.getMessages();
57
+ const hasPermissionPrompt = messages.some((message) => {
58
+ return message.content.includes('Permission Required');
59
+ });
60
+ expect(hasPermissionPrompt).toBe(false);
61
+ await new Promise((resolve) => {
62
+ setTimeout(resolve, 20);
63
+ });
64
+ }
65
+ await th.user(TEST_USER_ID).sendMessage({
66
+ content: 'PERMISSION_TYPING_MARKER add-directory-flow second',
67
+ });
68
+ await waitForBotMessageContaining({
69
+ discord: ctx.discord,
70
+ threadId: thread.id,
71
+ userId: TEST_USER_ID,
72
+ text: 'Permission Required',
73
+ timeout: 8_000,
74
+ });
75
+ const timeline = await th.text();
76
+ expect(timeline).toMatchInlineSnapshot(`
77
+ "--- from: user (add-directory-tester)
78
+ Reply with exactly: add-directory-setup
79
+ --- from: assistant (TestBot)
80
+ ⬥ ok
81
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
82
+ Directory preapproved for the next message in this thread.
83
+ \`/Users/morse\`
84
+ Kimaki will auto-accept matching external directory requests for \`/Users/morse/*\` during the next run only.
85
+ --- from: user (add-directory-tester)
86
+ PERMISSION_TYPING_MARKER add-directory-flow first
87
+ --- from: assistant (TestBot)
88
+ ⬥ requesting external read permission
89
+ ⬥ permission-flow-done
90
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
91
+ --- from: user (add-directory-tester)
92
+ PERMISSION_TYPING_MARKER add-directory-flow second
93
+ --- from: assistant (TestBot)
94
+ ⚠️ **Permission Required**
95
+ **Type:** \`external_directory\`
96
+ Agent is accessing files outside the project. [Learn more](https://opencode.ai/docs/permissions/#external-directories)
97
+ **Pattern:** \`/Users/morse/*\`
98
+ ⬥ requesting external read permission"
99
+ `);
100
+ }, 20_000);
101
+ });
@@ -302,8 +302,7 @@ describe('agent model resolution', () => {
302
302
  Reply with exactly: agent-model-check
303
303
  --- from: assistant (TestBot)
304
304
  ⬥ ok
305
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
306
- ⬥ ok"
305
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***"
307
306
  `);
308
307
  expect(footerMessage).toBeDefined();
309
308
  if (!footerMessage) {
@@ -346,7 +345,7 @@ describe('agent model resolution', () => {
346
345
  Reply with exactly: system-context-check
347
346
  --- from: assistant (TestBot)
348
347
  ⬥ system-context-ok
349
- *project ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***"
348
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***"
350
349
  `);
351
350
  }, 15_000);
352
351
  test('new thread uses channel model when channel model preference is set', async () => {
@@ -0,0 +1,280 @@
1
+ // E2e test for `kimaki send --channel` flow.
2
+ // Reproduces the race condition where the bot's MessageCreate GuildText handler
3
+ // tries to call startThread() on the same message that the CLI already created
4
+ // a thread for via REST, causing DiscordAPIError[160004].
5
+ //
6
+ // The test simulates the exact flow: bot posts a starter message with a
7
+ // `start: true` embed marker, then creates a thread on that message via REST.
8
+ // The ThreadCreate handler should pick it up and start a session. The
9
+ // MessageCreate handler must NOT try to startThread() on the same message.
10
+ //
11
+ // Uses opencode-deterministic-provider (no real LLM calls).
12
+ // Poll timeouts: 4s max, 100ms interval.
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import url from 'node:url';
16
+ import { describe, beforeAll, afterAll, test, expect } from 'vitest';
17
+ import { ChannelType, Client, GatewayIntentBits, Partials, Routes, } from 'discord.js';
18
+ import { DigitalDiscord } from 'discord-digital-twin/src';
19
+ import { buildDeterministicOpencodeConfig, } from 'opencode-deterministic-provider';
20
+ import { setDataDir } from './config.js';
21
+ import { store } from './store.js';
22
+ import { startDiscordBot } from './discord-bot.js';
23
+ import { setBotToken, initDatabase, closeDatabase, setChannelDirectory, setChannelVerbosity, } from './database.js';
24
+ import { startHranaServer, stopHranaServer } from './hrana-server.js';
25
+ import { initializeOpencodeForDirectory, stopOpencodeServer, } from './opencode.js';
26
+ import { chooseLockPort, cleanupTestSessions, waitForBotMessageContaining, waitForFooterMessage, } from './test-utils.js';
27
+ import yaml from 'js-yaml';
28
+ const TEST_USER_ID = '200000000000000830';
29
+ const TEXT_CHANNEL_ID = '200000000000000831';
30
+ const BOT_USER_ID = '200000000000000832';
31
+ function createRunDirectories() {
32
+ const root = path.resolve(process.cwd(), 'tmp', 'cli-send-thread-e2e');
33
+ fs.mkdirSync(root, { recursive: true });
34
+ const dataDir = fs.mkdtempSync(path.join(root, 'data-'));
35
+ const projectDirectory = path.join(root, 'project');
36
+ fs.mkdirSync(projectDirectory, { recursive: true });
37
+ return { root, dataDir, projectDirectory };
38
+ }
39
+ function createDiscordJsClient({ restUrl }) {
40
+ return new Client({
41
+ intents: [
42
+ GatewayIntentBits.Guilds,
43
+ GatewayIntentBits.GuildMessages,
44
+ GatewayIntentBits.MessageContent,
45
+ GatewayIntentBits.GuildVoiceStates,
46
+ ],
47
+ partials: [
48
+ Partials.Channel,
49
+ Partials.Message,
50
+ Partials.User,
51
+ Partials.ThreadMember,
52
+ ],
53
+ rest: {
54
+ api: restUrl,
55
+ version: '10',
56
+ },
57
+ });
58
+ }
59
+ function createDeterministicMatchers() {
60
+ const userReplyMatcher = {
61
+ id: 'user-reply',
62
+ priority: 10,
63
+ when: {
64
+ lastMessageRole: 'user',
65
+ latestUserTextIncludes: 'Reply with exactly:',
66
+ },
67
+ then: {
68
+ parts: [
69
+ { type: 'stream-start', warnings: [] },
70
+ { type: 'text-start', id: 'default-reply' },
71
+ { type: 'text-delta', id: 'default-reply', delta: 'ok' },
72
+ { type: 'text-end', id: 'default-reply' },
73
+ {
74
+ type: 'finish',
75
+ finishReason: 'stop',
76
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
77
+ },
78
+ ],
79
+ partDelaysMs: [0, 100, 0, 0, 0],
80
+ },
81
+ };
82
+ return [userReplyMatcher];
83
+ }
84
+ describe('kimaki send --channel thread creation', () => {
85
+ let directories;
86
+ let discord;
87
+ let botClient;
88
+ let previousDefaultVerbosity = null;
89
+ let testStartTime = Date.now();
90
+ beforeAll(async () => {
91
+ testStartTime = Date.now();
92
+ directories = createRunDirectories();
93
+ const lockPort = chooseLockPort({ key: TEXT_CHANNEL_ID });
94
+ process.env['KIMAKI_LOCK_PORT'] = String(lockPort);
95
+ setDataDir(directories.dataDir);
96
+ previousDefaultVerbosity = store.getState().defaultVerbosity;
97
+ store.setState({ defaultVerbosity: 'tools_and_text' });
98
+ const digitalDiscordDbPath = path.join(directories.dataDir, 'digital-discord.db');
99
+ discord = new DigitalDiscord({
100
+ botUser: { id: BOT_USER_ID },
101
+ guild: {
102
+ name: 'CLI Send E2E Guild',
103
+ // Use bot as guild owner so bot-authored messages pass
104
+ // hasKimakiBotPermission (owner check). This matches production where
105
+ // the bot typically has admin or is the app owner. Without this, the
106
+ // MessageCreate handler drops bot messages before reaching the GuildText
107
+ // path, hiding the race condition we're testing.
108
+ ownerId: BOT_USER_ID,
109
+ },
110
+ channels: [
111
+ {
112
+ id: TEXT_CHANNEL_ID,
113
+ name: 'cli-send-e2e',
114
+ type: ChannelType.GuildText,
115
+ },
116
+ ],
117
+ users: [
118
+ {
119
+ id: TEST_USER_ID,
120
+ username: 'cli-send-tester',
121
+ },
122
+ ],
123
+ dbUrl: `file:${digitalDiscordDbPath}`,
124
+ });
125
+ await discord.start();
126
+ const providerNpm = url
127
+ .pathToFileURL(path.resolve(process.cwd(), '..', 'opencode-deterministic-provider', 'src', 'index.ts'))
128
+ .toString();
129
+ const opencodeConfig = buildDeterministicOpencodeConfig({
130
+ providerName: 'deterministic-provider',
131
+ providerNpm,
132
+ model: 'deterministic-v2',
133
+ smallModel: 'deterministic-v2',
134
+ settings: {
135
+ strict: false,
136
+ matchers: createDeterministicMatchers(),
137
+ },
138
+ });
139
+ fs.writeFileSync(path.join(directories.projectDirectory, 'opencode.json'), JSON.stringify(opencodeConfig, null, 2));
140
+ const dbPath = path.join(directories.dataDir, 'discord-sessions.db');
141
+ const hranaResult = await startHranaServer({ dbPath });
142
+ if (hranaResult instanceof Error) {
143
+ throw hranaResult;
144
+ }
145
+ process.env['KIMAKI_DB_URL'] = hranaResult;
146
+ await initDatabase();
147
+ await setBotToken(discord.botUserId, discord.botToken);
148
+ await setChannelDirectory({
149
+ channelId: TEXT_CHANNEL_ID,
150
+ directory: directories.projectDirectory,
151
+ channelType: 'text',
152
+ });
153
+ await setChannelVerbosity(TEXT_CHANNEL_ID, 'tools_and_text');
154
+ botClient = createDiscordJsClient({ restUrl: discord.restUrl });
155
+ await startDiscordBot({
156
+ token: discord.botToken,
157
+ appId: discord.botUserId,
158
+ discordClient: botClient,
159
+ });
160
+ // Pre-warm the opencode server
161
+ const warmup = await initializeOpencodeForDirectory(directories.projectDirectory);
162
+ if (warmup instanceof Error) {
163
+ throw warmup;
164
+ }
165
+ }, 60_000);
166
+ afterAll(async () => {
167
+ if (directories) {
168
+ await cleanupTestSessions({
169
+ projectDirectory: directories.projectDirectory,
170
+ testStartTime,
171
+ });
172
+ }
173
+ if (botClient) {
174
+ botClient.destroy();
175
+ }
176
+ await stopOpencodeServer();
177
+ await Promise.all([
178
+ closeDatabase().catch(() => {
179
+ return;
180
+ }),
181
+ stopHranaServer().catch(() => {
182
+ return;
183
+ }),
184
+ discord?.stop().catch(() => {
185
+ return;
186
+ }),
187
+ ]);
188
+ delete process.env['KIMAKI_LOCK_PORT'];
189
+ delete process.env['KIMAKI_DB_URL'];
190
+ if (previousDefaultVerbosity) {
191
+ store.setState({ defaultVerbosity: previousDefaultVerbosity });
192
+ }
193
+ if (directories) {
194
+ fs.rmSync(directories.dataDir, { recursive: true, force: true });
195
+ }
196
+ }, 10_000);
197
+ test('bot-posted starter message with start marker creates thread without DiscordAPIError[160004]', async () => {
198
+ // Simulate what `kimaki send --channel` does:
199
+ // 1. Bot posts a starter message with `start: true` embed marker
200
+ // 2. Bot creates a thread on that message via REST
201
+ // The ThreadCreate handler should pick it up. The MessageCreate GuildText
202
+ // handler must NOT try to startThread() on the same message (race).
203
+ const prompt = 'Reply with exactly: cli-send-test';
204
+ const embedMarker = {
205
+ start: true,
206
+ username: 'cli-send-tester',
207
+ userId: TEST_USER_ID,
208
+ };
209
+ // Step 1: Bot posts the starter message (same as CLI's sendDiscordMessageWithOptionalAttachment)
210
+ const starterMessage = (await botClient.rest.post(Routes.channelMessages(TEXT_CHANNEL_ID), {
211
+ body: {
212
+ content: prompt,
213
+ embeds: [
214
+ { color: 0x2b2d31, footer: { text: yaml.dump(embedMarker) } },
215
+ ],
216
+ },
217
+ }));
218
+ // Give the bot's MessageCreate handler time to process the starter
219
+ // message. Without the fix, the handler enters the GuildText path and
220
+ // tries to startThread() on this message, which races the CLI's thread
221
+ // creation below. The digital twin enforces Discord's 160004 uniqueness
222
+ // constraint, so the second startThread call fails.
223
+ await new Promise((resolve) => {
224
+ setTimeout(resolve, 200);
225
+ });
226
+ // Verify the MessageCreate handler did NOT create a thread on this
227
+ // message. If the handler ignored the start marker (correct behavior),
228
+ // no thread exists yet and the REST call below succeeds.
229
+ const threadsBeforeCliCreate = await discord
230
+ .channel(TEXT_CHANNEL_ID)
231
+ .getThreads();
232
+ const preExistingThread = threadsBeforeCliCreate.find((t) => {
233
+ return t.name?.includes('cli-send-test');
234
+ });
235
+ // This is the core regression assertion: without the fix in discord-bot.ts
236
+ // (skipping start markers in the GuildText handler), the MessageCreate
237
+ // handler would create a thread here, and the CLI's REST call below would
238
+ // fail with 160004.
239
+ expect(preExistingThread).toBeUndefined();
240
+ // Step 2: Bot creates a thread on the starter message (same as CLI's Routes.threads call)
241
+ const threadData = (await botClient.rest.post(Routes.threads(TEXT_CHANNEL_ID, starterMessage.id), {
242
+ body: {
243
+ name: 'cli-send-test',
244
+ auto_archive_duration: 1440,
245
+ },
246
+ }));
247
+ // Add test user to thread
248
+ await botClient.rest.put(Routes.threadMembers(threadData.id, TEST_USER_ID));
249
+ // Wait for the bot to reply with the ⬥ prefix (proves ThreadCreate
250
+ // handler picked up the starter message and started a session)
251
+ await waitForBotMessageContaining({
252
+ discord,
253
+ threadId: threadData.id,
254
+ userId: discord.botUserId,
255
+ text: '⬥',
256
+ timeout: 4_000,
257
+ });
258
+ // Wait for footer message (proves session completed successfully)
259
+ await waitForFooterMessage({
260
+ discord,
261
+ threadId: threadData.id,
262
+ timeout: 4_000,
263
+ afterMessageIncludes: '⬥',
264
+ afterAuthorId: discord.botUserId,
265
+ });
266
+ // Verify no DiscordAPIError[160004] or other errors in the thread.
267
+ // Before the fix, the MessageCreate GuildText handler would race the
268
+ // CLI's thread creation and produce an error message here.
269
+ const messages = await discord.thread(threadData.id).getMessages();
270
+ const errorMessages = messages.filter((m) => {
271
+ return m.content.includes('Error:') || m.content.includes('160004');
272
+ });
273
+ expect(errorMessages).toHaveLength(0);
274
+ // Verify at least one ⬥ reply exists (session produced output)
275
+ const botReplies = messages.filter((m) => {
276
+ return (m.author.id === discord.botUserId && m.content.startsWith('⬥'));
277
+ });
278
+ expect(botReplies.length).toBeGreaterThanOrEqual(1);
279
+ }, 15_000);
280
+ });
package/dist/cli.js CHANGED
@@ -1662,6 +1662,8 @@ cli
1662
1662
  .option('--model <model>', 'Model to use (format: provider/model)')
1663
1663
  .option('--permission <rule>', z.array(z.string()).describe('Session permission rule (repeatable). Format: "tool:action" or "tool:pattern:action". ' +
1664
1664
  'Actions: allow, deny, ask. Examples: --permission "bash:deny" --permission "edit:deny"'))
1665
+ .option('--injection-guard <pattern>', z.array(z.string()).describe('Injection guard scan pattern (repeatable). Enables prompt injection detection for this session. ' +
1666
+ 'Format: "tool:argsGlob". Examples: --injection-guard "bash:*" --injection-guard "webfetch:*"'))
1665
1667
  .option('--send-at <schedule>', 'Schedule send for future (UTC ISO date/time ending in Z, or cron expression)')
1666
1668
  .option('--thread <threadId>', 'Post prompt to an existing thread')
1667
1669
  .option('--session <sessionId>', 'Post prompt to thread mapped to an existing session')
@@ -1892,6 +1894,7 @@ cli
1892
1894
  username: null,
1893
1895
  userId: null,
1894
1896
  permissions: options.permission?.length ? options.permission : null,
1897
+ injectionGuardPatterns: options.injectionGuard?.length ? options.injectionGuard : null,
1895
1898
  };
1896
1899
  const taskId = await createScheduledTask({
1897
1900
  scheduleKind: parsedSchedule.scheduleKind,
@@ -1912,8 +1915,9 @@ cli
1912
1915
  process.exit(0);
1913
1916
  }
1914
1917
  const threadPromptMarker = {
1915
- cliThreadPrompt: true,
1918
+ start: true,
1916
1919
  ...(options.permission?.length ? { permissions: options.permission } : {}),
1920
+ ...(options.injectionGuard?.length ? { injectionGuardPatterns: options.injectionGuard } : {}),
1917
1921
  };
1918
1922
  const promptEmbed = [
1919
1923
  {
@@ -2005,6 +2009,7 @@ cli
2005
2009
  username: resolvedUser?.username || null,
2006
2010
  userId: resolvedUser?.id || null,
2007
2011
  permissions: options.permission?.length ? options.permission : null,
2012
+ injectionGuardPatterns: options.injectionGuard?.length ? options.injectionGuard : null,
2008
2013
  };
2009
2014
  const taskId = await createScheduledTask({
2010
2015
  scheduleKind: parsedSchedule.scheduleKind,
@@ -2036,6 +2041,7 @@ cli
2036
2041
  ...(options.agent && { agent: options.agent }),
2037
2042
  ...(options.model && { model: options.model }),
2038
2043
  ...(options.permission?.length && { permissions: options.permission }),
2044
+ ...(options.injectionGuard?.length && { injectionGuardPatterns: options.injectionGuard }),
2039
2045
  };
2040
2046
  const autoStartEmbed = embedMarker
2041
2047
  ? [{ color: 0x2b2d31, footer: { text: yaml.dump(embedMarker) } }]
@@ -0,0 +1,67 @@
1
+ // /add-directory command - Preapprove an external directory for this thread.
2
+ import { ChannelType, MessageFlags, } from 'discord.js';
3
+ import { getThreadSession } from '../database.js';
4
+ import { normalizeAllowedDirectoryPath } from '../directory-permissions.js';
5
+ import { resolveWorkingDirectory, SILENT_MESSAGE_FLAGS, } from '../discord-utils.js';
6
+ import { createLogger } from '../logger.js';
7
+ import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js';
8
+ const logger = createLogger('ADD_DIR');
9
+ export async function handleAddDirectoryCommand({ command, appId, }) {
10
+ const inputPath = command.options.getString('path', true);
11
+ const channel = command.channel;
12
+ if (!channel) {
13
+ await command.reply({
14
+ content: 'This command can only be used in a channel',
15
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
16
+ });
17
+ return;
18
+ }
19
+ const isThread = [
20
+ ChannelType.PublicThread,
21
+ ChannelType.PrivateThread,
22
+ ChannelType.AnnouncementThread,
23
+ ].includes(channel.type);
24
+ if (!isThread) {
25
+ await command.reply({
26
+ content: 'This command can only be used in a thread with an active session',
27
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
28
+ });
29
+ return;
30
+ }
31
+ await command.deferReply({
32
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
33
+ });
34
+ const sessionId = await getThreadSession(channel.id);
35
+ if (!sessionId) {
36
+ await command.editReply('No active session in this thread');
37
+ return;
38
+ }
39
+ const resolved = await resolveWorkingDirectory({
40
+ channel: channel,
41
+ });
42
+ if (!resolved) {
43
+ await command.editReply('Could not determine project directory for this channel');
44
+ return;
45
+ }
46
+ const normalizedPath = normalizeAllowedDirectoryPath({
47
+ input: inputPath,
48
+ workingDirectory: resolved.workingDirectory,
49
+ });
50
+ if (normalizedPath instanceof Error) {
51
+ await command.editReply(normalizedPath.message);
52
+ return;
53
+ }
54
+ const runtime = getOrCreateRuntime({
55
+ threadId: channel.id,
56
+ thread: channel,
57
+ projectDirectory: resolved.projectDirectory,
58
+ sdkDirectory: resolved.workingDirectory,
59
+ channelId: channel.parentId || channel.id,
60
+ appId,
61
+ });
62
+ runtime.primeNextExternalDirectoryAccess({
63
+ directory: normalizedPath,
64
+ });
65
+ await command.editReply(`Directory preapproved for the next message in this thread.\n\`${normalizedPath}\`\nKimaki will auto-accept matching external directory requests for \`${normalizedPath}/*\` during the next run only.`);
66
+ logger.log(`Thread ${channel.id} primed one-shot directory ${normalizedPath}`);
67
+ }
@@ -2,12 +2,14 @@
2
2
  // Handles slash commands that map to user-configured commands in opencode.json.
3
3
  import { ChannelType, MessageFlags, } from 'discord.js';
4
4
  import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js';
5
- import { sendThreadMessage, SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
5
+ import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
6
6
  import { createLogger, LogPrefix } from '../logger.js';
7
7
  import { getChannelDirectory, getThreadSession } from '../database.js';
8
8
  import { store } from '../store.js';
9
9
  import fs from 'node:fs';
10
10
  const userCommandLogger = createLogger(LogPrefix.USER_CMD);
11
+ const DISCORD_MESSAGE_LIMIT = 2000;
12
+ const DISCORD_THREAD_NAME_LIMIT = 100;
11
13
  export const handleUserCommand = async ({ command, appId, }) => {
12
14
  const discordCommandName = command.commandName;
13
15
  // Look up the original OpenCode command name from the mapping populated at registration.
@@ -17,6 +19,10 @@ export const handleUserCommand = async ({ command, appId, }) => {
17
19
  const fallbackBase = discordCommandName.replace(/-(cmd|skill|mcp-prompt)$/, '');
18
20
  const commandName = registered?.name || fallbackBase;
19
21
  const args = command.options.getString('arguments') || '';
22
+ const commandInvocation = args ? `/${commandName} ${args}` : `/${commandName}`;
23
+ const threadOpeningMessage = commandInvocation.length <= DISCORD_MESSAGE_LIMIT
24
+ ? commandInvocation
25
+ : `${commandInvocation.slice(0, DISCORD_MESSAGE_LIMIT - 14)}... truncated`;
20
26
  userCommandLogger.log(`Executing /${commandName} (from /${discordCommandName}) argsLength=${args.length}`);
21
27
  const channel = command.channel;
22
28
  userCommandLogger.log(`Channel info: type=${channel?.type}, id=${channel?.id}, isNull=${channel === null}`);
@@ -81,7 +87,7 @@ export const handleUserCommand = async ({ command, appId, }) => {
81
87
  const commandPayload = { name: commandName, arguments: args };
82
88
  if (isThread && thread) {
83
89
  // Running in existing thread - just send the command
84
- await command.editReply(`Running /${commandName}...`);
90
+ await command.editReply(`Running ${commandInvocation}...`);
85
91
  const runtime = getOrCreateRuntime({
86
92
  threadId: thread.id,
87
93
  thread,
@@ -102,21 +108,16 @@ export const handleUserCommand = async ({ command, appId, }) => {
102
108
  else if (textChannel) {
103
109
  // Running in text channel - create a new thread
104
110
  const starterMessage = await textChannel.send({
105
- content: `**/${commandName}**`,
111
+ content: threadOpeningMessage,
106
112
  flags: SILENT_MESSAGE_FLAGS,
107
113
  });
108
- const threadName = `/${commandName}`;
109
114
  const newThread = await starterMessage.startThread({
110
- name: threadName.slice(0, 100),
115
+ name: commandInvocation.slice(0, DISCORD_THREAD_NAME_LIMIT),
111
116
  autoArchiveDuration: 1440,
112
117
  reason: `OpenCode command: ${commandName}`,
113
118
  });
114
119
  // Add user to thread so it appears in their sidebar
115
120
  await newThread.members.add(command.user.id);
116
- if (args) {
117
- const argsPreview = args.length > 1800 ? `${args.slice(0, 1800)}\n... truncated` : args;
118
- await sendThreadMessage(newThread, `Args: ${argsPreview}`);
119
- }
120
121
  await command.editReply(`Started /${commandName} in ${newThread.toString()}`);
121
122
  const runtime = getOrCreateRuntime({
122
123
  threadId: newThread.id,
@@ -49,18 +49,20 @@ export function shouldInjectBranch({ previousGitState, currentGitState, }) {
49
49
  const text = currentGitState.warning || `\n[current git branch is ${currentGitState.label}]`;
50
50
  return { inject: true, text };
51
51
  }
52
- export function shouldInjectPwd({ sessionDir, projectDir, announcedDir, }) {
53
- if (!sessionDir || sessionDir === projectDir) {
52
+ export function shouldInjectPwd({ currentDir, previousDir, announcedDir, }) {
53
+ if (announcedDir === currentDir) {
54
54
  return { inject: false };
55
55
  }
56
- if (announcedDir === sessionDir) {
56
+ const priorDirectory = announcedDir || previousDir;
57
+ if (!priorDirectory || priorDirectory === currentDir) {
57
58
  return { inject: false };
58
59
  }
59
60
  return {
60
61
  inject: true,
61
- text: `\n[working directory is ${sessionDir} (git worktree of ${projectDir}). ` +
62
- `All file reads, writes, and edits must use paths under ${sessionDir}, ` +
63
- `not ${projectDir}.]`,
62
+ text: `\n[working directory changed. Previous working directory: ${priorDirectory}. ` +
63
+ `Current working directory: ${currentDir}. ` +
64
+ `You MUST read, write, and edit files only under ${currentDir}. ` +
65
+ `Do NOT read, write, or edit files under ${priorDirectory}.]`,
64
66
  };
65
67
  }
66
68
  const TEN_MINUTES = 10 * 60 * 1000;
@@ -149,20 +151,25 @@ async function resolveGitState({ directory, }) {
149
151
  'create or switch to a branch before committing.]',
150
152
  };
151
153
  }
152
- // Resolve the session's actual working directory via the SDK.
153
- // Cached in SessionState.resolvedDirectory to avoid repeated HTTP calls.
154
+ // Resolve the last observed session directory via the SDK.
155
+ // Refreshed on every real user message because sessions can switch directories
156
+ // mid-thread and the pwd reminder must compare old vs new accurately.
154
157
  async function resolveSessionDirectory({ client, sessionID, state, }) {
155
- if (state.resolvedDirectory) {
156
- return state.resolvedDirectory;
157
- }
158
+ const previousDirectory = state.resolvedDirectory;
158
159
  const result = await errore.tryAsync(() => {
159
160
  return client.session.get({ path: { id: sessionID } });
160
161
  });
161
162
  if (result instanceof Error || !result.data?.directory) {
162
- return null;
163
+ return {
164
+ currentDirectory: previousDirectory || null,
165
+ previousDirectory,
166
+ };
163
167
  }
164
168
  state.resolvedDirectory = result.data.directory;
165
- return result.data.directory;
169
+ return {
170
+ currentDirectory: result.data.directory,
171
+ previousDirectory,
172
+ };
166
173
  }
167
174
  // ── Plugin ───────────────────────────────────────────────────────
168
175
  const contextAwarenessPlugin = async ({ directory, client }) => {
@@ -224,23 +231,30 @@ const contextAwarenessPlugin = async ({ directory, client }) => {
224
231
  }
225
232
  const messageID = first.messageID;
226
233
  // -- Resolve session working directory --
227
- const sessionDir = await resolveSessionDirectory({
234
+ const sessionDirectory = await resolveSessionDirectory({
228
235
  client,
229
236
  sessionID,
230
237
  state,
231
238
  });
232
- const effectiveDirectory = sessionDir || directory;
239
+ // The plugin request directory is the current directory Kimaki asked
240
+ // OpenCode to operate on for this message. Prefer it over session.get()
241
+ // when they disagree so reminders and MEMORY/branch context follow the
242
+ // new worktree immediately after a folder switch.
243
+ const effectiveDirectory = directory;
233
244
  // -- Branch / detached HEAD detection --
234
245
  // Resolved early but injected last so it appears at the end of parts.
235
246
  const gitState = await resolveGitState({ directory: effectiveDirectory });
236
247
  // -- Working directory change detection --
237
248
  const pwdResult = shouldInjectPwd({
238
- sessionDir,
239
- projectDir: directory,
249
+ currentDir: effectiveDirectory,
250
+ previousDir: sessionDirectory.previousDirectory ||
251
+ (sessionDirectory.currentDirectory !== effectiveDirectory
252
+ ? sessionDirectory.currentDirectory || undefined
253
+ : undefined),
240
254
  announcedDir: state.announcedDirectory,
241
255
  });
242
256
  if (pwdResult.inject) {
243
- state.announcedDirectory = sessionDir;
257
+ state.announcedDirectory = effectiveDirectory;
244
258
  output.parts.push({
245
259
  id: `prt_${crypto.randomUUID()}`,
246
260
  sessionID,