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.
- package/dist/anthropic-auth-plugin.js +78 -33
- package/dist/anthropic-auth-state.js +62 -27
- package/dist/anthropic-auth-state.test.js +55 -2
- package/dist/channel-management.js +29 -6
- package/dist/cli-commands/project.js +6 -1
- package/dist/commands/login.js +1 -1
- package/dist/commands/new-worktree.js +9 -3
- package/dist/commands/queue.js +12 -2
- package/dist/database.js +7 -8
- package/dist/db.js +1 -0
- package/dist/discord-bot.js +9 -0
- package/dist/hrana-server.js +19 -0
- package/dist/oauth-rotation-shared.js +21 -0
- package/dist/opencode.js +73 -4
- package/dist/schema.js +3 -0
- package/dist/session-handler/global-event-listener.js +38 -6
- package/dist/session-handler/thread-runtime-state.js +2 -0
- package/dist/session-handler/thread-session-runtime.js +19 -3
- package/dist/system-message.js +34 -20
- package/dist/system-message.test.js +34 -20
- package/dist/task-runner.js +8 -4
- package/dist/worktree-lifecycle.e2e.test.js +16 -2
- package/package.json +6 -6
- package/skills/holocron/SKILL.md +11 -8
- package/src/anthropic-auth-plugin.ts +82 -35
- package/src/anthropic-auth-state.test.ts +73 -1
- package/src/anthropic-auth-state.ts +85 -25
- package/src/channel-management.ts +35 -6
- package/src/cli-commands/project.ts +6 -1
- package/src/commands/login.ts +1 -1
- package/src/commands/new-worktree.ts +14 -3
- package/src/commands/queue.ts +13 -2
- package/src/database.ts +8 -9
- package/src/db.ts +1 -0
- package/src/discord-bot.ts +12 -0
- package/src/hrana-server.ts +19 -0
- package/src/oauth-rotation-shared.ts +22 -0
- package/src/opencode.ts +90 -6
- package/src/schema.sql +1 -0
- package/src/schema.ts +3 -0
- package/src/session-handler/global-event-listener.ts +38 -6
- package/src/session-handler/thread-runtime-state.ts +4 -1
- package/src/session-handler/thread-session-runtime.ts +25 -3
- package/src/system-message.test.ts +34 -20
- package/src/system-message.ts +34 -20
- package/src/task-runner.ts +8 -4
- package/src/worktree-lifecycle.e2e.test.ts +18 -2
package/skills/holocron/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
745
|
-
|
|
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 =
|
|
928
|
-
const
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
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
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
|
198
|
-
if (!
|
|
199
|
-
|
|
200
|
-
await
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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 =
|
|
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
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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 {
|
package/src/commands/login.ts
CHANGED
|
@@ -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
|
|
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)
|
package/src/commands/queue.ts
CHANGED
|
@@ -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 ${
|
|
173
|
+
content: `Cleared ${cleared.length} queued message${cleared.length > 1 ? 's' : ''}:\n${list}`,
|
|
163
174
|
flags: SILENT_MESSAGE_FLAGS,
|
|
164
175
|
})
|
|
165
176
|
|