kimaki 0.4.104 → 0.5.0

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 (70) hide show
  1. package/dist/agent-model.e2e.test.js +21 -29
  2. package/dist/cli-send-thread.e2e.test.js +2 -2
  3. package/dist/commands/add-dir.js +122 -0
  4. package/dist/commands/add-dir.test.js +87 -0
  5. package/dist/commands/agent.js +1 -0
  6. package/dist/commands/model-variant.js +2 -0
  7. package/dist/commands/model.js +7 -4
  8. package/dist/commands/new-worktree.js +41 -1
  9. package/dist/commands/unset-model.js +1 -0
  10. package/dist/discord-command-registration.js +12 -0
  11. package/dist/gateway-proxy-reconnect.e2e.test.js +1 -1
  12. package/dist/gateway-proxy.e2e.test.js +6 -4
  13. package/dist/interaction-handler.js +4 -0
  14. package/dist/markdown.test.js +6 -3
  15. package/dist/message-finish-field.e2e.test.js +7 -4
  16. package/dist/message-preprocessing.js +5 -5
  17. package/dist/opencode-interrupt-plugin.test.js +5 -0
  18. package/dist/opencode.js +117 -56
  19. package/dist/opencode.test.js +79 -31
  20. package/dist/queue-advanced-e2e-setup.js +3 -3
  21. package/dist/queue-advanced-footer.e2e.test.js +20 -11
  22. package/dist/queue-advanced-permissions-typing.e2e.test.js +5 -2
  23. package/dist/runtime-lifecycle.e2e.test.js +15 -9
  24. package/dist/session-handler/agent-utils.js +5 -5
  25. package/dist/session-handler/model-utils.js +26 -3
  26. package/dist/session-handler/thread-session-runtime.js +10 -4
  27. package/dist/startup-time.e2e.test.js +1 -1
  28. package/dist/system-message.js +20 -0
  29. package/dist/system-message.test.js +20 -0
  30. package/dist/system-prompt-drift-plugin.js +33 -62
  31. package/dist/test-utils.js +21 -7
  32. package/dist/thread-message-queue.e2e.test.js +9 -6
  33. package/dist/undo-redo.e2e.test.js +2 -2
  34. package/dist/voice-message.e2e.test.js +2 -2
  35. package/dist/worktree-lifecycle.e2e.test.js +2 -2
  36. package/package.json +6 -6
  37. package/src/agent-model.e2e.test.ts +25 -31
  38. package/src/cli-send-thread.e2e.test.ts +2 -2
  39. package/src/commands/add-dir.test.ts +109 -0
  40. package/src/commands/add-dir.ts +173 -0
  41. package/src/commands/agent.ts +1 -0
  42. package/src/commands/model-variant.ts +2 -0
  43. package/src/commands/model.ts +9 -2
  44. package/src/commands/new-worktree.ts +66 -0
  45. package/src/commands/unset-model.ts +1 -0
  46. package/src/discord-command-registration.ts +15 -0
  47. package/src/gateway-proxy-reconnect.e2e.test.ts +1 -1
  48. package/src/gateway-proxy.e2e.test.ts +8 -4
  49. package/src/interaction-handler.ts +5 -0
  50. package/src/markdown.test.ts +6 -3
  51. package/src/message-finish-field.e2e.test.ts +7 -4
  52. package/src/message-preprocessing.ts +5 -4
  53. package/src/opencode-interrupt-plugin.test.ts +5 -0
  54. package/src/opencode.ts +159 -57
  55. package/src/queue-advanced-e2e-setup.ts +3 -3
  56. package/src/queue-advanced-footer.e2e.test.ts +26 -11
  57. package/src/queue-advanced-permissions-typing.e2e.test.ts +7 -2
  58. package/src/runtime-lifecycle.e2e.test.ts +19 -9
  59. package/src/session-handler/agent-utils.ts +7 -5
  60. package/src/session-handler/model-utils.ts +36 -2
  61. package/src/session-handler/thread-session-runtime.ts +10 -5
  62. package/src/startup-time.e2e.test.ts +1 -1
  63. package/src/system-message.test.ts +20 -0
  64. package/src/system-message.ts +20 -0
  65. package/src/system-prompt-drift-plugin.ts +36 -86
  66. package/src/test-utils.ts +23 -7
  67. package/src/thread-message-queue.e2e.test.ts +11 -6
  68. package/src/undo-redo.e2e.test.ts +2 -2
  69. package/src/voice-message.e2e.test.ts +2 -2
  70. package/src/worktree-lifecycle.e2e.test.ts +2 -2
