kimaki 0.20.0 → 0.20.1

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.
@@ -1,11 +1,14 @@
1
1
  // User-defined OpenCode command handler.
2
2
  // Handles slash commands that map to user-configured commands in opencode.json.
3
- import { ChannelType, MessageFlags, } from 'discord.js';
3
+ import { ChannelType, MessageFlags, ThreadAutoArchiveDuration, } from 'discord.js';
4
4
  import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js';
5
5
  import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
6
6
  import { createLogger, LogPrefix } from '../logger.js';
7
- import { getChannelDirectory, getThreadSession } from '../database.js';
7
+ import { getChannelDirectory, getChannelWorktreesEnabled, getThreadSession, } from '../database.js';
8
8
  import { store } from '../store.js';
9
+ import { isGitRepositoryRoot } from '../worktrees.js';
10
+ import { formatAutoWorktreeName, createWorktreeInBackground, worktreeCreatingMessage, } from './new-worktree.js';
11
+ import { WORKTREE_PREFIX } from './merge-worktree.js';
9
12
  import fs from 'node:fs';
10
13
  const userCommandLogger = createLogger(LogPrefix.USER_CMD);
11
14
  const DISCORD_MESSAGE_LIMIT = 2000;
@@ -107,23 +110,62 @@ export const handleUserCommand = async ({ command, appId, }) => {
107
110
  }
108
111
  else if (textChannel) {
109
112
  // Running in text channel - create a new thread
113
+ // Check if worktrees should be enabled (CLI flag OR channel setting),
114
+ // mirroring the logic in discord-bot.ts message handler.
115
+ const wantsWorktrees = store.getState().useWorktrees ||
116
+ (await getChannelWorktreesEnabled(textChannel.id));
117
+ const shouldUseWorktrees = wantsWorktrees && (await isGitRepositoryRoot(projectDirectory));
118
+ if (wantsWorktrees && !shouldUseWorktrees) {
119
+ userCommandLogger.warn(`[WORKTREE] Skipping automatic worktree for non-git project directory: ${projectDirectory}`);
120
+ }
121
+ const baseThreadName = commandInvocation.slice(0, DISCORD_THREAD_NAME_LIMIT);
122
+ const threadName = shouldUseWorktrees
123
+ ? `${WORKTREE_PREFIX}${baseThreadName}`
124
+ : baseThreadName;
110
125
  const starterMessage = await textChannel.send({
111
126
  content: threadOpeningMessage,
112
127
  flags: SILENT_MESSAGE_FLAGS,
113
128
  });
114
129
  const newThread = await starterMessage.startThread({
115
- name: commandInvocation.slice(0, DISCORD_THREAD_NAME_LIMIT),
116
- autoArchiveDuration: 1440,
130
+ name: threadName.slice(0, DISCORD_THREAD_NAME_LIMIT),
131
+ autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
117
132
  reason: `OpenCode command: ${commandName}`,
118
133
  });
119
134
  // Add user to thread so it appears in their sidebar
120
135
  await newThread.members.add(command.user.id);
136
+ // Create worktree in background if enabled, same as discord-bot.ts
137
+ let worktreePromise;
138
+ if (shouldUseWorktrees) {
139
+ const worktreeName = formatAutoWorktreeName(baseThreadName.slice(0, 50));
140
+ userCommandLogger.log(`[WORKTREE] Creating worktree: ${worktreeName}`);
141
+ const worktreeStatusMessage = await newThread
142
+ .send({
143
+ content: worktreeCreatingMessage(worktreeName),
144
+ flags: SILENT_MESSAGE_FLAGS,
145
+ })
146
+ .catch(() => undefined);
147
+ worktreePromise = createWorktreeInBackground({
148
+ thread: newThread,
149
+ starterMessage: worktreeStatusMessage,
150
+ worktreeName,
151
+ projectDirectory,
152
+ rest: command.client.rest,
153
+ });
154
+ }
155
+ const sessionDirectory = await (async () => {
156
+ if (!worktreePromise)
157
+ return projectDirectory;
158
+ const result = await worktreePromise;
159
+ if (result instanceof Error)
160
+ return projectDirectory;
161
+ return result;
162
+ })();
121
163
  await command.editReply(`Started /${commandName} in ${newThread.toString()}`);
122
164
  const runtime = getOrCreateRuntime({
123
165
  threadId: newThread.id,
124
166
  thread: newThread,
125
167
  projectDirectory,
126
- sdkDirectory: projectDirectory,
168
+ sdkDirectory: sessionDirectory,
127
169
  channelId: textChannel.id,
128
170
  appId,
129
171
  });
@@ -261,9 +261,8 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
261
261
  discordLogger.warn(`[GATEWAY] Shard ${shardId} reconnecting: ${parts.join(', ')}`);
262
262
  if (state.attempts >= MAX_RECONNECT_ATTEMPTS) {
263
263
  discordLogger.error(`[GATEWAY] Shard ${shardId} exceeded ${MAX_RECONNECT_ATTEMPTS} reconnect attempts, self-restarting`);
264
- // Self-restart: cleanup then spawn a fresh process. This works whether
265
- // the bin.ts wrapper is present or not (unlike process.exit(1) which
266
- // only restarts when the wrapper is the parent).
264
+ // Self-restart: cleanup, then exit non-zero so the bin.ts wrapper
265
+ // restarts us. Without the wrapper this exits after logging a warning.
267
266
  void selfRestart('gateway-reconnect-limit');
268
267
  }
269
268
  });
