kimaki 0.20.1 → 0.22.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 (64) hide show
  1. package/dist/agent-model.e2e.test.js +6 -4
  2. package/dist/cli-commands/project.js +148 -24
  3. package/dist/cli-commands/send.js +48 -3
  4. package/dist/cli-runner.js +86 -7
  5. package/dist/cli-runner.test.js +28 -1
  6. package/dist/commands/btw.js +7 -3
  7. package/dist/commands/new-worktree.js +14 -2
  8. package/dist/commands/session.js +66 -13
  9. package/dist/commands/tasks.js +104 -19
  10. package/dist/commands/user-command.js +3 -2
  11. package/dist/database.js +14 -0
  12. package/dist/db.js +1 -0
  13. package/dist/discord-bot.js +65 -9
  14. package/dist/interaction-handler.js +6 -15
  15. package/dist/message-preprocessing.js +9 -2
  16. package/dist/queue-advanced-e2e-setup.js +22 -0
  17. package/dist/queue-delete-message.e2e.test.js +75 -0
  18. package/dist/schema.js +3 -0
  19. package/dist/session-handler/thread-runtime-state.js +9 -0
  20. package/dist/session-handler/thread-session-runtime.js +49 -1
  21. package/dist/system-message.js +50 -63
  22. package/dist/system-message.test.js +65 -61
  23. package/dist/task-runner.js +18 -3
  24. package/dist/task-schedule.js +4 -0
  25. package/dist/thread-message-queue.e2e.test.js +4 -4
  26. package/dist/voice-message.e2e.test.js +1 -1
  27. package/dist/worktree-lifecycle.e2e.test.js +70 -1
  28. package/dist/worktrees.js +63 -0
  29. package/package.json +11 -4
  30. package/skills/egaki/SKILL.md +26 -80
  31. package/skills/goke/SKILL.md +27 -20
  32. package/skills/holocron/SKILL.md +72 -8
  33. package/skills/new-skill/SKILL.md +87 -0
  34. package/skills/playwriter/SKILL.md +4 -4
  35. package/skills/spiceflow/SKILL.md +2 -0
  36. package/src/agent-model.e2e.test.ts +10 -4
  37. package/src/cli-commands/project.ts +183 -26
  38. package/src/cli-commands/send.ts +64 -5
  39. package/src/cli-runner.test.ts +39 -1
  40. package/src/cli-runner.ts +115 -6
  41. package/src/commands/btw.ts +9 -2
  42. package/src/commands/new-worktree.ts +14 -1
  43. package/src/commands/session.ts +79 -17
  44. package/src/commands/tasks.ts +119 -19
  45. package/src/commands/user-command.ts +3 -2
  46. package/src/database.ts +24 -0
  47. package/src/db.ts +1 -0
  48. package/src/discord-bot.ts +80 -8
  49. package/src/interaction-handler.ts +7 -19
  50. package/src/message-preprocessing.ts +11 -2
  51. package/src/queue-advanced-e2e-setup.ts +23 -0
  52. package/src/queue-delete-message.e2e.test.ts +102 -0
  53. package/src/schema.sql +1 -0
  54. package/src/schema.ts +3 -0
  55. package/src/session-handler/thread-runtime-state.ts +19 -0
  56. package/src/session-handler/thread-session-runtime.ts +75 -0
  57. package/src/system-message.test.ts +73 -61
  58. package/src/system-message.ts +63 -62
  59. package/src/task-runner.ts +34 -3
  60. package/src/task-schedule.ts +6 -0
  61. package/src/thread-message-queue.e2e.test.ts +4 -4
  62. package/src/voice-message.e2e.test.ts +1 -1
  63. package/src/worktree-lifecycle.e2e.test.ts +88 -1
  64. package/src/worktrees.ts +74 -0
