kimaki 0.26.0 → 0.27.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 (214) hide show
  1. package/dist/agent-model.e2e.test.js +25 -122
  2. package/dist/anthropic-account-identity.js +1 -0
  3. package/dist/anthropic-auth-plugin.js +1 -0
  4. package/dist/anthropic-auth-state.js +1 -0
  5. package/dist/bin.js +73 -19
  6. package/dist/cli-commands/maintenance.js +46 -7
  7. package/dist/cli-commands/send.js +24 -0
  8. package/dist/cli-commands/session.js +183 -8
  9. package/dist/cli-commands/task.js +13 -1
  10. package/dist/cli-parsing.test.js +59 -3
  11. package/dist/cli-runner.js +28 -4
  12. package/dist/cli-runner.test.js +27 -0
  13. package/dist/cli-send-thread.e2e.test.js +1 -1
  14. package/dist/cli.js +32 -0
  15. package/dist/commands/abort.js +5 -1
  16. package/dist/commands/agent.js +6 -8
  17. package/dist/commands/ask-question.js +21 -32
  18. package/dist/commands/ask-question.test.js +43 -1
  19. package/dist/commands/login.js +6 -0
  20. package/dist/commands/merge-worktree.js +36 -10
  21. package/dist/commands/model-variant.js +7 -1
  22. package/dist/commands/model.js +14 -8
  23. package/dist/commands/multioauth.js +1 -0
  24. package/dist/commands/new-worktree.js +170 -102
  25. package/dist/commands/user-command.js +6 -8
  26. package/dist/commands/worktrees.js +5 -13
  27. package/dist/config.js +16 -1
  28. package/dist/database.js +124 -3
  29. package/dist/db.js +22 -0
  30. package/dist/db.test.js +167 -1
  31. package/dist/discord-bot.js +34 -32
  32. package/dist/discord-command-registration.js +9 -1
  33. package/dist/discord-utils.js +13 -5
  34. package/dist/discord-utils.test.js +17 -1
  35. package/dist/errors.js +5 -0
  36. package/dist/exec-async.js +17 -5
  37. package/dist/external-directory-permissions.e2e.test.js +2 -2
  38. package/dist/file-edit-log.js +197 -0
  39. package/dist/file-edit-log.test.js +258 -0
  40. package/dist/format-tables.js +52 -0
  41. package/dist/format-tables.test.js +93 -0
  42. package/dist/gateway-proxy-reconnect.e2e.test.js +28 -8
  43. package/dist/gateway-proxy.e2e.test.js +5 -5
  44. package/dist/git-worktree-core.js +182 -17
  45. package/dist/ipc-tools-plugin.js +63 -3
  46. package/dist/kimaki-opencode-plugin-loading.e2e.test.js +122 -58
  47. package/dist/kimaki-opencode-plugin.js +7 -3
  48. package/dist/kimaki-workspace-adaptor.js +33 -10
  49. package/dist/logger.js +10 -1
  50. package/dist/markdown.js +3 -1
  51. package/dist/markdown.test.js +11 -3
  52. package/dist/message-formatting.js +18 -7
  53. package/dist/message-formatting.test.js +56 -9
  54. package/dist/oauth-rotation-shared.js +10 -0
  55. package/dist/openai-auth-plugin.js +1 -0
  56. package/dist/openai-auth-state.js +1 -0
  57. package/dist/opencode-command.test.js +60 -0
  58. package/dist/opencode.js +53 -12
  59. package/dist/question-abort-clears-dropdown.e2e.test.js +89 -0
  60. package/dist/question-text-before-dropdown.e2e.test.js +52 -0
  61. package/dist/queue-advanced-abort.e2e.test.js +1 -1
  62. package/dist/queue-advanced-action-buttons.e2e.test.js +3 -3
  63. package/dist/queue-advanced-e2e-setup.js +78 -5
  64. package/dist/queue-advanced-footer.e2e.test.js +40 -246
  65. package/dist/queue-advanced-model-switch.e2e.test.js +2 -2
  66. package/dist/queue-advanced-permissions-typing.e2e.test.js +7 -9
  67. package/dist/queue-advanced-question.e2e.test.js +31 -26
  68. package/dist/queue-advanced-typing-interrupt.e2e.test.js +2 -2
  69. package/dist/queue-delete-message.e2e.test.js +1 -1
  70. package/dist/queue-drain-after-interactive-ui.e2e.test.js +2 -2
  71. package/dist/queue-interrupt-drain.e2e.test.js +112 -2
  72. package/dist/queue-question-select-drain.e2e.test.js +30 -128
  73. package/dist/runtime-lifecycle.e2e.test.js +10 -37
  74. package/dist/schema.js +34 -0
  75. package/dist/session-handler/event-stream-state.js +267 -50
  76. package/dist/session-handler/event-stream-state.test.js +415 -8
  77. package/dist/session-handler/model-utils.js +159 -5
  78. package/dist/session-handler/model-utils.test.js +345 -0
  79. package/dist/session-handler/thread-session-runtime.js +252 -182
  80. package/dist/session-sleep.test.js +224 -0
  81. package/dist/session-title-rename.test.js +7 -39
  82. package/dist/sleep.e2e.test.js +491 -0
  83. package/dist/startup-time.e2e.test.js +2 -1
  84. package/dist/store.js +3 -0
  85. package/dist/system-message.js +59 -6
  86. package/dist/system-message.test.js +76 -6
  87. package/dist/task-runner.js +94 -4
  88. package/dist/task-schedule.js +56 -0
  89. package/dist/task-schedule.test.js +71 -1
  90. package/dist/thread-message-queue.e2e.test.js +10 -169
  91. package/dist/undo-redo.e2e.test.js +2 -2
  92. package/dist/voice-attachment.js +23 -1
  93. package/dist/voice-message.e2e.test.js +10 -10
  94. package/dist/voice.test.js +55 -29
  95. package/dist/worktree-clone-isolation.e2e.test.js +188 -0
  96. package/dist/worktree-lifecycle.e2e.test.js +29 -10
  97. package/dist/worktrees.js +226 -443
  98. package/dist/worktrees.test.js +435 -58
  99. package/dist/xai-auth-plugin.js +1 -0
  100. package/dist/xai-auth-state.js +1 -0
  101. package/package.json +8 -6
  102. package/skills/errore/SKILL.md +18 -0
  103. package/skills/goke/SKILL.md +150 -10
  104. package/skills/holocron/SKILL.md +37 -0
  105. package/skills/npm-package/SKILL.md +55 -35
  106. package/skills/profano/SKILL.md +17 -2
  107. package/skills/spiceflow/SKILL.md +39 -3
  108. package/skills/strada/SKILL.md +39 -0
  109. package/skills/zele/SKILL.md +19 -0
  110. package/src/agent-model.e2e.test.ts +25 -151
  111. package/src/anthropic-account-identity.ts +1 -0
  112. package/src/anthropic-auth-plugin.ts +1 -0
  113. package/src/anthropic-auth-state.ts +1 -0
  114. package/src/bin.ts +78 -26
  115. package/src/cli-commands/maintenance.ts +59 -13
  116. package/src/cli-commands/send.ts +25 -0
  117. package/src/cli-commands/session.ts +203 -9
  118. package/src/cli-commands/task.ts +13 -1
  119. package/src/cli-parsing.test.ts +69 -3
  120. package/src/cli-runner.test.ts +49 -0
  121. package/src/cli-runner.ts +28 -4
  122. package/src/cli-send-thread.e2e.test.ts +1 -1
  123. package/src/cli.ts +53 -0
  124. package/src/commands/abort.ts +6 -1
  125. package/src/commands/agent.ts +6 -6
  126. package/src/commands/ask-question.test.ts +48 -1
  127. package/src/commands/ask-question.ts +21 -32
  128. package/src/commands/login.ts +6 -0
  129. package/src/commands/merge-worktree.ts +43 -12
  130. package/src/commands/model-variant.ts +8 -1
  131. package/src/commands/model.ts +18 -8
  132. package/src/commands/multioauth.ts +1 -0
  133. package/src/commands/new-worktree.ts +138 -55
  134. package/src/commands/user-command.ts +6 -6
  135. package/src/commands/worktrees.ts +5 -12
  136. package/src/config.ts +18 -1
  137. package/src/database.ts +195 -3
  138. package/src/db.test.ts +188 -0
  139. package/src/db.ts +26 -0
  140. package/src/discord-bot.ts +35 -29
  141. package/src/discord-command-registration.ts +15 -1
  142. package/src/discord-utils.test.ts +19 -0
  143. package/src/discord-utils.ts +21 -6
  144. package/src/errors.ts +5 -0
  145. package/src/exec-async.ts +20 -11
  146. package/src/external-directory-permissions.e2e.test.ts +2 -2
  147. package/src/file-edit-log.test.ts +287 -0
  148. package/src/file-edit-log.ts +283 -0
  149. package/src/format-tables.test.ts +101 -0
  150. package/src/format-tables.ts +75 -0
  151. package/src/gateway-proxy-reconnect.e2e.test.ts +38 -8
  152. package/src/gateway-proxy.e2e.test.ts +5 -5
  153. package/src/git-worktree-core.ts +247 -20
  154. package/src/ipc-tools-plugin.ts +75 -5
  155. package/src/kimaki-opencode-plugin-loading.e2e.test.ts +188 -97
  156. package/src/kimaki-opencode-plugin.ts +7 -3
  157. package/src/kimaki-workspace-adaptor.ts +40 -11
  158. package/src/logger.ts +13 -4
  159. package/src/markdown.test.ts +13 -3
  160. package/src/markdown.ts +4 -1
  161. package/src/message-formatting.test.ts +86 -12
  162. package/src/message-formatting.ts +19 -7
  163. package/src/oauth-rotation-shared.ts +10 -0
  164. package/src/openai-auth-plugin.ts +1 -0
  165. package/src/openai-auth-state.ts +1 -0
  166. package/src/opencode-command.test.ts +81 -0
  167. package/src/opencode.ts +71 -11
  168. package/src/question-abort-clears-dropdown.e2e.test.ts +109 -0
  169. package/src/question-text-before-dropdown.e2e.test.ts +67 -0
  170. package/src/queue-advanced-abort.e2e.test.ts +1 -1
  171. package/src/queue-advanced-action-buttons.e2e.test.ts +3 -3
  172. package/src/queue-advanced-e2e-setup.ts +83 -4
  173. package/src/queue-advanced-footer.e2e.test.ts +47 -294
  174. package/src/queue-advanced-model-switch.e2e.test.ts +2 -2
  175. package/src/queue-advanced-permissions-typing.e2e.test.ts +7 -9
  176. package/src/queue-advanced-question.e2e.test.ts +35 -29
  177. package/src/queue-advanced-typing-interrupt.e2e.test.ts +2 -2
  178. package/src/queue-delete-message.e2e.test.ts +1 -1
  179. package/src/queue-drain-after-interactive-ui.e2e.test.ts +2 -2
  180. package/src/queue-interrupt-drain.e2e.test.ts +123 -2
  181. package/src/queue-question-select-drain.e2e.test.ts +32 -157
  182. package/src/runtime-lifecycle.e2e.test.ts +10 -41
  183. package/src/schema.sql +14 -1
  184. package/src/schema.ts +36 -0
  185. package/src/session-handler/event-stream-state.test.ts +437 -7
  186. package/src/session-handler/event-stream-state.ts +389 -53
  187. package/src/session-handler/model-utils.test.ts +415 -0
  188. package/src/session-handler/model-utils.ts +249 -5
  189. package/src/session-handler/thread-session-runtime.ts +300 -215
  190. package/src/session-sleep.test.ts +294 -0
  191. package/src/session-title-rename.test.ts +9 -48
  192. package/src/sleep.e2e.test.ts +562 -0
  193. package/src/startup-time.e2e.test.ts +5 -1
  194. package/src/store.ts +21 -0
  195. package/src/system-message.test.ts +86 -6
  196. package/src/system-message.ts +66 -7
  197. package/src/task-runner.ts +136 -2
  198. package/src/task-schedule.test.ts +76 -0
  199. package/src/task-schedule.ts +95 -0
  200. package/src/thread-message-queue.e2e.test.ts +10 -202
  201. package/src/undo-redo.e2e.test.ts +2 -2
  202. package/src/voice-attachment.ts +29 -1
  203. package/src/voice-message.e2e.test.ts +10 -10
  204. package/src/voice.test.ts +63 -29
  205. package/src/worktree-clone-isolation.e2e.test.ts +213 -0
  206. package/src/worktree-lifecycle.e2e.test.ts +29 -9
  207. package/src/worktrees.test.ts +505 -64
  208. package/src/worktrees.ts +257 -603
  209. package/src/xai-auth-plugin.ts +1 -0
  210. package/src/xai-auth-state.ts +1 -0
  211. package/dist/message-finish-field.e2e.test.js +0 -169
  212. package/dist/subagent-rate-limit-plugin.js +0 -184
  213. package/src/message-finish-field.e2e.test.ts +0 -196
  214. package/src/subagent-rate-limit-plugin.ts +0 -228
