@jobshimo/browser-link 0.27.0 → 0.28.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 (60) hide show
  1. package/dist/activity/db.d.ts +27 -0
  2. package/dist/activity/db.js +98 -0
  3. package/dist/activity/db.js.map +1 -0
  4. package/dist/activity/extract.d.ts +32 -0
  5. package/dist/activity/extract.js +126 -0
  6. package/dist/activity/extract.js.map +1 -0
  7. package/dist/activity/paths.d.ts +16 -0
  8. package/dist/activity/paths.js +24 -0
  9. package/dist/activity/paths.js.map +1 -0
  10. package/dist/activity/queries.d.ts +100 -0
  11. package/dist/activity/queries.js +286 -0
  12. package/dist/activity/queries.js.map +1 -0
  13. package/dist/activity/types.d.ts +119 -0
  14. package/dist/activity/types.js +41 -0
  15. package/dist/activity/types.js.map +1 -0
  16. package/dist/bridge/ws-bridge.d.ts +18 -1
  17. package/dist/bridge/ws-bridge.js +107 -0
  18. package/dist/bridge/ws-bridge.js.map +1 -1
  19. package/dist/cdp/detached-flows.d.ts +13 -0
  20. package/dist/cdp/detached-flows.js +33 -3
  21. package/dist/cdp/detached-flows.js.map +1 -1
  22. package/dist/cdp/inpage/deep-query.d.ts +1 -1
  23. package/dist/cdp/inpage/deep-query.js +1 -1
  24. package/dist/cdp/inpage/dom-helpers.d.ts +1 -1
  25. package/dist/cdp/inpage/dom-helpers.js +1 -1
  26. package/dist/cdp/transport.js +16 -5
  27. package/dist/cdp/transport.js.map +1 -1
  28. package/dist/cli.js +24 -0
  29. package/dist/cli.js.map +1 -1
  30. package/dist/commands/activity.d.ts +11 -0
  31. package/dist/commands/activity.js +347 -0
  32. package/dist/commands/activity.js.map +1 -0
  33. package/dist/config.d.ts +26 -0
  34. package/dist/config.js.map +1 -1
  35. package/dist/extension/activity.d.ts +201 -0
  36. package/dist/extension/activity.html +662 -0
  37. package/dist/extension/activity.js +555 -0
  38. package/dist/extension/activity.js.map +1 -0
  39. package/dist/extension/background.js +174 -3
  40. package/dist/extension/background.js.map +1 -1
  41. package/dist/extension/flow-registry.d.ts +14 -3
  42. package/dist/extension/flow-registry.js +37 -9
  43. package/dist/extension/flow-registry.js.map +1 -1
  44. package/dist/extension/inpage/deep-query.d.ts +1 -1
  45. package/dist/extension/inpage/deep-query.js +1 -1
  46. package/dist/extension/inpage/dom-helpers.d.ts +1 -1
  47. package/dist/extension/inpage/dom-helpers.js +1 -1
  48. package/dist/extension/manifest.json +3 -2
  49. package/dist/extension/popup.html +58 -0
  50. package/dist/extension/popup.js +11 -0
  51. package/dist/extension/popup.js.map +1 -1
  52. package/dist/messages.d.ts +80 -2
  53. package/dist/server.js +30 -0
  54. package/dist/server.js.map +1 -1
  55. package/dist/tools/browser-definitions.js +50 -2
  56. package/dist/tools/browser-definitions.js.map +1 -1
  57. package/dist/tools/browser-dispatch.d.ts +34 -7
  58. package/dist/tools/browser-dispatch.js +107 -1
  59. package/dist/tools/browser-dispatch.js.map +1 -1
  60. package/package.json +1 -1
