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.
@@ -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
- // Use a regex with global, case-insensitive, and word boundary flags
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 = { type: "text", text: CLAUDE_CODE_IDENTITY };
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 4 mutable variables (pendingByMessageId, latestAssistantMessageID,
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
- const opencodeConfigDir = path
717
- .join(os.homedir(), '.config', 'opencode')
718
- .replaceAll('\\', '/');
719
- rules.push({ permission: 'external_directory', pattern: opencodeConfigDir, action: 'allow' }, { permission: 'external_directory', pattern: `${opencodeConfigDir}/*`, action: 'allow' });
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
- const opensrcDir = path
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
- const kimakiDataDir = path
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
- const opencodeToolOutputDir = path
735
- .join(os.homedir(), '.local', 'share', 'opencode', 'tool-output')
736
- .replaceAll('\\', '/');
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/
@@ -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}` : ''}`