@mehmoodqureshi/chrome-mcp 0.5.2 → 0.6.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.
@@ -0,0 +1,162 @@
1
+ "use strict";
2
+ /**
3
+ * src/bridge/workspace.ts — the *active* task workspace (mutable, process-global)
4
+ * plus the memory writers that persist a task's artifacts into it.
5
+ *
6
+ * Modeled on the executor singleton in `executor/manager.ts`: the boot path
7
+ * installs an initial workspace, and the runtime tools (`profile_use`/`task_new`)
8
+ * swap it via {@link switchWorkspace}. Tool handlers reach the current workspace
9
+ * through {@link getActiveWorkspace} so downloads, extracted results, screenshots,
10
+ * and the action log all land under `profiles/<profile>/tasks/<task>/`.
11
+ *
12
+ * Every writer here is BEST-EFFORT: a failed persist (full disk, races a task
13
+ * switch) is logged to stderr and swallowed so it can never break a tool call.
14
+ */
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.setActiveWorkspace = setActiveWorkspace;
17
+ exports.getActiveWorkspace = getActiveWorkspace;
18
+ exports.peekActiveWorkspace = peekActiveWorkspace;
19
+ exports.resetActiveWorkspaceForTesting = resetActiveWorkspaceForTesting;
20
+ exports.switchWorkspace = switchWorkspace;
21
+ exports.saveResult = saveResult;
22
+ exports.saveScreenshot = saveScreenshot;
23
+ exports.captureDownload = captureDownload;
24
+ exports.appendHistory = appendHistory;
25
+ const node_fs_1 = require("node:fs");
26
+ const node_path_1 = require("node:path");
27
+ const config_1 = require("../config");
28
+ const download_1 = require("../../shared/download");
29
+ const datadir_1 = require("./datadir");
30
+ /** stderr only (never stdout in stdio mode); local to avoid an import cycle with mcp/server. */
31
+ function logErr(message) {
32
+ process.stderr.write(`[chrome-mcp] ${message}\n`);
33
+ }
34
+ let active = null;
35
+ /** Monotonic counter so saved result/screenshot filenames sort in capture order. */
36
+ let seq = 0;
37
+ /** Install the initial workspace at boot (from cli.ts). */
38
+ function setActiveWorkspace(w) {
39
+ active = w;
40
+ }
41
+ /** The current workspace. Throws if the boot path never installed one. */
42
+ function getActiveWorkspace() {
43
+ if (!active)
44
+ throw new Error('no active workspace (server not fully booted)');
45
+ return active;
46
+ }
47
+ /** The current workspace, or null before boot completes (for non-throwing callers). */
48
+ function peekActiveWorkspace() {
49
+ return active;
50
+ }
51
+ /** Reset for tests. */
52
+ function resetActiveWorkspaceForTesting() {
53
+ active = null;
54
+ seq = 0;
55
+ }
56
+ /**
57
+ * Switch the active profile and/or task, creating the workspace dirs if new, and
58
+ * make it current. Names are sanitized to a single safe path segment (reusing
59
+ * {@link sanitizeName}) so a tool argument can never escape the data dir. Returns
60
+ * the resolved workspace.
61
+ */
62
+ function switchWorkspace(opts) {
63
+ const cur = getActiveWorkspace();
64
+ const profile = opts.profile === undefined ? cur.profile : (0, config_1.sanitizeName)(opts.profile, 'profile');
65
+ // A profile switch resets the task to "default" unless the caller names one.
66
+ const task = opts.task !== undefined
67
+ ? (0, config_1.sanitizeName)(opts.task, 'task')
68
+ : opts.profile !== undefined
69
+ ? 'default'
70
+ : cur.task;
71
+ const w = (0, datadir_1.ensureWorkspace)(cur.dataDir, profile, task, {
72
+ ...opts.meta,
73
+ createdAt: new Date().toISOString(),
74
+ });
75
+ active = w;
76
+ return w;
77
+ }
78
+ // ---------------------------------------------------------------------------
79
+ // Memory writers — persist a task's artifacts under its workspace.
80
+ // ---------------------------------------------------------------------------
81
+ /** A short safe stem like `0007-get_text`; the seq keeps capture order + uniqueness. */
82
+ function stem(tool) {
83
+ seq += 1;
84
+ const n = String(seq).padStart(4, '0');
85
+ return `${n}-${tool.replace(/[^A-Za-z0-9._-]/g, '_')}`;
86
+ }
87
+ /**
88
+ * Save an extracted-content result (get_text / read_as_markdown / extract_links)
89
+ * into the active task's `results/`. `ext` is the file extension without a dot.
90
+ * Returns the path written, or null if persisted nowhere (no workspace / error).
91
+ */
92
+ function saveResult(tool, ext, body) {
93
+ const w = peekActiveWorkspace();
94
+ if (!w)
95
+ return null;
96
+ try {
97
+ const path = (0, node_path_1.join)(w.resultsDir, `${stem(tool)}.${ext}`);
98
+ (0, node_fs_1.writeFileSync)(path, body, { mode: 0o600 });
99
+ return path;
100
+ }
101
+ catch (err) {
102
+ logErr(`results save failed: ${err instanceof Error ? err.message : String(err)}`);
103
+ return null;
104
+ }
105
+ }
106
+ /** Save a screenshot PNG (base64) into the active task's `screenshots/`. */
107
+ function saveScreenshot(dataBase64) {
108
+ const w = peekActiveWorkspace();
109
+ if (!w)
110
+ return null;
111
+ try {
112
+ const path = (0, node_path_1.join)(w.screenshotsDir, `${stem('screenshot')}.png`);
113
+ (0, node_fs_1.writeFileSync)(path, Buffer.from(dataBase64, 'base64'), { mode: 0o600 });
114
+ return path;
115
+ }
116
+ catch (err) {
117
+ logErr(`screenshot save failed: ${err instanceof Error ? err.message : String(err)}`);
118
+ return null;
119
+ }
120
+ }
121
+ /**
122
+ * Move a file Chrome saved to the user's Downloads dir into the active task's
123
+ * `downloads/`. The name is re-hardened via {@link sanitizeDownloadName} and the
124
+ * size is capped at {@link MAX_DOWNLOAD_BYTES} (over-cap files are left where they
125
+ * are and rejected). Falls back to copy+unlink when source and destination are on
126
+ * different filesystems (rename's `EXDEV`). Returns the final path and byte size.
127
+ */
128
+ function captureDownload(sourcePath, suggestedName) {
129
+ const w = getActiveWorkspace();
130
+ const bytes = (0, node_fs_1.statSync)(sourcePath).size;
131
+ if (bytes > download_1.MAX_DOWNLOAD_BYTES) {
132
+ throw new Error(`download exceeds the ${download_1.MAX_DOWNLOAD_BYTES}-byte cap (left at ${sourcePath})`);
133
+ }
134
+ const name = (0, download_1.sanitizeDownloadName)(suggestedName ?? (0, node_path_1.basename)(sourcePath));
135
+ const dest = (0, node_path_1.join)(w.downloadDir, name);
136
+ try {
137
+ (0, node_fs_1.renameSync)(sourcePath, dest);
138
+ }
139
+ catch {
140
+ (0, node_fs_1.copyFileSync)(sourcePath, dest);
141
+ try {
142
+ (0, node_fs_1.unlinkSync)(sourcePath);
143
+ }
144
+ catch {
145
+ /* best effort: a left-behind source is harmless */
146
+ }
147
+ }
148
+ return { path: dest, bytes: (0, node_fs_1.statSync)(dest).size };
149
+ }
150
+ /** Append one action record to the active task's `history.jsonl`. */
151
+ function appendHistory(entry) {
152
+ const w = peekActiveWorkspace();
153
+ if (!w)
154
+ return;
155
+ try {
156
+ (0, node_fs_1.appendFileSync)(w.historyPath, `${JSON.stringify(entry)}\n`, { mode: 0o600 });
157
+ }
158
+ catch (err) {
159
+ logErr(`history append failed: ${err instanceof Error ? err.message : String(err)}`);
160
+ }
161
+ }
162
+ //# sourceMappingURL=workspace.js.map
package/dist/src/cli.js CHANGED
@@ -13,10 +13,12 @@ Object.defineProperty(exports, "__esModule", { value: true });
13
13
  const node_fs_1 = require("node:fs");
