kimaki 0.4.78 → 0.4.80

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 (90) hide show
  1. package/dist/anthropic-auth-plugin.js +628 -0
  2. package/dist/channel-management.js +2 -2
  3. package/dist/cli.js +316 -129
  4. package/dist/commands/action-buttons.js +1 -1
  5. package/dist/commands/login.js +634 -277
  6. package/dist/commands/model.js +91 -6
  7. package/dist/commands/paginated-select.js +57 -0
  8. package/dist/commands/resume.js +2 -2
  9. package/dist/commands/tasks.js +205 -0
  10. package/dist/commands/undo-redo.js +80 -18
  11. package/dist/context-awareness-plugin.js +347 -0
  12. package/dist/database.js +103 -7
  13. package/dist/db.js +39 -1
  14. package/dist/discord-bot.js +42 -19
  15. package/dist/discord-urls.js +11 -0
  16. package/dist/discord-ws-proxy.js +350 -0
  17. package/dist/discord-ws-proxy.test.js +500 -0
  18. package/dist/errors.js +1 -1
  19. package/dist/gateway-session.js +163 -0
  20. package/dist/hrana-server.js +114 -4
  21. package/dist/interaction-handler.js +30 -7
  22. package/dist/ipc-tools-plugin.js +186 -0
  23. package/dist/message-preprocessing.js +56 -11
  24. package/dist/onboarding-welcome.js +1 -1
  25. package/dist/opencode-interrupt-plugin.js +133 -75
  26. package/dist/opencode-plugin.js +12 -389
  27. package/dist/opencode.js +59 -5
  28. package/dist/parse-permission-rules.test.js +117 -0
  29. package/dist/queue-drain-after-interactive-ui.e2e.test.js +119 -0
  30. package/dist/session-handler/thread-session-runtime.js +68 -29
  31. package/dist/startup-time.e2e.test.js +295 -0
  32. package/dist/store.js +1 -0
  33. package/dist/system-message.js +3 -1
  34. package/dist/task-runner.js +7 -3
  35. package/dist/task-schedule.js +12 -0
  36. package/dist/thread-message-queue.e2e.test.js +13 -1
  37. package/dist/undo-redo.e2e.test.js +166 -0
  38. package/dist/utils.js +4 -1
  39. package/dist/voice-attachment.js +34 -0
  40. package/dist/voice-handler.js +11 -9
  41. package/dist/voice-message.e2e.test.js +78 -0
  42. package/dist/voice.test.js +31 -0
  43. package/package.json +12 -7
  44. package/skills/egaki/SKILL.md +80 -15
  45. package/skills/errore/SKILL.md +13 -0
  46. package/skills/lintcn/SKILL.md +749 -0
  47. package/skills/npm-package/SKILL.md +17 -3
  48. package/skills/spiceflow/SKILL.md +14 -0
  49. package/skills/zele/SKILL.md +9 -0
  50. package/src/anthropic-auth-plugin.ts +732 -0
  51. package/src/channel-management.ts +2 -2
  52. package/src/cli.ts +354 -132
  53. package/src/commands/action-buttons.ts +1 -0
  54. package/src/commands/login.ts +836 -337
  55. package/src/commands/model.ts +102 -7
  56. package/src/commands/paginated-select.ts +81 -0
  57. package/src/commands/resume.ts +6 -1
  58. package/src/commands/tasks.ts +293 -0
  59. package/src/commands/undo-redo.ts +87 -20
  60. package/src/context-awareness-plugin.ts +469 -0
  61. package/src/database.ts +138 -7
  62. package/src/db.ts +40 -1
  63. package/src/discord-bot.ts +46 -19
  64. package/src/discord-urls.ts +12 -0
  65. package/src/errors.ts +1 -1
  66. package/src/hrana-server.ts +124 -3
  67. package/src/interaction-handler.ts +41 -9
  68. package/src/ipc-tools-plugin.ts +228 -0
  69. package/src/message-preprocessing.ts +82 -11
  70. package/src/onboarding-welcome.ts +1 -1
  71. package/src/opencode-interrupt-plugin.ts +164 -91
  72. package/src/opencode-plugin.ts +13 -483
  73. package/src/opencode.ts +60 -5
  74. package/src/parse-permission-rules.test.ts +127 -0
  75. package/src/queue-drain-after-interactive-ui.e2e.test.ts +151 -0
  76. package/src/session-handler/thread-runtime-state.ts +4 -1
  77. package/src/session-handler/thread-session-runtime.ts +82 -20
  78. package/src/startup-time.e2e.test.ts +372 -0
  79. package/src/store.ts +8 -0
  80. package/src/system-message.ts +10 -1
  81. package/src/task-runner.ts +9 -22
  82. package/src/task-schedule.ts +15 -0
  83. package/src/thread-message-queue.e2e.test.ts +14 -1
  84. package/src/undo-redo.e2e.test.ts +207 -0
  85. package/src/utils.ts +7 -0
  86. package/src/voice-attachment.ts +51 -0
  87. package/src/voice-handler.ts +15 -7
  88. package/src/voice-message.e2e.test.ts +95 -0
  89. package/src/voice.test.ts +36 -0
  90. package/src/onboarding-tutorial-plugin.ts +0 -93
