kimaki 0.22.0 → 0.23.1

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 (50) hide show
  1. package/dist/anthropic-auth-plugin.js +78 -33
  2. package/dist/anthropic-auth-state.js +62 -27
  3. package/dist/anthropic-auth-state.test.js +55 -2
  4. package/dist/channel-management.js +29 -6
  5. package/dist/cli-commands/project.js +6 -1
  6. package/dist/commands/login.js +1 -1
  7. package/dist/commands/new-worktree.js +9 -3
  8. package/dist/commands/queue.js +12 -2
  9. package/dist/database.js +7 -8
  10. package/dist/db.js +1 -0
  11. package/dist/discord-bot.js +9 -0
  12. package/dist/hrana-server.js +19 -0
  13. package/dist/oauth-rotation-shared.js +21 -0
  14. package/dist/opencode.js +73 -4
  15. package/dist/schema.js +3 -0
  16. package/dist/session-handler/global-event-listener.js +38 -6
  17. package/dist/session-handler/thread-runtime-state.js +2 -0
  18. package/dist/session-handler/thread-session-runtime.js +19 -3
  19. package/dist/system-message.js +34 -20
  20. package/dist/system-message.test.js +34 -20
  21. package/dist/task-runner.js +8 -4
  22. package/dist/worktree-lifecycle.e2e.test.js +16 -2
  23. package/package.json +4 -4
  24. package/skills/holocron/SKILL.md +11 -8
  25. package/src/anthropic-auth-plugin.ts +82 -35
  26. package/src/anthropic-auth-state.test.ts +73 -1
  27. package/src/anthropic-auth-state.ts +85 -25
  28. package/src/channel-management.ts +35 -6
  29. package/src/cli-commands/project.ts +6 -1
  30. package/src/commands/login.ts +1 -1
  31. package/src/commands/new-worktree.ts +14 -3
  32. package/src/commands/queue.ts +13 -2
  33. package/src/database.ts +8 -9
  34. package/src/db.ts +1 -0
  35. package/src/discord-bot.ts +12 -0
  36. package/src/hrana-server.ts +19 -0
  37. package/src/oauth-rotation-shared.ts +22 -0
  38. package/src/opencode.ts +90 -6
  39. package/src/schema.sql +1 -0
  40. package/src/schema.ts +3 -0
  41. package/src/session-handler/global-event-listener.ts +38 -6
  42. package/src/session-handler/thread-runtime-state.ts +4 -1
  43. package/src/session-handler/thread-session-runtime.ts +25 -3
  44. package/src/system-message.test.ts +34 -20
  45. package/src/system-message.ts +34 -20
  46. package/src/task-runner.ts +8 -4
  47. package/src/worktree-lifecycle.e2e.test.ts +18 -2
  48. package/skills/batch/SKILL.md +0 -87
  49. package/skills/security-review/SKILL.md +0 -208
  50. package/skills/simplify/SKILL.md +0 -58
package/dist/opencode.js CHANGED
@@ -22,7 +22,7 @@ import readline from 'node:readline';
22
22
  import { fileURLToPath } from 'node:url';
23
23
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
24
24
  import { createOpencodeClient, } from '@opencode-ai/sdk/v2';
25
- import { restartGlobalEventListener } from './session-handler/global-event-listener.js';
25
+ import { restartGlobalEventListener, waitForGlobalEventListener, } from './session-handler/global-event-listener.js';
26
26
  import { getDataDir, getLockPort, } from './config.js';
27
27
  import { store } from './store.js';
28
28
  import { getHranaUrl } from './hrana-server.js';
@@ -195,6 +195,10 @@ function killSingleServerProcessNow({ reason, }) {
195
195
  if (!singleServer) {
196
196
  return;
197
197
  }
198
+ // Never kill a server we didn't spawn (discovered from another process)
199
+ if (singleServer.discovered || !singleServer.process) {
200
+ return;
201
+ }
198
202
  const serverProcess = singleServer.process;
199
203
  const pid = serverProcess.pid;
200
204
  if (!pid || serverProcess.killed) {
@@ -375,16 +379,70 @@ function ensureOpencodeHomeDirectories({ directories, }) {
375
379
  fs.mkdirSync(directory, { recursive: true });
376
380
  });
377
381
  }
