kimaki 0.28.0 → 0.30.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 (179) hide show
  1. package/dist/agent-model.e2e.test.js +33 -33
  2. package/dist/anthropic-account-identity.js +1 -1
  3. package/dist/anthropic-auth-plugin.js +5 -5
  4. package/dist/anthropic-auth-plugin.test.js +30 -2
  5. package/dist/anthropic-auth-state.js +1 -1
  6. package/dist/bash-tool-schema-plugin.js +18 -0
  7. package/dist/bash-tool-schema-plugin.test.js +20 -1
  8. package/dist/bin.js +7 -3
  9. package/dist/bot-lifecycle.test.js +206 -0
  10. package/dist/cache-drift-plugin.js +25 -18
  11. package/dist/cache-rewrite.js +39 -0
  12. package/dist/cache-rewrite.test.js +41 -0
  13. package/dist/channel-management.js +113 -51
  14. package/dist/cli-commands/misc.js +4 -1
  15. package/dist/cli-commands/project.js +7 -4
  16. package/dist/cli-commands/session.js +118 -36
  17. package/dist/cli-commands/thread.js +63 -0
  18. package/dist/cli-parsing.test.js +3 -3
  19. package/dist/cli-runner.js +11 -1
  20. package/dist/cli-send-thread.e2e.test.js +2 -2
  21. package/dist/cli.js +6 -4
  22. package/dist/commands/ask-question.js +72 -23
  23. package/dist/commands/queue.js +84 -36
  24. package/dist/commands/queue.test.js +15 -0
  25. package/dist/commands/restart-opencode-server.js +7 -1
  26. package/dist/commands/session-id.js +5 -5
  27. package/dist/database.js +149 -6
  28. package/dist/db.js +34 -0
  29. package/dist/db.test.js +199 -1
  30. package/dist/default-channel-provisioning.test.js +2 -1
  31. package/dist/discord-bot.js +220 -146
  32. package/dist/discord-command-registration.js +1 -1
  33. package/dist/external-directory-permissions.e2e.test.js +3 -3
  34. package/dist/external-directory-project-deny.e2e.test.js +1 -1
  35. package/dist/forum-sync/watchers.js +15 -0
  36. package/dist/gateway-proxy.e2e.test.js +9 -9
  37. package/dist/guild-categories.test.js +225 -0
  38. package/dist/hrana-server.js +68 -14
  39. package/dist/interaction-handler.js +5 -1
  40. package/dist/kimaki-opencode-plugin.js +2 -0
  41. package/dist/kimaki-opencode-plugin.test.js +28 -0
  42. package/dist/list-channel-threads.js +163 -0
  43. package/dist/list-channel-threads.test.js +80 -0
  44. package/dist/markdown.js +164 -102
  45. package/dist/markdown.test.js +88 -20
  46. package/dist/message-formatting.js +29 -18
  47. package/dist/message-formatting.test.js +111 -3
  48. package/dist/opencode.js +27 -12
  49. package/dist/question-abort-clears-dropdown.e2e.test.js +1 -1
  50. package/dist/question-abort-resume.e2e.test.js +100 -0
  51. package/dist/question-abort-retry-text-before-dropdown.e2e.test.js +1 -1
  52. package/dist/question-text-before-dropdown.e2e.test.js +1 -1
  53. package/dist/queue-advanced-abort.e2e.test.js +3 -3
  54. package/dist/queue-advanced-action-buttons.e2e.test.js +5 -5
  55. package/dist/queue-advanced-e2e-setup.js +63 -0
  56. package/dist/queue-advanced-footer.e2e.test.js +18 -18
  57. package/dist/queue-advanced-model-switch.e2e.test.js +3 -4
  58. package/dist/queue-advanced-permissions-typing.e2e.test.js +2 -2
  59. package/dist/queue-advanced-typing-interrupt.e2e.test.js +3 -3
  60. package/dist/queue-delete-message.e2e.test.js +4 -4
  61. package/dist/queue-drain-after-interactive-ui.e2e.test.js +3 -3
  62. package/dist/queue-interrupt-drain.e2e.test.js +10 -10
  63. package/dist/queue-question-select-drain.e2e.test.js +5 -5
  64. package/dist/runtime-lifecycle.e2e.test.js +95 -11
  65. package/dist/schema.js +21 -0
  66. package/dist/session-handler/event-stream-state.js +240 -6
  67. package/dist/session-handler/event-stream-state.test.js +1116 -130
  68. package/dist/session-handler/thread-runtime-state.js +4 -0
  69. package/dist/session-handler/thread-session-runtime.js +499 -89
  70. package/dist/sleep.e2e.test.js +7 -7
  71. package/dist/store.js +1 -1
  72. package/dist/system-message.js +64 -23
  73. package/dist/system-message.test.js +69 -23
  74. package/dist/task-id-plugin.js +16 -0
  75. package/dist/task-runner.js +7 -0
  76. package/dist/task-schedule.test.js +1 -1
  77. package/dist/test-utils.js +5 -3
  78. package/dist/thread-message-queue.e2e.test.js +30 -31
  79. package/dist/undo-redo.e2e.test.js +3 -3
  80. package/dist/voice-message.e2e.test.js +64 -26
  81. package/dist/voice-routing-input.e2e.test.js +4 -4
  82. package/dist/voice-routing-question.e2e.test.js +4 -4
  83. package/dist/voice.js +107 -21
  84. package/dist/voice.test.js +52 -1
  85. package/dist/wait-session.js +24 -2
  86. package/dist/worktree-lifecycle.e2e.test.js +8 -8
  87. package/package.json +7 -7
  88. package/skills/egaki/SKILL.md +14 -0
  89. package/skills/holocron/SKILL.md +125 -42
  90. package/skills/opensrc/SKILL.md +20 -3
  91. package/skills/spiceflow/SKILL.md +53 -172
  92. package/skills/strada/SKILL.md +61 -232
  93. package/src/agent-model.e2e.test.ts +33 -33
  94. package/src/anthropic-account-identity.ts +1 -1
  95. package/src/anthropic-auth-plugin.test.ts +37 -2
  96. package/src/anthropic-auth-plugin.ts +9 -7
  97. package/src/anthropic-auth-state.ts +1 -0
  98. package/src/bash-tool-schema-plugin.test.ts +29 -0
  99. package/src/bash-tool-schema-plugin.ts +31 -0
  100. package/src/bin.ts +7 -3
  101. package/src/bot-lifecycle.test.ts +252 -0
  102. package/src/cache-drift-plugin.ts +34 -24
  103. package/src/cache-rewrite.test.ts +49 -0
  104. package/src/cache-rewrite.ts +96 -0
  105. package/src/channel-management.ts +144 -65
  106. package/src/cli-commands/misc.ts +4 -1
  107. package/src/cli-commands/project.ts +9 -4
  108. package/src/cli-commands/session.ts +167 -38
  109. package/src/cli-commands/thread.ts +86 -0
  110. package/src/cli-parsing.test.ts +3 -3
  111. package/src/cli-runner.ts +11 -0
  112. package/src/cli-send-thread.e2e.test.ts +2 -2
  113. package/src/cli.ts +8 -6
  114. package/src/commands/ask-question.ts +88 -25
  115. package/src/commands/queue.test.ts +19 -0
  116. package/src/commands/queue.ts +111 -45
  117. package/src/commands/restart-opencode-server.ts +12 -1
  118. package/src/commands/session-id.ts +5 -5
  119. package/src/database.ts +219 -6
  120. package/src/db.test.ts +236 -0
  121. package/src/db.ts +38 -0
  122. package/src/default-channel-provisioning.test.ts +2 -1
  123. package/src/discord-bot.ts +272 -185
  124. package/src/discord-command-registration.ts +3 -1
  125. package/src/external-directory-permissions.e2e.test.ts +3 -3
  126. package/src/external-directory-project-deny.e2e.test.ts +1 -1
  127. package/src/forum-sync/watchers.ts +20 -0
  128. package/src/gateway-proxy.e2e.test.ts +9 -9
  129. package/src/guild-categories.test.ts +260 -0
  130. package/src/hrana-server.ts +96 -16
  131. package/src/interaction-handler.ts +7 -0
  132. package/src/kimaki-opencode-plugin.test.ts +34 -0
  133. package/src/kimaki-opencode-plugin.ts +2 -0
  134. package/src/list-channel-threads.test.ts +90 -0
  135. package/src/list-channel-threads.ts +252 -0
  136. package/src/markdown.test.ts +108 -22
  137. package/src/markdown.ts +216 -103
  138. package/src/message-formatting.test.ts +129 -2
  139. package/src/message-formatting.ts +28 -14
  140. package/src/opencode.ts +28 -13
  141. package/src/question-abort-clears-dropdown.e2e.test.ts +1 -1
  142. package/src/question-abort-resume.e2e.test.ts +121 -0
  143. package/src/question-abort-retry-text-before-dropdown.e2e.test.ts +1 -1
  144. package/src/question-text-before-dropdown.e2e.test.ts +1 -1
  145. package/src/queue-advanced-abort.e2e.test.ts +3 -3
  146. package/src/queue-advanced-action-buttons.e2e.test.ts +5 -5
  147. package/src/queue-advanced-e2e-setup.ts +65 -0
  148. package/src/queue-advanced-footer.e2e.test.ts +18 -18
  149. package/src/queue-advanced-model-switch.e2e.test.ts +3 -4
  150. package/src/queue-advanced-permissions-typing.e2e.test.ts +2 -2
  151. package/src/queue-advanced-typing-interrupt.e2e.test.ts +3 -3
  152. package/src/queue-delete-message.e2e.test.ts +4 -4
  153. package/src/queue-drain-after-interactive-ui.e2e.test.ts +3 -3
  154. package/src/queue-interrupt-drain.e2e.test.ts +10 -10
  155. package/src/queue-question-select-drain.e2e.test.ts +5 -5
  156. package/src/runtime-lifecycle.e2e.test.ts +104 -10
  157. package/src/schema.sql +16 -0
  158. package/src/schema.ts +23 -0
  159. package/src/session-handler/event-stream-state.test.ts +1184 -133
  160. package/src/session-handler/event-stream-state.ts +322 -8
  161. package/src/session-handler/thread-runtime-state.ts +5 -0
  162. package/src/session-handler/thread-session-runtime.ts +597 -95
  163. package/src/sleep.e2e.test.ts +7 -7
  164. package/src/store.ts +2 -2
  165. package/src/system-message.test.ts +70 -23
  166. package/src/system-message.ts +67 -22
  167. package/src/task-id-plugin.ts +17 -0
  168. package/src/task-runner.ts +8 -0
  169. package/src/task-schedule.test.ts +1 -1
  170. package/src/test-utils.ts +5 -3
  171. package/src/thread-message-queue.e2e.test.ts +30 -31
  172. package/src/undo-redo.e2e.test.ts +3 -3
  173. package/src/voice-message.e2e.test.ts +70 -26
  174. package/src/voice-routing-input.e2e.test.ts +4 -4
  175. package/src/voice-routing-question.e2e.test.ts +4 -4
  176. package/src/voice.test.ts +56 -0
  177. package/src/voice.ts +135 -25
  178. package/src/wait-session.ts +25 -2
  179. package/src/worktree-lifecycle.e2e.test.ts +8 -8
