kimaki 1.1.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 (76) hide show
  1. package/dist/bot.d.ts +6 -1
  2. package/dist/bot.js +9 -3
  3. package/dist/bot.js.map +1 -1
  4. package/dist/cli/bot.js +13 -5
  5. package/dist/cli/bot.js.map +1 -1
  6. package/dist/cli-lock.e2e.test.js +4 -2
  7. package/dist/cli-lock.e2e.test.js.map +1 -1
  8. package/dist/cli-sessions.e2e.test.js +2 -4
  9. package/dist/cli-sessions.e2e.test.js.map +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.d.ts +1 -1
  17. package/dist/ingress.js +25 -17
  18. package/dist/ingress.js.map +1 -1
  19. package/dist/lock-routes.d.ts +13 -13
  20. package/dist/main.d.ts +2 -0
  21. package/dist/main.js +3 -1
  22. package/dist/main.js.map +1 -1
  23. package/dist/plugin/index.js +42 -9
  24. package/dist/plugin/index.js.map +1 -1
  25. package/dist/scheduler.d.ts +1 -1
  26. package/dist/session-events.d.ts +1 -1
  27. package/dist/sessions.d.ts +5 -5
  28. package/dist/sessions.js +3 -3
  29. package/dist/sessions.js.map +1 -1
  30. package/dist/slash-commands.js +1 -1
  31. package/dist/slash-commands.js.map +1 -1
  32. package/dist/system-prompt.d.ts +14 -0
  33. package/dist/system-prompt.js +34 -0
  34. package/dist/system-prompt.js.map +1 -1
  35. package/dist/test/harness.d.ts +2 -1
  36. package/dist/test/harness.js +2 -1
  37. package/dist/test/harness.js.map +1 -1
  38. package/dist/test/replay.js +1 -0
  39. package/dist/test/replay.js.map +1 -1
  40. package/dist/thread-reducer.d.ts +3 -0
  41. package/dist/thread-reducer.fixtures.test.js +2 -2
  42. package/dist/thread-reducer.fixtures.test.js.map +1 -1
  43. package/dist/thread-reducer.js +32 -4
  44. package/dist/thread-reducer.js.map +1 -1
  45. package/dist/thread-reducer.test.js +31 -0
  46. package/dist/thread-reducer.test.js.map +1 -1
  47. package/dist/tunnel.d.ts +12 -0
  48. package/dist/tunnel.js +207 -0
  49. package/dist/tunnel.js.map +1 -0
  50. package/dist/tunnel.test.d.ts +1 -0
  51. package/dist/tunnel.test.js +38 -0
  52. package/dist/tunnel.test.js.map +1 -0
  53. package/dist/voice-call.js +1 -1
  54. package/dist/voice-call.js.map +1 -1
  55. package/package.json +5 -5
  56. package/skills/kimaki-tunnel/SKILL.md +14 -10
  57. package/src/bot.ts +16 -4
  58. package/src/cli/bot.ts +12 -5
  59. package/src/cli-lock.e2e.test.ts +4 -2
  60. package/src/event-loop.ts +4 -1
  61. package/src/hello-thread.e2e.test.ts +35 -0
  62. package/src/ingress.ts +25 -16
  63. package/src/main.ts +6 -1
  64. package/src/message-tool.e2e.test.ts +45 -0
  65. package/src/plugin/index.ts +42 -9
  66. package/src/sessions.ts +3 -2
  67. package/src/slash-commands.ts +1 -1
  68. package/src/system-prompt.ts +38 -0
  69. package/src/test/harness.ts +3 -0
  70. package/src/test/replay.ts +1 -0
  71. package/src/thread-reducer.fixtures.test.ts +2 -2
  72. package/src/thread-reducer.test.ts +31 -0
  73. package/src/thread-reducer.ts +36 -4
  74. package/src/tunnel.test.ts +39 -0
  75. package/src/tunnel.ts +209 -0
  76. package/src/voice-call.ts +1 -1
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
@@ -39,9 +40,13 @@ import { isVoiceAttachment, parseVoiceMessage, transcribe, voiceMediaType, type
39
40
 
40
41
  const logger = createLogger('INGRESS')
41
42
 
42
- // Owner, Administrator, Manage Server, or a role named "Kimaki". A role named
43
- // "no-kimaki" always denies. Missing member data fails closed.
44
- export async function canUseKimaki({ guild, userId }: { guild: Guild; userId: string }): Promise<boolean> {
43
+ // Owner, Administrator, Manage Server, or a role named "Kimaki"; with
44
+ // --allow-all-users every member. A role named "no-kimaki" always denies.
45
+ // Missing member data fails closed.
46
+ export async function canUseKimaki(
47
+ bot: Pick<Bot, 'allowAllUsers'>,
48
+ { guild, userId }: { guild: Guild; userId: string },
49
+ ): Promise<boolean> {
45
50
  const member = await guild.members.fetch({ user: userId, force: true }).catch((error: Error) => error)
46
51
  if (member instanceof Error) {
47
52
  // Still denied, but say why: a REST failure must not look like a missing role.
@@ -49,19 +54,22 @@ export async function canUseKimaki({ guild, userId }: { guild: Guild; userId: st
49
54
  return false
50
55
  }
51
56
  if (!(member instanceof GuildMember)) return false
52
- return memberCanUseKimaki(member)
57
+ return memberCanUseKimaki(bot, member)
53
58
  }
54
59
 
55
60
  // MESSAGE_CREATE carries the author's current roles: no REST fetch on the hot path.
56
61
  // Discord omits the member only for webhook and ephemeral messages, which never get here.
57
- function authorCanUseKimaki(message: Message<true>): Promise<boolean> {
58
- if (message.member instanceof GuildMember) return Promise.resolve(memberCanUseKimaki(message.member))
59
- return canUseKimaki({ guild: message.guild, userId: message.author.id })
62
+ function authorCanUseKimaki(bot: Bot, message: Message<true>): Promise<boolean> {
63
+ if (message.member instanceof GuildMember) return Promise.resolve(memberCanUseKimaki(bot, message.member))
64
+ return canUseKimaki(bot, { guild: message.guild, userId: message.author.id })
60
65
  }
61
66
 
62
- function memberCanUseKimaki(member: GuildMember): boolean {
67
+ function memberCanUseKimaki(bot: Pick<Bot, 'allowAllUsers'>, member: GuildMember): boolean {
63
68
  const roleNames = member.roles.cache.map((role) => role.name.toLowerCase())
64
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')
72
+ if (bot.allowAllUsers) return true
65
73
  if (member.guild.ownerId === member.id) return true
66
74
  if (member.permissions.has(PermissionFlagsBits.Administrator)) return true
67
75
  if (member.permissions.has(PermissionFlagsBits.ManageGuild)) return true
@@ -208,7 +216,7 @@ async function handleMessage(bot: Bot, message: Message) {
208
216
  if (!project) return
209
217
  if (project.channel_type === 'voice') {
210
218
  // The text chat of the voice channel talks to the running call.
211
- if (message.author.bot || thread || !(await authorCanUseKimaki(message))) return
219
+ if (message.author.bot || thread || !(await authorCanUseKimaki(bot, message))) return
212
220
  bot.features.voiceCalls.text(bot, {
213
221
  guildId: message.guild.id,
214
222
  channelId,
@@ -217,11 +225,10 @@ async function handleMessage(bot: Bot, message: Message) {
217
225
  })
218
226
  return
219
227
  }
220
- if (message.author.bot) {
221
- if (message.author.id !== bot.discord.user?.id) return
228
+ if (message.author.id === bot.discord.user?.id) {
222
229
  return handleRemoteEnvelope(bot, { message, channelId, threadId: thread?.id ?? null })
223
230
  }
224
- if (!(await authorCanUseKimaki(message))) {
231
+ if (!(await authorCanUseKimaki(bot, message))) {
225
232
  logger.log(`ignoring ${message.author.username}: no Kimaki permission`)
226
233
  return
227
234
  }
@@ -298,7 +305,9 @@ export function registerIngress(bot: Bot) {
298
305
  }
299
306
 
300
307
  bot.discord.on(Events.MessageCreate, (message) => {
301
- 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
302
311
  serialize(message.channelId, () => handleMessage(bot, message))
303
312
  })
304
313
  // Deleting a queued Discord message removes it from the queue.
@@ -317,7 +326,7 @@ export function registerIngress(bot: Bot) {
317
326
  serialize(message.channelId, async () => {
318
327
  const full = message.partial ? await message.fetch().catch(() => null) : message
319
328
  if (!full?.guild || !queuedItemFor(bot, { threadId: full.channelId, messageId: full.id })) return
320
- if (!(await canUseKimaki({ guild: full.guild, userId: full.author.id }))) return
329
+ if (!(await canUseKimaki(bot, { guild: full.guild, userId: full.author.id }))) return
321
330
  // The re-queued prompt must carry the message's attachments again.
322
331
  const files = await saveAttachments({ dataDir: bot.dataDir, messageId: full.id, attachments: [...full.attachments.values()] })
323
332
  if (files instanceof Error) {
package/src/main.ts CHANGED
@@ -65,8 +65,11 @@ export type StartBotOptions = {
65
65
  // How often due tasks and wakes run; null: never (tests call scheduler.runDueTasks).
66
66
  schedulerIntervalMs?: number | null
67
67
  autoWorktrees?: boolean
68
+ allowAllUsers?: boolean
68
69
  // Built-in skill ids denied in new sessions (builtin-skills.ts).
69
70
  disabledSkills?: readonly string[]
71
+ // `kimaki --message-tool`: agents also reply through send_message; Discord hides their plain text.
72
+ messageTool?: boolean
70
73
  }
71
74
 
72
75
  export type BotHandle = Bot & {
@@ -145,7 +148,7 @@ export async function startBot(options: StartBotOptions): Promise<Error | BotHan
145
148
  if (result instanceof Error) logger.warn(`transcript of ${sessionId} not synced`, result)
146
149
  })
147
150
  }
148
- 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 })
149
152
  const loaded = await eventLoop.load()
150
153
  if (loaded instanceof Error) return loaded
151
154
  // Before the service is used, so a service started by ensure() loads it at once.
@@ -182,7 +185,9 @@ export async function startBot(options: StartBotOptions): Promise<Error | BotHan
182
185
  transcriptionBaseUrls: options.transcriptionBaseUrls ?? {},
183
186
  realtimeBaseUrls: options.realtimeBaseUrls ?? {},
184
187
  autoWorktrees: options.autoWorktrees ?? false,
188
+ allowAllUsers: options.allowAllUsers ?? false,
185
189
  disabledSkills: options.disabledSkills ?? [],
190
+ messageTool: options.messageTool ?? false,
186
191
  features: { withSleepLock: createSleepLock(), waitForPlugin: createPluginWait({ opencode }), agentUi, voiceCalls },
187
192
  }
188
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
@@ -461,7 +461,7 @@ export function registerSlashCommands(
461
461
  if (project instanceof Error) return logger.warn(project)
462
462
  if (!project) return
463
463
  const guild = interaction.guild ?? (await discord.guilds.fetch(interaction.guildId).catch(() => null))
464
- if (!guild || !(await canUseKimaki({ guild, userId: interaction.user.id }))) {
464
+ if (!guild || !(await canUseKimaki(bot, { guild, userId: interaction.user.id }))) {
465
465
  if (interaction.isAutocomplete()) return respondChoices(interaction, [])
466
466
  if (interaction.isRepliable()) {
467
467
  await interaction.reply({ content: 'You do not have permission to use Kimaki', flags: MessageFlags.Ephemeral })
@@ -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')
@@ -55,6 +55,7 @@ import {
55
55
  import { closePermission, hydratePermissions, showPermission, STATUS, type PendingPermission } from './permissions.ts'
56
56
  import { closeForm, formatAnswer, hydrateForms, showForm, withAnswer, type PendingForm } from './questions.ts'
57
57
  import { cancelQueued, deliverQueued, enqueueInput, hydrateQueue, promoteQueued, queuedItems, type PendingInput } from './queue.ts'
58
+ import { MESSAGE_TOOL } from './system-prompt.ts'
58
59
 
59
60
  export type Turn = {
60
61
  startedAt: number
@@ -64,6 +65,14 @@ export type Turn = {
64
65
  tokens: number
65
66
  // `kimaki sleep` ran in this turn: the session wakes later, so the footer is silent.
66
67
  slept: boolean
68
+ // Message mode (Prefs.messageTool): a send_message call was shown in this turn.
69
+ messaged: boolean
70
+ // Message mode: the last hidden text, shown at the end if the turn sent no message.
71
+ unsent: string | null
72
+ }
73
+
74
+ function newTurn(at: number): Turn {
75
+ return { startedAt: at, model: null, agent: null, tokens: 0, slept: false, messaged: false, unsent: null }
67
76
  }
68
77
 
69
78
  export type Child = {
@@ -186,6 +195,8 @@ export type Prefs = {
186
195
  verbosity: Verbosity
187
196
  // "providerID/modelID" -> context window size in tokens.
188
197
  contextLimits: Readonly<Record<string, number>>
198
+ // `kimaki --message-tool`: Discord hides root plain text (kept for the TUI), only send_message shows.
199
+ messageTool: boolean
189
200
  }
190
201
 
191
202
  const RETRY_NOTICE_INTERVAL_MS = 10_000
@@ -281,6 +292,9 @@ function contextPercent({ turn, prefs }: { turn: Draft<Turn>; prefs: Prefs }): n
281
292
 
282
293
  // The footer closing the answer of `turn` at `at`.
283
294
  function emitFooter({ draft, prefs, emit, turn, at, notify }: Context & { turn: Draft<Turn>; at: number; notify: boolean }) {
295
+ // Message mode: a model that never called send_message would leave the user in silence.
296
+ if (!turn.messaged && turn.unsent) emit({ type: 'markdown', text: turn.unsent, blankLineBefore: draft.lastKind === 'tool' })
297
+ turn.unsent = null
284
298
  // No model step ran (a compaction-only execution): nothing to summarize.
285
299
  if (!turn.model) return
286
300
  // A child still runs: the answer comes later.
@@ -346,11 +360,11 @@ function applyRoot(context: Context & { event: V2Event }) {
346
360
  emit({ type: 'send', text: asSubtext(`Working directory changed to ${event.data.location.directory}`) })
347
361
  return
348
362
  case 'session.execution.started':
349
- draft.turn ??= { startedAt: event.created, model: null, agent: null, tokens: 0, slept: false }
363
+ draft.turn ??= newTurn(event.created)
350
364
  return
351
365
  case 'session.step.started': {
352
366
  const model = { providerID: event.data.model.providerID, id: event.data.model.id }
353
- draft.turn ??= { startedAt: event.created, model: null, agent: null, tokens: 0, slept: false }
367
+ draft.turn ??= newTurn(event.created)
354
368
  draft.turn.model = model
355
369
  draft.turn.agent = event.data.agent
356
370
  if (draft.bannerPending) emit({ type: 'send', text: formatBanner({ model: event.data.model, agent: event.data.agent }) })
@@ -373,6 +387,10 @@ function applyRoot(context: Context & { event: V2Event }) {
373
387
  case 'session.text.ended': {
374
388
  const text = event.data.text.trim()
375
389
  if (!text) return
390
+ if (prefs.messageTool && draft.turn) {
391
+ draft.turn.unsent = text
392
+ return
393
+ }
376
394
  emit({ type: 'markdown', text, blankLineBefore: draft.lastKind === 'tool' })
377
395
  draft.lastKind = 'text'
378
396
  return
@@ -434,6 +452,7 @@ function applyRoot(context: Context & { event: V2Event }) {
434
452
  emitFooter({ ...context, turn: draft.turn, at: event.created, notify: false })
435
453
  draft.turn.startedAt = event.created
436
454
  draft.turn.slept = false
455
+ draft.turn.messaged = false
437
456
  draft.lastKind = null
438
457
  }
439
458
  return deliverQueued({ draft, emit, inboxID: event.data.inboxID })
@@ -494,6 +513,15 @@ function applyTool(context: Context & { event: V2Event; label: string | undefine
494
513
  if (reused) Object.assign(reused, { ...call, agent: call.agent || reused.agent })
495
514
  }
496
515
  draft.tools[key] = tool
516
+ // The plugin offers send_message to root sessions only (MESSAGE_TOOL).
517
+ if (name === MESSAGE_TOOL && label === undefined) {
518
+ const text = stringInput(input, 'text').trim()
519
+ if (draft.turn) draft.turn.messaged = true
520
+ if (!text) return
521
+ context.emit({ type: 'markdown', text, blankLineBefore: draft.lastKind === 'tool' })
522
+ draft.lastKind = 'text'
523
+ return
524
+ }
497
525
  if (render && isToolVisible({ name, input }, prefs.verbosity)) toolLine(context, formatToolLine({ name, input }, { label }))
498
526
  return
499
527
  }
@@ -613,7 +641,7 @@ function applyKimaki({ draft, event, prefs, emit }: Context & { event: KimakiEve
613
641
  const active = new Set(event.activeSessionIds)
614
642
  for (const [id, child] of Object.entries(draft.children)) child.running = active.has(id)
615
643
  if (!active.has(draft.sessionId)) draft.turn = null
616
- else draft.turn ??= { startedAt: event.at, model: null, agent: null, tokens: 0, slept: false }
644
+ else draft.turn ??= newTurn(event.at)
617
645
  // Nothing runs: tool calls whose end was missed while disconnected are over.
618
646
  if (!isBusy(draft)) draft.tools = {}
619
647
  for (const session of event.sessions) {
@@ -645,9 +673,13 @@ export function replayEffects({
645
673
  const blocks = messages.flatMap((message): Block[] => {
646
674
  if (message.type !== 'assistant') return []
647
675
  return message.content.flatMap((part): Block[] => {
648
- if (part.type === 'text') return part.text.trim() ? [{ kind: 'text', text: part.text.trim() }] : []
676
+ if (part.type === 'text') return part.text.trim() && !prefs.messageTool ? [{ kind: 'text', text: part.text.trim() }] : []
649
677
  if (part.type !== 'tool' || part.state.status === 'streaming') return []
650
678
  const { input, metadata } = part.state
679
+ if (part.name === MESSAGE_TOOL) {
680
+ const text = stringInput(input, 'text').trim()
681
+ return text ? [{ kind: 'text', text }] : []
682
+ }
651
683
  const isExecute = part.name === 'execute'
652
684
  const lines = [{ name: part.name, input }, ...(isExecute ? executeCalls(metadata) : [])]
653
685
  .filter((call) => isToolVisible(call, prefs.verbosity))
@@ -0,0 +1,39 @@
1
+ import { expect, test } from 'vitest'
2
+ import { detectPortFromText, routeName } from './tunnel.ts'
3
+
4
+ test('detects the dev server port and skips noise lines', () => {
5
+ const vite = '\u001b[33mPort 5173 is in use, trying another one...\u001b[39m\n ➜ Local: \u001b[36mhttp://localhost:\u001b[1m5174\u001b[22m/\u001b[39m\n'
6
+ expect({
7
+ vite: detectPortFromText(vite),
8
+ next: detectPortFromText(' - Local: http://127.0.0.1:3000'),
9
+ bare: detectPortFromText('Server running at 0.0.0.0:4321'),
10
+ inspector: detectPortFromText('Debugger listening on ws://127.0.0.1:9229/abc\nready on http://localhost:4173'),
11
+ outOfRange: detectPortFromText('listening on localhost:70000'),
12
+ portOnly: detectPortFromText('Port 5173 is in use'),
13
+ }).toMatchInlineSnapshot(`
14
+ {
15
+ "bare": 4321,
16
+ "inspector": 4173,
17
+ "next": 3000,
18
+ "outOfRange": null,
19
+ "portOnly": null,
20
+ "vite": 5174,
21
+ }
22
+ `)
23
+ })
24
+
25
+ test('route name: explicit, then tuistory session, then random', () => {
26
+ expect({
27
+ explicit: routeName({ name: 'docs', tuistorySessionId: 'abc123' }),
28
+ invalid: String(routeName({ name: 'Bad_Name' })),
29
+ tuistory: routeName({ tuistorySessionId: 'ABC123' }),
30
+ random: String(routeName({})).length,
31
+ }).toMatchInlineSnapshot(`
32
+ {
33
+ "explicit": "docs",
34
+ "invalid": "Error: Invalid tunnel ID "Bad_Name": use lowercase letters, digits and dashes",
35
+ "random": 16,
36
+ "tuistory": "abc123",
37
+ }
38
+ `)
39
+ })