@smart-coders-hq/opencode-model-fallback 1.1.1 → 1.3.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/README.md CHANGED
@@ -5,7 +5,7 @@ OpenCode plugin that adds automatic model fallback when your primary model hits
5
5
  ## How it works
6
6
 
7
7
  1. **Preemptive redirect** — intercepts outgoing messages via `chat.message` hook; if the target model is known to be rate-limited, redirects the message to a healthy fallback _before_ it hits the provider (no 429 round-trip)
8
- 2. **Reactive fallback** — if a 429 still occurs (first hit, or preemptive not available), listens for `session.status: retry` events, aborts the retry loop, reverts the failed message, and replays it with the next healthy fallback model
8
+ 2. **Reactive fallback** — if a fallback-triggering error still occurs (first hit, or preemptive not available), listens for both `session.status: retry` and `session.error` (`APIError`) events, aborts the retry loop, reverts the failed message, and replays it with the next healthy fallback model
9
9
  3. Shows an inline toast notification and logs the event
10
10
  4. Tracks model health globally (rate limits are account-wide) — automatically recovers after configurable cooldown periods
11
11
  5. **Depth reset** — when the TUI reverts to the original model between messages, `fallbackDepth` resets so `maxFallbackDepth` only guards true cascading failures within a single message
@@ -176,6 +176,8 @@ With the `verbose` flag:
176
176
 
177
177
  Includes token/cost breakdown per model period.
178
178
 
179
+ When enabled, the plugin auto-creates `~/.config/opencode/commands/fallback-status.md` at startup so the slash command is available without manual setup.
180
+
179
181
  ## Health state machine
180
182
 
181
183
  ```
@@ -187,7 +189,7 @@ cooldown ──[retryOriginalAfterMs elapsed]──→ healthy
187
189
  - **healthy** — model is usable; preferred for fallback selection
188
190
  - **rate_limited** — recently hit a limit; skipped when walking fallback chain
189
191
  - **cooldown** — cooling off; used as last resort if no healthy model is available
190
- - State transitions are checked every 30 seconds via a background timer
192
+ - State transitions are checked every 30 seconds via a background timer (the timer is unref'ed so it does not keep the process alive)
191
193
  - When the original model recovers to healthy, a toast appears on the next `session.idle`
192
194
 
193
195
  ## Troubleshooting
@@ -195,6 +197,9 @@ cooldown ──[retryOriginalAfterMs elapsed]──→ healthy
195
197
  **Toast doesn't appear**
196
198
  The TUI notification requires an active OpenCode TUI session. Headless/API usage won't show toasts but logs are always written.
197
199
 
200
+ **`/fallback-status` command is missing**
201
+ The plugin writes `~/.config/opencode/commands/fallback-status.md` on startup. If the command does not appear, verify the directory is writable and check for `fallback-status.command.write.failed` in OpenCode logs.
202
+
198
203
  **"no fallback chain configured"**
199
204
  Your `model-fallback.json` has no `agents["*"].fallbackModels` (or no entry for the active agent). Add at least a wildcard entry with one model.
200
205
 
@@ -214,11 +219,14 @@ Key log events: `plugin.init`, `retry.detected`, `fallback.success`, `fallback.e
214
219
 
215
220
  To see the full event stream (including `event.received` and `retry.nomatch`), set `"logLevel": "debug"` in your config and restart OpenCode.
216
221
 
222
+ For safety, free-form provider error text is redacted in plugin logs; use category/model/session fields for diagnosis.
223
+
217
224
  ## Release automation
218
225
 
219
226
  - Uses **Conventional Commits** + `semantic-release` for automated versioning/changelog/release notes
220
227
  - CI runs lint, tests, type check, and build on every push/PR via `.github/workflows/ci.yml`
221
- - Release workflow runs on `main` after successful CI via `.github/workflows/release.yml`
228
+ - Trusted release gate runs on pushes to `main` via `.github/workflows/release-gate.yml`
229
+ - Release workflow (`.github/workflows/release.yml`) runs on successful `Release Gate` completion (`workflow_run`), only for `push` events on `main`, and only when `head_sha` matches `github.sha`
222
230
  - Published as `@smart-coders-hq/opencode-model-fallback`
223
231
  - To publish to npm, set repository secret `NPM_TOKEN`
224
232
 
