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
@@ -16,8 +16,8 @@ import { isAbortError } from '../utils.js';
16
16
  import { registerEventListener, unregisterEventListener, waitForGlobalEventListener, } from './global-event-listener.js';
17
17
  import { createLogger, LogPrefix } from '../logger.js';
18
18
  import { sendThreadMessage, SILENT_MESSAGE_FLAGS, NOTIFY_MESSAGE_FLAGS, } from '../discord-utils.js';
19
- import { formatPart } from '../message-formatting.js';
20
- import { getChannelVerbosity, getPartMessageIds, getDb, setPartMessage, getThreadSession, setThreadSession, getThreadParentSessionId, setThreadParentSessionId, getThreadWorktreeOrWorkspace, setSessionAgent, clearSessionModel, getVariantCascade, setSessionStartSource, appendSessionEventsSinceLastTimestamp, getSessionEventSnapshot, } from '../database.js';
19
+ import { formatPart, formatTaskToolTitle } from '../message-formatting.js';
20
+ import { getChannelVerbosity, getPartMessageIds, getDb, setPartMessage, getThreadSession, setThreadSession, getThreadParentSessionId, setThreadParentSessionId, getThreadWorktreeOrWorkspace, setSessionAgent, clearSessionModel, getVariantCascade, setSessionStartSource, completeScheduledTaskRunsForSession, failScheduledTaskRunsForSession, startScheduledTaskRunSession, appendSessionEventsSinceLastTimestamp, getSessionEventSnapshot, } from '../database.js';
21
21
  import * as orm from 'drizzle-orm';
22
22
  import * as schema from '../schema.js';
23
23
  import { showPermissionButtons, addPermissionRequestToContext, arePatternsCoveredBy, pendingPermissionContexts, } from '../commands/permissions.js';
@@ -30,7 +30,7 @@ import { getDataDir } from '../config.js';
30
30
  import { trackEvent, } from '../analytics.js';
31
31
  import { resolveValidatedAgentPreference } from './agent-utils.js';
32
32
  import { appendOpencodeSessionEventLog, getOpencodeEventSessionId, isOpencodeSessionEventLogEnabled, } from './opencode-session-event-log.js';
33
- import { doesLatestUserTurnHaveNaturalCompletion, didQuestionQueueHandoffSinceLatestQuestionAsked, getAssistantMessageIdsForLatestUserTurn, getCurrentTurnStartTime, isSessionBusy, getLatestRunInfo, getDerivedSubtaskIndex, getDerivedSubtaskAgentType, getLatestAssistantMessageIdForLatestUserTurn, hasAssistantMessageCompletedBefore, isAssistantMessageInLatestUserTurn, isAssistantMessageNaturalCompletion, } from './event-stream-state.js';
33
+ import { doesLatestUserTurnHaveNaturalCompletion, didQuestionQueueHandoffSinceLatestQuestionAsked, getAssistantMessageIdsForLatestUserTurn, getCurrentTurnStartTime, isSessionBusy, getLatestRunInfo, getIdleTokenUsageDelta, getDerivedSubtaskIndex, getDerivedSubtaskAgentType, getLatestAssistantMessageIdForLatestUserTurn, hasAssistantMessageCompletedBefore, isAssistantMessageInLatestUserTurn, isAssistantMessageNaturalCompletion, } from './event-stream-state.js';
34
34
  // Track multiple pending permissions per thread (keyed by permission ID).
35
35
  // OpenCode handles blocking/sequencing — we just need to track all pending
36
36
  // permissions to avoid duplicates and properly clean up on reply/teardown.
