@otto-assistant/bridge 0.4.92 → 0.4.96

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.
Files changed (36) hide show
  1. package/dist/agent-model.e2e.test.js +2 -1
  2. package/dist/anthropic-auth-plugin.js +30 -9
  3. package/dist/anthropic-auth-plugin.test.js +7 -1
  4. package/dist/anthropic-auth-state.js +17 -1
  5. package/dist/cli.js +2 -3
  6. package/dist/commands/agent.js +2 -2
  7. package/dist/commands/merge-worktree.js +11 -8
  8. package/dist/context-awareness-plugin.js +1 -1
  9. package/dist/context-awareness-plugin.test.js +2 -2
  10. package/dist/discord-utils.js +5 -2
  11. package/dist/kimaki-opencode-plugin.js +1 -0
  12. package/dist/logger.js +8 -2
  13. package/dist/session-handler/thread-session-runtime.js +20 -3
  14. package/dist/system-message.js +13 -0
  15. package/dist/system-message.test.js +13 -0
  16. package/dist/system-prompt-drift-plugin.js +251 -0
  17. package/dist/utils.js +5 -1
  18. package/package.json +2 -1
  19. package/skills/npm-package/SKILL.md +14 -9
  20. package/src/agent-model.e2e.test.ts +2 -1
  21. package/src/anthropic-auth-plugin.test.ts +7 -1
  22. package/src/anthropic-auth-plugin.ts +29 -9
  23. package/src/anthropic-auth-state.ts +28 -2
  24. package/src/cli.ts +2 -2
  25. package/src/commands/agent.ts +2 -2
  26. package/src/commands/merge-worktree.ts +11 -8
  27. package/src/context-awareness-plugin.test.ts +2 -2
  28. package/src/context-awareness-plugin.ts +1 -1
  29. package/src/discord-utils.ts +19 -17
  30. package/src/kimaki-opencode-plugin.ts +1 -0
  31. package/src/logger.ts +9 -2
  32. package/src/session-handler/thread-session-runtime.ts +23 -3
  33. package/src/system-message.test.ts +13 -0
  34. package/src/system-message.ts +13 -0
  35. package/src/system-prompt-drift-plugin.ts +379 -0
  36. package/src/utils.ts +5 -1
