kimaki 0.22.0 → 0.23.1

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 (50) hide show
  1. package/dist/anthropic-auth-plugin.js +78 -33
  2. package/dist/anthropic-auth-state.js +62 -27
  3. package/dist/anthropic-auth-state.test.js +55 -2
  4. package/dist/channel-management.js +29 -6
  5. package/dist/cli-commands/project.js +6 -1
  6. package/dist/commands/login.js +1 -1
  7. package/dist/commands/new-worktree.js +9 -3
  8. package/dist/commands/queue.js +12 -2
  9. package/dist/database.js +7 -8
  10. package/dist/db.js +1 -0
  11. package/dist/discord-bot.js +9 -0
  12. package/dist/hrana-server.js +19 -0
  13. package/dist/oauth-rotation-shared.js +21 -0
  14. package/dist/opencode.js +73 -4
  15. package/dist/schema.js +3 -0
  16. package/dist/session-handler/global-event-listener.js +38 -6
  17. package/dist/session-handler/thread-runtime-state.js +2 -0
  18. package/dist/session-handler/thread-session-runtime.js +19 -3
  19. package/dist/system-message.js +34 -20
  20. package/dist/system-message.test.js +34 -20
  21. package/dist/task-runner.js +8 -4
  22. package/dist/worktree-lifecycle.e2e.test.js +16 -2
  23. package/package.json +4 -4
  24. package/skills/holocron/SKILL.md +11 -8
  25. package/src/anthropic-auth-plugin.ts +82 -35
  26. package/src/anthropic-auth-state.test.ts +73 -1
  27. package/src/anthropic-auth-state.ts +85 -25
  28. package/src/channel-management.ts +35 -6
  29. package/src/cli-commands/project.ts +6 -1
  30. package/src/commands/login.ts +1 -1
  31. package/src/commands/new-worktree.ts +14 -3
  32. package/src/commands/queue.ts +13 -2
  33. package/src/database.ts +8 -9
  34. package/src/db.ts +1 -0
  35. package/src/discord-bot.ts +12 -0
  36. package/src/hrana-server.ts +19 -0
  37. package/src/oauth-rotation-shared.ts +22 -0
  38. package/src/opencode.ts +90 -6
  39. package/src/schema.sql +1 -0
  40. package/src/schema.ts +3 -0
  41. package/src/session-handler/global-event-listener.ts +38 -6
  42. package/src/session-handler/thread-runtime-state.ts +4 -1
  43. package/src/session-handler/thread-session-runtime.ts +25 -3
  44. package/src/system-message.test.ts +34 -20
  45. package/src/system-message.ts +34 -20
  46. package/src/task-runner.ts +8 -4
  47. package/src/worktree-lifecycle.e2e.test.ts +18 -2
  48. package/skills/batch/SKILL.md +0 -87
  49. package/skills/security-review/SKILL.md +0 -208
  50. package/skills/simplify/SKILL.md +0 -58
@@ -668,22 +668,23 @@ docs.jsonc ───►│ Vite Plugin │──────► Build Output
668
668
  ```
669
669
  ````
670
670
 
671
- ## Always run `diagrams fix` after editing diagrams
671
+ ## Always run `diagrams fix` after editing diagrams or tables
672
672
 
673
- LLMs cannot count characters reliably. Every time you create or edit a diagram in an MDX page, you **must** run the alignment fixer before committing. This is not optional.
673
+ LLMs cannot count characters or pad table columns reliably. Every time you create or edit a **diagram** or a **GFM table** in an MDX page, you **must** run the fixer before committing. This is not optional.
674
674
 
675
675
  ```bash
676
676
  npx -y "@holocron.so/cli" diagrams fix path/to/file.mdx
