@otto-assistant/bridge 0.4.96 → 0.4.100

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 (44) hide show
  1. package/dist/agent-model.e2e.test.js +7 -1
  2. package/dist/anthropic-account-identity.js +62 -0
  3. package/dist/anthropic-account-identity.test.js +38 -0
  4. package/dist/anthropic-auth-plugin.js +72 -12
  5. package/dist/anthropic-auth-state.js +28 -3
  6. package/dist/{anthropic-auth-plugin.test.js → anthropic-auth-state.test.js} +21 -2
  7. package/dist/cli-parsing.test.js +12 -9
  8. package/dist/cli-send-thread.e2e.test.js +4 -7
  9. package/dist/cli.js +25 -12
  10. package/dist/commands/screenshare.js +1 -1
  11. package/dist/commands/screenshare.test.js +2 -2
  12. package/dist/commands/vscode.js +269 -0
  13. package/dist/db.js +1 -0
  14. package/dist/discord-command-registration.js +7 -2
  15. package/dist/gateway-proxy-reconnect.e2e.test.js +2 -2
  16. package/dist/interaction-handler.js +4 -0
  17. package/dist/system-message.js +24 -23
  18. package/dist/system-message.test.js +24 -23
  19. package/dist/system-prompt-drift-plugin.js +41 -11
  20. package/dist/utils.js +1 -1
  21. package/dist/worktrees.js +0 -33
  22. package/package.json +1 -1
  23. package/src/agent-model.e2e.test.ts +8 -1
  24. package/src/anthropic-account-identity.test.ts +52 -0
  25. package/src/anthropic-account-identity.ts +77 -0
  26. package/src/anthropic-auth-plugin.ts +82 -12
  27. package/src/{anthropic-auth-plugin.test.ts → anthropic-auth-state.test.ts} +23 -1
  28. package/src/anthropic-auth-state.ts +36 -3
  29. package/src/cli-parsing.test.ts +16 -9
  30. package/src/cli-send-thread.e2e.test.ts +6 -7
  31. package/src/cli.ts +31 -13
  32. package/src/commands/screenshare.test.ts +2 -2
  33. package/src/commands/screenshare.ts +1 -1
  34. package/src/commands/vscode.ts +342 -0
  35. package/src/db.ts +1 -0
  36. package/src/discord-command-registration.ts +9 -2
  37. package/src/gateway-proxy-reconnect.e2e.test.ts +2 -2
  38. package/src/interaction-handler.ts +5 -0
  39. package/src/system-message.test.ts +24 -23
  40. package/src/system-message.ts +24 -23
  41. package/src/system-prompt-drift-plugin.ts +48 -12
  42. package/src/utils.ts +1 -1
  43. package/src/worktrees.test.ts +1 -0
  44. package/src/worktrees.ts +1 -47
@@ -16,7 +16,7 @@ describe('system-message', () => {
16
16
  ],
17
17
  }).replace(/`[^`]*\/kimaki\.log`/, '`<data-dir>/kimaki.log`')).toMatchInlineSnapshot(`
18
18
  "
19
- The user is reading your messages from inside Discord, via kimaki.xyz
19
+ The user is reading your messages from inside Discord, via kimaki.dev
20
20
 
21
21
  ## bash tool
22
22
 
