opencode-enhancer 1.1.0 → 1.2.1

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/index.js CHANGED
@@ -1,16 +1,17 @@
1
- import fs from 'node:fs';
2
- import { syncAuthFromOpenCode } from './auth-sync.js';
3
- import { createAuthorizationFlow, loginAccount, ensureValidToken } from './auth.js';
4
- import { extractRateLimitUpdate, getBlockingRateLimitResetAt, mergeRateLimits, parseRateLimitResetFromError, parseRetryAfterHeader } from './rate-limits.js';
5
- import { getNextAccount, markAuthInvalid, markModelUnsupported, markRateLimited, markWorkspaceDeactivated, getMinRemaining, selectBestAvailableAccount } from './rotation.js';
6
- import { getDefaultModels } from './models.js';
7
- import { getForceState, isForceActive } from './force-mode.js';
8
- import { getRuntimeSettings } from './settings.js';
9
- import { listAccounts, updateAccount, loadStore } from './store.js';
10
- import { DEFAULT_CONFIG } from './types.js';
11
- import { Errors } from './errors.js';
12
- import { decodeJwtPayload } from './jwt.js';
13
- import { PROVIDER_ID, CODEX_BASE_URL, URL_PATHS, OPENAI_HEADERS, OPENAI_HEADER_VALUES, JWT_CLAIM_PATH, TIMEOUTS, } from './constants.js';
1
+ import fs from "node:fs";
2
+ import { syncAuthFromOpenCode } from "./auth-sync.js";
3
+ import { createAuthorizationFlow, loginAccount, ensureValidToken } from "./auth.js";
4
+ import { extractRateLimitUpdate, getBlockingRateLimitResetAt, mergeRateLimits, parseRateLimitResetFromError, parseRetryAfterHeader, } from "./rate-limits.js";
5
+ import { getNextAccount, markAuthInvalid, markModelUnsupported, markRateLimited, markWorkspaceDeactivated, getMinRemaining, selectBestAvailableAccount, } from "./rotation.js";
6
+ import { compareAccountsByUsagePriority, getUsagePrioritySnapshot } from "./account-ranking.js";
7
+ import { getDefaultModels } from "./models.js";
8
+ import { getForceState, isForceActive } from "./force-mode.js";
9
+ import { getRuntimeSettings, isNotificationEnabled } from "./settings.js";
10
+ import { listAccounts, updateAccount, loadStore } from "./store.js";
11
+ import { DEFAULT_CONFIG, } from "./types.js";
12
+ import { Errors } from "./errors.js";
13
+ import { decodeJwtPayload } from "./jwt.js";
14
+ import { PROVIDER_ID, CODEX_BASE_URL, URL_PATHS, OPENAI_HEADERS, OPENAI_HEADER_VALUES, JWT_CLAIM_PATH, TIMEOUTS, } from "./constants.js";
14
15
  let pluginConfig = { ...DEFAULT_CONFIG };
