kimaki 0.24.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/dist/agent-model.e2e.test.js +135 -3
  2. package/dist/analytics.test.js +30 -1
  3. package/dist/cli-commands/send.js +13 -3
  4. package/dist/cli-commands/task.js +26 -7
  5. package/dist/cli-runner.js +66 -70
  6. package/dist/cli-runner.test.js +10 -1
  7. package/dist/cli.js +5 -0
  8. package/dist/cloudflare-transcription.js +56 -0
  9. package/dist/commands/agent.js +87 -74
  10. package/dist/commands/model-variant.js +17 -1
  11. package/dist/commands/model.js +18 -0
  12. package/dist/commands/multioauth.js +169 -1
  13. package/dist/commands/queue.js +64 -0
  14. package/dist/commands/tasks.js +6 -0
  15. package/dist/commands/unset-model.js +5 -20
  16. package/dist/config.js +9 -0
  17. package/dist/database.js +65 -0
  18. package/dist/discord-bot.js +8 -6
  19. package/dist/discord-command-registration.js +17 -12
  20. package/dist/external-directory-permissions.e2e.test.js +82 -0
  21. package/dist/external-directory-project-deny.e2e.test.js +68 -0
  22. package/dist/format-tables.js +53 -5
  23. package/dist/format-tables.test.js +121 -0
  24. package/dist/interaction-handler.js +8 -6
  25. package/dist/ipc-tools-plugin.js +4 -3
  26. package/dist/kimaki-opencode-plugin.js +2 -0
  27. package/dist/message-formatting.js +84 -5
  28. package/dist/message-formatting.test.js +160 -2
  29. package/dist/message-preprocessing.js +2 -25
  30. package/dist/oauth-rotation-shared.js +7 -1
  31. package/dist/openai-auth-state.js +20 -0
  32. package/dist/opencode.js +126 -96
  33. package/dist/queue-advanced-e2e-setup.js +63 -27
  34. package/dist/queue-advanced-permissions-typing.e2e.test.js +4 -0
  35. package/dist/schema.js +19 -0
  36. package/dist/session-handler/event-stream-state.js +147 -0
  37. package/dist/session-handler/event-stream-state.test.js +437 -1
  38. package/dist/session-handler/thread-session-runtime.js +95 -57
  39. package/dist/store.js +1 -0
  40. package/dist/subagent-rate-limit-plugin.js +13 -2
  41. package/dist/system-message.js +18 -4
  42. package/dist/system-message.test.js +28 -4
  43. package/dist/task-runner.js +143 -12
  44. package/dist/task-schedule.js +12 -0
  45. package/dist/task-schedule.test.js +149 -2
  46. package/dist/voice-handler.js +37 -11
  47. package/dist/xai-auth-plugin.js +237 -0
  48. package/dist/xai-auth-state.js +233 -0
  49. package/dist/xai-auth-state.test.js +183 -0
  50. package/package.json +6 -6
  51. package/skills/profano/SKILL.md +5 -3
  52. package/skills/spiceflow/SKILL.md +24 -1
  53. package/src/agent-model.e2e.test.ts +172 -4
  54. package/src/analytics.test.ts +30 -1
  55. package/src/analytics.ts +1 -0
  56. package/src/cli-commands/send.ts +22 -3
  57. package/src/cli-commands/task.ts +32 -19
  58. package/src/cli-runner.test.ts +16 -1
  59. package/src/cli-runner.ts +88 -89
  60. package/src/cli.ts +11 -0
  61. package/src/cloudflare-transcription.ts +76 -0
  62. package/src/commands/agent.ts +120 -84
  63. package/src/commands/model-variant.ts +26 -0
  64. package/src/commands/model.ts +25 -0
  65. package/src/commands/multioauth.ts +195 -2
  66. package/src/commands/queue.ts +83 -0
  67. package/src/commands/tasks.ts +6 -0
  68. package/src/commands/unset-model.ts +5 -21
  69. package/src/config.ts +10 -0
  70. package/src/database.ts +124 -0
  71. package/src/discord-bot.ts +7 -6
  72. package/src/discord-command-registration.ts +22 -15
  73. package/src/external-directory-permissions.e2e.test.ts +99 -0
  74. package/src/external-directory-project-deny.e2e.test.ts +82 -0
  75. package/src/format-tables.test.ts +126 -0
  76. package/src/format-tables.ts +68 -5
  77. package/src/interaction-handler.ts +10 -5
  78. package/src/ipc-tools-plugin.ts +4 -3
  79. package/src/kimaki-opencode-plugin.ts +2 -0
  80. package/src/message-formatting.test.ts +197 -2
  81. package/src/message-formatting.ts +98 -5
  82. package/src/message-preprocessing.ts +2 -37
  83. package/src/oauth-rotation-shared.ts +6 -1
  84. package/src/openai-auth-state.ts +19 -0
  85. package/src/opencode.ts +132 -111
  86. package/src/queue-advanced-e2e-setup.ts +78 -29
  87. package/src/queue-advanced-permissions-typing.e2e.test.ts +4 -0
  88. package/src/schema.sql +15 -0
  89. package/src/schema.ts +20 -0
  90. package/src/session-handler/event-stream-state.test.ts +490 -0
  91. package/src/session-handler/event-stream-state.ts +210 -0
  92. package/src/session-handler/model-utils.ts +1 -0
  93. package/src/session-handler/thread-runtime-state.ts +0 -4
  94. package/src/session-handler/thread-session-runtime.ts +108 -69
  95. package/src/store.ts +10 -0
  96. package/src/subagent-rate-limit-plugin.ts +12 -2
  97. package/src/system-message.test.ts +29 -4
  98. package/src/system-message.ts +20 -4
  99. package/src/task-runner.ts +168 -11
  100. package/src/task-schedule.test.ts +172 -2
  101. package/src/task-schedule.ts +23 -2
  102. package/src/voice-handler.ts +37 -10
  103. package/src/xai-auth-plugin.ts +302 -0
  104. package/src/xai-auth-state.test.ts +224 -0
  105. package/src/xai-auth-state.ts +293 -0
  106. package/dist/channel-reference-permissions.e2e.test.js +0 -87
  107. package/dist/commands/add-dir.js +0 -169
  108. package/dist/commands/add-dir.test.js +0 -126
  109. package/src/channel-reference-permissions.e2e.test.ts +0 -104
  110. package/src/commands/add-dir.test.ts +0 -154
  111. package/src/commands/add-dir.ts +0 -244
