kimaki 0.29.0 → 0.30.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.
- package/dist/agent-model.e2e.test.js +33 -33
- package/dist/anthropic-account-identity.js +1 -1
- package/dist/anthropic-auth-plugin.js +5 -5
- package/dist/anthropic-auth-plugin.test.js +30 -2
- package/dist/anthropic-auth-state.js +1 -1
- package/dist/bin.js +7 -3
- package/dist/bot-lifecycle.test.js +206 -0
- package/dist/cache-drift-plugin.js +25 -18
- package/dist/cache-rewrite.js +39 -0
- package/dist/cache-rewrite.test.js +41 -0
- package/dist/cli-commands/session.js +37 -6
- package/dist/cli-parsing.test.js +3 -3
- package/dist/cli-runner.js +11 -1
- package/dist/cli-send-thread.e2e.test.js +2 -2
- package/dist/cli.js +4 -4
- package/dist/commands/ask-question.js +72 -23
- package/dist/commands/queue.js +7 -7
- package/dist/database.js +73 -6
- package/dist/db.test.js +104 -1
- package/dist/discord-bot.js +212 -144
- package/dist/external-directory-permissions.e2e.test.js +3 -3
- package/dist/external-directory-project-deny.e2e.test.js +1 -1
- package/dist/forum-sync/watchers.js +15 -0
- package/dist/gateway-proxy.e2e.test.js +9 -9
- package/dist/hrana-server.js +68 -14
- package/dist/markdown.js +164 -102
- package/dist/markdown.test.js +88 -20
- package/dist/message-formatting.js +20 -2
- package/dist/message-formatting.test.js +20 -1
- package/dist/opencode.js +27 -12
- package/dist/question-abort-clears-dropdown.e2e.test.js +1 -1
- package/dist/question-abort-resume.e2e.test.js +100 -0
- package/dist/question-abort-retry-text-before-dropdown.e2e.test.js +1 -1
- package/dist/question-text-before-dropdown.e2e.test.js +1 -1
- package/dist/queue-advanced-abort.e2e.test.js +3 -3
- package/dist/queue-advanced-action-buttons.e2e.test.js +5 -5
- package/dist/queue-advanced-e2e-setup.js +63 -0
- package/dist/queue-advanced-footer.e2e.test.js +18 -18
- package/dist/queue-advanced-model-switch.e2e.test.js +3 -4
- package/dist/queue-advanced-permissions-typing.e2e.test.js +2 -2
- package/dist/queue-advanced-typing-interrupt.e2e.test.js +3 -3
- package/dist/queue-delete-message.e2e.test.js +4 -4
- package/dist/queue-drain-after-interactive-ui.e2e.test.js +3 -3
- package/dist/queue-interrupt-drain.e2e.test.js +10 -10
- package/dist/queue-question-select-drain.e2e.test.js +5 -5
- package/dist/runtime-lifecycle.e2e.test.js +95 -11
- package/dist/session-handler/event-stream-state.js +187 -1
- package/dist/session-handler/event-stream-state.test.js +764 -1
- package/dist/session-handler/thread-session-runtime.js +185 -16
- package/dist/sleep.e2e.test.js +7 -7
- package/dist/store.js +1 -1
- package/dist/system-message.js +9 -10
- package/dist/system-message.test.js +19 -10
- package/dist/task-runner.js +7 -0
- package/dist/task-schedule.test.js +1 -1
- package/dist/test-utils.js +5 -3
- package/dist/thread-message-queue.e2e.test.js +30 -31
- package/dist/undo-redo.e2e.test.js +3 -3
- package/dist/voice-message.e2e.test.js +64 -26
- package/dist/voice-routing-input.e2e.test.js +4 -4
- package/dist/voice-routing-question.e2e.test.js +4 -4
- package/dist/voice.js +107 -21
- package/dist/voice.test.js +52 -1
- package/dist/wait-session.js +24 -2
- package/dist/worktree-lifecycle.e2e.test.js +8 -8
- package/package.json +5 -5
- package/skills/egaki/SKILL.md +14 -0
- package/skills/holocron/SKILL.md +125 -42
- package/skills/opensrc/SKILL.md +20 -3
- package/skills/spiceflow/SKILL.md +53 -172
- package/src/agent-model.e2e.test.ts +33 -33
- package/src/anthropic-account-identity.ts +1 -1
- package/src/anthropic-auth-plugin.test.ts +37 -2
- package/src/anthropic-auth-plugin.ts +9 -7
- package/src/anthropic-auth-state.ts +1 -0
- package/src/bin.ts +7 -3
- package/src/bot-lifecycle.test.ts +252 -0
- package/src/cache-drift-plugin.ts +34 -24
- package/src/cache-rewrite.test.ts +49 -0
- package/src/cache-rewrite.ts +96 -0
- package/src/cli-commands/session.ts +48 -8
- package/src/cli-parsing.test.ts +3 -3
- package/src/cli-runner.ts +11 -0
- package/src/cli-send-thread.e2e.test.ts +2 -2
- package/src/cli.ts +6 -6
- package/src/commands/ask-question.ts +88 -25
- package/src/commands/queue.ts +7 -7
- package/src/database.ts +105 -6
- package/src/db.test.ts +124 -0
- package/src/discord-bot.ts +261 -183
- package/src/external-directory-permissions.e2e.test.ts +3 -3
- package/src/external-directory-project-deny.e2e.test.ts +1 -1
- package/src/forum-sync/watchers.ts +20 -0
- package/src/gateway-proxy.e2e.test.ts +9 -9
- package/src/hrana-server.ts +96 -16
- package/src/markdown.test.ts +108 -22
- package/src/markdown.ts +216 -103
- package/src/message-formatting.test.ts +24 -1
- package/src/message-formatting.ts +21 -2
- package/src/opencode.ts +28 -13
- package/src/question-abort-clears-dropdown.e2e.test.ts +1 -1
- package/src/question-abort-resume.e2e.test.ts +121 -0
- package/src/question-abort-retry-text-before-dropdown.e2e.test.ts +1 -1
- package/src/question-text-before-dropdown.e2e.test.ts +1 -1
- package/src/queue-advanced-abort.e2e.test.ts +3 -3
- package/src/queue-advanced-action-buttons.e2e.test.ts +5 -5
- package/src/queue-advanced-e2e-setup.ts +65 -0
- package/src/queue-advanced-footer.e2e.test.ts +18 -18
- package/src/queue-advanced-model-switch.e2e.test.ts +3 -4
- package/src/queue-advanced-permissions-typing.e2e.test.ts +2 -2
- package/src/queue-advanced-typing-interrupt.e2e.test.ts +3 -3
- package/src/queue-delete-message.e2e.test.ts +4 -4
- package/src/queue-drain-after-interactive-ui.e2e.test.ts +3 -3
- package/src/queue-interrupt-drain.e2e.test.ts +10 -10
- package/src/queue-question-select-drain.e2e.test.ts +5 -5
- package/src/runtime-lifecycle.e2e.test.ts +104 -10
- package/src/session-handler/event-stream-state.test.ts +825 -0
- package/src/session-handler/event-stream-state.ts +265 -3
- package/src/session-handler/thread-session-runtime.ts +217 -14
- package/src/sleep.e2e.test.ts +7 -7
- package/src/store.ts +2 -2
- package/src/system-message.test.ts +20 -10
- package/src/system-message.ts +9 -10
- package/src/task-runner.ts +8 -0
- package/src/task-schedule.test.ts +1 -1
- package/src/test-utils.ts +5 -3
- package/src/thread-message-queue.e2e.test.ts +30 -31
- package/src/undo-redo.e2e.test.ts +3 -3
- package/src/voice-message.e2e.test.ts +70 -26
- package/src/voice-routing-input.e2e.test.ts +4 -4
- package/src/voice-routing-question.e2e.test.ts +4 -4
- package/src/voice.test.ts +56 -0
- package/src/voice.ts +135 -25
- package/src/wait-session.ts +25 -2
- package/src/worktree-lifecycle.e2e.test.ts +8 -8
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { afterEach, expect, test } from 'vitest';
|
|
5
|
+
import { countSystemPromptDiffLines, formatSystemPromptPatch, writeSystemPromptPatch, } from './cache-rewrite.js';
|
|
6
|
+
const tempDirs = [];
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
for (const dir of tempDirs.splice(0)) {
|
|
9
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
10
|
+
}
|
|
11
|
+
});
|
|
12
|
+
test('counts added and removed system prompt lines', () => {
|
|
13
|
+
expect(countSystemPromptDiffLines({
|
|
14
|
+
beforeText: 'keep\nold line\n',
|
|
15
|
+
afterText: 'keep\nnew line\nextra\n',
|
|
16
|
+
})).toEqual({ additions: 2, deletions: 1 });
|
|
17
|
+
});
|
|
18
|
+
test('writes a unified patch only for the caller', async () => {
|
|
19
|
+
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kimaki-cache-rewrite-'));
|
|
20
|
+
tempDirs.push(dataDir);
|
|
21
|
+
const filePath = await writeSystemPromptPatch({
|
|
22
|
+
dataDir,
|
|
23
|
+
sessionId: 'ses_test',
|
|
24
|
+
beforeText: 'alpha\n',
|
|
25
|
+
afterText: 'beta\n',
|
|
26
|
+
model: 'openai/gpt-5',
|
|
27
|
+
agent: 'build',
|
|
28
|
+
now: 1_700_000_000_000,
|
|
29
|
+
});
|
|
30
|
+
expect(path.basename(filePath)).toBe('1700000000000-ses_test.patch');
|
|
31
|
+
const text = fs.readFileSync(filePath, 'utf8');
|
|
32
|
+
expect(text).toContain('session: ses_test');
|
|
33
|
+
expect(text).toContain('system +1 -1');
|
|
34
|
+
expect(text).toContain('-alpha');
|
|
35
|
+
expect(text).toContain('+beta');
|
|
36
|
+
expect(formatSystemPromptPatch({
|
|
37
|
+
beforeText: 'alpha\n',
|
|
38
|
+
afterText: 'beta\n',
|
|
39
|
+
sessionId: 'ses_test',
|
|
40
|
+
})).toContain('--- system-before.txt');
|
|
41
|
+
});
|
|
@@ -106,17 +106,27 @@ cli
|
|
|
106
106
|
process.exit(EXIT_NO_RESTART);
|
|
107
107
|
}
|
|
108
108
|
const client = getClient();
|
|
109
|
-
const [sessionsResponse, statusResponse] = await Promise.all([
|
|
109
|
+
const [sessionsResponse, statusResponse, questionsResponse] = await Promise.all([
|
|
110
110
|
client.session.list(),
|
|
111
111
|
client.session.status({ directory: projectDirectory }).catch(() => null),
|
|
112
|
+
client.question.list({ directory: projectDirectory }).catch(() => null),
|
|
112
113
|
]);
|
|
113
114
|
const statuses = statusResponse?.data || {};
|
|
115
|
+
const sessionsWithPendingQuestion = new Set((questionsResponse?.data || []).map((request) => request.sessionID));
|
|
114
116
|
for (const session of sessionsResponse.data || []) {
|
|
115
117
|
const status = statuses[session.id];
|
|
118
|
+
const isBusy = Boolean(status && status.type !== 'idle');
|
|
119
|
+
// Only relabel to showing-question when the session is actually busy.
|
|
120
|
+
// An orphaned question left in the list after an abort (session idle)
|
|
121
|
+
// must not hide or relabel an otherwise-idle session.
|
|
116
122
|
gathered.push({
|
|
117
123
|
session,
|
|
118
124
|
projectDirectory,
|
|
119
|
-
status:
|
|
125
|
+
status: isBusy && sessionsWithPendingQuestion.has(session.id)
|
|
126
|
+
? 'showing-question'
|
|
127
|
+
: isBusy
|
|
128
|
+
? 'busy'
|
|
129
|
+
: 'idle',
|
|
120
130
|
});
|
|
121
131
|
}
|
|
122
132
|
}
|
|
@@ -195,7 +205,12 @@ cli
|
|
|
195
205
|
: '';
|
|
196
206
|
const updatedAt = new Date(session.time.updated).toISOString();
|
|
197
207
|
const threadInfo = threadId ? ` | thread: ${threadId}` : '';
|
|
198
|
-
const
|
|
208
|
+
const statusLabel = entry.status === 'busy'
|
|
209
|
+
? 'working'
|
|
210
|
+
: entry.status === 'showing-question'
|
|
211
|
+
? 'showing-question'
|
|
212
|
+
: 'idle';
|
|
213
|
+
const statusInfo = ` | status: ${statusLabel}`;
|
|
199
214
|
const tokens = contextInfo(entry);
|
|
200
215
|
const tokensText = tokens ? ` | tokens: ${formatTokenCount(tokens)}` : '';
|
|
201
216
|
console.log(`${session.id} | ${session.title || 'Untitled Session'} | ${session.directory} | ${updatedAt} | ${source}${statusInfo}${tokensText}${threadInfo}${startedBy}`);
|
|
@@ -281,9 +296,18 @@ cli
|
|
|
281
296
|
}
|
|
282
297
|
});
|
|
283
298
|
cli
|
|
284
|
-
.command('session read <sessionId>',
|
|
299
|
+
.command('session read <sessionId>', dedent `
|
|
300
|
+
Read a session conversation as markdown (pipe to file to grep).
|
|
301
|
+
|
|
302
|
+
Thinking is omitted by default. Tool inputs are truncated. Use
|
|
303
|
+
\`--thinking\` and \`--verbose\` for the full dump.
|
|
304
|
+
`)
|
|
285
305
|
.option('--project <path>', 'Project directory (defaults to cwd)')
|
|
286
306
|
.option('--verbose', 'Show full tool inputs and outputs instead of compact summaries')
|
|
307
|
+
.option('--thinking', 'Include reasoning / thinking parts')
|
|
308
|
+
.option('--tool-input-max-chars <n>', z.number().default(80).describe('Max characters for compact tool input'))
|
|
309
|
+
.example('kimaki session read ses_xxx > ./tmp/session.md')
|
|
310
|
+
.example('kimaki session read ses_xxx --thinking --verbose')
|
|
287
311
|
.action(async (sessionId, options) => {
|
|
288
312
|
try {
|
|
289
313
|
const projectDirectory = path.resolve(options.project || '.');
|
|
@@ -297,7 +321,12 @@ cli
|
|
|
297
321
|
// Try current project first (fast path)
|
|
298
322
|
const compactTools = !options.verbose;
|
|
299
323
|
const markdown = new ShareMarkdown(getClient());
|
|
300
|
-
const result = await markdown.generate({
|
|
324
|
+
const result = await markdown.generate({
|
|
325
|
+
sessionID: sessionId,
|
|
326
|
+
compactTools,
|
|
327
|
+
includeThinking: options.thinking,
|
|
328
|
+
toolInputMaxChars: options.toolInputMaxChars,
|
|
329
|
+
});
|
|
301
330
|
if (!(result instanceof Error)) {
|
|
302
331
|
process.stdout.write(result);
|
|
303
332
|
process.exit(0);
|
|
@@ -331,6 +360,8 @@ cli
|
|
|
331
360
|
const otherResult = await otherMarkdown.generate({
|
|
332
361
|
sessionID: sessionId,
|
|
333
362
|
compactTools,
|
|
363
|
+
includeThinking: options.thinking,
|
|
364
|
+
toolInputMaxChars: options.toolInputMaxChars,
|
|
334
365
|
});
|
|
335
366
|
if (!(otherResult instanceof Error)) {
|
|
336
367
|
process.stdout.write(otherResult);
|
|
@@ -346,7 +377,7 @@ cli
|
|
|
346
377
|
}
|
|
347
378
|
});
|
|
348
379
|
cli
|
|
349
|
-
.command('session wait <sessionId>', 'Wait
|
|
380
|
+
.command('session wait <sessionId>', 'Wait until a session finishes or pauses for a user question, then print its conversation as markdown')
|
|
350
381
|
.action(async (sessionId) => {
|
|
351
382
|
try {
|
|
352
383
|
await initDatabase();
|
package/dist/cli-parsing.test.js
CHANGED
|
@@ -50,7 +50,7 @@ async function parseRootBotOptions(argv) {
|
|
|
50
50
|
const script = [
|
|
51
51
|
"import { goke } from 'goke'",
|
|
52
52
|
'const cli = goke(\'kimaki\')',
|
|
53
|
-
"cli.command('', 'bot').option('--no-analytics', 'Disable analytics').option('--
|
|
53
|
+
"cli.command('', 'bot').option('--no-analytics', 'Disable analytics').option('--enable-footer-mentions', 'Mention the thread creator in final footers')",
|
|
54
54
|
`const result = await cli.parse(${JSON.stringify(argv)}, { run: false })`,
|
|
55
55
|
'process.stdout.write(JSON.stringify({ args: result.args, options: result.options }))',
|
|
56
56
|
].join(';');
|
|
@@ -213,13 +213,13 @@ describe('goke CLI ID parsing', () => {
|
|
|
213
213
|
'node',
|
|
214
214
|
'kimaki',
|
|
215
215
|
'--no-analytics',
|
|
216
|
-
'--
|
|
216
|
+
'--enable-footer-mentions',
|
|
217
217
|
]);
|
|
218
218
|
expect(result.options).toMatchInlineSnapshot(`
|
|
219
219
|
{
|
|
220
220
|
"--": [],
|
|
221
|
+
"enableFooterMentions": true,
|
|
221
222
|
"noAnalytics": true,
|
|
222
|
-
"skipFooterMentions": true,
|
|
223
223
|
}
|
|
224
224
|
`);
|
|
225
225
|
});
|
package/dist/cli-runner.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Keeps cli.ts focused on command composition while preserving the bot onboarding flow.
|
|
3
3
|
import { intro, outro, text, password, note, cancel, isCancel, confirm, log, multiselect, select, spinner, } from '@clack/prompts';
|
|
4
4
|
import { deduplicateByKey, generateBotInstallUrl, generateDiscordInstallUrlForBot, KIMAKI_GATEWAY_APP_ID, KIMAKI_WEBSITE_URL, abbreviatePath, } from './utils.js';
|
|
5
|
-
import { getChannelsWithDescriptions, createDiscordClient, initDatabase, getChannelDirectory, startDiscordBot, initializeOpencodeForDirectory, assertCompatibleOpencodeVersion, createProjectChannels, createDefaultKimakiChannel, } from './discord-bot.js';
|
|
5
|
+
import { getChannelsWithDescriptions, createDiscordClient, initDatabase, getChannelDirectory, startDiscordBot, registerBotLifecycleHandlers, initializeOpencodeForDirectory, assertCompatibleOpencodeVersion, createProjectChannels, createDefaultKimakiChannel, } from './discord-bot.js';
|
|
6
6
|
import { getBotTokenWithMode, ensureServiceAuthToken, setBotToken, setBotMode, setChannelDirectory, findChannelsByDirectory, getDb, } from './database.js';
|
|
7
7
|
import * as orm from 'drizzle-orm';
|
|
8
8
|
import * as dbSchema from './schema.js';
|
|
@@ -1175,6 +1175,9 @@ export async function run({ restartOnboarding, addChannels, useWorktrees, enable
|
|
|
1175
1175
|
cliLogger.error('Failed to start hrana server:', hranaResult.message);
|
|
1176
1176
|
process.exit(EXIT_NO_RESTART);
|
|
1177
1177
|
}
|
|
1178
|
+
// We own the lock port from here on. Stop signals must release it in every
|
|
1179
|
+
// startup phase (Discord login, channel reconcile), not only after ready.
|
|
1180
|
+
registerBotLifecycleHandlers();
|
|
1178
1181
|
// Initialize database (connects to hrana server via HTTP)
|
|
1179
1182
|
await initDatabase();
|
|
1180
1183
|
const { appId, token, credentialSource, isGatewayMode, installerDiscordUserId } = await resolveCredentials({
|
|
@@ -1316,6 +1319,10 @@ export async function run({ restartOnboarding, addChannels, useWorktrees, enable
|
|
|
1316
1319
|
}
|
|
1317
1320
|
process.exit(EXIT_NO_RESTART);
|
|
1318
1321
|
}
|
|
1322
|
+
// Stop signal during Discord login: shutdown owns the process now.
|
|
1323
|
+
if (global.shuttingDown) {
|
|
1324
|
+
return;
|
|
1325
|
+
}
|
|
1319
1326
|
await setBotToken(appId, token);
|
|
1320
1327
|
// In gateway mode the bot only sees guilds the user has installed
|
|
1321
1328
|
// it in. Zero guilds means the install URL callback never completed or the
|
|
@@ -1351,6 +1358,9 @@ export async function run({ restartOnboarding, addChannels, useWorktrees, enable
|
|
|
1351
1358
|
// Start bot immediately — channel sync happens in the background.
|
|
1352
1359
|
cliLogger.log('Starting Discord bot...');
|
|
1353
1360
|
await startDiscordBot({ token, appId, discordClient, useWorktrees });
|
|
1361
|
+
if (global.shuttingDown) {
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1354
1364
|
cliLogger.log('Discord bot is running!');
|
|
1355
1365
|
// Background channel sync + role reconciliation + default channel creation.
|
|
1356
1366
|
// Never blocks ready state.
|
|
@@ -319,9 +319,9 @@ describe('kimaki send --channel thread creation', () => {
|
|
|
319
319
|
"--- from: user (cli-send-tester)
|
|
320
320
|
thread empty content seed
|
|
321
321
|
--- from: assistant (TestBot)
|
|
322
|
-
|
|
322
|
+
-# *using deterministic-provider/deterministic-v2*
|
|
323
323
|
> caught-by-model
|
|
324
|
-
|
|
324
|
+
-# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
325
325
|
--- from: user (cli-send-tester)
|
|
326
326
|
--- from: assistant (TestBot)
|
|
327
327
|
I can see you sent a message, but Discord did not include its text.
|
package/dist/cli.js
CHANGED
|
@@ -39,7 +39,7 @@ cli
|
|
|
39
39
|
.option('--verbosity <level>', 'Default verbosity for all channels (tools_and_text, text_and_essential_tools, or text_only)')
|
|
40
40
|
.option('--mention-mode', 'Bot only responds when @mentioned (default for all channels)')
|
|
41
41
|
.option('--no-critique', 'Disable automatic diff upload to critique.work in system prompts')
|
|
42
|
-
.option('--
|
|
42
|
+
.option('--enable-footer-mentions', 'Mention the thread creator in final session footers so they get a notification')
|
|
43
43
|
.option('--auto-restart', 'Automatically restart the bot on crash or OOM kill')
|
|
44
44
|
.option('--allow-all-users', 'Allow all Discord users to start sessions without needing Kimaki role or admin permissions (no-kimaki role still blocks)')
|
|
45
45
|
.option('--restrict-directories', 'Only allow the agent to access the session working directory and a few known-safe paths. Any other folder asks for permission. By default every directory is allowed and you protect folders with deny/ask rules in opencode.json')
|
|
@@ -176,7 +176,7 @@ cli
|
|
|
176
176
|
}),
|
|
177
177
|
...(options.mentionMode && { defaultMentionMode: true }),
|
|
178
178
|
...(options.noCritique && { critiqueEnabled: false }),
|
|
179
|
-
...(options.
|
|
179
|
+
...(options.enableFooterMentions && { footerMentionsEnabled: true }),
|
|
180
180
|
...(options.allowAllUsers && { allowAllUsers: true }),
|
|
181
181
|
...(options.restrictDirectories && { restrictExternalDirectories: true }),
|
|
182
182
|
...(permissionTimeoutMs !== undefined && { permissionTimeoutMs }),
|
|
@@ -212,8 +212,8 @@ cli
|
|
|
212
212
|
if (options.noCritique) {
|
|
213
213
|
cliLogger.log('Critique disabled: diffs will not be auto-uploaded to critique.work');
|
|
214
214
|
}
|
|
215
|
-
if (options.
|
|
216
|
-
cliLogger.log('Footer mentions
|
|
215
|
+
if (options.enableFooterMentions) {
|
|
216
|
+
cliLogger.log('Footer mentions enabled: final session footers will mention thread creators');
|
|
217
217
|
}
|
|
218
218
|
if (options.noAutoUpgrade) {
|
|
219
219
|
cliLogger.log('Auto-upgrade disabled: kimaki will not check for updates on startup');
|
|
@@ -8,6 +8,7 @@ import { getOpencodeClient } from '../opencode.js';
|
|
|
8
8
|
import { DiscordOperationError } from '../errors.js';
|
|
9
9
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
10
10
|
import { QUEUE_PREFIX } from '../message-formatting.js';
|
|
11
|
+
import { getRuntime } from '../session-handler/thread-session-runtime.js';
|
|
11
12
|
const logger = createLogger(LogPrefix.ASK_QUESTION);
|
|
12
13
|
// Store pending question contexts by hash.
|
|
13
14
|
// No TTL on purpose: a question can only be answered by the user, so expiring it
|
|
@@ -185,41 +186,89 @@ export async function handleAskQuestionSelectMenu(interaction) {
|
|
|
185
186
|
});
|
|
186
187
|
const username = interaction.user.globalName || interaction.user.username;
|
|
187
188
|
await sendThreadMessage(context.thread, `${QUEUE_PREFIX}**${username}:** ${answeredText}`);
|
|
188
|
-
// Check if all questions are answered
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
189
|
+
// Check if all questions are answered. Claim the context synchronously
|
|
190
|
+
// (delete before awaiting submit) so two concurrent final selections for a
|
|
191
|
+
// multi-question tool cannot both submit and double-resume the session.
|
|
192
|
+
if (areAllQuestionsAnswered(context)
|
|
193
|
+
&& pendingQuestionContexts.get(contextHash) === context) {
|
|
192
194
|
deletePendingQuestionContextsForRequest({
|
|
193
195
|
threadId: context.thread.id,
|
|
194
196
|
requestId: context.requestId,
|
|
195
197
|
});
|
|
198
|
+
await submitQuestionAnswers(context);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Format collected answers as a plain-text summary the model can read when
|
|
203
|
+
* the run is resumed after an abort (e.g. `"Which option?"="Alpha"`).
|
|
204
|
+
*
|
|
205
|
+
* Known limitation: if the user had queued items via /queue during the pending
|
|
206
|
+
* question AND aborted the run from another opencode client, the first queued
|
|
207
|
+
* item may have already been handed off to opencode and can lose its ordering
|
|
208
|
+
* on resume. That rare combination needs a queue-handoff redesign; the common
|
|
209
|
+
* (no-queue) abort case is handled correctly.
|
|
210
|
+
*/
|
|
211
|
+
function formatQuestionAnswersText(context) {
|
|
212
|
+
const parts = context.questions.map((q, i) => {
|
|
213
|
+
const answer = (context.answers[i] || []).join(', ') || 'Unanswered';
|
|
214
|
+
return `"${q.question}"="${answer}"`;
|
|
215
|
+
});
|
|
216
|
+
return `Answers to your previous questions: ${parts.join(', ')}`;
|
|
217
|
+
}
|
|
218
|
+
/** Resume the session by feeding the answers back as a new user prompt. */
|
|
219
|
+
async function resumeSessionWithAnswers(context) {
|
|
220
|
+
const runtime = getRuntime(context.thread.id);
|
|
221
|
+
const resumed = await runtime?.resumeWithText({
|
|
222
|
+
text: formatQuestionAnswersText(context),
|
|
223
|
+
});
|
|
224
|
+
if (!resumed) {
|
|
225
|
+
await sendThreadMessage(context.thread, '✗ Failed to submit answers: session is no longer active');
|
|
196
226
|
}
|
|
197
227
|
}
|
|
198
228
|
/**
|
|
199
229
|
* Submit all collected answers back to the OpenCode session.
|
|
200
|
-
*
|
|
230
|
+
*
|
|
231
|
+
* The decision is based on whether the session still has a live run:
|
|
232
|
+
*
|
|
233
|
+
* - Busy: a live run is parked on the question. Reply so it continues.
|
|
234
|
+
* - Idle: the run was aborted (from this or another opencode client). Replying
|
|
235
|
+
* would resolve a dead run and the session would never continue, so resume it
|
|
236
|
+
* with the answers as a fresh prompt instead.
|
|
237
|
+
*
|
|
238
|
+
* Note: `question.list` is not a reliable signal here. After an abort the
|
|
239
|
+
* question request can stay in the pending list but orphaned (no run awaiting
|
|
240
|
+
* it), so a reply would succeed yet the session would still not continue. The
|
|
241
|
+
* session busy state, derived from the event stream, is the accurate signal.
|
|
242
|
+
* The reply `.error` check is a safety net for the case where the request was
|
|
243
|
+
* actually removed while the session still looked busy.
|
|
201
244
|
*/
|
|
202
245
|
async function submitQuestionAnswers(context) {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
}
|
|
208
|
-
// Build answers array: each element is an array of selected labels for that question
|
|
209
|
-
const answers = context.questions.map((_, i) => {
|
|
210
|
-
return context.answers[i] || [];
|
|
211
|
-
});
|
|
212
|
-
await client.question.reply({
|
|
213
|
-
requestID: context.requestId,
|
|
214
|
-
directory: context.directory,
|
|
215
|
-
answers,
|
|
216
|
-
});
|
|
217
|
-
logger.log(`Submitted answers for question ${context.requestId} in session ${context.sessionId}`);
|
|
246
|
+
const client = getOpencodeClient(context.directory);
|
|
247
|
+
if (!client) {
|
|
248
|
+
logger.error('OpenCode server not found for directory');
|
|
249
|
+
return;
|
|
218
250
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
251
|
+
const runtime = getRuntime(context.thread.id);
|
|
252
|
+
if (runtime && !runtime.isBusy()) {
|
|
253
|
+
logger.log(`Session ${context.sessionId} idle; resuming with answers for question ${context.requestId}`);
|
|
254
|
+
await resumeSessionWithAnswers(context);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const answers = context.questions.map((_, i) => {
|
|
258
|
+
return context.answers[i] || [];
|
|
259
|
+
});
|
|
260
|
+
// throwOnError is off on this client, so failures come back in `.error`.
|
|
261
|
+
const replyResult = await client.question
|
|
262
|
+
.reply({ requestID: context.requestId, directory: context.directory, answers })
|
|
263
|
+
.catch((error) => ({ error }));
|
|
264
|
+
if (!replyResult.error) {
|
|
265
|
+
logger.log(`Submitted answers for question ${context.requestId} in session ${context.sessionId}`);
|
|
266
|
+
return;
|
|
222
267
|
}
|
|
268
|
+
// Reply failed (the request was removed while the session still looked busy).
|
|
269
|
+
// Resume so the answer still reaches the model.
|
|
270
|
+
logger.log(`Reply failed for question ${context.requestId}; resuming session ${context.sessionId} with answers`);
|
|
271
|
+
await resumeSessionWithAnswers(context);
|
|
223
272
|
}
|
|
224
273
|
/**
|
|
225
274
|
* Check if a tool part is an AskUserQuestion tool.
|
package/dist/commands/queue.js
CHANGED
|
@@ -5,7 +5,7 @@ import { deleteThreadQueueItem, getThreadSession } from '../database.js';
|
|
|
5
5
|
import { resolveWorkingDirectory, SILENT_MESSAGE_FLAGS, } from '../discord-utils.js';
|
|
6
6
|
import { getOrCreateRuntime, getRuntime, } from '../session-handler/thread-session-runtime.js';
|
|
7
7
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
8
|
-
import { QUEUE_PREFIX } from '../message-formatting.js';
|
|
8
|
+
import { asSystemLine, QUEUE_PREFIX } from '../message-formatting.js';
|
|
9
9
|
import { store } from '../store.js';
|
|
10
10
|
const logger = createLogger(LogPrefix.QUEUE);
|
|
11
11
|
export const QUEUE_REMOVE_CUSTOM_ID_PREFIX = 'queue_remove:';
|
|
@@ -75,7 +75,7 @@ export async function handleQueueRemoveButton(interaction) {
|
|
|
75
75
|
: await deleteThreadQueueItemWithoutRuntime(parsed.queueId);
|
|
76
76
|
if (!removed) {
|
|
77
77
|
await interaction.editReply({
|
|
78
|
-
content: 'Queued message is no longer in the queue',
|
|
78
|
+
content: asSystemLine('Queued message is no longer in the queue'),
|
|
79
79
|
components: [],
|
|
80
80
|
});
|
|
81
81
|
return;
|
|
@@ -84,7 +84,7 @@ export async function handleQueueRemoveButton(interaction) {
|
|
|
84
84
|
? `/${removed.command.name}`
|
|
85
85
|
: removed.prompt.slice(0, 120);
|
|
86
86
|
await interaction.editReply({
|
|
87
|
-
content: `Removed queued message: ${label}
|
|
87
|
+
content: asSystemLine(`Removed queued message: ${label}`),
|
|
88
88
|
components: [],
|
|
89
89
|
});
|
|
90
90
|
logger.log(`[QUEUE] User ${interaction.user.displayName} removed queued item ${parsed.queueId} in thread ${parsed.threadId}`);
|
|
@@ -145,7 +145,7 @@ export async function handleQueueCommand({ command, appId, }) {
|
|
|
145
145
|
mode: 'local-queue',
|
|
146
146
|
});
|
|
147
147
|
if (enqueueResult.queued && enqueueResult.queueId) {
|
|
148
|
-
const responseText = `Queued message${enqueueResult.position ? ` (position ${enqueueResult.position})` : ''}
|
|
148
|
+
const responseText = asSystemLine(`Queued message${enqueueResult.position ? ` (position ${enqueueResult.position})` : ''}`);
|
|
149
149
|
await command.reply({
|
|
150
150
|
content: responseText,
|
|
151
151
|
components: [
|
|
@@ -205,7 +205,7 @@ export async function handleClearQueueCommand({ command, }) {
|
|
|
205
205
|
return;
|
|
206
206
|
}
|
|
207
207
|
await command.reply({
|
|
208
|
-
content: `Cleared queued message at position ${position}
|
|
208
|
+
content: asSystemLine(`Cleared queued message at position ${position}`),
|
|
209
209
|
flags: SILENT_MESSAGE_FLAGS,
|
|
210
210
|
});
|
|
211
211
|
logger.log(`[QUEUE] User ${command.user.displayName} cleared queued position ${position} in thread ${channel.id}`);
|
|
@@ -223,7 +223,7 @@ export async function handleClearQueueCommand({ command, }) {
|
|
|
223
223
|
list = list.slice(0, 597) + '...';
|
|
224
224
|
}
|
|
225
225
|
await command.reply({
|
|
226
|
-
content: `Cleared ${cleared.length} queued message${cleared.length > 1 ? 's' : ''}:\n${list}
|
|
226
|
+
content: asSystemLine(`Cleared ${cleared.length} queued message${cleared.length > 1 ? 's' : ''}:\n${list}`),
|
|
227
227
|
flags: SILENT_MESSAGE_FLAGS,
|
|
228
228
|
});
|
|
229
229
|
logger.log(`[QUEUE] User ${command.user.displayName} cleared queue in thread ${channel.id}`);
|
|
@@ -299,7 +299,7 @@ export async function handleQueueCommandCommand({ command, appId, }) {
|
|
|
299
299
|
mode: 'local-queue',
|
|
300
300
|
});
|
|
301
301
|
if (enqueueResult.queued && enqueueResult.queueId) {
|
|
302
|
-
const responseText = `Queued message${enqueueResult.position ? ` (position ${enqueueResult.position})` : ''}
|
|
302
|
+
const responseText = asSystemLine(`Queued message${enqueueResult.position ? ` (position ${enqueueResult.position})` : ''}`);
|
|
303
303
|
await command.reply({
|
|
304
304
|
content: responseText,
|
|
305
305
|
components: [
|
package/dist/database.js
CHANGED
|
@@ -174,13 +174,13 @@ export async function recoverStaleRunningScheduledTasks({ staleBefore }) {
|
|
|
174
174
|
export async function deleteScheduledTask(taskId) {
|
|
175
175
|
const db = await getDb();
|
|
176
176
|
await db.delete(schema.scheduled_tasks)
|
|
177
|
-
.where(orm.eq(schema.scheduled_tasks.id, taskId));
|
|
177
|
+
.where(orm.and(orm.eq(schema.scheduled_tasks.id, taskId), orm.eq(schema.scheduled_tasks.status, 'running')));
|
|
178
178
|
}
|
|
179
179
|
export async function markScheduledTaskCronRescheduled({ taskId, completedAt, nextRunAt }) {
|
|
180
180
|
const db = await getDb();
|
|
181
181
|
await db.update(schema.scheduled_tasks)
|
|
182
182
|
.set({ status: 'planned', last_run_at: completedAt, running_started_at: null, last_error: null, next_run_at: nextRunAt })
|
|
183
|
-
.where(orm.eq(schema.scheduled_tasks.id, taskId));
|
|
183
|
+
.where(orm.and(orm.eq(schema.scheduled_tasks.id, taskId), orm.eq(schema.scheduled_tasks.status, 'running')));
|
|
184
184
|
}
|
|
185
185
|
export async function markScheduledTaskFailed({ taskId, failedAt, errorMessage }) {
|
|
186
186
|
const db = await getDb();
|
|
@@ -192,7 +192,7 @@ export async function markScheduledTaskFailed({ taskId, failedAt, errorMessage }
|
|
|
192
192
|
last_error: errorMessage,
|
|
193
193
|
attempts: orm.sql `${schema.scheduled_tasks.attempts} + 1`,
|
|
194
194
|
})
|
|
195
|
-
.where(orm.eq(schema.scheduled_tasks.id, taskId));
|
|
195
|
+
.where(orm.and(orm.eq(schema.scheduled_tasks.id, taskId), orm.eq(schema.scheduled_tasks.status, 'running')));
|
|
196
196
|
}
|
|
197
197
|
export async function markScheduledTaskCronRetry({ taskId, failedAt, errorMessage, nextRunAt }) {
|
|
198
198
|
const db = await getDb();
|
|
@@ -205,7 +205,7 @@ export async function markScheduledTaskCronRetry({ taskId, failedAt, errorMessag
|
|
|
205
205
|
last_error: errorMessage,
|
|
206
206
|
attempts: orm.sql `${schema.scheduled_tasks.attempts} + 1`,
|
|
207
207
|
})
|
|
208
|
-
.where(orm.eq(schema.scheduled_tasks.id, taskId));
|
|
208
|
+
.where(orm.and(orm.eq(schema.scheduled_tasks.id, taskId), orm.eq(schema.scheduled_tasks.status, 'running')));
|
|
209
209
|
}
|
|
210
210
|
export async function setSessionStartSource({ sessionId, scheduleKind, scheduledTaskId }) {
|
|
211
211
|
const db = await getDb();
|
|
@@ -913,20 +913,87 @@ export async function deleteChannelDirectoriesByDirectory(directory) {
|
|
|
913
913
|
const db = await getDb();
|
|
914
914
|
await db.delete(schema.channel_directories).where(orm.eq(schema.channel_directories.directory, directory));
|
|
915
915
|
}
|
|
916
|
-
export async function deleteChannelDirectoryById(channelId) {
|
|
916
|
+
export async function deleteChannelDirectoryById(channelId, { preserveMapping = false } = {}) {
|
|
917
917
|
const db = await getDb();
|
|
918
918
|
await db.batch([
|
|
919
|
+
db.update(schema.scheduled_tasks)
|
|
920
|
+
.set({
|
|
921
|
+
status: 'cancelled',
|
|
922
|
+
running_started_at: null,
|
|
923
|
+
last_error: 'Discord channel was deleted',
|
|
924
|
+
})
|
|
925
|
+
.where(orm.and(orm.eq(schema.scheduled_tasks.channel_id, channelId), orm.inArray(schema.scheduled_tasks.status, ['planned', 'running']))),
|
|
919
926
|
db.delete(schema.channel_models).where(orm.eq(schema.channel_models.channel_id, channelId)),
|
|
920
927
|
db.delete(schema.channel_agents).where(orm.eq(schema.channel_agents.channel_id, channelId)),
|
|
921
928
|
db.delete(schema.channel_worktrees).where(orm.eq(schema.channel_worktrees.channel_id, channelId)),
|
|
922
929
|
db.delete(schema.channel_verbosity).where(orm.eq(schema.channel_verbosity.channel_id, channelId)),
|
|
923
930
|
db.delete(schema.channel_mention_mode).where(orm.eq(schema.channel_mention_mode.channel_id, channelId)),
|
|
924
931
|
]);
|
|
932
|
+
if (preserveMapping) {
|
|
933
|
+
return Boolean(await db.query.channel_directories.findFirst({
|
|
934
|
+
where: { channel_id: channelId },
|
|
935
|
+
columns: { channel_id: true },
|
|
936
|
+
}));
|
|
937
|
+
}
|
|
925
938
|
const rows = await db.delete(schema.channel_directories)
|
|
926
939
|
.where(orm.eq(schema.channel_directories.channel_id, channelId))
|
|
927
940
|
.returning({ channel_id: schema.channel_directories.channel_id });
|
|
928
941
|
return rows.length > 0;
|
|
929
942
|
}
|
|
943
|
+
export async function cleanupDeletedThread(threadId) {
|
|
944
|
+
const db = await getDb();
|
|
945
|
+
const binding = await db.query.thread_sessions.findFirst({
|
|
946
|
+
where: { thread_id: threadId },
|
|
947
|
+
columns: { session_id: true },
|
|
948
|
+
});
|
|
949
|
+
const currentThreadId = binding
|
|
950
|
+
? await getThreadIdBySessionId(binding.session_id)
|
|
951
|
+
: undefined;
|
|
952
|
+
const currentSessionId = currentThreadId === threadId
|
|
953
|
+
? binding?.session_id
|
|
954
|
+
: undefined;
|
|
955
|
+
await db.batch([
|
|
956
|
+
db.delete(schema.thread_queue_items)
|
|
957
|
+
.where(orm.eq(schema.thread_queue_items.thread_id, threadId)),
|
|
958
|
+
db.update(schema.session_sleeps)
|
|
959
|
+
.set({ status: 'cancelled' })
|
|
960
|
+
.where(orm.and(orm.eq(schema.session_sleeps.status, 'planned'), orm.eq(schema.session_sleeps.session_id, currentSessionId ?? ''))),
|
|
961
|
+
db.update(schema.scheduled_tasks)
|
|
962
|
+
.set({
|
|
963
|
+
status: 'cancelled',
|
|
964
|
+
running_started_at: null,
|
|
965
|
+
last_error: 'Discord thread was deleted',
|
|
966
|
+
})
|
|
967
|
+
.where(orm.and(orm.eq(schema.scheduled_tasks.thread_id, threadId), orm.inArray(schema.scheduled_tasks.status, ['planned', 'running']))),
|
|
968
|
+
db.update(schema.ipc_requests)
|
|
969
|
+
.set({
|
|
970
|
+
status: 'cancelled',
|
|
971
|
+
response: JSON.stringify({ error: 'Discord thread was deleted' }),
|
|
972
|
+
})
|
|
973
|
+
.where(orm.and(orm.eq(schema.ipc_requests.thread_id, threadId), orm.inArray(schema.ipc_requests.status, ['pending', 'processing']))),
|
|
974
|
+
]);
|
|
975
|
+
}
|
|
976
|
+
export async function isCurrentThreadSessionBinding(threadId) {
|
|
977
|
+
const db = await getDb();
|
|
978
|
+
const binding = await db.query.thread_sessions.findFirst({
|
|
979
|
+
where: { thread_id: threadId },
|
|
980
|
+
columns: { session_id: true },
|
|
981
|
+
});
|
|
982
|
+
if (!binding)
|
|
983
|
+
return false;
|
|
984
|
+
return await getThreadIdBySessionId(binding.session_id) === threadId;
|
|
985
|
+
}
|
|
986
|
+
export async function clearGuildCategoryByChannelId(channelId) {
|
|
987
|
+
const db = await getDb();
|
|
988
|
+
await db.batch([
|
|
989
|
+
db.update(schema.guild_categories)
|
|
990
|
+
.set({ category_id: null })
|
|
991
|
+
.where(orm.eq(schema.guild_categories.category_id, channelId)),
|
|
992
|
+
db.update(schema.guild_categories)
|
|
993
|
+
.set({ audio_category_id: null })
|
|
994
|
+
.where(orm.eq(schema.guild_categories.audio_category_id, channelId)),
|
|
995
|
+
]);
|
|
996
|
+
}
|
|
930
997
|
export async function getVoiceChannelDirectory(channelId) {
|
|
931
998
|
const db = await getDb();
|
|
932
999
|
return (await db.query.channel_directories.findFirst({ where: { channel_id: channelId, channel_type: 'voice' } }))?.directory;
|
|
@@ -982,7 +1049,7 @@ export async function completeIpcRequest({ id, response }) {
|
|
|
982
1049
|
const db = await getDb();
|
|
983
1050
|
const [row] = await db.update(schema.ipc_requests)
|
|
984
1051
|
.set({ response, status: 'completed' })
|
|
985
|
-
.where(orm.eq(schema.ipc_requests.id, id))
|
|
1052
|
+
.where(orm.and(orm.eq(schema.ipc_requests.id, id), orm.inArray(schema.ipc_requests.status, ['pending', 'processing'])))
|
|
986
1053
|
.returning();
|
|
987
1054
|
return row;
|
|
988
1055
|
}
|