mouaif 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -0
  3. package/bin/mouaif.js +281 -0
  4. package/frontend/dist/assets/AgentFilePicker-CcKLJorU.js +1 -0
  5. package/frontend/dist/assets/CliModal-Hs5phmNZ.js +7 -0
  6. package/frontend/dist/assets/DictationPage-BI23lp42.js +2 -0
  7. package/frontend/dist/assets/FileEditor-DDl31c6d.js +2 -0
  8. package/frontend/dist/assets/GitModal-3EC_gpJ5.js +2 -0
  9. package/frontend/dist/assets/Inspector-Ba3R1w04.js +73 -0
  10. package/frontend/dist/assets/SettingsAbout-bvZGDEDw.js +1 -0
  11. package/frontend/dist/assets/SettingsActions-Dk6WX9jv.js +1 -0
  12. package/frontend/dist/assets/SettingsAgents-BNV0MgDB.js +1 -0
  13. package/frontend/dist/assets/SettingsDefaults-DbMmQbzc.js +1 -0
  14. package/frontend/dist/assets/SettingsHiddenContent-BZ2sloH1.js +1 -0
  15. package/frontend/dist/assets/SettingsMcp-DOrfbQd1.js +1 -0
  16. package/frontend/dist/assets/SettingsMcpEdit-BGMQ2CWC.js +3 -0
  17. package/frontend/dist/assets/SettingsMcpRegistry-BywXee_A.js +1 -0
  18. package/frontend/dist/assets/SettingsNotifications-B0LEs11a.js +1 -0
  19. package/frontend/dist/assets/SettingsPricing-BAg33iVF.js +1 -0
  20. package/frontend/dist/assets/SettingsProject-DNrKhCcZ.js +14 -0
  21. package/frontend/dist/assets/SettingsProjects-IqkBfDcm.js +1 -0
  22. package/frontend/dist/assets/SettingsPrompts-BgeiASuk.js +1 -0
  23. package/frontend/dist/assets/SettingsProviders-k0xJN0IK.js +1 -0
  24. package/frontend/dist/assets/SettingsTags-B5kjFdQi.js +1 -0
  25. package/frontend/dist/assets/agentNavigation-BiiCpFz5.js +1 -0
  26. package/frontend/dist/assets/codemirror-Bp6CUUFk.js +30 -0
  27. package/frontend/dist/assets/index-BGvI4n0T.js +61 -0
  28. package/frontend/dist/assets/index-Bgg1gnDf.css +1 -0
  29. package/frontend/dist/assets/index-C1sQFIC-.css +1 -0
  30. package/frontend/dist/assets/index-CANPYzQg.css +1 -0
  31. package/frontend/dist/assets/index-Crn1LdzK.css +1 -0
  32. package/frontend/dist/assets/index-FbCWDPiB.css +1 -0
  33. package/frontend/dist/assets/projectQS-D1cSZ7Gr.js +1 -0
  34. package/frontend/dist/assets/virtual-list-6H9b4K51.js +1 -0
  35. package/frontend/dist/icons/favicon-32.png +0 -0
  36. package/frontend/dist/icons/icon-180-apple.png +0 -0
  37. package/frontend/dist/icons/icon-192.png +0 -0
  38. package/frontend/dist/icons/icon-512.png +0 -0
  39. package/frontend/dist/icons/icon-maskable-512.png +0 -0
  40. package/frontend/dist/index.html +83 -0
  41. package/frontend/dist/manifest.webmanifest +33 -0
  42. package/frontend/dist/sw.js +482 -0
  43. package/package.json +98 -0
  44. package/scripts/patch-zimmerframe.js +58 -0
  45. package/src/access-auth.js +515 -0
  46. package/src/agentFeatures.js +294 -0
  47. package/src/agentFiles.js +164 -0
  48. package/src/agentSkills.js +147 -0
  49. package/src/agents.js +230 -0
  50. package/src/ai-chat.js +21 -0
  51. package/src/ai-endpoints.js +1880 -0
  52. package/src/ai-stream.js +2048 -0
  53. package/src/ai.js +68 -0
  54. package/src/auth.js +391 -0
  55. package/src/chatdb.js +816 -0
  56. package/src/chats.js +275 -0
  57. package/src/custom-actions.js +65 -0
  58. package/src/files.js +431 -0
  59. package/src/hideFileContent.js +327 -0
  60. package/src/http-server.js +535 -0
  61. package/src/index.js +15 -0
  62. package/src/inspector.js +731 -0
  63. package/src/inspectorProfiles.js +503 -0
  64. package/src/live-chat.js +107 -0
  65. package/src/mcp.js +1517 -0
  66. package/src/messages.js +238 -0
  67. package/src/modelList.js +137 -0
  68. package/src/notifications.js +52 -0
  69. package/src/oauth-anthropic.js +280 -0
  70. package/src/oauth-github-copilot.js +417 -0
  71. package/src/oauth-mcp.js +216 -0
  72. package/src/oauth-openrouter.js +285 -0
  73. package/src/package-version.js +20 -0
  74. package/src/projects.js +285 -0
  75. package/src/promptProfiles.js +256 -0
  76. package/src/prompts.js +384 -0
  77. package/src/providerShapes.js +44 -0
  78. package/src/providers/base.js +41 -0
  79. package/src/providers/index.js +25 -0
  80. package/src/push.js +315 -0
  81. package/src/qr.js +192 -0
  82. package/src/restart.js +47 -0
  83. package/src/server-handlers-access.js +306 -0
  84. package/src/server-handlers-actions.js +100 -0
  85. package/src/server-handlers-ai.js +248 -0
  86. package/src/server-handlers-auth.js +273 -0
  87. package/src/server-handlers-chats.js +1436 -0
  88. package/src/server-handlers-git.js +467 -0
  89. package/src/server-handlers-mcp-oauth.js +56 -0
  90. package/src/server-handlers-misc.js +783 -0
  91. package/src/server-handlers-projects.js +289 -0
  92. package/src/server-handlers-prompts.js +259 -0
  93. package/src/server-handlers-push.js +102 -0
  94. package/src/server-handlers-settings.js +406 -0
  95. package/src/server-handlers-tools.js +654 -0
  96. package/src/server-handlers-transcribe.js +399 -0
  97. package/src/server-shared.js +780 -0
  98. package/src/server-web-static.js +191 -0
  99. package/src/settings.js +898 -0
  100. package/src/statusBar.js +541 -0
  101. package/src/tags.js +414 -0
  102. package/src/toolFeedback.js +225 -0
  103. package/src/tools/ask.js +154 -0
  104. package/src/tools/authorization.js +932 -0
  105. package/src/tools/files.js +1150 -0
  106. package/src/tools/progress.js +71 -0
  107. package/src/tools/restart.js +32 -0
  108. package/src/tools/searchEngine.js +957 -0
  109. package/src/tools/shell.js +341 -0
  110. package/src/tools/subagent.js +47 -0
  111. package/src/tools/task.js +234 -0
  112. package/src/tools/webpreview.js +448 -0
  113. package/src/trace.js +103 -0
  114. package/src/transcribe.js +683 -0
  115. package/src/usage.js +389 -0
  116. package/src/util.js +151 -0