@@ -3,11 +3,15 @@ import { Routes } from 'discord.js';
3
3
  import { createDiscordRest } from './discord-urls.js';
4
4
  import { ensureThreadMember } from './discord-utils.js';
5
5
  import YAML from 'yaml';
6
- import { claimScheduledTaskRunning, getDuePlannedScheduledTasks, getScheduledTask, markScheduledTaskCronRescheduled, markScheduledTaskCronRetry, markScheduledTaskFailed, deleteScheduledTask, recoverStaleRunningScheduledTasks, } from './database.js';
6
+ import { claimScheduledTaskRunning, createScheduledTaskRun, failScheduledTaskRun, finishScheduledTaskRun, getDuePlannedScheduledTasks, getScheduledTask, getActiveScheduledTaskRuns, markScheduledTaskCronRescheduled, markScheduledTaskCronRetry, markScheduledTaskFailed, deleteScheduledTask, recoverStaleRunningScheduledTasks, releaseScheduledTaskClaim, setScheduledTaskRunThread, } from './database.js';
7
+ import { execAsync } from './exec-async.js';
8
+ import { initializeOpencodeForDirectory } from './opencode.js';
7
9
  import { createLogger, formatErrorWithStack, LogPrefix } from './logger.js';
8
10
  import { notifyError } from './sentry.js';
9
- import { getNextCronRun, getPromptPreview, parseScheduledTaskPayload, } from './task-schedule.js';
11
+ import { appendTaskCommandOutput, getNextCronRun, getPromptPreview, parseScheduledTaskPayload, } from './task-schedule.js';
10
12
  const taskLogger = createLogger(LogPrefix.TASK);
13
+ const MAX_SCHEDULED_PROMPT_LENGTH = 1_900;
14
+ const PENDING_RUN_TIMEOUT_MS = 120_000;
11
15
  function isRecord(value) {
12
16
  return typeof value === 'object' && value !== null;
13
17
  }