@@ -735,7 +735,8 @@ describe('agent model resolution', () => {
735
735
  --- from: assistant (TestBot)
736
736
  ⬥ ok
737
737
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
738
- Switched to **plan** agent for this session next messages (was **test-agent**)
738
+ Switched to **plan** agent for this session (was **test-agent**)
739
+ The agent will change on the next message.
739
740
  --- from: user (agent-model-tester)
740
741
  Reply with exactly: after-switch-msg
741
742
  --- from: assistant (TestBot)
@@ -430,15 +430,25 @@ function buildAuthorizeHandler(mode) {
430
430
  function toClaudeCodeToolName(name) {
431
431
  return OPENCODE_TO_CLAUDE_CODE_TOOL_NAME[name.toLowerCase()] ?? name;
432
432
  }
433
- function sanitizeSystemText(text) {
434
- return text.replaceAll(OPENCODE_IDENTITY, CLAUDE_CODE_IDENTITY);
433
+ function sanitizeSystemText(text, onError) {
434
+ const startIdx = text.indexOf(OPENCODE_IDENTITY);
435
+ if (startIdx === -1)
436
+ return text;
437
+ const codeRefsMarker = '# Code References';
438
+ const endIdx = text.indexOf(codeRefsMarker, startIdx);
439
+ if (endIdx === -1) {
440
+ onError?.(`sanitizeSystemText: could not find '# Code References' after OpenCode identity`);
441
+ return text;
442
+ }
443
+ // Remove everything from the OpenCode identity up to (but not including) '# Code References'
444
+ return text.slice(0, startIdx) + text.slice(endIdx);
435
445
  }
436
- function prependClaudeCodeIdentity(system) {
446
+ function prependClaudeCodeIdentity(system, onError) {
437
447
  const identityBlock = { type: 'text', text: CLAUDE_CODE_IDENTITY };
438
448
  if (typeof system === 'undefined')
439
449
  return [identityBlock];
440
450
  if (typeof system === 'string') {
441
- const sanitized = sanitizeSystemText(system);
451
+ const sanitized = sanitizeSystemText(system, onError);
442
452
  if (sanitized === CLAUDE_CODE_IDENTITY)
443
453
  return [identityBlock];
444
454
  return [identityBlock, { type: 'text', text: sanitized }];
@@ -447,11 +457,11 @@ function prependClaudeCodeIdentity(system) {
447
457
  return [identityBlock, system];
448
458
  const sanitized = system.map((item) => {
449
459
  if (typeof item === 'string')
450
- return { type: 'text', text: sanitizeSystemText(item) };
460
+ return { type: 'text', text: sanitizeSystemText(item, onError) };
451
461
  if (item && typeof item === 'object' && item.type === 'text') {
452
462
  const text = item.text;
453
463
  if (typeof text === 'string') {
454
- return { ...item, text: sanitizeSystemText(text) };
464
+ return { ...item, text: sanitizeSystemText(text, onError) };
455
465
  }
456
466
  }
457
467
  return item;
@@ -465,7 +475,7 @@ function prependClaudeCodeIdentity(system) {
465
475
  }
466
476
  return [identityBlock, ...sanitized];
467
477
  }
468
- function rewriteRequestPayload(body) {
478
+ function rewriteRequestPayload(body, onError) {
469
479
  if (!body)
470
480
  return { body, modelId: undefined, reverseToolNameMap: new Map() };
471
481
  try {
@@ -486,7 +496,7 @@ function rewriteRequestPayload(body) {
486
496
  });
487
497
  }
488
498
  // Rename system prompt
489
- payload.system = prependClaudeCodeIdentity(payload.system);
499
+ payload.system = prependClaudeCodeIdentity(payload.system, onError);
490
500
  // Rename tool_choice
491
501
  if (payload.tool_choice &&
492
502
  typeof payload.tool_choice === 'object' &&
@@ -658,7 +668,11 @@ const AnthropicAuthPlugin = async ({ client }) => {
658
668
  .text()
659
669
  .catch(() => undefined)
660
670
  : undefined;
661
- const rewritten = rewriteRequestPayload(originalBody);
671
+ const rewritten = rewriteRequestPayload(originalBody, (msg) => {
672
+ client.tui.showToast({
673
+ body: { message: msg, variant: 'error' },
674
+ }).catch(() => { });
675
+ });
662
676
  const headers = new Headers(init?.headers);
663
677
  if (input instanceof Request) {
664
678
  input.headers.forEach((v, k) => {
@@ -694,6 +708,13 @@ const AnthropicAuthPlugin = async ({ client }) => {
694
708
  if (shouldRotateAuth(response.status, bodyText)) {
695
709
  const rotated = await rotateAnthropicAccount(freshAuth, client);
696
710
  if (rotated) {
711
+ // Show toast notification so Discord thread shows the rotation
712
+ client.tui.showToast({
713
+ body: {
714
+ message: `Switching from account ${rotated.fromLabel} to account ${rotated.toLabel}`,
715
+ variant: 'info',
716
+ },
717
+ }).catch(() => { });
697
718
  const retryAuth = await getFreshOAuth(getAuth, client);
698
719
  if (retryAuth) {
699
720
  response = await runRequest(retryAuth);
@@ -67,7 +67,13 @@ describe('rotateAnthropicAccount', () => {
67
67
  const rotated = await rotateAnthropicAccount(firstAccount, client);
68
68
  const store = await loadAccountStore();
69
69
  const authJson = JSON.parse(await readFile(authFilePath(), 'utf8'));
70
- expect(rotated).toMatchObject({ refresh: 'refresh-second' });
70
+ expect(rotated).toMatchObject({
71
+ auth: { refresh: 'refresh-second' },
72
+ fromLabel: '#1 (refresh-...irst)',
73
+ toLabel: '#2 (refresh-...cond)',
74
+ fromIndex: 0,
75
+ toIndex: 1,
76
+ });
71
77
  expect(store.activeIndex).toBe(1);
72
78
  expect(authJson.anthropic?.refresh).toBe('refresh-second');
73
79
  expect(authSetCalls).toEqual([
@@ -94,6 +94,12 @@ export async function loadAccountStore() {
94
94
  export async function saveAccountStore(store) {
95
95
  await writeJson(accountsFilePath(), normalizeAccountStore(store));
96
96
  }
97
+ /** Short label for an account: first 8 + last 4 chars of refresh token. */
98
+ export function accountLabel(account, index) {
99
+ const r = account.refresh;
100
+ const short = r.length > 12 ? `${r.slice(0, 8)}...${r.slice(-4)}` : r;
101
+ return index !== undefined ? `#${index + 1} (${short})` : short;
102
+ }
97
103
  function findCurrentAccountIndex(store, auth) {
98
104
  if (!store.accounts.length)
99
105
  return 0;
@@ -165,10 +171,14 @@ export async function rotateAnthropicAccount(auth, client) {
165
171
  if (store.accounts.length < 2)
166
172
  return undefined;
167
173
  const currentIndex = findCurrentAccountIndex(store, auth);
174
+ const currentAccount = store.accounts[currentIndex];
168
175
  const nextIndex = (currentIndex + 1) % store.accounts.length;
169
176
  const nextAccount = store.accounts[nextIndex];
170
177
  if (!nextAccount)
171
178
  return undefined;
179
+ const fromLabel = currentAccount
180
+ ? accountLabel(currentAccount, currentIndex)
181
+ : accountLabel(auth, currentIndex);
172
182
  nextAccount.lastUsed = Date.now();
173
183
  store.activeIndex = nextIndex;
174
184
  await saveAccountStore(store);
@@ -179,7 +189,13 @@ export async function rotateAnthropicAccount(auth, client) {
179
189
  expires: nextAccount.expires,
180
190
  };
181
191
  await setAnthropicAuth(nextAuth, client);
182
- return nextAuth;
192
+ return {
193
+ auth: nextAuth,
194
+ fromLabel,
195
+ toLabel: accountLabel(nextAccount, nextIndex),
196
+ fromIndex: currentIndex,
197
+ toIndex: nextIndex,
198
+ };
183
199
  });
184
200
  }
185
201
  export async function removeAccount(index) {
package/dist/cli.js CHANGED
@@ -32,7 +32,7 @@ import { backgroundUpgradeKimaki, upgrade, getCurrentVersion, } from './upgrade.
32
32
  import { startHranaServer } from './hrana-server.js';
33
33
  import { startIpcPolling, stopIpcPolling } from './ipc-polling.js';
34
34
  import { getPromptPreview, parseSendAtValue, parseScheduledTaskPayload, serializeScheduledTaskPayload, } from './task-schedule.js';
35
- import { accountsFilePath, loadAccountStore, removeAccount, } from './anthropic-auth-state.js';
35
+ import { accountLabel, accountsFilePath, loadAccountStore, removeAccount, } from './anthropic-auth-state.js';
36
36
  const cliLogger = createLogger(LogPrefix.CLI);
37
37
  // Gateway bot mode constants.
38
38
  // KIMAKI_GATEWAY_APP_ID is the Discord Application ID of the gateway bot.
@@ -2234,8 +2234,7 @@ cli
2234
2234
  }
2235
2235
  store.accounts.forEach((account, index) => {
2236
2236
  const active = index === store.activeIndex ? '*' : ' ';
2237
- const label = `${account.refresh.slice(0, 8)}...${account.refresh.slice(-4)}`;
2238
- console.log(`${active} ${index + 1}. ${label}`);
2237
+ console.log(`${active} ${index + 1}. ${accountLabel(account)}`);
2239
2238
  });
2240
2239
  process.exit(0);
2241
2240
  });
@@ -255,7 +255,7 @@ export async function handleAgentSelectMenu(interaction) {
255
255
  await setAgentForContext({ context, agentName: selectedAgent });
256
256
  if (context.isThread && context.sessionId) {
257
257
  await interaction.editReply({
258
- content: `Agent preference set for this session next messages: **${selectedAgent}**`,
258
+ content: `Agent preference set for this session: **${selectedAgent}**\nThe agent will change on the next message.`,
259
259
  components: [],
260
260
  });
261
261
  }
@@ -317,7 +317,7 @@ export async function handleQuickAgentCommand({ command, appId, }) {
317
317
  : '';
318
318
  if (context.isThread && context.sessionId) {
319
319
  await command.editReply({
320
- content: `Switched to **${resolvedAgentName}** agent for this session next messages${previousText}`,
320
+ content: `Switched to **${resolvedAgentName}** agent for this session${previousText}\nThe agent will change on the next message.`,
321
321
  });
322
322
  }
323
323
  else {
@@ -103,15 +103,18 @@ export async function handleMergeWorktreeCommand({ command, appId, }) {
103
103
  await command.editReply('Rebase conflict detected. Asking the model to resolve...');
104
104
  await sendPromptToModel({
105
105
  prompt: [
106
- 'A rebase conflict occurred while merging this worktree into the default branch.',
106
+ `A rebase conflict occurred while merging this worktree into \`${result.target}\`.`,
107
107
  'Rebasing multiple commits can pause on each commit that conflicts, so you may need to repeat the resolve/continue loop several times.',
108
- 'Please resolve the rebase conflicts:',
109
- '1. Check `git status` to see which files have conflicts',
110
- '2. Edit the conflicted files to resolve the merge markers',
111
- '3. Stage resolved files with `git add`',
112
- '4. Continue the rebase with `git rebase --continue`',
113
- '5. If git reports more conflicts, repeat steps 1-4 until the rebase finishes (no more MERGE markers, `git status` shows no rebase in progress)',
114
- '6. Once the rebase is fully complete, tell me so I can run `/merge-worktree` again',
108
+ 'Before editing anything, first understand both sides so you preserve both intentions and do not drop features or fixes.',
109
+ '1. Check `git status` to see which files have conflicts and confirm the rebase is paused',
110
+ `2. Find the merge base between this worktree and \`${result.target}\`, then read the commit messages from both sides since that merge base so you understand the goal of each change`,
111
+ `3. Read the diffs from that merge base to both sides so you understand exactly what changed on this branch and on \`${result.target}\` before resolving conflicts`,
112
+ '4. Read the commit currently being replayed in the rebase so you know the intent of the specific conflicting patch',
113
+ '5. Edit the conflicted files to preserve both intended changes where possible instead of choosing one side wholesale',
114
+ '6. Stage resolved files with `git add`',
115
+ '7. Continue the rebase with `git rebase --continue`',
116
+ '8. If git reports more conflicts, repeat steps 1-7 until the rebase finishes (no more rebase in progress, `git status` is clean)',
117
+ '9. Once the rebase is fully complete, tell me so I can run `/merge-worktree` again',
115
118
  ].join('\n'),
116
119
  thread,
117
120
  projectDirectory: worktreeInfo.project_directory,
@@ -61,7 +61,7 @@ export function shouldInjectPwd({ currentDir, previousDir, announcedDir, }) {
61
61
  inject: true,
62
62
  text: `\n[working directory changed. Previous working directory: ${priorDirectory}. ` +
63
63
  `Current working directory: ${currentDir}. ` +
64
- `You MUST read, write, and edit files only under ${currentDir}. ` +
64
+ `You should read, write, and edit files under ${currentDir}. ` +
65
65
  `Do NOT read, write, or edit files under ${priorDirectory}.]`,
66
66
  };
67
67
  }
@@ -36,7 +36,7 @@ describe('shouldInjectPwd', () => {
36
36
  {
37
37
  "inject": true,
38
38
  "text": "
39
- [working directory changed. Previous working directory: /repo/main. Current working directory: /repo/worktree. You MUST read, write, and edit files only under /repo/worktree. Do NOT read, write, or edit files under /repo/main.]",
39
+ [working directory changed. Previous working directory: /repo/main. Current working directory: /repo/worktree. You should read, write, and edit files under /repo/worktree. Do NOT read, write, or edit files under /repo/main.]",
40
40
  }
41
41
  `);
42
42
  });
@@ -50,7 +50,7 @@ describe('shouldInjectPwd', () => {
50
50
  {
51
51
  "inject": true,
52
52
  "text": "
53
- [working directory changed. Previous working directory: /repo/worktree-a. Current working directory: /repo/worktree-b. You MUST read, write, and edit files only under /repo/worktree-b. Do NOT read, write, or edit files under /repo/worktree-a.]",
53
+ [working directory changed. Previous working directory: /repo/worktree-a. Current working directory: /repo/worktree-b. You should read, write, and edit files under /repo/worktree-b. Do NOT read, write, or edit files under /repo/worktree-a.]",
54
54
  }
55
55
  `);
56
56
  });
@@ -1,8 +1,11 @@
1
1
  // Discord-specific utility functions.
2
2
  // Handles markdown splitting for Discord's 2000-char limit, code block escaping,
3
3
  // thread message sending, and channel metadata extraction from topic tags.
4
- import { ChannelType, GuildMember, MessageFlags, PermissionsBitField, } from 'discord.js';
5
- import { REST, Routes } from 'discord.js';
4
+ // Use namespace import for CJS interop discord.js is CJS and its named
5
+ // exports aren't detectable by all ESM loaders (e.g. tsx/esbuild) because
6
+ // discord.js uses tslib's __exportStar which is opaque to static analysis.
7
+ import * as discord from 'discord.js';
8
+ const { ChannelType, GuildMember, MessageFlags, PermissionsBitField, REST, Routes } = discord;
6
9
  import { discordApiUrl } from './discord-urls.js';
7
10
  import { Lexer } from 'marked';
8
11
  import { splitTablesFromMarkdown } from './format-tables.js';
@@ -11,6 +11,7 @@
11
11
  export { ipcToolsPlugin } from './ipc-tools-plugin.js';
12
12
  export { contextAwarenessPlugin } from './context-awareness-plugin.js';
13
13
  export { interruptOpencodeSessionOnUserMessage } from './opencode-interrupt-plugin.js';
14
+ export { systemPromptDriftPlugin } from './system-prompt-drift-plugin.js';
14
15
  export { anthropicAuthPlugin } from './anthropic-auth-plugin.js';
15
16
  export { imageOptimizerPlugin } from './image-optimizer-plugin.js';
16
17
  export { kittyGraphicsPlugin } from 'kitty-graphics-agent';
package/dist/logger.js CHANGED
@@ -80,12 +80,18 @@ export function setLogFilePath(dataDir) {
80
80
  export function getLogFilePath() {
81
81
  return logFilePath;
82
82
  }
83
+ const MAX_LOG_ARG_LENGTH = 1000;
84
+ function truncate(str, max) {
85
+ if (str.length <= max)
86
+ return str;
87
+ return str.slice(0, max) + `… [truncated ${str.length - max} chars]`;
88
+ }
83
89
  function formatArg(arg) {
84
90
  if (typeof arg === 'string') {
85
- return sanitizeSensitiveText(arg, { redactPaths: false });
91
+ return truncate(sanitizeSensitiveText(arg, { redactPaths: false }), MAX_LOG_ARG_LENGTH);
86
92
  }
87
93
  const safeArg = sanitizeUnknownValue(arg, { redactPaths: false });
88
- return util.inspect(safeArg, { colors: true, depth: 4 });
94
+ return truncate(util.inspect(safeArg, { colors: true, depth: 4 }), MAX_LOG_ARG_LENGTH);
89
95
  }
90
96
  export function formatErrorWithStack(error) {
91
97
  if (error instanceof Error) {
@@ -40,6 +40,14 @@ import { extractLeadingOpencodeCommand } from '../opencode-command-detection.js'
40
40
  const logger = createLogger(LogPrefix.SESSION);
41
41
  const discordLogger = createLogger(LogPrefix.DISCORD);
42
42
  const DETERMINISTIC_CONTEXT_LIMIT = 100_000;
43
+ const TOAST_SESSION_ID_REGEX = /\b(ses_[A-Za-z0-9]+)\b\s*$/u;
44
+ function extractToastSessionId({ message }) {
45
+ const match = message.match(TOAST_SESSION_ID_REGEX);
46
+ return match?.[1];
47
+ }
48
+ function stripToastSessionId({ message }) {
49
+ return message.replace(TOAST_SESSION_ID_REGEX, '').trimEnd();
50
+ }
43
51
  const shouldLogSessionEvents = process.env['KIMAKI_LOG_SESSION_EVENTS'] === '1' ||
44
52
  process.env['KIMAKI_VITEST'] === '1';
45
53
  // ── Registry ─────────────────────────────────────────────────────
@@ -943,6 +951,9 @@ export class ThreadSessionRuntime {
943
951
  }
944
952
  const sessionId = this.state?.sessionId;
945
953
  const eventSessionId = getOpencodeEventSessionId(event);
954
+ const toastSessionId = event.type === 'tui.toast.show'
955
+ ? extractToastSessionId({ message: event.properties.message })
956
+ : undefined;
946
957
  if (shouldLogSessionEvents) {
947
958
  const eventDetails = (() => {
948
959
  if (event.type === 'session.error') {
@@ -970,6 +981,7 @@ export class ThreadSessionRuntime {
970
981
  logger.log(`[EVENT] type=${event.type} eventSessionId=${eventSessionId || 'none'} activeSessionId=${sessionId || 'none'} ${this.formatRunStateForLog()}${eventDetails}`);
971
982
  }
972
983
  const isGlobalEvent = event.type === 'tui.toast.show';
984
+ const isScopedToastEvent = Boolean(toastSessionId);
973
985
  // Drop events that don't match current session (stale events from
974
986
  // previous sessions), unless it's a global event or a subtask session.
975
987
  if (!isGlobalEvent && eventSessionId && eventSessionId !== sessionId) {
@@ -977,6 +989,11 @@ export class ThreadSessionRuntime {
977
989
  return; // stale event from previous session
978
990
  }
979
991
  }
992
+ if (isScopedToastEvent && toastSessionId !== sessionId) {
993
+ if (!this.getSubtaskInfoForSession(toastSessionId)) {
994
+ return;
995
+ }
996
+ }
980
997
  if (isOpencodeSessionEventLogEnabled()) {
981
998
  const eventLogResult = await appendOpencodeSessionEventLog({
982
999
  threadId: this.threadId,
@@ -2023,7 +2040,7 @@ export class ThreadSessionRuntime {
2023
2040
  if (properties.variant === 'warning') {
2024
2041
  return;
2025
2042
  }
2026
- const toastMessage = properties.message.trim();
2043
+ const toastMessage = stripToastSessionId({ message: properties.message }).trim();
2027
2044
  if (!toastMessage) {
2028
2045
  return;
2029
2046
  }
@@ -3132,8 +3149,8 @@ export class ThreadSessionRuntime {
3132
3149
  const truncate = (s, max) => {
3133
3150
  return s.length > max ? s.slice(0, max - 1) + '\u2026' : s;
3134
3151
  };
3135
- const truncatedFolder = truncate(folderName, 15);
3136
- const truncatedBranch = truncate(branchName, 15);
3152
+ const truncatedFolder = truncate(folderName, 30);
3153
+ const truncatedBranch = truncate(branchName, 30);
3137
3154
  const projectInfo = truncatedBranch
3138
3155
  ? `${truncatedFolder} ⋅ ${truncatedBranch} ⋅ `
3139
3156
  : `${truncatedFolder} ⋅ `;
@@ -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: