kimaki 0.22.0 → 0.23.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 (47) 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 +6 -6
  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
@@ -24,7 +24,7 @@
24
24
  */
25
25
  import { appendToastSessionMarker } from "./plugin-logger.js";
26
26
  import { createPluginClient } from "./plugin-opencode-client.js";
27
- import { loadAccountStore, rememberAnthropicOAuth, rotateAnthropicAccount, saveAccountStore, setAnthropicAuth, shouldRotateAuth, upsertAccount, withAuthStateLock, } from "./anthropic-auth-state.js";
27
+ import { isPermanentOAuthRefreshFailure, loadAccountStore, rememberAnthropicOAuth, removeAccountByAuth, rotateAnthropicAccount, saveAccountStore, setAnthropicAuth, shouldRotateAuth, upsertAccount, withAuthStateLock, } from "./anthropic-auth-state.js";
28
28
  import { extractAnthropicAccountIdentity, } from "./anthropic-account-identity.js";
29
29
  // PKCE (Proof Key for Code Exchange) using Web Crypto API.
30
30
  // Reference: https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/utils/oauth/pkce.ts
@@ -723,45 +723,86 @@ function mergeBetas(existing, required) {
723
723
  function isOAuthStored(auth) {
724
724
  return auth.type === "oauth";
725
725
  }
726
- async function getFreshOAuth(getAuth, client) {
726
+ /**
727
+ * Refresh Anthropic OAuth. On permanent refresh failure (invalid_grant),
728
+ * remove that account from the pool and retry with the next one.
729
+ * removeAccountByAuth runs outside withAuthStateLock to avoid nested locks.
730
+ */
731
+ async function getFreshOAuth(getAuth, client, options) {
727
732
  const auth = await getAuth();
728
733
  if (!isOAuthStored(auth))
729
734
  return undefined;
730
735
  if (auth.access && auth.expires > Date.now())
731
736
  return auth;
732
737
  const pending = pendingRefresh.get(auth.refresh);
733
- if (pending) {
738
+ if (pending)
734
739
  return pending;
735
- }
736
- const refreshPromise = withAuthStateLock(async () => {
737
- const latest = await getAuth();
738
- if (!isOAuthStored(latest)) {
739
- throw new Error("Anthropic OAuth credentials disappeared during refresh");
740
- }
741
- if (latest.access && latest.expires > Date.now())
742
- return latest;
743
- const refreshed = await refreshAnthropicToken(latest.refresh);
744
- await setAnthropicAuth(refreshed, client);
745
- const store = await loadAccountStore();
746
- if (store.accounts.length > 0) {
747
- const identity = (() => {
748
- const currentIndex = store.accounts.findIndex((account) => {
749
- return (account.refresh === latest.refresh ||
750
- account.access === latest.access);
740
+ const refreshPromise = (async () => {
741
+ const attempted = new Set();
742
+ while (true) {
743
+ let failedAuth;
744
+ try {
745
+ return await withAuthStateLock(async () => {
746
+ const latest = await getAuth();
747
+ if (!isOAuthStored(latest)) {
748
+ throw new Error("Anthropic OAuth credentials disappeared during refresh");
749
+ }
750
+ if (latest.access && latest.expires > Date.now())
751
+ return latest;
752
+ if (attempted.has(latest.refresh)) {
753
+ throw new Error("Anthropic OAuth refresh failed for all remaining accounts");
754
+ }
755
+ attempted.add(latest.refresh);
756
+ try {
757
+ const refreshed = await refreshAnthropicToken(latest.refresh);
758
+ await setAnthropicAuth(refreshed, client);
759
+ const store = await loadAccountStore();
760
+ if (store.accounts.length > 0) {
761
+ const current = store.accounts.find((account) => account.refresh === latest.refresh ||
762
+ account.access === latest.access);
763
+ const identity = current
764
+ ? {
765
+ ...(current.email ? { email: current.email } : {}),
766
+ ...(current.accountId
767
+ ? { accountId: current.accountId }
768
+ : {}),
769
+ }
770
+ : undefined;
771
+ upsertAccount(store, { ...refreshed, ...identity });
772
+ await saveAccountStore(store);
773
+ }
774
+ return refreshed;
775
+ }
776
+ catch (error) {
777
+ if (!isPermanentOAuthRefreshFailure(error))
778
+ throw error;
779
+ failedAuth = latest;
780
+ throw error;
781
+ }
751
782
  });
752
- const current = currentIndex >= 0 ? store.accounts[currentIndex] : undefined;
753
- if (!current)
754
- return undefined;
755
- return {
756
- ...(current.email ? { email: current.email } : {}),
757
- ...(current.accountId ? { accountId: current.accountId } : {}),
758
- };
759
- })();
760
- upsertAccount(store, { ...refreshed, ...identity });
761
- await saveAccountStore(store);
783
+ }
784
+ catch (error) {
785
+ if (!failedAuth || !isPermanentOAuthRefreshFailure(error))
786
+ throw error;
787
+ const removed = await removeAccountByAuth(failedAuth, client);
788
+ if (!removed)
789
+ throw error;
790
+ client.tui
791
+ .showToast({
792
+ message: appendToastSessionMarker({
793
+ message: removed.active
794
+ ? `Removed expired Anthropic account ${removed.removedLabel}, switched to ${removed.activeLabel ?? "next"}`
795
+ : `Removed expired Anthropic account ${removed.removedLabel} (no accounts left — re-login required)`,
796
+ sessionId: options?.sessionId,
797
+ }),
798
+ variant: removed.active ? "info" : "error",
799
+ })
800
+ .catch(() => { });
801
+ if (!removed.active)
802
+ throw error;
803
+ }
762
804
  }
763
- return refreshed;
764
- });
805
+ })();
765
806
  pendingRefresh.set(auth.refresh, refreshPromise);
766
807
  return refreshPromise.finally(() => {
767
808
  pendingRefresh.delete(auth.refresh);
@@ -846,7 +887,9 @@ const AnthropicAuthPlugin = async ({ serverUrl, directory }) => {
846
887
  headers: requestHeaders,
847
888
  });
848
889
  };
849
- const freshAuth = await getFreshOAuth(getAuth, client);
890
+ const freshAuth = await getFreshOAuth(getAuth, client, {
891
+ sessionId,
892
+ });
850
893
  if (!freshAuth)
851
894
  return fetch(input, init);
852
895
  let response = await runRequest(freshAuth);
@@ -868,7 +911,9 @@ const AnthropicAuthPlugin = async ({ serverUrl, directory }) => {
868
911
  variant: "info",
869
912
  })
870
913
  .catch(() => { });
871
- const retryAuth = await getFreshOAuth(getAuth, client);
914
+ const retryAuth = await getFreshOAuth(getAuth, client, {
915
+ sessionId,
916
+ });
872
917
  if (retryAuth) {
873
918
  response = await runRequest(retryAuth);
874
919
  }
@@ -7,8 +7,8 @@
7
7
  import { homedir } from 'node:os';
8
8
  import path from 'node:path';
9
9
  import { normalizeAnthropicAccountIdentity, } from './anthropic-account-identity.js';
10
- import { accountLabel, authFilePath, findCurrentAccountIndex, isOAuthStored, normalizeAccountStore, readJson, upsertAccount as sharedUpsertAccount, withAuthStateLock, writeJson, shouldRotateAuth, } from './oauth-rotation-shared.js';
11
- export { accountLabel, authFilePath, withAuthStateLock, shouldRotateAuth };
10
+ import { accountLabel, authFilePath, findCurrentAccountIndex, isOAuthStored, isPermanentOAuthRefreshFailure, normalizeAccountStore, readJson, upsertAccount as sharedUpsertAccount, withAuthStateLock, writeJson, shouldRotateAuth, } from './oauth-rotation-shared.js';
11
+ export { accountLabel, authFilePath, withAuthStateLock, shouldRotateAuth, isPermanentOAuthRefreshFailure, };
12
12
  // --- Store file path ---
13
13
  export function accountsFilePath() {
14
14
  if (process.env.XDG_DATA_HOME) {
@@ -114,37 +114,72 @@ export async function rotateAnthropicAccount(auth, client) {
114
114
  });
115
115
  }
116
116
  // --- Remove account ---
117
+ /** Splice account at index and promote next. Caller must hold withAuthStateLock. */
118
+ async function spliceAccountAndPromote({ store, index, client, }) {
119
+ store.accounts.splice(index, 1);
120
+ if (store.accounts.length === 0) {
121
+ store.activeIndex = 0;
122
+ await saveAccountStore(store);
123
+ await writeAnthropicAuthFile(undefined);
124
+ return { store, active: undefined, activeLabel: undefined };
125
+ }
126
+ if (store.activeIndex > index) {
127
+ store.activeIndex -= 1;
128
+ }
129
+ else if (store.activeIndex >= store.accounts.length) {
130
+ store.activeIndex = 0;
131
+ }
132
+ const active = store.accounts[store.activeIndex];
133
+ if (!active)
134
+ throw new Error('Active Anthropic account disappeared during removal');
135
+ active.lastUsed = Date.now();
136
+ await saveAccountStore(store);
137
+ const nextAuth = {
138
+ type: 'oauth',
139
+ refresh: active.refresh,
140
+ access: active.access,
141
+ expires: active.expires,
142
+ };
143
+ if (client) {
144
+ await setAnthropicAuth(nextAuth, client);
145
+ }
146
+ else {
147
+ await writeAnthropicAuthFile(nextAuth);
148
+ }
149
+ return {
150
+ store,
151
+ active: nextAuth,
152
+ activeLabel: accountLabel(active, store.activeIndex),
153
+ };
154
+ }
117
155
  export async function removeAccount(index) {
118
156
  return withAuthStateLock(async () => {
119
157
  const store = await loadAccountStore();
120
158
  if (!Number.isInteger(index) || index < 0 || index >= store.accounts.length) {
121
159
  throw new Error(`Account ${index + 1} does not exist`);
122
160
  }
123
- store.accounts.splice(index, 1);
124
- if (store.accounts.length === 0) {
125
- store.activeIndex = 0;
126
- await saveAccountStore(store);
127
- await writeAnthropicAuthFile(undefined);
128
- return { store, active: undefined };
129
- }
130
- if (store.activeIndex > index) {
131
- store.activeIndex -= 1;
132
- }
133
- else if (store.activeIndex >= store.accounts.length) {
134
- store.activeIndex = 0;
135
- }
136
- const active = store.accounts[store.activeIndex];
137
- if (!active)
138
- throw new Error('Active Anthropic account disappeared during removal');
139
- active.lastUsed = Date.now();
140
- await saveAccountStore(store);
141
- const nextAuth = {
142
- type: 'oauth',
143
- refresh: active.refresh,
144
- access: active.access,
145
- expires: active.expires,
161
+ return spliceAccountAndPromote({ store, index });
162
+ });
163
+ }
164
+ /**
165
+ * Remove the pool entry matching these OAuth credentials and promote the next
166
+ * account. Used when refresh fails permanently (invalid_grant).
167
+ */
168
+ export async function removeAccountByAuth(auth, client) {
169
+ return withAuthStateLock(async () => {
170
+ const store = await loadAccountStore();
171
+ const index = store.accounts.findIndex((account) => account.refresh === auth.refresh || account.access === auth.access);
172
+ if (index < 0)
173
+ return undefined;
174
+ const removed = store.accounts[index];
175
+ if (!removed)
176
+ return undefined;
177
+ const removedLabel = accountLabel(removed, index);
178
+ const result = await spliceAccountAndPromote({ store, index, client });
179
+ return {
180
+ removedLabel,
181
+ activeLabel: result.activeLabel,
182
+ active: result.active,
146
183
  };
147
- await writeAnthropicAuthFile(nextAuth);
148
- return { store, active: nextAuth };
149
184
  });
150
185
  }
@@ -1,9 +1,10 @@
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
  import { mkdtemp, readFile, rm, mkdir, writeFile } from 'node:fs/promises';
3
4
  import { tmpdir } from 'node:os';
4
5
  import path from 'node:path';
5
6
  import { afterEach, beforeEach, describe, expect, test } from 'vitest';
6
- import { accountLabel, authFilePath, loadAccountStore, rememberAnthropicOAuth, removeAccount, rotateAnthropicAccount, saveAccountStore, shouldRotateAuth, } from './anthropic-auth-state.js';
7
+ import { accountLabel, authFilePath, isPermanentOAuthRefreshFailure, loadAccountStore, rememberAnthropicOAuth, removeAccount, removeAccountByAuth, rotateAnthropicAccount, saveAccountStore, shouldRotateAuth, } from './anthropic-auth-state.js';
7
8
  const firstAccount = {
8
9
  type: 'oauth',
9
10
  refresh: 'refresh-first',
@@ -141,6 +142,51 @@ describe('removeAccount', () => {
141
142
  expect(authJson.anthropic).toBeUndefined();
142
143
  });
143
144
  });
145
+ describe('removeAccountByAuth', () => {
146
+ test('removes matched account, promotes next, and syncs auth.set', async () => {
147
+ await saveAccountStore({
148
+ version: 1,
149
+ activeIndex: 0,
150
+ accounts: [
151
+ { ...firstAccount, email: 'dead@example.com', addedAt: 1, lastUsed: 1 },
152
+ { ...secondAccount, email: 'live@example.com', addedAt: 2, lastUsed: 2 },
153
+ ],
154
+ });
155
+ const authSetCalls = [];
156
+ const client = {
157
+ auth: {
158
+ set: async (input) => {
159
+ authSetCalls.push(input);
160
+ },
161
+ },
162
+ };
163
+ const removed = await removeAccountByAuth(firstAccount, client);
164
+ const store = await loadAccountStore();
165
+ expect(removed).toMatchObject({
166
+ removedLabel: '#1 (dead@example.com)',
167
+ activeLabel: '#1 (live@example.com)',
168
+ active: { refresh: 'refresh-second' },
169
+ });
170
+ expect(store.accounts).toHaveLength(1);
171
+ expect(store.accounts[0]?.refresh).toBe('refresh-second');
172
+ expect(authSetCalls).toHaveLength(1);
173
+ });
174
+ test('returns undefined when auth is not in the pool', async () => {
175
+ await saveAccountStore({
176
+ version: 1,
177
+ activeIndex: 0,
178
+ accounts: [{ ...firstAccount, addedAt: 1, lastUsed: 1 }],
179
+ });
180
+ const removed = await removeAccountByAuth({
181
+ type: 'oauth',
182
+ refresh: 'unknown-refresh',
183
+ access: 'unknown-access',
184
+ expires: 1,
185
+ }, { auth: { set: async () => { } } });
186
+ expect(removed).toBeUndefined();
187
+ expect((await loadAccountStore()).accounts).toHaveLength(1);
188
+ });
189
+ });
144
190
  describe('shouldRotateAuth', () => {
145
191
  test('only rotates on rate limit or auth failures', () => {
146
192
  expect(shouldRotateAuth(429, '')).toBe(true);
@@ -148,3 +194,10 @@ describe('shouldRotateAuth', () => {
148
194
  expect(shouldRotateAuth(400, 'bad request')).toBe(false);
149
195
  });
150
196
  });
197
+ describe('isPermanentOAuthRefreshFailure', () => {
198
+ test('detects invalid_grant and expired refresh tokens', () => {
199
+ expect(isPermanentOAuthRefreshFailure(new Error('HTTP 400 from https://platform.claude.com/v1/oauth/token: {"error": "invalid_grant", "error_description": "Refresh token expired"}'))).toBe(true);
200
+ expect(isPermanentOAuthRefreshFailure(new Error('ECONNRESET'))).toBe(false);
201
+ expect(isPermanentOAuthRefreshFailure(new Error('rate_limit_error'))).toBe(false);
202
+ });
203
+ });
@@ -113,6 +113,10 @@ __pycache__/
113
113
  .venv/
114
114
  *.egg-info/
115
115
  `;
116
+ /** Returns the absolute path to the default kimaki project directory. */
117
+ export function getDefaultKimakiDirectory() {
118
+ return path.join(getProjectsDir(), 'kimaki');
119
+ }
116
120
  const DEFAULT_CHANNEL_TOPIC = 'General channel for misc tasks with Kimaki. Not connected to a specific OpenCode project or repository.';
117
121
  /**
118
122
  * Create (or find) the default "kimaki" channel for general-purpose tasks.
@@ -124,7 +128,7 @@ const DEFAULT_CHANNEL_TOPIC = 'General channel for misc tasks with Kimaki. Not c
124
128
  * as a fallback for channels created before DB mapping existed.
125
129
  */
126
130
  export async function createDefaultKimakiChannel({ guild, botName, appId, isGatewayMode, }) {
127
- const projectDirectory = path.join(getProjectsDir(), 'kimaki');
131
+ const projectDirectory = getDefaultKimakiDirectory();
128
132
  // Ensure the default kimaki project directory exists before any DB mapping
129
133
  // restoration or git setup. Custom data dirs may not have <dataDir>/projects
130
134
  // created yet, and later writes assume the full path is present.
@@ -146,11 +150,29 @@ export async function createDefaultKimakiChannel({ guild, botName, appId, isGate
146
150
  directory: projectDirectory,
147
151
  channelType: 'text',
148
152
  });
149
- const mappedChannelInGuild = existingMappings
150
- .map((row) => guild.channels.cache.get(row.channel_id))
151
- .find((ch) => ch?.type === ChannelType.GuildText);
152
- if (mappedChannelInGuild) {
153
- logger.log(`Default kimaki channel already exists: ${mappedChannelInGuild.id}`);
153
+ const mappedRow = existingMappings.find((row) => {
154
+ const ch = guild.channels.cache.get(row.channel_id);
155
+ return ch?.type === ChannelType.GuildText;
156
+ });
157
+ if (mappedRow) {
158
+ // Backfill guild_id for rows created before this column existed,
159
+ // so the tombstone check works if the channel is deleted later.
160
+ if (mappedRow.guild_id !== guild.id) {
161
+ await setChannelDirectory({
162
+ channelId: mappedRow.channel_id,
163
+ directory: projectDirectory,
164
+ channelType: 'text',
165
+ guildId: guild.id,
166
+ });
167
+ }
168
+ logger.log(`Default kimaki channel already exists: ${mappedRow.channel_id}`);
169
+ return null;
170
+ }
171
+ // 1b. If a mapping exists for this guild but the channel is gone from Discord,
172
+ // it was previously created and then deleted. Don't recreate it.
173
+ const staleForThisGuild = existingMappings.find((row) => row.guild_id === guild.id);
174
+ if (staleForThisGuild) {
175
+ logger.log(`Default kimaki channel was previously provisioned for guild ${guild.name} (${guild.id}) as ${staleForThisGuild.channel_id}, but no longer exists. Skipping recreation.`);
154
176
  return null;
155
177
  }
156
178
  // 2. Fallback: detect existing channel by name+category.
@@ -213,6 +235,7 @@ export async function createDefaultKimakiChannel({ guild, botName, appId, isGate
213
235
  channelId: textChannel.id,
214
236
  directory: projectDirectory,
215
237
  channelType: 'text',
238
+ guildId: guild.id,
216
239
  });
217
240
  logger.log(`Created default kimaki channel: #${channelName} (${textChannel.id})`);
218
241
  return {
@@ -11,6 +11,7 @@ import { fileURLToPath } from 'node:url';
11
11
  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
+ import { getDefaultKimakiDirectory } from '../channel-management.js';
14
15
  import { getBotTokenWithMode, getThreadSession, getThreadIdBySessionId, getSessionEventSnapshot, getDb, createScheduledTask, listScheduledTasks, cancelScheduledTask, getScheduledTask, updateScheduledTask, getSessionStartSourcesBySessionIds, deleteChannelDirectoryById, findChannelsByDirectory } from '../database.js';
15
16
  import { ShareMarkdown } from '../markdown.js';
16
17
  import { parseSessionSearchPattern, findFirstSessionSearchHit, buildSessionSearchSnippet, getPartSearchTexts } from '../session-search.js';
@@ -184,6 +185,7 @@ cli
184
185
  channel_id: ch.id,
185
186
  directory: '',
186
187
  channel_type: 'text',
188
+ guild_id: guildId,
187
189
  created_at: null,
188
190
  channelName: ch.name,
189
191
  guildId,
@@ -224,7 +226,10 @@ cli
224
226
  // Prune stale entries if requested
225
227
  let finalEntries = enrichedWithGuild;
226
228
  if (options.prune) {
227
- const stale = finalEntries.filter((ch) => ch.deleted && ch.isLocal);
229
+ // Skip default kimaki directory tombstones those are preserved
230
+ // intentionally so the tutorial channel isn't recreated after deletion.
231
+ const defaultDir = getDefaultKimakiDirectory();
232
+ const stale = finalEntries.filter((ch) => ch.deleted && ch.isLocal && ch.directory !== defaultDir);
228
233
  if (stale.length === 0) {
229
234
  cliLogger.log('No stale channels to prune');
230
235
  }
@@ -843,7 +843,7 @@ async function startOAuthFlow(interaction, ctx, hash) {
843
843
  // completing login in a browser (possibly on a different machine).
844
844
  const button = new ButtonBuilder()
845
845
  .setCustomId(`login_oauth_code_btn:${hash}`)
846
- .setLabel('Paste authorization code')
846
+ .setLabel('Paste authorization code or callback url')
847
847
  .setStyle(ButtonStyle.Primary);
848
848
  await interaction.editReply({
849
849
  content: message,
@@ -4,7 +4,7 @@
4
4
  import { ChannelType, REST, } from 'discord.js';
5
5
  import fs from 'node:fs';
6
6
  import { OpenCodeSdkError } from '../errors.js';
7
- import { createPendingWorkspace, setWorkspaceReady, setWorkspaceError, getChannelDirectory, getThreadSession, setThreadSession, } from '../database.js';
7
+ import { createPendingWorkspace, setWorkspaceReady, setWorkspaceError, getChannelDirectory, getThreadSession, getThreadWorktreeOrWorkspace, setThreadSession, } from '../database.js';
8
8
  import { SILENT_MESSAGE_FLAGS, reactToThread, resolveProjectDirectoryFromAutocomplete, resolveTextChannel, sendThreadMessage, } from '../discord-utils.js';
9
9
  import { createLogger, LogPrefix } from '../logger.js';
10
10
  import { notifyError } from '../sentry.js';
@@ -173,8 +173,8 @@ async function tryWorkspaceCreate({ threadId, worktreeName, projectDirectory, ba
173
173
  return new Error(`Workspace creation failed: ${JSON.stringify(response.error)}`);
174
174
  }
175
175
  const workspace = response.data;
176
- if (!workspace?.directory) {
177
- return new Error('Workspace SDK returned no directory');
176
+ if (!workspace?.directory || !workspace.id) {
177
+ return new Error('Workspace SDK returned no directory or ID');
178
178
  }
179
179
  return { directory: workspace.directory, workspaceId: workspace.id };
180
180
  }
@@ -451,6 +451,11 @@ async function handleWorktreeInThread({ command, thread, appId, }) {
451
451
  await sendThreadMessage(worktreeThread, 'Worktree is ready. Send a message here to start a fresh session in this checkout.');
452
452
  return;
453
453
  }
454
+ const workspace = await getThreadWorktreeOrWorkspace(worktreeThread.id);
455
+ if (!workspace?.workspace_id) {
456
+ await sendThreadMessage(worktreeThread, '✗ Worktree is ready, but OpenCode returned no workspace ID for context reuse.');
457
+ return;
458
+ }
454
459
  const getClient = await initializeOpencodeForDirectory(result, {
455
460
  originalRepoDirectory: projectDirectory,
456
461
  channelId: parent.id,
@@ -462,6 +467,7 @@ async function handleWorktreeInThread({ command, thread, appId, }) {
462
467
  const forkResponse = await getClient().session.fork({
463
468
  sessionID: sourceSessionId,
464
469
  directory: result,
470
+ workspace: workspace.workspace_id,
465
471
  }).catch((e) => new OpenCodeSdkError({ operation: 'session.fork', cause: e }));
466
472
  if (forkResponse instanceof Error) {
467
473
  logger.error('[NEW-WORKTREE] Failed to fork session into worktree:', forkResponse);
@@ -117,9 +117,19 @@ export async function handleClearQueueCommand({ command, }) {
117
117
  logger.log(`[QUEUE] User ${command.user.displayName} cleared queued position ${position} in thread ${channel.id}`);
118
118
  return;
119
119
  }
120
- runtime?.clearQueue();
120
+ const cleared = runtime?.clearQueue() ?? [];
121
+ const lines = cleared.map((item, i) => {
122
+ const label = item.command
123
+ ? `/${item.command.name}`
124
+ : item.prompt;
125
+ return `${i + 1}. ${label}`;
126
+ });
127
+ let list = lines.join('\n');
128
+ if (list.length > 600) {
129
+ list = list.slice(0, 597) + '...';
130
+ }
121
131
  await command.reply({
122
- content: `Cleared ${queueLength} queued message${queueLength > 1 ? 's' : ''}`,
132
+ content: `Cleared ${cleared.length} queued message${cleared.length > 1 ? 's' : ''}:\n${list}`,
123
133
  flags: SILENT_MESSAGE_FLAGS,
124
134
  });
125
135
  logger.log(`[QUEUE] User ${command.user.displayName} cleared queue in thread ${channel.id}`);
package/dist/database.js CHANGED
@@ -103,10 +103,9 @@ export async function recoverStaleRunningScheduledTasks({ staleBefore }) {
103
103
  .returning({ id: schema.scheduled_tasks.id });
104
104
  return countRows(rows);
105
105
  }
106
- export async function markScheduledTaskOneShotCompleted({ taskId, completedAt }) {
106
+ export async function deleteScheduledTask(taskId) {
107
107
  const db = await getDb();
108
- await db.update(schema.scheduled_tasks)
109
- .set({ status: 'completed', last_run_at: completedAt, running_started_at: null, last_error: null })
108
+ await db.delete(schema.scheduled_tasks)
110
109
  .where(orm.eq(schema.scheduled_tasks.id, taskId));
111
110
  }
112
111
  export async function markScheduledTaskCronRescheduled({ taskId, completedAt, nextRunAt }) {
@@ -584,17 +583,17 @@ export async function getAnyAudioApiKey() {
584
583
  return { provider: 'gemini', apiKey: row.gemini_api_key, appId: row.app_id };
585
584
  return null;
586
585
  }
587
- export async function setChannelDirectory({ channelId, directory, channelType, skipIfExists = false }) {
586
+ export async function setChannelDirectory({ channelId, directory, channelType, guildId, skipIfExists = false }) {
588
587
  const db = await getDb();
589
588
  if (skipIfExists) {
590
589
  await db.insert(schema.channel_directories)
591
- .values({ channel_id: channelId, directory, channel_type: channelType })
590
+ .values({ channel_id: channelId, directory, channel_type: channelType, guild_id: guildId })
592
591
  .onConflictDoNothing({ target: schema.channel_directories.channel_id });
593
592
  return;
594
593
  }
595
594
  await db.insert(schema.channel_directories)
596
- .values({ channel_id: channelId, directory, channel_type: channelType })
597
- .onConflictDoUpdate({ target: schema.channel_directories.channel_id, set: { directory, channel_type: channelType } });
595
+ .values({ channel_id: channelId, directory, channel_type: channelType, guild_id: guildId })
596
+ .onConflictDoUpdate({ target: schema.channel_directories.channel_id, set: { directory, channel_type: channelType, guild_id: guildId } });
598
597
  }
599
598
  export async function findChannelsByDirectory({ directory, channelType }) {
600
599
  const db = await getDb();
@@ -605,7 +604,7 @@ export async function findChannelsByDirectory({ directory, channelType }) {
605
604
  : channelType
606
605
  ? { channel_type: channelType }
607
606
  : undefined;
608
- return db.query.channel_directories.findMany({ where, columns: { channel_id: true, directory: true, channel_type: true } });
607
+ return db.query.channel_directories.findMany({ where, columns: { channel_id: true, directory: true, channel_type: true, guild_id: true } });
609
608
  }
610
609
  export async function getAllTextChannelDirectories() {
611
610
  const db = await getDb();
package/dist/db.js CHANGED
@@ -120,6 +120,7 @@ async function migrateSchema({ db, client, }) {
120
120
  "ALTER TABLE thread_sessions ADD COLUMN source TEXT DEFAULT 'kimaki'",
121
121
  'ALTER TABLE thread_sessions ADD COLUMN last_synced_name TEXT',
122
122
  'ALTER TABLE thread_sessions ADD COLUMN parent_session_id TEXT',
123
+ 'ALTER TABLE channel_directories ADD COLUMN guild_id TEXT',
123
124
  ];
124
125
  for (const stmt of alterStatements) {
125
126
  await client.execute(stmt).catch(() => undefined);
@@ -37,6 +37,7 @@ import { markDiscordGatewayReady, stopHranaServer } from './hrana-server.js';
37
37
  import { notifyError } from './sentry.js';
38
38
  import { flushDebouncedProcessCallbacks } from './debounced-process-flush.js';
39
39
  import { startRuntimeIdleSweeper } from './runtime-idle-sweeper.js';
40
+ import { getDefaultKimakiDirectory } from './channel-management.js';
40
41
  import { store } from './store.js';
41
42
  import { startExternalOpencodeSessionSync, stopExternalOpencodeSessionSync, } from './external-opencode-sync.js';
42
43
  export { initDatabase, closeDatabase, getChannelDirectory, } from './database.js';
@@ -1113,6 +1114,14 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
1113
1114
  // channel are disposed by their own ThreadDelete events from Discord.
1114
1115
  discordClient.on(Events.ChannelDelete, async (channel) => {
1115
1116
  try {
1117
+ // Check if this is the default kimaki channel. If so, preserve the
1118
+ // channel_directories row as a tombstone so we don't recreate it.
1119
+ const mapping = await getChannelDirectory(channel.id);
1120
+ const defaultDir = getDefaultKimakiDirectory();
1121
+ if (mapping && mapping.directory === defaultDir) {
1122
+ discordLogger.log(`Preserving channel_directories row for deleted default channel ${channel.id} as tombstone`);
1123
+ return;
1124
+ }
1116
1125
  const deleted = await deleteChannelDirectoryById(channel.id);
1117
1126
  if (deleted) {
1118
1127
  discordLogger.log(`Cleaned up channel_directories for deleted channel ${channel.id}`);
@@ -19,6 +19,11 @@ import { createLogger, LogPrefix } from './logger.js';
19
19
  import { ServerStartError, FetchError } from './errors.js';
20
20
  import { getLockPort } from './config.js';
21
21
  import { store } from './store.js';
22
+ // Circular import: opencode.ts → hrana-server.ts → opencode.ts.
23
+ // Safe because both sides only use lazy runtime function calls, never
24
+ // top-level initialization values. The cycle could be broken by moving
25
+ // the port into store.ts, but the current approach is simpler.
26
+ import { getOpencodeServerPort } from './opencode.js';
22
27
  const hranaLogger = createLogger(LogPrefix.DB);
23
28
  let db = null;
24
29
  let server = null;
@@ -146,6 +151,20 @@ export async function startHranaServer({ dbPath, bindAll = false, }) {
146
151
  res.end(JSON.stringify({ status: 'ok', pid: process.pid }));
147
152
  return;
148
153
  }
154
+ // OpenCode server port discovery — no auth required (localhost only).
155
+ // CLI subcommands query this to reuse the bot's running OpenCode server
156
+ // instead of spawning a redundant second server process.
157
+ if (pathname === '/kimaki/opencode-port') {
158
+ const port = getOpencodeServerPort();
159
+ if (port === null) {
160
+ res.writeHead(404, { 'content-type': 'application/json' });
161
+ res.end(JSON.stringify({ error: 'no_opencode_server' }));
162
+ return;
163
+ }
164
+ res.writeHead(200, { 'content-type': 'application/json' });
165
+ res.end(JSON.stringify({ port }));
166
+ return;
167
+ }
149
168
  // Hrana routes: /v2, /v2/pipeline — require auth
150
169
  if (pathname === '/v2' || pathname === '/v2/pipeline') {
151
170
  if (!isAuthorizedRequest(req)) {
@@ -203,6 +203,27 @@ export function isTokenRefreshError(message) {
203
203
  haystack.includes('refresh_token') ||
204
204
  (haystack.includes('401') && haystack.includes('refresh')));
205
205
  }
206
+ /** Permanent OAuth refresh death (invalid_grant / expired refresh). Remove account, do not rotate. */
207
+ export function isPermanentOAuthRefreshFailure(error) {
208
+ const message = typeof error === 'string'
209
+ ? error
210
+ : error instanceof Error
211
+ ? error.message
212
+ : '';
213
+ const haystack = message.toLowerCase();
214
+ if (!haystack)
215
+ return false;
216
+ if (haystack.includes('invalid_grant'))
217
+ return true;
218
+ if (haystack.includes('refresh token expired'))
219
+ return true;
220
+ if (haystack.includes('refresh_token') || haystack.includes('refresh token')) {
221
+ return (haystack.includes('expired') ||
222
+ haystack.includes('invalid') ||
223
+ haystack.includes('revoked'));
224
+ }
225
+ return false;
226
+ }
206
227
  // --- Auth file helpers ---
207
228
  export function isOAuthStored(value) {
208
229
  if (!value || typeof value !== 'object') {