kimaki 0.25.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 (70) 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/commands/agent.js +87 -74
  8. package/dist/commands/model.js +18 -0
  9. package/dist/commands/multioauth.js +169 -1
  10. package/dist/commands/tasks.js +6 -0
  11. package/dist/commands/unset-model.js +5 -20
  12. package/dist/database.js +65 -0
  13. package/dist/discord-bot.js +7 -1
  14. package/dist/ipc-tools-plugin.js +4 -3
  15. package/dist/kimaki-opencode-plugin.js +2 -0
  16. package/dist/message-formatting.js +84 -5
  17. package/dist/message-formatting.test.js +160 -2
  18. package/dist/oauth-rotation-shared.js +7 -1
  19. package/dist/openai-auth-state.js +20 -0
  20. package/dist/schema.js +19 -0
  21. package/dist/session-handler/event-stream-state.js +147 -0
  22. package/dist/session-handler/event-stream-state.test.js +437 -1
  23. package/dist/session-handler/thread-session-runtime.js +90 -37
  24. package/dist/subagent-rate-limit-plugin.js +13 -2
  25. package/dist/system-message.js +18 -4
  26. package/dist/system-message.test.js +28 -4
  27. package/dist/task-runner.js +143 -12
  28. package/dist/task-schedule.js +12 -0
  29. package/dist/task-schedule.test.js +149 -2
  30. package/dist/xai-auth-plugin.js +237 -0
  31. package/dist/xai-auth-state.js +233 -0
  32. package/dist/xai-auth-state.test.js +183 -0
  33. package/package.json +6 -6
  34. package/skills/profano/SKILL.md +5 -3
  35. package/skills/spiceflow/SKILL.md +24 -1
  36. package/src/agent-model.e2e.test.ts +172 -4
  37. package/src/analytics.test.ts +30 -1
  38. package/src/analytics.ts +1 -0
  39. package/src/cli-commands/send.ts +22 -3
  40. package/src/cli-commands/task.ts +32 -19
  41. package/src/cli-runner.test.ts +16 -1
  42. package/src/cli-runner.ts +88 -89
  43. package/src/commands/agent.ts +120 -84
  44. package/src/commands/model.ts +25 -0
  45. package/src/commands/multioauth.ts +195 -2
  46. package/src/commands/tasks.ts +6 -0
  47. package/src/commands/unset-model.ts +5 -21
  48. package/src/database.ts +124 -0
  49. package/src/discord-bot.ts +7 -1
  50. package/src/ipc-tools-plugin.ts +4 -3
  51. package/src/kimaki-opencode-plugin.ts +2 -0
  52. package/src/message-formatting.test.ts +197 -2
  53. package/src/message-formatting.ts +98 -5
  54. package/src/oauth-rotation-shared.ts +6 -1
  55. package/src/openai-auth-state.ts +19 -0
  56. package/src/schema.sql +15 -0
  57. package/src/schema.ts +20 -0
  58. package/src/session-handler/event-stream-state.test.ts +490 -0
  59. package/src/session-handler/event-stream-state.ts +210 -0
  60. package/src/session-handler/model-utils.ts +1 -0
  61. package/src/session-handler/thread-session-runtime.ts +105 -44
  62. package/src/subagent-rate-limit-plugin.ts +12 -2
  63. package/src/system-message.test.ts +29 -4
  64. package/src/system-message.ts +20 -4
  65. package/src/task-runner.ts +168 -11
  66. package/src/task-schedule.test.ts +172 -2
  67. package/src/task-schedule.ts +23 -2
  68. package/src/xai-auth-plugin.ts +302 -0
  69. package/src/xai-auth-state.test.ts +224 -0
  70. package/src/xai-auth-state.ts +293 -0
@@ -374,7 +374,8 @@ see --help for options like voice, speed, etc.
374
374
 
375
375
  ## requesting files from the user
376
376
 
