@petukhovart/agent-view 0.13.0 → 0.14.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 (39) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/README.md +52 -1
  4. package/dist/cdp/transport.d.ts.map +1 -1
  5. package/dist/cdp/transport.js +128 -4
  6. package/dist/cdp/transport.js.map +1 -1
  7. package/dist/cdp/types.d.ts +51 -0
  8. package/dist/cdp/types.d.ts.map +1 -1
  9. package/dist/cdp/types.js.map +1 -1
  10. package/dist/cli/commands/click.d.ts +1 -0
  11. package/dist/cli/commands/click.d.ts.map +1 -1
  12. package/dist/cli/commands/click.js +2 -0
  13. package/dist/cli/commands/click.js.map +1 -1
  14. package/dist/cli/commands/coverage.d.ts +14 -0
  15. package/dist/cli/commands/coverage.d.ts.map +1 -0
  16. package/dist/cli/commands/coverage.js +34 -0
  17. package/dist/cli/commands/coverage.js.map +1 -0
  18. package/dist/cli/commands/listeners.d.ts +11 -0
  19. package/dist/cli/commands/listeners.d.ts.map +1 -0
  20. package/dist/cli/commands/listeners.js +34 -0
  21. package/dist/cli/commands/listeners.js.map +1 -0
  22. package/dist/cli/index.js +30 -0
  23. package/dist/cli/index.js.map +1 -1
  24. package/dist/inspectors/coverage/index.d.ts +17 -0
  25. package/dist/inspectors/coverage/index.d.ts.map +1 -0
  26. package/dist/inspectors/coverage/index.js +65 -0
  27. package/dist/inspectors/coverage/index.js.map +1 -0
  28. package/dist/inspectors/listeners/index.d.ts +4 -0
  29. package/dist/inspectors/listeners/index.d.ts.map +1 -0
  30. package/dist/inspectors/listeners/index.js +30 -0
  31. package/dist/inspectors/listeners/index.js.map +1 -0
  32. package/dist/server/server.d.ts +13 -5
  33. package/dist/server/server.d.ts.map +1 -1
  34. package/dist/server/server.js +174 -64
  35. package/dist/server/server.js.map +1 -1
  36. package/package.json +1 -1
  37. package/skills/verify/SKILL.md +157 -421
  38. package/skills/verify/references/commands.md +358 -0
  39. package/skills/verify/references/design-conformance.md +23 -0
@@ -18,6 +18,8 @@ import { ConsoleStream } from '../cdp/_tests/console-stream.js';
18
18
  import { NetworkStream } from '../cdp/network-stream.js';
19
19
  import { formatNetworkList, formatNetworkDetail } from '../inspectors/network/index.js';
20
20
  import { formatDialogStatus, describePolicy, describeArm } from '../inspectors/dialog/index.js';
21
+ import { formatCoverage } from '../inspectors/coverage/index.js';
22
+ import { formatListeners } from '../inspectors/listeners/index.js';
21
23
  import { buildTauriArmScript, buildTauriStatusScript, TauriShimResult } from './tauri-dialog-shim.js';
22
24
  import { NetworkResourceType } from '../cdp/types.js';
23
25
  import { AxTreeCache } from '../cdp/ax-cache.js';
@@ -164,13 +166,9 @@ export class AgentViewServer {
164
166
  sceneCache = new Map();
165
167
  domTextCache = new Map();
166
168
  axTreeCache = new AxTreeCache();
167
- consoleStream = new ConsoleStream();
168
- networkStream = new NetworkStream();
169
- networkRefs = new Map();
170
- networkNextRef = 1;
169
+ portStates = new Map();
171
170
  token = '';
172
171
  activeWatches = new Set();
173
- logRecorder = null;
174
172
  handlers = {
175
173
  discover: (req) => this.handleDiscover(req),
176
174
  launch: (req) => this.handleLaunch(req),
@@ -189,6 +187,8 @@ export class AgentViewServer {
189
187
  logs: (req) => this.handleLogs(req),
190
188
  upload: (req) => this.handleUpload(req),
191
189
  dialog: (req) => this.handleDialog(req),
190
+ coverage: (req) => this.handleCoverage(req),
191
+ listeners: (req) => this.handleListeners(req),
192
192
  stop: () => this.handleStop(),
193
193
  };
194
194
  streamingCommands = new Set(['watch']);
@@ -210,10 +210,31 @@ export class AgentViewServer {
210
210
  });
211
211
  await writeFile(TOKEN_PATH, this.token, { mode: 0o600 });
212
212
  }
