@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.
- package/dist/agent-model.e2e.test.js +7 -1
- package/dist/anthropic-account-identity.js +62 -0
- package/dist/anthropic-account-identity.test.js +38 -0
- package/dist/anthropic-auth-plugin.js +72 -12
- package/dist/anthropic-auth-state.js +28 -3
- package/dist/{anthropic-auth-plugin.test.js → anthropic-auth-state.test.js} +21 -2
- package/dist/cli-parsing.test.js +12 -9
- package/dist/cli-send-thread.e2e.test.js +4 -7
- package/dist/cli.js +25 -12
- package/dist/commands/screenshare.js +1 -1
- package/dist/commands/screenshare.test.js +2 -2
- package/dist/commands/vscode.js +269 -0
- package/dist/db.js +1 -0
- package/dist/discord-command-registration.js +7 -2
- package/dist/gateway-proxy-reconnect.e2e.test.js +2 -2
- package/dist/interaction-handler.js +4 -0
- package/dist/system-message.js +24 -23
- package/dist/system-message.test.js +24 -23
- package/dist/system-prompt-drift-plugin.js +41 -11
- package/dist/utils.js +1 -1
- package/dist/worktrees.js +0 -33
- package/package.json +1 -1
- package/src/agent-model.e2e.test.ts +8 -1
- package/src/anthropic-account-identity.test.ts +52 -0
- package/src/anthropic-account-identity.ts +77 -0
- package/src/anthropic-auth-plugin.ts +82 -12
- package/src/{anthropic-auth-plugin.test.ts → anthropic-auth-state.test.ts} +23 -1
- package/src/anthropic-auth-state.ts +36 -3
- package/src/cli-parsing.test.ts +16 -9
- package/src/cli-send-thread.e2e.test.ts +6 -7
- package/src/cli.ts +31 -13
- package/src/commands/screenshare.test.ts +2 -2
- package/src/commands/screenshare.ts +1 -1
- package/src/commands/vscode.ts +342 -0
- package/src/db.ts +1 -0
- package/src/discord-command-registration.ts +9 -2
- package/src/gateway-proxy-reconnect.e2e.test.ts +2 -2
- package/src/interaction-handler.ts +5 -0
- package/src/system-message.test.ts +24 -23
- package/src/system-message.ts +24 -23
- package/src/system-prompt-drift-plugin.ts +48 -12
- package/src/utils.ts +1 -1
- package/src/worktrees.test.ts +1 -0
- 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.
|
|
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
|
|
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 \`
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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.
|
|
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
|
@@ -542,7 +542,14 @@ describe('agent model resolution', () => {
|
|
|
542
542
|
afterAuthorId: discord.botUserId,
|
|
543
543
|
})
|
|
544
544
|
|
|
545
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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) {
|