@petukhovart/agent-view 0.10.1 → 0.11.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/plugin.json +2 -2
- package/README.md +38 -3
- package/dist/cdp/transport.d.ts +3 -0
- package/dist/cdp/transport.d.ts.map +1 -1
- package/dist/cdp/transport.js +139 -13
- package/dist/cdp/transport.js.map +1 -1
- package/dist/cdp/types.d.ts +12 -1
- package/dist/cdp/types.d.ts.map +1 -1
- package/dist/cdp/types.js.map +1 -1
- package/dist/cli/client.d.ts +10 -1
- package/dist/cli/client.d.ts.map +1 -1
- package/dist/cli/client.js +83 -22
- package/dist/cli/client.js.map +1 -1
- package/dist/cli/commands/launch.js +1 -1
- package/dist/cli/commands/launch.js.map +1 -1
- package/dist/cli/commands/logs.d.ts +15 -0
- package/dist/cli/commands/logs.d.ts.map +1 -0
- package/dist/cli/commands/logs.js +51 -0
- package/dist/cli/commands/logs.js.map +1 -0
- package/dist/cli/commands/screenshot.d.ts +1 -0
- package/dist/cli/commands/screenshot.d.ts.map +1 -1
- package/dist/cli/commands/screenshot.js +2 -0
- package/dist/cli/commands/screenshot.js.map +1 -1
- package/dist/cli/commands/watch.d.ts.map +1 -1
- package/dist/cli/commands/watch.js +1 -45
- package/dist/cli/commands/watch.js.map +1 -1
- package/dist/cli/index.js +91 -6
- package/dist/cli/index.js.map +1 -1
- package/dist/config/manager.d.ts +9 -0
- package/dist/config/manager.d.ts.map +1 -1
- package/dist/config/manager.js +24 -1
- package/dist/config/manager.js.map +1 -1
- package/dist/config/types.d.ts +4 -0
- package/dist/config/types.d.ts.map +1 -1
- package/dist/server/log-recorder.d.ts +135 -0
- package/dist/server/log-recorder.d.ts.map +1 -0
- package/dist/server/log-recorder.js +348 -0
- package/dist/server/log-recorder.js.map +1 -0
- package/dist/server/pattern.d.ts +3 -0
- package/dist/server/pattern.d.ts.map +1 -0
- package/dist/server/pattern.js +10 -0
- package/dist/server/pattern.js.map +1 -0
- package/dist/server/server.d.ts +48 -1
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +399 -60
- package/dist/server/server.js.map +1 -1
- package/dist/types.d.ts +3 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/skills/verify/SKILL.md +41 -0
package/dist/server/server.js
CHANGED
|
@@ -3,7 +3,7 @@ import { writeFile, unlink } from 'node:fs/promises';
|
|
|
3
3
|
import { randomBytes } from 'node:crypto';
|
|
4
4
|
import { homedir, tmpdir } from 'node:os';
|
|
5
5
|
import { join, resolve } from 'node:path';
|
|
6
|
-
import { mkdirSync } from 'node:fs';
|
|
6
|
+
import { existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs';
|
|
7
7
|
import { getAdapter } from '../adapters/registry.js';
|
|
8
8
|
import { isAppTarget } from '../adapters/target-filter.js';
|
|
9
9
|
import { formatAccessibilityTree, countAccessibilityNodes, diffDomText } from '../inspectors/dom/index.js';
|
|
@@ -21,7 +21,17 @@ import { NetworkResourceType } from '../cdp/types.js';
|
|
|
21
21
|
import { AxTreeCache } from '../cdp/ax-cache.js';
|
|
22
22
|
import { WatchSession } from './watch-session.js';
|
|
23
23
|
import { StopReason, WATCH_MIN_INTERVAL_MS } from '../inspectors/watch/index.js';
|
|
24
|
+
import { buildMatcher } from './pattern.js';
|
|
25
|
+
import { DEFAULT_TAIL_LINES, LogRecorder, filterLogLines, localStamp, parseSinceToken, readFeedLines, resolveLogFile, } from './log-recorder.js';
|
|
24
26
|
const SERVER_PORT = 47922;
|
|
27
|
+
/**
|
|
28
|
+
* Every non-streaming command answers within this budget (plus its own `--timeout`,
|
|
29
|
+
* for the commands that poll). A CDP call that never returns used to leave the CLI
|
|
30
|
+
* hanging forever with no way back except killing the server.
|
|
31
|
+
*/
|
|
32
|
+
const REQUEST_DEADLINE_MS = envMs('AGENT_VIEW_REQUEST_DEADLINE_MS', 45_000);
|
|
33
|
+
/** `launch` legitimately blocks for minutes (60s Electron / 10min Tauri) — it bounds itself. */
|
|
34
|
+
const UNBOUNDED_COMMANDS = new Set(['launch']);
|
|
25
35
|
const VALID_RUNTIMES = new Set(Object.values(RuntimeType));
|
|
26
36
|
const VALID_ENGINES = new Set(Object.values(WebGLEngine));
|
|
27
37
|
const IDLE_TIMEOUT_MS = 5 * 60 * 1000;
|
|
@@ -34,11 +44,25 @@ const DEFAULT_CONSOLE_TARGETS = [TargetType.Page, TargetType.SharedWorker, Targe
|
|
|
34
44
|
const DEFAULT_NETWORK_BUFFER = 200;
|
|
35
45
|
const DEFAULT_NETWORK_MAX_LINES = 50;
|
|
36
46
|
const NETWORK_PAGE_TARGETS = new Set([TargetType.Page, TargetType.Iframe]);
|
|
47
|
+
/** Below this, a crop rect is a line of text rather than a container worth screenshotting. */
|
|
48
|
+
const TEXT_LINE_HEIGHT_PX = 32;
|
|
37
49
|
const RUNTIME_ONLY_TARGETS = new Set([
|
|
38
50
|
TargetType.SharedWorker,
|
|
39
51
|
TargetType.ServiceWorker,
|
|
40
52
|
TargetType.Worker,
|
|
41
53
|
]);
|
|
54
|
+
function envMs(name, fallback) {
|
|
55
|
+
const raw = Number(process.env[name]);
|
|
56
|
+
return Number.isFinite(raw) && raw > 0 ? raw : fallback;
|
|
57
|
+
}
|
|
58
|
+
/** Poll-based commands carry their own `--timeout` (seconds); the deadline must outlive it. */
|
|
59
|
+
export function requestDeadlineMs(command, args) {
|
|
60
|
+
if (UNBOUNDED_COMMANDS.has(command))
|
|
61
|
+
return null;
|
|
62
|
+
const ownTimeout = args['timeout'];
|
|
63
|
+
const extra = typeof ownTimeout === 'number' && ownTimeout > 0 ? ownTimeout * 1000 : 0;
|
|
64
|
+
return REQUEST_DEADLINE_MS + extra;
|
|
65
|
+
}
|
|
42
66
|
function argStr(args, key) {
|
|
43
67
|
const v = args[key];
|
|
44
68
|
return typeof v === 'string' ? v : undefined;
|
|
@@ -125,6 +149,7 @@ export class AgentViewServer {
|
|
|
125
149
|
networkNextRef = 1;
|
|
126
150
|
token = '';
|
|
127
151
|
activeWatches = new Set();
|
|
152
|
+
logRecorder = null;
|
|
128
153
|
handlers = {
|
|
129
154
|
discover: (req) => this.handleDiscover(req),
|
|
130
155
|
launch: (req) => this.handleLaunch(req),
|
|
@@ -140,6 +165,7 @@ export class AgentViewServer {
|
|
|
140
165
|
eval: (req) => this.handleEval(req),
|
|
141
166
|
console: (req) => this.handleConsole(req),
|
|
142
167
|
network: (req) => this.handleNetwork(req),
|
|
168
|
+
logs: (req) => this.handleLogs(req),
|
|
143
169
|
stop: () => this.handleStop(),
|
|
144
170
|
};
|
|
145
171
|
streamingCommands = new Set(['watch']);
|
|
@@ -148,8 +174,10 @@ export class AgentViewServer {
|
|
|
148
174
|
installCDPErrorGuard();
|
|
149
175
|
mkdirSync(TOKEN_DIR, { recursive: true });
|
|
150
176
|
this.token = randomBytes(32).toString('hex');
|
|
151
|
-
|
|
152
|
-
|
|
177
|
+
// Publish the token only after winning the port. A server that loses the bind race
|
|
178
|
+
// must not overwrite the live server's token — that leaves every CLI call
|
|
179
|
+
// "Unauthorized" with no way back, since nobody knows the running server's token.
|
|
180
|
+
await new Promise((resolve, reject) => {
|
|
153
181
|
this.server = createServer((socket) => this.handleSocket(socket));
|
|
154
182
|
this.server.on('error', reject);
|
|
155
183
|
this.server.listen(SERVER_PORT, '127.0.0.1', () => {
|
|
@@ -157,12 +185,14 @@ export class AgentViewServer {
|
|
|
157
185
|
resolve();
|
|
158
186
|
});
|
|
159
187
|
});
|
|
188
|
+
await writeFile(TOKEN_PATH, this.token, { mode: 0o600 });
|
|
160
189
|
}
|
|
161
190
|
resetIdleTimer() {
|
|
162
191
|
if (this.idleTimer)
|
|
163
192
|
clearTimeout(this.idleTimer);
|
|
164
|
-
if (this.activeWatches.size > 0) {
|
|
165
|
-
// Pause idle shutdown while streaming handlers are alive
|
|
193
|
+
if (this.activeWatches.size > 0 || this.logRecorder !== null) {
|
|
194
|
+
// Pause idle shutdown while streaming handlers or a log recording are alive —
|
|
195
|
+
// a recording that dies at the 5-min mark loses exactly the long scenario it was for.
|
|
166
196
|
this.idleTimer = null;
|
|
167
197
|
return;
|
|
168
198
|
}
|
|
@@ -188,7 +218,10 @@ export class AgentViewServer {
|
|
|
188
218
|
async processRequest(data, socket) {
|
|
189
219
|
try {
|
|
190
220
|
const request = JSON.parse(data);
|
|
191
|
-
|
|
221
|
+
// `stop` needs no token: it only shuts this server down (any local process can do
|
|
222
|
+
// that via the OS anyway), and it is the recovery path when the token file no
|
|
223
|
+
// longer matches a running server.
|
|
224
|
+
if (request.command !== 'stop' && request.token !== this.token) {
|
|
192
225
|
socket.end(JSON.stringify({ ok: false, error: 'Unauthorized' }) + DELIMITER);
|
|
193
226
|
return;
|
|
194
227
|
}
|
|
@@ -210,7 +243,7 @@ export class AgentViewServer {
|
|
|
210
243
|
await this.handleWatchStreaming(request, socket);
|
|
211
244
|
return;
|
|
212
245
|
}
|
|
213
|
-
const response = await this.
|
|
246
|
+
const response = await this.runWithDeadline(request);
|
|
214
247
|
socket.end(JSON.stringify(response) + DELIMITER);
|
|
215
248
|
}
|
|
216
249
|
catch (err) {
|
|
@@ -227,6 +260,61 @@ export class AgentViewServer {
|
|
|
227
260
|
return { ok: false, error: `Unknown command: ${req.command}` };
|
|
228
261
|
return handler(req);
|
|
229
262
|
}
|
|
263
|
+
/**
|
|
264
|
+
* Answer within the deadline no matter what CDP does. On expiry the cached sessions
|
|
265
|
+
* for that port are dropped, so the next command reconnects instead of queueing
|
|
266
|
+
* behind the same dead socket.
|
|
267
|
+
*/
|
|
268
|
+
async runWithDeadline(req) {
|
|
269
|
+
const budget = requestDeadlineMs(req.command, req.args);
|
|
270
|
+
if (budget === null)
|
|
271
|
+
return this.handleCommand(req);
|
|
272
|
+
const pending = this.handleCommand(req);
|
|
273
|
+
let timer;
|
|
274
|
+
const expired = Symbol('expired');
|
|
275
|
+
try {
|
|
276
|
+
const outcome = await Promise.race([
|
|
277
|
+
pending,
|
|
278
|
+
new Promise((resolveRace) => {
|
|
279
|
+
timer = setTimeout(() => resolveRace(expired), budget);
|
|
280
|
+
}),
|
|
281
|
+
]);
|
|
282
|
+
if (outcome !== expired)
|
|
283
|
+
return outcome;
|
|
284
|
+
}
|
|
285
|
+
finally {
|
|
286
|
+
if (timer)
|
|
287
|
+
clearTimeout(timer);
|
|
288
|
+
}
|
|
289
|
+
// The abandoned handler may still reject later — never let it reach the process guard.
|
|
290
|
+
pending.catch(() => { });
|
|
291
|
+
this.dropSessionsForPort(req.port);
|
|
292
|
+
return {
|
|
293
|
+
ok: false,
|
|
294
|
+
error: `Timed out after ${Math.round(budget / 1000)}s waiting for CDP (command: ${req.command}, port: ${req.port}). `
|
|
295
|
+
+ `Dropped cached CDP sessions for that port — retry the command. `
|
|
296
|
+
+ `If it keeps timing out, the app's DevTools endpoint is wedged: restart the app, or run \`agent-view stop\`.`,
|
|
297
|
+
code: ServerErrorCode.CDPTimeout,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
evictSession(connKey) {
|
|
301
|
+
const cached = this.connections.get(connKey);
|
|
302
|
+
if (!cached)
|
|
303
|
+
return;
|
|
304
|
+
this.connections.delete(connKey);
|
|
305
|
+
const targetId = cached.session.target.id;
|
|
306
|
+
this.consoleStream.detach(targetId);
|
|
307
|
+
this.networkStream.detach(targetId);
|
|
308
|
+
this.axTreeCache.invalidate(connKey);
|
|
309
|
+
cached.session.close().catch(() => { });
|
|
310
|
+
}
|
|
311
|
+
dropSessionsForPort(port) {
|
|
312
|
+
const prefix = `${port}:`;
|
|
313
|
+
for (const connKey of [...this.connections.keys()]) {
|
|
314
|
+
if (connKey.startsWith(prefix))
|
|
315
|
+
this.evictSession(connKey);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
230
318
|
async resolveWindow(req) {
|
|
231
319
|
const adapter = getAdapter(req.runtime);
|
|
232
320
|
const windowArg = req.args.window || undefined;
|
|
@@ -236,9 +324,11 @@ export class AgentViewServer {
|
|
|
236
324
|
}
|
|
237
325
|
let targetId;
|
|
238
326
|
if (windowArg) {
|
|
327
|
+
const q = windowArg.toLowerCase();
|
|
239
328
|
const byId = windows.find(w => w.id === windowArg);
|
|
240
|
-
const
|
|
241
|
-
const
|
|
329
|
+
const byIdPrefix = q.length >= 4 ? windows.filter(w => w.id.toLowerCase().startsWith(q)) : [];
|
|
330
|
+
const byTitle = windows.find(w => w.title.toLowerCase().includes(q));
|
|
331
|
+
const found = byId ?? (byIdPrefix.length === 1 ? byIdPrefix[0] : undefined) ?? byTitle;
|
|
242
332
|
if (!found) {
|
|
243
333
|
throw new Error(`Window not found: "${windowArg}". Available: ${windows.map(w => `"${w.title}" (${w.id})`).join(', ')}`);
|
|
244
334
|
}
|
|
@@ -260,6 +350,7 @@ export class AgentViewServer {
|
|
|
260
350
|
const adapter = getAdapter(req.runtime);
|
|
261
351
|
const session = await adapter.connect(req.port, targetId, this.axTreeCache);
|
|
262
352
|
this.connections.set(connKey, { kind: 'page', session });
|
|
353
|
+
session.onDisconnect(() => this.evictSession(connKey));
|
|
263
354
|
return session;
|
|
264
355
|
}
|
|
265
356
|
async getRuntimeSession(req, target) {
|
|
@@ -276,6 +367,7 @@ export class AgentViewServer {
|
|
|
276
367
|
}
|
|
277
368
|
const session = await connectToRuntime(req.port, target);
|
|
278
369
|
this.connections.set(connKey, { kind: 'runtime', session });
|
|
370
|
+
session.onDisconnect(() => this.evictSession(connKey));
|
|
279
371
|
return session;
|
|
280
372
|
}
|
|
281
373
|
async handleDiscover(req) {
|
|
@@ -628,7 +720,12 @@ export class AgentViewServer {
|
|
|
628
720
|
warning = `crop filter '${cropFilter}' matched nothing — capturing full window`;
|
|
629
721
|
}
|
|
630
722
|
else {
|
|
631
|
-
|
|
723
|
+
const cropUp = argNum(req.args, 'cropUp') ?? 0;
|
|
724
|
+
clip = await conn.getBoxRect(found.backendDOMNodeId, { scrollIntoView: true, ancestorLevels: cropUp });
|
|
725
|
+
if (clip.height < TEXT_LINE_HEIGHT_PX && cropUp === 0) {
|
|
726
|
+
warning = `crop matched a text-sized box (${Math.round(clip.width)}×${Math.round(clip.height)}) — `
|
|
727
|
+
+ `pass --crop-up 1 (or 2) to capture the surrounding container instead`;
|
|
728
|
+
}
|
|
632
729
|
}
|
|
633
730
|
}
|
|
634
731
|
const filepath = await this.captureScreenshotToFile(conn, { scale, clip });
|
|
@@ -712,10 +809,10 @@ export class AgentViewServer {
|
|
|
712
809
|
const windowArg = argStr(req.args, 'window');
|
|
713
810
|
const allTargets = await listSupportedTargets(req.port);
|
|
714
811
|
if (explicitId) {
|
|
715
|
-
const
|
|
716
|
-
if (found)
|
|
717
|
-
return
|
|
718
|
-
throw new Error(
|
|
812
|
+
const match = matchTarget(allTargets, explicitId);
|
|
813
|
+
if (match.kind === 'found')
|
|
814
|
+
return match.target;
|
|
815
|
+
throw new Error(describeTargetMatchFailure(explicitId, match));
|
|
719
816
|
}
|
|
720
817
|
if (windowArg) {
|
|
721
818
|
const pages = allTargets.filter(t => t.type === TargetType.Page);
|
|
@@ -854,62 +951,82 @@ export class AgentViewServer {
|
|
|
854
951
|
watch.stop(StopReason.EvalFailed, false);
|
|
855
952
|
}
|
|
856
953
|
}
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
// Recreate with config-tuned capacity on first attach
|
|
863
|
-
this.consoleStream = new ConsoleStream({ capacity: bufferSize });
|
|
864
|
-
}
|
|
865
|
-
const targetQuery = argStr(req.args, 'target');
|
|
866
|
-
// Fuzzy-resolve the target query once (id exact → title substring → url substring)
|
|
867
|
-
const all = await listSupportedTargets(req.port);
|
|
868
|
-
let resolvedTargetId;
|
|
869
|
-
if (targetQuery) {
|
|
870
|
-
const resolved = findTargetByIdOrSubstring(all, targetQuery);
|
|
871
|
-
if (!resolved) {
|
|
872
|
-
return { ok: false, error: `Target not found: "${targetQuery}". Run \`agent-view targets\` for the full list.` };
|
|
873
|
-
}
|
|
874
|
-
resolvedTargetId = resolved.id;
|
|
875
|
-
}
|
|
876
|
-
if (argBool(req.args, 'clear')) {
|
|
877
|
-
this.consoleStream.clear(resolvedTargetId);
|
|
878
|
-
return { ok: true, data: 'Console buffer cleared' };
|
|
879
|
-
}
|
|
880
|
-
const requestedTypes = argStrArray(req.args, 'consoleTargets')
|
|
954
|
+
/**
|
|
955
|
+
* Console target types for this request: explicit arg → project config → defaults.
|
|
956
|
+
*/
|
|
957
|
+
resolveConsoleTypes(req, config) {
|
|
958
|
+
const requested = argStrArray(req.args, 'consoleTargets')
|
|
881
959
|
?? config?.consoleTargets
|
|
882
960
|
?? DEFAULT_CONSOLE_TARGETS;
|
|
883
|
-
|
|
961
|
+
return new Set(requested
|
|
884
962
|
.filter((t) => typeof t === 'string')
|
|
885
963
|
.filter((t) => Object.values(TargetType).includes(t)));
|
|
886
|
-
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* Lazy-attach: give every matching target a session feeding `consoleStream`. Idempotent,
|
|
967
|
+
* and re-run on every console/logs call — that is how a restarted worker (fresh target id)
|
|
968
|
+
* rejoins the feed. Returns the sessions now attached.
|
|
969
|
+
*/
|
|
970
|
+
async attachConsoleTargets(req, opts) {
|
|
971
|
+
const sessions = [];
|
|
887
972
|
if (process.env.AV_DEBUG_CONSOLE) {
|
|
888
973
|
// eslint-disable-next-line no-console
|
|
889
|
-
console.error(`[av-debug]
|
|
974
|
+
console.error(`[av-debug] attachConsoleTargets: targets=${opts.targets.length} explicit=${opts.targetId ?? 'none'} types=${[...opts.allowedTypes].join(',')}`);
|
|
890
975
|
}
|
|
891
|
-
for (const t of
|
|
892
|
-
if (
|
|
976
|
+
for (const t of opts.targets) {
|
|
977
|
+
if (opts.targetId && t.id !== opts.targetId)
|
|
893
978
|
continue;
|
|
894
|
-
if (!
|
|
979
|
+
if (!opts.targetId && !opts.allowedTypes.has(t.type))
|
|
895
980
|
continue;
|
|
896
981
|
if (!RUNTIME_ONLY_TARGETS.has(t.type) && t.type !== TargetType.Page && t.type !== TargetType.Iframe)
|
|
897
982
|
continue;
|
|
898
983
|
try {
|
|
899
984
|
const session = await this.getRuntimeSession(req, t);
|
|
900
985
|
this.consoleStream.attach(session);
|
|
986
|
+
sessions.push(session);
|
|
901
987
|
if (process.env.AV_DEBUG_CONSOLE) {
|
|
902
988
|
// eslint-disable-next-line no-console
|
|
903
|
-
console.error(`[av-debug]
|
|
989
|
+
console.error(`[av-debug] attachConsoleTargets: attached ${t.type}:${t.id.slice(0, 8)} (stream now has ${this.consoleStream.attachedCount})`);
|
|
904
990
|
}
|
|
905
991
|
}
|
|
906
992
|
catch (err) {
|
|
907
993
|
if (process.env.AV_DEBUG_CONSOLE) {
|
|
908
994
|
// eslint-disable-next-line no-console
|
|
909
|
-
console.error(`[av-debug]
|
|
995
|
+
console.error(`[av-debug] attachConsoleTargets: SKIP ${t.type}:${t.id.slice(0, 8)} — ${err.message}`);
|
|
910
996
|
}
|
|
911
997
|
}
|
|
912
998
|
}
|
|
999
|
+
return sessions;
|
|
1000
|
+
}
|
|
1001
|
+
async handleConsole(req) {
|
|
1002
|
+
const cwd = argStr(req.args, 'cwd');
|
|
1003
|
+
const config = cwd ? readConfig(resolve(cwd)) : null;
|
|
1004
|
+
const bufferSize = config?.consoleBufferSize ?? 500;
|
|
1005
|
+
if (this.consoleStream.attachedCount === 0 && this.logRecorder === null) {
|
|
1006
|
+
// Recreate with config-tuned capacity on first attach. Never while recording — the
|
|
1007
|
+
// recorder's subscription lives on the stream instance and would be dropped silently.
|
|
1008
|
+
this.consoleStream = new ConsoleStream({ capacity: bufferSize });
|
|
1009
|
+
}
|
|
1010
|
+
const targetQuery = argStr(req.args, 'target');
|
|
1011
|
+
// Fuzzy-resolve the target query once (id exact → title substring → url substring)
|
|
1012
|
+
const all = await listSupportedTargets(req.port);
|
|
1013
|
+
let resolvedTargetId;
|
|
1014
|
+
if (targetQuery) {
|
|
1015
|
+
const match = matchTarget(all, targetQuery);
|
|
1016
|
+
if (match.kind !== 'found') {
|
|
1017
|
+
return { ok: false, error: describeTargetMatchFailure(targetQuery, match) };
|
|
1018
|
+
}
|
|
1019
|
+
resolvedTargetId = match.target.id;
|
|
1020
|
+
}
|
|
1021
|
+
if (argBool(req.args, 'clear')) {
|
|
1022
|
+
this.consoleStream.clear(resolvedTargetId);
|
|
1023
|
+
return { ok: true, data: 'Console buffer cleared' };
|
|
1024
|
+
}
|
|
1025
|
+
await this.attachConsoleTargets(req, {
|
|
1026
|
+
targets: all,
|
|
1027
|
+
allowedTypes: this.resolveConsoleTypes(req, config),
|
|
1028
|
+
targetId: resolvedTargetId,
|
|
1029
|
+
});
|
|
913
1030
|
const levelFilter = parseLevelFilter(argStrArray(req.args, 'levels'));
|
|
914
1031
|
const since = argNum(req.args, 'since');
|
|
915
1032
|
const follow = argBool(req.args, 'follow') ?? false;
|
|
@@ -972,11 +1089,11 @@ export class AgentViewServer {
|
|
|
972
1089
|
const targetQuery = argStr(req.args, 'target') ?? argStr(req.args, 'window');
|
|
973
1090
|
if (targetQuery) {
|
|
974
1091
|
const all = await listSupportedTargets(req.port);
|
|
975
|
-
const
|
|
976
|
-
if (
|
|
977
|
-
return { ok: false, error:
|
|
1092
|
+
const match = matchTarget(all, targetQuery);
|
|
1093
|
+
if (match.kind !== 'found') {
|
|
1094
|
+
return { ok: false, error: describeTargetMatchFailure(targetQuery, match) };
|
|
978
1095
|
}
|
|
979
|
-
resolvedTargetId =
|
|
1096
|
+
resolvedTargetId = match.target.id;
|
|
980
1097
|
}
|
|
981
1098
|
const reqN = argNum(req.args, 'req');
|
|
982
1099
|
if (reqN !== undefined) {
|
|
@@ -1055,6 +1172,134 @@ export class AgentViewServer {
|
|
|
1055
1172
|
const entries = this.networkStream.drain(filter);
|
|
1056
1173
|
return { ok: true, data: this.renderNetworkList(entries, maxLines) };
|
|
1057
1174
|
}
|
|
1175
|
+
/**
|
|
1176
|
+
* Durable side of the console feed. `console` answers from a ring buffer that dies with the
|
|
1177
|
+
* server; `logs` appends every message from every attached target to one file, so a diagnosis
|
|
1178
|
+
* can grep a timeline that outlives the scenario (and the 5-min idle shutdown).
|
|
1179
|
+
*/
|
|
1180
|
+
async handleLogs(req) {
|
|
1181
|
+
const action = argStr(req.args, 'action') ?? 'tail';
|
|
1182
|
+
const cwd = argStr(req.args, 'cwd');
|
|
1183
|
+
const projectDir = cwd ? resolve(cwd) : process.cwd();
|
|
1184
|
+
const config = cwd ? readConfig(projectDir) : null;
|
|
1185
|
+
const explicitFile = argStr(req.args, 'file');
|
|
1186
|
+
// An active recording owns the feed path — only an explicit --file overrides it.
|
|
1187
|
+
const file = explicitFile
|
|
1188
|
+
? resolveLogFile(projectDir, explicitFile)
|
|
1189
|
+
: this.logRecorder?.file ?? resolveLogFile(projectDir, config?.logFile);
|
|
1190
|
+
switch (action) {
|
|
1191
|
+
case 'start': return this.startLogRecording(req, config, file);
|
|
1192
|
+
case 'stop': return this.stopLogRecording();
|
|
1193
|
+
case 'status': return { ok: true, data: this.logRecorder ? formatRecorderStatus(this.logRecorder.status()) : formatIdleFeed(file) };
|
|
1194
|
+
case 'clear': return this.clearLogFeed(file);
|
|
1195
|
+
case 'tail': return this.tailLogFeed(req, file);
|
|
1196
|
+
default: return { ok: false, error: `Unknown logs action: ${action}` };
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
async startLogRecording(req, config, file) {
|
|
1200
|
+
if (this.logRecorder) {
|
|
1201
|
+
if (this.logRecorder.file === file) {
|
|
1202
|
+
return { ok: true, data: `Already recording\n${formatRecorderStatus(this.logRecorder.status())}` };
|
|
1203
|
+
}
|
|
1204
|
+
return { ok: false, error: `Already recording into ${this.logRecorder.file}. Run \`agent-view logs stop\` first.` };
|
|
1205
|
+
}
|
|
1206
|
+
const probes = parseProbes(req.args);
|
|
1207
|
+
if (probes.length > 0 && !config?.allowEval) {
|
|
1208
|
+
return { ok: false, error: 'logs --probe evaluates arbitrary JS. Set "allowEval": true in agent-view.config.json to enable it.' };
|
|
1209
|
+
}
|
|
1210
|
+
const targetQuery = argStr(req.args, 'target');
|
|
1211
|
+
let resolvedTargetId;
|
|
1212
|
+
const all = await listSupportedTargets(req.port);
|
|
1213
|
+
if (targetQuery) {
|
|
1214
|
+
const match = matchTarget(all, targetQuery);
|
|
1215
|
+
if (match.kind !== 'found') {
|
|
1216
|
+
return { ok: false, error: describeTargetMatchFailure(targetQuery, match) };
|
|
1217
|
+
}
|
|
1218
|
+
resolvedTargetId = match.target.id;
|
|
1219
|
+
}
|
|
1220
|
+
if (this.consoleStream.attachedCount === 0) {
|
|
1221
|
+
this.consoleStream = new ConsoleStream({ capacity: config?.consoleBufferSize ?? 500 });
|
|
1222
|
+
}
|
|
1223
|
+
const allowedTypes = this.resolveConsoleTypes(req, config);
|
|
1224
|
+
const recorder = new LogRecorder({
|
|
1225
|
+
file,
|
|
1226
|
+
levels: parseLevelFilter(argStrArray(req.args, 'levels')),
|
|
1227
|
+
targetId: resolvedTargetId,
|
|
1228
|
+
rescanMs: argNum(req.args, 'rescan'),
|
|
1229
|
+
maxBytes: config?.logMaxBytes,
|
|
1230
|
+
truncate: argBool(req.args, 'truncate') ?? false,
|
|
1231
|
+
probes,
|
|
1232
|
+
rescan: async () => this.attachConsoleTargets(req, {
|
|
1233
|
+
targets: await listSupportedTargets(req.port),
|
|
1234
|
+
allowedTypes,
|
|
1235
|
+
targetId: resolvedTargetId,
|
|
1236
|
+
}),
|
|
1237
|
+
subscribe: (handler) => this.consoleStream.subscribe(handler),
|
|
1238
|
+
});
|
|
1239
|
+
try {
|
|
1240
|
+
await recorder.start();
|
|
1241
|
+
}
|
|
1242
|
+
catch (err) {
|
|
1243
|
+
recorder.stop('start failed');
|
|
1244
|
+
return { ok: false, error: `Could not start recording into ${file}: ${err instanceof Error ? err.message : String(err)}` };
|
|
1245
|
+
}
|
|
1246
|
+
this.logRecorder = recorder;
|
|
1247
|
+
this.resetIdleTimer();
|
|
1248
|
+
return { ok: true, data: formatRecorderStatus(recorder.status()) };
|
|
1249
|
+
}
|
|
1250
|
+
async stopLogRecording() {
|
|
1251
|
+
if (!this.logRecorder)
|
|
1252
|
+
return { ok: true, data: 'Not recording' };
|
|
1253
|
+
const { file, lines } = this.logRecorder.status();
|
|
1254
|
+
this.logRecorder.stop('stop requested');
|
|
1255
|
+
this.logRecorder = null;
|
|
1256
|
+
this.resetIdleTimer();
|
|
1257
|
+
return { ok: true, data: `Recording stopped — ${lines} lines in ${file}` };
|
|
1258
|
+
}
|
|
1259
|
+
async clearLogFeed(file) {
|
|
1260
|
+
this.consoleStream.clear();
|
|
1261
|
+
if (this.logRecorder?.file === file) {
|
|
1262
|
+
this.logRecorder.clearFeed();
|
|
1263
|
+
return { ok: true, data: `Feed cleared, recording continues — ${file}` };
|
|
1264
|
+
}
|
|
1265
|
+
if (!existsSync(file)) {
|
|
1266
|
+
return { ok: true, data: `Console buffer cleared. No feed file at ${file}.` };
|
|
1267
|
+
}
|
|
1268
|
+
writeFileSync(file, '');
|
|
1269
|
+
return { ok: true, data: `Feed cleared — ${file}` };
|
|
1270
|
+
}
|
|
1271
|
+
async tailLogFeed(req, file) {
|
|
1272
|
+
if (!existsSync(file)) {
|
|
1273
|
+
return { ok: false, error: `No log feed at ${file}. Run \`agent-view logs start\` first.` };
|
|
1274
|
+
}
|
|
1275
|
+
const sinceToken = argStr(req.args, 'since');
|
|
1276
|
+
let since;
|
|
1277
|
+
if (sinceToken !== undefined) {
|
|
1278
|
+
const parsed = parseSinceToken(sinceToken);
|
|
1279
|
+
if (parsed === null) {
|
|
1280
|
+
return { ok: false, error: `Invalid --since "${sinceToken}". Use -5m, 09:31, 09:31:02.500, or an ISO timestamp.` };
|
|
1281
|
+
}
|
|
1282
|
+
since = parsed;
|
|
1283
|
+
}
|
|
1284
|
+
const { lines, scanTruncated } = readFeedLines(file);
|
|
1285
|
+
const selected = filterLogLines(lines, {
|
|
1286
|
+
grep: argStr(req.args, 'grep'),
|
|
1287
|
+
since,
|
|
1288
|
+
level: parseLevelFilter(argStrArray(req.args, 'levels')),
|
|
1289
|
+
limit: argNum(req.args, 'lines') ?? DEFAULT_TAIL_LINES,
|
|
1290
|
+
});
|
|
1291
|
+
if (selected.length === 0) {
|
|
1292
|
+
return { ok: true, data: lines.length === 0 ? '(feed is empty)' : '(no records match)' };
|
|
1293
|
+
}
|
|
1294
|
+
const { text, dropped } = capTailOutput(selected);
|
|
1295
|
+
const warnings = [
|
|
1296
|
+
dropped > 0 ? `Output cap hit — ${dropped} older matching records omitted.` : null,
|
|
1297
|
+
scanTruncated ? `Feed exceeds the scan window — older records are only in ${file}.` : null,
|
|
1298
|
+
// Without this, a static feed reads as "the app went quiet" instead of "nobody is recording".
|
|
1299
|
+
this.logRecorder?.file === file ? null : 'Not recording — this feed is static. Run `agent-view logs start`.',
|
|
1300
|
+
].filter((w) => w !== null);
|
|
1301
|
+
return { ok: true, data: text, warning: warnings.length > 0 ? warnings.join(' ') : undefined };
|
|
1302
|
+
}
|
|
1058
1303
|
async handleStop() {
|
|
1059
1304
|
setTimeout(() => this.shutdown(), 100);
|
|
1060
1305
|
return { ok: true, data: 'Server stopping' };
|
|
@@ -1065,6 +1310,8 @@ export class AgentViewServer {
|
|
|
1065
1310
|
for (const watch of [...this.activeWatches]) {
|
|
1066
1311
|
watch.stop(StopReason.ServerShutdown, false);
|
|
1067
1312
|
}
|
|
1313
|
+
this.logRecorder?.stop('server shutdown');
|
|
1314
|
+
this.logRecorder = null;
|
|
1068
1315
|
await unlink(TOKEN_PATH).catch(() => { });
|
|
1069
1316
|
this.consoleStream.detach();
|
|
1070
1317
|
this.networkStream.detach();
|
|
@@ -1079,13 +1326,34 @@ export class AgentViewServer {
|
|
|
1079
1326
|
process.exit(0);
|
|
1080
1327
|
}
|
|
1081
1328
|
}
|
|
1082
|
-
|
|
1083
|
-
|
|
1329
|
+
const MIN_ID_PREFIX_LENGTH = 4;
|
|
1330
|
+
/**
|
|
1331
|
+
* Resolve a `--target` query: exact id → id prefix → title/url substring.
|
|
1332
|
+
* The id-prefix step is load-bearing: `targets` prints ids truncated to 8 chars,
|
|
1333
|
+
* and that printed handle must be accepted back (workers have no title/url to
|
|
1334
|
+
* match on).
|
|
1335
|
+
*/
|
|
1336
|
+
export function matchTarget(targets, query) {
|
|
1084
1337
|
const byId = targets.find(t => t.id === query);
|
|
1085
1338
|
if (byId)
|
|
1086
|
-
return byId;
|
|
1339
|
+
return { kind: 'found', target: byId };
|
|
1087
1340
|
const q = query.toLowerCase();
|
|
1088
|
-
|
|
1341
|
+
if (q.length >= MIN_ID_PREFIX_LENGTH) {
|
|
1342
|
+
const byPrefix = targets.filter(t => t.id.toLowerCase().startsWith(q));
|
|
1343
|
+
if (byPrefix.length === 1)
|
|
1344
|
+
return { kind: 'found', target: byPrefix[0] };
|
|
1345
|
+
if (byPrefix.length > 1)
|
|
1346
|
+
return { kind: 'ambiguous', matches: byPrefix };
|
|
1347
|
+
}
|
|
1348
|
+
const bySubstring = targets.find(t => t.title.toLowerCase().includes(q) || t.url.toLowerCase().includes(q));
|
|
1349
|
+
return bySubstring ? { kind: 'found', target: bySubstring } : { kind: 'none' };
|
|
1350
|
+
}
|
|
1351
|
+
export function describeTargetMatchFailure(query, match) {
|
|
1352
|
+
if (match.kind === 'ambiguous') {
|
|
1353
|
+
const list = match.matches.map(t => `${t.id} (${t.type})`).join(', ');
|
|
1354
|
+
return `Target id prefix "${query}" is ambiguous — matches: ${list}. Pass more characters.`;
|
|
1355
|
+
}
|
|
1356
|
+
return `Target not found: "${query}". Run \`agent-view targets\` for the full list.`;
|
|
1089
1357
|
}
|
|
1090
1358
|
function parseMouseButton(value) {
|
|
1091
1359
|
if (!value)
|
|
@@ -1149,13 +1417,84 @@ function parseStatusFilter(tokens) {
|
|
|
1149
1417
|
includeFailed: includeFailed ? true : undefined,
|
|
1150
1418
|
};
|
|
1151
1419
|
}
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1420
|
+
/** Response cap for `logs tail` — a feed can be megabytes; an agent's context cannot. */
|
|
1421
|
+
const TAIL_OUTPUT_CAP = 200_000;
|
|
1422
|
+
function capTailOutput(lines) {
|
|
1423
|
+
let total = 0;
|
|
1424
|
+
let start = lines.length;
|
|
1425
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
1426
|
+
total += lines[i].length + 1;
|
|
1427
|
+
if (total > TAIL_OUTPUT_CAP)
|
|
1428
|
+
break;
|
|
1429
|
+
start = i;
|
|
1430
|
+
}
|
|
1431
|
+
const kept = start === lines.length ? lines.slice(-1) : lines.slice(start);
|
|
1432
|
+
return { text: kept.join('\n'), dropped: lines.length - kept.length };
|
|
1433
|
+
}
|
|
1434
|
+
function parseProbes(args) {
|
|
1435
|
+
const raw = args['probes'];
|
|
1436
|
+
if (!Array.isArray(raw))
|
|
1437
|
+
return [];
|
|
1438
|
+
const probes = [];
|
|
1439
|
+
for (const entry of raw) {
|
|
1440
|
+
if (!entry || typeof entry !== 'object')
|
|
1441
|
+
continue;
|
|
1442
|
+
const e = entry;
|
|
1443
|
+
if (typeof e.name !== 'string' || typeof e.source !== 'string')
|
|
1444
|
+
continue;
|
|
1445
|
+
probes.push({
|
|
1446
|
+
name: e.name,
|
|
1447
|
+
source: e.source,
|
|
1448
|
+
targetQuery: typeof e.targetQuery === 'string' ? e.targetQuery : undefined,
|
|
1449
|
+
});
|
|
1157
1450
|
}
|
|
1158
|
-
return
|
|
1451
|
+
return probes;
|
|
1452
|
+
}
|
|
1453
|
+
function formatRecorderStatus(s) {
|
|
1454
|
+
const out = [
|
|
1455
|
+
`recording ${s.file}`,
|
|
1456
|
+
`started ${localStamp(s.startedAt)} (${formatElapsed(Date.now() - s.startedAt)} ago)`,
|
|
1457
|
+
`feed ${s.lines} lines, ${formatBytes(s.bytes)}${s.rotations > 0 ? `, rotated ${s.rotations}x` : ''}`,
|
|
1458
|
+
`targets ${s.attached.length > 0 ? s.attached.join(', ') : '(none attached yet)'}`,
|
|
1459
|
+
`filters levels=${s.levels?.join(',') ?? 'all'} target=${s.targetId ? s.targetId.slice(0, 8) : 'all'}`,
|
|
1460
|
+
`rescan ${s.rescanMs}ms, ${s.ticks} ticks, last ${s.lastTickAt === null ? 'never' : localStamp(s.lastTickAt)}`,
|
|
1461
|
+
];
|
|
1462
|
+
if (s.probes.length > 0) {
|
|
1463
|
+
out.push(`probes ${s.probes.map(p => `${p.name}${p.targetQuery ? `@${p.targetQuery}` : ''} x${p.injections}`).join(', ')}`);
|
|
1464
|
+
}
|
|
1465
|
+
if (s.lastError)
|
|
1466
|
+
out.push(`last error ${s.lastError}`);
|
|
1467
|
+
return out.join('\n');
|
|
1468
|
+
}
|
|
1469
|
+
function formatIdleFeed(file) {
|
|
1470
|
+
if (!existsSync(file)) {
|
|
1471
|
+
return `not recording\nfeed ${file} (does not exist)\nstart with \`agent-view logs start\``;
|
|
1472
|
+
}
|
|
1473
|
+
const stats = statSync(file);
|
|
1474
|
+
return [
|
|
1475
|
+
'not recording',
|
|
1476
|
+
`feed ${file}`,
|
|
1477
|
+
`size ${formatBytes(stats.size)}, last write ${localStamp(stats.mtimeMs)}`,
|
|
1478
|
+
'start with `agent-view logs start` — `logs tail` still reads the existing feed',
|
|
1479
|
+
].join('\n');
|
|
1480
|
+
}
|
|
1481
|
+
function formatBytes(bytes) {
|
|
1482
|
+
if (bytes < 1024)
|
|
1483
|
+
return `${bytes} B`;
|
|
1484
|
+
if (bytes < 1024 * 1024)
|
|
1485
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
1486
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
1487
|
+
}
|
|
1488
|
+
function formatElapsed(ms) {
|
|
1489
|
+
const total = Math.max(0, Math.round(ms / 1000));
|
|
1490
|
+
const h = Math.floor(total / 3600);
|
|
1491
|
+
const m = Math.floor((total % 3600) / 60);
|
|
1492
|
+
const s = total % 60;
|
|
1493
|
+
if (h > 0)
|
|
1494
|
+
return `${h}h${String(m).padStart(2, '0')}m`;
|
|
1495
|
+
if (m > 0)
|
|
1496
|
+
return `${m}m${String(s).padStart(2, '0')}s`;
|
|
1497
|
+
return `${s}s`;
|
|
1159
1498
|
}
|
|
1160
1499
|
function formatConsoleMessages(msgs) {
|
|
1161
1500
|
if (msgs.length === 0)
|