@@ -85,39 +85,40 @@ describe('system-message', () => {
85
85
 
86
86
  To start a new thread/session in this channel pro-grammatically, run:
87
87
 
88
- kimaki send --channel chan_123 --prompt "your prompt here" --user "Tommy"
88
+ kimaki send --channel chan_123 --prompt "your prompt here" --agent <current_agent> --user "Tommy"
89
89
 
90
90
  You can use this to "spawn" parallel helper sessions like teammates: start new threads with focused prompts, then come back and collect the results.
91
+ Prefer passing the current agent with \`--agent <current_agent>\` so spawned or scheduled sessions keep the same agent unless you are intentionally switching. Replace \`<current_agent>\` with the value from the per-turn \`Current agent\` reminder.
91
92
 
92
93
  IMPORTANT: NEVER use \`--worktree\` unless the user explicitly asks for a worktree. Default to creating normal threads without worktrees.
93
94
 
94
95
  To send a prompt to an existing thread instead of creating a new one:
95
96
 
96
- kimaki send --thread <thread_id> --prompt "follow-up prompt"
97
+ kimaki send --thread <thread_id> --prompt "follow-up prompt" --agent <current_agent>
97
98
 
98
99
  Use this when you already have the Discord thread ID.
99
100
 
100
101
  To send to the thread associated with a known session:
101
102
 
102
- kimaki send --session <session_id> --prompt "follow-up prompt"
103
+ kimaki send --session <session_id> --prompt "follow-up prompt" --agent <current_agent>
103
104
 
104
105
  Use this when you have the OpenCode session ID.
105
106
 
106
107
  Use --notify-only to create a notification thread without starting an AI session:
107
108
 
108
- kimaki send --channel chan_123 --prompt "User cancelled subscription" --notify-only --user "Tommy"
109
+ kimaki send --channel chan_123 --prompt "User cancelled subscription" --notify-only --agent <current_agent> --user "Tommy"
109
110
 
110
111
  Use --user to add a specific Discord user to the new thread:
111
112
 
112
- kimaki send --channel chan_123 --prompt "Review the latest CI failure" --user "Tommy"
113
+ kimaki send --channel chan_123 --prompt "Review the latest CI failure" --agent <current_agent> --user "Tommy"
113
114
 
114
115
  Use --worktree to create a git worktree for the session (ONLY when the user explicitly asks for a worktree):
115
116
 
116
- kimaki send --channel chan_123 --prompt "Add dark mode support" --worktree dark-mode --user "Tommy"
117
+ kimaki send --channel chan_123 --prompt "Add dark mode support" --worktree dark-mode --agent <current_agent> --user "Tommy"
117
118
 
118
119
  Use --cwd to start a session in an existing git worktree directory (must be a worktree of the project):
119
120
 
120
- kimaki send --channel chan_123 --prompt "Continue work on feature" --cwd /path/to/existing-worktree --user "Tommy"
121
+ kimaki send --channel chan_123 --prompt "Continue work on feature" --cwd /path/to/existing-worktree --agent <current_agent> --user "Tommy"
121
122
 
122
123
  Important:
123
124
  - NEVER use \`--worktree\` unless the user explicitly requests a worktree. Most tasks should use normal threads without worktrees.
@@ -139,8 +140,8 @@ describe('system-message', () => {
139
140
 
140
141
  You can trigger registered opencode commands (slash commands, skills, MCP prompts) by starting the \`--prompt\` with \`/commandname\`:
141
142
 
142
- kimaki send --thread <thread_id> --prompt "/review fix the auth module"
143
- kimaki send --channel chan_123 --prompt "/build-cmd update dependencies" --user "Tommy"
143
+ kimaki send --thread <thread_id> --prompt "/review fix the auth module" --agent <current_agent>
144
+ kimaki send --channel chan_123 --prompt "/build-cmd update dependencies" --agent <current_agent> --user "Tommy"
144
145
 
145
146
  The command name must match a registered opencode command. If the command is not recognized, the prompt is sent as plain text to the model. This works for both new threads (\`--channel\`) and existing threads (\`--thread\`/\`--session\`).
146
147
 
@@ -150,14 +151,14 @@ describe('system-message', () => {
150
151
 
151
152
  You can also switch agents via \`kimaki send\`:
152
153
 
153
- kimaki send --thread <thread_id> --prompt "/<agentname>-agent"
154
+ kimaki send --thread <thread_id> --prompt "/<agentname>-agent" --agent <current_agent>
154
155
 
155
156
  ## scheduled sends and task management
156
157
 
157
158
  Use \`--send-at\` to schedule a one-time or recurring task:
158
159
 
159
- kimaki send --channel chan_123 --prompt "Reminder: review open PRs" --send-at "2026-03-01T09:00:00Z" --user "Tommy"
160
- kimaki send --channel chan_123 --prompt "Run weekly test suite and summarize failures" --send-at "0 9 * * 1" --user "Tommy"
160
+ kimaki send --channel chan_123 --prompt "Reminder: review open PRs" --send-at "2026-03-01T09:00:00Z" --agent <current_agent> --user "Tommy"
161
+ kimaki send --channel chan_123 --prompt "Run weekly test suite and summarize failures" --send-at "0 9 * * 1" --agent <current_agent> --user "Tommy"
161
162
 
162
163
  ALL scheduling is in UTC. Dates must be UTC ISO format ending with \`Z\`. Cron expressions also fire in UTC (e.g. \`0 9 * * 1\` means 9:00 UTC every Monday).
163
164
  When the user specifies a time without a timezone, ask them to confirm their timezone or the UTC equivalent. Never guess the user's timezone.
@@ -191,13 +192,13 @@ describe('system-message', () => {
191
192
 
192
193
  Use case patterns:
193
194
  - Reminder flows: create deadline reminders in this channel with one-time \`--send-at\`; mention only if action is required.
194
- - Proactive reminders: when you encounter time-sensitive information during your work (e.g. creating an API key that expires in 90 days, a certificate with an expiration date, a trial period ending, a deadline mentioned in code comments), proactively schedule a \`--notify-only\` reminder before the expiration so the user gets notified in time. For example, if you generate an API key expiring on 2026-06-01, schedule a reminder a few days before: \`kimaki send --channel chan_123 --prompt "Reminder: <@USER_ID> the API key created on 2026-03-01 expires on 2026-06-01. Renew it before it breaks production." --send-at "2026-05-28T09:00:00Z" --notify-only\`. Always tell the user you scheduled the reminder so they know.
195
+ - Proactive reminders: when you encounter time-sensitive information during your work (e.g. creating an API key that expires in 90 days, a certificate with an expiration date, a trial period ending, a deadline mentioned in code comments), proactively schedule a \`--notify-only\` reminder before the expiration so the user gets notified in time. For example, if you generate an API key expiring on 2026-06-01, schedule a reminder a few days before: \`kimaki send --channel chan_123 --prompt "Reminder: <@USER_ID> the API key created on 2026-03-01 expires on 2026-06-01. Renew it before it breaks production." --send-at "2026-05-28T09:00:00Z" --notify-only --agent <current_agent>\`. Always tell the user you scheduled the reminder so they know.
195
196
  - Weekly QA: schedule "run full test suite, inspect failures, post summary, and mention @username only when failures require review".
196
197
  - Weekly benchmark automation: schedule a benchmark prompt that runs model evals, writes JSON outputs in the repo, commits results, and mentions only for regressions.
197
198
  - Recurring maintenance: use cron \`--send-at\` for repetitive tasks like rotating secrets, checking dependency updates, running security audits, or cleaning up stale branches. Example: \`--send-at "0 9 1 * *"\` to run on the 1st of every month.
198
199
  - Thread reminders: when the user says "remind me about this in 2 hours" (or any duration), use \`--send-at\` with \`--thread\` to resurface the current thread. Compute the future UTC time and send a mention so Discord shows a notification:
199
200
 
200
- kimaki send --session ses_123 --prompt "Reminder: <@USER_ID> you asked to be reminded about this thread." --send-at "<future_UTC_time>" --notify-only
201
+ kimaki send --session ses_123 --prompt "Reminder: <@USER_ID> you asked to be reminded about this thread." --send-at "<future_UTC_time>" --notify-only --agent <current_agent>
201
202
 
202
203
  Replace \`<future_UTC_time>\` with the computed UTC ISO timestamp. The \`--notify-only\` flag creates just a notification message without starting a new AI session. The \`<@userId>\` mention ensures the user gets a Discord notification.
203
204
 
@@ -212,12 +213,12 @@ describe('system-message', () => {
212
213
  When the user asks to "create a worktree" or "make a worktree", they mean you should use the kimaki CLI to create it. Do NOT use raw \`git worktree add\` commands. Instead use:
213
214
 
214
215
  \`\`\`bash
215
- kimaki send --channel chan_123 --prompt "your task description" --worktree worktree-name --user "Tommy"
216
+ kimaki send --channel chan_123 --prompt "your task description" --worktree worktree-name --agent <current_agent> --user "Tommy"
216
217
  \`\`\`
217
218
 
218
219
  This creates a new Discord thread with an isolated git worktree and starts a session in it. The worktree name should be kebab-case and descriptive of the task.
219
220
 
220
- By default, worktrees are created from \`origin/HEAD\` (the remote's default branch). To change the base branch for a project, the user can run \`git remote set-head origin <branch>\` in the project directory. For example, \`git remote set-head origin dev\` makes all new worktrees branch off \`origin/dev\` instead of \`origin/main\`.
221
+ By default, worktrees are created from \`HEAD\`, which means whatever commit or branch the current checkout is on. If you want a different base, pass \`--base-branch\` or use the slash command option explicitly.
221
222
 
222
223
  Critical recursion guard:
223
224
  - If you already are in a worktree thread, do not create another worktree unless the user explicitly asks for a nested worktree.
@@ -228,7 +229,7 @@ describe('system-message', () => {
228
229
  Use \`--cwd\` to start a session in an existing git worktree directory instead of creating a new one:
229
230
 
230
231
  \`\`\`bash
231
- kimaki send --channel chan_123 --prompt "Continue work on feature X" --cwd /path/to/existing-worktree --user "Tommy"
232
+ kimaki send --channel chan_123 --prompt "Continue work on feature X" --cwd /path/to/existing-worktree --agent <current_agent> --user "Tommy"
232
233
  \`\`\`
233
234
 
234
235
  The path must be a git worktree of the project (validated via \`git worktree list\`). The session resolves to the correct project channel but uses the worktree as its working directory. Use \`--worktree\` to create a new worktree, \`--cwd\` to reuse an existing one.
@@ -242,7 +243,7 @@ describe('system-message', () => {
242
243
  When you are approaching the **context window limit** or the user explicitly asks to **handoff to a new thread**, use the \`kimaki send\` command to start a fresh session with context:
243
244
 
244
245
  \`\`\`bash
245
- kimaki send --channel chan_123 --prompt "Continuing from previous session: <summary of current task and state>" --user "Tommy"
246
+ kimaki send --channel chan_123 --prompt "Continuing from previous session: <summary of current task and state>" --agent <current_agent> --user "Tommy"
246
247
  \`\`\`
247
248
 
248
249
  The command automatically handles long prompts (over 2000 chars) by sending them as file attachments.
@@ -300,10 +301,10 @@ describe('system-message', () => {
300
301
 
301
302
  \`\`\`bash
302
303
  # Send to a specific channel
303
- kimaki send --channel <channel_id> --prompt "Plan how to update the API client to v2"
304
+ kimaki send --channel <channel_id> --prompt "Plan how to update the API client to v2" --agent <current_agent>
304
305
 
305
306
  # Or use --project to resolve from directory
306
- kimaki send --project /path/to/other-repo --prompt "Plan how to bump version to 1.2.0"
307
+ kimaki send --project /path/to/other-repo --prompt "Plan how to bump version to 1.2.0" --agent <current_agent>
307
308
  \`\`\`
308
309
 
309
310
  When sending prompts to other projects, always ask the agent to plan first, never build upfront. The prompt should start with "Plan how to ..." so the user can review before greenlighting implementation.
@@ -326,10 +327,10 @@ describe('system-message', () => {
326
327
 
327
328
  \`\`\`bash
328
329
  # Start a session and wait for it to finish
329
- kimaki send --channel <channel_id> --prompt "Fix the auth bug" --wait
330
+ kimaki send --channel <channel_id> --prompt "Fix the auth bug" --wait --agent <current_agent>
330
331
 
331
332
  # Send to an existing thread and wait
332
- kimaki send --thread <thread_id> --prompt "Run the tests" --wait
333
+ kimaki send --thread <thread_id> --prompt "Run the tests" --wait --agent <current_agent>
333
334
  \`\`\`
334
335
 
335
336
  The command exits with the session markdown on stdout once the model finishes responding.
@@ -70,7 +70,6 @@ function writeSystemPromptDiffFile({ dataDir, sessionId, beforePrompt, afterProm
70
70
  const timestamp = new Date().toISOString().replaceAll(':', '-');
71
71
  const sessionDir = path.join(getSystemPromptDiffDir({ dataDir }), sessionId);
72
72
  const filePath = path.join(sessionDir, `${timestamp}.diff`);
73
- const latestPromptPath = path.join(sessionDir, `${timestamp}.md`);
74
73
  const fileContent = [
75
74
  `Session: ${sessionId}`,
76
75
  `Created: ${new Date().toISOString()}`,
@@ -88,7 +87,6 @@ function writeSystemPromptDiffFile({ dataDir, sessionId, beforePrompt, afterProm
88
87
  additions: diff.additions,
89
88
  deletions: diff.deletions,
90
89
  filePath,
91
- latestPromptPath,
92
90
  };
93
91
  },
94
92
  catch: (error) => {
@@ -109,6 +107,7 @@ function getOrCreateSessionState({ sessions, sessionId, }) {
109
107
  comparedTurn: 0,
110
108
  previousTurnContext: undefined,
111
109
  currentTurnContext: undefined,
110
+ pendingCompareTimeout: undefined,
112
111
  };
113
112
  sessions.set(sessionId, state);
114
113
  return state;
@@ -162,8 +161,7 @@ async function handleSystemTransform({ input, output, sessions, dataDir, client,
162
161
  message: appendToastSessionMarker({
163
162
  sessionId,
164
163
  message: `system prompt changed since the previous message (+${diffFileResult.additions} / -${diffFileResult.deletions}). ` +
165
- `Diff: \`${abbreviatePath(diffFileResult.filePath)}\`. ` +
166
- `Latest prompt: \`${abbreviatePath(diffFileResult.latestPromptPath)}\``,
164
+ `Diff: \`${abbreviatePath(diffFileResult.filePath)}\`. `
167
165
  }),
