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.
@@ -80,7 +80,26 @@ function createDeterministicMatchers() {
80
80
  partDelaysMs: [0, 100, 0, 0, 0],
81
81
  },
82
82
  };
83
- return [userReplyMatcher];
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
- return !/^new session\s*-/i.test(session.title || '');
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
- function findRegisteredCommand({ token, registered, }) {
24
- // Try exact matches first (original name, then Discord-sanitized name).
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
- return registered.find((c) => {
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 resolved = findRegisteredCommand({ token, registered });
60
- if (!resolved)
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 even for known-looking commands', () => {
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 projectInfo = branchName
3133
- ? `${folderName} ${branchName} `
3134
- : `${folderName} ⋅ `;
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
@@ -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.91",
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
- "opencode-injection-guard": "^0.2.1",
69
- "traforo": "^0.2.4"
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
- return [userReplyMatcher]
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
- return !/^new session\s*-/i.test(session.title || '')
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 even for known-looking commands', () => {
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
- function findRegisteredCommand({
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
- }): RegisteredUserCommand | undefined {
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
- return registered.find((c) => {
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 resolved = findRegisteredCommand({ token, registered })
70
- if (!resolved) continue
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 projectInfo = branchName
4112
- ? `${folderName} ${branchName} `
4113
- : `${folderName} ⋅ `
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:
@@ -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: