@petukhovart/agent-view 0.3.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.
Files changed (77) hide show
  1. package/.claude-plugin/marketplace.json +14 -0
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/README.md +164 -113
  4. package/dist/cdp/ax-cache.d.ts +7 -0
  5. package/dist/cdp/ax-cache.d.ts.map +1 -1
  6. package/dist/cdp/ax-cache.js +7 -0
  7. package/dist/cdp/ax-cache.js.map +1 -1
  8. package/dist/cdp/transport.d.ts.map +1 -1
  9. package/dist/cdp/transport.js +98 -27
  10. package/dist/cdp/transport.js.map +1 -1
  11. package/dist/cdp/types.d.ts +45 -2
  12. package/dist/cdp/types.d.ts.map +1 -1
  13. package/dist/cdp/types.js +6 -0
  14. package/dist/cdp/types.js.map +1 -1
  15. package/dist/cli/commands/console.d.ts +1 -0
  16. package/dist/cli/commands/console.d.ts.map +1 -1
  17. package/dist/cli/commands/console.js +1 -0
  18. package/dist/cli/commands/console.js.map +1 -1
  19. package/dist/cli/commands/dom.d.ts +4 -0
  20. package/dist/cli/commands/dom.d.ts.map +1 -1
  21. package/dist/cli/commands/dom.js +4 -0
  22. package/dist/cli/commands/dom.js.map +1 -1
  23. package/dist/cli/commands/drag.d.ts +13 -0
  24. package/dist/cli/commands/drag.d.ts.map +1 -0
  25. package/dist/cli/commands/drag.js +72 -0
  26. package/dist/cli/commands/drag.js.map +1 -0
  27. package/dist/cli/commands/scene.d.ts +1 -0
  28. package/dist/cli/commands/scene.d.ts.map +1 -1
  29. package/dist/cli/commands/scene.js +2 -0
  30. package/dist/cli/commands/scene.js.map +1 -1
  31. package/dist/cli/commands/screenshot.d.ts +1 -0
  32. package/dist/cli/commands/screenshot.d.ts.map +1 -1
  33. package/dist/cli/commands/screenshot.js +4 -0
  34. package/dist/cli/commands/screenshot.js.map +1 -1
  35. package/dist/cli/commands/snap.d.ts +1 -0
  36. package/dist/cli/commands/snap.d.ts.map +1 -1
  37. package/dist/cli/commands/snap.js +2 -0
  38. package/dist/cli/commands/snap.js.map +1 -1
  39. package/dist/cli/commands/watch.d.ts +12 -0
  40. package/dist/cli/commands/watch.d.ts.map +1 -0
  41. package/dist/cli/commands/watch.js +233 -0
  42. package/dist/cli/commands/watch.js.map +1 -0
  43. package/dist/cli/index.js +48 -1
  44. package/dist/cli/index.js.map +1 -1
  45. package/dist/config/manager.js +1 -1
  46. package/dist/config/types.d.ts +0 -3
  47. package/dist/config/types.d.ts.map +1 -1
  48. package/dist/inspectors/dom/index.d.ts +13 -0
  49. package/dist/inspectors/dom/index.d.ts.map +1 -1
  50. package/dist/inspectors/dom/index.js +255 -103
  51. package/dist/inspectors/dom/index.js.map +1 -1
  52. package/dist/inspectors/scene/formatter.d.ts.map +1 -1
  53. package/dist/inspectors/scene/formatter.js +56 -8
  54. package/dist/inspectors/scene/formatter.js.map +1 -1
  55. package/dist/inspectors/scene/types.d.ts +1 -0
  56. package/dist/inspectors/scene/types.d.ts.map +1 -1
  57. package/dist/inspectors/watch/index.d.ts +18 -0
  58. package/dist/inspectors/watch/index.d.ts.map +1 -0
  59. package/dist/inspectors/watch/index.js +39 -0
  60. package/dist/inspectors/watch/index.js.map +1 -0
  61. package/dist/inspectors/watch/types.d.ts +70 -0
  62. package/dist/inspectors/watch/types.d.ts.map +1 -0
  63. package/dist/inspectors/watch/types.js +21 -0
  64. package/dist/inspectors/watch/types.js.map +1 -0
  65. package/dist/server/server.d.ts +9 -1
  66. package/dist/server/server.d.ts.map +1 -1
  67. package/dist/server/server.js +279 -48
  68. package/dist/server/server.js.map +1 -1
  69. package/dist/server/watch-session.d.ts +32 -0
  70. package/dist/server/watch-session.d.ts.map +1 -0
  71. package/dist/server/watch-session.js +165 -0
  72. package/dist/server/watch-session.js.map +1 -0
  73. package/dist/types.d.ts +2 -0
  74. package/dist/types.d.ts.map +1 -1
  75. package/package.json +61 -60
  76. package/skills/verify/SKILL.md +64 -13
  77. package/skills/verify-recipe/SKILL.md +185 -0