@@ -949,9 +949,20 @@ export class ThreadSessionRuntime {
949
949
  await this.handlePartUpdated(event.properties.part);
950
950
  break;
951
951
  case 'session.idle':
952
+ await completeScheduledTaskRunsForSession(event.properties.sessionID);
952
953
  await this.handleSessionIdle(event.properties.sessionID);
953
954
  break;
954
955
  case 'session.error':
956
+ if (event.properties.sessionID) {
957
+ const sessionError = event.properties.error;
958
+ const errorMessage = sessionError && typeof sessionError === 'object'
959
+ ? String(sessionError.data?.message || sessionError.name || 'Session failed')
960
+ : 'Session failed';
961
+ await failScheduledTaskRunsForSession({
962
+ sessionId: event.properties.sessionID,
963
+ error: errorMessage,
964
+ });
965
+ }
955
966
  await this.handleSessionError(event.properties);
956
967
  break;
957
968
  case 'permission.asked':
@@ -1433,6 +1444,35 @@ export class ThreadSessionRuntime {
1433
1444
  this.ensureTypingNow();
1434
1445
  return;
1435
1446
  }
1447
+ if (part.type === 'tool' &&
1448
+ part.tool === 'task' &&
1449
+ !this.state?.sentPartIds.has(part.id)) {
1450
+ const taskDisplay = formatTaskToolTitle(part);
1451
+ if (taskDisplay && (await this.getVerbosity()) !== 'text_only') {
1452
+ await this.flushBufferedParts({
1453
+ messageID: part.messageID,
1454
+ force: true,
1455
+ skipPartId: part.id,
1456
+ });
1457
+ threadState.updateThread(this.threadId, (t) => {
1458
+ const newIds = new Set(t.sentPartIds);
1459
+ newIds.add(part.id);
1460
+ return { ...t, sentPartIds: newIds };
1461
+ });
1462
+ const sendResult = await sendThreadMessage(this.thread, taskDisplay + '\n\n')
1463
+ .catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }));
1464
+ if (sendResult instanceof Error) {
1465
+ threadState.updateThread(this.threadId, (t) => {
1466
+ const newIds = new Set(t.sentPartIds);
1467
+ newIds.delete(part.id);
1468
+ return { ...t, sentPartIds: newIds };
1469
+ });
1470
+ discordLogger.error(`ERROR: Failed to send task part ${part.id}:`, sendResult);
1471
+ return;
1472
+ }
1473
+ await setPartMessage({ partId: part.id, messageId: sendResult.id, threadId: this.thread.id });
1474
+ }
1475
+ }
1436
1476
  if (part.type === 'tool' && part.state.status === 'running') {
1437
1477
  await this.flushBufferedParts({
1438
1478
  messageID: part.messageID,
@@ -1440,40 +1480,6 @@ export class ThreadSessionRuntime {
1440
1480
  skipPartId: part.id,
1441
1481
  });
1442
1482
  await this.sendPartMessage({ part });
1443
- // Track task tool spawning subtask sessions
1444
- if (part.tool === 'task' && !this.state?.sentPartIds.has(part.id)) {
1445
- const description = typeof part.state.input?.description === 'string'
1446
- ? part.state.input.description
1447
- : '';
1448
- const agent = typeof part.state.input?.subagent_type === 'string'
1449
- ? part.state.input.subagent_type
1450
- : 'task';
1451
- const childSessionId = typeof part.state.metadata?.sessionId === 'string'
1452
- ? part.state.metadata.sessionId
1453
- : '';
1454
- if (description && childSessionId) {
1455
- if ((await this.getVerbosity()) !== 'text_only') {
1456
- const taskDisplay = `┣ ${agent} **${description}**`;
1457
- threadState.updateThread(this.threadId, (t) => {
1458
- const newIds = new Set(t.sentPartIds);
1459
- newIds.add(part.id);
1460
- return { ...t, sentPartIds: newIds };
1461
- });
1462
- const sendResult = await sendThreadMessage(this.thread, taskDisplay + '\n\n')
1463
- .catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }));
1464
- if (sendResult instanceof Error) {
1465
- threadState.updateThread(this.threadId, (t) => {
1466
- const newIds = new Set(t.sentPartIds);
1467
- newIds.delete(part.id);
1468
- return { ...t, sentPartIds: newIds };
1469
- });
1470
- discordLogger.error(`ERROR: Failed to send task part ${part.id}:`, sendResult);
1471
- return;
1472
- }
1473
- await setPartMessage({ partId: part.id, messageId: sendResult.id, threadId: this.thread.id });
1474
- }
1475
- }
1476
- }
1477
1483
  return;
1478
1484
  }
1479
1485
  // Action buttons tool handler
@@ -1637,7 +1643,47 @@ export class ThreadSessionRuntime {
1637
1643
  await setPartMessage({ partId: part.id, messageId: sendResult.id, threadId: this.thread.id });
1638
1644
  this.requestTypingRepulse();
1639
1645
  }