677
677
  ```
678
678
 
679
679
  The command:
680
- - Fixes box alignment (padding, border widths, junctions) in-place
680
+ - Fixes box-drawing diagram alignment (padding, border widths, junctions) in-place
681
+ - Formats GFM tables (column padding, aligned pipes, blank lines above/below)
681
682
  - Reports how many lines were changed
682
- - Exits with code 1 if any lines exceed max width (94 cols by default)
683
+ - Exits with code 1 if any diagram lines exceed max width (94 cols by default) or tables still need formatting (`--check`)
683
684
 
684
- If the output says "No changes", the diagram was already correct. If it reports width violations, shorten the offending lines manually and re-run.
685
+ If the output says "No changes", the file was already correct. If it reports width violations, shorten the offending diagram lines manually and re-run.
685
686
 
686
- **Do not skip this step.** Even if the diagram looks correct in your editor, run the command. Off-by-one padding errors are invisible to LLMs but obvious to humans in monospace fonts.
687
+ **Do not skip this step.** Even if a diagram or table looks fine in your editor, run the command. Off-by-one padding and uneven table columns are invisible to LLMs but obvious to humans in monospace fonts.
687
688
 
688
689
  ## OpenAPI summaries
689
690
 
@@ -741,7 +742,9 @@ If a user wants to customize something that has no CSS variable, **do not hack i
741
742
  - All ASCII diagrams in MDX pages must use the `diagram` language hint on the
742
743
  code fence (` ```diagram `). This renders them with proper styling on the
743
744
  website instead of plain monospace.
744
- - After creating or editing any diagram, run `npx -y "@holocron.so/cli" diagrams fix <file>`
745
- to auto-fix alignment. LLMs cannot count characters reliably.
745
+ - After creating or editing any diagram or GFM table, run
746
+ `npx -y "@holocron.so/cli" diagrams fix <file>` to auto-fix diagram
747
+ alignment and table column padding. LLMs cannot count characters or pad
748
+ columns reliably.
746
749
  - Never pipe curl or `--help` output through `head`, `tail`, or any truncation
747
750
  command. Always read the full output.
@@ -28,8 +28,10 @@ import type { OpencodeClient } from "@opencode-ai/sdk/v2";
28
28
  import { appendToastSessionMarker } from "./plugin-logger.js";
29
29
  import { createPluginClient } from "./plugin-opencode-client.js";