14
14
  const node_path_1 = require("node:path");
15
15
  const config_1 = require("./config");
16
+ const tasks_1 = require("./bridge/tasks");
16
17
  const manager_1 = require("./executor/manager");
17
18
  const select_1 = require("./executor/select");
18
19
  const server_1 = require("./bridge/server");
19
20
  const datadir_1 = require("./bridge/datadir");
21
+ const workspace_1 = require("./bridge/workspace");
20
22
  const auth_1 = require("./bridge/auth");
21
23
  const server_2 = require("./mcp/server");
22
24
  /** Hard deadline for clean shutdown before we force-exit (a stuck socket must not hang us). */
@@ -49,7 +51,96 @@ function version() {
49
51
  return '0.0.0';
50
52
  }
51
53
  }
54
+ /** Render a byte count as a short human string (1.2 MB, 904 KB, …). */
55
+ function humanBytes(n) {
56
+ if (n < 1024)
57
+ return `${n} B`;
58
+ const units = ['KB', 'MB', 'GB', 'TB'];
59
+ let v = n / 1024;
60
+ let i = 0;
61
+ while (v >= 1024 && i < units.length - 1) {
62
+ v /= 1024;
63
+ i++;
64
+ }
65
+ return `${v.toFixed(1)} ${units[i]}`;
66
+ }
67
+ /** Pull `--flag value` / `--flag` out of an arg list, leaving positionals. */
68
+ function flag(args, name) {
69
+ const i = args.indexOf(name);
70
+ if (i === -1)
71
+ return undefined;
72
+ return args[i + 1];
73
+ }
74
+ function hasFlag(args, name) {
75
+ return args.includes(name);
76
+ }
77
+ const TASKS_HELP = `chrome-mcp tasks — inspect and prune per-profile task workspaces.
78
+
79
+ Usage:
80
+ chrome-mcp tasks list [--json] [--data-dir <path>]
81
+ chrome-mcp tasks gc [--older-than <days>] [--keep <n>] [--profile <name>]
82
+ [--dry-run] [--data-dir <path>]
83
+
84
+ gc removes a task only when it is NOT among the newest --keep AND is older than
85
+ --older-than. At least one of --older-than / --keep is required.
86
+ `;
87
+ /**
88
+ * Handle the `tasks` subcommand family (list / gc). Returns true if argv was a
89
+ * tasks command (so main() should not start the server), false otherwise.
90
+ */
91
+ function runTasksCommand(argv) {
92
+ if (argv[0] !== 'tasks')
93
+ return false;
94
+ const args = argv.slice(1);
95
+ const sub = args[0];
96
+ const dataDirFlag = flag(args, '--data-dir');
97
+ if (dataDirFlag)
98
+ process.env.CHROME_MCP_DATA = dataDirFlag;
99
+ const dataDir = (0, config_1.resolveDataDir)();
100
+ if (sub === 'list') {
101
+ const tasks = (0, tasks_1.listTasks)(dataDir);
102
+ if (hasFlag(args, '--json')) {
103
+ process.stdout.write(`${JSON.stringify(tasks, null, 2)}\n`);
104
+ return true;
105
+ }
106
+ if (tasks.length === 0) {
107
+ process.stdout.write('no tasks found.\n');
108
+ return true;
109
+ }
110
+ process.stdout.write('PROFILE\tTASK\tCREATED\tFILES\tSIZE\n');
111
+ for (const t of tasks) {
112
+ process.stdout.write(`${t.profile}\t${t.task}\t${t.createdAt}\t${t.downloads}\t${humanBytes(t.bytes)}\n`);
113
+ }
114
+ return true;
115
+ }
116
+ if (sub === 'gc') {
117
+ const olderThan = flag(args, '--older-than');
118
+ const keep = flag(args, '--keep');
119
+ if (olderThan === undefined && keep === undefined) {
120
+ process.stderr.write('tasks gc: pass --older-than <days> and/or --keep <n>.\n');
121
+ process.exitCode = 1;
122
+ return true;
123
+ }
124
+ const opts = {
125
+ olderThanDays: olderThan === undefined ? undefined : Number.parseFloat(olderThan),
126
+ keep: keep === undefined ? undefined : Number.parseInt(keep, 10),
127
+ profile: flag(args, '--profile'),
128
+ dryRun: hasFlag(args, '--dry-run'),
129
+ };
130
+ const { removed, freedBytes } = (0, tasks_1.gcTasks)(dataDir, opts, Date.now());
131
+ const prefix = opts.dryRun ? '[dry-run] would remove' : 'removed';
132
+ process.stdout.write(`${prefix} ${removed.length} task(s), ${humanBytes(freedBytes)}\n`);
133
+ for (const t of removed) {
134
+ process.stdout.write(` ${t.profile}/${t.task} (${humanBytes(t.bytes)})\n`);
135
+ }
136
+ return true;
137
+ }
138
+ process.stdout.write(TASKS_HELP);
139
+ return true;
140
+ }
52
141
  async function main() {
142
+ if (runTasksCommand(process.argv.slice(2)))
143
+ return;
53
144
  const cfg = (0, config_1.parseArgs)(process.argv.slice(2));
54
145
  if (cfg.showHelp) {
55
146
  process.stdout.write(config_1.HELP_TEXT);
@@ -93,6 +184,19 @@ async function main() {
93
184
  });
94
185
  return;
95
186
  }