@@ -20,7 +24,7 @@ function parseMessageId(value) {
20
24
  }
21
25
  return value.id;
22
26
  }
23
- async function executeThreadScheduledTask({ rest, task, payload, }) {
27
+ async function executeThreadScheduledTask({ rest, task, payload, prompt, runId, }) {
24
28
  const marker = {
25
29
  start: true,
26
30
  scheduledKind: task.schedule_kind,
@@ -28,6 +32,7 @@ async function executeThreadScheduledTask({ rest, task, payload, }) {
28
32
  // after execution, so the ID would be stale by the time the bot processes
29
33
  // the Discord event and tries to insert a session_start_sources row.
30
34
  ...(task.schedule_kind === 'cron' ? { scheduledTaskId: task.id } : {}),
35
+ ...(runId ? { scheduledTaskRunId: runId } : {}),
31
36
  ...(payload.agent ? { agent: payload.agent } : {}),
32
37
  ...(payload.model ? { model: payload.model } : {}),
33
38
  ...(payload.username ? { username: payload.username } : {}),
@@ -41,7 +46,7 @@ async function executeThreadScheduledTask({ rest, task, payload, }) {
41
46
  const embed = [{ color: 0x2b2d31, footer: { text: YAML.stringify(marker) } }];
42
47
  // Newline between prefix and prompt so leading /command detection can
43
48
  // find the command on its own line.
44
- const prefixedPrompt = `» **kimaki-cli:**\n${payload.prompt}`;
49
+ const prefixedPrompt = `» **kimaki-cli:**\n${prompt}`;
45
50
  // Re-join the user before posting, so the message they get notified about is
46
51
  // in a thread they are already a member of. Works on archived threads too;
47
52
  // the post below auto-unarchives.
@@ -69,8 +74,9 @@ async function executeThreadScheduledTask({ rest, task, payload, }) {
69
74
  });
70
75
  if (postResult instanceof Error)
71
76
  return postResult;
77
+ return payload.threadId;
72
78
  }
73
- async function executeChannelScheduledTask({ rest, task, payload, }) {
79
+ async function executeChannelScheduledTask({ rest, task, payload, prompt, runId, }) {
74
80
  const marker = payload.notifyOnly
75
81
  ? undefined
76
82
  : {
@@ -78,6 +84,7 @@ async function executeChannelScheduledTask({ rest, task, payload, }) {
78
84
  scheduledKind: task.schedule_kind,
79
85
  // Only include scheduledTaskId for cron tasks (see thread variant comment)
80
86
  ...(task.schedule_kind === 'cron' ? { scheduledTaskId: task.id } : {}),
87
+ ...(runId ? { scheduledTaskRunId: runId } : {}),
81
88
  ...(payload.worktreeName ? { worktree: payload.worktreeName } : {}),
82
89
  ...(payload.cwd ? { cwd: payload.cwd } : {}),
83
90
  ...(payload.agent ? { agent: payload.agent } : {}),
@@ -96,7 +103,7 @@ async function executeChannelScheduledTask({ rest, task, payload, }) {
96
103
  const starterResult = await rest
97
104
  .post(Routes.channelMessages(payload.channelId), {
98
105
  body: {
99
- content: payload.prompt,
106
+ content: prompt,
100
107
  embeds,
101
108
  },
102
109
  })
@@ -113,7 +120,7 @@ async function executeChannelScheduledTask({ rest, task, payload, }) {
113
120
  cause: starterMessageId,
114
121
  });
115
122
  }
116
- const threadName = (payload.name || getPromptPreview(payload.prompt)).slice(0, 100);
123
+ const threadName = (payload.name || getPromptPreview(prompt)).slice(0, 100);
117
124
  const threadResult = await rest
118
125
  .post(Routes.threads(payload.channelId, starterMessageId), {
119
126
  body: {
@@ -128,15 +135,14 @@ async function executeChannelScheduledTask({ rest, task, payload, }) {
128
135
  });
129
136
  if (threadResult instanceof Error)
130
137
  return threadResult;
131
- if (!payload.userId) {
132
- return;
133
- }
134
138
  const threadIdResult = parseMessageId(threadResult);
135
139
  if (threadIdResult instanceof Error) {
136
140
  return new Error(`Invalid thread response for task ${task.id}`, {
137
141
  cause: threadIdResult,
138
142
  });
139
143
  }
144
+ if (!payload.userId)
145
+ return threadIdResult;
140
146
  const addMemberResult = await ensureThreadMember({
141
147
  rest,
142
148
  threadId: threadIdResult,
@@ -145,25 +151,111 @@ async function executeChannelScheduledTask({ rest, task, payload, }) {
145
151
  if (addMemberResult instanceof Error) {
146
152
  return new Error(`Failed to add user to scheduled thread for task ${task.id}`, { cause: addMemberResult });
147
153
  }
154
+ return threadIdResult;
155
+ }
156
+ export async function runTaskCommand({ task, payload, }) {
157
+ if (!payload.preRunCommand)
158
+ return { kind: 'run', prompt: payload.prompt };
159
+ if (!task.project_directory) {
160
+ return new Error(`Task ${task.id} has a pre-run command but no project directory`);
161
+ }
162
+ const command = payload.preRunCommand;
163
+ const result = await execAsync(command, { cwd: task.project_directory }).catch((error) => error instanceof Error ? error : new Error(String(error)));
164
+ const stdout = isRecord(result) && typeof result.stdout === 'string'
165
+ ? result.stdout
166
+ : '';
167
+ const stderr = isRecord(result) && typeof result.stderr === 'string'
168
+ ? result.stderr
169
+ : '';
170
+ const exitCode = result instanceof Error && isRecord(result)
171
+ ? result.code ?? 1
172
+ : 0;
173
+ if (stdout)
174
+ taskLogger.log(`[task-runner] task ${task.id} pre-run stdout:\n${stdout}`);
175
+ if (stderr)
176
+ taskLogger.log(`[task-runner] task ${task.id} pre-run stderr:\n${stderr}`);
177
+ taskLogger.log(`[task-runner] task ${task.id} pre-run exited with ${exitCode}`);
178
+ if (result instanceof Error)
179
+ return { kind: 'skip' };
180
+ const prompt = appendTaskCommandOutput({ prompt: payload.prompt, stdout });
181
+ if (prompt.length > MAX_SCHEDULED_PROMPT_LENGTH) {
182
+ return new Error(`Task ${task.id} prompt and pre-run stdout exceed ${MAX_SCHEDULED_PROMPT_LENGTH} characters`);
183
+ }
184
+ return { kind: 'run', prompt };
185
+ }
186
+ async function hasRunningSession(task) {
187
+ const runs = await getActiveScheduledTaskRuns(task.id);
188
+ if (runs.length === 0)
189
+ return false;
190
+ let active = false;
191
+ for (const run of runs) {
192
+ if (!run.session_id) {
193
+ if (Date.now() - run.started_at.getTime() < PENDING_RUN_TIMEOUT_MS) {
194
+ active = true;
195
+ continue;
196
+ }
197
+ await failScheduledTaskRun({
198
+ runId: run.id,
199
+ error: 'Timed out waiting for scheduled session to start',
200
+ });
201
+ continue;
202
+ }
203
+ if (!run.project_directory) {
204
+ active = true;
205
+ continue;
206
+ }
207
+ const getClient = await initializeOpencodeForDirectory(run.project_directory);
208
+ if (getClient instanceof Error)
209
+ return getClient;
210
+ const statusResponse = await getClient().session.status({
211
+ directory: run.project_directory,
212
+ }).catch((error) => new Error('Failed to check scheduled session status', {
213
+ cause: error,
214
+ }));
215
+ if (statusResponse instanceof Error)
216
+ return statusResponse;
217
+ if (statusResponse.error)
218
+ return new Error('Failed to check scheduled session status');
219
+ const status = statusResponse.data?.[run.session_id];
220
+ if (status && status.type !== 'idle') {
221
+ active = true;
222
+ continue;
223
+ }
224
+ if (!status && Date.now() - run.started_at.getTime() < PENDING_RUN_TIMEOUT_MS) {
225
+ active = true;
226
+ continue;
227
+ }
228
+ await finishScheduledTaskRun({ runId: run.id, status: 'completed' });
229
+ }
230
+ return active;
148
231
  }
149
- async function executeScheduledTask({ rest, task, }) {
232
+ async function executeScheduledTask({ rest, task, runId, }) {
150
233
  const payloadResult = parseScheduledTaskPayload(task.payload_json);
151
234
  if (payloadResult instanceof Error) {
152
235
  return new Error(`Task ${task.id} has invalid payload`, {
153
236
  cause: payloadResult,
154
237
  });
155
238
  }
239
+ const commandResult = await runTaskCommand({ task, payload: payloadResult });
240
+ if (commandResult instanceof Error)
241
+ return commandResult;
242
+ if (commandResult.kind === 'skip')
243
+ return { kind: 'condition-not-met' };
156
244
  if (payloadResult.kind === 'thread') {
157
245
  return executeThreadScheduledTask({
158
246
  rest,
159
247
  task,
160
248
  payload: payloadResult,
249
+ prompt: commandResult.prompt,
250
+ runId,
161
251
  });
162
252
  }
163
253
  return executeChannelScheduledTask({
164
254
  rest,
165
255
  task,
166
256
  payload: payloadResult,
257
+ prompt: commandResult.prompt,
258
+ runId,
167
259
  });
168
260
  }
169
261
  async function finalizeSuccessfulTask({ task, completedAt, }) {
@@ -234,7 +326,32 @@ async function processDueTask({ rest, task, }) {
234
326
  if (!claimed) {
235
327
  return { kind: 'skipped' };
236
328
  }
237
- const executeResult = await executeScheduledTask({ rest, task });
329
+ const payload = parseScheduledTaskPayload(task.payload_json);
330
+ if (payload instanceof Error) {
331
+ await finalizeFailedTask({ task, failedAt: new Date(), error: payload });
332
+ return { kind: 'failed', error: payload };
333
+ }
334
+ const activeSession = payload.allowConcurrency
335
+ ? false
336
+ : await hasRunningSession(task);
337
+ if (activeSession instanceof Error) {
338
+ await releaseScheduledTaskClaim(task.id);
339
+ taskLogger.warn(`[task-runner] could not check concurrency for task ${task.id}: ${formatErrorWithStack(activeSession)}`);
340
+ return { kind: 'failed', error: activeSession };
341
+ }
342
+ if (activeSession) {
343
+ if (task.schedule_kind === 'cron') {
344
+ await finalizeSuccessfulTask({ task, completedAt: new Date() });
345
+ }
346
+ else {
347
+ await releaseScheduledTaskClaim(task.id);
348
+ }
349
+ return { kind: 'concurrency-blocked' };
350
+ }
351
+ const runId = task.schedule_kind === 'cron'
352
+ ? await createScheduledTaskRun({ taskId: task.id, startedAt })
353
+ : undefined;
354
+ const executeResult = await executeScheduledTask({ rest, task, runId });
238
355
  const finishedAt = new Date();
239
356
  if (executeResult instanceof Error) {
240
357
  taskLogger.warn(`[task-runner] task ${task.id} failed: ${formatErrorWithStack(executeResult)}`);
@@ -243,8 +360,22 @@ async function processDueTask({ rest, task, }) {
243
360
  failedAt: finishedAt,
244
361
  error: executeResult,
245
362
  });
363
+ if (runId)
364
+ await failScheduledTaskRun({ runId, error: executeResult.message });
246
365
  return { kind: 'failed', error: executeResult };
247
366
  }
367
+ if (!(typeof executeResult === 'string' || executeResult === null)) {
368
+ if (runId)
369
+ await finishScheduledTaskRun({ runId, status: 'skipped' });
370
+ await finalizeSuccessfulTask({ task, completedAt: finishedAt });
371
+ return executeResult;
372
+ }
373
+ if (executeResult && runId) {
374
+ await setScheduledTaskRunThread({ runId, threadId: executeResult });
375
+ }
376
+ else if (runId) {
377
+ await finishScheduledTaskRun({ runId, status: 'completed' });
378
+ }
248
379
  await finalizeSuccessfulTask({ task, completedAt: finishedAt });
249
380
  return { kind: 'success' };
250
381
  }
@@ -106,6 +106,12 @@ export function getNextCronRun({ cronExpr, timezone, from, }) {
106
106
  export function serializeScheduledTaskPayload(payload) {
107
107
  return JSON.stringify(payload);
108
108
  }
109
+ export function appendTaskCommandOutput({ prompt, stdout, }) {
110
+ const output = stdout.trim();
111
+ if (!output)
112
+ return prompt;
113
+ return `${prompt}\n\n## Pre-run command output\n\n${output}`;
114
+ }
109
115
  function isRecord(value) {
110
116
  return typeof value === 'object' && value !== null;
111
117
  }
@@ -135,6 +141,8 @@ export function parseScheduledTaskPayload(payloadJson) {
135
141
  return new Error('Task payload must be an object');
136
142
  }
137
143
  const kind = asString(parsed.kind);
144
+ const preRunCommand = asString(parsed.preRunCommand);
145
+ const allowConcurrency = parsed.allowConcurrency === true;
138
146
  if (kind === 'thread') {
139
147
  const threadId = asString(parsed.threadId);
140
148
  const prompt = asString(parsed.prompt);
@@ -159,6 +167,8 @@ export function parseScheduledTaskPayload(payloadJson) {
159
167
  permissions,
160
168
  injectionGuardPatterns,
161
169
  parentSessionId,
170
+ preRunCommand,
171
+ allowConcurrency,
162
172
  };
163
173
  }
164
174
  if (kind === 'channel') {
@@ -194,6 +204,8 @@ export function parseScheduledTaskPayload(payloadJson) {
194
204
  permissions,
195
205
  injectionGuardPatterns,
196
206
  parentSessionId,
207
+ preRunCommand,
208
+ allowConcurrency,
197
209
  };
198
210
  }
199
211
  return new Error('Task payload has unknown kind');
@@ -1,6 +1,12 @@
1
1
  // Tests for scheduled task date/cron parsing and UTC validation rules.
2
- import { describe, expect, test } from 'vitest';
3
- import { parseSendAtValue } from './task-schedule.js';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { afterAll, afterEach, describe, expect, test } from 'vitest';
6
+ import { completeScheduledTaskRunsForSession, createScheduledTask, createScheduledTaskRun, getActiveScheduledTaskRuns, getScheduledTask, startScheduledTaskRunSession, } from './database.js';
7
+ import { closeDb } from './db.js';
8
+ import { runTaskCommand } from './task-runner.js';
9
+ import { appendTaskCommandOutput, parseScheduledTaskPayload, parseSendAtValue, } from './task-schedule.js';
4
10
  describe('parseSendAtValue', () => {
5
11
  test('accepts UTC ISO date ending with Z', () => {
6
12
  const now = new Date('2026-02-22T13:00:00Z');
@@ -69,3 +75,144 @@ describe('parseSendAtValue', () => {
69
75
  expect(result.nextRunAt.toISOString()).toBe('2026-02-23T09:00:00.000Z');
70
76
  });
71
77
  });
78
+ describe('scheduled task execution options', () => {
79
+ const tempDirectories = [];
80
+ afterEach(() => {
81
+ for (const directory of tempDirectories.splice(0)) {
82
+ fs.rmSync(directory, { recursive: true, force: true });
83
+ }
84
+ });
85
+ test('defaults old payloads to no command and no concurrency', () => {
86
+ const payload = parseScheduledTaskPayload(JSON.stringify({
87
+ kind: 'thread',
88
+ threadId: 'thread-1',
89
+ prompt: 'Handle support requests',
90
+ }));
91
+ expect(payload).toMatchInlineSnapshot(`
92
+ {
93
+ "agent": null,
94
+ "allowConcurrency": false,
95
+ "injectionGuardPatterns": null,
96
+ "kind": "thread",
97
+ "model": null,
98
+ "parentSessionId": null,
99
+ "permissions": null,
100
+ "preRunCommand": null,
101
+ "prompt": "Handle support requests",
102
+ "threadId": "thread-1",
103
+ "userId": null,
104
+ "username": null,
105
+ }
106
+ `);
107
+ });
108
+ test('adds command stdout to the prompt', () => {
109
+ expect(appendTaskCommandOutput({
110
+ prompt: 'Handle support requests',
111
+ stdout: '{"ticketId":"ticket-123"}\n',
112
+ })).toMatchInlineSnapshot(`
113
+ "Handle support requests
114
+
115
+ ## Pre-run command output
116
+
117
+ {"ticketId":"ticket-123"}"
118
+ `);
119
+ });
120
+ test('runs the pre-run command in the project directory', async () => {
121
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'kimaki-task-'));
122
+ tempDirectories.push(directory);
123
+ fs.writeFileSync(path.join(directory, 'should-run.ts'), "process.stdout.write(JSON.stringify({ cwd: process.cwd(), ticketId: 'ticket-123' }))\n");
124
+ const taskId = await createScheduledTask({
125
+ scheduleKind: 'cron',
126
+ cronExpr: '*/5 * * * *',
127
+ timezone: 'UTC',
128
+ nextRunAt: new Date('2026-08-12T18:00:00Z'),
129
+ payloadJson: JSON.stringify({
130
+ kind: 'thread',
131
+ threadId: 'thread-1',
132
+ prompt: 'Handle support requests',
133
+ preRunCommand: 'tsx should-run.ts',
134
+ }),
135
+ promptPreview: 'Handle support requests',
136
+ projectDirectory: directory,
137
+ });
138
+ const task = await getScheduledTask(taskId);
139
+ if (!task)
140
+ throw new Error('Expected scheduled task');
141
+ const payload = parseScheduledTaskPayload(task.payload_json);
142
+ if (payload instanceof Error)
143
+ throw payload;
144
+ const result = await runTaskCommand({ task, payload });
145
+ const realDirectory = fs.realpathSync(directory);
146
+ expect(result instanceof Error || result.kind === 'skip' ? result : {
147
+ ...result,
148
+ prompt: result.prompt.replace(realDirectory, '<project-directory>'),
149
+ }).toMatchInlineSnapshot(`
150
+ {
151
+ "kind": "run",
152
+ "prompt": "Handle support requests
153
+
154
+ ## Pre-run command output
155
+
156
+ {"cwd":"<project-directory>","ticketId":"ticket-123"}",
157
+ }
158
+ `);
159
+ });
160
+ test('skips the task when the pre-run command exits nonzero', async () => {
161
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'kimaki-task-'));
162
+ tempDirectories.push(directory);
163
+ const taskId = await createScheduledTask({
164
+ scheduleKind: 'cron',
165
+ cronExpr: '*/5 * * * *',
166
+ timezone: 'UTC',
167
+ nextRunAt: new Date('2026-08-12T18:00:00Z'),
168
+ payloadJson: JSON.stringify({
169
+ kind: 'thread',
170
+ threadId: 'thread-skip',
171
+ prompt: 'Handle support requests',
172
+ preRunCommand: "node -e \"process.stdout.write('nothing pending'); process.exit(1)\"",
173
+ }),
174
+ promptPreview: 'Handle support requests',
175
+ projectDirectory: directory,
176
+ });
177
+ const task = await getScheduledTask(taskId);
178
+ if (!task)
179
+ throw new Error('Expected scheduled task');
180
+ const payload = parseScheduledTaskPayload(task.payload_json);
181
+ if (payload instanceof Error)
182
+ throw payload;
183
+ expect(await runTaskCommand({ task, payload })).toMatchInlineSnapshot(`
184
+ {
185
+ "kind": "skip",
186
+ }
187
+ `);
188
+ });
189
+ test('tracks active runs until their OpenCode session completes', async () => {
190
+ const taskId = await createScheduledTask({
191
+ scheduleKind: 'cron',
192
+ cronExpr: '*/5 * * * *',
193
+ timezone: 'UTC',
194
+ nextRunAt: new Date('2026-08-12T18:00:00Z'),
195
+ payloadJson: JSON.stringify({
196
+ kind: 'thread',
197
+ threadId: 'thread-2',
198
+ prompt: 'Handle support requests',
199
+ }),
200
+ promptPreview: 'Handle support requests',
201
+ });
202
+ const runId = await createScheduledTaskRun({
203
+ taskId,
204
+ startedAt: new Date('2026-08-12T18:00:00Z'),
205
+ });
206
+ await startScheduledTaskRunSession({
207
+ runId,
208
+ sessionId: 'session-1',
209
+ projectDirectory: '/project',
210
+ });
211
+ expect(await getActiveScheduledTaskRuns(taskId)).toHaveLength(1);
212
+ await completeScheduledTaskRunsForSession('session-1');
213
+ expect(await getActiveScheduledTaskRuns(taskId)).toHaveLength(0);
214
+ });
215
+ });
216
+ afterAll(async () => {
217
+ await closeDb();
218
+ });
@@ -11,7 +11,8 @@ import * as prism from 'prism-media';
11
11
  import dedent from 'string-dedent';
12
12
  import { Events, } from 'discord.js';
13
13
  import { createGenAIWorker } from './genai-worker-wrapper.js';
14
- import { getVoiceChannelDirectory, getGeminiApiKey, getTranscriptionApiKey, findTextChannelByVoiceChannel, } from './database.js';
14
+ import { getVoiceChannelDirectory, getGeminiApiKey, getTranscriptionApiKey, getBotTokenWithMode, findTextChannelByVoiceChannel, } from './database.js';
15
+ import { transcribeViaKimakiGateway } from './cloudflare-transcription.js';
15
16
  import { sendThreadMessage, escapeDiscordFormatting, NOTIFY_MESSAGE_FLAGS, hasKimakiBotPermission, } from './discord-utils.js';
16
17
  import { requestAudioApiKey } from './commands/gemini-apikey.js';
17
18
  import { transcribeAudio } from './voice.js';
@@ -446,7 +447,30 @@ export async function processVoiceAttachment({ message, thread, projectDirectory
446
447
  transcriptionApiKey = process.env.GEMINI_API_KEY;
447
448
  transcriptionProvider = 'gemini';
448
449
  }
450
+ // No user-configured key: gateway-mode installs get one free transcription
451
+ // via the kimaki.dev Whisper fallback before we bother the user with the
452
+ // "add API key" dialog. This fallback has no tool-calling, so it can't
453
+ // detect queueMessage/agent hints spoken in the voice message the way the
454
+ // OpenAI/Gemini path can.
455
+ let gatewayTranscription;
449
456
  if (!transcriptionApiKey) {
457
+ const botRow = await getBotTokenWithMode().catch(() => undefined);
458
+ if (botRow?.mode === 'gateway' && botRow.clientId && botRow.clientSecret) {
459
+ const result = await transcribeViaKimakiGateway({
460
+ audio: audioBuffer,
461
+ mediaType: audioAttachment.contentType || undefined,
462
+ clientId: botRow.clientId,
463
+ clientSecret: botRow.clientSecret,
464
+ });
465
+ if (result instanceof Error) {
466
+ voiceLogger.log(`Kimaki gateway transcription fallback failed:`, result);
467
+ }
468
+ else {
469
+ gatewayTranscription = result;
470
+ }
471
+ }
472
+ }
473
+ if (!transcriptionApiKey && !gatewayTranscription) {
450
474
  if (!appId) {
451
475
  await sendThreadMessage(thread, 'Voice transcription requires an API key. Set OPENAI_API_KEY or GEMINI_API_KEY, or use /login in this channel.');
452
476
  return null;
@@ -461,16 +485,18 @@ export async function processVoiceAttachment({ message, thread, projectDirectory
461
485
  transcriptionApiKey = requested.apiKey;
462
486
  transcriptionProvider = requested.provider;
463
487
  }
464
- const transcription = await transcribeAudio({
465
- audio: audioBuffer,
466
- prompt: transcriptionPrompt,
467
- apiKey: transcriptionApiKey,
468
- provider: transcriptionProvider,
469
- mediaType: audioAttachment.contentType || undefined,
470
- currentSessionContext,
471
- lastSessionContext,
472
- agents,
473
- });
488
+ const transcription = gatewayTranscription
489
+ ? { transcription: gatewayTranscription, queueMessage: false, agent: undefined }
490
+ : await transcribeAudio({
491
+ audio: audioBuffer,
492
+ prompt: transcriptionPrompt,
493
+ apiKey: transcriptionApiKey,
494
+ provider: transcriptionProvider,
495
+ mediaType: audioAttachment.contentType || undefined,
496
+ currentSessionContext,
497
+ lastSessionContext,
498
+ agents,
499
+ });
474
500
  if (transcription instanceof Error) {
475
501
  const errMsg = errore.matchError(transcription, {
476
502
  ApiKeyMissingError: (e) => e.message,