30
30
  import {
31
+ isPermanentOAuthRefreshFailure,
31
32
  loadAccountStore,
32
33
  rememberAnthropicOAuth,
34
+ removeAccountByAuth,
33
35
  rotateAnthropicAccount,
34
36
  saveAccountStore,
35
37
  setAnthropicAuth,
@@ -911,50 +913,91 @@ function isOAuthStored(auth: { type: string }): auth is OAuthStored {
911
913
  return auth.type === "oauth";
912
914
  }
913
915
 
916
+ /**
917
+ * Refresh Anthropic OAuth. On permanent refresh failure (invalid_grant),
918
+ * remove that account from the pool and retry with the next one.
919
+ * removeAccountByAuth runs outside withAuthStateLock to avoid nested locks.
920
+ */
914
921
  async function getFreshOAuth(
915
922
  getAuth: () => Promise<OAuthStored | { type: string }>,
916
923
  client: OpencodeClient,
917
- ) {
924
+ options?: { sessionId?: string },
925
+ ): Promise<OAuthStored | undefined> {
918
926
  const auth = await getAuth();
919
927
  if (!isOAuthStored(auth)) return undefined;
920
928
  if (auth.access && auth.expires > Date.now()) return auth;
921
929
 
922
930
  const pending = pendingRefresh.get(auth.refresh);
923
- if (pending) {
924
- return pending;
925
- }
931
+ if (pending) return pending;
926
932
 
927
- const refreshPromise = withAuthStateLock(async () => {
928
- const latest = await getAuth();
929
- if (!isOAuthStored(latest)) {
930
- throw new Error("Anthropic OAuth credentials disappeared during refresh");
931
- }
932
- if (latest.access && latest.expires > Date.now()) return latest;
933
-
934
- const refreshed = await refreshAnthropicToken(latest.refresh);
935
- await setAnthropicAuth(refreshed, client);
936
- const store = await loadAccountStore();
937
- if (store.accounts.length > 0) {
938
- const identity: AnthropicAccountIdentity | undefined = (() => {
939
- const currentIndex = store.accounts.findIndex((account) => {
940
- return (
941
- account.refresh === latest.refresh ||
942
- account.access === latest.access
943
- );
933
+ const refreshPromise = (async () => {
934
+ const attempted = new Set<string>();
935
+ while (true) {
936
+ let failedAuth: OAuthStored | undefined;
937
+ try {
938
+ return await withAuthStateLock(async () => {
939
+ const latest = await getAuth();
940
+ if (!isOAuthStored(latest)) {
941
+ throw new Error(
942
+ "Anthropic OAuth credentials disappeared during refresh",
943
+ );
944
+ }
945
+ if (latest.access && latest.expires > Date.now()) return latest;
946
+ if (attempted.has(latest.refresh)) {
947
+ throw new Error(
948
+ "Anthropic OAuth refresh failed for all remaining accounts",
949
+ );
950
+ }
951
+ attempted.add(latest.refresh);
952
+
953
+ try {
954
+ const refreshed = await refreshAnthropicToken(latest.refresh);
955
+ await setAnthropicAuth(refreshed, client);
956
+ const store = await loadAccountStore();
957
+ if (store.accounts.length > 0) {
958
+ const current = store.accounts.find(
959
+ (account) =>
960
+ account.refresh === latest.refresh ||
961
+ account.access === latest.access,
962
+ );
963
+ const identity: AnthropicAccountIdentity | undefined = current
964
+ ? {
965
+ ...(current.email ? { email: current.email } : {}),
966
+ ...(current.accountId
967
+ ? { accountId: current.accountId }
968
+ : {}),
969
+ }
970
+ : undefined;
971
+ upsertAccount(store, { ...refreshed, ...identity });
972
+ await saveAccountStore(store);
973
+ }
974
+ return refreshed;
975
+ } catch (error) {
976
+ if (!isPermanentOAuthRefreshFailure(error)) throw error;
977
+ failedAuth = latest;
978
+ throw error;
979
+ }
944
980
  });
945
- const current =
946
- currentIndex >= 0 ? store.accounts[currentIndex] : undefined;
947
- if (!current) return undefined;
948
- return {
949
- ...(current.email ? { email: current.email } : {}),
950
- ...(current.accountId ? { accountId: current.accountId } : {}),
951
- };
952
- })();
953
- upsertAccount(store, { ...refreshed, ...identity });
954
- await saveAccountStore(store);
981
+ } catch (error) {
982
+ if (!failedAuth || !isPermanentOAuthRefreshFailure(error)) throw error;
983
+ const removed = await removeAccountByAuth(failedAuth, client);
984
+ if (!removed) throw error;
985
+ client.tui
986
+ .showToast({
987
+ message: appendToastSessionMarker({
988
+ message: removed.active
989
+ ? `Removed expired Anthropic account ${removed.removedLabel}, switched to ${removed.activeLabel ?? "next"}`
990
+ : `Removed expired Anthropic account ${removed.removedLabel} (no accounts left — re-login required)`,
991
+ sessionId: options?.sessionId,
992
+ }),
993
+ variant: removed.active ? "info" : "error",
994
+ })
995
+ .catch(() => {});
996
+ if (!removed.active) throw error;
997
+ }
955
998
  }
956
- return refreshed;
957
- });
999
+ })();
1000
+
958
1001
  pendingRefresh.set(auth.refresh, refreshPromise);
959
1002
  return refreshPromise.finally(() => {
960
1003
  pendingRefresh.delete(auth.refresh);
@@ -1060,7 +1103,9 @@ const AnthropicAuthPlugin: Plugin = async ({ serverUrl, directory }) => {
1060
1103
  });
1061
1104
  };
1062
1105
 
1063
- const freshAuth = await getFreshOAuth(getAuth, client);
1106
+ const freshAuth = await getFreshOAuth(getAuth, client, {
1107
+ sessionId,
1108
+ });
1064
1109
  if (!freshAuth) return fetch(input, init);
1065
1110
 
1066
1111
  let response = await runRequest(freshAuth);
@@ -1082,7 +1127,9 @@ const AnthropicAuthPlugin: Plugin = async ({ serverUrl, directory }) => {
1082
1127
  variant: "info",
1083
1128
  })
1084
1129
  .catch(() => {});
1085
- const retryAuth = await getFreshOAuth(getAuth, client);
1130
+ const retryAuth = await getFreshOAuth(getAuth, client, {
1131
+ sessionId,
1132
+ });
1086
1133
  if (retryAuth) {
1087
1134
  response = await runRequest(retryAuth);
1088
1135
  }
@@ -1,4 +1,5 @@
1
- // Tests Anthropic OAuth account persistence, deduplication, and rotation.
1
+ // Tests Anthropic OAuth account persistence, deduplication, rotation, and
2
+ // permanent refresh-failure removal.
2
3
 
3
4
  import { mkdtemp, readFile, rm, mkdir, writeFile } from 'node:fs/promises'
4
5
  import { tmpdir } from 'node:os'
@@ -7,9 +8,11 @@ import { afterEach, beforeEach, describe, expect, test } from 'vitest'
7
8
  import {
8
9
  accountLabel,
9
10
  authFilePath,
11
+ isPermanentOAuthRefreshFailure,
10
12
  loadAccountStore,
11
13
  rememberAnthropicOAuth,
12
14
  removeAccount,
15
+ removeAccountByAuth,
13
16
  rotateAnthropicAccount,
14
17
  saveAccountStore,
15
18
  shouldRotateAuth,
@@ -178,6 +181,61 @@ describe('removeAccount', () => {
178
181
  })
179
182
  })
180
183
 
184
+ describe('removeAccountByAuth', () => {
185
+ test('removes matched account, promotes next, and syncs auth.set', async () => {
186
+ await saveAccountStore({
187
+ version: 1,
188
+ activeIndex: 0,
189
+ accounts: [
190
+ { ...firstAccount, email: 'dead@example.com', addedAt: 1, lastUsed: 1 },
191
+ { ...secondAccount, email: 'live@example.com', addedAt: 2, lastUsed: 2 },
192
+ ],
193
+ })
194
+
195
+ const authSetCalls: unknown[] = []
196
+ const client = {
197
+ auth: {
198
+ set: async (input: unknown) => {
199
+ authSetCalls.push(input)
200
+ },
201
+ },
202
+ }
203
+
204
+ const removed = await removeAccountByAuth(firstAccount, client as never)
205
+ const store = await loadAccountStore()
206
+
207
+ expect(removed).toMatchObject({
208
+ removedLabel: '#1 (dead@example.com)',
209
+ activeLabel: '#1 (live@example.com)',
210
+ active: { refresh: 'refresh-second' },
211
+ })
212
+ expect(store.accounts).toHaveLength(1)
213
+ expect(store.accounts[0]?.refresh).toBe('refresh-second')
214
+ expect(authSetCalls).toHaveLength(1)
215
+ })
216
+
217
+ test('returns undefined when auth is not in the pool', async () => {
218
+ await saveAccountStore({
219
+ version: 1,
220
+ activeIndex: 0,
221
+ accounts: [{ ...firstAccount, addedAt: 1, lastUsed: 1 }],
222
+ })
223
+
224
+ const removed = await removeAccountByAuth(
225
+ {
226
+ type: 'oauth',
227
+ refresh: 'unknown-refresh',
228
+ access: 'unknown-access',
229
+ expires: 1,
230
+ },
231
+ { auth: { set: async () => {} } } as never,
232
+ )
233
+
234
+ expect(removed).toBeUndefined()
235
+ expect((await loadAccountStore()).accounts).toHaveLength(1)
236
+ })
237
+ })
238
+
181
239
  describe('shouldRotateAuth', () => {
182
240
  test('only rotates on rate limit or auth failures', () => {
183
241
  expect(shouldRotateAuth(429, '')).toBe(true)
@@ -185,3 +243,17 @@ describe('shouldRotateAuth', () => {
185
243
  expect(shouldRotateAuth(400, 'bad request')).toBe(false)
186
244
  })
187
245
  })
246
+
247
+ describe('isPermanentOAuthRefreshFailure', () => {
248
+ test('detects invalid_grant and expired refresh tokens', () => {
249
+ expect(
250
+ isPermanentOAuthRefreshFailure(
251
+ new Error(
252
+ 'HTTP 400 from https://platform.claude.com/v1/oauth/token: {"error": "invalid_grant", "error_description": "Refresh token expired"}',
253
+ ),
254
+ ),
255
+ ).toBe(true)
256
+ expect(isPermanentOAuthRefreshFailure(new Error('ECONNRESET'))).toBe(false)
257
+ expect(isPermanentOAuthRefreshFailure(new Error('rate_limit_error'))).toBe(false)
258
+ })
259
+ })
@@ -20,6 +20,7 @@ import {
20
20
  authFilePath,
21
21
  findCurrentAccountIndex,
22
22
  isOAuthStored,
23
+ isPermanentOAuthRefreshFailure,
23
24
  normalizeAccountStore,
24
25
  readJson,
25
26
  upsertAccount as sharedUpsertAccount,
@@ -30,7 +31,13 @@ import {
30
31
 
31
32
  // Re-export types and functions that consumers rely on
32
33
  export type { OAuthStored, RotationResult }
33
- export { accountLabel, authFilePath, withAuthStateLock, shouldRotateAuth }
34
+ export {
35
+ accountLabel,
36
+ authFilePath,
37
+ withAuthStateLock,
38
+ shouldRotateAuth,
39
+ isPermanentOAuthRefreshFailure,
40
+ }
34
41
 
35
42
  export type CurrentAnthropicAccount = {
36
43
  auth: OAuthStored
@@ -38,6 +45,12 @@ export type CurrentAnthropicAccount = {
38
45
  index?: number
39
46
  }
40
47
 
48
+ export type RemoveAccountByAuthResult = {
49
+ removedLabel: string
50
+ activeLabel: string | undefined
51
+ active: OAuthStored | undefined
52
+ }
53
+
41
54
  // --- Store file path ---
42
55
 
43
56
  export function accountsFilePath() {
@@ -173,38 +186,85 @@ export async function rotateAnthropicAccount(
173
186
 
174
187
  // --- Remove account ---
175
188
 
189
+ /** Splice account at index and promote next. Caller must hold withAuthStateLock. */
190
+ async function spliceAccountAndPromote({
191
+ store,
192
+ index,
193
+ client,
194
+ }: {
195
+ store: AccountStore
196
+ index: number
197
+ client?: OpencodeClient
198
+ }) {
199
+ store.accounts.splice(index, 1)
200
+ if (store.accounts.length === 0) {
201
+ store.activeIndex = 0
202
+ await saveAccountStore(store)
203
+ await writeAnthropicAuthFile(undefined)
204
+ return { store, active: undefined as OAuthStored | undefined, activeLabel: undefined as string | undefined }
205
+ }
206
+
207
+ if (store.activeIndex > index) {
208
+ store.activeIndex -= 1
209
+ } else if (store.activeIndex >= store.accounts.length) {
210
+ store.activeIndex = 0
211
+ }
212
+
213
+ const active = store.accounts[store.activeIndex]
214
+ if (!active) throw new Error('Active Anthropic account disappeared during removal')
215
+ active.lastUsed = Date.now()
216
+ await saveAccountStore(store)
217
+ const nextAuth: OAuthStored = {
218
+ type: 'oauth',
219
+ refresh: active.refresh,
220
+ access: active.access,
221
+ expires: active.expires,
222
+ }
223
+ if (client) {
224
+ await setAnthropicAuth(nextAuth, client)
225
+ } else {
226
+ await writeAnthropicAuthFile(nextAuth)
227
+ }
228
+ return {
229
+ store,
230
+ active: nextAuth,
231
+ activeLabel: accountLabel(active, store.activeIndex),
232
+ }
233
+ }
234
+
176
235
  export async function removeAccount(index: number) {
177
236
  return withAuthStateLock(async () => {
178
237
  const store = await loadAccountStore()
179
238
  if (!Number.isInteger(index) || index < 0 || index >= store.accounts.length) {
180
239
  throw new Error(`Account ${index + 1} does not exist`)
181
240
  }
241
+ return spliceAccountAndPromote({ store, index })
242
+ })
243
+ }
182
244
 
183
- store.accounts.splice(index, 1)
184
- if (store.accounts.length === 0) {
185
- store.activeIndex = 0
186
- await saveAccountStore(store)
187
- await writeAnthropicAuthFile(undefined)
188
- return { store, active: undefined }
189
- }
190
-
191
- if (store.activeIndex > index) {
192
- store.activeIndex -= 1
193
- } else if (store.activeIndex >= store.accounts.length) {
194
- store.activeIndex = 0
195
- }
245
+ /**
246
+ * Remove the pool entry matching these OAuth credentials and promote the next
247
+ * account. Used when refresh fails permanently (invalid_grant).
248
+ */
249
+ export async function removeAccountByAuth(
250
+ auth: OAuthStored,
251
+ client: OpencodeClient,
252
+ ): Promise<RemoveAccountByAuthResult | undefined> {
253
+ return withAuthStateLock(async () => {
254
+ const store = await loadAccountStore()
255
+ const index = store.accounts.findIndex(
256
+ (account) => account.refresh === auth.refresh || account.access === auth.access,
257
+ )
258
+ if (index < 0) return undefined
196
259
 
197
- const active = store.accounts[store.activeIndex]
198
- if (!active) throw new Error('Active Anthropic account disappeared during removal')
199
- active.lastUsed = Date.now()
200
- await saveAccountStore(store)
201
- const nextAuth: OAuthStored = {
202
- type: 'oauth',
203
- refresh: active.refresh,
204
- access: active.access,
205
- expires: active.expires,
260
+ const removed = store.accounts[index]
261
+ if (!removed) return undefined
262
+ const removedLabel = accountLabel(removed, index)
263
+ const result = await spliceAccountAndPromote({ store, index, client })
264
+ return {
265
+ removedLabel,
266
+ activeLabel: result.activeLabel,
267
+ active: result.active,
206
268
  }
207
- await writeAnthropicAuthFile(nextAuth)
208
- return { store, active: nextAuth }
209
269
  })
210
270
  }
@@ -188,6 +188,11 @@ __pycache__/
188
188
  *.egg-info/
189
189
  `
190
190
 
191
+ /** Returns the absolute path to the default kimaki project directory. */
192
+ export function getDefaultKimakiDirectory(): string {
193
+ return path.join(getProjectsDir(), 'kimaki')
194
+ }
195
+
191
196
  const DEFAULT_CHANNEL_TOPIC =
192
197
  'General channel for misc tasks with Kimaki. Not connected to a specific OpenCode project or repository.'
193
198
 
@@ -216,7 +221,7 @@ export async function createDefaultKimakiChannel({
216
221
  channelName: string
217
222
  projectDirectory: string
218
223
  } | null> {
219
- const projectDirectory = path.join(getProjectsDir(), 'kimaki')
224
+ const projectDirectory = getDefaultKimakiDirectory()
220
225
 
221
226
  // Ensure the default kimaki project directory exists before any DB mapping
222
227
  // restoration or git setup. Custom data dirs may not have <dataDir>/projects
@@ -242,11 +247,34 @@ export async function createDefaultKimakiChannel({
242
247
  directory: projectDirectory,
243
248
  channelType: 'text',
244
249
  })
245
- const mappedChannelInGuild = existingMappings
246
- .map((row) => guild.channels.cache.get(row.channel_id))
247
- .find((ch): ch is TextChannel => ch?.type === ChannelType.GuildText)
248
- if (mappedChannelInGuild) {
249
- logger.log(`Default kimaki channel already exists: ${mappedChannelInGuild.id}`)
250
+ const mappedRow = existingMappings.find((row) => {
251
+ const ch = guild.channels.cache.get(row.channel_id)
252
+ return ch?.type === ChannelType.GuildText
253
+ })
254
+ if (mappedRow) {
255
+ // Backfill guild_id for rows created before this column existed,
256
+ // so the tombstone check works if the channel is deleted later.
257
+ if (mappedRow.guild_id !== guild.id) {
258
+ await setChannelDirectory({
259
+ channelId: mappedRow.channel_id,
260
+ directory: projectDirectory,
261
+ channelType: 'text',
262
+ guildId: guild.id,
263
+ })
264
+ }
265
+ logger.log(`Default kimaki channel already exists: ${mappedRow.channel_id}`)
266
+ return null
267
+ }
268
+
269
+ // 1b. If a mapping exists for this guild but the channel is gone from Discord,
270
+ // it was previously created and then deleted. Don't recreate it.
271
+ const staleForThisGuild = existingMappings.find(
272
+ (row) => row.guild_id === guild.id,
273
+ )
274
+ if (staleForThisGuild) {
275
+ logger.log(
276
+ `Default kimaki channel was previously provisioned for guild ${guild.name} (${guild.id}) as ${staleForThisGuild.channel_id}, but no longer exists. Skipping recreation.`,
277
+ )
250
278
  return null
251
279
  }
252
280
 
@@ -318,6 +346,7 @@ export async function createDefaultKimakiChannel({
318
346
  channelId: textChannel.id,
319
347
  directory: projectDirectory,
320
348
  channelType: 'text',
349
+ guildId: guild.id,
321
350
  })
322
351
 
323
352
  logger.log(`Created default kimaki channel: #${channelName} (${textChannel.id})`)
@@ -12,6 +12,7 @@ import { fileURLToPath } from 'node:url'
12
12
  import { spawn, execSync } from 'node:child_process'
13
13
  import { createLogger, LogPrefix, initLogFile } from '../logger.js'
14
14
  import { createDiscordClient, initDatabase, getChannelDirectory, initializeOpencodeForDirectory, createProjectChannels } from '../discord-bot.js'
15
+ import { getDefaultKimakiDirectory } from '../channel-management.js'
15
16
  import { getBotTokenWithMode, getThreadSession, getThreadIdBySessionId, getSessionEventSnapshot, getDb, createScheduledTask, listScheduledTasks, cancelScheduledTask, getScheduledTask, updateScheduledTask, getSessionStartSourcesBySessionIds, deleteChannelDirectoryById, findChannelsByDirectory } from '../database.js'
16
17
  import { ShareMarkdown } from '../markdown.js'
17
18
  import { parseSessionSearchPattern, findFirstSessionSearchHit, buildSessionSearchSnippet, getPartSearchTexts } from '../session-search.js'
@@ -277,6 +278,7 @@ cli
277
278
  channel_id: ch.id,
278
279
  directory: '',
279
280
  channel_type: 'text' as const,
281
+ guild_id: guildId,
280
282
  created_at: null,
281
283
  channelName: ch.name,
282
284
  guildId,
@@ -323,7 +325,10 @@ cli
323
325
  // Prune stale entries if requested
324
326
  let finalEntries = enrichedWithGuild
325
327
  if (options.prune) {
326
- const stale = finalEntries.filter((ch) => ch.deleted && ch.isLocal)
328
+ // Skip default kimaki directory tombstones those are preserved
329
+ // intentionally so the tutorial channel isn't recreated after deletion.
330
+ const defaultDir = getDefaultKimakiDirectory()
331
+ const stale = finalEntries.filter((ch) => ch.deleted && ch.isLocal && ch.directory !== defaultDir)
327
332
  if (stale.length === 0) {
328
333
  cliLogger.log('No stale channels to prune')
329
334
  } else {
@@ -1127,7 +1127,7 @@ async function startOAuthFlow(
1127
1127
  // completing login in a browser (possibly on a different machine).
1128
1128
  const button = new ButtonBuilder()
1129
1129
  .setCustomId(`login_oauth_code_btn:${hash}`)
1130
- .setLabel('Paste authorization code')
1130
+ .setLabel('Paste authorization code or callback url')
1131
1131
  .setStyle(ButtonStyle.Primary)
1132
1132
 
1133
1133
  await interaction.editReply({
@@ -18,6 +18,7 @@ import {
18
18
  setWorkspaceError,
19
19
  getChannelDirectory,
20
20
  getThreadSession,
21
+ getThreadWorktreeOrWorkspace,
21
22
  setThreadSession,
22
23
  } from '../database.js'
23
24
  import {
@@ -223,7 +224,7 @@ async function tryWorkspaceCreate({
223
224
  worktreeName: string
224
225
  projectDirectory: string
225
226
  baseBranch?: string
226
- }): Promise<{ directory: string; workspaceId?: string } | Error> {
227
+ }): Promise<{ directory: string; workspaceId: string } | Error> {
227
228
  const getClient = await initializeOpencodeForDirectory(projectDirectory)
228
229
  if (getClient instanceof Error) return getClient
229
230
 
@@ -239,8 +240,8 @@ async function tryWorkspaceCreate({
239
240
  return new Error(`Workspace creation failed: ${JSON.stringify(response.error)}`)
240
241
  }
241
242
  const workspace = response.data
242
- if (!workspace?.directory) {
243
- return new Error('Workspace SDK returned no directory')
243
+ if (!workspace?.directory || !workspace.id) {
244
+ return new Error('Workspace SDK returned no directory or ID')
244
245
  }
245
246
  return { directory: workspace.directory, workspaceId: workspace.id }
246
247
  }
@@ -613,6 +614,15 @@ async function handleWorktreeInThread({
613
614
  return
614
615
  }
615
616
 
617
+ const workspace = await getThreadWorktreeOrWorkspace(worktreeThread.id)
618
+ if (!workspace?.workspace_id) {
619
+ await sendThreadMessage(
620
+ worktreeThread,
621
+ '✗ Worktree is ready, but OpenCode returned no workspace ID for context reuse.',
622
+ )
623
+ return
624
+ }
625
+
616
626
  const getClient = await initializeOpencodeForDirectory(result, {
617
627
  originalRepoDirectory: projectDirectory,
618
628
  channelId: parent.id,
@@ -628,6 +638,7 @@ async function handleWorktreeInThread({
628
638
  const forkResponse = await getClient().session.fork({
629
639
  sessionID: sourceSessionId,
630
640
  directory: result,
641
+ workspace: workspace.workspace_id,
631
642
  }).catch((e) => new OpenCodeSdkError({ operation: 'session.fork', cause: e }))
632
643
  if (forkResponse instanceof Error) {
633
644
  logger.error('[NEW-WORKTREE] Failed to fork session into worktree:', forkResponse)
@@ -156,10 +156,21 @@ export async function handleClearQueueCommand({
156
156
  return
157
157
  }
158
158
 
159
- runtime?.clearQueue()
159
+ const cleared = runtime?.clearQueue() ?? []
160
+
161
+ const lines = cleared.map((item, i) => {
162
+ const label = item.command
163
+ ? `/${item.command.name}`
164
+ : item.prompt
165
+ return `${i + 1}. ${label}`
166
+ })
167
+ let list = lines.join('\n')
168
+ if (list.length > 600) {
169
+ list = list.slice(0, 597) + '...'
170
+ }
160
171
 
161
172
  await command.reply({
162
- content: `Cleared ${queueLength} queued message${queueLength > 1 ? 's' : ''}`,
173
+ content: `Cleared ${cleared.length} queued message${cleared.length > 1 ? 's' : ''}:\n${list}`,
163
174
  flags: SILENT_MESSAGE_FLAGS,
164
175
  })
165
176