@otto-assistant/bridge 0.4.93 → 0.4.97

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 (40) hide show
  1. package/dist/anthropic-account-identity.js +62 -0
  2. package/dist/anthropic-account-identity.test.js +38 -0
  3. package/dist/anthropic-auth-plugin.js +88 -16
  4. package/dist/anthropic-auth-state.js +28 -3
  5. package/dist/{anthropic-auth-plugin.test.js → anthropic-auth-state.test.js} +21 -2
  6. package/dist/cli-parsing.test.js +12 -9
  7. package/dist/cli.js +23 -10
  8. package/dist/context-awareness-plugin.js +1 -1
  9. package/dist/context-awareness-plugin.test.js +2 -2
  10. package/dist/discord-command-registration.js +2 -2
  11. package/dist/discord-utils.js +5 -2
  12. package/dist/kimaki-opencode-plugin.js +1 -0
  13. package/dist/logger.js +8 -2
  14. package/dist/session-handler/thread-session-runtime.js +18 -1
  15. package/dist/system-message.js +1 -1
  16. package/dist/system-message.test.js +1 -1
  17. package/dist/system-prompt-drift-plugin.js +251 -0
  18. package/dist/utils.js +5 -1
  19. package/dist/worktrees.js +0 -33
  20. package/package.json +2 -1
  21. package/src/anthropic-account-identity.test.ts +52 -0
  22. package/src/anthropic-account-identity.ts +77 -0
  23. package/src/anthropic-auth-plugin.ts +97 -16
  24. package/src/{anthropic-auth-plugin.test.ts → anthropic-auth-state.test.ts} +23 -1
  25. package/src/anthropic-auth-state.ts +36 -3
  26. package/src/cli-parsing.test.ts +16 -9
  27. package/src/cli.ts +29 -11
  28. package/src/context-awareness-plugin.test.ts +2 -2
  29. package/src/context-awareness-plugin.ts +1 -1
  30. package/src/discord-command-registration.ts +2 -2
  31. package/src/discord-utils.ts +19 -17
  32. package/src/kimaki-opencode-plugin.ts +1 -0
  33. package/src/logger.ts +9 -2
  34. package/src/session-handler/thread-session-runtime.ts +21 -1
  35. package/src/system-message.test.ts +1 -1
  36. package/src/system-message.ts +1 -1
  37. package/src/system-prompt-drift-plugin.ts +379 -0
  38. package/src/utils.ts +5 -1
  39. package/src/worktrees.test.ts +1 -0
  40. package/src/worktrees.ts +1 -47
@@ -35,6 +35,10 @@ import {
35
35
  upsertAccount,
36
36
  withAuthStateLock,
37
37
  } from './anthropic-auth-state.js'
38
+ import {
39
+ extractAnthropicAccountIdentity,
40
+ type AnthropicAccountIdentity,
41
+ } from './anthropic-account-identity.js'
38
42
  // PKCE (Proof Key for Code Exchange) using Web Crypto API.
39
43
  // Reference: https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/utils/oauth/pkce.ts
