kimaki 0.20.1 → 0.21.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 +6 -4
- package/dist/cli-commands/project.js +72 -15
- package/dist/cli-commands/send.js +5 -0
- package/dist/cli-runner.js +29 -6
- package/dist/cli-runner.test.js +28 -1
- package/dist/commands/btw.js +3 -0
- package/dist/commands/tasks.js +97 -19
- package/dist/database.js +14 -0
- package/dist/db.js +1 -0
- package/dist/discord-bot.js +27 -1
- package/dist/interaction-handler.js +6 -15
- package/dist/queue-advanced-e2e-setup.js +22 -0
- package/dist/queue-delete-message.e2e.test.js +75 -0
- package/dist/schema.js +3 -0
- package/dist/session-handler/thread-runtime-state.js +9 -0
- package/dist/session-handler/thread-session-runtime.js +49 -1
- package/dist/system-message.js +31 -56
- package/dist/system-message.test.js +49 -54
- package/dist/task-runner.js +18 -3
- package/dist/task-schedule.js +4 -0
- package/dist/thread-message-queue.e2e.test.js +4 -4
- package/dist/voice-message.e2e.test.js +1 -1
- package/package.json +12 -5
- package/skills/egaki/SKILL.md +26 -80
- package/skills/goke/SKILL.md +27 -20
- package/skills/holocron/SKILL.md +49 -8
- package/skills/new-skill/SKILL.md +87 -0
- package/skills/playwriter/SKILL.md +4 -4
- package/skills/spiceflow/SKILL.md +2 -0
- package/src/agent-model.e2e.test.ts +10 -4
- package/src/cli-commands/project.ts +89 -15
- package/src/cli-commands/send.ts +8 -0
- package/src/cli-runner.test.ts +39 -1
- package/src/cli-runner.ts +34 -6
- package/src/commands/btw.ts +3 -0
- package/src/commands/tasks.ts +111 -19
- package/src/database.ts +24 -0
- package/src/db.ts +1 -0
- package/src/discord-bot.ts +38 -1
- package/src/interaction-handler.ts +7 -19
- package/src/queue-advanced-e2e-setup.ts +23 -0
- package/src/queue-delete-message.e2e.test.ts +102 -0
- package/src/schema.sql +1 -0
- package/src/schema.ts +3 -0
- package/src/session-handler/thread-runtime-state.ts +19 -0
- package/src/session-handler/thread-session-runtime.ts +75 -0
- package/src/system-message.test.ts +57 -54
- package/src/system-message.ts +44 -55
- package/src/task-runner.ts +34 -3
- package/src/task-schedule.ts +6 -0
- package/src/thread-message-queue.e2e.test.ts +4 -4
- package/src/voice-message.e2e.test.ts +1 -1
|
@@ -539,13 +539,14 @@ describe('agent model resolution', () => {
|
|
|
539
539
|
const db = await getDb();
|
|
540
540
|
await db.delete(schema.channel_agents).where(orm.eq(schema.channel_agents.channel_id, TEXT_CHANNEL_ID));
|
|
541
541
|
await db.delete(schema.channel_models).where(orm.eq(schema.channel_models.channel_id, TEXT_CHANNEL_ID));
|
|
542
|
+
const existingThreadIds = new Set((await discord.channel(TEXT_CHANNEL_ID).getThreads()).map((thread) => thread.id));
|
|
542
543
|
await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
543
544
|
content: 'Reply with exactly: btw-source-msg',
|
|
544
545
|
});
|
|
545
546
|
const sourceThread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
546
547
|
timeout: 4_000,
|
|
547
|
-
predicate: (
|
|
548
|
-
return
|
|
548
|
+
predicate: (thread) => {
|
|
549
|
+
return !existingThreadIds.has(thread.id);
|
|
549
550
|
},
|
|
550
551
|
});
|
|
551
552
|
await waitForFooterMessage({
|
|
@@ -567,13 +568,14 @@ describe('agent model resolution', () => {
|
|
|
567
568
|
channelId: TEXT_CHANNEL_ID,
|
|
568
569
|
modelId: `${PROVIDER_NAME}/${CHANNEL_MODEL}`,
|
|
569
570
|
});
|
|
571
|
+
const existingForkThreadIds = new Set((await discord.channel(TEXT_CHANNEL_ID).getThreads()).map((thread) => thread.id));
|
|
570
572
|
await discord.thread(sourceThread.id).user(TEST_USER_ID).sendMessage({
|
|
571
573
|
content: 'Reply with exactly: btw-model-check. btw',
|
|
572
574
|
});
|
|
573
575
|
const forkedThread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
574
576
|
timeout: 4_000,
|
|
575
|
-
predicate: (
|
|
576
|
-
return
|
|
577
|
+
predicate: (thread) => {
|
|
578
|
+
return !existingForkThreadIds.has(thread.id);
|
|
577
579
|
},
|
|
578
580
|
});
|
|
579
581
|
await waitForFooterMessage({
|
|
@@ -110,16 +110,18 @@ cli
|
|
|
110
110
|
cliLogger.log('No projects registered');
|
|
111
111
|
process.exit(0);
|
|
112
112
|
}
|
|
113
|
-
// Fetch Discord channel names via REST API
|
|
113
|
+
// Fetch Discord channel names and guild IDs via REST API
|
|
114
114
|
const botRow = await getBotTokenWithMode();
|
|
115
115
|
const rest = botRow ? createDiscordRest(botRow.token) : null;
|
|
116
116
|
const enriched = await Promise.all(channels.map(async (ch) => {
|
|
117
117
|
let channelName = '';
|
|
118
|
+
let guildId = '';
|
|
118
119
|
let deleted = false;
|
|
119
120
|
if (rest) {
|
|
120
121
|
try {
|
|
121
122
|
const data = (await rest.get(Routes.channel(ch.channel_id)));
|
|
122
123
|
channelName = data.name || '';
|
|
124
|
+
guildId = data.guild_id || '';
|
|
123
125
|
}
|
|
124
126
|
catch (error) {
|
|
125
127
|
// Only mark as deleted for Unknown Channel (10003) or 404,
|
|
@@ -130,13 +132,43 @@ cli
|
|
|
130
132
|
deleted = isUnknownChannel;
|
|
131
133
|
}
|
|
132
134
|
}
|
|
133
|
-
return { ...ch, channelName, deleted };
|
|
135
|
+
return { ...ch, channelName, guildId, deleted };
|
|
134
136
|
}));
|
|
137
|
+
// Fetch guild names for unique guild IDs (deduplicated to save API calls)
|
|
138
|
+
const guildNameMap = new Map();
|
|
139
|
+
if (rest) {
|
|
140
|
+
const uniqueGuildIds = [...new Set(enriched.map((ch) => ch.guildId).filter(Boolean))];
|
|
141
|
+
await Promise.all(uniqueGuildIds.map(async (guildId) => {
|
|
142
|
+
try {
|
|
143
|
+
const data = (await rest.get(Routes.guild(guildId)));
|
|
144
|
+
guildNameMap.set(guildId, data.name || '');
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
cliLogger.debug(`Failed to fetch guild ${guildId}:`, error instanceof Error ? error.stack : String(error));
|
|
148
|
+
}
|
|
149
|
+
}));
|
|
150
|
+
}
|
|
151
|
+
// Build final enriched entries with guild names resolved
|
|
152
|
+
const enrichedWithGuild = enriched.map((ch) => ({
|
|
153
|
+
...ch,
|
|
154
|
+
guildName: ch.guildId ? (guildNameMap.get(ch.guildId) || '') : '',
|
|
155
|
+
}));
|
|
156
|
+
// Warn on stderr if the same directory appears in multiple channels (multi-guild duplicates)
|
|
157
|
+
const directoryCounts = new Map();
|
|
158
|
+
for (const ch of enrichedWithGuild) {
|
|
159
|
+
if (!ch.deleted) {
|
|
160
|
+
directoryCounts.set(ch.directory, (directoryCounts.get(ch.directory) || 0) + 1);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
for (const [dir, count] of directoryCounts) {
|
|
164
|
+
if (count > 1) {
|
|
165
|
+
cliLogger.warn(`Directory "${dir}" is registered in ${count} channels. Use channel_id to disambiguate.`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
135
168
|
// Prune stale entries if requested
|
|
169
|
+
let finalEntries = enrichedWithGuild;
|
|
136
170
|
if (options.prune) {
|
|
137
|
-
const stale =
|
|
138
|
-
return ch.deleted;
|
|
139
|
-
});
|
|
171
|
+
const stale = finalEntries.filter((ch) => ch.deleted);
|
|
140
172
|
if (stale.length === 0) {
|
|
141
173
|
cliLogger.log('No stale channels to prune');
|
|
142
174
|
}
|
|
@@ -147,21 +179,18 @@ cli
|
|
|
147
179
|
}
|
|
148
180
|
cliLogger.log(`Pruned ${stale.length} stale channel(s)`);
|
|
149
181
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
return !ch.deleted;
|
|
153
|
-
});
|
|
154
|
-
if (live.length === 0) {
|
|
182
|
+
finalEntries = finalEntries.filter((ch) => !ch.deleted);
|
|
183
|
+
if (finalEntries.length === 0) {
|
|
155
184
|
cliLogger.log('No projects registered');
|
|
156
185
|
process.exit(0);
|
|
157
186
|
}
|
|
158
|
-
enriched.length = 0;
|
|
159
|
-
enriched.push(...live);
|
|
160
187
|
}
|
|
161
188
|
if (options.json) {
|
|
162
|
-
const output =
|
|
189
|
+
const output = finalEntries.map((ch) => ({
|
|
163
190
|
channel_id: ch.channel_id,
|
|
164
191
|
channel_name: ch.channelName,
|
|
192
|
+
guild_id: ch.guildId,
|
|
193
|
+
guild_name: ch.guildName,
|
|
165
194
|
directory: ch.directory,
|
|
166
195
|
folder_name: path.basename(ch.directory),
|
|
167
196
|
deleted: ch.deleted,
|
|
@@ -169,15 +198,43 @@ cli
|
|
|
169
198
|
console.log(JSON.stringify(output, null, 2));
|
|
170
199
|
process.exit(0);
|
|
171
200
|
}
|
|
172
|
-
for (const ch of
|
|
201
|
+
for (const ch of finalEntries) {
|
|
173
202
|
const folderName = path.basename(ch.directory);
|
|
174
203
|
const deletedTag = ch.deleted ? ' (deleted from Discord)' : '';
|
|
175
204
|
const channelLabel = ch.channelName ? `#${ch.channelName}` : ch.channel_id;
|
|
176
|
-
|
|
205
|
+
const guildLabel = ch.guildName || ch.guildId || '';
|
|
206
|
+
const guildSuffix = guildLabel ? ` (${guildLabel})` : '';
|
|
207
|
+
console.log(`\n${channelLabel}${guildSuffix}${deletedTag}`);
|
|
177
208
|
console.log(` Folder: ${folderName}`);
|
|
178
209
|
console.log(` Directory: ${ch.directory}`);
|
|
179
210
|
console.log(` Channel ID: ${ch.channel_id}`);
|
|
211
|
+
if (ch.guildId) {
|
|
212
|
+
console.log(` Guild ID: ${ch.guildId}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
process.exit(0);
|
|
216
|
+
});
|
|
217
|
+
cli
|
|
218
|
+
.command('project remove <channelId>', 'Remove a project channel mapping from the local database (does not delete the Discord channel)')
|
|
219
|
+
.action(async (channelId) => {
|
|
220
|
+
await initDatabase();
|
|
221
|
+
const db = await getDb();
|
|
222
|
+
const row = await db.query.channel_directories.findFirst({
|
|
223
|
+
where: { channel_id: channelId },
|
|
224
|
+
});
|
|
225
|
+
if (!row) {
|
|
226
|
+
cliLogger.error(`No channel mapping found for channel ID: ${channelId}`);
|
|
227
|
+
process.exit(EXIT_NO_RESTART);
|
|
228
|
+
}
|
|
229
|
+
const removed = await deleteChannelDirectoryById(channelId);
|
|
230
|
+
if (!removed) {
|
|
231
|
+
cliLogger.error(`Channel mapping disappeared before it could be removed: ${channelId}`);
|
|
232
|
+
process.exit(EXIT_NO_RESTART);
|
|
180
233
|
}
|
|
234
|
+
cliLogger.log(`Removed channel mapping:`);
|
|
235
|
+
cliLogger.log(` Channel ID: ${channelId}`);
|
|
236
|
+
cliLogger.log(` Directory: ${row.directory}`);
|
|
237
|
+
cliLogger.log(` Type: ${row.channel_type}`);
|
|
181
238
|
process.exit(0);
|
|
182
239
|
});
|
|
183
240
|
cli
|
|
@@ -51,6 +51,7 @@ cli
|
|
|
51
51
|
.option('--send-at <schedule>', 'Schedule send for future (UTC ISO date/time ending in Z, or cron expression)')
|
|
52
52
|
.option('--thread <threadId>', 'Post prompt to an existing thread')
|
|
53
53
|
.option('--session <sessionId>', 'Post prompt to thread mapped to an existing session')
|
|
54
|
+
.option('--parent-session <sessionId>', 'Parent OpenCode session ID for newly created child sessions')
|
|
54
55
|
.option('--wait', 'Wait for session to complete, then print session text to stdout')
|
|
55
56
|
.action(async (options) => {
|
|
56
57
|
try {
|
|
@@ -299,6 +300,7 @@ cli
|
|
|
299
300
|
userId: null,
|
|
300
301
|
permissions: options.permission?.length ? options.permission : null,
|
|
301
302
|
injectionGuardPatterns: options.injectionGuard?.length ? options.injectionGuard : null,
|
|
303
|
+
parentSessionId: options.parentSession || null,
|
|
302
304
|
};
|
|
303
305
|
const taskId = await createScheduledTask({
|
|
304
306
|
scheduleKind: parsedSchedule.scheduleKind,
|
|
@@ -325,6 +327,7 @@ cli
|
|
|
325
327
|
...(options.model && { model: options.model }),
|
|
326
328
|
...(options.permission?.length ? { permissions: options.permission } : {}),
|
|
327
329
|
...(options.injectionGuard?.length ? { injectionGuardPatterns: options.injectionGuard } : {}),
|
|
330
|
+
...(options.parentSession && { parentSessionId: options.parentSession }),
|
|
328
331
|
};
|
|
329
332
|
const promptEmbed = [
|
|
330
333
|
{
|
|
@@ -438,6 +441,7 @@ cli
|
|
|
438
441
|
userId: resolvedUser?.id || null,
|
|
439
442
|
permissions: options.permission?.length ? options.permission : null,
|
|
440
443
|
injectionGuardPatterns: options.injectionGuard?.length ? options.injectionGuard : null,
|
|
444
|
+
parentSessionId: options.parentSession || null,
|
|
441
445
|
};
|
|
442
446
|
const taskId = await createScheduledTask({
|
|
443
447
|
scheduleKind: parsedSchedule.scheduleKind,
|
|
@@ -471,6 +475,7 @@ cli
|
|
|
471
475
|
...(options.model && { model: options.model }),
|
|
472
476
|
...(options.permission?.length && { permissions: options.permission }),
|
|
473
477
|
...(options.injectionGuard?.length && { injectionGuardPatterns: options.injectionGuard }),
|
|
478
|
+
...(options.parentSession && { parentSessionId: options.parentSession }),
|
|
474
479
|
};
|
|
475
480
|
const autoStartEmbed = embedMarker
|
|
476
481
|
? [{ color: 0x2b2d31, footer: { text: YAML.stringify(embedMarker) } }]
|
package/dist/cli-runner.js
CHANGED
|
@@ -12,6 +12,7 @@ import { discordApiUrl, getDiscordRestApiUrl, getGatewayProxyRestBaseUrl, getInt
|
|
|
12
12
|
import crypto from 'node:crypto';
|
|
13
13
|
import path from 'node:path';
|
|
14
14
|
import fs from 'node:fs';
|
|
15
|
+
import os from 'node:os';
|
|
15
16
|
import { spawn } from 'node:child_process';
|
|
16
17
|
import { createLogger, LogPrefix } from './logger.js';
|
|
17
18
|
import { notifyError } from './sentry.js';
|
|
@@ -174,11 +175,8 @@ export async function sendDiscordMessageWithOptionalAttachment({ channelId, prom
|
|
|
174
175
|
}
|
|
175
176
|
const preview = prompt.slice(0, 100).replace(/\n/g, ' ');
|
|
176
177
|
const summaryContent = `Prompt attached as file (${prompt.length} chars)\n\n> ${preview}...`;
|
|
177
|
-
const tmpDir = path.join(
|
|
178
|
-
|
|
179
|
-
fs.mkdirSync(tmpDir, { recursive: true });
|
|
180
|
-
}
|
|
181
|
-
const tmpFile = path.join(tmpDir, `prompt-${Date.now()}.md`);
|
|
178
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kimaki-prompt-'));
|
|
179
|
+
const tmpFile = path.join(tmpDir, 'prompt.md');
|
|
182
180
|
// Wrap long lines so the file is readable in Discord's preview
|
|
183
181
|
// (Discord doesn't wrap text in file attachments)
|
|
184
182
|
const wrappedPrompt = prompt
|
|
@@ -235,6 +233,7 @@ export async function sendDiscordMessageWithOptionalAttachment({ channelId, prom
|
|
|
235
233
|
}
|
|
236
234
|
finally {
|
|
237
235
|
fs.unlinkSync(tmpFile);
|
|
236
|
+
fs.rmdirSync(tmpDir);
|
|
238
237
|
}
|
|
239
238
|
}
|
|
240
239
|
export function formatRelativeTime(target) {
|
|
@@ -308,7 +307,7 @@ function readErrorField(error, key) {
|
|
|
308
307
|
}
|
|
309
308
|
return undefined;
|
|
310
309
|
}
|
|
311
|
-
/** Transient network errors that may resolve on retry (DNS down, gateway unreachable). */
|
|
310
|
+
/** Transient network errors that may resolve on retry (DNS down, gateway unreachable, TLS blips). */
|
|
312
311
|
const TRANSIENT_ERROR_CODES = new Set([
|
|
313
312
|
'ENOTFOUND',
|
|
314
313
|
'ECONNREFUSED',
|
|
@@ -318,13 +317,37 @@ const TRANSIENT_ERROR_CODES = new Set([
|
|
|
318
317
|
'EPIPE',
|
|
319
318
|
'EHOSTUNREACH',
|
|
320
319
|
'ENETUNREACH',
|
|
320
|
+
// TLS/cert errors can be transient (intermediate CA blip, MITM proxy, cert rotation).
|
|
321
|
+
// Without these, Discord login failure exits EXIT_NO_RESTART and the bin wrapper
|
|
322
|
+
// never restarts — the bot dies permanently on "unable to verify the first certificate".
|
|
323
|
+
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
|
|
324
|
+
'UNABLE_TO_GET_ISSUER_CERT',
|
|
325
|
+
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
|
|
326
|
+
'UNABLE_TO_DECODE_ISSUER_PUBLIC_KEY',
|
|
327
|
+
'CERT_SIGNATURE_FAILURE',
|
|
328
|
+
'CERT_NOT_YET_VALID',
|
|
329
|
+
'CERT_HAS_EXPIRED',
|
|
330
|
+
'CRL_HAS_EXPIRED',
|
|
331
|
+
'DEPTH_ZERO_SELF_SIGNED_CERT',
|
|
332
|
+
'SELF_SIGNED_CERT_IN_CHAIN',
|
|
333
|
+
'ERR_TLS_CERT_ALTNAME_INVALID',
|
|
321
334
|
]);
|
|
335
|
+
const TRANSIENT_ERROR_MESSAGE_PATTERNS = [
|
|
336
|
+
/unable to verify the first certificate/i,
|
|
337
|
+
/certificate has expired/i,
|
|
338
|
+
/self[- ]signed certificate/i,
|
|
339
|
+
/unable to get local issuer certificate/i,
|
|
340
|
+
];
|
|
322
341
|
export function isTransientNetworkError(error) {
|
|
323
342
|
if (!(error instanceof Error))
|
|
324
343
|
return false;
|
|
325
344
|
const code = error.code;
|
|
326
345
|
if (code && TRANSIENT_ERROR_CODES.has(code))
|
|
327
346
|
return true;
|
|
347
|
+
// Fallback when code is stripped by wrappers (discord.js sometimes rethrows by message only).
|
|
348
|
+
if (TRANSIENT_ERROR_MESSAGE_PATTERNS.some((pattern) => pattern.test(error.message))) {
|
|
349
|
+
return true;
|
|
350
|
+
}
|
|
328
351
|
// discord.js wraps errors in cause chains
|
|
329
352
|
if (error.cause instanceof Error)
|
|
330
353
|
return isTransientNetworkError(error.cause);
|
package/dist/cli-runner.test.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from 'vitest';
|
|
2
|
-
import { getOpenUrlCommand } from './cli-runner.js';
|
|
2
|
+
import { getOpenUrlCommand, isTransientNetworkError } from './cli-runner.js';
|
|
3
3
|
describe('getOpenUrlCommand', () => {
|
|
4
4
|
const installUrl = 'https://kimaki.dev/discord-install?clientId=abc&clientSecret=def';
|
|
5
5
|
test('uses a shell-free opener on Windows', () => {
|
|
@@ -21,3 +21,30 @@ describe('getOpenUrlCommand', () => {
|
|
|
21
21
|
});
|
|
22
22
|
});
|
|
23
23
|
});
|
|
24
|
+
describe('isTransientNetworkError', () => {
|
|
25
|
+
test('treats TLS leaf verification failures as transient', () => {
|
|
26
|
+
const error = Object.assign(new Error('unable to verify the first certificate'), {
|
|
27
|
+
code: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
|
|
28
|
+
});
|
|
29
|
+
expect(isTransientNetworkError(error)).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
test('matches TLS cert failures by message when code is missing', () => {
|
|
32
|
+
expect(isTransientNetworkError(new Error('unable to verify the first certificate'))).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
test('walks cause chains for nested TLS errors', () => {
|
|
35
|
+
const cause = Object.assign(new Error('unable to verify the first certificate'), {
|
|
36
|
+
code: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
|
|
37
|
+
});
|
|
38
|
+
expect(isTransientNetworkError(new Error('Discord login failed', { cause }))).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
test('keeps fatal auth-style errors non-transient', () => {
|
|
41
|
+
expect(isTransientNetworkError(new Error('An invalid token was provided.'))).toBe(false);
|
|
42
|
+
expect(isTransientNetworkError(new Error('Used disallowed intents'))).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
test('still treats classic socket codes as transient', () => {
|
|
45
|
+
const error = Object.assign(new Error('getaddrinfo ENOTFOUND'), {
|
|
46
|
+
code: 'ENOTFOUND',
|
|
47
|
+
});
|
|
48
|
+
expect(isTransientNetworkError(error)).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
});
|
package/dist/commands/btw.js
CHANGED
|
@@ -57,6 +57,9 @@ export async function forkSessionToBtwThread({ sourceThread, projectDirectory, p
|
|
|
57
57
|
sendThreadMessage(thread, `Reusing context from ${sourceThreadLink} to answer prompt...\n${prompt}`),
|
|
58
58
|
]);
|
|
59
59
|
logger.log(`Created btw fork session ${forkedSession.id} in thread ${thread.id} from source thread ${sourceThread.id} (session ${sessionId})`);
|
|
60
|
+
// Parent context stays in the user prompt only. Do NOT pass parentSessionId
|
|
61
|
+
// into enqueueIncoming: that would inject a parent block into the system
|
|
62
|
+
// message and bust prompt cache shared with the parent session.
|
|
60
63
|
const wrappedPrompt = [
|
|
61
64
|
`The user asked a side question while you were working on another task.`,
|
|
62
65
|
`This is a forked session whose ONLY goal is to answer this question.`,
|
package/dist/commands/tasks.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// /tasks command — list all scheduled tasks sorted by next run time.
|
|
2
2
|
// Renders a markdown table that the CV2 pipeline auto-formats for Discord,
|
|
3
|
-
// including HTML-backed
|
|
3
|
+
// including HTML-backed Run now / Delete action buttons.
|
|
4
4
|
import { ButtonInteraction, ChatInputCommandInteraction, ComponentType, MessageFlags, } from 'discord.js';
|
|
5
5
|
import { cancelScheduledTask, listScheduledTasks, } from '../database.js';
|
|
6
6
|
import { splitTablesFromMarkdown } from '../format-tables.js';
|
|
7
7
|
import { buildHtmlActionCustomId, cancelHtmlActionsForOwner, registerHtmlAction, } from '../html-actions.js';
|
|
8
|
+
import { runScheduledTaskNow } from '../task-runner.js';
|
|
8
9
|
import { formatTimeAgo } from './worktrees.js';
|
|
9
10
|
function formatTimeUntil(date) {
|
|
10
11
|
const diffMs = date.getTime() - Date.now();
|
|
@@ -34,6 +35,9 @@ function scheduleLabel(task) {
|
|
|
34
35
|
}
|
|
35
36
|
return 'one-time';
|
|
36
37
|
}
|
|
38
|
+
function canRunTask(task) {
|
|
39
|
+
return task.status === 'planned';
|
|
40
|
+
}
|
|
37
41
|
function canCancelTask(task) {
|
|
38
42
|
return task.status === 'planned' || task.status === 'running';
|
|
39
43
|
}
|
|
@@ -42,22 +46,25 @@ function canCancelTask(task) {
|
|
|
42
46
|
function sanitizeTableCell(value) {
|
|
43
47
|
return value.replaceAll('|', '\\|').replace(/\s+/g, ' ').trim();
|
|
44
48
|
}
|
|
45
|
-
function
|
|
46
|
-
|
|
49
|
+
function buildRunCell(task) {
|
|
50
|
+
if (!canRunTask(task)) {
|
|
51
|
+
return '-';
|
|
52
|
+
}
|
|
53
|
+
return `<button id="run-task-${task.id}" variant="primary">Run now</button>`;
|
|
47
54
|
}
|
|
48
|
-
function
|
|
55
|
+
function buildCancelCell(task) {
|
|
49
56
|
if (!canCancelTask(task)) {
|
|
50
57
|
return '-';
|
|
51
58
|
}
|
|
52
|
-
return
|
|
59
|
+
return `<button id="cancel-task-${task.id}" variant="secondary">Delete</button>`;
|
|
53
60
|
}
|
|
54
61
|
// Cap rows to avoid exceeding Discord's 40-component CV2 limit.
|
|
55
|
-
// Each
|
|
56
|
-
// so
|
|
57
|
-
const MAX_TASK_ROWS =
|
|
62
|
+
// Each actionable row is text + action row + up to 2 buttons (~4 components),
|
|
63
|
+
// so 7 rows stays under the budget with separators.
|
|
64
|
+
const MAX_TASK_ROWS = 7;
|
|
58
65
|
function buildTaskTable({ tasks, }) {
|
|
59
|
-
const header = '| ID | Status | Prompt | Schedule | Next Run |
|
|
60
|
-
const separator = '
|
|
66
|
+
const header = '| ID | Status | Prompt | Schedule | Next Run | Run | Delete |';
|
|
67
|
+
const separator = '|---|---|---|---|---|---|---|';
|
|
61
68
|
const rows = tasks.map((task) => {
|
|
62
69
|
const id = String(task.id);
|
|
63
70
|
const status = task.status;
|
|
@@ -73,8 +80,9 @@ function buildTaskTable({ tasks, }) {
|
|
|
73
80
|
}
|
|
74
81
|
return formatTimeUntil(task.next_run_at);
|
|
75
82
|
})();
|
|
76
|
-
const
|
|
77
|
-
|
|
83
|
+
const run = buildRunCell(task);
|
|
84
|
+
const cancel = buildCancelCell(task);
|
|
85
|
+
return `| ${id} | ${status} | ${prompt} | ${schedule} | ${nextRun} | ${run} | ${cancel} |`;
|
|
78
86
|
});
|
|
79
87
|
return [header, separator, ...rows].join('\n');
|
|
80
88
|
}
|
|
@@ -107,12 +115,15 @@ async function renderTasksReply({ guildId, userId, channelId, showAll, notice, e
|
|
|
107
115
|
? `Showing ${MAX_TASK_ROWS}/${allTasks.length} tasks. Use \`kimaki task list\` for full list.`
|
|
108
116
|
: undefined;
|
|
109
117
|
const combinedNotice = [notice, truncatedNotice].filter(Boolean).join('\n');
|
|
118
|
+
const runnableTasksByButtonId = new Map();
|
|
110
119
|
const cancellableTasksByButtonId = new Map();
|
|
111
120
|
tasks.forEach((task) => {
|
|
112
|
-
if (
|
|
113
|
-
|
|
121
|
+
if (canRunTask(task)) {
|
|
122
|
+
runnableTasksByButtonId.set(`run-task-${task.id}`, task);
|
|
123
|
+
}
|
|
124
|
+
if (canCancelTask(task)) {
|
|
125
|
+
cancellableTasksByButtonId.set(`cancel-task-${task.id}`, task);
|
|
114
126
|
}
|
|
115
|
-
cancellableTasksByButtonId.set(`cancel-task-${task.id}`, task);
|
|
116
127
|
});
|
|
117
128
|
const tableMarkdown = buildTaskTable({ tasks });
|
|
118
129
|
const markdown = combinedNotice
|
|
@@ -120,17 +131,32 @@ async function renderTasksReply({ guildId, userId, channelId, showAll, notice, e
|
|
|
120
131
|
: tableMarkdown;
|
|
121
132
|
const segments = splitTablesFromMarkdown(markdown, {
|
|
122
133
|
resolveButtonCustomId: ({ button }) => {
|
|
123
|
-
const
|
|
124
|
-
if (
|
|
134
|
+
const runTask = runnableTasksByButtonId.get(button.id);
|
|
135
|
+
if (runTask) {
|
|
136
|
+
const actionId = registerHtmlAction({
|
|
137
|
+
ownerKey,
|
|
138
|
+
threadId: String(runTask.id),
|
|
139
|
+
run: async ({ interaction }) => {
|
|
140
|
+
await handleRunTaskAction({
|
|
141
|
+
interaction,
|
|
142
|
+
taskId: runTask.id,
|
|
143
|
+
showAll,
|
|
144
|
+
});
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
return buildHtmlActionCustomId(actionId);
|
|
148
|
+
}
|
|
149
|
+
const cancelTask = cancellableTasksByButtonId.get(button.id);
|
|
150
|
+
if (!cancelTask) {
|
|
125
151
|
return new Error(`No task registered for button ${button.id}`);
|
|
126
152
|
}
|
|
127
153
|
const actionId = registerHtmlAction({
|
|
128
154
|
ownerKey,
|
|
129
|
-
threadId: String(
|
|
155
|
+
threadId: String(cancelTask.id),
|
|
130
156
|
run: async ({ interaction }) => {
|
|
131
157
|
await handleCancelTaskAction({
|
|
132
158
|
interaction,
|
|
133
|
-
taskId:
|
|
159
|
+
taskId: cancelTask.id,
|
|
134
160
|
showAll,
|
|
135
161
|
});
|
|
136
162
|
},
|
|
@@ -153,6 +179,58 @@ async function renderTasksReply({ guildId, userId, channelId, showAll, notice, e
|
|
|
153
179
|
flags: MessageFlags.IsComponentsV2,
|
|
154
180
|
});
|
|
155
181
|
}
|
|
182
|
+
async function handleRunTaskAction({ interaction, taskId, showAll, }) {
|
|
183
|
+
const guildId = interaction.guildId;
|
|
184
|
+
if (!guildId) {
|
|
185
|
+
await interaction.editReply({
|
|
186
|
+
components: [
|
|
187
|
+
{
|
|
188
|
+
type: ComponentType.TextDisplay,
|
|
189
|
+
content: 'This action can only be used in a server.',
|
|
190
|
+
},
|
|
191
|
+
],
|
|
192
|
+
flags: MessageFlags.IsComponentsV2,
|
|
193
|
+
});
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const token = interaction.client.token;
|
|
197
|
+
if (!token) {
|
|
198
|
+
await renderTasksReply({
|
|
199
|
+
guildId,
|
|
200
|
+
userId: interaction.user.id,
|
|
201
|
+
channelId: interaction.channelId,
|
|
202
|
+
showAll,
|
|
203
|
+
notice: `Could not run task #${taskId}: bot token unavailable.`,
|
|
204
|
+
editReply: (options) => {
|
|
205
|
+
return interaction.editReply(options);
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const result = await runScheduledTaskNow({ token, taskId });
|
|
211
|
+
const notice = (() => {
|
|
212
|
+
if (result instanceof Error) {
|
|
213
|
+
return `Could not run task #${taskId}: ${result.message}`;
|
|
214
|
+
}
|
|
215
|
+
if (result.kind === 'skipped') {
|
|
216
|
+
return `Task #${taskId} is already running or was claimed elsewhere.`;
|
|
217
|
+
}
|
|
218
|
+
if (result.kind === 'failed') {
|
|
219
|
+
return `Task #${taskId} failed: ${result.error.message}`;
|
|
220
|
+
}
|
|
221
|
+
return `Started task #${taskId}.`;
|
|
222
|
+
})();
|
|
223
|
+
await renderTasksReply({
|
|
224
|
+
guildId,
|
|
225
|
+
userId: interaction.user.id,
|
|
226
|
+
channelId: interaction.channelId,
|
|
227
|
+
showAll,
|
|
228
|
+
notice,
|
|
229
|
+
editReply: (options) => {
|
|
230
|
+
return interaction.editReply(options);
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
}
|
|
156
234
|
async function handleCancelTaskAction({ interaction, taskId, showAll, }) {
|
|
157
235
|
const guildId = interaction.guildId;
|
|
158
236
|
if (!guildId) {
|
package/dist/database.js
CHANGED
|
@@ -380,6 +380,20 @@ export async function upsertThreadSession({ threadId, sessionId, source }) {
|
|
|
380
380
|
.values({ thread_id: threadId, session_id: sessionId, source })
|
|
381
381
|
.onConflictDoUpdate({ target: schema.thread_sessions.thread_id, set: { session_id: sessionId, source } });
|
|
382
382
|
}
|
|
383
|
+
export async function getThreadParentSessionId(threadId) {
|
|
384
|
+
const db = await getDb();
|
|
385
|
+
return (await db.query.thread_sessions.findFirst({
|
|
386
|
+
where: { thread_id: threadId },
|
|
387
|
+
columns: { parent_session_id: true },
|
|
388
|
+
}))?.parent_session_id ?? undefined;
|
|
389
|
+
}
|
|
390
|
+
export async function setThreadParentSessionId({ threadId, parentSessionId, }) {
|
|
391
|
+
const db = await getDb();
|
|
392
|
+
await db
|
|
393
|
+
.update(schema.thread_sessions)
|
|
394
|
+
.set({ parent_session_id: parentSessionId })
|
|
395
|
+
.where(orm.eq(schema.thread_sessions.thread_id, threadId));
|
|
396
|
+
}
|
|
383
397
|
export async function getThreadSessionSource(threadId) {
|
|
384
398
|
const db = await getDb();
|
|
385
399
|
return (await db.query.thread_sessions.findFirst({ where: { thread_id: threadId }, columns: { source: true } }))?.source;
|
package/dist/db.js
CHANGED
|
@@ -119,6 +119,7 @@ async function migrateSchema({ db, client, }) {
|
|
|
119
119
|
'ALTER TABLE bot_tokens ADD COLUMN last_used_at DATETIME',
|
|
120
120
|
"ALTER TABLE thread_sessions ADD COLUMN source TEXT DEFAULT 'kimaki'",
|
|
121
121
|
'ALTER TABLE thread_sessions ADD COLUMN last_synced_name TEXT',
|
|
122
|
+
'ALTER TABLE thread_sessions ADD COLUMN parent_session_id TEXT',
|
|
122
123
|
];
|
|
123
124
|
for (const stmt of alterStatements) {
|
|
124
125
|
await client.execute(stmt).catch(() => undefined);
|
package/dist/discord-bot.js
CHANGED
|
@@ -317,6 +317,9 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
317
317
|
const cliInjectedInjectionGuardPatterns = isCliInjectedPrompt
|
|
318
318
|
? promptMarker?.injectionGuardPatterns
|
|
319
319
|
: undefined;
|
|
320
|
+
const cliInjectedParentSessionId = isCliInjectedPrompt
|
|
321
|
+
? promptMarker?.parentSessionId
|
|
322
|
+
: undefined;
|
|
320
323
|
// Always ignore our own messages (unless CLI-injected prompt above).
|
|
321
324
|
// Without this, assigning the Kimaki role to the bot itself would loop.
|
|
322
325
|
if (isSelfBotMessage && !isCliInjectedPrompt) {
|
|
@@ -606,6 +609,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
606
609
|
model: cliInjectedModel,
|
|
607
610
|
permissions: cliInjectedPermissions,
|
|
608
611
|
injectionGuardPatterns: cliInjectedInjectionGuardPatterns,
|
|
612
|
+
parentSessionId: cliInjectedParentSessionId,
|
|
609
613
|
noReply: isLeadingMentionToOtherUser || undefined,
|
|
610
614
|
sessionStartSource: sessionStartSource
|
|
611
615
|
? {
|
|
@@ -627,7 +631,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
627
631
|
});
|
|
628
632
|
// Notify when a voice message was queued instead of sent immediately
|
|
629
633
|
if (enqueueResult.queued && enqueueResult.position) {
|
|
630
|
-
await sendThreadMessage(thread, `Queued at position ${enqueueResult.position}. Edit your message to update
|
|
634
|
+
await sendThreadMessage(thread, `Queued at position ${enqueueResult.position}. Edit or delete your message to update the queue`);
|
|
631
635
|
}
|
|
632
636
|
}
|
|
633
637
|
if (channel.type === ChannelType.GuildText) {
|
|
@@ -850,6 +854,27 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
850
854
|
discordLogger.error('Error handling message update:', error instanceof Error ? error.stack : String(error));
|
|
851
855
|
}
|
|
852
856
|
});
|
|
857
|
+
// Handle user message deletes to remove queued messages.
|
|
858
|
+
// Discord delete events do not include author/content, so attribution comes
|
|
859
|
+
// from the queued item captured at enqueue time.
|
|
860
|
+
discordClient.on(Events.MessageDelete, async (message) => {
|
|
861
|
+
try {
|
|
862
|
+
const channel = message.channel;
|
|
863
|
+
if (!channel.isThread())
|
|
864
|
+
return;
|
|
865
|
+
const runtime = getRuntime(channel.id);
|
|
866
|
+
if (!runtime)
|
|
867
|
+
return;
|
|
868
|
+
const removed = runtime.removeQueuedMessage(message.id);
|
|
869
|
+
if (!removed)
|
|
870
|
+
return;
|
|
871
|
+
discordLogger.log(`[MESSAGE_DELETE] Removed queued message ${message.id} in thread ${channel.id}`);
|
|
872
|
+
await sendThreadMessage(channel, `⬦ **${removed.username}** removed message from queue`);
|
|
873
|
+
}
|
|
874
|
+
catch (error) {
|
|
875
|
+
discordLogger.error('Error handling message delete:', error instanceof Error ? error.stack : String(error));
|
|
876
|
+
}
|
|
877
|
+
});
|
|
853
878
|
// Handle bot-initiated threads created by `kimaki send` (without --notify-only)
|
|
854
879
|
// Uses JSON embed marker to pass options (start, worktree name)
|
|
855
880
|
discordClient.on(Events.ThreadCreate, async (thread, newlyCreated) => {
|
|
@@ -1017,6 +1042,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
1017
1042
|
model: marker.model,
|
|
1018
1043
|
permissions: marker.permissions,
|
|
1019
1044
|
injectionGuardPatterns: marker.injectionGuardPatterns,
|
|
1045
|
+
parentSessionId: marker.parentSessionId,
|
|
1020
1046
|
mode: 'opencode',
|
|
1021
1047
|
sessionStartSource: botThreadStartSource
|
|
1022
1048
|
? {
|
|
@@ -103,22 +103,13 @@ export function registerInteractionHandler({ discordClient, appId, }) {
|
|
|
103
103
|
// Multi-machine routing: only handle interactions for channels owned
|
|
104
104
|
// by this machine (have a project directory configured in local db).
|
|
105
105
|
// If not owned, silently return so the other machine handles it.
|
|
106
|
+
// Setup commands (create-new-project, add-project) bypass this check
|
|
107
|
+
// because they are designed to run from any channel — they create new
|
|
108
|
+
// project channels rather than requiring one to already exist.
|
|
109
|
+
const isSetupCommand = interaction.isChatInputCommand() &&
|
|
110
|
+
SETUP_COMMANDS.has(interaction.commandName);
|
|
106
111
|
const owned = await isInteractionOwnedByThisMachine(interaction);
|
|
107
|
-
if (!owned) {
|
|
108
|
-
// Setup commands get a helpful reply instead of silent ignore.
|
|
109
|
-
// Both machines may race to reply; one wins, the other silently
|
|
110
|
-
// fails (interaction tokens are single-use). The message is the
|
|
111
|
-
// same from either machine so the race is harmless.
|
|
112
|
-
if (interaction.isChatInputCommand() &&
|
|
113
|
-
SETUP_COMMANDS.has(interaction.commandName)) {
|
|
114
|
-
await interaction.reply({
|
|
115
|
-
content: 'Run this command in an existing Kimaki project channel.\nTo add a new project channel from the terminal, run:\n```\nkimaki project add /path/to/folder\n```',
|
|
116
|
-
flags: MessageFlags.Ephemeral,
|
|
117
|
-
}).catch(() => {
|
|
118
|
-
// Another machine already responded, safe to ignore
|
|
119
|
-
});
|
|
120
|
-
return;
|
|
121
|
-
}
|
|
112
|
+
if (!owned && !isSetupCommand) {
|
|
122
113
|
interactionLogger.log(`[IGNORED] Channel ${interaction.channelId} has no project directory configured, skipping interaction`);
|
|
123
114
|
// Do not respond at all — consuming the interaction token would
|
|
124
115
|
// prevent the owning machine from responding (tokens are single-use).
|