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.
- package/LICENSE +21 -0
- package/README.md +140 -0
- package/bin/mouaif.js +281 -0
- package/frontend/dist/assets/AgentFilePicker-CcKLJorU.js +1 -0
- package/frontend/dist/assets/CliModal-Hs5phmNZ.js +7 -0
- package/frontend/dist/assets/DictationPage-BI23lp42.js +2 -0
- package/frontend/dist/assets/FileEditor-DDl31c6d.js +2 -0
- package/frontend/dist/assets/GitModal-3EC_gpJ5.js +2 -0
- package/frontend/dist/assets/Inspector-Ba3R1w04.js +73 -0
- package/frontend/dist/assets/SettingsAbout-bvZGDEDw.js +1 -0
- package/frontend/dist/assets/SettingsActions-Dk6WX9jv.js +1 -0
- package/frontend/dist/assets/SettingsAgents-BNV0MgDB.js +1 -0
- package/frontend/dist/assets/SettingsDefaults-DbMmQbzc.js +1 -0
- package/frontend/dist/assets/SettingsHiddenContent-BZ2sloH1.js +1 -0
- package/frontend/dist/assets/SettingsMcp-DOrfbQd1.js +1 -0
- package/frontend/dist/assets/SettingsMcpEdit-BGMQ2CWC.js +3 -0
- package/frontend/dist/assets/SettingsMcpRegistry-BywXee_A.js +1 -0
- package/frontend/dist/assets/SettingsNotifications-B0LEs11a.js +1 -0
- package/frontend/dist/assets/SettingsPricing-BAg33iVF.js +1 -0
- package/frontend/dist/assets/SettingsProject-DNrKhCcZ.js +14 -0
- package/frontend/dist/assets/SettingsProjects-IqkBfDcm.js +1 -0
- package/frontend/dist/assets/SettingsPrompts-BgeiASuk.js +1 -0
- package/frontend/dist/assets/SettingsProviders-k0xJN0IK.js +1 -0
- package/frontend/dist/assets/SettingsTags-B5kjFdQi.js +1 -0
- package/frontend/dist/assets/agentNavigation-BiiCpFz5.js +1 -0
- package/frontend/dist/assets/codemirror-Bp6CUUFk.js +30 -0
- package/frontend/dist/assets/index-BGvI4n0T.js +61 -0
- package/frontend/dist/assets/index-Bgg1gnDf.css +1 -0
- package/frontend/dist/assets/index-C1sQFIC-.css +1 -0
- package/frontend/dist/assets/index-CANPYzQg.css +1 -0
- package/frontend/dist/assets/index-Crn1LdzK.css +1 -0
- package/frontend/dist/assets/index-FbCWDPiB.css +1 -0
- package/frontend/dist/assets/projectQS-D1cSZ7Gr.js +1 -0
- package/frontend/dist/assets/virtual-list-6H9b4K51.js +1 -0
- package/frontend/dist/icons/favicon-32.png +0 -0
- package/frontend/dist/icons/icon-180-apple.png +0 -0
- package/frontend/dist/icons/icon-192.png +0 -0
- package/frontend/dist/icons/icon-512.png +0 -0
- package/frontend/dist/icons/icon-maskable-512.png +0 -0
- package/frontend/dist/index.html +83 -0
- package/frontend/dist/manifest.webmanifest +33 -0
- package/frontend/dist/sw.js +482 -0
- package/package.json +98 -0
- package/scripts/patch-zimmerframe.js +58 -0
- package/src/access-auth.js +515 -0
- package/src/agentFeatures.js +294 -0
- package/src/agentFiles.js +164 -0
- package/src/agentSkills.js +147 -0
- package/src/agents.js +230 -0
- package/src/ai-chat.js +21 -0
- package/src/ai-endpoints.js +1880 -0
- package/src/ai-stream.js +2048 -0
- package/src/ai.js +68 -0
- package/src/auth.js +391 -0
- package/src/chatdb.js +816 -0
- package/src/chats.js +275 -0
- package/src/custom-actions.js +65 -0
- package/src/files.js +431 -0
- package/src/hideFileContent.js +327 -0
- package/src/http-server.js +535 -0
- package/src/index.js +15 -0
- package/src/inspector.js +731 -0
- package/src/inspectorProfiles.js +503 -0
- package/src/live-chat.js +107 -0
- package/src/mcp.js +1517 -0
- package/src/messages.js +238 -0
- package/src/modelList.js +137 -0
- package/src/notifications.js +52 -0
- package/src/oauth-anthropic.js +280 -0
- package/src/oauth-github-copilot.js +417 -0
- package/src/oauth-mcp.js +216 -0
- package/src/oauth-openrouter.js +285 -0
- package/src/package-version.js +20 -0
- package/src/projects.js +285 -0
- package/src/promptProfiles.js +256 -0
- package/src/prompts.js +384 -0
- package/src/providerShapes.js +44 -0
- package/src/providers/base.js +41 -0
- package/src/providers/index.js +25 -0
- package/src/push.js +315 -0
- package/src/qr.js +192 -0
- package/src/restart.js +47 -0
- package/src/server-handlers-access.js +306 -0
- package/src/server-handlers-actions.js +100 -0
- package/src/server-handlers-ai.js +248 -0
- package/src/server-handlers-auth.js +273 -0
- package/src/server-handlers-chats.js +1436 -0
- package/src/server-handlers-git.js +467 -0
- package/src/server-handlers-mcp-oauth.js +56 -0
- package/src/server-handlers-misc.js +783 -0
- package/src/server-handlers-projects.js +289 -0
- package/src/server-handlers-prompts.js +259 -0
- package/src/server-handlers-push.js +102 -0
- package/src/server-handlers-settings.js +406 -0
- package/src/server-handlers-tools.js +654 -0
- package/src/server-handlers-transcribe.js +399 -0
- package/src/server-shared.js +780 -0
- package/src/server-web-static.js +191 -0
- package/src/settings.js +898 -0
- package/src/statusBar.js +541 -0
- package/src/tags.js +414 -0
- package/src/toolFeedback.js +225 -0
- package/src/tools/ask.js +154 -0
- package/src/tools/authorization.js +932 -0
- package/src/tools/files.js +1150 -0
- package/src/tools/progress.js +71 -0
- package/src/tools/restart.js +32 -0
- package/src/tools/searchEngine.js +957 -0
- package/src/tools/shell.js +341 -0
- package/src/tools/subagent.js +47 -0
- package/src/tools/task.js +234 -0
- package/src/tools/webpreview.js +448 -0
- package/src/trace.js +103 -0
- package/src/transcribe.js +683 -0
- package/src/usage.js +389 -0
- 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
|
+
};
|
package/src/live-chat.js
ADDED
|
@@ -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
|
+
};
|