kimaki 1.2.0 → 1.3.0

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 (69) hide show
  1. package/dist/bot.d.ts +7 -3
  2. package/dist/bot.js +9 -3
  3. package/dist/bot.js.map +1 -1
  4. package/dist/cli/bot.js +10 -5
  5. package/dist/cli/bot.js.map +1 -1
  6. package/dist/cli-lock.e2e.test.js +3 -2
  7. package/dist/cli-lock.e2e.test.js.map +1 -1
  8. package/dist/commands/login-commands.d.ts +1 -1
  9. package/dist/commands/preference-commands.d.ts +1 -1
  10. package/dist/commands/worktree-commands.d.ts +1 -1
  11. package/dist/event-loop.d.ts +2 -1
  12. package/dist/event-loop.js +2 -2
  13. package/dist/event-loop.js.map +1 -1
  14. package/dist/hello-thread.e2e.test.js +31 -0
  15. package/dist/hello-thread.e2e.test.js.map +1 -1
  16. package/dist/ingress.js +11 -6
  17. package/dist/ingress.js.map +1 -1
  18. package/dist/lock-routes.d.ts +14 -14
  19. package/dist/main.d.ts +1 -0
  20. package/dist/main.js +2 -1
  21. package/dist/main.js.map +1 -1
  22. package/dist/plugin/index.js +42 -9
  23. package/dist/plugin/index.js.map +1 -1
  24. package/dist/prompt.d.ts +6 -6
  25. package/dist/scheduler.d.ts +1 -1
  26. package/dist/sessions.d.ts +10 -10
  27. package/dist/sessions.js +3 -3
  28. package/dist/sessions.js.map +1 -1
  29. package/dist/system-prompt.d.ts +14 -0
  30. package/dist/system-prompt.js +34 -0
  31. package/dist/system-prompt.js.map +1 -1
  32. package/dist/test/harness.d.ts +2 -1
  33. package/dist/test/harness.js +2 -1
  34. package/dist/test/harness.js.map +1 -1
  35. package/dist/test/replay.js +1 -0
  36. package/dist/test/replay.js.map +1 -1
  37. package/dist/thread-reducer.d.ts +3 -0
  38. package/dist/thread-reducer.fixtures.test.js +2 -2
  39. package/dist/thread-reducer.fixtures.test.js.map +1 -1
  40. package/dist/thread-reducer.js +32 -4
  41. package/dist/thread-reducer.js.map +1 -1
  42. package/dist/thread-reducer.test.js +31 -0
  43. package/dist/thread-reducer.test.js.map +1 -1
  44. package/dist/tunnel.d.ts +12 -0
  45. package/dist/tunnel.js +207 -0
  46. package/dist/tunnel.js.map +1 -0
  47. package/dist/tunnel.test.d.ts +1 -0
  48. package/dist/tunnel.test.js +38 -0
  49. package/dist/tunnel.test.js.map +1 -0
  50. package/package.json +5 -5
  51. package/skills/kimaki-tunnel/SKILL.md +14 -10
  52. package/src/bot.ts +14 -4
  53. package/src/cli/bot.ts +9 -5
  54. package/src/cli-lock.e2e.test.ts +3 -2
  55. package/src/event-loop.ts +4 -1
  56. package/src/hello-thread.e2e.test.ts +35 -0
  57. package/src/ingress.ts +9 -5
  58. package/src/main.ts +4 -1
  59. package/src/message-tool.e2e.test.ts +45 -0
  60. package/src/plugin/index.ts +42 -9
  61. package/src/sessions.ts +3 -2
  62. package/src/system-prompt.ts +38 -0
  63. package/src/test/harness.ts +3 -0
  64. package/src/test/replay.ts +1 -0
  65. package/src/thread-reducer.fixtures.test.ts +2 -2
  66. package/src/thread-reducer.test.ts +31 -0
  67. package/src/thread-reducer.ts +36 -4
  68. package/src/tunnel.test.ts +39 -0
  69. package/src/tunnel.ts +209 -0
@@ -5,26 +5,30 @@ description: Share a local dev server with the user through a public URL. Use wh
5
5
 
6
6
  # Dev servers with tunnel access
7
7
 
8
- Localhost URLs are useless from Discord. Wrap the dev server in `kimaki tunnel` to get a public URL, and run it in a named background `tuistory` session so you can wait for output, read logs and stop it later. Name the session descriptively (e.g. `projectname-dev`) and reuse it. Run `tuistory --help` first (or `bunx tuistory` when it is not installed). Invoke `kimaki` directly, not via `npx` or `bunx`.
8
+ Localhost URLs are useless from Discord. Wrap the dev server in `kimaki tunnel` to get a public `https://<name>.<id>.opentunnel.xyz` URL, and run it in a named background `tuistory` session so you can wait for output, read logs and stop it later. Name the session descriptively (e.g. `projectname-dev`) and reuse it. Run `tuistory --help` first (or `bunx tuistory` when it is not installed). Invoke `kimaki` directly, not via `npx` or `bunx`.
9
9
 
10
- - Use random tunnel IDs by default. Pass `-t <id>` only for services that are safe to be publicly discoverable.
11
- - `kimaki tunnel` detects the local port from the child output. Pass `--port` only when the server prints no detectable localhost URL or port line.
12
- - `kimaki tunnel` injects `TRAFORO_URL` into the child process. Wire the app to it so OAuth callbacks, webhook URLs and absolute links use the public URL.
10
+ - Use random names by default. Pass `-t <name>` only for services that are safe to be publicly discoverable.
11
+ - `kimaki tunnel` detects the local port from the child output. Pass `--port` only when the server prints no detectable localhost URL.
12
+ - `kimaki tunnel` injects `KIMAKI_TUNNEL_URL` into the child process. Wire the app to it so OAuth callbacks, webhook URLs and absolute links use the public URL.
13
+ - TLS ends on this machine and the request reaches the app unchanged. Dev servers that check the `Host` header (Vite) must allow it, e.g. `server.allowedHosts: true` or `['.opentunnel.xyz']`.
14
+ - The first run on a machine creates the tunnel and waits up to a minute for its certificate.
13
15
 