@@ -0,0 +1,225 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { afterEach, beforeEach, expect, test } from 'vitest';
4
+ import { ChannelType, Client, Events, GatewayIntentBits } from 'discord.js';
5
+ import { DigitalDiscord } from 'discord-digital-twin/src';
6
+ import { setDataDir } from './config.js';
7
+ import { closeDb } from './db.js';
8
+ import { createDefaultKimakiChannel, createProjectChannels, ensureKimakiCategory, } from './channel-management.js';
9
+ import { getGuildCategories, initDatabase, setChannelDirectory, } from './database.js';
10
+ const guildId = '100000000000000111';
11
+ const otherGuildId = '100000000000000222';
12
+ let directory;
13
+ let discord;
14
+ let client;
15
+ async function startDiscord({ dataDir }) {
16
+ discord = new DigitalDiscord({
17
+ dbUrl: `file:${path.join(dataDir, 'discord.db')}`,
18
+ users: [{ id: '100000000000000555', username: 'server-owner' }],
19
+ guilds: [
20
+ { id: guildId, name: 'Studio', ownerId: '100000000000000555' },
21
+ { id: otherGuildId, name: 'Office', ownerId: '100000000000000555' },
22
+ ],
23
+ });
24
+ await discord.start();
25
+ client = new Client({
26
+ intents: [GatewayIntentBits.Guilds],
27
+ rest: { api: discord.restUrl, version: '10' },
28
+ });
29
+ const ready = new Promise((resolve) => client.once(Events.ClientReady, () => resolve()));
30
+ await client.login(discord.botToken);
31
+ await ready;
32
+ }
33
+ async function openDataDir() {
34
+ const root = path.resolve('tmp/guild-categories');
35
+ fs.mkdirSync(root, { recursive: true });
36
+ directory = fs.mkdtempSync(path.join(root, 'run-'));
37
+ setDataDir(directory);
38
+ await closeDb();
39
+ await initDatabase();
40
+ }
41
+ beforeEach(async () => {
42
+ await openDataDir();
43
+ await startDiscord({ dataDir: directory });
44
+ });
45
+ afterEach(async () => {
46
+ await client?.destroy();
47
+ await discord?.stop();
48
+ await closeDb();
49
+ fs.rmSync(directory, { recursive: true, force: true });
50
+ });
51
+ function guild() {
52
+ return client.guilds.cache.get(guildId);
53
+ }
54
+ test('first project creates a Kimaki group and stores its id', async () => {
55
+ const created = await createProjectChannels({
56
+ guild: guild(),
57
+ projectDirectory: path.join(directory, 'web'),
58
+ });
59
+ const channel = await guild().channels.fetch(created.textChannelId);
60
+ expect(channel?.parentId).toBeTruthy();
61
+ const category = await guild().channels.fetch(channel.parentId);
62
+ expect(category?.name).toBe('Kimaki');
63
+ expect(await getGuildCategories(guildId)).toEqual({
64
+ guild_id: guildId,
65
+ category_id: category.id,
66
+ audio_category_id: null,
67
+ });
68
+ });
69
+ test('renamed group still receives new project channels', async () => {
70
+ const first = await createProjectChannels({
71
+ guild: guild(),
72
+ projectDirectory: path.join(directory, 'web'),
73
+ });
74
+ const firstChannel = await guild().channels.fetch(first.textChannelId);
75
+ const category = await guild().channels.fetch(firstChannel.parentId);
76
+ await category.setName('laptop');
77
+ const second = await createProjectChannels({
78
+ guild: guild(),
79
+ projectDirectory: path.join(directory, 'api'),
80
+ });
81
+ const secondChannel = await guild().channels.fetch(second.textChannelId);
82
+ expect(secondChannel?.parentId).toBe(firstChannel?.parentId);
83
+ expect((await guild().channels.fetch(secondChannel.parentId))?.name).toBe('laptop');
84
+ });
85
+ test('a second machine creates its own Kimaki group', async () => {
86
+ const first = await createProjectChannels({
87
+ guild: guild(),
88
+ projectDirectory: path.join(directory, 'web'),
89
+ });
90
+ const firstParent = (await guild().channels.fetch(first.textChannelId))
91
+ ?.parentId;
92
+ await closeDb();
93
+ await openDataDir();
94
+ const second = await createProjectChannels({
95
+ guild: guild(),
96
+ projectDirectory: path.join(directory, 'api'),
97
+ });
98
+ const secondParent = (await guild().channels.fetch(second.textChannelId))
99
+ ?.parentId;
100
+ expect(secondParent).toBeTruthy();
101
+ expect(secondParent).not.toBe(firstParent);
102
+ const categories = [...guild().channels.cache.values()].filter((channel) => channel.type === ChannelType.GuildCategory);
103
+ expect(categories.map((channel) => channel.name).sort()).toEqual([
104
+ 'Kimaki',
105
+ 'Kimaki',
106
+ ]);
107
+ });
108
+ test('existing project channels adopt their parent without creating a new group', async () => {
109
+ const category = await guild().channels.create({
110
+ name: 'studio',
111
+ type: ChannelType.GuildCategory,
112
+ });
113
+ const text = await guild().channels.create({
114
+ name: 'web',
115
+ type: ChannelType.GuildText,
116
+ parent: category,
117
+ });
118
+ await setChannelDirectory({
119
+ channelId: text.id,
120
+ directory: path.join(directory, 'web'),
121
+ channelType: 'text',
122
+ guildId,
123
+ });
124
+ const resolved = await ensureKimakiCategory(guild());
125
+ expect(resolved.id).toBe(category.id);
126
+ expect(await getGuildCategories(guildId)).toMatchObject({
127
+ guild_id: guildId,
128
+ category_id: category.id,
129
+ });
130
+ const categoryCount = [...guild().channels.cache.values()].filter((channel) => channel.type === ChannelType.GuildCategory).length;
131
+ expect(categoryCount).toBe(1);
132
+ });
133
+ test('a second machine gets its own default kimaki channel', async () => {
134
+ const first = await createDefaultKimakiChannel({
135
+ guild: guild(),
136
+ appId: discord.botUserId,
137
+ isGatewayMode: true,
138
+ });
139
+ expect(first).toBeTruthy();
140
+ await closeDb();
141
+ await openDataDir();
142
+ const second = await createDefaultKimakiChannel({
143
+ guild: guild(),
144
+ appId: discord.botUserId,
145
+ isGatewayMode: true,
146
+ });
147
+ expect(second).toBeTruthy();
148
+ expect(second.textChannelId).not.toBe(first.textChannelId);
149
+ expect(second.textChannel.parentId).not.toBe(first.textChannel.parentId);
150
+ });
151
+ test('legacy rows from another guild do not block adoption', async () => {
152
+ const otherGuild = client.guilds.cache.get(otherGuildId);
153
+ const foreignCategory = await otherGuild.channels.create({
154
+ name: 'foreign',
155
+ type: ChannelType.GuildCategory,
156
+ });
157
+ const foreignChannel = await otherGuild.channels.create({
158
+ name: 'web',
159
+ type: ChannelType.GuildText,
160
+ parent: foreignCategory,
161
+ });
162
+ const localCategory = await guild().channels.create({
163
+ name: 'studio',
164
+ type: ChannelType.GuildCategory,
165
+ });
166
+ const localChannel = await guild().channels.create({
167
+ name: 'api',
168
+ type: ChannelType.GuildText,
169
+ parent: localCategory,
170
+ });
171
+ await setChannelDirectory({
172
+ channelId: foreignChannel.id,
173
+ directory: path.join(directory, 'foreign'),
174
+ channelType: 'text',
175
+ });
176
+ await setChannelDirectory({
177
+ channelId: localChannel.id,
178
+ directory: path.join(directory, 'api'),
179
+ channelType: 'text',
180
+ });
181
+ const resolved = await ensureKimakiCategory(guild());
182
+ expect(resolved.id).toBe(localCategory.id);
183
+ });
184
+ test('a kimaki-prefixed project channel does not block the default channel', async () => {
185
+ await createProjectChannels({
186
+ guild: guild(),
187
+ projectDirectory: path.join(directory, 'kimaki-tools'),
188
+ });
189
+ const created = await createDefaultKimakiChannel({
190
+ guild: guild(),
191
+ appId: discord.botUserId,
192
+ isGatewayMode: true,
193
+ });
194
+ expect(created).toBeTruthy();
195
+ expect(created.channelName).toBe('kimaki');
196
+ });
197
+ test('concurrent first creates share one group', async () => {
198
+ const [first, second] = await Promise.all([
199
+ ensureKimakiCategory(guild()),
200
+ ensureKimakiCategory(guild()),
201
+ ]);
202
+ expect(first.id).toBe(second.id);
203
+ const categories = [...guild().channels.cache.values()].filter((channel) => channel.type === ChannelType.GuildCategory);
204
+ expect(categories).toHaveLength(1);
205
+ });
206
+ test('deleted group is replaced and SQLite is updated', async () => {
207
+ const first = await createProjectChannels({
208
+ guild: guild(),
209
+ projectDirectory: path.join(directory, 'web'),
210
+ });
211
+ const firstParent = (await guild().channels.fetch(first.textChannelId))
212
+ ?.parentId;
213
+ await (await guild().channels.fetch(firstParent)).delete();
214
+ const second = await createProjectChannels({
215
+ guild: guild(),
216
+ projectDirectory: path.join(directory, 'api'),
217
+ });
218
+ const secondParent = (await guild().channels.fetch(second.textChannelId))
219
+ ?.parentId;
220
+ expect(secondParent).toBeTruthy();
221
+ expect(secondParent).not.toBe(firstParent);
222
+ expect(await getGuildCategories(guildId)).toMatchObject({
223
+ category_id: secondParent,
224
+ });
225
+ });
@@ -148,7 +148,7 @@ export async function startHranaServer({ dbPath, bindAll = false, }) {
148
148
  // Health check — no auth required
149
149
  if (pathname === '/health') {
150
150
  res.writeHead(200, { 'content-type': 'application/json' });
151
- res.end(JSON.stringify({ status: 'ok', pid: process.pid }));
151
+ res.end(JSON.stringify({ status: 'ok', pid: process.pid, wrapperPid: getWrapperPid() }));
152
152
  return;
153
153
  }
154
154
  // OpenCode server port discovery — no auth required (localhost only).
@@ -230,8 +230,12 @@ export async function stopHranaServer() {
230
230
  * Evict a previous kimaki instance on the lock port.
231
231
  * Fetches /health to get the running process PID, then kills it directly.
232
232
  * No lsof/netstat/spawnSync needed — the PID comes from the health response.
233
+ *
234
+ * SIGTERM first so the old bot can clean up. Its own shutdown deadline is
235
+ * 15s, so after a longer grace period we SIGKILL: a stuck process must never
236
+ * block every future start. Safe because the PID comes from our own /health.
233
237
  */
234
- export async function evictExistingInstance({ port }) {
238
+ export async function evictExistingInstance({ port, gracePeriodMs = 20_000, }) {
235
239
  const url = `http://127.0.0.1:${port}/health`;
236
240
  const probe = await fetch(url, { signal: AbortSignal.timeout(1000) }).catch((e) => new FetchError({ url, cause: e }));
237
241
  if (probe instanceof Error)
@@ -242,9 +246,15 @@ export async function evictExistingInstance({ port }) {
242
246
  const targetPid = body.pid;
243
247
  if (!targetPid || targetPid === process.pid)
244
248
  return;
245
- hranaLogger.log(`Evicting existing kimaki process (PID: ${targetPid}) on port ${port}`);
249
+ // Signal the bin.ts wrapper, not only the child: killing just the child
250
+ // looks like a crash and the old wrapper respawns it, which then evicts us.
251
+ // The wrapper forwards SIGTERM and does not restart after it.
252
+ const wrapperPid = body.wrapperPid && body.wrapperPid !== process.ppid
253
+ ? body.wrapperPid
254
+ : null;
255
+ hranaLogger.log(`Evicting existing kimaki process (PID: ${targetPid}, wrapper: ${wrapperPid ?? 'none'}) on port ${port}`);
246
256
  const killResult = errore.try(() => {
247
- process.kill(targetPid, 'SIGTERM');
257
+ process.kill(wrapperPid ?? targetPid, 'SIGTERM');
248
258
  }, (e) => new Error('Failed to send SIGTERM to existing kimaki process', {
249
259
  cause: e,
250
260
  }));
@@ -252,17 +262,61 @@ export async function evictExistingInstance({ port }) {
252
262
  hranaLogger.log(`Failed to kill PID ${targetPid}: ${killResult.message}`);
253
263
  return;
254
264
  }
255
- for (let attempt = 0; attempt < 10; attempt += 1) {
265
+ // Wait for process exit, not just a failed probe: a process that already
266
+ // closed its HTTP server can still be alive and holding the socket briefly.
267
+ if (await waitForProcessExit({ pid: targetPid, timeoutMs: gracePeriodMs })) {
268
+ return;
269
+ }
270
+ hranaLogger.log(`PID ${targetPid} still alive after ${gracePeriodMs / 1000}s SIGTERM grace period, sending SIGKILL`);
271
+ // Wrapper first so it cannot respawn the child we are about to kill.
272
+ const wrapperKillResult = wrapperPid
273
+ ? errore.try(() => {
274
+ process.kill(wrapperPid, 'SIGKILL');
275
+ }, (e) => new Error('Failed to send SIGKILL to kimaki wrapper', { cause: e }))
276
+ : null;
277
+ if (wrapperKillResult instanceof Error) {
278
+ hranaLogger.log(`Failed to kill wrapper PID ${wrapperPid}: ${wrapperKillResult.message}`);
279
+ }
280
+ const forceKillResult = errore.try(() => {
281
+ process.kill(targetPid, 'SIGKILL');
282
+ }, (e) => new Error('Failed to send SIGKILL to existing kimaki process', {
283
+ cause: e,
284
+ }));
285
+ if (forceKillResult instanceof Error) {
286
+ hranaLogger.log(`Failed to kill PID ${targetPid}: ${forceKillResult.message}`);
287
+ return;
288
+ }
289
+ if (await waitForProcessExit({ pid: targetPid, timeoutMs: 5_000 })) {
290
+ return;
291
+ }
292
+ hranaLogger.log(`PID ${targetPid} still alive after SIGKILL`);
293
+ }
294
+ // PID of the bin.ts respawn wrapper, only while it is alive (IPC connected).
295
+ // Without the connected check an orphan would report ppid 1.
296
+ function getWrapperPid() {
297
+ if (!process.env.__KIMAKI_CHILD || !process.connected) {
298
+ return null;
299
+ }
300
+ return process.ppid;
301
+ }
302
+ function isProcessAlive(pid) {
303
+ const result = errore.try(() => {
304
+ process.kill(pid, 0);
305
+ }, (e) => new Error('Process liveness check failed', { cause: e }));
306
+ if (result instanceof Error) {
307
+ // EPERM means the PID exists but belongs to another user.
308
+ return result.cause instanceof Error && Reflect.get(result.cause, 'code') === 'EPERM';
309
+ }
310
+ return true;
311
+ }
312
+ async function waitForProcessExit({ pid, timeoutMs, }) {
313
+ const deadline = Date.now() + timeoutMs;
314
+ while (Date.now() < deadline) {
315
+ if (!isProcessAlive(pid))
316
+ return true;
256
317
  await new Promise((resolve) => {
257
- setTimeout(resolve, 1000);
318
+ setTimeout(resolve, 200);
258
319
  });
259
- // Verify it's gone. Some shutdown paths need a few seconds to run cleanup,
260
- // so we avoid SIGKILL and just poll for up to 10 seconds.
261
- const secondProbe = await fetch(url, {
262
- signal: AbortSignal.timeout(2000),
263
- }).catch((e) => new FetchError({ url, cause: e }));
264
- if (secondProbe instanceof Error)
265
- return;
266
320
  }
267
- hranaLogger.log(`PID ${targetPid} still alive after 10s SIGTERM grace period`);
321
+ return !isProcessAlive(pid);
268
322
  }
@@ -28,7 +28,7 @@ import { handleAskQuestionSelectMenu } from './commands/ask-question.js';
28
28
  import { handleFileUploadButton, handleFileUploadModalSubmit, } from './commands/file-upload.js';
29
29
  import { handleActionButton } from './commands/action-buttons.js';
30
30
  import { handleHtmlActionButton } from './html-actions.js';
31
- import { handleQueueCommand, handleClearQueueCommand, handleQueueCommandCommand, handleQueueCommandAutocomplete, } from './commands/queue.js';
31
+ import { handleQueueCommand, handleClearQueueCommand, handleQueueCommandCommand, handleQueueCommandAutocomplete, handleQueueRemoveButton, QUEUE_REMOVE_CUSTOM_ID_PREFIX, } from './commands/queue.js';
32
32
  import { handleUndoCommand, handleRedoCommand } from './commands/undo-redo.js';
33
33
  import { handleUserCommand } from './commands/user-command.js';
34
34
  import { handleVerbosityCommand, handleVerbositySelectMenu, } from './commands/verbosity.js';
@@ -397,6 +397,10 @@ export function registerInteractionHandler({ discordClient, appId, }) {
397
397
  await handleActionButton(interaction);
398
398
  return;
399
399
  }
400
+ if (customId.startsWith(QUEUE_REMOVE_CUSTOM_ID_PREFIX)) {
401
+ await handleQueueRemoveButton(interaction);
402
+ return;
403
+ }
400
404
  if (customId.startsWith('html_action:')) {
401
405
  await handleHtmlActionButton(interaction);
402
406
  return;
@@ -11,6 +11,7 @@
11
11
  // - kitty-graphics-plugin: extract Kitty Graphics Protocol images from bash output
12
12
  // - file-edit-log: record edit/write/apply_patch files per session
13
13
  // - bash-tool-schema-plugin: add description and hasSideEffect to bash
14
+ // - task-id-plugin: drop invalid model-generated task resume IDs
14
15
  export { ipcToolsPlugin } from './ipc-tools-plugin.js';
15
16
  export { contextAwarenessPlugin } from './context-awareness-plugin.js';
16
17
  export { memoryOverviewPlugin } from './memory-overview-plugin.js';
@@ -29,3 +30,4 @@ export { injectionGuardInternal as injectionGuard } from 'opencode-injection-gua
29
30
  export { kimakiWorkspaceAdaptorPlugin } from './kimaki-workspace-adaptor.js';
30
31
  export { fileEditTrackerPlugin } from './file-edit-log.js';
31
32
  export { bashToolSchemaPlugin } from './bash-tool-schema-plugin.js';
33
+ export { taskIdPlugin } from './task-id-plugin.js';
@@ -1,5 +1,6 @@
1
1
  import { test, expect, describe } from 'vitest';
2
2
  import { condenseMemoryMd } from './condense-memory.js';
3
+ import { sanitizeTaskArguments } from './task-id-plugin.js';
3
4
  describe('condenseMemoryMd', () => {
4
5
  test('multiple headings with body content', () => {
5
6
  const content = [
@@ -96,3 +97,30 @@ describe('condenseMemoryMd', () => {
96
97
  `);
97
98
  });
98
99
  });
100
+ describe('sanitizeTaskArguments', () => {
101
+ test('removes a model-invented UUID task_id', () => {
102
+ const args = {
103
+ description: 'inspect repository',
104
+ prompt: 'Find the implementation',
105
+ subagent_type: 'explore',
106
+ task_id: 'ff120f9f-a28d-4d29-b691-b3a5f770759b',
107
+ };
108
+ sanitizeTaskArguments(args);
109
+ expect(args).toMatchInlineSnapshot(`
110
+ {
111
+ "description": "inspect repository",
112
+ "prompt": "Find the implementation",
113
+ "subagent_type": "explore",
114
+ }
115
+ `);
116
+ });
117
+ test('keeps an OpenCode session task_id for resume', () => {
118
+ const args = { task_id: 'ses_child' };
119
+ sanitizeTaskArguments(args);
120
+ expect(args).toMatchInlineSnapshot(`
121
+ {
122
+ "task_id": "ses_child",
123
+ }
124
+ `);
125
+ });
126
+ });
@@ -0,0 +1,163 @@
1
+ import { ChannelType, Routes } from 'discord.js';
2
+ import * as errore from 'errore';
3
+ import { DiscordApiError } from './errors.js';
4
+ import { isThreadChannelType } from './discord-utils.js';
5
+ export const DISCORD_THREAD_LIST_PAGE_SIZE = 100;
6
+ const ARCHIVED_THREAD_FETCH_LIMIT = 200;
7
+ class DiscordThreadListError extends errore.createTaggedError({
8
+ name: 'DiscordThreadListError',
9
+ message: 'Failed to list Discord threads for channel $channelId',
10
+ }) {
11
+ }
12
+ export function parseDiscordThreadPayload({ payload, parentId, guildId, }) {
13
+ if (!payload.id || !isThreadChannelType(payload.type ?? -1)) {
14
+ return null;
15
+ }
16
+ if (payload.parent_id && payload.parent_id !== parentId) {
17
+ return null;
18
+ }
19
+ const archived = Boolean(payload.thread_metadata?.archived);
20
+ return {
21
+ id: payload.id,
22
+ name: payload.name || payload.id,
23
+ parentId,
24
+ guildId: payload.guild_id || guildId,
25
+ archived,
26
+ archiveState: archived ? 'archived' : 'active',
27
+ lastMessageId: payload.last_message_id ?? null,
28
+ };
29
+ }
30
+ export function mergeDiscordChannelThreads({ threads, }) {
31
+ const byId = new Map();
32
+ for (const thread of threads) {
33
+ byId.set(thread.id, thread);
34
+ }
35
+ return [...byId.values()].sort((left, right) => {
36
+ if (left.archived !== right.archived) {
37
+ return left.archived ? 1 : -1;
38
+ }
39
+ const leftMessage = left.lastMessageId ?? '';
40
+ const rightMessage = right.lastMessageId ?? '';
41
+ if (leftMessage !== rightMessage) {
42
+ return rightMessage.localeCompare(leftMessage);
43
+ }
44
+ return left.name.localeCompare(right.name);
45
+ });
46
+ }
47
+ function parseThreadListPayload({ payload, parentId, guildId, }) {
48
+ if (!payload || typeof payload !== 'object') {
49
+ return { threads: [], hasMore: false };
50
+ }
51
+ const list = payload;
52
+ const threads = (list.threads ?? []).flatMap((thread) => {
53
+ const parsed = parseDiscordThreadPayload({
54
+ payload: thread,
55
+ parentId,
56
+ guildId,
57
+ });
58
+ return parsed ? [parsed] : [];
59
+ });
60
+ return {
61
+ threads,
62
+ hasMore: Boolean(list.has_more),
63
+ };
64
+ }
65
+ async function fetchDiscordJson({ rest, route, query, }) {
66
+ const result = await rest
67
+ .get(route, query ? { query } : undefined)
68
+ .catch((cause) => {
69
+ return new DiscordApiError({
70
+ status: String(Reflect.get(cause, 'status') ?? 'unknown'),
71
+ body: cause instanceof Error ? cause.message : String(cause),
72
+ cause,
73
+ });
74
+ });
75
+ return result;
76
+ }
77
+ async function listArchivedPublicThreads({ rest, channelId, guildId, }) {
78
+ const threads = [];
79
+ let before;
80
+ while (threads.length < ARCHIVED_THREAD_FETCH_LIMIT) {
81
+ const query = new URLSearchParams({
82
+ limit: String(DISCORD_THREAD_LIST_PAGE_SIZE),
83
+ });
84
+ if (before) {
85
+ query.set('before', before);
86
+ }
87
+ const payload = await fetchDiscordJson({
88
+ rest,
89
+ route: Routes.channelThreads(channelId, 'public'),
90
+ query,
91
+ });
92
+ if (payload instanceof DiscordApiError) {
93
+ return payload;
94
+ }
95
+ const page = parseThreadListPayload({
96
+ payload,
97
+ parentId: channelId,
98
+ guildId,
99
+ });
100
+ threads.push(...page.threads);
101
+ const lastThread = page.threads.at(-1);
102
+ if (!page.hasMore || !lastThread) {
103
+ break;
104
+ }
105
+ before = lastThread.id;
106
+ }
107
+ return threads;
108
+ }
109
+ export async function listDiscordChannelThreads({ rest, channelId, }) {
110
+ const channelPayload = await fetchDiscordJson({
111
+ rest,
112
+ route: Routes.channel(channelId),
113
+ });
114
+ if (channelPayload instanceof DiscordApiError) {
115
+ return { ok: false, error: channelPayload };
116
+ }
117
+ const channel = (channelPayload ?? {});
118
+ if (channel.type !== ChannelType.GuildText) {
119
+ return {
120
+ ok: false,
121
+ error: new DiscordThreadListError({
122
+ channelId,
123
+ cause: new Error('Channel is not a guild text channel'),
124
+ }),
125
+ };
126
+ }
127
+ const guildId = channel.guild_id;
128
+ if (!guildId) {
129
+ return {
130
+ ok: false,
131
+ error: new DiscordThreadListError({
132
+ channelId,
133
+ cause: new Error('Channel has no guild ID'),
134
+ }),
135
+ };
136
+ }
137
+ const activePayload = await fetchDiscordJson({
138
+ rest,
139
+ route: Routes.guildActiveThreads(guildId),
140
+ });
141
+ if (activePayload instanceof DiscordApiError) {
142
+ return { ok: false, error: activePayload };
143
+ }
144
+ const activeThreads = parseThreadListPayload({
145
+ payload: activePayload,
146
+ parentId: channelId,
147
+ guildId,
148
+ }).threads;
149
+ const archivedThreads = await listArchivedPublicThreads({
150
+ rest,
151
+ channelId,
152
+ guildId,
153
+ });
154
+ if (archivedThreads instanceof DiscordApiError) {
155
+ return { ok: false, error: archivedThreads };
156
+ }
157
+ return {
158
+ ok: true,
159
+ threads: mergeDiscordChannelThreads({
160
+ threads: [...activeThreads, ...archivedThreads],
161
+ }),
162
+ };
163
+ }
@@ -0,0 +1,80 @@
1
+ import { ChannelType } from 'discord.js';
2
+ import { describe, expect, test } from 'vitest';
3
+ import { mergeDiscordChannelThreads, parseDiscordThreadPayload, } from './list-channel-threads.js';
4
+ describe('list-channel-threads', () => {
5
+ test('parses public threads and skips other channel types', () => {
6
+ const parsed = parseDiscordThreadPayload({
7
+ payload: {
8
+ id: 'thread-1',
9
+ name: 'Fix auth timeout',
10
+ type: ChannelType.PublicThread,
11
+ parent_id: 'channel-1',
12
+ guild_id: 'guild-1',
13
+ last_message_id: 'msg-9',
14
+ thread_metadata: { archived: false },
15
+ },
16
+ parentId: 'channel-1',
17
+ guildId: 'guild-1',
18
+ });
19
+ expect(parsed).toMatchInlineSnapshot(`
20
+ {
21
+ "archiveState": "active",
22
+ "archived": false,
23
+ "guildId": "guild-1",
24
+ "id": "thread-1",
25
+ "lastMessageId": "msg-9",
26
+ "name": "Fix auth timeout",
27
+ "parentId": "channel-1",
28
+ }
29
+ `);
30
+ expect(parseDiscordThreadPayload({
31
+ payload: {
32
+ id: 'not-a-thread',
33
+ name: 'website',
34
+ type: ChannelType.GuildText,
35
+ parent_id: 'category-1',
36
+ },
37
+ parentId: 'channel-1',
38
+ guildId: 'guild-1',
39
+ })).toBe(null);
40
+ });
41
+ test('keeps active threads first and drops duplicate ids', () => {
42
+ const merged = mergeDiscordChannelThreads({
43
+ threads: [
44
+ {
45
+ id: 'thread-old',
46
+ name: 'Old work',
47
+ parentId: 'channel-1',
48
+ guildId: 'guild-1',
49
+ archived: true,
50
+ archiveState: 'archived',
51
+ lastMessageId: '2',
52
+ },
53
+ {
54
+ id: 'thread-new',
55
+ name: 'New work',
56
+ parentId: 'channel-1',
57
+ guildId: 'guild-1',
58
+ archived: false,
59
+ archiveState: 'active',
60
+ lastMessageId: '1',
61
+ },
62
+ {
63
+ id: 'thread-new',
64
+ name: 'New work duplicate',
65
+ parentId: 'channel-1',
66
+ guildId: 'guild-1',
67
+ archived: false,
68
+ archiveState: 'active',
69
+ lastMessageId: '9',
70
+ },
71
+ ],
72
+ });
73
+ expect(merged.map((thread) => thread.id)).toMatchInlineSnapshot(`
74
+ [
75
+ "thread-new",
76
+ "thread-old",
77
+ ]
78
+ `);
79
+ });
80
+ });