@mehmoodqureshi/chrome-mcp 0.5.2 → 0.6.1

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/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # chrome-mcp
2
2
 
3
+ [![CI](https://github.com/Mehmoodqureshi/chrome-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/Mehmoodqureshi/chrome-mcp/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/%40mehmoodqureshi%2Fchrome-mcp?label=npm)](https://www.npmjs.com/package/@mehmoodqureshi/chrome-mcp)
5
+ [![license](https://img.shields.io/npm/l/%40mehmoodqureshi%2Fchrome-mcp?label=license)](LICENSE)
6
+
3
7
  Drive a **real Chrome browser** from Claude (or any MCP host). One pluggable
4
8
  `Executor` interface, two backends:
5
9
 
@@ -48,6 +48,10 @@ export interface HelloFrame extends BaseFrame {
48
48
  version: string;
49
49
  chrome: string;
50
50
  };
51
+ /** Routing label: which profile this browser pairs as. Absent/empty → "default".
52
+ * NOT a security boundary (the token is) — it selects which connection slot the
53
+ * server routes commands to, so several browsers can stay paired at once. */
54
+ profile?: string;
51
55
  }
52
56
  /**
53
57
  * The wire-serializable subset of the server's policy, delivered in `welcome` so
@@ -6,3 +6,42 @@
6
6
  /** Create (if needed) and return the data dir, 0700 so only the user can read it. */
7
7
  export declare function ensureDataDir(dir?: string): string;
8
8
  export declare function handshakePath(dir: string): string;
9
+ /**
10
+ * One-time move of the pre-0.6 flat layout into the `default` profile's
11
+ * workspace: `<dataDir>/cdp-profile` → `profiles/default/cdp-profile` (logins)
12
+ * and `<dataDir>/downloads` → `profiles/default/tasks/default/downloads`.
13
+ *
14
+ * Idempotent and conservative: a leg is migrated only when the legacy dir exists
15
+ * AND its target does not, so it runs at most once and never clobbers a profile
16
+ * the user has already populated. Must be called BEFORE {@link ensureWorkspace},
17
+ * which would otherwise create the (empty) target and block the rename. Returns
18
+ * a human-readable description of each move performed.
19
+ */
20
+ export declare function migrateLegacyLayout(dataDir: string): string[];
21
+ export interface Workspace {
22
+ /** The data dir this workspace lives under — needed to switch profile/task at runtime. */
23
+ dataDir: string;
24
+ profile: string;
25
+ task: string;
26
+ /** `profiles/<profile>/` — passed to the CDP executor as its userDataDir. */
27
+ profileDir: string;
28
+ /** `profiles/<profile>/tasks/<task>/` — per-run artifact root. */
29
+ taskDir: string;
30
+ /** `profiles/<profile>/tasks/<task>/downloads` — captured files for this run. */
31
+ downloadDir: string;
32
+ /** `profiles/<profile>/tasks/<task>/results` — extracted text/markdown/links. */
33
+ resultsDir: string;
34
+ /** `profiles/<profile>/tasks/<task>/screenshots` — PNGs captured during the run. */
35
+ screenshotsDir: string;
36
+ /** `profiles/<profile>/tasks/<task>/history.jsonl` — append-only action log. */
37
+ historyPath: string;
38
+ }
39
+ /**
40
+ * Create (0700) the profile + task directories and stamp the task with a
41
+ * `meta.json`, returning the resolved paths. The CDP profile (identity: cookies
42
+ * & logins) and the downloads (per-run artifacts) live under here so distinct
43
+ * identities and distinct runs never collide. `createdAt` is preserved across
44
+ * restarts so a resumed task keeps its original timestamp; the meta write is
45
+ * best-effort and never fatal.
46
+ */
47
+ export declare function ensureWorkspace(dataDir: string, profile: string, task: string, meta?: Record<string, unknown>): Workspace;
@@ -7,6 +7,8 @@
7
7
  Object.defineProperty(exports, "__esModule", { value: true });
8
8
  exports.ensureDataDir = ensureDataDir;
9
9
  exports.handshakePath = handshakePath;
10
+ exports.migrateLegacyLayout = migrateLegacyLayout;
11
+ exports.ensureWorkspace = ensureWorkspace;
10
12
  const node_fs_1 = require("node:fs");
11
13
  const node_path_1 = require("node:path");
12
14
  const config_1 = require("../config");
@@ -19,4 +21,68 @@ function ensureDataDir(dir) {
19
21
  function handshakePath(dir) {
20
22
  return (0, node_path_1.join)(dir, 'handshake.json');
21
23
  }
24
+ /**
25
+ * One-time move of the pre-0.6 flat layout into the `default` profile's
26
+ * workspace: `<dataDir>/cdp-profile` → `profiles/default/cdp-profile` (logins)
27
+ * and `<dataDir>/downloads` → `profiles/default/tasks/default/downloads`.
28
+ *
29
+ * Idempotent and conservative: a leg is migrated only when the legacy dir exists
30
+ * AND its target does not, so it runs at most once and never clobbers a profile
31
+ * the user has already populated. Must be called BEFORE {@link ensureWorkspace},
32
+ * which would otherwise create the (empty) target and block the rename. Returns
33
+ * a human-readable description of each move performed.
34
+ */
35
+ function migrateLegacyLayout(dataDir) {
36
+ const moved = [];
37
+ const legs = [
38
+ { from: (0, node_path_1.join)(dataDir, 'cdp-profile'), to: (0, node_path_1.join)((0, config_1.resolveProfileDir)(dataDir, 'default'), 'cdp-profile') },
39
+ { from: (0, node_path_1.join)(dataDir, 'downloads'), to: (0, node_path_1.join)((0, config_1.resolveTaskDir)(dataDir, 'default', 'default'), 'downloads') },
40
+ ];
41
+ for (const { from, to } of legs) {
42
+ if (!(0, node_fs_1.existsSync)(from) || (0, node_fs_1.existsSync)(to))
43
+ continue;
44
+ try {
45
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(to), { recursive: true, mode: 0o700 });
46
+ (0, node_fs_1.renameSync)(from, to);
47
+ moved.push(`${from} → ${to}`);
48
+ }
49
+ catch {
50
+ /* best effort: a failed move leaves the legacy dir untouched and usable */
51
+ }
52
+ }
53
+ return moved;
54
+ }
55
+ /**
56
+ * Create (0700) the profile + task directories and stamp the task with a
57
+ * `meta.json`, returning the resolved paths. The CDP profile (identity: cookies
58
+ * & logins) and the downloads (per-run artifacts) live under here so distinct
59
+ * identities and distinct runs never collide. `createdAt` is preserved across
60
+ * restarts so a resumed task keeps its original timestamp; the meta write is
61
+ * best-effort and never fatal.
62
+ */
63
+ function ensureWorkspace(dataDir, profile, task, meta = {}) {
64
+ const profileDir = (0, config_1.resolveProfileDir)(dataDir, profile);
65
+ const taskDir = (0, config_1.resolveTaskDir)(dataDir, profile, task);
66
+ const downloadDir = (0, node_path_1.join)(taskDir, 'downloads');
67
+ const resultsDir = (0, node_path_1.join)(taskDir, 'results');
68
+ const screenshotsDir = (0, node_path_1.join)(taskDir, 'screenshots');
69
+ const historyPath = (0, node_path_1.join)(taskDir, 'history.jsonl');
70
+ // Create the three artifact buckets up front (0700) so every capture path can
71
+ // assume its directory exists.
72
+ for (const d of [downloadDir, resultsDir, screenshotsDir]) {
73
+ (0, node_fs_1.mkdirSync)(d, { recursive: true, mode: 0o700 });
74
+ }
75
+ const metaPath = (0, node_path_1.join)(taskDir, 'meta.json');
76
+ try {
77
+ let createdAt;
78
+ if ((0, node_fs_1.existsSync)(metaPath)) {
79
+ createdAt = JSON.parse((0, node_fs_1.readFileSync)(metaPath, 'utf8')).createdAt;
80
+ }
81
+ (0, node_fs_1.writeFileSync)(metaPath, JSON.stringify({ ...meta, profile, task, createdAt: createdAt ?? meta.createdAt }, null, 2), { mode: 0o600 });
82
+ }
83
+ catch {
84
+ /* non-fatal: a missing meta.json never blocks the server */
85
+ }
86
+ return { dataDir, profile, task, profileDir, taskDir, downloadDir, resultsDir, screenshotsDir, historyPath };
87
+ }
22
88
  //# sourceMappingURL=datadir.js.map
@@ -34,7 +34,10 @@ export interface BridgeOptions {
34
34
  export declare class BridgeServer {
35
35
  private readonly opts;
36
36
  private wss;
37
- private active;
37
+ /** Profile routing key → its live connection. Multiple browsers stay paired at
38
+ * once; a command is routed to the connection for its target profile. A new
39
+ * hello for the SAME profile supersedes that profile's connection only. */
40
+ private conns;
38
41
  private boundPort;
39
42
  private readonly heartbeatMs;
40
43
  constructor(opts: BridgeOptions);
@@ -44,16 +47,26 @@ export declare class BridgeServer {
44
47
  private listenOnce;
45
48
  stop(): Promise<void>;
46
49
  get port(): number;
50
+ /** True when ANY browser is paired (used as the selector's cheap gate). */
47
51
  hasActiveExtension(): boolean;
48
- /** Send a command to the active extension, or reject if none is connected. */
52
+ /** True when the given profile has a live connection. */
53
+ hasConnection(profile: string): boolean;
54
+ /** Profiles with a live connection right now. */
55
+ connectedProfiles(): string[];
56
+ /**
57
+ * Send a command to the connection for `opts.profile` (default "default").
58
+ * Rejects with an actionable message if that profile has no live browser.
59
+ */
49
60
  sendCommand(method: WireMethod, params: Record<string, unknown>, opts?: {
50
61
  tabId?: string;
51
62
  timeoutMs?: number;
63
+ profile?: string;
52
64
  }): Promise<unknown>;
65
+ private noPairMessage;
53
66
  status(): {
54
67
  extensionConnected: boolean;
55
68
  port: number;
56
- sessionId: string | null;
69
+ connectedProfiles: string[];
57
70
  };
58
71
  private handleConnection;
59
72
  private reject;
@@ -20,6 +20,20 @@ const policy_1 = require("../../shared/policy");
20
20
  const types_1 = require("../executor/types");
21
21
  const connection_1 = require("./connection");
22
22
  const auth_1 = require("./auth");
23
+ const config_1 = require("../config");
24
+ /** The routing label for a hello with no/blank profile — the back-compat default. */
25
+ const DEFAULT_PROFILE = 'default';
26
+ /** Reduce a hello's profile label to a safe routing key; blank/invalid → "default". */
27
+ function routeKey(profile) {
28
+ if (!profile || !profile.trim())
29
+ return DEFAULT_PROFILE;
30
+ try {
31
+ return (0, config_1.sanitizeName)(profile, 'profile');
32
+ }
33
+ catch {
34
+ return DEFAULT_PROFILE;
35
+ }
36
+ }
23
37
  const HELLO_TIMEOUT_MS = 5_000;
24
38
  const DEFAULT_HEARTBEAT_MS = 15_000;
25
39
  /** Max pre-auth frames a socket may send before a valid hello (anti-idle-hold). */
@@ -41,7 +55,10 @@ function portBusyMessage(host, port) {
41
55
  class BridgeServer {
42
56
  opts;
43
57
  wss = null;
44
- active = null;
58
+ /** Profile routing key → its live connection. Multiple browsers stay paired at
59
+ * once; a command is routed to the connection for its target profile. A new
60
+ * hello for the SAME profile supersedes that profile's connection only. */
61
+ conns = new Map();
45
62
  boundPort = 0;
46
63
  heartbeatMs;
47
64
  constructor(opts) {
@@ -106,8 +123,9 @@ class BridgeServer {
106
123
  });
107
124
  }
108
125
  async stop() {
109
- this.active?.close(1001, 'server stopping');
110
- this.active = null;
126
+ for (const conn of this.conns.values())
127
+ conn.close(1001, 'server stopping');
128
+ this.conns.clear();
111
129
  const wss = this.wss;
112
130
  this.wss = null;
113
131
  if (wss)
@@ -116,21 +134,48 @@ class BridgeServer {
116
134
  get port() {
117
135
  return this.boundPort;
118
136
  }
137
+ /** True when ANY browser is paired (used as the selector's cheap gate). */
119
138
  hasActiveExtension() {
120
- return this.active?.isOpen() ?? false;
139
+ for (const conn of this.conns.values())
140
+ if (conn.isOpen())
141
+ return true;
142
+ return false;
121
143
  }
122
- /** Send a command to the active extension, or reject if none is connected. */
144
+ /** True when the given profile has a live connection. */
145
+ hasConnection(profile) {
146
+ const conn = this.conns.get(routeKey(profile));
147
+ return !!conn && conn.isOpen();
148
+ }
149
+ /** Profiles with a live connection right now. */
150
+ connectedProfiles() {
151
+ const out = [];
152
+ for (const [profile, conn] of this.conns)
153
+ if (conn.isOpen())
154
+ out.push(profile);
155
+ return out;
156
+ }
157
+ /**
158
+ * Send a command to the connection for `opts.profile` (default "default").
159
+ * Rejects with an actionable message if that profile has no live browser.
160
+ */
123
161
  async sendCommand(method, params, opts) {
124
- if (!this.active || !this.active.isOpen()) {
125
- throw new types_1.ExecutorError('EXTENSION_DISCONNECTED', 'no extension is paired');
162
+ const profile = routeKey(opts?.profile);
163
+ const conn = this.conns.get(profile);
164
+ if (!conn || !conn.isOpen()) {
165
+ throw new types_1.ExecutorError('EXTENSION_DISCONNECTED', this.noPairMessage(profile));
126
166
  }
127
- return this.active.sendCommand(method, params, opts);
167
+ return conn.sendCommand(method, params, opts);
168
+ }
169
+ noPairMessage(profile) {
170
+ return (`No browser is paired for profile "${profile}". In that Chrome's chrome-mcp ` +
171
+ `extension Options, set Port ${this.boundPort}, paste the token, set Profile to ` +
172
+ `"${profile}", and Save — then it joins without disturbing your other profiles.`);
128
173
  }
129
174
  status() {
130
175
  return {
131
176
  extensionConnected: this.hasActiveExtension(),
132
177
  port: this.boundPort,
133
- sessionId: this.active?.sessionId ?? null,
178
+ connectedProfiles: this.connectedProfiles(),
134
179
  };
135
180
  }
136
181
  // -- internals ----------------------------------------------------------
@@ -174,11 +219,11 @@ class BridgeServer {
174
219
  this.reject(ws, 'bad_token');
175
220
  return;
176
221
  }
177
- // Authenticated. Hand the socket to an ExtensionConnection.
222
+ // Authenticated. Hand the socket to an ExtensionConnection under its profile.
178
223
  authed = true;
179
224
  clearTimeout(helloTimer);
180
225
  ws.off('message', onMessage);
181
- this.promote(ws, frame.ext ?? { id: 'unknown', version: '0', chrome: '0' });
226
+ this.promote(ws, frame.ext ?? { id: 'unknown', version: '0', chrome: '0' }, routeKey(frame.profile));
182
227
  };
183
228
  ws.on('message', onMessage);
184
229
  ws.on('error', () => {
@@ -195,12 +240,14 @@ class BridgeServer {
195
240
  /* ignore */
196
241
  }
197
242
  }
198
- promote(ws, ext) {
243
+ promote(ws, ext, profile) {
199
244
  const sessionId = (0, node_crypto_1.randomUUID)();
200
- if (this.active && this.active.isOpen()) {
201
- const prev = this.active;
245
+ // Supersede only the SAME profile's connection (a re-pair). Other profiles
246
+ // keep their live connections, so several browsers stay paired at once.
247
+ const prev = this.conns.get(profile);
248
+ if (prev && prev.isOpen()) {
202
249
  const differentId = prev.extId !== ext.id;
203
- this.log(`extension "${ext.id}" superseded active connection "${prev.extId}"` +
250
+ this.log(`extension "${ext.id}" superseded profile "${profile}" connection "${prev.extId}"` +
204
251
  (differentId ? ' (DIFFERENT id — possible hijack; surfaced to status)' : ''));
205
252
  try {
206
253
  this.opts.onDisplacement?.({ oldExtId: prev.extId, newExtId: ext.id, differentId });
@@ -219,11 +266,12 @@ class BridgeServer {
219
266
  onEvent: this.opts.onEvent,
220
267
  onLog: (m) => this.log(m),
221
268
  onClose: () => {
222
- if (this.active?.sessionId === sessionId)
223
- this.active = null;
269
+ // Only clear if a newer re-pair hasn't already replaced this slot.
270
+ if (this.conns.get(profile)?.sessionId === sessionId)
271
+ this.conns.delete(profile);
224
272
  },
225
273
  });
226
- this.active = conn;
274
+ this.conns.set(profile, conn);
227
275
  const welcome = {
228
276
  type: 'welcome',
229
277
  v: protocol_1.PROTOCOL_VERSION,
@@ -233,7 +281,7 @@ class BridgeServer {
233
281
  policy: this.opts.policy ?? policy_1.DENY_ALL_WIRE_POLICY,
234
282
  };
235
283
  this.send(ws, welcome);
236
- this.log(`extension paired (session ${sessionId}, id "${ext.id}")`);
284
+ this.log(`extension paired (profile "${profile}", session ${sessionId}, id "${ext.id}")`);
237
285
  }
238
286
  send(ws, frame) {
239
287
  try {
@@ -0,0 +1,44 @@
1
+ /**
2
+ * src/bridge/tasks.ts — listing and garbage-collection over the per-profile
3
+ * task workspaces written by `ensureWorkspace` (datadir.ts).
4
+ *
5
+ * A task is `profiles/<profile>/tasks/<task>/`, carrying a `meta.json` and a
6
+ * `downloads/` bucket. These helpers walk that tree read-only (listTasks) or
7
+ * prune it (gcTasks). `now` is threaded in rather than read from the clock so
8
+ * GC is deterministic under test.
9
+ */
10
+ export interface TaskInfo {
11
+ profile: string;
12
+ task: string;
13
+ /** Absolute path to `profiles/<profile>/tasks/<task>/`. */
14
+ dir: string;
15
+ /** ISO timestamp from meta.json, falling back to the dir's mtime. */
16
+ createdAt: string;
17
+ /** Total bytes under the task dir (downloads + meta.json; excludes the profile). */
18
+ bytes: number;
19
+ /** File count in `downloads/`. */
20
+ downloads: number;
21
+ }
22
+ /** Enumerate every task across every profile, newest first. */
23
+ export declare function listTasks(dataDir: string): TaskInfo[];
24
+ export interface GcOptions {
25
+ /** Remove tasks created more than this many days ago. */
26
+ olderThanDays?: number;
27
+ /** Always retain the newest N tasks (per scope), regardless of age. */
28
+ keep?: number;
29
+ /** Limit to a single profile; otherwise all profiles. */
30
+ profile?: string;
31
+ /** Compute what would be removed without deleting anything. */
32
+ dryRun?: boolean;
33
+ }
34
+ export interface GcResult {
35
+ removed: TaskInfo[];
36
+ freedBytes: number;
37
+ }
38
+ /**
39
+ * Prune task workspaces. A task is removed when it is NOT among the newest
40
+ * `keep` (if set) AND is older than `olderThanDays` (if set). At least one of
41
+ * `keep`/`olderThanDays` must be provided — the caller is responsible for
42
+ * refusing an unbounded sweep. `dryRun` reports the selection without deleting.
43
+ */
44
+ export declare function gcTasks(dataDir: string, opts: GcOptions, now: number): GcResult;
@@ -0,0 +1,131 @@
1
+ "use strict";
2
+ /**
3
+ * src/bridge/tasks.ts — listing and garbage-collection over the per-profile
4
+ * task workspaces written by `ensureWorkspace` (datadir.ts).
5
+ *
6
+ * A task is `profiles/<profile>/tasks/<task>/`, carrying a `meta.json` and a
7
+ * `downloads/` bucket. These helpers walk that tree read-only (listTasks) or
8
+ * prune it (gcTasks). `now` is threaded in rather than read from the clock so
9
+ * GC is deterministic under test.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.listTasks = listTasks;
13
+ exports.gcTasks = gcTasks;
14
+ const node_fs_1 = require("node:fs");
15
+ const node_path_1 = require("node:path");
16
+ function subdirs(dir) {
17
+ try {
18
+ return (0, node_fs_1.readdirSync)(dir, { withFileTypes: true })
19
+ .filter((e) => e.isDirectory())
20
+ .map((e) => e.name);
21
+ }
22
+ catch {
23
+ return [];
24
+ }
25
+ }
26
+ function dirSize(dir) {
27
+ let total = 0;
28
+ let entries;
29
+ try {
30
+ entries = (0, node_fs_1.readdirSync)(dir, { withFileTypes: true });
31
+ }
32
+ catch {
33
+ return 0;
34
+ }
35
+ for (const e of entries) {
36
+ const p = (0, node_path_1.join)(dir, e.name);
37
+ if (e.isDirectory())
38
+ total += dirSize(p);
39
+ else {
40
+ try {
41
+ total += (0, node_fs_1.statSync)(p).size;
42
+ }
43
+ catch {
44
+ /* vanished mid-walk */
45
+ }
46
+ }
47
+ }
48
+ return total;
49
+ }
50
+ function countFiles(dir) {
51
+ try {
52
+ return (0, node_fs_1.readdirSync)(dir, { withFileTypes: true }).filter((e) => e.isFile()).length;
53
+ }
54
+ catch {
55
+ return 0;
56
+ }
57
+ }
58
+ function readCreatedAt(taskDir) {
59
+ try {
60
+ const meta = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(taskDir, 'meta.json'), 'utf8'));
61
+ if (typeof meta.createdAt === 'string')
62
+ return meta.createdAt;
63
+ }
64
+ catch {
65
+ /* fall through to mtime */
66
+ }
67
+ try {
68
+ return (0, node_fs_1.statSync)(taskDir).mtime.toISOString();
69
+ }
70
+ catch {
71
+ return '';
72
+ }
73
+ }
74
+ /** Enumerate every task across every profile, newest first. */
75
+ function listTasks(dataDir) {
76
+ const profilesRoot = (0, node_path_1.join)(dataDir, 'profiles');
77
+ if (!(0, node_fs_1.existsSync)(profilesRoot))
78
+ return [];
79
+ const out = [];
80
+ for (const profile of subdirs(profilesRoot)) {
81
+ const tasksRoot = (0, node_path_1.join)(profilesRoot, profile, 'tasks');
82
+ for (const task of subdirs(tasksRoot)) {
83
+ const dir = (0, node_path_1.join)(tasksRoot, task);
84
+ out.push({
85
+ profile,
86
+ task,
87
+ dir,
88
+ createdAt: readCreatedAt(dir),
89
+ bytes: dirSize(dir),
90
+ downloads: countFiles((0, node_path_1.join)(dir, 'downloads')),
91
+ });
92
+ }
93
+ }
94
+ return out.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
95
+ }
96
+ /**
97
+ * Prune task workspaces. A task is removed when it is NOT among the newest
98
+ * `keep` (if set) AND is older than `olderThanDays` (if set). At least one of
99
+ * `keep`/`olderThanDays` must be provided — the caller is responsible for
100
+ * refusing an unbounded sweep. `dryRun` reports the selection without deleting.
101
+ */
102
+ function gcTasks(dataDir, opts, now) {
103
+ if (opts.olderThanDays === undefined && opts.keep === undefined) {
104
+ throw new Error('gcTasks requires olderThanDays or keep (refusing to remove every task)');
105
+ }
106
+ const scoped = listTasks(dataDir).filter((t) => !opts.profile || t.profile === opts.profile);
107
+ const protectedDirs = new Set(opts.keep === undefined ? [] : scoped.slice(0, opts.keep).map((t) => t.dir));
108
+ const ageCutoffMs = (opts.olderThanDays ?? 0) * 86_400_000;
109
+ const removed = scoped.filter((t) => {
110
+ if (protectedDirs.has(t.dir))
111
+ return false;
112
+ if (opts.olderThanDays !== undefined) {
113
+ const created = Date.parse(t.createdAt);
114
+ if (Number.isNaN(created) || now - created <= ageCutoffMs)
115
+ return false;
116
+ }
117
+ return true;
118
+ });
119
+ if (!opts.dryRun) {
120
+ for (const t of removed) {
121
+ try {
122
+ (0, node_fs_1.rmSync)(t.dir, { recursive: true, force: true });
123
+ }
124
+ catch {
125
+ /* best effort */
126
+ }
127
+ }
128
+ }
129
+ return { removed, freedBytes: removed.reduce((sum, t) => sum + t.bytes, 0) };
130
+ }
131
+ //# sourceMappingURL=tasks.js.map
@@ -0,0 +1,54 @@
1
+ /**
2
+ * src/bridge/workspace.ts — the *active* task workspace (mutable, process-global)
3
+ * plus the memory writers that persist a task's artifacts into it.
4
+ *
5
+ * Modeled on the executor singleton in `executor/manager.ts`: the boot path
6
+ * installs an initial workspace, and the runtime tools (`profile_use`/`task_new`)
7
+ * swap it via {@link switchWorkspace}. Tool handlers reach the current workspace
8
+ * through {@link getActiveWorkspace} so downloads, extracted results, screenshots,
9
+ * and the action log all land under `profiles/<profile>/tasks/<task>/`.
10
+ *
11
+ * Every writer here is BEST-EFFORT: a failed persist (full disk, races a task
12
+ * switch) is logged to stderr and swallowed so it can never break a tool call.
13
+ */
14
+ import { type Workspace } from './datadir';
15
+ /** Install the initial workspace at boot (from cli.ts). */
16
+ export declare function setActiveWorkspace(w: Workspace): void;
17
+ /** The current workspace. Throws if the boot path never installed one. */
18
+ export declare function getActiveWorkspace(): Workspace;
19
+ /** The current workspace, or null before boot completes (for non-throwing callers). */
20
+ export declare function peekActiveWorkspace(): Workspace | null;
21
+ /** Reset for tests. */
22
+ export declare function resetActiveWorkspaceForTesting(): void;
23
+ /**
24
+ * Switch the active profile and/or task, creating the workspace dirs if new, and
25
+ * make it current. Names are sanitized to a single safe path segment (reusing
26
+ * {@link sanitizeName}) so a tool argument can never escape the data dir. Returns
27
+ * the resolved workspace.
28
+ */
29
+ export declare function switchWorkspace(opts: {
30
+ profile?: string;
31
+ task?: string;
32
+ meta?: Record<string, unknown>;
33
+ }): Workspace;
34
+ /**
35
+ * Save an extracted-content result (get_text / read_as_markdown / extract_links)
36
+ * into the active task's `results/`. `ext` is the file extension without a dot.
37
+ * Returns the path written, or null if persisted nowhere (no workspace / error).
38
+ */
39
+ export declare function saveResult(tool: string, ext: string, body: string): string | null;
40
+ /** Save a screenshot PNG (base64) into the active task's `screenshots/`. */
41
+ export declare function saveScreenshot(dataBase64: string): string | null;
42
+ /**
43
+ * Move a file Chrome saved to the user's Downloads dir into the active task's
44
+ * `downloads/`. The name is re-hardened via {@link sanitizeDownloadName} and the
45
+ * size is capped at {@link MAX_DOWNLOAD_BYTES} (over-cap files are left where they
46
+ * are and rejected). Falls back to copy+unlink when source and destination are on
47
+ * different filesystems (rename's `EXDEV`). Returns the final path and byte size.
48
+ */
49
+ export declare function captureDownload(sourcePath: string, suggestedName?: string): {
50
+ path: string;
51
+ bytes: number;
52
+ };
53
+ /** Append one action record to the active task's `history.jsonl`. */
54
+ export declare function appendHistory(entry: Record<string, unknown>): void;