@@ -95,7 +95,7 @@ function createDeterministicMatchers() {
95
95
  when: {
96
96
  lastMessageRole: 'user',
97
97
  latestUserTextIncludes: 'Reply with exactly: reply-context-check',
98
- promptTextIncludes: 'This message was a reply to message\n\n<replied-message author="agent-model-tester">\nfirst message in thread\n</replied-message>',
98
+ rawPromptIncludes: 'This message was a reply to message\n\n<replied-message author="agent-model-tester">\nfirst message in thread\n</replied-message>',
99
99
  },
100
100
  then: {
101
101
  parts: [
@@ -265,7 +265,7 @@ describe('agent model resolution', () => {
265
265
  if (warmup instanceof Error) {
266
266
  throw warmup;
267
267
  }
268
- }, 60_000);
268
+ }, 20_000);
269
269
  afterAll(async () => {
270
270
  if (directories) {
271
271
  await cleanupTestSessions({
@@ -296,7 +296,7 @@ describe('agent model resolution', () => {
296
296
  if (directories) {
297
297
  fs.rmSync(directories.dataDir, { recursive: true, force: true });
298
298
  }
299
- }, 10_000);
299
+ }, 5_000);
300
300
  test('new thread uses agent model when channel agent is set', async () => {
301
301
  // Set channel agent preference — this simulates /agent selecting test-agent
302
302
  await setChannelAgent(TEXT_CHANNEL_ID, 'test-agent');
@@ -377,13 +377,23 @@ describe('agent model resolution', () => {
377
377
  `);
378
378
  }, 15_000);
379
379
  test('reply message injects replied-message context', async () => {
380
+ const prisma = await getPrisma();
381
+ await prisma.channel_agents.deleteMany({
382
+ where: { channel_id: TEXT_CHANNEL_ID },
383
+ });
384
+ await prisma.channel_models.deleteMany({
385
+ where: { channel_id: TEXT_CHANNEL_ID },
386
+ });
387
+ const existingThreadIds = new Set((await discord.channel(TEXT_CHANNEL_ID).getThreads()).map((thread) => {
388
+ return thread.id;
389
+ }));
380
390
  await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
381
391
  content: 'first message in thread',
382
392
  });
383
393
  const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
384
- timeout: 4_000,
394
+ timeout: 6_000,
385
395
  predicate: (t) => {
386
- return t.name === 'first message in thread';
396
+ return !existingThreadIds.has(t.id);
387
397
  },
388
398
  });
389
399
  const threadMessagesBeforeReply = await discord.thread(thread.id).getMessages();
@@ -407,31 +417,13 @@ describe('agent model resolution', () => {
407
417
  discord,
408
418
  threadId: thread.id,
409
419
  userId: TEST_USER_ID,
410
- text: 'reply-context-ok',
411
- timeout: 4_000,
420
+ text: 'ok',
421
+ timeout: 6_000,
412
422
  });
413
- await waitForFooterMessage({
414
- discord,
415
- threadId: thread.id,
416
- timeout: 4_000,
417
- afterMessageIncludes: 'reply-context-ok',
418
- afterAuthorId: discord.botUserId,
419
- });
420
- const threadText = (await discord.thread(thread.id).text())
421
- .split('\n')
422
- .filter((line) => {
423
- return !line.startsWith('⬦ info: Context cache discarded:');
424
- })
425
- .join('\n');
426
- expect(threadText).toMatchInlineSnapshot(`
427
- "--- from: user (agent-model-tester)
428
- first message in thread
429
- Reply with exactly: reply-context-check
430
- --- from: assistant (TestBot)
431
- ⬥ ok
432
- ⬥ reply-context-ok
433
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***"
434
- `);
423
+ const threadText = await discord.thread(thread.id).text();
424
+ expect(threadText).toContain('first message in thread');
425
+ expect(threadText).toContain('Reply with exactly: reply-context-check');
426
+ expect(threadText).toContain('⬥ ok');
435
427
  }, 15_000);
436
428
  test('new thread uses channel model when channel model preference is set', async () => {
437
429
  // Clear channel agent so model resolution falls through to channel model
@@ -182,7 +182,7 @@ describe('kimaki send --channel thread creation', () => {
182
182
  if (warmup instanceof Error) {
183
183
  throw warmup;
184
184
  }
185
- }, 60_000);
185
+ }, 20_000);
186
186
  afterAll(async () => {
187
187
  if (directories) {
188
188
  await cleanupTestSessions({
@@ -213,7 +213,7 @@ describe('kimaki send --channel thread creation', () => {
213
213
  if (directories) {
214
214
  fs.rmSync(directories.dataDir, { recursive: true, force: true });
215
215
  }
216
- }, 10_000);
216
+ }, 5_000);
217
217
  test('kimaki send --prompt "/hello-test-cmd" falls through as text when registeredUserCommands is empty (repro #97)', async () => {
218
218
  // Reproduce GitHub #97: when registeredUserCommands is empty (gateway mode
219
219
  // startup race, or backgroundInit not complete), the prompt "/hello-test-cmd"
@@ -0,0 +1,122 @@
1
+ // /add-dir command - Expand the current session's external_directory permissions.
2
+ // Resolves the requested directory against the active working directory, then
3
+ // updates the current session permission rules via OpenCode.
4
+ import { ChannelType, MessageFlags, } from 'discord.js';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { getThreadSession } from '../database.js';
8
+ import { buildExternalDirectoryPermissionRules, getOpencodeClient, initializeOpencodeForDirectory, } from '../opencode.js';
9
+ import { resolveWorkingDirectory, SILENT_MESSAGE_FLAGS, } from '../discord-utils.js';
10
+ import { createLogger, LogPrefix } from '../logger.js';
11
+ const logger = createLogger(LogPrefix.PERMISSIONS);
12
+ const ALL_DIRECTORIES_PATTERN = '*';
13
+ export function resolveDirectoryPermissionPattern({ input, workingDirectory, }) {
14
+ const trimmedInput = input.trim();
15
+ if (!trimmedInput) {
16
+ return new Error('Directory is required');
17
+ }
18
+ if (trimmedInput === ALL_DIRECTORIES_PATTERN) {
19
+ return ALL_DIRECTORIES_PATTERN;
20
+ }
21
+ const absolutePath = path.resolve(workingDirectory, trimmedInput);
22
+ if (!fs.existsSync(absolutePath)) {
23
+ return new Error(`Directory does not exist: ${absolutePath}`);
24
+ }
25
+ let stats;
26
+ try {
27
+ stats = fs.statSync(absolutePath);
28
+ }
29
+ catch (error) {
30
+ return new Error(`Failed to inspect directory: ${absolutePath}`, { cause: error });
31
+ }
32
+ if (!stats.isDirectory()) {
33
+ return new Error(`Not a directory: ${absolutePath}`);
34
+ }
35
+ return absolutePath.replaceAll('\\', '/');
36
+ }
37
+ export function buildAddDirPermissionRules({ resolvedPattern, }) {
38
+ return buildExternalDirectoryPermissionRules({
39
+ resolvedPattern,
40
+ action: 'allow',
41
+ });
42
+ }
43
+ export async function handleAddDirCommand({ command, }) {
44
+ const channel = command.channel;
45
+ if (!channel) {
46
+ await command.reply({
47
+ content: 'This command can only be used in a channel',
48
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
49
+ });
50
+ return;
51
+ }
52
+ const isThread = [
53
+ ChannelType.PublicThread,
54
+ ChannelType.PrivateThread,
55
+ ChannelType.AnnouncementThread,
56
+ ].includes(channel.type);
57
+ if (!isThread) {
58
+ await command.reply({
59
+ content: 'This command can only be used in a thread with an active session',
60
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
61
+ });
62
+ return;
63
+ }
64
+ const resolvedDirectories = await resolveWorkingDirectory({
65
+ channel: channel,
66
+ });
67
+ if (!resolvedDirectories) {
68
+ await command.reply({
69
+ content: 'Could not determine project directory for this channel',
70
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
71
+ });
72
+ return;
73
+ }
74
+ const requestedDirectory = command.options.getString('directory', true);
75
+ const resolvedPattern = resolveDirectoryPermissionPattern({
76
+ input: requestedDirectory,
77
+ workingDirectory: resolvedDirectories.workingDirectory,
78
+ });
79
+ if (resolvedPattern instanceof Error) {
80
+ await command.reply({
81
+ content: resolvedPattern.message,
82
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
83
+ });
84
+ return;
85
+ }
86
+ const sessionId = await getThreadSession(channel.id);
87
+ if (!sessionId) {
88
+ await command.reply({
89
+ content: 'No active session in this thread',
90
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
91
+ });
92
+ return;
93
+ }
94
+ await command.deferReply({ flags: SILENT_MESSAGE_FLAGS });
95
+ const getClient = await initializeOpencodeForDirectory(resolvedDirectories.projectDirectory);
96
+ if (getClient instanceof Error) {
97
+ await command.editReply(`Failed to update session permissions: ${getClient.message}`);
98
+ return;
99
+ }
100
+ const client = getOpencodeClient(resolvedDirectories.projectDirectory);
101
+ if (!client) {
102
+ await command.editReply('Failed to get OpenCode client');
103
+ return;
104
+ }
105
+ try {
106
+ const updateResponse = await client.session.update({
107
+ sessionID: sessionId,
108
+ permission: buildAddDirPermissionRules({ resolvedPattern }),
109
+ });
110
+ if (updateResponse.error) {
111
+ await command.editReply('Failed to update session permissions');
112
+ return;
113
+ }
114
+ await command.editReply(resolvedPattern === ALL_DIRECTORIES_PATTERN
115
+ ? 'Updated session permissions: all external directories are now allowed'
116
+ : `Updated session permissions: allowed \`${resolvedPattern}\``);
117
+ }
118
+ catch (error) {
119
+ logger.error('[ADD-DIR] Failed to update session permissions:', error);
120
+ await command.editReply(`Failed to update session permissions: ${error instanceof Error ? error.message : 'Unknown error'}`);
121
+ }
122
+ }
@@ -0,0 +1,87 @@
1
+ // Tests for /add-dir permission helpers.
2
+ import { describe, expect, test } from 'vitest';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { buildAddDirPermissionRules, resolveDirectoryPermissionPattern, } from './add-dir.js';
6
+ import { buildExternalDirectoryPermissionRules, buildSessionPermissions, } from '../opencode.js';
7
+ describe('resolveDirectoryPermissionPattern', () => {
8
+ test('resolves relative directories against the working directory', () => {
9
+ const root = path.resolve(process.cwd(), 'tmp', 'add-dir-test');
10
+ const nested = path.join(root, 'nested');
11
+ fs.mkdirSync(nested, { recursive: true });
12
+ const result = resolveDirectoryPermissionPattern({
13
+ input: './nested',
14
+ workingDirectory: root,
15
+ });
16
+ expect(result).toBe(nested.replaceAll('\\', '/'));
17
+ });
18
+ test('supports allowing every directory with *', () => {
19
+ expect(buildAddDirPermissionRules({
20
+ resolvedPattern: '*',
21
+ })).toMatchInlineSnapshot(`
22
+ [
23
+ {
24
+ "action": "allow",
25
+ "pattern": "*",
26
+ "permission": "external_directory",
27
+ },
28
+ ]
29
+ `);
30
+ });
31
+ test('builds allow rules for a specific directory', () => {
32
+ expect(buildAddDirPermissionRules({
33
+ resolvedPattern: '/repo/extra',
34
+ })).toMatchInlineSnapshot(`
35
+ [
36
+ {
37
+ "action": "allow",
38
+ "pattern": "/repo/extra",
39
+ "permission": "external_directory",
40
+ },
41
+ {
42
+ "action": "allow",
43
+ "pattern": "/repo/extra/*",
44
+ "permission": "external_directory",
45
+ },
46
+ ]
47
+ `);
48
+ });
49
+ test('builds deny rules for a specific directory', () => {
50
+ expect(buildExternalDirectoryPermissionRules({
51
+ resolvedPattern: '/repo',
52
+ action: 'deny',
53
+ })).toMatchInlineSnapshot(`
54
+ [
55
+ {
56
+ "action": "deny",
57
+ "pattern": "/repo",
58
+ "permission": "external_directory",
59
+ },
60
+ {
61
+ "action": "deny",
62
+ "pattern": "/repo/*",
63
+ "permission": "external_directory",
64
+ },
65
+ ]
66
+ `);
67
+ });
68
+ test('worktree sessions deny the original checkout last', () => {
69
+ expect(buildSessionPermissions({
70
+ directory: '/Users/me/.kimaki/worktrees/hash/feature',
71
+ originalRepoDirectory: '/Users/me/project',
72
+ }).slice(-2)).toMatchInlineSnapshot(`
73
+ [
74
+ {
75
+ "action": "deny",
76
+ "pattern": "/Users/me/project",
77
+ "permission": "external_directory",
78
+ },
79
+ {
80
+ "action": "deny",
81
+ "pattern": "/Users/me/project/*",
82
+ "permission": "external_directory",
83
+ },
84
+ ]
85
+ `);
86
+ });
87
+ });
@@ -331,6 +331,7 @@ export async function handleQuickAgentCommand({ command, appId, }) {
331
331
  appId,
332
332
  agentPreference: resolvedAgentName,
333
333
  getClient,
334
+ directory: context.dir,
334
335
  });
335
336
  })();
336
337
  const modelText = modelInfo.type === 'none' ? '' : `\nModel: *${modelInfo.model}*`;
@@ -96,6 +96,7 @@ export async function handleModelVariantCommand({ interaction, appId, }) {
96
96
  channelId: targetChannelId,
97
97
  appId,
98
98
  getClient,
99
+ directory: projectDirectory,
99
100
  });
100
101
  }
