kimaki 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/anthropic-auth-plugin.js +8 -11
- package/dist/commands/add-dir.test.js +35 -0
- package/dist/kimaki-opencode-plugin.js +2 -0
- package/dist/opencode-interrupt-plugin.js +29 -2
- package/dist/opencode.js +18 -24
- package/dist/plugin-logger.js +9 -0
- package/dist/session-handler/thread-session-runtime.js +4 -0
- package/dist/subagent-rate-limit-plugin.js +175 -0
- package/dist/subagent-rate-limit-plugin.test.js +120 -0
- package/dist/system-prompt-drift-plugin.js +1 -5
- package/dist/tools.js +24 -16
- package/package.json +7 -7
- package/skills/new-skill/SKILL.md +3 -1
- package/skills/opensrc/SKILL.md +78 -0
- package/src/anthropic-auth-plugin.ts +11 -17
- package/src/bash-tool.test.ts +103 -0
- package/src/bash-tool.ts +287 -0
- package/src/commands/add-dir.test.ts +38 -0
- package/src/kimaki-opencode-plugin.ts +2 -0
- package/src/opencode-interrupt-plugin.ts +32 -2
- package/src/opencode.ts +25 -34
- package/src/plugin-logger.ts +16 -0
- package/src/session-handler/thread-session-runtime.ts +4 -0
- package/src/subagent-rate-limit-plugin.ts +218 -0
- package/src/system-prompt-drift-plugin.ts +1 -12
- package/src/tools.ts +41 -26
- package/skills/gitchamber/SKILL.md +0 -93
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* - https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/utils/oauth/anthropic.ts
|
|
23
23
|
* - https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/providers/anthropic.ts
|
|
24
24
|
*/
|
|
25
|
+
import { appendToastSessionMarker } from "./plugin-logger.js";
|
|
25
26
|
import { loadAccountStore, rememberAnthropicOAuth, rotateAnthropicAccount, saveAccountStore, setAnthropicAuth, shouldRotateAuth, upsertAccount, withAuthStateLock, } from "./anthropic-auth-state.js";
|
|
26
27
|
import { extractAnthropicAccountIdentity, } from "./anthropic-account-identity.js";
|
|
27
28
|
// PKCE (Proof Key for Code Exchange) using Web Crypto API.
|
|
@@ -485,13 +486,12 @@ function sanitizeAnthropicSystemText(text, onError) {
|
|
|
485
486
|
return text;
|
|
486
487
|
}
|
|
487
488
|
// Re-inject the process working directory that was inside the stripped block.
|
|
488
|
-
const envContext = `\n<environment>\n<cwd>${process.cwd()}</cwd>\n</environment>\n`;
|
|
489
|
-
// Replace all case-insensitive whole-word occurrences of "opencode" with "openc0de"
|
|
489
|
+
const envContext = `\n<environment>\n<cwd>${process.cwd()}</cwd>\n</environment>\n\n`;
|
|
490
490
|
const result = text.slice(0, startIdx) +
|
|
491
491
|
envContext +
|
|
492
492
|
text.slice(endIdx);
|
|
493
|
-
|
|
494
|
-
return result.replace(/\bopencode\b/gi, "openc0de");
|
|
493
|
+
return result;
|
|
494
|
+
// return result.replace(/\bopencode\b/gi, "openc0de");
|
|
495
495
|
}
|
|
496
496
|
function mapSystemTextPart(part, onError) {
|
|
497
497
|
if (typeof part === "string") {
|
|
@@ -511,7 +511,10 @@ function mapSystemTextPart(part, onError) {
|
|
|
511
511
|
return part;
|
|
512
512
|
}
|
|
513
513
|
function prependClaudeCodeIdentity(system, onError) {
|
|
514
|
-
const identityBlock = {
|
|
514
|
+
const identityBlock = {
|
|
515
|
+
type: "text",
|
|
516
|
+
text: CLAUDE_CODE_IDENTITY,
|
|
517
|
+
};
|
|
515
518
|
if (typeof system === "undefined")
|
|
516
519
|
return [identityBlock];
|
|
517
520
|
if (typeof system === "string") {
|
|
@@ -649,12 +652,6 @@ function wrapResponseStream(response, reverseToolNameMap) {
|
|
|
649
652
|
headers: response.headers,
|
|
650
653
|
});
|
|
651
654
|
}
|
|
652
|
-
function appendToastSessionMarker({ message, sessionId, }) {
|
|
653
|
-
if (!sessionId) {
|
|
654
|
-
return message;
|
|
655
|
-
}
|
|
656
|
-
return `${message} ${sessionId}`;
|
|
657
|
-
}
|
|
658
655
|
// --- Beta headers ---
|
|
659
656
|
function getRequiredBetas(modelId) {
|
|
660
657
|
const betas = [
|
|
@@ -84,4 +84,39 @@ describe('resolveDirectoryPermissionPattern', () => {
|
|
|
84
84
|
]
|
|
85
85
|
`);
|
|
86
86
|
});
|
|
87
|
+
test('pre-allows common toolchain caches under home with ~ patterns', () => {
|
|
88
|
+
expect(buildSessionPermissions({
|
|
89
|
+
directory: '/Users/me/project',
|
|
90
|
+
}).filter((rule) => {
|
|
91
|
+
return [
|
|
92
|
+
'~/.cache/zig',
|
|
93
|
+
'~/.cargo',
|
|
94
|
+
'~/.cache/go-build',
|
|
95
|
+
'~/go/pkg',
|
|
96
|
+
].includes(rule.pattern);
|
|
97
|
+
})).toMatchInlineSnapshot(`
|
|
98
|
+
[
|
|
99
|
+
{
|
|
100
|
+
"action": "allow",
|
|
101
|
+
"pattern": "~/.cache/zig",
|
|
102
|
+
"permission": "external_directory",
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
"action": "allow",
|
|
106
|
+
"pattern": "~/.cargo",
|
|
107
|
+
"permission": "external_directory",
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
"action": "allow",
|
|
111
|
+
"pattern": "~/.cache/go-build",
|
|
112
|
+
"permission": "external_directory",
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
"action": "allow",
|
|
116
|
+
"pattern": "~/go/pkg",
|
|
117
|
+
"permission": "external_directory",
|
|
118
|
+
},
|
|
119
|
+
]
|
|
120
|
+
`);
|
|
121
|
+
});
|
|
87
122
|
});
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// - context-awareness-plugin: branch, pwd, memory reminder, onboarding tutorial
|
|
9
9
|
// - memory-overview-plugin: frozen MEMORY.md heading overview per session
|
|
10
10
|
// - opencode-interrupt-plugin: interrupt queued messages at step boundaries
|
|
11
|
+
// - subagent-rate-limit-plugin: aborts only task subagents after rate limits
|
|
11
12
|
// - kitty-graphics-plugin: extract Kitty Graphics Protocol images from bash output
|
|
12
13
|
export { ipcToolsPlugin } from './ipc-tools-plugin.js';
|
|
13
14
|
export { contextAwarenessPlugin } from './context-awareness-plugin.js';
|
|
@@ -16,5 +17,6 @@ export { interruptOpencodeSessionOnUserMessage } from './opencode-interrupt-plug
|
|
|
16
17
|
export { systemPromptDriftPlugin } from './system-prompt-drift-plugin.js';
|
|
17
18
|
export { anthropicAuthPlugin } from './anthropic-auth-plugin.js';
|
|
18
19
|
export { imageOptimizerPlugin } from './image-optimizer-plugin.js';
|
|
20
|
+
export { subagentRateLimitPlugin } from './subagent-rate-limit-plugin.js';
|
|
19
21
|
export { kittyGraphicsPlugin } from 'kitty-graphics-agent';
|
|
20
22
|
export { injectionGuardInternal as injectionGuard } from 'opencode-injection-guard';
|
|
@@ -68,8 +68,7 @@ function getInterruptStepTimeoutMsFromEnv() {
|
|
|
68
68
|
return parsed;
|
|
69
69
|
}
|
|
70
70
|
// ── Encapsulated interrupt state ─────────────────────────────────
|
|
71
|
-
// All
|
|
72
|
-
// recoveringSessions, waiters) are trapped inside this closure. The plugin
|
|
71
|
+
// All mutable variables are trapped inside this closure. The plugin
|
|
73
72
|
// hooks only see the returned API methods — they cannot break invariants
|
|
74
73
|
// like forgetting to clear a timer or leaving a stale recovery lock.
|
|
75
74
|
function createInterruptState() {
|
|
@@ -77,6 +76,11 @@ function createInterruptState() {
|
|
|
77
76
|
const latestAssistantMessageIDBySession = new Map();
|
|
78
77
|
const recoveringSessions = new Set();
|
|
79
78
|
const waiters = new Set();
|
|
79
|
+
// Messages that were replayed after an abort. chat.message must skip
|
|
80
|
+
// scheduling a new interrupt timer for these to prevent an infinite
|
|
81
|
+
// abort→replay loop when the LLM takes >interruptStepTimeoutMs to
|
|
82
|
+
// return the first token (e.g. 239K token prompts).
|
|
83
|
+
const replayedMessageIds = new Set();
|
|
80
84
|
function clearPending(messageID) {
|
|
81
85
|
const pending = pendingByMessageId.get(messageID);
|
|
82
86
|
if (!pending) {
|
|
@@ -176,6 +180,15 @@ function createInterruptState() {
|
|
|
176
180
|
clearLatestAssistantMessage(sessionID) {
|
|
177
181
|
latestAssistantMessageIDBySession.delete(sessionID);
|
|
178
182
|
},
|
|
183
|
+
markReplayed(messageID) {
|
|
184
|
+
replayedMessageIds.add(messageID);
|
|
185
|
+
},
|
|
186
|
+
isReplayed(messageID) {
|
|
187
|
+
return replayedMessageIds.has(messageID);
|
|
188
|
+
},
|
|
189
|
+
clearReplayed(messageID) {
|
|
190
|
+
replayedMessageIds.delete(messageID);
|
|
191
|
+
},
|
|
179
192
|
// Clean up all state for a deleted session — timers, recovery locks, etc.
|
|
180
193
|
cleanupSession(sessionID) {
|
|
181
194
|
latestAssistantMessageIDBySession.delete(sessionID);
|
|
@@ -183,6 +196,7 @@ function createInterruptState() {
|
|
|
183
196
|
if (pending.sessionID !== sessionID) {
|
|
184
197
|
return;
|
|
185
198
|
}
|
|
199
|
+
replayedMessageIds.delete(messageID);
|
|
186
200
|
clearPending(messageID);
|
|
187
201
|
});
|
|
188
202
|
},
|
|
@@ -257,6 +271,12 @@ const interruptOpencodeSessionOnUserMessage = async (ctx) => {
|
|
|
257
271
|
if (currentPending.model) {
|
|
258
272
|
replayBody.model = currentPending.model;
|
|
259
273
|
}
|
|
274
|
+
// Mark as replayed BEFORE promptAsync so the chat.message hook
|
|
275
|
+
// (which fires synchronously when opencode processes the message)
|
|
276
|
+
// knows to skip scheduling a new interrupt timer. Without this,
|
|
277
|
+
// replayed messages re-enter the interrupt pipeline and create an
|
|
278
|
+
// infinite abort→replay loop when the LLM takes >timeout to respond.
|
|
279
|
+
state.markReplayed(messageID);
|
|
260
280
|
await ctx.client.session.promptAsync({
|
|
261
281
|
path: { id: sessionID },
|
|
262
282
|
body: replayBody,
|
|
@@ -337,6 +357,13 @@ const interruptOpencodeSessionOnUserMessage = async (ctx) => {
|
|
|
337
357
|
if (!messageID) {
|
|
338
358
|
return;
|
|
339
359
|
}
|
|
360
|
+
// Skip replayed messages — they were already interrupted and replayed
|
|
361
|
+
// by interruptPendingMessage. Scheduling a new timer would create an
|
|
362
|
+
// infinite abort→replay loop when the LLM is slow (large context).
|
|
363
|
+
if (state.isReplayed(messageID)) {
|
|
364
|
+
state.clearReplayed(messageID);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
340
367
|
if (state.hasPending(messageID)) {
|
|
341
368
|
return;
|
|
342
369
|
}
|
package/dist/opencode.js
CHANGED
|
@@ -710,39 +710,33 @@ export function buildSessionPermissions({ directory, originalRepoDirectory, }) {
|
|
|
710
710
|
{ permission: 'external_directory', pattern: normalizedDirectory, action: 'allow' },
|
|
711
711
|
{ permission: 'external_directory', pattern: `${normalizedDirectory}/*`, action: 'allow' },
|
|
712
712
|
];
|
|
713
|
+
const homeDirectoryRules = ({ relativePath }) => {
|
|
714
|
+
const normalizedRelativePath = relativePath.replaceAll('\\', '/');
|
|
715
|
+
const basePattern = path.resolve(os.homedir(), normalizedRelativePath);
|
|
716
|
+
return [
|
|
717
|
+
{ permission: 'external_directory', pattern: basePattern, action: 'allow' },
|
|
718
|
+
{ permission: 'external_directory', pattern: `${basePattern}/*`, action: 'allow' },
|
|
719
|
+
];
|
|
720
|
+
};
|
|
713
721
|
// Allow ~/.config/opencode so the agent doesn't get permission prompts when
|
|
714
722
|
// it tries to read the global AGENTS.md or opencode config (the path is
|
|
715
723
|
// visible in the system prompt, so models sometimes try to read it).
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
rules.push({
|
|
724
|
+
rules.push(...homeDirectoryRules({ relativePath: '.config/opencode' }));
|
|
725
|
+
// Allow ~/.config/openc0de too because the Anthropic plugin rewrites the
|
|
726
|
+
// name in the system prompt and some models may try to inspect that path.
|
|
727
|
+
rules.push(...homeDirectoryRules({ relativePath: '.config/openc0de' }));
|
|
720
728
|
// Allow ~/.opensrc so agents can inspect cached opensrc checkouts without
|
|
721
729
|
// permission prompts.
|
|
722
|
-
|
|
723
|
-
.join(os.homedir(), '.opensrc')
|
|
724
|
-
.replaceAll('\\', '/');
|
|
725
|
-
rules.push({ permission: 'external_directory', pattern: opensrcDir, action: 'allow' }, { permission: 'external_directory', pattern: `${opensrcDir}/*`, action: 'allow' });
|
|
730
|
+
rules.push(...homeDirectoryRules({ relativePath: '.opensrc' }));
|
|
726
731
|
// Allow ~/.kimaki so the agent can access kimaki data dir (logs, db, etc.)
|
|
727
732
|
// without permission prompts.
|
|
728
|
-
|
|
729
|
-
.join(os.homedir(), '.kimaki')
|
|
730
|
-
.replaceAll('\\', '/');
|
|
731
|
-
rules.push({ permission: 'external_directory', pattern: kimakiDataDir, action: 'allow' }, { permission: 'external_directory', pattern: `${kimakiDataDir}/*`, action: 'allow' });
|
|
733
|
+
rules.push(...homeDirectoryRules({ relativePath: '.kimaki' }));
|
|
732
734
|
// Allow opencode tool output artifacts under XDG data so agents can inspect
|
|
733
735
|
// prior tool outputs without interactive permission prompts.
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
rules.push({
|
|
738
|
-
permission: 'external_directory',
|
|
739
|
-
pattern: opencodeToolOutputDir,
|
|
740
|
-
action: 'allow',
|
|
741
|
-
}, {
|
|
742
|
-
permission: 'external_directory',
|
|
743
|
-
pattern: `${opencodeToolOutputDir}/*`,
|
|
744
|
-
action: 'allow',
|
|
745
|
-
});
|
|
736
|
+
rules.push(...homeDirectoryRules({ relativePath: '.local/share/opencode/tool-output' }));
|
|
737
|
+
// Allow common language caches under the user's home directory so toolchains
|
|
738
|
+
// can inspect downloaded modules and artifacts without external_directory prompts.
|
|
739
|
+
rules.push(...homeDirectoryRules({ relativePath: '.cache/zig' }), ...homeDirectoryRules({ relativePath: '.cargo' }), ...homeDirectoryRules({ relativePath: '.cache/go-build' }), ...homeDirectoryRules({ relativePath: 'go/pkg' }));
|
|
746
740
|
// For worktree sessions: explicitly deny the original checkout so agents do
|
|
747
741
|
// not keep editing the main repo after the thread has moved to a managed
|
|
748
742
|
// worktree. Deny rules are appended last so they override earlier allow/
|
package/dist/plugin-logger.js
CHANGED
|
@@ -57,3 +57,12 @@ export function createPluginLogger(prefix) {
|
|
|
57
57
|
},
|
|
58
58
|
};
|
|
59
59
|
}
|
|
60
|
+
// Append a session ID marker at the end of a toast message so the bot-side
|
|
61
|
+
// handleTuiToast can route the toast to the correct Discord thread.
|
|
62
|
+
// Without this marker the toast is silently dropped.
|
|
63
|
+
export function appendToastSessionMarker({ message, sessionId, }) {
|
|
64
|
+
if (!sessionId) {
|
|
65
|
+
return message;
|
|
66
|
+
}
|
|
67
|
+
return `${message} ${sessionId}`;
|
|
68
|
+
}
|
|
@@ -2106,6 +2106,10 @@ export class ThreadSessionRuntime {
|
|
|
2106
2106
|
if (properties.variant === 'warning') {
|
|
2107
2107
|
return;
|
|
2108
2108
|
}
|
|
2109
|
+
const toastSessionId = extractToastSessionId({ message: properties.message });
|
|
2110
|
+
if (!toastSessionId) {
|
|
2111
|
+
return;
|
|
2112
|
+
}
|
|
2109
2113
|
const toastMessage = stripToastSessionId({ message: properties.message }).trim();
|
|
2110
2114
|
if (!toastMessage) {
|
|
2111
2115
|
return;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// OpenCode plugin that aborts task-created subagent sessions after rate limits.
|
|
2
|
+
import * as errore from 'errore';
|
|
3
|
+
import { appendToastSessionMarker, createPluginLogger, formatPluginErrorWithStack, setPluginLogFilePath, } from './plugin-logger.js';
|
|
4
|
+
import { initSentry, notifyError } from './sentry.js';
|
|
5
|
+
const logger = createPluginLogger('SUBMODEL');
|
|
6
|
+
const RATE_LIMIT_TEXT_PATTERNS = [
|
|
7
|
+
'rate_limit',
|
|
8
|
+
'rate limit',
|
|
9
|
+
'resource exhausted',
|
|
10
|
+
'retry after',
|
|
11
|
+
'too many requests',
|
|
12
|
+
'quota exceeded',
|
|
13
|
+
];
|
|
14
|
+
function isRateLimitText(text) {
|
|
15
|
+
if (!text) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
const haystack = text.toLowerCase();
|
|
19
|
+
return RATE_LIMIT_TEXT_PATTERNS.some((pattern) => {
|
|
20
|
+
return haystack.includes(pattern);
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
function getTaskChildSession(event) {
|
|
24
|
+
if (event.type !== 'message.part.updated') {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
const part = event.properties.part;
|
|
28
|
+
if (part.type !== 'tool' || part.tool !== 'task' || part.state.status === 'pending') {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
const childSessionId = part.state.metadata?.sessionId;
|
|
32
|
+
if (typeof childSessionId !== 'string' || childSessionId.length === 0) {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
const subagentType = part.state.input?.subagent_type;
|
|
36
|
+
return {
|
|
37
|
+
childSessionId,
|
|
38
|
+
subagentType: typeof subagentType === 'string' ? subagentType : undefined,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function getEventSessionId(event) {
|
|
42
|
+
if (event.type === 'session.status' || event.type === 'session.idle') {
|
|
43
|
+
return event.properties.sessionID;
|
|
44
|
+
}
|
|
45
|
+
if (event.type === 'session.error') {
|
|
46
|
+
return event.properties.sessionID;
|
|
47
|
+
}
|
|
48
|
+
if (event.type === 'message.updated') {
|
|
49
|
+
return event.properties.info.sessionID;
|
|
50
|
+
}
|
|
51
|
+
if (event.type === 'message.part.updated') {
|
|
52
|
+
return event.properties.part.sessionID;
|
|
53
|
+
}
|
|
54
|
+
if (event.type === 'session.created'
|
|
55
|
+
|| event.type === 'session.updated'
|
|
56
|
+
|| event.type === 'session.deleted') {
|
|
57
|
+
return event.properties.info.id;
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
function extractRateLimitReason(event) {
|
|
62
|
+
if (event.type === 'session.status' && event.properties.status.type === 'retry') {
|
|
63
|
+
return isRateLimitText(event.properties.status.message)
|
|
64
|
+
? event.properties.status.message
|
|
65
|
+
: undefined;
|
|
66
|
+
}
|
|
67
|
+
if (event.type === 'message.part.updated' && event.properties.part.type === 'retry') {
|
|
68
|
+
const retryError = event.properties.part.error;
|
|
69
|
+
if (retryError.data.statusCode === 429) {
|
|
70
|
+
return retryError.data.message;
|
|
71
|
+
}
|
|
72
|
+
if (isRateLimitText(retryError.data.responseBody)) {
|
|
73
|
+
return retryError.data.responseBody;
|
|
74
|
+
}
|
|
75
|
+
return isRateLimitText(retryError.data.message)
|
|
76
|
+
? retryError.data.message
|
|
77
|
+
: undefined;
|
|
78
|
+
}
|
|
79
|
+
const apiError = (() => {
|
|
80
|
+
if (event.type === 'session.error' && event.properties.error?.name === 'APIError') {
|
|
81
|
+
return event.properties.error.data;
|
|
82
|
+
}
|
|
83
|
+
if (event.type === 'message.updated'
|
|
84
|
+
&& event.properties.info.role === 'assistant'
|
|
85
|
+
&& event.properties.info.error?.name === 'APIError') {
|
|
86
|
+
return event.properties.info.error.data;
|
|
87
|
+
}
|
|
88
|
+
return undefined;
|
|
89
|
+
})();
|
|
90
|
+
if (!apiError) {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
if (apiError.statusCode === 429) {
|
|
94
|
+
return apiError.message;
|
|
95
|
+
}
|
|
96
|
+
if (isRateLimitText(apiError.responseBody)) {
|
|
97
|
+
return apiError.responseBody;
|
|
98
|
+
}
|
|
99
|
+
return isRateLimitText(apiError.message) ? apiError.message : undefined;
|
|
100
|
+
}
|
|
101
|
+
export const subagentRateLimitPlugin = async ({ client, directory }) => {
|
|
102
|
+
initSentry();
|
|
103
|
+
const dataDir = process.env.KIMAKI_DATA_DIR;
|
|
104
|
+
if (dataDir) {
|
|
105
|
+
setPluginLogFilePath(dataDir);
|
|
106
|
+
}
|
|
107
|
+
const subagentSessions = new Map();
|
|
108
|
+
return {
|
|
109
|
+
event: async ({ event }) => {
|
|
110
|
+
const taskChild = getTaskChildSession(event);
|
|
111
|
+
if (taskChild) {
|
|
112
|
+
const existing = subagentSessions.get(taskChild.childSessionId);
|
|
113
|
+
if (existing) {
|
|
114
|
+
if (taskChild.subagentType) {
|
|
115
|
+
existing.subagentType = taskChild.subagentType;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
subagentSessions.set(taskChild.childSessionId, {
|
|
120
|
+
subagentType: taskChild.subagentType,
|
|
121
|
+
aborting: false,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const eventSessionId = getEventSessionId(event);
|
|
126
|
+
if (!eventSessionId) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (event.type === 'session.deleted' || event.type === 'session.idle') {
|
|
130
|
+
subagentSessions.delete(eventSessionId);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const rateLimitReason = extractRateLimitReason(event);
|
|
134
|
+
if (!rateLimitReason) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const subagent = subagentSessions.get(eventSessionId);
|
|
138
|
+
if (!subagent || subagent.aborting) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
subagent.aborting = true;
|
|
142
|
+
const abortResult = await errore.tryAsync({
|
|
143
|
+
try: async () => {
|
|
144
|
+
await client.session.abort({
|
|
145
|
+
path: { id: eventSessionId },
|
|
146
|
+
query: { directory },
|
|
147
|
+
});
|
|
148
|
+
await client.tui.showToast({
|
|
149
|
+
body: {
|
|
150
|
+
message: appendToastSessionMarker({
|
|
151
|
+
message: `Aborting ${subagent.subagentType || 'subagent'} after rate limit so the parent task can recover: ${rateLimitReason}`,
|
|
152
|
+
sessionId: eventSessionId,
|
|
153
|
+
}),
|
|
154
|
+
variant: 'info',
|
|
155
|
+
},
|
|
156
|
+
}).catch(() => {
|
|
157
|
+
return;
|
|
158
|
+
});
|
|
159
|
+
logger.info(`Aborted subagent ${eventSessionId} after rate limit`);
|
|
160
|
+
},
|
|
161
|
+
catch: (error) => {
|
|
162
|
+
return new Error('Subagent rate-limit abort failed', {
|
|
163
|
+
cause: error,
|
|
164
|
+
});
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
subagentSessions.delete(eventSessionId);
|
|
168
|
+
if (!(abortResult instanceof Error)) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
logger.warn(`[subagent-rate-limit-plugin] ${formatPluginErrorWithStack(abortResult)}`);
|
|
172
|
+
void notifyError(abortResult, 'subagent rate-limit plugin abort failed');
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Tests the fallback model ranking for subagent rate-limit recovery.
|
|
2
|
+
import { describe, expect, test } from 'vitest';
|
|
3
|
+
import { listCandidateModels } from './subagent-rate-limit-plugin.js';
|
|
4
|
+
function createProviderData() {
|
|
5
|
+
return {
|
|
6
|
+
connected: ['anthropic', 'openai'],
|
|
7
|
+
default: {
|
|
8
|
+
anthropic: 'claude-sonnet-4-5',
|
|
9
|
+
openai: 'gpt-5',
|
|
10
|
+
},
|
|
11
|
+
all: [
|
|
12
|
+
{
|
|
13
|
+
id: 'anthropic',
|
|
14
|
+
api: 'https://api.anthropic.com',
|
|
15
|
+
name: 'Anthropic',
|
|
16
|
+
env: ['ANTHROPIC_API_KEY'],
|
|
17
|
+
models: {
|
|
18
|
+
'claude-sonnet-4-5': {
|
|
19
|
+
id: 'claude-sonnet-4-5',
|
|
20
|
+
name: 'Claude Sonnet 4.5',
|
|
21
|
+
release_date: '2026-01-01',
|
|
22
|
+
attachment: true,
|
|
23
|
+
reasoning: true,
|
|
24
|
+
temperature: true,
|
|
25
|
+
tool_call: true,
|
|
26
|
+
limit: { context: 200_000, output: 16_000 },
|
|
27
|
+
options: {},
|
|
28
|
+
cost: { input: 3, output: 15 },
|
|
29
|
+
modalities: { input: ['text'], output: ['text'] },
|
|
30
|
+
},
|
|
31
|
+
'claude-haiku-4-5': {
|
|
32
|
+
id: 'claude-haiku-4-5',
|
|
33
|
+
name: 'Claude Haiku 4.5',
|
|
34
|
+
release_date: '2026-01-01',
|
|
35
|
+
attachment: true,
|
|
36
|
+
reasoning: false,
|
|
37
|
+
temperature: true,
|
|
38
|
+
tool_call: true,
|
|
39
|
+
limit: { context: 200_000, output: 16_000 },
|
|
40
|
+
options: {},
|
|
41
|
+
cost: { input: 1, output: 5 },
|
|
42
|
+
modalities: { input: ['text'], output: ['text'] },
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: 'openai',
|
|
48
|
+
api: 'https://api.openai.com/v1',
|
|
49
|
+
name: 'OpenAI',
|
|
50
|
+
env: ['OPENAI_API_KEY'],
|
|
51
|
+
models: {
|
|
52
|
+
'gpt-5': {
|
|
53
|
+
id: 'gpt-5',
|
|
54
|
+
name: 'GPT-5',
|
|
55
|
+
release_date: '2026-01-01',
|
|
56
|
+
attachment: true,
|
|
57
|
+
reasoning: true,
|
|
58
|
+
temperature: true,
|
|
59
|
+
tool_call: true,
|
|
60
|
+
limit: { context: 200_000, output: 16_000 },
|
|
61
|
+
options: {},
|
|
62
|
+
cost: { input: 1.25, output: 10 },
|
|
63
|
+
modalities: { input: ['text'], output: ['text'] },
|
|
64
|
+
},
|
|
65
|
+
'gpt-4o-mini': {
|
|
66
|
+
id: 'gpt-4o-mini',
|
|
67
|
+
name: 'GPT-4o mini',
|
|
68
|
+
release_date: '2026-01-01',
|
|
69
|
+
attachment: true,
|
|
70
|
+
reasoning: false,
|
|
71
|
+
temperature: true,
|
|
72
|
+
tool_call: true,
|
|
73
|
+
limit: { context: 200_000, output: 16_000 },
|
|
74
|
+
options: {},
|
|
75
|
+
cost: { input: 0.15, output: 0.6 },
|
|
76
|
+
modalities: { input: ['text'], output: ['text'] },
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
describe('listCandidateModels', () => {
|
|
84
|
+
test('prefers the cheapest model from other connected providers', () => {
|
|
85
|
+
const result = listCandidateModels({
|
|
86
|
+
providerData: createProviderData(),
|
|
87
|
+
currentModel: {
|
|
88
|
+
providerID: 'anthropic',
|
|
89
|
+
modelID: 'claude-sonnet-4-5',
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
expect(result).toMatchInlineSnapshot(`
|
|
93
|
+
[
|
|
94
|
+
{
|
|
95
|
+
"modelID": "gpt-4o-mini",
|
|
96
|
+
"providerID": "openai",
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
"modelID": "gpt-5",
|
|
100
|
+
"providerID": "openai",
|
|
101
|
+
},
|
|
102
|
+
]
|
|
103
|
+
`);
|
|
104
|
+
});
|
|
105
|
+
test('never falls back to models from the same provider', () => {
|
|
106
|
+
const providerData = createProviderData();
|
|
107
|
+
providerData.connected = ['anthropic'];
|
|
108
|
+
providerData.all = providerData.all.filter((provider) => {
|
|
109
|
+
return provider.id === 'anthropic';
|
|
110
|
+
});
|
|
111
|
+
const result = listCandidateModels({
|
|
112
|
+
providerData,
|
|
113
|
+
currentModel: {
|
|
114
|
+
providerID: 'anthropic',
|
|
115
|
+
modelID: 'claude-sonnet-4-5',
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
expect(result).toEqual([]);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -5,16 +5,12 @@
|
|
|
5
5
|
// mutating the system prompt unexpectedly.
|
|
6
6
|
import { diffLines } from 'diff';
|
|
7
7
|
import * as errore from 'errore';
|
|
8
|
-
import { createPluginLogger, formatPluginErrorWithStack, setPluginLogFilePath } from './plugin-logger.js';
|
|
8
|
+
import { appendToastSessionMarker, createPluginLogger, formatPluginErrorWithStack, setPluginLogFilePath } from './plugin-logger.js';
|
|
9
9
|
import { initSentry, notifyError } from './sentry.js';
|
|
10
10
|
const logger = createPluginLogger('OPENCODE');
|
|
11
|
-
const TOAST_SESSION_MARKER_SEPARATOR = ' ';
|
|
12
11
|
function normalizeSystemPrompt({ system }) {
|
|
13
12
|
return system.join('\n');
|
|
14
13
|
}
|
|
15
|
-
function appendToastSessionMarker({ message, sessionId, }) {
|
|
16
|
-
return `${message}${TOAST_SESSION_MARKER_SEPARATOR}${sessionId}`;
|
|
17
|
-
}
|
|
18
14
|
function buildTurnContext({ input, directory, }) {
|
|
19
15
|
const model = input.model
|
|
20
16
|
? `${input.model.providerID}/${input.model.modelID}${input.variant ? `:${input.variant}` : ''}`
|