@@ -195,26 +195,14 @@ export function registerInteractionHandler({
195
195
  // Multi-machine routing: only handle interactions for channels owned
196
196
  // by this machine (have a project directory configured in local db).
197
197
  // If not owned, silently return so the other machine handles it.
198
+ // Setup commands (create-new-project, add-project) bypass this check
199
+ // because they are designed to run from any channel — they create new
200
+ // project channels rather than requiring one to already exist.
201
+ const isSetupCommand =
202
+ interaction.isChatInputCommand() &&
203
+ SETUP_COMMANDS.has(interaction.commandName)
198
204
  const owned = await isInteractionOwnedByThisMachine(interaction)
199
- if (!owned) {
200
- // Setup commands get a helpful reply instead of silent ignore.
201
- // Both machines may race to reply; one wins, the other silently
202
- // fails (interaction tokens are single-use). The message is the
203
- // same from either machine so the race is harmless.
204
- if (
205
- interaction.isChatInputCommand() &&
206
- SETUP_COMMANDS.has(interaction.commandName)
207
- ) {
208
- await interaction.reply({
209
- content:
210
- 'Run this command in an existing Kimaki project channel.\nTo add a new project channel from the terminal, run:\n```\nkimaki project add /path/to/folder\n```',
211
- flags: MessageFlags.Ephemeral,
212
- }).catch(() => {
213
- // Another machine already responded, safe to ignore
214
- })
215
- return
216
- }
217
-
205
+ if (!owned && !isSetupCommand) {
218
206
  interactionLogger.log(
219
207
  `[IGNORED] Channel ${interaction.channelId} has no project directory configured, skipping interaction`,
220
208
  )
@@ -390,10 +390,18 @@ export async function preprocessNewSessionMessage({
390
390
  }
391
391
 
392
392
  const qs = extractQueueSuffix(prompt)
393
+
394
+ const fileAttachments = await getFileAttachments(message)
395
+ const textAttachmentsContent = await getTextAttachments(message)
396
+ const finalPrompt = textAttachmentsContent
397
+ ? `${qs.prompt}\n\n${textAttachmentsContent}`
398
+ : qs.prompt
399
+
393
400
  if (
394
401
  shouldSkipEmptyPrompt({
395
402
  message,
396
- prompt: qs.prompt,
403
+ prompt: finalPrompt,
404
+ images: fileAttachments,
397
405
  hasVoiceAttachment,
398
406
  })
399
407
  ) {
@@ -401,7 +409,8 @@ export async function preprocessNewSessionMessage({
401
409
  }
402
410
 
403
411
  return {
404
- prompt: qs.prompt,
412
+ prompt: finalPrompt,
413
+ images: fileAttachments.length > 0 ? fileAttachments : undefined,
405
414
  repliedMessage,
406
415
  permissionRules,
407
416
  mode: qs.forceQueue || voiceResult?.queueMessage ? 'local-queue' : 'opencode',
@@ -127,6 +127,28 @@ export function createDeterministicMatchers(): DeterministicMatcher[] {
127
127
  },
128
128
  }
129
129
 
130
+ const slowBusyMatcher: DeterministicMatcher = {
131
+ id: 'slow-busy-reply',
132
+ priority: 100,
133
+ when: {
134
+ latestUserTextIncludes: 'SLOW_BUSY_MARKER',
135
+ },
136
+ then: {
137
+ parts: [
138
+ { type: 'stream-start', warnings: [] },
139
+ { type: 'text-start', id: 'slow-busy' },
140
+ { type: 'text-delta', id: 'slow-busy', delta: 'slow-busy-reply' },
141
+ { type: 'text-end', id: 'slow-busy' },
142
+ {
143
+ type: 'finish',
144
+ finishReason: 'stop',
145
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
146
+ },
147
+ ],
148
+ partDelaysMs: [0, 0, 0, 2_000, 0],
149
+ },
150
+ }
151
+
130
152
  const toolFollowupMatcher: DeterministicMatcher = {
131
153
  id: 'tool-followup',
132
154
  priority: 50,
@@ -755,6 +777,7 @@ export function createDeterministicMatchers(): DeterministicMatcher[] {
755
777
 
756
778
  return [
757
779
  slowAbortMatcher,
780
+ slowBusyMatcher,
758
781
  typingRepulseMatcher,
759
782
  pluginTimeoutSleepMatcher,
760
783
  actionButtonClickFollowupMatcher,
@@ -0,0 +1,102 @@
1
+ // E2e tests for deleting queued Discord messages.
2
+ // Covers the MessageDelete path that removes pending local queue items before drain.
3
+
4
+ import { describe, test, expect } from 'vitest'
5
+ import {
6
+ setupQueueAdvancedSuite,
7
+ TEST_USER_ID,
8
+ } from './queue-advanced-e2e-setup.js'
9
+ import {
10
+ waitForBotMessageContaining,
11
+ waitForFooterMessage,
12
+ waitForThreadState,
13
+ } from './test-utils.js'
14
+
15
+ const TEXT_CHANNEL_ID = '200000000000001071'
16
+
17
+ const e2eTest = describe
18
+
19
+ e2eTest('queue delete message', () => {
20
+ const ctx = setupQueueAdvancedSuite({
21
+ channelId: TEXT_CHANNEL_ID,
22
+ channelName: 'queue-delete-message-e2e',
23
+ dirName: 'queue-delete-message-e2e',
24
+ username: 'queue-delete-tester',
25
+ })
26
+
27
+ test(
28
+ 'deleting a queued Discord message removes it from queue',
29
+ async () => {
30
+ await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
31
+ content: 'SLOW_BUSY_MARKER Reply with exactly: delete-queue-setup',
32
+ })
33
+
34
+ const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
35
+ timeout: 4_000,
36
+ predicate: (t) => {
37
+ return t.name === 'SLOW_BUSY_MARKER Reply with exactly: delete-queue-setup'
38
+ },
39
+ })
40
+ const th = ctx.discord.thread(thread.id)
41
+
42
+ await th.waitForBotReply({ timeout: 4_000 })
43
+
44
+ const queuedMsg = await th.user(TEST_USER_ID).sendMessage({
45
+ content: 'Reply with exactly: deleted-queue. queue',
46
+ })
47
+
48
+ await waitForThreadState({
49
+ threadId: thread.id,
50
+ predicate: (state) => {
51
+ return state.queueItems.some((item) => {
52
+ return item.sourceMessageId === queuedMsg.id
53
+ })
54
+ },
55
+ timeout: 4_000,
56
+ description: 'queue has item to delete',
57
+ })
58
+
59
+ await th.user(TEST_USER_ID).deleteMessage({ messageId: queuedMsg.id })
60
+
61
+ await waitForThreadState({
62
+ threadId: thread.id,
63
+ predicate: (state) => {
64
+ return !state.queueItems.some((item) => {
65
+ return item.sourceMessageId === queuedMsg.id
66
+ })
67
+ },
68
+ timeout: 4_000,
69
+ description: 'queued item removed after Discord delete',
70
+ })
71
+
72
+ await waitForBotMessageContaining({
73
+ discord: ctx.discord,
74
+ threadId: thread.id,
75
+ text: 'removed message from queue',
76
+ timeout: 4_000,
77
+ })
78
+
79
+ await waitForFooterMessage({
80
+ discord: ctx.discord,
81
+ threadId: thread.id,
82
+ timeout: 8_000,
83
+ })
84
+
85
+ expect(await th.text()).toMatchInlineSnapshot(`
86
+ "--- from: user (queue-delete-tester)
87
+ SLOW_BUSY_MARKER Reply with exactly: delete-queue-setup
88
+ --- from: assistant (TestBot)
89
+ *using deterministic-provider/deterministic-v2*
90
+ Queued at position 1. Edit or delete your message to update the queue
91
+ ⬦ **queue-delete-tester** removed message from queue
92
+ ⬥ slow-busy-reply
93
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
94
+ `)
95
+ const finalText = await th.text()
96
+ expect(finalText).not.toContain(
97
+ '» **queue-delete-tester:** Reply with exactly: deleted-queue',
98
+ )
99
+ },
100
+ 12_000,
101
+ )
102
+ })
package/src/schema.sql CHANGED
@@ -168,6 +168,7 @@ CREATE TABLE IF NOT EXISTS `thread_sessions` (
168
168
  `session_id` text NOT NULL,
169
169
  `source` text DEFAULT 'kimaki' NOT NULL,
170
170
  `last_synced_name` text,
171
+ `parent_session_id` text,
171
172
  `created_at` datetime DEFAULT CURRENT_TIMESTAMP
172
173
  );
173
174
 
package/src/schema.ts CHANGED
@@ -29,6 +29,9 @@ export const thread_sessions = sqliteCore.sqliteTable('thread_sessions', {
29
29
  session_id: sqliteCore.text('session_id').notNull(),
30
30
  source: sqliteCore.text('source', { enum: ['kimaki', 'external_poll'] }).notNull().default('kimaki'),
31
31
  last_synced_name: sqliteCore.text('last_synced_name'),
32
+ // Parent OpenCode session that spawned this thread via kimaki send --parent-session.
33
+ // Survives bot restarts so child multi-turn system prompts keep the parent ID.
34
+ parent_session_id: sqliteCore.text('parent_session_id'),
32
35
  created_at: datetime('created_at').default(orm.sql`CURRENT_TIMESTAMP`),
33
36
  })
34
37
 
@@ -51,6 +51,10 @@ export type QueuedMessage = {
51
51
  // Written to a temp config file after session creation so the plugin
52
52
  // can check per-session whether to scan tool outputs.
53
53
  injectionGuardPatterns?: string[]
54
+ // Parent OpenCode session ID from `kimaki send --parent-session`.
55
+ // Applied once on first session create so the child system message can
56
+ // expose how to message the parent when the user asks.
57
+ parentSessionId?: string
54
58
  // Discord message ID and thread ID of the source message. Embedded in
55
59
  // <discord-user> synthetic context so the external sync loop can detect
56
60
  // messages that originated from Discord and skip re-mirroring them.
@@ -82,6 +86,11 @@ export type ThreadRunState = {
82
86
  sessionUsername: string | undefined
83
87
  sessionUserId: string | undefined
84
88
 
89
+ // Parent OpenCode session that spawned this thread via
90
+ // `kimaki send --parent-session`. Set once on first ingress and reused for
91
+ // the session-stable system prompt.
92
+ parentSessionId: string | undefined
93
+
85
94
  // FIFO queue of pending inputs waiting for kimaki-local dispatch.
86
95
  // Normal user messages default to opencode queue mode; this queue is
87
96
  // for explicit local-queue flows (for example /queue).
@@ -107,6 +116,7 @@ export function initialThreadState(): ThreadRunState {
107
116
  sessionId: undefined,
108
117
  sessionUsername: undefined,
109
118
  sessionUserId: undefined,
119
+ parentSessionId: undefined,
110
120
  queueItems: [],
111
121
  sentPartIds: new Set(),
112
122
  }
@@ -180,6 +190,15 @@ export function setSessionUserId(threadId: string, userId: string): void {
180
190
  })
181
191
  }
182
192
 
193
+ export function setParentSessionId(threadId: string, parentSessionId: string): void {
194
+ updateThread(threadId, (t) => {
195
+ if (t.parentSessionId) {
196
+ return t
197
+ }
198
+ return { ...t, parentSessionId }
199
+ })
200
+ }
201
+
183
202
  export function enqueueItem(threadId: string, item: QueuedMessage): void {
184
203
  updateThread(threadId, (t) => ({
185
204
  ...t,
@@ -50,6 +50,8 @@ import {
50
50
  setPartMessage,
51
51
  getThreadSession,
52
52
  setThreadSession,
53
+ getThreadParentSessionId,
54
+ setThreadParentSessionId,
53
55
  getThreadWorktreeOrWorkspace,
54
56
  setSessionAgent,
55
57
  clearSessionModel,
@@ -557,6 +559,12 @@ export type IngressInput = {
557
559
  permissions?: string[]
558
560
  permissionRules?: PermissionRuleset
559
561
  injectionGuardPatterns?: string[]
562
+ /**
563
+ * Parent OpenCode session ID from explicit `kimaki send --parent-session` only.
564
+ * Stored once on first ingress and injected into the child system message.
565
+ * Never set for /btw, /fork, or task/subagent children (keeps system prompt cache).
566
+ */
567
+ parentSessionId?: string
560
568
  sessionStartSource?: { scheduleKind: 'at' | 'cron'; scheduledTaskId?: number }
561
569
  /** Optional guard for retries: skip enqueue when session has changed. */
562
570
  expectedSessionId?: string
@@ -3079,6 +3087,7 @@ export class ThreadSessionRuntime {
3079
3087
  agents: availableAgents,
3080
3088
  username: this.state?.sessionUsername || input.username,
3081
3089
  userId: this.state?.sessionUserId || input.userId,
3090
+ parentSessionId: this.state?.parentSessionId || input.parentSessionId,
3082
3091
  }),
3083
3092
  ...(resolvedAgent ? { agent: resolvedAgent } : {}),
3084
3093
  ...(modelField ? { model: modelField } : {}),
@@ -3132,6 +3141,7 @@ export class ThreadSessionRuntime {
3132
3141
  permissions: input.permissions,
3133
3142
  permissionRules: input.permissionRules,
3134
3143
  injectionGuardPatterns: input.injectionGuardPatterns,
3144
+ parentSessionId: input.parentSessionId,
3135
3145
  sourceMessageId: input.sourceMessageId,
3136
3146
  sourceThreadId: input.sourceThreadId,
3137
3147
  repliedMessage: input.repliedMessage,
@@ -3182,6 +3192,9 @@ export class ThreadSessionRuntime {
3182
3192
  async enqueueIncoming(input: IngressInput): Promise<EnqueueResult> {
3183
3193
  threadState.setSessionUsername(this.threadId, input.username)
3184
3194
  threadState.setSessionUserId(this.threadId, input.userId)
3195
+ await this.ensureParentSessionId({
3196
+ parentSessionId: input.parentSessionId,
3197
+ })
3185
3198
 
3186
3199
  // When a preprocessor is provided, we must resolve it inside
3187
3200
  // dispatchAction before we know the final mode for routing.
@@ -3204,6 +3217,43 @@ export class ThreadSessionRuntime {
3204
3217
  return this.submitViaOpencodeQueue(input)
3205
3218
  }
3206
3219
 
3220
+ /**
3221
+ * Resolve parent session ID for child system prompts.
3222
+ * Prefer in-memory state, then SQLite, then the ingress marker.
3223
+ * Persist once so multi-turn child sessions keep the parent after restart.
3224
+ */
3225
+ private async ensureParentSessionId({
3226
+ parentSessionId,
3227
+ }: {
3228
+ parentSessionId?: string
3229
+ }) {
3230
+ if (this.state?.parentSessionId) {
3231
+ return
3232
+ }
3233
+
3234
+ const storedParentSessionId = await getThreadParentSessionId(this.threadId)
3235
+ if (storedParentSessionId) {
3236
+ threadState.setParentSessionId(this.threadId, storedParentSessionId)
3237
+ return
3238
+ }
3239
+
3240
+ if (!parentSessionId) {
3241
+ return
3242
+ }
3243
+
3244
+ threadState.setParentSessionId(this.threadId, parentSessionId)
3245
+ // Row may not exist yet on first ingress before ensureSession creates it.
3246
+ // Best-effort write; ensureSession path also persists after setThreadSession.
3247
+ await setThreadParentSessionId({
3248
+ threadId: this.threadId,
3249
+ parentSessionId,
3250
+ }).catch((error) => {
3251
+ logger.warn(
3252
+ `[PARENT SESSION] Failed to persist parent session for thread ${this.threadId}: ${error instanceof Error ? error.message : String(error)}`,
3253
+ )
3254
+ })
3255
+ }
3256
+
3207
3257
  /**
3208
3258
  * Serialize the preprocess callback via a lightweight promise chain, then
3209
3259
  * route the resolved input through the normal enqueue paths.
@@ -3466,6 +3516,17 @@ export class ThreadSessionRuntime {
3466
3516
  return { found: true, removed: false }
3467
3517
  }
3468
3518
 
3519
+ /** Remove a queued message identified by its Discord source message ID. */
3520
+ removeQueuedMessage(
3521
+ sourceMessageId: string,
3522
+ ): threadState.QueuedMessage | undefined {
3523
+ return threadState.updateQueueItemBySourceMessageId(
3524
+ this.threadId,
3525
+ sourceMessageId,
3526
+ () => null,
3527
+ )
3528
+ }
3529
+
3469
3530
  // ── Queue Drain ─────────────────────────────────────────────
3470
3531
 
3471
3532
  /**
@@ -3924,6 +3985,7 @@ export class ThreadSessionRuntime {
3924
3985
  agents: earlyAvailableAgents,
3925
3986
  username: this.state?.sessionUsername || input.username,
3926
3987
  userId: this.state?.sessionUserId || input.userId,
3988
+ parentSessionId: this.state?.parentSessionId || input.parentSessionId,
3927
3989
  }),
3928
3990
  model: earlyModelParam,
3929
3991
  agent: earlyAgentPreference,
@@ -4125,6 +4187,19 @@ export class ThreadSessionRuntime {
4125
4187
  // Store session in DB and thread state
4126
4188
  await setThreadSession(this.thread.id, session.id)
4127
4189
  threadState.setSessionId(this.threadId, session.id)
4190
+ // Parent may have been set on ingress before the thread_sessions row
4191
+ // existed; write it now that the row is guaranteed.
4192
+ const parentSessionId = this.state?.parentSessionId
4193
+ if (parentSessionId) {
4194
+ await setThreadParentSessionId({
4195
+ threadId: this.thread.id,
4196
+ parentSessionId,
4197
+ }).catch((error) => {
4198
+ logger.warn(
4199
+ `[PARENT SESSION] Failed to persist parent session for thread ${this.threadId}: ${error instanceof Error ? error.message : String(error)}`,
4200
+ )
4201
+ })
4202
+ }
4128
4203
  await this.hydrateSessionEventsFromDatabase({ sessionId: session.id })
4129
4204
 
4130
4205
  // Store session start source for scheduled tasks