40
44
  function base64urlEncode(bytes: Uint8Array): string {
@@ -68,6 +72,8 @@ const CLIENT_ID = (() => {
68
72
 
69
73
  const TOKEN_URL = 'https://platform.claude.com/v1/oauth/token'
70
74
  const CREATE_API_KEY_URL = 'https://api.anthropic.com/api/oauth/claude_cli/create_api_key'
75
+ const CLIENT_DATA_URL = 'https://api.anthropic.com/api/oauth/claude_cli/client_data'
76
+ const PROFILE_URL = 'https://api.anthropic.com/api/oauth/profile'
71
77
  const CALLBACK_PORT = 53692
72
78
  const CALLBACK_PATH = '/callback'
73
79
  const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`
@@ -81,6 +87,7 @@ const CLAUDE_CODE_BETA = 'claude-code-20250219'
81
87
  const OAUTH_BETA = 'oauth-2025-04-20'
82
88
  const FINE_GRAINED_TOOL_STREAMING_BETA = 'fine-grained-tool-streaming-2025-05-14'
83
89
  const INTERLEAVED_THINKING_BETA = 'interleaved-thinking-2025-05-14'
90
+ const TOAST_SESSION_HEADER = 'x-kimaki-session-id'
84
91
 
85
92
  const ANTHROPIC_HOSTS = new Set([
86
93
  'api.anthropic.com',
@@ -298,6 +305,28 @@ async function createApiKey(accessToken: string): Promise<ApiKeySuccess> {
298
305
  return { type: 'success', key: json.raw_key }
299
306
  }
300
307
 
308
+ async function fetchAnthropicAccountIdentity(accessToken: string) {
309
+ const urls = [CLIENT_DATA_URL, PROFILE_URL]
310
+ for (const url of urls) {
311
+ const responseText = await requestText(url, {
312
+ method: 'GET',
313
+ headers: {
314
+ Accept: 'application/json',
315
+ authorization: `Bearer ${accessToken}`,
316
+ 'user-agent': process.env.OPENCODE_ANTHROPIC_USER_AGENT || `claude-cli/${CLAUDE_CODE_VERSION}`,
317
+ 'x-app': 'cli',
318
+ },
319
+ }).catch(() => {
320
+ return undefined
321
+ })
322
+ if (!responseText) continue
323
+ const parsed = JSON.parse(responseText) as unknown
324
+ const identity = extractAnthropicAccountIdentity(parsed)
325
+ if (identity) return identity
326
+ }
327
+ return undefined
328
+ }
329
+
301
330
  // --- Localhost callback server ---
302
331
 
303
332
  type CallbackResult = { code: string; state: string }
@@ -469,12 +498,13 @@ function buildAuthorizeHandler(mode: 'oauth' | 'apikey') {
469
498
  if (mode === 'apikey') {
470
499
  return createApiKey(creds.access)
471
500
  }
501
+ const identity = await fetchAnthropicAccountIdentity(creds.access)
472
502
  await rememberAnthropicOAuth({
473
503
  type: 'oauth',
474
504
  refresh: creds.refresh,
475
505
  access: creds.access,
476
506
  expires: creds.expires,
477
- })
507
+ }, identity)
478
508
  return creds
479
509
  }
480
510
 
@@ -489,8 +519,7 @@ function buildAuthorizeHandler(mode: 'oauth' | 'apikey') {
489
519
  try {
490
520
  const result = await waitForCallback(auth.callbackServer)
491
521
  return await finalize(result)
492
- } catch (error) {
493
- console.error(`[anthropic-auth] ${error}`)
522
+ } catch {
494
523
  return { type: 'failed' }
495
524
  }
496
525
  })()
@@ -509,8 +538,7 @@ function buildAuthorizeHandler(mode: 'oauth' | 'apikey') {
509
538
  try {
510
539
  const result = await waitForCallback(auth.callbackServer, input)
511
540
  return await finalize(result)
512
- } catch (error) {
513
- console.error(`[anthropic-auth] ${error}`)
541
+ } catch {
514
542
  return { type: 'failed' }
515
543
  }
516
544
  })()
@@ -528,17 +556,26 @@ function toClaudeCodeToolName(name: string) {
528
556
  return OPENCODE_TO_CLAUDE_CODE_TOOL_NAME[name.toLowerCase()] ?? name
529
557
  }
530
558
 
531
- function sanitizeSystemText(text: string) {
532
- return text.replaceAll(OPENCODE_IDENTITY, CLAUDE_CODE_IDENTITY)
559
+ function sanitizeSystemText(text: string, onError?: (msg: string) => void) {
560
+ const startIdx = text.indexOf(OPENCODE_IDENTITY)
561
+ if (startIdx === -1) return text
562
+ const codeRefsMarker = '# Code References'
563
+ const endIdx = text.indexOf(codeRefsMarker, startIdx)
564
+ if (endIdx === -1) {
565
+ onError?.(`sanitizeSystemText: could not find '# Code References' after OpenCode identity`)
566
+ return text
567
+ }
568
+ // Remove everything from the OpenCode identity up to (but not including) '# Code References'
569
+ return text.slice(0, startIdx) + text.slice(endIdx)
533
570
  }
534
571
 
535
- function prependClaudeCodeIdentity(system: unknown) {
572
+ function prependClaudeCodeIdentity(system: unknown, onError?: (msg: string) => void) {
536
573
  const identityBlock = { type: 'text', text: CLAUDE_CODE_IDENTITY }
537
574
 
538
575
  if (typeof system === 'undefined') return [identityBlock]
539
576
 
540
577
  if (typeof system === 'string') {
541
- const sanitized = sanitizeSystemText(system)
578
+ const sanitized = sanitizeSystemText(system, onError)
542
579
  if (sanitized === CLAUDE_CODE_IDENTITY) return [identityBlock]
543
580
  return [identityBlock, { type: 'text', text: sanitized }]
544
581
  }
@@ -546,11 +583,11 @@ function prependClaudeCodeIdentity(system: unknown) {
546
583
  if (!Array.isArray(system)) return [identityBlock, system]
547
584
 
548
585
  const sanitized = system.map((item) => {
549
- if (typeof item === 'string') return { type: 'text', text: sanitizeSystemText(item) }
586
+ if (typeof item === 'string') return { type: 'text', text: sanitizeSystemText(item, onError) }
550
587
  if (item && typeof item === 'object' && (item as { type?: unknown }).type === 'text') {
551
588
  const text = (item as { text?: unknown }).text
552
589
  if (typeof text === 'string') {
553
- return { ...(item as Record<string, unknown>), text: sanitizeSystemText(text) }
590
+ return { ...(item as Record<string, unknown>), text: sanitizeSystemText(text, onError) }
554
591
  }
555
592
  }
556
593
  return item
@@ -568,7 +605,7 @@ function prependClaudeCodeIdentity(system: unknown) {
568
605
  return [identityBlock, ...sanitized]
569
606
  }
570
607
 
571
- function rewriteRequestPayload(body: string | undefined) {
608
+ function rewriteRequestPayload(body: string | undefined, onError?: (msg: string) => void) {
572
609
  if (!body) return { body, modelId: undefined, reverseToolNameMap: new Map<string, string>() }
573
610
 
574
611
  try {
@@ -589,7 +626,7 @@ function rewriteRequestPayload(body: string | undefined) {
589
626
  }
590
627
 
591
628
  // Rename system prompt
592
- payload.system = prependClaudeCodeIdentity(payload.system)
629
+ payload.system = prependClaudeCodeIdentity(payload.system, onError)
593
630
 
594
631
  // Rename tool_choice
595
632
  if (
@@ -673,6 +710,19 @@ function wrapResponseStream(response: Response, reverseToolNameMap: Map<string,
673
710
  })
674
711
  }
675
712
 
713
+ function appendToastSessionMarker({
714
+ message,
715
+ sessionId,
716
+ }: {
717
+ message: string
718
+ sessionId: string | undefined
719
+ }) {
720
+ if (!sessionId) {
721
+ return message
722
+ }
723
+ return `${message} ${sessionId}`
724
+ }
725
+
676
726
  // --- Beta headers ---
677
727
 
678
728
  function getRequiredBetas(modelId: string | undefined) {
@@ -728,7 +778,18 @@ async function getFreshOAuth(
728
778
  await setAnthropicAuth(refreshed, client)
729
779
  const store = await loadAccountStore()
730
780
  if (store.accounts.length > 0) {
731
- upsertAccount(store, refreshed)
781
+ const identity: AnthropicAccountIdentity | undefined = (() => {
782
+ const currentIndex = store.accounts.findIndex((account) => {
783
+ return account.refresh === latest.refresh || account.access === latest.access
784
+ })
785
+ const current = currentIndex >= 0 ? store.accounts[currentIndex] : undefined
786
+ if (!current) return undefined
787
+ return {
788
+ ...(current.email ? { email: current.email } : {}),
789
+ ...(current.accountId ? { accountId: current.accountId } : {}),
790
+ }
791
+ })()
792
+ upsertAccount(store, { ...refreshed, ...identity })
732
793
  await saveAccountStore(store)
733
794
  }
734
795
  return refreshed
@@ -743,6 +804,12 @@ async function getFreshOAuth(
743
804
 
744
805
  const AnthropicAuthPlugin: Plugin = async ({ client }) => {
745
806
  return {
807
+ 'chat.headers': async (input, output) => {
808
+ if (input.model.providerID !== 'anthropic') {
809
+ return
810
+ }
811
+ output.headers[TOAST_SESSION_HEADER] = input.sessionID
812
+ },
746
813
  auth: {
747
814
  provider: 'anthropic',
748
815
  async loader(
@@ -779,17 +846,27 @@ const AnthropicAuthPlugin: Plugin = async ({ client }) => {
779
846
  .catch(() => undefined)
780
847
  : undefined
781
848
 
782
- const rewritten = rewriteRequestPayload(originalBody)
783
849
  const headers = new Headers(init?.headers)
784
850
  if (input instanceof Request) {
785
851
  input.headers.forEach((v, k) => {
786
852
  if (!headers.has(k)) headers.set(k, v)
787
853
  })
788
854
  }
855
+ const sessionId = headers.get(TOAST_SESSION_HEADER) ?? undefined
856
+
857
+ const rewritten = rewriteRequestPayload(originalBody, (msg) => {
858
+ client.tui.showToast({
859
+ body: {
860
+ message: appendToastSessionMarker({ message: msg, sessionId }),
861
+ variant: 'error',
862
+ },
863
+ }).catch(() => {})
864
+ })
789
865
  const betas = getRequiredBetas(rewritten.modelId)
790
866
 
791
867
  const runRequest = async (auth: OAuthStored) => {
792
868
  const requestHeaders = new Headers(headers)
869
+ requestHeaders.delete(TOAST_SESSION_HEADER)
793
870
  requestHeaders.set('accept', 'application/json')
794
871
  requestHeaders.set(
795
872
  'anthropic-beta',
@@ -826,9 +903,13 @@ const AnthropicAuthPlugin: Plugin = async ({ client }) => {
826
903
  // Show toast notification so Discord thread shows the rotation
827
904
  client.tui.showToast({
828
905
  body: {
829
- message: `Switching from account ${rotated.fromLabel} to account ${rotated.toLabel}`,
906
+ message: appendToastSessionMarker({
907
+ message: `Switching from account ${rotated.fromLabel} to account ${rotated.toLabel}`,
908
+ sessionId,
909
+ }),
830
910
  variant: 'info',
831
911
  },
912
+
832
913
  }).catch(() => {})
833
914
  const retryAuth = await getFreshOAuth(getAuth, client)
834
915
  if (retryAuth) {
@@ -1,10 +1,11 @@
1
- // Tests for Anthropic OAuth multi-account persistence and rotation.
1
+ // Tests Anthropic OAuth account persistence, deduplication, and rotation.
2
2
 
3
3
  import { mkdtemp, readFile, rm, mkdir, writeFile } from 'node:fs/promises'
4
4
  import { tmpdir } from 'node:os'
5
5
  import path from 'node:path'
6
6
  import { afterEach, beforeEach, describe, expect, test } from 'vitest'
7
7
  import {
8
+ accountLabel,
8
9
  authFilePath,
9
10
  loadAccountStore,
10
11
  rememberAnthropicOAuth,
@@ -60,6 +61,27 @@ describe('rememberAnthropicOAuth', () => {
60
61
  expires: 3,
61
62
  })
62
63
  })
64
+
65
+ test('deduplicates new tokens by email or account ID', async () => {
66
+ await rememberAnthropicOAuth(firstAccount, {
67
+ email: 'user@example.com',
68
+ accountId: 'usr_123',
69
+ })
70
+ await rememberAnthropicOAuth(secondAccount, {
71
+ email: 'User@example.com',
72
+ accountId: 'usr_123',
73
+ })
74
+
75
+ const store = await loadAccountStore()
76
+ expect(store.accounts).toHaveLength(1)
77
+ expect(store.accounts[0]).toMatchObject({
78
+ refresh: 'refresh-second',
79
+ access: 'access-second',
80
+ email: 'user@example.com',
81
+ accountId: 'usr_123',
82
+ })
83
+ expect(accountLabel(store.accounts[0]!)).toBe('user@example.com')
84
+ })
63
85
  })
64
86
 
65
87
  describe('rotateAnthropicAccount', () => {
@@ -2,6 +2,10 @@ import type { Plugin } from '@opencode-ai/plugin'
2
2
  import * as fs from 'node:fs/promises'
3
3
  import { homedir } from 'node:os'
4
4
  import path from 'node:path'
5
+ import {
6
+ normalizeAnthropicAccountIdentity,
7
+ type AnthropicAccountIdentity,
8
+ } from './anthropic-account-identity.js'
5
9
 
6
10
  const AUTH_LOCK_STALE_MS = 30_000
7
11
  const AUTH_LOCK_RETRY_MS = 100
@@ -14,6 +18,8 @@ export type OAuthStored = {
14
18
  }
15
19
 
16
20
  type AccountRecord = OAuthStored & {
21
+ email?: string
22
+ accountId?: string
17
23
  addedAt: number
18
24
  lastUsed: number
19
25
  }
@@ -114,6 +120,8 @@ export function normalizeAccountStore(
114
120
  typeof account.refresh === 'string' &&
115
121
  typeof account.access === 'string' &&
116
122
  typeof account.expires === 'number' &&
123
+ (typeof account.email === 'undefined' || typeof account.email === 'string') &&
124
+ (typeof account.accountId === 'undefined' || typeof account.accountId === 'string') &&
117
125
  typeof account.addedAt === 'number' &&
118
126
  typeof account.lastUsed === 'number',
119
127
  )
@@ -135,8 +143,13 @@ export async function saveAccountStore(store: AccountStore) {
135
143
 
136
144
  /** Short label for an account: first 8 + last 4 chars of refresh token. */
137
145
  export function accountLabel(account: OAuthStored, index?: number): string {
146
+ const accountWithIdentity = account as OAuthStored & AnthropicAccountIdentity
147
+ const identity = accountWithIdentity.email || accountWithIdentity.accountId
138
148
  const r = account.refresh
139
149
  const short = r.length > 12 ? `${r.slice(0, 8)}...${r.slice(-4)}` : r
150
+ if (identity) {
151
+ return index !== undefined ? `#${index + 1} (${identity})` : identity
152
+ }
140
153
  return index !== undefined ? `#${index + 1} (${short})` : short
141
154
  }
142
155
 
@@ -162,14 +175,29 @@ function findCurrentAccountIndex(store: AccountStore, auth: OAuthStored) {
162
175
  }
163
176
 
164
177
  export function upsertAccount(store: AccountStore, auth: OAuthStored, now = Date.now()) {
178
+ const authWithIdentity = auth as OAuthStored & AnthropicAccountIdentity
179
+ const identity = normalizeAnthropicAccountIdentity({
180
+ email: authWithIdentity.email,
181
+ accountId: authWithIdentity.accountId,
182
+ })
165
183
  const index = store.accounts.findIndex((account) => {
166
- return account.refresh === auth.refresh || account.access === auth.access
184
+ if (account.refresh === auth.refresh || account.access === auth.access) {
185
+ return true
186
+ }
187
+ if (identity?.accountId && account.accountId === identity.accountId) {
188
+ return true
189
+ }
190
+ if (identity?.email && account.email === identity.email) {
191
+ return true
192
+ }
193
+ return false
167
194
  })
168
195
  const nextAccount: AccountRecord = {
169
196
  type: 'oauth',
170
197
  refresh: auth.refresh,
171
198
  access: auth.access,
172
199
  expires: auth.expires,
200
+ ...identity,
173
201
  addedAt: now,
174
202
  lastUsed: now,
175
203
  }
@@ -186,15 +214,20 @@ export function upsertAccount(store: AccountStore, auth: OAuthStored, now = Date
186
214
  ...existing,
187
215
  ...nextAccount,
188
216
  addedAt: existing.addedAt,
217
+ email: nextAccount.email || existing.email,
218
+ accountId: nextAccount.accountId || existing.accountId,
189
219
  }
190
220
  store.activeIndex = index
191
221
  return index
192
222
  }
193
223
 
194
- export async function rememberAnthropicOAuth(auth: OAuthStored) {
224
+ export async function rememberAnthropicOAuth(
225
+ auth: OAuthStored,
226
+ identity?: AnthropicAccountIdentity,
227
+ ) {
195
228
  await withAuthStateLock(async () => {
196
229
  const store = await loadAccountStore()
197
- upsertAccount(store, auth)
230
+ upsertAccount(store, { ...auth, ...normalizeAnthropicAccountIdentity(identity) })
198
231
  await saveAccountStore(store)
199
232
  })
200
233
  }
@@ -27,8 +27,8 @@ function createCliForIdParsing() {
27
27
  .option('-g, --guild <guildId>', 'Discord guild/server ID')
28
28
 
29
29
  cli.command('task delete <id>', 'Delete task')
30
- cli.command('anthropic-accounts list', 'List stored Anthropic accounts').hidden()
31
- cli.command('anthropic-accounts remove <index>', 'Remove stored Anthropic account').hidden()
30
+ cli.command('anthropic-accounts list', 'List stored Anthropic accounts')
31
+ cli.command('anthropic-accounts remove <indexOrEmail>', 'Remove stored Anthropic account')
32
32
 
33
33
  return cli
34
34
  }
@@ -163,19 +163,26 @@ describe('goke CLI ID parsing', () => {
163
163
  expect(typeof result.args[0]).toBe('string')
164
164
  })
165
165
 
166
- test('hidden anthropic account commands still parse', () => {
166
+ test('anthropic account remove parses index and email as strings', () => {
167
167
  const cli = createCliForIdParsing()
168
168
 
169
- const result = cli.parse(
169
+ const indexResult = cli.parse(
170
170
  ['node', 'kimaki', 'anthropic-accounts', 'remove', '2'],
171
171
  { run: false },
172
172
  )
173
173
 
174
- expect(result.args[0]).toBe('2')
175
- expect(typeof result.args[0]).toBe('string')
174
+ const emailResult = cli.parse(
175
+ ['node', 'kimaki', 'anthropic-accounts', 'remove', 'user@example.com'],
176
+ { run: false },
177
+ )
178
+
179
+ expect(indexResult.args[0]).toBe('2')
180
+ expect(typeof indexResult.args[0]).toBe('string')
181
+ expect(emailResult.args[0]).toBe('user@example.com')
182
+ expect(typeof emailResult.args[0]).toBe('string')
176
183
  })
177
184
 
178
- test('hidden anthropic account commands are excluded from help output', () => {
185
+ test('anthropic account commands are included in help output', () => {
179
186
  const stdout = {
180
187
  text: '',
181
188
  write(data: string | Uint8Array) {
@@ -185,11 +192,11 @@ describe('goke CLI ID parsing', () => {
185
192
 
186
193
  const cli = goke('kimaki', { stdout: stdout as never })
187
194
  cli.command('send', 'Send a message')
188
- cli.command('anthropic-accounts list', 'List stored Anthropic accounts').hidden()
195
+ cli.command('anthropic-accounts list', 'List stored Anthropic accounts')
189
196
  cli.help()
190
197
  cli.parse(['node', 'kimaki', '--help'], { run: false })
191
198
 
192
199
  expect(stdout.text).toContain('send')
193
- expect(stdout.text).not.toContain('anthropic-accounts')
200
+ expect(stdout.text).toContain('anthropic-accounts')
194
201
  })
195
202
  })
package/src/cli.ts CHANGED
@@ -1024,7 +1024,8 @@ async function resolveCredentials({
1024
1024
  options: [
1025
1025
  {
1026
1026
  value: 'gateway' as const,
1027
- label: 'Gateway (pre-built Kimaki bot — no setup needed)',
1027
+ disabled: true,
1028
+ label: 'Gateway (pre-built Kimaki bot, currently disabled because of Discord verification process. will be re-enabled soon)',
1028
1029
  },
1029
1030
  {
1030
1031
  value: 'self_hosted' as const,
@@ -3168,7 +3169,6 @@ cli
3168
3169
  'anthropic-accounts list',
3169
3170
  'List stored Anthropic OAuth accounts used for automatic rotation',
3170
3171
  )
3171
- .hidden()
3172
3172
  .action(async () => {
3173
3173
  const store = await loadAccountStore()
3174
3174
  console.log(`Store: ${accountsFilePath()}`)
@@ -3187,19 +3187,37 @@ cli
3187
3187
 
3188
3188
  cli
3189
3189
  .command(
3190
- 'anthropic-accounts remove <index>',
3191
- 'Remove a stored Anthropic OAuth account from the rotation pool',
3190
+ 'anthropic-accounts remove <indexOrEmail>',
3191
+ 'Remove a stored Anthropic OAuth account from the rotation pool by index or email',
3192
3192
  )
3193
- .hidden()
3194
- .action(async (index: string) => {
3195
- const value = Number(index)
3196
- if (!Number.isInteger(value) || value < 1) {
3197
- cliLogger.error('Usage: kimaki anthropic-accounts remove <index>')
3193
+ .action(async (indexOrEmail: string) => {
3194
+ const value = Number(indexOrEmail)
3195
+ const store = await loadAccountStore()
3196
+ const resolvedIndex = (() => {
3197
+ if (Number.isInteger(value) && value >= 1) {
3198
+ return value - 1
3199
+ }
3200
+ const email = indexOrEmail.trim().toLowerCase()
3201
+ if (!email) {
3202
+ return -1
3203
+ }
3204
+ return store.accounts.findIndex((account) => {
3205
+ return account.email?.toLowerCase() === email
3206
+ })
3207
+ })()
3208
+
3209
+ if (resolvedIndex < 0) {
3210
+ cliLogger.error(
3211
+ 'Usage: kimaki anthropic-accounts remove <index-or-email>',
3212
+ )
3198
3213
  process.exit(EXIT_NO_RESTART)
3199
3214
  }
3200
3215
 
3201
- await removeAccount(value - 1)
3202
- cliLogger.log(`Removed Anthropic account ${value}`)
3216
+ const removed = store.accounts[resolvedIndex]
3217
+ await removeAccount(resolvedIndex)
3218
+ cliLogger.log(
3219
+ `Removed Anthropic account ${removed ? accountLabel(removed, resolvedIndex) : indexOrEmail}`,
3220
+ )
3203
3221
  process.exit(0)
3204
3222
  })
3205
3223
 
@@ -46,7 +46,7 @@ describe('shouldInjectPwd', () => {
46
46
  {
47
47
  "inject": true,
48
48
  "text": "
49
- [working directory changed. Previous working directory: /repo/main. Current working directory: /repo/worktree. You MUST read, write, and edit files only under /repo/worktree. Do NOT read, write, or edit files under /repo/main.]",
49
+ [working directory changed. Previous working directory: /repo/main. Current working directory: /repo/worktree. You should read, write, and edit files under /repo/worktree. Do NOT read, write, or edit files under /repo/main.]",
50
50
  }
51
51
  `)
52
52
  })
@@ -62,7 +62,7 @@ describe('shouldInjectPwd', () => {
62
62
  {
63
63
  "inject": true,
64
64
  "text": "
65
- [working directory changed. Previous working directory: /repo/worktree-a. Current working directory: /repo/worktree-b. You MUST read, write, and edit files only under /repo/worktree-b. Do NOT read, write, or edit files under /repo/worktree-a.]",
65
+ [working directory changed. Previous working directory: /repo/worktree-a. Current working directory: /repo/worktree-b. You should read, write, and edit files under /repo/worktree-b. Do NOT read, write, or edit files under /repo/worktree-a.]",
66
66
  }
67
67
  `)
68
68
  })
@@ -126,7 +126,7 @@ export function shouldInjectPwd({
126
126
  text:
127
127
  `\n[working directory changed. Previous working directory: ${priorDirectory}. ` +
128
128
  `Current working directory: ${currentDir}. ` +
129
- `You MUST read, write, and edit files only under ${currentDir}. ` +
129
+ `You should read, write, and edit files under ${currentDir}. ` +
130
130
  `Do NOT read, write, or edit files under ${priorDirectory}.]`,
131
131
  }
132
132
  }
@@ -182,7 +182,7 @@ export async function registerCommands({
182
182
  new SlashCommandBuilder()
183
183
  .setName('new-worktree')
184
184
  .setDescription(
185
- truncateCommandDescription('Create a git worktree branch from origin/HEAD (or main). Optionally pick a base branch.'),
185
+ truncateCommandDescription('Create a git worktree branch from HEAD by default. Optionally pick a base branch.'),
186
186
  )
187
187
  .addStringOption((option) => {
188
188
  option
@@ -198,7 +198,7 @@ export async function registerCommands({
198
198
  option
199
199
  .setName('base-branch')
200
200
  .setDescription(
201
- truncateCommandDescription('Branch to create the worktree from (default: origin/HEAD or main)'),
201
+ truncateCommandDescription('Branch to create the worktree from (default: HEAD)'),
202
202
  )
203
203
  .setRequired(false)
204
204
  .setAutocomplete(true)
@@ -2,19 +2,21 @@
2
2
  // Handles markdown splitting for Discord's 2000-char limit, code block escaping,
3
3
  // thread message sending, and channel metadata extraction from topic tags.
4
4
 
5
- import {
6
- type APIInteractionGuildMember,
7
- type AutocompleteInteraction,
8
- ChannelType,
9
- GuildMember,
10
- MessageFlags,
11
- PermissionsBitField,
12
- type Guild,
13
- type Message,
14
- type TextChannel,
15
- type ThreadChannel,
5
+ // Use namespace import for CJS interop — discord.js is CJS and its named
6
+ // exports aren't detectable by all ESM loaders (e.g. tsx/esbuild) because
7
+ // discord.js uses tslib's __exportStar which is opaque to static analysis.
8
+ import * as discord from 'discord.js'
9
+ import type {
10
+ APIInteractionGuildMember,
11
+ AutocompleteInteraction,
12
+ GuildMember as GuildMemberType,
13
+ Guild,
14
+ Message,
15
+ REST as RESTType,
16
+ TextChannel,
17
+ ThreadChannel,
16
18
  } from 'discord.js'
17
- import { REST, Routes } from 'discord.js'
19
+ const { ChannelType, GuildMember, MessageFlags, PermissionsBitField, REST, Routes } = discord
18
20
  import type { OpencodeClient } from '@opencode-ai/sdk/v2'
19
21
  import { discordApiUrl } from './discord-urls.js'
20
22
  import { Lexer } from 'marked'
@@ -37,7 +39,7 @@ const discordLogger = createLogger(LogPrefix.DISCORD)
37
39
  * Returns false if member is null or has the "no-kimaki" role (overrides all).
38
40
  */
39
41
  export function hasKimakiBotPermission(
40
- member: GuildMember | APIInteractionGuildMember | null,
42
+ member: GuildMemberType | APIInteractionGuildMember | null,
41
43
  guild?: Guild | null,
42
44
  ): boolean {
43
45
  if (!member) {
@@ -61,7 +63,7 @@ export function hasKimakiBotPermission(
61
63
  }
62
64
 
63
65
  function hasRoleByName(
64
- member: GuildMember | APIInteractionGuildMember,
66
+ member: GuildMemberType | APIInteractionGuildMember,
65
67
  roleName: string,
66
68
  guild?: Guild | null,
67
69
  ): boolean {
@@ -89,7 +91,7 @@ function hasRoleByName(
89
91
  * Check if the member has the "no-kimaki" role that blocks bot access.
90
92
  * Separate from hasKimakiBotPermission so callers can show a specific error message.
91
93
  */
92
- export function hasNoKimakiRole(member: GuildMember | null): boolean {
94
+ export function hasNoKimakiRole(member: GuildMemberType | null): boolean {
93
95
  if (!member?.roles?.cache) {
94
96
  return false
95
97
  }
@@ -108,7 +110,7 @@ export async function reactToThread({
108
110
  channelId,
109
111
  emoji,
110
112
  }: {
111
- rest: REST
113
+ rest: RESTType
112
114
  threadId: string
113
115
  /** Parent channel ID where the thread starter message lives.
114
116
  * If not provided, fetches the thread info from Discord API to resolve it. */
@@ -169,7 +171,7 @@ export async function archiveThread({
169
171
  client,
170
172
  archiveDelay = 0,
171
173
  }: {
172
- rest: REST
174
+ rest: RESTType
173
175
  threadId: string
174
176
  parentChannelId?: string
175
177
  sessionId?: string
@@ -12,6 +12,7 @@
12
12
  export { ipcToolsPlugin } from './ipc-tools-plugin.js'
13
13
  export { contextAwarenessPlugin } from './context-awareness-plugin.js'
14
14
  export { interruptOpencodeSessionOnUserMessage } from './opencode-interrupt-plugin.js'
15
+ export { systemPromptDriftPlugin } from './system-prompt-drift-plugin.js'
15
16
  export { anthropicAuthPlugin } from './anthropic-auth-plugin.js'
16
17
  export { imageOptimizerPlugin } from './image-optimizer-plugin.js'
17
18
  export { kittyGraphicsPlugin } from 'kitty-graphics-agent'
package/src/logger.ts CHANGED
@@ -95,12 +95,19 @@ export function getLogFilePath(): string | null {
95
95
  return logFilePath
96
96
  }
97
97
 
98
+ const MAX_LOG_ARG_LENGTH = 1000
99
+
100
+ function truncate(str: string, max: number): string {
101
+ if (str.length <= max) return str
102
+ return str.slice(0, max) + `… [truncated ${str.length - max} chars]`
103
+ }
104
+
98
105
  function formatArg(arg: unknown): string {
99
106
  if (typeof arg === 'string') {
100
- return sanitizeSensitiveText(arg, { redactPaths: false })
107
+ return truncate(sanitizeSensitiveText(arg, { redactPaths: false }), MAX_LOG_ARG_LENGTH)
101
108
  }
102
109
  const safeArg = sanitizeUnknownValue(arg, { redactPaths: false })
103
- return util.inspect(safeArg, { colors: true, depth: 4 })
110
+ return truncate(util.inspect(safeArg, { colors: true, depth: 4 }), MAX_LOG_ARG_LENGTH)
104
111
  }
105
112
 
106
113
  export function formatErrorWithStack(error: unknown): string {