14
16
  ```bash
15
17
  # start in a named background session, wait for output, then read the tunnel URL
16
18
  tuistory launch "kimaki tunnel -- pnpm dev" -s myapp-dev
17
- tuistory -s myapp-dev wait "/ready|local|tunnel/i" --timeout 30000
19
+ tuistory -s myapp-dev wait "/Tunnel: https/" --timeout 90000
18
20
  tuistory read -s myapp-dev
19
21
 
20
- # pass the public URL to the app (better-auth, Next.js, Vite; node reads process.env.TRAFORO_URL)
21
- tuistory launch "kimaki tunnel -- sh -c 'BETTER_AUTH_URL=$TRAFORO_URL exec pnpm dev'" -s myapp-dev
22
- tuistory launch "kimaki tunnel -- sh -c 'APP_URL=$TRAFORO_URL exec pnpm dev'" -s myapp-dev
23
- tuistory launch "kimaki tunnel -- sh -c 'VITE_BASE_URL=$TRAFORO_URL exec pnpm dev'" -s myapp-dev
22
+ # pass the public URL to the app (better-auth, Next.js, Vite; node reads process.env.KIMAKI_TUNNEL_URL)
23
+ tuistory launch "kimaki tunnel -- sh -c 'BETTER_AUTH_URL=\$KIMAKI_TUNNEL_URL exec pnpm dev'" -s myapp-dev
24
+ tuistory launch "kimaki tunnel -- sh -c 'APP_URL=\$KIMAKI_TUNNEL_URL exec pnpm dev'" -s myapp-dev
24
25
 
25
- # custom tunnel ID (only for intentionally public-safe services)
26
+ # fixed name (only for intentionally public-safe services)
26
27
  tuistory launch "kimaki tunnel -t holocron -- pnpm dev" -s holocron-dev
27
28
 
29
+ # restart the server on a known port and keep the URL
30
+ tuistory launch "kimaki tunnel -t holocron -p 5173 --kill -- pnpm dev --port 5173" -s holocron-dev
31
+
28
32
  # list sessions; stop with Ctrl+C, then close
29
33
  tuistory sessions
30
34
  tuistory -s myapp-dev press ctrl c
package/src/bot.ts CHANGED
@@ -69,6 +69,8 @@ export type Bot = {
69
69
  allowAllUsers: boolean
70
70
  // Built-in skill ids (builtin-skills.ts) denied through the permissions of new sessions.
71
71
  disabledSkills: readonly string[]
72
+ // `kimaki --message-tool`: root sessions also reply through send_message; Discord hides their plain text.
73
+ messageTool: boolean
72
74
  features: BotFeatures
73
75
  }
74
76
 
@@ -134,6 +136,7 @@ export type SessionMarker = {
134
136
  fields: { readonly [key: string]: JsonValue }
135
137
  dataDir: string | null
136
138
  lockPort: number | null
139
+ messageTool: boolean
137
140
  // Explicit `kimaki send --parent-session`, so /resume keeps the instruction line.
138
141
  parentSessionId: string | null
139
142
  // The scheduled run stored at session.create, so /resume keeps the same instruction section.
@@ -147,7 +150,7 @@ function jsonObject(value: JsonValue | undefined): { readonly [key: string]: Jso
147
150
  export function readMarker(metadata: SessionMetadata | undefined): SessionMarker | null {
148
151
  const fields = jsonObject(metadata?.['kimaki'])
149
152
  if (!fields) return null
150
- const { dataDir, lockPort, parentSessionId } = fields
153
+ const { dataDir, lockPort, messageTool, parentSessionId } = fields
151
154
  const task = jsonObject(fields['task'])
152
155
  const taskId = task?.['id']
153
156
  const cronExpr = task?.['cronExpr']
@@ -156,6 +159,7 @@ export function readMarker(metadata: SessionMetadata | undefined): SessionMarker
156
159
  fields,
157
160
  dataDir: typeof dataDir === 'string' ? dataDir : null,
158
161
  lockPort: typeof lockPort === 'number' ? lockPort : null,
162
+ messageTool: messageTool === true,
159
163
  parentSessionId: typeof parentSessionId === 'string' ? parentSessionId : null,
160
164
  task: typeof taskId === 'number'
161
165
  ? { id: taskId, cronExpr: typeof cronExpr === 'string' ? cronExpr : null, timezone: typeof timezone === 'string' ? timezone : null }
@@ -163,9 +167,15 @@ export function readMarker(metadata: SessionMetadata | undefined): SessionMarker
163
167
  }
164
168
  }
165
169
 
166
- // The marker fields that point the agent's `kimaki` calls at this bot.
167
- export function cliContext(bot: Bot) {
168
- return { dataDir: bot.dataDir, lockPort: bot.lockPort }
170
+ // The marker fields that follow this bot run: where the agent's `kimaki` calls
171
+ // go, and whether the plugin gives the session send_message.
172
+ export function cliContext(bot: Pick<Bot, 'dataDir' | 'lockPort' | 'messageTool'>) {
173
+ return { dataDir: bot.dataDir, lockPort: bot.lockPort, messageTool: bot.messageTool }
174
+ }
175
+
176
+ // False when the session marker was written by another bot run or with another --message-tool.
177
+ export function markerIsCurrent(bot: Pick<Bot, 'dataDir' | 'lockPort' | 'messageTool'>, marker: SessionMarker): boolean {
178
+ return marker.dataDir === bot.dataDir && marker.lockPort === bot.lockPort && marker.messageTool === bot.messageTool
169
179
  }
170
180
 
171
181
  // Default model of new sessions in channels without their own (/model "All channels").
package/src/cli/bot.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // Bot lifecycle and setup: the root command (start + onboarding), provider
2
2
  // login, tools (tunnel, tts), and bot status, logs, credentials and keys.
3
- // main.ts, onboarding.ts, traforo, SQLite and voice are loaded only by the
3
+ // main.ts, onboarding.ts, tunnel.ts, SQLite and voice are loaded only by the
4
4
  // commands that run them (see the note in shared.ts).
5
5
 
6
6
  import { spawn, type ChildProcess } from 'node:child_process'
@@ -89,6 +89,7 @@ export function registerStartCommand(cli: Goke) {
89
89
  .option('--restart-onboarding', 'Choose credentials again')
90
90
  .option('--worktrees', 'Use a fresh Git worktree for new sessions unless the channel overrides it')
91
91
  .option('--allow-all-users', 'Every server member can use the bot (role "no-kimaki" still denies; provider login stays admin-only)')
92
+ .option('--message-tool', 'Discord shows only a concise reply the agent sends with a send_message tool; plain text stays visible in the OpenCode TUI')
92
93
  .option('--voice <name>', 'Voice of voice calls, saved for later starts (e.g. cove, juniper, sol). "default" resets it')
93
94
  .option('--disable-skills <list>', 'Built-in Kimaki skills to turn off: "all" or a comma separated list (kimaki-debug,kimaki-tunnel)')
94
95
  .option('--no-analytics', 'Disable anonymous usage analytics (same as KIMAKI_STRADA_ENABLED=0)')
@@ -219,6 +220,7 @@ export function registerStartCommand(cli: Goke) {
219
220
  autoWorktrees: Boolean(options.worktrees),
220
221
  allowAllUsers: Boolean(options.allowAllUsers),
221
222
  disabledSkills,
223
+ messageTool: Boolean(options.messageTool),
222
224
  })
223
225
  if (bot instanceof Error) failStartup(bot)
224
226
  // exit() keeps process.exitCode: the restart route sets RESTART_EXIT_CODE before its SIGTERM.
@@ -248,6 +250,7 @@ export function registerStartCommand(cli: Goke) {
248
250
  ...(options.worktrees ? ['--worktrees'] : []),
249
251
  ...(options.allowAllUsers ? ['--allow-all-users'] : []),
250
252
  ...(options.disableSkills ? ['--disable-skills', options.disableSkills] : []),
253
+ ...(options.messageTool ? ['--message-tool'] : []),
251
254
  ...(options.noAnalytics ? ['--no-analytics'] : []),
252
255
  ...(options.machineName ? ['--machine-name', options.machineName] : []),
253
256
  ] })
@@ -283,9 +286,9 @@ export function registerLoginCommands(cli: Goke) {
283
286
 
284
287
  // tunnel, tts
285
288
  export function registerToolCommands(cli: Goke) {
286
- cli.command('tunnel', 'Run a command and expose its local port with a public URL. The child gets TRAFORO_URL')
289
+ cli.command('tunnel', 'Run a command and expose its local port with a public opentunnel.xyz URL. The child gets KIMAKI_TUNNEL_URL')
287
290
  .option('-p, --port <port>', 'Local port (default: read from the command output)')
288
- .option('-t, --tunnel-id <id>', 'Fixed tunnel ID (default: random). Only for public-safe services')
291
+ .option('-t, --tunnel-id <id>', 'Fixed subdomain (default: random). Only for public-safe services')
289
292
  .option('--host <host>', 'Local host (default: localhost)')
290
293
  .option('-k, --kill', 'Kill the process on --port first')
291
294
  .example('kimaki tunnel -- pnpm dev')
@@ -294,8 +297,9 @@ export function registerToolCommands(cli: Goke) {
294
297
  const port = options.port ? Number(options.port) : undefined
295
298
  if (port !== undefined && (!Number.isInteger(port) || port < 1 || port > 65_535)) fail(new Error(`Invalid --port ${options.port}`))
296
299
  if (!port && command.length === 0) fail(new Error('Pass a command after --, or --port <port>. Example: kimaki tunnel -- pnpm dev'))
297
- const { runTunnel } = await import('traforo/run-tunnel')
298
- await runTunnel({ port, command: command.length > 0 ? command : undefined, tunnelId: options.tunnelId, localHost: options.host, baseDomain: 'kimaki.dev', kill: options.kill })
300
+ const { runTunnel } = await import('../tunnel.ts')
301
+ const result = await runTunnel({ port, command: command.length > 0 ? command : undefined, name: options.tunnelId, host: options.host, kill: options.kill })
302
+ if (result instanceof Error) fail(result)
299
303
  })
300
304
 
301
305
  cli.command('tts [text]', 'Text to speech with OpenAI or Gemini. Reads stdin if no text is given')
@@ -321,9 +321,9 @@ test('CLI help documents the supported P7 commands', async () => {
321
321
  -c, --channel <id> Kimaki voice channel to post in (default in voice calls: KIMAKI_CHANNEL_ID)
322
322
 
323
323
  Tools:
324
- tunnel Run a command and expose its local port with a public URL. The child gets TRAFORO_URL
324
+ tunnel Run a command and expose its local port with a public opentunnel.xyz URL. The child gets KIMAKI_TUNNEL_URL
325
325
  -p, --port <port> Local port (default: read from the command output)
326
- -t, --tunnel-id <id> Fixed tunnel ID (default: random). Only for public-safe services
326
+ -t, --tunnel-id <id> Fixed subdomain (default: random). Only for public-safe services
327
327
  --host <host> Local host (default: localhost)
328
328
  -k, --kill Kill the process on --port first
329
329
 
@@ -376,6 +376,7 @@ test('CLI help documents the supported P7 commands', async () => {
376
376
  --restart-onboarding Choose credentials again
377
377
  --worktrees Use a fresh Git worktree for new sessions unless the channel overrides it
378
378
  --allow-all-users Every server member can use the bot (role "no-kimaki" still denies; provider login stays admin-only)
379
+ --message-tool Discord shows only a concise reply the agent sends with a send_message tool; plain text stays visible in the OpenCode TUI
379
380
  --voice <name> Voice of voice calls, saved for later starts (e.g. cove, juniper, sol). "default" resets it
380
381
  --disable-skills <list> Built-in Kimaki skills to turn off: "all" or a comma separated list (kimaki-debug,kimaki-tunnel)
381
382
  --no-analytics Disable anonymous usage analytics (same as KIMAKI_STRADA_ENABLED=0)
package/src/event-loop.ts CHANGED
@@ -67,6 +67,7 @@ export function createEventLoop({
67
67
  effects,
68
68
  recorder,
69
69
  analytics,
70
+ messageTool,
70
71
  onRunEnded,
71
72
  onTranscriptDue,
72
73
  }: {
@@ -76,6 +77,8 @@ export function createEventLoop({
76
77
  effects: EffectsRunner
77
78
  recorder: EventRecorder
78
79
  analytics: Analytics
80
+ // `kimaki --message-tool`: plain text of root sessions stays hidden (Prefs.messageTool).
81
+ messageTool: boolean
79
82
  // Every finished or failed run of a thread's root session (voice call notifications).
80
83
  onRunEnded: (run: RunEnded) => void
81
84
  // A root session stopped running (also after an interrupt): its transcript file is stale.
@@ -122,7 +125,7 @@ export function createEventLoop({
122
125
  if (!view) return
123
126
  const verbosity = state.verbosity[view.channelId] ?? verbosityFromV1(null)
124
127
  const contextLimits = modelLimits.get(view.directory)?.limits ?? {}
125
- const result = reduce({ view, event, prefs: { verbosity, contextLimits } })
128
+ const result = reduce({ view, event, prefs: { verbosity, contextLimits, messageTool } })
126
129
  if (result.view !== view) store.setState((current) => ({ threads: { ...current.threads, [threadId]: result.view } }))
127
130
  effects.run(threadId, result.effects)
128
131
  if (event.type === 'session.moved' && event.data.sessionID === view.sessionId) void loadModelLimits(event.data.location.directory)
@@ -177,6 +177,41 @@ test('message from a user without permission creates no thread', async () => {
177
177
  await expectNoNewThread({ channelId, before })
178
178
  })
179
179
 
180
+ test('another bot needs the Kimaki role, and no-kimaki still denies it', async () => {
181
+ const { discord, channelId } = twin
182
+ const helperBotId = '200000000000000050'
183
+ const guildId = discord.guildId
184
+ await discord.prisma.user.create({ data: { id: helperBotId, username: 'helper-bot', bot: true } })
185
+ await discord.prisma.guildMember.create({ data: { guildId, userId: helperBotId } })
186
+ const member = { guildId_userId: { guildId, userId: helperBotId } }
187
+ const helper = discord.channel(channelId).user(helperBotId)
188
+
189
+ const before = (await discord.channel(channelId).getThreads()).length
190
+ await helper.sendMessage({ content: 'no role bot-marker hello-marker' })
191
+ await expectNoNewThread({ channelId, before })
192
+
193
+ const guild = await bot.discord.guilds.fetch(guildId)
194
+ const kimakiRole = await guild.roles.create({ name: 'Kimaki' })
195
+ const denyRole = await guild.roles.create({ name: 'no-kimaki' })
196
+ await discord.prisma.guildMember.update({ where: member, data: { roles: JSON.stringify([kimakiRole.id]) } })
197
+ await helper.sendMessage({ content: 'with role bot-marker hello-marker' })
198
+ const thread = await discord.channel(channelId).waitForThread({ predicate: (thread) => thread.name?.startsWith('with role bot-marker') ?? false })
199
+ await waitForFooter({ discord, threadId: thread.id })
200
+ expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
201
+ "--- from: assistant (helper-bot)
202
+ with role bot-marker hello-marker
203
+ --- from: assistant (TestBot)
204
+ -# *using deterministic-provider/deterministic-v2 ⋅ build*
205
+ Hello from the deterministic model.
206
+ -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
207
+ `)
208
+
209
+ const withRole = (await discord.channel(channelId).getThreads()).length
210
+ await discord.prisma.guildMember.update({ where: member, data: { roles: JSON.stringify([kimakiRole.id, denyRole.id]) } })
211
+ await helper.sendMessage({ content: 'denied bot-marker hello-marker' })
212
+ await expectNoNewThread({ channelId, before: withRole })
213
+ })
214
+
180
215
  test('message in a channel without a project creates no thread', async () => {
181
216
  const { discord, unregisteredChannelId } = twin
182
217
  await discord.channel(unregisteredChannelId).user(TEST_USER_ID).sendMessage({ content: 'hello-marker' })
package/src/ingress.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // Discord ingress (spec 4): gates every messageCreate, parses it into a Route
2
- // and calls actions. Gates, in order: bots (including ourselves), channel
3
- // ownership (only channels mapped in this machine's SQLite), permission.
2
+ // and calls actions. Gates, in order: webhooks and our own messages (except
3
+ // remote send envelopes), channel ownership (only channels mapped in this
4
+ // machine's SQLite), permission (other bots need the Kimaki role).
4
5
  // Messages of one channel are handled in arrival order, after OpenCode is ready.
5
6
  //
6
7
  // Messages sent while the bot was offline are not fetched here: gateway-proxy
@@ -66,6 +67,8 @@ function authorCanUseKimaki(bot: Bot, message: Message<true>): Promise<boolean>
66
67
  function memberCanUseKimaki(bot: Pick<Bot, 'allowAllUsers'>, member: GuildMember): boolean {
67
68
  const roleNames = member.roles.cache.map((role) => role.name.toLowerCase())
68
69
  if (roleNames.includes('no-kimaki')) return false
70
+ // Other bots are opt-in: only the Kimaki role lets them in, never admin rights or --allow-all-users.
71
+ if (member.user.bot) return roleNames.includes('kimaki')
69
72
  if (bot.allowAllUsers) return true
70
73
  if (member.guild.ownerId === member.id) return true
71
74
  if (member.permissions.has(PermissionFlagsBits.Administrator)) return true
@@ -222,8 +225,7 @@ async function handleMessage(bot: Bot, message: Message) {
222
225
  })
223
226
  return
224
227
  }
225
- if (message.author.bot) {
226
- if (message.author.id !== bot.discord.user?.id) return
228
+ if (message.author.id === bot.discord.user?.id) {
227
229
  return handleRemoteEnvelope(bot, { message, channelId, threadId: thread?.id ?? null })
228
230
  }
229
231
  if (!(await authorCanUseKimaki(bot, message))) {
@@ -303,7 +305,9 @@ export function registerIngress(bot: Bot) {
303
305
  }
304
306
 
305
307
  bot.discord.on(Events.MessageCreate, (message) => {
306
- if (message.author.bot && !message.embeds[0]?.footer?.text.startsWith(REMOTE_SEND_PREFIX)) return
308
+ // Webhooks have no member to check. Own messages only matter as remote send envelopes.
309
+ if (message.webhookId) return
310
+ if (message.author.id === bot.discord.user?.id && !message.embeds[0]?.footer?.text.startsWith(REMOTE_SEND_PREFIX)) return
307
311
  serialize(message.channelId, () => handleMessage(bot, message))
308
312
  })
309
313
  // Deleting a queued Discord message removes it from the queue.
package/src/main.ts CHANGED
@@ -68,6 +68,8 @@ export type StartBotOptions = {
68
68
  allowAllUsers?: boolean
69
69
  // Built-in skill ids denied in new sessions (builtin-skills.ts).
70
70
  disabledSkills?: readonly string[]
71
+ // `kimaki --message-tool`: agents also reply through send_message; Discord hides their plain text.
72
+ messageTool?: boolean
71
73
  }
72
74
 
73
75
  export type BotHandle = Bot & {
@@ -146,7 +148,7 @@ export async function startBot(options: StartBotOptions): Promise<Error | BotHan
146
148
  if (result instanceof Error) logger.warn(`transcript of ${sessionId} not synced`, result)
147
149
  })
148
150
  }
149
- const eventLoop = createEventLoop({ store, db, discord, effects, recorder, analytics: options.analytics, onRunEnded: voiceCalls.runEnded, onTranscriptDue })
151
+ const eventLoop = createEventLoop({ store, db, discord, effects, recorder, analytics: options.analytics, messageTool: options.messageTool ?? false, onRunEnded: voiceCalls.runEnded, onTranscriptDue })
150
152
  const loaded = await eventLoop.load()
151
153
  if (loaded instanceof Error) return loaded
152
154
  // Before the service is used, so a service started by ensure() loads it at once.
@@ -185,6 +187,7 @@ export async function startBot(options: StartBotOptions): Promise<Error | BotHan
185
187
  autoWorktrees: options.autoWorktrees ?? false,
186
188
  allowAllUsers: options.allowAllUsers ?? false,
187
189
  disabledSkills: options.disabledSkills ?? [],
190
+ messageTool: options.messageTool ?? false,
188
191
  features: { withSleepLock: createSleepLock(), waitForPlugin: createPluginWait({ opencode }), agentUi, voiceCalls },
189
192
  }
