@otto-assistant/bridge 0.4.92 → 0.4.96
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 +2 -1
- package/dist/anthropic-auth-plugin.js +30 -9
- package/dist/anthropic-auth-plugin.test.js +7 -1
- package/dist/anthropic-auth-state.js +17 -1
- package/dist/cli.js +2 -3
- package/dist/commands/agent.js +2 -2
- package/dist/commands/merge-worktree.js +11 -8
- package/dist/context-awareness-plugin.js +1 -1
- package/dist/context-awareness-plugin.test.js +2 -2
- package/dist/discord-utils.js +5 -2
- package/dist/kimaki-opencode-plugin.js +1 -0
- package/dist/logger.js +8 -2
- package/dist/session-handler/thread-session-runtime.js +20 -3
- package/dist/system-message.js +13 -0
- package/dist/system-message.test.js +13 -0
- package/dist/system-prompt-drift-plugin.js +251 -0
- package/dist/utils.js +5 -1
- package/package.json +2 -1
- package/skills/npm-package/SKILL.md +14 -9
- package/src/agent-model.e2e.test.ts +2 -1
- package/src/anthropic-auth-plugin.test.ts +7 -1
- package/src/anthropic-auth-plugin.ts +29 -9
- package/src/anthropic-auth-state.ts +28 -2
- package/src/cli.ts +2 -2
- package/src/commands/agent.ts +2 -2
- package/src/commands/merge-worktree.ts +11 -8
- package/src/context-awareness-plugin.test.ts +2 -2
- package/src/context-awareness-plugin.ts +1 -1
- package/src/discord-utils.ts +19 -17
- package/src/kimaki-opencode-plugin.ts +1 -0
- package/src/logger.ts +9 -2
- package/src/session-handler/thread-session-runtime.ts +23 -3
- package/src/system-message.test.ts +13 -0
- package/src/system-message.ts +13 -0
- package/src/system-prompt-drift-plugin.ts +379 -0
- package/src/utils.ts +5 -1
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// OpenCode plugin that detects per-session system prompt drift across turns.
|
|
2
|
+
// When the effective system prompt changes after the first user message, it
|
|
3
|
+
// writes a debug diff file and shows a toast because prompt-cache invalidation
|
|
4
|
+
// increases rate-limit usage and usually means another plugin is mutating the
|
|
5
|
+
// system prompt unexpectedly.
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { createPatch, diffLines } from 'diff';
|
|
9
|
+
import * as errore from 'errore';
|
|
10
|
+
import { createPluginLogger, formatPluginErrorWithStack, setPluginLogFilePath } from './plugin-logger.js';
|
|
11
|
+
import { initSentry, notifyError } from './sentry.js';
|
|
12
|
+
import { abbreviatePath } from './utils.js';
|
|
13
|
+
const logger = createPluginLogger('OPENCODE');
|
|
14
|
+
const TOAST_SESSION_MARKER_SEPARATOR = ' ';
|
|
15
|
+
function getSystemPromptDiffDir({ dataDir }) {
|
|
16
|
+
return path.join(dataDir, 'system-prompt-diffs');
|
|
17
|
+
}
|
|
18
|
+
function normalizeSystemPrompt({ system }) {
|
|
19
|
+
return system.join('\n');
|
|
20
|
+
}
|
|
21
|
+
function appendToastSessionMarker({ message, sessionId, }) {
|
|
22
|
+
return `${message}${TOAST_SESSION_MARKER_SEPARATOR}${sessionId}`;
|
|
23
|
+
}
|
|
24
|
+
function buildTurnContext({ input, directory, }) {
|
|
25
|
+
const model = input.model
|
|
26
|
+
? `${input.model.providerID}/${input.model.modelID}${input.variant ? `:${input.variant}` : ''}`
|
|
27
|
+
: undefined;
|
|
28
|
+
return {
|
|
29
|
+
agent: input.agent,
|
|
30
|
+
model,
|
|
31
|
+
directory,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function shouldSuppressDiffNotice({ previousContext, currentContext, }) {
|
|
35
|
+
if (!previousContext || !currentContext) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
return (previousContext.agent !== currentContext.agent
|
|
39
|
+
|| previousContext.model !== currentContext.model
|
|
40
|
+
|| previousContext.directory !== currentContext.directory);
|
|
41
|
+
}
|
|
42
|
+
function buildPatch({ beforeText, afterText, beforeLabel, afterLabel, }) {
|
|
43
|
+
const changes = diffLines(beforeText, afterText);
|
|
44
|
+
const additions = changes.reduce((count, change) => {
|
|
45
|
+
if (!change.added) {
|
|
46
|
+
return count;
|
|
47
|
+
}
|
|
48
|
+
return count + change.count;
|
|
49
|
+
}, 0);
|
|
50
|
+
const deletions = changes.reduce((count, change) => {
|
|
51
|
+
if (!change.removed) {
|
|
52
|
+
return count;
|
|
53
|
+
}
|
|
54
|
+
return count + change.count;
|
|
55
|
+
}, 0);
|
|
56
|
+
const patch = createPatch(afterLabel, beforeText, afterText, beforeLabel, afterLabel);
|
|
57
|
+
return {
|
|
58
|
+
additions,
|
|
59
|
+
deletions,
|
|
60
|
+
patch,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function writeSystemPromptDiffFile({ dataDir, sessionId, beforePrompt, afterPrompt, }) {
|
|
64
|
+
const diff = buildPatch({
|
|
65
|
+
beforeText: beforePrompt,
|
|
66
|
+
afterText: afterPrompt,
|
|
67
|
+
beforeLabel: 'system-before.txt',
|
|
68
|
+
afterLabel: 'system-after.txt',
|
|
69
|
+
});
|
|
70
|
+
const timestamp = new Date().toISOString().replaceAll(':', '-');
|
|
71
|
+
const sessionDir = path.join(getSystemPromptDiffDir({ dataDir }), sessionId);
|
|
72
|
+
const filePath = path.join(sessionDir, `${timestamp}.diff`);
|
|
73
|
+
const latestPromptPath = path.join(sessionDir, `${timestamp}.md`);
|
|
74
|
+
const fileContent = [
|
|
75
|
+
`Session: ${sessionId}`,
|
|
76
|
+
`Created: ${new Date().toISOString()}`,
|
|
77
|
+
`Additions: ${diff.additions}`,
|
|
78
|
+
`Deletions: ${diff.deletions}`,
|
|
79
|
+
'',
|
|
80
|
+
diff.patch,
|
|
81
|
+
].join('\n');
|
|
82
|
+
return errore.try({
|
|
83
|
+
try: () => {
|
|
84
|
+
fs.mkdirSync(sessionDir, { recursive: true });
|
|
85
|
+
fs.writeFileSync(filePath, fileContent);
|
|
86
|
+
// fs.writeFileSync(latestPromptPath, afterPrompt)
|
|
87
|
+
return {
|
|
88
|
+
additions: diff.additions,
|
|
89
|
+
deletions: diff.deletions,
|
|
90
|
+
filePath,
|
|
91
|
+
latestPromptPath,
|
|
92
|
+
};
|
|
93
|
+
},
|
|
94
|
+
catch: (error) => {
|
|
95
|
+
return new Error('Failed to write system prompt diff file', { cause: error });
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
function getOrCreateSessionState({ sessions, sessionId, }) {
|
|
100
|
+
const existing = sessions.get(sessionId);
|
|
101
|
+
if (existing) {
|
|
102
|
+
return existing;
|
|
103
|
+
}
|
|
104
|
+
const state = {
|
|
105
|
+
userTurnCount: 0,
|
|
106
|
+
previousTurnPrompt: undefined,
|
|
107
|
+
latestTurnPrompt: undefined,
|
|
108
|
+
latestTurnPromptTurn: 0,
|
|
109
|
+
comparedTurn: 0,
|
|
110
|
+
previousTurnContext: undefined,
|
|
111
|
+
currentTurnContext: undefined,
|
|
112
|
+
};
|
|
113
|
+
sessions.set(sessionId, state);
|
|
114
|
+
return state;
|
|
115
|
+
}
|
|
116
|
+
async function handleSystemTransform({ input, output, sessions, dataDir, client, }) {
|
|
117
|
+
const sessionId = input.sessionID;
|
|
118
|
+
if (!sessionId) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const currentPrompt = normalizeSystemPrompt({ system: output.system });
|
|
122
|
+
const state = getOrCreateSessionState({
|
|
123
|
+
sessions,
|
|
124
|
+
sessionId,
|
|
125
|
+
});
|
|
126
|
+
const currentTurn = state.userTurnCount;
|
|
127
|
+
state.latestTurnPrompt = currentPrompt;
|
|
128
|
+
state.latestTurnPromptTurn = currentTurn;
|
|
129
|
+
if (currentTurn <= 1) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (state.comparedTurn === currentTurn) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const previousPrompt = state.previousTurnPrompt;
|
|
136
|
+
state.comparedTurn = currentTurn;
|
|
137
|
+
if (!previousPrompt || previousPrompt === currentPrompt) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (shouldSuppressDiffNotice({
|
|
141
|
+
previousContext: state.previousTurnContext,
|
|
142
|
+
currentContext: state.currentTurnContext,
|
|
143
|
+
})) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (!dataDir) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const diffFileResult = writeSystemPromptDiffFile({
|
|
150
|
+
dataDir,
|
|
151
|
+
sessionId,
|
|
152
|
+
beforePrompt: previousPrompt,
|
|
153
|
+
afterPrompt: currentPrompt,
|
|
154
|
+
});
|
|
155
|
+
if (diffFileResult instanceof Error) {
|
|
156
|
+
throw diffFileResult;
|
|
157
|
+
}
|
|
158
|
+
await client.tui.showToast({
|
|
159
|
+
body: {
|
|
160
|
+
variant: 'info',
|
|
161
|
+
title: 'Context cache discarded',
|
|
162
|
+
message: appendToastSessionMarker({
|
|
163
|
+
sessionId,
|
|
164
|
+
message: `system prompt changed since the previous message (+${diffFileResult.additions} / -${diffFileResult.deletions}). ` +
|
|
165
|
+
`Diff: \`${abbreviatePath(diffFileResult.filePath)}\`. ` +
|
|
166
|
+
`Latest prompt: \`${abbreviatePath(diffFileResult.latestPromptPath)}\``,
|
|
167
|
+
}),
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
const systemPromptDriftPlugin = async ({ client, directory }) => {
|
|
172
|
+
initSentry();
|
|
173
|
+
const dataDir = process.env.KIMAKI_DATA_DIR;
|
|
174
|
+
if (dataDir) {
|
|
175
|
+
setPluginLogFilePath(dataDir);
|
|
176
|
+
}
|
|
177
|
+
const sessions = new Map();
|
|
178
|
+
return {
|
|
179
|
+
'chat.message': async (input) => {
|
|
180
|
+
const sessionId = input.sessionID;
|
|
181
|
+
if (!sessionId) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const state = getOrCreateSessionState({ sessions, sessionId });
|
|
185
|
+
if (state.userTurnCount > 0
|
|
186
|
+
&& state.latestTurnPromptTurn === state.userTurnCount) {
|
|
187
|
+
state.previousTurnPrompt = state.latestTurnPrompt;
|
|
188
|
+
state.previousTurnContext = state.currentTurnContext;
|
|
189
|
+
}
|
|
190
|
+
state.currentTurnContext = buildTurnContext({ input, directory });
|
|
191
|
+
state.userTurnCount += 1;
|
|
192
|
+
},
|
|
193
|
+
'experimental.chat.system.transform': async (input, output) => {
|
|
194
|
+
const result = await errore.tryAsync({
|
|
195
|
+
try: async () => {
|
|
196
|
+
await handleSystemTransform({
|
|
197
|
+
input,
|
|
198
|
+
output,
|
|
199
|
+
sessions,
|
|
200
|
+
dataDir,
|
|
201
|
+
client,
|
|
202
|
+
});
|
|
203
|
+
},
|
|
204
|
+
catch: (error) => {
|
|
205
|
+
return new Error('system prompt drift transform hook failed', {
|
|
206
|
+
cause: error,
|
|
207
|
+
});
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
if (result instanceof Error) {
|
|
211
|
+
logger.warn(`[system-prompt-drift-plugin] ${formatPluginErrorWithStack(result)}`);
|
|
212
|
+
void notifyError(result, 'system prompt drift plugin transform hook failed');
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
event: async ({ event }) => {
|
|
216
|
+
const result = await errore.tryAsync({
|
|
217
|
+
try: async () => {
|
|
218
|
+
if (event.type !== 'session.deleted') {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const deletedSessionId = getDeletedSessionId({ event });
|
|
222
|
+
if (!deletedSessionId) {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
sessions.delete(deletedSessionId);
|
|
226
|
+
},
|
|
227
|
+
catch: (error) => {
|
|
228
|
+
return new Error('system prompt drift event hook failed', {
|
|
229
|
+
cause: error,
|
|
230
|
+
});
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
if (result instanceof Error) {
|
|
234
|
+
logger.warn(`[system-prompt-drift-plugin] ${formatPluginErrorWithStack(result)}`);
|
|
235
|
+
void notifyError(result, 'system prompt drift plugin event hook failed');
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
};
|
|
240
|
+
function getDeletedSessionId({ event }) {
|
|
241
|
+
if (event.type !== 'session.deleted') {
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
const sessionInfo = event.properties?.info;
|
|
245
|
+
if (!sessionInfo || typeof sessionInfo !== 'object') {
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
const id = 'id' in sessionInfo ? sessionInfo.id : undefined;
|
|
249
|
+
return typeof id === 'string' ? id : undefined;
|
|
250
|
+
}
|
|
251
|
+
export { systemPromptDriftPlugin };
|
package/dist/utils.js
CHANGED
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
// Includes Discord OAuth URL generation, array deduplication,
|
|
3
3
|
// abort error detection, and date/time formatting helpers.
|
|
4
4
|
import os from 'node:os';
|
|
5
|
-
import
|
|
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
|
+
const { PermissionsBitField } = discord;
|
|
6
10
|
import * as errore from 'errore';
|
|
7
11
|
export function generateBotInstallUrl({ clientId, permissions = [
|
|
8
12
|
PermissionsBitField.Flags.ViewChannel,
|
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.
|
|
5
|
+
"version": "0.4.96",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"dev": "tsx src/bin.ts",
|
|
8
8
|
"prepublishOnly": "pnpm generate && pnpm tsc",
|
|
@@ -66,6 +66,7 @@
|
|
|
66
66
|
"@prisma/client": "7.4.2",
|
|
67
67
|
"@purinton/resampler": "^1.0.4",
|
|
68
68
|
"cron-parser": "^5.5.0",
|
|
69
|
+
"diff": "^8.0.4",
|
|
69
70
|
"discord.js": "^14.25.1",
|
|
70
71
|
"domhandler": "^6.0.1",
|
|
71
72
|
"errore": "workspace:^",
|
|
@@ -39,15 +39,20 @@ Use this skill when scaffolding or fixing npm packages.
|
|
|
39
39
|
- any runtime-required extra files (for example `schema.prisma`)
|
|
40
40
|
- docs like `README.md` and `CHANGELOG.md`
|
|
41
41
|
- if tests are inside src and gets included in dist, it's fine. don't try to exclude them
|
|
42
|
-
10. `scripts.build` should be `
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
build
|
|
42
|
+
10. `scripts.build` should be `tsc && chmod +x dist/cli.js` (skip the chmod if
|
|
43
|
+
the package has no bin). No bundling. Do not delete `dist/` in `build` by
|
|
44
|
+
default because forcing a clean build on every local build can cause
|
|
45
|
+
issues. Optionally include running scripts with `tsx` if needed to
|
|
46
|
+
generate build artifacts.
|
|
47
|
+
11. `prepublishOnly` must always do the cleanup before `build` (optionally run
|
|
48
|
+
generation before build when required). Always add this script:
|
|
47
49
|
```json
|
|
48
|
-
{ "prepublishOnly": "pnpm build" }
|
|
50
|
+
{ "prepublishOnly": "rimraf dist \"*.tsbuildinfo\" && pnpm build" }
|
|
49
51
|
```
|
|
50
|
-
This ensures `dist/` is fresh before every `npm publish
|
|
52
|
+
This ensures `dist/` is fresh before every `npm publish`, so deleted files
|
|
53
|
+
do not accidentally stay in the published package. Use `rimraf` here
|
|
54
|
+
instead of bare shell globs so the script behaves the same in zsh, bash,
|
|
55
|
+
and Windows shells even when no `.tsbuildinfo` file exists.
|
|
51
56
|
|
|
52
57
|
## bin field
|
|
53
58
|
|
|
@@ -71,8 +76,8 @@ Add the shebang as the first line of the source file (`src/cli.ts`):
|
|
|
71
76
|
```
|
|
72
77
|
|
|
73
78
|
`tsc` preserves the shebang in the emitted `.js` file. The `chmod +x` is
|
|
74
|
-
already part of the `build` script, so `prepublishOnly
|
|
75
|
-
|
|
79
|
+
already part of the `build` script, so `prepublishOnly` still gets it through
|
|
80
|
+
`pnpm build` after the cleanup step.
|
|
76
81
|
|
|
77
82
|
## Reading package version at runtime
|
|
78
83
|
|
|
@@ -950,7 +950,8 @@ describe('agent model resolution', () => {
|
|
|
950
950
|
--- from: assistant (TestBot)
|
|
951
951
|
⬥ ok
|
|
952
952
|
*project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
|
|
953
|
-
Switched to **plan** agent for this session
|
|
953
|
+
Switched to **plan** agent for this session (was **test-agent**)
|
|
954
|
+
The agent will change on the next message.
|
|
954
955
|
--- from: user (agent-model-tester)
|
|
955
956
|
Reply with exactly: after-switch-msg
|
|
956
957
|
--- from: assistant (TestBot)
|
|
@@ -88,7 +88,13 @@ describe('rotateAnthropicAccount', () => {
|
|
|
88
88
|
anthropic?: { refresh?: string }
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
expect(rotated).toMatchObject({
|
|
91
|
+
expect(rotated).toMatchObject({
|
|
92
|
+
auth: { refresh: 'refresh-second' },
|
|
93
|
+
fromLabel: '#1 (refresh-...irst)',
|
|
94
|
+
toLabel: '#2 (refresh-...cond)',
|
|
95
|
+
fromIndex: 0,
|
|
96
|
+
toIndex: 1,
|
|
97
|
+
})
|
|
92
98
|
expect(store.activeIndex).toBe(1)
|
|
93
99
|
expect(authJson.anthropic?.refresh).toBe('refresh-second')
|
|
94
100
|
expect(authSetCalls).toEqual([
|
|
@@ -528,17 +528,26 @@ function toClaudeCodeToolName(name: string) {
|
|
|
528
528
|
return OPENCODE_TO_CLAUDE_CODE_TOOL_NAME[name.toLowerCase()] ?? name
|
|
529
529
|
}
|
|
530
530
|
|
|
531
|
-
function sanitizeSystemText(text: string) {
|
|
532
|
-
|
|
531
|
+
function sanitizeSystemText(text: string, onError?: (msg: string) => void) {
|
|
532
|
+
const startIdx = text.indexOf(OPENCODE_IDENTITY)
|
|
533
|
+
if (startIdx === -1) return text
|
|
534
|
+
const codeRefsMarker = '# Code References'
|
|
535
|
+
const endIdx = text.indexOf(codeRefsMarker, startIdx)
|
|
536
|
+
if (endIdx === -1) {
|
|
537
|
+
onError?.(`sanitizeSystemText: could not find '# Code References' after OpenCode identity`)
|
|
538
|
+
return text
|
|
539
|
+
}
|
|
540
|
+
// Remove everything from the OpenCode identity up to (but not including) '# Code References'
|
|
541
|
+
return text.slice(0, startIdx) + text.slice(endIdx)
|
|
533
542
|
}
|
|
534
543
|
|
|
535
|
-
function prependClaudeCodeIdentity(system: unknown) {
|
|
544
|
+
function prependClaudeCodeIdentity(system: unknown, onError?: (msg: string) => void) {
|
|
536
545
|
const identityBlock = { type: 'text', text: CLAUDE_CODE_IDENTITY }
|
|
537
546
|
|
|
538
547
|
if (typeof system === 'undefined') return [identityBlock]
|
|
539
548
|
|
|
540
549
|
if (typeof system === 'string') {
|
|
541
|
-
const sanitized = sanitizeSystemText(system)
|
|
550
|
+
const sanitized = sanitizeSystemText(system, onError)
|
|
542
551
|
if (sanitized === CLAUDE_CODE_IDENTITY) return [identityBlock]
|
|
543
552
|
return [identityBlock, { type: 'text', text: sanitized }]
|
|
544
553
|
}
|
|
@@ -546,11 +555,11 @@ function prependClaudeCodeIdentity(system: unknown) {
|
|
|
546
555
|
if (!Array.isArray(system)) return [identityBlock, system]
|
|
547
556
|
|
|
548
557
|
const sanitized = system.map((item) => {
|
|
549
|
-
if (typeof item === 'string') return { type: 'text', text: sanitizeSystemText(item) }
|
|
558
|
+
if (typeof item === 'string') return { type: 'text', text: sanitizeSystemText(item, onError) }
|
|
550
559
|
if (item && typeof item === 'object' && (item as { type?: unknown }).type === 'text') {
|
|
551
560
|
const text = (item as { text?: unknown }).text
|
|
552
561
|
if (typeof text === 'string') {
|
|
553
|
-
return { ...(item as Record<string, unknown>), text: sanitizeSystemText(text) }
|
|
562
|
+
return { ...(item as Record<string, unknown>), text: sanitizeSystemText(text, onError) }
|
|
554
563
|
}
|
|
555
564
|
}
|
|
556
565
|
return item
|
|
@@ -568,7 +577,7 @@ function prependClaudeCodeIdentity(system: unknown) {
|
|
|
568
577
|
return [identityBlock, ...sanitized]
|
|
569
578
|
}
|
|
570
579
|
|
|
571
|
-
function rewriteRequestPayload(body: string | undefined) {
|
|
580
|
+
function rewriteRequestPayload(body: string | undefined, onError?: (msg: string) => void) {
|
|
572
581
|
if (!body) return { body, modelId: undefined, reverseToolNameMap: new Map<string, string>() }
|
|
573
582
|
|
|
574
583
|
try {
|
|
@@ -589,7 +598,7 @@ function rewriteRequestPayload(body: string | undefined) {
|
|
|
589
598
|
}
|
|
590
599
|
|
|
591
600
|
// Rename system prompt
|
|
592
|
-
payload.system = prependClaudeCodeIdentity(payload.system)
|
|
601
|
+
payload.system = prependClaudeCodeIdentity(payload.system, onError)
|
|
593
602
|
|
|
594
603
|
// Rename tool_choice
|
|
595
604
|
if (
|
|
@@ -779,7 +788,11 @@ const AnthropicAuthPlugin: Plugin = async ({ client }) => {
|
|
|
779
788
|
.catch(() => undefined)
|
|
780
789
|
: undefined
|
|
781
790
|
|
|
782
|
-
const rewritten = rewriteRequestPayload(originalBody)
|
|
791
|
+
const rewritten = rewriteRequestPayload(originalBody, (msg) => {
|
|
792
|
+
client.tui.showToast({
|
|
793
|
+
body: { message: msg, variant: 'error' },
|
|
794
|
+
}).catch(() => {})
|
|
795
|
+
})
|
|
783
796
|
const headers = new Headers(init?.headers)
|
|
784
797
|
if (input instanceof Request) {
|
|
785
798
|
input.headers.forEach((v, k) => {
|
|
@@ -823,6 +836,13 @@ const AnthropicAuthPlugin: Plugin = async ({ client }) => {
|
|
|
823
836
|
if (shouldRotateAuth(response.status, bodyText)) {
|
|
824
837
|
const rotated = await rotateAnthropicAccount(freshAuth, client)
|
|
825
838
|
if (rotated) {
|
|
839
|
+
// Show toast notification so Discord thread shows the rotation
|
|
840
|
+
client.tui.showToast({
|
|
841
|
+
body: {
|
|
842
|
+
message: `Switching from account ${rotated.fromLabel} to account ${rotated.toLabel}`,
|
|
843
|
+
variant: 'info',
|
|
844
|
+
},
|
|
845
|
+
}).catch(() => {})
|
|
826
846
|
const retryAuth = await getFreshOAuth(getAuth, client)
|
|
827
847
|
if (retryAuth) {
|
|
828
848
|
response = await runRequest(retryAuth)
|
|
@@ -133,6 +133,21 @@ export async function saveAccountStore(store: AccountStore) {
|
|
|
133
133
|
await writeJson(accountsFilePath(), normalizeAccountStore(store))
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/** Short label for an account: first 8 + last 4 chars of refresh token. */
|
|
137
|
+
export function accountLabel(account: OAuthStored, index?: number): string {
|
|
138
|
+
const r = account.refresh
|
|
139
|
+
const short = r.length > 12 ? `${r.slice(0, 8)}...${r.slice(-4)}` : r
|
|
140
|
+
return index !== undefined ? `#${index + 1} (${short})` : short
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export type RotationResult = {
|
|
144
|
+
auth: OAuthStored
|
|
145
|
+
fromLabel: string
|
|
146
|
+
toLabel: string
|
|
147
|
+
fromIndex: number
|
|
148
|
+
toIndex: number
|
|
149
|
+
}
|
|
150
|
+
|
|
136
151
|
function findCurrentAccountIndex(store: AccountStore, auth: OAuthStored) {
|
|
137
152
|
if (!store.accounts.length) return 0
|
|
138
153
|
const byRefresh = store.accounts.findIndex((account) => {
|
|
@@ -206,16 +221,21 @@ export async function setAnthropicAuth(
|
|
|
206
221
|
export async function rotateAnthropicAccount(
|
|
207
222
|
auth: OAuthStored,
|
|
208
223
|
client: Parameters<Plugin>[0]['client'],
|
|
209
|
-
) {
|
|
224
|
+
): Promise<RotationResult | undefined> {
|
|
210
225
|
return withAuthStateLock(async () => {
|
|
211
226
|
const store = await loadAccountStore()
|
|
212
227
|
if (store.accounts.length < 2) return undefined
|
|
213
228
|
|
|
214
229
|
const currentIndex = findCurrentAccountIndex(store, auth)
|
|
230
|
+
const currentAccount = store.accounts[currentIndex]
|
|
215
231
|
const nextIndex = (currentIndex + 1) % store.accounts.length
|
|
216
232
|
const nextAccount = store.accounts[nextIndex]
|
|
217
233
|
if (!nextAccount) return undefined
|
|
218
234
|
|
|
235
|
+
const fromLabel = currentAccount
|
|
236
|
+
? accountLabel(currentAccount, currentIndex)
|
|
237
|
+
: accountLabel(auth, currentIndex)
|
|
238
|
+
|
|
219
239
|
nextAccount.lastUsed = Date.now()
|
|
220
240
|
store.activeIndex = nextIndex
|
|
221
241
|
await saveAccountStore(store)
|
|
@@ -227,7 +247,13 @@ export async function rotateAnthropicAccount(
|
|
|
227
247
|
expires: nextAccount.expires,
|
|
228
248
|
}
|
|
229
249
|
await setAnthropicAuth(nextAuth, client)
|
|
230
|
-
return
|
|
250
|
+
return {
|
|
251
|
+
auth: nextAuth,
|
|
252
|
+
fromLabel,
|
|
253
|
+
toLabel: accountLabel(nextAccount, nextIndex),
|
|
254
|
+
fromIndex: currentIndex,
|
|
255
|
+
toIndex: nextIndex,
|
|
256
|
+
}
|
|
231
257
|
})
|
|
232
258
|
}
|
|
233
259
|
|
package/src/cli.ts
CHANGED
|
@@ -125,6 +125,7 @@ import {
|
|
|
125
125
|
type ScheduledTaskPayload,
|
|
126
126
|
} from './task-schedule.js'
|
|
127
127
|
import {
|
|
128
|
+
accountLabel,
|
|
128
129
|
accountsFilePath,
|
|
129
130
|
loadAccountStore,
|
|
130
131
|
removeAccount,
|
|
@@ -3178,8 +3179,7 @@ cli
|
|
|
3178
3179
|
|
|
3179
3180
|
store.accounts.forEach((account, index) => {
|
|
3180
3181
|
const active = index === store.activeIndex ? '*' : ' '
|
|
3181
|
-
|
|
3182
|
-
console.log(`${active} ${index + 1}. ${label}`)
|
|
3182
|
+
console.log(`${active} ${index + 1}. ${accountLabel(account)}`)
|
|
3183
3183
|
})
|
|
3184
3184
|
|
|
3185
3185
|
process.exit(0)
|
package/src/commands/agent.ts
CHANGED
|
@@ -379,7 +379,7 @@ export async function handleAgentSelectMenu(
|
|
|
379
379
|
|
|
380
380
|
if (context.isThread && context.sessionId) {
|
|
381
381
|
await interaction.editReply({
|
|
382
|
-
content: `Agent preference set for this session
|
|
382
|
+
content: `Agent preference set for this session: **${selectedAgent}**\nThe agent will change on the next message.`,
|
|
383
383
|
components: [],
|
|
384
384
|
})
|
|
385
385
|
} else {
|
|
@@ -457,7 +457,7 @@ export async function handleQuickAgentCommand({
|
|
|
457
457
|
|
|
458
458
|
if (context.isThread && context.sessionId) {
|
|
459
459
|
await command.editReply({
|
|
460
|
-
content: `Switched to **${resolvedAgentName}** agent for this session
|
|
460
|
+
content: `Switched to **${resolvedAgentName}** agent for this session${previousText}\nThe agent will change on the next message.`,
|
|
461
461
|
})
|
|
462
462
|
} else {
|
|
463
463
|
await command.editReply({
|
|
@@ -155,15 +155,18 @@ export async function handleMergeWorktreeCommand({
|
|
|
155
155
|
)
|
|
156
156
|
await sendPromptToModel({
|
|
157
157
|
prompt: [
|
|
158
|
-
|
|
158
|
+
`A rebase conflict occurred while merging this worktree into \`${result.target}\`.`,
|
|
159
159
|
'Rebasing multiple commits can pause on each commit that conflicts, so you may need to repeat the resolve/continue loop several times.',
|
|
160
|
-
'
|
|
161
|
-
'1. Check `git status` to see which files have conflicts',
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
'4.
|
|
165
|
-
'5.
|
|
166
|
-
'6.
|
|
160
|
+
'Before editing anything, first understand both sides so you preserve both intentions and do not drop features or fixes.',
|
|
161
|
+
'1. Check `git status` to see which files have conflicts and confirm the rebase is paused',
|
|
162
|
+
`2. Find the merge base between this worktree and \`${result.target}\`, then read the commit messages from both sides since that merge base so you understand the goal of each change`,
|
|
163
|
+
`3. Read the diffs from that merge base to both sides so you understand exactly what changed on this branch and on \`${result.target}\` before resolving conflicts`,
|
|
164
|
+
'4. Read the commit currently being replayed in the rebase so you know the intent of the specific conflicting patch',
|
|
165
|
+
'5. Edit the conflicted files to preserve both intended changes where possible instead of choosing one side wholesale',
|
|
166
|
+
'6. Stage resolved files with `git add`',
|
|
167
|
+
'7. Continue the rebase with `git rebase --continue`',
|
|
168
|
+
'8. If git reports more conflicts, repeat steps 1-7 until the rebase finishes (no more rebase in progress, `git status` is clean)',
|
|
169
|
+
'9. Once the rebase is fully complete, tell me so I can run `/merge-worktree` again',
|
|
167
170
|
].join('\n'),
|
|
168
171
|
thread,
|
|
169
172
|
projectDirectory: worktreeInfo.project_directory,
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
}
|