@@ -0,0 +1,286 @@
1
+ import { statSync } from 'node:fs';
2
+ import { getActivityDb, MAX_ACTIVITY_ROWS, pruneIfDue } from './db.js';
3
+ import { getActivityDbPath } from './paths.js';
4
+ /** Hard ceiling on rows a single read can return, whatever `limit` asks for.
5
+ * The panel pages; nothing needs ten thousand rows in one response, and an
6
+ * unbounded read is how a renderer freezes a browser. */
7
+ export const MAX_QUERY_LIMIT = 500;
8
+ const DEFAULT_QUERY_LIMIT = 200;
9
+ /** Longest stored `payload` / `error`. A `browser.evaluate` expression can be
10
+ * arbitrarily long and an error can carry a page's worth of text; neither is
11
+ * more legible at 40 KB than at 4 KB, and the trail is meant to stay
12
+ * greppable. Truncation is marked so a reader never mistakes a cut string for
13
+ * the whole thing. */
14
+ export const MAX_TEXT_LEN = 4_000;
15
+ const TRUNCATION_MARK = '…[truncated]';
16
+ export function truncate(value, max = MAX_TEXT_LEN) {
17
+ if (value === null)
18
+ return null;
19
+ if (value.length <= max)
20
+ return value;
21
+ return value.slice(0, max - TRUNCATION_MARK.length) + TRUNCATION_MARK;
22
+ }
23
+ function toRecord(row) {
24
+ return {
25
+ id: row.id,
26
+ at: row.at,
27
+ tool: row.tool,
28
+ tabId: row.tab_id,
29
+ transport: row.transport ?? null,
30
+ url: row.url,
31
+ title: row.title,
32
+ agent: row.agent,
33
+ agentPid: row.agent_pid,
34
+ selector: row.selector,
35
+ payload: row.payload,
36
+ outcome: row.outcome === 'error' ? 'error' : 'ok',
37
+ error: row.error,
38
+ durationMs: row.duration_ms,
39
+ flowId: row.flow_id,
40
+ };
41
+ }
42
+ /**
43
+ * Append one dispatched call to the trail.
44
+ *
45
+ * NEVER THROWS. Recording is observation, not the work: an agent's click must
46
+ * not fail because the disk is full or the DB is locked. The caller
47
+ * (`browser-dispatch.ts`) treats this as fire-and-forget, and a lost row is
48
+ * strictly better than a lost action.
49
+ */
50
+ export function record(input, db) {
51
+ try {
52
+ const handle = db ?? getActivityDb();
53
+ const at = new Date().toISOString();
54
+ const result = handle
55
+ .prepare(`INSERT INTO activity
56
+ (at, tool, tab_id, transport, url, title, agent, agent_pid,
57
+ selector, payload, outcome, error, duration_ms, flow_id)
58
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
59
+ .run(at, input.tool, input.tabId, input.transport, truncate(input.url), truncate(input.title, 300), input.agent, input.agentPid, truncate(input.selector), truncate(input.payload), input.outcome, truncate(input.error), Math.max(0, Math.round(input.durationMs)), input.flowId);
60
+ pruneIfDue(handle);
61
+ return { ...input, id: Number(result.lastInsertRowid), at };
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ }
67
+ /** Build the shared WHERE clause + bindings for a filter. Kept in one place
68
+ * so `query` and its COUNT can never drift apart and report a total that does
69
+ * not match the rows beside it. */
70
+ function buildFilter(q) {
71
+ const clauses = [];
72
+ const params = [];
73
+ if (q.sinceId !== undefined) {
74
+ clauses.push('id > ?');
75
+ params.push(q.sinceId);
76
+ }
77
+ if (q.beforeId !== undefined) {
78
+ clauses.push('id < ?');
79
+ params.push(q.beforeId);
80
+ }
81
+ if (q.tabId !== undefined) {
82
+ clauses.push('tab_id = ?');
83
+ params.push(q.tabId);
84
+ }
85
+ if (q.agent !== undefined) {
86
+ clauses.push('agent = ?');
87
+ params.push(q.agent);
88
+ }
89
+ if (q.tool !== undefined) {
90
+ clauses.push('tool = ?');
91
+ params.push(q.tool);
92
+ }
93
+ if (q.flowId !== undefined) {
94
+ clauses.push('flow_id = ?');
95
+ params.push(q.flowId);
96
+ }
97
+ if (q.outcome !== undefined) {
98
+ clauses.push('outcome = ?');
99
+ params.push(q.outcome);
100
+ }
101
+ if (q.since !== undefined) {
102
+ clauses.push('at >= ?');
103
+ params.push(q.since);
104
+ }
105
+ return { where: clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '', params };
106
+ }
107
+ /**
108
+ * Read a page of the trail, NEWEST FIRST.
109
+ *
110
+ * Newest-first is what both readers want and it is the only ordering that
111
+ * makes `limit` honest: with oldest-first, "the last 200" would require
112
+ * counting the whole table before choosing an offset. The window reverses the
113
+ * page for display; the CLI prints it as-is.
114
+ */
115
+ export function query(q = {}, db) {
116
+ const handle = db ?? getActivityDb();
117
+ const limit = Math.min(Math.max(1, q.limit ?? DEFAULT_QUERY_LIMIT), MAX_QUERY_LIMIT);
118
+ const { where, params } = buildFilter(q);
119
+ const rows = handle
120
+ .prepare(`SELECT * FROM activity ${where} ORDER BY id DESC LIMIT ?`)
121
+ .all(...params, limit);
122
+ const { total } = handle
123
+ .prepare(`SELECT COUNT(*) AS total FROM activity ${where}`)
124
+ .get(...params);
125
+ const records = rows.map(toRecord);
126
+ // `latestId` is the highest id in the page — rows come back DESC, so that is
127
+ // the first one. Falls back to the request's cursor (not 0) when the page is
128
+ // empty, so a tailing caller that polls an idle bridge does not rewind and
129
+ // re-read everything on the next tick.
130
+ const latestId = records.length > 0 ? records[0].id : (q.sinceId ?? 0);
131
+ return { records, latestId, total };
132
+ }
133
+ /** Every distinct agent that appears in the trail, most recently seen first.
134
+ * Feeds the window's filter chips — a hardcoded list would go stale the
135
+ * moment someone wires up a new MCP client. */
136
+ export function listAgents(db) {
137
+ const handle = db ?? getActivityDb();
138
+ return handle
139
+ .prepare(`SELECT agent, COUNT(*) AS count FROM activity
140
+ WHERE agent IS NOT NULL
141
+ GROUP BY agent ORDER BY MAX(id) DESC`)
142
+ .all();
143
+ }
144
+ /** Delete the trail, or the part of it older than `before`. Returns rows
145
+ * removed. VACUUM is the caller's call (the CLI offers it) — it rewrites the
146
+ * whole file and has no business running inside a tool dispatch. */
147
+ export function clear(before, db) {
148
+ const handle = db ?? getActivityDb();
149
+ if (before === undefined) {
150
+ return handle.prepare('DELETE FROM activity').run().changes;
151
+ }
152
+ return handle.prepare('DELETE FROM activity WHERE at < ?').run(before).changes;
153
+ }
154
+ function rangeFilter(range) {
155
+ const clauses = [];
156
+ const params = [];
157
+ if (range.from !== undefined) {
158
+ clauses.push('at >= ?');
159
+ params.push(range.from);
160
+ }
161
+ if (range.to !== undefined) {
162
+ clauses.push('at <= ?');
163
+ params.push(range.to);
164
+ }
165
+ return { where: clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '', params };
166
+ }
167
+ /**
168
+ * How many rows a purge of this range WOULD remove.
169
+ *
170
+ * Exists so the UI can tell the user the number before they confirm, not
171
+ * after. "Delete 4,812 of 12,483 actions" is a decision; "Delete?" is a
172
+ * gamble, and this delete is permanent.
173
+ */
174
+ export function countInRange(range, db) {
175
+ const handle = db ?? getActivityDb();
176
+ const { where, params } = rangeFilter(range);
177
+ const row = handle.prepare(`SELECT COUNT(*) AS n FROM activity ${where}`).get(...params);
178
+ return row.n;
179
+ }
180
+ /**
181
+ * Delete every row inside an inclusive date range. Returns rows removed.
182
+ *
183
+ * `vacuum` defaults ON here, unlike `clear`. The whole point of purging from
184
+ * the UI is that the file got big, and a DELETE alone leaves the pages
185
+ * allocated — the user would purge half the trail, watch the reported size not
186
+ * move, and reasonably conclude it did nothing. VACUUM rewrites the file so
187
+ * the number they are watching actually drops.
188
+ */
189
+ export function purgeRange(range, options = {}, db) {
190
+ const handle = db ?? getActivityDb();
191
+ const { where, params } = rangeFilter(range);
192
+ const removed = handle.prepare(`DELETE FROM activity ${where}`).run(...params).changes;
193
+ if (removed > 0 && options.vacuum !== false) {
194
+ try {
195
+ // VACUUM ALONE IS NOT ENOUGH IN WAL MODE, and getting this wrong is
196
+ // invisible in the worst way: the rows go, the reported size does not
197
+ // move, and the user reasonably concludes the purge did nothing.
198
+ //
199
+ // In WAL mode the freed pages land in `activity.db-wal` rather than
200
+ // being handed back to the filesystem, so the file set stays exactly as
201
+ // large as before. TRUNCATE checkpoints fold the WAL back into the main
202
+ // DB and then truncate the sidecar to zero — once before, so VACUUM
203
+ // rewrites a DB that already contains everything, and once after, so the
204
+ // pages VACUUM itself freed are actually released.
205
+ handle.pragma('wal_checkpoint(TRUNCATE)');
206
+ handle.exec('VACUUM');
207
+ handle.pragma('wal_checkpoint(TRUNCATE)');
208
+ }
209
+ catch {
210
+ // A checkpoint or VACUUM can fail while another connection holds the
211
+ // file. The rows are already gone, which is what was asked for; the
212
+ // space comes back on the next successful purge.
213
+ }
214
+ }
215
+ return removed;
216
+ }
217
+ export function stats(db) {
218
+ const handle = db ?? getActivityDb();
219
+ const row = handle
220
+ .prepare('SELECT COUNT(*) AS rows, MIN(at) AS oldest, MAX(at) AS newest FROM activity')
221
+ .get();
222
+ return {
223
+ rows: row.rows,
224
+ bytes: activityBytesOnDisk(),
225
+ oldestAt: row.oldest,
226
+ newestAt: row.newest,
227
+ maxRows: MAX_ACTIVITY_ROWS,
228
+ };
229
+ }
230
+ /** Sum the SQLite file set. Missing sidecars are simply 0 — a freshly
231
+ * checkpointed DB has no -wal, and that is not an error. */
232
+ function activityBytesOnDisk() {
233
+ const base = getActivityDbPath();
234
+ let total = 0;
235
+ for (const suffix of ['', '-wal', '-shm']) {
236
+ try {
237
+ total += statSync(`${base}${suffix}`).size;
238
+ }
239
+ catch {
240
+ // Not present — nothing to add.
241
+ }
242
+ }
243
+ return total;
244
+ }
245
+ /**
246
+ * Stream the whole trail for export, oldest first, in batches.
247
+ *
248
+ * A generator rather than an array on purpose: an export of 50k rows must not
249
+ * materialise 50k objects to hand to a JSON serializer. Oldest-first because
250
+ * an exported file is read top-to-bottom as a story, which is the opposite of
251
+ * how a live panel is read.
252
+ */
253
+ export function* exportAll(filter = {}, db) {
254
+ const handle = db ?? getActivityDb();
255
+ const { where, params } = buildFilter(filter);
256
+ const stmt = handle.prepare(`SELECT * FROM activity ${where} ORDER BY id ASC`);
257
+ for (const row of stmt.iterate(...params)) {
258
+ yield toRecord(row);
259
+ }
260
+ }
261
+ /** Strip the free-text columns from a record on its way out of the machine.
262
+ * Applied at EXPORT time, never at record time — see the PRIVACY CONTRACT in
263
+ * `types.ts`. The row keeps its shape so a redacted export is still a valid,
264
+ * diffable trail; only what was typed or evaluated goes. */
265
+ export function redact(record) {
266
+ return {
267
+ ...record,
268
+ payload: record.payload === null ? null : '[redacted]',
269
+ title: record.title === null ? null : '[redacted]',
270
+ url: record.url === null ? null : redactUrl(record.url),
271
+ };
272
+ }
273
+ /** Keep origin + pathname, drop query string and fragment — that is where
274
+ * tokens, search terms and session ids live. A trail that says
275
+ * `https://app.example.com/orders` is still useful; one that says
276
+ * `?token=abc123` is a leak. */
277
+ function redactUrl(url) {
278
+ try {
279
+ const parsed = new URL(url);
280
+ return `${parsed.origin}${parsed.pathname}`;
281
+ }
282
+ catch {
283
+ return '[redacted]';
284
+ }
285
+ }
286
+ //# sourceMappingURL=queries.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"queries.js","sourceRoot":"","sources":["../../src/activity/queries.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,119 @@
1
+ /**
2
+ * The activity trail: one row per browser tool an agent dispatched.
3
+ *
4
+ * WHY THIS LIVES ON THE SERVER, NOT IN THE EXTENSION
5
+ * --------------------------------------------------
6
+ * The extension's flow history (`flow-registry.ts`) is a "what just happened
7
+ * on this tab" panel: `chrome.storage.session`, 20 entries per tab, gone when
8
+ * the browser closes. That is the right shape for the 320px popup and the
9
+ * wrong shape for an audit trail.
10
+ *
11
+ * The server already sees EVERY action — `handleBrowserTool` is the single
12
+ * funnel both transports pass through, extension and cdp-direct alike — and
13
+ * it has a real filesystem. Recording here means one hook instead of one per
14
+ * transport, no `chrome.storage` quota, and a trail that outlives the
15
+ * browser AND the server process.
16
+ *
17
+ * PRIVACY CONTRACT
18
+ * ----------------
19
+ * This is deliberately a DIFFERENT contract from the popup's.
20
+ *
21
+ * `flow-registry.ts` keeps counts and fixed enums only, because it renders
22
+ * into a UI the user did not ask for and cannot clear. This trail is the
23
+ * opposite: the user asked for it explicitly, it is theirs, it lives in
24
+ * their own data dir, and it is worthless for auditing an agent if it omits
25
+ * what the agent actually typed or evaluated.
26
+ *
27
+ * So `payload` DOES carry page-derived and agent-supplied text — the string
28
+ * that was typed, the expression that was evaluated, the URL navigated to.
29
+ * Three properties keep that honest:
30
+ *
31
+ * 1. It never leaves the machine on its own. Nothing uploads it.
32
+ * 2. Recording it is a config switch (`activity.record-payloads`), so a
33
+ * user who wants the shape of the trail without its contents can have
34
+ * exactly that.
35
+ * 3. Redaction happens on the way OUT (`activity export --redact`), not on
36
+ * the way in. The owner keeps the full trail; what they hand to someone
37
+ * else is a decision made at export time, once, deliberately — rather
38
+ * than a decision made silently at record time and impossible to undo.
39
+ */
40
+ /** Outcome of a dispatched tool. `error` is the failure branch of the same
41
+ * call, not a separate kind of event: an agent's failed click is exactly as
42
+ * interesting as a successful one when you are reconstructing what happened. */
43
+ export type ActivityOutcome = 'ok' | 'error';
44
+ /** Which wire the call travelled over. `null` for tools that never touch a
45
+ * tab (`browser.list_tabs`, `browser.my_tabs`), so the column can be read as
46
+ * "how this reached the page" rather than being forced to lie. */
47
+ export type ActivityTransport = 'extension' | 'cdp';
48
+ /** One dispatched tool call, as stored. Field order mirrors the table. */
49
+ export interface ActivityRecord {
50
+ /** Monotonic rowid. Also the pagination cursor — see `queries.ts`. */
51
+ id: number;
52
+ /** ISO-8601, UTC. */
53
+ at: string;
54
+ /** Full MCP tool name, e.g. `browser.click`. Never abbreviated: the trail
55
+ * is read by humans and by other agents, and `click` alone is ambiguous
56
+ * once flows and map tools are in the same list. */
57
+ tool: string;
58
+ /** browser-link tab id (`tab_1`, `cdp:TARGET-1`), or null for tabless tools. */
59
+ tabId: string | null;
60
+ transport: ActivityTransport | null;
61
+ /** Tab URL at dispatch time. The single most useful column when reading
62
+ * the trail back weeks later — a selector without a page is noise. */
63
+ url: string | null;
64
+ title: string | null;
65
+ /** Self-declared agent label from the claim layer ("claude-code",
66
+ * "opencode"), falling back to the caller's binary. What makes an exported
67
+ * trail useful across agents: it answers "which of them did this". */
68
+ agent: string | null;
69
+ /** The agent's process id. Disambiguates two runs of the same binary. */
70
+ agentPid: number | null;
71
+ /** CSS selector the call targeted, when the tool takes one. */
72
+ selector: string | null;
73
+ /** Agent-supplied or page-derived text: typed string, evaluate expression,
74
+ * navigate URL. Null when the tool has none, or when payload recording is
75
+ * off. See the PRIVACY CONTRACT above. */
76
+ payload: string | null;
77
+ outcome: ActivityOutcome;
78
+ /** Error message when `outcome` is `error`. Truncated, never a stack. */
79
+ error: string | null;
80
+ /** Wall-clock duration of the dispatch, milliseconds. */
81
+ durationMs: number;
82
+ /** Set when this call was a step inside a `browser.flow`, so the window can
83
+ * group a 40-step flow under one heading instead of flooding the timeline. */
84
+ flowId: string | null;
85
+ }
86
+ /** What the recorder is handed. `id` and `at` are assigned on write — a
87
+ * caller that could choose its own id could also rewrite history. */
88
+ export type ActivityInput = Omit<ActivityRecord, 'id' | 'at'>;
89
+ /** Filters for reading the trail back. Every field is optional and ANDed;
90
+ * the default is "the most recent page of everything". */
91
+ export interface ActivityQuery {
92
+ /** Return rows with `id` strictly greater than this. The cursor for the
93
+ * live-tailing panel: it keeps the last id it rendered and asks for more,
94
+ * exactly like `browser.events`' `since_id`. */
95
+ sinceId?: number;
96
+ /** Return rows with `id` strictly less than this — paging BACKWARDS through
97
+ * history, which is what the window's "load older" does. */
98
+ beforeId?: number;
99
+ tabId?: string;
100
+ agent?: string;
101
+ tool?: string;
102
+ flowId?: string;
103
+ outcome?: ActivityOutcome;
104
+ /** Rows at or after this ISO timestamp. */
105
+ since?: string;
106
+ /** Hard cap on rows returned. Clamped by the query layer — an unbounded
107
+ * read of a table with no ceiling is how a panel freezes a browser. */
108
+ limit?: number;
109
+ }
110
+ /** A page of the trail plus the cursor needed to ask for the next one. */
111
+ export interface ActivityPage {
112
+ records: ActivityRecord[];
113
+ /** Highest id in this page, or the request's `sinceId` when empty. Feed it
114
+ * back as `sinceId` to tail without re-reading what you already have. */
115
+ latestId: number;
116
+ /** Total rows matching the filter, ignoring `limit`. Lets the window say
117
+ * "showing 200 of 4813" instead of silently truncating. */
118
+ total: number;
119
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The activity trail: one row per browser tool an agent dispatched.
3
+ *
4
+ * WHY THIS LIVES ON THE SERVER, NOT IN THE EXTENSION
5
+ * --------------------------------------------------
6
+ * The extension's flow history (`flow-registry.ts`) is a "what just happened
7
+ * on this tab" panel: `chrome.storage.session`, 20 entries per tab, gone when
8
+ * the browser closes. That is the right shape for the 320px popup and the
9
+ * wrong shape for an audit trail.
10
+ *
11
+ * The server already sees EVERY action — `handleBrowserTool` is the single
12
+ * funnel both transports pass through, extension and cdp-direct alike — and
13
+ * it has a real filesystem. Recording here means one hook instead of one per
14
+ * transport, no `chrome.storage` quota, and a trail that outlives the
15
+ * browser AND the server process.
16
+ *
17
+ * PRIVACY CONTRACT
18
+ * ----------------
19
+ * This is deliberately a DIFFERENT contract from the popup's.
20
+ *
21
+ * `flow-registry.ts` keeps counts and fixed enums only, because it renders
22
+ * into a UI the user did not ask for and cannot clear. This trail is the
23
+ * opposite: the user asked for it explicitly, it is theirs, it lives in
24
+ * their own data dir, and it is worthless for auditing an agent if it omits
25
+ * what the agent actually typed or evaluated.
26
+ *
27
+ * So `payload` DOES carry page-derived and agent-supplied text — the string
28
+ * that was typed, the expression that was evaluated, the URL navigated to.
29
+ * Three properties keep that honest:
30
+ *
31
+ * 1. It never leaves the machine on its own. Nothing uploads it.
32
+ * 2. Recording it is a config switch (`activity.record-payloads`), so a
33
+ * user who wants the shape of the trail without its contents can have
34
+ * exactly that.
35
+ * 3. Redaction happens on the way OUT (`activity export --redact`), not on
36
+ * the way in. The owner keeps the full trail; what they hand to someone
37
+ * else is a decision made at export time, once, deliberately — rather
38
+ * than a decision made silently at record time and impossible to undo.
39
+ */
40
+ export {};
41
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/activity/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG"}
@@ -1,5 +1,5 @@
1
1
  import { WebSocket, WebSocketServer } from 'ws';
2
- import type { FlowRecordedPayload, ServerToExtension, SettingsUpdateMessage } from '../messages.js';
2
+ import type { ActivityPurgeMessage, ActivityQueryMessage, FlowRecordedPayload, ServerToExtension, SettingsUpdateMessage } from '../messages.js';
3
3
  import { type BridgeEventLog } from './events.js';
4
4
  export declare const WS_HOST = "127.0.0.1";
5
5
  export declare const WS_PORT = 17529;
@@ -103,6 +103,23 @@ export declare function handleFlowRecordedMessage(payload: FlowRecordedPayload,
103
103
  * connected — the caller surfaces the standard not-connected error from
104
104
  * the status call that follows, rather than two competing diagnoses.
105
105
  */
106
+ /**
107
+ * Answer one `activity.query` frame.
108
+ *
109
+ * Exported so it is unit-testable directly, same as
110
+ * `handleFlowRecordedMessage`. Never throws: a trail read that fails comes
111
+ * back as `ok: false` with a message the window can render, because a broken
112
+ * audit panel must not take the bridge connection down with it.
113
+ */
114
+ export declare function handleActivityQueryMessage(msg: ActivityQueryMessage): ServerToExtension;
115
+ /**
116
+ * Answer one `activity.purge` frame.
117
+ *
118
+ * `dry_run` is not a courtesy — it is how the window can promise the user a
119
+ * real number before an irreversible delete. Both paths return fresh stats so
120
+ * the panel's size readout updates from the same round trip that changed it.
121
+ */
122
+ export declare function handleActivityPurgeMessage(msg: ActivityPurgeMessage): ServerToExtension;
106
123
  export declare function sendToolCancel(tabs: Map<string, TabSession>, tabId: string, flowId: string): boolean;
107
124
  /** Build the callback that sends a tool.request frame to a specific tab and
108
125
  * resolves with the matching tool.response (or rejects on timeout). Kept here
@@ -1,4 +1,5 @@
1
1
  import { WebSocket, WebSocketServer } from 'ws';
2
+ import { countInRange as countActivityInRange, listAgents as listActivityAgents, purgeRange as purgeActivityRange, query as queryActivity, stats as activityStats, } from '../activity/queries.js';
2
3
  import { isAllowedBrowser } from '../auth/allowlist.js';
3
4
  import { lookupPeerProcess } from '../auth/process-identity.js';
4
5
  import { VERSION } from '../version.js';
@@ -221,6 +222,27 @@ export function startWsBridge(tabs, pendingRequests, events) {
221
222
  send(ws, result);
222
223
  return;
223
224
  }
225
+ if (msg.kind === 'activity.purge') {
226
+ // The one DESTRUCTIVE frame the extension can send, and irreversible.
227
+ // The window resolves a count with `dry_run` first and puts that
228
+ // number in front of the user before this ever arrives for real.
229
+ send(ws, handleActivityPurgeMessage(msg));
230
+ return;
231
+ }
232
+ if (msg.kind === 'activity.query') {
233
+ // Read-only and deliberately NOT scoped to `assignedTabId`, unlike
234
+ // `flow.recorded` above. The Activity window's whole job is showing
235
+ // what happened across every tab and every agent, and it reaches the
236
+ // bridge through whichever tab's socket happens to be open. A
237
+ // per-connection scope here would silently hide most of the trail.
238
+ //
239
+ // The connection is already authenticated (browser allowlist + peer
240
+ // process identity at handshake), and the trail never leaves the
241
+ // machine, so "any authenticated extension connection may read the
242
+ // local trail" is the intended boundary.
243
+ send(ws, handleActivityQueryMessage(msg));
244
+ return;
245
+ }
224
246
  });
225
247
  ws.on('close', () => {
226
248
  if (assignedTabId) {
@@ -397,6 +419,91 @@ export function handleFlowRecordedMessage(payload, events) {
397
419
  * connected — the caller surfaces the standard not-connected error from
398
420
  * the status call that follows, rather than two competing diagnoses.
399
421
  */
422
+ /**
423
+ * Answer one `activity.query` frame.
424
+ *
425
+ * Exported so it is unit-testable directly, same as
426
+ * `handleFlowRecordedMessage`. Never throws: a trail read that fails comes
427
+ * back as `ok: false` with a message the window can render, because a broken
428
+ * audit panel must not take the bridge connection down with it.
429
+ */
430
+ export function handleActivityQueryMessage(msg) {
431
+ try {
432
+ const q = msg.query ?? {};
433
+ const page = queryActivity({
434
+ ...(typeof q.since_id === 'number' ? { sinceId: q.since_id } : {}),
435
+ ...(typeof q.before_id === 'number' ? { beforeId: q.before_id } : {}),
436
+ ...(typeof q.tab_id === 'string' ? { tabId: q.tab_id } : {}),
437
+ ...(typeof q.agent === 'string' ? { agent: q.agent } : {}),
438
+ ...(typeof q.tool === 'string' ? { tool: q.tool } : {}),
439
+ ...(typeof q.flow_id === 'string' ? { flowId: q.flow_id } : {}),
440
+ ...(q.outcome === 'ok' || q.outcome === 'error' ? { outcome: q.outcome } : {}),
441
+ ...(typeof q.limit === 'number' ? { limit: q.limit } : {}),
442
+ });
443
+ return {
444
+ kind: 'activity.result',
445
+ request_id: msg.request_id,
446
+ ok: true,
447
+ records: page.records,
448
+ latest_id: page.latestId,
449
+ total: page.total,
450
+ agents: listActivityAgents(),
451
+ stats: toStatsPayload(activityStats()),
452
+ };
453
+ }
454
+ catch (error) {
455
+ return {
456
+ kind: 'activity.result',
457
+ request_id: msg.request_id,
458
+ ok: false,
459
+ error: error instanceof Error ? error.message : String(error),
460
+ };
461
+ }
462
+ }
463
+ /** camelCase store shape → snake_case wire shape. One mapper so the two can
464
+ * never drift into reporting different numbers under the same name. */
465
+ function toStatsPayload(s) {
466
+ return {
467
+ rows: s.rows,
468
+ bytes: s.bytes,
469
+ oldest_at: s.oldestAt,
470
+ newest_at: s.newestAt,
471
+ max_rows: s.maxRows,
472
+ };
473
+ }
474
+ /**
475
+ * Answer one `activity.purge` frame.
476
+ *
477
+ * `dry_run` is not a courtesy — it is how the window can promise the user a
478
+ * real number before an irreversible delete. Both paths return fresh stats so
479
+ * the panel's size readout updates from the same round trip that changed it.
480
+ */
481
+ export function handleActivityPurgeMessage(msg) {
482
+ try {
483
+ const range = {
484
+ ...(typeof msg.from === 'string' ? { from: msg.from } : {}),
485
+ ...(typeof msg.to === 'string' ? { to: msg.to } : {}),
486
+ };
487
+ const dryRun = msg.dry_run === true;
488
+ const removed = dryRun ? countActivityInRange(range) : purgeActivityRange(range);
489
+ return {
490
+ kind: 'activity.purge.result',
491
+ request_id: msg.request_id,
492
+ ok: true,
493
+ removed,
494
+ dry_run: dryRun,
495
+ stats: toStatsPayload(activityStats()),
496
+ };
497
+ }
498
+ catch (error) {
499
+ return {
500
+ kind: 'activity.purge.result',
501
+ request_id: msg.request_id,
502
+ ok: false,
503
+ error: error instanceof Error ? error.message : String(error),
504
+ };
505
+ }
506
+ }
400
507
  export function sendToolCancel(tabs, tabId, flowId) {
401
508
  const session = tabs.get(tabId);
402
509
  if (!session || session.ws.readyState !== WebSocket.OPEN)