@petukhovart/agent-view 0.12.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.
Files changed (46) hide show
  1. package/.claude-plugin/plugin.json +21 -21
  2. package/LICENSE +21 -21
  3. package/README.md +41 -1
  4. package/dist/cdp/console-stream.d.ts +35 -0
  5. package/dist/cdp/console-stream.d.ts.map +1 -0
  6. package/dist/cdp/console-stream.js +103 -0
  7. package/dist/cdp/console-stream.js.map +1 -0
  8. package/dist/cdp/transport.d.ts.map +1 -1
  9. package/dist/cdp/transport.js +273 -12
  10. package/dist/cdp/transport.js.map +1 -1
  11. package/dist/cdp/types.d.ts +93 -0
  12. package/dist/cdp/types.d.ts.map +1 -1
  13. package/dist/cdp/types.js +15 -0
  14. package/dist/cdp/types.js.map +1 -1
  15. package/dist/cli/commands/dialog.d.ts +11 -0
  16. package/dist/cli/commands/dialog.d.ts.map +1 -0
  17. package/dist/cli/commands/dialog.js +30 -0
  18. package/dist/cli/commands/dialog.js.map +1 -0
  19. package/dist/cli/commands/upload.d.ts +10 -0
  20. package/dist/cli/commands/upload.d.ts.map +1 -0
  21. package/dist/cli/commands/upload.js +35 -0
  22. package/dist/cli/commands/upload.js.map +1 -0
  23. package/dist/cli/index.js +71 -2
  24. package/dist/cli/index.js.map +1 -1
  25. package/dist/inspectors/dialog/index.d.ts +20 -0
  26. package/dist/inspectors/dialog/index.d.ts.map +1 -0
  27. package/dist/inspectors/dialog/index.js +58 -0
  28. package/dist/inspectors/dialog/index.js.map +1 -0
  29. package/dist/inspectors/dom.d.ts +17 -0
  30. package/dist/inspectors/dom.d.ts.map +1 -0
  31. package/dist/inspectors/dom.js +131 -0
  32. package/dist/inspectors/dom.js.map +1 -0
  33. package/dist/inspectors/scene/pixi.d.ts +3 -0
  34. package/dist/inspectors/scene/pixi.d.ts.map +1 -0
  35. package/dist/inspectors/scene/pixi.js +52 -0
  36. package/dist/inspectors/scene/pixi.js.map +1 -0
  37. package/dist/server/server.d.ts +36 -5
  38. package/dist/server/server.d.ts.map +1 -1
  39. package/dist/server/server.js +288 -62
  40. package/dist/server/server.js.map +1 -1
  41. package/dist/server/tauri-dialog-shim.d.ts +27 -0
  42. package/dist/server/tauri-dialog-shim.d.ts.map +1 -0
  43. package/dist/server/tauri-dialog-shim.js +55 -0
  44. package/dist/server/tauri-dialog-shim.js.map +1 -0
  45. package/package.json +1 -1
  46. package/skills/verify/SKILL.md +119 -2
@@ -17,6 +17,8 @@ import { listSupportedTargets, connectToRuntime } from '../cdp/transport.js';
17
17
  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
+ import { formatDialogStatus, describePolicy, describeArm } from '../inspectors/dialog/index.js';
21
+ import { buildTauriArmScript, buildTauriStatusScript, TauriShimResult } from './tauri-dialog-shim.js';
20
22
  import { NetworkResourceType } from '../cdp/types.js';
21
23
  import { AxTreeCache } from '../cdp/ax-cache.js';
22
24
  import { WatchSession } from './watch-session.js';
@@ -135,6 +137,25 @@ export function parseFilter(filter) {
135
137
  }
136
138
  return { kind: 'simple', name: filter };
137
139
  }