168
166
  },
169
167
  });
@@ -193,13 +191,41 @@ const systemPromptDriftPlugin = async ({ client, directory }) => {
193
191
  'experimental.chat.system.transform': async (input, output) => {
194
192
  const result = await errore.tryAsync({
195
193
  try: async () => {
196
- await handleSystemTransform({
197
- input,
198
- output,
199
- sessions,
200
- dataDir,
201
- client,
202
- });
194
+ const sessionId = input.sessionID;
195
+ if (!sessionId) {
196
+ return;
197
+ }
198
+ const state = getOrCreateSessionState({ sessions, sessionId });
199
+ if (state.pendingCompareTimeout) {
200
+ clearTimeout(state.pendingCompareTimeout);
201
+ }
202
+ // Delay one tick so other system-transform hooks can finish mutating
203
+ // output.system before we snapshot it for drift detection.
204
+ state.pendingCompareTimeout = setTimeout(() => {
205
+ state.pendingCompareTimeout = undefined;
206
+ void errore.tryAsync({
207
+ try: async () => {
208
+ await handleSystemTransform({
209
+ input,
210
+ output,
211
+ sessions,
212
+ dataDir,
213
+ client,
214
+ });
215
+ },
216
+ catch: (error) => {
217
+ return new Error('system prompt drift transform hook failed', {
218
+ cause: error,
219
+ });
220
+ },
221
+ }).then((delayedResult) => {
222
+ if (!(delayedResult instanceof Error)) {
223
+ return;
224
+ }
225
+ logger.warn(`[system-prompt-drift-plugin] ${formatPluginErrorWithStack(delayedResult)}`);
226
+ void notifyError(delayedResult, 'system prompt drift plugin transform hook failed');
227
+ });
228
+ }, 0);
203
229
  },
204
230
  catch: (error) => {
205
231
  return new Error('system prompt drift transform hook failed', {
@@ -222,6 +248,10 @@ const systemPromptDriftPlugin = async ({ client, directory }) => {
222
248
  if (!deletedSessionId) {
223
249
  return;
224
250
  }
251
+ const state = sessions.get(deletedSessionId);
252
+ if (state?.pendingCompareTimeout) {
253
+ clearTimeout(state.pendingCompareTimeout);
254
+ }
225
255
  sessions.delete(deletedSessionId);
226
256
  },
227
257
  catch: (error) => {
package/dist/utils.js CHANGED
@@ -50,7 +50,7 @@ export function generateBotInstallUrl({ clientId, permissions = [
50
50
  return url.toString();
51
51
  }
52
52
  export const KIMAKI_GATEWAY_APP_ID = process.env.KIMAKI_GATEWAY_APP_ID || '1477605701202481173';
53
- export const KIMAKI_WEBSITE_URL = process.env.KIMAKI_WEBSITE_URL || 'https://kimaki.xyz';
53
+ export const KIMAKI_WEBSITE_URL = process.env.KIMAKI_WEBSITE_URL || 'https://kimaki.dev';
54
54
  export function generateDiscordInstallUrlForBot({ appId, mode, clientId, clientSecret, gatewayCallbackUrl, reachableUrl, }) {
55
55
  if (mode !== 'gateway') {
56
56
  return generateBotInstallUrl({ clientId: appId });
package/dist/worktrees.js CHANGED
@@ -394,39 +394,6 @@ async function validateSubmodulePointers(directory) {
394
394
  return new Error(`Submodule validation failed: ${validationIssues.join('; ')}`);
395
395
  }
396
396
  async function resolveDefaultWorktreeTarget(directory) {
397
- const remoteHead = await execAsync('git symbolic-ref refs/remotes/origin/HEAD', {
398
- cwd: directory,
399
- }).catch(() => {
400
- return null;
401
- });
402
- const remoteRef = remoteHead?.stdout.trim();
403
- if (remoteRef?.startsWith('refs/remotes/')) {
404
- return remoteRef.replace('refs/remotes/', '');
405
- }
406
- const hasMain = await execAsync('git show-ref --verify --quiet refs/heads/main', {
407
- cwd: directory,
408
- })
409
- .then(() => {
410
- return true;
411
- })
412
- .catch(() => {
413
- return false;
414
- });
415
- if (hasMain) {
416
- return 'main';
417
- }
418
- const hasMaster = await execAsync('git show-ref --verify --quiet refs/heads/master', {
419
- cwd: directory,
420
- })
421
- .then(() => {
422
- return true;
423
- })
424
- .catch(() => {
425
- return false;
426
- });
427
- if (hasMaster) {
428
- return 'master';
429
- }
430
397
  return 'HEAD';
431
398
  }
432
399
  function getManagedWorktreeDirectory({ directory, name, }) {
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@otto-assistant/bridge",
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
- "version": "0.4.96",
5
+ "version": "0.4.100",
6
6
  "scripts": {
7
7
  "dev": "tsx src/bin.ts",
8
8
  "prepublishOnly": "pnpm generate && pnpm tsc",
@@ -542,7 +542,14 @@ describe('agent model resolution', () => {
542
542
  afterAuthorId: discord.botUserId,
543
543
  })
544
544
 
545
- expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
545
+ const threadText = (await discord.thread(thread.id).text())
546
+ .split('\n')
547
+ .filter((line) => {
548
+ return !line.startsWith('⬦ info: Context cache discarded:')
549
+ })
550
+ .join('\n')
551
+
552
+ expect(threadText).toMatchInlineSnapshot(`
546
553
  "--- from: user (agent-model-tester)
547
554
  first message in thread
548
555
  Reply with exactly: reply-context-check
@@ -0,0 +1,52 @@
1
+ // Tests Anthropic OAuth account identity parsing and normalization.
2
+
3
+ import { describe, expect, test } from 'vitest'
4
+ import {
5
+ extractAnthropicAccountIdentity,
6
+ normalizeAnthropicAccountIdentity,
7
+ } from './anthropic-account-identity.js'
8
+
9
+ describe('normalizeAnthropicAccountIdentity', () => {
10
+ test('normalizes email casing and drops empty values', () => {
11
+ expect(
12
+ normalizeAnthropicAccountIdentity({
13
+ email: ' User@Example.com ',
14
+ accountId: ' user_123 ',
15
+ }),
16
+ ).toEqual({
17
+ email: 'user@example.com',
18
+ accountId: 'user_123',
19
+ })
20
+
21
+ expect(normalizeAnthropicAccountIdentity({ email: ' ' })).toBeUndefined()
22
+ })
23
+ })
24
+
25
+ describe('extractAnthropicAccountIdentity', () => {
26
+ test('prefers nested user profile identity from client_data responses', () => {
27
+ expect(
28
+ extractAnthropicAccountIdentity({
29
+ organizations: [{ id: 'org_123', name: 'Workspace' }],
30
+ user: {
31
+ id: 'usr_123',
32
+ email: 'User@Example.com',
33
+ },
34
+ }),
35
+ ).toEqual({
36
+ accountId: 'usr_123',
37
+ email: 'user@example.com',
38
+ })
39
+ })
40
+
41
+ test('falls back to profile-style payloads without email', () => {
42
+ expect(
43
+ extractAnthropicAccountIdentity({
44
+ profile: {
45
+ user_id: 'usr_456',
46
+ },
47
+ }),
48
+ ).toEqual({
49
+ accountId: 'usr_456',
50
+ })
51
+ })
52
+ })
@@ -0,0 +1,77 @@
1
+ // Helpers for extracting and normalizing Anthropic OAuth account identity.
2
+
3
+ export type AnthropicAccountIdentity = {
4
+ email?: string
5
+ accountId?: string
6
+ }
7
+
8
+ type IdentityCandidate = AnthropicAccountIdentity & {
9
+ score: number
10
+ }
11
+
12
+ const identityHintKeys = new Set(['user', 'profile', 'account', 'viewer'])
13
+ const idKeys = ['user_id', 'userId', 'account_id', 'accountId', 'id', 'sub']
14
+
15
+ export function normalizeAnthropicAccountIdentity(
16
+ identity: AnthropicAccountIdentity | null | undefined,
17
+ ) {
18
+ const email =
19
+ typeof identity?.email === 'string' && identity.email.trim()
20
+ ? identity.email.trim().toLowerCase()
21
+ : undefined
22
+ const accountId =
23
+ typeof identity?.accountId === 'string' && identity.accountId.trim()
24
+ ? identity.accountId.trim()
25
+ : undefined
26
+ if (!email && !accountId) return undefined
27
+ return {
28
+ ...(email ? { email } : {}),
29
+ ...(accountId ? { accountId } : {}),
30
+ }
31
+ }
32
+
33
+ function getCandidateFromRecord(record: Record<string, unknown>, path: string[]) {
34
+ const email = typeof record.email === 'string' ? record.email : undefined
35
+ const accountId = idKeys
36
+ .map((key) => {
37
+ const value = record[key]
38
+ return typeof value === 'string' ? value : undefined
39
+ })
40
+ .find((value) => {
41
+ return Boolean(value)
42
+ })
43
+ const normalized = normalizeAnthropicAccountIdentity({ email, accountId })
44
+ if (!normalized) return undefined
45
+ const hasIdentityHint = path.some((segment) => {
46
+ return identityHintKeys.has(segment)
47
+ })
48
+ return {
49
+ ...normalized,
50
+ score: (normalized.email ? 4 : 0) + (normalized.accountId ? 2 : 0) + (hasIdentityHint ? 2 : 0),
51
+ } satisfies IdentityCandidate
52
+ }
53
+
54
+ function collectIdentityCandidates(value: unknown, path: string[] = []): IdentityCandidate[] {
55
+ if (!value || typeof value !== 'object') return []
56
+ if (Array.isArray(value)) {
57
+ return value.flatMap((entry) => {
58
+ return collectIdentityCandidates(entry, path)
59
+ })
60
+ }
61
+
62
+ const record = value as Record<string, unknown>
63
+ const nested = Object.entries(record).flatMap(([key, entry]) => {
64
+ return collectIdentityCandidates(entry, [...path, key])
65
+ })
66
+ const current = getCandidateFromRecord(record, path)
67
+ return current ? [current, ...nested] : nested
68
+ }
69
+
70
+ export function extractAnthropicAccountIdentity(value: unknown) {
71
+ const candidates = collectIdentityCandidates(value)
72
+ const best = candidates.sort((a, b) => {
73
+ return b.score - a.score
74
+ })[0]
75
+ if (!best) return undefined
76
+ return normalizeAnthropicAccountIdentity(best)
77
+ }
@@ -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
  })()
@@ -531,6 +559,8 @@ function toClaudeCodeToolName(name: string) {
531
559
  function sanitizeSystemText(text: string, onError?: (msg: string) => void) {
532
560
  const startIdx = text.indexOf(OPENCODE_IDENTITY)
533
561
  if (startIdx === -1) return text
562
+ // to find the last heading to match readhttps://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/session/prompt/anthropic.txt
563
+ // it contains the opencode injected prompt. you must keep the codeRefsMarker updated with that package
534
564
  const codeRefsMarker = '# Code References'
535
565
  const endIdx = text.indexOf(codeRefsMarker, startIdx)
536
566
  if (endIdx === -1) {
@@ -682,6 +712,19 @@ function wrapResponseStream(response: Response, reverseToolNameMap: Map<string,
682
712
  })
683
713
  }
684
714
 
715
+ function appendToastSessionMarker({
716
+ message,
717
+ sessionId,
718
+ }: {
719
+ message: string
720
+ sessionId: string | undefined
721
+ }) {
722
+ if (!sessionId) {
723
+ return message
724
+ }
725
+ return `${message} ${sessionId}`
726
+ }
727
+
685
728
  // --- Beta headers ---
686
729
 
687
730
  function getRequiredBetas(modelId: string | undefined) {
@@ -737,7 +780,18 @@ async function getFreshOAuth(
737
780
  await setAnthropicAuth(refreshed, client)
738
781
  const store = await loadAccountStore()
739
782
  if (store.accounts.length > 0) {
740
- upsertAccount(store, refreshed)
783
+ const identity: AnthropicAccountIdentity | undefined = (() => {
784
+ const currentIndex = store.accounts.findIndex((account) => {
785
+ return account.refresh === latest.refresh || account.access === latest.access
786
+ })
787
+ const current = currentIndex >= 0 ? store.accounts[currentIndex] : undefined
788
+ if (!current) return undefined
789
+ return {
790
+ ...(current.email ? { email: current.email } : {}),
791
+ ...(current.accountId ? { accountId: current.accountId } : {}),
792
+ }
793
+ })()
794
+ upsertAccount(store, { ...refreshed, ...identity })
741
795
  await saveAccountStore(store)
742
796
  }
743
797
  return refreshed
@@ -752,6 +806,12 @@ async function getFreshOAuth(
752
806
 
753
807
  const AnthropicAuthPlugin: Plugin = async ({ client }) => {
754
808
  return {
809
+ 'chat.headers': async (input, output) => {
810
+ if (input.model.providerID !== 'anthropic') {
811
+ return
812
+ }
813
+ output.headers[TOAST_SESSION_HEADER] = input.sessionID
814
+ },
755
815
  auth: {
756
816
  provider: 'anthropic',
757
817
  async loader(
@@ -788,21 +848,27 @@ const AnthropicAuthPlugin: Plugin = async ({ client }) => {
788
848
  .catch(() => undefined)
789
849
  : undefined
790
850
 
791
- const rewritten = rewriteRequestPayload(originalBody, (msg) => {
792
- client.tui.showToast({
793
- body: { message: msg, variant: 'error' },
794
- }).catch(() => {})
795
- })
796
851
  const headers = new Headers(init?.headers)
797
852
  if (input instanceof Request) {
798
853
  input.headers.forEach((v, k) => {
799
854
  if (!headers.has(k)) headers.set(k, v)
800
855
  })
801
856
  }
857
+ const sessionId = headers.get(TOAST_SESSION_HEADER) ?? undefined
858
+
859
+ const rewritten = rewriteRequestPayload(originalBody, (msg) => {
860
+ client.tui.showToast({
861
+ body: {
862
+ message: appendToastSessionMarker({ message: msg, sessionId }),
863
+ variant: 'error',
864
+ },
865
+ }).catch(() => {})
866
+ })
802
867
  const betas = getRequiredBetas(rewritten.modelId)
803
868
 
804
869
  const runRequest = async (auth: OAuthStored) => {
805
870
  const requestHeaders = new Headers(headers)
871
+ requestHeaders.delete(TOAST_SESSION_HEADER)
806
872
  requestHeaders.set('accept', 'application/json')
807
873
  requestHeaders.set(
808
874
  'anthropic-beta',
@@ -839,9 +905,13 @@ const AnthropicAuthPlugin: Plugin = async ({ client }) => {
839
905
  // Show toast notification so Discord thread shows the rotation
840
906
  client.tui.showToast({
841
907
  body: {
842
- message: `Switching from account ${rotated.fromLabel} to account ${rotated.toLabel}`,
908
+ message: appendToastSessionMarker({
909
+ message: `Switching from account ${rotated.fromLabel} to account ${rotated.toLabel}`,
910
+ sessionId,
911
+ }),
843
912
  variant: 'info',
844
913
  },
914
+
845
915
  }).catch(() => {})
846
916
  const retryAuth = await getFreshOAuth(getAuth, client)
847
917
  if (retryAuth) {