190
193
  const scheduler = createScheduler({
@@ -0,0 +1,45 @@
1
+ // `kimaki --message-tool`: the plugin gives root sessions a send_message tool
2
+ // and the thread shows only its text. The model's plain text stays hidden.
3
+
4
+ import fs from 'node:fs'
5
+ import { afterAll, beforeAll, expect, test } from 'vitest'
6
+
7
+ import type { BotHandle } from './main.ts'
8
+ import { scriptedTurn, seedProjectChannel, startOpencodeTestServer, startTestBot, startTwin, tempDataDir, TEST_USER_ID, waitForFooter, warmUp, type OpencodeTestServer, type TestTwin } from './test/harness.ts'
9
+
10
+ const dataDir = tempDataDir()
11
+ let server: OpencodeTestServer
12
+ let twin: TestTwin
13
+ let bot: BotHandle
14
+
15
+ beforeAll(async () => {
16
+ const matchers = scriptedTurn({
17
+ marker: 'message-tool-marker',
18
+ steps: [{ id: 'message-call', tool: 'send_message', input: { text: 'Hello from **send_message**' } }],
19
+ finalText: 'hidden scratchpad text',
20
+ })
21
+ ;[server, twin] = await Promise.all([startOpencodeTestServer({ matchers }), startTwin()])
22
+ await seedProjectChannel({ dataDir, channelId: twin.channelId, guildId: twin.discord.guildId, directory: server.projectDirectory })
23
+ bot = await startTestBot({ dataDir, twin, server, messageTool: true })
24
+ await warmUp({ server })
25
+ }, 60_000)
26
+
27
+ afterAll(async () => {
28
+ await bot?.stop()
29
+ await Promise.all([server?.stop(), twin?.stop()])
30
+ fs.rmSync(dataDir, { recursive: true, force: true })
31
+ })
32
+
33
+ test('send_message text shows in Discord, plain text of the same turn does not', async () => {
34
+ await twin.discord.channel(twin.channelId).user(TEST_USER_ID).sendMessage({ content: 'message-tool-marker' })
35
+ const thread = await twin.discord.channel(twin.channelId).waitForThread({ timeout: 8_000 })
36
+ await waitForFooter({ discord: twin.discord, threadId: thread.id })
37
+ expect(await twin.discord.thread(thread.id).text()).toMatchInlineSnapshot(`
38
+ "--- from: user (tommy)
39
+ message-tool-marker
40
+ --- from: assistant (TestBot)
41
+ -# *using deterministic-provider/deterministic-v2 ⋅ build*
42
+ Hello from **send_message**
43
+ -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
44
+ `)
45
+ })
@@ -1,3 +1,4 @@
1
+ import type { JsonValue } from '@opencode/client'
1
2
  import { Plugin, Skill } from '@opencode/plugin'
2
3
  import { Schema } from 'effect'
3
4
  import { execFile } from 'node:child_process'
@@ -7,7 +8,7 @@ import path from 'node:path'
7
8
  import { autostartBot } from '../autostart.ts'
8
9
  import { readBuiltinSkills } from '../builtin-skills.ts'
9
10
  import { fileEditTool, recordToolEdits } from '../file-edit-log.ts'
10
- import { turnContext } from '../system-prompt.ts'
11
+ import { MESSAGE_MODE_INSTRUCTIONS, MESSAGE_TOOL, MESSAGE_TOOL_DESCRIPTION, MESSAGE_TOOL_INPUT, turnContext } from '../system-prompt.ts'
11
12
 
12
13
  const exec = promisify(execFile)
13
14
 
@@ -31,17 +32,32 @@ export default Plugin.define({
31
32
  }
32
33
  })
33
34
  }