140
+ /**
141
+ * CDP takes any path without complaint and the app then reads an empty `File`,
142
+ * so this is the only place a bad path stays diagnosable. A directory has to be
143
+ * rejected too — it passes an existence check and produces that same empty
144
+ * `File`, which is the failure the check exists to prevent.
145
+ */
146
+ export function resolveUploadPath(cwd, raw) {
147
+ const abs = resolve(cwd, raw);
148
+ let stats;
149
+ try {
150
+ stats = statSync(abs);
151
+ }
152
+ catch {
153
+ return { error: `File not found: ${abs}` };
154
+ }
155
+ if (!stats.isFile())
156
+ return { error: `Not a file: ${abs}` };
157
+ return { path: abs };
158
+ }
138
159
  export class AgentViewServer {
139
160
  server = null;
140
161
  connections = new Map();
@@ -143,13 +164,9 @@ export class AgentViewServer {
143
164
  sceneCache = new Map();
144
165
  domTextCache = new Map();
145
166
  axTreeCache = new AxTreeCache();
146
- consoleStream = new ConsoleStream();
147
- networkStream = new NetworkStream();
148
- networkRefs = new Map();
149
- networkNextRef = 1;
167
+ portStates = new Map();
150
168
  token = '';
151
169
  activeWatches = new Set();
152
- logRecorder = null;
153
170
  handlers = {
154
171
  discover: (req) => this.handleDiscover(req),
155
172
  launch: (req) => this.handleLaunch(req),
@@ -166,6 +183,8 @@ export class AgentViewServer {
166
183
  console: (req) => this.handleConsole(req),
167
184
  network: (req) => this.handleNetwork(req),
168
185
  logs: (req) => this.handleLogs(req),
186
+ upload: (req) => this.handleUpload(req),
187
+ dialog: (req) => this.handleDialog(req),
169
188
  stop: () => this.handleStop(),
170
189
  };
171
190
  streamingCommands = new Set(['watch']);
@@ -187,10 +206,31 @@ export class AgentViewServer {
187
206
  });
188
207
  await writeFile(TOKEN_PATH, this.token, { mode: 0o600 });
189
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
+ }
190
230
  resetIdleTimer() {
191
231
  if (this.idleTimer)
192
232
  clearTimeout(this.idleTimer);
193
- if (this.activeWatches.size > 0 || this.logRecorder !== null) {
233
+ if (this.activeWatches.size > 0 || this.isAnyRecording()) {
194
234
  // Pause idle shutdown while streaming handlers or a log recording are alive —
195
235
  // a recording that dies at the 5-min mark loses exactly the long scenario it was for.
196
236
  this.idleTimer = null;
@@ -303,8 +343,9 @@ export class AgentViewServer {
303
343
  return;
304
344
  this.connections.delete(connKey);
305
345
  const targetId = cached.session.target.id;
306
- this.consoleStream.detach(targetId);
307
- 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);
308
349
  this.axTreeCache.invalidate(connKey);
309
350
  cached.session.close().catch(() => { });
310
351
  }
@@ -418,8 +459,9 @@ export class AgentViewServer {
418
459
  * mirroring the consoleStream pattern.
419
460
  */
420
461
  async ensureNetworkAttached(req, config) {
421
- if (this.networkStream.attachedCount === 0) {
422
- this.networkStream = new NetworkStream({
462
+ const state = this.stateFor(req.port);
463
+ if (state.networkStream.attachedCount === 0) {
464
+ state.networkStream = new NetworkStream({
423
465
  capacity: config?.networkBufferSize ?? DEFAULT_NETWORK_BUFFER,
424
466
  captureBody: config?.captureBody ?? false,
425
467
  });
@@ -430,7 +472,7 @@ export class AgentViewServer {
430
472
  continue;
431
473
  try {
432
474
  const session = await this.getPageSession(req, t.id);
433
- await this.networkStream.attach(session);
475
+ await state.networkStream.attach(session);
434
476
  }
435
477
  catch { /* a single unreachable target shouldn't abort capture */ }
436
478
  }
@@ -675,6 +717,167 @@ export class AgentViewServer {
675
717
  this.axTreeCache.invalidate(cacheKey);
676
718
  return { ok: true, data: `Filled ref ${fillRef} with "${value}"` };
677
719
  }
720
+ /**
721
+ * Puts files straight into a file input. The native chooser never opens, so
722
+ * there is nothing to close and nothing to arm — this is the cheapest path
723
+ * whenever the input exists in the DOM before the click.
724
+ */
725
+ async handleUpload(req) {
726
+ const rawFiles = argStrArray(req.args, 'files') ?? [];
727
+ if (rawFiles.length === 0) {
728
+ return { ok: false, error: 'upload requires at least one --file' };
729
+ }
730
+ const cwd = argStr(req.args, 'cwd') ?? process.cwd();
731
+ const files = [];
732
+ for (const raw of rawFiles) {
733
+ const resolved = resolveUploadPath(cwd, raw);
734
+ if ('error' in resolved)
735
+ return { ok: false, error: resolved.error };
736
+ files.push(resolved.path);
737
+ }
738
+ const { targetId } = await this.resolveWindow(req);
739
+ const conn = await this.getPageSession(req, targetId);
740
+ const cacheKey = `${req.port}:${targetId}`;
741
+ const label = files.map(f => f.split(/[\\/]/).pop()).join(', ');
742
+ const selector = argStr(req.args, 'selector');
743
+ if (selector) {
744
+ const found = await conn.uploadBySelector(selector, files);
745
+ if (!found)
746
+ return { ok: false, error: `No element matches selector "${selector}"` };
747
+ this.axTreeCache.invalidate(cacheKey);
748
+ return { ok: true, data: `Set ${files.length} file(s) on "${selector}": ${label}` };
749
+ }
750
+ // No `--filter` here, unlike `click`/`fill`: an accessible name resolves to
751
+ // whatever node carries it, which for an upload control is the label or the
752
+ // button, not the `<input type=file>` behind it. Setting files on the wrong
753
+ // node fails deep inside CDP. `--selector` addresses the input itself.
754
+ const ref = argNum(req.args, 'ref');
755
+ if (ref === undefined) {
756
+ return { ok: false, error: 'upload requires --selector or --ref' };
757
+ }
758
+ const entry = this.refStore.get(ref);
759
+ if (!entry) {
760
+ return { ok: false, error: `Invalid ref: ${ref}. Run \`agent-view dom\` to get fresh refs.` };
761
+ }
762
+ await conn.uploadByNodeId(entry.backendDOMNodeId, files);
763
+ this.axTreeCache.invalidate(cacheKey);
764
+ return { ok: true, data: `Set ${files.length} file(s) on ref ${ref}: ${label}` };
765
+ }
766
+ /**
767
+ * `dialog` covers JS modals only — `alert`/`confirm`/`prompt`/`beforeunload`.
768
+ * They are answered automatically the moment they open (see
769
+ * `attachJsDialogSubscription`); the commands here read that log, change the
770
+ * standing answer, and force an answer for a dialog that was already open
771
+ * before agent-view attached.
772
+ */
773
+ async handleDialog(req) {
774
+ const { targetId } = await this.resolveWindow(req);
775
+ const conn = await this.getPageSession(req, targetId);
776
+ const action = argStr(req.args, 'action') ?? 'status';
777
+ const text = argStr(req.args, 'text');
778
+ if (action === 'accept' || action === 'dismiss') {
779
+ const accept = action === 'accept';
780
+ try {
781
+ await conn.answerJsDialog(accept, text);
782
+ }
783
+ catch (err) {
784
+ const detail = err instanceof Error ? err.message : String(err);
785
+ return { ok: false, error: `No JS dialog is showing on this window (${detail}).` };
786
+ }
787
+ const suffix = accept && text !== undefined ? ` with "${text}"` : '';
788
+ return { ok: true, data: `Dialog ${accept ? 'accepted' : 'dismissed'}${suffix}.` };
789
+ }
790
+ if (action === 'policy') {
791
+ const mode = argStr(req.args, 'mode');
792
+ if (mode !== 'accept' && mode !== 'dismiss') {
793
+ return { ok: false, error: 'dialog policy requires accept or dismiss' };
794
+ }
795
+ // Storing prompt text alongside a dismiss would be dead state the status
796
+ // output cannot show — rejecting it keeps what is stored and what is
797
+ // reported the same thing.
798
+ if (mode === 'dismiss' && text !== undefined) {
799
+ return { ok: false, error: '--text applies to accept only; a dismissed prompt returns null' };
800
+ }
801
+ const policy = { accept: mode === 'accept', ...(text === undefined ? {} : { promptText: text }) };
802
+ conn.setJsDialogPolicy(policy);
803
+ return { ok: true, data: `JS dialog policy: ${describePolicy(policy)}` };
804
+ }
805
+ if (action === 'arm' || action === 'disarm') {
806
+ return this.armFileChooser(req, conn, action === 'arm');
807
+ }
808
+ if (action !== 'status') {
809
+ return { ok: false, error: `Unknown dialog action: "${action}". Use status, accept, dismiss, policy, arm, or disarm.` };
810
+ }
811
+ const shim = await this.readTauriShimStatus(conn);
812
+ const status = {
813
+ policy: conn.getJsDialogPolicy(),
814
+ dialogs: conn.recentJsDialogs(),
815
+ chooserArm: conn.fileChooserArm(),
816
+ choosers: conn.recentFileChoosers(),
817
+ tauriPatched: shim.patched,
818
+ // The shim's arm lives in the page, the CDP arm lives in the session —
819
+ // different lifetimes. A dropped session leaves the page still armed, and
820
+ // reporting only the CDP half would call that "not armed".
821
+ tauriArmed: shim.armed,
822
+ };
823
+ return { ok: true, data: formatDialogStatus(status, localStamp) };
824
+ }
825
+ /**
826
+ * Arms both engines at once. Which one fires depends on how the app opens its
827
+ * dialog — a webview `<input>` goes through CDP, `@tauri-apps/plugin-dialog`
828
+ * goes through the IPC shim — and the caller has no reason to know which.
829
+ */
830
+ async armFileChooser(req, conn, arming) {
831
+ let arm = null;
832
+ if (arming) {
833
+ const cancel = argBool(req.args, 'cancel') ?? false;
834
+ const rawFiles = argStrArray(req.args, 'files') ?? [];
835
+ if (cancel && rawFiles.length > 0) {
836
+ return { ok: false, error: 'dialog arm takes either --file or --cancel, not both' };
837
+ }
838
+ if (!cancel && rawFiles.length === 0) {
839
+ return { ok: false, error: 'dialog arm requires at least one --file, or --cancel' };
840
+ }
841
+ if (cancel) {
842
+ arm = { kind: 'cancel' };
843
+ }
844
+ else {
845
+ const cwd = argStr(req.args, 'cwd') ?? process.cwd();
846
+ const files = [];
847
+ for (const raw of rawFiles) {
848
+ const resolved = resolveUploadPath(cwd, raw);
849
+ if ('error' in resolved)
850
+ return { ok: false, error: resolved.error };
851
+ files.push(resolved.path);
852
+ }
853
+ arm = { kind: 'files', files };
854
+ }
855
+ }
856
+ // The IPC shim goes first because it is the failure-prone half: it runs a
857
+ // script inside the page. Arming CDP only after it succeeds keeps the two
858
+ // engines from disagreeing — a reported failure must not leave a live
859
+ // intercept behind, which would silently swallow a later real chooser.
860
+ const tauri = await conn.evaluate(buildTauriArmScript(arm));
861
+ try {
862
+ await conn.armFileChooser(arm);
863
+ }
864
+ catch (err) {
865
+ await conn.evaluate(buildTauriArmScript(null)).catch(() => { });
866
+ throw err;
867
+ }
868
+ const engines = tauri === TauriShimResult.Armed ? 'CDP + Tauri IPC' : 'CDP';
869
+ if (!arming)
870
+ return { ok: true, data: `File chooser disarmed (${engines}).` };
871
+ return { ok: true, data: `File chooser ${describeArm(arm)} (${engines}). Arm before the click that opens it.` };
872
+ }
873
+ async readTauriShimStatus(conn) {
874
+ try {
875
+ return await conn.evaluate(buildTauriStatusScript());
876
+ }
877
+ catch {
878
+ return { patched: false, armed: false, fired: [] };
879
+ }
880
+ }
678
881
  async handleWait(req) {
679
882
  const filter = argStr(req.args, 'filter');
680
883
  if (!filter) {
@@ -969,6 +1172,7 @@ export class AgentViewServer {
969
1172
  */
970
1173
  async attachConsoleTargets(req, opts) {
971
1174
  const sessions = [];
1175
+ const state = this.stateFor(req.port);
972
1176
  if (process.env.AV_DEBUG_CONSOLE) {
973
1177
  // eslint-disable-next-line no-console
974
1178
  console.error(`[av-debug] attachConsoleTargets: targets=${opts.targets.length} explicit=${opts.targetId ?? 'none'} types=${[...opts.allowedTypes].join(',')}`);
@@ -982,11 +1186,11 @@ export class AgentViewServer {
982
1186
  continue;
983
1187
  try {
984
1188
  const session = await this.getRuntimeSession(req, t);
985
- this.consoleStream.attach(session);
1189
+ state.consoleStream.attach(session);
986
1190
  sessions.push(session);
987
1191
  if (process.env.AV_DEBUG_CONSOLE) {
988
1192
  // eslint-disable-next-line no-console
989
- 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})`);
990
1194
  }
991
1195
  }
992
1196
  catch (err) {
@@ -1002,10 +1206,11 @@ export class AgentViewServer {
1002
1206
  const cwd = argStr(req.args, 'cwd');
1003
1207
  const config = cwd ? readConfig(resolve(cwd)) : null;
1004
1208
  const bufferSize = config?.consoleBufferSize ?? 500;
1005
- if (this.consoleStream.attachedCount === 0 && this.logRecorder === null) {
1209
+ const state = this.stateFor(req.port);
1210
+ if (state.consoleStream.attachedCount === 0 && state.logRecorder === null) {
1006
1211
  // Recreate with config-tuned capacity on first attach. Never while recording — the
1007
1212
  // recorder's subscription lives on the stream instance and would be dropped silently.
1008
- this.consoleStream = new ConsoleStream({ capacity: bufferSize });
1213
+ state.consoleStream = new ConsoleStream({ capacity: bufferSize });
1009
1214
  }
1010
1215
  const targetQuery = argStr(req.args, 'target');
1011
1216
  // Fuzzy-resolve the target query once (id exact → title substring → url substring)
@@ -1019,7 +1224,7 @@ export class AgentViewServer {
1019
1224
  resolvedTargetId = match.target.id;
1020
1225
  }
1021
1226
  if (argBool(req.args, 'clear')) {
1022
- this.consoleStream.clear(resolvedTargetId);
1227
+ state.consoleStream.clear(resolvedTargetId);
1023
1228
  return { ok: true, data: 'Console buffer cleared' };
1024
1229
  }
1025
1230
  await this.attachConsoleTargets(req, {
@@ -1036,7 +1241,7 @@ export class AgentViewServer {
1036
1241
  }
1037
1242
  if (follow) {
1038
1243
  const timeoutSec = argNum(req.args, 'timeout') ?? 10;
1039
- const collected = this.consoleStream.drain({
1244
+ const collected = state.consoleStream.drain({
1040
1245
  since,
1041
1246
  level: levelFilter,
1042
1247
  targetId: resolvedTargetId,
@@ -1049,7 +1254,7 @@ export class AgentViewServer {
1049
1254
  return { ok: true, data: formatConsoleMessages(collected.slice(0, earlyMatch + 1)) };
1050
1255
  }
1051
1256
  const timedOut = await new Promise((resolveFollow) => {
1052
- const dispose = this.consoleStream.subscribe((msg) => {
1257
+ const dispose = state.consoleStream.subscribe((msg) => {
1053
1258
  if (resolvedTargetId && msg.targetId !== resolvedTargetId)
1054
1259
  return;
1055
1260
  if (levelFilter && !levelFilter.has(msg.level))
@@ -1074,7 +1279,7 @@ export class AgentViewServer {
1074
1279
  }
1075
1280
  return { ok: true, data: formatConsoleMessages(collected) };
1076
1281
  }
1077
- const messages = this.consoleStream.drain({
1282
+ const messages = state.consoleStream.drain({
1078
1283
  since,
1079
1284
  level: levelFilter,
1080
1285
  targetId: resolvedTargetId,
@@ -1085,6 +1290,7 @@ export class AgentViewServer {
1085
1290
  const cwd = argStr(req.args, 'cwd');
1086
1291
  const config = cwd ? readConfig(resolve(cwd)) : null;
1087
1292
  await this.ensureNetworkAttached(req, config);
1293
+ const state = this.stateFor(req.port);
1088
1294
  let resolvedTargetId;
1089
1295
  const targetQuery = argStr(req.args, 'target') ?? argStr(req.args, 'window');
1090
1296
  if (targetQuery) {
@@ -1097,18 +1303,18 @@ export class AgentViewServer {
1097
1303
  }
1098
1304
  const reqN = argNum(req.args, 'req');
1099
1305
  if (reqN !== undefined) {
1100
- const ref = this.networkRefs.get(reqN);
1306
+ const ref = state.networkRefs.get(reqN);
1101
1307
  if (!ref) {
1102
1308
  return { ok: false, error: `Invalid req: ${reqN}. Run \`agent-view network\` to get fresh handles.` };
1103
1309
  }
1104
- const entry = this.networkStream.getEntry(ref.targetId, ref.requestId);
1310
+ const entry = state.networkStream.getEntry(ref.targetId, ref.requestId);
1105
1311
  if (!entry) {
1106
1312
  return { ok: false, error: `Request ${reqN} is no longer buffered (evicted or app restarted).` };
1107
1313
  }
1108
1314
  return { ok: true, data: formatNetworkDetail(entry, { rawHeaders: argBool(req.args, 'rawHeaders') ?? false }) };
1109
1315
  }
1110
1316
  if (argBool(req.args, 'clear')) {
1111
- this.networkStream.clear(resolvedTargetId);
1317
+ state.networkStream.clear(resolvedTargetId);
1112
1318
  return { ok: true, data: 'Network buffer cleared' };
1113
1319
  }
1114
1320
  const filter = {
@@ -1128,31 +1334,33 @@ export class AgentViewServer {
1128
1334
  if (follow) {
1129
1335
  return this.followNetwork(req, filter, maxLines, untilPattern);
1130
1336
  }
1131
- const entries = this.networkStream.drain(filter);
1132
- 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) };
1133
1339
  }
1134
- renderNetworkList(entries, maxLines) {
1135
- const { text, refs, nextRef } = formatNetworkList(entries, { startRef: this.networkNextRef, maxLines });
1136
- 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();
1137
1344
  for (const r of refs)
1138
- this.networkRefs.set(r.ref, { targetId: r.targetId, requestId: r.requestId });
1139
- this.networkNextRef = nextRef;
1345
+ state.networkRefs.set(r.ref, { targetId: r.targetId, requestId: r.requestId });
1346
+ state.networkNextRef = nextRef;
1140
1347
  return text;
1141
1348
  }
1142
1349
  async followNetwork(req, filter, maxLines, untilPattern) {
1143
1350
  const timeoutSec = argNum(req.args, 'timeout') ?? 10;
1144
1351
  const matcher = untilPattern ? buildMatcher(untilPattern) : null;
1145
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);
1146
1354
  if (matcher) {
1147
- const pre = this.networkStream.drain(filter);
1355
+ const pre = state.networkStream.drain(filter);
1148
1356
  const hit = pre.findIndex(e => matcher(matchText(e)));
1149
1357
  if (hit !== -1)
1150
- 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) };
1151
1359
  }
1152
1360
  const matched = await new Promise((resolveFollow) => {
1153
1361
  const dispose = matcher
1154
- ? this.networkStream.subscribe(() => {
1155
- const cur = this.networkStream.drain(filter);
1362
+ ? state.networkStream.subscribe(() => {
1363
+ const cur = state.networkStream.drain(filter);
1156
1364
  if (cur.some(e => matcher(matchText(e)))) {
1157
1365
  clearTimeout(timer);
1158
1366
  dispose();
@@ -1169,8 +1377,8 @@ export class AgentViewServer {
1169
1377
  if (matcher && !matched) {
1170
1378
  return { ok: false, error: `Timeout: pattern not seen in ${timeoutSec}s` };
1171
1379
  }
1172
- const entries = this.networkStream.drain(filter);
1173
- 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) };
1174
1382
  }
1175
1383
  /**
1176
1384
  * Durable side of the console feed. `console` answers from a ring buffer that dies with the
@@ -1183,25 +1391,35 @@ export class AgentViewServer {
1183
1391
  const projectDir = cwd ? resolve(cwd) : process.cwd();
1184
1392
  const config = cwd ? readConfig(projectDir) : null;
1185
1393
  const explicitFile = argStr(req.args, 'file');
1186
- // 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);
1187
1397
  const file = explicitFile
1188
1398
  ? resolveLogFile(projectDir, explicitFile)
1189
- : this.logRecorder?.file ?? resolveLogFile(projectDir, config?.logFile);
1399
+ : state.logRecorder?.file ?? resolveLogFile(projectDir, config?.logFile);
1190
1400
  switch (action) {
1191
1401
  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);
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);
1195
1405
  case 'tail': return this.tailLogFeed(req, file);
1196
1406
  default: return { ok: false, error: `Unknown logs action: ${action}` };
1197
1407
  }
1198
1408
  }
1199
1409
  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())}` };
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.` };
1203
1422
  }
1204
- return { ok: false, error: `Already recording into ${this.logRecorder.file}. Run \`agent-view logs stop\` first.` };
1205
1423
  }
1206
1424
  const probes = parseProbes(req.args);
1207
1425
  if (probes.length > 0 && !config?.allowEval) {
@@ -1217,8 +1435,8 @@ export class AgentViewServer {
1217
1435
  }
1218
1436
  resolvedTargetId = match.target.id;
1219
1437
  }
1220
- if (this.consoleStream.attachedCount === 0) {
1221
- this.consoleStream = new ConsoleStream({ capacity: config?.consoleBufferSize ?? 500 });
1438
+ if (state.consoleStream.attachedCount === 0) {
1439
+ state.consoleStream = new ConsoleStream({ capacity: config?.consoleBufferSize ?? 500 });
1222
1440
  }
1223
1441
  const allowedTypes = this.resolveConsoleTypes(req, config);
1224
1442
  const recorder = new LogRecorder({
@@ -1234,7 +1452,7 @@ export class AgentViewServer {
1234
1452
  allowedTypes,
1235
1453
  targetId: resolvedTargetId,
1236
1454
  }),
1237
- subscribe: (handler) => this.consoleStream.subscribe(handler),
1455
+ subscribe: (handler) => state.consoleStream.subscribe(handler),
1238
1456
  });
1239
1457
  try {
1240
1458
  await recorder.start();
@@ -1243,23 +1461,25 @@ export class AgentViewServer {
1243
1461
  recorder.stop('start failed');
1244
1462
  return { ok: false, error: `Could not start recording into ${file}: ${err instanceof Error ? err.message : String(err)}` };
1245
1463
  }
1246
- this.logRecorder = recorder;
1464
+ state.logRecorder = recorder;
1247
1465
  this.resetIdleTimer();
1248
1466
  return { ok: true, data: formatRecorderStatus(recorder.status()) };
1249
1467
  }
1250
- async stopLogRecording() {
1251
- if (!this.logRecorder)
1468
+ async stopLogRecording(port) {
1469
+ const state = this.stateFor(port);
1470
+ if (!state.logRecorder)
1252
1471
  return { ok: true, data: 'Not recording' };
1253
- const { file, lines } = this.logRecorder.status();
1254
- this.logRecorder.stop('stop requested');
1255
- this.logRecorder = null;
1472
+ const { file, lines } = state.logRecorder.status();
1473
+ state.logRecorder.stop('stop requested');
1474
+ state.logRecorder = null;
1256
1475
  this.resetIdleTimer();
1257
1476
  return { ok: true, data: `Recording stopped — ${lines} lines in ${file}` };
1258
1477
  }
1259
- async clearLogFeed(file) {
1260
- this.consoleStream.clear();
1261
- if (this.logRecorder?.file === file) {
1262
- 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();
1263
1483
  return { ok: true, data: `Feed cleared, recording continues — ${file}` };
1264
1484
  }
1265
1485
  if (!existsSync(file)) {
@@ -1281,6 +1501,7 @@ export class AgentViewServer {
1281
1501
  }
1282
1502
  since = parsed;
1283
1503
  }
1504
+ const state = this.stateFor(req.port);
1284
1505
  const { lines, scanTruncated } = readFeedLines(file);
1285
1506
  const selected = filterLogLines(lines, {
1286
1507
  grep: argStr(req.args, 'grep'),
@@ -1296,7 +1517,7 @@ export class AgentViewServer {
1296
1517
  dropped > 0 ? `Output cap hit — ${dropped} older matching records omitted.` : null,
1297
1518
  scanTruncated ? `Feed exceeds the scan window — older records are only in ${file}.` : null,
1298
1519
  // 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`.',
1520
+ state.logRecorder?.file === file ? null : 'Not recording — this feed is static. Run `agent-view logs start`.',
1300
1521
  ].filter((w) => w !== null);
1301
1522
  return { ok: true, data: text, warning: warnings.length > 0 ? warnings.join(' ') : undefined };
1302
1523
  }
@@ -1310,11 +1531,16 @@ export class AgentViewServer {
1310
1531
  for (const watch of [...this.activeWatches]) {
1311
1532
  watch.stop(StopReason.ServerShutdown, false);
1312
1533
  }
1313
- this.logRecorder?.stop('server shutdown');
1314
- this.logRecorder = null;
1534
+ for (const state of this.portStates.values()) {
1535
+ state.logRecorder?.stop('server shutdown');
1536
+ state.logRecorder = null;
1537
+ }
1315
1538
  await unlink(TOKEN_PATH).catch(() => { });
1316
- this.consoleStream.detach();
1317
- this.networkStream.detach();
1539
+ for (const state of this.portStates.values()) {
1540
+ state.consoleStream.detach();
1541
+ state.networkStream.detach();
1542
+ }
1543
+ this.portStates.clear();
1318
1544
  for (const cached of this.connections.values()) {
1319
1545
  try {
1320
1546
  await cached.session.close();