15
16
  function readEnv(...keys) {
16
17
  for (const key of keys) {
@@ -21,37 +22,37 @@ function readEnv(...keys) {
21
22
  return undefined;
22
23
  }
23
24
  function isDebugEnabled() {
24
- return readEnv('OPENCODE_ENHANCER_DEBUG', 'OPENCODE_MULTI_AUTH_DEBUG') === '1';
25
+ return readEnv("OPENCODE_ENHANCER_DEBUG", "OPENCODE_MULTI_AUTH_DEBUG") === "1";
25
26
  }
26
27
  function configure(config) {
27
28
  pluginConfig = { ...pluginConfig, ...config };
28
29
  }
29
30
  function formatUsageWindow(label, window) {
30
- if (!window || typeof window.remaining !== 'number')
31
+ if (!window || typeof window.remaining !== "number")
31
32
  return undefined;
32
33
  if (window.limit === 100)
33
34
  return `${label}: ${window.remaining}%`;
34
- if (typeof window.limit === 'number')
35
+ if (typeof window.limit === "number")
35
36
  return `${label}: ${window.remaining}/${window.limit}`;
36
37
  return `${label}: ${window.remaining}`;
37
38
  }
38
39
  function formatAccountUsageSummary(rateLimits) {
39
40
  const parts = [
40
- formatUsageWindow('5h', rateLimits?.fiveHour),
41
- formatUsageWindow('wk', rateLimits?.weekly)
41
+ formatUsageWindow("5h", rateLimits?.fiveHour),
42
+ formatUsageWindow("wk", rateLimits?.weekly),
42
43
  ].filter(Boolean);
43
- return parts.join(' · ');
44
+ return parts.join(" · ");
44
45
  }
45
46
  function buildAccountSelectOption(account) {
46
47
  const label = account.email?.trim() || account.alias;
47
48
  return {
48
49
  label,
49
50
  value: account.alias,
50
- hint: formatAccountUsageSummary(account.rateLimits)
51
+ hint: formatAccountUsageSummary(account.rateLimits),
51
52
  };
52
53
  }
53
54
  function extractRequestUrl(input) {
54
- if (typeof input === 'string')
55
+ if (typeof input === "string")
55
56
  return input;
56
57
  if (input instanceof URL)
57
58
  return input.toString();
@@ -71,10 +72,10 @@ function extractPathAndSearch(url) {
71
72
  catch {
72
73
  // best-effort fallback
73
74
  }
74
- const trimmed = String(url || '').trim();
75
- if (trimmed.startsWith('/'))
75
+ const trimmed = String(url || "").trim();
76
+ if (trimmed.startsWith("/"))
76
77
  return trimmed;
77
- const firstSlash = trimmed.indexOf('/');
78
+ const firstSlash = trimmed.indexOf("/");
78
79
  if (firstSlash >= 0)
79
80
  return trimmed.slice(firstSlash);
80
81
  return trimmed;
@@ -86,8 +87,8 @@ function toCodexBackendUrl(originalUrl) {
86
87
  if (mapped.includes(URL_PATHS.RESPONSES)) {
87
88
  mapped = mapped.replace(URL_PATHS.RESPONSES, URL_PATHS.CODEX_RESPONSES);
88
89
  }
89
- else if (mapped.includes('/chat/completions')) {
90
- mapped = mapped.replace('/chat/completions', '/codex/chat/completions');
90
+ else if (mapped.includes("/chat/completions")) {
91
+ mapped = mapped.replace("/chat/completions", "/codex/chat/completions");
91
92
  }
92
93
  return new URL(mapped, CODEX_BASE_URL).toString();
93
94
  }
@@ -95,9 +96,9 @@ function filterInput(input) {
95
96
  if (!Array.isArray(input))
96
97
  return input;
97
98
  return input
98
- .filter((item) => item?.type !== 'item_reference')
99
+ .filter((item) => item?.type !== "item_reference")
99
100
  .map((item) => {
100
- if (item && typeof item === 'object' && 'id' in item) {
101
+ if (item && typeof item === "object" && "id" in item) {
101
102
  const { id, ...rest } = item;
102
103
  return rest;
103
104
  }
@@ -106,18 +107,19 @@ function filterInput(input) {
106
107
  }
107
108
  function normalizeModel(model) {
108
109
  if (!model)
109
- return 'gpt-5.1';
110
- const modelId = model.includes('/') ? model.split('/').pop() : model;
111
- const baseModel = modelId.replace(/-(?:fast|none|minimal|low|medium|high|xhigh)$/, '');
110
+ return "gpt-5.1";
111
+ const modelId = model.includes("/") ? model.split("/").pop() : model;
112
+ const baseModel = modelId.replace(/-(?:fast|none|minimal|low|medium|high|xhigh)$/, "");
112
113
  // OpenCode may lag behind the latest ChatGPT Codex model allowlist. Route known
113
114
  // older Codex selections to the latest backend model when enabled.
114
115
  // Codex model on the ChatGPT backend for users who want the newest model without
115
116
  // waiting for upstream registry updates.
116
- const preferLatestRaw = readEnv('OPENCODE_ENHANCER_PREFER_CODEX_LATEST', 'OPENCODE_MULTI_AUTH_PREFER_CODEX_LATEST');
117
- const preferLatest = preferLatestRaw === '1' || preferLatestRaw === 'true';
117
+ const preferLatestRaw = readEnv("OPENCODE_ENHANCER_PREFER_CODEX_LATEST", "OPENCODE_MULTI_AUTH_PREFER_CODEX_LATEST");
118
+ const preferLatest = preferLatestRaw === "1" || preferLatestRaw === "true";
118
119
  if (preferLatest &&
119
- (baseModel === 'gpt-5.3-codex' || baseModel === 'gpt-5.2-codex' || baseModel === 'gpt-5-codex')) {
120
- const latestModel = (readEnv('OPENCODE_ENHANCER_CODEX_LATEST_MODEL', 'OPENCODE_MULTI_AUTH_CODEX_LATEST_MODEL') || 'gpt-5.4').trim();
120
+ (baseModel === "gpt-5.3-codex" || baseModel === "gpt-5.2-codex" || baseModel === "gpt-5-codex")) {
121
+ const latestModel = (readEnv("OPENCODE_ENHANCER_CODEX_LATEST_MODEL", "OPENCODE_MULTI_AUTH_CODEX_LATEST_MODEL") ||
122
+ "gpt-5.4").trim();
121
123
  if (isDebugEnabled()) {
122
124
  console.log(`[enhancer] model map: ${baseModel} -> ${latestModel}`);
123
125
  }
@@ -127,43 +129,39 @@ function normalizeModel(model) {
127
129
  }
128
130
  function ensureContentType(headers) {
129
131
  const responseHeaders = new Headers(headers);
130
- if (!responseHeaders.has('content-type')) {
131
- responseHeaders.set('content-type', 'text/event-stream; charset=utf-8');
132
+ if (!responseHeaders.has("content-type")) {
133
+ responseHeaders.set("content-type", "text/event-stream; charset=utf-8");
132
134
  }
133
135
  return responseHeaders;
134
136
  }
135
- function extractErrorMessage(payload, fallbackText = '') {
136
- if (!payload || typeof payload !== 'object') {
137
+ function extractErrorMessage(payload, fallbackText = "") {
138
+ if (!payload || typeof payload !== "object") {
137
139
  return fallbackText;
138
140
  }
139
- const detailMessage = typeof payload?.detail?.message === 'string'
141
+ const detailMessage = typeof payload?.detail?.message === "string"
140
142
  ? payload.detail.message
141
- : typeof payload?.detail === 'string'
143
+ : typeof payload?.detail === "string"
142
144
  ? payload.detail
143
- : '';
144
- const errorMessage = typeof payload?.error?.message === 'string'
145
- ? payload.error.message
146
- : '';
147
- const topLevelMessage = typeof payload?.message === 'string'
148
- ? payload.message
149
- : '';
145
+ : "";
146
+ const errorMessage = typeof payload?.error?.message === "string" ? payload.error.message : "";
147
+ const topLevelMessage = typeof payload?.message === "string" ? payload.message : "";
150
148
  return detailMessage || errorMessage || topLevelMessage || fallbackText;
151
149
  }
152
150
  function resolveRateLimitedUntil(rateLimits, headers, errorText, fallbackCooldownMs, now = Date.now()) {
153
- const retryAfterUntil = parseRetryAfterHeader(headers.get('retry-after'), now) || 0;
151
+ const retryAfterUntil = parseRetryAfterHeader(headers.get("retry-after"), now) || 0;
154
152
  const windowResetUntil = getBlockingRateLimitResetAt(rateLimits, now) || 0;
155
153
  const messageResetUntil = parseRateLimitResetFromError(errorText, now) || 0;
156
154
  const fallbackUntil = now + fallbackCooldownMs;
157
155
  return Math.max(fallbackUntil, retryAfterUntil, windowResetUntil, messageResetUntil);
158
156
  }
159
157
  function parseSseStream(sseText) {
160
- const lines = sseText.split('\n');
158
+ const lines = sseText.split("\n");
161
159
  for (const line of lines) {
162
- if (!line.startsWith('data: '))
160
+ if (!line.startsWith("data: "))
163
161
  continue;
164
162
  try {
165
163
  const data = JSON.parse(line.substring(6));
166
- if (data?.type === 'response.done' || data?.type === 'response.completed') {
164
+ if (data?.type === "response.done" || data?.type === "response.completed") {
167
165
  return data.response;
168
166
  }
169
167
  }
@@ -175,11 +173,11 @@ function parseSseStream(sseText) {
175
173
  }
176
174
  async function convertSseToJson(response, headers) {
177
175
  if (!response.body) {
178
- throw new Error('[enhancer] Response has no body');
176
+ throw new Error("[enhancer] Response has no body");
179
177
  }
180
178
  const reader = response.body.getReader();
181
179
  const decoder = new TextDecoder();
182
- let fullText = '';
180
+ let fullText = "";
183
181
  while (true) {
184
182
  const { done, value } = await reader.read();
185
183
  if (done)
@@ -191,15 +189,15 @@ async function convertSseToJson(response, headers) {
191
189
  return new Response(fullText, {
192
190
  status: response.status,
193
191
  statusText: response.statusText,
194
- headers
192
+ headers,
195
193
  });
196
194
  }
197
195
  const jsonHeaders = new Headers(headers);
198
- jsonHeaders.set('content-type', 'application/json; charset=utf-8');
196
+ jsonHeaders.set("content-type", "application/json; charset=utf-8");
199
197
  return new Response(JSON.stringify(finalResponse), {
200
198
  status: response.status,
201
199
  statusText: response.statusText,
202
- headers: jsonHeaders
200
+ headers: jsonHeaders,
203
201
  });
204
202
  }
205
203
  /**
@@ -207,12 +205,9 @@ async function convertSseToJson(response, headers) {
207
205
  *
208
206
  * Rotates between multiple ChatGPT Plus/Pro accounts for rate limit resilience.
209
207
  */
210
- const MultiAuthPlugin = async ({ client, $, serverUrl, project, directory }) => {
208
+ const MultiAuthPlugin = async ({ client, $, serverUrl, project, directory, }) => {
211
209
  const terminalNotifierPath = (() => {
212
- const candidates = [
213
- '/opt/homebrew/bin/terminal-notifier',
214
- '/usr/local/bin/terminal-notifier'
215
- ];
210
+ const candidates = ["/opt/homebrew/bin/terminal-notifier", "/usr/local/bin/terminal-notifier"];
216
211
  for (const c of candidates) {
217
212
  try {
218
213
  if (fs.existsSync(c))
@@ -224,26 +219,108 @@ const MultiAuthPlugin = async ({ client, $, serverUrl, project, directory }) =>
224
219
  }
225
220
  return null;
226
221
  })();
227
- const notifyEnabledRaw = readEnv('OPENCODE_ENHANCER_NOTIFY', 'OPENCODE_MULTI_AUTH_NOTIFY');
228
- const notifyEnabled = notifyEnabledRaw !== '0' && notifyEnabledRaw !== 'false';
229
- const notifySound = (readEnv('OPENCODE_ENHANCER_NOTIFY_SOUND', 'OPENCODE_MULTI_AUTH_NOTIFY_SOUND') || '/System/Library/Sounds/Glass.aiff').trim();
222
+ const notifyEnabledRaw = readEnv("OPENCODE_ENHANCER_NOTIFY", "OPENCODE_MULTI_AUTH_NOTIFY");
223
+ const notifyEnabled = notifyEnabledRaw !== "0" && notifyEnabledRaw !== "false";
224
+ const notifyBackend = (() => {
225
+ const raw = (readEnv("OPENCODE_ENHANCER_NOTIFY_BACKEND", "OPENCODE_MULTI_AUTH_NOTIFY_BACKEND") || "auto")
226
+ .trim()
227
+ .toLowerCase();
228
+ if (raw === "terminal" || raw === "system")
229
+ return raw;
230
+ return "auto";
231
+ })();
232
+ const notifySound = (readEnv("OPENCODE_ENHANCER_NOTIFY_SOUND", "OPENCODE_MULTI_AUTH_NOTIFY_SOUND") ||
233
+ "/System/Library/Sounds/Glass.aiff").trim();
230
234
  const lastStatusBySession = new Map();
231
235
  const lastNotifiedAtByKey = new Map();
232
236
  const lastRetryAttemptBySession = new Map();
237
+ const sanitizeOscText = (value) => {
238
+ return String(value || "")
239
+ .replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
240
+ .replace(/\s+/g, " ")
241
+ .trim();
242
+ };
243
+ const truncateText = (value, maxLength) => {
244
+ if (value.length <= maxLength)
245
+ return value;
246
+ return `${value.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;
247
+ };
248
+ const formatOsc9Message = (title, body) => {
249
+ const safeTitle = sanitizeOscText(title);
250
+ const safeBody = sanitizeOscText(body);
251
+ const combined = [safeTitle, safeBody].filter(Boolean).join(" — ");
252
+ const prefixed = /^\d+;/.test(combined) ? `OpenCode ${combined}` : combined;
253
+ return truncateText(prefixed, 512);
254
+ };
255
+ const getTerminalNotificationSupport = () => {
256
+ if (process.env.ZELLIJ) {
257
+ return { supported: false, reason: "zellij-not-supported" };
258
+ }
259
+ if (process.env.TMUX) {
260
+ return { supported: false, reason: "tmux-requires-passthrough" };
261
+ }
262
+ if (process.env.STY) {
263
+ return { supported: false, reason: "screen-not-supported" };
264
+ }
265
+ const termProgram = (process.env.TERM_PROGRAM || "").trim().toLowerCase();
266
+ const term = (process.env.TERM || "").trim().toLowerCase();
267
+ if (termProgram === "ghostty" || term.includes("ghostty")) {
268
+ return { supported: true, terminal: "ghostty" };
269
+ }
270
+ if (termProgram === "iterm.app") {
271
+ return { supported: true, terminal: "iterm2" };
272
+ }
273
+ if (process.env.KITTY_WINDOW_ID || term.includes("kitty")) {
274
+ return { supported: true, terminal: "kitty" };
275
+ }
276
+ if (termProgram === "wezterm" || process.env.WEZTERM_PANE) {
277
+ return { supported: true, terminal: "wezterm" };
278
+ }
279
+ return { supported: false, reason: "terminal-unsupported" };
280
+ };
281
+ const writeTerminalSequence = (sequence) => {
282
+ try {
283
+ fs.appendFileSync("/dev/tty", sequence, { encoding: "utf8" });
284
+ return true;
285
+ }
286
+ catch {
287
+ // fall back to attached TTY streams below
288
+ }
289
+ for (const stream of [process.stderr, process.stdout]) {
290
+ if (!stream?.isTTY || typeof stream.write !== "function")
291
+ continue;
292
+ try {
293
+ stream.write(sequence);
294
+ return true;
295
+ }
296
+ catch {
297
+ // try next stream
298
+ }
299
+ }
300
+ return false;
301
+ };
302
+ const notifyTerminal = (title, body) => {
303
+ if (!notifyEnabled)
304
+ return false;
305
+ const message = formatOsc9Message(title, body);
306
+ if (!message)
307
+ return false;
308
+ return writeTerminalSequence(`\u001b]9;${message}\u001b\\`);
309
+ };
233
310
  const escapeAppleScriptString = (value) => {
234
311
  return String(value)
235
- .replaceAll('\\', '\\\\')
312
+ .replaceAll("\\", "\\\\")
236
313
  .replaceAll('"', '\"')
237
- .replaceAll(String.fromCharCode(10), '\n');
314
+ .replaceAll(String.fromCharCode(10), "\n");
238
315
  };
239
316
  let didWarnTerminalNotifier = false;
240
317
  const notifyMac = (title, message, clickUrl) => {
241
318
  if (!notifyEnabled)
242
- return;
243
- if (process.platform !== 'darwin')
244
- return;
245
- const macOpenRaw = readEnv('OPENCODE_ENHANCER_NOTIFY_MAC_OPEN', 'OPENCODE_MULTI_AUTH_NOTIFY_MAC_OPEN');
246
- const macOpenEnabled = macOpenRaw !== '0' && macOpenRaw !== 'false';
319
+ return false;
320
+ if (process.platform !== "darwin")
321
+ return false;
322
+ const macOpenRaw = readEnv("OPENCODE_ENHANCER_NOTIFY_MAC_OPEN", "OPENCODE_MULTI_AUTH_NOTIFY_MAC_OPEN");
323
+ const macOpenEnabled = macOpenRaw !== "0" && macOpenRaw !== "false";
247
324
  // Best effort: clickable notifications require terminal-notifier.
248
325
  if (macOpenEnabled && clickUrl && terminalNotifierPath) {
249
326
  try {
@@ -259,11 +336,11 @@ const MultiAuthPlugin = async ({ client, $, serverUrl, project, directory }) =>
259
336
  if (macOpenEnabled && clickUrl && !terminalNotifierPath && !didWarnTerminalNotifier) {
260
337
  didWarnTerminalNotifier = true;
261
338
  if (isDebugEnabled()) {
262
- console.log('[enhancer] mac click-to-open requires terminal-notifier (brew install terminal-notifier)');
339
+ console.log("[enhancer] mac click-to-open requires terminal-notifier (brew install terminal-notifier)");
263
340
  }
264
341
  }
265
342
  try {
266
- const osascript = '/usr/bin/osascript';
343
+ const osascript = "/usr/bin/osascript";
267
344
  const safeTitle = escapeAppleScriptString(title);
268
345
  const safeMessage = escapeAppleScriptString(message);
269
346
  const script = `display notification "${safeMessage}" with title "${safeTitle}"`;
@@ -275,25 +352,26 @@ const MultiAuthPlugin = async ({ client, $, serverUrl, project, directory }) =>
275
352
  }
276
353
  }
277
354
  if (!notifySound)
278
- return;
355
+ return true;
279
356
  try {
280
- const afplay = '/usr/bin/afplay';
357
+ const afplay = "/usr/bin/afplay";
281
358
  $ `${afplay} ${notifySound}`.nothrow().catch(() => { });
282
359
  }
283
360
  catch {
284
361
  // ignore
285
362
  }
363
+ return true;
286
364
  };
287
- const ntfyUrl = (readEnv('OPENCODE_ENHANCER_NOTIFY_NTFY_URL', 'OPENCODE_MULTI_AUTH_NOTIFY_NTFY_URL') || '').trim();
288
- const ntfyToken = (readEnv('OPENCODE_ENHANCER_NOTIFY_NTFY_TOKEN', 'OPENCODE_MULTI_AUTH_NOTIFY_NTFY_TOKEN') || '').trim();
289
- const notifyUiBaseUrl = (readEnv('OPENCODE_ENHANCER_NOTIFY_UI_BASE_URL', 'OPENCODE_MULTI_AUTH_NOTIFY_UI_BASE_URL') || '').trim();
365
+ const ntfyUrl = (readEnv("OPENCODE_ENHANCER_NOTIFY_NTFY_URL", "OPENCODE_MULTI_AUTH_NOTIFY_NTFY_URL") || "").trim();
366
+ const ntfyToken = (readEnv("OPENCODE_ENHANCER_NOTIFY_NTFY_TOKEN", "OPENCODE_MULTI_AUTH_NOTIFY_NTFY_TOKEN") || "").trim();
367
+ const notifyUiBaseUrl = (readEnv("OPENCODE_ENHANCER_NOTIFY_UI_BASE_URL", "OPENCODE_MULTI_AUTH_NOTIFY_UI_BASE_URL") || "").trim();
290
368
  const getSessionUrl = (sessionID) => {
291
- const base = (notifyUiBaseUrl || serverUrl?.origin || '').replace(/\/$/, '');
369
+ const base = (notifyUiBaseUrl || serverUrl?.origin || "").replace(/\/$/, "");
292
370
  if (!base)
293
- return '';
371
+ return "";
294
372
  return `${base}/session/${sessionID}`;
295
373
  };
296
- const projectLabel = (project?.name || project?.id || '').trim() || 'OpenCode';
374
+ const projectLabel = (project?.name || project?.id || "").trim() || "OpenCode";
297
375
  const sessionMetaCache = new Map();
298
376
  const getSessionMeta = async (sessionID) => {
299
377
  const cached = sessionMetaCache.get(sessionID);
@@ -302,7 +380,7 @@ const MultiAuthPlugin = async ({ client, $, serverUrl, project, directory }) =>
302
380
  try {
303
381
  const res = await client.session.get({
304
382
  path: { id: sessionID },
305
- query: { directory }
383
+ query: { directory },
306
384
  });
307
385
  // @opencode-ai/sdk returns { data } shape.
308
386
  const data = res?.data;
@@ -316,50 +394,104 @@ const MultiAuthPlugin = async ({ client, $, serverUrl, project, directory }) =>
316
394
  return meta;
317
395
  }
318
396
  };
397
+ const isPrimarySession = async (sessionID) => {
398
+ try {
399
+ const res = await client.session.get({ path: { id: sessionID }, query: { directory } });
400
+ return !res.data?.parentID;
401
+ }
402
+ catch {
403
+ return true;
404
+ }
405
+ };
406
+ const shouldNotifyKind = (kind) => {
407
+ if (!notifyEnabled)
408
+ return false;
409
+ if (kind === "retry")
410
+ return true;
411
+ if (kind === "taskComplete")
412
+ return isNotificationEnabled("taskComplete");
413
+ if (kind === "error")
414
+ return isNotificationEnabled("error");
415
+ if (kind === "permissionRequest")
416
+ return isNotificationEnabled("permissionRequest");
417
+ return isNotificationEnabled("question");
418
+ };
319
419
  const formatTitle = (kind) => {
320
- if (kind === 'error')
420
+ if (kind === "taskComplete")
421
+ return `OpenCode - ${projectLabel}`;
422
+ if (kind === "error")
321
423
  return `OpenCode - ${projectLabel} - Error`;
322
- if (kind === 'retry')
424
+ if (kind === "retry")
323
425
  return `OpenCode - ${projectLabel} - Retrying`;
324
- return `OpenCode - ${projectLabel}`;
426
+ if (kind === "permissionRequest")
427
+ return `OpenCode - ${projectLabel} - Permission`;
428
+ return `OpenCode - ${projectLabel} - Question`;
325
429
  };
326
- const formatBody = async (kind, sessionID, detail) => {
430
+ const formatSessionBody = async (kind, sessionID, detail) => {
327
431
  const meta = await getSessionMeta(sessionID);
328
- const titleLine = meta.title ? `Task: ${meta.title}` : '';
432
+ const titleLine = meta.title ? `Task: ${meta.title}` : "";
329
433
  const url = getSessionUrl(sessionID);
330
- if (kind === 'idle') {
331
- return [titleLine, `Session finished: ${sessionID}`, detail || '', url].filter(Boolean).join('\n');
434
+ if (kind === "taskComplete") {
435
+ return [titleLine, `Session finished: ${sessionID}`, detail || "", url]
436
+ .filter(Boolean)
437
+ .join("\n");
332
438
  }
333
- if (kind === 'retry') {
334
- return [titleLine, `Retrying: ${sessionID}`, detail || '', url].filter(Boolean).join('\n');
439
+ if (kind === "retry") {
440
+ return [titleLine, `Retrying: ${sessionID}`, detail || "", url].filter(Boolean).join("\n");
335
441
  }
336
- return [titleLine, `Error: ${sessionID}`, detail || '', url].filter(Boolean).join('\n');
442
+ return [titleLine, `Error: ${sessionID}`, detail || "", url].filter(Boolean).join("\n");
337
443
  };
338
- const notifyMacRich = async (kind, sessionID, detail) => {
339
- const body = await formatBody(kind, sessionID, detail);
340
- notifyMac(formatTitle(kind), body, getSessionUrl(sessionID) || undefined);
444
+ const formatContextBody = async (sessionID, lines) => {
445
+ const meta = sessionID ? await getSessionMeta(sessionID) : {};
446
+ const titleLine = meta.title ? `Task: ${meta.title}` : "";
447
+ const url = sessionID ? getSessionUrl(sessionID) : "";
448
+ return [titleLine, ...lines, url].filter(Boolean).join("\n");
341
449
  };
342
- const notifyNtfyRich = async (kind, sessionID, detail) => {
450
+ const notifyNtfy = async (title, body, priority, clickUrl) => {
343
451
  if (!notifyEnabled)
344
452
  return;
345
453
  if (!ntfyUrl)
346
454
  return;
347
- const sessionUrl = getSessionUrl(sessionID);
348
- const title = formatTitle(kind);
349
- const body = await formatBody(kind, sessionID, detail);
350
- // ntfy priority: 1=min, 3=default, 5=max
351
- const priority = kind === 'error' ? '5' : kind === 'retry' ? '4' : '3';
352
455
  const headers = {
353
- 'Content-Type': 'text/plain; charset=utf-8',
354
- 'Title': title,
355
- 'Priority': priority
456
+ "Content-Type": "text/plain; charset=utf-8",
457
+ Title: title,
458
+ Priority: priority,
356
459
  };
357
- if (sessionUrl)
358
- headers['Click'] = sessionUrl;
460
+ if (clickUrl)
461
+ headers["Click"] = clickUrl;
359
462
  if (ntfyToken)
360
- headers['Authorization'] = `Bearer ${ntfyToken}`;
463
+ headers["Authorization"] = `Bearer ${ntfyToken}`;
464
+ try {
465
+ await fetch(ntfyUrl, { method: "POST", headers, body });
466
+ }
467
+ catch {
468
+ // ignore
469
+ }
470
+ };
471
+ const notifyTargets = async (title, body, priority, clickUrl) => {
472
+ let localDelivered = false;
473
+ const terminalSupport = getTerminalNotificationSupport();
474
+ if (notifyBackend === "terminal" || (notifyBackend === "auto" && terminalSupport.supported)) {
475
+ try {
476
+ localDelivered = notifyTerminal(title, body);
477
+ }
478
+ catch {
479
+ localDelivered = false;
480
+ }
481
+ if (!localDelivered && isDebugEnabled()) {
482
+ console.log(`[enhancer] terminal notification unavailable (${terminalSupport.reason || terminalSupport.terminal || "write-failed"})`);
483
+ }
484
+ }
485
+ if (!localDelivered && notifyBackend !== "terminal") {
486
+ try {
487
+ localDelivered = notifyMac(title, body, clickUrl);
488
+ }
489
+ catch {
490
+ // ignore
491
+ }
492
+ }
361
493
  try {
362
- await fetch(ntfyUrl, { method: 'POST', headers, body });
494
+ await notifyNtfy(title, body, priority, clickUrl);
363
495
  }
364
496
  catch {
365
497
  // ignore
@@ -374,52 +506,108 @@ const MultiAuthPlugin = async ({ client, $, serverUrl, project, directory }) =>
374
506
  return false;
375
507
  };
376
508
  const formatRetryDetail = (status) => {
377
- const attempt = typeof status?.attempt === 'number' ? status.attempt : undefined;
378
- const message = typeof status?.message === 'string' ? status.message : '';
379
- const next = typeof status?.next === 'number' ? status.next : undefined;
509
+ const attempt = typeof status?.attempt === "number" ? status.attempt : undefined;
510
+ const message = typeof status?.message === "string" ? status.message : "";
511
+ const next = typeof status?.next === "number" ? status.next : undefined;
380
512
  const parts = [];
381
- if (typeof attempt === 'number')
513
+ if (typeof attempt === "number")
382
514
  parts.push(`Attempt: ${attempt}`);
383
515
  // OpenCode has emitted both "seconds-until-next" and "epoch ms" variants over time.
384
- if (typeof next === 'number') {
385
- const seconds = next > 1e12 ? Math.max(0, Math.round((next - Date.now()) / 1000)) : Math.max(0, Math.round(next));
516
+ if (typeof next === "number") {
517
+ const seconds = next > 1e12
518
+ ? Math.max(0, Math.round((next - Date.now()) / 1000))
519
+ : Math.max(0, Math.round(next));
386
520
  parts.push(`Next in: ${seconds}s`);
387
521
  }
388
522
  if (message)
389
523
  parts.push(message);
390
- return parts.join(' | ');
524
+ return parts.join(" | ");
391
525
  };
392
526
  const formatErrorDetail = (err) => {
393
- if (!err || typeof err !== 'object')
394
- return '';
395
- const name = typeof err.name === 'string' ? err.name : '';
396
- const code = typeof err.code === 'string' ? err.code : '';
397
- const message = (typeof err.message === 'string' && err.message) ||
398
- (typeof err.error?.message === 'string' && err.error.message) ||
399
- '';
400
- return [name, code, message].filter(Boolean).join(': ');
527
+ if (!err || typeof err !== "object")
528
+ return "";
529
+ const name = typeof err.name === "string" ? err.name : "";
530
+ const code = typeof err.code === "string" ? err.code : "";
531
+ const message = (typeof err.message === "string" && err.message) ||
532
+ (typeof err.error?.message === "string" && err.error.message) ||
533
+ "";
534
+ return [name, code, message].filter(Boolean).join(": ");
401
535
  };
402
- const notifyRich = async (kind, sessionID, detail) => {
403
- try {
404
- await notifyMacRich(kind, sessionID, detail);
405
- }
406
- catch {
407
- // ignore
408
- }
409
- try {
410
- await notifyNtfyRich(kind, sessionID, detail);
411
- }
412
- catch {
413
- // ignore
414
- }
536
+ const notifySessionEvent = async (kind, sessionID, detail) => {
537
+ if (!shouldNotifyKind(kind))
538
+ return;
539
+ const body = await formatSessionBody(kind, sessionID, detail);
540
+ const priority = kind === "error" ? "5" : kind === "retry" ? "4" : "3";
541
+ const clickUrl = getSessionUrl(sessionID) || undefined;
542
+ await notifyTargets(formatTitle(kind), body, priority, clickUrl);
543
+ };
544
+ const notifyPermissionRequested = async (request) => {
545
+ if (!shouldNotifyKind("permissionRequest"))
546
+ return;
547
+ const sessionID = typeof request?.sessionID === "string" ? request.sessionID : "";
548
+ const permissionLabel = (typeof request?.title === "string" && request.title) ||
549
+ (typeof request?.permission === "string" && request.permission) ||
550
+ (typeof request?.type === "string" && request.type) ||
551
+ "Permission request";
552
+ const patterns = [
553
+ ...(Array.isArray(request?.patterns) ? request.patterns : []),
554
+ ...(typeof request?.pattern === "string" ? [request.pattern] : []),
555
+ ].filter((value) => typeof value === "string" && value.trim().length > 0);
556
+ const body = await formatContextBody(sessionID || undefined, [
557
+ `Permission: ${permissionLabel}`,
558
+ patterns.length > 0 ? `Patterns: ${patterns.join(", ")}` : "",
559
+ ]);
560
+ await notifyTargets(formatTitle("permissionRequest"), body, "4", sessionID ? getSessionUrl(sessionID) || undefined : undefined);
561
+ };
562
+ const notifyQuestionRequested = async (request) => {
563
+ if (!shouldNotifyKind("question"))
564
+ return;
565
+ const sessionID = typeof request?.sessionID === "string" ? request.sessionID : "";
566
+ const questions = Array.isArray(request?.questions) ? request.questions : [];
567
+ const firstQuestion = questions[0];
568
+ const header = typeof firstQuestion?.header === "string" ? firstQuestion.header : "";
569
+ const prompt = typeof firstQuestion?.question === "string" ? firstQuestion.question : "";
570
+ const extraCount = questions.length > 1 ? ` (+${questions.length - 1} more)` : "";
571
+ const optionLabels = Array.isArray(firstQuestion?.options)
572
+ ? firstQuestion.options
573
+ .map((option) => (typeof option?.label === "string" ? option.label : ""))
574
+ .filter(Boolean)
575
+ : [];
576
+ const body = await formatContextBody(sessionID || undefined, [
577
+ `Question${header ? `: ${header}` : ""}${extraCount}`,
578
+ prompt,
579
+ optionLabels.length > 0 ? `Options: ${optionLabels.join(", ")}` : "",
580
+ ]);
581
+ await notifyTargets(formatTitle("question"), body, "4", sessionID ? getSessionUrl(sessionID) || undefined : undefined);
415
582
  };
416
583
  return {
417
584
  event: async ({ event }) => {
418
585
  if (!notifyEnabled)
419
586
  return;
420
- if (!event || !('type' in event))
587
+ if (!event || !("type" in event))
588
+ return;
589
+ const eventType = event.type;
590
+ if (!eventType)
591
+ return;
592
+ if (eventType === "permission.asked" || eventType === "permission.updated") {
593
+ const request = event.properties;
594
+ const requestID = (typeof request?.id === "string" && request.id) ||
595
+ (typeof request?.permissionID === "string" && request.permissionID) ||
596
+ "unknown";
597
+ if (shouldThrottle(`permission:${requestID}`, 2000))
598
+ return;
599
+ await notifyPermissionRequested(request);
600
+ return;
601
+ }
602
+ if (eventType === "question.asked") {
603
+ const request = event.properties;
604
+ const requestID = typeof request?.id === "string" ? request.id : "unknown";
605
+ if (shouldThrottle(`question:${requestID}`, 2000))
606
+ return;
607
+ await notifyQuestionRequested(request);
421
608
  return;
422
- if (event.type === 'session.created' || event.type === 'session.updated') {
609
+ }
610
+ if (eventType === "session.created" || eventType === "session.updated") {
423
611
  const info = event.properties?.info;
424
612
  const id = info?.id;
425
613
  if (id) {
@@ -427,69 +615,85 @@ const MultiAuthPlugin = async ({ client, $, serverUrl, project, directory }) =>
427
615
  }
428
616
  return;
429
617
  }
430
- if (event.type === 'session.status') {
618
+ if (eventType === "session.status") {
431
619
  const sessionID = event.properties?.sessionID;
432
620
  const status = event.properties?.status;
433
621
  const statusType = status?.type;
434
622
  if (!sessionID || !statusType)
435
623
  return;
436
624
  lastStatusBySession.set(sessionID, statusType);
437
- if (statusType === 'retry') {
438
- const attempt = typeof status?.attempt === 'number' ? status.attempt : undefined;
625
+ if (statusType === "retry") {
626
+ const attempt = typeof status?.attempt === "number" ? status.attempt : undefined;
439
627
  const prevAttempt = lastRetryAttemptBySession.get(sessionID);
440
- if (typeof attempt === 'number') {
628
+ if (typeof attempt === "number") {
441
629
  if (prevAttempt === attempt && shouldThrottle(`retry:${sessionID}:${attempt}`, 5000)) {
442
630
  return;
443
631
  }
444
632
  lastRetryAttemptBySession.set(sessionID, attempt);
445
633
  }
446
- const key = `retry:${sessionID}:${typeof attempt === 'number' ? attempt : 'na'}`;
634
+ const key = `retry:${sessionID}:${typeof attempt === "number" ? attempt : "na"}`;
447
635
  if (shouldThrottle(key, 2000))
448
636
  return;
449
- await notifyRich('retry', sessionID, formatRetryDetail(status));
637
+ await notifySessionEvent("retry", sessionID, formatRetryDetail(status));
450
638
  }
451
639
  return;
452
640
  }
453
- if (event.type === 'session.error') {
641
+ if (eventType === "session.error") {
454
642
  const sessionID = event.properties?.sessionID;
455
- const id = sessionID || 'unknown';
643
+ const id = sessionID || "unknown";
456
644
  const err = event.properties?.error;
457
645
  const detail = formatErrorDetail(err);
458
646
  const key = `error:${id}:${detail}`;
459
647
  if (shouldThrottle(key, 2000))
460
648
  return;
461
- await notifyRich('error', id, detail);
649
+ if (sessionID) {
650
+ lastStatusBySession.set(sessionID, "error");
651
+ }
652
+ await notifySessionEvent("error", id, detail);
462
653
  return;
463
654
  }
464
- if (event.type === 'session.idle') {
655
+ if (eventType === "session.idle") {
465
656
  const sessionID = event.properties?.sessionID;
466
657
  if (!sessionID)
467
658
  return;
468
659
  const prev = lastStatusBySession.get(sessionID);
469
- if (prev === 'busy' || prev === 'retry') {
660
+ if (prev !== "error" && (await isPrimarySession(sessionID))) {
470
661
  if (shouldThrottle(`idle:${sessionID}`, 2000))
471
662
  return;
472
- await notifyRich('idle', sessionID);
663
+ await notifySessionEvent("taskComplete", sessionID);
473
664
  }
474
- lastStatusBySession.set(sessionID, 'idle');
665
+ lastStatusBySession.set(sessionID, "idle");
475
666
  }
476
667
  },
668
+ "tool.execute.before": async (input) => {
669
+ if (input.tool !== "question")
670
+ return;
671
+ if (!(await isPrimarySession(input.sessionID)))
672
+ return;
673
+ if (shouldThrottle(`question-tool:${input.sessionID}`, 2000))
674
+ return;
675
+ const body = await formatContextBody(input.sessionID || undefined, [
676
+ "Question requires your input",
677
+ ]);
678
+ await notifyTargets(formatTitle("question"), body, "4", input.sessionID ? getSessionUrl(input.sessionID) || undefined : undefined);
679
+ },
477
680
  config: async (config) => {
478
- const injectModelsRaw = readEnv('OPENCODE_ENHANCER_INJECT_MODELS', 'OPENCODE_MULTI_AUTH_INJECT_MODELS');
479
- const injectModels = injectModelsRaw !== '0' && injectModelsRaw !== 'false';
681
+ const injectModelsRaw = readEnv("OPENCODE_ENHANCER_INJECT_MODELS", "OPENCODE_MULTI_AUTH_INJECT_MODELS");
682
+ const injectModels = injectModelsRaw !== "0" && injectModelsRaw !== "false";
480
683
  if (!injectModels)
481
684
  return;
482
- const latestModel = (readEnv('OPENCODE_ENHANCER_CODEX_LATEST_MODEL', 'OPENCODE_MULTI_AUTH_CODEX_LATEST_MODEL') || 'gpt-5.4').trim();
685
+ const latestModel = (readEnv("OPENCODE_ENHANCER_CODEX_LATEST_MODEL", "OPENCODE_MULTI_AUTH_CODEX_LATEST_MODEL") ||
686
+ "gpt-5.4").trim();
483
687
  try {
484
688
  const openai = config.provider?.[PROVIDER_ID] || null;
485
- if (!openai || typeof openai !== 'object')
689
+ if (!openai || typeof openai !== "object")
486
690
  return;
487
691
  openai.models ||= {};
488
692
  openai.whitelist ||= [];
489
693
  const defaultModels = getDefaultModels();
490
694
  const injectedModelIds = [latestModel];
491
- if (latestModel === 'gpt-5.4' && defaultModels['gpt-5.4-fast']) {
492
- injectedModelIds.push('gpt-5.4-fast');
695
+ if (latestModel === "gpt-5.4" && defaultModels["gpt-5.4-fast"]) {
696
+ injectedModelIds.push("gpt-5.4-fast");
493
697
  }
494
698
  for (const modelID of injectedModelIds) {
495
699
  const model = defaultModels[modelID];
@@ -503,12 +707,12 @@ const MultiAuthPlugin = async ({ client, $, serverUrl, project, directory }) =>
503
707
  }
504
708
  }
505
709
  if (isDebugEnabled()) {
506
- console.log(`[enhancer] injected runtime models: ${injectedModelIds.join(', ')}`);
710
+ console.log(`[enhancer] injected runtime models: ${injectedModelIds.join(", ")}`);
507
711
  }
508
712
  }
509
713
  catch (err) {
510
714
  if (isDebugEnabled()) {
511
- console.log('[enhancer] config injection failed:', err);
715
+ console.log("[enhancer] config injection failed:", err);
512
716
  }
513
717
  }
514
718
  },
@@ -521,7 +725,7 @@ const MultiAuthPlugin = async ({ client, $, serverUrl, project, directory }) =>
521
725
  await syncAuthFromOpenCode(getAuth);
522
726
  const accounts = listAccounts();
523
727
  if (accounts.length === 0) {
524
- console.log('[enhancer] No accounts configured. Run: opencode-enhancer add <alias>');
728
+ console.log("[enhancer] No accounts configured. Run: opencode-enhancer add <alias>");
525
729
  return {};
526
730
  }
527
731
  const customFetch = async (input, init) => {
@@ -529,13 +733,13 @@ const MultiAuthPlugin = async ({ client, $, serverUrl, project, directory }) =>
529
733
  const store = loadStore();
530
734
  const forceState = getForceState();
531
735
  const forcePinned = isForceActive() && !!forceState.forcedAlias;
532
- const eligibleCount = Object.values(store.accounts).filter(acc => {
736
+ const eligibleCount = Object.values(store.accounts).filter((acc) => {
533
737
  const now = Date.now();
534
- return (!acc.rateLimitedUntil || acc.rateLimitedUntil < now) &&
738
+ return ((!acc.rateLimitedUntil || acc.rateLimitedUntil < now) &&
535
739
  (!acc.modelUnsupportedUntil || acc.modelUnsupportedUntil < now) &&
536
740
  (!acc.workspaceDeactivatedUntil || acc.workspaceDeactivatedUntil < now) &&
537
741
  !acc.authInvalid &&
538
- acc.enabled !== false;
742
+ acc.enabled !== false);
539
743
  }).length;
540
744
  const maxAttempts = forcePinned ? 1 : Math.max(1, eligibleCount);
541
745
  const triedAliases = new Set();
@@ -545,7 +749,7 @@ const MultiAuthPlugin = async ({ client, $, serverUrl, project, directory }) =>
545
749
  const settings = getRuntimeSettings();
546
750
  const effectiveConfig = {
547
751
  ...pluginConfig,
548
- rotationStrategy: settings.settings.rotationStrategy
752
+ rotationStrategy: settings.settings.rotationStrategy,
549
753
  };
550
754
  const rotation = await getNextAccount(effectiveConfig);
551
755
  if (!rotation) {
@@ -555,16 +759,16 @@ const MultiAuthPlugin = async ({ client, $, serverUrl, project, directory }) =>
555
759
  if (forced?.rateLimitedUntil && forced.rateLimitedUntil > now) {
556
760
  return new Response(JSON.stringify({
557
761
  error: {
558
- code: 'RATE_LIMITED',
762
+ code: "RATE_LIMITED",
559
763
  message: `Forced account '${forced.alias}' is rate-limited until ${new Date(forced.rateLimitedUntil).toISOString()}`,
560
- details: { alias: forced.alias, rateLimitedUntil: forced.rateLimitedUntil }
561
- }
562
- }), { status: 429, headers: { 'Content-Type': 'application/json' } });
764
+ details: { alias: forced.alias, rateLimitedUntil: forced.rateLimitedUntil },
765
+ },
766
+ }), { status: 429, headers: { "Content-Type": "application/json" } });
563
767
  }
564
768
  }
565
769
  return new Response(JSON.stringify({
566
- error: Errors.noEligibleAccounts('No available accounts after filtering')
567
- }), { status: 503, headers: { 'Content-Type': 'application/json' } });
770
+ error: Errors.noEligibleAccounts("No available accounts after filtering"),
771
+ }), { status: 503, headers: { "Content-Type": "application/json" } });
568
772
  }
569
773
  let { account, token } = rotation;
570
774
  // Auto-switch: if current account has low remaining usage and
@@ -572,7 +776,9 @@ const MultiAuthPlugin = async ({ client, $, serverUrl, project, directory }) =>
572
776
  if (pluginConfig.autoSwitchOnLowUsage && !forcePinned) {
573
777
  const currentRemaining = getMinRemaining(account.rateLimits);
574
778
  const threshold = pluginConfig.autoSwitchThreshold;
575
- if (typeof currentRemaining === 'number' && currentRemaining !== Infinity && currentRemaining <= threshold) {
779
+ if (typeof currentRemaining === "number" &&
780
+ currentRemaining !== Infinity &&
781
+ currentRemaining <= threshold) {
576
782
  const betterAlias = selectBestAvailableAccount(account.alias);
577
783
  if (betterAlias) {
578
784
  const betterToken = await ensureValidToken(betterAlias);
@@ -580,14 +786,15 @@ const MultiAuthPlugin = async ({ client, $, serverUrl, project, directory }) =>
580
786
  const betterStore = loadStore();
581
787
  const betterAccount = betterStore.accounts[betterAlias];
582
788
  if (betterAccount) {
583
- const betterRemaining = getMinRemaining(betterAccount.rateLimits);
584
- if (betterRemaining > currentRemaining) {
789
+ const currentUsage = getUsagePrioritySnapshot(account.rateLimits);
790
+ const betterUsage = getUsagePrioritySnapshot(betterAccount.rateLimits);
791
+ if (compareAccountsByUsagePriority(betterAccount, account) < 0) {
585
792
  if (isDebugEnabled()) {
586
- console.log(`[enhancer] Auto-switching from ${account.alias} (${currentRemaining}% remaining) to ${betterAlias} (${betterRemaining}% remaining)`);
793
+ console.log(`[enhancer] Auto-switching from ${account.alias} (5h=${currentUsage.fiveHourRemaining ?? "unknown"}%, weekly=${currentUsage.weeklyRemaining ?? "unknown"}%) to ${betterAlias} (5h=${betterUsage.fiveHourRemaining ?? "unknown"}%, weekly=${betterUsage.weeklyRemaining ?? "unknown"}%)`);
587
794
  }
588
795
  updateAccount(betterAlias, {
589
796
  usageCount: (betterAccount.usageCount || 0) + 1,
590
- lastUsed: Date.now()
797
+ lastUsed: Date.now(),
591
798
  });
592
799
  account = betterAccount;
593
800
  token = betterToken;
@@ -609,10 +816,10 @@ const MultiAuthPlugin = async ({ client, $, serverUrl, project, directory }) =>
609
816
  }
610
817
  return new Response(JSON.stringify({
611
818
  error: {
612
- code: 'TOKEN_PARSE_ERROR',
613
- message: '[enhancer] Failed to extract accountId from token or stored account metadata'
614
- }
615
- }), { status: 401, headers: { 'Content-Type': 'application/json' } });
819
+ code: "TOKEN_PARSE_ERROR",
820
+ message: "[enhancer] Failed to extract accountId from token or stored account metadata",
821
+ },
822
+ }), { status: 401, headers: { "Content-Type": "application/json" } });
616
823
  }
617
824
  const originalUrl = extractRequestUrl(input);
618
825
  const url = toCodexBackendUrl(originalUrl);
@@ -625,17 +832,20 @@ const MultiAuthPlugin = async ({ client, $, serverUrl, project, directory }) =>
625
832
  }
626
833
  const isStreaming = body?.stream === true;
627
834
  const normalizedModel = normalizeModel(body.model);
628
- const fastMode = /-fast$/.test(body.model || '');
629
- const supportedFastMode = fastMode && normalizedModel === 'gpt-5.4';
835
+ const fastMode = /-fast$/.test(body.model || "");
836
+ const supportedFastMode = fastMode && normalizedModel === "gpt-5.4";
630
837
  const reasoningMatch = body.model?.match(/-(none|low|medium|high|xhigh)$/);
631
838
  const payload = {
632
839
  ...body,
633
840
  model: normalizedModel,
634
- store: false
841
+ store: false,
635
842
  };
636
843
  if (payload.truncation === undefined) {
637
- const truncationRaw = (readEnv('OPENCODE_ENHANCER_TRUNCATION', 'OPENCODE_MULTI_AUTH_TRUNCATION') || '').trim();
638
- if (truncationRaw && truncationRaw !== 'disabled' && truncationRaw !== 'false' && truncationRaw !== '0') {
844
+ const truncationRaw = (readEnv("OPENCODE_ENHANCER_TRUNCATION", "OPENCODE_MULTI_AUTH_TRUNCATION") || "").trim();
845
+ if (truncationRaw &&
846
+ truncationRaw !== "disabled" &&
847
+ truncationRaw !== "false" &&
848
+ truncationRaw !== "0") {
639
849
  payload.truncation = truncationRaw;
640
850
  }
641
851
  }
@@ -646,27 +856,27 @@ const MultiAuthPlugin = async ({ client, $, serverUrl, project, directory }) =>
646
856
  payload.reasoning = {
647
857
  ...(payload.reasoning || {}),
648
858
  effort: reasoningMatch[1],
649
- summary: payload.reasoning?.summary || 'auto'
859
+ summary: payload.reasoning?.summary || "auto",
650
860
  };
651
861
  }
652
862
  if (supportedFastMode) {
653
- payload.service_tier = payload.service_tier || 'priority';
863
+ payload.service_tier = payload.service_tier || "priority";
654
864
  if (isDebugEnabled()) {
655
- console.log('[enhancer] fast mode enabled: gpt-5.4 + service_tier=priority');
865
+ console.log("[enhancer] fast mode enabled: gpt-5.4 + service_tier=priority");
656
866
  }
657
867
  }
658
868
  else if (fastMode && isDebugEnabled()) {
659
869
  console.log(`[enhancer] fast mode ignored for unsupported model: ${normalizedModel}`);
660
870
  }
661
- if (isDebugEnabled() && payload.service_tier === 'priority') {
871
+ if (isDebugEnabled() && payload.service_tier === "priority") {
662
872
  console.log(`[enhancer] priority service tier requested for ${normalizedModel}`);
663
873
  }
664
874
  delete payload.reasoning_effort;
665
875
  try {
666
876
  const headers = new Headers(init?.headers || {});
667
- headers.delete('x-api-key');
668
- headers.set('Content-Type', 'application/json');
669
- headers.set('Authorization', `Bearer ${token}`);
877
+ headers.delete("x-api-key");
878
+ headers.set("Content-Type", "application/json");
879
+ headers.set("Authorization", `Bearer ${token}`);
670
880
  headers.set(OPENAI_HEADERS.ACCOUNT_ID, accountId);
671
881
  headers.set(OPENAI_HEADERS.BETA, OPENAI_HEADER_VALUES.BETA_RESPONSES);
672
882
  headers.set(OPENAI_HEADERS.ORIGINATOR, OPENAI_HEADER_VALUES.ORIGINATOR_CODEX);
@@ -679,14 +889,14 @@ const MultiAuthPlugin = async ({ client, $, serverUrl, project, directory }) =>
679
889
  headers.delete(OPENAI_HEADERS.CONVERSATION_ID);
680
890
  headers.delete(OPENAI_HEADERS.SESSION_ID);
681
891
  }
682
- headers.set('accept', 'text/event-stream');
892
+ headers.set("accept", "text/event-stream");
683
893
  const upstreamTimeoutMs = (() => {
684
- const raw = readEnv('OPENCODE_ENHANCER_UPSTREAM_TIMEOUT_MS', 'OPENCODE_MULTI_AUTH_UPSTREAM_TIMEOUT_MS');
894
+ const raw = readEnv("OPENCODE_ENHANCER_UPSTREAM_TIMEOUT_MS", "OPENCODE_MULTI_AUTH_UPSTREAM_TIMEOUT_MS");
685
895
  const parsed = raw ? Number(raw) : NaN;
686
896
  return Number.isFinite(parsed) && parsed > 0 ? parsed : TIMEOUTS.UPSTREAM_FETCH_MS;
687
897
  })();
688
898
  const res = await fetch(url, {
689
- method: init?.method || 'POST',
899
+ method: init?.method || "POST",
690
900
  headers,
691
901
  body: JSON.stringify(payload),
692
902
  signal: AbortSignal.timeout(upstreamTimeoutMs),
@@ -697,24 +907,30 @@ const MultiAuthPlugin = async ({ client, $, serverUrl, project, directory }) =>
697
907
  : account.rateLimits;
698
908
  if (limitUpdate) {
699
909
  updateAccount(account.alias, {
700
- rateLimits: mergedRateLimits
910
+ rateLimits: mergedRateLimits,
701
911
  });
702
912
  }
703
913
  if (res.status === 401 || res.status === 403) {
704
- const errorData = await res.clone().json().catch(() => ({}));
705
- const message = errorData?.error?.message || '';
706
- if (message.toLowerCase().includes('invalidated') || res.status === 401) {
914
+ const errorData = (await res
915
+ .clone()
916
+ .json()
917
+ .catch(() => ({})));
918
+ const message = errorData?.error?.message || "";
919
+ if (message.toLowerCase().includes("invalidated") || res.status === 401) {
707
920
  markAuthInvalid(account.alias);
708
921
  }
709
922
  if (attempt < maxAttempts) {
710
923
  continue;
711
924
  }
712
925
  return new Response(JSON.stringify({
713
- error: Errors.maxRetriesExceeded(attempt, Array.from(triedAliases))
714
- }), { status: res.status, headers: { 'Content-Type': 'application/json' } });
926
+ error: Errors.maxRetriesExceeded(attempt, Array.from(triedAliases)),
927
+ }), { status: res.status, headers: { "Content-Type": "application/json" } });
715
928
  }
716
929
  if (res.status === 429) {
717
- const errorData = await res.clone().json().catch(() => ({}));
930
+ const errorData = (await res
931
+ .clone()
932
+ .json()
933
+ .catch(() => ({})));
718
934
  const errorText = extractErrorMessage(errorData);
719
935
  const rateLimitedUntil = resolveRateLimitedUntil(mergedRateLimits, res.headers, errorText, pluginConfig.rateLimitCooldownMs);
720
936
  markRateLimited(account.alias, rateLimitedUntil);
@@ -722,80 +938,92 @@ const MultiAuthPlugin = async ({ client, $, serverUrl, project, directory }) =>
722
938
  continue;
723
939
  }
724
940
  return new Response(JSON.stringify({
725
- error: Errors.maxRetriesExceeded(attempt, Array.from(triedAliases))
726
- }), { status: 429, headers: { 'Content-Type': 'application/json' } });
941
+ error: Errors.maxRetriesExceeded(attempt, Array.from(triedAliases)),
942
+ }), { status: 429, headers: { "Content-Type": "application/json" } });
727
943
  }
728
944
  if (res.status === 402) {
729
- const errorData = await res.clone().json().catch(() => null);
730
- const errorText = await res.clone().text().catch(() => '');
731
- const code = (typeof errorData?.detail?.code === 'string' && errorData.detail.code) ||
732
- (typeof errorData?.error?.code === 'string' && errorData.error.code) ||
733
- '';
734
- const message = (typeof errorData?.detail?.message === 'string' && errorData.detail.message) ||
735
- (typeof errorData?.detail === 'string' && errorData.detail) ||
736
- (typeof errorData?.error?.message === 'string' && errorData.error.message) ||
737
- (typeof errorData?.message === 'string' && errorData.message) ||
945
+ const errorData = (await res
946
+ .clone()
947
+ .json()
948
+ .catch(() => null));
949
+ const errorText = await res
950
+ .clone()
951
+ .text()
952
+ .catch(() => "");
953
+ const code = (typeof errorData?.detail?.code === "string" && errorData.detail.code) ||
954
+ (typeof errorData?.error?.code === "string" && errorData.error.code) ||
955
+ "";
956
+ const message = (typeof errorData?.detail?.message === "string" && errorData.detail.message) ||
957
+ (typeof errorData?.detail === "string" && errorData.detail) ||
958
+ (typeof errorData?.error?.message === "string" && errorData.error.message) ||
959
+ (typeof errorData?.message === "string" && errorData.message) ||
738
960
  errorText ||
739
- '';
740
- const isDeactivatedWorkspace = code === 'deactivated_workspace' ||
741
- message.toLowerCase().includes('deactivated_workspace') ||
742
- message.toLowerCase().includes('deactivated workspace');
961
+ "";
962
+ const isDeactivatedWorkspace = code === "deactivated_workspace" ||
963
+ message.toLowerCase().includes("deactivated_workspace") ||
964
+ message.toLowerCase().includes("deactivated workspace");
743
965
  if (isDeactivatedWorkspace) {
744
966
  markWorkspaceDeactivated(account.alias, pluginConfig.workspaceDeactivatedCooldownMs, {
745
- error: message || code
967
+ error: message || code,
746
968
  });
747
969
  if (attempt < maxAttempts) {
748
970
  continue;
749
971
  }
750
972
  return new Response(JSON.stringify({
751
- error: Errors.maxRetriesExceeded(attempt, Array.from(triedAliases))
752
- }), { status: 402, headers: { 'Content-Type': 'application/json' } });
973
+ error: Errors.maxRetriesExceeded(attempt, Array.from(triedAliases)),
974
+ }), { status: 402, headers: { "Content-Type": "application/json" } });
753
975
  }
754
976
  }
755
977
  if (res.status === 400) {
756
- const errorData = await res.clone().json().catch(() => ({}));
757
- const message = (typeof errorData?.detail === 'string' && errorData.detail) ||
758
- (typeof errorData?.error?.message === 'string' && errorData.error.message) ||
759
- (typeof errorData?.message === 'string' && errorData.message) ||
760
- '';
761
- const isModelUnsupported = typeof message === 'string' &&
762
- message.toLowerCase().includes('model is not supported') &&
763
- message.toLowerCase().includes('chatgpt account');
978
+ const errorData = (await res
979
+ .clone()
980
+ .json()
981
+ .catch(() => ({})));
982
+ const message = (typeof errorData?.detail === "string" && errorData.detail) ||
983
+ (typeof errorData?.error?.message === "string" && errorData.error.message) ||
984
+ (typeof errorData?.message === "string" && errorData.message) ||
985
+ "";
986
+ const isModelUnsupported = typeof message === "string" &&
987
+ message.toLowerCase().includes("model is not supported") &&
988
+ message.toLowerCase().includes("chatgpt account");
764
989
  if (isModelUnsupported) {
765
990
  markModelUnsupported(account.alias, pluginConfig.modelUnsupportedCooldownMs, {
766
991
  model: normalizedModel,
767
- error: message
992
+ error: message,
768
993
  });
769
994
  if (attempt < maxAttempts) {
770
995
  continue;
771
996
  }
772
997
  return new Response(JSON.stringify({
773
- error: Errors.maxRetriesExceeded(attempt, Array.from(triedAliases))
774
- }), { status: 400, headers: { 'Content-Type': 'application/json' } });
998
+ error: Errors.maxRetriesExceeded(attempt, Array.from(triedAliases)),
999
+ }), { status: 400, headers: { "Content-Type": "application/json" } });
775
1000
  }
776
1001
  }
777
1002
  if (!res.ok) {
778
1003
  return res;
779
1004
  }
780
1005
  const responseHeaders = ensureContentType(res.headers);
781
- if (!isStreaming && responseHeaders.get('content-type')?.includes('text/event-stream')) {
1006
+ if (!isStreaming &&
1007
+ responseHeaders.get("content-type")?.includes("text/event-stream")) {
782
1008
  return await convertSseToJson(res, responseHeaders);
783
1009
  }
784
1010
  return res;
785
1011
  }
786
1012
  catch (err) {
787
- return new Response(JSON.stringify({ error: { code: 'REQUEST_FAILED', message: `[enhancer] Request failed: ${err}` } }), { status: 500, headers: { 'Content-Type': 'application/json' } });
1013
+ return new Response(JSON.stringify({
1014
+ error: { code: "REQUEST_FAILED", message: `[enhancer] Request failed: ${err}` },
1015
+ }), { status: 500, headers: { "Content-Type": "application/json" } });
788
1016
  }
789
1017
  }
790
1018
  return new Response(JSON.stringify({
791
- error: Errors.maxRetriesExceeded(attempt, Array.from(triedAliases))
792
- }), { status: 503, headers: { 'Content-Type': 'application/json' } });
1019
+ error: Errors.maxRetriesExceeded(attempt, Array.from(triedAliases)),
1020
+ }), { status: 503, headers: { "Content-Type": "application/json" } });
793
1021
  };
794
1022
  // Return SDK configuration with custom fetch for rotation
795
1023
  return {
796
- apiKey: 'chatgpt-oauth',
1024
+ apiKey: "chatgpt-oauth",
797
1025
  baseURL: CODEX_BASE_URL,
798
- fetch: customFetch
1026
+ fetch: customFetch,
799
1027
  };
800
1028
  },
801
1029
  methods: (() => {
@@ -808,43 +1036,43 @@ const MultiAuthPlugin = async ({ client, $, serverUrl, project, directory }) =>
808
1036
  // login needed. The user picks an account and it's instant.
809
1037
  return [
810
1038
  {
811
- label: 'Use existing account',
812
- type: 'oauth',
1039
+ label: "Use existing account",
1040
+ type: "oauth",
813
1041
  prompts: [
814
1042
  {
815
- type: 'select',
816
- key: 'alias',
817
- message: 'Select account',
1043
+ type: "select",
1044
+ key: "alias",
1045
+ message: "Select account",
818
1046
  options: [
819
- ...aliases.map(a => buildAccountSelectOption(store.accounts[a])),
820
- { label: '+ Add new account', value: '__new__' }
821
- ]
822
- }
1047
+ ...aliases.map((a) => buildAccountSelectOption(store.accounts[a])),
1048
+ { label: "+ Add new account", value: "__new__" },
1049
+ ],
1050
+ },
823
1051
  ],
824
1052
  authorize: async (inputs) => {
825
1053
  const selectedAlias = inputs?.alias;
826
1054
  // "Add new account" — full OAuth browser flow
827
- if (!selectedAlias || selectedAlias === '__new__') {
1055
+ if (!selectedAlias || selectedAlias === "__new__") {
828
1056
  const flow = await createAuthorizationFlow();
829
1057
  return {
830
1058
  url: flow.url,
831
- method: 'auto',
832
- instructions: 'Login with your ChatGPT Plus/Pro account',
1059
+ method: "auto",
1060
+ instructions: "Login with your ChatGPT Plus/Pro account",
833
1061
  callback: async () => {
834
1062
  try {
835
1063
  const account = await loginAccount(undefined, flow);
836
1064
  return {
837
- type: 'success',
1065
+ type: "success",
838
1066
  provider: PROVIDER_ID,
839
1067
  refresh: account.refreshToken,
840
1068
  access: account.accessToken,
841
- expires: account.expiresAt
1069
+ expires: account.expiresAt,
842
1070
  };
843
1071
  }
844
1072
  catch {
845
- return { type: 'failed' };
1073
+ return { type: "failed" };
846
1074
  }
847
- }
1075
+ },
848
1076
  };
849
1077
  }
850
1078
  // Selected an existing account — auto-resolve with stored tokens.
@@ -856,140 +1084,148 @@ const MultiAuthPlugin = async ({ client, $, serverUrl, project, directory }) =>
856
1084
  const flow = await createAuthorizationFlow();
857
1085
  return {
858
1086
  url: flow.url,
859
- method: 'auto',
860
- instructions: 'Login with your ChatGPT Plus/Pro account',
1087
+ method: "auto",
1088
+ instructions: "Login with your ChatGPT Plus/Pro account",
861
1089
  callback: async () => {
862
1090
  try {
863
1091
  const acc = await loginAccount(undefined, flow);
864
- return { type: 'success', provider: PROVIDER_ID, refresh: acc.refreshToken, access: acc.accessToken, expires: acc.expiresAt };
1092
+ return {
1093
+ type: "success",
1094
+ provider: PROVIDER_ID,
1095
+ refresh: acc.refreshToken,
1096
+ access: acc.accessToken,
1097
+ expires: acc.expiresAt,
1098
+ };
865
1099
  }
866
1100
  catch {
867
- return { type: 'failed' };
1101
+ return { type: "failed" };
868
1102
  }
869
- }
1103
+ },
870
1104
  };
871
1105
  }
872
1106
  return {
873
- url: 'data:text/html,<html><body><h1>Already authenticated</h1><p>Using stored credentials for ' + (account.email || account.alias) + '. You can close this tab.</p></body></html>',
874
- method: 'auto',
1107
+ url: "data:text/html,<html><body><h1>Already authenticated</h1><p>Using stored credentials for " +
1108
+ (account.email || account.alias) +
1109
+ ". You can close this tab.</p></body></html>",
1110
+ method: "auto",
875
1111
  instructions: `Using stored account: ${account.email || account.alias}`,
876
1112
  callback: async () => ({
877
- type: 'success',
1113
+ type: "success",
878
1114
  provider: PROVIDER_ID,
879
1115
  refresh: account.refreshToken,
880
1116
  access: account.accessToken,
881
- expires: account.expiresAt
882
- })
1117
+ expires: account.expiresAt,
1118
+ }),
883
1119
  };
884
- }
1120
+ },
885
1121
  },
886
1122
  {
887
- label: 'Add new ChatGPT account',
888
- type: 'oauth',
1123
+ label: "Add new ChatGPT account",
1124
+ type: "oauth",
889
1125
  authorize: async () => {
890
1126
  const flow = await createAuthorizationFlow();
891
1127
  return {
892
1128
  url: flow.url,
893
- method: 'auto',
894
- instructions: 'Login with your ChatGPT Plus/Pro account',
1129
+ method: "auto",
1130
+ instructions: "Login with your ChatGPT Plus/Pro account",
895
1131
  callback: async () => {
896
1132
  try {
897
1133
  const account = await loginAccount(undefined, flow);
898
1134
  return {
899
- type: 'success',
1135
+ type: "success",
900
1136
  provider: PROVIDER_ID,
901
1137
  refresh: account.refreshToken,
902
1138
  access: account.accessToken,
903
- expires: account.expiresAt
1139
+ expires: account.expiresAt,
904
1140
  };
905
1141
  }
906
1142
  catch {
907
- return { type: 'failed' };
1143
+ return { type: "failed" };
908
1144
  }
909
- }
1145
+ },
910
1146
  };
911
- }
1147
+ },
912
1148
  },
913
1149
  {
914
- label: 'Use API key',
915
- type: 'api',
1150
+ label: "Use API key",
1151
+ type: "api",
916
1152
  prompts: [
917
1153
  {
918
- type: 'text',
919
- key: 'apiKey',
920
- message: 'Enter your OpenAI API key (sk-...)',
921
- placeholder: 'sk-...'
922
- }
1154
+ type: "text",
1155
+ key: "apiKey",
1156
+ message: "Enter your OpenAI API key (sk-...)",
1157
+ placeholder: "sk-...",
1158
+ },
923
1159
  ],
924
1160
  authorize: async (inputs) => {
925
1161
  const apiKey = inputs?.apiKey?.trim();
926
1162
  if (!apiKey) {
927
- return { type: 'failed' };
1163
+ return { type: "failed" };
928
1164
  }
929
1165
  return {
930
- type: 'success',
1166
+ type: "success",
931
1167
  key: apiKey,
932
- provider: PROVIDER_ID
1168
+ provider: PROVIDER_ID,
933
1169
  };
934
- }
935
- }
1170
+ },
1171
+ },
936
1172
  ];
937
1173
  }
938
1174
  // No accounts yet — must go through full OAuth flow or use API key
939
1175
  return [
940
1176
  {
941
- label: 'ChatGPT OAuth (Multi-Account)',
942
- type: 'oauth',
1177
+ label: "ChatGPT OAuth (Multi-Account)",
1178
+ type: "oauth",
943
1179
  authorize: async () => {
944
1180
  const flow = await createAuthorizationFlow();
945
1181
  return {
946
1182
  url: flow.url,
947
- method: 'auto',
948
- instructions: 'Login with your ChatGPT Plus/Pro account',
1183
+ method: "auto",
1184
+ instructions: "Login with your ChatGPT Plus/Pro account",
949
1185
  callback: async () => {
950
1186
  try {
951
1187
  const account = await loginAccount(undefined, flow);
952
1188
  return {
953
- type: 'success',
1189
+ type: "success",
954
1190
  provider: PROVIDER_ID,
955
1191
  refresh: account.refreshToken,
956
1192
  access: account.accessToken,
957
- expires: account.expiresAt
1193
+ expires: account.expiresAt,
958
1194
  };
959
1195
  }
960
1196
  catch {
961
- return { type: 'failed' };
1197
+ return { type: "failed" };
962
1198
  }
963
- }
1199
+ },
964
1200
  };
965
- }
1201
+ },
966
1202
  },
967
1203
  {
968
- label: 'Use API key',
969
- type: 'api',
1204
+ label: "Use API key",
1205
+ type: "api",
970
1206
  prompts: [
971
1207
  {
972
- type: 'text',
973
- key: 'apiKey',
974
- message: 'Enter your OpenAI API key (sk-...)',
975
- placeholder: 'sk-...'
976
- }
1208
+ type: "text",
1209
+ key: "apiKey",
1210
+ message: "Enter your OpenAI API key (sk-...)",
1211
+ placeholder: "sk-...",
1212
+ },
977
1213
  ],
978
1214
  authorize: async (inputs) => {
979
1215
  const apiKey = inputs?.apiKey?.trim();
980
1216
  if (!apiKey) {
981
- return { type: 'failed' };
1217
+ return { type: "failed" };
982
1218
  }
983
1219
  return {
984
- type: 'success',
1220
+ type: "success",
985
1221
  key: apiKey,
986
- provider: PROVIDER_ID
1222
+ provider: PROVIDER_ID,
987
1223
  };
988
- }
989
- }
1224
+ },
1225
+ },
990
1226
  ];
991
- })()
992
- }
1227
+ })(),
1228
+ },
993
1229
  };
994
1230
  };
995
1231
  export default MultiAuthPlugin;