@@ -5,16 +5,18 @@ 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/index.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, } from '../cdp/types.js';
14
+ import { TargetType, ConsoleLevel, EvaluationError, MouseButton, } from '../cdp/types.js';
15
15
  import { listSupportedTargets, connectToRuntime } from '../cdp/transport.js';
16
16
  import { ConsoleStream } from '../cdp/_tests/console-stream.js';
17
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';
18
20
  const SERVER_PORT = 47922;
19
21
  const VALID_RUNTIMES = new Set(Object.values(RuntimeType));
20
22
  const VALID_ENGINES = new Set(Object.values(WebGLEngine));
@@ -62,23 +64,23 @@ export function resolveDepth(filter, explicit) {
62
64
  }
63
65
  export async function textContentFallback(conn, filter) {
64
66
  const safeFilter = JSON.stringify(filter);
65
- const js = `(() => {
66
- const q = ${safeFilter};
67
- const results = [];
68
- for (const el of document.querySelectorAll('body *')) {
69
- if (results.length >= 5) break;
70
- const directText = Array.from(el.childNodes)
71
- .filter(n => n.nodeType === Node.TEXT_NODE)
72
- .map(n => n.textContent.trim())
73
- .join(' ');
74
- if (directText.toLowerCase().includes(q.toLowerCase()) && el.offsetParent !== null) {
75
- const id = el.id ? '#' + el.id : '';
76
- const cls = el.className && typeof el.className === 'string' && el.className.trim()
77
- ? '.' + el.className.trim().split(/\\s+/)[0] : '';
78
- results.push(el.tagName.toLowerCase() + id + cls);
79
- }
80
- }
81
- return results.length ? results.join(', ') : null;
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;
82
84
  })()`;
83
85
  const result = await conn.evaluate(js);
84
86
  if (!result || typeof result !== 'string') {
@@ -108,14 +110,17 @@ export class AgentViewServer {
108
110
  refStore = new RefStore();
109
111
  idleTimer = null;
110
112
  sceneCache = new Map();
113
+ domTextCache = new Map();
111
114
  axTreeCache = new AxTreeCache();
112
115
  consoleStream = new ConsoleStream();
113
116
  token = '';
117
+ activeWatches = new Set();
114
118
  handlers = {
115
119
  discover: (req) => this.handleDiscover(req),
116
120
  launch: (req) => this.handleLaunch(req),
117
121
  dom: (req) => this.handleDom(req),
118
122
  click: (req) => this.handleClick(req),
123
+ drag: (req) => this.handleDrag(req),
119
124
  fill: (req) => this.handleFill(req),
120
125
  wait: (req) => this.handleWait(req),
121
126
  screenshot: (req) => this.handleScreenshot(req),
@@ -126,7 +131,8 @@ export class AgentViewServer {
126
131
  console: (req) => this.handleConsole(req),
127
132
  stop: () => this.handleStop(),
128
133
  };
129
- validCommands = new Set(Object.keys(this.handlers));
134
+ streamingCommands = new Set(['watch']);
135
+ validCommands = new Set([...Object.keys(this.handlers), ...this.streamingCommands]);
130
136
  async start() {
131
137
  mkdirSync(TOKEN_DIR, { recursive: true });
132
138
  this.token = randomBytes(32).toString('hex');
@@ -143,6 +149,11 @@ export class AgentViewServer {
143
149
  resetIdleTimer() {
144
150
  if (this.idleTimer)
145
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
+ }
146
157
  this.idleTimer = setTimeout(() => this.shutdown(), IDLE_TIMEOUT_MS);
147
158
  }
148
159
  handleSocket(socket) {
@@ -183,6 +194,10 @@ export class AgentViewServer {
183
194
  socket.end(JSON.stringify({ ok: false, error: 'Invalid args' }) + DELIMITER);
184
195
  return;
185
196
  }
197
+ if (this.streamingCommands.has(request.command)) {
198
+ await this.handleWatchStreaming(request, socket);
199
+ return;
200
+ }
186
201
  const response = await this.handleCommand(request);
187
202
  socket.end(JSON.stringify(response) + DELIMITER);
188
203
  }
@@ -281,17 +296,40 @@ export class AgentViewServer {
281
296
  const conn = await this.getPageSession(req, targetId);
282
297
  const filter = argStr(req.args, 'filter');
283
298
  const useText = argBool(req.args, 'text') ?? false;
284
- const nodes = await conn.getAccessibilityTree();
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
+ }
285
311
  const { text, refs, nextRef } = formatAccessibilityTree(nodes, {
286
312
  filter,
287
313
  depth: resolveDepth(filter, argNum(req.args, 'depth')),
288
314
  startRef: this.refStore.getNextRef(),
315
+ compact,
316
+ maxLines: argNum(req.args, 'maxLines'),
289
317
  });
290
318
  this.refStore.store(refs, req.port, targetId, nextRef);
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
+ }
291
328
  if (useText && filter && text.startsWith('(no matching')) {
292
329
  return { ok: true, data: await textContentFallback(conn, filter) };
293
330
  }
294
- return { ok: true, data: text };
331
+ const data = fromCache ? `[cache]\n${text}` : text;
332
+ return { ok: true, data };
295
333
  }
296
334
  async findByFilter(conn, filter, req, targetId, preferRoles) {
297
335
  const parsed = parseFilter(filter);
@@ -419,6 +457,45 @@ export class AgentViewServer {
419
457
  this.axTreeCache.invalidate(cacheKey);
420
458
  return { ok: true, data: `Clicked ref ${ref}` };
421
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
+ }
422
499
  async handleFill(req) {
423
500
  const { targetId } = await this.resolveWindow(req);
424
501
  const conn = await this.getPageSession(req, targetId);
@@ -475,17 +552,32 @@ export class AgentViewServer {
475
552
  }
476
553
  return { ok: false, error: `Timeout: "${filter}" not found after ${timeout}s` };
477
554
  }
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}`;
559
+ const filepath = join(tmpdir(), filename);
560
+ await writeFile(filepath, buffer);
561
+ return filepath;
562
+ }
478
563
  async handleScreenshot(req) {
479
564
  const { targetId } = await this.resolveWindow(req);
480
565
  const conn = await this.getPageSession(req, targetId);
481
566
  const scale = argNum(req.args, 'scale');
482
- const opts = scale !== undefined ? { scale } : undefined;
483
- const buffer = await conn.captureScreenshot(opts);
484
- const ext = scale !== undefined && scale < 1 ? 'jpg' : 'png';
485
- const filename = `agent-view-screenshot-${Date.now()}.${ext}`;
486
- const filepath = join(tmpdir(), filename);
487
- await writeFile(filepath, buffer);
488
- return { ok: true, data: filepath };
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 };
489
581
  }
490
582
  async handleScene(req) {
491
583
  const { targetId } = await this.resolveWindow(req);
@@ -495,6 +587,7 @@ export class AgentViewServer {
495
587
  const sceneFilter = argStr(req.args, 'filter');
496
588
  const sceneDepth = argNum(req.args, 'depth');
497
589
  const sceneVerbose = argBool(req.args, 'verbose');
590
+ const sceneCompact = argBool(req.args, 'compact');
498
591
  if (isDiff) {
499
592
  const curr = await getRawScene(conn, req.engine);
500
593
  if (!curr) {
@@ -507,6 +600,7 @@ export class AgentViewServer {
507
600
  filter: sceneFilter,
508
601
  depth: sceneDepth,
509
602
  verbose: sceneVerbose,
603
+ compact: sceneCompact,
510
604
  });
511
605
  return { ok: true, data: text };
512
606
  }
@@ -516,6 +610,7 @@ export class AgentViewServer {
516
610
  filter: sceneFilter,
517
611
  depth: sceneDepth,
518
612
  verbose: sceneVerbose,
613
+ compact: sceneCompact,
519
614
  });
520
615
  return { ok: true, data: text };
521
616
  }
@@ -524,6 +619,7 @@ export class AgentViewServer {
524
619
  const conn = await this.getPageSession(req, targetId);
525
620
  const snapFilter = argStr(req.args, 'filter');
526
621
  const snapDepth = argNum(req.args, 'depth');
622
+ const snapScale = argNum(req.args, 'scale');
527
623
  const nodes = await conn.getAccessibilityTree();
528
624
  const { text: domText, refs, nextRef } = formatAccessibilityTree(nodes, {
529
625
  filter: snapFilter,
@@ -541,6 +637,10 @@ export class AgentViewServer {
541
637
  sections.push(`=== Scene ===\n${sceneText}`);
542
638
  }
543
639
  }
640
+ if (snapScale !== undefined) {
641
+ const filepath = await this.captureScreenshotToFile(conn, { scale: snapScale });
642
+ sections.push(`=== Screenshot ===\n${filepath}`);
643
+ }
544
644
  return { ok: true, data: sections.join('\n\n') };
545
645
  }
546
646
  // ── New v0.3.0 commands ────────────────────────────────────────────────────
@@ -557,13 +657,9 @@ export class AgentViewServer {
557
657
  const windowArg = argStr(req.args, 'window');
558
658
  const allTargets = await listSupportedTargets(req.port);
559
659
  if (explicitId) {
560
- const byId = allTargets.find(t => t.id === explicitId);
561
- if (byId)
562
- return byId;
563
- const bySubstr = allTargets.find(t => t.title.toLowerCase().includes(explicitId.toLowerCase())
564
- || t.url.toLowerCase().includes(explicitId.toLowerCase()));
565
- if (bySubstr)
566
- return bySubstr;
660
+ const found = findTargetByIdOrSubstring(allTargets, explicitId);
661
+ if (found)
662
+ return found;
567
663
  throw new Error(`Target not found: "${explicitId}". Run \`agent-view targets\` for the full list.`);
568
664
  }
569
665
  if (windowArg) {
@@ -618,6 +714,91 @@ export class AgentViewServer {
618
714
  return { ok: false, error: err instanceof Error ? err.message : String(err) };
619
715
  }
620
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
+ }
621
802
  async handleConsole(req) {
622
803
  const cwd = argStr(req.args, 'cwd');
623
804
  const config = cwd ? readConfig(resolve(cwd)) : null;
@@ -626,9 +807,19 @@ export class AgentViewServer {
626
807
  // Recreate with config-tuned capacity on first attach
627
808
  this.consoleStream = new ConsoleStream({ capacity: bufferSize });
628
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
+ }
629
821
  if (argBool(req.args, 'clear')) {
630
- const targetId = argStr(req.args, 'target');
631
- this.consoleStream.clear(targetId);
822
+ this.consoleStream.clear(resolvedTargetId);
632
823
  return { ok: true, data: 'Console buffer cleared' };
633
824
  }
634
825
  const requestedTypes = argStrArray(req.args, 'consoleTargets')
@@ -638,16 +829,14 @@ export class AgentViewServer {
638
829
  .filter((t) => typeof t === 'string')
639
830
  .filter((t) => Object.values(TargetType).includes(t)));
640
831
  // Lazy attach: ensure every matching target has a session
641
- const all = await listSupportedTargets(req.port);
642
- const explicitTarget = argStr(req.args, 'target');
643
832
  if (process.env.AV_DEBUG_CONSOLE) {
644
833
  // eslint-disable-next-line no-console
645
- console.error(`[av-debug] handleConsole: targets=${all.length} explicit=${explicitTarget ?? 'none'} types=${[...allowedTypes].join(',')}`);
834
+ console.error(`[av-debug] handleConsole: targets=${all.length} explicit=${resolvedTargetId ?? 'none'} types=${[...allowedTypes].join(',')}`);
646
835
  }
647
836
  for (const t of all) {
648
- if (explicitTarget && t.id !== explicitTarget)
837
+ if (resolvedTargetId && t.id !== resolvedTargetId)
649
838
  continue;
650
- if (!explicitTarget && !allowedTypes.has(t.type))
839
+ if (!resolvedTargetId && !allowedTypes.has(t.type))
651
840
  continue;
652
841
  if (!RUNTIME_ONLY_TARGETS.has(t.type) && t.type !== TargetType.Page && t.type !== TargetType.Iframe)
653
842
  continue;
@@ -669,36 +858,54 @@ export class AgentViewServer {
669
858
  const levelFilter = parseLevelFilter(argStrArray(req.args, 'levels'));
670
859
  const since = argNum(req.args, 'since');
671
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
+ }
672
865
  if (follow) {
673
866
  const timeoutSec = argNum(req.args, 'timeout') ?? 10;
674
867
  const collected = this.consoleStream.drain({
675
868
  since,
676
869
  level: levelFilter,
677
- targetId: explicitTarget,
870
+ targetId: resolvedTargetId,
678
871
  });
679
872
  const seenAt = collected.length > 0 ? collected[collected.length - 1].ts : (since ?? Date.now());
680
- await new Promise((resolveFollow) => {
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) => {
681
880
  const dispose = this.consoleStream.subscribe((msg) => {
682
- if (explicitTarget && msg.targetId !== explicitTarget)
881
+ if (resolvedTargetId && msg.targetId !== resolvedTargetId)
683
882
  return;
684
883
  if (levelFilter && !levelFilter.has(msg.level))
685
884
  return;
686
885
  if (msg.ts <= seenAt)
687
886
  return;
688
887
  collected.push(msg);
888
+ if (matcher && matcher(msg.text)) {
889
+ clearTimeout(timer);
890
+ dispose();
891
+ resolveFollow(false);
892
+ }
689
893
  });
690
894
  const timer = setTimeout(() => {
691
895
  dispose();
692
- resolveFollow();
896
+ resolveFollow(true);
693
897
  }, timeoutSec * 1000);
694
898
  timer.unref?.();
695
899
  });
900
+ if (timedOut && matcher) {
901
+ return { ok: false, error: `Timeout: pattern not seen in ${timeoutSec}s` };
902
+ }
696
903
  return { ok: true, data: formatConsoleMessages(collected) };
697
904
  }
698
905
  const messages = this.consoleStream.drain({
699
906
  since,
700
907
  level: levelFilter,
701
- targetId: explicitTarget,
908
+ targetId: resolvedTargetId,
702
909
  });
703
910
  return { ok: true, data: formatConsoleMessages(messages) };
704
911
  }
@@ -709,6 +916,9 @@ export class AgentViewServer {
709
916
  async shutdown() {
710
917
  if (this.idleTimer)
711
918
  clearTimeout(this.idleTimer);
919
+ for (const watch of [...this.activeWatches]) {
920
+ watch.stop(StopReason.ServerShutdown, false);
921
+ }
712
922
  await unlink(TOKEN_PATH).catch(() => { });
713
923
  this.consoleStream.detach();
714
924
  for (const cached of this.connections.values()) {
@@ -723,6 +933,19 @@ export class AgentViewServer {
723
933
  }
724
934
  }
725
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
+ }
726
949
  function parseLevelFilter(levels) {
727
950
  if (!levels || levels.length === 0)
728
951
  return undefined;
@@ -734,6 +957,14 @@ function parseLevelFilter(levels) {
734
957
  }
735
958
  return set.size > 0 ? set : undefined;
736
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
+ }
737
968
  function formatConsoleMessages(msgs) {
738
969
  if (msgs.length === 0)
739
970
  return '(no console messages)';