1646
+ trackIdleTokenUsage(idleSessionId) {
1647
+ let idleEventIndex;
1648
+ for (let i = this.eventBuffer.length - 1; i >= 0; i--) {
1649
+ const event = this.eventBuffer[i]?.event;
1650
+ if (event?.type === 'session.idle' && event.properties.sessionID === idleSessionId) {
1651
+ idleEventIndex = i;
1652
+ break;
1653
+ }
1654
+ }
1655
+ if (idleEventIndex === undefined) {
1656
+ return;
1657
+ }
1658
+ const usage = getIdleTokenUsageDelta({
1659
+ events: this.eventBuffer,
1660
+ sessionId: idleSessionId,
1661
+ idleEventIndex,
1662
+ });
1663
+ if (!usage) {
1664
+ return;
1665
+ }
1666
+ const properties = {
1667
+ tokens_input: usage.input,
1668
+ tokens_output: usage.output,
1669
+ tokens_reasoning: usage.reasoning,
1670
+ tokens_cache_read: usage.cacheRead,
1671
+ tokens_cache_write: usage.cacheWrite,
1672
+ tokens_total: usage.total,
1673
+ cost: usage.cost,
1674
+ assistant_message_count: usage.assistantMessageCount,
1675
+ is_subagent: Boolean(this.getSubtaskInfoForSession(idleSessionId)),
1676
+ };
1677
+ if (usage.model) {
1678
+ properties.model = usage.model;
1679
+ }
1680
+ if (usage.providerID) {
1681
+ properties.provider = usage.providerID;
1682
+ }
1683
+ trackEvent('tokens_used', properties);
1684
+ }
1640
1685
  async handleSessionIdle(idleSessionId) {
1686
+ this.trackIdleTokenUsage(idleSessionId);
1641
1687
  const sessionId = this.state?.sessionId;
1642
1688
  // ── Subtask idle ──────────────────────────────────────────
1643
1689
  const subtask = this.getSubtaskInfoForSession(idleSessionId);
@@ -2122,7 +2168,6 @@ export class ThreadSessionRuntime {
2122
2168
  prompt: input.prompt,
2123
2169
  agent: input.agent,
2124
2170
  permissions: input.permissions,
2125
- permissionRules: input.permissionRules,
2126
2171
  injectionGuardPatterns: input.injectionGuardPatterns,
2127
2172
  sessionStartScheduleKind: input.sessionStartSource?.scheduleKind,
2128
2173
  sessionStartScheduledTaskId: input.sessionStartSource?.scheduledTaskId,
@@ -2137,7 +2182,6 @@ export class ThreadSessionRuntime {
2137
2182
  sessionId: session.id,
2138
2183
  createdNewSession,
2139
2184
  permissions: input.permissions,
2140
- permissionRules: input.permissionRules,
2141
2185
  });
2142
2186
  if (updatePermissionsResult instanceof Error) {
2143
2187
  await cleanupOnError(`Failed to update session permissions: ${updatePermissionsResult.message}`);
@@ -2333,6 +2377,13 @@ export class ThreadSessionRuntime {
2333
2377
  await cleanupOnError(`✗ OpenCode API error: ${errorMessage}`);
2334
2378
  return;
2335
2379
  }
2380
+ if (input.sessionStartSource?.scheduledTaskRunId) {
2381
+ await startScheduledTaskRunSession({
2382
+ runId: input.sessionStartSource.scheduledTaskRunId,
2383
+ sessionId: session.id,
2384
+ projectDirectory: this.sdkDirectory,
2385
+ });
2386
+ }
2336
2387
  logger.log(`[INGRESS] promptAsync accepted by opencode queue sessionId=${session.id} threadId=${this.threadId}`);
2337
2388
  if (!input.noReply) {
2338
2389
  trackTurnStarted({
@@ -2369,7 +2420,6 @@ export class ThreadSessionRuntime {
2369
2420
  agent: input.agent,
2370
2421
  model: input.model,
2371
2422
  permissions: input.permissions,
2372
- permissionRules: input.permissionRules,
2373
2423
  injectionGuardPatterns: input.injectionGuardPatterns,
2374
2424
  parentSessionId: input.parentSessionId,
2375
2425
  sourceMessageId: input.sourceMessageId,
@@ -2505,10 +2555,6 @@ export class ThreadSessionRuntime {
2505
2555
  // no explicit agent was already set (CLI --agent flag wins).
2506
2556
  agent: input.agent || result.agent,
2507
2557
  repliedMessage: result.repliedMessage,
2508
- permissionRules: [
2509
- ...(input.permissionRules ?? []),
2510
- ...(result.permissionRules ?? []),
2511
- ],
2512
2558
  preprocess: undefined,
2513
2559
  });
2514
2560
  const hasPromptText = resolvedInput.prompt.trim().length > 0;
@@ -2753,7 +2799,6 @@ export class ThreadSessionRuntime {
2753
2799
  prompt: input.prompt,
2754
2800
  agent: input.agent,
2755
2801
  permissions: input.permissions,
2756
- permissionRules: input.permissionRules,
2757
2802
  injectionGuardPatterns: input.injectionGuardPatterns,
2758
2803
  sessionStartScheduleKind: input.sessionStartScheduleKind,
2759
2804
  sessionStartScheduledTaskId: input.sessionStartScheduledTaskId,
@@ -2772,7 +2817,6 @@ export class ThreadSessionRuntime {
2772
2817
  sessionId: session.id,
2773
2818
  createdNewSession,
2774
2819
  permissions: input.permissions,
2775
- permissionRules: input.permissionRules,
2776
2820
  });
2777
2821
  if (updatePermissionsResult instanceof Error) {
2778
2822
  this.stopTyping();
@@ -3132,14 +3176,11 @@ export class ThreadSessionRuntime {
3132
3176
  }
3133
3177
  // ── Session Ensure ──────────────────────────────────────────
3134
3178
  // Creates or reuses the OpenCode session for this thread.
3135
- async updateExistingSessionPermissions({ client, sessionId, createdNewSession, permissions, permissionRules, }) {
3179
+ async updateExistingSessionPermissions({ client, sessionId, createdNewSession, permissions, }) {
3136
3180
  if (createdNewSession) {
3137
3181
  return null;
3138
3182
  }
3139
- const rules = [
3140
- ...(permissionRules ?? []),
3141
- ...parsePermissionRules(permissions ?? []),
3142
- ];
3183
+ const rules = parsePermissionRules(permissions ?? []);
3143
3184
  if (rules.length === 0) {
3144
3185
  return null;
3145
3186
  }
@@ -3154,7 +3195,7 @@ export class ThreadSessionRuntime {
3154
3195
  }
3155
3196
  return null;
3156
3197
  }
3157
- async ensureSession({ prompt, agent, permissions, permissionRules, injectionGuardPatterns, sessionStartScheduleKind, sessionStartScheduledTaskId, }) {
3198
+ async ensureSession({ prompt, agent, permissions, injectionGuardPatterns, sessionStartScheduleKind, sessionStartScheduledTaskId, }) {
3158
3199
  const directory = this.sdkDirectory;
3159
3200
  // Resolve worktree info for server initialization
3160
3201
  const workspaceInfo = await getThreadWorktreeOrWorkspace(this.thread.id);
@@ -3195,10 +3236,8 @@ export class ThreadSessionRuntime {
3195
3236
  }
3196
3237
  }
3197
3238
  if (!session) {
3198
- // Pass per-session external_directory permissions so this session can
3199
- // access its own project directory (and worktree origin if applicable)
3200
- // without prompts. These override the server-level 'ask' default via
3201
- // opencode's findLast() rule evaluation.
3239
+ // Pass per-session external_directory permissions. By default this is a
3240
+ // single allow-everything rule plus the worktree-origin deny rule.
3202
3241
  // CLI --permission rules are appended after base rules so they win
3203
3242
  // via opencode's findLast() evaluation.
3204
3243
  const sessionPermissions = [
@@ -3206,7 +3245,6 @@ export class ThreadSessionRuntime {
3206
3245
  directory: this.sdkDirectory,
3207
3246
  originalRepoDirectory,
3208
3247
  }),
3209
- ...(permissionRules ?? []),
3210
3248
  ...parsePermissionRules(permissions ?? []),
3211
3249
  ];
3212
3250
  // Omit title so OpenCode auto-generates a summary from the conversation
package/dist/store.js CHANGED
@@ -13,6 +13,7 @@ export const store = createStore(() => ({
13
13
  disabledSkills: [],
14
14
  allowedMentions: ['users'],
15
15
  allowAllUsers: false,
16
+ restrictExternalDirectories: false,
16
17
  permissionTimeoutMs: 10 * 60 * 1000,
17
18
  useWorktrees: false,
18
19
  autoUpgradeEnabled: true,
@@ -10,6 +10,9 @@ const RATE_LIMIT_TEXT_PATTERNS = [
10
10
  'retry after',
11
11
  'too many requests',
12
12
  'quota exceeded',
13
+ 'balance exhausted',
14
+ 'spending-limit',
15
+ 'run out of credits',
13
16
  ];
14
17
  function isRateLimitText(text) {
15
18
  if (!text) {
@@ -66,7 +69,7 @@ function extractRateLimitReason(event) {
66
69
  }
67
70
  if (event.type === 'message.part.updated' && event.properties.part.type === 'retry') {
68
71
  const retryError = event.properties.part.error;
69
- if (retryError.data.statusCode === 429) {
72
+ if (retryError.data.statusCode === 429 || retryError.data.statusCode === 402) {
70
73
  return retryError.data.message;
71
74
  }
72
75
  if (isRateLimitText(retryError.data.responseBody)) {
@@ -90,9 +93,17 @@ function extractRateLimitReason(event) {
90
93
  if (!apiError) {
91
94
  return undefined;
92
95
  }
93
- if (apiError.statusCode === 429) {
96
+ if (apiError.statusCode === 429 || apiError.statusCode === 402) {
94
97
  return apiError.message;
95
98
  }
99
+ // xAI returns 403 with spending-limit or run out of credits text.
100
+ // Only treat 403 as a usage limit if the body matches specific patterns,
101
+ // not all 403s (which can be auth errors handled elsewhere).
102
+ if (apiError.statusCode === 403) {
103
+ const errorText = [apiError.message, apiError.responseBody].filter(Boolean).join(' ');
104
+ if (isRateLimitText(errorText))
105
+ return apiError.message;
106
+ }
96
107
  if (isRateLimitText(apiError.responseBody)) {
97
108
  return apiError.responseBody;
98
109
  }
@@ -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?"