kimaki 0.4.88 → 0.4.90
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/add-directory.e2e.test.js +101 -0
- package/dist/agent-model.e2e.test.js +3 -6
- package/dist/cli-send-thread.e2e.test.js +2 -1
- package/dist/cli.js +11 -1
- package/dist/commands/add-directory.js +67 -0
- package/dist/commands/btw.js +7 -2
- package/dist/commands/user-command.js +10 -9
- package/dist/context-awareness-plugin.js +32 -18
- package/dist/context-awareness-plugin.test.js +57 -0
- package/dist/directory-permissions.js +38 -0
- package/dist/directory-permissions.test.js +37 -0
- package/dist/discord-bot.js +10 -4
- package/dist/event-stream-real-capture.e2e.test.js +2 -1
- package/dist/gateway-proxy.e2e.test.js +5 -6
- package/dist/generated/models/thread_allowed_directories.js +1 -0
- package/dist/kimaki-digital-twin.e2e.test.js +2 -1
- package/dist/markdown.test.js +5 -38
- package/dist/message-finish-field.e2e.test.js +4 -3
- package/dist/opencode.js +52 -3
- package/dist/queue-advanced-abort.e2e.test.js +14 -16
- package/dist/queue-advanced-action-buttons.e2e.test.js +1 -12
- package/dist/queue-advanced-e2e-setup.js +2 -0
- package/dist/queue-advanced-footer.e2e.test.js +5 -42
- package/dist/queue-advanced-model-switch.e2e.test.js +2 -5
- package/dist/queue-advanced-permissions-typing.e2e.test.js +26 -36
- package/dist/queue-advanced-question.e2e.test.js +22 -40
- package/dist/queue-advanced-typing-interrupt.e2e.test.js +11 -7
- package/dist/queue-question-select-drain.e2e.test.js +30 -28
- package/dist/runtime-lifecycle.e2e.test.js +3 -4
- package/dist/session-handler/thread-session-runtime.js +24 -10
- package/dist/startup-time.e2e.test.js +2 -1
- package/dist/task-runner.js +6 -0
- package/dist/task-schedule.js +4 -0
- package/dist/test-utils.js +20 -0
- package/dist/thread-message-queue.e2e.test.js +20 -34
- package/dist/undo-redo.e2e.test.js +0 -1
- package/dist/voice-message.e2e.test.js +3 -7
- package/dist/worktree-lifecycle.e2e.test.js +1 -1
- package/dist/worktrees.test.js +2 -2
- package/package.json +3 -3
- package/src/agent-model.e2e.test.ts +3 -5
- package/src/cli-send-thread.e2e.test.ts +2 -0
- package/src/cli.ts +21 -2
- package/src/commands/btw.ts +8 -2
- package/src/commands/user-command.ts +11 -11
- package/src/context-awareness-plugin.test.ts +66 -0
- package/src/context-awareness-plugin.ts +46 -26
- package/src/discord-bot.ts +12 -4
- package/src/event-stream-real-capture.e2e.test.ts +2 -1
- package/src/gateway-proxy.e2e.test.ts +5 -5
- package/src/kimaki-digital-twin.e2e.test.ts +2 -1
- package/src/markdown.test.ts +4 -37
- package/src/message-finish-field.e2e.test.ts +4 -3
- package/src/opencode.ts +66 -3
- package/src/queue-advanced-abort.e2e.test.ts +14 -16
- package/src/queue-advanced-action-buttons.e2e.test.ts +1 -12
- package/src/queue-advanced-e2e-setup.ts +2 -0
- package/src/queue-advanced-footer.e2e.test.ts +5 -42
- package/src/queue-advanced-model-switch.e2e.test.ts +2 -5
- package/src/queue-advanced-permissions-typing.e2e.test.ts +34 -36
- package/src/queue-advanced-question.e2e.test.ts +22 -43
- package/src/queue-advanced-typing-interrupt.e2e.test.ts +14 -7
- package/src/queue-question-select-drain.e2e.test.ts +31 -29
- package/src/runtime-lifecycle.e2e.test.ts +3 -3
- package/src/session-handler/thread-runtime-state.ts +4 -0
- package/src/session-handler/thread-session-runtime.ts +35 -6
- package/src/startup-time.e2e.test.ts +2 -1
- package/src/system-message.ts +10 -1
- package/src/task-runner.ts +6 -0
- package/src/task-schedule.ts +6 -0
- package/src/test-utils.ts +21 -0
- package/src/thread-message-queue.e2e.test.ts +20 -36
- package/src/undo-redo.e2e.test.ts +0 -1
- package/src/voice-message.e2e.test.ts +3 -6
- package/src/worktree-lifecycle.e2e.test.ts +1 -1
- package/src/worktrees.test.ts +2 -2
|
@@ -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
|
+
});
|
|
@@ -23,7 +23,7 @@ import { setBotToken, initDatabase, closeDatabase, setChannelDirectory, setChann
|
|
|
23
23
|
import { getPrisma } from './db.js';
|
|
24
24
|
import { startHranaServer, stopHranaServer } from './hrana-server.js';
|
|
25
25
|
import { initializeOpencodeForDirectory, stopOpencodeServer } from './opencode.js';
|
|
26
|
-
import { chooseLockPort, cleanupTestSessions, waitForBotMessageContaining, waitForFooterMessage, } from './test-utils.js';
|
|
26
|
+
import { chooseLockPort, cleanupTestSessions, initTestGitRepo, waitForBotMessageContaining, waitForFooterMessage, } from './test-utils.js';
|
|
27
27
|
import { buildQuickAgentCommandDescription } from './commands/agent.js';
|
|
28
28
|
const TEST_USER_ID = '200000000000000920';
|
|
29
29
|
const TEXT_CHANNEL_ID = '200000000000000921';
|
|
@@ -38,6 +38,7 @@ function createRunDirectories() {
|
|
|
38
38
|
const dataDir = fs.mkdtempSync(path.join(root, 'data-'));
|
|
39
39
|
const projectDirectory = path.join(root, 'project');
|
|
40
40
|
fs.mkdirSync(projectDirectory, { recursive: true });
|
|
41
|
+
initTestGitRepo(projectDirectory);
|
|
41
42
|
return { root, dataDir, projectDirectory };
|
|
42
43
|
}
|
|
43
44
|
function createDiscordJsClient({ restUrl }) {
|
|
@@ -345,8 +346,7 @@ describe('agent model resolution', () => {
|
|
|
345
346
|
Reply with exactly: system-context-check
|
|
346
347
|
--- from: assistant (TestBot)
|
|
347
348
|
⬥ system-context-ok
|
|
348
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
|
|
349
|
-
⬥ ok"
|
|
349
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***"
|
|
350
350
|
`);
|
|
351
351
|
}, 15_000);
|
|
352
352
|
test('new thread uses channel model when channel model preference is set', async () => {
|
|
@@ -513,7 +513,6 @@ describe('agent model resolution', () => {
|
|
|
513
513
|
Reply with exactly: second-thread-msg
|
|
514
514
|
--- from: assistant (TestBot)
|
|
515
515
|
⬥ ok
|
|
516
|
-
⬥ ok
|
|
517
516
|
*project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***"
|
|
518
517
|
`);
|
|
519
518
|
const secondMessages = await discord.thread(thread.id).getMessages();
|
|
@@ -596,7 +595,6 @@ describe('agent model resolution', () => {
|
|
|
596
595
|
Reply with exactly: default-second-msg
|
|
597
596
|
--- from: assistant (TestBot)
|
|
598
597
|
⬥ ok
|
|
599
|
-
⬥ ok
|
|
600
598
|
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
601
599
|
`);
|
|
602
600
|
const secondMessages = await discord.thread(thread.id).getMessages();
|
|
@@ -664,7 +662,6 @@ describe('agent model resolution', () => {
|
|
|
664
662
|
Reply with exactly: after-switch-msg
|
|
665
663
|
--- from: assistant (TestBot)
|
|
666
664
|
⬥ ok
|
|
667
|
-
⬥ ok
|
|
668
665
|
*project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan***"
|
|
669
666
|
`);
|
|
670
667
|
const secondFooter = [...(await discord.thread(thread.id).getMessages())]
|
|
@@ -23,7 +23,7 @@ import { startDiscordBot } from './discord-bot.js';
|
|
|
23
23
|
import { setBotToken, initDatabase, closeDatabase, setChannelDirectory, setChannelVerbosity, } from './database.js';
|
|
24
24
|
import { startHranaServer, stopHranaServer } from './hrana-server.js';
|
|
25
25
|
import { initializeOpencodeForDirectory, stopOpencodeServer, } from './opencode.js';
|
|
26
|
-
import { chooseLockPort, cleanupTestSessions, waitForBotMessageContaining, waitForFooterMessage, } from './test-utils.js';
|
|
26
|
+
import { chooseLockPort, cleanupTestSessions, initTestGitRepo, waitForBotMessageContaining, waitForFooterMessage, } from './test-utils.js';
|
|
27
27
|
import yaml from 'js-yaml';
|
|
28
28
|
const TEST_USER_ID = '200000000000000830';
|
|
29
29
|
const TEXT_CHANNEL_ID = '200000000000000831';
|
|
@@ -34,6 +34,7 @@ function createRunDirectories() {
|
|
|
34
34
|
const dataDir = fs.mkdtempSync(path.join(root, 'data-'));
|
|
35
35
|
const projectDirectory = path.join(root, 'project');
|
|
36
36
|
fs.mkdirSync(projectDirectory, { recursive: true });
|
|
37
|
+
initTestGitRepo(projectDirectory);
|
|
37
38
|
return { root, dataDir, projectDirectory };
|
|
38
39
|
}
|
|
39
40
|
function createDiscordJsClient({ restUrl }) {
|
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
|
-
|
|
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) } }]
|
|
@@ -2504,7 +2510,11 @@ cli
|
|
|
2504
2510
|
cli
|
|
2505
2511
|
.command('project create <name>', 'Create a new project folder with git and Discord channels')
|
|
2506
2512
|
.option('-g, --guild <guildId>', 'Discord guild ID')
|
|
2513
|
+
.option('--projects-dir <path>', 'Directory where new projects are created (default: <data-dir>/projects)')
|
|
2507
2514
|
.action(async (name, options) => {
|
|
2515
|
+
if (options.projectsDir) {
|
|
2516
|
+
setProjectsDir(options.projectsDir);
|
|
2517
|
+
}
|
|
2508
2518
|
const sanitizedName = name
|
|
2509
2519
|
.toLowerCase()
|
|
2510
2520
|
.replace(/[^a-z0-9-]/g, '-')
|
|
@@ -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
|
+
}
|
package/dist/commands/btw.js
CHANGED
|
@@ -86,7 +86,12 @@ export async function handleBtwCommand({ command, appId, }) {
|
|
|
86
86
|
// Short status message with prompt instead of replaying past messages
|
|
87
87
|
const sourceThreadLink = `<#${channel.id}>`;
|
|
88
88
|
await sendThreadMessage(thread, `Reusing context from ${sourceThreadLink} to answer prompt...\n${prompt}`);
|
|
89
|
-
|
|
89
|
+
const wrappedPrompt = [
|
|
90
|
+
`The user asked a side question while you were working on another task.`,
|
|
91
|
+
`This is a forked session whose ONLY goal is to answer this question.`,
|
|
92
|
+
`Do NOT continue, resume, or reference the previous task. Only answer the question below.\n`,
|
|
93
|
+
prompt,
|
|
94
|
+
].join('\n');
|
|
90
95
|
const runtime = getOrCreateRuntime({
|
|
91
96
|
threadId: thread.id,
|
|
92
97
|
thread,
|
|
@@ -96,7 +101,7 @@ export async function handleBtwCommand({ command, appId, }) {
|
|
|
96
101
|
appId,
|
|
97
102
|
});
|
|
98
103
|
await runtime.enqueueIncoming({
|
|
99
|
-
prompt,
|
|
104
|
+
prompt: wrappedPrompt,
|
|
100
105
|
userId: command.user.id,
|
|
101
106
|
username: command.user.displayName,
|
|
102
107
|
appId,
|
|
@@ -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 {
|
|
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
|
|
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:
|
|
111
|
+
content: threadOpeningMessage,
|
|
106
112
|
flags: SILENT_MESSAGE_FLAGS,
|
|
107
113
|
});
|
|
108
|
-
const threadName = `/${commandName}`;
|
|
109
114
|
const newThread = await starterMessage.startThread({
|
|
110
|
-
name:
|
|
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({
|
|
53
|
-
if (
|
|
52
|
+
export function shouldInjectPwd({ currentDir, previousDir, announcedDir, }) {
|
|
53
|
+
if (announcedDir === currentDir) {
|
|
54
54
|
return { inject: false };
|
|
55
55
|
}
|
|
56
|
-
|
|
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
|
|
62
|
-
`
|
|
63
|
-
`
|
|
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
|
|
153
|
-
//
|
|
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
|
-
|
|
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
|
|
163
|
+
return {
|
|
164
|
+
currentDirectory: previousDirectory || null,
|
|
165
|
+
previousDirectory,
|
|
166
|
+
};
|
|
163
167
|
}
|
|
164
168
|
state.resolvedDirectory = result.data.directory;
|
|
165
|
-
return
|
|
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
|
|
234
|
+
const sessionDirectory = await resolveSessionDirectory({
|
|
228
235
|
client,
|
|
229
236
|
sessionID,
|
|
230
237
|
state,
|
|
231
238
|
});
|
|
232
|
-
|
|
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
|
-
|
|
239
|
-
|
|
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 =
|
|
257
|
+
state.announcedDirectory = effectiveDirectory;
|
|
244
258
|
output.parts.push({
|
|
245
259
|
id: `prt_${crypto.randomUUID()}`,
|
|
246
260
|
sessionID,
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Tests for context-awareness directory switch reminders.
|
|
2
|
+
import { describe, expect, test } from 'vitest';
|
|
3
|
+
import { shouldInjectPwd } from './context-awareness-plugin.js';
|
|
4
|
+
describe('shouldInjectPwd', () => {
|
|
5
|
+
test('does not inject when current directory matches announced directory', () => {
|
|
6
|
+
const result = shouldInjectPwd({
|
|
7
|
+
currentDir: '/repo/worktree',
|
|
8
|
+
previousDir: '/repo/main',
|
|
9
|
+
announcedDir: '/repo/worktree',
|
|
10
|
+
});
|
|
11
|
+
expect(result).toMatchInlineSnapshot(`
|
|
12
|
+
{
|
|
13
|
+
"inject": false,
|
|
14
|
+
}
|
|
15
|
+
`);
|
|
16
|
+
});
|
|
17
|
+
test('does not inject without a previous directory to warn about', () => {
|
|
18
|
+
const result = shouldInjectPwd({
|
|
19
|
+
currentDir: '/repo/worktree',
|
|
20
|
+
previousDir: undefined,
|
|
21
|
+
announcedDir: undefined,
|
|
22
|
+
});
|
|
23
|
+
expect(result).toMatchInlineSnapshot(`
|
|
24
|
+
{
|
|
25
|
+
"inject": false,
|
|
26
|
+
}
|
|
27
|
+
`);
|
|
28
|
+
});
|
|
29
|
+
test('names previous and current directories in the correct order', () => {
|
|
30
|
+
const result = shouldInjectPwd({
|
|
31
|
+
currentDir: '/repo/worktree',
|
|
32
|
+
previousDir: '/repo/main',
|
|
33
|
+
announcedDir: undefined,
|
|
34
|
+
});
|
|
35
|
+
expect(result).toMatchInlineSnapshot(`
|
|
36
|
+
{
|
|
37
|
+
"inject": true,
|
|
38
|
+
"text": "
|
|
39
|
+
[working directory changed. Previous working directory: /repo/main. Current working directory: /repo/worktree. You MUST read, write, and edit files only under /repo/worktree. Do NOT read, write, or edit files under /repo/main.]",
|
|
40
|
+
}
|
|
41
|
+
`);
|
|
42
|
+
});
|
|
43
|
+
test('prefers the last announced directory as the previous directory', () => {
|
|
44
|
+
const result = shouldInjectPwd({
|
|
45
|
+
currentDir: '/repo/worktree-b',
|
|
46
|
+
previousDir: '/repo/main',
|
|
47
|
+
announcedDir: '/repo/worktree-a',
|
|
48
|
+
});
|
|
49
|
+
expect(result).toMatchInlineSnapshot(`
|
|
50
|
+
{
|
|
51
|
+
"inject": true,
|
|
52
|
+
"text": "
|
|
53
|
+
[working directory changed. Previous working directory: /repo/worktree-a. Current working directory: /repo/worktree-b. You MUST read, write, and edit files only under /repo/worktree-b. Do NOT read, write, or edit files under /repo/worktree-a.]",
|
|
54
|
+
}
|
|
55
|
+
`);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Directory permission helpers for one-shot external directory preapproval.
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
export function normalizeAllowedDirectoryPath({ input, workingDirectory, }) {
|
|
5
|
+
const trimmedInput = input.trim();
|
|
6
|
+
if (!trimmedInput) {
|
|
7
|
+
return new Error('Path cannot be empty');
|
|
8
|
+
}
|
|
9
|
+
const withoutTrailingGlob = trimmedInput.replace(/[\\/]\*+$/u, '');
|
|
10
|
+
if (!withoutTrailingGlob) {
|
|
11
|
+
return new Error('Path cannot be empty');
|
|
12
|
+
}
|
|
13
|
+
if (withoutTrailingGlob.includes('*') || withoutTrailingGlob.includes('?')) {
|
|
14
|
+
return new Error('Path must be a directory, not a glob pattern');
|
|
15
|
+
}
|
|
16
|
+
const expandedHomeDirectory = (() => {
|
|
17
|
+
if (withoutTrailingGlob === '~') {
|
|
18
|
+
return os.homedir();
|
|
19
|
+
}
|
|
20
|
+
if (withoutTrailingGlob.startsWith('~/')) {
|
|
21
|
+
return path.join(os.homedir(), withoutTrailingGlob.slice(2));
|
|
22
|
+
}
|
|
23
|
+
return withoutTrailingGlob;
|
|
24
|
+
})();
|
|
25
|
+
const absolutePath = path.isAbsolute(expandedHomeDirectory)
|
|
26
|
+
? expandedHomeDirectory
|
|
27
|
+
: path.resolve(workingDirectory, expandedHomeDirectory);
|
|
28
|
+
const normalizedPath = path.normalize(absolutePath);
|
|
29
|
+
const root = path.parse(normalizedPath).root;
|
|
30
|
+
const withoutTrailingSlash = normalizedPath.length > root.length
|
|
31
|
+
? normalizedPath.replace(/[\\/]+$/u, '')
|
|
32
|
+
: normalizedPath;
|
|
33
|
+
return withoutTrailingSlash.replaceAll('\\', '/');
|
|
34
|
+
}
|
|
35
|
+
export function buildAllowedDirectoryPatterns({ directory, }) {
|
|
36
|
+
const childPattern = directory.endsWith('/') ? `${directory}*` : `${directory}/*`;
|
|
37
|
+
return [directory, childPattern];
|
|
38
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Tests for one-shot directory permission path normalization helpers.
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { describe, expect, test } from 'vitest';
|
|
5
|
+
import { buildAllowedDirectoryPatterns, normalizeAllowedDirectoryPath, } from './directory-permissions.js';
|
|
6
|
+
describe('normalizeAllowedDirectoryPath', () => {
|
|
7
|
+
test('resolves relative paths from the working directory', () => {
|
|
8
|
+
const result = normalizeAllowedDirectoryPath({
|
|
9
|
+
input: '../shared/',
|
|
10
|
+
workingDirectory: '/repo/worktree/app',
|
|
11
|
+
});
|
|
12
|
+
expect(result).toBe('/repo/worktree/shared');
|
|
13
|
+
});
|
|
14
|
+
test('expands home directories and strips implicit trailing glob', () => {
|
|
15
|
+
const result = normalizeAllowedDirectoryPath({
|
|
16
|
+
input: '~/projects/*',
|
|
17
|
+
workingDirectory: '/repo/worktree/app',
|
|
18
|
+
});
|
|
19
|
+
expect(result).toBe(`${os.homedir().replaceAll('\\', '/')}/projects`);
|
|
20
|
+
});
|
|
21
|
+
test('rejects glob patterns in the middle of the path', () => {
|
|
22
|
+
const result = normalizeAllowedDirectoryPath({
|
|
23
|
+
input: 'src/*/nested',
|
|
24
|
+
workingDirectory: '/repo/worktree/app',
|
|
25
|
+
});
|
|
26
|
+
expect(result instanceof Error ? result.message : result).toBe('Path must be a directory, not a glob pattern');
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
describe('buildAllowedDirectoryPatterns', () => {
|
|
30
|
+
test('adds exact and child wildcard patterns for a directory', () => {
|
|
31
|
+
const directory = path.join('/repo', 'shared').replaceAll('\\', '/');
|
|
32
|
+
expect(buildAllowedDirectoryPatterns({ directory })).toEqual([
|
|
33
|
+
'/repo/shared',
|
|
34
|
+
'/repo/shared/*',
|
|
35
|
+
]);
|
|
36
|
+
});
|
|
37
|
+
});
|
package/dist/discord-bot.js
CHANGED
|
@@ -268,15 +268,19 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
268
268
|
const cliInjectedPermissions = isCliInjectedPrompt
|
|
269
269
|
? promptMarker?.permissions
|
|
270
270
|
: undefined;
|
|
271
|
+
const cliInjectedInjectionGuardPatterns = isCliInjectedPrompt
|
|
272
|
+
? promptMarker?.injectionGuardPatterns
|
|
273
|
+
: undefined;
|
|
271
274
|
// Always ignore our own messages (unless CLI-injected prompt above).
|
|
272
275
|
// Without this, assigning the Kimaki role to the bot itself would loop.
|
|
273
276
|
if (isSelfBotMessage && !isCliInjectedPrompt) {
|
|
274
277
|
return;
|
|
275
278
|
}
|
|
276
|
-
// Allow
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
|
|
279
|
+
// Allow CLI-injected prompts from this Kimaki bot through even when role
|
|
280
|
+
// reconciliation did not give the bot the "Kimaki" role yet. Other bots
|
|
281
|
+
// still need Kimaki permission so multi-agent orchestration stays opt-in.
|
|
282
|
+
const isInjectedSelfBotMessage = isCliInjectedPrompt && message.author?.id === discordClient.user?.id;
|
|
283
|
+
if (message.author?.bot && !isInjectedSelfBotMessage) {
|
|
280
284
|
if (!hasKimakiBotPermission(message.member)) {
|
|
281
285
|
return;
|
|
282
286
|
}
|
|
@@ -488,6 +492,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
488
492
|
agent: cliInjectedAgent,
|
|
489
493
|
model: cliInjectedModel,
|
|
490
494
|
permissions: cliInjectedPermissions,
|
|
495
|
+
injectionGuardPatterns: cliInjectedInjectionGuardPatterns,
|
|
491
496
|
sessionStartSource: sessionStartSource
|
|
492
497
|
? {
|
|
493
498
|
scheduleKind: sessionStartSource.scheduleKind,
|
|
@@ -759,6 +764,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
759
764
|
agent: marker.agent,
|
|
760
765
|
model: marker.model,
|
|
761
766
|
permissions: marker.permissions,
|
|
767
|
+
injectionGuardPatterns: marker.injectionGuardPatterns,
|
|
762
768
|
mode: 'opencode',
|
|
763
769
|
sessionStartSource: botThreadStartSource
|
|
764
770
|
? {
|