@@ -227,7 +235,7 @@ To see the full event stream (including `event.received` and `retry.nomatch`), s
227
235
  ```bash
228
236
  bun install
229
237
  bun run lint # lint checks
230
- bun test # 145 tests across 11 files
238
+ bun test # 163 tests across 16 files
231
239
  bunx tsc --noEmit # type check
232
240
  bun run build # build to dist/
233
241
  ```
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/plugin.ts
2
- import { existsSync as existsSync4, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "fs";
2
+ import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "fs";
3
3
  import { homedir as homedir5 } from "os";
4
4
  import { dirname as dirname2, join as join4 } from "path";
5
5
 
@@ -3262,8 +3262,8 @@ function classifyError(message, statusCode) {
3262
3262
 
3263
3263
  // src/display/notifier.ts
3264
3264
  async function notifyFallback(client, from, to, reason) {
3265
- const fromLabel = from ? shortModelName(from) : "current model";
3266
- const message = `Model fallback: switched from ${fromLabel} to ${shortModelName(to)} (${reason})`;
3265
+ const fromLabel = from ? labelModel(from) : "current model";
3266
+ const message = `Model fallback: switched from ${fromLabel} to ${labelModel(to)} (${reason})`;
3267
3267
  await client.tui.showToast({
3268
3268
  body: {
3269
3269
  title: "Model Fallback",
@@ -3274,7 +3274,7 @@ async function notifyFallback(client, from, to, reason) {
3274
3274
  }).catch(() => {});
3275
3275
  }
3276
3276
  async function notifyFallbackActive(client, originalModel, currentModel) {
3277
- const message = `Using ${shortModelName(currentModel)} (fallback from ${shortModelName(originalModel)})`;
3277
+ const message = `Using ${labelModel(currentModel)} (fallback from ${labelModel(originalModel)})`;
3278
3278
  await client.tui.showToast({
3279
3279
  body: {
3280
3280
  title: "Fallback Active",
@@ -3285,7 +3285,7 @@ async function notifyFallbackActive(client, originalModel, currentModel) {
3285
3285
  }).catch(() => {});
3286
3286
  }
3287
3287
  async function notifyRecovery(client, originalModel) {
3288
- const message = `Original model ${shortModelName(originalModel)} is available again`;
3288
+ const message = `Original model ${labelModel(originalModel)} is available again`;
3289
3289
  await client.tui.showToast({
3290
3290
  body: {
3291
3291
  title: "Model Recovered",
@@ -3295,13 +3295,17 @@ async function notifyRecovery(client, originalModel) {
3295
3295
  }
3296
3296
  }).catch(() => {});
3297
3297
  }
3298
- function shortModelName(key) {
3299
- const parts = key.split("/");
3300
- return parts.length > 1 ? parts.slice(1).join("/") : key;
3298
+ function labelModel(key) {
3299
+ const slash = key.indexOf("/");
3300
+ if (slash === -1)
3301
+ return key;
3302
+ const provider = key.slice(0, slash);
3303
+ const model = key.slice(slash + 1);
3304
+ return `${model} [${provider}]`;
3301
3305
  }
3302
3306
 
3303
3307
  // src/logging/logger.ts
3304
- import { appendFileSync, existsSync as existsSync3, mkdirSync, writeFileSync } from "fs";
3308
+ import { appendFileSync, mkdirSync, writeFileSync } from "fs";
3305
3309
  import { dirname } from "path";
3306
3310
 
3307
3311
  class Logger {
@@ -3310,6 +3314,7 @@ class Logger {
3310
3314
  enabled;
3311
3315
  minLevel;
3312
3316
  dirCreated = false;
3317
+ fileErrorNotified = false;
3313
3318
  constructor(client, logPath, enabled, minLevel = "info") {
3314
3319
  this.client = client;
3315
3320
  this.logPath = logPath;
@@ -3317,18 +3322,19 @@ class Logger {
3317
3322
  this.minLevel = minLevel;
3318
3323
  }
3319
3324
  log(level, event, fields = {}) {
3325
+ const sanitizedFields = sanitizeFields(fields);
3320
3326
  const entry = {
3321
3327
  ts: new Date().toISOString(),
3322
3328
  level,
3323
3329
  event,
3324
- ...fields
3330
+ ...sanitizedFields
3325
3331
  };
3326
3332
  const shouldWrite = this.enabled && (this.minLevel === "debug" || level !== "debug");
3327
3333
  if (shouldWrite) {
3328
3334
  this.writeToFile(entry);
3329
3335
  }
3330
3336
  if (level !== "debug") {
3331
- const message = `[model-fallback] ${event}${Object.keys(fields).length ? " " + JSON.stringify(fields) : ""}`;
3337
+ const message = `[model-fallback] ${event}${Object.keys(sanitizedFields).length ? " " + JSON.stringify(sanitizedFields) : ""}`;
3332
3338
  this.client.app.log({
3333
3339
  body: { service: "model-fallback", level, message }
3334
3340
  }).catch(() => {});
@@ -3352,13 +3358,65 @@ class Logger {
3352
3358
  mkdirSync(dirname(this.logPath), { recursive: true, mode: 448 });
3353
3359
  this.dirCreated = true;
3354
3360
  }
3355
- if (!existsSync3(this.logPath)) {
3356
- writeFileSync(this.logPath, "", { mode: 384 });
3357
- }
3361
+ try {
3362
+ writeFileSync(this.logPath, "", { mode: 384, flag: "ax" });
3363
+ } catch {}
3358
3364
  appendFileSync(this.logPath, JSON.stringify(entry) + `
3359
3365
  `, "utf-8");
3360
- } catch {}
3366
+ } catch (err) {
3367
+ if (!this.fileErrorNotified) {
3368
+ this.fileErrorNotified = true;
3369
+ const message = `[model-fallback] logging.file.write.failed ${JSON.stringify({
3370
+ logPath: this.logPath,
3371
+ error: summarizeError(err)
3372
+ })}`;
3373
+ this.client.app.log({
3374
+ body: { service: "model-fallback", level: "warn", message }
3375
+ }).catch(() => {});
3376
+ }
3377
+ }
3378
+ }
3379
+ }
3380
+ function sanitizeFields(fields) {
3381
+ const out = {};
3382
+ for (const [key, value] of Object.entries(fields)) {
3383
+ out[key] = sanitizeValue(key, value);
3384
+ }
3385
+ return out;
3386
+ }
3387
+ function sanitizeValue(key, value) {
3388
+ if (value === null || value === undefined)
3389
+ return value;
3390
+ if (isSensitiveKey(key)) {
3391
+ if (typeof value === "string") {
3392
+ return { redacted: true, length: value.length };
3393
+ }
3394
+ if (value instanceof Error) {
3395
+ return { redacted: true, type: value.name, code: getErrorCode(value) };
3396
+ }
3397
+ return { redacted: true, type: typeof value };
3398
+ }
3399
+ if (value instanceof Error) {
3400
+ return { name: value.name, message: value.message };
3401
+ }
3402
+ return value;
3403
+ }
3404
+ function isSensitiveKey(key) {
3405
+ return /(?:^|_)(message|prompt|content|parts|error|err|stack|body)(?:$|_)/i.test(key);
3406
+ }
3407
+ function getErrorCode(err) {
3408
+ const code = err.code;
3409
+ return typeof code === "string" ? code : undefined;
3410
+ }
3411
+ function summarizeError(err) {
3412
+ if (err && typeof err === "object") {
3413
+ const e = err;
3414
+ return {
3415
+ type: typeof e.name === "string" ? e.name : "Error",
3416
+ code: typeof e.code === "string" ? e.code : undefined
3417
+ };
3361
3418
  }
3419
+ return { type: typeof err };
3362
3420
  }
3363
3421
 
3364
3422
  // src/resolution/agent-resolver.ts
@@ -3501,7 +3559,7 @@ async function attemptFallback(sessionId, reason, client, store, config, logger,
3501
3559
  const result = await client.session.messages({ path: { id: sessionId } });
3502
3560
  messageEntries = Array.isArray(result.data) ? result.data : [];
3503
3561
  } catch (err) {
3504
- logger.error("replay.messages.failed", { sessionId, err: String(err) });
3562
+ logger.error("replay.messages.failed", { sessionId, err });
3505
3563
  return { success: false, error: "messages fetch failed" };
3506
3564
  }
3507
3565
  let lastUserEntry = null;
@@ -3571,7 +3629,7 @@ async function attemptFallback(sessionId, reason, client, store, config, logger,
3571
3629
  return { success: false, error: "all fallback models exhausted" };
3572
3630
  }
3573
3631
  const currentModel = sessionState.currentModel;
3574
- if (currentModel) {
3632
+ if (currentModel && shouldMarkRateLimited(reason)) {
3575
3633
  store.health.markRateLimited(currentModel, config.defaults.cooldownMs, config.defaults.retryOriginalAfterMs);
3576
3634
  }
3577
3635
  sessionState.lastFallbackAt = Date.now();
@@ -3579,7 +3637,7 @@ async function attemptFallback(sessionId, reason, client, store, config, logger,
3579
3637
  await client.session.abort({ path: { id: sessionId } });
3580
3638
  logger.debug("replay.abort.ok", { sessionId });
3581
3639
  } catch (err) {
3582
- logger.error("replay.abort.failed", { sessionId, err: String(err) });
3640
+ logger.error("replay.abort.failed", { sessionId, err });
3583
3641
  return { success: false, error: "abort failed" };
3584
3642
  }
3585
3643
  try {
@@ -3592,7 +3650,7 @@ async function attemptFallback(sessionId, reason, client, store, config, logger,
3592
3650
  messageID: lastUserEntry.id
3593
3651
  });
3594
3652
  } catch (err) {
3595
- logger.error("replay.revert.failed", { sessionId, err: String(err) });
3653
+ logger.error("replay.revert.failed", { sessionId, err });
3596
3654
  return { success: false, error: "revert failed" };
3597
3655
  }
3598
3656
  const promptParts = convertPartsForPrompt(lastUserEntry.parts);
@@ -3615,7 +3673,7 @@ async function attemptFallback(sessionId, reason, client, store, config, logger,
3615
3673
  logger.error("replay.prompt.failed", {
3616
3674
  sessionId,
3617
3675
  fallbackModel,
3618
- err: String(err)
3676
+ err
3619
3677
  });
3620
3678
  return { success: false, error: "prompt failed" };
3621
3679
  }
@@ -3630,11 +3688,14 @@ async function attemptFallback(sessionId, reason, client, store, config, logger,
3630
3688
  reason,
3631
3689
  depth: newDepth
3632
3690
  });
3633
- return { success: true, fallbackModel };
3691
+ return { success: true, fallbackModel, fromModel: currentModel };
3634
3692
  } finally {
3635
3693
  store.sessions.releaseLock(sessionId);
3636
3694
  }
3637
3695
  }
3696
+ function shouldMarkRateLimited(reason) {
3697
+ return reason === "rate_limit" || reason === "quota_exceeded";
3698
+ }
3638
3699
  function sanitizeParts(parts) {
3639
3700
  if (!Array.isArray(parts))
3640
3701
  return [];
@@ -3648,7 +3709,7 @@ class ModelHealthStore {
3648
3709
  onTransition;
3649
3710
  constructor(opts) {
3650
3711
  this.onTransition = opts?.onTransition;
3651
- this.timer = setInterval(() => this.tick(), 30000);
3712
+ this.timer = setInterval(() => this.tick(), 30000).unref();
3652
3713
  }
3653
3714
  get(modelKey2) {
3654
3715
  return this.store.get(modelKey2) ?? this.newHealth(modelKey2);
@@ -3778,6 +3839,7 @@ class SessionStateStore {
3778
3839
  agentName
3779
3840
  };
3780
3841
  state.currentModel = toModel;
3842
+ state.fallbackDepth++;
3781
3843
  state.recoveryNotifiedForModel = null;
3782
3844
  state.fallbackHistory.push(event);
3783
3845
  if (agentName)
@@ -4022,22 +4084,25 @@ function getLastUserModelAndAgent(data) {
4022
4084
  }
4023
4085
 
4024
4086
  // src/plugin.ts
4025
- var createPlugin = async ({ client, directory }) => {
4026
- const { config, path: configPath, warnings, migrated } = loadConfig(directory);
4027
- const logger = new Logger(client, config.logPath, config.logging, config.logLevel);
4028
- const cmdPath = join4(homedir5(), ".config/opencode/commands/fallback-status.md");
4087
+ function resolveFallbackStatusCommandPath() {
4088
+ return join4(homedir5(), ".config", "opencode", "commands", "fallback-status.md");
4089
+ }
4090
+ function ensureFallbackStatusCommand(logger, cmdPath) {
4029
4091
  try {
4030
- if (!existsSync4(cmdPath)) {
4031
- mkdirSync2(dirname2(cmdPath), { recursive: true });
4032
- writeFileSync2(cmdPath, `Call the fallback-status tool and display the full output.
4033
- `);
4034
- }
4035
- } catch (err) {
4036
- logger.warn("fallback-status.command.write.failed", {
4037
- cmdPath,
4038
- err: String(err)
4092
+ mkdirSync2(dirname2(cmdPath), { recursive: true, mode: 448 });
4093
+ writeFileSync2(cmdPath, `Call the fallback-status tool and display the full output.
4094
+ `, {
4095
+ flag: "wx"
4039
4096
  });
4097
+ } catch (err) {
4098
+ if (err.code !== "EEXIST") {
4099
+ logger.warn("fallback-status.command.write.failed", { cmdPath, err });
4100
+ }
4040
4101
  }
4102
+ }
4103
+ var createPlugin = async ({ client, directory }) => {
4104
+ const { config, path: configPath, warnings, migrated } = loadConfig(directory);
4105
+ const logger = new Logger(client, config.logPath, config.logging, config.logLevel);
4041
4106
  logger.info("plugin.init", {
4042
4107
  configPath,
4043
4108
  enabled: config.enabled,
@@ -4045,17 +4110,19 @@ var createPlugin = async ({ client, directory }) => {
4045
4110
  agentCount: Object.keys(config.agents).length
4046
4111
  });
4047
4112
  for (const w of warnings) {
4048
- logger.warn("config.warning", { message: w });
4113
+ logger.warn("config.warning", { warning: w });
4049
4114
  }
4050
4115
  if (migrated) {
4051
4116
  logger.info("config.migrated", {
4052
- message: "Auto-migrated from old rate-limit-fallback.json format"
4117
+ note: "Auto-migrated from old rate-limit-fallback.json format"
4053
4118
  });
4054
4119
  }
4055
4120
  if (!config.enabled) {
4056
4121
  logger.info("plugin.disabled");
4057
4122
  return {};
4058
4123
  }
4124
+ const cmdPath = resolveFallbackStatusCommandPath();
4125
+ ensureFallbackStatusCommand(logger, cmdPath);
4059
4126
  const store = new FallbackStore(config, logger);
4060
4127
  const hooks = {
4061
4128
  async event({ event }) {
@@ -4120,8 +4187,7 @@ async function handleEvent(event, client, store, config, logger, directory) {
4120
4187
  if (config.defaults.fallbackOn.includes(category)) {
4121
4188
  const result = await attemptFallback(sessionID, category, client, store, config, logger, directory);
4122
4189
  if (result.success && result.fallbackModel) {
4123
- const state = store.sessions.get(sessionID);
4124
- await notifyFallback(client, state.originalModel, result.fallbackModel, category);
4190
+ await notifyFallback(client, result.fromModel ?? null, result.fallbackModel, category);
4125
4191
  }
4126
4192
  }
4127
4193
  }
@@ -4141,12 +4207,16 @@ async function handleEvent(event, client, store, config, logger, directory) {
4141
4207
  }
4142
4208
  async function handleRetry(sessionId, message, client, store, config, logger, directory) {
4143
4209
  if (!matchesAnyPattern(message, config.patterns)) {
4144
- logger.debug("retry.nomatch", { sessionId, message });
4210
+ logger.debug("retry.nomatch", { sessionId, messageLength: message.length });
4145
4211
  return;
4146
4212
  }
4147
4213
  const category = classifyError(message);
4148
4214
  if (!config.defaults.fallbackOn.includes(category)) {
4149
- logger.debug("retry.ignored", { sessionId, message, category });
4215
+ logger.debug("retry.ignored", {
4216
+ sessionId,
4217
+ category,
4218
+ messageLength: message.length
4219
+ });
4150
4220
  return;
4151
4221
  }
4152
4222
  const sessionState = store.sessions.get(sessionId);
@@ -4174,15 +4244,14 @@ async function handleRetry(sessionId, message, client, store, config, logger, di
4174
4244
  }
4175
4245
  logger.info("retry.detected", {
4176
4246
  sessionId,
4177
- message,
4247
+ messageLength: message.length,
4178
4248
  category,
4179
4249
  agentName: sessionState.agentName,
4180
4250
  agentFile: sessionState.agentFile
4181
4251
  });
4182
4252
  const result = await attemptFallback(sessionId, category, client, store, config, logger, directory);
4183
4253
  if (result.success && result.fallbackModel) {
4184
- const state = store.sessions.get(sessionId);
4185
- await notifyFallback(client, state.originalModel, result.fallbackModel, category);
4254
+ await notifyFallback(client, result.fromModel ?? null, result.fallbackModel, category);
4186
4255
  }
4187
4256
  }
4188
4257
  async function handleIdle(sessionId, client, store, _config, logger) {
@@ -4,5 +4,6 @@ type Client = PluginInput["client"];
4
4
  export declare function notifyFallback(client: Client, from: ModelKey | null, to: ModelKey, reason: ErrorCategory): Promise<void>;
5
5
  export declare function notifyFallbackActive(client: Client, originalModel: ModelKey, currentModel: ModelKey): Promise<void>;
6
6
  export declare function notifyRecovery(client: Client, originalModel: ModelKey): Promise<void>;
7
+ export declare function labelModel(key: ModelKey): string;
7
8
  export {};
8
9
  //# sourceMappingURL=notifier.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"notifier.d.ts","sourceRoot":"","sources":["../../../src/display/notifier.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AACvD,OAAO,KAAK,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAE3D,KAAK,MAAM,GAAG,WAAW,CAAC,QAAQ,CAAC,CAAC;AAEpC,wBAAsB,cAAc,CAClC,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,QAAQ,GAAG,IAAI,EACrB,EAAE,EAAE,QAAQ,EACZ,MAAM,EAAE,aAAa,GACpB,OAAO,CAAC,IAAI,CAAC,CAef;AAED,wBAAsB,oBAAoB,CACxC,MAAM,EAAE,MAAM,EACd,aAAa,EAAE,QAAQ,EACvB,YAAY,EAAE,QAAQ,GACrB,OAAO,CAAC,IAAI,CAAC,CAYf;AAED,wBAAsB,cAAc,CAAC,MAAM,EAAE,MAAM,EAAE,aAAa,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAY3F"}
1
+ {"version":3,"file":"notifier.d.ts","sourceRoot":"","sources":["../../../src/display/notifier.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AACvD,OAAO,KAAK,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAE3D,KAAK,MAAM,GAAG,WAAW,CAAC,QAAQ,CAAC,CAAC;AAEpC,wBAAsB,cAAc,CAClC,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,QAAQ,GAAG,IAAI,EACrB,EAAE,EAAE,QAAQ,EACZ,MAAM,EAAE,aAAa,GACpB,OAAO,CAAC,IAAI,CAAC,CAef;AAED,wBAAsB,oBAAoB,CACxC,MAAM,EAAE,MAAM,EACd,aAAa,EAAE,QAAQ,EACvB,YAAY,EAAE,QAAQ,GACrB,OAAO,CAAC,IAAI,CAAC,CAYf;AAED,wBAAsB,cAAc,CAAC,MAAM,EAAE,MAAM,EAAE,aAAa,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAY3F;AAED,wBAAgB,UAAU,CAAC,GAAG,EAAE,QAAQ,GAAG,MAAM,CAMhD"}
@@ -13,6 +13,7 @@ export declare class Logger {
13
13
  private enabled;
14
14
  private minLevel;
15
15
  private dirCreated;
16
+ private fileErrorNotified;
16
17
  constructor(client: Client, logPath: string, enabled: boolean, minLevel?: "debug" | "info");
17
18
  log(level: LogLevel, event: string, fields?: Record<string, unknown>): void;
18
19
  info(event: string, fields?: Record<string, unknown>): void;
@@ -1 +1 @@
1
- {"version":3,"file":"logger.d.ts","sourceRoot":"","sources":["../../../src/logging/logger.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAIvD,KAAK,MAAM,GAAG,WAAW,CAAC,QAAQ,CAAC,CAAC;AAEpC,MAAM,MAAM,QAAQ,GAAG,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC;AAE3D,MAAM,WAAW,QAAQ;IACvB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,QAAQ,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,qBAAa,MAAM;IACjB,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,OAAO,CAAU;IACzB,OAAO,CAAC,QAAQ,CAAmB;IACnC,OAAO,CAAC,UAAU,CAAS;gBAGzB,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,OAAO,EAChB,QAAQ,GAAE,OAAO,GAAG,MAAe;IAQrC,GAAG,CAAC,KAAK,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM,GAAG,IAAI;IA0B/E,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI;IAI3D,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI;IAI3D,KAAK,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI;IAI5D,KAAK,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI;IAI5D,OAAO,CAAC,WAAW;CAepB"}
1
+ {"version":3,"file":"logger.d.ts","sourceRoot":"","sources":["../../../src/logging/logger.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAIvD,KAAK,MAAM,GAAG,WAAW,CAAC,QAAQ,CAAC,CAAC;AAEpC,MAAM,MAAM,QAAQ,GAAG,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC;AAE3D,MAAM,WAAW,QAAQ;IACvB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,QAAQ,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,qBAAa,MAAM;IACjB,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,OAAO,CAAU;IACzB,OAAO,CAAC,QAAQ,CAAmB;IACnC,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,iBAAiB,CAAS;gBAGhC,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,OAAO,EAChB,QAAQ,GAAE,OAAO,GAAG,MAAe;IAQrC,GAAG,CAAC,KAAK,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM,GAAG,IAAI;IA2B/E,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI;IAI3D,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI;IAI3D,KAAK,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI;IAI5D,KAAK,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI;IAI5D,OAAO,CAAC,WAAW;CA8BpB"}
@@ -3,6 +3,7 @@ import type { Event } from "@opencode-ai/sdk";
3
3
  import { loadConfig } from "./config/loader.js";
4
4
  import { Logger } from "./logging/logger.js";
5
5
  import { FallbackStore } from "./state/store.js";
6
+ export declare function ensureFallbackStatusCommand(logger: Logger, cmdPath: string): void;
6
7
  export declare const createPlugin: Plugin;
7
8
  export declare function handleEvent(event: Event, client: Parameters<Plugin>[0]["client"], store: FallbackStore, config: ReturnType<typeof loadConfig>["config"], logger: Logger, directory: string): Promise<void>;
8
9
  export declare function handleIdle(sessionId: string, client: Parameters<Plugin>[0]["client"], store: FallbackStore, _config: ReturnType<typeof loadConfig>["config"], logger: Logger): Promise<void>;
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../../src/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAS,MAAM,EAAE,MAAM,qBAAqB,CAAC;AACzD,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,kBAAkB,CAAC;AAK9C,OAAO,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAIhD,OAAO,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAG7C,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAIjD,eAAO,MAAM,YAAY,EAAE,MAqG1B,CAAC;AAEF,wBAAsB,WAAW,CAC/B,KAAK,EAAE,KAAK,EACZ,MAAM,EAAE,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,EACvC,KAAK,EAAE,aAAa,EACpB,MAAM,EAAE,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC,QAAQ,CAAC,EAC/C,MAAM,EAAE,MAAM,EACd,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,IAAI,CAAC,CAyDf;AAqFD,wBAAsB,UAAU,CAC9B,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,EACvC,KAAK,EAAE,aAAa,EACpB,OAAO,EAAE,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC,QAAQ,CAAC,EAChD,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,IAAI,CAAC,CAuBf"}
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../../src/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAS,MAAM,EAAE,MAAM,qBAAqB,CAAC;AACzD,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,kBAAkB,CAAC;AAK9C,OAAO,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAIhD,OAAO,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAG7C,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAQjD,wBAAgB,2BAA2B,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAWjF;AAED,eAAO,MAAM,YAAY,EAAE,MA2F1B,CAAC;AAEF,wBAAsB,WAAW,CAC/B,KAAK,EAAE,KAAK,EACZ,MAAM,EAAE,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,EACvC,KAAK,EAAE,aAAa,EACpB,MAAM,EAAE,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC,QAAQ,CAAC,EAC/C,MAAM,EAAE,MAAM,EACd,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,IAAI,CAAC,CAwDf;AAwFD,wBAAsB,UAAU,CAC9B,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,EACvC,KAAK,EAAE,aAAa,EACpB,OAAO,EAAE,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC,QAAQ,CAAC,EAChD,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,IAAI,CAAC,CAuBf"}
@@ -6,6 +6,7 @@ type Client = PluginInput["client"];
6
6
  export interface ReplayResult {
7
7
  success: boolean;
8
8
  fallbackModel?: ModelKey;
9
+ fromModel?: ModelKey | null;
9
10
  error?: string;
10
11
  }
11
12
  export declare function attemptFallback(sessionId: string, reason: ErrorCategory, client: Client, store: FallbackStore, config: PluginConfig, logger: Logger, directory: string): Promise<ReplayResult>;
@@ -1 +1 @@
1
- {"version":3,"file":"orchestrator.d.ts","sourceRoot":"","sources":["../../../src/replay/orchestrator.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAGvD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,sBAAsB,CAAC;AAGnD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AACvD,OAAO,KAAK,EAAE,aAAa,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAGzE,KAAK,MAAM,GAAG,WAAW,CAAC,QAAQ,CAAC,CAAC;AAEpC,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,OAAO,CAAC;IACjB,aAAa,CAAC,EAAE,QAAQ,CAAC;IACzB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,wBAAsB,eAAe,CACnC,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,aAAa,EACrB,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,aAAa,EACpB,MAAM,EAAE,YAAY,EACpB,MAAM,EAAE,MAAM,EACd,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,YAAY,CAAC,CAwOvB"}
1
+ {"version":3,"file":"orchestrator.d.ts","sourceRoot":"","sources":["../../../src/replay/orchestrator.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAGvD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,sBAAsB,CAAC;AAGnD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AACvD,OAAO,KAAK,EAAE,aAAa,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAGzE,KAAK,MAAM,GAAG,WAAW,CAAC,QAAQ,CAAC,CAAC;AAEpC,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,OAAO,CAAC;IACjB,aAAa,CAAC,EAAE,QAAQ,CAAC;IACzB,SAAS,CAAC,EAAE,QAAQ,GAAG,IAAI,CAAC;IAC5B,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,wBAAsB,eAAe,CACnC,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,aAAa,EACrB,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,aAAa,EACpB,MAAM,EAAE,YAAY,EACpB,MAAM,EAAE,MAAM,EACd,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,YAAY,CAAC,CAwOvB"}
@@ -1 +1 @@
1
- {"version":3,"file":"model-health.d.ts","sourceRoot":"","sources":["../../../src/state/model-health.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAEtE,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,KAAK,CAAoC;IACjD,OAAO,CAAC,KAAK,CAA+C;IAC5D,OAAO,CAAC,YAAY,CAAC,CAAmE;gBAE5E,IAAI,CAAC,EAAE;QACjB,YAAY,CAAC,EAAE,CAAC,QAAQ,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,EAAE,EAAE,WAAW,KAAK,IAAI,CAAC;KACjF;IAMD,GAAG,CAAC,QAAQ,EAAE,QAAQ,GAAG,WAAW;IAIpC,eAAe,CAAC,QAAQ,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,EAAE,oBAAoB,EAAE,MAAM,GAAG,IAAI;IAc3F,QAAQ,CAAC,QAAQ,EAAE,QAAQ,GAAG,OAAO;IAKrC,WAAW,CAAC,QAAQ,EAAE,QAAQ,GAAG,MAAM;IAQvC,MAAM,IAAI,WAAW,EAAE;IAIvB,OAAO,IAAI,IAAI;IAOf,OAAO,CAAC,IAAI;IA4BZ,OAAO,CAAC,SAAS;CAUlB"}
1
+ {"version":3,"file":"model-health.d.ts","sourceRoot":"","sources":["../../../src/state/model-health.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAEtE,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,KAAK,CAAoC;IACjD,OAAO,CAAC,KAAK,CAA+C;IAC5D,OAAO,CAAC,YAAY,CAAC,CAAmE;gBAE5E,IAAI,CAAC,EAAE;QACjB,YAAY,CAAC,EAAE,CAAC,QAAQ,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,EAAE,EAAE,WAAW,KAAK,IAAI,CAAC;KACjF;IAOD,GAAG,CAAC,QAAQ,EAAE,QAAQ,GAAG,WAAW;IAIpC,eAAe,CAAC,QAAQ,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,EAAE,oBAAoB,EAAE,MAAM,GAAG,IAAI;IAc3F,QAAQ,CAAC,QAAQ,EAAE,QAAQ,GAAG,OAAO;IAKrC,WAAW,CAAC,QAAQ,EAAE,QAAQ,GAAG,MAAM;IAQvC,MAAM,IAAI,WAAW,EAAE;IAIvB,OAAO,IAAI,IAAI;IAOf,OAAO,CAAC,IAAI;IA4BZ,OAAO,CAAC,SAAS;CAUlB"}
@@ -1 +1 @@
1
- {"version":3,"file":"session-state.d.ts","sourceRoot":"","sources":["../../../src/state/session-state.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAiB,QAAQ,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AAEhG,qBAAa,iBAAiB;IAC5B,OAAO,CAAC,KAAK,CAA2C;IAExD,GAAG,CAAC,SAAS,EAAE,MAAM,GAAG,oBAAoB;IAS5C,WAAW,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO;IASvC,WAAW,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI;IAKpC,eAAe,CAAC,SAAS,EAAE,MAAM,EAAE,QAAQ,SAAQ,GAAG,OAAO;IAM7D,cAAc,CACZ,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,QAAQ,EACnB,OAAO,EAAE,QAAQ,EACjB,MAAM,EAAE,aAAa,EACrB,SAAS,EAAE,MAAM,GAAG,IAAI,GACvB,IAAI;IAmBP,wBAAwB,CACtB,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,QAAQ,EACnB,OAAO,EAAE,QAAQ,EACjB,SAAS,EAAE,MAAM,GAAG,IAAI,GACvB,IAAI;IAkBP,gBAAgB,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,QAAQ,GAAG,IAAI;IAQ1D,YAAY,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,IAAI;IAKxD,YAAY,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,IAAI;IAKxD,YAAY,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI;IASrC,MAAM,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI;IAI/B,MAAM,IAAI,oBAAoB,EAAE;IAIhC,OAAO,CAAC,QAAQ;CAcjB"}
1
+ {"version":3,"file":"session-state.d.ts","sourceRoot":"","sources":["../../../src/state/session-state.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAiB,QAAQ,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AAEhG,qBAAa,iBAAiB;IAC5B,OAAO,CAAC,KAAK,CAA2C;IAExD,GAAG,CAAC,SAAS,EAAE,MAAM,GAAG,oBAAoB;IAS5C,WAAW,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO;IASvC,WAAW,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI;IAKpC,eAAe,CAAC,SAAS,EAAE,MAAM,EAAE,QAAQ,SAAQ,GAAG,OAAO;IAM7D,cAAc,CACZ,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,QAAQ,EACnB,OAAO,EAAE,QAAQ,EACjB,MAAM,EAAE,aAAa,EACrB,SAAS,EAAE,MAAM,GAAG,IAAI,GACvB,IAAI;IAmBP,wBAAwB,CACtB,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,QAAQ,EACnB,OAAO,EAAE,QAAQ,EACjB,SAAS,EAAE,MAAM,GAAG,IAAI,GACvB,IAAI;IAmBP,gBAAgB,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,QAAQ,GAAG,IAAI;IAQ1D,YAAY,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,IAAI;IAKxD,YAAY,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,IAAI;IAKxD,YAAY,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI;IASrC,MAAM,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI;IAI/B,MAAM,IAAI,oBAAoB,EAAE;IAIhC,OAAO,CAAC,QAAQ;CAcjB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smart-coders-hq/opencode-model-fallback",
3
- "version": "1.1.1",
3
+ "version": "1.3.0",
4
4
  "description": "Ordered model fallback chains with health tracking for OpenCode",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/plugin.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "id": "opencode-model-fallback",
4
4
  "name": "Model Fallback",
5
5
  "description": "Ordered model fallback chains with health tracking and preemptive redirects for OpenCode",
6
- "version": "1.1.1",
6
+ "version": "1.3.0",
7
7
  "entry": "dist/index.js",
8
8
  "links": [
9
9
  {