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
@@ -0,0 +1,503 @@
1
+ 'use strict';
2
+
3
+ // Inspector — Chrome *user profiles* and the debug endpoint each one
4
+ // points at.
5
+ //
6
+ // The Inspector has always had exactly one debug endpoint (`inspector.
7
+ // getDebuggerUrl()`, default `http://127.0.0.1:9222`). That is fine
8
+ // until the user has more than one Chrome profile: Chrome only
9
+ // exposes `--remote-debugging-port` for the instance that was started
10
+ // with it, so "my work profile is on 9223, my personal one on 9224"
11
+ // means the Inspector needs to know *which* profile the user means —
12
+ // not just a URL the user has to remember and retype.
13
+ //
14
+ // This module supplies that mapping:
15
+ //
16
+ // 1. it discovers the Chrome *user-data-dirs* on this machine,
17
+ // which is where Chrome keeps its profiles;
18
+ // 2. it lists the profile inside each one (`Default`,
19
+ // `Profile 1`, ...) with the human name from Chrome's own
20
+ // `Local State` / `Preferences` and the signed-in account when
21
+ // there is one;
22
+ // 3. it holds a per-profile endpoint override in the app settings
23
+ // store, and can make one profile *active* — which is just
24
+ // "write this profile's endpoint into the existing global
25
+ // debugger URL", so every other Inspector code path (targets,
26
+ // open, close, navigate, the CDP proxy) keeps working unchanged.
27
+ //
28
+ // Deliberate scope limits:
29
+ //
30
+ // * mouaif never launches or stops Chrome. The Inspector attaches to
31
+ // a browser the user started; this module only *describes* the
32
+ // profiles on disk and points the Inspector at one of them.
33
+ // * Discovery is read-only and offline-safe: it reads two JSON files
34
+ // per user-data-dir and never opens a socket, so the list still
35
+ // renders when no Chrome is running.
36
+ // * `id` is the *directory* name (`Default`), not the *GUID* Chrome
37
+ // keeps in `Local State`. The directory name is what a user
38
+ // recognises and what a command line names
39
+ // (`--profile-directory="Profile 1"`); the GUID is opaque and
40
+ // changes when a profile is recreated. `key` (dir + '::' + id) is
41
+ // the storage key, so two user-data-dirs may each have a `Default`
42
+ // without colliding.
43
+
44
+ const fs = require('fs');
45
+ const os = require('os');
46
+ const path = require('path');
47
+
48
+ const settings = require('./settings.js');
49
+ const inspector = require('./inspector.js');
50
+
51
+ // App-settings key. Shape:
52
+ // { activeId: 'Default' | null,
53
+ // endpoints: { '<key>': 'http://127.0.0.1:9223' },
54
+ // dirs: ['/abs/custom/user-data-dir'] }
55
+ // `endpoints` is keyed by `key` (see the header) so a profile keeps its
56
+ // port even when the same directory name exists in two user-data-dirs.
57
+ const APP_KEY_PROFILES = 'inspectorProfiles';
58
+
59
+ // Chrome's two metadata files, relative to a user-data-dir.
60
+ const LOCAL_STATE = 'Local State';
61
+ const PREFERENCES = 'Preferences';
62
+
63
+ // Chrome's profile directories. Everything else in a user-data-dir
64
+ // (`ShaderCache`, `Crashpad`, `Extensions`, ...) is shared state, not a
65
+ // profile. The `Default` profile is always present in a real
66
+ // user-data-dir; `Profile N` appear as the user adds profiles.
67
+ const PROFILE_DIR_RE = /^(Default|Profile \d+)$/;
68
+ const DEFAULT_PROFILE = 'Default';
69
+
70
+ // The user-data-dir scripts/start-chrome-debug.ps1 boots for the
71
+ // Inspector. It is tagged `mouaif` in the list so a machine with only
72
+ // this one profile still shows something familiar rather than
73
+ // "Default" for a directory the user never created.
74
+ const MOUAIF_PROFILE_DIR = path.join(os.tmpdir(), 'mouaif-chrome-debug-profile');
75
+
76
+ // ---- candidate user-data-dirs ------------------------------------------
77
+
78
+ // candidateRoots(opts) — the per-platform default user-data-dirs for the
79
+ // Chromium-family browsers, in preference order. Only the paths matter;
80
+ // a path that does not exist is filtered out later by the scanner, so an
81
+ // Edge-less machine costs nothing.
82
+ //
83
+ // `opts` is injectable so the platforms are unit-testable without
84
+ // std::{darwin,win32} hardware:
85
+ // { platform, home, env } (defaults: process.platform, os.homedir(), process.env)
86
+ function candidateRoots(opts) {
87
+ const o = opts || {};
88
+ const env = o.env || process.env;
89
+ const home = o.home || os.homedir();
90
+ const platform = o.platform || process.platform;
91
+ const out = [];
92
+ const push = (p) => { if (p && !out.includes(p)) out.push(p); };
93
+
94
+ if (platform === 'darwin') {
95
+ push(path.join(home, 'Library', 'Application Support', 'Google', 'Chrome'));
96
+ push(path.join(home, 'Library', 'Application Support', 'Chromium'));
97
+ push(path.join(home, 'Library', 'Application Support', 'BraveSoftware', 'Brave-Browser'));
98
+ push(path.join(home, 'Library', 'Application Support', 'Microsoft Edge'));
99
+ } else if (platform === 'win32') {
100
+ const local = env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
101
+ push(path.join(local, 'Google', 'Chrome', 'User Data'));
102
+ push(path.join(local, 'Chromium', 'User Data'));
103
+ push(path.join(local, 'BraveSoftware', 'Brave-Browser', 'User Data'));
104
+ push(path.join(local, 'Microsoft', 'Edge', 'User Data'));
105
+ } else {
106
+ const config = env.XDG_CONFIG_HOME || path.join(home, '.config');
107
+ push(path.join(config, 'google-chrome'));
108
+ push(path.join(config, 'chromium'));
109
+ push(path.join(config, 'BraveSoftware', 'Brave-Browser'));
110
+ push(path.join(config, 'microsoft-edge'));
111
+ }
112
+ return out;
113
+ }
114
+
115
+ // isDir — fs.statSync wrapped so a missing path is `false`, not a throw.
116
+ function isDir(abs) {
117
+ try { return fs.statSync(abs).isDirectory(); } catch { return false; }
118
+ }
119
+
120
+ // listDirs — directory names inside `abs`, or [] when unreadable.
121
+ function listDirs(abs) {
122
+ try { return fs.readdirSync(abs); } catch { return []; }
123
+ }
124
+
125
+ // readJsonFile — Chrome's metadata files are large and can be caught
126
+ // mid-write; a malformed one must degrade to "no metadata", never throw.
127
+ function readJsonFile(abs) {
128
+ try { return JSON.parse(fs.readFileSync(abs, 'utf8')); } catch { return null; }
129
+ }
130
+
131
+ // ---- naming ------------------------------------------------------------
132
+
133
+ // labelFor(prefsEntry, prefsFile, id) — the name Chrome shows in its own
134
+ // profile picker. `Local State`'s `profile.info_cache[<dir>].name` is the
135
+ // renamed profile ("Work"); `local_profile_name` (older builds) and
136
+ // `Preferences`' `profile.name` are the fallbacks. A profile Chrome has
137
+ // never renamed falls back to the directory name, which is what Chrome
138
+ // itself displays ("Person 1" lives only in Preferences, so the
139
+ // directory name is a better default than an invented one).
140
+ function labelFor(infoEntry, prefsFile, id) {
141
+ const candidates = [
142
+ infoEntry && infoEntry.name,
143
+ infoEntry && infoEntry.local_profile_name,
144
+ prefsFile && prefsFile.profile && prefsFile.profile.name
145
+ ];
146
+ for (const value of candidates) {
147
+ if (typeof value === 'string' && value.trim()) return value.trim();
148
+ }
149
+ return id;
150
+ }
151
+
152
+ // accountFor(infoEntry) — the signed-in account's email, when the
153
+ // profile has one. Optional decoration only: a signed-out profile has no
154
+ // `account_info` and simply reports nothing.
155
+ function accountFor(infoEntry) {
156
+ const list = infoEntry && infoEntry.account_info;
157
+ if (!Array.isArray(list) || !list.length) return '';
158
+ const first = list.find((a) => a && typeof a.email === 'string' && a.email.trim()) || list[0];
159
+ return (first && typeof first.email === 'string') ? first.email.trim() : '';
160
+ }
161
+
162
+ // ---- scanning ----------------------------------------------------------
163
+
164
+ // profileIdSort — `Default` first, then `Profile 1`, `Profile 2`, ...
165
+ // Chrome's own order. Parsed numerically so `Profile 10` follows
166
+ // `Profile 9` instead of sorting between `Profile 1` and `Profile 2`.
167
+ function profileIdSort(a, b) {
168
+ const rank = (id) => {
169
+ if (id === DEFAULT_PROFILE) return -1;
170
+ const m = /^Profile (\d+)$/.exec(id);
171
+ return m ? Number(m[1]) : 0;
172
+ };
173
+ const diff = rank(a) - rank(b);
174
+ return diff !== 0 ? diff : String(a).localeCompare(String(b));
175
+ }
176
+
177
+ // readUserDataDir(abs, kind) — describe one user-data-dir, or `null` when
178
+ // the directory is not one. A real Chrome user-data-dir has either a
179
+ // `Local State` file or a `Default` directory (Chrome creates `Default`
180
+ // eagerly, `Local State` on first write), so requiring one of the two
181
+ // keeps `--user-data-dir=/tmp/whatever` and a plain source checkout out
182
+ // of the list.
183
+ function readUserDataDir(abs, kind) {
184
+ if (!isDir(abs)) return null;
185
+ const localState = readJsonFile(path.join(abs, LOCAL_STATE));
186
+ const hasDefault = isDir(path.join(abs, DEFAULT_PROFILE));
187
+ if (!localState && !hasDefault) return null;
188
+
189
+ const infoCache = (localState && localState.profile && localState.profile.info_cache) || {};
190
+ const ids = new Set();
191
+ for (const id of Object.keys(infoCache)) if (PROFILE_DIR_RE.test(id)) ids.add(id);
192
+ if (hasDefault) ids.add(DEFAULT_PROFILE);
193
+ // The info cache can be stale (a profile directory added by a newer
194
+ // Chrome, or a hand-copied one), so the directories on disk are the
195
+ // floor and the cache only adds names.
196
+ for (const name of listDirs(abs)) {
197
+ if (PROFILE_DIR_RE.test(name) && isDir(path.join(abs, name))) ids.add(name);
198
+ }
199
+
200
+ const profiles = Array.from(ids).sort(profileIdSort).map((id) => {
201
+ const infoEntry = infoCache[id] || {};
202
+ const prefsFile = readJsonFile(path.join(abs, id, PREFERENCES));
203
+ return {
204
+ id,
205
+ key: abs + '::' + id,
206
+ dir: abs,
207
+ label: labelFor(infoEntry, prefsFile, id),
208
+ account: accountFor(infoEntry),
209
+ kind: kind || 'auto',
210
+ builtin: true
211
+ };
212
+ });
213
+
214
+ return { dir: abs, kind: kind || 'auto', profiles };
215
+ }
216
+
217
+ // scanUserDataDirs(opts) — every Chrome user-data-dir we can find, in
218
+ // preference order: the explicit `opts.dir` (one dir, used by tests and
219
+ // by "add a profile folder"), then `opts.dirs`, then the user's extra
220
+ // dirs from settings, then the per-platform candidates. Duplicates are
221
+ // dropped by realpath so a symlinked home does not list the same
222
+ // profile twice.
223
+ //
224
+ // opts: { dir, dirs, extraDirs, app, platform, home, env }
225
+ function scanUserDataDirs(opts) {
226
+ const o = opts || {};
227
+ const app = o.app || settings.getApp() || {};
228
+ const store = app[APP_KEY_PROFILES] || {};
229
+
230
+ const explicit = [];
231
+ if (typeof o.dir === 'string' && o.dir.trim()) explicit.push({ dir: o.dir.trim(), kind: 'custom' });
232
+ if (Array.isArray(o.dirs)) for (const d of o.dirs) if (typeof d === 'string' && d.trim()) explicit.push({ dir: d.trim(), kind: 'custom' });
233
+
234
+ const requested = explicit.slice();
235
+ const extra = Array.isArray(o.extraDirs) ? o.extraDirs : (Array.isArray(store.dirs) ? store.dirs : []);
236
+ for (const d of extra) if (typeof d === 'string' && d.trim()) requested.push({ dir: d.trim(), kind: 'custom' });
237
+ for (const d of candidateRoots(o)) requested.push({ dir: d, kind: 'auto' });
238
+ if (!o.dir && !Array.isArray(o.dirs)) requested.push({ dir: MOUAIF_PROFILE_DIR, kind: 'mouaif' });
239
+
240
+ const seen = new Set();
241
+ const out = [];
242
+ for (const entry of requested) {
243
+ let real = entry.dir;
244
+ try { real = fs.realpathSync(entry.dir); } catch { /* keep as written */ }
245
+ if (seen.has(real)) continue;
246
+ seen.add(real);
247
+ const found = readUserDataDir(entry.dir, entry.kind);
248
+ if (found) out.push(found);
249
+ }
250
+ return out;
251
+ }
252
+
253
+ // ---- endpoints ---------------------------------------------------------
254
+
255
+ // splitEndpoint(url) — the host/port behind a debugger URL, or null for
256
+ // anything that is not an http(s) URL. Used to show the port on the
257
+ // profile row ("127.0.0.1:9223") without re-parsing in the UI.
258
+ function splitEndpoint(url) {
259
+ try {
260
+ const u = new URL(String(url || ''));
261
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
262
+ const port = u.port ? Number(u.port) : (u.protocol === 'https:' ? 443 : 80);
263
+ return { host: u.hostname, port, url: u.origin };
264
+ } catch { return null; }
265
+ }
266
+
267
+ // buildProfileList(descriptors, store) — flatten the scanned dirs into the
268
+ // flat profile list the API returns, merging in the per-profile endpoint
269
+ // override and the active flag. Pure: everything it needs is an argument,
270
+ // which is what the module's tests assert against.
271
+ //
272
+ // Endpoint precedence, per profile:
273
+ // 1. the profile's own saved override (`store.endpoints[key]`);
274
+ // 2. the current global debugger URL — there is only one Inspector
275
+ // attach point today, so "not configured yet" honestly means "this
276
+ // profile would use the endpoint you already have".
277
+ function buildProfileList(descriptors, store, globals) {
278
+ const s = store || {};
279
+ const g = globals || {};
280
+ const endpoints = s.endpoints && typeof s.endpoints === 'object' ? s.endpoints : {};
281
+ const globalUrl = typeof g.globalUrl === 'string' && g.globalUrl ? g.globalUrl : inspector.getDebuggerUrl();
282
+ const activeId = typeof s.activeId === 'string' && s.activeId ? s.activeId : '';
283
+
284
+ const profiles = [];
285
+ for (const desc of descriptors || []) {
286
+ for (const p of (desc && desc.profiles) || []) {
287
+ const saved = typeof endpoints[p.key] === 'string' ? endpoints[p.key].trim() : '';
288
+ const url = saved || globalUrl;
289
+ const endpoint = splitEndpoint(url);
290
+ profiles.push({
291
+ id: p.id,
292
+ key: p.key,
293
+ dir: p.dir,
294
+ label: p.label,
295
+ account: p.account || '',
296
+ kind: p.kind || 'auto',
297
+ builtin: p.builtin !== false,
298
+ url,
299
+ port: endpoint ? endpoint.port : null,
300
+ configured: !!saved,
301
+ active: !!activeId && p.id === activeId
302
+ });
303
+ }
304
+ }
305
+ return {
306
+ activeId,
307
+ globalUrl,
308
+ defaultUrl: inspector.defaultDebuggerUrl(),
309
+ dirs: (descriptors || []).map((d) => d.dir),
310
+ // Which of those dirs the user registered themselves. The UI needs
311
+ // this to decide whether to offer "Remove": a directory the scanner
312
+ // found on its own cannot be un-registered, so offering Remove for it
313
+ // (or labelling it "added") would be a lie. Deriving it from "does
314
+ // this dir have profiles" instead is wrong — a real user-data-dir can
315
+ // legitimately have zero profiles, e.g. a Chrome that was started
316
+ // once and never asked to create one, which then gets mislabelled as
317
+ // user-added and cannot be removed.
318
+ addedDirs: (descriptors || []).filter((d) => d && d.kind === 'custom').map((d) => d.dir),
319
+ profiles
320
+ };
321
+ }
322
+
323
+ // listProfiles(opts) — discovery + endpoints in one call. `opts` is
324
+ // forwarded to scanUserDataDirs; `opts.app` also supplies the store.
325
+ function listProfiles(opts) {
326
+ const o = opts || {};
327
+ const app = o.app || settings.getApp() || {};
328
+ const store = app[APP_KEY_PROFILES] || {};
329
+ const descriptors = scanUserDataDirs(o);
330
+ return buildProfileList(descriptors, store, { globalUrl: inspector.getDebuggerUrl() });
331
+ }
332
+
333
+ // resolveProfile(id, opts) — the flat profile record for a profile id, or
334
+ // null. The *first* match wins, which is the same order the UI lists, so
335
+ // tapping a row and resolving by id agree on which row was tapped.
336
+ function resolveProfile(id, opts) {
337
+ const wanted = String(id == null ? '' : id);
338
+ if (!wanted) return null;
339
+ const list = listProfiles(opts);
340
+ return list.profiles.find((p) => p.id === wanted) || null;
341
+ }
342
+
343
+ // ---- mutations (app settings only) -------------------------------------
344
+
345
+ function readStore() {
346
+ const app = settings.getApp() || {};
347
+ const store = app[APP_KEY_PROFILES];
348
+ return store && typeof store === 'object' && !Array.isArray(store) ? store : {};
349
+ }
350
+
351
+ function writeStore(patch) {
352
+ const next = Object.assign({}, readStore(), patch);
353
+ settings.setApp({ [APP_KEY_PROFILES]: next });
354
+ return next;
355
+ }
356
+
357
+ // setProfileEndpoint(id, url, opts) — remember the endpoint for ONE
358
+ // profile, without making it active. `url` must be an http(s) URL;
359
+ // validated so a stored typo cannot turn into a broken attach later.
360
+ // `opts` is forwarded to the scan (see scanUserDataDirs) so a caller can
361
+ // resolve an id in a specific user-data-dir; the HTTP route passes none,
362
+ // which resolves against every dir the settings know about.
363
+ function setProfileEndpoint(id, url, opts) {
364
+ const profile = resolveProfile(id, opts);
365
+ if (!profile) {
366
+ const err = new Error('unknown profile: ' + String(id));
367
+ err.code = 'EPROFILE_NOT_FOUND';
368
+ throw err;
369
+ }
370
+ const cleaned = String(url == null ? '' : url).trim();
371
+ const endpoint = splitEndpoint(cleaned);
372
+ if (!endpoint) {
373
+ const err = new Error('url must be an http or https URL');
374
+ err.code = 'EBADURL';
375
+ throw err;
376
+ }
377
+ const store = readStore();
378
+ const endpoints = Object.assign({}, store.endpoints || {});
379
+ endpoints[profile.key] = endpoint.url;
380
+ writeStore({ endpoints });
381
+ return { profile, url: endpoint.url };
382
+ }
383
+
384
+ // switchProfile(id, opts) — make a profile the Inspector's attach point.
385
+ // This is intentionally just "write the global debugger URL": every other
386
+ // Inspector code path (targets, open/close, navigate, the CDP proxy)
387
+ // already reads that one value, so no other module needs to learn about
388
+ // profiles. `opts` is forwarded to the scan, as in setProfileEndpoint.
389
+ function switchProfile(id, opts) {
390
+ const profile = resolveProfile(id, opts);
391
+ if (!profile) {
392
+ const err = new Error('unknown profile: ' + String(id));
393
+ err.code = 'EPROFILE_NOT_FOUND';
394
+ throw err;
395
+ }
396
+ const endpoint = splitEndpoint(profile.url);
397
+ if (!endpoint) {
398
+ const err = new Error('profile has no usable endpoint');
399
+ err.code = 'EBADURL';
400
+ throw err;
401
+ }
402
+ inspector.setDebuggerUrl(endpoint.url);
403
+ // The label is stored alongside the id so the setup screen can name the
404
+ // active profile with no discovery pass at all (see activeProfile).
405
+ // Chrome's own display name can drift from what was stored when the
406
+ // user renames a profile, which is why the profiles sheet — which
407
+ // re-scans — remains the authority on names.
408
+ writeStore({ activeId: profile.id, activeLabel: profile.label });
409
+ return { profile, url: endpoint.url };
410
+ }
411
+
412
+ // activeProfile() — `{ id, label }` for the active profile, or null when
413
+ // none is active. Read straight from the store: no filesystem scan, so
414
+ // the Inspector's mount path can call it for free.
415
+ function activeProfile() {
416
+ const store = readStore();
417
+ const id = typeof store.activeId === 'string' ? store.activeId : '';
418
+ if (!id) return null;
419
+ return { id, label: typeof store.activeLabel === 'string' && store.activeLabel ? store.activeLabel : id };
420
+ }
421
+
422
+ // clearActive() — forget the active profile. Called when the user edits
423
+ // the debugger URL by hand: that URL is no longer "some profile's
424
+ // endpoint", and keeping the badge would claim an association that no
425
+ // longer holds.
426
+ function clearActive() {
427
+ writeStore({ activeId: '', activeLabel: '' });
428
+ return { activeId: '' };
429
+ }
430
+
431
+ // addDir(dir) — register an extra user-data-dir the scan does not know
432
+ // about (a portable Chrome, or a profile tree on another volume). The
433
+ // directory must actually look like a Chrome user-data-dir, so a typo or
434
+ // an unrelated folder is rejected at the door instead of silently
435
+ // returning an empty list forever.
436
+ function addDir(dir) {
437
+ const abs = String(dir == null ? '' : dir).trim();
438
+ if (!abs) {
439
+ const err = new Error('dir is required');
440
+ err.code = 'EBADINPUT';
441
+ throw err;
442
+ }
443
+ const found = readUserDataDir(abs, 'custom');
444
+ if (!found) {
445
+ const err = new Error('not a Chrome user-data-dir: ' + abs);
446
+ err.code = 'ENOTPROFILEDIR';
447
+ throw err;
448
+ }
449
+ const store = readStore();
450
+ const dirs = Array.isArray(store.dirs) ? store.dirs.slice() : [];
451
+ if (!dirs.includes(abs)) dirs.push(abs);
452
+ writeStore({ dirs });
453
+ return { dir: abs, profiles: found.profiles.length };
454
+ }
455
+
456
+ // removeDir(dir) — unregister an extra user-data-dir. Only the extras
457
+ // list is touched: a per-platform candidate is discovered, not stored, so
458
+ // it needs no removal.
459
+ function removeDir(dir) {
460
+ const abs = String(dir == null ? '' : dir).trim();
461
+ const store = readStore();
462
+ const dirs = Array.isArray(store.dirs) ? store.dirs : [];
463
+ const next = dirs.filter((d) => d !== abs);
464
+ // Endpoint overrides for profiles that lived in the removed directory
465
+ // are dropped with it. Leaving them behind would let the same
466
+ // directory name in *another* user-data-dir inherit a stale port.
467
+ const endpoints = Object.assign({}, store.endpoints || {});
468
+ let dropped = 0;
469
+ for (const key of Object.keys(endpoints)) {
470
+ if (key.startsWith(abs + '::')) { delete endpoints[key]; dropped++; }
471
+ }
472
+ const patch = { dirs: next, endpoints };
473
+ if (typeof store.activeId === 'string' && store.activeId) {
474
+ // `extraDirs: next` overrides the store's list for this one probe.
475
+ // Without it the removed directory is still in `store.dirs` (the
476
+ // write has not happened yet), so the scan would find the profile it
477
+ // is supposed to be forgetting.
478
+ const stillThere = listProfiles({ extraDirs: next }).profiles.some((p) => p.id === store.activeId);
479
+ if (!stillThere) { patch.activeId = ''; patch.activeLabel = ''; }
480
+ }
481
+ writeStore(patch);
482
+ return { dir: abs, removed: dirs.length !== next.length, endpoints: dropped };
483
+ }
484
+
485
+ module.exports = {
486
+ APP_KEY_PROFILES,
487
+ DEFAULT_PROFILE,
488
+ PROFILE_DIR_RE,
489
+ MOUAIF_PROFILE_DIR,
490
+ candidateRoots,
491
+ readUserDataDir,
492
+ scanUserDataDirs,
493
+ buildProfileList,
494
+ listProfiles,
495
+ resolveProfile,
496
+ splitEndpoint,
497
+ setProfileEndpoint,
498
+ switchProfile,
499
+ activeProfile,
500
+ clearActive,
501
+ addDir,
502
+ removeDir
503
+ };
@@ -0,0 +1,107 @@
1
+ 'use strict';
2
+
3
+ // Per-chat live-replay registry.
4
+ //
5
+ // While a chat has an in-flight streaming run, the transient tool events
6
+ // (shell/subagent/progress/auth prompts) are buffered here and fanned out
7
+ // to followers. Persisted transcript rows use message `seq`; this module
8
+ // uses a separate `liveSeq` cursor for transient events so a reconnect can
9
+ // replay only the live chunks the client has not consumed yet.
10
+
11
+ const runs = new Map();
12
+
13
+ function sseFrame(name, data) {
14
+ return 'event: ' + name + '\ndata: ' + JSON.stringify(data) + '\n\n';
15
+ }
16
+
17
+ function ensureLiveChat(runKey) {
18
+ let r = runs.get(runKey);
19
+ if (!r) {
20
+ r = { nextLiveSeq: 0, buffer: [], subscribers: new Set() };
21
+ runs.set(runKey, r);
22
+ }
23
+ return r;
24
+ }
25
+
26
+ function pushLive(runKey, name, data) {
27
+ const r = runs.get(runKey);
28
+ if (!r) return;
29
+ const ev = { liveSeq: r.nextLiveSeq++, name, data };
30
+ r.buffer.push(ev);
31
+ const frame = sseFrame(name, Object.assign({ liveSeq: ev.liveSeq }, data || {}));
32
+ for (const sub of r.subscribers) {
33
+ try { sub.write(frame); } catch { /* socket closed */ }
34
+ }
35
+ }
36
+
37
+ function eventToolId(e) {
38
+ const data = e && e.data;
39
+ if (!data) return '';
40
+ if (e.name === 'shell_output') return String(data.id == null ? '' : data.id);
41
+ if (e.name === 'subagent_event') return String(data.parentCallId == null ? '' : data.parentCallId);
42
+ if (e.name === 'authorization_required' || e.name === 'ask_user_required') return String(data.callId == null ? '' : data.callId);
43
+ return '';
44
+ }
45
+
46
+ function pruneLive(runKey, toolId) {
47
+ const r = runs.get(runKey);
48
+ if (!r) return;
49
+ const id = String(toolId == null ? '' : toolId);
50
+ if (!id || !r.buffer.length) return;
51
+ const hadOverlay = r.buffer.some((e) =>
52
+ (e.name === 'authorization_required' || e.name === 'ask_user_required') && eventToolId(e) === id
53
+ );
54
+ r.buffer = r.buffer.filter((e) => eventToolId(e) !== id);
55
+ if (hadOverlay) {
56
+ const ev = { liveSeq: r.nextLiveSeq++, name: 'authorization_resolved', data: { callId: id } };
57
+ r.buffer.push(ev);
58
+ const frame = sseFrame(ev.name, Object.assign({ liveSeq: ev.liveSeq }, ev.data));
59
+ for (const sub of r.subscribers) {
60
+ try { sub.write(frame); } catch { /* socket closed */ }
61
+ }
62
+ }
63
+ }
64
+
65
+ function addSubscriber(runKey, req, res, options) {
66
+ const r = ensureLiveChat(runKey);
67
+ const fromLiveSeq = options && Number.isFinite(options.fromLiveSeq) ? options.fromLiveSeq : 0;
68
+ res.writeHead(200, {
69
+ 'Content-Type': 'text/event-stream',
70
+ 'Cache-Control': 'no-cache',
71
+ Connection: 'keep-alive'
72
+ });
73
+ const replay = r.buffer.filter((e) => e.liveSeq >= fromLiveSeq);
74
+ res.write(sseFrame('live_subscribed', {
75
+ count: replay.length,
76
+ nextLiveSeq: r.nextLiveSeq,
77
+ fromLiveSeq
78
+ }));
79
+ for (const ev of replay) {
80
+ try { res.write(sseFrame(ev.name, Object.assign({ liveSeq: ev.liveSeq }, ev.data || {}))); } catch { break; }
81
+ }
82
+ r.subscribers.add(res);
83
+ const onClose = () => { r.subscribers.delete(res); };
84
+ if (req && typeof req.on === 'function') req.on('close', onClose);
85
+ res.on('close', onClose);
86
+ return res;
87
+ }
88
+
89
+ function finishLiveChat(runKey) {
90
+ const r = runs.get(runKey);
91
+ if (!r) return;
92
+ runs.delete(runKey);
93
+ const frame = sseFrame('run_end', { nextLiveSeq: r.nextLiveSeq });
94
+ for (const sub of r.subscribers) {
95
+ try { sub.write(frame); } catch { /* socket closed */ }
96
+ try { sub.end(); } catch { /* already closed */ }
97
+ }
98
+ r.subscribers.clear();
99
+ }
100
+
101
+ module.exports = {
102
+ ensureLiveChat,
103
+ pushLive,
104
+ pruneLive,
105
+ addSubscriber,
106
+ finishLiveChat
107
+ };