@phnx-labs/agents-cli 1.22.93 → 1.22.94
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/CHANGELOG.md +3 -1
- package/dist/commands/browser.d.ts +1 -0
- package/dist/commands/browser.js +145 -11
- package/dist/lib/browser/arc-discovery.d.ts +60 -0
- package/dist/lib/browser/arc-discovery.js +187 -0
- package/dist/lib/browser/arc-dom.d.ts +14 -0
- package/dist/lib/browser/arc-dom.js +121 -0
- package/dist/lib/browser/drivers/arc.d.ts +57 -0
- package/dist/lib/browser/drivers/arc.js +281 -0
- package/dist/lib/browser/profiles.d.ts +5 -1
- package/dist/lib/browser/profiles.js +94 -9
- package/dist/lib/browser/refs.d.ts +2 -0
- package/dist/lib/browser/refs.js +2 -1
- package/dist/lib/browser/resolve-target.d.ts +2 -0
- package/dist/lib/browser/resolve-target.js +8 -0
- package/dist/lib/browser/runtime-state.js +9 -1
- package/dist/lib/browser/service.d.ts +37 -3
- package/dist/lib/browser/service.js +571 -41
- package/dist/lib/browser/types.d.ts +55 -1
- package/dist/lib/types.d.ts +7 -0
- package/package.json +1 -1
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { truncate } from '../format.js';
|
|
2
|
+
const INTERACTIVE_SELECTOR = [
|
|
3
|
+
'a[href]', 'button', 'input', 'select', 'textarea', '[contenteditable="true"]',
|
|
4
|
+
'[role="button"]', '[role="link"]', '[role="textbox"]', '[role="checkbox"]',
|
|
5
|
+
'[role="radio"]', '[role="combobox"]', '[role="listbox"]', '[role="option"]',
|
|
6
|
+
'[role="menuitem"]', '[role="tab"]', '[role="slider"]', '[role="spinbutton"]',
|
|
7
|
+
'[role="searchbox"]', '[role="switch"]', '[role="treeitem"]',
|
|
8
|
+
].join(',');
|
|
9
|
+
/** Build one synchronous expression that reads DOM-backed refs from native Arc. */
|
|
10
|
+
export function arcRefsExpression(opts = {}) {
|
|
11
|
+
const interactive = opts.interactive ?? true;
|
|
12
|
+
const limit = opts.limit ?? 500;
|
|
13
|
+
return `(() => {
|
|
14
|
+
const selectorFor = (el) => {
|
|
15
|
+
if (el.id) return '#' + CSS.escape(el.id);
|
|
16
|
+
const parts = [];
|
|
17
|
+
for (let node = el; node && node.nodeType === 1 && node !== document.documentElement; node = node.parentElement) {
|
|
18
|
+
let part = node.localName;
|
|
19
|
+
if (!part) return '';
|
|
20
|
+
const siblings = node.parentElement ? Array.from(node.parentElement.children).filter((s) => s.localName === node.localName) : [];
|
|
21
|
+
if (siblings.length > 1) part += ':nth-of-type(' + (siblings.indexOf(node) + 1) + ')';
|
|
22
|
+
parts.unshift(part);
|
|
23
|
+
}
|
|
24
|
+
return parts.join(' > ');
|
|
25
|
+
};
|
|
26
|
+
const roleFor = (el) => {
|
|
27
|
+
const explicit = el.getAttribute('role');
|
|
28
|
+
if (explicit) return explicit.toLowerCase();
|
|
29
|
+
if (el.matches('button')) return 'button';
|
|
30
|
+
if (el.matches('a[href]')) return 'link';
|
|
31
|
+
if (el.matches('textarea,[contenteditable="true"]')) return 'textbox';
|
|
32
|
+
if (el.matches('select')) return 'combobox';
|
|
33
|
+
if (el.matches('input')) {
|
|
34
|
+
const type = (el.getAttribute('type') || 'text').toLowerCase();
|
|
35
|
+
if (type === 'checkbox') return 'checkbox';
|
|
36
|
+
if (type === 'radio') return 'radio';
|
|
37
|
+
if (type === 'range') return 'slider';
|
|
38
|
+
if (type === 'number') return 'spinbutton';
|
|
39
|
+
return 'textbox';
|
|
40
|
+
}
|
|
41
|
+
return el.localName || 'generic';
|
|
42
|
+
};
|
|
43
|
+
const nameFor = (el) => el.getAttribute('aria-label') || el.getAttribute('title') ||
|
|
44
|
+
(el.labels && el.labels[0] && el.labels[0].innerText) || el.innerText || el.value || el.getAttribute('placeholder') || '';
|
|
45
|
+
const attrsFor = (el) => ['disabled','checked','selected','expanded','required','readonly','invalid']
|
|
46
|
+
.filter((name) => el[name] === true || el.getAttribute('aria-' + name) === 'true');
|
|
47
|
+
const editorFor = (el) => {
|
|
48
|
+
for (let node = el, i = 0; node && i < 6; node = node.parentElement, i++) {
|
|
49
|
+
if (node.hasAttribute('data-lexical-editor')) return 'lexical';
|
|
50
|
+
if (node.classList.contains('ProseMirror')) return 'prosemirror';
|
|
51
|
+
if (node.hasAttribute('data-slate-editor')) return 'slate';
|
|
52
|
+
if (Array.from(node.classList).some((c) => /^DraftEditor-/.test(c))) return 'draft';
|
|
53
|
+
if (node.classList.contains('ql-editor')) return 'quill';
|
|
54
|
+
if (node.classList.contains('ck-editor__editable')) return 'ckeditor5';
|
|
55
|
+
if (node.tagName === 'TRIX-EDITOR') return 'trix';
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
};
|
|
59
|
+
const source = ${interactive ? `document.querySelectorAll(${JSON.stringify(INTERACTIVE_SELECTOR)})` : `document.querySelectorAll('body *')`};
|
|
60
|
+
const rows = [];
|
|
61
|
+
for (const el of source) {
|
|
62
|
+
if (rows.length >= ${JSON.stringify(limit)}) break;
|
|
63
|
+
const selector = selectorFor(el);
|
|
64
|
+
if (!selector) continue;
|
|
65
|
+
const row = { role: roleFor(el), name: String(nameFor(el)).trim(), attrs: attrsFor(el), selector };
|
|
66
|
+
const editor = editorFor(el);
|
|
67
|
+
if (editor) row.editor = editor;
|
|
68
|
+
rows.push(row);
|
|
69
|
+
}
|
|
70
|
+
return JSON.stringify(rows);
|
|
71
|
+
})()`;
|
|
72
|
+
}
|
|
73
|
+
export function parseArcRefsResult(value, opts = {}) {
|
|
74
|
+
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
|
|
75
|
+
if (!Array.isArray(parsed))
|
|
76
|
+
throw new Error('Arc returned an invalid DOM ref listing.');
|
|
77
|
+
const nodeMap = new Map();
|
|
78
|
+
const lines = [];
|
|
79
|
+
const compact = opts.compact ?? false;
|
|
80
|
+
parsed.forEach((raw, index) => {
|
|
81
|
+
const row = raw;
|
|
82
|
+
if (typeof row.role !== 'string' || typeof row.name !== 'string' ||
|
|
83
|
+
typeof row.selector !== 'string' || !Array.isArray(row.attrs) ||
|
|
84
|
+
!row.attrs.every((attr) => typeof attr === 'string')) {
|
|
85
|
+
throw new Error('Arc returned an invalid DOM ref entry.');
|
|
86
|
+
}
|
|
87
|
+
const ref = index + 1;
|
|
88
|
+
const node = { ref, role: row.role, name: row.name, attrs: row.attrs, selector: row.selector };
|
|
89
|
+
if (typeof row.editor === 'string')
|
|
90
|
+
node.editor = row.editor;
|
|
91
|
+
nodeMap.set(ref, node);
|
|
92
|
+
const name = row.name ? ` "${truncate(row.name, 50)}"` : '';
|
|
93
|
+
const attrs = row.attrs.length ? ` [${row.attrs.join('] [')}]` : '';
|
|
94
|
+
const editor = row.editor ? ` [editor=${row.editor}]` : '';
|
|
95
|
+
lines.push(`${compact ? '' : '- '}${row.role}${name} [ref=${ref}]${attrs}${editor}`);
|
|
96
|
+
});
|
|
97
|
+
return { refs: lines.join('\n'), nodeMap, opts: { interactive: opts.interactive ?? true, limit: opts.limit ?? 500 } };
|
|
98
|
+
}
|
|
99
|
+
export function arcClickExpression(selector) {
|
|
100
|
+
return `(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (!el) throw new Error('DOM ref is missing'); el.click(); return true; })()`;
|
|
101
|
+
}
|
|
102
|
+
export function arcFillExpression(selector, text, clear = true) {
|
|
103
|
+
return `(() => {
|
|
104
|
+
const el = document.querySelector(${JSON.stringify(selector)});
|
|
105
|
+
if (!el) throw new Error('DOM ref is missing');
|
|
106
|
+
const next = ${JSON.stringify(text)};
|
|
107
|
+
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
|
|
108
|
+
const proto = el instanceof HTMLInputElement ? HTMLInputElement.prototype : HTMLTextAreaElement.prototype;
|
|
109
|
+
const setter = Object.getOwnPropertyDescriptor(proto, 'value').set;
|
|
110
|
+
setter.call(el, ${clear ? 'next' : 'el.value + next'});
|
|
111
|
+
} else if (el.isContentEditable) {
|
|
112
|
+
el.textContent = ${clear ? 'next' : '(el.textContent || "") + next'};
|
|
113
|
+
} else throw new Error('DOM ref is not editable');
|
|
114
|
+
el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: next }));
|
|
115
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
116
|
+
return true;
|
|
117
|
+
})()`;
|
|
118
|
+
}
|
|
119
|
+
export function arcScrollExpression(deltaX, deltaY) {
|
|
120
|
+
return `(() => { window.scrollBy(${JSON.stringify(deltaX)}, ${JSON.stringify(deltaY)}); return true; })()`;
|
|
121
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export interface ArcNativeTabRef {
|
|
2
|
+
windowId: string;
|
|
3
|
+
spaceId: string;
|
|
4
|
+
tabId: string;
|
|
5
|
+
}
|
|
6
|
+
export interface ArcNativeTab extends ArcNativeTabRef {
|
|
7
|
+
url: string;
|
|
8
|
+
title: string;
|
|
9
|
+
}
|
|
10
|
+
export interface ArcEnumeratedSpace {
|
|
11
|
+
windowId: string;
|
|
12
|
+
spaceId: string;
|
|
13
|
+
spaceTitle: string;
|
|
14
|
+
activeTabId?: string;
|
|
15
|
+
tabs: ArcNativeTab[];
|
|
16
|
+
}
|
|
17
|
+
export declare const ARC_NATIVE_CAPABILITIES: Readonly<{
|
|
18
|
+
readonly createTab: true;
|
|
19
|
+
readonly navigate: true;
|
|
20
|
+
readonly evaluateSync: true;
|
|
21
|
+
readonly closeTab: true;
|
|
22
|
+
readonly enumerate: true;
|
|
23
|
+
readonly screenshot: false;
|
|
24
|
+
readonly asyncEvaluate: false;
|
|
25
|
+
readonly networkCapture: false;
|
|
26
|
+
readonly consoleCapture: false;
|
|
27
|
+
readonly upload: false;
|
|
28
|
+
readonly pdf: false;
|
|
29
|
+
readonly background: false;
|
|
30
|
+
}>;
|
|
31
|
+
export declare class ArcNativeCapabilityError extends Error {
|
|
32
|
+
readonly capability: string;
|
|
33
|
+
constructor(capability: string, message?: string);
|
|
34
|
+
}
|
|
35
|
+
/** AppleScript strings do not implement JSON's \uXXXX escape syntax. */
|
|
36
|
+
export declare function escapeAppleScriptString(value: string): string;
|
|
37
|
+
/** No shell, bounded output and lifetime, and no blocking of the shared daemon. */
|
|
38
|
+
export declare function execAppleScript(source: string, timeoutMs?: number): Promise<string>;
|
|
39
|
+
export declare function isArcRunning(): Promise<boolean>;
|
|
40
|
+
export declare function enumerateArcSpaces(): Promise<ArcEnumeratedSpace[]>;
|
|
41
|
+
/** Caller durably records marker intent before calling; never use a real page URL as ownership. */
|
|
42
|
+
export declare function createArcTab(target: Pick<ArcNativeTabRef, 'windowId' | 'spaceId'>, markerUrl: string): Promise<ArcNativeTab>;
|
|
43
|
+
export declare function resolveArcTab(ref: ArcNativeTabRef): Promise<ArcNativeTab | null>;
|
|
44
|
+
export declare function navigateArcTab(ref: ArcNativeTabRef, url: string): Promise<void>;
|
|
45
|
+
/** Arc serializes a returned object once. JSON.stringify at the top level would double-encode. */
|
|
46
|
+
export declare function executeJavaScript(ref: ArcNativeTabRef, expression: string): Promise<unknown>;
|
|
47
|
+
export declare function closeArcTab(ref: ArcNativeTabRef): Promise<'closed' | 'missing'>;
|
|
48
|
+
/** Explicit user-facing focus only. Evaluation and cleanup never invoke this. */
|
|
49
|
+
export declare function selectArcTab(ref: ArcNativeTabRef): Promise<void>;
|
|
50
|
+
/**
|
|
51
|
+
* Select `tabId` wherever it lives in window `windowId` (any Space). Used to put
|
|
52
|
+
* the owner back on their tab after a creation that failed mid-way, when there is
|
|
53
|
+
* no owned tab to check against. Returns false when the tab is gone.
|
|
54
|
+
*/
|
|
55
|
+
export declare function selectWindowTab(windowId: string, tabId: string): Promise<boolean>;
|
|
56
|
+
/** Restore only while the task's new tab is still selected; preserve a later human choice. */
|
|
57
|
+
export declare function restoreArcSelection(owned: ArcNativeTabRef, previousTabId: string): Promise<boolean>;
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/** Native Apple Events transport. Never uses CDP or launches an Arc process. */
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
export const ARC_NATIVE_CAPABILITIES = Object.freeze({
|
|
4
|
+
createTab: true, navigate: true, evaluateSync: true, closeTab: true, enumerate: true,
|
|
5
|
+
screenshot: false, asyncEvaluate: false, networkCapture: false, consoleCapture: false,
|
|
6
|
+
upload: false, pdf: false, background: false,
|
|
7
|
+
});
|
|
8
|
+
export class ArcNativeCapabilityError extends Error {
|
|
9
|
+
capability;
|
|
10
|
+
constructor(capability, message) {
|
|
11
|
+
super(message ?? `Native Arc does not support ${capability}. Use a configured browser with that capability.`);
|
|
12
|
+
this.capability = capability;
|
|
13
|
+
this.name = 'ArcNativeCapabilityError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const TIMEOUT_MS = 15_000;
|
|
17
|
+
const MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
|
|
18
|
+
const APP_ID = 'company.thebrowser.Browser';
|
|
19
|
+
let operationQueue = Promise.resolve();
|
|
20
|
+
/** Serialize native operations in this service, including failures. */
|
|
21
|
+
function serialized(operation) {
|
|
22
|
+
const result = operationQueue.then(operation, operation);
|
|
23
|
+
operationQueue = result.catch(() => undefined);
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
/** AppleScript strings do not implement JSON's \uXXXX escape syntax. */
|
|
27
|
+
export function escapeAppleScriptString(value) {
|
|
28
|
+
const parts = value.split(/([\u0000-\u001f])/u).filter(Boolean);
|
|
29
|
+
if (parts.length === 0)
|
|
30
|
+
return '""';
|
|
31
|
+
return '(' + parts.map(part => part.length === 1 && part.charCodeAt(0) < 32
|
|
32
|
+
? `(character id ${part.charCodeAt(0)})`
|
|
33
|
+
: `"${part.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`).join(' & ') + ')';
|
|
34
|
+
}
|
|
35
|
+
/** No shell, bounded output and lifetime, and no blocking of the shared daemon. */
|
|
36
|
+
export function execAppleScript(source, timeoutMs = TIMEOUT_MS) {
|
|
37
|
+
if (process.platform !== 'darwin')
|
|
38
|
+
return Promise.reject(new Error('Native Arc requires macOS.'));
|
|
39
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
|
|
40
|
+
return Promise.reject(new Error('Invalid Apple Events timeout.'));
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
const child = spawn('/usr/bin/osascript', [], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
43
|
+
const chunks = [];
|
|
44
|
+
const errors = [];
|
|
45
|
+
let bytes = 0;
|
|
46
|
+
let failure;
|
|
47
|
+
const abort = (error) => {
|
|
48
|
+
failure ??= error;
|
|
49
|
+
child.kill('SIGKILL');
|
|
50
|
+
};
|
|
51
|
+
const timer = setTimeout(() => abort(new Error(`Arc Apple Event timed out after ${timeoutMs}ms.`)), timeoutMs);
|
|
52
|
+
for (const [stream, target] of [[child.stdout, chunks], [child.stderr, errors]]) {
|
|
53
|
+
stream.on('data', (chunk) => {
|
|
54
|
+
bytes += chunk.length;
|
|
55
|
+
if (bytes > MAX_OUTPUT_BYTES)
|
|
56
|
+
abort(new Error('Arc Apple Event output exceeded the safety limit.'));
|
|
57
|
+
else
|
|
58
|
+
target.push(chunk);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
child.stdin.on('error', error => abort(error));
|
|
62
|
+
child.once('error', error => { clearTimeout(timer); reject(error); });
|
|
63
|
+
child.once('close', (code, signal) => {
|
|
64
|
+
clearTimeout(timer);
|
|
65
|
+
if (failure) {
|
|
66
|
+
reject(failure);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (code !== 0) {
|
|
70
|
+
reject(new Error(`Arc Apple Event failed (${signal ?? code}): ${Buffer.concat(errors).toString('utf8').trim()}`));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
resolve(Buffer.concat(chunks).toString('utf8').trim());
|
|
74
|
+
});
|
|
75
|
+
child.stdin.end(source);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
// Foundation serializes arbitrary titles/URLs, including tabs, newlines and Unicode.
|
|
79
|
+
// Delimiter splitting is not a safe wire format for browser-controlled strings.
|
|
80
|
+
const PRELUDE = `use framework "Foundation"
|
|
81
|
+
use scripting additions
|
|
82
|
+
on jsonString(rawValue)
|
|
83
|
+
if rawValue is missing value then set rawValue to ""
|
|
84
|
+
set wrapped to {current application's NSString's stringWithString:(rawValue as text)}
|
|
85
|
+
set jsonData to current application's NSJSONSerialization's dataWithJSONObject:wrapped options:0 |error|:(missing value)
|
|
86
|
+
set jsonText to (current application's NSString's alloc()'s initWithData:jsonData encoding:(current application's NSUTF8StringEncoding)) as text
|
|
87
|
+
return text 2 thru -2 of jsonText
|
|
88
|
+
end jsonString
|
|
89
|
+
`;
|
|
90
|
+
function script(body) {
|
|
91
|
+
return `${PRELUDE}\nif application id "${APP_ID}" is not running then error "Arc is not running. Open it before starting a native task."
|
|
92
|
+
tell application id "${APP_ID}"
|
|
93
|
+
${body}
|
|
94
|
+
end tell`;
|
|
95
|
+
}
|
|
96
|
+
function identity(value, kind) {
|
|
97
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > 256)
|
|
98
|
+
throw new Error(`Missing or invalid native ${kind} ID.`);
|
|
99
|
+
return escapeAppleScriptString(value);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Resolve all ordinals afresh INSIDE the operation; never persist an index.
|
|
103
|
+
*
|
|
104
|
+
* Every `X of every Y` below is ONE Apple Event returning a list. The naive
|
|
105
|
+
* form (`id of tab ti of targetSpace` inside a repeat) costs one round trip per
|
|
106
|
+
* tab — on a 30-tab Space that alone took the create path past the 15 s budget
|
|
107
|
+
* from inside the shared daemon, whose loop also serves everything else.
|
|
108
|
+
*/
|
|
109
|
+
function lookup(ref, missing = 'error "Native Arc target is no longer in its original window and Space."') {
|
|
110
|
+
return `set targetWindowIndex to 0
|
|
111
|
+
set windowIds to id of every window
|
|
112
|
+
set windowVisible to visible of every window
|
|
113
|
+
repeat with wi from 1 to count of windowIds
|
|
114
|
+
if (item wi of windowIds as text) is ${identity(ref.windowId, 'window')} and (item wi of windowVisible) then set targetWindowIndex to wi as integer
|
|
115
|
+
end repeat
|
|
116
|
+
if targetWindowIndex is 0 then
|
|
117
|
+
${missing}
|
|
118
|
+
end if
|
|
119
|
+
set targetSpaceIndex to 0
|
|
120
|
+
set spaceIds to id of every space of window targetWindowIndex
|
|
121
|
+
repeat with si from 1 to count of spaceIds
|
|
122
|
+
if (item si of spaceIds as text) is ${identity(ref.spaceId, 'Space')} then set targetSpaceIndex to si as integer
|
|
123
|
+
end repeat
|
|
124
|
+
if targetSpaceIndex is 0 then
|
|
125
|
+
${missing}
|
|
126
|
+
end if
|
|
127
|
+
set targetSpace to a reference to space targetSpaceIndex of window targetWindowIndex
|
|
128
|
+
${ref.tabId === undefined ? '' : `set targetTabIndex to 0
|
|
129
|
+
set tabIds to id of every tab of targetSpace
|
|
130
|
+
repeat with ti from 1 to count of tabIds
|
|
131
|
+
if (item ti of tabIds as text) is ${identity(ref.tabId, 'tab')} then set targetTabIndex to ti as integer
|
|
132
|
+
end repeat
|
|
133
|
+
if targetTabIndex is 0 then
|
|
134
|
+
${missing}
|
|
135
|
+
end if
|
|
136
|
+
set targetTab to a reference to tab targetTabIndex of targetSpace`}`;
|
|
137
|
+
}
|
|
138
|
+
const TAB_JSON = `"{\\"windowId\\":" & my jsonString(id of window targetWindowIndex) & ",\\"spaceId\\":" & my jsonString(id of targetSpace) & ",\\"tabId\\":" & my jsonString(id of targetTab) & ",\\"url\\":" & my jsonString(URL of targetTab) & ",\\"title\\":" & my jsonString(title of targetTab) & "}"`;
|
|
139
|
+
function parse(raw) {
|
|
140
|
+
try {
|
|
141
|
+
return JSON.parse(raw);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
throw new Error('Arc returned an invalid native response; no target was adopted.');
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
export function isArcRunning() {
|
|
148
|
+
if (process.platform !== 'darwin')
|
|
149
|
+
return Promise.resolve(false);
|
|
150
|
+
return serialized(async () => (await execAppleScript(`return application id "${APP_ID}" is running`)) === 'true');
|
|
151
|
+
}
|
|
152
|
+
export function enumerateArcSpaces() {
|
|
153
|
+
return serialized(async () => parse(await execAppleScript(script(`set resultText to "["
|
|
154
|
+
set separator to ""
|
|
155
|
+
set windowIds to id of every window
|
|
156
|
+
set windowVisible to visible of every window
|
|
157
|
+
repeat with wi from 1 to count of windowIds
|
|
158
|
+
if item wi of windowVisible then
|
|
159
|
+
set targetWindowIndex to wi as integer
|
|
160
|
+
set windowIdText to my jsonString(item wi of windowIds)
|
|
161
|
+
set activeId to ""
|
|
162
|
+
try
|
|
163
|
+
set activeId to id of active tab of window targetWindowIndex as text
|
|
164
|
+
end try
|
|
165
|
+
set spaceIds to id of every space of window targetWindowIndex
|
|
166
|
+
set spaceTitles to title of every space of window targetWindowIndex
|
|
167
|
+
repeat with si from 1 to count of spaceIds
|
|
168
|
+
set targetSpace to a reference to space si of window targetWindowIndex
|
|
169
|
+
set resultText to resultText & separator & "{\\"windowId\\":" & windowIdText & ",\\"spaceId\\":" & my jsonString(item si of spaceIds) & ",\\"spaceTitle\\":" & my jsonString(item si of spaceTitles) & ",\\"activeTabId\\":" & my jsonString(activeId) & ",\\"tabs\\":["
|
|
170
|
+
set tabIds to id of every tab of targetSpace
|
|
171
|
+
set tabUrls to URL of every tab of targetSpace
|
|
172
|
+
set tabTitles to title of every tab of targetSpace
|
|
173
|
+
set tabSeparator to ""
|
|
174
|
+
repeat with ti from 1 to count of tabIds
|
|
175
|
+
set resultText to resultText & tabSeparator & "{\\"windowId\\":" & windowIdText & ",\\"spaceId\\":" & my jsonString(item si of spaceIds) & ",\\"tabId\\":" & my jsonString(item ti of tabIds) & ",\\"url\\":" & my jsonString(item ti of tabUrls) & ",\\"title\\":" & my jsonString(item ti of tabTitles) & "}"
|
|
176
|
+
set tabSeparator to ","
|
|
177
|
+
end repeat
|
|
178
|
+
set resultText to resultText & "]}"
|
|
179
|
+
set separator to ","
|
|
180
|
+
end repeat
|
|
181
|
+
end if
|
|
182
|
+
end repeat
|
|
183
|
+
return resultText & "]"`))));
|
|
184
|
+
}
|
|
185
|
+
/** Caller durably records marker intent before calling; never use a real page URL as ownership. */
|
|
186
|
+
export function createArcTab(target, markerUrl) {
|
|
187
|
+
if (!markerUrl || markerUrl.length > 16_384)
|
|
188
|
+
return Promise.reject(new Error('A bounded unique tab creation marker URL is required.'));
|
|
189
|
+
return serialized(async () => parse(await execAppleScript(script(`${lookup(target)}
|
|
190
|
+
set existingUrls to URL of every tab of targetSpace
|
|
191
|
+
repeat with ti from 1 to count of existingUrls
|
|
192
|
+
if (item ti of existingUrls as text) is ${escapeAppleScriptString(markerUrl)} then error "Creation marker already exists; reconcile the saved intent instead of creating another tab."
|
|
193
|
+
end repeat
|
|
194
|
+
tell targetSpace
|
|
195
|
+
make new tab with properties {URL:${escapeAppleScriptString(markerUrl)}}
|
|
196
|
+
end tell
|
|
197
|
+
set matchCount to 0
|
|
198
|
+
set targetTabIndex to 0
|
|
199
|
+
-- Arc commits the new tab's URL asynchronously: read back immediately it is
|
|
200
|
+
-- still empty. Poll briefly (bounded) until the marker shows up.
|
|
201
|
+
repeat with attempt from 1 to 30
|
|
202
|
+
set matchCount to 0
|
|
203
|
+
set createdUrls to URL of every tab of targetSpace
|
|
204
|
+
repeat with ti from 1 to count of createdUrls
|
|
205
|
+
if (item ti of createdUrls as text) is ${escapeAppleScriptString(markerUrl)} then
|
|
206
|
+
set matchCount to matchCount + 1
|
|
207
|
+
set targetTabIndex to ti as integer
|
|
208
|
+
end if
|
|
209
|
+
end repeat
|
|
210
|
+
if matchCount is not 0 then exit repeat
|
|
211
|
+
delay 0.1
|
|
212
|
+
end repeat
|
|
213
|
+
if matchCount is not 1 then error "Creation marker did not resolve exactly one tab; preserve the creation intent for recovery."
|
|
214
|
+
set targetTab to a reference to tab targetTabIndex of targetSpace
|
|
215
|
+
return ${TAB_JSON}`))));
|
|
216
|
+
}
|
|
217
|
+
export function resolveArcTab(ref) {
|
|
218
|
+
return serialized(async () => parse(await execAppleScript(script(`${lookup(ref, 'return "null"')}\nreturn ${TAB_JSON}`))));
|
|
219
|
+
}
|
|
220
|
+
export function navigateArcTab(ref, url) {
|
|
221
|
+
return serialized(async () => {
|
|
222
|
+
await execAppleScript(script(`${lookup(ref)}\nset URL of targetTab to ${escapeAppleScriptString(url)}\nreturn "navigated"`));
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
/** Arc serializes a returned object once. JSON.stringify at the top level would double-encode. */
|
|
226
|
+
export function executeJavaScript(ref, expression) {
|
|
227
|
+
const wrapper = `(()=>{try{const value=(0,eval)(${JSON.stringify(expression)});if(value!=null&&typeof value.then==='function')return {ok:false,error:'Native Arc does not support asynchronous evaluation.'};return {ok:true,hasValue:value!==undefined,value:value===undefined?null:JSON.parse(JSON.stringify(value))}}catch(error){return {ok:false,error:String(error)}}})()`;
|
|
228
|
+
return serialized(async () => {
|
|
229
|
+
const result = parse(await execAppleScript(script(`${lookup(ref)}\nreturn execute targetTab javascript ${escapeAppleScriptString(wrapper)}`)));
|
|
230
|
+
if (!result || result.ok !== true)
|
|
231
|
+
throw new Error(result?.error ?? 'Native Arc JavaScript returned no result.');
|
|
232
|
+
return result.hasValue ? result.value : undefined;
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
export function closeArcTab(ref) {
|
|
236
|
+
return serialized(async () => {
|
|
237
|
+
const result = await execAppleScript(script(`${lookup(ref, 'return "missing"')}\nclose targetTab\nreturn "closed"`));
|
|
238
|
+
if (result !== 'closed' && result !== 'missing')
|
|
239
|
+
throw new Error('Arc did not confirm tab cleanup.');
|
|
240
|
+
return result;
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
/** Explicit user-facing focus only. Evaluation and cleanup never invoke this. */
|
|
244
|
+
export function selectArcTab(ref) {
|
|
245
|
+
return serialized(async () => { await execAppleScript(script(`${lookup(ref)}\nselect targetTab\nreturn "selected"`)); });
|
|
246
|
+
}
|
|
247
|
+
/** AppleScript that selects `tabId` in whatever Space of window `targetWindowIndex` holds it; returns "true"/"false". */
|
|
248
|
+
function selectTabInWindowScript(tabId) {
|
|
249
|
+
return `set spaceCount to count of spaces of window targetWindowIndex
|
|
250
|
+
repeat with si from 1 to spaceCount
|
|
251
|
+
set candidateIds to id of every tab of space si of window targetWindowIndex
|
|
252
|
+
repeat with ti from 1 to count of candidateIds
|
|
253
|
+
if (item ti of candidateIds as text) is ${identity(tabId, 'tab')} then
|
|
254
|
+
select tab ti of space si of window targetWindowIndex
|
|
255
|
+
return "true"
|
|
256
|
+
end if
|
|
257
|
+
end repeat
|
|
258
|
+
end repeat
|
|
259
|
+
return "false"`;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Select `tabId` wherever it lives in window `windowId` (any Space). Used to put
|
|
263
|
+
* the owner back on their tab after a creation that failed mid-way, when there is
|
|
264
|
+
* no owned tab to check against. Returns false when the tab is gone.
|
|
265
|
+
*/
|
|
266
|
+
export function selectWindowTab(windowId, tabId) {
|
|
267
|
+
return serialized(async () => (await execAppleScript(script(`set targetWindowIndex to 0
|
|
268
|
+
set windowIds to id of every window
|
|
269
|
+
set windowVisible to visible of every window
|
|
270
|
+
repeat with wi from 1 to count of windowIds
|
|
271
|
+
if (item wi of windowIds as text) is ${identity(windowId, 'window')} and (item wi of windowVisible) then set targetWindowIndex to wi as integer
|
|
272
|
+
end repeat
|
|
273
|
+
if targetWindowIndex is 0 then return "false"
|
|
274
|
+
${selectTabInWindowScript(tabId)}`))) === 'true');
|
|
275
|
+
}
|
|
276
|
+
/** Restore only while the task's new tab is still selected; preserve a later human choice. */
|
|
277
|
+
export function restoreArcSelection(owned, previousTabId) {
|
|
278
|
+
return serialized(async () => (await execAppleScript(script(`${lookup(owned, 'return "false"')}
|
|
279
|
+
if (id of active tab of window targetWindowIndex as text) is not ${identity(owned.tabId, 'tab')} then return "false"
|
|
280
|
+
${selectTabInWindowScript(previousTabId)}`))) === 'true');
|
|
281
|
+
}
|
|
@@ -85,8 +85,12 @@ export declare function getProfileDownloadsDir(name: string): string;
|
|
|
85
85
|
* one-shot migration in migrate.ts folds old captures into here.
|
|
86
86
|
*/
|
|
87
87
|
export declare function getProfileSessionsDir(name: string, task: string): string;
|
|
88
|
+
/** Whether this name has persisted agents-cli metadata on the current device. */
|
|
89
|
+
export declare function isProfileDeclaredHere(name: string): boolean;
|
|
88
90
|
export declare function listProfiles(): Promise<BrowserProfileWithDeclarations[]>;
|
|
89
91
|
export declare function getProfile(name: string): Promise<BrowserProfileWithDeclarations | null>;
|
|
92
|
+
/** Persist only the agents-cli alias for an auto-discovered Arc profile. */
|
|
93
|
+
export declare function persistDiscoveredArcProfile(name: string): Promise<void>;
|
|
90
94
|
/**
|
|
91
95
|
* True when `profile` can actually launch or attach on THIS machine.
|
|
92
96
|
*
|
|
@@ -245,7 +249,7 @@ export declare function updateProfile(profile: BrowserProfile): Promise<void>;
|
|
|
245
249
|
* ({@link getProfileRuntimeDir}) and any live `<name>@<endpoint>` connection, so
|
|
246
250
|
* changing either orphans the cached browser data. Delete and recreate instead.
|
|
247
251
|
*/
|
|
248
|
-
export type EditableProfileFields = Partial<Pick<BrowserProfile, 'description' | 'binary' | 'electron' | 'targetFilter' | 'endpoints' | 'launchPolicy' | 'userDataDir' | 'chrome' | 'secrets' | 'viewport'>>;
|
|
252
|
+
export type EditableProfileFields = Partial<Pick<BrowserProfile, 'description' | 'binary' | 'electron' | 'targetFilter' | 'endpoints' | 'launchPolicy' | 'userDataDir' | 'chrome' | 'secrets' | 'viewport' | 'arc'>>;
|
|
249
253
|
export interface EditProfileResult {
|
|
250
254
|
profile: BrowserProfile;
|
|
251
255
|
devices: string[];
|
|
@@ -5,6 +5,7 @@ import { getConfigValue } from '../device-config.js';
|
|
|
5
5
|
import { machineId } from '../machine-id.js';
|
|
6
6
|
import { declaringDevices, profileRegistry, } from './registry.js';
|
|
7
7
|
import { findBrowserPath, isPortInUse } from './chrome.js';
|
|
8
|
+
import { arcSpaceProfiles, discoverArcProfiles } from './arc-discovery.js';
|
|
8
9
|
export { declaringDevices, profileKind, profileRegistry, } from './registry.js';
|
|
9
10
|
/**
|
|
10
11
|
* Name of the profile the setup wizards pin as this machine's default browser
|
|
@@ -129,6 +130,7 @@ function configToProfile(name, config, devices = []) {
|
|
|
129
130
|
viewport: config.viewport,
|
|
130
131
|
logDir: config.logDir,
|
|
131
132
|
logHost: config.logHost,
|
|
133
|
+
arc: config.arc,
|
|
132
134
|
devices,
|
|
133
135
|
};
|
|
134
136
|
}
|
|
@@ -162,26 +164,100 @@ function profileToConfig(profile) {
|
|
|
162
164
|
config.logDir = profile.logDir;
|
|
163
165
|
if (profile.logHost)
|
|
164
166
|
config.logHost = profile.logHost;
|
|
167
|
+
if (profile.arc)
|
|
168
|
+
config.arc = profile.arc;
|
|
165
169
|
return config;
|
|
166
170
|
}
|
|
171
|
+
/**
|
|
172
|
+
* Every Arc Space on this Mac as an agents-cli profile (PHNX-2399). A Space
|
|
173
|
+
* already carries its Arc profile's cookies and logins, so it IS the browser
|
|
174
|
+
* profile agents pick with `--profile` — no second concept.
|
|
175
|
+
*
|
|
176
|
+
* Discovery reads files Arc rewrites on every Space or tab change, so a torn
|
|
177
|
+
* read is an ordinary event, not corruption. It is reported as `error` rather
|
|
178
|
+
* than thrown: the profile store serves every browser on this machine, and a
|
|
179
|
+
* momentary Arc read must never take the Comet or Chrome rows down with it.
|
|
180
|
+
* The error surfaces where an Arc profile is actually asked for by name.
|
|
181
|
+
*/
|
|
182
|
+
function discoverArcSpaceProfiles() {
|
|
183
|
+
const discovered = discoverArcProfiles();
|
|
184
|
+
if (!discovered.ok) {
|
|
185
|
+
if (discovered.kind === 'unsupported' || discovered.kind === 'not-installed')
|
|
186
|
+
return { profiles: [] };
|
|
187
|
+
return { profiles: [], error: `Cannot discover Arc Spaces: ${discovered.reason}` };
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
profiles: arcSpaceProfiles(discovered).map((space) => ({
|
|
191
|
+
name: space.name,
|
|
192
|
+
description: `Arc Space "${space.spaceTitle}" (${space.profileName})`,
|
|
193
|
+
browser: 'arc',
|
|
194
|
+
endpoints: { native: { target: 'arc-native://local' } },
|
|
195
|
+
defaultEndpoint: 'native',
|
|
196
|
+
launchPolicy: 'attach-only',
|
|
197
|
+
arc: {
|
|
198
|
+
profileId: space.profileId,
|
|
199
|
+
profileName: space.profileName,
|
|
200
|
+
spaceId: space.spaceId,
|
|
201
|
+
spaceTitle: space.spaceTitle,
|
|
202
|
+
},
|
|
203
|
+
devices: [machineId()],
|
|
204
|
+
})),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Bring a persisted Arc alias up to date with the live Space (title renames).
|
|
209
|
+
* A Space that is no longer discoverable leaves the alias as stored: listing
|
|
210
|
+
* still works, and using the profile fails loud in `resolveArcSpace` with the
|
|
211
|
+
* Space that is missing from Arc's visible windows.
|
|
212
|
+
*/
|
|
213
|
+
function refreshLocalArcProfile(profile, live) {
|
|
214
|
+
if (!profile.arc || !profile.devices.includes(machineId()))
|
|
215
|
+
return profile;
|
|
216
|
+
const current = live.find((candidate) => candidate.arc?.spaceId === profile.arc?.spaceId);
|
|
217
|
+
if (!current)
|
|
218
|
+
return profile;
|
|
219
|
+
return { ...profile, arc: current.arc, description: profile.description ?? current.description };
|
|
220
|
+
}
|
|
167
221
|
function selectedDeclaration(declarations) {
|
|
168
222
|
return declarations.find((declaration) => declaration.device === machineId()) ?? declarations[0];
|
|
169
223
|
}
|
|
170
224
|
function localDeclaration(name) {
|
|
171
225
|
return profileRegistry().get(name)?.find((declaration) => declaration.device === machineId());
|
|
172
226
|
}
|
|
227
|
+
/** Whether this name has persisted agents-cli metadata on the current device. */
|
|
228
|
+
export function isProfileDeclaredHere(name) {
|
|
229
|
+
return localDeclaration(name) !== undefined;
|
|
230
|
+
}
|
|
173
231
|
export async function listProfiles() {
|
|
174
|
-
|
|
232
|
+
const native = discoverArcSpaceProfiles();
|
|
233
|
+
const configured = [...profileRegistry()].map(([name, declarations]) => {
|
|
175
234
|
const selected = selectedDeclaration(declarations);
|
|
176
|
-
return configToProfile(name, selected.config, declarations.map((declaration) => declaration.device));
|
|
235
|
+
return refreshLocalArcProfile(configToProfile(name, selected.config, declarations.map((declaration) => declaration.device)), native.profiles);
|
|
177
236
|
});
|
|
237
|
+
const names = new Set(configured.map((profile) => profile.name));
|
|
238
|
+
return [...configured, ...native.profiles.filter((profile) => !names.has(profile.name))];
|
|
178
239
|
}
|
|
179
240
|
export async function getProfile(name) {
|
|
241
|
+
const native = discoverArcSpaceProfiles();
|
|
180
242
|
const declarations = profileRegistry().get(name);
|
|
181
|
-
if (
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
243
|
+
if (declarations?.length) {
|
|
244
|
+
const selected = selectedDeclaration(declarations);
|
|
245
|
+
return refreshLocalArcProfile(configToProfile(name, selected.config, declarations.map((declaration) => declaration.device)), native.profiles);
|
|
246
|
+
}
|
|
247
|
+
const discovered = native.profiles.find((profile) => profile.name === name) ?? null;
|
|
248
|
+
// Only an Arc profile asked for by name pays for a failed Arc read.
|
|
249
|
+
if (!discovered && native.error && name.startsWith('arc-'))
|
|
250
|
+
throw new Error(native.error);
|
|
251
|
+
return discovered;
|
|
252
|
+
}
|
|
253
|
+
/** Persist only the agents-cli alias for an auto-discovered Arc profile. */
|
|
254
|
+
export async function persistDiscoveredArcProfile(name) {
|
|
255
|
+
if (localDeclaration(name))
|
|
256
|
+
return;
|
|
257
|
+
const profile = discoverArcSpaceProfiles().profiles.find((candidate) => candidate.name === name);
|
|
258
|
+
if (!profile)
|
|
259
|
+
return;
|
|
260
|
+
await createProfile(profile);
|
|
185
261
|
}
|
|
186
262
|
/**
|
|
187
263
|
* True when `profile` can actually launch or attach on THIS machine.
|
|
@@ -194,6 +270,10 @@ export async function getProfile(name) {
|
|
|
194
270
|
* for the current machine.
|
|
195
271
|
*/
|
|
196
272
|
export function isProfileLaunchableHere(profile) {
|
|
273
|
+
const nativeArc = Object.values(getEndpointPresets(profile)).some((preset) => preset.target.startsWith('arc-native:'));
|
|
274
|
+
if (nativeArc) {
|
|
275
|
+
return !!profile.arc && discoverArcSpaceProfiles().profiles.some((candidate) => candidate.arc?.spaceId === profile.arc?.spaceId);
|
|
276
|
+
}
|
|
197
277
|
const remote = Object.values(getEndpointPresets(profile)).some((preset) => preset.target.startsWith('ssh://'));
|
|
198
278
|
if (remote)
|
|
199
279
|
return true;
|
|
@@ -334,7 +414,7 @@ export async function ensureDefaultBrowserProfile() {
|
|
|
334
414
|
const configured = getConfiguredDefaultProfileName();
|
|
335
415
|
if (configured && configured !== DEFAULT_PROFILE_ALIAS) {
|
|
336
416
|
const chosen = await getProfile(configured);
|
|
337
|
-
if (chosen && isProfileLaunchableHere(chosen))
|
|
417
|
+
if (chosen && (!chosen.devices.includes(machineId()) || isProfileLaunchableHere(chosen)))
|
|
338
418
|
return chosen;
|
|
339
419
|
if (!chosen) {
|
|
340
420
|
const central = readMeta().browser ?? {};
|
|
@@ -520,7 +600,11 @@ export async function createProfile(profile) {
|
|
|
520
600
|
// Skip for SSH profiles: the browser binary lives on the remote host, so a
|
|
521
601
|
// local lookup would validate the wrong machine. The remote launcher resolves
|
|
522
602
|
// it at connect time.
|
|
523
|
-
|
|
603
|
+
const nativeArc = Object.values(getEndpointPresets(profile)).some((preset) => preset.target.startsWith('arc-native:'));
|
|
604
|
+
if (nativeArc && (!profile.arc || profile.browser !== 'arc')) {
|
|
605
|
+
throw new Error('An arc-native profile requires stable Arc Space metadata. Pick a discovered one from `agents browser profiles list`.');
|
|
606
|
+
}
|
|
607
|
+
if (!hasSshEndpoint(profile.endpoints) && !nativeArc) {
|
|
524
608
|
findBrowserPath(profile.browser, profile.binary);
|
|
525
609
|
}
|
|
526
610
|
const config = profileToConfig(profile);
|
|
@@ -575,7 +659,8 @@ export async function editProfile(name, patch) {
|
|
|
575
659
|
assertLocalPortFree(merged, { ignore: name });
|
|
576
660
|
// Same rule as create: the binary lives on the remote for an SSH profile, so a
|
|
577
661
|
// local lookup would validate the wrong machine.
|
|
578
|
-
|
|
662
|
+
const nativeArc = Object.values(getEndpointPresets(merged)).some((preset) => preset.target.startsWith('arc-native:'));
|
|
663
|
+
if (!hasSshEndpoint(merged.endpoints) && !nativeArc) {
|
|
579
664
|
findBrowserPath(merged.browser, merged.binary);
|
|
580
665
|
}
|
|
581
666
|
await updateProfile(merged);
|