@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.
- package/.claude-plugin/plugin.json +21 -21
- package/LICENSE +21 -21
- package/README.md +41 -1
- package/dist/cdp/console-stream.d.ts +35 -0
- package/dist/cdp/console-stream.d.ts.map +1 -0
- package/dist/cdp/console-stream.js +103 -0
- package/dist/cdp/console-stream.js.map +1 -0
- package/dist/cdp/transport.d.ts.map +1 -1
- package/dist/cdp/transport.js +273 -12
- package/dist/cdp/transport.js.map +1 -1
- package/dist/cdp/types.d.ts +93 -0
- package/dist/cdp/types.d.ts.map +1 -1
- package/dist/cdp/types.js +15 -0
- package/dist/cdp/types.js.map +1 -1
- package/dist/cli/commands/dialog.d.ts +11 -0
- package/dist/cli/commands/dialog.d.ts.map +1 -0
- package/dist/cli/commands/dialog.js +30 -0
- package/dist/cli/commands/dialog.js.map +1 -0
- package/dist/cli/commands/upload.d.ts +10 -0
- package/dist/cli/commands/upload.d.ts.map +1 -0
- package/dist/cli/commands/upload.js +35 -0
- package/dist/cli/commands/upload.js.map +1 -0
- package/dist/cli/index.js +71 -2
- package/dist/cli/index.js.map +1 -1
- package/dist/inspectors/dialog/index.d.ts +20 -0
- package/dist/inspectors/dialog/index.d.ts.map +1 -0
- package/dist/inspectors/dialog/index.js +58 -0
- package/dist/inspectors/dialog/index.js.map +1 -0
- package/dist/inspectors/dom.d.ts +17 -0
- package/dist/inspectors/dom.d.ts.map +1 -0
- package/dist/inspectors/dom.js +131 -0
- package/dist/inspectors/dom.js.map +1 -0
- package/dist/inspectors/scene/pixi.d.ts +3 -0
- package/dist/inspectors/scene/pixi.d.ts.map +1 -0
- package/dist/inspectors/scene/pixi.js +52 -0
- package/dist/inspectors/scene/pixi.js.map +1 -0
- package/dist/server/server.d.ts +36 -5
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +288 -62
- package/dist/server/server.js.map +1 -1
- package/dist/server/tauri-dialog-shim.d.ts +27 -0
- package/dist/server/tauri-dialog-shim.d.ts.map +1 -0
- package/dist/server/tauri-dialog-shim.js +55 -0
- package/dist/server/tauri-dialog-shim.js.map +1 -0
- package/package.json +1 -1
- package/skills/verify/SKILL.md +119 -2
package/dist/server/server.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
307
|
-
|
|
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
|
-
|
|
422
|
-
|
|
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
|
|
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
|
-
|
|
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 ${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
|
1136
|
-
|
|
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
|
-
|
|
1139
|
-
|
|
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 =
|
|
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
|
-
?
|
|
1155
|
-
const cur =
|
|
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 =
|
|
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
|
-
:
|
|
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:
|
|
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
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
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 (
|
|
1221
|
-
|
|
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) =>
|
|
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
|
-
|
|
1464
|
+
state.logRecorder = recorder;
|
|
1247
1465
|
this.resetIdleTimer();
|
|
1248
1466
|
return { ok: true, data: formatRecorderStatus(recorder.status()) };
|
|
1249
1467
|
}
|
|
1250
|
-
async stopLogRecording() {
|
|
1251
|
-
|
|
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 } =
|
|
1254
|
-
|
|
1255
|
-
|
|
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.
|
|
1261
|
-
|
|
1262
|
-
|
|
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
|
-
|
|
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.
|
|
1314
|
-
|
|
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.
|
|
1317
|
-
|
|
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();
|