@petukhovart/agent-view 0.1.0 → 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/.claude-plugin/marketplace.json +14 -0
- package/.claude-plugin/plugin.json +13 -2
- package/README.md +211 -61
- package/dist/adapters/browser.d.ts.map +1 -1
- package/dist/adapters/browser.js +11 -11
- package/dist/adapters/browser.js.map +1 -1
- package/dist/adapters/electron.d.ts.map +1 -1
- package/dist/adapters/electron.js +11 -11
- package/dist/adapters/electron.js.map +1 -1
- package/dist/adapters/tauri.d.ts.map +1 -1
- package/dist/adapters/tauri.js +11 -11
- package/dist/adapters/tauri.js.map +1 -1
- package/dist/adapters/types.d.ts +5 -2
- package/dist/adapters/types.d.ts.map +1 -1
- package/dist/cdp/_tests/console-stream.d.ts +35 -0
- package/dist/cdp/_tests/console-stream.d.ts.map +1 -0
- package/dist/cdp/_tests/console-stream.js +103 -0
- package/dist/cdp/_tests/console-stream.js.map +1 -0
- package/dist/cdp/ax-cache.d.ts +16 -0
- package/dist/cdp/ax-cache.d.ts.map +1 -0
- package/dist/cdp/ax-cache.js +31 -0
- package/dist/cdp/ax-cache.js.map +1 -0
- package/dist/cdp/console-stream.d.ts +35 -0
- package/dist/cdp/console-stream.d.ts.map +1 -0
- package/dist/cdp/console-stream.js +103 -0
- package/dist/cdp/console-stream.js.map +1 -0
- package/dist/cdp/transport.d.ts +5 -2
- package/dist/cdp/transport.d.ts.map +1 -1
- package/dist/cdp/transport.js +309 -26
- package/dist/cdp/transport.js.map +1 -1
- package/dist/cdp/types.d.ts +109 -8
- package/dist/cdp/types.d.ts.map +1 -1
- package/dist/cdp/types.js +35 -1
- package/dist/cdp/types.js.map +1 -1
- package/dist/cli/commands/console.d.ts +12 -0
- package/dist/cli/commands/console.d.ts.map +1 -0
- package/dist/cli/commands/console.js +32 -0
- package/dist/cli/commands/console.js.map +1 -0
- package/dist/cli/commands/dom.d.ts +5 -0
- package/dist/cli/commands/dom.d.ts.map +1 -1
- package/dist/cli/commands/dom.js +5 -0
- package/dist/cli/commands/dom.js.map +1 -1
- package/dist/cli/commands/drag.d.ts +13 -0
- package/dist/cli/commands/drag.d.ts.map +1 -0
- package/dist/cli/commands/drag.js +72 -0
- package/dist/cli/commands/drag.js.map +1 -0
- package/dist/cli/commands/eval.d.ts +9 -0
- package/dist/cli/commands/eval.d.ts.map +1 -0
- package/dist/cli/commands/eval.js +30 -0
- package/dist/cli/commands/eval.js.map +1 -0
- package/dist/cli/commands/scene.d.ts +1 -0
- package/dist/cli/commands/scene.d.ts.map +1 -1
- package/dist/cli/commands/scene.js +2 -0
- package/dist/cli/commands/scene.js.map +1 -1
- package/dist/cli/commands/screenshot.d.ts +2 -0
- package/dist/cli/commands/screenshot.d.ts.map +1 -1
- package/dist/cli/commands/screenshot.js +6 -0
- package/dist/cli/commands/screenshot.js.map +1 -1
- package/dist/cli/commands/snap.d.ts +1 -0
- package/dist/cli/commands/snap.d.ts.map +1 -1
- package/dist/cli/commands/snap.js +2 -0
- package/dist/cli/commands/snap.js.map +1 -1
- package/dist/cli/commands/targets.d.ts +7 -0
- package/dist/cli/commands/targets.d.ts.map +1 -0
- package/dist/cli/commands/targets.js +30 -0
- package/dist/cli/commands/targets.js.map +1 -0
- package/dist/cli/commands/watch.d.ts +12 -0
- package/dist/cli/commands/watch.d.ts.map +1 -0
- package/dist/cli/commands/watch.js +233 -0
- package/dist/cli/commands/watch.js.map +1 -0
- package/dist/cli/index.js +86 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/config/manager.d.ts.map +1 -1
- package/dist/config/manager.js +11 -1
- package/dist/config/manager.js.map +1 -1
- package/dist/config/types.d.ts +11 -3
- package/dist/config/types.d.ts.map +1 -1
- package/dist/inspectors/dom/index.d.ts +30 -0
- package/dist/inspectors/dom/index.d.ts.map +1 -0
- package/dist/inspectors/dom/index.js +283 -0
- package/dist/inspectors/dom/index.js.map +1 -0
- package/dist/inspectors/scene/adapters/pixi/adapter.d.ts +3 -0
- package/dist/inspectors/scene/adapters/pixi/adapter.d.ts.map +1 -0
- package/dist/inspectors/scene/adapters/pixi/adapter.js +51 -0
- package/dist/inspectors/scene/adapters/pixi/adapter.js.map +1 -0
- package/dist/inspectors/scene/adapters/pixi/index.d.ts +2 -0
- package/dist/inspectors/scene/adapters/pixi/index.d.ts.map +1 -0
- package/dist/inspectors/scene/adapters/pixi/index.js +2 -0
- package/dist/inspectors/scene/adapters/pixi/index.js.map +1 -0
- package/dist/inspectors/scene/adapters/pixi/injection.d.ts +39 -0
- package/dist/inspectors/scene/adapters/pixi/injection.d.ts.map +1 -0
- package/dist/inspectors/scene/adapters/pixi/injection.js +41 -0
- package/dist/inspectors/scene/adapters/pixi/injection.js.map +1 -0
- package/dist/inspectors/scene/formatter.d.ts +0 -1
- package/dist/inspectors/scene/formatter.d.ts.map +1 -1
- package/dist/inspectors/scene/formatter.js +100 -43
- package/dist/inspectors/scene/formatter.js.map +1 -1
- package/dist/inspectors/scene/index.d.ts +4 -4
- package/dist/inspectors/scene/index.d.ts.map +1 -1
- package/dist/inspectors/scene/index.js +8 -5
- package/dist/inspectors/scene/index.js.map +1 -1
- package/dist/inspectors/scene/pixi.js.map +1 -1
- package/dist/inspectors/scene/registry.d.ts +2 -2
- package/dist/inspectors/scene/registry.d.ts.map +1 -1
- package/dist/inspectors/scene/registry.js +8 -8
- package/dist/inspectors/scene/registry.js.map +1 -1
- package/dist/inspectors/scene/types.d.ts +6 -10
- package/dist/inspectors/scene/types.d.ts.map +1 -1
- package/dist/inspectors/watch/index.d.ts +18 -0
- package/dist/inspectors/watch/index.d.ts.map +1 -0
- package/dist/inspectors/watch/index.js +39 -0
- package/dist/inspectors/watch/index.js.map +1 -0
- package/dist/inspectors/watch/types.d.ts +70 -0
- package/dist/inspectors/watch/types.d.ts.map +1 -0
- package/dist/inspectors/watch/types.js +21 -0
- package/dist/inspectors/watch/types.js.map +1 -0
- package/dist/server/server.d.ts +31 -1
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +620 -67
- package/dist/server/server.js.map +1 -1
- package/dist/server/watch-session.d.ts +32 -0
- package/dist/server/watch-session.d.ts.map +1 -0
- package/dist/server/watch-session.js +165 -0
- package/dist/server/watch-session.js.map +1 -0
- package/dist/types.d.ts +4 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -0
- package/dist/types.js.map +1 -1
- package/package.json +61 -59
- package/skills/verify/SKILL.md +146 -11
- package/skills/verify-recipe/SKILL.md +185 -0
package/dist/server/server.js
CHANGED
|
@@ -5,14 +5,19 @@ import { homedir, tmpdir } from 'node:os';
|
|
|
5
5
|
import { join, resolve } from 'node:path';
|
|
6
6
|
import { mkdirSync } from 'node:fs';
|
|
7
7
|
import { getAdapter } from '../adapters/registry.js';
|
|
8
|
-
import { formatAccessibilityTree } from '../inspectors/dom.js';
|
|
8
|
+
import { formatAccessibilityTree, countAccessibilityNodes, diffDomText } from '../inspectors/dom/index.js';
|
|
9
9
|
import { getSceneGraph, getRawScene, diffScenes } from '../inspectors/scene/index.js';
|
|
10
10
|
import { RefStore } from './ref-store.js';
|
|
11
11
|
import { launch, isRunning } from './launcher.js';
|
|
12
12
|
import { readConfig } from '../config/manager.js';
|
|
13
13
|
import { RuntimeType, WebGLEngine } from '../types.js';
|
|
14
|
+
import { TargetType, ConsoleLevel, EvaluationError, MouseButton, } from '../cdp/types.js';
|
|
15
|
+
import { listSupportedTargets, connectToRuntime } from '../cdp/transport.js';
|
|
16
|
+
import { ConsoleStream } from '../cdp/_tests/console-stream.js';
|
|
17
|
+
import { AxTreeCache } from '../cdp/ax-cache.js';
|
|
18
|
+
import { WatchSession } from './watch-session.js';
|
|
19
|
+
import { StopReason, WATCH_MIN_INTERVAL_MS } from '../inspectors/watch/index.js';
|
|
14
20
|
const SERVER_PORT = 47922;
|
|
15
|
-
const VALID_COMMANDS = new Set(['discover', 'launch', 'dom', 'click', 'fill', 'screenshot', 'scene', 'snap', 'wait', 'stop']);
|
|
16
21
|
const VALID_RUNTIMES = new Set(Object.values(RuntimeType));
|
|
17
22
|
const VALID_ENGINES = new Set(Object.values(WebGLEngine));
|
|
18
23
|
const IDLE_TIMEOUT_MS = 5 * 60 * 1000;
|
|
@@ -20,6 +25,13 @@ const DELIMITER = '\n';
|
|
|
20
25
|
const MAX_BUFFER_SIZE = 1_048_576; // 1 MB
|
|
21
26
|
const TOKEN_DIR = join(homedir(), '.agent-view');
|
|
22
27
|
const TOKEN_PATH = join(TOKEN_DIR, 'token');
|
|
28
|
+
const EVAL_OUTPUT_CAP = 64 * 1024;
|
|
29
|
+
const DEFAULT_CONSOLE_TARGETS = [TargetType.Page, TargetType.SharedWorker, TargetType.ServiceWorker];
|
|
30
|
+
const RUNTIME_ONLY_TARGETS = new Set([
|
|
31
|
+
TargetType.SharedWorker,
|
|
32
|
+
TargetType.ServiceWorker,
|
|
33
|
+
TargetType.Worker,
|
|
34
|
+
]);
|
|
23
35
|
function argStr(args, key) {
|
|
24
36
|
const v = args[key];
|
|
25
37
|
return typeof v === 'string' ? v : undefined;
|
|
@@ -32,13 +44,95 @@ function argBool(args, key) {
|
|
|
32
44
|
const v = args[key];
|
|
33
45
|
return typeof v === 'boolean' ? v : undefined;
|
|
34
46
|
}
|
|
47
|
+
function argStrArray(args, key) {
|
|
48
|
+
const v = args[key];
|
|
49
|
+
if (!Array.isArray(v))
|
|
50
|
+
return undefined;
|
|
51
|
+
return v.filter((x) => typeof x === 'string');
|
|
52
|
+
}
|
|
53
|
+
const ARIA_ROLES = new Set([
|
|
54
|
+
'button', 'link', 'menuitem', 'tab', 'checkbox', 'radio',
|
|
55
|
+
'textbox', 'searchbox', 'combobox', 'spinbutton', 'textarea',
|
|
56
|
+
'listitem', 'option', 'treeitem', 'cell', 'row', 'heading',
|
|
57
|
+
]);
|
|
58
|
+
export function resolveDepth(filter, explicit) {
|
|
59
|
+
if (explicit !== undefined)
|
|
60
|
+
return explicit;
|
|
61
|
+
if (filter !== undefined)
|
|
62
|
+
return undefined; // unlimited depth when filtering
|
|
63
|
+
return 4; // default snapshot depth
|
|
64
|
+
}
|
|
65
|
+
export async function textContentFallback(conn, filter) {
|
|
66
|
+
const safeFilter = JSON.stringify(filter);
|
|
67
|
+
const js = `(() => {
|
|
68
|
+
const q = ${safeFilter};
|
|
69
|
+
const results = [];
|
|
70
|
+
for (const el of document.querySelectorAll('body *')) {
|
|
71
|
+
if (results.length >= 5) break;
|
|
72
|
+
const directText = Array.from(el.childNodes)
|
|
73
|
+
.filter(n => n.nodeType === Node.TEXT_NODE)
|
|
74
|
+
.map(n => n.textContent.trim())
|
|
75
|
+
.join(' ');
|
|
76
|
+
if (directText.toLowerCase().includes(q.toLowerCase()) && el.offsetParent !== null) {
|
|
77
|
+
const id = el.id ? '#' + el.id : '';
|
|
78
|
+
const cls = el.className && typeof el.className === 'string' && el.className.trim()
|
|
79
|
+
? '.' + el.className.trim().split(/\\s+/)[0] : '';
|
|
80
|
+
results.push(el.tagName.toLowerCase() + id + cls);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return results.length ? results.join(', ') : null;
|
|
84
|
+
})()`;
|
|
85
|
+
const result = await conn.evaluate(js);
|
|
86
|
+
if (!result || typeof result !== 'string') {
|
|
87
|
+
return `(no text-match for "${filter}")`;
|
|
88
|
+
}
|
|
89
|
+
return result.split(', ')
|
|
90
|
+
.map(loc => `[text-match] "${filter}" found in ${loc} (no accessible role in AX tree)`)
|
|
91
|
+
.join('\n');
|
|
92
|
+
}
|
|
93
|
+
export function parseFilter(filter) {
|
|
94
|
+
const colonIdx = filter.indexOf(':');
|
|
95
|
+
if (colonIdx > 0) {
|
|
96
|
+
const role = filter.slice(0, colonIdx).trim().toLowerCase();
|
|
97
|
+
const name = filter.slice(colonIdx + 1).trim();
|
|
98
|
+
if (name && ARIA_ROLES.has(role)) {
|
|
99
|
+
return { kind: 'simple', name, role };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (filter.startsWith('~') || /[.*+?^${}()|[\]\\]/.test(filter)) {
|
|
103
|
+
return { kind: 'heuristic', raw: filter };
|
|
104
|
+
}
|
|
105
|
+
return { kind: 'simple', name: filter };
|
|
106
|
+
}
|
|
35
107
|
export class AgentViewServer {
|
|
36
108
|
server = null;
|
|
37
109
|
connections = new Map();
|
|
38
110
|
refStore = new RefStore();
|
|
39
111
|
idleTimer = null;
|
|
40
112
|
sceneCache = new Map();
|
|
113
|
+
domTextCache = new Map();
|
|
114
|
+
axTreeCache = new AxTreeCache();
|
|
115
|
+
consoleStream = new ConsoleStream();
|
|
41
116
|
token = '';
|
|
117
|
+
activeWatches = new Set();
|
|
118
|
+
handlers = {
|
|
119
|
+
discover: (req) => this.handleDiscover(req),
|
|
120
|
+
launch: (req) => this.handleLaunch(req),
|
|
121
|
+
dom: (req) => this.handleDom(req),
|
|
122
|
+
click: (req) => this.handleClick(req),
|
|
123
|
+
drag: (req) => this.handleDrag(req),
|
|
124
|
+
fill: (req) => this.handleFill(req),
|
|
125
|
+
wait: (req) => this.handleWait(req),
|
|
126
|
+
screenshot: (req) => this.handleScreenshot(req),
|
|
127
|
+
scene: (req) => this.handleScene(req),
|
|
128
|
+
snap: (req) => this.handleSnap(req),
|
|
129
|
+
targets: (req) => this.handleTargets(req),
|
|
130
|
+
eval: (req) => this.handleEval(req),
|
|
131
|
+
console: (req) => this.handleConsole(req),
|
|
132
|
+
stop: () => this.handleStop(),
|
|
133
|
+
};
|
|
134
|
+
streamingCommands = new Set(['watch']);
|
|
135
|
+
validCommands = new Set([...Object.keys(this.handlers), ...this.streamingCommands]);
|
|
42
136
|
async start() {
|
|
43
137
|
mkdirSync(TOKEN_DIR, { recursive: true });
|
|
44
138
|
this.token = randomBytes(32).toString('hex');
|
|
@@ -55,6 +149,11 @@ export class AgentViewServer {
|
|
|
55
149
|
resetIdleTimer() {
|
|
56
150
|
if (this.idleTimer)
|
|
57
151
|
clearTimeout(this.idleTimer);
|
|
152
|
+
if (this.activeWatches.size > 0) {
|
|
153
|
+
// Pause idle shutdown while streaming handlers are alive.
|
|
154
|
+
this.idleTimer = null;
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
58
157
|
this.idleTimer = setTimeout(() => this.shutdown(), IDLE_TIMEOUT_MS);
|
|
59
158
|
}
|
|
60
159
|
handleSocket(socket) {
|
|
@@ -81,7 +180,7 @@ export class AgentViewServer {
|
|
|
81
180
|
socket.end(JSON.stringify({ ok: false, error: 'Unauthorized' }) + DELIMITER);
|
|
82
181
|
return;
|
|
83
182
|
}
|
|
84
|
-
if (typeof request.command !== 'string' || !
|
|
183
|
+
if (typeof request.command !== 'string' || !this.validCommands.has(request.command) ||
|
|
85
184
|
(request.command !== 'stop' && (!VALID_RUNTIMES.has(request.runtime) ||
|
|
86
185
|
typeof request.port !== 'number' || request.port < 1 || request.port > 65535))) {
|
|
87
186
|
socket.end(JSON.stringify({ ok: false, error: 'Invalid request' }) + DELIMITER);
|
|
@@ -95,6 +194,10 @@ export class AgentViewServer {
|
|
|
95
194
|
socket.end(JSON.stringify({ ok: false, error: 'Invalid args' }) + DELIMITER);
|
|
96
195
|
return;
|
|
97
196
|
}
|
|
197
|
+
if (this.streamingCommands.has(request.command)) {
|
|
198
|
+
await this.handleWatchStreaming(request, socket);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
98
201
|
const response = await this.handleCommand(request);
|
|
99
202
|
socket.end(JSON.stringify(response) + DELIMITER);
|
|
100
203
|
}
|
|
@@ -107,30 +210,10 @@ export class AgentViewServer {
|
|
|
107
210
|
}
|
|
108
211
|
}
|
|
109
212
|
async handleCommand(req) {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
return this.handleLaunch(req);
|
|
115
|
-
case 'dom':
|
|
116
|
-
return this.handleDom(req);
|
|
117
|
-
case 'click':
|
|
118
|
-
return this.handleClick(req);
|
|
119
|
-
case 'fill':
|
|
120
|
-
return this.handleFill(req);
|
|
121
|
-
case 'screenshot':
|
|
122
|
-
return this.handleScreenshot(req);
|
|
123
|
-
case 'scene':
|
|
124
|
-
return this.handleScene(req);
|
|
125
|
-
case 'snap':
|
|
126
|
-
return this.handleSnap(req);
|
|
127
|
-
case 'wait':
|
|
128
|
-
return this.handleWait(req);
|
|
129
|
-
case 'stop':
|
|
130
|
-
return this.handleStop();
|
|
131
|
-
default:
|
|
132
|
-
return { ok: false, error: `Unknown command: ${req.command}` };
|
|
133
|
-
}
|
|
213
|
+
const handler = this.handlers[req.command];
|
|
214
|
+
if (!handler)
|
|
215
|
+
return { ok: false, error: `Unknown command: ${req.command}` };
|
|
216
|
+
return handler(req);
|
|
134
217
|
}
|
|
135
218
|
async resolveWindow(req) {
|
|
136
219
|
const adapter = getAdapter(req.runtime);
|
|
@@ -154,38 +237,50 @@ export class AgentViewServer {
|
|
|
154
237
|
}
|
|
155
238
|
return { targetId, windows };
|
|
156
239
|
}
|
|
157
|
-
async
|
|
240
|
+
async getPageSession(req, targetId) {
|
|
158
241
|
const connKey = `${req.port}:${targetId}`;
|
|
159
|
-
|
|
160
|
-
if (
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
242
|
+
const cached = this.connections.get(connKey);
|
|
243
|
+
if (cached) {
|
|
244
|
+
if (cached.kind === 'page')
|
|
245
|
+
return cached.session;
|
|
246
|
+
throw new Error(`Cached session for ${targetId} is runtime-only — cannot use page operations.`);
|
|
164
247
|
}
|
|
165
|
-
|
|
248
|
+
const adapter = getAdapter(req.runtime);
|
|
249
|
+
const session = await adapter.connect(req.port, targetId, this.axTreeCache);
|
|
250
|
+
this.connections.set(connKey, { kind: 'page', session });
|
|
251
|
+
return session;
|
|
252
|
+
}
|
|
253
|
+
async getRuntimeSession(req, target) {
|
|
254
|
+
const connKey = `${req.port}:${target.id}`;
|
|
255
|
+
const cached = this.connections.get(connKey);
|
|
256
|
+
if (cached)
|
|
257
|
+
return cached.session;
|
|
258
|
+
if (target.type === TargetType.Page || target.type === TargetType.Iframe) {
|
|
259
|
+
// Page targets can serve runtime requests via their PageSession (it extends RuntimeSession).
|
|
260
|
+
return this.getPageSession(req, target.id);
|
|
261
|
+
}
|
|
262
|
+
if (!RUNTIME_ONLY_TARGETS.has(target.type)) {
|
|
263
|
+
throw new Error(`Target type "${target.type}" does not support eval/console.`);
|
|
264
|
+
}
|
|
265
|
+
const session = await connectToRuntime(req.port, target);
|
|
266
|
+
this.connections.set(connKey, { kind: 'runtime', session });
|
|
267
|
+
return session;
|
|
166
268
|
}
|
|
167
269
|
async handleDiscover(req) {
|
|
168
270
|
const adapter = getAdapter(req.runtime);
|
|
169
271
|
const windows = await adapter.discover(req.port);
|
|
170
272
|
return {
|
|
171
273
|
ok: true,
|
|
172
|
-
data: {
|
|
173
|
-
runtime: req.runtime,
|
|
174
|
-
port: req.port,
|
|
175
|
-
windows,
|
|
176
|
-
},
|
|
274
|
+
data: { runtime: req.runtime, port: req.port, windows },
|
|
177
275
|
};
|
|
178
276
|
}
|
|
179
277
|
async handleLaunch(req) {
|
|
180
278
|
const launchCmd = argStr(req.args, 'launch');
|
|
181
279
|
const cwd = argStr(req.args, 'cwd');
|
|
182
|
-
if (!launchCmd)
|
|
280
|
+
if (!launchCmd)
|
|
183
281
|
return { ok: false, error: 'No launch command provided' };
|
|
184
|
-
|
|
185
|
-
if (!cwd) {
|
|
282
|
+
if (!cwd)
|
|
186
283
|
return { ok: false, error: 'launch requires cwd to validate config' };
|
|
187
|
-
}
|
|
188
|
-
// Validate launch command against on-disk config to prevent injection
|
|
189
284
|
const config = readConfig(resolve(cwd));
|
|
190
285
|
if (!config || config.launch !== launchCmd) {
|
|
191
286
|
return { ok: false, error: 'Launch command does not match project config' };
|
|
@@ -198,17 +293,76 @@ export class AgentViewServer {
|
|
|
198
293
|
}
|
|
199
294
|
async handleDom(req) {
|
|
200
295
|
const { targetId } = await this.resolveWindow(req);
|
|
201
|
-
const conn = await this.
|
|
202
|
-
const
|
|
296
|
+
const conn = await this.getPageSession(req, targetId);
|
|
297
|
+
const filter = argStr(req.args, 'filter');
|
|
298
|
+
const useText = argBool(req.args, 'text') ?? false;
|
|
299
|
+
const compact = argBool(req.args, 'compact') ?? false;
|
|
300
|
+
const useCount = argBool(req.args, 'count') ?? false;
|
|
301
|
+
const isDiff = argBool(req.args, 'diff') ?? false;
|
|
302
|
+
const cacheKey = `${req.port}:${targetId}`;
|
|
303
|
+
const { nodes, fromCache } = await conn.getAccessibilityTreeMeta();
|
|
304
|
+
if (useCount) {
|
|
305
|
+
const { count } = countAccessibilityNodes(nodes, {
|
|
306
|
+
filter,
|
|
307
|
+
depth: resolveDepth(filter, argNum(req.args, 'depth')),
|
|
308
|
+
});
|
|
309
|
+
return { ok: true, data: String(count) };
|
|
310
|
+
}
|
|
203
311
|
const { text, refs, nextRef } = formatAccessibilityTree(nodes, {
|
|
204
|
-
filter
|
|
205
|
-
depth: argNum(req.args, 'depth'),
|
|
312
|
+
filter,
|
|
313
|
+
depth: resolveDepth(filter, argNum(req.args, 'depth')),
|
|
206
314
|
startRef: this.refStore.getNextRef(),
|
|
315
|
+
compact,
|
|
316
|
+
maxLines: argNum(req.args, 'maxLines'),
|
|
207
317
|
});
|
|
208
318
|
this.refStore.store(refs, req.port, targetId, nextRef);
|
|
209
|
-
|
|
319
|
+
if (isDiff) {
|
|
320
|
+
const prev = this.domTextCache.get(cacheKey);
|
|
321
|
+
this.domTextCache.set(cacheKey, text);
|
|
322
|
+
if (prev === undefined) {
|
|
323
|
+
// First call — no snapshot yet, return full tree
|
|
324
|
+
return { ok: true, data: text };
|
|
325
|
+
}
|
|
326
|
+
return { ok: true, data: diffDomText(prev, text) };
|
|
327
|
+
}
|
|
328
|
+
if (useText && filter && text.startsWith('(no matching')) {
|
|
329
|
+
return { ok: true, data: await textContentFallback(conn, filter) };
|
|
330
|
+
}
|
|
331
|
+
const data = fromCache ? `[cache]\n${text}` : text;
|
|
332
|
+
return { ok: true, data };
|
|
210
333
|
}
|
|
211
334
|
async findByFilter(conn, filter, req, targetId, preferRoles) {
|
|
335
|
+
const parsed = parseFilter(filter);
|
|
336
|
+
if (parsed.kind === 'simple') {
|
|
337
|
+
const queryNodes = await conn.queryAXTree({ accessibleName: parsed.name, role: parsed.role });
|
|
338
|
+
if (queryNodes !== null) {
|
|
339
|
+
if (queryNodes.length === 0)
|
|
340
|
+
return null;
|
|
341
|
+
const startRef = this.refStore.getNextRef();
|
|
342
|
+
let refNum = startRef;
|
|
343
|
+
const refs = [];
|
|
344
|
+
for (const node of queryNodes) {
|
|
345
|
+
if (node.backendDOMNodeId) {
|
|
346
|
+
refs.push({ ref: refNum++, backendDOMNodeId: node.backendDOMNodeId });
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
this.refStore.store(refs, req.port, targetId, refNum);
|
|
350
|
+
if (refs.length === 0)
|
|
351
|
+
return null;
|
|
352
|
+
if (preferRoles) {
|
|
353
|
+
for (let i = 0; i < queryNodes.length; i++) {
|
|
354
|
+
const node = queryNodes[i];
|
|
355
|
+
if (node.backendDOMNodeId && preferRoles.has(node.role?.value ?? '')) {
|
|
356
|
+
return { backendDOMNodeId: node.backendDOMNodeId, name: node.name?.value ?? parsed.name };
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
const first = queryNodes.find(n => n.backendDOMNodeId);
|
|
361
|
+
if (!first?.backendDOMNodeId)
|
|
362
|
+
return null;
|
|
363
|
+
return { backendDOMNodeId: first.backendDOMNodeId, name: first.name?.value ?? parsed.name };
|
|
364
|
+
}
|
|
365
|
+
}
|
|
212
366
|
const nodes = await conn.getAccessibilityTree();
|
|
213
367
|
const { refs, nextRef } = formatAccessibilityTree(nodes, {
|
|
214
368
|
filter,
|
|
@@ -217,7 +371,6 @@ export class AgentViewServer {
|
|
|
217
371
|
this.refStore.store(refs, req.port, targetId, nextRef);
|
|
218
372
|
if (refs.length === 0)
|
|
219
373
|
return null;
|
|
220
|
-
// Build map with fallback name resolution (same logic as dom.ts)
|
|
221
374
|
const nodeById = new Map();
|
|
222
375
|
for (const node of nodes)
|
|
223
376
|
nodeById.set(node.nodeId, node);
|
|
@@ -250,7 +403,6 @@ export class AgentViewServer {
|
|
|
250
403
|
}
|
|
251
404
|
}
|
|
252
405
|
const lowerFilter = filter.toLowerCase();
|
|
253
|
-
// If preferred roles specified, try those first
|
|
254
406
|
if (preferRoles) {
|
|
255
407
|
for (const entry of refs) {
|
|
256
408
|
const info = nodeByDOMId.get(entry.backendDOMNodeId);
|
|
@@ -259,26 +411,26 @@ export class AgentViewServer {
|
|
|
259
411
|
}
|
|
260
412
|
}
|
|
261
413
|
}
|
|
262
|
-
// Pick the deepest match whose name contains the filter text (leaf-first)
|
|
263
414
|
for (let i = refs.length - 1; i >= 0; i--) {
|
|
264
415
|
const info = nodeByDOMId.get(refs[i].backendDOMNodeId);
|
|
265
416
|
if (info && info.name.toLowerCase().includes(lowerFilter)) {
|
|
266
417
|
return { backendDOMNodeId: refs[i].backendDOMNodeId, name: info.name };
|
|
267
418
|
}
|
|
268
419
|
}
|
|
269
|
-
// Fallback: last ref (deepest element in filtered tree)
|
|
270
420
|
const last = refs[refs.length - 1];
|
|
271
421
|
const lastInfo = nodeByDOMId.get(last.backendDOMNodeId);
|
|
272
422
|
return { backendDOMNodeId: last.backendDOMNodeId, name: lastInfo?.name ?? filter };
|
|
273
423
|
}
|
|
274
424
|
async handleClick(req) {
|
|
275
425
|
const { targetId } = await this.resolveWindow(req);
|
|
276
|
-
const conn = await this.
|
|
426
|
+
const conn = await this.getPageSession(req, targetId);
|
|
427
|
+
const cacheKey = `${req.port}:${targetId}`;
|
|
277
428
|
if (req.args.pos && typeof req.args.pos === 'object') {
|
|
278
429
|
const pos = req.args.pos;
|
|
279
430
|
const x = typeof pos.x === 'number' ? pos.x : 0;
|
|
280
431
|
const y = typeof pos.y === 'number' ? pos.y : 0;
|
|
281
432
|
await conn.clickAtPosition(x, y);
|
|
433
|
+
this.axTreeCache.invalidate(cacheKey);
|
|
282
434
|
return { ok: true, data: `Clicked at (${x}, ${y})` };
|
|
283
435
|
}
|
|
284
436
|
const clickFilter = argStr(req.args, 'filter');
|
|
@@ -290,6 +442,7 @@ export class AgentViewServer {
|
|
|
290
442
|
return { ok: false, error: `No element found matching "${filter}"` };
|
|
291
443
|
}
|
|
292
444
|
await conn.clickByNodeId(found.backendDOMNodeId);
|
|
445
|
+
this.axTreeCache.invalidate(cacheKey);
|
|
293
446
|
return { ok: true, data: `Clicked "${found.name}"` };
|
|
294
447
|
}
|
|
295
448
|
const ref = argNum(req.args, 'ref');
|
|
@@ -301,11 +454,52 @@ export class AgentViewServer {
|
|
|
301
454
|
return { ok: false, error: `Invalid ref: ${ref}. Run \`agent-view dom\` to get fresh refs.` };
|
|
302
455
|
}
|
|
303
456
|
await conn.clickByNodeId(entry.backendDOMNodeId);
|
|
457
|
+
this.axTreeCache.invalidate(cacheKey);
|
|
304
458
|
return { ok: true, data: `Clicked ref ${ref}` };
|
|
305
459
|
}
|
|
460
|
+
async handleDrag(req) {
|
|
461
|
+
const { targetId } = await this.resolveWindow(req);
|
|
462
|
+
const conn = await this.getPageSession(req, targetId);
|
|
463
|
+
const cacheKey = `${req.port}:${targetId}`;
|
|
464
|
+
const from = await this.resolveDragPoint(req, conn, 'from', { scrollIntoView: true });
|
|
465
|
+
if ('error' in from)
|
|
466
|
+
return { ok: false, error: from.error };
|
|
467
|
+
const to = await this.resolveDragPoint(req, conn, 'to', { scrollIntoView: false });
|
|
468
|
+
if ('error' in to)
|
|
469
|
+
return { ok: false, error: to.error };
|
|
470
|
+
const opts = {
|
|
471
|
+
steps: argNum(req.args, 'steps'),
|
|
472
|
+
button: parseMouseButton(argStr(req.args, 'button')),
|
|
473
|
+
holdMs: argNum(req.args, 'holdMs'),
|
|
474
|
+
};
|
|
475
|
+
await conn.dragBetweenPositions(from.point, to.point, opts);
|
|
476
|
+
this.axTreeCache.invalidate(cacheKey);
|
|
477
|
+
return {
|
|
478
|
+
ok: true,
|
|
479
|
+
data: `Dragged (${from.point.x.toFixed(0)}, ${from.point.y.toFixed(0)}) → (${to.point.x.toFixed(0)}, ${to.point.y.toFixed(0)})`,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
async resolveDragPoint(req, conn, side, opts) {
|
|
483
|
+
const ref = argNum(req.args, `${side}Ref`);
|
|
484
|
+
const x = argNum(req.args, `${side}X`);
|
|
485
|
+
const y = argNum(req.args, `${side}Y`);
|
|
486
|
+
if (ref !== undefined) {
|
|
487
|
+
const entry = this.refStore.get(ref);
|
|
488
|
+
if (!entry) {
|
|
489
|
+
return { error: `Invalid --${side} ref: ${ref}. Run \`agent-view dom\` to get fresh refs.` };
|
|
490
|
+
}
|
|
491
|
+
const point = await conn.getBoxCenter(entry.backendDOMNodeId, opts);
|
|
492
|
+
return { point };
|
|
493
|
+
}
|
|
494
|
+
if (x !== undefined && y !== undefined) {
|
|
495
|
+
return { point: { x, y } };
|
|
496
|
+
}
|
|
497
|
+
return { error: `drag requires --${side} <ref> or --${side}-pos <x,y>` };
|
|
498
|
+
}
|
|
306
499
|
async handleFill(req) {
|
|
307
500
|
const { targetId } = await this.resolveWindow(req);
|
|
308
|
-
const conn = await this.
|
|
501
|
+
const conn = await this.getPageSession(req, targetId);
|
|
502
|
+
const cacheKey = `${req.port}:${targetId}`;
|
|
309
503
|
const value = argStr(req.args, 'value');
|
|
310
504
|
if (value === undefined) {
|
|
311
505
|
return { ok: false, error: 'fill requires --value' };
|
|
@@ -319,6 +513,7 @@ export class AgentViewServer {
|
|
|
319
513
|
return { ok: false, error: `No element found matching "${filter}"` };
|
|
320
514
|
}
|
|
321
515
|
await conn.fillByNodeId(found.backendDOMNodeId, value);
|
|
516
|
+
this.axTreeCache.invalidate(cacheKey);
|
|
322
517
|
return { ok: true, data: `Filled "${found.name}" with "${value}"` };
|
|
323
518
|
}
|
|
324
519
|
const fillRef = argNum(req.args, 'ref');
|
|
@@ -330,6 +525,7 @@ export class AgentViewServer {
|
|
|
330
525
|
return { ok: false, error: `Invalid ref: ${fillRef}. Run \`agent-view dom\` to get fresh refs.` };
|
|
331
526
|
}
|
|
332
527
|
await conn.fillByNodeId(entry.backendDOMNodeId, value);
|
|
528
|
+
this.axTreeCache.invalidate(cacheKey);
|
|
333
529
|
return { ok: true, data: `Filled ref ${fillRef} with "${value}"` };
|
|
334
530
|
}
|
|
335
531
|
async handleWait(req) {
|
|
@@ -341,7 +537,7 @@ export class AgentViewServer {
|
|
|
341
537
|
const interval = 500;
|
|
342
538
|
const maxAttempts = Math.ceil((timeout * 1000) / interval);
|
|
343
539
|
const { targetId } = await this.resolveWindow(req);
|
|
344
|
-
const conn = await this.
|
|
540
|
+
const conn = await this.getPageSession(req, targetId);
|
|
345
541
|
for (let i = 0; i < maxAttempts; i++) {
|
|
346
542
|
const nodes = await conn.getAccessibilityTree();
|
|
347
543
|
const { refs, text, nextRef } = formatAccessibilityTree(nodes, {
|
|
@@ -356,23 +552,42 @@ export class AgentViewServer {
|
|
|
356
552
|
}
|
|
357
553
|
return { ok: false, error: `Timeout: "${filter}" not found after ${timeout}s` };
|
|
358
554
|
}
|
|
359
|
-
async
|
|
360
|
-
const {
|
|
361
|
-
const
|
|
362
|
-
const
|
|
363
|
-
const filename = `agent-view-screenshot-${Date.now()}.png`;
|
|
555
|
+
async captureScreenshotToFile(conn, opts = {}) {
|
|
556
|
+
const { buffer, format } = await conn.captureScreenshot(opts);
|
|
557
|
+
const ext = format === 'jpeg' ? 'jpg' : format;
|
|
558
|
+
const filename = `agent-view-screenshot-${Date.now()}.${ext}`;
|
|
364
559
|
const filepath = join(tmpdir(), filename);
|
|
365
560
|
await writeFile(filepath, buffer);
|
|
366
|
-
return
|
|
561
|
+
return filepath;
|
|
562
|
+
}
|
|
563
|
+
async handleScreenshot(req) {
|
|
564
|
+
const { targetId } = await this.resolveWindow(req);
|
|
565
|
+
const conn = await this.getPageSession(req, targetId);
|
|
566
|
+
const scale = argNum(req.args, 'scale');
|
|
567
|
+
const cropFilter = argStr(req.args, 'crop');
|
|
568
|
+
let warning;
|
|
569
|
+
let clip;
|
|
570
|
+
if (cropFilter !== undefined) {
|
|
571
|
+
const found = await this.findByFilter(conn, cropFilter, req, targetId);
|
|
572
|
+
if (!found) {
|
|
573
|
+
warning = `crop filter '${cropFilter}' matched nothing — capturing full window`;
|
|
574
|
+
}
|
|
575
|
+
else {
|
|
576
|
+
clip = await conn.getBoxRect(found.backendDOMNodeId, { scrollIntoView: true });
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
const filepath = await this.captureScreenshotToFile(conn, { scale, clip });
|
|
580
|
+
return { ok: true, data: filepath, warning };
|
|
367
581
|
}
|
|
368
582
|
async handleScene(req) {
|
|
369
583
|
const { targetId } = await this.resolveWindow(req);
|
|
370
|
-
const conn = await this.
|
|
584
|
+
const conn = await this.getPageSession(req, targetId);
|
|
371
585
|
const isDiff = argBool(req.args, 'diff') ?? false;
|
|
372
586
|
const cacheKey = `${req.port}:${targetId}`;
|
|
373
587
|
const sceneFilter = argStr(req.args, 'filter');
|
|
374
588
|
const sceneDepth = argNum(req.args, 'depth');
|
|
375
589
|
const sceneVerbose = argBool(req.args, 'verbose');
|
|
590
|
+
const sceneCompact = argBool(req.args, 'compact');
|
|
376
591
|
if (isDiff) {
|
|
377
592
|
const curr = await getRawScene(conn, req.engine);
|
|
378
593
|
if (!curr) {
|
|
@@ -385,6 +600,7 @@ export class AgentViewServer {
|
|
|
385
600
|
filter: sceneFilter,
|
|
386
601
|
depth: sceneDepth,
|
|
387
602
|
verbose: sceneVerbose,
|
|
603
|
+
compact: sceneCompact,
|
|
388
604
|
});
|
|
389
605
|
return { ok: true, data: text };
|
|
390
606
|
}
|
|
@@ -394,15 +610,16 @@ export class AgentViewServer {
|
|
|
394
610
|
filter: sceneFilter,
|
|
395
611
|
depth: sceneDepth,
|
|
396
612
|
verbose: sceneVerbose,
|
|
613
|
+
compact: sceneCompact,
|
|
397
614
|
});
|
|
398
615
|
return { ok: true, data: text };
|
|
399
616
|
}
|
|
400
617
|
async handleSnap(req) {
|
|
401
618
|
const { targetId } = await this.resolveWindow(req);
|
|
402
|
-
const conn = await this.
|
|
619
|
+
const conn = await this.getPageSession(req, targetId);
|
|
403
620
|
const snapFilter = argStr(req.args, 'filter');
|
|
404
621
|
const snapDepth = argNum(req.args, 'depth');
|
|
405
|
-
|
|
622
|
+
const snapScale = argNum(req.args, 'scale');
|
|
406
623
|
const nodes = await conn.getAccessibilityTree();
|
|
407
624
|
const { text: domText, refs, nextRef } = formatAccessibilityTree(nodes, {
|
|
408
625
|
filter: snapFilter,
|
|
@@ -420,8 +637,278 @@ export class AgentViewServer {
|
|
|
420
637
|
sections.push(`=== Scene ===\n${sceneText}`);
|
|
421
638
|
}
|
|
422
639
|
}
|
|
640
|
+
if (snapScale !== undefined) {
|
|
641
|
+
const filepath = await this.captureScreenshotToFile(conn, { scale: snapScale });
|
|
642
|
+
sections.push(`=== Screenshot ===\n${filepath}`);
|
|
643
|
+
}
|
|
423
644
|
return { ok: true, data: sections.join('\n\n') };
|
|
424
645
|
}
|
|
646
|
+
// ── New v0.3.0 commands ────────────────────────────────────────────────────
|
|
647
|
+
async handleTargets(req) {
|
|
648
|
+
const targets = await listSupportedTargets(req.port);
|
|
649
|
+
const typeFilter = argStrArray(req.args, 'types');
|
|
650
|
+
const filtered = typeFilter && typeFilter.length > 0
|
|
651
|
+
? targets.filter(t => typeFilter.includes(t.type))
|
|
652
|
+
: targets;
|
|
653
|
+
return { ok: true, data: { runtime: req.runtime, port: req.port, targets: filtered } };
|
|
654
|
+
}
|
|
655
|
+
async resolveTarget(req) {
|
|
656
|
+
const explicitId = argStr(req.args, 'target');
|
|
657
|
+
const windowArg = argStr(req.args, 'window');
|
|
658
|
+
const allTargets = await listSupportedTargets(req.port);
|
|
659
|
+
if (explicitId) {
|
|
660
|
+
const found = findTargetByIdOrSubstring(allTargets, explicitId);
|
|
661
|
+
if (found)
|
|
662
|
+
return found;
|
|
663
|
+
throw new Error(`Target not found: "${explicitId}". Run \`agent-view targets\` for the full list.`);
|
|
664
|
+
}
|
|
665
|
+
if (windowArg) {
|
|
666
|
+
const pages = allTargets.filter(t => t.type === TargetType.Page);
|
|
667
|
+
const byId = pages.find(t => t.id === windowArg);
|
|
668
|
+
const byTitle = pages.find(t => t.title.toLowerCase().includes(windowArg.toLowerCase()));
|
|
669
|
+
const found = byId ?? byTitle;
|
|
670
|
+
if (!found) {
|
|
671
|
+
throw new Error(`Window not found: "${windowArg}".`);
|
|
672
|
+
}
|
|
673
|
+
return found;
|
|
674
|
+
}
|
|
675
|
+
const firstPage = allTargets.find(t => t.type === TargetType.Page);
|
|
676
|
+
if (!firstPage)
|
|
677
|
+
throw new Error('No page targets found.');
|
|
678
|
+
return firstPage;
|
|
679
|
+
}
|
|
680
|
+
async handleEval(req) {
|
|
681
|
+
const cwd = argStr(req.args, 'cwd');
|
|
682
|
+
if (!cwd) {
|
|
683
|
+
return { ok: false, error: 'eval requires cwd to validate allowEval policy' };
|
|
684
|
+
}
|
|
685
|
+
const config = readConfig(resolve(cwd));
|
|
686
|
+
if (!config?.allowEval) {
|
|
687
|
+
return {
|
|
688
|
+
ok: false,
|
|
689
|
+
error: 'eval is disabled. Set "allowEval": true in agent-view.config.json to enable.',
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
const expression = argStr(req.args, 'expression');
|
|
693
|
+
if (!expression)
|
|
694
|
+
return { ok: false, error: 'eval requires --expression' };
|
|
695
|
+
const target = await this.resolveTarget(req);
|
|
696
|
+
if (!RUNTIME_ONLY_TARGETS.has(target.type) && target.type !== TargetType.Page && target.type !== TargetType.Iframe) {
|
|
697
|
+
return { ok: false, error: `Target type "${target.type}" does not support eval.` };
|
|
698
|
+
}
|
|
699
|
+
const session = await this.getRuntimeSession(req, target);
|
|
700
|
+
const awaitPromise = argBool(req.args, 'await') ?? false;
|
|
701
|
+
const asJson = argBool(req.args, 'json') ?? false;
|
|
702
|
+
try {
|
|
703
|
+
const value = await session.evaluate(expression, { awaitPromise });
|
|
704
|
+
const formatted = asJson ? safeJSONStringify(value) : formatEvalValue(value);
|
|
705
|
+
const capped = formatted.length > EVAL_OUTPUT_CAP
|
|
706
|
+
? `${formatted.slice(0, EVAL_OUTPUT_CAP)}\n... <${formatted.length - EVAL_OUTPUT_CAP} more bytes truncated>`
|
|
707
|
+
: formatted;
|
|
708
|
+
return { ok: true, data: { target: { id: target.id, type: target.type }, result: capped } };
|
|
709
|
+
}
|
|
710
|
+
catch (err) {
|
|
711
|
+
if (err instanceof EvaluationError) {
|
|
712
|
+
return { ok: false, error: err.message + (err.stack ? `\n${err.stack}` : '') };
|
|
713
|
+
}
|
|
714
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
async handleWatchStreaming(req, socket) {
|
|
718
|
+
const writeFrame = (frame) => {
|
|
719
|
+
if (socket.writableEnded || socket.destroyed)
|
|
720
|
+
return false;
|
|
721
|
+
return socket.write(JSON.stringify(frame) + DELIMITER);
|
|
722
|
+
};
|
|
723
|
+
const writeError = (msg) => {
|
|
724
|
+
if (!socket.writableEnded && !socket.destroyed) {
|
|
725
|
+
socket.end(JSON.stringify({ ok: false, error: msg }) + DELIMITER);
|
|
726
|
+
}
|
|
727
|
+
};
|
|
728
|
+
const cwd = argStr(req.args, 'cwd');
|
|
729
|
+
if (!cwd)
|
|
730
|
+
return writeError('watch requires cwd to validate allowEval policy');
|
|
731
|
+
const config = readConfig(resolve(cwd));
|
|
732
|
+
if (!config?.allowEval) {
|
|
733
|
+
return writeError('watch is disabled. Set "allowEval": true in agent-view.config.json to enable.');
|
|
734
|
+
}
|
|
735
|
+
const expression = argStr(req.args, 'expression');
|
|
736
|
+
if (!expression)
|
|
737
|
+
return writeError('watch requires --expression');
|
|
738
|
+
const intervalRaw = argNum(req.args, 'intervalMs') ?? 250;
|
|
739
|
+
const intervalMs = Math.max(WATCH_MIN_INTERVAL_MS, intervalRaw);
|
|
740
|
+
const durationS = argNum(req.args, 'durationS') ?? 30;
|
|
741
|
+
const maxChanges = argNum(req.args, 'maxChanges') ?? 10;
|
|
742
|
+
const until = argStr(req.args, 'until');
|
|
743
|
+
if (maxChanges <= 0)
|
|
744
|
+
return writeError('--max-changes must be > 0');
|
|
745
|
+
if (durationS <= 0)
|
|
746
|
+
return writeError('--duration must be > 0');
|
|
747
|
+
let target;
|
|
748
|
+
try {
|
|
749
|
+
target = await this.resolveTarget(req);
|
|
750
|
+
}
|
|
751
|
+
catch (err) {
|
|
752
|
+
return writeError(err instanceof Error ? err.message : String(err));
|
|
753
|
+
}
|
|
754
|
+
if (!RUNTIME_ONLY_TARGETS.has(target.type) && target.type !== TargetType.Page && target.type !== TargetType.Iframe) {
|
|
755
|
+
return writeError(`Target type "${target.type}" does not support watch.`);
|
|
756
|
+
}
|
|
757
|
+
let session;
|
|
758
|
+
try {
|
|
759
|
+
session = await this.getRuntimeSession(req, target);
|
|
760
|
+
}
|
|
761
|
+
catch (err) {
|
|
762
|
+
return writeError(err instanceof Error ? err.message : String(err));
|
|
763
|
+
}
|
|
764
|
+
if (this.idleTimer) {
|
|
765
|
+
clearTimeout(this.idleTimer);
|
|
766
|
+
this.idleTimer = null;
|
|
767
|
+
}
|
|
768
|
+
const watch = new WatchSession(session, {
|
|
769
|
+
expression,
|
|
770
|
+
intervalMs,
|
|
771
|
+
durationS,
|
|
772
|
+
maxChanges,
|
|
773
|
+
until,
|
|
774
|
+
emit: writeFrame,
|
|
775
|
+
});
|
|
776
|
+
this.activeWatches.add(watch);
|
|
777
|
+
const cleanup = () => {
|
|
778
|
+
if (!this.activeWatches.has(watch))
|
|
779
|
+
return;
|
|
780
|
+
this.activeWatches.delete(watch);
|
|
781
|
+
if (!socket.writableEnded && !socket.destroyed)
|
|
782
|
+
socket.end();
|
|
783
|
+
if (this.activeWatches.size === 0)
|
|
784
|
+
this.resetIdleTimer();
|
|
785
|
+
};
|
|
786
|
+
watch.onStop(cleanup);
|
|
787
|
+
socket.on('close', () => {
|
|
788
|
+
// Client closed (e.g. SIGINT). Stop watch with sigint reason if not already stopped.
|
|
789
|
+
watch.stop(StopReason.Sigint, true);
|
|
790
|
+
});
|
|
791
|
+
socket.on('error', () => {
|
|
792
|
+
watch.stop(StopReason.Sigint, true);
|
|
793
|
+
});
|
|
794
|
+
try {
|
|
795
|
+
await watch.start();
|
|
796
|
+
}
|
|
797
|
+
catch (err) {
|
|
798
|
+
writeFrame({ type: 'error', ts: new Date().toISOString(), message: err instanceof Error ? err.message : String(err) });
|
|
799
|
+
watch.stop(StopReason.EvalFailed, false);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
async handleConsole(req) {
|
|
803
|
+
const cwd = argStr(req.args, 'cwd');
|
|
804
|
+
const config = cwd ? readConfig(resolve(cwd)) : null;
|
|
805
|
+
const bufferSize = config?.consoleBufferSize ?? 500;
|
|
806
|
+
if (this.consoleStream.attachedCount === 0) {
|
|
807
|
+
// Recreate with config-tuned capacity on first attach
|
|
808
|
+
this.consoleStream = new ConsoleStream({ capacity: bufferSize });
|
|
809
|
+
}
|
|
810
|
+
const targetQuery = argStr(req.args, 'target');
|
|
811
|
+
// Fuzzy-resolve the target query once (id exact → title substring → url substring)
|
|
812
|
+
const all = await listSupportedTargets(req.port);
|
|
813
|
+
let resolvedTargetId;
|
|
814
|
+
if (targetQuery) {
|
|
815
|
+
const resolved = findTargetByIdOrSubstring(all, targetQuery);
|
|
816
|
+
if (!resolved) {
|
|
817
|
+
return { ok: false, error: `Target not found: "${targetQuery}". Run \`agent-view targets\` for the full list.` };
|
|
818
|
+
}
|
|
819
|
+
resolvedTargetId = resolved.id;
|
|
820
|
+
}
|
|
821
|
+
if (argBool(req.args, 'clear')) {
|
|
822
|
+
this.consoleStream.clear(resolvedTargetId);
|
|
823
|
+
return { ok: true, data: 'Console buffer cleared' };
|
|
824
|
+
}
|
|
825
|
+
const requestedTypes = argStrArray(req.args, 'consoleTargets')
|
|
826
|
+
?? config?.consoleTargets
|
|
827
|
+
?? DEFAULT_CONSOLE_TARGETS;
|
|
828
|
+
const allowedTypes = new Set(requestedTypes
|
|
829
|
+
.filter((t) => typeof t === 'string')
|
|
830
|
+
.filter((t) => Object.values(TargetType).includes(t)));
|
|
831
|
+
// Lazy attach: ensure every matching target has a session
|
|
832
|
+
if (process.env.AV_DEBUG_CONSOLE) {
|
|
833
|
+
// eslint-disable-next-line no-console
|
|
834
|
+
console.error(`[av-debug] handleConsole: targets=${all.length} explicit=${resolvedTargetId ?? 'none'} types=${[...allowedTypes].join(',')}`);
|
|
835
|
+
}
|
|
836
|
+
for (const t of all) {
|
|
837
|
+
if (resolvedTargetId && t.id !== resolvedTargetId)
|
|
838
|
+
continue;
|
|
839
|
+
if (!resolvedTargetId && !allowedTypes.has(t.type))
|
|
840
|
+
continue;
|
|
841
|
+
if (!RUNTIME_ONLY_TARGETS.has(t.type) && t.type !== TargetType.Page && t.type !== TargetType.Iframe)
|
|
842
|
+
continue;
|
|
843
|
+
try {
|
|
844
|
+
const session = await this.getRuntimeSession(req, t);
|
|
845
|
+
this.consoleStream.attach(session);
|
|
846
|
+
if (process.env.AV_DEBUG_CONSOLE) {
|
|
847
|
+
// eslint-disable-next-line no-console
|
|
848
|
+
console.error(`[av-debug] handleConsole: attached ${t.type}:${t.id.slice(0, 8)} (stream now has ${this.consoleStream.attachedCount})`);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
catch (err) {
|
|
852
|
+
if (process.env.AV_DEBUG_CONSOLE) {
|
|
853
|
+
// eslint-disable-next-line no-console
|
|
854
|
+
console.error(`[av-debug] handleConsole: SKIP ${t.type}:${t.id.slice(0, 8)} — ${err.message}`);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
const levelFilter = parseLevelFilter(argStrArray(req.args, 'levels'));
|
|
859
|
+
const since = argNum(req.args, 'since');
|
|
860
|
+
const follow = argBool(req.args, 'follow') ?? false;
|
|
861
|
+
const untilPattern = argStr(req.args, 'until');
|
|
862
|
+
if (untilPattern && !follow) {
|
|
863
|
+
return { ok: false, error: '--until requires --follow' };
|
|
864
|
+
}
|
|
865
|
+
if (follow) {
|
|
866
|
+
const timeoutSec = argNum(req.args, 'timeout') ?? 10;
|
|
867
|
+
const collected = this.consoleStream.drain({
|
|
868
|
+
since,
|
|
869
|
+
level: levelFilter,
|
|
870
|
+
targetId: resolvedTargetId,
|
|
871
|
+
});
|
|
872
|
+
const seenAt = collected.length > 0 ? collected[collected.length - 1].ts : (since ?? Date.now());
|
|
873
|
+
const matcher = untilPattern ? buildMatcher(untilPattern) : null;
|
|
874
|
+
// Check if pattern already matched in buffered messages
|
|
875
|
+
const earlyMatch = matcher ? collected.findIndex(m => matcher(m.text)) : -1;
|
|
876
|
+
if (earlyMatch !== -1) {
|
|
877
|
+
return { ok: true, data: formatConsoleMessages(collected.slice(0, earlyMatch + 1)) };
|
|
878
|
+
}
|
|
879
|
+
const timedOut = await new Promise((resolveFollow) => {
|
|
880
|
+
const dispose = this.consoleStream.subscribe((msg) => {
|
|
881
|
+
if (resolvedTargetId && msg.targetId !== resolvedTargetId)
|
|
882
|
+
return;
|
|
883
|
+
if (levelFilter && !levelFilter.has(msg.level))
|
|
884
|
+
return;
|
|
885
|
+
if (msg.ts <= seenAt)
|
|
886
|
+
return;
|
|
887
|
+
collected.push(msg);
|
|
888
|
+
if (matcher && matcher(msg.text)) {
|
|
889
|
+
clearTimeout(timer);
|
|
890
|
+
dispose();
|
|
891
|
+
resolveFollow(false);
|
|
892
|
+
}
|
|
893
|
+
});
|
|
894
|
+
const timer = setTimeout(() => {
|
|
895
|
+
dispose();
|
|
896
|
+
resolveFollow(true);
|
|
897
|
+
}, timeoutSec * 1000);
|
|
898
|
+
timer.unref?.();
|
|
899
|
+
});
|
|
900
|
+
if (timedOut && matcher) {
|
|
901
|
+
return { ok: false, error: `Timeout: pattern not seen in ${timeoutSec}s` };
|
|
902
|
+
}
|
|
903
|
+
return { ok: true, data: formatConsoleMessages(collected) };
|
|
904
|
+
}
|
|
905
|
+
const messages = this.consoleStream.drain({
|
|
906
|
+
since,
|
|
907
|
+
level: levelFilter,
|
|
908
|
+
targetId: resolvedTargetId,
|
|
909
|
+
});
|
|
910
|
+
return { ok: true, data: formatConsoleMessages(messages) };
|
|
911
|
+
}
|
|
425
912
|
async handleStop() {
|
|
426
913
|
setTimeout(() => this.shutdown(), 100);
|
|
427
914
|
return { ok: true, data: 'Server stopping' };
|
|
@@ -429,10 +916,14 @@ export class AgentViewServer {
|
|
|
429
916
|
async shutdown() {
|
|
430
917
|
if (this.idleTimer)
|
|
431
918
|
clearTimeout(this.idleTimer);
|
|
919
|
+
for (const watch of [...this.activeWatches]) {
|
|
920
|
+
watch.stop(StopReason.ServerShutdown, false);
|
|
921
|
+
}
|
|
432
922
|
await unlink(TOKEN_PATH).catch(() => { });
|
|
433
|
-
|
|
923
|
+
this.consoleStream.detach();
|
|
924
|
+
for (const cached of this.connections.values()) {
|
|
434
925
|
try {
|
|
435
|
-
await
|
|
926
|
+
await cached.session.close();
|
|
436
927
|
}
|
|
437
928
|
catch { /* ignore */ }
|
|
438
929
|
}
|
|
@@ -441,4 +932,66 @@ export class AgentViewServer {
|
|
|
441
932
|
process.exit(0);
|
|
442
933
|
}
|
|
443
934
|
}
|
|
935
|
+
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
936
|
+
export function findTargetByIdOrSubstring(targets, query) {
|
|
937
|
+
const byId = targets.find(t => t.id === query);
|
|
938
|
+
if (byId)
|
|
939
|
+
return byId;
|
|
940
|
+
const q = query.toLowerCase();
|
|
941
|
+
return targets.find(t => t.title.toLowerCase().includes(q) || t.url.toLowerCase().includes(q)) ?? null;
|
|
942
|
+
}
|
|
943
|
+
function parseMouseButton(value) {
|
|
944
|
+
if (!value)
|
|
945
|
+
return undefined;
|
|
946
|
+
const valid = Object.values(MouseButton);
|
|
947
|
+
return valid.includes(value) ? value : undefined;
|
|
948
|
+
}
|
|
949
|
+
function parseLevelFilter(levels) {
|
|
950
|
+
if (!levels || levels.length === 0)
|
|
951
|
+
return undefined;
|
|
952
|
+
const valid = Object.values(ConsoleLevel);
|
|
953
|
+
const set = new Set();
|
|
954
|
+
for (const l of levels) {
|
|
955
|
+
if (valid.includes(l))
|
|
956
|
+
set.add(l);
|
|
957
|
+
}
|
|
958
|
+
return set.size > 0 ? set : undefined;
|
|
959
|
+
}
|
|
960
|
+
function buildMatcher(pattern) {
|
|
961
|
+
const regexMatch = /^\/(.+)\/([gimsuy]*)$/.exec(pattern);
|
|
962
|
+
if (regexMatch) {
|
|
963
|
+
const re = new RegExp(regexMatch[1], regexMatch[2]);
|
|
964
|
+
return (text) => re.test(text);
|
|
965
|
+
}
|
|
966
|
+
return (text) => text.includes(pattern);
|
|
967
|
+
}
|
|
968
|
+
function formatConsoleMessages(msgs) {
|
|
969
|
+
if (msgs.length === 0)
|
|
970
|
+
return '(no console messages)';
|
|
971
|
+
return msgs.map(formatOneConsoleMessage).join('\n');
|
|
972
|
+
}
|
|
973
|
+
function formatOneConsoleMessage(msg) {
|
|
974
|
+
const time = new Date(msg.ts).toISOString().slice(11, 23);
|
|
975
|
+
const head = `[${time}] [${msg.level}] [${msg.targetType}:${msg.targetId.slice(0, 8)}] ${msg.text}`;
|
|
976
|
+
return msg.stack ? `${head}\n${msg.stack}` : head;
|
|
977
|
+
}
|
|
978
|
+
function safeJSONStringify(value) {
|
|
979
|
+
try {
|
|
980
|
+
return JSON.stringify(value, null, 2) ?? String(value);
|
|
981
|
+
}
|
|
982
|
+
catch {
|
|
983
|
+
return String(value);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
function formatEvalValue(value) {
|
|
987
|
+
if (value === undefined)
|
|
988
|
+
return 'undefined';
|
|
989
|
+
if (value === null)
|
|
990
|
+
return 'null';
|
|
991
|
+
if (typeof value === 'string')
|
|
992
|
+
return JSON.stringify(value);
|
|
993
|
+
if (typeof value === 'number' || typeof value === 'boolean')
|
|
994
|
+
return String(value);
|
|
995
|
+
return safeJSONStringify(value);
|
|
996
|
+
}
|
|
444
997
|
//# sourceMappingURL=server.js.map
|