package/dist/db.js CHANGED
@@ -89,6 +89,20 @@ async function initializeDb() {
89
89
  dbInstance = db;
90
90
  return db;
91
91
  }
92
+ // CREATE TABLE IF NOT EXISTS cannot drop columns. The first two session_sleeps
93
+ // shapes kept thread_id NOT NULL and posted_at. Inserts now omit thread_id, so
94
+ // existing DBs fail with SQLITE_CONSTRAINT_NOTNULL until those columns go away.
95
+ async function dropLegacySessionSleepsColumns(client) {
96
+ const info = await client.execute('PRAGMA table_info(session_sleeps)');
97
+ const columns = new Set(info.rows.map((row) => String(row.name)));
98
+ if (columns.has('thread_id')) {
99
+ await client.execute('ALTER TABLE session_sleeps DROP COLUMN thread_id');
100
+ }
101
+ if (columns.has('posted_at')) {
102
+ await client.execute('ALTER TABLE session_sleeps DROP COLUMN posted_at');
103
+ }
104
+ await client.execute("UPDATE session_sleeps SET status = 'planned' WHERE status = 'posted'");
105
+ }
92
106
  async function migrateSchema({ db, client, }) {
93
107
  const schemaPath = path.join(__dirname, '../src/schema.sql');
94
108
  const sql = fs.readFileSync(schemaPath, 'utf-8');
@@ -120,7 +134,12 @@ async function migrateSchema({ db, client, }) {
120
134
  "ALTER TABLE thread_sessions ADD COLUMN source TEXT DEFAULT 'kimaki'",
121
135
  'ALTER TABLE thread_sessions ADD COLUMN last_synced_name TEXT',
122
136
  'ALTER TABLE thread_sessions ADD COLUMN parent_session_id TEXT',
137
+ 'ALTER TABLE thread_sessions ADD COLUMN updated_at DATETIME',
123
138
  'ALTER TABLE channel_directories ADD COLUMN guild_id TEXT',
139
+ // First session_sleeps shape had thread_id only. Later ticks query these.
140
+ 'ALTER TABLE session_sleeps ADD COLUMN delivery_id TEXT',
141
+ 'ALTER TABLE session_sleeps ADD COLUMN attempts INTEGER DEFAULT 0',
142
+ 'ALTER TABLE session_sleeps ADD COLUMN last_attempt_at DATETIME',
124
143
  ];
125
144
  for (const stmt of alterStatements) {
126
145
  await client.execute(stmt).catch(() => undefined);
@@ -140,10 +159,13 @@ async function migrateSchema({ db, client, }) {
140
159
  "UPDATE bot_tokens SET bot_mode = 'self_hosted' WHERE bot_mode = 'self-hosted'",
141
160
  "UPDATE bot_tokens SET proxy_url = REPLACE(proxy_url, 'discord-gateway.kimaki.xyz', 'discord-gateway.kimaki.dev') WHERE bot_mode = 'gateway' AND proxy_url LIKE '%discord-gateway.kimaki.xyz%'",
142
161
  "UPDATE thread_worktrees SET status = 'pending' WHERE status IS NULL",
162
+ "UPDATE session_sleeps SET delivery_id = lower(hex(randomblob(16))) WHERE delivery_id IS NULL",
163
+ 'UPDATE session_sleeps SET attempts = 0 WHERE attempts IS NULL',
143
164
  ];
144
165
  for (const stmt of migrationStatements) {
145
166
  await client.execute(stmt).catch(() => undefined);
146
167
  }
168
+ await dropLegacySessionSleepsColumns(client);
147
169
  // Migrate legacy thread_worktrees rows into thread_workspaces.
148
170
  // Rows that already exist in thread_workspaces (by thread_id) are skipped.
149
171
  await client.execute(`
package/dist/db.test.js CHANGED
@@ -7,7 +7,8 @@ import { afterAll, describe, expect, test } from 'vitest';
7
7
  import { closeDb, getDb } from './db.js';
8
8
  import * as orm from 'drizzle-orm';
9
9
  import * as schema from './schema.js';
10
- import { appendSessionEventsSinceLastTimestamp, createPendingWorkspace, getSessionEventSnapshot, getSessionModel, setSessionModel, } from './database.js';
10
+ import { appendSessionEventsSinceLastTimestamp, createPendingWorkspace, getDueSessionSleeps, getSessionEventSnapshot, getSessionModel, getSessionSleep, setSessionModel, upsertSessionSleep, } from './database.js';
11
+ import { createClient } from '@libsql/client';
11
12
  import { startHranaServer, stopHranaServer } from './hrana-server.js';
12
13
  import { chooseLockPort } from './test-utils.js';
13
14
  import { copyCurrentSessionModel } from './commands/model.js';
@@ -15,6 +16,171 @@ afterAll(async () => {
15
16
  await closeDb();
16
17
  });
17
18
  describe('getDb', () => {
19
+ test('schema.sql creates every drizzle table', () => {
20
+ const schemaTs = fs.readFileSync(path.join(import.meta.dirname, 'schema.ts'), 'utf8');
21
+ const schemaSql = fs.readFileSync(path.join(import.meta.dirname, 'schema.sql'), 'utf8');
22
+ const tablesFromTs = [
23
+ ...schemaTs.matchAll(/sqliteTable\('([^']+)'/g),
24
+ ].map((match) => match[1]);
25
+ const tablesFromSql = [
26
+ ...schemaSql.matchAll(/CREATE TABLE IF NOT EXISTS `([^`]+)`/g),
27
+ ].map((match) => match[1]);
28
+ expect(new Set(tablesFromSql)).toEqual(new Set(tablesFromTs));
29
+ expect(tablesFromSql).toContain('session_sleeps');
30
+ });
31
+ test('adds session_sleeps delivery columns on databases created before that schema', async () => {
32
+ await closeDb();
33
+ const previousDbUrl = process.env['KIMAKI_DB_URL'];
34
+ const dbPath = path.join(process.cwd(), `tmp/test-db-legacy-sleeps-${crypto.randomUUID().slice(0, 8)}.db`);
35
+ try {
36
+ const client = createClient({ url: `file:${dbPath}` });
37
+ await client.execute(`
38
+ CREATE TABLE thread_sessions (
39
+ thread_id text PRIMARY KEY,
40
+ session_id text NOT NULL
41
+ )
42
+ `);
43
+ await client.execute(`
44
+ CREATE TABLE session_sleeps (
45
+ session_id text PRIMARY KEY,
46
+ thread_id text NOT NULL,
47
+ wake_at datetime NOT NULL,
48
+ reason text,
49
+ status text DEFAULT 'planned' NOT NULL,
50
+ created_at datetime DEFAULT CURRENT_TIMESTAMP
51
+ )
52
+ `);
53
+ await client.execute(`
54
+ INSERT INTO thread_sessions (thread_id, session_id)
55
+ VALUES ('thr-legacy', 'ses-legacy')
56
+ `);
57
+ await client.execute(`
58
+ INSERT INTO session_sleeps (session_id, thread_id, wake_at, status)
59
+ VALUES ('ses-legacy', 'thr-legacy', '2020-01-01T00:00:00.000Z', 'planned')
60
+ `);
61
+ client.close();
62
+ process.env['KIMAKI_DB_URL'] = `file:${dbPath}`;
63
+ await getDb();
64
+ const due = await getDueSessionSleeps({
65
+ now: new Date('2026-08-21T00:00:00Z'),
66
+ retryAfterMs: 30_000,
67
+ limit: 10,
68
+ });
69
+ const legacy = due.find((row) => row.session_id === 'ses-legacy');
70
+ expect(legacy?.delivery_id).toBeTruthy();
71
+ expect(legacy?.attempts).toBe(0);
72
+ // The first table required thread_id. After the column was dropped from
73
+ // the schema, inserts that omit it must still work on existing databases.
74
+ await upsertSessionSleep({
75
+ sessionId: 'ses-legacy-new',
76
+ wakeAt: new Date('2026-08-26T00:00:00Z'),
77
+ reason: 'check the reply',
78
+ });
79
+ const created = await getSessionSleep({ sessionId: 'ses-legacy-new' });
80
+ expect(created?.status).toBe('planned');
81
+ expect(created?.reason).toBe('check the reply');
82
+ expect(created?.delivery_id).toBeTruthy();
83
+ }
84
+ finally {
85
+ await closeDb();
86
+ if (previousDbUrl === undefined) {
87
+ delete process.env['KIMAKI_DB_URL'];
88
+ }
89
+ else {
90
+ process.env['KIMAKI_DB_URL'] = previousDbUrl;
91
+ }
92
+ for (const file of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) {
93
+ try {
94
+ fs.unlinkSync(file);
95
+ }
96
+ catch {
97
+ // Test cleanup best effort.
98
+ }
99
+ }
100
+ }
101
+ });
102
+ test('rebuilds session_sleeps that still have posted_at from the intermediate schema', async () => {
103
+ await closeDb();
104
+ const previousDbUrl = process.env['KIMAKI_DB_URL'];
105
+ const dbPath = path.join(process.cwd(), `tmp/test-db-posted-sleeps-${crypto.randomUUID().slice(0, 8)}.db`);
106
+ try {
107
+ const client = createClient({ url: `file:${dbPath}` });
108
+ await client.execute(`
109
+ CREATE TABLE thread_sessions (
110
+ thread_id text PRIMARY KEY,
111
+ session_id text NOT NULL
112
+ )
113
+ `);
114
+ await client.execute(`
115
+ CREATE TABLE session_sleeps (
116
+ session_id text PRIMARY KEY,
117
+ thread_id text NOT NULL,
118
+ wake_at datetime NOT NULL,
119
+ reason text,
120
+ status text DEFAULT 'planned' NOT NULL,
121
+ delivery_id text NOT NULL,
122
+ attempts integer DEFAULT 0 NOT NULL,
123
+ last_attempt_at datetime,
124
+ posted_at datetime,
125
+ created_at datetime DEFAULT CURRENT_TIMESTAMP
126
+ )
127
+ `);
128
+ await client.execute(`
129
+ INSERT INTO thread_sessions (thread_id, session_id)
130
+ VALUES ('thr-posted', 'ses-posted')
131
+ `);
132
+ await client.execute(`
133
+ INSERT INTO session_sleeps (
134
+ session_id, thread_id, wake_at, status, delivery_id, posted_at
135
+ )
136
+ VALUES (
137
+ 'ses-posted',
138
+ 'thr-posted',
139
+ '2020-01-01T00:00:00.000Z',
140
+ 'posted',
141
+ 'del-posted',
142
+ '2020-01-01T00:00:01.000Z'
143
+ )
144
+ `);
145
+ client.close();
146
+ process.env['KIMAKI_DB_URL'] = `file:${dbPath}`;
147
+ await getDb();
148
+ const due = await getDueSessionSleeps({
149
+ now: new Date('2026-08-21T00:00:00Z'),
150
+ retryAfterMs: 30_000,
151
+ limit: 10,
152
+ });
153
+ const posted = due.find((row) => row.session_id === 'ses-posted');
154
+ expect(posted?.status).toBe('planned');
155
+ expect(posted?.delivery_id).toBe('del-posted');
156
+ await upsertSessionSleep({
157
+ sessionId: 'ses-posted',
158
+ wakeAt: new Date('2026-08-26T00:00:00Z'),
159
+ reason: 'retry after schema change',
160
+ });
161
+ const updated = await getSessionSleep({ sessionId: 'ses-posted' });
162
+ expect(updated?.status).toBe('planned');
163
+ expect(updated?.reason).toBe('retry after schema change');
164
+ expect(updated?.delivery_id).not.toBe('del-posted');
165
+ }
166
+ finally {
167
+ await closeDb();
168
+ if (previousDbUrl === undefined) {
169
+ delete process.env['KIMAKI_DB_URL'];
170
+ }
171
+ else {
172
+ process.env['KIMAKI_DB_URL'] = previousDbUrl;
173
+ }
174
+ for (const file of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) {
175
+ try {
176
+ fs.unlinkSync(file);
177
+ }
178
+ catch {
179
+ // Test cleanup best effort.
180
+ }
181
+ }
182
+ }
183
+ });
18
184
  test('creates sqlite file and migrates schema automatically', async () => {
19
185
  const db = await getDb();
20
186
  const [session] = await db.insert(schema.thread_sessions)
@@ -8,7 +8,7 @@
8
8
  // suppresses these when shuttingDown is already set, and we removeAllListeners()
9
9
  // before destroying the client to prevent late events from becoming uncaught.
10
10
  import { DiscordOperationError } from './errors.js';
11
- import { initDatabase, closeDatabase, getThreadWorktreeOrWorkspace, getThreadSession, getChannelWorktreesEnabled, getChannelMentionMode, getChannelDirectory, cancelAllPendingIpcRequests, deleteChannelDirectoryById, createPendingWorkspace, setWorkspaceReady, } from './database.js';
11
+ import { initDatabase, closeDatabase, getThreadWorktreeOrWorkspace, getThreadSession, getChannelWorktreesEnabled, getChannelMentionMode, getChannelDirectory, cancelAllPendingIpcRequests, consumeSessionSleepWake, deleteChannelDirectoryById, createPendingWorkspace, setWorkspaceReady, } from './database.js';
12
12
  import { stopOpencodeServer, } from './opencode.js';
13
13
  import { formatAutoWorktreeName, createWorktreeInBackground, worktreeCreatingMessage } from './commands/new-worktree.js';
14
14
  import { resolveSessionWorkingDirectory, git, isGitRepositoryRoot } from './worktrees.js';
@@ -277,8 +277,8 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
277
277
  discordLogger.warn(`[GATEWAY] Shard ${shardId} reconnecting: ${parts.join(', ')}`);
278
278
  if (state.attempts >= MAX_RECONNECT_ATTEMPTS) {
279
279
  discordLogger.error(`[GATEWAY] Shard ${shardId} exceeded ${MAX_RECONNECT_ATTEMPTS} reconnect attempts, self-restarting`);
280
- // Self-restart: cleanup, then exit non-zero so the bin.ts wrapper
281
- // restarts us. Without the wrapper this exits after logging a warning.
280
+ // Self-restart: cleanup, then SIGKILL so the bin.ts wrapper
281
+ // restarts us. Without the wrapper this dies after logging a warning.
282
282
  void selfRestart('gateway-reconnect-limit');
283
283
  }
284
284
  });
@@ -613,6 +613,21 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
613
613
  }
