kimaki 0.22.0 → 0.23.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 (47) 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 +6 -6
  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
package/src/database.ts CHANGED
@@ -193,10 +193,9 @@ export async function recoverStaleRunningScheduledTasks({ staleBefore }: { stale
193
193
  return countRows(rows)
194
194
  }
195
195
 
196
- export async function markScheduledTaskOneShotCompleted({ taskId, completedAt }: { taskId: number; completedAt: Date }) {
196
+ export async function deleteScheduledTask(taskId: number) {
197
197
  const db = await getDb()
198
- await db.update(schema.scheduled_tasks)
199
- .set({ status: 'completed', last_run_at: completedAt, running_started_at: null, last_error: null })
198
+ await db.delete(schema.scheduled_tasks)
200
199
  .where(orm.eq(schema.scheduled_tasks.id, taskId))
201
200
  }
202
201
 
@@ -762,20 +761,20 @@ export async function getAnyAudioApiKey(): Promise<{ provider: 'openai' | 'gemin
762
761
 
763
762
 
764
763
 
765
- export async function setChannelDirectory({ channelId, directory, channelType, skipIfExists = false }: { channelId: string; directory: string; channelType: DatabaseChannelType; skipIfExists?: boolean }) {
764
+ export async function setChannelDirectory({ channelId, directory, channelType, guildId, skipIfExists = false }: { channelId: string; directory: string; channelType: DatabaseChannelType; guildId?: string; skipIfExists?: boolean }) {
766
765
  const db = await getDb()
767
766
  if (skipIfExists) {
768
767
  await db.insert(schema.channel_directories)
769
- .values({ channel_id: channelId, directory, channel_type: channelType })
768
+ .values({ channel_id: channelId, directory, channel_type: channelType, guild_id: guildId })
770
769
  .onConflictDoNothing({ target: schema.channel_directories.channel_id })
771
770
  return
772
771
  }
773
772
  await db.insert(schema.channel_directories)
774
- .values({ channel_id: channelId, directory, channel_type: channelType })
775
- .onConflictDoUpdate({ target: schema.channel_directories.channel_id, set: { directory, channel_type: channelType } })
773
+ .values({ channel_id: channelId, directory, channel_type: channelType, guild_id: guildId })
774
+ .onConflictDoUpdate({ target: schema.channel_directories.channel_id, set: { directory, channel_type: channelType, guild_id: guildId } })
776
775
  }
777
776
 
778
- export async function findChannelsByDirectory({ directory, channelType }: { directory?: string; channelType?: DatabaseChannelType }): Promise<Array<{ channel_id: string; directory: string; channel_type: string }>> {
777
+ export async function findChannelsByDirectory({ directory, channelType }: { directory?: string; channelType?: DatabaseChannelType }): Promise<Array<{ channel_id: string; directory: string; channel_type: string; guild_id: string | null }>> {
779
778
  const db = await getDb()
780
779
  const where = directory && channelType
781
780
  ? { directory, channel_type: channelType }
@@ -784,7 +783,7 @@ export async function findChannelsByDirectory({ directory, channelType }: { dire
784
783
  : channelType
785
784
  ? { channel_type: channelType }
786
785
  : undefined
787
- return db.query.channel_directories.findMany({ where, columns: { channel_id: true, directory: true, channel_type: true } })
786
+ return db.query.channel_directories.findMany({ where, columns: { channel_id: true, directory: true, channel_type: true, guild_id: true } })
788
787
  }
789
788
 
790
789
  export async function getAllTextChannelDirectories() {
package/src/db.ts CHANGED
@@ -159,6 +159,7 @@ async function migrateSchema({
159
159
  "ALTER TABLE thread_sessions ADD COLUMN source TEXT DEFAULT 'kimaki'",
160
160
  'ALTER TABLE thread_sessions ADD COLUMN last_synced_name TEXT',
161
161
  'ALTER TABLE thread_sessions ADD COLUMN parent_session_id TEXT',
162
+ 'ALTER TABLE channel_directories ADD COLUMN guild_id TEXT',
162
163
  ]
163
164
  for (const stmt of alterStatements) {
164
165
  await client.execute(stmt).catch(() => undefined)
@@ -97,6 +97,7 @@ import { markDiscordGatewayReady, stopHranaServer } from './hrana-server.js'
97
97
  import { notifyError } from './sentry.js'
98
98
  import { flushDebouncedProcessCallbacks } from './debounced-process-flush.js'
99
99
  import { startRuntimeIdleSweeper } from './runtime-idle-sweeper.js'
100
+ import { getDefaultKimakiDirectory } from './channel-management.js'
100
101
  import { store } from './store.js'
101
102
  import {
102
103
  startExternalOpencodeSessionSync,
@@ -1455,6 +1456,17 @@ export async function startDiscordBot({
1455
1456
  // channel are disposed by their own ThreadDelete events from Discord.
1456
1457
  discordClient.on(Events.ChannelDelete, async (channel) => {
1457
1458
  try {
1459
+ // Check if this is the default kimaki channel. If so, preserve the
1460
+ // channel_directories row as a tombstone so we don't recreate it.
1461
+ const mapping = await getChannelDirectory(channel.id)
1462
+ const defaultDir = getDefaultKimakiDirectory()
1463
+ if (mapping && mapping.directory === defaultDir) {
1464
+ discordLogger.log(
1465
+ `Preserving channel_directories row for deleted default channel ${channel.id} as tombstone`,
1466
+ )
1467
+ return
1468
+ }
1469
+
1458
1470
  const deleted = await deleteChannelDirectoryById(channel.id)
1459
1471
  if (deleted) {
1460
1472
  discordLogger.log(
@@ -24,6 +24,11 @@ import { createLogger, LogPrefix } from './logger.js'
24
24
  import { ServerStartError, FetchError } from './errors.js'
25
25
  import { getLockPort } from './config.js'
26
26
  import { store } from './store.js'
27
+ // Circular import: opencode.ts → hrana-server.ts → opencode.ts.
28
+ // Safe because both sides only use lazy runtime function calls, never
29
+ // top-level initialization values. The cycle could be broken by moving
30
+ // the port into store.ts, but the current approach is simpler.
31
+ import { getOpencodeServerPort } from './opencode.js'
27
32
 
28
33
  const hranaLogger = createLogger(LogPrefix.DB)
29
34
 
@@ -175,6 +180,20 @@ export async function startHranaServer({
175
180
  res.end(JSON.stringify({ status: 'ok', pid: process.pid }))
176
181
  return
177
182
  }
183
+ // OpenCode server port discovery — no auth required (localhost only).
184
+ // CLI subcommands query this to reuse the bot's running OpenCode server
185
+ // instead of spawning a redundant second server process.
186
+ if (pathname === '/kimaki/opencode-port') {
187
+ const port = getOpencodeServerPort()
188
+ if (port === null) {
189
+ res.writeHead(404, { 'content-type': 'application/json' })
190
+ res.end(JSON.stringify({ error: 'no_opencode_server' }))
191
+ return
192
+ }
193
+ res.writeHead(200, { 'content-type': 'application/json' })
194
+ res.end(JSON.stringify({ port }))
195
+ return
196
+ }
178
197
  // Hrana routes: /v2, /v2/pipeline — require auth
179
198
  if (pathname === '/v2' || pathname === '/v2/pipeline') {
180
199
  if (!isAuthorizedRequest(req)) {
@@ -279,6 +279,28 @@ export function isTokenRefreshError(message: string) {
279
279
  )
280
280
  }
281
281
 
282
+ /** Permanent OAuth refresh death (invalid_grant / expired refresh). Remove account, do not rotate. */
283
+ export function isPermanentOAuthRefreshFailure(error: unknown): boolean {
284
+ const message =
285
+ typeof error === 'string'
286
+ ? error
287
+ : error instanceof Error
288
+ ? error.message
289
+ : ''
290
+ const haystack = message.toLowerCase()
291
+ if (!haystack) return false
292
+ if (haystack.includes('invalid_grant')) return true
293
+ if (haystack.includes('refresh token expired')) return true
294
+ if (haystack.includes('refresh_token') || haystack.includes('refresh token')) {
295
+ return (
296
+ haystack.includes('expired') ||
297
+ haystack.includes('invalid') ||
298
+ haystack.includes('revoked')
299
+ )
300
+ }
301
+ return false
302
+ }
303
+
282
304
  // --- Auth file helpers ---
283
305
 
284
306
  export function isOAuthStored(value: unknown): value is OAuthStored {
package/src/opencode.ts CHANGED
@@ -30,7 +30,10 @@ import {
30
30
  type PermissionRuleset,
31
31
  } from '@opencode-ai/sdk/v2'
32
32
 
33
- import { restartGlobalEventListener } from './session-handler/global-event-listener.js'
33
+ import {
34
+ restartGlobalEventListener,
35
+ waitForGlobalEventListener,
36
+ } from './session-handler/global-event-listener.js'
34
37
  import {
35
38
  getDataDir,
36
39
  getLockPort,
@@ -281,9 +284,12 @@ function buildStartupTimeoutReason({
281
284
  // Clients are created per-directory with the x-opencode-directory header.
282
285
 
283
286
  type SingleServer = {
284
- process: ChildProcess
287
+ process: ChildProcess | null
285
288
  port: number
286
289
  baseUrl: string
290
+ /** True when this server was discovered from the bot's hrana endpoint,
291
+ * not spawned by this process. We must not kill it on cleanup. */
292
+ discovered?: boolean
287
293
  }
288
294
 
289
295
  type ServerLifecycleEvent =
@@ -321,6 +327,11 @@ function killSingleServerProcessNow({
321
327
  return
322
328
  }
323
329
 
330
+ // Never kill a server we didn't spawn (discovered from another process)
331
+ if (singleServer.discovered || !singleServer.process) {
332
+ return
333
+ }
334
+
324
335
  const serverProcess = singleServer.process
325
336
  const pid = serverProcess.pid
326
337
  if (!pid || serverProcess.killed) {
@@ -559,22 +570,82 @@ function ensureOpencodeHomeDirectories({
559
570
  })
560
571
  }
561
572
 
573
+ /**
574
+ * Try to discover an OpenCode server already running in the bot process.
575
+ * Queries the hrana server on the lock port for the OpenCode server port,
576
+ * then verifies the server is healthy. Returns null if no server found.
577
+ */
578
+ async function discoverExistingServer(): Promise<SingleServer | null> {
579
+ const lockPort = getLockPort()
580
+ try {
581
+ const portResponse = await requestHealthcheck({
582
+ url: `http://127.0.0.1:${lockPort}/kimaki/opencode-port`,
583
+ timeoutMs: 2000,
584
+ })
585
+ if (portResponse.status !== 200) {
586
+ return null
587
+ }
588
+ const parsed = JSON.parse(portResponse.body)
589
+ const port = parsed?.port
590
+ if (typeof port !== 'number') {
591
+ return null
592
+ }
593
+
594
+ // Verify the OpenCode server is actually healthy
595
+ const healthResponse = await requestHealthcheck({
596
+ url: `http://127.0.0.1:${port}/api/health`,
597
+ timeoutMs: 2000,
598
+ })
599
+ if (healthResponse.status >= 500) {
600
+ return null
601
+ }
602
+
603
+ opencodeLogger.log(
604
+ `Discovered existing OpenCode server on port ${port} via hrana lock port ${lockPort}`,
605
+ )
606
+ return {
607
+ process: null,
608
+ port,
609
+ baseUrl: `http://127.0.0.1:${port}`,
610
+ discovered: true,
611
+ }
612
+ } catch {
613
+ // Connection refused or other network error — no bot running
614
+ return null
615
+ }
616
+ }
617
+
562
618
  async function ensureSingleServer({
563
619
  directory,
564
620
  }: {
565
621
  directory?: string
566
622
  } = {}): Promise<ServerStartError | SingleServer> {
567
623
  const startupDirectory = directory || preferredStartupDirectory || undefined
568
- if (singleServer && !singleServer.process.killed) {
624
+ if (singleServer && !singleServer.process?.killed) {
569
625
  return singleServer
570
626
  }
571
627
 
572
- // Deduplicate concurrent startup attempts
628
+ // Deduplicate concurrent startup attempts (covers both discovery and spawn)
573
629
  if (startingServer) {
574
630
  return startingServer
575
631
  }
576
632
 
577
- startingServer = startSingleServer({ directory: startupDirectory })
633
+ // Wrap discovery + spawn in a single shared promise so concurrent callers
634
+ // don't each run discoverExistingServer() and then each spawn a server.
635
+ startingServer = (async () => {
636
+ // Try to discover an already-running server from the bot process via
637
+ // the hrana server's /kimaki/opencode-port endpoint. This lets CLI
638
+ // subcommands (kimaki session list, archive, wait, etc.) reuse the
639
+ // bot's OpenCode server instead of spawning a redundant one.
640
+ const discovered = await discoverExistingServer()
641
+ if (discovered) {
642
+ singleServer = discovered
643
+ return discovered
644
+ }
645
+
646
+ return startSingleServer({ directory: startupDirectory })
647
+ })()
648
+
578
649
  try {
579
650
  return await startingServer
580
651
  } finally {
@@ -788,6 +859,7 @@ async function startSingleServer({
788
859
  OPENCODE_PORT: port.toString(),
789
860
  KIMAKI: '1',
790
861
  OPENCODE_EXPERIMENTAL_WORKSPACES: 'true',
862
+ OPENCODE_ENABLE_EXA: '1',
791
863
  KIMAKI_DATA_DIR: getDataDir(),
792
864
  KIMAKI_LOCK_PORT: getLockPort().toString(),
793
865
  KIMAKI_PARENT_LOCK_PORT: getLockPort().toString(),
@@ -1316,13 +1388,23 @@ export async function stopOpencodeServer(): Promise<boolean> {
1316
1388
  }
1317
1389
 
1318
1390
  const server = singleServer
1391
+
1392
+ // For discovered servers (from another process), just clear local state
1393
+ // without killing the process we don't own.
1394
+ if (server.discovered || !server.process) {
1395
+ singleServer = null
1396
+ clientCache.clear()
1397
+ serverRetryCount = 0
1398
+ return true
1399
+ }
1400
+
1319
1401
  opencodeLogger.log(
1320
1402
  `Stopping opencode server (pid: ${server.process.pid}, port: ${server.port})`,
1321
1403
  )
1322
1404
  if (!server.process.killed) {
1323
1405
  const killResult = errore.try(
1324
1406
  () => {
1325
- server.process.kill('SIGTERM')
1407
+ server.process!.kill('SIGTERM')
1326
1408
  },
1327
1409
  (error) => {
1328
1410
  return new Error('Failed to send SIGTERM to opencode server', {
@@ -1366,5 +1448,7 @@ export async function restartOpencodeServer(): Promise<OpenCodeErrors | true> {
1366
1448
 
1367
1449
  const result = await ensureSingleServer()
1368
1450
  if (result instanceof Error) return result
1451
+ restartGlobalEventListener()
1452
+ await waitForGlobalEventListener()
1369
1453
  return true
1370
1454
  }
package/src/schema.sql CHANGED
@@ -31,6 +31,7 @@ CREATE TABLE IF NOT EXISTS `channel_directories` (
31
31
  `channel_id` text PRIMARY KEY,
32
32
  `directory` text NOT NULL,
33
33
  `channel_type` text NOT NULL,
34
+ `guild_id` text,
34
35
  `created_at` datetime DEFAULT CURRENT_TIMESTAMP
35
36
  );
36
37
 
package/src/schema.ts CHANGED
@@ -69,6 +69,9 @@ export const channel_directories = sqliteCore.sqliteTable('channel_directories',
69
69
  channel_id: sqliteCore.text('channel_id').primaryKey().notNull(),
70
70
  directory: sqliteCore.text('directory').notNull(),
71
71
  channel_type: sqliteCore.text('channel_type', { enum: ['text', 'voice'] }).notNull(),
72
+ // Guild that owns this channel. Used to scope the default-channel tombstone
73
+ // check so multi-guild setups don't cross-block each other.
74
+ guild_id: sqliteCore.text('guild_id'),
72
75
  created_at: datetime('created_at').default(orm.sql`CURRENT_TIMESTAMP`),
73
76
  })
74
77
 
@@ -22,8 +22,18 @@ function isAbortError(err: unknown): boolean {
22
22
  return false
23
23
  }
24
24
 
25
- function delay(ms: number): Promise<void> {
26
- return new Promise((resolve) => setTimeout(resolve, ms))
25
+ function delay(ms: number, signal: AbortSignal): Promise<void> {
26
+ return new Promise((resolve) => {
27
+ if (signal.aborted) {
28
+ resolve()
29
+ return
30
+ }
31
+ const timeout = setTimeout(resolve, ms)
32
+ signal.addEventListener('abort', () => {
33
+ clearTimeout(timeout)
34
+ resolve()
35
+ }, { once: true })
36
+ })
27
37
  }
28
38
 
29
39
  // ── Types ──────────────────────────────────────────────────────
@@ -36,6 +46,8 @@ const callbacks = new Map<string, EventCallback>()
36
46
  let loopRunning = false
37
47
  let disposed = false
38
48
  let controller: AbortController | null = null
49
+ let connected = false
50
+ const connectionWaiters = new Set<() => void>()
39
51
 
40
52
  // ── Public API ─────────────────────────────────────────────────
41
53
 
@@ -70,6 +82,7 @@ export function unregisterEventListener(threadId: string): void {
70
82
  export function disposeGlobalEventListener(): void {
71
83
  disposed = true
72
84
  loopRunning = false
85
+ connected = false
73
86
  controller?.abort()
74
87
  controller = null
75
88
  callbacks.clear()
@@ -81,9 +94,19 @@ export function disposeGlobalEventListener(): void {
81
94
  */
82
95
  export function restartGlobalEventListener(): void {
83
96
  if (disposed) return
97
+ connected = false
84
98
  controller?.abort()
85
99
  }
86
100
 
101
+ /** Wait until the event stream is connected before starting event-producing work. */
102
+ export function waitForGlobalEventListener(): Promise<void> {
103
+ if (callbacks.size === 0 || connected) return Promise.resolve()
104
+ ensureListenerRunning()
105
+ return new Promise((resolve) => {
106
+ connectionWaiters.add(resolve)
107
+ })
108
+ }
109
+
87
110
  // ── Internals ──────────────────────────────────────────────────
88
111
 
89
112
  // Lazy subscription to opencode server lifecycle. Deferred to avoid
@@ -161,7 +184,7 @@ async function runEventLoop(): Promise<void> {
161
184
  logger.warn(
162
185
  `[GLOBAL LISTENER] No OpenCode server available, retrying in ${backoffMs}ms`,
163
186
  )
164
- await delay(backoffMs)
187
+ await delay(backoffMs, signal)
165
188
  backoffMs = Math.min(backoffMs * 2, maxBackoffMs)
166
189
  continue
167
190
  }
@@ -181,13 +204,16 @@ async function runEventLoop(): Promise<void> {
181
204
  `[GLOBAL LISTENER] Subscribe failed, retrying in ${backoffMs}ms:`,
182
205
  subscribeResult.message,
183
206
  )
184
- await delay(backoffMs)
207
+ await delay(backoffMs, signal)
185
208
  backoffMs = Math.min(backoffMs * 2, maxBackoffMs)
186
209
  continue
187
210
  }
188
211
 
189
212
  const events = subscribeResult.stream
190
213
 
214
+ connected = true
215
+ for (const resolve of connectionWaiters) resolve()
216
+ connectionWaiters.clear()
191
217
  logger.log('[GLOBAL LISTENER] Connected to global event stream')
192
218
 
193
219
  let receivedAnyEvent = false
@@ -199,6 +225,8 @@ async function runEventLoop(): Promise<void> {
199
225
  })()
200
226
  .catch((e) => new OpenCodeSdkError({ operation: 'event.iterate', cause: e }))
201
227
 
228
+ connected = false
229
+
202
230
  if (receivedAnyEvent) {
203
231
  backoffMs = 500
204
232
  }
@@ -213,13 +241,17 @@ async function runEventLoop(): Promise<void> {
213
241
  `[GLOBAL LISTENER] Stream broke, reconnecting in ${backoffMs}ms:`,
214
242
  iterResult.message,
215
243
  )
216
- await delay(backoffMs)
244
+ await delay(backoffMs, signal)
217
245
  backoffMs = Math.min(backoffMs * 2, maxBackoffMs)
218
246
  } else {
247
+ if (signal.aborted) {
248
+ backoffMs = 500
249
+ continue
250
+ }
219
251
  logger.log(
220
252
  `[GLOBAL LISTENER] Stream ended normally, reconnecting in ${backoffMs}ms`,
221
253
  )
222
- await delay(backoffMs)
254
+ await delay(backoffMs, signal)
223
255
  backoffMs = Math.min(backoffMs * 2, maxBackoffMs)
224
256
  }
225
257
  }
@@ -224,8 +224,11 @@ export function dequeueItem(threadId: string): QueuedMessage | undefined {
224
224
  return next
225
225
  }
226
226
 
227
- export function clearQueueItems(threadId: string): void {
227
+ export function clearQueueItems(threadId: string): QueuedMessage[] {
228
+ const items =
229
+ store.getState().threads.get(threadId)?.queueItems.slice() ?? []
228
230
  updateThread(threadId, (t) => ({ ...t, queueItems: [] }))
231
+ return items
229
232
  }
230
233
 
231
234
  export function removeQueueItemAtPosition(
@@ -34,6 +34,7 @@ import { isAbortError } from '../utils.js'
34
34
  import {
35
35
  registerEventListener,
36
36
  unregisterEventListener,
37
+ waitForGlobalEventListener,
37
38
  } from './global-event-listener.js'
38
39
  import { createLogger, LogPrefix } from '../logger.js'
39
40
  import {
@@ -2309,6 +2310,25 @@ export class ThreadSessionRuntime {
2309
2310
  repulseTyping: false,
2310
2311
  })
2311
2312
 
2313
+ // Skip footer if model produced no visible output (no text, no tool calls,
2314
+ // just step-start/step-finish lifecycle parts). This happens when the model
2315
+ // decides not to respond.
2316
+ const hasVisibleOutput = assistantMessageIds.some((msgId) => {
2317
+ const parts = this.getBufferedParts(msgId)
2318
+ return parts.some(
2319
+ (part) => part.type !== 'step-start' && part.type !== 'step-finish',
2320
+ )
2321
+ })
2322
+ if (!hasVisibleOutput) {
2323
+ this.stopTyping()
2324
+ this.resetPerRunState()
2325
+ this.clearBufferedPartsForMessages(assistantMessageIds)
2326
+ logger.log(
2327
+ `[ASSISTANT COMPLETED] no visible output, skipping footer for message ${completedMessageId} sessionId=${sessionId}`,
2328
+ )
2329
+ return
2330
+ }
2331
+
2312
2332
  this.stopTyping()
2313
2333
 
2314
2334
  const turnStartTime = getCurrentTurnStartTime({
@@ -3094,6 +3114,7 @@ export class ThreadSessionRuntime {
3094
3114
  ...variantField,
3095
3115
  ...(input.noReply ? { noReply: true } : {}),
3096
3116
  }
3117
+ await waitForGlobalEventListener()
3097
3118
  const promptResult = await getClient().session.promptAsync(request)
3098
3119
  .catch((e) => new OpenCodeSdkError({ operation: 'session.promptAsync', cause: e }))
3099
3120
  if (promptResult instanceof Error || promptResult.error) {
@@ -3482,9 +3503,9 @@ export class ThreadSessionRuntime {
3482
3503
  : NOTIFY_MESSAGE_FLAGS
3483
3504
  }
3484
3505
 
3485
- /** Clear all queued messages. */
3486
- clearQueue(): void {
3487
- threadState.clearQueueItems(this.threadId)
3506
+ /** Clear all queued messages. Returns the removed items. */
3507
+ clearQueue(): threadState.QueuedMessage[] {
3508
+ return threadState.clearQueueItems(this.threadId)
3488
3509
  }
3489
3510
 
3490
3511
  /** Remove a queued message by its 1-based position. */
@@ -3972,6 +3993,7 @@ export class ThreadSessionRuntime {
3972
3993
  return
3973
3994
  }
3974
3995
 
3996
+ await waitForGlobalEventListener()
3975
3997
  const promptResponse = await getClient().session.promptAsync({
3976
3998
  sessionID: session.id,
3977
3999
  directory: this.sdkDirectory,
@@ -282,18 +282,30 @@ describe('system-message', () => {
282
282
 
283
283
  \`--wait\` is incompatible with \`--send-at\` because scheduled tasks run in the future.
284
284
 
285
- For scheduled tasks, use long and detailed prompts with goal, constraints, expected output format, and explicit completion criteria.
285
+ 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:
286
286
 
287
- 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.
287
+ \`\`\`bash
288
+ 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>'
289
+ \`\`\`
290
+
291
+ The task file should contain all the detail: goal, constraints, expected output, completion criteria. Use this frontmatter format:
292
+
293
+ \`\`\`yaml
294
+ ---
295
+ title: Weekly test suite
296
+ description: >
297
+ Managed by kimaki scheduled task. Do not move or delete this file
298
+ without also updating the kimaki task (kimaki task list / kimaki task edit).
299
+ ---
300
+ \`\`\`
301
+
302
+ For simple reminders and notifications (\`--notify-only\`), inline the prompt directly since there is no AI session to read files.
288
303
 
289
- Notification strategy for scheduled tasks:
304
+ Notification strategy:
290
305
  - 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.
291
- - Prefer selective mentions in the prompt instead of relying on broad thread notifications.
292
- - 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".
293
- - Without \`--user\`, there is no guaranteed direct user mention path; task output should mention users only when relevant.
294
- - With \`--user\`, the user is added to the thread and may receive more frequent thread-level notifications.
295
- - 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.
296
- - Example no-op cleanup command: \`kimaki session archive thread_123 (or --session ses_123)\`
306
+ - If a task needs user attention, add "mention the user via Discord user ID when task requires user review" in the task md file.
307
+ - With \`--user\`, the user is added to the thread and receives thread-level notifications.
308
+ - If a scheduled task completes with no actionable result, archive the session: \`kimaki session archive thread_123 (or --session ses_123)\`
297
309
 
298
310
  Manage scheduled tasks with:
299
311
 
@@ -303,20 +315,22 @@ describe('system-message', () => {
303
315
 
304
316
  \`kimaki session list\` also shows if a session was started by a scheduled \`delay\` or \`cron\` task, including task ID when available.
305
317
 
318
+ **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:
319
+
320
+ \`\`\`bash
321
+ # runs at 9:00 UTC and 18:00 UTC every day
322
+ kimaki task edit <id> --send-at '0 9,18 * * *'
323
+ \`\`\`
324
+
306
325
  Use case patterns:
307
- - Reminder flows: create deadline reminders in this channel with one-time \`--send-at\`; mention only if action is required.
308
- - 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.
309
- - Weekly QA: schedule "run full test suite, inspect failures, post summary, and mention the user via Discord ID only when failures require review".
310
- - Weekly benchmark automation: schedule a benchmark prompt that runs model evals, writes JSON outputs in the repo, commits results, and mentions only for regressions.
311
- - 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.
312
- - 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.
313
- - 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:
326
+ - Reminder flows: create deadline reminders with one-time \`--send-at\` and \`--notify-only\`; mention only if action is required.
327
+ - 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.
328
+ - Weekly QA / recurring maintenance: write the full task spec in \`tasks/\` and schedule a short prompt pointing to it.
329
+ - Thread reminders: when the user says "remind me about this in 2 hours", use \`--send-at\` with \`--thread\` to resurface the current thread:
314
330
 
315
331
  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>
316
332
 
317
- 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.
318
-
319
- 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.
333
+ Replace \`<future_UTC_time>\` with the computed UTC ISO timestamp.
320
334
 
321
335
  Worktrees are useful for handing off parallel tasks that need to be isolated from each other (each session works on its own branch).
322
336
 
@@ -798,7 +812,7 @@ describe('system-message', () => {
798
812
  - Main repo path (previous folder, DO NOT TOUCH): /repo
799
813
  - To find the base branch (the branch this worktree was created from): \`git -C /repo symbolic-ref --short HEAD\`
800
814
  - To find the base commit (the commit this worktree diverged from): \`git merge-base <base-branch> HEAD\`
801
- 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.
815
+ 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.
802
816
  </system-reminder>
803
817
  "
804
818
  `)