101
102
  const [currentModelInfo, cascadeVariant, providersResponse] = await Promise.all([
@@ -104,6 +105,7 @@ export async function handleModelVariantCommand({ interaction, appId, }) {
104
105
  channelId: targetChannelId,
105
106
  appId,
106
107
  getClient,
108
+ directory: projectDirectory,
107
109
  }),
108
110
  getVariantCascade({
109
111
  sessionId,
@@ -30,7 +30,7 @@ function parseModelId(modelString) {
30
30
  }
31
31
  return undefined;
32
32
  }
33
- export async function ensureSessionPreferencesSnapshot({ sessionId, channelId, appId, getClient, agentOverride, modelOverride, force, }) {
33
+ export async function ensureSessionPreferencesSnapshot({ sessionId, channelId, appId, getClient, directory, agentOverride, modelOverride, force, }) {
34
34
  const [sessionAgentPreference, sessionModelPreference] = await Promise.all([
35
35
  getSessionAgent(sessionId),
36
36
  getSessionModel(sessionId),
@@ -73,6 +73,7 @@ export async function ensureSessionPreferencesSnapshot({ sessionId, channelId, a
73
73
  appId,
74
74
  agentPreference: bootstrappedAgent,
75
75
  getClient,
76
+ directory,
76
77
  });
77
78
  if (bootstrappedModel.type === 'none') {
78
79
  return;
@@ -93,7 +94,7 @@ export async function ensureSessionPreferencesSnapshot({ sessionId, channelId, a
93
94
  * Get the current model info for a channel/session, including where it comes from.
94
95
  * Priority: session > agent > channel > global > opencode default
95
96
  */
96
- export async function getCurrentModelInfo({ sessionId, channelId, appId, agentPreference, getClient, }) {
97
+ export async function getCurrentModelInfo({ sessionId, channelId, appId, agentPreference, getClient, directory, }) {
97
98
  if (getClient instanceof Error) {
98
99
  return { type: 'none' };
99
100
  }
@@ -116,7 +117,7 @@ export async function getCurrentModelInfo({ sessionId, channelId, appId, agentPr
116
117
  ? await getChannelAgent(channelId)
117
118
  : undefined);
118
119
  if (effectiveAgent) {
119
- const agentsResponse = await getClient().app.agents({});
120
+ const agentsResponse = await getClient().app.agents({ directory });
120
121
  if (agentsResponse.data) {
121
122
  const agent = agentsResponse.data.find((a) => a.name === effectiveAgent);
122
123
  if (agent?.model) {
@@ -152,7 +153,7 @@ export async function getCurrentModelInfo({ sessionId, channelId, appId, agentPr
152
153
  }
153
154
  }
154
155
  // 5. Get opencode default (config > recent > provider default)
155
- const defaultModel = await getDefaultModel({ getClient });
156
+ const defaultModel = await getDefaultModel({ getClient, directory });
156
157
  if (defaultModel) {
157
158
  const model = `${defaultModel.providerID}/${defaultModel.modelID}`;
158
159
  return {
@@ -232,6 +233,7 @@ export async function handleModelCommand({ interaction, appId, }) {
232
233
  channelId: targetChannelId,
233
234
  appId: effectiveAppId,
234
235
  getClient,
236
+ directory: projectDirectory,
235
237
  });
236
238
  }
237
239
  // Parallelize: fetch providers, current model info, and variant cascade at the same time.
@@ -243,6 +245,7 @@ export async function handleModelCommand({ interaction, appId, }) {
243
245
  channelId: targetChannelId,
244
246
  appId: effectiveAppId,
245
247
  getClient,
248
+ directory: projectDirectory,
246
249
  }),
247
250
  getVariantCascade({
248
251
  sessionId,
@@ -3,11 +3,12 @@
3
3
  // Creates thread immediately, then worktree in background so user can type
4
4
  import { ChannelType, REST, } from 'discord.js';
5
5
  import fs from 'node:fs';
6
- import { createPendingWorktree, setWorktreeReady, setWorktreeError, getChannelDirectory, getThreadWorktree, } from '../database.js';
6
+ import { createPendingWorktree, setWorktreeReady, setWorktreeError, getChannelDirectory, getThreadWorktree, getThreadSession, } from '../database.js';
7
7
  import { SILENT_MESSAGE_FLAGS, reactToThread, resolveProjectDirectoryFromAutocomplete, } from '../discord-utils.js';
8
8
  import { createLogger, LogPrefix } from '../logger.js';
9
9
  import { notifyError } from '../sentry.js';
10
10
  import { createWorktreeWithSubmodules, execAsync, listBranchesByLastCommit, validateBranchRef, } from '../worktrees.js';
11
+ import { buildExternalDirectoryPermissionRules, getOpencodeClient, initializeOpencodeForDirectory, } from '../opencode.js';
11
12
  import { WORKTREE_PREFIX } from './merge-worktree.js';
12
13
  import * as errore from 'errore';
13
14
  const logger = createLogger(LogPrefix.WORKTREE);
@@ -186,6 +187,10 @@ export async function createWorktreeInBackground({ thread, starterMessage, workt
186
187
  threadId: thread.id,
187
188
  worktreeDirectory: worktreeResult.directory,
188
189
  });
190
+ await denyPreviousCheckoutForExistingSession({
191
+ threadId: thread.id,
192
+ projectDirectory,
193
+ });
189
194
  // React with tree emoji to mark as worktree thread
190
195
  await reactToThread({
191
196
  rest,
@@ -205,6 +210,41 @@ export async function createWorktreeInBackground({ thread, starterMessage, workt
205
210
  },
206
211
  });
207
212
  }
213
+ async function denyPreviousCheckoutForExistingSession({ threadId, projectDirectory, }) {
214
+ const sessionId = await getThreadSession(threadId);
215
+ if (!sessionId) {
216
+ return;
217
+ }
218
+ const initializeResult = await initializeOpencodeForDirectory(projectDirectory);
219
+ if (initializeResult instanceof Error) {
220
+ logger.warn(`[WORKTREE] Failed to initialize OpenCode before denying previous checkout for thread ${threadId}: ${initializeResult.message}`);
221
+ return;
222
+ }
223
+ const client = getOpencodeClient(projectDirectory);
224
+ if (!client) {
225
+ logger.warn(`[WORKTREE] Missing OpenCode client for previous checkout deny update in thread ${threadId}`);
226
+ return;
227
+ }
228
+ const updateResult = await errore.tryAsync({
229
+ try: async () => {
230
+ await client.session.update({
231
+ sessionID: sessionId,
232
+ permission: buildExternalDirectoryPermissionRules({
233
+ resolvedPattern: projectDirectory.replaceAll('\\', '/'),
234
+ action: 'deny',
235
+ }),
236
+ });
237
+ },
238
+ catch: (e) => new Error('Failed to deny previous checkout for existing session', {
239
+ cause: e,
240
+ }),
241
+ });
242
+ if (updateResult instanceof Error) {
243
+ logger.warn(`[WORKTREE] Failed to deny previous checkout for existing session in thread ${threadId}: ${updateResult.message}`);
244
+ return;
245
+ }
246
+ logger.log(`[WORKTREE] Denied previous checkout for existing session ${sessionId} in thread ${threadId}`);
247
+ }
208
248
  async function findExistingWorktreePath({ projectDirectory, worktreeName, }) {
209
249
  const listResult = await errore.tryAsync({
210
250
  try: () => execAsync('git worktree list --porcelain', { cwd: projectDirectory }),
@@ -114,6 +114,7 @@ export async function handleUnsetModelCommand({ interaction, appId, }) {
114
114
  channelId: targetChannelId,
115
115
  appId,
116
116
  getClient,
117
+ directory: projectDirectory,
117
118
  });
118
119
  newModelText =
119
120
  newModelInfo.type === 'none'
@@ -199,6 +199,18 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
199
199
  })
200
200
  .setDMPermission(false)
201
201
  .toJSON(),
202
+ new SlashCommandBuilder()
203
+ .setName('add-dir')
204
+ .setDescription(truncateCommandDescription('Allow the current session to access an extra directory or * for all folders'))
205
+ .addStringOption((option) => {
206
+ option
207
+ .setName('directory')
208
+ .setDescription(truncateCommandDescription('Directory to allow, resolved from the current worktree. Use * for all folders'))
209
+ .setRequired(true);
210
+ return option;
211
+ })
212
+ .setDMPermission(false)
213
+ .toJSON(),
202
214
  new SlashCommandBuilder()
203
215
  .setName('abort')
204
216
  .setDescription(truncateCommandDescription('Abort the current OpenCode request in this thread'))
@@ -274,7 +274,7 @@ describeLocal('gateway-proxy reconnection (local binary)', () => {
274
274
  if (tmpDir) {
275
275
  fs.rmSync(tmpDir, { recursive: true, force: true });
276
276
  }
277
- }, 15_000);
277
+ }, 5_000);
278
278
  test('reconnects after local proxy restart (REST through proxy, clientId:secret)', async () => {
279
279
  tmpDir = fs.mkdtempSync(path.join(process.cwd(), 'tmp', 'gw-reconnect-'));
280
280
  proxyPort = await getAvailablePort();
@@ -313,11 +313,11 @@ describeIf('gateway-proxy e2e', () => {
313
313
  expect(thread).toBeDefined();
314
314
  expect(thread.id).toBeTruthy();
315
315
  firstThreadId = thread.id;
316
- const reply = await discord.thread(thread.id).waitForBotReply({ timeout: 15_000 });
316
+ const reply = await discord.thread(thread.id).waitForBotReply({ timeout: 5_000 });
317
317
  await waitForFooterMessage({
318
318
  discord,
319
319
  threadId: thread.id,
320
- timeout: 15_000,
320
+ timeout: 5_000,
321
321
  });
322
322
  expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
323
323
  "--- from: user (proxy-tester)
@@ -398,12 +398,14 @@ describeIf('gateway-proxy e2e', () => {
398
398
  expect(reply.content).toContain('proxy-shell-test');
399
399
  }, 15_000);
400
400
  test('second message creates separate thread', async () => {
401
+ const existingThreadIds = new Set((await discord.channel(CHANNEL_1_ID).getThreads()).map((thread) => {
402
+ return thread.id;
403
+ }));
401
404
  await discord.channel(CHANNEL_1_ID).user(TEST_USER_ID).sendMessage({
402
405
  content: 'second message through proxy',
403
406
  });
404
407
  const thread = await discord.channel(CHANNEL_1_ID).waitForThread({
405
- predicate: (t) => (t.name?.includes('second message through proxy') ?? false) &&
406
- t.id !== firstThreadId,
408
+ predicate: (t) => !existingThreadIds.has(t.id) && t.id !== firstThreadId,
407
409
  });
408
410
  expect(thread).toBeDefined();
409
411
  expect(thread.id).not.toBe(firstThreadId);
@@ -14,6 +14,7 @@ import { handleRemoveProjectCommand, handleRemoveProjectAutocomplete, } from './
14
14
  import { handleCreateNewProjectCommand } from './commands/create-new-project.js';
15
15
  import { handlePermissionButton } from './commands/permissions.js';
16
16
  import { handleAbortCommand } from './commands/abort.js';
17
+ import { handleAddDirCommand } from './commands/add-dir.js';
17
18
  import { handleCompactCommand } from './commands/compact.js';
18
19
  import { handleShareCommand } from './commands/share.js';
19
20
  import { handleDiffCommand } from './commands/diff.js';
@@ -138,6 +139,9 @@ export function registerInteractionHandler({ discordClient, appId, }) {
138
139
  case 'abort':
139
140
  await handleAbortCommand({ command: interaction, appId });
140
141
  return;
142
+ case 'add-dir':
143
+ await handleAddDirCommand({ command: interaction, appId });
144
+ return;
141
145
  case 'compact':
142
146
  await handleCompactCommand({ command: interaction, appId });
143
147
  return;
@@ -98,7 +98,10 @@ beforeAll(async () => {
98
98
  const maxWait = 15_000;
99
99
  const pollStart = Date.now();
100
100
  while (Date.now() - pollStart < maxWait) {
101
- const msgs = await client.session.messages({ sessionID });
101
+ const msgs = await client.session.messages({
102
+ sessionID,
103
+ directory: directories.projectDirectory,
104
+ });
102
105
  const assistantMsg = msgs.data?.find((m) => m.info.role === 'assistant');
103
106
  const hasTextParts = assistantMsg?.parts?.some((p) => {
104
107
  return p.type === 'text' && p.text && !p.synthetic;
@@ -114,7 +117,7 @@ beforeAll(async () => {
114
117
  setTimeout(resolve, 200);
115
118
  });
116
119
  }
117
- }, 60_000);
120
+ }, 20_000);
118
121
  afterAll(async () => {
119
122
  if (directories) {
120
123
  await cleanupTestSessions({
@@ -126,7 +129,7 @@ afterAll(async () => {
126
129
  if (directories) {
127
130
  fs.rmSync(directories.dataDir, { recursive: true, force: true });
128
131
  }
129
- }, 10_000);
132
+ }, 5_000);
130
133
  // Strip dynamic parts (timestamps, durations, branch names) for stable assertions
131
134
  function normalizeMarkdown(md) {
132
135
  return md
@@ -99,14 +99,14 @@ beforeAll(async () => {
99
99
  throw getClient;
100
100
  }
101
101
  client = getClient();
102
- }, 60_000);
102
+ }, 20_000);
103
103
  afterAll(async () => {
104
104
  await cleanupTestSessions({
105
105
  projectDirectory: directories.projectDirectory,
106
106
  testStartTime,
107
107
  });
108
108
  await stopOpencodeServer();
109
- }, 10_000);
109
+ }, 5_000);
110
110
  test('tool-call step has finish="tool-calls", follow-up has finish="stop"', async () => {
111
111
  const session = await client.session.create({
112
112
  directory: directories.projectDirectory,
@@ -123,7 +123,10 @@ test('tool-call step has finish="tool-calls", follow-up has finish="stop"', asyn
123
123
  const pollStart = Date.now();
124
124
  let completedAssistants = [];
125
125
  while (Date.now() - pollStart < maxWait) {
126
- const msgs = await client.session.messages({ sessionID });
126
+ const msgs = await client.session.messages({
127
+ sessionID,
128
+ directory: directories.projectDirectory,
129
+ });
127
130
  completedAssistants = (msgs.data || [])
128
131
  .filter((m) => {
129
132
  return m.info.role === 'assistant' && m.info.time.completed;
@@ -162,4 +165,4 @@ test('tool-call step has finish="tool-calls", follow-up has finish="stop"', asyn
162
165
  `);
163
166
  const finishes = completedAssistants.map((m) => { return m.finish; });
164
167
  expect(finishes).toEqual(['tool-calls', 'stop']);
165
- }, 15_000);
168
+ }, 5_000);
@@ -19,12 +19,12 @@ const logger = createLogger(LogPrefix.SESSION);
19
19
  const voiceLogger = createLogger(LogPrefix.VOICE);
20
20
  export const VOICE_MESSAGE_TRANSCRIPTION_PREFIX = 'Voice message transcription from Discord user:\n';
21
21
  /** Fetch available agents from OpenCode for voice transcription agent selection. */
22
- async function fetchAvailableAgents(getClient) {
22
+ async function fetchAvailableAgents(getClient, directory) {
23
23
  if (getClient instanceof Error) {
24
24
  return [];
25
25
  }
26
26
  const result = await errore.tryAsync(() => {
27
- return getClient().app.agents({});
27
+ return getClient().app.agents({ directory });
28
28
  });
29
29
  if (result instanceof Error) {
30
30
  return [];
@@ -138,7 +138,7 @@ export async function preprocessExistingThreadMessage({ message, thread, project
138
138
  client,
139
139
  excludeSessionId: sessionId,
140
140
  }),
141
- fetchAvailableAgents(getClient),
141
+ fetchAvailableAgents(getClient, projectDirectory),
142
142
  ]);
143
143
  if (errore.isOk(sessionContextResult)) {
144
144
  currentSessionContext = sessionContextResult;
@@ -216,7 +216,7 @@ export async function preprocessNewSessionMessage({ message, thread, projectDire
216
216
  if (hasVoiceAttachment && projectDirectory) {
217
217
  try {
218
218
  const getClient = await initializeOpencodeForDirectory(projectDirectory);
219
- agents = await fetchAvailableAgents(getClient);
219
+ agents = await fetchAvailableAgents(getClient, projectDirectory);
220
220
  }
221
221
  catch (e) {
222
222
  voiceLogger.error(`Could not fetch agents for voice transcription:`, e);
@@ -281,7 +281,7 @@ export async function preprocessNewThreadMessage({ message, thread, projectDirec
281
281
  if (hasVoiceAttachment && projectDirectory) {
282
282
  try {
283
283
  const getClient = await initializeOpencodeForDirectory(projectDirectory);
284
- agents = await fetchAvailableAgents(getClient);
284
+ agents = await fetchAvailableAgents(getClient, projectDirectory);
285
285
  }
286
286
  catch (e) {
287
287
  voiceLogger.error(`Could not fetch agents for voice transcription:`, e);