614
614
  void cancelPendingFileUpload(thread.id);
615
615
  }
616
+ // A sleep wake only becomes a turn if it can still claim its own row.
617
+ // The claim fails when the user cancelled the sleep while the wake was
618
+ // being posted, so the superseded wake is dropped instead of starting a
619
+ // turn the user already replaced. Cancellation for every other kind of
620
+ // message happens inside runtime.enqueueIncoming().
621
+ const isSleepWake = Boolean(promptMarker?.sleepWake);
622
+ if (isSleepWake) {
623
+ const claimedWake = promptMarker?.sleepId
624
+ ? await consumeSessionSleepWake({ deliveryId: promptMarker.sleepId })
625
+ : false;
626
+ if (!claimedWake) {
627
+ discordLogger.log(`[SLEEP] ignoring superseded wake in thread ${thread.id}`);
628
+ return;
629
+ }
630
+ }
616
631
  // Expensive pre-processing (voice transcription, context fetch,
617
632
  // attachment download) runs inside the runtime's serialized
618
633
  // preprocess chain, preserving Discord arrival order without
@@ -631,6 +646,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
631
646
  permissions: cliInjectedPermissions,
632
647
  injectionGuardPatterns: cliInjectedInjectionGuardPatterns,
633
648
  parentSessionId: cliInjectedParentSessionId,
649
+ isSleepWake: isSleepWake || undefined,
634
650
  noReply: isLeadingMentionToOtherUser || undefined,
635
651
  sessionStartSource: sessionStartSource
636
652
  ? {
@@ -771,15 +787,10 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
771
787
  rest: discordClient.rest,
772
788
  });
773
789
  }
774
- const sessionDirectory = await (async () => {
775
- if (!worktreePromise) {
776
- return projectDirectory;
777
- }
778
- const result = await worktreePromise;
779
- if (result instanceof Error)
780
- return projectDirectory;
781
- return result;
782
- })();
790
+ const worktreeResult = worktreePromise ? await worktreePromise : projectDirectory;
791
+ if (worktreeResult instanceof Error)
792
+ return;
793
+ const sessionDirectory = worktreeResult;
783
794
  const channelRuntime = getOrCreateRuntime({
784
795
  threadId: thread.id,
785
796
  thread,
@@ -1055,18 +1066,10 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
1055
1066
  }
1056
1067
  discordLogger.log(`[BOT_SESSION] Starting session for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}..."`);
1057
1068
  const botThreadStartSource = parseSessionStartSourceFromMarker(marker);
1058
- const sessionDirectory = await (async () => {
1059
- if (cwdDirectory) {
1060
- return cwdDirectory;
1061
- }
1062
- if (!worktreePromise) {
1063
- return projectDirectory;
1064
- }
1065
- const result = await worktreePromise;
1066
- if (result instanceof Error)
1067
- return projectDirectory;
1068
- return result;
1069
- })();
1069
+ const worktreeResult = worktreePromise ? await worktreePromise : undefined;
1070
+ if (worktreeResult instanceof Error)
1071
+ return;
1072
+ const sessionDirectory = cwdDirectory ?? worktreeResult ?? projectDirectory;
1070
1073
  const runtime = getOrCreateRuntime({
1071
1074
  threadId: thread.id,
1072
1075
  thread,
@@ -1244,13 +1247,11 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
1244
1247
  discordLogger.error('Failed to write heap snapshot:', e instanceof Error ? e.message : String(e));
1245
1248
  });
1246
1249
  });
