@otto-assistant/bridge 0.4.101 → 0.4.103
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 +1 -0
- package/dist/anthropic-auth-plugin.js +22 -1
- package/dist/anthropic-auth-state.js +31 -0
- package/dist/btw-prefix-detection.js +17 -0
- package/dist/btw-prefix-detection.test.js +63 -0
- package/dist/cli.js +101 -15
- package/dist/commands/agent.js +21 -2
- package/dist/commands/ask-question.js +50 -4
- package/dist/commands/ask-question.test.js +92 -0
- package/dist/commands/btw.js +71 -66
- package/dist/commands/new-worktree.js +92 -35
- package/dist/commands/queue.js +17 -0
- package/dist/commands/worktrees.js +196 -139
- package/dist/context-awareness-plugin.js +16 -8
- package/dist/context-awareness-plugin.test.js +4 -2
- package/dist/discord-bot.js +35 -2
- package/dist/discord-command-registration.js +9 -2
- package/dist/memory-overview-plugin.js +3 -1
- package/dist/opencode.js +24 -1
- package/dist/queue-question-select-drain.e2e.test.js +135 -10
- package/dist/session-handler/thread-runtime-state.js +27 -0
- package/dist/session-handler/thread-session-runtime.js +58 -28
- package/dist/session-title-rename.test.js +12 -0
- package/dist/skill-filter.js +31 -0
- package/dist/skill-filter.test.js +65 -0
- package/dist/store.js +2 -0
- package/dist/system-message.js +12 -3
- package/dist/system-message.test.js +10 -6
- package/dist/thread-message-queue.e2e.test.js +109 -0
- package/dist/worktree-lifecycle.e2e.test.js +4 -1
- package/dist/worktrees.js +106 -12
- package/dist/worktrees.test.js +232 -6
- package/package.json +2 -2
- package/skills/goke/SKILL.md +13 -619
- package/skills/new-skill/SKILL.md +34 -10
- package/skills/npm-package/SKILL.md +336 -2
- package/skills/profano/SKILL.md +24 -0
- package/skills/zele/SKILL.md +50 -21
- package/src/agent-model.e2e.test.ts +1 -0
- package/src/anthropic-auth-plugin.ts +24 -4
- package/src/anthropic-auth-state.ts +45 -0
- package/src/btw-prefix-detection.test.ts +73 -0
- package/src/btw-prefix-detection.ts +23 -0
- package/src/cli.ts +138 -46
- package/src/commands/agent.ts +24 -2
- package/src/commands/ask-question.test.ts +111 -0
- package/src/commands/ask-question.ts +69 -4
- package/src/commands/btw.ts +105 -85
- package/src/commands/new-worktree.ts +107 -40
- package/src/commands/queue.ts +22 -0
- package/src/commands/worktrees.ts +246 -154
- package/src/context-awareness-plugin.test.ts +4 -2
- package/src/context-awareness-plugin.ts +16 -8
- package/src/discord-bot.ts +40 -2
- package/src/discord-command-registration.ts +12 -2
- package/src/memory-overview-plugin.ts +3 -1
- package/src/opencode.ts +31 -1
- package/src/queue-question-select-drain.e2e.test.ts +174 -10
- package/src/session-handler/thread-runtime-state.ts +36 -1
- package/src/session-handler/thread-session-runtime.ts +72 -32
- package/src/session-title-rename.test.ts +18 -0
- package/src/skill-filter.test.ts +83 -0
- package/src/skill-filter.ts +42 -0
- package/src/store.ts +17 -0
- package/src/system-message.test.ts +10 -6
- package/src/system-message.ts +12 -3
- package/src/thread-message-queue.e2e.test.ts +126 -0
- package/src/worktree-lifecycle.e2e.test.ts +6 -1
- package/src/worktrees.test.ts +274 -9
- package/src/worktrees.ts +144 -23
|
@@ -18,7 +18,9 @@ function createSessionState() {
|
|
|
18
18
|
};
|
|
19
19
|
}
|
|
20
20
|
function buildMemoryOverviewReminder({ condensed }) {
|
|
21
|
-
|
|
21
|
+
// Trailing newline so this synthetic part does not fuse with the next text
|
|
22
|
+
// part when the model concatenates message parts.
|
|
23
|
+
return `<system-reminder>Project memory from MEMORY.md (condensed table of contents, line numbers shown):\n${condensed}\nOnly headings are shown above — section bodies are hidden. Use Grep to search MEMORY.md for specific topics, or Read with offset and limit to read a section's content. When writing to MEMORY.md, keep titles concise (under 10 words) and content brief (2-3 sentences max). Only track non-obvious learnings that prevent future mistakes and are not already documented in code comments or AGENTS.md. Do not duplicate information that is self-evident from the code.</system-reminder>\n`;
|
|
22
24
|
}
|
|
23
25
|
async function freezeMemoryOverview({ directory, state, }) {
|
|
24
26
|
if (state.hasFrozenOverview) {
|
package/dist/opencode.js
CHANGED
|
@@ -28,6 +28,7 @@ import { createLogger, LogPrefix } from './logger.js';
|
|
|
28
28
|
import { notifyError } from './sentry.js';
|
|
29
29
|
import { DirectoryNotAccessibleError, ServerStartError, ServerNotReadyError, FetchError, } from './errors.js';
|
|
30
30
|
import { ensureKimakiCommandShim, getPathEnvKey, getSpawnCommandAndArgs, prependPathEntry, selectResolvedCommand, } from './opencode-command.js';
|
|
31
|
+
import { computeSkillPermission } from './skill-filter.js';
|
|
31
32
|
const opencodeLogger = createLogger(LogPrefix.OPENCODE);
|
|
32
33
|
// Tracks directories that have been initialized, to avoid repeated log spam
|
|
33
34
|
// from the external sync polling loop.
|
|
@@ -335,6 +336,9 @@ async function startSingleServer() {
|
|
|
335
336
|
const opencodeConfigDir = path
|
|
336
337
|
.join(os.homedir(), '.config', 'opencode')
|
|
337
338
|
.replaceAll('\\', '/');
|
|
339
|
+
const opensrcDir = path
|
|
340
|
+
.join(os.homedir(), '.opensrc')
|
|
341
|
+
.replaceAll('\\', '/');
|
|
338
342
|
const kimakiDataDir = path
|
|
339
343
|
.join(os.homedir(), '.kimaki')
|
|
340
344
|
.replaceAll('\\', '/');
|
|
@@ -349,6 +353,8 @@ async function startSingleServer() {
|
|
|
349
353
|
[`${tmpdir}/*`]: 'allow',
|
|
350
354
|
[opencodeConfigDir]: 'allow',
|
|
351
355
|
[`${opencodeConfigDir}/*`]: 'allow',
|
|
356
|
+
[opensrcDir]: 'allow',
|
|
357
|
+
[`${opensrcDir}/*`]: 'allow',
|
|
352
358
|
[kimakiDataDir]: 'allow',
|
|
353
359
|
[`${kimakiDataDir}/*`]: 'allow',
|
|
354
360
|
};
|
|
@@ -388,16 +394,27 @@ async function startSingleServer() {
|
|
|
388
394
|
// priority chain, so project-level opencode.json can override kimaki defaults.
|
|
389
395
|
// OPENCODE_CONFIG_CONTENT was loaded last and overrode user project configs,
|
|
390
396
|
// causing issue #90 (project permissions not being respected).
|
|
397
|
+
const isDev = import.meta.url.endsWith('.ts') || import.meta.url.endsWith('.tsx');
|
|
398
|
+
// Skill whitelist/blacklist from --enable-skill / --disable-skill CLI flags.
|
|
399
|
+
// Applied as opencode permission.skill rules so every agent inherits the
|
|
400
|
+
// filter via Permission.merge(defaults, agentRules, user).
|
|
401
|
+
const skillPermission = computeSkillPermission({
|
|
402
|
+
enabledSkills: store.getState().enabledSkills,
|
|
403
|
+
disabledSkills: store.getState().disabledSkills,
|
|
404
|
+
});
|
|
391
405
|
const opencodeConfig = {
|
|
392
406
|
$schema: 'https://opencode.ai/config.json',
|
|
393
407
|
lsp: false,
|
|
394
408
|
formatter: false,
|
|
395
|
-
plugin: [
|
|
409
|
+
plugin: [
|
|
410
|
+
new URL(isDev ? './kimaki-opencode-plugin.ts' : './kimaki-opencode-plugin.js', import.meta.url).href,
|
|
411
|
+
],
|
|
396
412
|
permission: {
|
|
397
413
|
edit: 'allow',
|
|
398
414
|
bash: 'allow',
|
|
399
415
|
external_directory: externalDirectoryPermissions,
|
|
400
416
|
webfetch: 'allow',
|
|
417
|
+
...(skillPermission && { skill: skillPermission }),
|
|
401
418
|
},
|
|
402
419
|
agent: {
|
|
403
420
|
explore: {
|
|
@@ -668,6 +685,12 @@ export function buildSessionPermissions({ directory, originalRepoDirectory, }) {
|
|
|
668
685
|
.join(os.homedir(), '.config', 'opencode')
|
|
669
686
|
.replaceAll('\\', '/');
|
|
670
687
|
rules.push({ permission: 'external_directory', pattern: opencodeConfigDir, action: 'allow' }, { permission: 'external_directory', pattern: `${opencodeConfigDir}/*`, action: 'allow' });
|
|
688
|
+
// Allow ~/.opensrc so agents can inspect cached opensrc checkouts without
|
|
689
|
+
// permission prompts.
|
|
690
|
+
const opensrcDir = path
|
|
691
|
+
.join(os.homedir(), '.opensrc')
|
|
692
|
+
.replaceAll('\\', '/');
|
|
693
|
+
rules.push({ permission: 'external_directory', pattern: opensrcDir, action: 'allow' }, { permission: 'external_directory', pattern: `${opensrcDir}/*`, action: 'allow' });
|
|
671
694
|
// Allow ~/.kimaki so the agent can access kimaki data dir (logs, db, etc.)
|
|
672
695
|
// without permission prompts.
|
|
673
696
|
const kimakiDataDir = path
|
|
@@ -22,6 +22,22 @@ async function waitForPendingQuestion({ threadId, timeoutMs, }) {
|
|
|
22
22
|
}
|
|
23
23
|
throw new Error('Timed out waiting for pending question context');
|
|
24
24
|
}
|
|
25
|
+
async function expectNoBotMessageContaining({ discord, threadId, text, timeout, }) {
|
|
26
|
+
const start = Date.now();
|
|
27
|
+
while (Date.now() - start < timeout) {
|
|
28
|
+
const messages = await discord.thread(threadId).getMessages();
|
|
29
|
+
const match = messages.find((message) => {
|
|
30
|
+
return (message.author.id === discord.botUserId
|
|
31
|
+
&& message.content.includes(text));
|
|
32
|
+
});
|
|
33
|
+
if (match) {
|
|
34
|
+
throw new Error(`Unexpected bot message containing ${JSON.stringify(text)} while it should still be queued`);
|
|
35
|
+
}
|
|
36
|
+
await new Promise((resolve) => {
|
|
37
|
+
setTimeout(resolve, 20);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
25
41
|
describe('queue drain after question select answer', () => {
|
|
26
42
|
const ctx = setupQueueAdvancedSuite({
|
|
27
43
|
channelId: TEXT_CHANNEL_ID,
|
|
@@ -52,16 +68,10 @@ describe('queue drain after question select answer', () => {
|
|
|
52
68
|
});
|
|
53
69
|
// Get the pending question context hash from the internal map.
|
|
54
70
|
// By this point the question message is visible so the context must exist.
|
|
55
|
-
const pending = (
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
return entry ? { contextHash: entry[0] } : null;
|
|
60
|
-
})();
|
|
61
|
-
expect(pending).toBeTruthy();
|
|
62
|
-
if (!pending) {
|
|
63
|
-
throw new Error('Expected pending question context');
|
|
64
|
-
}
|
|
71
|
+
const pending = await waitForPendingQuestion({
|
|
72
|
+
threadId: thread.id,
|
|
73
|
+
timeoutMs: 8_000,
|
|
74
|
+
});
|
|
65
75
|
const questionMsg = questionMessages.find((m) => {
|
|
66
76
|
return m.content.includes('How to proceed?');
|
|
67
77
|
});
|
|
@@ -117,4 +127,119 @@ describe('queue drain after question select answer', () => {
|
|
|
117
127
|
expect(timeline).toContain('⬥ ok');
|
|
118
128
|
expect(timeline).toContain('*project ⋅ main ⋅');
|
|
119
129
|
}, 20_000);
|
|
130
|
+
test('only the first queued message is handed off after dropdown answer', async () => {
|
|
131
|
+
const marker = 'QUESTION_SELECT_QUEUE_MARKER second-test';
|
|
132
|
+
await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
133
|
+
content: marker,
|
|
134
|
+
});
|
|
135
|
+
const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
136
|
+
timeout: 8_000,
|
|
137
|
+
predicate: (t) => {
|
|
138
|
+
return t.name === marker;
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
const th = ctx.discord.thread(thread.id);
|
|
142
|
+
const questionMessages = await waitForBotMessageContaining({
|
|
143
|
+
discord: ctx.discord,
|
|
144
|
+
threadId: thread.id,
|
|
145
|
+
text: 'How to proceed?',
|
|
146
|
+
timeout: 12_000,
|
|
147
|
+
});
|
|
148
|
+
const pending = await waitForPendingQuestion({
|
|
149
|
+
threadId: thread.id,
|
|
150
|
+
timeoutMs: 8_000,
|
|
151
|
+
});
|
|
152
|
+
const questionMsg = questionMessages.find((message) => {
|
|
153
|
+
return message.content.includes('How to proceed?');
|
|
154
|
+
});
|
|
155
|
+
expect(questionMsg).toBeTruthy();
|
|
156
|
+
if (!questionMsg) {
|
|
157
|
+
throw new Error('Expected question message');
|
|
158
|
+
}
|
|
159
|
+
const firstQueuedPrompt = 'SLOW_ABORT_MARKER run long response';
|
|
160
|
+
const secondQueuedPrompt = 'Reply with exactly: post-question-second';
|
|
161
|
+
const { id: firstQueueInteractionId } = await th.user(TEST_USER_ID)
|
|
162
|
+
.runSlashCommand({
|
|
163
|
+
name: 'queue',
|
|
164
|
+
options: [{ name: 'message', type: 3, value: firstQueuedPrompt }],
|
|
165
|
+
});
|
|
166
|
+
await th.waitForInteractionAck({
|
|
167
|
+
interactionId: firstQueueInteractionId,
|
|
168
|
+
timeout: 8_000,
|
|
169
|
+
});
|
|
170
|
+
const { id: secondQueueInteractionId } = await th.user(TEST_USER_ID)
|
|
171
|
+
.runSlashCommand({
|
|
172
|
+
name: 'queue',
|
|
173
|
+
options: [{ name: 'message', type: 3, value: secondQueuedPrompt }],
|
|
174
|
+
});
|
|
175
|
+
await th.waitForInteractionAck({
|
|
176
|
+
interactionId: secondQueueInteractionId,
|
|
177
|
+
timeout: 8_000,
|
|
178
|
+
});
|
|
179
|
+
const interaction = await th.user(TEST_USER_ID).selectMenu({
|
|
180
|
+
messageId: questionMsg.id,
|
|
181
|
+
customId: `ask_question:${pending.contextHash}:0`,
|
|
182
|
+
values: ['0'],
|
|
183
|
+
});
|
|
184
|
+
await th.waitForInteractionAck({
|
|
185
|
+
interactionId: interaction.id,
|
|
186
|
+
timeout: 8_000,
|
|
187
|
+
});
|
|
188
|
+
await waitForBotMessageContaining({
|
|
189
|
+
discord: ctx.discord,
|
|
190
|
+
threadId: thread.id,
|
|
191
|
+
text: `» **question-select-tester:** ${firstQueuedPrompt}`,
|
|
192
|
+
timeout: 8_000,
|
|
193
|
+
});
|
|
194
|
+
await expectNoBotMessageContaining({
|
|
195
|
+
discord: ctx.discord,
|
|
196
|
+
threadId: thread.id,
|
|
197
|
+
text: `» **question-select-tester:** ${secondQueuedPrompt}`,
|
|
198
|
+
timeout: 200,
|
|
199
|
+
});
|
|
200
|
+
await waitForFooterMessage({
|
|
201
|
+
discord: ctx.discord,
|
|
202
|
+
threadId: thread.id,
|
|
203
|
+
timeout: 8_000,
|
|
204
|
+
afterMessageIncludes: `» **question-select-tester:** ${firstQueuedPrompt}`,
|
|
205
|
+
afterAuthorId: ctx.discord.botUserId,
|
|
206
|
+
});
|
|
207
|
+
await waitForBotMessageContaining({
|
|
208
|
+
discord: ctx.discord,
|
|
209
|
+
threadId: thread.id,
|
|
210
|
+
text: `» **question-select-tester:** ${secondQueuedPrompt}`,
|
|
211
|
+
timeout: 8_000,
|
|
212
|
+
});
|
|
213
|
+
await waitForFooterMessage({
|
|
214
|
+
discord: ctx.discord,
|
|
215
|
+
threadId: thread.id,
|
|
216
|
+
timeout: 8_000,
|
|
217
|
+
afterMessageIncludes: `» **question-select-tester:** ${secondQueuedPrompt}`,
|
|
218
|
+
afterAuthorId: ctx.discord.botUserId,
|
|
219
|
+
});
|
|
220
|
+
const timeline = await th.text({ showInteractions: true });
|
|
221
|
+
expect(timeline).toMatchInlineSnapshot(`
|
|
222
|
+
"--- from: user (question-select-tester)
|
|
223
|
+
QUESTION_SELECT_QUEUE_MARKER second-test
|
|
224
|
+
--- from: assistant (TestBot)
|
|
225
|
+
**Select action**
|
|
226
|
+
How to proceed?
|
|
227
|
+
✓ _Alpha_
|
|
228
|
+
[user interaction]
|
|
229
|
+
Queued message (position 1)
|
|
230
|
+
[user interaction]
|
|
231
|
+
Queued message (position 2)
|
|
232
|
+
[user selects dropdown: 0]
|
|
233
|
+
» **question-select-tester:** SLOW_ABORT_MARKER run long response
|
|
234
|
+
⬥ slow-response-started
|
|
235
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
236
|
+
» **question-select-tester:** Reply with exactly: post-question-second
|
|
237
|
+
⬥ ok
|
|
238
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
239
|
+
`);
|
|
240
|
+
expect(timeline).toContain(`» **question-select-tester:** ${firstQueuedPrompt}`);
|
|
241
|
+
expect(timeline).toContain('⬥ slow-response-started');
|
|
242
|
+
expect(timeline).toContain(`» **question-select-tester:** ${secondQueuedPrompt}`);
|
|
243
|
+
expect(timeline).toContain('⬥ ok');
|
|
244
|
+
}, 20_000);
|
|
120
245
|
});
|
|
@@ -95,6 +95,33 @@ export function dequeueItem(threadId) {
|
|
|
95
95
|
export function clearQueueItems(threadId) {
|
|
96
96
|
updateThread(threadId, (t) => ({ ...t, queueItems: [] }));
|
|
97
97
|
}
|
|
98
|
+
export function removeQueueItemAtPosition(threadId, position) {
|
|
99
|
+
if (position < 1) {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
let removedItem;
|
|
103
|
+
store.setState((s) => {
|
|
104
|
+
const t = s.threads.get(threadId);
|
|
105
|
+
if (!t) {
|
|
106
|
+
return s;
|
|
107
|
+
}
|
|
108
|
+
const index = position - 1;
|
|
109
|
+
const removed = t.queueItems[index];
|
|
110
|
+
if (!removed) {
|
|
111
|
+
return s;
|
|
112
|
+
}
|
|
113
|
+
removedItem = removed;
|
|
114
|
+
const newThreads = new Map(s.threads);
|
|
115
|
+
newThreads.set(threadId, {
|
|
116
|
+
...t,
|
|
117
|
+
queueItems: t.queueItems.filter((_, itemIndex) => {
|
|
118
|
+
return itemIndex !== index;
|
|
119
|
+
}),
|
|
120
|
+
});
|
|
121
|
+
return { threads: newThreads };
|
|
122
|
+
});
|
|
123
|
+
return removedItem;
|
|
124
|
+
}
|
|
98
125
|
// ── Queries ──────────────────────────────────────────────────────
|
|
99
126
|
export function getThreadState(threadId) {
|
|
100
127
|
return store.getState().threads.get(threadId);
|
|
@@ -238,11 +238,13 @@ export function isEssentialToolPart(part) {
|
|
|
238
238
|
// ── Thread title derivation ──────────────────────────────────────
|
|
239
239
|
const DISCORD_THREAD_NAME_MAX = 100;
|
|
240
240
|
const WORKTREE_THREAD_PREFIX = '⬦ ';
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
241
|
+
// Prefixes that should survive OpenCode session title renames.
|
|
242
|
+
// When a thread starts with one of these, the rename preserves it.
|
|
243
|
+
const PRESERVED_THREAD_PREFIXES = [
|
|
244
|
+
WORKTREE_THREAD_PREFIX,
|
|
245
|
+
'btw: ',
|
|
246
|
+
'Fork: ',
|
|
247
|
+
];
|
|
246
248
|
export function deriveThreadNameFromSessionTitle({ sessionTitle, currentName, }) {
|
|
247
249
|
const trimmed = sessionTitle?.trim();
|
|
248
250
|
if (!trimmed) {
|
|
@@ -251,9 +253,10 @@ export function deriveThreadNameFromSessionTitle({ sessionTitle, currentName, })
|
|
|
251
253
|
if (/^new session\s*-/i.test(trimmed)) {
|
|
252
254
|
return undefined;
|
|
253
255
|
}
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
256
|
+
const matchedPrefix = PRESERVED_THREAD_PREFIXES.find((p) => {
|
|
257
|
+
return currentName.startsWith(p);
|
|
258
|
+
}) ?? '';
|
|
259
|
+
const candidate = `${matchedPrefix}${trimmed}`.slice(0, DISCORD_THREAD_NAME_MAX);
|
|
257
260
|
if (candidate === currentName) {
|
|
258
261
|
return undefined;
|
|
259
262
|
}
|
|
@@ -1493,6 +1496,7 @@ export class ThreadSessionRuntime {
|
|
|
1493
1496
|
await this.handleMainPart(part);
|
|
1494
1497
|
}
|
|
1495
1498
|
async handleMainPart(part) {
|
|
1499
|
+
const sessionId = this.state?.sessionId;
|
|
1496
1500
|
if (part.type === 'step-start') {
|
|
1497
1501
|
this.ensureTypingNow();
|
|
1498
1502
|
return;
|
|
@@ -1506,13 +1510,36 @@ export class ThreadSessionRuntime {
|
|
|
1506
1510
|
await this.sendPartMessage({ part });
|
|
1507
1511
|
// Track task tool spawning subtask sessions
|
|
1508
1512
|
if (part.tool === 'task' && !this.state?.sentPartIds.has(part.id)) {
|
|
1509
|
-
const description = part.state.input?.description
|
|
1510
|
-
|
|
1511
|
-
|
|
1513
|
+
const description = typeof part.state.input?.description === 'string'
|
|
1514
|
+
? part.state.input.description
|
|
1515
|
+
: '';
|
|
1516
|
+
const agent = typeof part.state.input?.subagent_type === 'string'
|
|
1517
|
+
? part.state.input.subagent_type
|
|
1518
|
+
: 'task';
|
|
1519
|
+
const childSessionId = typeof part.state.metadata?.sessionId === 'string'
|
|
1520
|
+
? part.state.metadata.sessionId
|
|
1521
|
+
: '';
|
|
1512
1522
|
if (description && childSessionId) {
|
|
1513
1523
|
if ((await this.getVerbosity()) !== 'text_only') {
|
|
1514
1524
|
const taskDisplay = `┣ ${agent} **${description}**`;
|
|
1515
|
-
|
|
1525
|
+
threadState.updateThread(this.threadId, (t) => {
|
|
1526
|
+
const newIds = new Set(t.sentPartIds);
|
|
1527
|
+
newIds.add(part.id);
|
|
1528
|
+
return { ...t, sentPartIds: newIds };
|
|
1529
|
+
});
|
|
1530
|
+
const sendResult = await errore.tryAsync(() => {
|
|
1531
|
+
return sendThreadMessage(this.thread, taskDisplay + '\n\n');
|
|
1532
|
+
});
|
|
1533
|
+
if (sendResult instanceof Error) {
|
|
1534
|
+
threadState.updateThread(this.threadId, (t) => {
|
|
1535
|
+
const newIds = new Set(t.sentPartIds);
|
|
1536
|
+
newIds.delete(part.id);
|
|
1537
|
+
return { ...t, sentPartIds: newIds };
|
|
1538
|
+
});
|
|
1539
|
+
discordLogger.error(`ERROR: Failed to send task part ${part.id}:`, sendResult);
|
|
1540
|
+
return;
|
|
1541
|
+
}
|
|
1542
|
+
await setPartMessage(part.id, sendResult.id, this.thread.id);
|
|
1516
1543
|
}
|
|
1517
1544
|
}
|
|
1518
1545
|
}
|
|
@@ -1899,8 +1926,9 @@ export class ThreadSessionRuntime {
|
|
|
1899
1926
|
this.onInteractiveUiStateChanged();
|
|
1900
1927
|
// When a question is answered and the local queue has items, the model may
|
|
1901
1928
|
// continue the same run without ever reaching the local-queue idle gate.
|
|
1902
|
-
// Hand the queued
|
|
1903
|
-
//
|
|
1929
|
+
// Hand off only the next queued item to OpenCode immediately so the queue
|
|
1930
|
+
// resumes, but keep later items local so their `» user:` indicators still
|
|
1931
|
+
// appear one-by-one when they actually become active.
|
|
1904
1932
|
if (this.getQueueLength() > 0 && !this.questionReplyQueueHandoffPromise) {
|
|
1905
1933
|
logger.log(`[QUESTION REPLIED] Queue has ${this.getQueueLength()} items, handing off to opencode queue`);
|
|
1906
1934
|
this.questionReplyQueueHandoffPromise = this.handoffQueuedItemsAfterQuestionReply({
|
|
@@ -1916,8 +1944,8 @@ export class ThreadSessionRuntime {
|
|
|
1916
1944
|
}
|
|
1917
1945
|
}
|
|
1918
1946
|
// Detached helper promise for the "question answered while local queue has
|
|
1919
|
-
// items" flow. Prevents starting two overlapping
|
|
1920
|
-
//
|
|
1947
|
+
// items" flow. Prevents starting two overlapping single-item handoffs when
|
|
1948
|
+
// multiple question replies land close together.
|
|
1921
1949
|
questionReplyQueueHandoffPromise = null;
|
|
1922
1950
|
async handoffQueuedItemsAfterQuestionReply({ sessionId, }) {
|
|
1923
1951
|
if (this.listenerAborted) {
|
|
@@ -1927,19 +1955,17 @@ export class ThreadSessionRuntime {
|
|
|
1927
1955
|
logger.log(`[QUESTION REPLIED] Session changed before queue handoff for thread ${this.threadId}`);
|
|
1928
1956
|
return;
|
|
1929
1957
|
}
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
return;
|
|
1934
|
-
}
|
|
1935
|
-
const displayText = next.command
|
|
1936
|
-
? `/${next.command.name}`
|
|
1937
|
-
: `${next.prompt.slice(0, 150)}${next.prompt.length > 150 ? '...' : ''}`;
|
|
1938
|
-
if (displayText.trim()) {
|
|
1939
|
-
await sendThreadMessage(this.thread, `» **${next.username}:** ${displayText}`);
|
|
1940
|
-
}
|
|
1941
|
-
await this.submitViaOpencodeQueue(next);
|
|
1958
|
+
const next = threadState.dequeueItem(this.threadId);
|
|
1959
|
+
if (!next) {
|
|
1960
|
+
return;
|
|
1942
1961
|
}
|
|
1962
|
+
const displayText = next.command
|
|
1963
|
+
? `/${next.command.name}`
|
|
1964
|
+
: `${next.prompt.slice(0, 150)}${next.prompt.length > 150 ? '...' : ''}`;
|
|
1965
|
+
if (displayText.trim()) {
|
|
1966
|
+
await sendThreadMessage(this.thread, `» **${next.username}:** ${displayText}`);
|
|
1967
|
+
}
|
|
1968
|
+
await this.submitViaOpencodeQueue(next);
|
|
1943
1969
|
}
|
|
1944
1970
|
async handleSessionStatus(properties) {
|
|
1945
1971
|
const sessionId = this.state?.sessionId;
|
|
@@ -2556,6 +2582,10 @@ export class ThreadSessionRuntime {
|
|
|
2556
2582
|
clearQueue() {
|
|
2557
2583
|
threadState.clearQueueItems(this.threadId);
|
|
2558
2584
|
}
|
|
2585
|
+
/** Remove a queued message by its 1-based position. */
|
|
2586
|
+
removeQueuePosition(position) {
|
|
2587
|
+
return threadState.removeQueueItemAtPosition(this.threadId, position);
|
|
2588
|
+
}
|
|
2559
2589
|
// ── Queue Drain ─────────────────────────────────────────────
|
|
2560
2590
|
/**
|
|
2561
2591
|
* Check if we can dispatch the next queued message. If so, dequeue and
|
|
@@ -67,6 +67,18 @@ describe('deriveThreadNameFromSessionTitle', () => {
|
|
|
67
67
|
currentName: 'seed',
|
|
68
68
|
})).toMatchInlineSnapshot(`undefined`);
|
|
69
69
|
});
|
|
70
|
+
test('preserves btw: prefix from current name', () => {
|
|
71
|
+
expect(deriveThreadNameFromSessionTitle({
|
|
72
|
+
sessionTitle: 'Side question about auth',
|
|
73
|
+
currentName: 'btw: why is auth broken',
|
|
74
|
+
})).toMatchInlineSnapshot(`"btw: Side question about auth"`);
|
|
75
|
+
});
|
|
76
|
+
test('preserves Fork: prefix from current name', () => {
|
|
77
|
+
expect(deriveThreadNameFromSessionTitle({
|
|
78
|
+
sessionTitle: 'Forked task title',
|
|
79
|
+
currentName: 'Fork: old session title',
|
|
80
|
+
})).toMatchInlineSnapshot(`"Fork: Forked task title"`);
|
|
81
|
+
});
|
|
70
82
|
test('returns undefined for null/undefined title', () => {
|
|
71
83
|
expect(deriveThreadNameFromSessionTitle({
|
|
72
84
|
sessionTitle: null,
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Computes opencode permission.skill rules from kimaki's --enable-skill /
|
|
2
|
+
// --disable-skill CLI flags.
|
|
3
|
+
//
|
|
4
|
+
// OpenCode filters skills available to the model via
|
|
5
|
+
// Permission.evaluate("skill", skill.name, agent.permission). We inject a
|
|
6
|
+
// top-level permission.skill ruleset into the generated opencode-config.json
|
|
7
|
+
// so every agent inherits the same whitelist/blacklist via Permission.merge.
|
|
8
|
+
//
|
|
9
|
+
// Whitelist mode: { '*': 'deny', 'name': 'allow', ... }
|
|
10
|
+
// Blacklist mode: { 'name': 'deny', ... }
|
|
11
|
+
// Neither set: undefined (skills are unfiltered)
|
|
12
|
+
//
|
|
13
|
+
// cli.ts validates mutual exclusion of the two flags at startup, so this
|
|
14
|
+
// helper assumes at most one of the two arrays is non-empty.
|
|
15
|
+
export function computeSkillPermission({ enabledSkills, disabledSkills, }) {
|
|
16
|
+
if (enabledSkills.length > 0) {
|
|
17
|
+
const rules = { '*': 'deny' };
|
|
18
|
+
for (const name of enabledSkills) {
|
|
19
|
+
rules[name] = 'allow';
|
|
20
|
+
}
|
|
21
|
+
return rules;
|
|
22
|
+
}
|
|
23
|
+
if (disabledSkills.length > 0) {
|
|
24
|
+
const rules = {};
|
|
25
|
+
for (const name of disabledSkills) {
|
|
26
|
+
rules[name] = 'deny';
|
|
27
|
+
}
|
|
28
|
+
return rules;
|
|
29
|
+
}
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, test, expect } from 'vitest';
|
|
2
|
+
import { computeSkillPermission } from './skill-filter.js';
|
|
3
|
+
describe('computeSkillPermission', () => {
|
|
4
|
+
test('empty inputs returns undefined (no filtering)', () => {
|
|
5
|
+
expect(computeSkillPermission({ enabledSkills: [], disabledSkills: [] })).toMatchInlineSnapshot(`undefined`);
|
|
6
|
+
});
|
|
7
|
+
test('whitelist single skill', () => {
|
|
8
|
+
expect(computeSkillPermission({
|
|
9
|
+
enabledSkills: ['npm-package'],
|
|
10
|
+
disabledSkills: [],
|
|
11
|
+
})).toMatchInlineSnapshot(`
|
|
12
|
+
{
|
|
13
|
+
"*": "deny",
|
|
14
|
+
"npm-package": "allow",
|
|
15
|
+
}
|
|
16
|
+
`);
|
|
17
|
+
});
|
|
18
|
+
test('whitelist multiple skills', () => {
|
|
19
|
+
expect(computeSkillPermission({
|
|
20
|
+
enabledSkills: ['npm-package', 'playwriter', 'errore'],
|
|
21
|
+
disabledSkills: [],
|
|
22
|
+
})).toMatchInlineSnapshot(`
|
|
23
|
+
{
|
|
24
|
+
"*": "deny",
|
|
25
|
+
"errore": "allow",
|
|
26
|
+
"npm-package": "allow",
|
|
27
|
+
"playwriter": "allow",
|
|
28
|
+
}
|
|
29
|
+
`);
|
|
30
|
+
});
|
|
31
|
+
test('blacklist single skill', () => {
|
|
32
|
+
expect(computeSkillPermission({
|
|
33
|
+
enabledSkills: [],
|
|
34
|
+
disabledSkills: ['jitter'],
|
|
35
|
+
})).toMatchInlineSnapshot(`
|
|
36
|
+
{
|
|
37
|
+
"jitter": "deny",
|
|
38
|
+
}
|
|
39
|
+
`);
|
|
40
|
+
});
|
|
41
|
+
test('blacklist multiple skills', () => {
|
|
42
|
+
expect(computeSkillPermission({
|
|
43
|
+
enabledSkills: [],
|
|
44
|
+
disabledSkills: ['jitter', 'termcast'],
|
|
45
|
+
})).toMatchInlineSnapshot(`
|
|
46
|
+
{
|
|
47
|
+
"jitter": "deny",
|
|
48
|
+
"termcast": "deny",
|
|
49
|
+
}
|
|
50
|
+
`);
|
|
51
|
+
});
|
|
52
|
+
test('whitelist takes precedence when both are set (cli.ts is expected to reject this upstream)', () => {
|
|
53
|
+
// cli.ts validates mutual exclusion before reaching this helper. This
|
|
54
|
+
// test documents the defensive behavior if both arrays ever leak through.
|
|
55
|
+
expect(computeSkillPermission({
|
|
56
|
+
enabledSkills: ['npm-package'],
|
|
57
|
+
disabledSkills: ['jitter'],
|
|
58
|
+
})).toMatchInlineSnapshot(`
|
|
59
|
+
{
|
|
60
|
+
"*": "deny",
|
|
61
|
+
"npm-package": "allow",
|
|
62
|
+
}
|
|
63
|
+
`);
|
|
64
|
+
});
|
|
65
|
+
});
|
package/dist/store.js
CHANGED
|
@@ -9,6 +9,8 @@ export const store = createStore(() => ({
|
|
|
9
9
|
defaultVerbosity: 'text_and_essential_tools',
|
|
10
10
|
defaultMentionMode: false,
|
|
11
11
|
critiqueEnabled: true,
|
|
12
|
+
enabledSkills: [],
|
|
13
|
+
disabledSkills: [],
|
|
12
14
|
discordBaseUrl: 'https://discord.com',
|
|
13
15
|
gatewayToken: null,
|
|
14
16
|
registeredUserCommands: [],
|
package/dist/system-message.js
CHANGED
|
@@ -236,11 +236,17 @@ ${escapePromptText(repliedMessage.text)}
|
|
|
236
236
|
: []),
|
|
237
237
|
...(worktree && worktreeChanged
|
|
238
238
|
? [
|
|
239
|
-
`<system-reminder>\nThis session is running inside a git worktree.\n-
|
|
239
|
+
`<system-reminder>\nThis session is running inside a git worktree. The working directory (cwd / pwd) has changed. The user expects you to edit files in the new cwd. You MUST operate inside the new worktree from now on.\n- New worktree path (new cwd / pwd, edit files here): ${worktree.worktreeDirectory}\n- Branch: ${worktree.branch}\n- Main repo path (previous folder, DO NOT TOUCH): ${worktree.mainRepoDirectory}\nYou MUST read, write, and edit files only under the new worktree path ${worktree.worktreeDirectory}. You MUST NOT read, write, or edit any files under the main repo path ${worktree.mainRepoDirectory} — even though it is the same project, that folder is a separate checkout and the user or another agent may be actively working there, so writing to it would override their unrelated changes. Run all checks (tests, builds, lint) inside the new worktree. Do not create another worktree by default. Ask before merging changes back to the main branch.\n</system-reminder>`,
|
|
240
240
|
]
|
|
241
241
|
: []),
|
|
242
242
|
];
|
|
243
|
-
|
|
243
|
+
if (sections.length === 0) {
|
|
244
|
+
return '';
|
|
245
|
+
}
|
|
246
|
+
// Always end synthetic context with a trailing newline so it does not fuse
|
|
247
|
+
// with the next text part (for example the user's actual prompt) when the
|
|
248
|
+
// model concatenates message parts.
|
|
249
|
+
return `${sections.join('\n\n')}\n`;
|
|
244
250
|
}
|
|
245
251
|
export function getOpencodeSystemMessage({ sessionId, channelId, guildId, threadId, channelTopic, agents, username, }) {
|
|
246
252
|
const userArg = ` --user ${JSON.stringify(username || 'username')}`;
|
|
@@ -266,7 +272,7 @@ This is required to distinguish essential bash calls from read-only ones in low-
|
|
|
266
272
|
|
|
267
273
|
Your current OpenCode session ID is: ${sessionId}${channelId ? `\nYour current Discord channel ID is: ${channelId}` : ''}${threadId ? `\nYour current Discord thread ID is: ${threadId}` : ''}${guildId ? `\nYour current Discord guild ID is: ${guildId}` : ''}
|
|
268
274
|
|
|
269
|
-
Per-turn Discord metadata like the current user and current agent is delivered in synthetic user message parts.
|
|
275
|
+
Per-turn Discord metadata like the current user and current agent is delivered in synthetic user message parts.
|
|
270
276
|
|
|
271
277
|
## permissions
|
|
272
278
|
|
|
@@ -414,6 +420,8 @@ Notification strategy for scheduled tasks:
|
|
|
414
420
|
- Replace \`@username\` with the relevant user from the current thread context.
|
|
415
421
|
- Without \`--user\`, there is no guaranteed direct user mention path; task output should mention users only when relevant.
|
|
416
422
|
- With \`--user\`, the user is added to the thread and may receive more frequent thread-level notifications.
|
|
423
|
+
- If a scheduled task completes with no actionable result and no user-visible change, prefer archiving the session after the final message so Discord does not keep a no-op thread highlighted.
|
|
424
|
+
- Example no-op cleanup command: \`kimaki session archive --session ${sessionId}\`
|
|
417
425
|
|
|
418
426
|
Manage scheduled tasks with:
|
|
419
427
|
|
|
@@ -429,6 +437,7 @@ Use case patterns:
|
|
|
429
437
|
- Weekly QA: schedule "run full test suite, inspect failures, post summary, and mention @username only when failures require review".
|
|
430
438
|
- Weekly benchmark automation: schedule a benchmark prompt that runs model evals, writes JSON outputs in the repo, commits results, and mentions only for regressions.
|
|
431
439
|
- Recurring maintenance: use cron \`--send-at\` for repetitive tasks like rotating secrets, checking dependency updates, running security audits, or cleaning up stale branches. Example: \`--send-at "0 9 1 * *"\` to run on the 1st of every month.
|
|
440
|
+
- Quiet no-op checks: if a recurring task checks something and finds nothing to report, let it post a brief final summary and then archive the session with \`kimaki session archive --session ${sessionId}\`. Example: a scheduled email triage run that finds no new emails should archive itself so it does not add noise to Discord.
|
|
432
441
|
- Thread reminders: when the user says "remind me about this in 2 hours" (or any duration), use \`--send-at\` with \`--thread\` to resurface the current thread. Compute the future UTC time and send a mention so Discord shows a notification:
|
|
433
442
|
|
|
434
443
|
kimaki send --session ${sessionId} --prompt "Reminder: <@USER_ID> you asked to be reminded about this thread." --send-at "<future_UTC_time>" --notify-only --agent <current_agent>
|
|
@@ -30,7 +30,7 @@ describe('system-message', () => {
|
|
|
30
30
|
Your current Discord thread ID is: thread_123
|
|
31
31
|
Your current Discord guild ID is: guild_123
|
|
32
32
|
|
|
33
|
-
Per-turn Discord metadata like the current user and current agent is delivered in synthetic user message parts.
|
|
33
|
+
Per-turn Discord metadata like the current user and current agent is delivered in synthetic user message parts.
|
|
34
34
|
|
|
35
35
|
## permissions
|
|
36
36
|
|
|
@@ -181,6 +181,8 @@ describe('system-message', () => {
|
|
|
181
181
|
- Replace \`@username\` with the relevant user from the current thread context.
|
|
182
182
|
- Without \`--user\`, there is no guaranteed direct user mention path; task output should mention users only when relevant.
|
|
183
183
|
- With \`--user\`, the user is added to the thread and may receive more frequent thread-level notifications.
|
|
184
|
+
- If a scheduled task completes with no actionable result and no user-visible change, prefer archiving the session after the final message so Discord does not keep a no-op thread highlighted.
|
|
185
|
+
- Example no-op cleanup command: \`kimaki session archive --session ses_123\`
|
|
184
186
|
|
|
185
187
|
Manage scheduled tasks with:
|
|
186
188
|
|
|
@@ -196,6 +198,7 @@ describe('system-message', () => {
|
|
|
196
198
|
- Weekly QA: schedule "run full test suite, inspect failures, post summary, and mention @username only when failures require review".
|
|
197
199
|
- Weekly benchmark automation: schedule a benchmark prompt that runs model evals, writes JSON outputs in the repo, commits results, and mentions only for regressions.
|
|
198
200
|
- Recurring maintenance: use cron \`--send-at\` for repetitive tasks like rotating secrets, checking dependency updates, running security audits, or cleaning up stale branches. Example: \`--send-at "0 9 1 * *"\` to run on the 1st of every month.
|
|
201
|
+
- Quiet no-op checks: if a recurring task checks something and finds nothing to report, let it post a brief final summary and then archive the session with \`kimaki session archive --session ses_123\`. Example: a scheduled email triage run that finds no new emails should archive itself so it does not add noise to Discord.
|
|
199
202
|
- Thread reminders: when the user says "remind me about this in 2 hours" (or any duration), use \`--send-at\` with \`--thread\` to resurface the current thread. Compute the future UTC time and send a mention so Discord shows a notification:
|
|
200
203
|
|
|
201
204
|
kimaki send --session ses_123 --prompt "Reminder: <@USER_ID> you asked to be reminded about this thread." --send-at "<future_UTC_time>" --notify-only --agent <current_agent>
|
|
@@ -605,12 +608,13 @@ describe('system-message', () => {
|
|
|
605
608
|
</system-reminder>
|
|
606
609
|
|
|
607
610
|
<system-reminder>
|
|
608
|
-
This session is running inside a git worktree.
|
|
609
|
-
-
|
|
611
|
+
This session is running inside a git worktree. The working directory (cwd / pwd) has changed. The user expects you to edit files in the new cwd. You MUST operate inside the new worktree from now on.
|
|
612
|
+
- New worktree path (new cwd / pwd, edit files here): /repo/.worktrees/prompt-cache
|
|
610
613
|
- Branch: prompt-cache
|
|
611
|
-
- Main repo: /repo
|
|
612
|
-
Run checks
|
|
613
|
-
</system-reminder>
|
|
614
|
+
- Main repo path (previous folder, DO NOT TOUCH): /repo
|
|
615
|
+
You MUST read, write, and edit files only under the new worktree path /repo/.worktrees/prompt-cache. You MUST NOT read, write, or edit any files under the main repo path /repo — even though it is the same project, that folder is a separate checkout and the user or another agent may be actively working there, so writing to it would override their unrelated changes. Run all checks (tests, builds, lint) inside the new worktree. Do not create another worktree by default. Ask before merging changes back to the main branch.
|
|
616
|
+
</system-reminder>
|
|
617
|
+
"
|
|
614
618
|
`);
|
|
615
619
|
});
|
|
616
620
|
});
|