opencode-enhancer 1.1.0 → 1.2.0

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