1247
- // Self-restart: exit with code 1 so the bin.ts wrapper restarts us with
1248
- // exponential backoff and crash-loop detection. When running without the
1249
- // wrapper (e.g. `tsx src/cli.ts`), the process just exits and the user
1250
- // sees the non-zero exit code — they should use `tsx src/bin.ts` instead
1251
- // for auto-restart support. The previous detached-spawn fallback was
1252
- // unreliable: process.exit(0) could kill the child before it started,
1253
- // there was no backoff between restarts, and spawn failures were silent.
1250
+ // Self-restart: die so the bin.ts wrapper restarts us with exponential
1251
+ // backoff and crash-loop detection. process.exit() can hang joining native
1252
+ // worker threads after Discord gateway failures, so SIGKILL instead.
1253
+ // When running without the wrapper (e.g. `tsx src/cli.ts`), the process
1254
+ // just dies — use `tsx src/bin.ts` for auto-restart support.
1254
1255
  let selfRestarting = false;
1255
1256
  async function selfRestart(reason) {
1256
1257
  if (selfRestarting) {
@@ -1259,6 +1260,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
1259
1260
  }
1260
1261
  selfRestarting = true;
1261
1262
  discordLogger.log(`Self-restarting (reason: ${reason})...`);
1263
+ setTimeout(() => process.kill(process.pid, 'SIGKILL'), 15_000);
1262
1264
  try {
1263
1265
  await handleShutdown(reason, { skipExit: true });
1264
1266
  }
@@ -1268,7 +1270,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
1268
1270
  if (!process.env.__KIMAKI_CHILD) {
1269
1271
  discordLogger.warn('No restart wrapper detected. Run via `tsx src/bin.ts` (dev) or `kimaki` (npm) for auto-restart on crash.');
1270
1272
  }
1271
- process.exit(1);
1273
+ process.kill(process.pid, 'SIGKILL');
1272
1274
  }