@@ -0,0 +1,372 @@
1
+ // Measures time-to-ready for the kimaki Discord bot startup.
2
+ // Used as a baseline to track startup performance and guide optimizations
3
+ // for scale-to-zero deployments where cold start time is critical.
4
+ //
5
+ // Measures each phase independently:
6
+ // 1. Hrana server start (DB + lock port)
7
+ // 2. Database init (Prisma connect via HTTP)
8
+ // 3. Discord.js client creation + login (Gateway READY)
9
+ // 4. startDiscordBot (event handlers + markDiscordGatewayReady)
10
+ // 5. OpenCode server startup (spawn + health poll)
11
+ // 6. Total wall-clock time from zero to "bot ready"
12
+ //
13
+ // Uses discord-digital-twin so Gateway READY is instant (no real Discord).
14
+ // OpenCode startup uses deterministic provider (no real LLM).
15
+
16
+ import fs from 'node:fs'
17
+ import path from 'node:path'
18
+ import url from 'node:url'
19
+ import { describe, test, expect, afterAll } from 'vitest'
20
+ import { ChannelType, Client, GatewayIntentBits, Partials } from 'discord.js'
21
+ import { DigitalDiscord } from 'discord-digital-twin/src'
22
+ import {
23
+ buildDeterministicOpencodeConfig,
24
+ type DeterministicMatcher,
25
+ } from 'opencode-deterministic-provider'
26
+ import { setDataDir } from './config.js'
27
+ import { startDiscordBot } from './discord-bot.js'
28
+ import {
29
+ setBotToken,
30
+ initDatabase,
31
+ closeDatabase,
32
+ setChannelDirectory,
33
+ } from './database.js'
34
+ import { startHranaServer, stopHranaServer } from './hrana-server.js'
35
+ import { initializeOpencodeForDirectory, stopOpencodeServer } from './opencode.js'
36
+ import { chooseLockPort, cleanupTestSessions } from './test-utils.js'
37
+
38
+ interface PhaseTimings {
39
+ hranaServerMs: number
40
+ databaseInitMs: number
41
+ discordLoginMs: number
42
+ startDiscordBotMs: number
43
+ opencodeServerMs: number
44
+ totalMs: number
45
+ }
46
+
47
+ function createRunDirectories() {
48
+ const root = path.resolve(process.cwd(), 'tmp', 'startup-time-e2e')
49
+ fs.mkdirSync(root, { recursive: true })
50
+
51
+ const dataDir = fs.mkdtempSync(path.join(root, 'data-'))
52
+ const projectDirectory = path.join(root, 'project')
53
+ fs.mkdirSync(projectDirectory, { recursive: true })
54
+
55
+ return { root, dataDir, projectDirectory }
56
+ }
57
+
58
+ function createDiscordJsClient({ restUrl }: { restUrl: string }) {
59
+ return new Client({
60
+ intents: [
61
+ GatewayIntentBits.Guilds,
62
+ GatewayIntentBits.GuildMessages,
63
+ GatewayIntentBits.MessageContent,
64
+ GatewayIntentBits.GuildVoiceStates,
65
+ ],
66
+ partials: [
67
+ Partials.Channel,
68
+ Partials.Message,
69
+ Partials.User,
70
+ Partials.ThreadMember,
71
+ ],
72
+ rest: {
73
+ api: restUrl,
74
+ version: '10',
75
+ },
76
+ })
77
+ }
78
+
79
+ function createMinimalMatchers(): DeterministicMatcher[] {
80
+ return [
81
+ {
82
+ id: 'startup-test-reply',
83
+ priority: 10,
84
+ when: {
85
+ lastMessageRole: 'user',
86
+ rawPromptIncludes: 'startup-test',
87
+ },
88
+ then: {
89
+ parts: [
90
+ { type: 'stream-start', warnings: [] },
91
+ { type: 'text-start', id: 'startup-reply' },
92
+ { type: 'text-delta', id: 'startup-reply', delta: 'ok' },
93
+ { type: 'text-end', id: 'startup-reply' },
94
+ {
95
+ type: 'finish',
96
+ finishReason: 'stop',
97
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
98
+ },
99
+ ],
100
+ },
101
+ },
102
+ ]
103
+ }
104
+
105
+ const TEST_USER_ID = '900000000000000777'
106
+ const TEXT_CHANNEL_ID = '900000000000000778'
107
+
108
+ describe('startup time measurement', () => {
109
+ let directories: ReturnType<typeof createRunDirectories>
110
+ let discord: DigitalDiscord
111
+ let botClient: Client | null = null
112
+ const testStartTime = Date.now()
113
+
114
+ afterAll(async () => {
115
+ if (directories) {
116
+ await cleanupTestSessions({
117
+ projectDirectory: directories.projectDirectory,
118
+ testStartTime,
119
+ })
120
+ }
121
+
122
+ if (botClient) {
123
+ botClient.destroy()
124
+ }
125
+
126
+ await Promise.all([
127
+ stopOpencodeServer().catch(() => {}),
128
+ closeDatabase().catch(() => {}),
129
+ stopHranaServer().catch(() => {}),
130
+ discord?.stop().catch(() => {}),
131
+ ])
132
+
133
+ delete process.env['KIMAKI_LOCK_PORT']
134
+ delete process.env['KIMAKI_DB_URL']
135
+
136
+ if (directories) {
137
+ fs.rmSync(directories.dataDir, { recursive: true, force: true })
138
+ }
139
+ })
140
+
141
+ test('measures per-phase startup timings', async () => {
142
+ directories = createRunDirectories()
143
+ const lockPort = chooseLockPort({ key: 'startup-time-e2e' })
144
+
145
+ process.env['KIMAKI_LOCK_PORT'] = String(lockPort)
146
+ setDataDir(directories.dataDir)
147
+
148
+ const digitalDiscordDbPath = path.join(
149
+ directories.dataDir,
150
+ 'digital-discord.db',
151
+ )
152
+
153
+ discord = new DigitalDiscord({
154
+ guild: {
155
+ name: 'Startup Time Guild',
156
+ ownerId: TEST_USER_ID,
157
+ },
158
+ channels: [
159
+ {
160
+ id: TEXT_CHANNEL_ID,
161
+ name: 'startup-time',
162
+ type: ChannelType.GuildText,
163
+ },
164
+ ],
165
+ users: [
166
+ {
167
+ id: TEST_USER_ID,
168
+ username: 'startup-tester',
169
+ },
170
+ ],
171
+ dbUrl: `file:${digitalDiscordDbPath}`,
172
+ })
173
+
174
+ await discord.start()
175
+
176
+ // Write deterministic opencode config
177
+ const providerNpm = url
178
+ .pathToFileURL(
179
+ path.resolve(
180
+ process.cwd(),
181
+ '..',
182
+ 'opencode-deterministic-provider',
183
+ 'src',
184
+ 'index.ts',
185
+ ),
186
+ )
187
+ .toString()
188
+
189
+ const opencodeConfig = buildDeterministicOpencodeConfig({
190
+ providerName: 'deterministic-provider',
191
+ providerNpm,
192
+ model: 'deterministic-v2',
193
+ smallModel: 'deterministic-v2',
194
+ settings: {
195
+ strict: false,
196
+ matchers: createMinimalMatchers(),
197
+ },
198
+ })
199
+ fs.writeFileSync(
200
+ path.join(directories.projectDirectory, 'opencode.json'),
201
+ JSON.stringify(opencodeConfig, null, 2),
202
+ )
203
+
204
+ // ── Phase timings ──
205
+ const totalStart = performance.now()
206
+
207
+ // Phase 1: Hrana server
208
+ const hranaStart = performance.now()
209
+ const dbPath = path.join(directories.dataDir, 'discord-sessions.db')
210
+ const hranaResult = await startHranaServer({ dbPath })
211
+ if (hranaResult instanceof Error) {
212
+ throw hranaResult
213
+ }
214
+ process.env['KIMAKI_DB_URL'] = hranaResult
215
+ const hranaMs = performance.now() - hranaStart
216
+
217
+ // Phase 2: Database init
218
+ const dbStart = performance.now()
219
+ await initDatabase()
220
+ await setBotToken(discord.botUserId, discord.botToken)
221
+ await setChannelDirectory({
222
+ channelId: TEXT_CHANNEL_ID,
223
+ directory: directories.projectDirectory,
224
+ channelType: 'text',
225
+ })
226
+ const dbMs = performance.now() - dbStart
227
+
228
+ // Phase 3+4: Discord.js login + startDiscordBot
229
+ // In the real cli.ts flow, login happens first (line 2077), then
230
+ // startDiscordBot is called with the already-logged-in client (line 2130).
231
+ // startDiscordBot calls login() again internally (line 1069) which is
232
+ // a no-op on already-connected clients. We measure them together since
233
+ // that's the real critical path.
234
+ const loginStart = performance.now()
235
+ botClient = createDiscordJsClient({ restUrl: discord.restUrl })
236
+ // Don't pre-login — let startDiscordBot handle login internally.
237
+ // This avoids the double-login overhead that inflates measurements.
238
+ const loginMs = Math.round(performance.now() - loginStart)
239
+
240
+ const botStart = performance.now()
241
+ await startDiscordBot({
242
+ token: discord.botToken,
243
+ appId: discord.botUserId,
244
+ discordClient: botClient,
245
+ })
246
+ const botMs = performance.now() - botStart
247
+
248
+ // Phase 5: OpenCode server startup (biggest bottleneck)
249
+ const opencodeStart = performance.now()
250
+ const opencodeResult = await initializeOpencodeForDirectory(
251
+ directories.projectDirectory,
252
+ )
253
+ if (opencodeResult instanceof Error) {
254
+ throw opencodeResult
255
+ }
256
+ const opencodeMs = performance.now() - opencodeStart
257
+
258
+ const totalMs = performance.now() - totalStart
259
+
260
+ const timings: PhaseTimings = {
261
+ hranaServerMs: Math.round(hranaMs),
262
+ databaseInitMs: Math.round(dbMs),
263
+ discordLoginMs: Math.round(loginMs),
264
+ startDiscordBotMs: Math.round(botMs),
265
+ opencodeServerMs: Math.round(opencodeMs),
266
+ totalMs: Math.round(totalMs),
267
+ }
268
+
269
+ // Print timings for CI/local visibility
270
+ console.log('\n┌─────────────────────────────────────────────┐')
271
+ console.log('│ Kimaki Startup Time Breakdown │')
272
+ console.log('├─────────────────────────────────────────────┤')
273
+ console.log(`│ Hrana server: ${String(timings.hranaServerMs).padStart(6)} ms │`)
274
+ console.log(`│ Database init: ${String(timings.databaseInitMs).padStart(6)} ms │`)
275
+ console.log(`│ Discord.js login: ${String(timings.discordLoginMs).padStart(6)} ms │`)
276
+ console.log(`│ startDiscordBot: ${String(timings.startDiscordBotMs).padStart(6)} ms │`)
277
+ console.log(`│ OpenCode server: ${String(timings.opencodeServerMs).padStart(6)} ms │`)
278
+ console.log('├─────────────────────────────────────────────┤')
279
+ console.log(`│ TOTAL: ${String(timings.totalMs).padStart(6)} ms │`)
280
+ console.log('└─────────────────────────────────────────────┘\n')
281
+
282
+ // Sanity assertions — these are baselines, not targets yet.
283
+ // Each phase should complete (no infinite hang).
284
+ expect(timings.hranaServerMs).toBeLessThan(5_000)
285
+ expect(timings.databaseInitMs).toBeLessThan(5_000)
286
+ expect(timings.discordLoginMs).toBeLessThan(10_000)
287
+ expect(timings.startDiscordBotMs).toBeLessThan(5_000)
288
+ expect(timings.opencodeServerMs).toBeLessThan(30_000)
289
+ expect(timings.totalMs).toBeLessThan(60_000)
290
+
291
+ // Verify the bot is actually functional by sending a message
292
+ // and getting a response (validates the full pipeline works)
293
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
294
+ content: 'startup-test ping',
295
+ })
296
+
297
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
298
+ timeout: 10_000,
299
+ })
300
+
301
+ const reply = await discord.thread(thread.id).waitForBotReply({
302
+ timeout: 30_000,
303
+ })
304
+
305
+ expect(reply.content.length).toBeGreaterThan(0)
306
+ expect(thread.id.length).toBeGreaterThan(0)
307
+ }, 120_000)
308
+
309
+ test('measures parallel startup (discord + opencode simultaneously)', async () => {
310
+ // This test reuses the infrastructure from test 1 (hrana, db already up)
311
+ // to measure what happens when we run Discord login + OpenCode in parallel.
312
+ // In a fresh cold start, hrana+db init would add ~50ms on top.
313
+
314
+ // Stop opencode server from test 1 so we get a fresh measurement
315
+ await stopOpencodeServer().catch(() => {})
316
+
317
+ // Destroy and recreate bot client for a clean login measurement
318
+ if (botClient) {
319
+ botClient.destroy()
320
+ botClient = null
321
+ }
322
+
323
+ // ── Parallel phase: Discord login + OpenCode server simultaneously ──
324
+ const parallelStart = performance.now()
325
+
326
+ const [discordResult, opencodeResult] = await Promise.all([
327
+ // Discord path: create client, login, start bot
328
+ (async () => {
329
+ const loginStart = performance.now()
330
+ const client = createDiscordJsClient({ restUrl: discord.restUrl })
331
+ await startDiscordBot({
332
+ token: discord.botToken,
333
+ appId: discord.botUserId,
334
+ discordClient: client,
335
+ })
336
+ return {
337
+ client,
338
+ totalMs: Math.round(performance.now() - loginStart),
339
+ }
340
+ })(),
341
+ // OpenCode path: spawn server + wait for health
342
+ (async () => {
343
+ const start = performance.now()
344
+ const result = await initializeOpencodeForDirectory(
345
+ directories.projectDirectory,
346
+ )
347
+ if (result instanceof Error) {
348
+ throw result
349
+ }
350
+ return { ms: Math.round(performance.now() - start) }
351
+ })(),
352
+ ])
353
+
354
+ const parallelMs = Math.round(performance.now() - parallelStart)
355
+ botClient = discordResult.client
356
+
357
+ console.log('\n┌─────────────────────────────────────────────┐')
358
+ console.log('│ Parallel Startup Time Breakdown │')
359
+ console.log('├─────────────────────────────────────────────┤')
360
+ console.log(`│ Discord login+bot: ${String(discordResult.totalMs).padStart(6)} ms │`)
361
+ console.log(`│ OpenCode server: ${String(opencodeResult.ms).padStart(6)} ms │`)
362
+ console.log('├─────────────────────────────────────────────┤')
363
+ console.log(`│ PARALLEL TOTAL: ${String(parallelMs).padStart(6)} ms │`)
364
+ console.log(`│ (vs sequential: ${String(discordResult.totalMs + opencodeResult.ms).padStart(6)} ms) │`)
365
+ console.log('└─────────────────────────────────────────────┘\n')
366
+
367
+ // Parallel total should be dominated by the slower path,
368
+ // not the sum of both.
369
+ const maxSingle = Math.max(discordResult.totalMs, opencodeResult.ms)
370
+ expect(parallelMs).toBeLessThan(maxSingle + 500)
371
+ }, 120_000)
372
+ })
package/src/store.ts CHANGED
@@ -70,6 +70,13 @@ export type KimakiState = {
70
70
  // Read by: discord-urls.ts (getDiscordRestApiUrl), REST client construction.
71
71
  discordBaseUrl: string
72
72
 
73
+ // Service auth token (client_id:client_secret) used to authenticate
74
+ // control-plane requests like /kimaki/wake. Always set at startup in all
75
+ // modes so localhost and internet paths share one auth model.
76
+ // Changes: set in cli.ts after credential resolution and persisted in sqlite.
77
+ // Read by: hrana-server.ts to validate Authorization bearer token.
78
+ gatewayToken: string | null
79
+
73
80
  // User-defined slash commands registered with Discord, populated after
74
81
  // registerCommands() completes during startup. Maps sanitized Discord
75
82
  // command names back to original OpenCode command names.
@@ -105,6 +112,7 @@ export const store = createStore<KimakiState>(() => ({
105
112
  critiqueEnabled: true,
106
113
  verboseOpencodeServer: false,
107
114
  discordBaseUrl: 'https://discord.com',
115
+ gatewayToken: null,
108
116
  registeredUserCommands: [],
109
117
  threads: new Map(),
110
118
  test: { deterministicTranscription: null },
@@ -206,6 +206,13 @@ export type ThreadStartMarker = {
206
206
  scheduledKind?: 'at' | 'cron'
207
207
  /** Scheduled task ID that triggered this message */
208
208
  scheduledTaskId?: number
209
+ /**
210
+ * Per-session permission overrides as raw "tool:action" or "tool:pattern:action"
211
+ * strings. Parsed into PermissionRuleset entries by parsePermissionRules() in
212
+ * opencode.ts and appended after buildSessionPermissions() so they win via
213
+ * opencode's findLast() evaluation.
214
+ */
215
+ permissions?: string[]
209
216
  }
210
217
 
211
218
  export type AgentInfo = {
@@ -363,7 +370,8 @@ Use \`--send-at\` to schedule a one-time or recurring task:
363
370
  kimaki send --channel ${channelId} --prompt "Reminder: review open PRs" --send-at "2026-03-01T09:00:00Z"
364
371
  kimaki send --channel ${channelId} --prompt "Run weekly test suite and summarize failures" --send-at "0 9 * * 1"
365
372
 
366
- When using a date for \`--send-at\`, it must be UTC in ISO format ending with \`Z\`.
373
+ 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).
374
+ When the user specifies a time without a timezone, ask them to confirm their timezone or the UTC equivalent. Never guess the user's timezone.
367
375
 
368
376
  \`--send-at\` supports the same useful options for new threads:
369
377
  - \`--notify-only\` to create a reminder thread without auto-starting a session
@@ -387,6 +395,7 @@ Notification strategy for scheduled tasks:
387
395
  Manage scheduled tasks with:
388
396
 
389
397
  kimaki task list
398
+ kimaki task edit <id> --prompt "new prompt" [--send-at "new schedule"]
390
399
  kimaki task delete <id>
391
400
 
392
401
  \`kimaki session list\` also shows if a session was started by a scheduled \`delay\` or \`cron\` task, including task ID when available.
@@ -17,7 +17,7 @@ import { createLogger, formatErrorWithStack, LogPrefix } from './logger.js'
17
17
  import { notifyError } from './sentry.js'
18
18
  import type { ThreadStartMarker } from './system-message.js'
19
19
  import {
20
- getLocalTimeZone,
20
+ type ScheduledTaskPayload,
21
21
  getNextCronRun,
22
22
  getPromptPreview,
23
23
  parseScheduledTaskPayload,
@@ -53,14 +53,7 @@ async function executeThreadScheduledTask({
53
53
  }: {
54
54
  rest: REST
55
55
  task: ScheduledTask
56
- payload: {
57
- threadId: string
58
- prompt: string
59
- agent: string | null
60
- model: string | null
61
- username: string | null
62
- userId: string | null
63
- }
56
+ payload: Extract<ScheduledTaskPayload, { kind: 'thread' }>
64
57
  }): Promise<void | Error> {
65
58
  const marker: ThreadStartMarker = {
66
59
  cliThreadPrompt: true,
@@ -70,6 +63,7 @@ async function executeThreadScheduledTask({
70
63
  ...(payload.model ? { model: payload.model } : {}),
71
64
  ...(payload.username ? { username: payload.username } : {}),
72
65
  ...(payload.userId ? { userId: payload.userId } : {}),
66
+ ...(payload.permissions?.length ? { permissions: payload.permissions } : {}),
73
67
  }
74
68
  const embed = [{ color: 0x2b2d31, footer: { text: yaml.dump(marker) } }]
75
69
  const prefixedPrompt = `» **kimaki-cli:** ${payload.prompt}`
@@ -99,17 +93,7 @@ async function executeChannelScheduledTask({
99
93
  }: {
100
94
  rest: REST
101
95
  task: ScheduledTask
102
- payload: {
103
- channelId: string
104
- prompt: string
105
- name: string | null
106
- notifyOnly: boolean
107
- worktreeName: string | null
108
- agent: string | null
109
- model: string | null
110
- username: string | null
111
- userId: string | null
112
- }
96
+ payload: Extract<ScheduledTaskPayload, { kind: 'channel' }>
113
97
  }): Promise<void | Error> {
114
98
  const marker: ThreadStartMarker | undefined = payload.notifyOnly
115
99
  ? undefined
@@ -122,6 +106,7 @@ async function executeChannelScheduledTask({
122
106
  ...(payload.model ? { model: payload.model } : {}),
123
107
  ...(payload.username ? { username: payload.username } : {}),
124
108
  ...(payload.userId ? { userId: payload.userId } : {}),
109
+ ...(payload.permissions?.length ? { permissions: payload.permissions } : {}),
125
110
  }
126
111
  const embeds = marker
127
112
  ? [{ color: 0x2b2d31, footer: { text: yaml.dump(marker) } }]
@@ -246,7 +231,8 @@ async function finalizeSuccessfulTask({
246
231
  return
247
232
  }
248
233
 
249
- const timezone = task.timezone || getLocalTimeZone()
234
+ // Use stored timezone, falling back to UTC (not machine local) for consistency
235
+ const timezone = task.timezone || 'UTC'
250
236
  const nextRunResult = getNextCronRun({
251
237
  cronExpr: task.cron_expr,
252
238
  timezone,
@@ -278,7 +264,8 @@ async function finalizeFailedTask({
278
264
  error: Error
279
265
  }): Promise<void> {
280
266
  if (task.schedule_kind === 'cron' && task.cron_expr) {
281
- const timezone = task.timezone || getLocalTimeZone()
267
+ // Use stored timezone, falling back to UTC (not machine local) for consistency
268
+ const timezone = task.timezone || 'UTC'
282
269
  const nextRunResult = getNextCronRun({
283
270
  cronExpr: task.cron_expr,
284
271
  timezone,
@@ -12,6 +12,7 @@ export type ScheduledTaskPayload =
12
12
  model: string | null
13
13
  username: string | null
14
14
  userId: string | null
15
+ permissions: string[] | null
15
16
  }
16
17
  | {
17
18
  kind: 'channel'
@@ -24,6 +25,7 @@ export type ScheduledTaskPayload =
24
25
  model: string | null
25
26
  username: string | null
26
27
  userId: string | null
28
+ permissions: string[] | null
27
29
  }
28
30
 
29
31
  export type ParsedSendAt =
@@ -215,6 +217,15 @@ function asString(value: unknown): string | null {
215
217
  return value
216
218
  }
217
219
 
220
+ function asStringArray(value: unknown): string[] | null {
221
+ if (!Array.isArray(value)) {
222
+ return null
223
+ }
224
+ return value.filter((v): v is string => {
225
+ return typeof v === 'string'
226
+ })
227
+ }
228
+
218
229
  export function parseScheduledTaskPayload(
219
230
  payloadJson: string,
220
231
  ): ScheduledTaskPayload | Error {
@@ -241,6 +252,7 @@ export function parseScheduledTaskPayload(
241
252
  const model = asString(parsed.model)
242
253
  const username = asString(parsed.username)
243
254
  const userId = asString(parsed.userId)
255
+ const permissions = asStringArray(parsed.permissions)
244
256
  if (!threadId || !prompt) {
245
257
  return new Error('Thread task payload requires threadId and prompt')
246
258
  }
@@ -252,6 +264,7 @@ export function parseScheduledTaskPayload(
252
264
  model,
253
265
  username,
254
266
  userId,
267
+ permissions,
255
268
  }
256
269
  }
257
270
 
@@ -266,6 +279,7 @@ export function parseScheduledTaskPayload(
266
279
  const model = asString(parsed.model)
267
280
  const username = asString(parsed.username)
268
281
  const userId = asString(parsed.userId)
282
+ const permissions = asStringArray(parsed.permissions)
269
283
  if (!channelId || !prompt) {
270
284
  return new Error('Channel task payload requires channelId and prompt')
271
285
  }
@@ -280,6 +294,7 @@ export function parseScheduledTaskPayload(
280
294
  model,
281
295
  username,
282
296
  userId,
297
+ permissions,
283
298
  }
284
299
  }
285
300
 
@@ -539,12 +539,22 @@ e2eTest('thread message queue ordering', () => {
539
539
 
540
540
  const th = discord.thread(thread.id)
541
541
 
542
- // Wait for the first bot reply so session is established
542
+ // Wait for the first bot reply AND its footer so the first response
543
+ // cycle is fully complete before sending follow-ups. Without this,
544
+ // the footer for "one" can still be in-flight when the snapshot runs.
543
545
  const firstReply = await th.waitForBotReply({
544
546
  timeout: 4_000,
545
547
  })
546
548
  expect(firstReply.content.trim().length).toBeGreaterThan(0)
547
549
 
550
+ await waitForFooterMessage({
551
+ discord,
552
+ threadId: thread.id,
553
+ timeout: 4_000,
554
+ afterMessageIncludes: 'one',
555
+ afterAuthorId: TEST_USER_ID,
556
+ })
557
+
548
558
  // Snapshot bot message count before sending follow-ups
549
559
  const before = await th.getMessages()
550
560
  const beforeBotCount = before.filter((m) => {
@@ -588,10 +598,13 @@ e2eTest('thread message queue ordering', () => {
588
598
  Reply with exactly: one
589
599
  --- from: assistant (TestBot)
590
600
  ⬥ ok
601
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
591
602
  --- from: user (queue-tester)
592
603
  Reply with exactly: two
593
604
  Reply with exactly: three
594
605
  --- from: assistant (TestBot)
606
+ ⬥ ok
607
+ ⬥ ok
595
608
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
596
609
  `)
597
610
  const userThreeIndex = after.findIndex((message) => {