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.
- package/dist/commands/user-command.js +47 -5
- package/dist/discord-bot.js +12 -17
- package/dist/discord-utils.js +18 -1
- package/dist/message-formatting.js +8 -6
- package/dist/message-formatting.test.js +4 -4
- package/dist/system-message.js +16 -4
- package/dist/system-message.test.js +16 -4
- package/package.json +3 -3
- package/skills/egaki/SKILL.md +17 -0
- package/skills/sigillo/SKILL.md +35 -4
- package/src/commands/user-command.ts +65 -4
- package/src/discord-bot.ts +14 -18
- package/src/discord-utils.ts +24 -0
- package/src/message-formatting.test.ts +4 -4
- package/src/message-formatting.ts +9 -8
- package/src/system-message.test.ts +16 -4
- package/src/system-message.ts +16 -4
|
@@ -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:
|
|
116
|
-
autoArchiveDuration:
|
|
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:
|
|
168
|
+
sdkDirectory: sessionDirectory,
|
|
127
169
|
channelId: textChannel.id,
|
|
128
170
|
appId,
|
|
129
171
|
});
|
package/dist/discord-bot.js
CHANGED
|
@@ -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
|
|
265
|
-
//
|
|
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:
|
|
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.
|
|
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
|
-
|
|
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...');
|
package/dist/discord-utils.js
CHANGED
|
@@ -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
|
|
276
|
-
*
|
|
277
|
-
*
|
|
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
|
|
296
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 -
|
|
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', () => {
|
package/dist/system-message.js
CHANGED
|
@@ -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
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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.
|
|
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": {
|
package/skills/egaki/SKILL.md
CHANGED
|
@@ -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
|
package/skills/sigillo/SKILL.md
CHANGED
|
@@ -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
|
-
#
|
|
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
|
|
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
|
-
"
|
|
149
|
-
"
|
|
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 {
|
|
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:
|
|
154
|
-
autoArchiveDuration:
|
|
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:
|
|
230
|
+
sdkDirectory: sessionDirectory,
|
|
170
231
|
channelId: textChannel.id,
|
|
171
232
|
appId,
|
|
172
233
|
})
|
package/src/discord-bot.ts
CHANGED
|
@@ -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
|
|
419
|
-
//
|
|
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:
|
|
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.
|
|
1545
|
-
|
|
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', () => {
|
package/src/discord-utils.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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 -
|
|
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
|
|
360
|
-
*
|
|
361
|
-
*
|
|
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
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
package/src/system-message.ts
CHANGED
|
@@ -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
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
|