187
+ // Each profile is an identity (its own Chrome user-data dir); each task is a
188
+ // run whose downloads/meta.json live under it. Handshake stays at the data-dir
189
+ // root (auth is machine-scoped, not per-profile). Fold any pre-0.6 flat layout
190
+ // into profiles/default/ first — before ensureWorkspace creates the targets.
191
+ for (const m of (0, datadir_1.migrateLegacyLayout)(dataDir))
192
+ (0, server_2.logErr)(`migrated legacy layout: ${m}`);
193
+ const workspace = (0, datadir_1.ensureWorkspace)(dataDir, cfg.profile, cfg.task, {
194
+ version: version(),
195
+ pid: process.pid,
196
+ createdAt: new Date().toISOString(),
197
+ });
198
+ (0, workspace_1.setActiveWorkspace)(workspace);
199
+ (0, server_2.logErr)(`workspace: profile "${workspace.profile}" task "${workspace.task}" → ${workspace.taskDir}`);
96
200
  (0, manager_1.configureManager)({
97
201
  policy: cfg.policy,
98
202
  select: (0, select_1.createSelector)({
@@ -102,7 +206,8 @@ async function main() {
102
206
  cdp: {
103
207
  mode: cfg.cdpEndpoint ? 'connect' : 'launch',
104
208
  cdpEndpoint: cfg.cdpEndpoint,
105
- userDataDir: dataDir,
209
+ userDataDir: workspace.profileDir,
210
+ downloadDir: workspace.downloadDir,
106
211
  headless: cfg.headless,
107
212
  },
108
213
  }),
@@ -12,8 +12,12 @@ export type BackendPreference = 'extension' | 'cdp';
12
12
  export interface CliConfig {
13
13
  /** Port the bridge binds; 0 = ephemeral (written to the handshake file). */
14
14
  wsPort: number;
15
- /** Directory holding handshake.json and the CDP-fallback profile. */
15
+ /** Directory holding handshake.json and the per-profile workspaces. */
16
16
  dataDir: string;
17
+ /** Browser profile (identity). Selects `profiles/<profile>/` under the data dir. */
18
+ profile: string;
19
+ /** Task (run). Artifacts land in `profiles/<profile>/tasks/<task>/`. */
20
+ task: string;
17
21
  /** Resolved, fully-defaulted security policy. */
18
22
  policy: Policy;
19
23
  /** Whether to fall back to a Playwright-driven Chromium when no extension is paired. */
@@ -34,6 +38,20 @@ export interface CliConfig {
34
38
  }
35
39
  /** Resolve the data dir: `$CHROME_MCP_DATA` or `~/.chrome-mcp`. */
36
40
  export declare function resolveDataDir(): string;
41
+ /**
42
+ * Reduce an arbitrary label to a single safe path segment: no separators, no
43
+ * `..` traversal, no leading dots. Profile/task names become directories under
44
+ * the data dir, so they must never escape it.
45
+ */
46
+ export declare function sanitizeName(name: string, kind: 'profile' | 'task'): string;
47
+ /** Active profile: `$CHROME_MCP_PROFILE` (set by `--profile`) or "default". */
48
+ export declare function resolveProfile(): string;
49
+ /** Active task: `$CHROME_MCP_TASK` (set by `--task`) or "default". */
50
+ export declare function resolveTask(): string;
51
+ /** `profiles/<profile>/` — holds the Chrome user-data dir and this profile's tasks. */
52
+ export declare function resolveProfileDir(dataDir: string, profile: string): string;
53
+ /** `profiles/<profile>/tasks/<task>/` — per-run artifacts (downloads, meta.json). */
54
+ export declare function resolveTaskDir(dataDir: string, profile: string, task: string): string;
37
55
  /**
38
56
  * Parse argv (the slice AFTER `node script`, i.e. `process.argv.slice(2)`) plus
39
57
  * env into a fully-resolved `CliConfig`. Pure except for the optional policy-file
@@ -10,6 +10,11 @@
10
10
  Object.defineProperty(exports, "__esModule", { value: true });
11
11
  exports.HELP_TEXT = void 0;
12
12
  exports.resolveDataDir = resolveDataDir;
13
+ exports.sanitizeName = sanitizeName;
14
+ exports.resolveProfile = resolveProfile;
15
+ exports.resolveTask = resolveTask;
16
+ exports.resolveProfileDir = resolveProfileDir;
17
+ exports.resolveTaskDir = resolveTaskDir;
13
18
  exports.parseArgs = parseArgs;
14
19
  const node_os_1 = require("node:os");
15
20
  const node_path_1 = require("node:path");
@@ -20,6 +25,36 @@ const policy_1 = require("./security/policy");
20
25
  function resolveDataDir() {
21
26
  return process.env.CHROME_MCP_DATA ?? (0, node_path_1.join)((0, node_os_1.homedir)(), '.chrome-mcp');
22
27
  }
28
+ const DEFAULT_PROFILE = 'default';
29
+ const DEFAULT_TASK = 'default';
30
+ /**
31
+ * Reduce an arbitrary label to a single safe path segment: no separators, no
32
+ * `..` traversal, no leading dots. Profile/task names become directories under
33
+ * the data dir, so they must never escape it.
34
+ */
35
+ function sanitizeName(name, kind) {
36
+ const clean = name.trim().replace(/[^A-Za-z0-9._-]/g, '-').replace(/^\.+/, '');
37
+ if (!clean || clean === '.' || clean === '..') {
38
+ throw new Error(`invalid ${kind} name: ${JSON.stringify(name)}`);
39
+ }
40
+ return clean;
41
+ }
42
+ /** Active profile: `$CHROME_MCP_PROFILE` (set by `--profile`) or "default". */
43
+ function resolveProfile() {
44
+ return sanitizeName(process.env.CHROME_MCP_PROFILE ?? DEFAULT_PROFILE, 'profile');
45
+ }
46
+ /** Active task: `$CHROME_MCP_TASK` (set by `--task`) or "default". */
47
+ function resolveTask() {
48
+ return sanitizeName(process.env.CHROME_MCP_TASK ?? DEFAULT_TASK, 'task');
49
+ }
50
+ /** `profiles/<profile>/` — holds the Chrome user-data dir and this profile's tasks. */
51
+ function resolveProfileDir(dataDir, profile) {
52
+ return (0, node_path_1.join)(dataDir, 'profiles', profile);
53
+ }
54
+ /** `profiles/<profile>/tasks/<task>/` — per-run artifacts (downloads, meta.json). */
55
+ function resolveTaskDir(dataDir, profile, task) {
56
+ return (0, node_path_1.join)(resolveProfileDir(dataDir, profile), 'tasks', task);
57
+ }
23
58
  function readPolicyFile(path) {
24
59
  const raw = (0, node_fs_1.readFileSync)(path, 'utf8');
25
60
  const parsed = JSON.parse(raw);
@@ -66,6 +101,12 @@ function parseArgs(argv) {
66
101
  case '--data-dir':
67
102
  process.env.CHROME_MCP_DATA = requireValue(argv[++i], '--data-dir');
68
103
  break;
104
+ case '--profile':
105
+ process.env.CHROME_MCP_PROFILE = requireValue(argv[++i], '--profile');
106
+ break;
107
+ case '--task':
108
+ process.env.CHROME_MCP_TASK = requireValue(argv[++i], '--task');
109
+ break;
69
110
  case '--policy':
70
111
  policyFile = readPolicyFile(requireValue(argv[++i], '--policy'));
71
112
  break;
@@ -131,6 +172,8 @@ function parseArgs(argv) {
131
172
  return {
132
173
  wsPort,
133
174
  dataDir: resolveDataDir(),
175
+ profile: resolveProfile(),
176
+ task: resolveTask(),
134
177
  policy,
135
178
  cdpFallback,
136
179
  cdpEndpoint,
@@ -180,10 +223,19 @@ function requireLogLevel(value) {
180
223
  exports.HELP_TEXT = `chrome-mcp — drive a real Chrome browser over MCP.
181
224
 
182
225
  Usage: chrome-mcp [options]
226
+ chrome-mcp tasks list [--json]
227
+ chrome-mcp tasks gc [--older-than <days>] [--keep <n>] [--profile <name>] [--dry-run]
183
228
 
184
229
  Connection:
185
230
  --port <n> WebSocket bridge port (default ${protocol_1.DEFAULT_WS_PORT}; 0 = ephemeral)
186
231
  --data-dir <path> Override the data dir (default ~/.chrome-mcp)
232
+ --profile <name> Default browser profile / identity (default "default").
233
+ Artifacts live under profiles/<name>/. At runtime, switch
234
+ with the profile_use tool. Several browsers can pair to the
235
+ SAME port+token at once, each declaring its own Profile in
236
+ the extension Options; tools route to the active profile.
237
+ --task <name> Task label (default "default"). Downloads and a meta.json
238
+ land in profiles/<profile>/tasks/<task>/.
187
239
  --print-pairing Write the handshake and print its path, then exit
188
240
  --persist-token Reuse a stable on-disk token across restarts so the
189
241
  extension never has to re-pair (default: fresh per boot).
@@ -13,6 +13,9 @@ export declare class ExtensionExecutor implements Executor {
13
13
  private readonly bridge;
14
14
  readonly backend: BackendKind;
15
15
  constructor(bridge: BridgeServer);
16
+ /** The profile this executor routes to = the active task workspace's profile
17
+ * (set by `profile_use`). Falls back to "default" before a workspace exists. */
18
+ private activeProfile;
16
19
  private send;
17
20
  status(): ExecutorStatus;
18
21
  ensureReady(): Promise<void>;
@@ -11,6 +11,7 @@
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.ExtensionExecutor = void 0;
13
13
  const types_1 = require("./types");
14
+ const workspace_1 = require("../bridge/workspace");
14
15
  /** Flatten a Target into the params a wire command carries. */
15
16
  function targetParams(t) {
16
17
  if (!t)
@@ -23,17 +24,26 @@ class ExtensionExecutor {
23
24
  constructor(bridge) {
24
25
  this.bridge = bridge;
25
26
  }
27
+ /** The profile this executor routes to = the active task workspace's profile
28
+ * (set by `profile_use`). Falls back to "default" before a workspace exists. */
29
+ activeProfile() {
30
+ return (0, workspace_1.peekActiveWorkspace)()?.profile ?? 'default';
31
+ }
26
32
  send(method, params, opts) {
27
- return this.bridge.sendCommand(method, params, opts);
33
+ return this.bridge.sendCommand(method, params, { ...opts, profile: this.activeProfile() });
28
34
  }
29
35
  status() {
30
- const connected = this.bridge.hasActiveExtension();
36
+ const profile = this.activeProfile();
37
+ const connected = this.bridge.hasConnection(profile);
31
38
  return {
32
39
  ready: connected,
33
40
  backend: this.backend,
34
41
  activeTabId: null, // not known synchronously
35
42
  extensionConnected: connected,
36
43
  cdpAttached: false,
44
+ detail: connected ? undefined : `active profile "${profile}" has no paired browser`,
45
+ activeProfile: profile,
46
+ connectedProfiles: this.bridge.connectedProfiles(),
37
47
  };
38
48
  }
39
49
  async ensureReady() {
@@ -41,7 +51,7 @@ class ExtensionExecutor {
41
51
  // already confirmed an extension is paired + responsive before picking us.
42
52
  }
43
53
  async ping(deadlineMs = 800) {
44
- if (!this.bridge.hasActiveExtension())
54
+ if (!this.bridge.hasConnection(this.activeProfile()))
45
55
  return false;
46
56
  try {
47
57
  await this.send('ping_probe', {}, { timeoutMs: deadlineMs });
@@ -131,7 +141,22 @@ class ExtensionExecutor {
131
141
  }
132
142
  // -- privileged ---------------------------------------------------------
133
143
  async download(args) {
134
- return (await this.send('download_file', { url: args.url, ...targetParams(args.target), suggestedName: args.suggestedName }, { tabId: args.tabId }));
144
+ const res = (await this.send('download_file', { url: args.url, ...targetParams(args.target), suggestedName: args.suggestedName }, { tabId: args.tabId }));
145
+ // The extension can only write to the user's Downloads dir; relocate the file
146
+ // into the active task's downloads/ so each task collects its own artifacts.
147
+ // If the move fails, keep Chrome's path rather than reporting a phantom failure.
148
+ if (res.sourcePath) {
149
+ try {
150
+ const moved = (0, workspace_1.captureDownload)(res.sourcePath, res.suggestedName);
151
+ return { ...res, path: moved.path, bytes: moved.bytes, sourcePath: undefined };
152
+ }
153
+ catch {
154
+ // Capture failed (e.g. over the size cap): leave the file where Chrome put
155
+ // it and report that path instead of failing the call.
156
+ return { ...res, sourcePath: undefined };
157
+ }
158
+ }
159
+ return res;
135
160
  }
136
161
  async uploadFile(t, files, opts) {
137
162
  return (await this.send('upload_file', { ...targetParams(t), files }, { tabId: opts?.tabId }));
@@ -86,6 +86,15 @@ export interface DownloadResult {
86
86
  bytes: number;
87
87
  mimeType?: string;
88
88
  suggestedName?: string;
89
+ /**
90
+ * Extension backend only: the absolute path where Chrome first saved the file
91
+ * (the user's Downloads dir). The server moves it into the active task's
92
+ * `downloads/` and clears this field, so it is never present in a final result
93
+ * returned to the MCP client.
94
+ */
95
+ sourcePath?: string;
96
+ /** Extension backend only: the chrome.downloads id, surfaced for diagnostics. */
97
+ downloadId?: number;
89
98
  }
90
99
  /** One interactive/landmark element in an accessibility snapshot. `ref` is stable until the tab navigates. */
91
100
  export interface SnapshotNode {
@@ -128,6 +137,10 @@ export interface ExecutorStatus {
128
137
  detail?: string;
129
138
  extensionConnected: boolean;
130
139
  cdpAttached: boolean;
140
+ /** Extension backend: the profile tools currently route to (set by profile_use). */
141
+ activeProfile?: string;
142
+ /** Extension backend: every profile with a live browser right now. */
143
+ connectedProfiles?: string[];
131
144
  }
132
145
  export interface Executor {
133
146
  readonly backend: BackendKind;