@@ -1166,7 +1165,13 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
1166
1165
  discordLogger.error('Failed to write heap snapshot:', e instanceof Error ? e.message : String(e));
1167
1166
  });
1168
1167
  });
1169
- // Self-restart: prefer bin.ts wrapper (keeps Ctrl+C), fall back to detached spawn.
1168
+ // Self-restart: exit with code 1 so the bin.ts wrapper restarts us with
1169
+ // exponential backoff and crash-loop detection. When running without the
1170
+ // wrapper (e.g. `tsx src/cli.ts`), the process just exits and the user
1171
+ // sees the non-zero exit code — they should use `tsx src/bin.ts` instead
1172
+ // for auto-restart support. The previous detached-spawn fallback was
1173
+ // unreliable: process.exit(0) could kill the child before it started,
1174
+ // there was no backoff between restarts, and spawn failures were silent.
1170
1175
  let selfRestarting = false;
1171
1176
  async function selfRestart(reason) {
1172
1177
  if (selfRestarting) {
@@ -1181,20 +1186,10 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
1181
1186
  catch (error) {
1182
1187
  voiceLogger.error(`[${reason}] Error during shutdown:`, error);
1183
1188
  }
1184
- if (process.env.__KIMAKI_CHILD) {
1185
- discordLogger.log('Wrapper detected, exiting for wrapper restart');
1186
- process.exit(1);
1189
+ if (!process.env.__KIMAKI_CHILD) {
1190
+ discordLogger.warn('No restart wrapper detected. Run via `tsx src/bin.ts` (dev) or `kimaki` (npm) for auto-restart on crash.');
1187
1191
  }
1188
- const { spawn } = await import('node:child_process');
1189
- const env = { ...process.env };
1190
- delete env.__KIMAKI_CHILD;
1191
- spawn(process.argv[0], [...process.execArgv, ...process.argv.slice(1)], {
1192
- stdio: 'inherit',
1193
- detached: true,
1194
- cwd: process.cwd(),
1195
- env,
1196
- }).unref();
1197
- process.exit(0);
1192
+ process.exit(1);
1198
1193
  }
1199
1194
  process.on('SIGUSR2', () => {
1200
1195
  discordLogger.log('Received SIGUSR2, restarting after cleanup...');
@@ -590,14 +590,31 @@ export async function resolveWorkingDirectory({ channel, }) {
590
590
  workingDirectory,
591
591
  };
592
592
  }
593
+ // Discord upload size limits per server boost tier (bytes).
594
+ // Bots default to 25 MB; boosted servers raise the ceiling.
595
+ export const DISCORD_DEFAULT_MAX_FILE_SIZE = 25 * 1024 * 1024;
593
596
  /**
594
597
  * Upload files to a Discord thread/channel in a single message.
595
598
  * Sending all files in one message causes Discord to display images in a grid layout.
599
+ *
600
+ * Files are validated against the Discord upload size limit before reading them
601
+ * into memory. Pass `maxFileSize` if you know the guild's boost tier limit;
602
+ * otherwise the conservative 25 MB bot default is used.
596
603
  */
597
- export async function uploadFilesToDiscord({ threadId, botToken, files, }) {
604
+ export async function uploadFilesToDiscord({ threadId, botToken, files, maxFileSize = DISCORD_DEFAULT_MAX_FILE_SIZE, }) {
598
605
  if (files.length === 0) {
599
606
  return;
600
607
  }
608
+ // Fail fast: check file sizes before reading anything into memory
609
+ const sizeLimit = maxFileSize ?? DISCORD_DEFAULT_MAX_FILE_SIZE;
610
+ for (const file of files) {
611
+ const stat = fs.statSync(file);
612
+ if (stat.size > sizeLimit) {
613
+ const fileMB = (stat.size / 1024 / 1024).toFixed(1);
614
+ const limitMB = (sizeLimit / 1024 / 1024).toFixed(0);
615
+ throw new Error(`File "${path.basename(file)}" is ${fileMB} MB, which exceeds Discord's ${limitMB} MB upload limit`);
616
+ }
617
+ }
601
618
  // Build attachments array for all files
602
619
  const attachments = files.map((file, index) => ({
603
620
  id: index,
@@ -272,9 +272,9 @@ const MAX_BASH_COMMAND_INLINE_LENGTH = 100;
272
272
  * 2. Long/multiline command with description → show description
273
273
  * 3. Long/multiline command without description → truncate first line of command
274
274
  *
275
- * The description field was removed from the bash tool schema in newer opencode
276
- * versions, so case 3 is the common path now. Without this fallback, long commands
277
- * would render as just "┣ bash" with no context.
275
+ * The description field was removed from the opencode v2 bash tool schema but
276
+ * kimaki's system prompt instructs models to always send it as an extra field.
277
+ * Case 3 is the fallback when a model omits it.
278
278
  */
279
279
  export function formatBashToolTitle({ command, description, stateTitle, }) {
280
280
  if (!command && !description && !stateTitle)
@@ -292,10 +292,12 @@ export function formatBashToolTitle({ command, description, stateTitle, }) {
292
292
  return ` _${escapeInlineMarkdown(description)}_`;
293
293
  }
294
294
  if (firstMeaningfulLine.length > 0) {
295
- const truncated = firstMeaningfulLine.length > MAX_BASH_COMMAND_INLINE_LENGTH
296
- ? firstMeaningfulLine.slice(0, MAX_BASH_COMMAND_INLINE_LENGTH) + '…'
295
+ const needsTruncation = firstMeaningfulLine.length > MAX_BASH_COMMAND_INLINE_LENGTH;
296
+ const base = needsTruncation
297
+ ? firstMeaningfulLine.slice(0, MAX_BASH_COMMAND_INLINE_LENGTH)
297
298
  : firstMeaningfulLine;
298
- return ` _${escapeInlineMarkdown(truncated)}_`;
299
+ // Always add ellipsis when showing a partial command (multiline or length-truncated)
300
+ return ` _${escapeInlineMarkdown(base)}…_`;
299
301
  }
300
302
  if (stateTitle) {
301
303
  return ` _${escapeInlineMarkdown(stateTitle)}_`;
@@ -47,7 +47,7 @@ describe('formatBashToolTitle', () => {
47
47
  expect(formatBashToolTitle({ command: 'echo hello' })).toMatchInlineSnapshot(`" _echo hello_"`);
48
48
  });
49
49
  test('multiline command without description truncates to first line', () => {
50
- expect(formatBashToolTitle({ command: 'echo hello\necho world\necho done' })).toMatchInlineSnapshot(`" _echo hello_"`);
50
+ expect(formatBashToolTitle({ command: 'echo hello\necho world\necho done' })).toMatchInlineSnapshot(`" _echo hello…_"`);
51
51
  });
52
52
  test('long single-line command is truncated with ellipsis', () => {
53
53
  const longCommand = 'a'.repeat(150);
@@ -68,16 +68,16 @@ describe('formatBashToolTitle', () => {
68
68
  expect(formatBashToolTitle({ command: '' })).toBe('');
69
69
  });
70
70
  test('leading blank line skipped, uses first meaningful line', () => {
71
- expect(formatBashToolTitle({ command: '\npnpm test\npnpm build' })).toMatchInlineSnapshot(`" _pnpm test_"`);
71
+ expect(formatBashToolTitle({ command: '\npnpm test\npnpm build' })).toMatchInlineSnapshot(`" _pnpm test…_"`);
72
72
  });
73
73
  test('whitespace-only first line skipped', () => {
74
- expect(formatBashToolTitle({ command: ' \npnpm test' })).toMatchInlineSnapshot(`" _pnpm test_"`);
74
+ expect(formatBashToolTitle({ command: ' \npnpm test' })).toMatchInlineSnapshot(`" _pnpm test…_"`);
75
75
  });
76
76
  test('no description field (new opencode) with multiline command', () => {
77
77
  // This is the exact scenario that was broken: opencode removed `description`
78
78
  // from the bash tool schema, so multiline commands rendered as just "┣ bash"
79
79
  const command = 'git diff HEAD~1 --stat && git log --oneline -5';
80
- expect(formatBashToolTitle({ command: command + '\n' + 'echo done' })).toMatchInlineSnapshot(`" _git diff HEAD\\~1 --stat && git log --oneline -5_"`);
80
+ expect(formatBashToolTitle({ command: command + '\n' + 'echo done' })).toMatchInlineSnapshot(`" _git diff HEAD\\~1 --stat && git log --oneline -5…_"`);
81
81
  });
82
82
  });
83
83
  describe('formatTodoList', () => {
@@ -279,10 +279,22 @@ The user is reading your messages from inside Discord, via kimaki.dev
279
279
 
280
280
  ## bash tool
281
281
 
282
- When calling the bash tool, always include a boolean field \`hasSideEffect\`.
283
- Set \`hasSideEffect: true\` for any command that writes files, modifies repo state, installs packages, changes config, runs scripts that mutate state, or triggers external effects.
284
- Set \`hasSideEffect: false\` for read-only commands (e.g. ls, tree, cat, rg, grep, git status, git diff, pwd, whoami, etc).
285
- This is required to distinguish essential bash calls from read-only ones in low-verbosity mode.
282
+ When calling the bash tool, always include these extra fields alongside \`command\`:
283
+
284
+ \`\`\`ts
285
+ interface BashToolInput {
286
+ command: string
287
+ /** Short 5-10 word summary of what this command does */
288
+ description: string
289
+ /** true if the command writes files, modifies state, installs packages, or triggers external effects */
290
+ hasSideEffect: boolean
291
+ workdir?: string
292
+ timeout?: number
293
+ }
294
+ \`\`\`
295
+
296
+ \`description\` is shown to the user in Discord as a summary of the bash call.
297
+ \`hasSideEffect\` distinguishes essential bash calls from read-only ones in low-verbosity mode.
286
298
 
287
299
  Your current OpenCode session ID is: ${sessionId}${channelId ? `\nYour current Discord channel ID is: ${channelId}` : ''}${threadId ? `\nYour current Discord thread ID is: ${threadId}` : ''}${guildId ? `\nYour current Discord guild ID is: ${guildId}` : ''}
288
300
 
@@ -34,10 +34,22 @@ describe('system-message', () => {
34
34
 
35
35
  ## bash tool
36
36
 
37
- When calling the bash tool, always include a boolean field \`hasSideEffect\`.
38
- Set \`hasSideEffect: true\` for any command that writes files, modifies repo state, installs packages, changes config, runs scripts that mutate state, or triggers external effects.
39
- Set \`hasSideEffect: false\` for read-only commands (e.g. ls, tree, cat, rg, grep, git status, git diff, pwd, whoami, etc).
40
- This is required to distinguish essential bash calls from read-only ones in low-verbosity mode.
37
+ When calling the bash tool, always include these extra fields alongside \`command\`:
38
+
39
+ \`\`\`ts
40
+ interface BashToolInput {
41
+ command: string
42
+ /** Short 5-10 word summary of what this command does */
43
+ description: string
44
+ /** true if the command writes files, modifies state, installs packages, or triggers external effects */
45
+ hasSideEffect: boolean
46
+ workdir?: string
47
+ timeout?: number
48
+ }
49
+ \`\`\`
50
+
51
+ \`description\` is shown to the user in Discord as a summary of the bash call.
52
+ \`hasSideEffect\` distinguishes essential bash calls from read-only ones in low-verbosity mode.
41
53
 
42
54
  Your current OpenCode session ID is: ses_123
43
55
  Your current Discord channel ID is: chan_123
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "kimaki",
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
- "version": "0.20.0",
5
+ "version": "0.20.1",
6
6
  "repository": "https://github.com/remorses/kimaki",
7
7
  "bin": "bin.js",
8
8
  "files": [
@@ -62,9 +62,9 @@
62
62
  "yaml": "^2.8.3",
63
63
  "zod": "^4.3.6",
64
64
  "zustand": "^5.0.11",
65
- "libsqlproxy": "^0.1.0",
66
- "opencode-injection-guard": "^0.2.1",
67
65
  "errore": "^0.14.1",
66
+ "opencode-injection-guard": "^0.2.1",
67
+ "libsqlproxy": "^0.1.0",
68
68
  "traforo": "^0.7.1"
69
69
  },
70
70
  "optionalDependencies": {
@@ -54,6 +54,23 @@ The `--model` / `-m` flag is **optional** on both `egaki image` and `egaki video
54
54
 
55
55
  Agents should always pass `-m` explicitly to avoid the interactive picker.
56
56
 
57
+ ### Preferred image models
58
+
59
+ When the user does not specify a model, prefer these in order:
60
+
61
+ 1. **`gpt-image-1`** (or `gpt-image-2` / `chatgpt-image-latest` if available)
62
+ 2. **`grok-imagine-image`** (xAI Grok)
63
+ 3. **`nano-banana-pro-preview`**
64
+
65
+ Always try the first available model. Fall back to the next if auth is missing
66
+ for the preferred one.
67
+
68
+ ### Preferred video models
69
+
70
+ When the user does not specify a video model, prefer:
71
+
72
+ 1. **`grok-imagine-video-1.5`** (xAI Grok)
73
+
57
74
  ## Example commands
58
75
 
59
76
  ```bash
@@ -25,6 +25,19 @@ sigillo --help
25
25
 
26
26
  **NEVER truncate this either.**
27
27
 
28
+ If a flag or command documented here is missing from `--help`, the installed binary is likely outdated. Update before proceeding:
29
+
30
+ ```bash
31
+ npm i -g sigillo@latest
32
+ ```
33
+
34
+ If sigillo is symlinked to a local dev build (check with `which sigillo`), rebuild from source instead:
35
+
36
+ ```bash
37
+ cd /path/to/sigillo/cli && zig build -Dtarget=aarch64-macos
38
+ cp zig-out/bin/sigillo dist/darwin-arm64/sigillo
39
+ ```
40
+
28
41
  ## New project setup workflow
29
42
 
30
43
  This mirrors the Doppler workflow: check auth → link project → list secrets → run.
@@ -88,6 +101,16 @@ To read a specific value:
88
101
  sigillo secrets get DATABASE_URL
89
102
  ```
90
103
 
104
+ `secrets get` returns YAML by default. Pass `--raw` to output only the raw value (no YAML wrapping). When stdout is piped, `--raw` is implied automatically:
105
+
106
+ ```bash
107
+ # copy a secret between environments (piped stdout auto-enables raw mode)
108
+ sigillo secrets get DATABASE_URL -c dev | sigillo secrets set DATABASE_URL -c preview
109
+
110
+ # force raw output in a terminal (useful for scripting)
111
+ sigillo secrets get DATABASE_URL --raw
112
+ ```
113
+
91
114
  **5. Run your app with secrets injected:**
92
115
 
93
116
  ```bash
@@ -113,11 +136,11 @@ Never read a secret value into the agent context window or pass it as plain text
113
136
  **Copying a secret from one env to another:**
114
137
 
115
138
  ```bash
116
- # value flows through stdin, never seen by the agent
139
+ # piped stdout auto-enables raw mode, value never seen by the agent
117
140
  sigillo secrets get DATABASE_URL -c dev | sigillo secrets set DATABASE_URL -c preview
118
141
  ```
119
142
 
120
- The same pattern works for any secret copy between environments, or when seeding a new environment from an existing one.
143
+ The same pattern works for any secret copy, between environments, or when seeding a new environment from an existing one.
121
144
 
122
145
  ### Never read `.env` files or `~/.sigillo/*`
123
146
 
@@ -145,14 +168,22 @@ If a `package.json` script requires secrets, embed `sigillo run` directly in the
145
168
  {
146
169
  "scripts": {
147
170
  "dev": "sigillo run -c dev -- vite dev",
148
- "deployment": "sigillo run -c preview -- pnpm db:migrate:preview && CLOUDFLARE_ENV=preview sigillo run -c preview --command 'vite build && wrangler deploy --env preview'",
149
- "deployment:prod": "sigillo run -c prod -- pnpm db:migrate:prod && sigillo run -c prod --command 'vite build && wrangler deploy'"
171
+ "deploy": "pnpm db:migrate:preview && CLOUDFLARE_ENV=preview sigillo run -c preview --command 'tsc && vite build && wrangler deploy --env preview'",
172
+ "deploy:prod": "pnpm db:migrate:prod && sigillo run -c prod --command 'tsc && vite build && wrangler deploy'"
150
173
  }
151
174
  }
152
175
  ```
153
176
 
177
+ These scripts work because pnpm adds `node_modules/.bin` to PATH before executing the script string, and sigillo inherits that PATH. Commands like `tsc`, `vite`, and `wrangler` resolve correctly inside `--command` as long as the entry point is `pnpm run deploy`.
178
+
154
179
  Use `-c dev` for local development, `-c preview` for staging or preview deployments, and `-c prod` or the repo's production slug for production.
155
180
 
181
+ ### Always run `sigillo run` via pnpm scripts, not directly
182
+
183
+ `sigillo run --command` spawns `$SHELL -c '...'` and passes the inherited `PATH`. But pnpm only adds `node_modules/.bin` to `PATH` when it's running a `package.json` script. If you call `sigillo run --command 'tsc && vite build'` directly from the terminal, `tsc` and `vite` won't be found because `node_modules/.bin` is not in PATH.
184
+
185
+ Always invoke deploy/build commands through `pnpm run <script>` so pnpm augments PATH before sigillo inherits it. If you must run sigillo directly, use `pnpm exec` to resolve binaries: `sigillo run --command 'pnpm exec tsc && pnpm exec vite build'`.
186
+
156
187
  ### Use `--command` for shell expansion
157
188
 
158
189
  Use direct argv mode when no shell syntax is needed:
@@ -5,14 +5,26 @@ import type { CommandHandler } from './types.js'
5
5
  import {
6
6
  ChannelType,
7
7
  MessageFlags,
8
+ ThreadAutoArchiveDuration,
8
9
  type TextChannel,
9
10
  type ThreadChannel,
10
11
  } from 'discord.js'
11
12
  import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js'
12
13
  import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
13
14
  import { createLogger, LogPrefix } from '../logger.js'
14
- import { getChannelDirectory, getThreadSession } from '../database.js'
15
+ import {
16
+ getChannelDirectory,
17
+ getChannelWorktreesEnabled,
18
+ getThreadSession,
19
+ } from '../database.js'
15
20
  import { store } from '../store.js'
21
+ import { isGitRepositoryRoot } from '../worktrees.js'
22
+ import {
23
+ formatAutoWorktreeName,
24
+ createWorktreeInBackground,
25
+ worktreeCreatingMessage,
26
+ } from './new-worktree.js'
27
+ import { WORKTREE_PREFIX } from './merge-worktree.js'
16
28
  import fs from 'node:fs'
17
29
 
18
30
  const userCommandLogger = createLogger(LogPrefix.USER_CMD)
@@ -144,20 +156,69 @@ export const handleUserCommand: CommandHandler = async ({
144
156
  })
145
157
  } else if (textChannel) {
146
158
  // Running in text channel - create a new thread
159
+
160
+ // Check if worktrees should be enabled (CLI flag OR channel setting),
161
+ // mirroring the logic in discord-bot.ts message handler.
162
+ const wantsWorktrees =
163
+ store.getState().useWorktrees ||
164
+ (await getChannelWorktreesEnabled(textChannel.id))
165
+ const shouldUseWorktrees =
166
+ wantsWorktrees && (await isGitRepositoryRoot(projectDirectory))
167
+
168
+ if (wantsWorktrees && !shouldUseWorktrees) {
169
+ userCommandLogger.warn(
170
+ `[WORKTREE] Skipping automatic worktree for non-git project directory: ${projectDirectory}`,
171
+ )
172
+ }
173
+
174
+ const baseThreadName = commandInvocation.slice(0, DISCORD_THREAD_NAME_LIMIT)
175
+ const threadName = shouldUseWorktrees
176
+ ? `${WORKTREE_PREFIX}${baseThreadName}`
177
+ : baseThreadName
178
+
147
179
  const starterMessage = await textChannel.send({
148
180
  content: threadOpeningMessage,
149
181
  flags: SILENT_MESSAGE_FLAGS,
150
182
  })
151
183
 
152
184
  const newThread = await starterMessage.startThread({
153
- name: commandInvocation.slice(0, DISCORD_THREAD_NAME_LIMIT),
154
- autoArchiveDuration: 1440,
185
+ name: threadName.slice(0, DISCORD_THREAD_NAME_LIMIT),
186
+ autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
155
187
  reason: `OpenCode command: ${commandName}`,
156
188
  })
157
189
 
158
190
  // Add user to thread so it appears in their sidebar
159
191
  await newThread.members.add(command.user.id)
160
192
 
193
+ // Create worktree in background if enabled, same as discord-bot.ts
194
+ let worktreePromise: Promise<string | Error> | undefined
195
+ if (shouldUseWorktrees) {
196
+ const worktreeName = formatAutoWorktreeName(baseThreadName.slice(0, 50))
197
+ userCommandLogger.log(`[WORKTREE] Creating worktree: ${worktreeName}`)
198
+
199
+ const worktreeStatusMessage = await newThread
200
+ .send({
201
+ content: worktreeCreatingMessage(worktreeName),
202
+ flags: SILENT_MESSAGE_FLAGS,
203
+ })
204
+ .catch(() => undefined)
205
+
206
+ worktreePromise = createWorktreeInBackground({
207
+ thread: newThread,
208
+ starterMessage: worktreeStatusMessage,
209
+ worktreeName,
210
+ projectDirectory,
211
+ rest: command.client.rest,
212
+ })
213
+ }
214
+
215
+ const sessionDirectory = await (async () => {
216
+ if (!worktreePromise) return projectDirectory
217
+ const result = await worktreePromise
218
+ if (result instanceof Error) return projectDirectory
219
+ return result
220
+ })()
221
+
161
222
  await command.editReply(
162
223
  `Started /${commandName} in ${newThread.toString()}`,
163
224
  )
@@ -166,7 +227,7 @@ export const handleUserCommand: CommandHandler = async ({
166
227
  threadId: newThread.id,
167
228
  thread: newThread,
168
229
  projectDirectory,
169
- sdkDirectory: projectDirectory,
230
+ sdkDirectory: sessionDirectory,
170
231
  channelId: textChannel.id,
171
232
  appId,
172
233
  })
@@ -415,9 +415,8 @@ export async function startDiscordBot({
415
415
  discordLogger.error(
416
416
  `[GATEWAY] Shard ${shardId} exceeded ${MAX_RECONNECT_ATTEMPTS} reconnect attempts, self-restarting`,
417
417
  )
418
- // Self-restart: cleanup then spawn a fresh process. This works whether
419
- // the bin.ts wrapper is present or not (unlike process.exit(1) which
420
- // only restarts when the wrapper is the parent).
418
+ // Self-restart: cleanup, then exit non-zero so the bin.ts wrapper
419
+ // restarts us. Without the wrapper this exits after logging a warning.
421
420
  void selfRestart('gateway-reconnect-limit')
422
421
  }
423
422
  })
@@ -1525,7 +1524,13 @@ export async function startDiscordBot({
1525
1524
  })
1526
1525
  })
1527
1526
 
1528
- // Self-restart: prefer bin.ts wrapper (keeps Ctrl+C), fall back to detached spawn.
1527
+ // Self-restart: exit with code 1 so the bin.ts wrapper restarts us with
1528
+ // exponential backoff and crash-loop detection. When running without the
1529
+ // wrapper (e.g. `tsx src/cli.ts`), the process just exits and the user
1530
+ // sees the non-zero exit code — they should use `tsx src/bin.ts` instead
1531
+ // for auto-restart support. The previous detached-spawn fallback was
1532
+ // unreliable: process.exit(0) could kill the child before it started,
1533
+ // there was no backoff between restarts, and spawn failures were silent.
1529
1534
  let selfRestarting = false
1530
1535
  async function selfRestart(reason: string) {
1531
1536
  if (selfRestarting) {
@@ -1540,21 +1545,12 @@ export async function startDiscordBot({
1540
1545
  voiceLogger.error(`[${reason}] Error during shutdown:`, error)
1541
1546
  }
1542
1547
 
1543
- if (process.env.__KIMAKI_CHILD) {
1544
- discordLogger.log('Wrapper detected, exiting for wrapper restart')
1545
- process.exit(1)
1548
+ if (!process.env.__KIMAKI_CHILD) {
1549
+ discordLogger.warn(
1550
+ 'No restart wrapper detected. Run via `tsx src/bin.ts` (dev) or `kimaki` (npm) for auto-restart on crash.',
1551
+ )
1546
1552
  }
1547
-
1548
- const { spawn } = await import('node:child_process')
1549
- const env = { ...process.env }
1550
- delete env.__KIMAKI_CHILD
1551
- spawn(process.argv[0]!, [...process.execArgv, ...process.argv.slice(1)], {
1552
- stdio: 'inherit',
1553
- detached: true,
1554
- cwd: process.cwd(),
1555
- env,
1556
- }).unref()
1557
- process.exit(0)
1553
+ process.exit(1)
1558
1554
  }
1559
1555
 
1560
1556
  process.on('SIGUSR2', () => {
@@ -788,23 +788,47 @@ export async function resolveWorkingDirectory({
788
788
  }
789
789
  }
790
790
 
791
+ // Discord upload size limits per server boost tier (bytes).
792
+ // Bots default to 25 MB; boosted servers raise the ceiling.
793
+ export const DISCORD_DEFAULT_MAX_FILE_SIZE = 25 * 1024 * 1024
794
+
791
795
  /**
792
796
  * Upload files to a Discord thread/channel in a single message.
793
797
  * Sending all files in one message causes Discord to display images in a grid layout.
798
+ *
799
+ * Files are validated against the Discord upload size limit before reading them
800
+ * into memory. Pass `maxFileSize` if you know the guild's boost tier limit;
801
+ * otherwise the conservative 25 MB bot default is used.
794
802
  */
795
803
  export async function uploadFilesToDiscord({
796
804
  threadId,
797
805
  botToken,
798
806
  files,
807
+ maxFileSize = DISCORD_DEFAULT_MAX_FILE_SIZE,
799
808
  }: {
800
809
  threadId: string
801
810
  botToken: string
802
811
  files: string[]
812
+ /** Per-file size limit in bytes. Defaults to 25 MB (bot default). */
813
+ maxFileSize?: number
803
814
  }): Promise<void> {
804
815
  if (files.length === 0) {
805
816
  return
806
817
  }
807
818
 
819
+ // Fail fast: check file sizes before reading anything into memory
820
+ const sizeLimit = maxFileSize ?? DISCORD_DEFAULT_MAX_FILE_SIZE
821
+ for (const file of files) {
822
+ const stat = fs.statSync(file)
823
+ if (stat.size > sizeLimit) {
824
+ const fileMB = (stat.size / 1024 / 1024).toFixed(1)
825
+ const limitMB = (sizeLimit / 1024 / 1024).toFixed(0)
826
+ throw new Error(
827
+ `File "${path.basename(file)}" is ${fileMB} MB, which exceeds Discord's ${limitMB} MB upload limit`,
828
+ )
829
+ }
830
+ }
831
+
808
832
  // Build attachments array for all files
809
833
  const attachments = files.map((file, index) => ({
810
834
  id: index,
@@ -56,7 +56,7 @@ describe('formatBashToolTitle', () => {
56
56
  test('multiline command without description truncates to first line', () => {
57
57
  expect(
58
58
  formatBashToolTitle({ command: 'echo hello\necho world\necho done' }),
59
- ).toMatchInlineSnapshot(`" _echo hello_"`)
59
+ ).toMatchInlineSnapshot(`" _echo hello…_"`)
60
60
  })
61
61
 
62
62
  test('long single-line command is truncated with ellipsis', () => {
@@ -88,13 +88,13 @@ describe('formatBashToolTitle', () => {
88
88
  test('leading blank line skipped, uses first meaningful line', () => {
89
89
  expect(
90
90
  formatBashToolTitle({ command: '\npnpm test\npnpm build' }),
91
- ).toMatchInlineSnapshot(`" _pnpm test_"`)
91
+ ).toMatchInlineSnapshot(`" _pnpm test…_"`)
92
92
  })
93
93
 
94
94
  test('whitespace-only first line skipped', () => {
95
95
  expect(
96
96
  formatBashToolTitle({ command: ' \npnpm test' }),
97
- ).toMatchInlineSnapshot(`" _pnpm test_"`)
97
+ ).toMatchInlineSnapshot(`" _pnpm test…_"`)
98
98
  })
99
99
 
100
100
  test('no description field (new opencode) with multiline command', () => {
@@ -102,7 +102,7 @@ describe('formatBashToolTitle', () => {
102
102
  // from the bash tool schema, so multiline commands rendered as just "┣ bash"
103
103
  const command = 'git diff HEAD~1 --stat && git log --oneline -5'
104
104
  expect(formatBashToolTitle({ command: command + '\n' + 'echo done' })).toMatchInlineSnapshot(
105
- `" _git diff HEAD\\~1 --stat && git log --oneline -5_"`,
105
+ `" _git diff HEAD\\~1 --stat && git log --oneline -5…_"`,
106
106
  )
107
107
  })
108
108
  })
@@ -356,9 +356,9 @@ const MAX_BASH_COMMAND_INLINE_LENGTH = 100
356
356
  * 2. Long/multiline command with description → show description
357
357
  * 3. Long/multiline command without description → truncate first line of command
358
358
  *
359
- * The description field was removed from the bash tool schema in newer opencode
360
- * versions, so case 3 is the common path now. Without this fallback, long commands
361
- * would render as just "┣ bash" with no context.
359
+ * The description field was removed from the opencode v2 bash tool schema but
360
+ * kimaki's system prompt instructs models to always send it as an extra field.
361
+ * Case 3 is the fallback when a model omits it.
362
362
  */
363
363
  export function formatBashToolTitle({
364
364
  command,
@@ -386,11 +386,12 @@ export function formatBashToolTitle({
386
386
  return ` _${escapeInlineMarkdown(description)}_`
387
387
  }
388
388
  if (firstMeaningfulLine.length > 0) {
389
- const truncated =
390
- firstMeaningfulLine.length > MAX_BASH_COMMAND_INLINE_LENGTH
391
- ? firstMeaningfulLine.slice(0, MAX_BASH_COMMAND_INLINE_LENGTH) + '…'
392
- : firstMeaningfulLine
393
- return ` _${escapeInlineMarkdown(truncated)}_`
389
+ const needsTruncation = firstMeaningfulLine.length > MAX_BASH_COMMAND_INLINE_LENGTH
390
+ const base = needsTruncation
391
+ ? firstMeaningfulLine.slice(0, MAX_BASH_COMMAND_INLINE_LENGTH)
392
+ : firstMeaningfulLine
393
+ // Always add ellipsis when showing a partial command (multiline or length-truncated)
394
+ return ` _${escapeInlineMarkdown(base)}…_`
394
395
  }
395
396
  if (stateTitle) {
396
397
  return ` _${escapeInlineMarkdown(stateTitle)}_`
@@ -44,10 +44,22 @@ describe('system-message', () => {
44
44
 
45
45
  ## bash tool
46
46
 
47
- When calling the bash tool, always include a boolean field \`hasSideEffect\`.
48
- Set \`hasSideEffect: true\` for any command that writes files, modifies repo state, installs packages, changes config, runs scripts that mutate state, or triggers external effects.
49
- Set \`hasSideEffect: false\` for read-only commands (e.g. ls, tree, cat, rg, grep, git status, git diff, pwd, whoami, etc).
50
- This is required to distinguish essential bash calls from read-only ones in low-verbosity mode.
47
+ When calling the bash tool, always include these extra fields alongside \`command\`:
48
+
49
+ \`\`\`ts
50
+ interface BashToolInput {
51
+ command: string
52
+ /** Short 5-10 word summary of what this command does */
53
+ description: string
54
+ /** true if the command writes files, modifies state, installs packages, or triggers external effects */
55
+ hasSideEffect: boolean
56
+ workdir?: string
57
+ timeout?: number
58
+ }
59
+ \`\`\`
60
+
61
+ \`description\` is shown to the user in Discord as a summary of the bash call.
62
+ \`hasSideEffect\` distinguishes essential bash calls from read-only ones in low-verbosity mode.
51
63
 
52
64
  Your current OpenCode session ID is: ses_123
53
65
  Your current Discord channel ID is: chan_123
@@ -392,10 +392,22 @@ The user is reading your messages from inside Discord, via kimaki.dev
392
392
 
393
393
  ## bash tool
394
394
 
395
- When calling the bash tool, always include a boolean field \`hasSideEffect\`.
396
- Set \`hasSideEffect: true\` for any command that writes files, modifies repo state, installs packages, changes config, runs scripts that mutate state, or triggers external effects.
397
- Set \`hasSideEffect: false\` for read-only commands (e.g. ls, tree, cat, rg, grep, git status, git diff, pwd, whoami, etc).
398
- This is required to distinguish essential bash calls from read-only ones in low-verbosity mode.
395
+ When calling the bash tool, always include these extra fields alongside \`command\`:
396
+
397
+ \`\`\`ts
398
+ interface BashToolInput {
399
+ command: string
400
+ /** Short 5-10 word summary of what this command does */
401
+ description: string
402
+ /** true if the command writes files, modifies state, installs packages, or triggers external effects */
403
+ hasSideEffect: boolean
404
+ workdir?: string
405
+ timeout?: number
406
+ }
407
+ \`\`\`
408
+
409
+ \`description\` is shown to the user in Discord as a summary of the bash call.
410
+ \`hasSideEffect\` distinguishes essential bash calls from read-only ones in low-verbosity mode.
399
411
 
400
412
  Your current OpenCode session ID is: ${sessionId}${channelId ? `\nYour current Discord channel ID is: ${channelId}` : ''}${threadId ? `\nYour current Discord thread ID is: ${threadId}` : ''}${guildId ? `\nYour current Discord guild ID is: ${guildId}` : ''}
401
413