kimaki 0.4.91 → 0.4.92
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/cli-send-thread.e2e.test.js +76 -1
- package/dist/external-opencode-sync.js +5 -1
- package/dist/opencode-command-detection.js +16 -21
- package/dist/opencode-command-detection.test.js +31 -1
- package/dist/session-handler/thread-session-runtime.js +8 -3
- package/dist/system-message.js +13 -0
- package/dist/system-message.test.js +13 -0
- package/package.json +4 -4
- package/src/cli-send-thread.e2e.test.ts +98 -1
- package/src/external-opencode-sync.ts +5 -1
- package/src/opencode-command-detection.test.ts +40 -1
- package/src/opencode-command-detection.ts +18 -21
- package/src/session-handler/thread-session-runtime.ts +8 -3
- package/src/system-message.test.ts +13 -0
- package/src/system-message.ts +13 -0
|
@@ -80,7 +80,26 @@ function createDeterministicMatchers() {
|
|
|
80
80
|
partDelaysMs: [0, 100, 0, 0, 0],
|
|
81
81
|
},
|
|
82
82
|
};
|
|
83
|
-
|
|
83
|
+
// Catch-all: any user message gets a reply
|
|
84
|
+
const catchAll = {
|
|
85
|
+
id: 'catch-all',
|
|
86
|
+
priority: 0,
|
|
87
|
+
when: { lastMessageRole: 'user' },
|
|
88
|
+
then: {
|
|
89
|
+
parts: [
|
|
90
|
+
{ type: 'stream-start', warnings: [] },
|
|
91
|
+
{ type: 'text-start', id: 'catch' },
|
|
92
|
+
{ type: 'text-delta', id: 'catch', delta: 'caught-by-model' },
|
|
93
|
+
{ type: 'text-end', id: 'catch' },
|
|
94
|
+
{
|
|
95
|
+
type: 'finish',
|
|
96
|
+
finishReason: 'stop',
|
|
97
|
+
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
return [userReplyMatcher, catchAll];
|
|
84
103
|
}
|
|
85
104
|
describe('kimaki send --channel thread creation', () => {
|
|
86
105
|
let directories;
|
|
@@ -195,6 +214,62 @@ describe('kimaki send --channel thread creation', () => {
|
|
|
195
214
|
fs.rmSync(directories.dataDir, { recursive: true, force: true });
|
|
196
215
|
}
|
|
197
216
|
}, 10_000);
|
|
217
|
+
test('kimaki send --prompt "/hello-test-cmd" falls through as text when registeredUserCommands is empty (repro #97)', async () => {
|
|
218
|
+
// Reproduce GitHub #97: when registeredUserCommands is empty (gateway mode
|
|
219
|
+
// startup race, or backgroundInit not complete), the prompt "/hello-test-cmd"
|
|
220
|
+
// is NOT detected as a command and is sent to the model as plain text.
|
|
221
|
+
const prevCommands = store.getState().registeredUserCommands;
|
|
222
|
+
// Ensure store is empty — this is the bug condition
|
|
223
|
+
store.setState({ registeredUserCommands: [] });
|
|
224
|
+
try {
|
|
225
|
+
const prompt = '/hello-test-cmd';
|
|
226
|
+
const embedMarker = {
|
|
227
|
+
start: true,
|
|
228
|
+
username: 'cli-send-tester',
|
|
229
|
+
userId: TEST_USER_ID,
|
|
230
|
+
};
|
|
231
|
+
const starterMessage = (await botClient.rest.post(Routes.channelMessages(TEXT_CHANNEL_ID), {
|
|
232
|
+
body: {
|
|
233
|
+
content: prompt,
|
|
234
|
+
embeds: [
|
|
235
|
+
{ color: 0x2b2d31, footer: { text: YAML.stringify(embedMarker) } },
|
|
236
|
+
],
|
|
237
|
+
},
|
|
238
|
+
}));
|
|
239
|
+
await new Promise((resolve) => {
|
|
240
|
+
setTimeout(resolve, 200);
|
|
241
|
+
});
|
|
242
|
+
const threadData = (await botClient.rest.post(Routes.threads(TEXT_CHANNEL_ID, starterMessage.id), {
|
|
243
|
+
body: { name: 'cmd-detection-test', auto_archive_duration: 1440 },
|
|
244
|
+
}));
|
|
245
|
+
await botClient.rest.put(Routes.threadMembers(threadData.id, TEST_USER_ID));
|
|
246
|
+
// Wait for any bot reply AFTER the starter message
|
|
247
|
+
await waitForBotMessageContaining({
|
|
248
|
+
discord,
|
|
249
|
+
threadId: threadData.id,
|
|
250
|
+
userId: discord.botUserId,
|
|
251
|
+
text: '',
|
|
252
|
+
afterMessageId: starterMessage.id,
|
|
253
|
+
timeout: 4_000,
|
|
254
|
+
});
|
|
255
|
+
const messages = await discord.thread(threadData.id).getMessages();
|
|
256
|
+
const botReplies = messages.filter((m) => {
|
|
257
|
+
return m.author.id === discord.botUserId && m.id !== starterMessage.id;
|
|
258
|
+
});
|
|
259
|
+
const allContent = botReplies.map((m) => {
|
|
260
|
+
return m.content.slice(0, 200);
|
|
261
|
+
});
|
|
262
|
+
expect(allContent).toMatchInlineSnapshot(`
|
|
263
|
+
[
|
|
264
|
+
"✗ opencode session error: Command not found: "hello-test". Available commands: init, review, goke, security-review, jitter, proxyman, gitchamber, event-sourcing-state, usecomputer, spiceflow, batch, x",
|
|
265
|
+
"✗ OpenCode API error: Command not found: "hello-test". Available commands: init, review, goke, security-review, jitter, proxyman, gitchamber, event-sourcing-state, usecomputer, spiceflow, batch, x-art",
|
|
266
|
+
]
|
|
267
|
+
`);
|
|
268
|
+
}
|
|
269
|
+
finally {
|
|
270
|
+
store.setState({ registeredUserCommands: prevCommands });
|
|
271
|
+
}
|
|
272
|
+
}, 15_000);
|
|
198
273
|
test('bot-posted starter message with start marker creates thread without DiscordAPIError[160004]', async () => {
|
|
199
274
|
// Simulate what `kimaki send --channel` does:
|
|
200
275
|
// 1. Bot posts a starter message with `start: true` embed marker
|
|
@@ -414,7 +414,11 @@ async function pollExternalSessions({ discordClient, }) {
|
|
|
414
414
|
}).catch(() => { });
|
|
415
415
|
}
|
|
416
416
|
const sessions = (sessionsResponse.data || []).filter((session) => {
|
|
417
|
-
|
|
417
|
+
const title = session.title || '';
|
|
418
|
+
if (/^new session\s*-/i.test(title)) {
|
|
419
|
+
return false;
|
|
420
|
+
}
|
|
421
|
+
return !/subagent\)\s*$/i.test(title);
|
|
418
422
|
});
|
|
419
423
|
const sorted = sortSessionsByRecency(sessions);
|
|
420
424
|
for (const session of sorted) {
|
|
@@ -20,32 +20,32 @@ function stripDiscordSuffix(token) {
|
|
|
20
20
|
}
|
|
21
21
|
return token;
|
|
22
22
|
}
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
// Resolve a /token against registeredUserCommands. When the list is empty
|
|
24
|
+
// (gateway startup race), falls back to suffix-stripping so tokens like
|
|
25
|
+
// /build-cmd still route to session.command('build'). Tokens without a
|
|
26
|
+
// recognizable suffix return undefined to avoid false positives.
|
|
27
|
+
function resolveCommandName({ token, registered, }) {
|
|
25
28
|
const exact = registered.find((c) => {
|
|
26
29
|
return c.name === token || c.discordCommandName === token;
|
|
27
30
|
});
|
|
28
31
|
if (exact)
|
|
29
|
-
return exact;
|
|
30
|
-
// Fall back to matching after stripping -cmd / -skill / -mcp-prompt from
|
|
31
|
-
// the user's token. This lets `/build-cmd` resolve to an opencode command
|
|
32
|
-
// whose base name is `build`.
|
|
32
|
+
return exact.name;
|
|
33
33
|
const base = stripDiscordSuffix(token);
|
|
34
34
|
if (base === token)
|
|
35
35
|
return undefined;
|
|
36
|
-
|
|
36
|
+
const stripped = registered.find((c) => {
|
|
37
37
|
return c.name === base || c.discordCommandName === base;
|
|
38
38
|
});
|
|
39
|
+
if (stripped)
|
|
40
|
+
return stripped.name;
|
|
41
|
+
// Empty registry fallback: suffix was stripped, trust it
|
|
42
|
+
if (registered.length === 0)
|
|
43
|
+
return base;
|
|
44
|
+
return undefined;
|
|
39
45
|
}
|
|
40
46
|
export function extractLeadingOpencodeCommand(prompt, registered = store.getState().registeredUserCommands) {
|
|
41
47
|
if (!prompt)
|
|
42
48
|
return null;
|
|
43
|
-
if (registered.length === 0)
|
|
44
|
-
return null;
|
|
45
|
-
// Scan each line; the first line whose trimmed start is `/<token>` and
|
|
46
|
-
// resolves against registeredUserCommands wins. Args are everything after
|
|
47
|
-
// the command token on that line. Lines before and after are ignored —
|
|
48
|
-
// they're prefix (`» **name:**`) or context noise.
|
|
49
49
|
for (const line of prompt.split('\n')) {
|
|
50
50
|
const trimmed = line.trimStart();
|
|
51
51
|
if (!trimmed.startsWith('/'))
|
|
@@ -56,15 +56,10 @@ export function extractLeadingOpencodeCommand(prompt, registered = store.getStat
|
|
|
56
56
|
const [, token, rest] = match;
|
|
57
57
|
if (!token)
|
|
58
58
|
continue;
|
|
59
|
-
const
|
|
60
|
-
if (!
|
|
59
|
+
const name = resolveCommandName({ token, registered });
|
|
60
|
+
if (!name)
|
|
61
61
|
continue;
|
|
62
|
-
return {
|
|
63
|
-
command: {
|
|
64
|
-
name: resolved.name,
|
|
65
|
-
arguments: (rest ?? '').trim(),
|
|
66
|
-
},
|
|
67
|
-
};
|
|
62
|
+
return { command: { name, arguments: (rest ?? '').trim() } };
|
|
68
63
|
}
|
|
69
64
|
return null;
|
|
70
65
|
}
|
|
@@ -152,9 +152,39 @@ describe('extractLeadingOpencodeCommand', () => {
|
|
|
152
152
|
test('empty string returns null', () => {
|
|
153
153
|
expect(extractLeadingOpencodeCommand('', fixtures)).toMatchInlineSnapshot(`null`);
|
|
154
154
|
});
|
|
155
|
-
test('empty registry returns null
|
|
155
|
+
test('empty registry returns null for tokens without Discord suffix', () => {
|
|
156
156
|
expect(extractLeadingOpencodeCommand('/build foo', [])).toMatchInlineSnapshot(`null`);
|
|
157
157
|
});
|
|
158
|
+
test('empty registry fallback: -cmd suffix strips and returns base name', () => {
|
|
159
|
+
expect(extractLeadingOpencodeCommand('/hello-test-cmd', [])).toMatchInlineSnapshot(`
|
|
160
|
+
{
|
|
161
|
+
"command": {
|
|
162
|
+
"arguments": "",
|
|
163
|
+
"name": "hello-test",
|
|
164
|
+
},
|
|
165
|
+
}
|
|
166
|
+
`);
|
|
167
|
+
});
|
|
168
|
+
test('empty registry fallback: -skill suffix with args', () => {
|
|
169
|
+
expect(extractLeadingOpencodeCommand('/review-skill check auth', [])).toMatchInlineSnapshot(`
|
|
170
|
+
{
|
|
171
|
+
"command": {
|
|
172
|
+
"arguments": "check auth",
|
|
173
|
+
"name": "review",
|
|
174
|
+
},
|
|
175
|
+
}
|
|
176
|
+
`);
|
|
177
|
+
});
|
|
178
|
+
test('empty registry fallback skips non-suffixed, matches suffixed on next line', () => {
|
|
179
|
+
expect(extractLeadingOpencodeCommand('/unknown\n/deploy-cmd now', [])).toMatchInlineSnapshot(`
|
|
180
|
+
{
|
|
181
|
+
"command": {
|
|
182
|
+
"arguments": "now",
|
|
183
|
+
"name": "deploy",
|
|
184
|
+
},
|
|
185
|
+
}
|
|
186
|
+
`);
|
|
187
|
+
});
|
|
158
188
|
test('leading whitespace before slash still matches', () => {
|
|
159
189
|
expect(extractLeadingOpencodeCommand(' /build foo', fixtures)).toMatchInlineSnapshot(`
|
|
160
190
|
{
|
|
@@ -3129,9 +3129,14 @@ export class ThreadSessionRuntime {
|
|
|
3129
3129
|
if (contextResult instanceof Error) {
|
|
3130
3130
|
logger.error('Failed to fetch provider info for context percentage:', contextResult);
|
|
3131
3131
|
}
|
|
3132
|
-
const
|
|
3133
|
-
?
|
|
3134
|
-
|
|
3132
|
+
const truncate = (s, max) => {
|
|
3133
|
+
return s.length > max ? s.slice(0, max - 1) + '\u2026' : s;
|
|
3134
|
+
};
|
|
3135
|
+
const truncatedFolder = truncate(folderName, 15);
|
|
3136
|
+
const truncatedBranch = truncate(branchName, 15);
|
|
3137
|
+
const projectInfo = truncatedBranch
|
|
3138
|
+
? `${truncatedFolder} ⋅ ${truncatedBranch} ⋅ `
|
|
3139
|
+
: `${truncatedFolder} ⋅ `;
|
|
3135
3140
|
const footerText = `*${projectInfo}${sessionDuration}${contextInfo}${modelInfo}${agentInfo}*`;
|
|
3136
3141
|
this.stopTyping();
|
|
3137
3142
|
// Skip notification if there's a queued message next — the user only
|
package/dist/system-message.js
CHANGED
|
@@ -368,10 +368,23 @@ Use --agent to specify which agent to use for the session:
|
|
|
368
368
|
kimaki send --channel ${channelId} --prompt "Plan the refactor of the auth module" --agent plan${userArg}
|
|
369
369
|
${availableAgentsContext}
|
|
370
370
|
|
|
371
|
+
## running opencode commands via kimaki send
|
|
372
|
+
|
|
373
|
+
You can trigger registered opencode commands (slash commands, skills, MCP prompts) by starting the \`--prompt\` with \`/commandname\`:
|
|
374
|
+
|
|
375
|
+
kimaki send --thread <thread_id> --prompt "/review fix the auth module"
|
|
376
|
+
kimaki send --channel ${channelId} --prompt "/build-cmd update dependencies"${userArg}
|
|
377
|
+
|
|
378
|
+
The command name must match a registered opencode command. If the command is not recognized, the prompt is sent as plain text to the model. This works for both new threads (\`--channel\`) and existing threads (\`--thread\`/\`--session\`).
|
|
379
|
+
|
|
371
380
|
## switching agents in the current session
|
|
372
381
|
|
|
373
382
|
The user can switch the active agent mid-session using the Discord slash command \`/<agentname>-agent\`. For example if you are in plan mode and the user asks you to edit files, tell them to run \`/build-agent\` to switch to the build agent first.
|
|
374
383
|
|
|
384
|
+
You can also switch agents via \`kimaki send\`:
|
|
385
|
+
|
|
386
|
+
kimaki send --thread <thread_id> --prompt "/<agentname>-agent"
|
|
387
|
+
|
|
375
388
|
## scheduled sends and task management
|
|
376
389
|
|
|
377
390
|
Use \`--send-at\` to schedule a one-time or recurring task:
|
|
@@ -135,10 +135,23 @@ describe('system-message', () => {
|
|
|
135
135
|
- \`plan\`: planning only
|
|
136
136
|
- \`build\`: edits files
|
|
137
137
|
|
|
138
|
+
## running opencode commands via kimaki send
|
|
139
|
+
|
|
140
|
+
You can trigger registered opencode commands (slash commands, skills, MCP prompts) by starting the \`--prompt\` with \`/commandname\`:
|
|
141
|
+
|
|
142
|
+
kimaki send --thread <thread_id> --prompt "/review fix the auth module"
|
|
143
|
+
kimaki send --channel chan_123 --prompt "/build-cmd update dependencies" --user "Tommy"
|
|
144
|
+
|
|
145
|
+
The command name must match a registered opencode command. If the command is not recognized, the prompt is sent as plain text to the model. This works for both new threads (\`--channel\`) and existing threads (\`--thread\`/\`--session\`).
|
|
146
|
+
|
|
138
147
|
## switching agents in the current session
|
|
139
148
|
|
|
140
149
|
The user can switch the active agent mid-session using the Discord slash command \`/<agentname>-agent\`. For example if you are in plan mode and the user asks you to edit files, tell them to run \`/build-agent\` to switch to the build agent first.
|
|
141
150
|
|
|
151
|
+
You can also switch agents via \`kimaki send\`:
|
|
152
|
+
|
|
153
|
+
kimaki send --thread <thread_id> --prompt "/<agentname>-agent"
|
|
154
|
+
|
|
142
155
|
## scheduled sends and task management
|
|
143
156
|
|
|
144
157
|
Use \`--send-at\` to schedule a one-time or recurring task:
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "kimaki",
|
|
3
3
|
"module": "index.ts",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.4.
|
|
5
|
+
"version": "0.4.92",
|
|
6
6
|
"repository": "https://github.com/remorses/kimaki",
|
|
7
7
|
"bin": "bin.js",
|
|
8
8
|
"files": [
|
|
@@ -26,8 +26,8 @@
|
|
|
26
26
|
"tsx": "^4.20.5",
|
|
27
27
|
"undici": "^8.0.2",
|
|
28
28
|
"discord-digital-twin": "^0.1.0",
|
|
29
|
-
"opencode-cached-provider": "^0.0.1",
|
|
30
29
|
"opencode-deterministic-provider": "^0.0.1",
|
|
30
|
+
"opencode-cached-provider": "^0.0.1",
|
|
31
31
|
"db": "^0.0.0"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
@@ -65,8 +65,8 @@
|
|
|
65
65
|
"zustand": "^5.0.11",
|
|
66
66
|
"errore": "^0.14.1",
|
|
67
67
|
"libsqlproxy": "^0.1.0",
|
|
68
|
-
"
|
|
69
|
-
"
|
|
68
|
+
"traforo": "^0.2.4",
|
|
69
|
+
"opencode-injection-guard": "^0.2.1"
|
|
70
70
|
},
|
|
71
71
|
"optionalDependencies": {
|
|
72
72
|
"@snazzah/davey": "^0.1.10",
|
|
@@ -112,7 +112,27 @@ function createDeterministicMatchers(): DeterministicMatcher[] {
|
|
|
112
112
|
},
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
|
|
115
|
+
// Catch-all: any user message gets a reply
|
|
116
|
+
const catchAll: DeterministicMatcher = {
|
|
117
|
+
id: 'catch-all',
|
|
118
|
+
priority: 0,
|
|
119
|
+
when: { lastMessageRole: 'user' },
|
|
120
|
+
then: {
|
|
121
|
+
parts: [
|
|
122
|
+
{ type: 'stream-start', warnings: [] },
|
|
123
|
+
{ type: 'text-start', id: 'catch' },
|
|
124
|
+
{ type: 'text-delta', id: 'catch', delta: 'caught-by-model' },
|
|
125
|
+
{ type: 'text-end', id: 'catch' },
|
|
126
|
+
{
|
|
127
|
+
type: 'finish',
|
|
128
|
+
finishReason: 'stop',
|
|
129
|
+
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return [userReplyMatcher, catchAll]
|
|
116
136
|
}
|
|
117
137
|
|
|
118
138
|
describe('kimaki send --channel thread creation', () => {
|
|
@@ -257,6 +277,83 @@ describe('kimaki send --channel thread creation', () => {
|
|
|
257
277
|
}
|
|
258
278
|
}, 10_000)
|
|
259
279
|
|
|
280
|
+
test(
|
|
281
|
+
'kimaki send --prompt "/hello-test-cmd" falls through as text when registeredUserCommands is empty (repro #97)',
|
|
282
|
+
async () => {
|
|
283
|
+
// Reproduce GitHub #97: when registeredUserCommands is empty (gateway mode
|
|
284
|
+
// startup race, or backgroundInit not complete), the prompt "/hello-test-cmd"
|
|
285
|
+
// is NOT detected as a command and is sent to the model as plain text.
|
|
286
|
+
|
|
287
|
+
const prevCommands = store.getState().registeredUserCommands
|
|
288
|
+
// Ensure store is empty — this is the bug condition
|
|
289
|
+
store.setState({ registeredUserCommands: [] })
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
const prompt = '/hello-test-cmd'
|
|
293
|
+
const embedMarker: ThreadStartMarker = {
|
|
294
|
+
start: true,
|
|
295
|
+
username: 'cli-send-tester',
|
|
296
|
+
userId: TEST_USER_ID,
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const starterMessage = (await botClient.rest.post(
|
|
300
|
+
Routes.channelMessages(TEXT_CHANNEL_ID),
|
|
301
|
+
{
|
|
302
|
+
body: {
|
|
303
|
+
content: prompt,
|
|
304
|
+
embeds: [
|
|
305
|
+
{ color: 0x2b2d31, footer: { text: YAML.stringify(embedMarker) } },
|
|
306
|
+
],
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
)) as { id: string }
|
|
310
|
+
|
|
311
|
+
await new Promise((resolve) => {
|
|
312
|
+
setTimeout(resolve, 200)
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
const threadData = (await botClient.rest.post(
|
|
316
|
+
Routes.threads(TEXT_CHANNEL_ID, starterMessage.id),
|
|
317
|
+
{
|
|
318
|
+
body: { name: 'cmd-detection-test', auto_archive_duration: 1440 },
|
|
319
|
+
},
|
|
320
|
+
)) as { id: string }
|
|
321
|
+
|
|
322
|
+
await botClient.rest.put(
|
|
323
|
+
Routes.threadMembers(threadData.id, TEST_USER_ID),
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
// Wait for any bot reply AFTER the starter message
|
|
327
|
+
await waitForBotMessageContaining({
|
|
328
|
+
discord,
|
|
329
|
+
threadId: threadData.id,
|
|
330
|
+
userId: discord.botUserId,
|
|
331
|
+
text: '',
|
|
332
|
+
afterMessageId: starterMessage.id,
|
|
333
|
+
timeout: 4_000,
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
const messages = await discord.thread(threadData.id).getMessages()
|
|
337
|
+
const botReplies = messages.filter((m) => {
|
|
338
|
+
return m.author.id === discord.botUserId && m.id !== starterMessage.id
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
const allContent = botReplies.map((m) => {
|
|
342
|
+
return m.content.slice(0, 200)
|
|
343
|
+
})
|
|
344
|
+
expect(allContent).toMatchInlineSnapshot(`
|
|
345
|
+
[
|
|
346
|
+
"✗ opencode session error: Command not found: "hello-test". Available commands: init, review, goke, security-review, jitter, proxyman, gitchamber, event-sourcing-state, usecomputer, spiceflow, batch, x",
|
|
347
|
+
"✗ OpenCode API error: Command not found: "hello-test". Available commands: init, review, goke, security-review, jitter, proxyman, gitchamber, event-sourcing-state, usecomputer, spiceflow, batch, x-art",
|
|
348
|
+
]
|
|
349
|
+
`)
|
|
350
|
+
} finally {
|
|
351
|
+
store.setState({ registeredUserCommands: prevCommands })
|
|
352
|
+
}
|
|
353
|
+
},
|
|
354
|
+
15_000,
|
|
355
|
+
)
|
|
356
|
+
|
|
260
357
|
test(
|
|
261
358
|
'bot-posted starter message with start marker creates thread without DiscordAPIError[160004]',
|
|
262
359
|
async () => {
|
|
@@ -600,7 +600,11 @@ async function pollExternalSessions({
|
|
|
600
600
|
}
|
|
601
601
|
|
|
602
602
|
const sessions = (sessionsResponse.data || []).filter((session) => {
|
|
603
|
-
|
|
603
|
+
const title = session.title || ''
|
|
604
|
+
if (/^new session\s*-/i.test(title)) {
|
|
605
|
+
return false
|
|
606
|
+
}
|
|
607
|
+
return !/subagent\)\s*$/i.test(title)
|
|
604
608
|
})
|
|
605
609
|
const sorted = sortSessionsByRecency(sessions)
|
|
606
610
|
|
|
@@ -200,12 +200,51 @@ describe('extractLeadingOpencodeCommand', () => {
|
|
|
200
200
|
)
|
|
201
201
|
})
|
|
202
202
|
|
|
203
|
-
test('empty registry returns null
|
|
203
|
+
test('empty registry returns null for tokens without Discord suffix', () => {
|
|
204
204
|
expect(extractLeadingOpencodeCommand('/build foo', [])).toMatchInlineSnapshot(
|
|
205
205
|
`null`,
|
|
206
206
|
)
|
|
207
207
|
})
|
|
208
208
|
|
|
209
|
+
test('empty registry fallback: -cmd suffix strips and returns base name', () => {
|
|
210
|
+
expect(
|
|
211
|
+
extractLeadingOpencodeCommand('/hello-test-cmd', []),
|
|
212
|
+
).toMatchInlineSnapshot(`
|
|
213
|
+
{
|
|
214
|
+
"command": {
|
|
215
|
+
"arguments": "",
|
|
216
|
+
"name": "hello-test",
|
|
217
|
+
},
|
|
218
|
+
}
|
|
219
|
+
`)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
test('empty registry fallback: -skill suffix with args', () => {
|
|
223
|
+
expect(
|
|
224
|
+
extractLeadingOpencodeCommand('/review-skill check auth', []),
|
|
225
|
+
).toMatchInlineSnapshot(`
|
|
226
|
+
{
|
|
227
|
+
"command": {
|
|
228
|
+
"arguments": "check auth",
|
|
229
|
+
"name": "review",
|
|
230
|
+
},
|
|
231
|
+
}
|
|
232
|
+
`)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
test('empty registry fallback skips non-suffixed, matches suffixed on next line', () => {
|
|
236
|
+
expect(
|
|
237
|
+
extractLeadingOpencodeCommand('/unknown\n/deploy-cmd now', []),
|
|
238
|
+
).toMatchInlineSnapshot(`
|
|
239
|
+
{
|
|
240
|
+
"command": {
|
|
241
|
+
"arguments": "now",
|
|
242
|
+
"name": "deploy",
|
|
243
|
+
},
|
|
244
|
+
}
|
|
245
|
+
`)
|
|
246
|
+
})
|
|
247
|
+
|
|
209
248
|
test('leading whitespace before slash still matches', () => {
|
|
210
249
|
expect(
|
|
211
250
|
extractLeadingOpencodeCommand(' /build foo', fixtures),
|
|
@@ -25,27 +25,34 @@ function stripDiscordSuffix(token: string): string {
|
|
|
25
25
|
return token
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
// Resolve a /token against registeredUserCommands. When the list is empty
|
|
29
|
+
// (gateway startup race), falls back to suffix-stripping so tokens like
|
|
30
|
+
// /build-cmd still route to session.command('build'). Tokens without a
|
|
31
|
+
// recognizable suffix return undefined to avoid false positives.
|
|
32
|
+
function resolveCommandName({
|
|
29
33
|
token,
|
|
30
34
|
registered,
|
|
31
35
|
}: {
|
|
32
36
|
token: string
|
|
33
37
|
registered: RegisteredUserCommand[]
|
|
34
|
-
}):
|
|
35
|
-
// Try exact matches first (original name, then Discord-sanitized name).
|
|
38
|
+
}): string | undefined {
|
|
36
39
|
const exact = registered.find((c) => {
|
|
37
40
|
return c.name === token || c.discordCommandName === token
|
|
38
41
|
})
|
|
39
|
-
if (exact) return exact
|
|
42
|
+
if (exact) return exact.name
|
|
40
43
|
|
|
41
|
-
// Fall back to matching after stripping -cmd / -skill / -mcp-prompt from
|
|
42
|
-
// the user's token. This lets `/build-cmd` resolve to an opencode command
|
|
43
|
-
// whose base name is `build`.
|
|
44
44
|
const base = stripDiscordSuffix(token)
|
|
45
45
|
if (base === token) return undefined
|
|
46
|
-
|
|
46
|
+
|
|
47
|
+
const stripped = registered.find((c) => {
|
|
47
48
|
return c.name === base || c.discordCommandName === base
|
|
48
49
|
})
|
|
50
|
+
if (stripped) return stripped.name
|
|
51
|
+
|
|
52
|
+
// Empty registry fallback: suffix was stripped, trust it
|
|
53
|
+
if (registered.length === 0) return base
|
|
54
|
+
|
|
55
|
+
return undefined
|
|
49
56
|
}
|
|
50
57
|
|
|
51
58
|
export function extractLeadingOpencodeCommand(
|
|
@@ -53,12 +60,7 @@ export function extractLeadingOpencodeCommand(
|
|
|
53
60
|
registered: RegisteredUserCommand[] = store.getState().registeredUserCommands,
|
|
54
61
|
): { command: { name: string; arguments: string } } | null {
|
|
55
62
|
if (!prompt) return null
|
|
56
|
-
if (registered.length === 0) return null
|
|
57
63
|
|
|
58
|
-
// Scan each line; the first line whose trimmed start is `/<token>` and
|
|
59
|
-
// resolves against registeredUserCommands wins. Args are everything after
|
|
60
|
-
// the command token on that line. Lines before and after are ignored —
|
|
61
|
-
// they're prefix (`» **name:**`) or context noise.
|
|
62
64
|
for (const line of prompt.split('\n')) {
|
|
63
65
|
const trimmed = line.trimStart()
|
|
64
66
|
if (!trimmed.startsWith('/')) continue
|
|
@@ -66,14 +68,9 @@ export function extractLeadingOpencodeCommand(
|
|
|
66
68
|
if (!match) continue
|
|
67
69
|
const [, token, rest] = match
|
|
68
70
|
if (!token) continue
|
|
69
|
-
const
|
|
70
|
-
if (!
|
|
71
|
-
return {
|
|
72
|
-
command: {
|
|
73
|
-
name: resolved.name,
|
|
74
|
-
arguments: (rest ?? '').trim(),
|
|
75
|
-
},
|
|
76
|
-
}
|
|
71
|
+
const name = resolveCommandName({ token, registered })
|
|
72
|
+
if (!name) continue
|
|
73
|
+
return { command: { name, arguments: (rest ?? '').trim() } }
|
|
77
74
|
}
|
|
78
75
|
return null
|
|
79
76
|
}
|
|
@@ -4108,9 +4108,14 @@ export class ThreadSessionRuntime {
|
|
|
4108
4108
|
)
|
|
4109
4109
|
}
|
|
4110
4110
|
|
|
4111
|
-
const
|
|
4112
|
-
?
|
|
4113
|
-
|
|
4111
|
+
const truncate = (s: string, max: number) => {
|
|
4112
|
+
return s.length > max ? s.slice(0, max - 1) + '\u2026' : s
|
|
4113
|
+
}
|
|
4114
|
+
const truncatedFolder = truncate(folderName, 15)
|
|
4115
|
+
const truncatedBranch = truncate(branchName, 15)
|
|
4116
|
+
const projectInfo = truncatedBranch
|
|
4117
|
+
? `${truncatedFolder} ⋅ ${truncatedBranch} ⋅ `
|
|
4118
|
+
: `${truncatedFolder} ⋅ `
|
|
4114
4119
|
const footerText = `*${projectInfo}${sessionDuration}${contextInfo}${modelInfo}${agentInfo}*`
|
|
4115
4120
|
this.stopTyping()
|
|
4116
4121
|
|
|
@@ -142,10 +142,23 @@ describe('system-message', () => {
|
|
|
142
142
|
- \`plan\`: planning only
|
|
143
143
|
- \`build\`: edits files
|
|
144
144
|
|
|
145
|
+
## running opencode commands via kimaki send
|
|
146
|
+
|
|
147
|
+
You can trigger registered opencode commands (slash commands, skills, MCP prompts) by starting the \`--prompt\` with \`/commandname\`:
|
|
148
|
+
|
|
149
|
+
kimaki send --thread <thread_id> --prompt "/review fix the auth module"
|
|
150
|
+
kimaki send --channel chan_123 --prompt "/build-cmd update dependencies" --user "Tommy"
|
|
151
|
+
|
|
152
|
+
The command name must match a registered opencode command. If the command is not recognized, the prompt is sent as plain text to the model. This works for both new threads (\`--channel\`) and existing threads (\`--thread\`/\`--session\`).
|
|
153
|
+
|
|
145
154
|
## switching agents in the current session
|
|
146
155
|
|
|
147
156
|
The user can switch the active agent mid-session using the Discord slash command \`/<agentname>-agent\`. For example if you are in plan mode and the user asks you to edit files, tell them to run \`/build-agent\` to switch to the build agent first.
|
|
148
157
|
|
|
158
|
+
You can also switch agents via \`kimaki send\`:
|
|
159
|
+
|
|
160
|
+
kimaki send --thread <thread_id> --prompt "/<agentname>-agent"
|
|
161
|
+
|
|
149
162
|
## scheduled sends and task management
|
|
150
163
|
|
|
151
164
|
Use \`--send-at\` to schedule a one-time or recurring task:
|
package/src/system-message.ts
CHANGED
|
@@ -477,10 +477,23 @@ Use --agent to specify which agent to use for the session:
|
|
|
477
477
|
kimaki send --channel ${channelId} --prompt "Plan the refactor of the auth module" --agent plan${userArg}
|
|
478
478
|
${availableAgentsContext}
|
|
479
479
|
|
|
480
|
+
## running opencode commands via kimaki send
|
|
481
|
+
|
|
482
|
+
You can trigger registered opencode commands (slash commands, skills, MCP prompts) by starting the \`--prompt\` with \`/commandname\`:
|
|
483
|
+
|
|
484
|
+
kimaki send --thread <thread_id> --prompt "/review fix the auth module"
|
|
485
|
+
kimaki send --channel ${channelId} --prompt "/build-cmd update dependencies"${userArg}
|
|
486
|
+
|
|
487
|
+
The command name must match a registered opencode command. If the command is not recognized, the prompt is sent as plain text to the model. This works for both new threads (\`--channel\`) and existing threads (\`--thread\`/\`--session\`).
|
|
488
|
+
|
|
480
489
|
## switching agents in the current session
|
|
481
490
|
|
|
482
491
|
The user can switch the active agent mid-session using the Discord slash command \`/<agentname>-agent\`. For example if you are in plan mode and the user asks you to edit files, tell them to run \`/build-agent\` to switch to the build agent first.
|
|
483
492
|
|
|
493
|
+
You can also switch agents via \`kimaki send\`:
|
|
494
|
+
|
|
495
|
+
kimaki send --thread <thread_id> --prompt "/<agentname>-agent"
|
|
496
|
+
|
|
484
497
|
## scheduled sends and task management
|
|
485
498
|
|
|
486
499
|
Use \`--send-at\` to schedule a one-time or recurring task:
|