@petukhovart/agent-view 0.13.0 → 0.13.1

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.
@@ -164,13 +164,9 @@ export class AgentViewServer {
164
164
  sceneCache = new Map();
165
165
  domTextCache = new Map();
166
166
  axTreeCache = new AxTreeCache();
167
- consoleStream = new ConsoleStream();
168
- networkStream = new NetworkStream();
169
- networkRefs = new Map();
170
- networkNextRef = 1;
167
+ portStates = new Map();
171
168
  token = '';
172
169
  activeWatches = new Set();
173
- logRecorder = null;
174
170
  handlers = {
175
171
  discover: (req) => this.handleDiscover(req),
176
172
  launch: (req) => this.handleLaunch(req),
@@ -210,10 +206,31 @@ export class AgentViewServer {
210
206
  });
211
207
  await writeFile(TOKEN_PATH, this.token, { mode: 0o600 });
212
208
  }
209
+ /** Per-port state bucket, created on first use. */
210
+ stateFor(port) {
211
+ let state = this.portStates.get(port);
212
+ if (!state) {
213
+ state = {
214
+ consoleStream: new ConsoleStream(),
215
+ networkStream: new NetworkStream(),
216
+ networkRefs: new Map(),
217
+ networkNextRef: 1,
218
+ logRecorder: null,
219
+ };
220
+ this.portStates.set(port, state);
221
+ }
222
+ return state;
223
+ }
224
+ isAnyRecording() {
225
+ for (const state of this.portStates.values())
226
+ if (state.logRecorder)
227
+ return true;
228
+ return false;
229
+ }
213
230
  resetIdleTimer() {
214
231
  if (this.idleTimer)
215
232
  clearTimeout(this.idleTimer);
216
- if (this.activeWatches.size > 0 || this.logRecorder !== null) {
233
+ if (this.activeWatches.size > 0 || this.isAnyRecording()) {
217
234
  // Pause idle shutdown while streaming handlers or a log recording are alive —
218
235
  // a recording that dies at the 5-min mark loses exactly the long scenario it was for.
219
236
  this.idleTimer = null;
@@ -326,8 +343,9 @@ export class AgentViewServer {
326
343
  return;
327
344
  this.connections.delete(connKey);
328
345
  const targetId = cached.session.target.id;
329
- this.consoleStream.detach(targetId);
330
- this.networkStream.detach(targetId);
346
+ const state = this.portStates.get(Number(connKey.slice(0, connKey.indexOf(':'))));
347
+ state?.consoleStream.detach(targetId);
348
+ state?.networkStream.detach(targetId);
331
349
  this.axTreeCache.invalidate(connKey);
332
350
  cached.session.close().catch(() => { });
333
351
  }
@@ -441,8 +459,9 @@ export class AgentViewServer {
441
459
  * mirroring the consoleStream pattern.
442
460
  */
443
461
  async ensureNetworkAttached(req, config) {
444
- if (this.networkStream.attachedCount === 0) {
445
- this.networkStream = new NetworkStream({
462
+ const state = this.stateFor(req.port);
463
+ if (state.networkStream.attachedCount === 0) {
464
+ state.networkStream = new NetworkStream({
446
465
  capacity: config?.networkBufferSize ?? DEFAULT_NETWORK_BUFFER,
447
466
  captureBody: config?.captureBody ?? false,
448
467
  });
@@ -453,7 +472,7 @@ export class AgentViewServer {
453
472
  continue;
454
473
  try {
455
474
  const session = await this.getPageSession(req, t.id);
456
- await this.networkStream.attach(session);
475
+ await state.networkStream.attach(session);
457
476
  }
458
477
  catch { /* a single unreachable target shouldn't abort capture */ }
459
478
  }
@@ -1153,6 +1172,7 @@ export class AgentViewServer {
1153
1172
  */
1154
1173
  async attachConsoleTargets(req, opts) {
1155
1174
  const sessions = [];
1175
+ const state = this.stateFor(req.port);
1156
1176
  if (process.env.AV_DEBUG_CONSOLE) {
1157
1177
  // eslint-disable-next-line no-console
1158
1178
  console.error(`[av-debug] attachConsoleTargets: targets=${opts.targets.length} explicit=${opts.targetId ?? 'none'} types=${[...opts.allowedTypes].join(',')}`);
@@ -1166,11 +1186,11 @@ export class AgentViewServer {
1166
1186
  continue;
1167
1187
  try {
1168
1188
  const session = await this.getRuntimeSession(req, t);
1169
- this.consoleStream.attach(session);
1189
+ state.consoleStream.attach(session);
1170
1190
  sessions.push(session);
1171
1191
  if (process.env.AV_DEBUG_CONSOLE) {
1172
1192
  // 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})`);
1193
+ console.error(`[av-debug] attachConsoleTargets: attached ${t.type}:${t.id.slice(0, 8)} (stream now has ${state.consoleStream.attachedCount})`);
1174
1194
  }
1175
1195
  }
1176
1196
  catch (err) {
@@ -1186,10 +1206,11 @@ export class AgentViewServer {
1186
1206
  const cwd = argStr(req.args, 'cwd');
1187
1207
  const config = cwd ? readConfig(resolve(cwd)) : null;
1188
1208
  const bufferSize = config?.consoleBufferSize ?? 500;
1189
- if (this.consoleStream.attachedCount === 0 && this.logRecorder === null) {
1209
+ const state = this.stateFor(req.port);
1210
+ if (state.consoleStream.attachedCount === 0 && state.logRecorder === null) {
1190
1211
  // Recreate with config-tuned capacity on first attach. Never while recording — the
1191
1212
  // recorder's subscription lives on the stream instance and would be dropped silently.
1192
- this.consoleStream = new ConsoleStream({ capacity: bufferSize });
1213
+ state.consoleStream = new ConsoleStream({ capacity: bufferSize });
1193
1214
  }
1194
1215
  const targetQuery = argStr(req.args, 'target');
1195
1216
  // Fuzzy-resolve the target query once (id exact → title substring → url substring)
@@ -1203,7 +1224,7 @@ export class AgentViewServer {
1203
1224
  resolvedTargetId = match.target.id;
1204
1225
  }
1205
1226
  if (argBool(req.args, 'clear')) {
1206
- this.consoleStream.clear(resolvedTargetId);
1227
+ state.consoleStream.clear(resolvedTargetId);
1207
1228
  return { ok: true, data: 'Console buffer cleared' };
1208
1229
  }
1209
1230
  await this.attachConsoleTargets(req, {
@@ -1220,7 +1241,7 @@ export class AgentViewServer {
1220
1241
  }
1221
1242
  if (follow) {
1222
1243
  const timeoutSec = argNum(req.args, 'timeout') ?? 10;
1223
- const collected = this.consoleStream.drain({
1244
+ const collected = state.consoleStream.drain({
1224
1245
  since,
1225
1246
  level: levelFilter,
1226
1247
  targetId: resolvedTargetId,
@@ -1233,7 +1254,7 @@ export class AgentViewServer {
1233
1254
  return { ok: true, data: formatConsoleMessages(collected.slice(0, earlyMatch + 1)) };
1234
1255
  }
1235
1256
  const timedOut = await new Promise((resolveFollow) => {
1236
- const dispose = this.consoleStream.subscribe((msg) => {
1257
+ const dispose = state.consoleStream.subscribe((msg) => {
1237
1258
  if (resolvedTargetId && msg.targetId !== resolvedTargetId)
1238
1259
  return;
1239
1260
  if (levelFilter && !levelFilter.has(msg.level))
@@ -1258,7 +1279,7 @@ export class AgentViewServer {
1258
1279
  }
1259
1280
  return { ok: true, data: formatConsoleMessages(collected) };
1260
1281
  }
1261
- const messages = this.consoleStream.drain({
1282
+ const messages = state.consoleStream.drain({
1262
1283
  since,
1263
1284
  level: levelFilter,
1264
1285
  targetId: resolvedTargetId,
@@ -1269,6 +1290,7 @@ export class AgentViewServer {
1269
1290
  const cwd = argStr(req.args, 'cwd');
1270
1291
  const config = cwd ? readConfig(resolve(cwd)) : null;
1271
1292
  await this.ensureNetworkAttached(req, config);
1293
+ const state = this.stateFor(req.port);
1272
1294
  let resolvedTargetId;
1273
1295
  const targetQuery = argStr(req.args, 'target') ?? argStr(req.args, 'window');
1274
1296
  if (targetQuery) {
@@ -1281,18 +1303,18 @@ export class AgentViewServer {
1281
1303
  }
1282
1304
  const reqN = argNum(req.args, 'req');
1283
1305
  if (reqN !== undefined) {
1284
- const ref = this.networkRefs.get(reqN);
1306
+ const ref = state.networkRefs.get(reqN);
1285
1307
  if (!ref) {
1286
1308
  return { ok: false, error: `Invalid req: ${reqN}. Run \`agent-view network\` to get fresh handles.` };
1287
1309
  }
1288
- const entry = this.networkStream.getEntry(ref.targetId, ref.requestId);
1310
+ const entry = state.networkStream.getEntry(ref.targetId, ref.requestId);
1289
1311
  if (!entry) {
1290
1312
  return { ok: false, error: `Request ${reqN} is no longer buffered (evicted or app restarted).` };
1291
1313
  }
1292
1314
  return { ok: true, data: formatNetworkDetail(entry, { rawHeaders: argBool(req.args, 'rawHeaders') ?? false }) };
1293
1315
  }
1294
1316
  if (argBool(req.args, 'clear')) {
1295
- this.networkStream.clear(resolvedTargetId);
1317
+ state.networkStream.clear(resolvedTargetId);
1296
1318
  return { ok: true, data: 'Network buffer cleared' };
1297
1319
  }
1298
1320
  const filter = {
@@ -1312,31 +1334,33 @@ export class AgentViewServer {
1312
1334
  if (follow) {
1313
1335
  return this.followNetwork(req, filter, maxLines, untilPattern);
1314
1336
  }
1315
- const entries = this.networkStream.drain(filter);
1316
- return { ok: true, data: this.renderNetworkList(entries, maxLines) };
1337
+ const entries = state.networkStream.drain(filter);
1338
+ return { ok: true, data: this.renderNetworkList(req.port, entries, maxLines) };
1317
1339
  }
1318
- renderNetworkList(entries, maxLines) {
1319
- const { text, refs, nextRef } = formatNetworkList(entries, { startRef: this.networkNextRef, maxLines });
1320
- this.networkRefs.clear();
1340
+ renderNetworkList(port, entries, maxLines) {
1341
+ const state = this.stateFor(port);
1342
+ const { text, refs, nextRef } = formatNetworkList(entries, { startRef: state.networkNextRef, maxLines });
1343
+ state.networkRefs.clear();
1321
1344
  for (const r of refs)
1322
- this.networkRefs.set(r.ref, { targetId: r.targetId, requestId: r.requestId });
1323
- this.networkNextRef = nextRef;
1345
+ state.networkRefs.set(r.ref, { targetId: r.targetId, requestId: r.requestId });
1346
+ state.networkNextRef = nextRef;
1324
1347
  return text;
1325
1348
  }
1326
1349
  async followNetwork(req, filter, maxLines, untilPattern) {
1327
1350
  const timeoutSec = argNum(req.args, 'timeout') ?? 10;
1328
1351
  const matcher = untilPattern ? buildMatcher(untilPattern) : null;
1329
1352
  const matchText = (e) => `${e.method ?? (e.isWebSocket ? 'WS' : e.isEventSource ? 'SSE' : '')} ${e.status ?? e.state} ${e.url}`;
1353
+ const state = this.stateFor(req.port);
1330
1354
  if (matcher) {
1331
- const pre = this.networkStream.drain(filter);
1355
+ const pre = state.networkStream.drain(filter);
1332
1356
  const hit = pre.findIndex(e => matcher(matchText(e)));
1333
1357
  if (hit !== -1)
1334
- return { ok: true, data: this.renderNetworkList(pre.slice(0, hit + 1), maxLines) };
1358
+ return { ok: true, data: this.renderNetworkList(req.port, pre.slice(0, hit + 1), maxLines) };
1335
1359
  }
1336
1360
  const matched = await new Promise((resolveFollow) => {
1337
1361
  const dispose = matcher
1338
- ? this.networkStream.subscribe(() => {
1339
- const cur = this.networkStream.drain(filter);
1362
+ ? state.networkStream.subscribe(() => {
1363
+ const cur = state.networkStream.drain(filter);
1340
1364
  if (cur.some(e => matcher(matchText(e)))) {
1341
1365
  clearTimeout(timer);
1342
1366
  dispose();
@@ -1353,8 +1377,8 @@ export class AgentViewServer {
1353
1377
  if (matcher && !matched) {
1354
1378
  return { ok: false, error: `Timeout: pattern not seen in ${timeoutSec}s` };
1355
1379
  }
1356
- const entries = this.networkStream.drain(filter);
1357
- return { ok: true, data: this.renderNetworkList(entries, maxLines) };
1380
+ const entries = state.networkStream.drain(filter);
1381
+ return { ok: true, data: this.renderNetworkList(req.port, entries, maxLines) };
1358
1382
  }
1359
1383
  /**
1360
1384
  * Durable side of the console feed. `console` answers from a ring buffer that dies with the
@@ -1367,25 +1391,35 @@ export class AgentViewServer {
1367
1391
  const projectDir = cwd ? resolve(cwd) : process.cwd();
1368
1392
  const config = cwd ? readConfig(projectDir) : null;
1369
1393
  const explicitFile = argStr(req.args, 'file');
1370
- // An active recording owns the feed path — only an explicit --file overrides it.
1394
+ // An active recording owns the feed path — only an explicit --file overrides it. "Active"
1395
+ // means *this port's* recording: another slot's recorder must never redirect this feed.
1396
+ const state = this.stateFor(req.port);
1371
1397
  const file = explicitFile
1372
1398
  ? resolveLogFile(projectDir, explicitFile)
1373
- : this.logRecorder?.file ?? resolveLogFile(projectDir, config?.logFile);
1399
+ : state.logRecorder?.file ?? resolveLogFile(projectDir, config?.logFile);
1374
1400
  switch (action) {
1375
1401
  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);
1402
+ case 'stop': return this.stopLogRecording(req.port);
1403
+ case 'status': return { ok: true, data: state.logRecorder ? formatRecorderStatus(state.logRecorder.status()) : formatIdleFeed(file) };
1404
+ case 'clear': return this.clearLogFeed(req.port, file);
1379
1405
  case 'tail': return this.tailLogFeed(req, file);
1380
1406
  default: return { ok: false, error: `Unknown logs action: ${action}` };
1381
1407
  }
1382
1408
  }
1383
1409
  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())}` };
1410
+ const state = this.stateFor(req.port);
1411
+ if (state.logRecorder) {
1412
+ if (state.logRecorder.file === file) {
1413
+ return { ok: true, data: `Already recording\n${formatRecorderStatus(state.logRecorder.status())}` };
1414
+ }
1415
+ return { ok: false, error: `Already recording into ${state.logRecorder.file}. Run \`agent-view logs stop\` first.` };
1416
+ }
1417
+ // Two checkouts pointed at one feed file interleave records and truncate each other, which is
1418
+ // exactly the cross-slot corruption port-scoping removes elsewhere — refuse it instead.
1419
+ for (const [otherPort, other] of this.portStates) {
1420
+ if (otherPort !== req.port && other.logRecorder?.file === file) {
1421
+ return { ok: false, error: `Port ${otherPort} is already recording into ${file}. Use a per-slot feed path (--file) or stop that recording first.` };
1387
1422
  }
1388
- return { ok: false, error: `Already recording into ${this.logRecorder.file}. Run \`agent-view logs stop\` first.` };
1389
1423
  }
1390
1424
  const probes = parseProbes(req.args);
1391
1425
  if (probes.length > 0 && !config?.allowEval) {
@@ -1401,8 +1435,8 @@ export class AgentViewServer {
1401
1435
  }
1402
1436
  resolvedTargetId = match.target.id;
1403
1437
  }
1404
- if (this.consoleStream.attachedCount === 0) {
1405
- this.consoleStream = new ConsoleStream({ capacity: config?.consoleBufferSize ?? 500 });
1438
+ if (state.consoleStream.attachedCount === 0) {
1439
+ state.consoleStream = new ConsoleStream({ capacity: config?.consoleBufferSize ?? 500 });
1406
1440
  }
1407
1441
  const allowedTypes = this.resolveConsoleTypes(req, config);
1408
1442
  const recorder = new LogRecorder({
@@ -1418,7 +1452,7 @@ export class AgentViewServer {
1418
1452
  allowedTypes,
1419
1453
  targetId: resolvedTargetId,
1420
1454
  }),
1421
- subscribe: (handler) => this.consoleStream.subscribe(handler),
1455
+ subscribe: (handler) => state.consoleStream.subscribe(handler),
1422
1456
  });
1423
1457
  try {
1424
1458
  await recorder.start();
@@ -1427,23 +1461,25 @@ export class AgentViewServer {
1427
1461
  recorder.stop('start failed');
1428
1462
  return { ok: false, error: `Could not start recording into ${file}: ${err instanceof Error ? err.message : String(err)}` };
1429
1463
  }
1430
- this.logRecorder = recorder;
1464
+ state.logRecorder = recorder;
1431
1465
  this.resetIdleTimer();
1432
1466
  return { ok: true, data: formatRecorderStatus(recorder.status()) };
1433
1467
  }
1434
- async stopLogRecording() {
1435
- if (!this.logRecorder)
1468
+ async stopLogRecording(port) {
1469
+ const state = this.stateFor(port);
1470
+ if (!state.logRecorder)
1436
1471
  return { ok: true, data: 'Not recording' };
1437
- const { file, lines } = this.logRecorder.status();
1438
- this.logRecorder.stop('stop requested');
1439
- this.logRecorder = null;
1472
+ const { file, lines } = state.logRecorder.status();
1473
+ state.logRecorder.stop('stop requested');
1474
+ state.logRecorder = null;
1440
1475
  this.resetIdleTimer();
1441
1476
  return { ok: true, data: `Recording stopped — ${lines} lines in ${file}` };
1442
1477
  }
1443
- async clearLogFeed(file) {
1444
- this.consoleStream.clear();
1445
- if (this.logRecorder?.file === file) {
1446
- this.logRecorder.clearFeed();
1478
+ async clearLogFeed(port, file) {
1479
+ const state = this.stateFor(port);
1480
+ state.consoleStream.clear();
1481
+ if (state.logRecorder?.file === file) {
1482
+ state.logRecorder.clearFeed();
1447
1483
  return { ok: true, data: `Feed cleared, recording continues — ${file}` };
1448
1484
  }
1449
1485
  if (!existsSync(file)) {
@@ -1465,6 +1501,7 @@ export class AgentViewServer {
1465
1501
  }
1466
1502
  since = parsed;
1467
1503
  }
1504
+ const state = this.stateFor(req.port);
1468
1505
  const { lines, scanTruncated } = readFeedLines(file);
1469
1506
  const selected = filterLogLines(lines, {
1470
1507
  grep: argStr(req.args, 'grep'),
@@ -1480,7 +1517,7 @@ export class AgentViewServer {
1480
1517
  dropped > 0 ? `Output cap hit — ${dropped} older matching records omitted.` : null,
1481
1518
  scanTruncated ? `Feed exceeds the scan window — older records are only in ${file}.` : null,
1482
1519
  // 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`.',
1520
+ state.logRecorder?.file === file ? null : 'Not recording — this feed is static. Run `agent-view logs start`.',
1484
1521
  ].filter((w) => w !== null);
1485
1522
  return { ok: true, data: text, warning: warnings.length > 0 ? warnings.join(' ') : undefined };
1486
1523
  }
@@ -1494,11 +1531,16 @@ export class AgentViewServer {
1494
1531
  for (const watch of [...this.activeWatches]) {
1495
1532
  watch.stop(StopReason.ServerShutdown, false);
1496
1533
  }
1497
- this.logRecorder?.stop('server shutdown');
1498
- this.logRecorder = null;
1534
+ for (const state of this.portStates.values()) {
1535
+ state.logRecorder?.stop('server shutdown');
1536
+ state.logRecorder = null;
1537
+ }
1499
1538
  await unlink(TOKEN_PATH).catch(() => { });
1500
- this.consoleStream.detach();
1501
- this.networkStream.detach();
1539
+ for (const state of this.portStates.values()) {
1540
+ state.consoleStream.detach();
1541
+ state.networkStream.detach();
1542
+ }
1543
+ this.portStates.clear();
1502
1544
  for (const cached of this.connections.values()) {
1503
1545
  try {
1504
1546
  await cached.session.close();