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.
- package/dist/agent-model.e2e.test.js +21 -29
- package/dist/cli-send-thread.e2e.test.js +2 -2
- package/dist/commands/add-dir.js +122 -0
- package/dist/commands/add-dir.test.js +87 -0
- package/dist/commands/agent.js +1 -0
- package/dist/commands/model-variant.js +2 -0
- package/dist/commands/model.js +7 -4
- package/dist/commands/new-worktree.js +41 -1
- package/dist/commands/unset-model.js +1 -0
- package/dist/discord-command-registration.js +12 -0
- package/dist/gateway-proxy-reconnect.e2e.test.js +1 -1
- package/dist/gateway-proxy.e2e.test.js +6 -4
- package/dist/interaction-handler.js +4 -0
- package/dist/markdown.test.js +6 -3
- package/dist/message-finish-field.e2e.test.js +7 -4
- package/dist/message-preprocessing.js +5 -5
- package/dist/opencode-interrupt-plugin.test.js +5 -0
- package/dist/opencode.js +117 -56
- package/dist/opencode.test.js +79 -31
- package/dist/queue-advanced-e2e-setup.js +3 -3
- package/dist/queue-advanced-footer.e2e.test.js +20 -11
- package/dist/queue-advanced-permissions-typing.e2e.test.js +5 -2
- package/dist/runtime-lifecycle.e2e.test.js +15 -9
- package/dist/session-handler/agent-utils.js +5 -5
- package/dist/session-handler/model-utils.js +26 -3
- package/dist/session-handler/thread-session-runtime.js +10 -4
- package/dist/startup-time.e2e.test.js +1 -1
- package/dist/system-message.js +20 -0
- package/dist/system-message.test.js +20 -0
- package/dist/system-prompt-drift-plugin.js +33 -62
- package/dist/test-utils.js +21 -7
- package/dist/thread-message-queue.e2e.test.js +9 -6
- package/dist/undo-redo.e2e.test.js +2 -2
- package/dist/voice-message.e2e.test.js +2 -2
- package/dist/worktree-lifecycle.e2e.test.js +2 -2
- package/package.json +6 -6
- package/src/agent-model.e2e.test.ts +25 -31
- package/src/cli-send-thread.e2e.test.ts +2 -2
- package/src/commands/add-dir.test.ts +109 -0
- package/src/commands/add-dir.ts +173 -0
- package/src/commands/agent.ts +1 -0
- package/src/commands/model-variant.ts +2 -0
- package/src/commands/model.ts +9 -2
- package/src/commands/new-worktree.ts +66 -0
- package/src/commands/unset-model.ts +1 -0
- package/src/discord-command-registration.ts +15 -0
- package/src/gateway-proxy-reconnect.e2e.test.ts +1 -1
- package/src/gateway-proxy.e2e.test.ts +8 -4
- package/src/interaction-handler.ts +5 -0
- package/src/markdown.test.ts +6 -3
- package/src/message-finish-field.e2e.test.ts +7 -4
- package/src/message-preprocessing.ts +5 -4
- package/src/opencode-interrupt-plugin.test.ts +5 -0
- package/src/opencode.ts +159 -57
- package/src/queue-advanced-e2e-setup.ts +3 -3
- package/src/queue-advanced-footer.e2e.test.ts +26 -11
- package/src/queue-advanced-permissions-typing.e2e.test.ts +7 -2
- package/src/runtime-lifecycle.e2e.test.ts +19 -9
- package/src/session-handler/agent-utils.ts +7 -5
- package/src/session-handler/model-utils.ts +36 -2
- package/src/session-handler/thread-session-runtime.ts +10 -5
- package/src/startup-time.e2e.test.ts +1 -1
- package/src/system-message.test.ts +20 -0
- package/src/system-message.ts +20 -0
- package/src/system-prompt-drift-plugin.ts +36 -86
- package/src/test-utils.ts +23 -7
- package/src/thread-message-queue.e2e.test.ts +11 -6
- package/src/undo-redo.e2e.test.ts +2 -2
- package/src/voice-message.e2e.test.ts +2 -2
- 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
|
-
|
|
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
|
-
},
|
|
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
|
-
},
|
|
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:
|
|
394
|
+
timeout: 6_000,
|
|
385
395
|
predicate: (t) => {
|
|
386
|
-
return t.
|
|
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: '
|
|
411
|
-
timeout:
|
|
420
|
+
text: 'ok',
|
|
421
|
+
timeout: 6_000,
|
|
412
422
|
});
|
|
413
|
-
await
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
},
|
|
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
|
-
},
|
|
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
|
+
});
|
package/dist/commands/agent.js
CHANGED
|
@@ -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,
|
package/dist/commands/model.js
CHANGED
|
@@ -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 }),
|
|
@@ -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
|
-
},
|
|
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:
|
|
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:
|
|
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.
|
|
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;
|
package/dist/markdown.test.js
CHANGED
|
@@ -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({
|
|
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
|
-
},
|
|
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
|
-
},
|
|
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
|
-
},
|
|
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
|
-
},
|
|
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({
|
|
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
|
-
},
|
|
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);
|