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
@@ -2,8 +2,8 @@
2
2
  // When the system prompt changes between user messages, it logs a warning
3
3
  // with addition/deletion line counts so you know context cache was discarded.
4
4
  // This helps spot plugins that mutate the system prompt unexpectedly.
5
- import { diffLines } from 'diff';
6
5
  import * as errore from 'errore';
6
+ import { countSystemPromptDiffLines, writeSystemPromptPatch, } from './cache-rewrite.js';
7
7
  import { createPluginLogger, formatPluginErrorWithStack, setPluginLogFilePath } from './plugin-logger.js';
8
8
  import { initSentry, notifyError } from './sentry.js';
9
9
  const logger = createPluginLogger('OPENCODE');
@@ -27,18 +27,6 @@ function shouldSuppressDriftWarning({ previousContext, currentContext, }) {
27
27
  || previousContext.model !== currentContext.model
28
28
  || previousContext.directory !== currentContext.directory);
29
29
  }
30
- function countDiffLines({ beforeText, afterText, }) {
31
- const changes = diffLines(beforeText, afterText);
32
- let additions = 0;
33
- let deletions = 0;
34
- for (const change of changes) {
35
- if (change.added)
36
- additions += change.count ?? 0;
37
- if (change.removed)
38
- deletions += change.count ?? 0;
39
- }
40
- return { additions, deletions };
41
- }
42
30
  function getOrCreateSessionState({ sessions, sessionId, }) {
43
31
  const existing = sessions.get(sessionId);
44
32
  if (existing)
@@ -56,7 +44,7 @@ function getOrCreateSessionState({ sessions, sessionId, }) {
56
44
  sessions.set(sessionId, state);
57
45
  return state;
58
46
  }
59
- function handleSystemTransform({ input, output, sessions, }) {
47
+ function handleSystemTransform({ input, output, sessions, dataDir, }) {
60
48
  const sessionId = input.sessionID;
61
49
  if (!sessionId)
62
50
  return;
@@ -79,12 +67,31 @@ function handleSystemTransform({ input, output, sessions, }) {
79
67
  })) {
80
68
  return;
81
69
  }
82
- const { additions, deletions } = countDiffLines({
70
+ const { additions, deletions } = countSystemPromptDiffLines({
71
+ beforeText: previousPrompt,
72
+ afterText: currentPrompt,
73
+ });
74
+ const model = state.currentTurnContext?.model;
75
+ const agent = state.currentTurnContext?.agent;
76
+ if (!dataDir) {
77
+ logger.warn(`[cache-drift] system prompt changed for session ${sessionId} `
78
+ + `(+${additions} / -${deletions}), no data dir for patch`);
79
+ return;
80
+ }
81
+ void writeSystemPromptPatch({
82
+ dataDir,
83
+ sessionId,
83
84
  beforeText: previousPrompt,
84
85
  afterText: currentPrompt,
86
+ model,
87
+ agent,
88
+ }).then((filePath) => {
89
+ logger.warn(`[cache-drift] system prompt changed for session ${sessionId} `
90
+ + `(+${additions} / -${deletions}) patch=${filePath}`);
91
+ }).catch((error) => {
92
+ logger.warn(`[cache-drift] ${formatPluginErrorWithStack(error)}`);
93
+ void notifyError(error, 'cache drift patch write failed');
85
94
  });
86
- logger.warn(`[cache-drift] context cache discarded for session ${sessionId}: `
87
- + `system prompt changed since previous message (+${additions} / -${deletions} lines)`);
88
95
  }
89
96
  function getDeletedSessionId({ event }) {
90
97
  if (event.type !== 'session.deleted')
@@ -131,7 +138,7 @@ const cacheDriftPlugin = async ({ directory }) => {
131
138
  state.pendingCompareTimeout = setTimeout(() => {
132
139
  state.pendingCompareTimeout = undefined;
133
140
  try {
134
- handleSystemTransform({ input, output, sessions });
141
+ handleSystemTransform({ input, output, sessions, dataDir });
135
142
  }
136
143
  catch (err) {
137
144
  logger.warn(`[cache-drift] ${formatPluginErrorWithStack(err)}`);
@@ -0,0 +1,39 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createTwoFilesPatch, diffLines } from 'diff';
4
+ export const CACHE_REWRITES_DIR = 'cache-rewrites';
5
+ export function countSystemPromptDiffLines({ beforeText, afterText, }) {
6
+ const changes = diffLines(beforeText, afterText);
7
+ let additions = 0;
8
+ let deletions = 0;
9
+ for (const change of changes) {
10
+ if (change.added)
11
+ additions += change.count ?? 0;
12
+ if (change.removed)
13
+ deletions += change.count ?? 0;
14
+ }
15
+ return { additions, deletions };
16
+ }
17
+ export function formatSystemPromptPatch({ beforeText, afterText, sessionId, model, agent, }) {
18
+ const { additions, deletions } = countSystemPromptDiffLines({ beforeText, afterText });
19
+ const patch = createTwoFilesPatch('system-before.txt', 'system-after.txt', beforeText, afterText, undefined, undefined, { context: 3 });
20
+ return [
21
+ `session: ${sessionId}`,
22
+ `model: ${model ?? 'unknown'}`,
23
+ `agent: ${agent ?? 'unknown'}`,
24
+ `system +${additions} -${deletions}`,
25
+ '',
26
+ patch,
27
+ ].join('\n');
28
+ }
29
+ export function cacheRewritePatchPath({ dataDir, sessionId, now = Date.now(), }) {
30
+ const safeSessionId = sessionId.replace(/[^a-zA-Z0-9_-]/g, '_');
31
+ return path.join(dataDir, CACHE_REWRITES_DIR, `${now}-${safeSessionId}.patch`);
32
+ }
33
+ export async function writeSystemPromptPatch({ dataDir, sessionId, beforeText, afterText, model, agent, now, }) {
34
+ const filePath = cacheRewritePatchPath({ dataDir, sessionId, now });
35
+ const dirPath = path.dirname(filePath);
36
+ await fs.promises.mkdir(dirPath, { recursive: true, mode: 0o700 });
37
+ await fs.promises.writeFile(filePath, formatSystemPromptPatch({ beforeText, afterText, sessionId, model, agent }), { encoding: 'utf8', mode: 0o600 });
38
+ return filePath;
39
+ }
@@ -0,0 +1,41 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { afterEach, expect, test } from 'vitest';
5
+ import { countSystemPromptDiffLines, formatSystemPromptPatch, writeSystemPromptPatch, } from './cache-rewrite.js';
6
+ const tempDirs = [];
7
+ afterEach(() => {
8
+ for (const dir of tempDirs.splice(0)) {
9
+ fs.rmSync(dir, { recursive: true, force: true });
10
+ }
11
+ });
12
+ test('counts added and removed system prompt lines', () => {
13
+ expect(countSystemPromptDiffLines({
14
+ beforeText: 'keep\nold line\n',
15
+ afterText: 'keep\nnew line\nextra\n',
16
+ })).toEqual({ additions: 2, deletions: 1 });
17
+ });
18
+ test('writes a unified patch only for the caller', async () => {
19
+ const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kimaki-cache-rewrite-'));
20
+ tempDirs.push(dataDir);
21
+ const filePath = await writeSystemPromptPatch({
22
+ dataDir,
23
+ sessionId: 'ses_test',
24
+ beforeText: 'alpha\n',
25
+ afterText: 'beta\n',
26
+ model: 'openai/gpt-5',
27
+ agent: 'build',
28
+ now: 1_700_000_000_000,
29
+ });
30
+ expect(path.basename(filePath)).toBe('1700000000000-ses_test.patch');
31
+ const text = fs.readFileSync(filePath, 'utf8');
32
+ expect(text).toContain('session: ses_test');
33
+ expect(text).toContain('system +1 -1');
34
+ expect(text).toContain('-alpha');
35
+ expect(text).toContain('+beta');
36
+ expect(formatSystemPromptPatch({
37
+ beforeText: 'alpha\n',
38
+ afterText: 'beta\n',
39
+ sessionId: 'ses_test',
40
+ })).toContain('--- system-before.txt');
41
+ });
@@ -4,7 +4,7 @@
4
4
  import { ChannelType, } from 'discord.js';
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
- import { getChannelDirectory, setChannelDirectory, findChannelsByDirectory, listTrackedTextChannels, } from './database.js';
7
+ import { getChannelDirectory, setChannelDirectory, findChannelsByDirectory, listTrackedTextChannels, getGuildCategories, setGuildCategoryId, } from './database.js';
8
8
  import { getProjectsDir } from './config.js';
9
9
  import { execAsync } from './worktrees.js';
10
10
  import { createLogger, LogPrefix } from './logger.js';
@@ -39,41 +39,117 @@ async function trackProjectRegistered({ projectKind, source, }) {
39
39
  trackEvent('project_registered', props);
40
40
  }
41
41
  const logger = createLogger(LogPrefix.CHANNEL);
42
- export async function ensureKimakiCategory(guild, botName) {
43
- // Skip appending bot name if it's already "kimaki" to avoid "Kimaki kimaki"
42
+ function defaultCategoryName(kind, botName) {
44
43
  const isKimakiBot = botName?.toLowerCase() === 'kimaki';
45
- const categoryName = botName && !isKimakiBot ? `Kimaki ${botName}` : 'Kimaki';
46
- const existingCategory = guild.channels.cache.find((channel) => {
47
- if (channel.type !== ChannelType.GuildCategory) {
48
- return false;
44
+ if (kind === 'audio') {
45
+ return botName && !isKimakiBot ? `Kimaki Audio ${botName}` : 'Kimaki Audio';
46
+ }
47
+ return botName && !isKimakiBot ? `Kimaki ${botName}` : 'Kimaki';
48
+ }
49
+ function defaultKimakiChannelName({ botName, isGatewayMode, }) {
50
+ if (isGatewayMode || !botName)
51
+ return 'kimaki';
52
+ const sanitized = botName
53
+ .toLowerCase()
54
+ .replace(/[^a-z0-9-]/g, '-')
55
+ .replace(/-+/g, '-')
56
+ .replace(/^-|-$/g, '');
57
+ if (!sanitized || sanitized === 'kimaki')
58
+ return 'kimaki';
59
+ return `kimaki-${sanitized}`.slice(0, 100);
60
+ }
61
+ const categoryEnsures = new Map();
62
+ function ensureCategorySerialized({ key, run, }) {
63
+ const existing = categoryEnsures.get(key);
64
+ if (existing)
65
+ return existing;
66
+ const promise = run().finally(() => {
67
+ if (categoryEnsures.get(key) === promise) {
68
+ categoryEnsures.delete(key);
49
69
  }
50
- return channel.name.toLowerCase() === categoryName.toLowerCase();
51
70
  });
52
- if (existingCategory) {
53
- return existingCategory;
71
+ categoryEnsures.set(key, promise);
72
+ return promise;
73
+ }
74
+ function isUnknownDiscordChannel(error) {
75
+ const code = error instanceof Error ? Reflect.get(error, 'code') : undefined;
76
+ const status = error instanceof Error ? Reflect.get(error, 'status') : undefined;
77
+ return code === 10003 || status === 404;
78
+ }
79
+ function isCategoryChannel(channel) {
80
+ return channel?.type === ChannelType.GuildCategory;
81
+ }
82
+ async function fetchCategoryById(guild, categoryId) {
83
+ const cached = guild.channels.cache.get(categoryId);
84
+ if (isCategoryChannel(cached))
85
+ return cached;
86
+ try {
87
+ const fetched = await guild.channels.fetch(categoryId);
88
+ return isCategoryChannel(fetched) ? fetched : null;
89
+ }
90
+ catch (error) {
91
+ if (isUnknownDiscordChannel(error))
92
+ return null;
93
+ throw error;
54
94
  }
55
- return guild.channels.create({
56
- name: categoryName,
57
- type: ChannelType.GuildCategory,
58
- });
59
95
  }
60
- export async function ensureKimakiAudioCategory(guild, botName) {
61
- // Skip appending bot name if it's already "kimaki" to avoid "Kimaki Audio kimaki"
62
- const isKimakiBot = botName?.toLowerCase() === 'kimaki';
63
- const categoryName = botName && !isKimakiBot ? `Kimaki Audio ${botName}` : 'Kimaki Audio';
64
- const existingCategory = guild.channels.cache.find((channel) => {
65
- if (channel.type !== ChannelType.GuildCategory) {
66
- return false;
67
- }
68
- return channel.name.toLowerCase() === categoryName.toLowerCase();
96
+ async function adoptParentFromTrackedChannels({ guild, channelType, }) {
97
+ const mappings = await findChannelsByDirectory({ channelType });
98
+ const channels = await guild.channels.fetch();
99
+ for (const row of mappings) {
100
+ if (row.guild_id && row.guild_id !== guild.id)
101
+ continue;
102
+ const channel = channels.get(row.channel_id);
103
+ if (!channel?.parentId)
104
+ continue;
105
+ const parent = await fetchCategoryById(guild, channel.parentId);
106
+ if (parent)
107
+ return parent;
108
+ }
109
+ return null;
110
+ }
111
+ async function resolveKimakiCategory({ guild, kind, botName, }) {
112
+ const stored = await getGuildCategories(guild.id);
113
+ const storedId = kind === 'audio' ? stored?.audio_category_id : stored?.category_id;
114
+ if (storedId) {
115
+ const existing = await fetchCategoryById(guild, storedId);
116
+ if (existing)
117
+ return existing;
118
+ }
119
+ const adopted = await adoptParentFromTrackedChannels({
120
+ guild,
121
+ channelType: kind === 'audio' ? 'voice' : 'text',
69
122
  });
70
- if (existingCategory) {
71
- return existingCategory;
123
+ if (adopted) {
124
+ await setGuildCategoryId({
125
+ guildId: guild.id,
126
+ kind,
127
+ categoryId: adopted.id,
128
+ });
129
+ return adopted;
72
130
  }
73
- return guild.channels.create({
74
- name: categoryName,
131
+ const created = await guild.channels.create({
132
+ name: defaultCategoryName(kind, botName),
75
133
  type: ChannelType.GuildCategory,
76
134
  });
135
+ await setGuildCategoryId({
136
+ guildId: guild.id,
137
+ kind,
138
+ categoryId: created.id,
139
+ });
140
+ return created;
141
+ }
142
+ export function ensureKimakiCategory(guild, botName) {
143
+ return ensureCategorySerialized({
144
+ key: `${guild.id}:text`,
145
+ run: () => resolveKimakiCategory({ guild, kind: 'text', botName }),
146
+ });
147
+ }
148
+ export function ensureKimakiAudioCategory(guild, botName) {
149
+ return ensureCategorySerialized({
150
+ key: `${guild.id}:audio`,
151
+ run: () => resolveKimakiCategory({ guild, kind: 'audio', botName }),
152
+ });
77
153
  }
78
154
  export async function createProjectChannels({ guild, projectDirectory, botName, enableVoiceChannels = false, analyticsSource = 'cli', }) {
79
155
  const baseName = path.basename(projectDirectory);
@@ -92,6 +168,7 @@ export async function createProjectChannels({ guild, projectDirectory, botName,
92
168
  channelId: textChannel.id,
93
169
  directory: projectDirectory,
94
170
  channelType: 'text',
171
+ guildId: guild.id,
95
172
  });
96
173
  await trackProjectRegistered({
97
174
  projectKind: 'user',
@@ -109,6 +186,7 @@ export async function createProjectChannels({ guild, projectDirectory, botName,
109
186
  channelId: voiceChannel.id,
110
187
  directory: projectDirectory,
111
188
  channelType: 'voice',
189
+ guildId: guild.id,
112
190
  });
113
191
  voiceChannelId = voiceChannel.id;
114
192
  }
@@ -158,8 +236,9 @@ const DEFAULT_CHANNEL_TOPIC = 'General channel for misc tasks with Kimaki. Not c
158
236
  * Directory is ~/.kimaki/projects/kimaki, git-initialized with a .gitignore.
159
237
  *
160
238
  * Idempotency: checks the database for an existing channel mapped to the
161
- * kimaki projects directory. Also scans guild channels by name+category
162
- * as a fallback for channels created before DB mapping existed.
239
+ * kimaki projects directory. Also scans this machine's category for the
240
+ * exact default channel name as a fallback for channels created before
241
+ * DB mapping existed.
163
242
  */
164
243
  export async function createDefaultKimakiChannel({ guild, botName, appId, isGatewayMode, }) {
165
244
  const projectDirectory = getDefaultKimakiDirectory();
@@ -209,11 +288,9 @@ export async function createDefaultKimakiChannel({ guild, botName, appId, isGate
209
288
  logger.log(`Default kimaki channel was previously provisioned for guild ${guild.name} (${guild.id}) as ${staleForThisGuild.channel_id}, but no longer exists. Skipping recreation.`);
210
289
  return null;
211
290
  }
212
- // 2. Fallback: detect existing channel by name+category.
213
- // If a "kimaki" channel already exists in the guild but is NOT in our local
214
- // DB, it was likely created by another kimaki instance (different machine).
215
- // Do NOT adopt it — just skip channel creation entirely to avoid both
216
- // instances fighting over the same channel.
291
+ // 2. Fallback: detect an existing default channel in THIS machine's group.
292
+ // A #kimaki channel in another machine's group is ignored.
293
+ const channelName = defaultKimakiChannelName({ botName, isGatewayMode });
217
294
  const kimakiCategory = await ensureKimakiCategory(guild, botName);
218
295
  const existingByName = guild.channels.cache.find((ch) => {
219
296
  if (ch.type !== ChannelType.GuildText) {
@@ -222,10 +299,10 @@ export async function createDefaultKimakiChannel({ guild, botName, appId, isGate
222
299
  if (ch.parentId !== kimakiCategory.id) {
223
300
  return false;
224
301
  }
225
- return ch.name === 'kimaki' || ch.name.startsWith('kimaki-');
302
+ return ch.name === channelName;
226
303
  });
227
304
  if (existingByName) {
228
- logger.log(`Found existing default kimaki channel by name: ${existingByName.id}, but it is not in our DB — skipping (likely owned by another kimaki instance)`);
305
+ logger.log(`Found existing default kimaki channel by name: ${existingByName.id}. Skipping recreation.`);
229
306
  return null;
230
307
  }
231
308
  // Git init — gracefully skip if git is not installed
@@ -244,21 +321,6 @@ export async function createDefaultKimakiChannel({ guild, botName, appId, isGate
244
321
  if (!fs.existsSync(gitignorePath)) {
245
322
  fs.writeFileSync(gitignorePath, DEFAULT_GITIGNORE);
246
323
  }
247
- // Channel name: "kimaki-{botName}" for self-hosted, "kimaki" for gateway
248
- const channelName = (() => {
249
- if (isGatewayMode || !botName) {
250
- return 'kimaki';
251
- }
252
- const sanitized = botName
253
- .toLowerCase()
254
- .replace(/[^a-z0-9-]/g, '-')
255
- .replace(/-+/g, '-')
256
- .replace(/^-|-$/g, '');
257
- if (!sanitized || sanitized === 'kimaki') {
258
- return 'kimaki';
259
- }
260
- return `kimaki-${sanitized}`.slice(0, 100);
261
- })();
262
324
  const textChannel = await guild.channels.create({
263
325
  name: channelName,
264
326
  type: ChannelType.GuildText,
@@ -24,6 +24,7 @@ import { execAsync, validateWorktreeDirectory } from '../worktrees.js';
24
24
  import { upgrade, getCurrentVersion } from '../upgrade.js';
25
25
  import { getPromptPreview, parseSendAtValue, parseScheduledTaskPayload, serializeScheduledTaskPayload } from '../task-schedule.js';
26
26
  import { EXIT_NO_RESTART, formatMemberLookupUnavailableMessage, formatRelativeTime, formatTaskScheduleLine, isDiscordMemberLookupUnavailable, isGuildMemberSearchResult, isThreadChannelType, printDiscordInstallUrlAndExit, resolveBotCredentials, resolveDiscordUserOption, sendDiscordMessageWithOptionalAttachment, } from '../cli-runner.js';
27
+ import { resolveUploadToDiscordSessionId } from '../bash-tool-schema-plugin.js';
27
28
  const cliLogger = createLogger(LogPrefix.CLI);
28
29
  const cli = goke();
29
30
  cli
@@ -31,7 +32,9 @@ cli
31
32
  .option('-s, --session <sessionId>', 'OpenCode session ID')
32
33
  .action(async (files, options) => {
33
34
  try {
34
- const { session: sessionId } = options;
35
+ const sessionId = resolveUploadToDiscordSessionId({
36
+ flagSessionId: options.session,
37
+ });
35
38
  if (!sessionId) {
36
39
  cliLogger.error('Session ID is required. Use --session <sessionId>');
37
40
  process.exit(EXIT_NO_RESTART);
@@ -12,7 +12,7 @@ import { spawn, execSync } from 'node:child_process';
12
12
  import { createLogger, LogPrefix, initLogFile } from '../logger.js';
13
13
  import { createDiscordClient, initDatabase, getChannelDirectory, initializeOpencodeForDirectory, createProjectChannels } from '../discord-bot.js';
14
14
  import { getDefaultKimakiDirectory } from '../channel-management.js';
15
- import { getBotTokenWithMode, getThreadSession, getThreadIdBySessionId, getSessionEventSnapshot, getDb, createScheduledTask, listScheduledTasks, cancelScheduledTask, getScheduledTask, updateScheduledTask, getSessionStartSourcesBySessionIds, deleteChannelDirectoryById, findChannelsByDirectory, findRegisteredTextChannelForDirectory, formatProjectAlreadyRegisteredError } from '../database.js';
15
+ import { getBotTokenWithMode, getThreadSession, getThreadIdBySessionId, getSessionEventSnapshot, getDb, createScheduledTask, listScheduledTasks, cancelScheduledTask, getScheduledTask, updateScheduledTask, getSessionStartSourcesBySessionIds, deleteChannelDirectoryById, findChannelsByDirectory, findRegisteredTextChannelForDirectory, formatProjectAlreadyRegisteredError, listGuildCategoryIds } from '../database.js';
16
16
  import { ShareMarkdown } from '../markdown.js';
17
17
  import { parseSessionSearchPattern, findFirstSessionSearchHit, buildSessionSearchSnippet, getPartSearchTexts } from '../session-search.js';
18
18
  import { formatWorktreeName, formatAutoWorktreeName } from '../commands/new-worktree.js';
@@ -88,7 +88,7 @@ cli
88
88
  cli
89
89
  .command('project list', 'List all registered projects with their Discord channels')
90
90
  .option('--json', 'Output as JSON')
91
- .option('--all', 'Include remote projects from other machines (scans Kimaki category in Discord)')
91
+ .option('--all', 'Include remote projects from other machines (scans Kimaki groups in Discord)')
92
92
  .option('-g, --guild <guildId>', 'Discord guild/server ID to scan (used with --all when no local projects exist)')
93
93
  .option('--prune', 'Remove stale entries whose Discord channel no longer exists')
94
94
  .action(async (options) => {
@@ -158,12 +158,15 @@ cli
158
158
  process.exit(EXIT_NO_RESTART);
159
159
  }
160
160
  let guildScanFailures = 0;
161
+ const storedCategoryIds = new Set(await listGuildCategoryIds());
161
162
  for (const guildId of uniqueGuildIds) {
162
163
  try {
163
164
  const guildChannels = (await rest.get(Routes.guildChannels(guildId)));
164
- // Find Kimaki category channels (type 4 = GuildCategory)
165
+ // Kimaki groups: name still starts with Kimaki, or this machine stored the id
166
+ // after a rename. Other machines' renamed groups cannot be found by name.
165
167
  const kimakiCategoryIds = new Set(guildChannels
166
- .filter((ch) => ch.type === 4 && /^kimaki(\s|$)/i.test(ch.name))
168
+ .filter((ch) => ch.type === 4 &&
169
+ (/^kimaki(\s|$)/i.test(ch.name) || storedCategoryIds.has(ch.id)))
167
170
  .map((ch) => ch.id));
168
171
  // Find text channels (type 0) in Kimaki categories that are not in our local DB
169
172
  for (const ch of guildChannels) {