package/src/ai.js ADDED
@@ -0,0 +1,68 @@
1
+ 'use strict';
2
+
3
+ // AI client core — facade.
4
+ //
5
+ // This module is the public entry point for the AI client. The actual
6
+ // implementation lives in focused sub-modules so the largest file in the
7
+ // repo stays under ~2 000 lines each:
8
+ //
9
+ // src/ai-endpoints.js — provider endpoints (ENDPOINTS), model-list
10
+ // adapters (listModels / listTranscriptionModels / listImageModels),
11
+ // request builders
12
+ // (BUILDERS), event parsers (PARSERS), and the
13
+ // shared helpers they use.
14
+ // src/ai-stream.js — the multi-turn streaming loop (streamChat,
15
+ // runSingleToolCall, runUpstreamTurn).
16
+ // src/ai-chat.js — non-streaming convenience wrapper (chat).
17
+ //
18
+ // The public surface is unchanged: streamChat, chat, runSingleToolCall,
19
+ // ENDPOINTS, listModels, listTranscriptionModels, listImageModels, plus the test-facing helpers
20
+ // parseSSEFrame / readSSE / readNDJSON / BUILDERS / PARSERS / copilotCacheClear.
21
+ // See the module-level comment below for the request/response contract.
22
+ //
23
+ // Implements docs/decisions.md section 10: server-side proxy with SSE
24
+ // streaming for the configured providers. The mobile UI never holds an
25
+ // API key — it POSTs to /api/ai/chat and reads the SSE stream back.
26
+ //
27
+ // Public surface:
28
+ //
29
+ // streamChat({ model, messages, signal, onEvent }) -> Promise<{ ok, usage, error? }>
30
+ //
31
+ // `model` is the resolved model record (id, provider, baseUrl, apiKey,
32
+ // contextWindow, ...). `messages` is the OpenAI-style array:
33
+ // [{ role: 'system'|'user'|'assistant', content: '...' }, ...]
34
+ // `signal` is an AbortSignal so the HTTP layer can cancel mid-stream.
35
+ // `onEvent(eventName, data)` is called for every SSE event as it
36
+ // arrives. The function returns once the upstream has finished (or
37
+ // failed). Usage is reported in `{ promptTokens, completionTokens }`.
38
+
39
+ const {
40
+ ENDPOINTS,
41
+ listModels,
42
+ listTranscriptionModels,
43
+ listImageModels,
44
+ BUILDERS,
45
+ PARSERS,
46
+ copilotCacheClear
47
+ } = require('./ai-endpoints.js');
48
+ const { streamChat, runSingleToolCall, parseSSEFrame, readSSE, readNDJSON } = require('./ai-stream.js');
49
+ const { chat } = require('./ai-chat.js');
50
+
51
+ module.exports = {
52
+ // public
53
+ streamChat,
54
+ chat,
55
+ runSingleToolCall,
56
+ ENDPOINTS,
57
+ listModels,
58
+ listTranscriptionModels,
59
+ listImageModels,
60
+ // exposed for tests
61
+ parseSSEFrame,
62
+ readSSE,
63
+ readNDJSON,
64
+ BUILDERS,
65
+ PARSERS,
66
+ // exposed for tests + the OAuth module's refresher path
67
+ copilotCacheClear
68
+ };
package/src/auth.js ADDED
@@ -0,0 +1,391 @@
1
+ 'use strict';
2
+
3
+ // Auth — @napi-rs/keyring wrapper + non-secret account index.
4
+ //
5
+ // Implements docs/decisions.md section 11. OAuth tokens live in the OS
6
+ // keychain; the app SQLite store keeps a non-secret index of which
7
+ // provider/account pairs are signed in, so the UI can list "logged in
8
+ // as ..." without ever touching the keychain.
9
+ //
10
+ // In this commit, the keyring is the only auth storage. Provider-specific
11
+ // OAuth flows (authorization endpoints, code exchange, refresh) land in
12
+ // later commits — one per provider. The loopback callback in src/index.js
13
+ // is wired but provider-neutral: it routes by `provider` and stores the
14
+ // token blob returned by the provider's exchange.
15
+ //
16
+ // Public surface:
17
+ //
18
+ // const auth = require('./auth.js');
19
+ //
20
+ // await auth.setToken('openai', 'me@example.com', JSON.stringify(blob));
21
+ // const blob = await auth.getToken('openai', 'me@example.com'); // string | null
22
+ // await auth.deleteToken('openai', 'me@example.com');
23
+ //
24
+ // auth.listAccounts(); // -> { openai: ['me@example.com'], anthropic: [], ... }
25
+ // auth.resolveAccount(model); // -> account string or null, for a model with auth='oauth'
26
+ // auth.tokenForModel(model); // -> blob string or null, the credential the AI client needs
27
+ //
28
+ // auth.recordPending(provider, { state, codeVerifier?, redirectUri, scopes, accountHint? });
29
+ // auth.consumePending(provider, state); // -> the pending record or null
30
+ // auth.clearPending(provider, state);
31
+ //
32
+ // Errors are typed: EKEYRING (OS keychain unavailable), ENOENT (no such
33
+ // account), EBADINPUT (validation), EPROVIDER (unknown provider).
34
+
35
+ const { Entry } = require('@napi-rs/keyring');
36
+ const settings = require('./settings.js');
37
+
38
+ // 'mouaif' is the keyring service prefix; per-provider accounts are
39
+ // namespaced under 'mouaif/<provider>'. Linux/Windows Credential Manager
40
+ // will show entries with the full name; we do not strip the prefix.
41
+ const SERVICE_PREFIX = 'mouaif';
42
+
43
+ const SUPPORTED_PROVIDERS = ['openai', 'anthropic', 'google', 'github-copilot', 'openrouter'];
44
+
45
+ function serviceName(provider) {
46
+ if (!SUPPORTED_PROVIDERS.includes(provider)) {
47
+ const e = new Error('Unknown provider: ' + provider);
48
+ e.code = 'EPROVIDER';
49
+ throw e;
50
+ }
51
+ return SERVICE_PREFIX + '/' + provider;
52
+ }
53
+
54
+ function validateAccount(provider, account) {
55
+ if (!SUPPORTED_PROVIDERS.includes(provider)) {
56
+ const e = new Error('Unknown provider: ' + provider);
57
+ e.code = 'EPROVIDER';
58
+ throw e;
59
+ }
60
+ if (!account || typeof account !== 'string' || !account.length) {
61
+ const e = new Error('Account is required');
62
+ e.code = 'EBADINPUT';
63
+ throw e;
64
+ }
65
+ }
66
+
67
+ // ---- Token CRUD --------------------------------------------------------
68
+
69
+ async function setToken(provider, account, blob) {
70
+ validateAccount(provider, account);
71
+ if (typeof blob !== 'string') {
72
+ const e = new Error('Token blob must be a string');
73
+ e.code = 'EBADINPUT';
74
+ throw e;
75
+ }
76
+ let entry;
77
+ try {
78
+ entry = new Entry(serviceName(provider), account);
79
+ entry.setPassword(blob);
80
+ } catch (e) {
81
+ const wrapped = new Error('Keyring set failed: ' + (e && e.message || e));
82
+ wrapped.code = 'EKEYRING';
83
+ wrapped.cause = e;
84
+ throw wrapped;
85
+ }
86
+ // Update the non-secret index only after the keychain write succeeded.
87
+ indexAdd(provider, account);
88
+ return { provider, account };
89
+ }
90
+
91
+ function getToken(provider, account) {
92
+ validateAccount(provider, account);
93
+ let entry;
94
+ try {
95
+ entry = new Entry(serviceName(provider), account);
96
+ return entry.getPassword();
97
+ } catch (e) {
98
+ // @napi-rs/keyring throws when the entry does not exist. That's our
99
+ // "no such account" signal; map it to null so callers don't have to
100
+ // distinguish "absent" from "wrong password".
101
+ const msg = (e && e.message) || String(e);
102
+ if (/no matching entry|not found|No such file/i.test(msg)) return null;
103
+ const wrapped = new Error('Keyring get failed: ' + msg);
104
+ wrapped.code = 'EKEYRING';
105
+ wrapped.cause = e;
106
+ throw wrapped;
107
+ }
108
+ }
109
+
110
+ function deleteToken(provider, account) {
111
+ validateAccount(provider, account);
112
+ try {
113
+ const entry = new Entry(serviceName(provider), account);
114
+ entry.deletePassword();
115
+ indexRemove(provider, account);
116
+ return { provider, account, deleted: true };
117
+ } catch (e) {
118
+ // deletePassword throws if the entry does not exist; we treat that
119
+ // as a successful no-op (idempotent delete).
120
+ const msg = (e && e.message) || String(e);
121
+ if (/no matching entry|not found|No such file/i.test(msg)) {
122
+ indexRemove(provider, account);
123
+ return { provider, account, deleted: false };
124
+ }
125
+ const wrapped = new Error('Keyring delete failed: ' + msg);
126
+ wrapped.code = 'EKEYRING';
127
+ wrapped.cause = e;
128
+ throw wrapped;
129
+ }
130
+ }
131
+
132
+ // ---- Non-secret account index ------------------------------------------
133
+ // The keychain is the source of truth for credentials; this index is a
134
+ // hint so the UI can list "signed in accounts" without round-tripping
135
+ // the keychain. Out of sync is recoverable: getToken/deleteToken still
136
+ // work, and the index can be rebuilt by walking the keychain (later
137
+ // commit, when @napi-rs/keyring exposes a find-by-service API).
138
+
139
+ function readIndex() {
140
+ const app = settings.getApp();
141
+ const raw = app.authAccounts;
142
+ const out = {};
143
+ for (const p of SUPPORTED_PROVIDERS) {
144
+ out[p] = Array.isArray(raw && raw[p]) ? raw[p].slice() : [];
145
+ }
146
+ return out;
147
+ }
148
+
149
+ function writeIndex(idx) {
150
+ settings.setApp({ authAccounts: idx });
151
+ }
152
+
153
+ function indexAdd(provider, account) {
154
+ const idx = readIndex();
155
+ if (!idx[provider].includes(account)) idx[provider].push(account);
156
+ idx[provider].sort();
157
+ writeIndex(idx);
158
+ }
159
+
160
+ function indexRemove(provider, account) {
161
+ const idx = readIndex();
162
+ idx[provider] = idx[provider].filter(a => a !== account);
163
+ writeIndex(idx);
164
+ }
165
+
166
+ function listAccounts() {
167
+ return readIndex();
168
+ }
169
+
170
+ // ---- Model integration -------------------------------------------------
171
+
172
+ // Map an AI client provider (the value stored on the model record's
173
+ // `provider` field) to the auth provider that the keychain is keyed
174
+ // under. The model record keeps the AI client provider verbatim per
175
+ // decision §10; the mapping lives here, in the auth module, because
176
+ // that is the subsystem that owns the keyring namespace.
177
+ //
178
+ // Today's pairs are 1:1 except for the OpenAI-compatible family, which
179
+ // uses the openai keyring namespace so any signed-in OpenAI account
180
+ // can serve an openai-compatible model. New AI clients that should
181
+ // re-use an existing auth flow register here.
182
+ const AI_TO_AUTH_PROVIDER = Object.freeze({
183
+ 'openai-compatible': 'openai',
184
+ 'anthropic': 'anthropic',
185
+ 'gemini': 'google',
186
+ 'ollama': 'ollama', // no keychain; rejected upstream as a non-OAuth model
187
+ 'github-copilot': 'github-copilot',
188
+ // Azure, Mistral, Groq, DeepSeek and the other OpenAI-shaped
189
+ // providers take API keys only; they map to their own keyring
190
+ // namespaces so a key stored under one provider is never treated
191
+ // as a credential for another (the settings UI never offers an
192
+ // OAuth option for them anyway).
193
+ 'azure': 'azure',
194
+ 'mistral': 'mistral',
195
+ 'groq': 'groq',
196
+ 'deepseek': 'deepseek',
197
+ // OpenRouter has its own keyring namespace ('openrouter'). The
198
+ // PKCE sign-in flow (src/oauth-openrouter.js) stores the issued
199
+ // API key under that namespace; the apikey path uses
200
+ // model.apiKey directly and does not touch the keychain. Two
201
+ // signed-in OpenRouter accounts therefore live as separate rows
202
+ // in the OAuth account picker; an OpenAI key in the 'openai'
203
+ // namespace is NOT a valid OpenRouter credential — the two
204
+ // services have different billing.
205
+ 'openrouter': 'openrouter'
206
+ });
207
+
208
+ function authProviderFor(model) {
209
+ if (!model || !model.provider) return null;
210
+ if (Object.prototype.hasOwnProperty.call(AI_TO_AUTH_PROVIDER, model.provider)) {
211
+ return AI_TO_AUTH_PROVIDER[model.provider];
212
+ }
213
+ // Unknown AI provider: best-effort fall through. The AI client will
214
+ // surface this as EUNKNOWN_PROVIDER before we ever read a token.
215
+ return model.provider;
216
+ }
217
+
218
+ function resolveAccount(model) {
219
+ if (!model || model.auth !== 'oauth') return null;
220
+ if (model.oauthAccount) return model.oauthAccount;
221
+ // Fallback: if the model has no explicit account but the user has a
222
+ // single account on the auth provider, use it. Multi-account users
223
+ // must set oauthAccount explicitly.
224
+ const idx = readIndex();
225
+ const authProv = authProviderFor(model);
226
+ const list = idx[authProv] || [];
227
+ if (list.length === 1) return list[0];
228
+ return null;
229
+ }
230
+
231
+ function tokenForModel(model) {
232
+ const account = resolveAccount(model);
233
+ if (!account) return null;
234
+ const authProv = authProviderFor(model);
235
+ if (!authProv) return null;
236
+ return getToken(authProv, account);
237
+ }
238
+
239
+ // ---- Pending OAuth state ----------------------------------------------
240
+ // Used by the loopback callback to remember which (state, code_verifier,
241
+ // redirect_uri, scopes) tuple the client started, so the callback can
242
+ // finish the exchange. The state is opaque, generated by the client and
243
+ // echoed by the provider. Persisted in the app store (not the keychain —
244
+ // these are not secrets per se, but the code_verifier is a PKCE secret
245
+ // and we treat it as one).
246
+ //
247
+ // In-memory would be enough for a single-process server, but the
248
+ // callback is a separate HTTP request and the rest of the auth commit's
249
+ // code can land across processes (CLI + server). SQLite is the right
250
+ // home; we use the same app_kv table with a 'auth_pending' key.
251
+
252
+ function readPending() {
253
+ const app = settings.getApp();
254
+ return Array.isArray(app.authPending) ? app.authPending : [];
255
+ }
256
+
257
+ function writePending(list) {
258
+ settings.setApp({ authPending: list });
259
+ }
260
+
261
+ function recordPending(provider, rec) {
262
+ if (!SUPPORTED_PROVIDERS.includes(provider)) {
263
+ const e = new Error('Unknown provider: ' + provider);
264
+ e.code = 'EPROVIDER';
265
+ throw e;
266
+ }
267
+ if (!rec || !rec.state) {
268
+ const e = new Error('state is required');
269
+ e.code = 'EBADINPUT';
270
+ throw e;
271
+ }
272
+ const list = readPending().filter(p => !(p.provider === provider && p.state === rec.state));
273
+ list.push(Object.assign({ provider, createdAt: new Date().toISOString() }, rec));
274
+ writePending(list);
275
+ }
276
+
277
+ function consumePending(provider, state) {
278
+ const list = readPending();
279
+ const idx = list.findIndex(p => p.provider === provider && p.state === state);
280
+ if (idx === -1) return null;
281
+ const [rec] = list.splice(idx, 1);
282
+ writePending(list);
283
+ return rec;
284
+ }
285
+
286
+ function clearPending(provider, state) {
287
+ writePending(readPending().filter(p => !(p.provider === provider && p.state === state)));
288
+ }
289
+
290
+ // A pending record is only removed on success (consumePending) or explicit
291
+ // cancel (clearPending). If the user closes the OAuth tab, the record leaks
292
+ // forever and the app store grows unbounded. Drop anything older than
293
+ // maxAgeMs (default 1h — far longer than any real OAuth round-trip). Returns
294
+ // the number of records pruned. Records with a missing/unparseable createdAt
295
+ // are treated as stale and pruned.
296
+ const PENDING_MAX_AGE_MS = 60 * 60 * 1000;
297
+
298
+ function prunePending(maxAgeMs = PENDING_MAX_AGE_MS) {
299
+ const now = Date.now();
300
+ const list = readPending();
301
+ const kept = list.filter((p) => {
302
+ const t = p && p.createdAt ? Date.parse(p.createdAt) : NaN;
303
+ return Number.isFinite(t) && (now - t) < maxAgeMs;
304
+ });
305
+ if (kept.length !== list.length) writePending(kept);
306
+ return list.length - kept.length;
307
+ }
308
+
309
+ module.exports = {
310
+ // introspection
311
+ SUPPORTED_PROVIDERS,
312
+ SERVICE_PREFIX,
313
+ // token CRUD
314
+ setToken,
315
+ getToken,
316
+ deleteToken,
317
+ // index
318
+ listAccounts,
319
+ // model helpers (used by src/ai.js)
320
+ resolveAccount,
321
+ tokenForModel,
322
+ // pending OAuth state
323
+ recordPending,
324
+ consumePending,
325
+ clearPending,
326
+ prunePending,
327
+ // provider exchange + refresher registration (used by per-provider OAuth commits)
328
+ registerExchange,
329
+ getExchange,
330
+ registerRefresher,
331
+ getRefresher,
332
+ // model integration
333
+ authProviderFor,
334
+ AI_TO_AUTH_PROVIDER
335
+ };
336
+
337
+ // ---- Provider exchange registration -----------------------------------
338
+ // Each per-provider OAuth commit calls registerExchange('openai', async fn)
339
+ // where `fn` takes the pending record + the authorization code and
340
+ // returns { accessToken, refreshToken?, expiresAt?, account } or
341
+ // { error: '...' }. The callback in src/index.js calls getExchange(provider)
342
+ // to find the right fn. If none is registered, the callback returns 501
343
+ // with a clear message — that is the expected state in this commit.
344
+
345
+ const _exchanges = Object.create(null);
346
+ const _refreshers = Object.create(null);
347
+
348
+ function registerExchange(provider, fn) {
349
+ if (!SUPPORTED_PROVIDERS.includes(provider)) {
350
+ const e = new Error('Unknown provider: ' + provider);
351
+ e.code = 'EPROVIDER';
352
+ throw e;
353
+ }
354
+ if (typeof fn !== 'function') {
355
+ const e = new Error('Exchange must be a function');
356
+ e.code = 'EBADINPUT';
357
+ throw e;
358
+ }
359
+ _exchanges[provider] = fn;
360
+ }
361
+
362
+ function getExchange(provider) {
363
+ return _exchanges[provider] || null;
364
+ }
365
+
366
+ // registerRefresher / getRefresher — same shape as the exchange
367
+ // registry, but for proactive refresh. A refresher takes
368
+ // ({ provider, account, refreshToken, scope, baseUrl }) and returns
369
+ // the same shape as a fresh login:
370
+ // { accessToken, refreshToken?, expiresAt?, scope?, account? }
371
+ // The AI client (src/ai.js) calls this just before an OAuth request
372
+ // if the stored access_token expires within the next minute, so a
373
+ // long chat doesn't hit a 401 mid-stream. Persisting the new blob
374
+ // is the refresh path's responsibility; see oauth-anthropic.js.
375
+ function registerRefresher(provider, fn) {
376
+ if (!SUPPORTED_PROVIDERS.includes(provider)) {
377
+ const e = new Error('Unknown provider: ' + provider);
378
+ e.code = 'EPROVIDER';
379
+ throw e;
380
+ }
381
+ if (typeof fn !== 'function') {
382
+ const e = new Error('Refresher must be a function');
383
+ e.code = 'EBADINPUT';
384
+ throw e;
385
+ }
386
+ _refreshers[provider] = fn;
387
+ }
388
+
389
+ function getRefresher(provider) {
390
+ return _refreshers[provider] || null;
391
+ }