382
+ /**
383
+ * Try to discover an OpenCode server already running in the bot process.
384
+ * Queries the hrana server on the lock port for the OpenCode server port,
385
+ * then verifies the server is healthy. Returns null if no server found.
386
+ */
387
+ async function discoverExistingServer() {
388
+ const lockPort = getLockPort();
389
+ try {
390
+ const portResponse = await requestHealthcheck({
391
+ url: `http://127.0.0.1:${lockPort}/kimaki/opencode-port`,
392
+ timeoutMs: 2000,
393
+ });
394
+ if (portResponse.status !== 200) {
395
+ return null;
396
+ }
397
+ const parsed = JSON.parse(portResponse.body);
398
+ const port = parsed?.port;
399
+ if (typeof port !== 'number') {
400
+ return null;
401
+ }
402
+ // Verify the OpenCode server is actually healthy
403
+ const healthResponse = await requestHealthcheck({
404
+ url: `http://127.0.0.1:${port}/api/health`,
405
+ timeoutMs: 2000,
406
+ });
407
+ if (healthResponse.status >= 500) {
408
+ return null;
409
+ }
410
+ opencodeLogger.log(`Discovered existing OpenCode server on port ${port} via hrana lock port ${lockPort}`);
411
+ return {
412
+ process: null,
413
+ port,
414
+ baseUrl: `http://127.0.0.1:${port}`,
415
+ discovered: true,
416
+ };
417
+ }
418
+ catch {
419
+ // Connection refused or other network error — no bot running
420
+ return null;
421
+ }
422
+ }
378
423
  async function ensureSingleServer({ directory, } = {}) {
379
424
  const startupDirectory = directory || preferredStartupDirectory || undefined;
380
- if (singleServer && !singleServer.process.killed) {
425
+ if (singleServer && !singleServer.process?.killed) {
381
426
  return singleServer;
382
427
  }
383
- // Deduplicate concurrent startup attempts
428
+ // Deduplicate concurrent startup attempts (covers both discovery and spawn)
384
429
  if (startingServer) {
385
430
  return startingServer;
386
431
  }
387
- startingServer = startSingleServer({ directory: startupDirectory });
432
+ // Wrap discovery + spawn in a single shared promise so concurrent callers
433
+ // don't each run discoverExistingServer() and then each spawn a server.
434
+ startingServer = (async () => {
435
+ // Try to discover an already-running server from the bot process via
436
+ // the hrana server's /kimaki/opencode-port endpoint. This lets CLI
437
+ // subcommands (kimaki session list, archive, wait, etc.) reuse the
438
+ // bot's OpenCode server instead of spawning a redundant one.
439
+ const discovered = await discoverExistingServer();
440
+ if (discovered) {
441
+ singleServer = discovered;
442
+ return discovered;
443
+ }
444
+ return startSingleServer({ directory: startupDirectory });
445
+ })();
388
446
  try {
389
447
  return await startingServer;
390
448
  }
@@ -579,6 +637,7 @@ async function startSingleServer({ directory, } = {}) {
579
637
  OPENCODE_PORT: port.toString(),
580
638
  KIMAKI: '1',
581
639
  OPENCODE_EXPERIMENTAL_WORKSPACES: 'true',
640
+ OPENCODE_ENABLE_EXA: '1',
582
641
  KIMAKI_DATA_DIR: getDataDir(),
583
642
  KIMAKI_LOCK_PORT: getLockPort().toString(),
584
643
  KIMAKI_PARENT_LOCK_PORT: getLockPort().toString(),
@@ -991,6 +1050,14 @@ export async function stopOpencodeServer() {
991
1050
  return false;
992
1051
  }
993
1052
  const server = singleServer;
1053
+ // For discovered servers (from another process), just clear local state
1054
+ // without killing the process we don't own.
1055
+ if (server.discovered || !server.process) {
1056
+ singleServer = null;
1057
+ clientCache.clear();
1058
+ serverRetryCount = 0;
1059
+ return true;
1060
+ }
994
1061
  opencodeLogger.log(`Stopping opencode server (pid: ${server.process.pid}, port: ${server.port})`);
995
1062
  if (!server.process.killed) {
996
1063
  const killResult = errore.try(() => {
@@ -1032,5 +1099,7 @@ export async function restartOpencodeServer() {
1032
1099
  const result = await ensureSingleServer();
1033
1100
  if (result instanceof Error)
1034
1101
  return result;
1102
+ restartGlobalEventListener();
1103
+ await waitForGlobalEventListener();
1035
1104
  return true;
1036
1105
  }
package/dist/schema.js CHANGED
@@ -59,6 +59,9 @@ export const channel_directories = sqliteCore.sqliteTable('channel_directories',
59
59
  channel_id: sqliteCore.text('channel_id').primaryKey().notNull(),
60
60
  directory: sqliteCore.text('directory').notNull(),
61
61
  channel_type: sqliteCore.text('channel_type', { enum: ['text', 'voice'] }).notNull(),
62
+ // Guild that owns this channel. Used to scope the default-channel tombstone
63
+ // check so multi-guild setups don't cross-block each other.
64
+ guild_id: sqliteCore.text('guild_id'),
62
65
  created_at: datetime('created_at').default(orm.sql `CURRENT_TIMESTAMP`),
63
66
  });
64
67
  export const bot_api_keys = sqliteCore.sqliteTable('bot_api_keys', {
@@ -18,14 +18,26 @@ function isAbortError(err) {
18
18
  return true;
19
19
  return false;
20
20
  }
21
- function delay(ms) {
22
- return new Promise((resolve) => setTimeout(resolve, ms));
21
+ function delay(ms, signal) {
22
+ return new Promise((resolve) => {
23
+ if (signal.aborted) {
24
+ resolve();
25
+ return;
26
+ }
27
+ const timeout = setTimeout(resolve, ms);
28
+ signal.addEventListener('abort', () => {
29
+ clearTimeout(timeout);
30
+ resolve();
31
+ }, { once: true });
32
+ });
23
33
  }
24
34
  // ── State ──────────────────────────────────────────────────────
25
35
  const callbacks = new Map();
26
36
  let loopRunning = false;
27
37
  let disposed = false;
28
38
  let controller = null;
39
+ let connected = false;
40
+ const connectionWaiters = new Set();
29
41
  // ── Public API ─────────────────────────────────────────────────
30
42
  /**
31
43
  * Register a thread runtime to receive global events. Every event from the
@@ -53,6 +65,7 @@ export function unregisterEventListener(threadId) {
53
65
  export function disposeGlobalEventListener() {
54
66
  disposed = true;
55
67
  loopRunning = false;
68
+ connected = false;
56
69
  controller?.abort();
57
70
  controller = null;
58
71
  callbacks.clear();
@@ -64,8 +77,18 @@ export function disposeGlobalEventListener() {
64
77
  export function restartGlobalEventListener() {
65
78
  if (disposed)
66
79
  return;
80
+ connected = false;
67
81
  controller?.abort();
68
82
  }
83
+ /** Wait until the event stream is connected before starting event-producing work. */
84
+ export function waitForGlobalEventListener() {
85
+ if (callbacks.size === 0 || connected)
86
+ return Promise.resolve();
87
+ ensureListenerRunning();
88
+ return new Promise((resolve) => {
89
+ connectionWaiters.add(resolve);
90
+ });
91
+ }
69
92
  // ── Internals ──────────────────────────────────────────────────
70
93
  // Lazy subscription to opencode server lifecycle. Deferred to avoid
71
94
  // circular import: global-event-listener imports opencode.ts which
@@ -128,7 +151,7 @@ async function runEventLoop() {
128
151
  return;
129
152
  }
130
153
  logger.warn(`[GLOBAL LISTENER] No OpenCode server available, retrying in ${backoffMs}ms`);
131
- await delay(backoffMs);
154
+ await delay(backoffMs, signal);
132
155
  backoffMs = Math.min(backoffMs * 2, maxBackoffMs);
133
156
  continue;
134
157
  }
@@ -143,11 +166,15 @@ async function runEventLoop() {
143
166
  continue;
144
167
  }
145
168
  logger.warn(`[GLOBAL LISTENER] Subscribe failed, retrying in ${backoffMs}ms:`, subscribeResult.message);
146
- await delay(backoffMs);
169
+ await delay(backoffMs, signal);
147
170
  backoffMs = Math.min(backoffMs * 2, maxBackoffMs);
148
171
  continue;
149
172
  }
150
173
  const events = subscribeResult.stream;
174
+ connected = true;
175
+ for (const resolve of connectionWaiters)
176
+ resolve();
177
+ connectionWaiters.clear();
151
178
  logger.log('[GLOBAL LISTENER] Connected to global event stream');
152
179
  let receivedAnyEvent = false;
153
180
  const iterResult = await (async () => {
@@ -157,6 +184,7 @@ async function runEventLoop() {
157
184
  }
158
185
  })()
159
186
  .catch((e) => new OpenCodeSdkError({ operation: 'event.iterate', cause: e }));
187
+ connected = false;
160
188
  if (receivedAnyEvent) {
161
189
  backoffMs = 500;
162
190
  }
@@ -168,12 +196,16 @@ async function runEventLoop() {
168
196
  continue;
169
197
  }
170
198
  logger.warn(`[GLOBAL LISTENER] Stream broke, reconnecting in ${backoffMs}ms:`, iterResult.message);
171
- await delay(backoffMs);
199
+ await delay(backoffMs, signal);
172
200
  backoffMs = Math.min(backoffMs * 2, maxBackoffMs);
173
201
  }
174
202
  else {
203
+ if (signal.aborted) {
204
+ backoffMs = 500;
205
+ continue;
206
+ }
175
207
  logger.log(`[GLOBAL LISTENER] Stream ended normally, reconnecting in ${backoffMs}ms`);
176
- await delay(backoffMs);
208
+ await delay(backoffMs, signal);
177
209
  backoffMs = Math.min(backoffMs * 2, maxBackoffMs);
178
210
  }
179
211
  }
@@ -110,7 +110,9 @@ export function dequeueItem(threadId) {
110
110
  return next;
111
111
  }
112
112
  export function clearQueueItems(threadId) {
113
+ const items = store.getState().threads.get(threadId)?.queueItems.slice() ?? [];
113
114
  updateThread(threadId, (t) => ({ ...t, queueItems: [] }));
115
+ return items;
114
116
  }
115
117
  export function removeQueueItemAtPosition(threadId, position) {
116
118
  if (position < 1) {
@@ -13,7 +13,7 @@ import * as errore from 'errore';
13
13
  import * as threadState from './thread-runtime-state.js';
14
14
  import { getOpencodeClient, initializeOpencodeForDirectory, buildSessionPermissions, parsePermissionRules, writeInjectionGuardConfig, extractSdkErrorMessage, } from '../opencode.js';
15
15
  import { isAbortError } from '../utils.js';
16
- import { registerEventListener, unregisterEventListener, } from './global-event-listener.js';
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
19
  import { formatPart } from '../message-formatting.js';
@@ -1663,6 +1663,20 @@ export class ThreadSessionRuntime {
1663
1663
  force: true,
1664
1664
  repulseTyping: false,
1665
1665
  });
1666
+ // Skip footer if model produced no visible output (no text, no tool calls,
1667
+ // just step-start/step-finish lifecycle parts). This happens when the model
1668
+ // decides not to respond.
1669
+ const hasVisibleOutput = assistantMessageIds.some((msgId) => {
1670
+ const parts = this.getBufferedParts(msgId);
1671
+ return parts.some((part) => part.type !== 'step-start' && part.type !== 'step-finish');
1672
+ });
1673
+ if (!hasVisibleOutput) {
1674
+ this.stopTyping();
1675
+ this.resetPerRunState();
1676
+ this.clearBufferedPartsForMessages(assistantMessageIds);
1677
+ logger.log(`[ASSISTANT COMPLETED] no visible output, skipping footer for message ${completedMessageId} sessionId=${sessionId}`);
1678
+ return;
1679
+ }
1666
1680
  this.stopTyping();
1667
1681
  const turnStartTime = getCurrentTurnStartTime({
1668
1682
  events: this.eventBuffer,
@@ -2281,6 +2295,7 @@ export class ThreadSessionRuntime {
2281
2295
  ...variantField,
2282
2296
  ...(input.noReply ? { noReply: true } : {}),
2283
2297
  };
2298
+ await waitForGlobalEventListener();
2284
2299
  const promptResult = await getClient().session.promptAsync(request)
2285
2300
  .catch((e) => new OpenCodeSdkError({ operation: 'session.promptAsync', cause: e }));
2286
2301
  if (promptResult instanceof Error || promptResult.error) {
@@ -2591,9 +2606,9 @@ export class ThreadSessionRuntime {
2591
2606
  ? SILENT_MESSAGE_FLAGS
2592
2607
  : NOTIFY_MESSAGE_FLAGS;
2593
2608
  }
2594
- /** Clear all queued messages. */
2609
+ /** Clear all queued messages. Returns the removed items. */
2595
2610
  clearQueue() {
2596
- threadState.clearQueueItems(this.threadId);
2611
+ return threadState.clearQueueItems(this.threadId);
2597
2612
  }
2598
2613
  /** Remove a queued message by its 1-based position. */
2599
2614
  removeQueuePosition(position) {
@@ -2987,6 +3002,7 @@ export class ThreadSessionRuntime {
2987
3002
  logger.log(`[DISPATCH] Successfully ran command for session ${session.id}`);
2988
3003
  return;
2989
3004
  }
3005
+ await waitForGlobalEventListener();
2990
3006
  const promptResponse = await getClient().session.promptAsync({
2991
3007
  sessionID: session.id,
2992
3008
  directory: this.sdkDirectory,
@@ -211,7 +211,7 @@ ${escapePromptText(repliedMessage.text)}
211
211
  : []),
212
212
  ...(worktree && worktreeChanged
213
213
  ? [
214
- `<system-reminder>\nThis session is running inside a git worktree. The working directory (cwd / pwd) has changed. The user expects you to edit files in the new cwd. You MUST operate inside the new worktree from now on.\n- New worktree path (new cwd / pwd, edit files here): ${worktree.worktreeDirectory}\n- Branch: ${worktree.branch}\n- Main repo path (previous folder, DO NOT TOUCH): ${worktree.mainRepoDirectory}\n- To find the base branch (the branch this worktree was created from): \`git -C ${worktree.mainRepoDirectory} symbolic-ref --short HEAD\`\n- To find the base commit (the commit this worktree diverged from): \`git merge-base <base-branch> HEAD\`\nYou MUST read, write, and edit files only under the new worktree path ${worktree.worktreeDirectory}. You MUST NOT read, write, or edit any files under the main repo path ${worktree.mainRepoDirectory} — even though it is the same project, that folder is a separate checkout and the user or another agent may be actively working there, so writing to it would override their unrelated changes. Run all checks (tests, builds, lint) inside the new worktree. Do not create another worktree by default. Ask before merging changes back to the main branch.\n</system-reminder>`,
214
+ `<system-reminder>\nThis session is running inside a git worktree. The working directory (cwd / pwd) has changed. The user expects you to edit files in the new cwd. You MUST operate inside the new worktree from now on.\n- New worktree path (new cwd / pwd, edit files here): ${worktree.worktreeDirectory}\n- Branch: ${worktree.branch}\n- Main repo path (previous folder, DO NOT TOUCH): ${worktree.mainRepoDirectory}\n- To find the base branch (the branch this worktree was created from): \`git -C ${worktree.mainRepoDirectory} symbolic-ref --short HEAD\`\n- To find the base commit (the commit this worktree diverged from): \`git merge-base <base-branch> HEAD\`\nYou MUST read, write, and edit files only under the new worktree path ${worktree.worktreeDirectory}. You MUST NOT read, write, or edit any files under the main repo path ${worktree.mainRepoDirectory} — even though it is the same project, that folder is a separate checkout and the user or another agent may be actively working there, so writing to it would override their unrelated changes. Run all checks (tests, builds, lint) inside the new worktree. Do not create another worktree by default. To merge this worktree into the main branch, run \`kimaki merge-worktree\`. If it reports rebase conflicts, resolve them and rerun until it succeeds.\n</system-reminder>`,
215
215
  ]
216
216
  : []),
217
217
  ];
@@ -447,18 +447,30 @@ When the user specifies a time without a timezone, ask them to confirm their tim
447
447
 
448
448
  \`--wait\` is incompatible with \`--send-at\` because scheduled tasks run in the future.
449
449
 
450
- For scheduled tasks, use long and detailed prompts with goal, constraints, expected output format, and explicit completion criteria.
450
+ Keep scheduled task prompts **short**. The prompt text becomes the first message in the Discord thread, so long prompts clutter the channel. Instead of inlining the full task description in \`--prompt\`, write a markdown file in the project's \`tasks/\` folder and reference it:
451
451
 
452
- Notification prompts must be very detailed. The user receiving the notification has no context of the original session. Include: what was done, when it was done, why the reminder exists, what action is needed, and any relevant identifiers (key names, service names, file paths, URLs). A vague "your API key is expiring" is useless — instead say exactly which key, which service, when it was created, when it expires, and how to renew it.
452
+ \`\`\`bash
453
+ kimaki send --channel ${channelId} --prompt 'Read tasks/weekly-test-suite.md and follow instructions' --send-at '0 9 * * 1' --agent <current_agent>${parentSessionArg}${userArg}
454
+ \`\`\`
455
+
456
+ The task file should contain all the detail: goal, constraints, expected output, completion criteria. Use this frontmatter format:
457
+
458
+ \`\`\`yaml
459
+ ---
460
+ title: Weekly test suite
461
+ description: >
462
+ Managed by kimaki scheduled task. Do not move or delete this file
463
+ without also updating the kimaki task (kimaki task list / kimaki task edit).
464
+ ---
465
+ \`\`\`
466
+
467
+ For simple reminders and notifications (\`--notify-only\`), inline the prompt directly since there is no AI session to read files.
453
468
 
454
- Notification strategy for scheduled tasks:
469
+ Notification strategy:
455
470
  - NEVER use \`@username\` (e.g. \`@Tommy\`) directly in task prompts. The prompt text becomes the first message in the thread, so a raw \`@\` mention triggers an actual Discord ping every time the task fires. Instead, wrap it in inline code like \`\\\`@Tommy\\\`\`, or use Discord user ID mentions like \`<@USER_ID>\` only in the body of the prompt where the agent will process it, not in the opening line.
456
- - Prefer selective mentions in the prompt instead of relying on broad thread notifications.
457
- - If a task needs user attention, include this instruction in the prompt: "mention the user via Discord user ID when task requires user review or notification".
458
- - Without \`--user\`, there is no guaranteed direct user mention path; task output should mention users only when relevant.
459
- - With \`--user\`, the user is added to the thread and may receive more frequent thread-level notifications.
460
- - If a scheduled task completes with no actionable result and no user-visible change, prefer archiving the session after the final message so Discord does not keep a no-op thread highlighted.
461
- - Example no-op cleanup command: \`kimaki session archive ${archiveTarget}\`
471
+ - If a task needs user attention, add "mention the user via Discord user ID when task requires user review" in the task md file.
472
+ - With \`--user\`, the user is added to the thread and receives thread-level notifications.
473
+ - If a scheduled task completes with no actionable result, archive the session: \`kimaki session archive ${archiveTarget}\`
462
474
 
463
475
  Manage scheduled tasks with:
464
476
 
@@ -468,20 +480,22 @@ kimaki task delete <id>
468
480
 
469
481
  \`kimaki session list\` also shows if a session was started by a scheduled \`delay\` or \`cron\` task, including task ID when available.
470
482
 
483
+ **Never duplicate tasks to run more frequently.** If a task should run twice a day (morning and evening), edit the existing task's cron expression instead of creating a second task. Cron supports comma-separated hours:
484
+
485
+ \`\`\`bash
486
+ # runs at 9:00 UTC and 18:00 UTC every day
487
+ kimaki task edit <id> --send-at '0 9,18 * * *'
488
+ \`\`\`
489
+
471
490
  Use case patterns:
472
- - Reminder flows: create deadline reminders in this channel with one-time \`--send-at\`; mention only if action is required.
473
- - Proactive reminders: when you encounter time-sensitive information during your work (e.g. creating an API key that expires in 90 days, a certificate with an expiration date, a trial period ending, a deadline mentioned in code comments), proactively schedule a \`--notify-only\` reminder before the expiration so the user gets notified in time. For example, if you generate an API key expiring on 2026-06-01, schedule a reminder a few days before: \`kimaki send --channel ${channelId} --prompt 'Reminder: <@USER_ID> the API key created on 2026-03-01 expires on 2026-06-01. Renew it before it breaks production.' --send-at '2026-05-28T09:00:00Z' --notify-only --agent <current_agent>\`. Always tell the user you scheduled the reminder so they know.
474
- - Weekly QA: schedule "run full test suite, inspect failures, post summary, and mention the user via Discord ID only when failures require review".
475
- - Weekly benchmark automation: schedule a benchmark prompt that runs model evals, writes JSON outputs in the repo, commits results, and mentions only for regressions.
476
- - Recurring maintenance: use cron \`--send-at\` for repetitive tasks like rotating secrets, checking dependency updates, running security audits, or cleaning up stale branches. Example: \`--send-at "0 9 1 * *"\` to run on the 1st of every month.
477
- - Quiet no-op checks: if a recurring task checks something and finds nothing to report, let it post a brief final summary and then archive the session with \`kimaki session archive ${archiveTarget}\`. Example: a scheduled email triage run that finds no new emails should archive itself so it does not add noise to Discord.
478
- - Thread reminders: when the user says "remind me about this in 2 hours" (or any duration), use \`--send-at\` with \`--thread\` to resurface the current thread. Compute the future UTC time and send a mention so Discord shows a notification:
491
+ - Reminder flows: create deadline reminders with one-time \`--send-at\` and \`--notify-only\`; mention only if action is required.
492
+ - Proactive reminders: when you encounter time-sensitive information (API key expiration, certificate renewal, trial ending), schedule a \`--notify-only\` reminder before the deadline. Always tell the user you scheduled the reminder so they know.
493
+ - Weekly QA / recurring maintenance: write the full task spec in \`tasks/\` and schedule a short prompt pointing to it.
494
+ - Thread reminders: when the user says "remind me about this in 2 hours", use \`--send-at\` with \`--thread\` to resurface the current thread:
479
495
 
480
496
  kimaki send ${sendToSelfTarget} --prompt 'Reminder: <@USER_ID> you asked to be reminded about this thread.' --send-at '<future_UTC_time>' --notify-only --agent <current_agent>
481
497
 
482
- Replace \`<future_UTC_time>\` with the computed UTC ISO timestamp. The \`--notify-only\` flag creates just a notification message without starting a new AI session. The \`<@userId>\` mention ensures the user gets a Discord notification.
483
-
484
- Scheduled tasks can maintain project memory by reading and updating an md file in the repository (for example \`docs/automation-notes.md\`) on each run.
498
+ Replace \`<future_UTC_time>\` with the computed UTC ISO timestamp.
485
499
 
486
500
  Worktrees are useful for handing off parallel tasks that need to be isolated from each other (each session works on its own branch).
487
501
 
@@ -264,18 +264,30 @@ describe('system-message', () => {
264
264
 
265
265
  \`--wait\` is incompatible with \`--send-at\` because scheduled tasks run in the future.
266
266
 
267
- For scheduled tasks, use long and detailed prompts with goal, constraints, expected output format, and explicit completion criteria.
267
+ Keep scheduled task prompts **short**. The prompt text becomes the first message in the Discord thread, so long prompts clutter the channel. Instead of inlining the full task description in \`--prompt\`, write a markdown file in the project's \`tasks/\` folder and reference it:
268
268
 
269
- Notification prompts must be very detailed. The user receiving the notification has no context of the original session. Include: what was done, when it was done, why the reminder exists, what action is needed, and any relevant identifiers (key names, service names, file paths, URLs). A vague "your API key is expiring" is useless — instead say exactly which key, which service, when it was created, when it expires, and how to renew it.
269
+ \`\`\`bash
270
+ kimaki send --channel chan_123 --prompt 'Read tasks/weekly-test-suite.md and follow instructions' --send-at '0 9 * * 1' --agent <current_agent> --parent-session ses_123 --user '<discord-user-id>'
271
+ \`\`\`
272
+
273
+ The task file should contain all the detail: goal, constraints, expected output, completion criteria. Use this frontmatter format:
274
+
275
+ \`\`\`yaml
276
+ ---
277
+ title: Weekly test suite
278
+ description: >
279
+ Managed by kimaki scheduled task. Do not move or delete this file
280
+ without also updating the kimaki task (kimaki task list / kimaki task edit).
281
+ ---
282
+ \`\`\`
283
+
284
+ For simple reminders and notifications (\`--notify-only\`), inline the prompt directly since there is no AI session to read files.
270
285
 
271
- Notification strategy for scheduled tasks:
286
+ Notification strategy:
272
287
  - NEVER use \`@username\` (e.g. \`@Tommy\`) directly in task prompts. The prompt text becomes the first message in the thread, so a raw \`@\` mention triggers an actual Discord ping every time the task fires. Instead, wrap it in inline code like \`\\\`@Tommy\\\`\`, or use Discord user ID mentions like \`<@USER_ID>\` only in the body of the prompt where the agent will process it, not in the opening line.
273
- - Prefer selective mentions in the prompt instead of relying on broad thread notifications.
274
- - If a task needs user attention, include this instruction in the prompt: "mention the user via Discord user ID when task requires user review or notification".
275
- - Without \`--user\`, there is no guaranteed direct user mention path; task output should mention users only when relevant.
276
- - With \`--user\`, the user is added to the thread and may receive more frequent thread-level notifications.
277
- - If a scheduled task completes with no actionable result and no user-visible change, prefer archiving the session after the final message so Discord does not keep a no-op thread highlighted.
278
- - Example no-op cleanup command: \`kimaki session archive thread_123 (or --session ses_123)\`
288
+ - If a task needs user attention, add "mention the user via Discord user ID when task requires user review" in the task md file.
289
+ - With \`--user\`, the user is added to the thread and receives thread-level notifications.
290
+ - If a scheduled task completes with no actionable result, archive the session: \`kimaki session archive thread_123 (or --session ses_123)\`
279
291
 
280
292
  Manage scheduled tasks with:
281
293
 
@@ -285,20 +297,22 @@ describe('system-message', () => {
285
297
 
286
298
  \`kimaki session list\` also shows if a session was started by a scheduled \`delay\` or \`cron\` task, including task ID when available.
287
299
 
300
+ **Never duplicate tasks to run more frequently.** If a task should run twice a day (morning and evening), edit the existing task's cron expression instead of creating a second task. Cron supports comma-separated hours:
301
+
302
+ \`\`\`bash
303
+ # runs at 9:00 UTC and 18:00 UTC every day
304
+ kimaki task edit <id> --send-at '0 9,18 * * *'
305
+ \`\`\`
306
+
288
307
  Use case patterns:
289
- - Reminder flows: create deadline reminders in this channel with one-time \`--send-at\`; mention only if action is required.
290
- - Proactive reminders: when you encounter time-sensitive information during your work (e.g. creating an API key that expires in 90 days, a certificate with an expiration date, a trial period ending, a deadline mentioned in code comments), proactively schedule a \`--notify-only\` reminder before the expiration so the user gets notified in time. For example, if you generate an API key expiring on 2026-06-01, schedule a reminder a few days before: \`kimaki send --channel chan_123 --prompt 'Reminder: <@USER_ID> the API key created on 2026-03-01 expires on 2026-06-01. Renew it before it breaks production.' --send-at '2026-05-28T09:00:00Z' --notify-only --agent <current_agent>\`. Always tell the user you scheduled the reminder so they know.
291
- - Weekly QA: schedule "run full test suite, inspect failures, post summary, and mention the user via Discord ID only when failures require review".
292
- - Weekly benchmark automation: schedule a benchmark prompt that runs model evals, writes JSON outputs in the repo, commits results, and mentions only for regressions.
293
- - Recurring maintenance: use cron \`--send-at\` for repetitive tasks like rotating secrets, checking dependency updates, running security audits, or cleaning up stale branches. Example: \`--send-at "0 9 1 * *"\` to run on the 1st of every month.
294
- - Quiet no-op checks: if a recurring task checks something and finds nothing to report, let it post a brief final summary and then archive the session with \`kimaki session archive thread_123 (or --session ses_123)\`. Example: a scheduled email triage run that finds no new emails should archive itself so it does not add noise to Discord.
295
- - Thread reminders: when the user says "remind me about this in 2 hours" (or any duration), use \`--send-at\` with \`--thread\` to resurface the current thread. Compute the future UTC time and send a mention so Discord shows a notification:
308
+ - Reminder flows: create deadline reminders with one-time \`--send-at\` and \`--notify-only\`; mention only if action is required.
309
+ - Proactive reminders: when you encounter time-sensitive information (API key expiration, certificate renewal, trial ending), schedule a \`--notify-only\` reminder before the deadline. Always tell the user you scheduled the reminder so they know.
310
+ - Weekly QA / recurring maintenance: write the full task spec in \`tasks/\` and schedule a short prompt pointing to it.
311
+ - Thread reminders: when the user says "remind me about this in 2 hours", use \`--send-at\` with \`--thread\` to resurface the current thread:
296
312
 
297
313
  kimaki send --thread thread_123 (or --session ses_123) --prompt 'Reminder: <@USER_ID> you asked to be reminded about this thread.' --send-at '<future_UTC_time>' --notify-only --agent <current_agent>
298
314
 
299
- Replace \`<future_UTC_time>\` with the computed UTC ISO timestamp. The \`--notify-only\` flag creates just a notification message without starting a new AI session. The \`<@userId>\` mention ensures the user gets a Discord notification.
300
-
301
- Scheduled tasks can maintain project memory by reading and updating an md file in the repository (for example \`docs/automation-notes.md\`) on each run.
315
+ Replace \`<future_UTC_time>\` with the computed UTC ISO timestamp.
302
316
 
303
317
  Worktrees are useful for handing off parallel tasks that need to be isolated from each other (each session works on its own branch).
304
318
 
@@ -777,7 +791,7 @@ describe('system-message', () => {
777
791
  - Main repo path (previous folder, DO NOT TOUCH): /repo
778
792
  - To find the base branch (the branch this worktree was created from): \`git -C /repo symbolic-ref --short HEAD\`
779
793
  - To find the base commit (the commit this worktree diverged from): \`git merge-base <base-branch> HEAD\`
780
- You MUST read, write, and edit files only under the new worktree path /repo/.worktrees/prompt-cache. You MUST NOT read, write, or edit any files under the main repo path /repo — even though it is the same project, that folder is a separate checkout and the user or another agent may be actively working there, so writing to it would override their unrelated changes. Run all checks (tests, builds, lint) inside the new worktree. Do not create another worktree by default. Ask before merging changes back to the main branch.
794
+ You MUST read, write, and edit files only under the new worktree path /repo/.worktrees/prompt-cache. You MUST NOT read, write, or edit any files under the main repo path /repo — even though it is the same project, that folder is a separate checkout and the user or another agent may be actively working there, so writing to it would override their unrelated changes. Run all checks (tests, builds, lint) inside the new worktree. Do not create another worktree by default. To merge this worktree into the main branch, run \`kimaki merge-worktree\`. If it reports rebase conflicts, resolve them and rerun until it succeeds.
781
795
  </system-reminder>
782
796
  "
783
797
  `);
@@ -2,7 +2,7 @@
2
2
  import { Routes } from 'discord.js';
3
3
  import { createDiscordRest } from './discord-urls.js';
4
4
  import YAML from 'yaml';
5
- import { claimScheduledTaskRunning, getDuePlannedScheduledTasks, getScheduledTask, markScheduledTaskCronRescheduled, markScheduledTaskCronRetry, markScheduledTaskFailed, markScheduledTaskOneShotCompleted, recoverStaleRunningScheduledTasks, } from './database.js';
5
+ import { claimScheduledTaskRunning, getDuePlannedScheduledTasks, getScheduledTask, markScheduledTaskCronRescheduled, markScheduledTaskCronRetry, markScheduledTaskFailed, deleteScheduledTask, recoverStaleRunningScheduledTasks, } from './database.js';
6
6
  import { createLogger, formatErrorWithStack, LogPrefix } from './logger.js';
7
7
  import { notifyError } from './sentry.js';
8
8
  import { getNextCronRun, getPromptPreview, parseScheduledTaskPayload, } from './task-schedule.js';
@@ -23,7 +23,10 @@ async function executeThreadScheduledTask({ rest, task, payload, }) {
23
23
  const marker = {
24
24
  start: true,
25
25
  scheduledKind: task.schedule_kind,
26
- scheduledTaskId: task.id,
26
+ // Only include scheduledTaskId for cron tasks. One-shot tasks are deleted
27
+ // after execution, so the ID would be stale by the time the bot processes
28
+ // the Discord event and tries to insert a session_start_sources row.
29
+ ...(task.schedule_kind === 'cron' ? { scheduledTaskId: task.id } : {}),
27
30
  ...(payload.agent ? { agent: payload.agent } : {}),
28
31
  ...(payload.model ? { model: payload.model } : {}),
29
32
  ...(payload.username ? { username: payload.username } : {}),
@@ -59,7 +62,8 @@ async function executeChannelScheduledTask({ rest, task, payload, }) {
59
62
  : {
60
63
  start: true,
61
64
  scheduledKind: task.schedule_kind,
62
- scheduledTaskId: task.id,
65
+ // Only include scheduledTaskId for cron tasks (see thread variant comment)
66
+ ...(task.schedule_kind === 'cron' ? { scheduledTaskId: task.id } : {}),
63
67
  ...(payload.worktreeName ? { worktree: payload.worktreeName } : {}),
64
68
  ...(payload.cwd ? { cwd: payload.cwd } : {}),
65
69
  ...(payload.agent ? { agent: payload.agent } : {}),
@@ -149,7 +153,7 @@ async function executeScheduledTask({ rest, task, }) {
149
153
  }
150
154
  async function finalizeSuccessfulTask({ task, completedAt, }) {
151
155
  if (task.schedule_kind === 'at') {
152
- await markScheduledTaskOneShotCompleted({ taskId: task.id, completedAt });
156
+ await deleteScheduledTask(task.id);
153
157
  return;
154
158
  }
155
159
  if (!task.cron_expr) {
@@ -36,6 +36,7 @@ function normalizeWorktreeLifecycleText(text) {
36
36
  .replaceAll(CHANNEL_WORKTREE_NAME, 'CHANNEL_WORKTREE_NAME')
37
37
  .replaceAll(AUTO_WORKTREE_SUFFIX, 'AUTO_WORKTREE_NAME')
38
38
  .replaceAll(WORKTREE_NAME, 'WORKTREE_NAME')
39
+ .replace(/opencode\/kimaki-rply-wth-exctly-snd-at-wt-[a-z0-9]+/g, 'AUTO_WORKTREE_BRANCH')
39
40
  .replaceAll(WORKTREE_SUFFIX, 'SUFFIX')
40
41
  .replace(/ses_[a-zA-Z0-9]+/g, 'ses_TEST')
41
42
  .replace(/<#\d+>/g, '<#THREAD_ID>')
@@ -350,11 +351,24 @@ describe('worktree lifecycle', () => {
350
351
  expect(sourceSessionAfterFork).toBe(sessionBefore);
351
352
  const worktreeSession = await getThreadSession(worktreeThread.id);
352
353
  expect(worktreeSession).toBeTruthy();
354
+ if (!worktreeSession)
355
+ throw new Error('Worktree session was not persisted');
353
356
  expect(worktreeSession).not.toBe(sessionBefore);
354
357
  await expect(getThreadWorktreeOrWorkspace(thread.id)).resolves.toBeUndefined();
355
358
  const worktreeInfo = await getThreadWorktreeOrWorkspace(worktreeThread.id);
356
359
  expect(worktreeInfo?.status).toBe('ready');
357
360
  expect(worktreeInfo?.workspace_directory).toContain(WORKTREE_NAME);
361
+ const worktreeDirectory = worktreeInfo?.workspace_directory;
362
+ if (!worktreeDirectory)
363
+ throw new Error('Worktree directory was not persisted');
364
+ const getWorktreeClient = await initializeOpencodeForDirectory(worktreeDirectory);
365
+ if (getWorktreeClient instanceof Error)
366
+ throw getWorktreeClient;
367
+ const worktreeSessionResponse = await getWorktreeClient().session.get({
368
+ sessionID: worktreeSession,
369
+ directory: worktreeDirectory,
370
+ });
371
+ expect(worktreeSessionResponse.data?.directory).toBe(worktreeDirectory);
358
372
  const runtimeAfter = getRuntime(thread.id);
359
373
  expect(runtimeAfter).toBe(runtimeBefore);
360
374
  expect(runtimeAfter.sdkDirectory).toBe(directories.projectDirectory);
@@ -683,9 +697,9 @@ describe('worktree lifecycle', () => {
683
697
  » **kimaki-cli:**
684
698
  Reply with exactly: send-auto-wt-SUFFIX
685
699
  [embed]
686
- 🌳 **Worktree: opencode/kimaki-rply-wth-exctly-snd-at-wt-l8kct**
700
+ 🌳 **Worktree: AUTO_WORKTREE_BRANCH**
687
701
  📁 \`/tmp/worktrees/WORKTREE_NAME\`
688
- 🌿 Branch: \`opencode/kimaki-rply-wth-exctly-snd-at-wt-l8kct\`
702
+ 🌿 Branch: \`AUTO_WORKTREE_BRANCH\`
689
703
  *using deterministic-provider/deterministic-v2*
690
704
  ⬥ ok"
691
705
  `);
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "kimaki",
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
- "version": "0.22.0",
5
+ "version": "0.23.1",
6
6
  "repository": "https://github.com/remorses/kimaki",
7
7
  "bin": "bin.js",
8
8
  "exports": {
@@ -32,8 +32,8 @@
32
32
  "tsx": "^4.20.5",
33
33
  "undici": "^8.0.2",
34
34
  "discord-digital-twin": "^0.1.1",
35
- "opencode-deterministic-provider": "^0.0.1",
36
35
  "opencode-cached-provider": "^0.0.1",
36
+ "opencode-deterministic-provider": "^0.0.1",
37
37
  "db": "^0.0.0"
38
38
  },
39
39
  "dependencies": {
@@ -70,8 +70,8 @@
70
70
  "zod": "^4.3.6",
71
71
  "zustand": "^5.0.11",
72
72
  "errore": "^0.14.1",
73
- "traforo": "^0.7.1",
74
73
  "opencode-injection-guard": "^0.2.1",
74
+ "traforo": "^0.7.2",
75
75
  "libsqlproxy": "^0.1.0"
76
76
  },
77
77
  "optionalDependencies": {
@@ -93,6 +93,6 @@
93
93
  "register-commands": "tsx scripts/register-commands.ts",
94
94
  "lint": "lintcn lint",
95
95
  "format": "oxfmt src",
96
- "sync-skills": "mkdir -p skills && cp -R ../skills/. skills && tsx scripts/sync-skills.ts"
96
+ "sync-skills": "tsx scripts/sync-skills.ts"
97
97
  }
98
98
  }