@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.
Files changed (52) hide show
  1. package/.claude-plugin/plugin.json +2 -2
  2. package/README.md +38 -3
  3. package/dist/cdp/transport.d.ts +3 -0
  4. package/dist/cdp/transport.d.ts.map +1 -1
  5. package/dist/cdp/transport.js +139 -13
  6. package/dist/cdp/transport.js.map +1 -1
  7. package/dist/cdp/types.d.ts +12 -1
  8. package/dist/cdp/types.d.ts.map +1 -1
  9. package/dist/cdp/types.js.map +1 -1
  10. package/dist/cli/client.d.ts +10 -1
  11. package/dist/cli/client.d.ts.map +1 -1
  12. package/dist/cli/client.js +83 -22
  13. package/dist/cli/client.js.map +1 -1
  14. package/dist/cli/commands/launch.js +1 -1
  15. package/dist/cli/commands/launch.js.map +1 -1
  16. package/dist/cli/commands/logs.d.ts +15 -0
  17. package/dist/cli/commands/logs.d.ts.map +1 -0
  18. package/dist/cli/commands/logs.js +51 -0
  19. package/dist/cli/commands/logs.js.map +1 -0
  20. package/dist/cli/commands/screenshot.d.ts +1 -0
  21. package/dist/cli/commands/screenshot.d.ts.map +1 -1
  22. package/dist/cli/commands/screenshot.js +2 -0
  23. package/dist/cli/commands/screenshot.js.map +1 -1
  24. package/dist/cli/commands/watch.d.ts.map +1 -1
  25. package/dist/cli/commands/watch.js +1 -45
  26. package/dist/cli/commands/watch.js.map +1 -1
  27. package/dist/cli/index.js +91 -6
  28. package/dist/cli/index.js.map +1 -1
  29. package/dist/config/manager.d.ts +9 -0
  30. package/dist/config/manager.d.ts.map +1 -1
  31. package/dist/config/manager.js +24 -1
  32. package/dist/config/manager.js.map +1 -1
  33. package/dist/config/types.d.ts +4 -0
  34. package/dist/config/types.d.ts.map +1 -1
  35. package/dist/server/log-recorder.d.ts +135 -0
  36. package/dist/server/log-recorder.d.ts.map +1 -0
  37. package/dist/server/log-recorder.js +348 -0
  38. package/dist/server/log-recorder.js.map +1 -0
  39. package/dist/server/pattern.d.ts +3 -0
  40. package/dist/server/pattern.d.ts.map +1 -0
  41. package/dist/server/pattern.js +10 -0
  42. package/dist/server/pattern.js.map +1 -0
  43. package/dist/server/server.d.ts +48 -1
  44. package/dist/server/server.d.ts.map +1 -1
  45. package/dist/server/server.js +399 -60
  46. package/dist/server/server.js.map +1 -1
  47. package/dist/types.d.ts +3 -1
  48. package/dist/types.d.ts.map +1 -1
  49. package/dist/types.js +2 -0
  50. package/dist/types.js.map +1 -1
  51. package/package.json +1 -1
  52. package/skills/verify/SKILL.md +41 -0
@@ -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
- await writeFile(TOKEN_PATH, this.token, { mode: 0o600 });
152
- return new Promise((resolve, reject) => {
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
- if (request.token !== this.token) {
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.handleCommand(request);
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 byTitle = windows.find(w => w.title.toLowerCase().includes(windowArg.toLowerCase()));
241
- const found = byId ?? byTitle;
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
- clip = await conn.getBoxRect(found.backendDOMNodeId, { scrollIntoView: true });
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 found = findTargetByIdOrSubstring(allTargets, explicitId);
716
- if (found)
717
- return found;
718
- throw new Error(`Target not found: "${explicitId}". Run \`agent-view targets\` for the full list.`);
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
- async handleConsole(req) {
858
- const cwd = argStr(req.args, 'cwd');
859
- const config = cwd ? readConfig(resolve(cwd)) : null;
860
- const bufferSize = config?.consoleBufferSize ?? 500;
861
- if (this.consoleStream.attachedCount === 0) {
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
- const allowedTypes = new Set(requestedTypes
961
+ return new Set(requested
884
962
  .filter((t) => typeof t === 'string')
885
963
  .filter((t) => Object.values(TargetType).includes(t)));
886
- // Lazy attach: ensure every matching target has a session
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] handleConsole: targets=${all.length} explicit=${resolvedTargetId ?? 'none'} types=${[...allowedTypes].join(',')}`);
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 all) {
892
- if (resolvedTargetId && t.id !== resolvedTargetId)
976
+ for (const t of opts.targets) {
977
+ if (opts.targetId && t.id !== opts.targetId)
893
978
  continue;
894
- if (!resolvedTargetId && !allowedTypes.has(t.type))
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] handleConsole: attached ${t.type}:${t.id.slice(0, 8)} (stream now has ${this.consoleStream.attachedCount})`);
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] handleConsole: SKIP ${t.type}:${t.id.slice(0, 8)} — ${err.message}`);
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 resolved = findTargetByIdOrSubstring(all, targetQuery);
976
- if (!resolved) {
977
- return { ok: false, error: `Target not found: "${targetQuery}". Run \`agent-view targets\` for the full list.` };
1092
+ const match = matchTarget(all, targetQuery);
1093
+ if (match.kind !== 'found') {
1094
+ return { ok: false, error: describeTargetMatchFailure(targetQuery, match) };
978
1095
  }
979
- resolvedTargetId = resolved.id;
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
- // ── helpers ──────────────────────────────────────────────────────────────────
1083
- export function findTargetByIdOrSubstring(targets, query) {
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
- return targets.find(t => t.title.toLowerCase().includes(q) || t.url.toLowerCase().includes(q)) ?? null;
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
- function buildMatcher(pattern) {
1153
- const regexMatch = /^\/(.+)\/([gimsuy]*)$/.exec(pattern);
1154
- if (regexMatch) {
1155
- const re = new RegExp(regexMatch[1], regexMatch[2]);
1156
- return (text) => re.test(text);
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 (text) => text.includes(pattern);
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)