1273
1275
  process.on('SIGUSR2', () => {
1274
1276
  discordLogger.log('Received SIGUSR2, restarting after cleanup...');
@@ -98,7 +98,15 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
98
98
  .toJSON(),
99
99
  new SlashCommandBuilder()
100
100
  .setName('merge-worktree')
101
- .setDescription(truncateCommandDescription('Squash-merge worktree into default branch. Aborts if main has uncommitted changes.'))
101
+ .setDescription(truncateCommandDescription('Merge worktree commits into the default branch'))
102
+ .addStringOption((option) => {
103
+ option
104
+ .setName('strategy')
105
+ .setDescription(truncateCommandDescription('How to merge commits (default: keep commits)'))
106
+ .setRequired(false)
107
+ .addChoices({ name: 'Keep commits (rebase)', value: 'rebase' }, { name: 'Squash into one commit', value: 'squash' });
108
+ return option;
109
+ })
102
110
  .addStringOption((option) => {
103
111
  option
104
112
  .setName('target-branch')
@@ -168,11 +168,6 @@ export async function archiveThread({ rest, threadId, parentChannelId, sessionId
168
168
  if (updateResult instanceof Error) {
169
169
  discordLogger.warn(`[archive-thread] ${updateResult.message}`);
170
170
  }
171
- const abortResult = await client.session.abort({ sessionID: sessionId })
172
- .catch((e) => new Error('Failed to abort session', { cause: e }));
173
- if (abortResult instanceof Error) {
174
- discordLogger.warn(`[archive-thread] ${abortResult.message}`);
175
- }
176
171
  }
177
172
  if (archiveDelay > 0) {
178
173
  await new Promise((resolve) => {
@@ -185,6 +180,19 @@ export async function archiveThread({ rest, threadId, parentChannelId, sessionId
185
180
  body: { archived: true },
186
181
  });
187
182
  }
183
+ export const DISCORD_THREAD_RENAME_TIMEOUT_MS = 3000;
184
+ // Discord rename can hang on the 3rd call (~2 per 10 min). Race so it never blocks.
185
+ export async function raceDiscordRename({ rename, timeoutMs = DISCORD_THREAD_RENAME_TIMEOUT_MS, }) {
186
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
187
+ return Promise.race([
188
+ rename,
189
+ new Promise((resolve) => {
190
+ timeoutSignal.addEventListener('abort', () => {
191
+ resolve('timeout');
192
+ });
193
+ }),
194
+ ]);
195
+ }
188
196
  /**
189
197
  * Add a user as a thread member so the thread shows up in their Discord left
190
198
  * sidebar. Discord only lists threads you are a member of, so a scheduled
@@ -1,6 +1,6 @@
1
1
  import { PermissionsBitField } from 'discord.js';
2
2
  import { afterEach, describe, expect, test } from 'vitest';
3
- import { hasKimakiAdminPermission, hasKimakiBotPermission, resolveGuildMessageMember, splitMarkdownForDiscord, } from './discord-utils.js';
3
+ import { hasKimakiAdminPermission, hasKimakiBotPermission, raceDiscordRename, resolveGuildMessageMember, splitMarkdownForDiscord, } from './discord-utils.js';
4
4
  import { store } from './store.js';
5
5
  describe('splitMarkdownForDiscord', () => {
6
6
  test('never returns chunks over the max length with code fences', () => {
@@ -248,3 +248,19 @@ describe('resolveGuildMessageMember', () => {
248
248
  await expect(resolveGuildMessageMember(message)).resolves.toBe(null);
249
249
  });
250
250
  });
251
+ describe('raceDiscordRename', () => {
252
+ test('returns timeout when rename hangs', async () => {
253
+ const result = await raceDiscordRename({
254
+ rename: new Promise(() => { }),
255
+ timeoutMs: 20,
256
+ });
257
+ expect(result).toBe('timeout');
258
+ });
259
+ test('returns rename result when it finishes first', async () => {
260
+ const result = await raceDiscordRename({
261
+ rename: Promise.resolve('ok'),
262
+ timeoutMs: 1000,
263
+ });
264
+ expect(result).toBe('ok');
265
+ });
266
+ });
package/dist/errors.js CHANGED
@@ -109,6 +109,11 @@ export class OpenCodeSdkError extends errore.createTaggedError({
109
109
  message: 'OpenCode SDK call failed: $operation',
110
110
  }) {
111
111
  }
112
+ export class InvalidModelError extends errore.createTaggedError({
113
+ name: 'InvalidModelError',
114
+ message: 'Invalid model "$model": $reason',
115
+ }) {
116
+ }
112
117
  export class FilesystemOperationError extends errore.createTaggedError({
113
118
  name: 'FilesystemOperationError',
114
119
  message: 'Filesystem operation failed: $operation',
@@ -1,23 +1,35 @@
1
- import { exec } from 'node:child_process';
1
+ import { exec, execFile } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
3
  const DEFAULT_EXEC_TIMEOUT_MS = 10_000;
4
4
  const _execAsync = promisify(exec);
5
+ const _execFileAsync = promisify(execFile);
5
6
  export function execAsync(command, options) {
6
7
  const timeoutMs = options?.timeout || DEFAULT_EXEC_TIMEOUT_MS;
7
- const execPromise = _execAsync(command, options);
8
+ const childPromise = typeof command === 'string'
9
+ ? _execAsync(command, options)
10
+ : _execFileAsync(command.command, command.args, options);
11
+ const execPromise = childPromise.then(({ stdout, stderr }) => {
12
+ return {
13
+ stdout: typeof stdout === 'string' ? stdout : stdout.toString(),
14
+ stderr: typeof stderr === 'string' ? stderr : stderr.toString(),
15
+ };
16
+ });
17
+ const commandLabel = typeof command === 'string'
18
+ ? command
19
+ : [command.command, ...command.args].join(' ');
8
20
  let timer;
9
21
  const timeoutPromise = new Promise((_, reject) => {
10
22
  timer = setTimeout(() => {
11
- const pid = execPromise.child?.pid;
23
+ const pid = childPromise.child?.pid;
12
24
  if (pid) {
13
25
  try {
14
26
  process.kill(-pid, 'SIGTERM');
15
27
  }
16
28
  catch {
17
- execPromise.child?.kill('SIGTERM');
29
+ childPromise.child?.kill('SIGTERM');
18
30
  }
19
31
  }
20
- reject(new Error(`Command timed out after ${timeoutMs}ms: ${command}`));
32
+ reject(new Error(`Command timed out after ${timeoutMs}ms: ${commandLabel}`));
21
33
  }, timeoutMs);
22
34
  });
23
35
  return Promise.race([execPromise, timeoutPromise]).finally(() => {
@@ -68,14 +68,14 @@ describe('external directory permissions', () => {
68
68
  ⬥ reading external directory
69
69
  ┣ read *probe.txt*
70
70
  ⬥ external-directory-probe-done
71
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
71
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>
72
72
  --- from: user (external-directory-tester)
73
73
  EXTERNAL_DIRECTORY_PROBE_MARKER followup
74
74
  --- from: assistant (TestBot)
75
75
  ⬥ reading external directory
76
76
  ┣ read *probe.txt*
77
77
  ⬥ external-directory-probe-done
78
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
78
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>"
79
79
  `);
80
80
  expect(text).not.toContain('Permission Required');
81
81
  });