34
- async function marker(sessionID: string) {
35
+ // metadata.kimaki of the session, or of the thread's root for a subagent (`root: false`).
36
+ async function marker(sessionID: string): Promise<{ fields: Map<string, JsonValue>; root: boolean } | null> {
35
37
  const session = await ctx.session.get({ sessionID }).catch(() => null)
36
38
  const value: unknown = session?.metadata?.['kimaki']
37
39
  if (!value || typeof value !== 'object' || Array.isArray(value)) return null
38
40
  const current = new Map(Object.entries(value))
39
- if (!session?.parentID) return current
41
+ if (!session?.parentID) return { fields: current, root: true }
40
42
  const parent = await ctx.session.get({ sessionID: session.parentID }).catch(() => null)
41
43
  const parentMarker = parent?.metadata?.['kimaki']
42
- if (parentMarker && typeof parentMarker === 'object' && !Array.isArray(parentMarker) && parentMarker['threadId'] === current.get('threadId')) return marker(session.parentID)
43
- return current
44
+ if (parentMarker && typeof parentMarker === 'object' && !Array.isArray(parentMarker) && parentMarker['threadId'] === current.get('threadId')) {
45
+ const root = await marker(session.parentID)
46
+ return root && { fields: root.fields, root: false }
47
+ }
48
+ return { fields: current, root: true }
44
49
  }
50
+ // Registered for every session; the context hook removes it outside message mode.
51
+ await ctx.tool.transform((editor) => {
52
+ editor.add({
53
+ name: MESSAGE_TOOL,
54
+ description: MESSAGE_TOOL_DESCRIPTION,
55
+ input: MESSAGE_TOOL_INPUT,
56
+ options: { codemode: false },
57
+ // Discord renders the call input (thread-reducer.ts); the tool itself does nothing.
58
+ execute: async () => ({ content: 'Message sent to the user.' }),
59
+ })
60
+ })
45
61
  // The git branch reaches the model as a synthetic message saved right before
46
62
  // the user prompt, only when it changed. Text in event.system would change the
47
63
  // cached prompt prefix on every branch switch. The prompt hook runs before
@@ -58,11 +74,28 @@ export default Plugin.define({
58
74
  if (last?.type === 'synthetic' && last.text === text) return
59
75
  await ctx.session.synthetic({ sessionID: event.sessionID, text, delivery: event.delivery, resume: false }).catch(() => undefined)
60
76
  })
77
+ // send_message only for root sessions of a bot started with --message-tool.
78
+ // Every request kind that carries the tool list gets the same tools and system
79
+ // text, so the prompt cache keeps hitting and no other session ever sees the tool.
80
+ async function applyMessageMode(event: { sessionID: string; tools: Record<string, unknown>; system: Array<{ type: 'text'; text: string }> }) {
81
+ const info = await marker(event.sessionID)
82
+ const messageMode = info?.root === true && info.fields.get('messageTool') === true
83
+ if (!messageMode) delete event.tools[MESSAGE_TOOL]
84
+ else event.system.push({ type: 'text', text: MESSAGE_MODE_INSTRUCTIONS })
85
+ return info
86
+ }
87
+ await ctx.session.hook('compaction', async (event) => {
88
+ await applyMessageMode(event)
89
+ })
90
+ await ctx.session.hook('generate', async (event) => {
91
+ await applyMessageMode(event)
92
+ })
61
93
  await ctx.session.hook('context', async (event) => {
62
94
  // The only prompt change for every session, TUI included. The ID never
63
95
  // changes in a session, so the cached prompt prefix stays stable.
64
96
  event.system.push({ type: 'text', text: `OpenCode session ID: ${event.sessionID}` })
65
- if (!(await marker(event.sessionID))) return
97
+ const info = await applyMessageMode(event)
98
+ if (!info) return
66
99
  // The <discord-user> context of each prompt, built from its metadata, so the
67
100
  // stored text stays clean for the TUI and other clients. Same metadata, same
68
101
  // text on every request: the cached prompt prefix stays stable.
@@ -92,8 +125,8 @@ export default Plugin.define({
92
125
  await ctx.tool.hook('execute.before', async (event) => {
93
126
  if (event.tool !== 'shell' || !event.input || typeof event.input !== 'object' || Array.isArray(event.input)) return
94
127
  const info = await marker(event.sessionID)
95
- const dataDir = info?.get('dataDir')
96
- const lockPort = info?.get('lockPort')
128
+ const dataDir = info?.fields.get('dataDir')
129
+ const lockPort = info?.fields.get('lockPort')
97
130
  if (typeof dataDir !== 'string' || typeof lockPort !== 'number') return
98
131
  if (!('command' in event.input) || typeof event.input.command !== 'string') return
99
132
  const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`
@@ -102,7 +135,7 @@ export default Plugin.define({
102
135
  // File edits feed `kimaki session editors` (v1-compatible JSONL index).
103
136
  await ctx.tool.hook('execute.after', async (event) => {
104
137
  if (event.status !== 'completed' || !fileEditTool(event.tool)) return
105
- const dataDir = (await marker(event.sessionID))?.get('dataDir')
138
+ const dataDir = (await marker(event.sessionID))?.fields.get('dataDir')
106
139
  if (typeof dataDir !== 'string') return
107
140
  // Plugins stay silent: a failed append only loses one index entry.
108
141
  const recorded = await recordToolEdits({ dataDir, directory: ctx.location.directory, sessionId: event.sessionID, tool: event.tool, input: event.input })
package/src/sessions.ts CHANGED
@@ -12,6 +12,7 @@ import * as orm from 'drizzle-orm'
12
12
  import {
13
13
  cliContext,
14
14
  globalModel,
15
+ markerIsCurrent,
15
16
  oc,
16
17
  parseModel,
17
18
  projectOf,
@@ -273,7 +274,7 @@ export async function ensureSessionMarker(
273
274
  const info = await oc(bot, `get session ${sessionId}`, (client) => client.session.get({ sessionID: sessionId }))
274
275
  if (info instanceof Error) return info
275
276
  const marker = readMarker(info.metadata)
276
- if (marker && marker.dataDir === bot.dataDir && marker.lockPort === bot.lockPort) return
277
+ if (marker && markerIsCurrent(bot, marker)) return
277
278
  // Instructions first: the marker means "set up", so a failure here is retried on the next input.
278
279
  if (!marker) {
279
280
  logger.log(`adopting legacy session ${sessionId} of thread ${thread.id}`)
@@ -303,7 +304,7 @@ export async function refreshCliContext(bot: Bot) {
303
304
  }
304
305
  const marker = readMarker(info.metadata)
305
306
  if (!marker) continue
306
- if (marker.dataDir === bot.dataDir && marker.lockPort === bot.lockPort) continue
307
+ if (markerIsCurrent(bot, marker)) continue
307
308
  const metadata = { ...info.metadata, kimaki: { ...marker.fields, ...cliContext(bot) } }
308
309
  const result = await oc(bot, 'refresh Kimaki CLI context', (client) => client.session.update({ sessionID: sessionId, metadata }))
309
310
  if (result instanceof Error) return result
@@ -645,6 +645,44 @@ export function turnContext(metadata: Readonly<Record<string, unknown>> | undefi
645
645
  return `${files}<discord-user name="${escapeAttribute(username)}" user-id="${userId}" message-id="${messageId}" thread-id="${threadId}" thread-name="${escapeAttribute(threadName)}" />`
646
646
  }
647
647
 
648
+ // `kimaki --message-tool`: root sessions get this tool. Discord shows only its text;
649
+ // plain text parts stay visible in the OpenCode TUI and other clients.
650
+ // Wording adapted from Grok Bot's SendMessage tool.
651
+ export const MESSAGE_TOOL = 'send_message'
652
+
653
+ export const MESSAGE_TOOL_DESCRIPTION = dedent`
654
+ Send a concise reply to the user in the Discord thread. Discord shows only the text of send_message calls, not your plain text. Keep writing your normal plain text replies: they stay visible in the OpenCode terminal UI. Then repeat the part the Discord user needs as a short send_message, also for short replies like "Done" or "Yes, it does".
655
+
656
+ If you end a turn where the user waits on you without calling send_message, Discord shows silence.
657
+
658
+ Be very concise. Send meaningful beats only: a real result, a decision, a blocker, a change of plan. Do not narrate routine steps, retries or minor snags. Prefer fewer, higher-signal messages over a play-by-play, but never vanish into a long silent run.
659
+
660
+ An opening acknowledgement does not deliver the result. If you ran something for the user, send the actual result before you end the turn.
661
+
662
+ \`text\` is Discord markdown, shown like a normal message: headings, lists, code blocks, tables and <callout> blocks work. Send files with \`kimaki upload-to-discord\`, never through this tool. After your final send_message, end the turn.
663
+ `
664
+
665
+ export const MESSAGE_TOOL_INPUT = {
666
+ type: 'object',
667
+ properties: { text: { type: 'string', description: 'Markdown shown to the user in Discord. Keep it short.' } },
668
+ required: ['text'],
669
+ additionalProperties: false,
670
+ }
671
+
672
+ // Same text for the whole session (the prompt cache keeps hitting). It overrides
673
+ // the "Discord output" rules of the instruction entry, which assume Discord shows text parts.
674
+ export const MESSAGE_MODE_INSTRUCTIONS = dedent`
675
+ ## Discord shows only send_message
676
+
677
+ This overrides every rule above about what Discord shows: Discord does NOT show your plain text. Only the \`text\` of \`send_message\` tool calls reaches the Discord thread. The same session can also be open in the OpenCode terminal UI, which shows your plain text normally.
678
+
679
+ - Write your plain text replies as usual, in full. They are for the terminal UI.
680
+ - Then repeat a concise version as send_message, in Discord markdown: every reply, question and final answer, even one word of small talk.
681
+ - Where these instructions say "write the text first" before \`kimaki buttons\`, \`kimaki upload-request\` or \`kimaki sleep\`, call send_message with that text first.
682
+ - Never end a turn with a reply only in plain text.
683
+ - Never mention send_message, tool names or this rule to the user.
684
+ `
685
+
648
686
  export type VoiceCallUser = { id: string; username: string }
649
687
  export type VoiceCallProject = { channelId: string; channelName: string; directory: string }
650
688
  // skill.list entry of OpenCode's global location; path is the absolute SKILL.md.
@@ -499,6 +499,7 @@ export async function startTestBot({
499
499
  analytics,
500
500
  clock,
501
501
  schedulerIntervalMs,
502
+ messageTool,
502
503
  }: {
503
504
  dataDir: string
504
505
  twin: TestTwin
@@ -519,6 +520,7 @@ export async function startTestBot({
519
520
  // Scheduling tests pass manualClock() and null, then call bot.scheduler.runDueTasks().
520
521
  clock?: Clock
521
522
  schedulerIntervalMs?: number | null
523
+ messageTool?: boolean
522
524
  }): Promise<BotHandle> {
523
525
  // What credential resolution saves in production; `kimaki project add` reads it.
524
526
  const credentials: Credentials = twin.discord.botToken.includes(':')
@@ -547,6 +549,7 @@ export async function startTestBot({
547
549
  analytics: analytics ?? disabledAnalytics,
548
550
  ...(clock && { clock }),
549
551
  ...(schedulerIntervalMs !== undefined && { schedulerIntervalMs }),
552
+ ...(messageTool && { messageTool }),
550
553
  ...(realtimeBaseUrls && { realtimeBaseUrls }),
551
554
  transcriptionBaseUrls: {
552
555
  ...(geminiBaseUrl && { gemini: geminiBaseUrl }),
@@ -28,6 +28,7 @@ export function rootSessionId(events: V2Event[]): string {
28
28
  export const DEFAULT_PREFS: Prefs = {
29
29
  verbosity: 'tools',
30
30
  contextLimits: { 'openai/gpt-6-luna': 400_000 },
31
+ messageTool: false,
31
32
  }
32
33
 
33
34
  export function replay({
@@ -31,7 +31,7 @@ test('tools: names from tool.input.started, failed tool lines, read-only tools h
31
31
  test('tools at text verbosity: text, edits and errors only', () => {
32
32
  const { effects } = replay({
33
33
  events: loadFixture('tools.events.jsonl'),
34
- prefs: { verbosity: 'text', contextLimits: {} },
34
+ prefs: { verbosity: 'text', contextLimits: {}, messageTool: false },
35
35
  })
36
36
  expect(effectLines(effects)).toMatchInlineSnapshot(`
37
37
  [
@@ -324,7 +324,7 @@ test('permission: two requests, the rejected one fails its tool call', () => {
324
324
  test('shell-background: background shell line even without side effects, finished line when its job ends', () => {
325
325
  const events = loadFixture('shell-background.events.jsonl')
326
326
  const view = emptyView({ sessionId: 'ses_ee56d6caeffech0rMI5NTnkky1', channelId: 'channel', directory: '/project', isNew: false })
327
- const { effects, view: end } = replay({ events, view, prefs: { verbosity: 'tools', contextLimits: {} } })
327
+ const { effects, view: end } = replay({ events, view, prefs: { verbosity: 'tools', contextLimits: {}, messageTool: false } })
328
328
  expect(effectLines(effects)).toMatchInlineSnapshot(`
329
329
  [
330
330
  "» **user:** start a task in background that runs sleep 100",
@@ -55,6 +55,37 @@ test('text-only turn: banner, text, typing, footer with context percent', () =>
55
55
  expect(view.turn).toBe(null)
56
56
  })
57
57
 
58
+ test('message mode: send_message shows as markdown, plain text only when the turn sent nothing', () => {
59
+ const turn = loadFixture('tools.events.jsonl').filter((event) => !event.type.startsWith('session.tool.'))
60
+ const prefs = { ...DEFAULT_PREFS, messageTool: true }
61
+ const textIndex = turn.findIndex((event) => event.type === 'session.text.ended')
62
+ const text = turn[textIndex]!
63
+ if (text.type !== 'session.text.ended') throw new Error('Expected text')
64
+ const call = { sessionID: text.data.sessionID, assistantMessageID: text.data.assistantMessageID, id: 'call_message' }
65
+ const durable = { aggregateID: text.data.sessionID, seq: 1, version: 1 as const }
66
+ const message: V2Event[] = [
67
+ { id: 'evt_message_input', created: text.created, durable, type: 'session.tool.input.started', data: { ...call, name: 'send_message' } },
68
+ { id: 'evt_message_called', created: text.created, durable, type: 'session.tool.called', data: { ...call, executed: false, input: { text: 'Visible **reply**' } } },
69
+ ]
70
+ const sent = replay({ prefs, events: [...turn.slice(0, textIndex), ...message, ...turn.slice(textIndex)] })
71
+ const silent = replay({ prefs, events: turn })
72
+ const lines = (effects: typeof sent.effects) => effectLines(effects).filter((line) => !line.startsWith('»') && !line.startsWith('[typing'))
73
+ expect({ sent: lines(sent.effects), silent: lines(silent.effects) }).toMatchInlineSnapshot(`
74
+ {
75
+ "sent": [
76
+ "-# *using openai/gpt-6-luna low ⋅ build*",
77
+ "Visible **reply**",
78
+ "-# *project ⋅ main ⋅ 17s ⋅ 6% ⋅ gpt-6-luna*",
79
+ ],
80
+ "silent": [
81
+ "-# *using openai/gpt-6-luna low ⋅ build*",
82
+ "Done. \`tmp-events/hello.txt\` contains \`hello world\`.",
83
+ "-# *project ⋅ main ⋅ 17s ⋅ 6% ⋅ gpt-6-luna*",
84
+ ],
85
+ }
86
+ `)
87
+ })
88
+
58
89
  test('kimaki sleep in a turn makes its footer silent, the next turn notifies again', () => {
59
90
  const turn = loadFixture('tools.events.jsonl').filter((event) => !event.type.startsWith('session.tool.'))
60
91
  const end = turn.findIndex((event) => event.type === 'session.execution.succeeded')