@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +2 -2
- package/README.md +52 -1
- package/dist/cdp/transport.d.ts.map +1 -1
- package/dist/cdp/transport.js +128 -4
- package/dist/cdp/transport.js.map +1 -1
- package/dist/cdp/types.d.ts +51 -0
- package/dist/cdp/types.d.ts.map +1 -1
- package/dist/cdp/types.js.map +1 -1
- package/dist/cli/commands/click.d.ts +1 -0
- package/dist/cli/commands/click.d.ts.map +1 -1
- package/dist/cli/commands/click.js +2 -0
- package/dist/cli/commands/click.js.map +1 -1
- package/dist/cli/commands/coverage.d.ts +14 -0
- package/dist/cli/commands/coverage.d.ts.map +1 -0
- package/dist/cli/commands/coverage.js +34 -0
- package/dist/cli/commands/coverage.js.map +1 -0
- package/dist/cli/commands/listeners.d.ts +11 -0
- package/dist/cli/commands/listeners.d.ts.map +1 -0
- package/dist/cli/commands/listeners.js +34 -0
- package/dist/cli/commands/listeners.js.map +1 -0
- package/dist/cli/index.js +30 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/inspectors/coverage/index.d.ts +17 -0
- package/dist/inspectors/coverage/index.d.ts.map +1 -0
- package/dist/inspectors/coverage/index.js +65 -0
- package/dist/inspectors/coverage/index.js.map +1 -0
- package/dist/inspectors/listeners/index.d.ts +4 -0
- package/dist/inspectors/listeners/index.d.ts.map +1 -0
- package/dist/inspectors/listeners/index.js +30 -0
- package/dist/inspectors/listeners/index.js.map +1 -0
- package/dist/server/server.d.ts +13 -5
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +174 -64
- package/dist/server/server.js.map +1 -1
- package/package.json +1 -1
- package/skills/verify/SKILL.md +157 -421
- package/skills/verify/references/commands.md +358 -0
- package/skills/verify/references/design-conformance.md +23 -0
package/dist/server/server.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
330
|
-
|
|
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
|
-
|
|
445
|
-
|
|
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
|
|
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
|
|
597
|
-
const
|
|
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
|
-
|
|
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 ${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
|
1320
|
-
|
|
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
|
-
|
|
1323
|
-
|
|
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 =
|
|
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
|
-
?
|
|
1339
|
-
const cur =
|
|
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 =
|
|
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
|
-
:
|
|
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:
|
|
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
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
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 (
|
|
1405
|
-
|
|
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) =>
|
|
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
|
-
|
|
1532
|
+
state.logRecorder = recorder;
|
|
1431
1533
|
this.resetIdleTimer();
|
|
1432
1534
|
return { ok: true, data: formatRecorderStatus(recorder.status()) };
|
|
1433
1535
|
}
|
|
1434
|
-
async stopLogRecording() {
|
|
1435
|
-
|
|
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 } =
|
|
1438
|
-
|
|
1439
|
-
|
|
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.
|
|
1445
|
-
|
|
1446
|
-
|
|
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
|
-
|
|
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.
|
|
1498
|
-
|
|
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.
|
|
1501
|
-
|
|
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();
|