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.
- package/dist/anthropic-auth-plugin.js +628 -0
- package/dist/channel-management.js +2 -2
- package/dist/cli.js +316 -129
- package/dist/commands/action-buttons.js +1 -1
- package/dist/commands/login.js +634 -277
- package/dist/commands/model.js +91 -6
- package/dist/commands/paginated-select.js +57 -0
- package/dist/commands/resume.js +2 -2
- package/dist/commands/tasks.js +205 -0
- package/dist/commands/undo-redo.js +80 -18
- package/dist/context-awareness-plugin.js +347 -0
- package/dist/database.js +103 -7
- package/dist/db.js +39 -1
- package/dist/discord-bot.js +42 -19
- package/dist/discord-urls.js +11 -0
- package/dist/discord-ws-proxy.js +350 -0
- package/dist/discord-ws-proxy.test.js +500 -0
- package/dist/errors.js +1 -1
- package/dist/gateway-session.js +163 -0
- package/dist/hrana-server.js +114 -4
- package/dist/interaction-handler.js +30 -7
- package/dist/ipc-tools-plugin.js +186 -0
- package/dist/message-preprocessing.js +56 -11
- package/dist/onboarding-welcome.js +1 -1
- package/dist/opencode-interrupt-plugin.js +133 -75
- package/dist/opencode-plugin.js +12 -389
- package/dist/opencode.js +59 -5
- package/dist/parse-permission-rules.test.js +117 -0
- package/dist/queue-drain-after-interactive-ui.e2e.test.js +119 -0
- package/dist/session-handler/thread-session-runtime.js +68 -29
- package/dist/startup-time.e2e.test.js +295 -0
- package/dist/store.js +1 -0
- package/dist/system-message.js +3 -1
- package/dist/task-runner.js +7 -3
- package/dist/task-schedule.js +12 -0
- package/dist/thread-message-queue.e2e.test.js +13 -1
- package/dist/undo-redo.e2e.test.js +166 -0
- package/dist/utils.js +4 -1
- package/dist/voice-attachment.js +34 -0
- package/dist/voice-handler.js +11 -9
- package/dist/voice-message.e2e.test.js +78 -0
- package/dist/voice.test.js +31 -0
- package/package.json +12 -7
- package/skills/egaki/SKILL.md +80 -15
- package/skills/errore/SKILL.md +13 -0
- package/skills/lintcn/SKILL.md +749 -0
- package/skills/npm-package/SKILL.md +17 -3
- package/skills/spiceflow/SKILL.md +14 -0
- package/skills/zele/SKILL.md +9 -0
- package/src/anthropic-auth-plugin.ts +732 -0
- package/src/channel-management.ts +2 -2
- package/src/cli.ts +354 -132
- package/src/commands/action-buttons.ts +1 -0
- package/src/commands/login.ts +836 -337
- package/src/commands/model.ts +102 -7
- package/src/commands/paginated-select.ts +81 -0
- package/src/commands/resume.ts +6 -1
- package/src/commands/tasks.ts +293 -0
- package/src/commands/undo-redo.ts +87 -20
- package/src/context-awareness-plugin.ts +469 -0
- package/src/database.ts +138 -7
- package/src/db.ts +40 -1
- package/src/discord-bot.ts +46 -19
- package/src/discord-urls.ts +12 -0
- package/src/errors.ts +1 -1
- package/src/hrana-server.ts +124 -3
- package/src/interaction-handler.ts +41 -9
- package/src/ipc-tools-plugin.ts +228 -0
- package/src/message-preprocessing.ts +82 -11
- package/src/onboarding-welcome.ts +1 -1
- package/src/opencode-interrupt-plugin.ts +164 -91
- package/src/opencode-plugin.ts +13 -483
- package/src/opencode.ts +60 -5
- package/src/parse-permission-rules.test.ts +127 -0
- package/src/queue-drain-after-interactive-ui.e2e.test.ts +151 -0
- package/src/session-handler/thread-runtime-state.ts +4 -1
- package/src/session-handler/thread-session-runtime.ts +82 -20
- package/src/startup-time.e2e.test.ts +372 -0
- package/src/store.ts +8 -0
- package/src/system-message.ts +10 -1
- package/src/task-runner.ts +9 -22
- package/src/task-schedule.ts +15 -0
- package/src/thread-message-queue.e2e.test.ts +14 -1
- package/src/undo-redo.e2e.test.ts +207 -0
- package/src/utils.ts +7 -0
- package/src/voice-attachment.ts +51 -0
- package/src/voice-handler.ts +15 -7
- package/src/voice-message.e2e.test.ts +95 -0
- package/src/voice.test.ts +36 -0
- 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 },
|
package/src/system-message.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
package/src/task-runner.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/src/task-schedule.ts
CHANGED
|
@@ -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
|
|
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) => {
|