377
- To ask the user to upload files from their device, use the \`kimaki_file_upload\` tool. This shows a native file picker dialog in Discord. The files are downloaded to the project's \`uploads/\` directory and the tool returns the local file paths.
377
+ To ask the user to upload files from their device, use \`kimaki_file_upload\`. This shows a native file picker dialog in Discord. The files are downloaded to the project's \`uploads/\` directory and the tool returns the local file paths.
378
+ You MUST call \`kimaki_file_upload\` LAST, after ALL text.
378
379
 
379
380
  ## archiving the current thread
380
381
 
@@ -492,6 +493,10 @@ Use \`--send-at\` to schedule a one-time or recurring task:
492
493
  kimaki send --channel ${channelId} --prompt 'Reminder: review open PRs' --send-at '2026-03-01T09:00:00Z' --agent <current_agent>${parentSessionArg}${userArg}
493
494
  kimaki send --channel ${channelId} --prompt 'Run weekly test suite and summarize failures' --send-at '0 9 * * 1' --agent <current_agent>${parentSessionArg}${userArg}
494
495
 
496
+ Use \`--pre-run '<command>'\` to check whether a scheduled task should start. Kimaki runs the command in the project directory. Exit code 0 starts the session and appends stdout to the prompt. Any other exit code skips that occurrence. Command output is written to the Kimaki log.
497
+
498
+ Scheduled tasks do not overlap by default. Add \`--allow-concurrency\` only when concurrent sessions from the same task are safe.
499
+
495
500
  **ALWAYS pass \`--user\` when scheduling a task.** Discord only shows a thread in the left sidebar to its members. Without \`--user\`, kimaki does not ensure anyone is a member, so the task can fire completely unnoticed if the user never joined the thread or already left it. This applies to \`--channel\` and \`--thread\` scheduling alike.
496
501
 
497
502
  ALL scheduling is in UTC. Dates must be UTC ISO format ending with \`Z\`. Cron expressions also fire in UTC (e.g. \`0 9 * * 1\` means 9:00 UTC every Monday).
@@ -501,6 +506,8 @@ When the user specifies a time without a timezone, ask them to confirm their tim
501
506
  - \`--notify-only\` to create a reminder thread without auto-starting a session
502
507
  - \`--worktree\` to create the scheduled thread as a worktree session (only if the user explicitly asks for a worktree)
503
508
  - \`--agent\` and \`--model\` to control scheduled session behavior
509
+ - \`--pre-run\` to start only when a project command exits with code 0
510
+ - \`--allow-concurrency\` to permit overlapping runs from the same task
504
511
  - \`--parent-session\` to pass this session as parent of the scheduled child
505
512
  - \`--user\` to add a specific user to the scheduled thread (always pass this)
506
513
 
@@ -534,7 +541,7 @@ Notification strategy:
534
541
  Manage scheduled tasks with:
535
542
 
536
543
  kimaki task list
537
- kimaki task edit <id> --prompt "new prompt" [--send-at "new schedule"] [--user "<discord-user-id>"] [--model "provider/model"] [--agent "<agent>"]
544
+ kimaki task edit <id> --prompt "new prompt" [--send-at "new schedule"] [--pre-run "command"] [--allow-concurrency true|false] [--user "<discord-user-id>"] [--model "provider/model"] [--agent "<agent>"]
538
545
  kimaki task delete <id>
539
546
 
540
547
  \`kimaki task list\` prints \`userId\`, \`agent\`, and \`model\` columns. A \`-\` in \`userId\` means nobody is added to the thread when that task fires, so the user may never see it. Fix it with \`kimaki task edit <id> --user '<discord-user-id>'\` instead of deleting and recreating the task. Change model or agent in place with \`--model\` / \`--agent\` (empty string clears the override). Do not read SQLite or recreate the task just to swap model.
@@ -827,9 +834,16 @@ Only ask questions when the request is genuinely ambiguous with multiple valid a
827
834
 
828
835
  ## ending conversations with options
829
836
 
830
- The question tool must be called last, after all text parts. Always use it when you ask questions.
837
+ You MUST write ALL user-visible text FIRST.
838
+ You MUST call \`question\` LAST, after ALL text parts.
839
+ NEVER call \`question\` before your text. Discord will hide the message.
840
+
841
+ The same rule applies to \`kimaki_action_buttons\` and \`kimaki_file_upload\`.
842
+ You MUST call them LAST, after ALL text.
843
+
844
+ ALWAYS use \`question\` when you ask the user a question. Do not write a numbered list in plain text.
831
845
 
832
- IMPORTANT: Do NOT use the question tool to ask permission before doing work. Do the work first, then offer follow-ups.
846
+ IMPORTANT: Do NOT use \`question\` to ask permission before doing work. Do the work first, then offer follow-ups.
833
847
 
834
848
  Examples:
835
849
  - After completing edits: offer "Commit changes?"
@@ -24,6 +24,16 @@ describe('system-message', () => {
24
24
  expect(message).toContain('- failed commands');
25
25
  expect(message).toContain('<callout accent="#f59e0b">');
26
26
  });
27
+ test('requires interactive tools after all text, using exact tool names', () => {
28
+ const message = getOpencodeSystemMessage({
29
+ sessionId: 'ses_123',
30
+ });
31
+ expect(message).toContain('You MUST write ALL user-visible text FIRST');
32
+ expect(message).toContain('You MUST call `question` LAST');
33
+ expect(message).toContain('NEVER call `question` before your text');
34
+ expect(message).toContain('`kimaki_action_buttons`');
35
+ expect(message).toContain('`kimaki_file_upload`');
36
+ });
27
37
  test('persists and reads session system prompt for command path', async () => {
28
38
  const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kimaki-system-'));
29
39
  tempDirs.push(dataDir);
@@ -174,7 +184,8 @@ describe('system-message', () => {
174
184
 
175
185
  ## requesting files from the user
176
186
 
177
- To ask the user to upload files from their device, use the \`kimaki_file_upload\` tool. This shows a native file picker dialog in Discord. The files are downloaded to the project's \`uploads/\` directory and the tool returns the local file paths.
187
+ To ask the user to upload files from their device, use \`kimaki_file_upload\`. This shows a native file picker dialog in Discord. The files are downloaded to the project's \`uploads/\` directory and the tool returns the local file paths.
188
+ You MUST call \`kimaki_file_upload\` LAST, after ALL text.
178
189
 
179
190
  ## archiving the current thread
180
191
 
@@ -295,6 +306,10 @@ describe('system-message', () => {
295
306
  kimaki send --channel chan_123 --prompt 'Reminder: review open PRs' --send-at '2026-03-01T09:00:00Z' --agent <current_agent> --parent-session ses_123 --user '<discord-user-id>'
296
307
  kimaki send --channel chan_123 --prompt 'Run weekly test suite and summarize failures' --send-at '0 9 * * 1' --agent <current_agent> --parent-session ses_123 --user '<discord-user-id>'
297
308
 
309
+ Use \`--pre-run '<command>'\` to check whether a scheduled task should start. Kimaki runs the command in the project directory. Exit code 0 starts the session and appends stdout to the prompt. Any other exit code skips that occurrence. Command output is written to the Kimaki log.
310
+
311
+ Scheduled tasks do not overlap by default. Add \`--allow-concurrency\` only when concurrent sessions from the same task are safe.
312
+
298
313
  **ALWAYS pass \`--user\` when scheduling a task.** Discord only shows a thread in the left sidebar to its members. Without \`--user\`, kimaki does not ensure anyone is a member, so the task can fire completely unnoticed if the user never joined the thread or already left it. This applies to \`--channel\` and \`--thread\` scheduling alike.
299
314
 
300
315
  ALL scheduling is in UTC. Dates must be UTC ISO format ending with \`Z\`. Cron expressions also fire in UTC (e.g. \`0 9 * * 1\` means 9:00 UTC every Monday).
@@ -304,6 +319,8 @@ describe('system-message', () => {
304
319
  - \`--notify-only\` to create a reminder thread without auto-starting a session
305
320
  - \`--worktree\` to create the scheduled thread as a worktree session (only if the user explicitly asks for a worktree)
306
321
  - \`--agent\` and \`--model\` to control scheduled session behavior
322
+ - \`--pre-run\` to start only when a project command exits with code 0
323
+ - \`--allow-concurrency\` to permit overlapping runs from the same task
307
324
  - \`--parent-session\` to pass this session as parent of the scheduled child
308
325
  - \`--user\` to add a specific user to the scheduled thread (always pass this)
309
326
 
@@ -337,7 +354,7 @@ describe('system-message', () => {
337
354
  Manage scheduled tasks with:
338
355
 
339
356
  kimaki task list
340
- kimaki task edit <id> --prompt "new prompt" [--send-at "new schedule"] [--user "<discord-user-id>"] [--model "provider/model"] [--agent "<agent>"]
357
+ kimaki task edit <id> --prompt "new prompt" [--send-at "new schedule"] [--pre-run "command"] [--allow-concurrency true|false] [--user "<discord-user-id>"] [--model "provider/model"] [--agent "<agent>"]
341
358
  kimaki task delete <id>
342
359
 
343
360
  \`kimaki task list\` prints \`userId\`, \`agent\`, and \`model\` columns. A \`-\` in \`userId\` means nobody is added to the thread when that task fires, so the user may never see it. Fix it with \`kimaki task edit <id> --user '<discord-user-id>'\` instead of deleting and recreating the task. Change model or agent in place with \`--model\` / \`--agent\` (empty string clears the override). Do not read SQLite or recreate the task just to swap model.
@@ -782,9 +799,16 @@ describe('system-message', () => {
782
799
 
783
800
  ## ending conversations with options
784
801
 
785
- The question tool must be called last, after all text parts. Always use it when you ask questions.
802
+ You MUST write ALL user-visible text FIRST.
803
+ You MUST call \`question\` LAST, after ALL text parts.
804
+ NEVER call \`question\` before your text. Discord will hide the message.
805
+
806
+ The same rule applies to \`kimaki_action_buttons\` and \`kimaki_file_upload\`.
807
+ You MUST call them LAST, after ALL text.
808
+
809
+ ALWAYS use \`question\` when you ask the user a question. Do not write a numbered list in plain text.
786
810
 
787
- IMPORTANT: Do NOT use the question tool to ask permission before doing work. Do the work first, then offer follow-ups.
811
+ IMPORTANT: Do NOT use \`question\` to ask permission before doing work. Do the work first, then offer follow-ups.
788
812
 
789
813
  Examples:
790
814
  - After completing edits: offer "Commit changes?"
@@ -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
+ });