213
+ /** Per-port state bucket, created on first use. */
214
+ stateFor(port) {
215
+ let state = this.portStates.get(port);
216
+ if (!state) {
217
+ state = {
218
+ consoleStream: new ConsoleStream(),
219
+ networkStream: new NetworkStream(),
220
+ networkRefs: new Map(),
221
+ networkNextRef: 1,
222
+ logRecorder: null,
223
+ };
224
+ this.portStates.set(port, state);
225
+ }
226
+ return state;
227
+ }
228
+ isAnyRecording() {
229
+ for (const state of this.portStates.values())
230
+ if (state.logRecorder)
231
+ return true;
232
+ return false;
233
+ }
213
234
  resetIdleTimer() {
214
235
  if (this.idleTimer)
215
236
  clearTimeout(this.idleTimer);
216
- if (this.activeWatches.size > 0 || this.logRecorder !== null) {
237
+ if (this.activeWatches.size > 0 || this.isAnyRecording()) {
217
238
  // Pause idle shutdown while streaming handlers or a log recording are alive —
218
239
  // a recording that dies at the 5-min mark loses exactly the long scenario it was for.
219
240
  this.idleTimer = null;
@@ -326,8 +347,9 @@ export class AgentViewServer {
326
347
  return;
327
348
  this.connections.delete(connKey);
328
349
  const targetId = cached.session.target.id;
329
- this.consoleStream.detach(targetId);
330
- this.networkStream.detach(targetId);
350
+ const state = this.portStates.get(Number(connKey.slice(0, connKey.indexOf(':'))));
351
+ state?.consoleStream.detach(targetId);
352
+ state?.networkStream.detach(targetId);
331
353
  this.axTreeCache.invalidate(connKey);
332
354
  cached.session.close().catch(() => { });
333
355
  }
@@ -441,8 +463,9 @@ export class AgentViewServer {
441
463
  * mirroring the consoleStream pattern.
442
464
  */
443
465
  async ensureNetworkAttached(req, config) {
444
- if (this.networkStream.attachedCount === 0) {
445
- this.networkStream = new NetworkStream({
466
+ const state = this.stateFor(req.port);
467
+ if (state.networkStream.attachedCount === 0) {
468
+ state.networkStream = new NetworkStream({
446
469
  capacity: config?.networkBufferSize ?? DEFAULT_NETWORK_BUFFER,
447
470
  captureBody: config?.captureBody ?? false,
448
471
  });
@@ -453,7 +476,7 @@ export class AgentViewServer {
453
476
  continue;
454
477
  try {
455
478
  const session = await this.getPageSession(req, t.id);
456
- await this.networkStream.attach(session);
479
+ await state.networkStream.attach(session);
457
480
  }
458
481
  catch { /* a single unreachable target shouldn't abort capture */ }
459
482
  }
@@ -593,8 +616,11 @@ export class AgentViewServer {
593
616
  const conn = await this.getPageSession(req, targetId);
594
617
  const cacheKey = `${req.port}:${targetId}`;
595
618
  const clicks = argBool(req.args, 'double') ? 2 : 1;
596
- const clickOpts = clicks > 1 ? { clicks } : undefined;
597
- const verb = clicks > 1 ? 'Double-clicked' : 'Clicked';
619
+ const right = argBool(req.args, 'right');
620
+ const clickOpts = clicks > 1 || right
621
+ ? { ...(clicks > 1 ? { clicks } : {}), ...(right ? { button: MouseButton.Right } : {}) }
622
+ : undefined;
623
+ const verb = right ? 'Right-clicked' : clicks > 1 ? 'Double-clicked' : 'Clicked';
598
624
  if (req.args.pos && typeof req.args.pos === 'object') {
599
625
  const pos = req.args.pos;
600
626
  const x = typeof pos.x === 'number' ? pos.x : 0;
@@ -859,6 +885,67 @@ export class AgentViewServer {
859
885
  return { patched: false, armed: false, fired: [] };
860
886
  }
861
887
  }
888
+ /**
889
+ * Forward reachability. `--clear` opens a coverage window, the next call closes
890
+ * it and reports what ran inside — so a click can be attributed to the functions
891
+ * it actually executed. Purchases only positive answers: an empty delta proves
892
+ * that *this* action did not reach the code, never that nothing can.
893
+ */
894
+ async handleCoverage(req) {
895
+ const target = await this.resolveTarget(req);
896
+ const conn = await this.getRuntimeSession(req, target);
897
+ if (argBool(req.args, 'clear')) {
898
+ await conn.startCoverage();
899
+ return { ok: true, data: 'Coverage window cleared' };
900
+ }
901
+ const scripts = await conn.takeCoverage();
902
+ if (scripts === null) {
903
+ return {
904
+ ok: false,
905
+ error: 'No coverage window open. Run `agent-view coverage --clear` before the action you want to attribute.',
906
+ };
907
+ }
908
+ const result = formatCoverage(scripts, {
909
+ filter: argStr(req.args, 'filter'),
910
+ file: argStr(req.args, 'file'),
911
+ all: argBool(req.args, 'all'),
912
+ maxLines: argNum(req.args, 'maxLines'),
913
+ });
914
+ if (argBool(req.args, 'count'))
915
+ return { ok: true, data: String(result.functions) };
916
+ return { ok: true, data: result.text };
917
+ }
918
+ /** Which handlers are wired to a node, and where each was declared. */
919
+ async handleListeners(req) {
920
+ const { targetId } = await this.resolveWindow(req);
921
+ const conn = await this.getPageSession(req, targetId);
922
+ const depth = argNum(req.args, 'depth') ?? 0;
923
+ const selector = argStr(req.args, 'selector');
924
+ if (selector) {
925
+ const listeners = await conn.getEventListenersBySelector(selector, depth);
926
+ if (listeners === null)
927
+ return { ok: false, error: `No element matches selector "${selector}"` };
928
+ return { ok: true, data: formatListeners(`"${selector}"`, listeners) };
929
+ }
930
+ const filter = argStr(req.args, 'filter');
931
+ if (filter) {
932
+ const found = await this.findByFilter(conn, filter, req, targetId);
933
+ if (!found)
934
+ return { ok: false, error: `No element found matching "${filter}"` };
935
+ const listeners = await conn.getEventListeners(found.backendDOMNodeId, depth);
936
+ return { ok: true, data: formatListeners(`"${found.name}"`, listeners) };
937
+ }
938
+ const ref = argNum(req.args, 'ref');
939
+ if (ref === undefined) {
940
+ return { ok: false, error: 'listeners requires --filter, --ref, or --selector' };
941
+ }
942
+ const entry = this.refStore.get(ref);
943
+ if (!entry) {
944
+ return { ok: false, error: `Invalid ref: ${ref}. Run \`agent-view dom\` to get fresh refs.` };
945
+ }
946
+ const listeners = await conn.getEventListeners(entry.backendDOMNodeId, depth);
947
+ return { ok: true, data: formatListeners(`[ref=${ref}]`, listeners) };
948
+ }
862
949
  async handleWait(req) {
863
950
  const filter = argStr(req.args, 'filter');
864
951
  if (!filter) {
@@ -1153,6 +1240,7 @@ export class AgentViewServer {
1153
1240
  */
1154
1241
  async attachConsoleTargets(req, opts) {
1155
1242
  const sessions = [];
1243
+ const state = this.stateFor(req.port);
1156
1244
  if (process.env.AV_DEBUG_CONSOLE) {
1157
1245
  // eslint-disable-next-line no-console
1158
1246
  console.error(`[av-debug] attachConsoleTargets: targets=${opts.targets.length} explicit=${opts.targetId ?? 'none'} types=${[...opts.allowedTypes].join(',')}`);
@@ -1166,11 +1254,11 @@ export class AgentViewServer {
1166
1254
  continue;
1167
1255
  try {
1168
1256
  const session = await this.getRuntimeSession(req, t);
1169
- this.consoleStream.attach(session);
1257
+ state.consoleStream.attach(session);
1170
1258
  sessions.push(session);
1171
1259
  if (process.env.AV_DEBUG_CONSOLE) {
1172
1260
  // eslint-disable-next-line no-console
1173
- console.error(`[av-debug] attachConsoleTargets: attached ${t.type}:${t.id.slice(0, 8)} (stream now has ${this.consoleStream.attachedCount})`);
1261
+ console.error(`[av-debug] attachConsoleTargets: attached ${t.type}:${t.id.slice(0, 8)} (stream now has ${state.consoleStream.attachedCount})`);
1174
1262
  }
1175
1263
  }
1176
1264
  catch (err) {
@@ -1186,10 +1274,11 @@ export class AgentViewServer {
1186
1274
  const cwd = argStr(req.args, 'cwd');
1187
1275
  const config = cwd ? readConfig(resolve(cwd)) : null;
1188
1276
  const bufferSize = config?.consoleBufferSize ?? 500;
1189
- if (this.consoleStream.attachedCount === 0 && this.logRecorder === null) {
1277
+ const state = this.stateFor(req.port);
1278
+ if (state.consoleStream.attachedCount === 0 && state.logRecorder === null) {
1190
1279
  // Recreate with config-tuned capacity on first attach. Never while recording — the
1191
1280
  // recorder's subscription lives on the stream instance and would be dropped silently.
1192
- this.consoleStream = new ConsoleStream({ capacity: bufferSize });
1281
+ state.consoleStream = new ConsoleStream({ capacity: bufferSize });
1193
1282
  }
1194
1283
  const targetQuery = argStr(req.args, 'target');
1195
1284
  // Fuzzy-resolve the target query once (id exact → title substring → url substring)
@@ -1203,7 +1292,7 @@ export class AgentViewServer {
1203
1292
  resolvedTargetId = match.target.id;
1204
1293
  }
1205
1294
  if (argBool(req.args, 'clear')) {
1206
- this.consoleStream.clear(resolvedTargetId);
1295
+ state.consoleStream.clear(resolvedTargetId);
1207
1296
  return { ok: true, data: 'Console buffer cleared' };
1208
1297
  }
1209
1298
  await this.attachConsoleTargets(req, {
@@ -1220,7 +1309,7 @@ export class AgentViewServer {
1220
1309
  }
1221
1310
  if (follow) {
1222
1311
  const timeoutSec = argNum(req.args, 'timeout') ?? 10;
1223
- const collected = this.consoleStream.drain({
1312
+ const collected = state.consoleStream.drain({
1224
1313
  since,
1225
1314
  level: levelFilter,
1226
1315
  targetId: resolvedTargetId,
@@ -1233,7 +1322,7 @@ export class AgentViewServer {
1233
1322
  return { ok: true, data: formatConsoleMessages(collected.slice(0, earlyMatch + 1)) };
1234
1323
  }
1235
1324
  const timedOut = await new Promise((resolveFollow) => {
1236
- const dispose = this.consoleStream.subscribe((msg) => {
1325
+ const dispose = state.consoleStream.subscribe((msg) => {
1237
1326
  if (resolvedTargetId && msg.targetId !== resolvedTargetId)
1238
1327
  return;
1239
1328
  if (levelFilter && !levelFilter.has(msg.level))
@@ -1258,7 +1347,7 @@ export class AgentViewServer {
1258
1347
  }
1259
1348
  return { ok: true, data: formatConsoleMessages(collected) };
1260
1349
  }
1261
- const messages = this.consoleStream.drain({
1350
+ const messages = state.consoleStream.drain({
1262
1351
  since,
1263
1352
  level: levelFilter,
1264
1353
  targetId: resolvedTargetId,
@@ -1269,6 +1358,7 @@ export class AgentViewServer {
1269
1358
  const cwd = argStr(req.args, 'cwd');
1270
1359
  const config = cwd ? readConfig(resolve(cwd)) : null;
1271
1360
  await this.ensureNetworkAttached(req, config);
1361
+ const state = this.stateFor(req.port);
1272
1362
  let resolvedTargetId;
1273
1363
  const targetQuery = argStr(req.args, 'target') ?? argStr(req.args, 'window');
1274
1364
  if (targetQuery) {
@@ -1281,18 +1371,18 @@ export class AgentViewServer {
1281
1371
  }
1282
1372
  const reqN = argNum(req.args, 'req');
1283
1373
  if (reqN !== undefined) {
1284
- const ref = this.networkRefs.get(reqN);
1374
+ const ref = state.networkRefs.get(reqN);
1285
1375
  if (!ref) {
1286
1376
  return { ok: false, error: `Invalid req: ${reqN}. Run \`agent-view network\` to get fresh handles.` };
1287
1377
  }
1288
- const entry = this.networkStream.getEntry(ref.targetId, ref.requestId);
1378
+ const entry = state.networkStream.getEntry(ref.targetId, ref.requestId);
1289
1379
  if (!entry) {
1290
1380
  return { ok: false, error: `Request ${reqN} is no longer buffered (evicted or app restarted).` };
1291
1381
  }
1292
1382
  return { ok: true, data: formatNetworkDetail(entry, { rawHeaders: argBool(req.args, 'rawHeaders') ?? false }) };
1293
1383
  }
1294
1384
  if (argBool(req.args, 'clear')) {
1295
- this.networkStream.clear(resolvedTargetId);
1385
+ state.networkStream.clear(resolvedTargetId);
1296
1386
  return { ok: true, data: 'Network buffer cleared' };
1297
1387
  }
1298
1388
  const filter = {
@@ -1312,31 +1402,33 @@ export class AgentViewServer {
1312
1402
  if (follow) {
1313
1403
  return this.followNetwork(req, filter, maxLines, untilPattern);
1314
1404
  }
1315
- const entries = this.networkStream.drain(filter);
1316
- return { ok: true, data: this.renderNetworkList(entries, maxLines) };
1405
+ const entries = state.networkStream.drain(filter);
1406
+ return { ok: true, data: this.renderNetworkList(req.port, entries, maxLines) };
1317
1407
  }
1318
- renderNetworkList(entries, maxLines) {
1319
- const { text, refs, nextRef } = formatNetworkList(entries, { startRef: this.networkNextRef, maxLines });
1320
- this.networkRefs.clear();
1408
+ renderNetworkList(port, entries, maxLines) {
1409
+ const state = this.stateFor(port);
1410
+ const { text, refs, nextRef } = formatNetworkList(entries, { startRef: state.networkNextRef, maxLines });
1411
+ state.networkRefs.clear();
1321
1412
  for (const r of refs)
1322
- this.networkRefs.set(r.ref, { targetId: r.targetId, requestId: r.requestId });
1323
- this.networkNextRef = nextRef;
1413
+ state.networkRefs.set(r.ref, { targetId: r.targetId, requestId: r.requestId });
1414
+ state.networkNextRef = nextRef;
1324
1415
  return text;
1325
1416
  }
1326
1417
  async followNetwork(req, filter, maxLines, untilPattern) {
1327
1418
  const timeoutSec = argNum(req.args, 'timeout') ?? 10;
1328
1419
  const matcher = untilPattern ? buildMatcher(untilPattern) : null;
1329
1420
  const matchText = (e) => `${e.method ?? (e.isWebSocket ? 'WS' : e.isEventSource ? 'SSE' : '')} ${e.status ?? e.state} ${e.url}`;
1421
+ const state = this.stateFor(req.port);
1330
1422
  if (matcher) {
1331
- const pre = this.networkStream.drain(filter);
1423
+ const pre = state.networkStream.drain(filter);
1332
1424
  const hit = pre.findIndex(e => matcher(matchText(e)));
1333
1425
  if (hit !== -1)
1334
- return { ok: true, data: this.renderNetworkList(pre.slice(0, hit + 1), maxLines) };
1426
+ return { ok: true, data: this.renderNetworkList(req.port, pre.slice(0, hit + 1), maxLines) };
1335
1427
  }
1336
1428
  const matched = await new Promise((resolveFollow) => {
1337
1429
  const dispose = matcher
1338
- ? this.networkStream.subscribe(() => {
1339
- const cur = this.networkStream.drain(filter);
1430
+ ? state.networkStream.subscribe(() => {
1431
+ const cur = state.networkStream.drain(filter);
1340
1432
  if (cur.some(e => matcher(matchText(e)))) {
1341
1433
  clearTimeout(timer);
1342
1434
  dispose();
@@ -1353,8 +1445,8 @@ export class AgentViewServer {
1353
1445
  if (matcher && !matched) {
1354
1446
  return { ok: false, error: `Timeout: pattern not seen in ${timeoutSec}s` };
1355
1447
  }
1356
- const entries = this.networkStream.drain(filter);
1357
- return { ok: true, data: this.renderNetworkList(entries, maxLines) };
1448
+ const entries = state.networkStream.drain(filter);
1449
+ return { ok: true, data: this.renderNetworkList(req.port, entries, maxLines) };
1358
1450
  }
1359
1451
  /**
1360
1452
  * Durable side of the console feed. `console` answers from a ring buffer that dies with the
@@ -1367,25 +1459,35 @@ export class AgentViewServer {
1367
1459
  const projectDir = cwd ? resolve(cwd) : process.cwd();
1368
1460
  const config = cwd ? readConfig(projectDir) : null;
1369
1461
  const explicitFile = argStr(req.args, 'file');
1370
- // An active recording owns the feed path — only an explicit --file overrides it.
1462
+ // An active recording owns the feed path — only an explicit --file overrides it. "Active"
1463
+ // means *this port's* recording: another slot's recorder must never redirect this feed.
1464
+ const state = this.stateFor(req.port);
1371
1465
  const file = explicitFile
1372
1466
  ? resolveLogFile(projectDir, explicitFile)
1373
- : this.logRecorder?.file ?? resolveLogFile(projectDir, config?.logFile);
1467
+ : state.logRecorder?.file ?? resolveLogFile(projectDir, config?.logFile);
1374
1468
  switch (action) {
1375
1469
  case 'start': return this.startLogRecording(req, config, file);
1376
- case 'stop': return this.stopLogRecording();
1377
- case 'status': return { ok: true, data: this.logRecorder ? formatRecorderStatus(this.logRecorder.status()) : formatIdleFeed(file) };
1378
- case 'clear': return this.clearLogFeed(file);
1470
+ case 'stop': return this.stopLogRecording(req.port);
1471
+ case 'status': return { ok: true, data: state.logRecorder ? formatRecorderStatus(state.logRecorder.status()) : formatIdleFeed(file) };
1472
+ case 'clear': return this.clearLogFeed(req.port, file);
1379
1473
  case 'tail': return this.tailLogFeed(req, file);
1380
1474
  default: return { ok: false, error: `Unknown logs action: ${action}` };
1381
1475
  }
1382
1476
  }
1383
1477
  async startLogRecording(req, config, file) {
1384
- if (this.logRecorder) {
1385
- if (this.logRecorder.file === file) {
1386
- return { ok: true, data: `Already recording\n${formatRecorderStatus(this.logRecorder.status())}` };
1478
+ const state = this.stateFor(req.port);
1479
+ if (state.logRecorder) {
1480
+ if (state.logRecorder.file === file) {
1481
+ return { ok: true, data: `Already recording\n${formatRecorderStatus(state.logRecorder.status())}` };
1482
+ }
1483
+ return { ok: false, error: `Already recording into ${state.logRecorder.file}. Run \`agent-view logs stop\` first.` };
1484
+ }
1485
+ // Two checkouts pointed at one feed file interleave records and truncate each other, which is
1486
+ // exactly the cross-slot corruption port-scoping removes elsewhere — refuse it instead.
1487
+ for (const [otherPort, other] of this.portStates) {
1488
+ if (otherPort !== req.port && other.logRecorder?.file === file) {
1489
+ return { ok: false, error: `Port ${otherPort} is already recording into ${file}. Use a per-slot feed path (--file) or stop that recording first.` };
1387
1490
  }
1388
- return { ok: false, error: `Already recording into ${this.logRecorder.file}. Run \`agent-view logs stop\` first.` };
1389
1491
  }
1390
1492
  const probes = parseProbes(req.args);
1391
1493
  if (probes.length > 0 && !config?.allowEval) {
@@ -1401,8 +1503,8 @@ export class AgentViewServer {
1401
1503
  }
1402
1504
  resolvedTargetId = match.target.id;
1403
1505
  }
1404
- if (this.consoleStream.attachedCount === 0) {
1405
- this.consoleStream = new ConsoleStream({ capacity: config?.consoleBufferSize ?? 500 });
1506
+ if (state.consoleStream.attachedCount === 0) {
1507
+ state.consoleStream = new ConsoleStream({ capacity: config?.consoleBufferSize ?? 500 });
1406
1508
  }
1407
1509
  const allowedTypes = this.resolveConsoleTypes(req, config);
1408
1510
  const recorder = new LogRecorder({
@@ -1418,7 +1520,7 @@ export class AgentViewServer {
1418
1520
  allowedTypes,
1419
1521
  targetId: resolvedTargetId,
1420
1522
  }),
1421
- subscribe: (handler) => this.consoleStream.subscribe(handler),
1523
+ subscribe: (handler) => state.consoleStream.subscribe(handler),
1422
1524
  });
1423
1525
  try {
1424
1526
  await recorder.start();
@@ -1427,23 +1529,25 @@ export class AgentViewServer {
1427
1529
  recorder.stop('start failed');
1428
1530
  return { ok: false, error: `Could not start recording into ${file}: ${err instanceof Error ? err.message : String(err)}` };
1429
1531
  }
1430
- this.logRecorder = recorder;
1532
+ state.logRecorder = recorder;
1431
1533
  this.resetIdleTimer();
1432
1534
  return { ok: true, data: formatRecorderStatus(recorder.status()) };
1433
1535
  }
1434
- async stopLogRecording() {
1435
- if (!this.logRecorder)
1536
+ async stopLogRecording(port) {
1537
+ const state = this.stateFor(port);
1538
+ if (!state.logRecorder)
1436
1539
  return { ok: true, data: 'Not recording' };
1437
- const { file, lines } = this.logRecorder.status();
1438
- this.logRecorder.stop('stop requested');
1439
- this.logRecorder = null;
1540
+ const { file, lines } = state.logRecorder.status();
1541
+ state.logRecorder.stop('stop requested');
1542
+ state.logRecorder = null;
1440
1543
  this.resetIdleTimer();
1441
1544
  return { ok: true, data: `Recording stopped — ${lines} lines in ${file}` };
1442
1545
  }
1443
- async clearLogFeed(file) {
1444
- this.consoleStream.clear();
1445
- if (this.logRecorder?.file === file) {
1446
- this.logRecorder.clearFeed();
1546
+ async clearLogFeed(port, file) {
1547
+ const state = this.stateFor(port);
1548
+ state.consoleStream.clear();
1549
+ if (state.logRecorder?.file === file) {
1550
+ state.logRecorder.clearFeed();
1447
1551
  return { ok: true, data: `Feed cleared, recording continues — ${file}` };
1448
1552
  }
1449
1553
  if (!existsSync(file)) {
@@ -1465,6 +1569,7 @@ export class AgentViewServer {
1465
1569
  }
1466
1570
  since = parsed;
1467
1571
  }
1572
+ const state = this.stateFor(req.port);
1468
1573
  const { lines, scanTruncated } = readFeedLines(file);
1469
1574
  const selected = filterLogLines(lines, {
1470
1575
  grep: argStr(req.args, 'grep'),
@@ -1480,7 +1585,7 @@ export class AgentViewServer {
1480
1585
  dropped > 0 ? `Output cap hit — ${dropped} older matching records omitted.` : null,
1481
1586
  scanTruncated ? `Feed exceeds the scan window — older records are only in ${file}.` : null,
1482
1587
  // Without this, a static feed reads as "the app went quiet" instead of "nobody is recording".
1483
- this.logRecorder?.file === file ? null : 'Not recording — this feed is static. Run `agent-view logs start`.',
1588
+ state.logRecorder?.file === file ? null : 'Not recording — this feed is static. Run `agent-view logs start`.',
1484
1589
  ].filter((w) => w !== null);
1485
1590
  return { ok: true, data: text, warning: warnings.length > 0 ? warnings.join(' ') : undefined };
1486
1591
  }
@@ -1494,11 +1599,16 @@ export class AgentViewServer {
1494
1599
  for (const watch of [...this.activeWatches]) {
1495
1600
  watch.stop(StopReason.ServerShutdown, false);
1496
1601
  }
1497
- this.logRecorder?.stop('server shutdown');
1498
- this.logRecorder = null;
1602
+ for (const state of this.portStates.values()) {
1603
+ state.logRecorder?.stop('server shutdown');
1604
+ state.logRecorder = null;
1605
+ }
1499
1606
  await unlink(TOKEN_PATH).catch(() => { });
1500
- this.consoleStream.detach();
1501
- this.networkStream.detach();
1607
+ for (const state of this.portStates.values()) {
1608
+ state.consoleStream.detach();
1609
+ state.networkStream.detach();
1610
+ }
1611
+ this.portStates.clear();
1502
1612
  for (const cached of this.connections.values()) {
1503
1613
  try {
1504
1614
  await cached.session.close();