claude4arc 0.5.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 +275 -0
- package/bin/claude4arc.js +405 -0
- package/extension/background.js +153 -0
- package/extension/guard.js +66 -0
- package/extension/icons/icon-128.png +0 -0
- package/extension/icons/icon-16.png +0 -0
- package/extension/icons/icon-32.png +0 -0
- package/extension/icons/icon-48.png +0 -0
- package/extension/manifest.json +41 -0
- package/host/host.js +368 -0
- package/lib/blocklist.js +52 -0
- package/lib/browsers.js +56 -0
- package/lib/client.js +89 -0
- package/lib/commands.js +353 -0
- package/lib/config.js +22 -0
- package/lib/dnd.js +60 -0
- package/lib/editors.js +373 -0
- package/lib/frames.js +39 -0
- package/lib/housekeeping.js +46 -0
- package/lib/inpage.js +1381 -0
- package/lib/input.js +242 -0
- package/lib/keys.js +109 -0
- package/lib/page.js +1530 -0
- package/lib/paths.js +10 -0
- package/lib/shim.js +186 -0
- package/lib/task.js +350 -0
- package/lib/util.js +64 -0
- package/package.json +43 -0
- package/skill/SKILL.md +138 -0
package/lib/paths.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
6
|
+
export const STATE_DIR = path.join(os.homedir(), ".arc-bridge");
|
|
7
|
+
export const LOG_PATH = path.join(STATE_DIR, "host.log");
|
|
8
|
+
export const TASKS_PATH = path.join(STATE_DIR, "tasks.json");
|
|
9
|
+
export const HOST_NAME = "com.arcforclaude.bridge";
|
|
10
|
+
export const EXTENSION_ID = "bfbcdjkeonepklbmjjghoddpahhhnimp";
|
package/lib/shim.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
function agentShim(version) {
|
|
4
|
+
if (window.__arcShim?.version === version) return;
|
|
5
|
+
const state = { log: [], policy: null };
|
|
6
|
+
const opens = [];
|
|
7
|
+
const stubs = new Map();
|
|
8
|
+
const shim = {
|
|
9
|
+
version,
|
|
10
|
+
dialogs: state,
|
|
11
|
+
opens,
|
|
12
|
+
deliver(id, data, origin) {
|
|
13
|
+
const stub = stubs.get(id);
|
|
14
|
+
const event = new MessageEvent("message", { data, origin });
|
|
15
|
+
if (stub) Object.defineProperty(event, "source", { value: stub });
|
|
16
|
+
window.dispatchEvent(event);
|
|
17
|
+
return Boolean(stub);
|
|
18
|
+
},
|
|
19
|
+
closeStub(id) {
|
|
20
|
+
const stub = stubs.get(id);
|
|
21
|
+
if (stub) stub.closed = true;
|
|
22
|
+
},
|
|
23
|
+
setStubUrl(id, url) {
|
|
24
|
+
const entry = stubs.get(id)?.entry;
|
|
25
|
+
if (entry) entry.url = url;
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
Object.defineProperty(window, "__arcShim", { value: shim, configurable: true, writable: true });
|
|
29
|
+
const current = () => window.__arcShim === shim;
|
|
30
|
+
const events = [];
|
|
31
|
+
shim.events = events;
|
|
32
|
+
const note = (kind, detail) => {
|
|
33
|
+
events.push({ kind, detail: String(detail ?? "").slice(0, 160) });
|
|
34
|
+
};
|
|
35
|
+
const answer = (type, message, fallback) => {
|
|
36
|
+
const policy = state.policy;
|
|
37
|
+
state.policy = null;
|
|
38
|
+
const accepted = type === "alert" ? true : Boolean(policy?.accept);
|
|
39
|
+
state.log.push({ type, message: String(message ?? ""), accepted });
|
|
40
|
+
if (type === "confirm") return accepted;
|
|
41
|
+
if (type === "prompt") return accepted ? String(policy.text ?? fallback ?? "") : null;
|
|
42
|
+
};
|
|
43
|
+
window.alert = function alert(message) {
|
|
44
|
+
answer("alert", message);
|
|
45
|
+
};
|
|
46
|
+
window.confirm = function confirm(message) {
|
|
47
|
+
return answer("confirm", message);
|
|
48
|
+
};
|
|
49
|
+
window.prompt = function prompt(message, fallback) {
|
|
50
|
+
return answer("prompt", message, fallback);
|
|
51
|
+
};
|
|
52
|
+
const absolute = (url) => {
|
|
53
|
+
try {
|
|
54
|
+
return new URL(url, location.href).href;
|
|
55
|
+
} catch {
|
|
56
|
+
return String(url);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
const namedFrameExists = (name) => {
|
|
60
|
+
const selector = `iframe[name="${CSS.escape(name)}"], frame[name="${CSS.escape(name)}"]`;
|
|
61
|
+
if (document.querySelector(selector)) return true;
|
|
62
|
+
try {
|
|
63
|
+
if (window.top.document.querySelector(selector)) return true;
|
|
64
|
+
} catch {}
|
|
65
|
+
let view = window;
|
|
66
|
+
try {
|
|
67
|
+
while (view) {
|
|
68
|
+
if (view.name === name) return true;
|
|
69
|
+
if (view === view.parent) break;
|
|
70
|
+
view = view.parent;
|
|
71
|
+
}
|
|
72
|
+
} catch {}
|
|
73
|
+
return false;
|
|
74
|
+
};
|
|
75
|
+
const opensWindow = (target) => {
|
|
76
|
+
const name = String(target || "");
|
|
77
|
+
const lower = name.toLowerCase();
|
|
78
|
+
if (!name || lower === "_self" || lower === "_parent" || lower === "_top") return false;
|
|
79
|
+
if (lower === "_blank") return true;
|
|
80
|
+
return !namedFrameExists(name);
|
|
81
|
+
};
|
|
82
|
+
const nativeOpen = window.open;
|
|
83
|
+
window.open = function open(url, target) {
|
|
84
|
+
if (!current() || (target && !opensWindow(target))) return nativeOpen.apply(window, arguments);
|
|
85
|
+
const entry = { kind: "window.open", url: url ? absolute(url) : "about:blank", id: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}` };
|
|
86
|
+
opens.push(entry);
|
|
87
|
+
const setUrl = (value) => {
|
|
88
|
+
entry.url = absolute(value);
|
|
89
|
+
};
|
|
90
|
+
const place = { assign: setUrl, replace: setUrl, reload() {} };
|
|
91
|
+
Object.defineProperty(place, "href", { get: () => entry.url, set: setUrl });
|
|
92
|
+
const stub = { closed: false, opener: window, entry, focus() {}, blur() {}, postMessage() {}, close() { stub.closed = true; } };
|
|
93
|
+
Object.defineProperty(stub, "location", { get: () => place, set: setUrl });
|
|
94
|
+
stubs.set(entry.id, stub);
|
|
95
|
+
return stub;
|
|
96
|
+
};
|
|
97
|
+
const retarget = (form, submitter) => {
|
|
98
|
+
if (!current()) return;
|
|
99
|
+
const target = submitter?.getAttribute?.("formtarget") || form.getAttribute("target");
|
|
100
|
+
if (!opensWindow(target)) return;
|
|
101
|
+
const url = new URL(absolute(form.getAttribute("action") || location.href));
|
|
102
|
+
if ((form.method || "get").toLowerCase() === "get") url.search = new URLSearchParams(new FormData(form, submitter ?? null)).toString();
|
|
103
|
+
opens.push({ kind: "form", url: url.href, method: (form.method || "get").toLowerCase() });
|
|
104
|
+
form.setAttribute("target", "_top");
|
|
105
|
+
if (submitter?.hasAttribute?.("formtarget")) submitter.setAttribute("formtarget", "_top");
|
|
106
|
+
};
|
|
107
|
+
addEventListener("submit", (event) => retarget(event.target, event.submitter), true);
|
|
108
|
+
const nativeSubmit = HTMLFormElement.prototype.submit;
|
|
109
|
+
HTMLFormElement.prototype.submit = function submit() {
|
|
110
|
+
retarget(this);
|
|
111
|
+
return nativeSubmit.call(this);
|
|
112
|
+
};
|
|
113
|
+
addEventListener("click", (event) => {
|
|
114
|
+
if (!current() || event.defaultPrevented || event.button !== 0) return;
|
|
115
|
+
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
116
|
+
const anchor = event.composedPath().find((node) => node instanceof Element && node.matches("a[href], area[href]"));
|
|
117
|
+
if (!anchor || !anchor.href || anchor.href.startsWith("javascript:")) return;
|
|
118
|
+
if (!/^(https?|blob|data|about):/i.test(anchor.href)) {
|
|
119
|
+
event.preventDefault();
|
|
120
|
+
note("external link", anchor.href);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (anchor.hasAttribute("download") || !opensWindow(anchor.getAttribute("target"))) return;
|
|
124
|
+
event.preventDefault();
|
|
125
|
+
opens.push({ kind: "link", url: anchor.href });
|
|
126
|
+
});
|
|
127
|
+
const clipboard = { text: "" };
|
|
128
|
+
shim.clipboard = clipboard;
|
|
129
|
+
const fakeClipboard = {
|
|
130
|
+
writeText: async (text) => {
|
|
131
|
+
clipboard.text = String(text);
|
|
132
|
+
note("clipboard", clipboard.text);
|
|
133
|
+
},
|
|
134
|
+
write: async () => {
|
|
135
|
+
note("clipboard", "(rich content)");
|
|
136
|
+
},
|
|
137
|
+
readText: async () => clipboard.text,
|
|
138
|
+
read: async () => [],
|
|
139
|
+
};
|
|
140
|
+
try {
|
|
141
|
+
Object.defineProperty(navigator, "clipboard", { value: fakeClipboard, configurable: true });
|
|
142
|
+
} catch {}
|
|
143
|
+
const nativeExec = document.execCommand.bind(document);
|
|
144
|
+
document.execCommand = function execCommand(command, ...rest) {
|
|
145
|
+
if (current() && /^(copy|cut)$/i.test(command)) {
|
|
146
|
+
clipboard.text = String(document.getSelection() ?? "");
|
|
147
|
+
note("clipboard", clipboard.text);
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
return nativeExec(command, ...rest);
|
|
151
|
+
};
|
|
152
|
+
window.print = function print() {
|
|
153
|
+
note("print dialog", location.href);
|
|
154
|
+
};
|
|
155
|
+
const blockFullscreen = function () {
|
|
156
|
+
note("fullscreen", this?.tagName ?? "document");
|
|
157
|
+
return Promise.resolve();
|
|
158
|
+
};
|
|
159
|
+
for (const name of ["requestFullscreen", "webkitRequestFullscreen", "webkitRequestFullScreen"]) {
|
|
160
|
+
if (Element.prototype[name]) Element.prototype[name] = blockFullscreen;
|
|
161
|
+
}
|
|
162
|
+
if (navigator.share) navigator.share = async (data) => note("share sheet", JSON.stringify(data ?? {}));
|
|
163
|
+
if (window.Notification) window.Notification.requestPermission = async () => {
|
|
164
|
+
note("permission", "notifications denied");
|
|
165
|
+
return "denied";
|
|
166
|
+
};
|
|
167
|
+
if (navigator.geolocation) {
|
|
168
|
+
const deny = (success, failure) => {
|
|
169
|
+
note("permission", "location denied");
|
|
170
|
+
failure?.({ code: 1, message: "User denied Geolocation", PERMISSION_DENIED: 1 });
|
|
171
|
+
return 0;
|
|
172
|
+
};
|
|
173
|
+
navigator.geolocation.getCurrentPosition = deny;
|
|
174
|
+
navigator.geolocation.watchPosition = deny;
|
|
175
|
+
}
|
|
176
|
+
if (navigator.mediaDevices?.getUserMedia) {
|
|
177
|
+
navigator.mediaDevices.getUserMedia = async () => {
|
|
178
|
+
note("permission", "camera/microphone denied");
|
|
179
|
+
throw new DOMException("Permission denied", "NotAllowedError");
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const SHIM_SOURCE = agentShim.toString();
|
|
185
|
+
|
|
186
|
+
export const AGENT_SHIM = `(${SHIM_SOURCE})(${JSON.stringify(createHash("sha1").update(SHIM_SOURCE).digest("hex").slice(0, 10))});`;
|
package/lib/task.js
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import { blockedBy, blockedError, readBlocklist } from "./blocklist.js";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { Page } from "./page.js";
|
|
6
|
+
import { normalizeUrl } from "./util.js";
|
|
7
|
+
import { TASKS_PATH } from "./paths.js";
|
|
8
|
+
|
|
9
|
+
async function loadState() {
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(await fs.readFile(TASKS_PATH, "utf8"));
|
|
12
|
+
} catch {
|
|
13
|
+
return { nextId: 1, tasks: {} };
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function writeState(state) {
|
|
18
|
+
await fs.mkdir(path.dirname(TASKS_PATH), { recursive: true, mode: 0o700 });
|
|
19
|
+
const temp = `${TASKS_PATH}.${process.pid}.${Date.now()}.tmp`;
|
|
20
|
+
await fs.writeFile(temp, JSON.stringify(state, null, 2));
|
|
21
|
+
await fs.rename(temp, TASKS_PATH);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function saveRecord(id, record) {
|
|
25
|
+
const state = await loadState();
|
|
26
|
+
if (record) state.tasks[String(id)] = record;
|
|
27
|
+
else delete state.tasks[String(id)];
|
|
28
|
+
state.nextId = Math.max(state.nextId, Number(id) + 1);
|
|
29
|
+
await writeState(state);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function allocateId() {
|
|
33
|
+
const directory = path.join(path.dirname(TASKS_PATH), "task-ids");
|
|
34
|
+
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
35
|
+
const state = await loadState();
|
|
36
|
+
const used = new Set(await fs.readdir(directory));
|
|
37
|
+
let id = state.nextId;
|
|
38
|
+
while (true) {
|
|
39
|
+
if (!used.has(String(id))) {
|
|
40
|
+
try {
|
|
41
|
+
await fs.writeFile(path.join(directory, String(id)), "", { flag: "wx" });
|
|
42
|
+
return id;
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if (error.code !== "EEXIST") throw error;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
id++;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const OWNER_SOURCE = process.env.CLAUDE_JOB_DIR ?? process.env.CLAUDE_CODE_MESSAGING_SOCKET ?? null;
|
|
52
|
+
const OWNER = OWNER_SOURCE ? createHash("sha1").update(OWNER_SOURCE).digest("hex").slice(0, 12) : null;
|
|
53
|
+
|
|
54
|
+
function summarizeTab(tab) {
|
|
55
|
+
return {
|
|
56
|
+
tabId: tab.id,
|
|
57
|
+
windowId: tab.windowId,
|
|
58
|
+
title: tab.title,
|
|
59
|
+
url: tab.url,
|
|
60
|
+
active: tab.active,
|
|
61
|
+
pinned: tab.pinned,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function browserOfTask(id) {
|
|
66
|
+
const state = await loadState();
|
|
67
|
+
return state.tasks[String(id)]?.browser ?? null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class Task {
|
|
71
|
+
static async open(bridge, nameOrId, { active = false, url } = {}) {
|
|
72
|
+
const state = await loadState();
|
|
73
|
+
if (typeof nameOrId === "number" || /^\d+$/.test(String(nameOrId))) {
|
|
74
|
+
const record = state.tasks[String(nameOrId)];
|
|
75
|
+
if (!record) throw new Error(`Task ${nameOrId} does not exist. Use the id printed by \`claude4arc new\`, or run \`claude4arc status\`.`);
|
|
76
|
+
if (record.owner && OWNER && record.owner !== OWNER && !process.env.CLAUDE4ARC_ANY_TASK && !process.env.ARC_BROWSER_ANY_TASK) {
|
|
77
|
+
throw new Error(`Task ${nameOrId} belongs to another Claude session. Use the id printed by your own \`claude4arc new\`.`);
|
|
78
|
+
}
|
|
79
|
+
if (record.browser && bridge.browser && record.browser !== bridge.browser) {
|
|
80
|
+
throw new Error(`Task ${nameOrId} runs in ${record.browser}, but this command is connected to ${bridge.browser}.`);
|
|
81
|
+
}
|
|
82
|
+
const task = new Task(bridge, state, record);
|
|
83
|
+
await task.#prune();
|
|
84
|
+
return task;
|
|
85
|
+
}
|
|
86
|
+
const record = {
|
|
87
|
+
id: await allocateId(),
|
|
88
|
+
name: String(nameOrId ?? "task"),
|
|
89
|
+
createdAt: new Date().toISOString(),
|
|
90
|
+
owner: OWNER,
|
|
91
|
+
browser: bridge.browser,
|
|
92
|
+
nextLabel: 1,
|
|
93
|
+
pages: {},
|
|
94
|
+
};
|
|
95
|
+
state.tasks[String(record.id)] = record;
|
|
96
|
+
const task = new Task(bridge, state, record);
|
|
97
|
+
await task.newPage({ active, url });
|
|
98
|
+
return task;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
#state;
|
|
102
|
+
#record;
|
|
103
|
+
#pages = new Map();
|
|
104
|
+
#popupWatchers = new Set();
|
|
105
|
+
#finished = false;
|
|
106
|
+
#saving = Promise.resolve();
|
|
107
|
+
|
|
108
|
+
constructor(bridge, state, record) {
|
|
109
|
+
this.bridge = bridge;
|
|
110
|
+
this.#state = state;
|
|
111
|
+
this.#record = record;
|
|
112
|
+
bridge.on((message) => this.#dispatch(message));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
get spaceId() {
|
|
116
|
+
return this.#record.id;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
get name() {
|
|
120
|
+
return this.#record.name;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
#dispatch(message) {
|
|
124
|
+
if (message.type === "tabCreated") {
|
|
125
|
+
for (const watcher of this.#popupWatchers) watcher(message.tab);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (message.type === "tabRemoved") {
|
|
129
|
+
const label = this.#labelForTab(message.tabId);
|
|
130
|
+
if (label) this._forget(label);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const page = this.#pageForTab(message.tabId);
|
|
134
|
+
page?._onEvent(message);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
#labelForTab(tabId) {
|
|
138
|
+
return Object.entries(this.#record.pages).find(([, entry]) => entry.tabId === tabId)?.[0];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
#pageForTab(tabId) {
|
|
142
|
+
const label = this.#labelForTab(tabId);
|
|
143
|
+
return label ? this.page(label) : null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
#save() {
|
|
147
|
+
this.#saving = this.#saving
|
|
148
|
+
.catch(() => {})
|
|
149
|
+
.then(() => {
|
|
150
|
+
if (this.#finished) return;
|
|
151
|
+
return saveRecord(this.#record.id, this.#record);
|
|
152
|
+
});
|
|
153
|
+
return this.#saving;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async #prune() {
|
|
157
|
+
const tabs = await this.bridge.call("tabs.query", {});
|
|
158
|
+
const alive = new Set(tabs.map((tab) => tab.id));
|
|
159
|
+
let changed = false;
|
|
160
|
+
for (const [label, entry] of Object.entries(this.#record.pages)) {
|
|
161
|
+
if (!alive.has(entry.tabId)) {
|
|
162
|
+
delete this.#record.pages[label];
|
|
163
|
+
changed = true;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (changed) await this.#save();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async #register(tab, openedBy, as) {
|
|
170
|
+
const label = as ?? `p${this.#record.nextLabel++}`;
|
|
171
|
+
if (this.#record.pages[label]) throw new Error(`Label ${label} is already in use.`);
|
|
172
|
+
this.#record.pages[label] = { tabId: tab.id, openedBy };
|
|
173
|
+
this.#record.current = label;
|
|
174
|
+
await this.#save();
|
|
175
|
+
return this.page(label);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
get current() {
|
|
179
|
+
return this.#record.pages[this.#record.current] ? this.#record.current : Object.keys(this.#record.pages).at(-1) ?? null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async use(label) {
|
|
183
|
+
this.page(label);
|
|
184
|
+
if (this.#record.current !== label) {
|
|
185
|
+
this.#record.current = label;
|
|
186
|
+
await this.#save();
|
|
187
|
+
}
|
|
188
|
+
return this.page(label);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
page(label = this.current) {
|
|
192
|
+
const entry = this.#record.pages[label];
|
|
193
|
+
if (!entry) {
|
|
194
|
+
const known = Object.keys(this.#record.pages).join(", ") || "none";
|
|
195
|
+
throw new Error(`Task ${this.spaceId} has no page ${label}. Known pages: ${known}.`);
|
|
196
|
+
}
|
|
197
|
+
let page = this.#pages.get(label);
|
|
198
|
+
if (!page || page.tabId !== entry.tabId) {
|
|
199
|
+
page = new Page(this, label, entry.tabId, entry.openedBy);
|
|
200
|
+
this.#pages.set(label, page);
|
|
201
|
+
}
|
|
202
|
+
return page;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async pages() {
|
|
206
|
+
await this.#prune();
|
|
207
|
+
return Object.keys(this.#record.pages).map((label) => this.page(label));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async tabs() {
|
|
211
|
+
const tabs = await this.bridge.call("tabs.query", {});
|
|
212
|
+
return tabs.map((tab) => {
|
|
213
|
+
const label = this.#labelForTab(tab.id);
|
|
214
|
+
return {
|
|
215
|
+
label: label ?? null,
|
|
216
|
+
openedBy: label ? this.#record.pages[label].openedBy : "unknown",
|
|
217
|
+
...summarizeTab(tab),
|
|
218
|
+
};
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async userTab() {
|
|
223
|
+
const [tab] = await this.bridge.call("tabs.query", { active: true, lastFocusedWindow: true });
|
|
224
|
+
if (!tab) return null;
|
|
225
|
+
const label = this.#labelForTab(tab.id);
|
|
226
|
+
return { label: label ?? null, ...summarizeTab(tab) };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async newPage({ url, active = false, timeout = 30_000, opener = null } = {}) {
|
|
230
|
+
const pattern = url ? blockedBy(normalizeUrl(url), readBlocklist()) : null;
|
|
231
|
+
if (pattern) throw blockedError(normalizeUrl(url), pattern);
|
|
232
|
+
if (opener && url) {
|
|
233
|
+
const tab = await this.bridge.call("tabs.create", { url: "about:blank", active });
|
|
234
|
+
await this.bridge.call("guard.enable", tab.id).catch(() => {});
|
|
235
|
+
this.bridge.call("tabs.update", tab.id, { muted: true }).catch(() => {});
|
|
236
|
+
const page = await this.#register(tab, "agent");
|
|
237
|
+
await this.bridge.call("host.linkPopup", tab.id, opener);
|
|
238
|
+
await page.goto(url, { waitUntil: "commit", timeout }).catch(() => {});
|
|
239
|
+
await page._waitForTabLoad(Math.min(timeout, 10_000)).catch(() => {});
|
|
240
|
+
return page;
|
|
241
|
+
}
|
|
242
|
+
const tab = await this.bridge.call("tabs.create", { url: url ? normalizeUrl(url) : "about:blank", active });
|
|
243
|
+
this.bridge.call("guard.enable", tab.id).catch(() => {});
|
|
244
|
+
this.bridge.call("tabs.update", tab.id, { muted: true }).catch(() => {});
|
|
245
|
+
const page = await this.#register(tab, "agent");
|
|
246
|
+
if (url && url !== "about:blank") {
|
|
247
|
+
await page._waitForTabLoad(timeout);
|
|
248
|
+
await page.settle({ quiet: 100, max: 1_000 }).catch(() => {});
|
|
249
|
+
}
|
|
250
|
+
return page;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async adopt(target, { as } = {}) {
|
|
254
|
+
const tabId = typeof target === "number" ? target : target?.tabId ?? target?.id;
|
|
255
|
+
if (typeof tabId !== "number") throw new Error("adopt() needs a tab id or a tab object from tabs() or userTab().");
|
|
256
|
+
const existing = this.#labelForTab(tabId);
|
|
257
|
+
if (existing) return this.page(existing);
|
|
258
|
+
const tab = await this.bridge.call("tabs.get", tabId);
|
|
259
|
+
const pattern = blockedBy(tab.url, readBlocklist());
|
|
260
|
+
if (pattern) throw blockedError(tab.url, pattern);
|
|
261
|
+
return this.#register(tab, "user", as);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async release(label) {
|
|
265
|
+
const page = this.page(label);
|
|
266
|
+
await page._detach();
|
|
267
|
+
this._forget(label);
|
|
268
|
+
await this.#save();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
_forget(label) {
|
|
272
|
+
delete this.#record.pages[label];
|
|
273
|
+
this.#pages.delete(label);
|
|
274
|
+
this.#save().catch(() => {});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
_watchPopups(opener, onPopup) {
|
|
278
|
+
const watcher = async (tab) => {
|
|
279
|
+
if (tab.openerTabId !== opener.tabId) return;
|
|
280
|
+
const page = await this.#register(tab, "agent");
|
|
281
|
+
onPopup(page);
|
|
282
|
+
};
|
|
283
|
+
this.#popupWatchers.add(watcher);
|
|
284
|
+
return () => this.#popupWatchers.delete(watcher);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
_waitForPopup(opener, timeout) {
|
|
288
|
+
return new Promise((resolve, reject) => {
|
|
289
|
+
const stop = this._watchPopups(opener, (page) => {
|
|
290
|
+
clearTimeout(timer);
|
|
291
|
+
stop();
|
|
292
|
+
resolve(page);
|
|
293
|
+
});
|
|
294
|
+
const timer = setTimeout(() => {
|
|
295
|
+
stop();
|
|
296
|
+
reject(new Error(`No popup from page ${opener.label} within ${timeout}ms`));
|
|
297
|
+
}, timeout);
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async finish({ keep = [] } = {}) {
|
|
302
|
+
const kept = [];
|
|
303
|
+
const closed = [];
|
|
304
|
+
const released = [];
|
|
305
|
+
const work = [];
|
|
306
|
+
for (const [label, entry] of Object.entries({ ...this.#record.pages })) {
|
|
307
|
+
if (keep.includes(label)) {
|
|
308
|
+
kept.push(label);
|
|
309
|
+
work.push(this.page(label)._detach());
|
|
310
|
+
} else if (entry.openedBy === "agent") {
|
|
311
|
+
closed.push(label);
|
|
312
|
+
work.push(this.bridge.call("tabs.remove", entry.tabId).catch(() => {}));
|
|
313
|
+
} else {
|
|
314
|
+
released.push(label);
|
|
315
|
+
work.push(this.page(label)._detach());
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
await Promise.all(work);
|
|
319
|
+
this.#finished = true;
|
|
320
|
+
await this.#saving.catch(() => {});
|
|
321
|
+
await saveRecord(this.#record.id, null);
|
|
322
|
+
this.#record.pages = {};
|
|
323
|
+
return { spaceId: this.spaceId, closed, kept, released };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async _settleDialogs() {
|
|
327
|
+
const notes = [];
|
|
328
|
+
for (const page of this.#pages.values()) {
|
|
329
|
+
const dialog = page.dialog;
|
|
330
|
+
if (!dialog) continue;
|
|
331
|
+
await page.dismissDialog().catch(() => {});
|
|
332
|
+
notes.push(`${page.label}: ${dialog.type} "${dialog.message}" was dismissed. Chain "-- accept [text]" after the action to accept it.`);
|
|
333
|
+
}
|
|
334
|
+
return notes;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
toJSON() {
|
|
338
|
+
return { spaceId: this.spaceId, name: this.name, pages: Object.keys(this.#record.pages) };
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export async function listTasks() {
|
|
343
|
+
const state = await loadState();
|
|
344
|
+
return Object.values(state.tasks).map((record) => ({
|
|
345
|
+
spaceId: record.id,
|
|
346
|
+
name: record.name,
|
|
347
|
+
createdAt: record.createdAt,
|
|
348
|
+
pages: Object.fromEntries(Object.entries(record.pages).map(([label, entry]) => [label, entry.tabId])),
|
|
349
|
+
}));
|
|
350
|
+
}
|
package/lib/util.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
|
|
4
|
+
export const run = promisify(execFile);
|
|
5
|
+
export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
6
|
+
export const TRANSIENT =
|
|
7
|
+
/Execution context was destroyed|Cannot find default execution context|Inspected target navigated|Cannot find context with specified id|No frame with given id|Target closed|Debugger is not attached/i;
|
|
8
|
+
|
|
9
|
+
export function withTimeout(promise, ms, message) {
|
|
10
|
+
let timer;
|
|
11
|
+
return Promise.race([
|
|
12
|
+
promise,
|
|
13
|
+
new Promise((_, reject) => {
|
|
14
|
+
timer = setTimeout(() => reject(new Error(message)), ms);
|
|
15
|
+
}),
|
|
16
|
+
]).finally(() => clearTimeout(timer));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function poll(check, { timeout, interval = 50, message }) {
|
|
20
|
+
const deadline = Date.now() + timeout;
|
|
21
|
+
let lastError = null;
|
|
22
|
+
while (true) {
|
|
23
|
+
try {
|
|
24
|
+
const value = await check();
|
|
25
|
+
if (value) return value;
|
|
26
|
+
lastError = null;
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if (error.fatal) throw error;
|
|
29
|
+
lastError = error;
|
|
30
|
+
}
|
|
31
|
+
if (Date.now() >= deadline) {
|
|
32
|
+
throw new Error(lastError && !TRANSIENT.test(lastError.message) ? `${message}: ${lastError.message}` : message);
|
|
33
|
+
}
|
|
34
|
+
await sleep(interval);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function fatal(error) {
|
|
39
|
+
error.fatal = true;
|
|
40
|
+
return error;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function normalizeUrl(url) {
|
|
44
|
+
if (/^(localhost|\d+\.\d+\.\d+\.\d+|\[[0-9a-f:]+\])(:\d+)?(\/|$)/i.test(url)) return `http://${url}`;
|
|
45
|
+
if (/^[a-z][a-z0-9+.-]*:(?!\d)/i.test(url)) return url;
|
|
46
|
+
return `https://${url}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function urlMatches(url, matcher) {
|
|
50
|
+
if (matcher instanceof RegExp) return matcher.test(url);
|
|
51
|
+
if (typeof matcher === "function") return Boolean(matcher(url));
|
|
52
|
+
if (matcher.includes("*")) {
|
|
53
|
+
const pattern = matcher.split("*").map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*");
|
|
54
|
+
return new RegExp(`^${pattern}$`).test(url);
|
|
55
|
+
}
|
|
56
|
+
return url === matcher || url.includes(matcher);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function pbcopy(text) {
|
|
60
|
+
return new Promise((resolve, reject) => {
|
|
61
|
+
const child = execFile("pbcopy", (error) => (error ? reject(error) : resolve()));
|
|
62
|
+
child.stdin.end(text);
|
|
63
|
+
});
|
|
64
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "claude4arc",
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Let Claude Code drive your real Arc browser through a local extension bridge.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"claude4arc": "bin/claude4arc.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "node --test \"tests/unit/*.test.js\"",
|
|
11
|
+
"test:e2e": "node tests/run.js",
|
|
12
|
+
"check": "for f in bin/*.js lib/*.js host/*.js extension/*.js tests/*.js tests/unit/*.js bench/*.js scripts/*.js; do node --check \"$f\" || exit 1; done",
|
|
13
|
+
"package:extension": "node scripts/package-extension.js"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"bin",
|
|
17
|
+
"lib",
|
|
18
|
+
"host",
|
|
19
|
+
"extension",
|
|
20
|
+
"skill"
|
|
21
|
+
],
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=22"
|
|
24
|
+
},
|
|
25
|
+
"os": [
|
|
26
|
+
"darwin"
|
|
27
|
+
],
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/nesetkab/claude4arc.git"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://github.com/nesetkab/claude4arc#readme",
|
|
33
|
+
"keywords": [
|
|
34
|
+
"claude",
|
|
35
|
+
"claude-code",
|
|
36
|
+
"arc",
|
|
37
|
+
"browser",
|
|
38
|
+
"automation",
|
|
39
|
+
"cdp",
|
|
40
|
+
"skill"
|
|
41
|
+
],
|
|
42
|
+
"license": "MIT"
|
|
43
|
+
}
|