@tonyclaw/agent-inspector 2.0.27 → 2.0.29

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 (47) hide show
  1. package/.output/cli.js +169 -13
  2. package/.output/nitro.json +1 -1
  3. package/.output/public/assets/{CompareDrawer-B_p6vmMQ.js → CompareDrawer-DcqxlgSQ.js} +1 -1
  4. package/.output/public/assets/ProxyViewerContainer-D650kQlv.js +115 -0
  5. package/.output/public/assets/{ReplayDialog-BmprJ6D3.js → ReplayDialog-CBcnPgx1.js} +1 -1
  6. package/.output/public/assets/{RequestAnatomy-DqO0_SVJ.js → RequestAnatomy-DSgSqCYt.js} +1 -1
  7. package/.output/public/assets/{ResponseView-FZbPNdHa.js → ResponseView-CjqzBSoF.js} +1 -1
  8. package/.output/public/assets/{StreamingChunkSequence-BZPzfJWj.js → StreamingChunkSequence-DdBBKolI.js} +1 -1
  9. package/.output/public/assets/_sessionId-wctOoGh_.js +1 -0
  10. package/.output/public/assets/index-BhQGSdhG.js +1 -0
  11. package/.output/public/assets/{index-DsiKfWCp.css → index-CwlHPmgL.css} +1 -1
  12. package/.output/public/assets/{main-CzItFZlM.js → main-B6OLZCp9.js} +2 -2
  13. package/.output/server/_libs/lucide-react.mjs +13 -13
  14. package/.output/server/{_sessionId-DZH8SrEZ.mjs → _sessionId-CCfKJJ19.mjs} +2 -2
  15. package/.output/server/_ssr/{CompareDrawer-L0aE1UV1.mjs → CompareDrawer-uaPI5R6e.mjs} +2 -2
  16. package/.output/server/_ssr/{ProxyViewerContainer-B62RB9ER.mjs → ProxyViewerContainer-BV2nIxXy.mjs} +83 -20
  17. package/.output/server/_ssr/{ReplayDialog-FVWnpx2C.mjs → ReplayDialog-eE2Hy2Nl.mjs} +4 -4
  18. package/.output/server/_ssr/{RequestAnatomy-DIyqW-Ny.mjs → RequestAnatomy-B7vwWo57.mjs} +2 -2
  19. package/.output/server/_ssr/{ResponseView-BX4mxEZ5.mjs → ResponseView-Bm6Qp8mo.mjs} +2 -2
  20. package/.output/server/_ssr/{StreamingChunkSequence-Cs3nzOor.mjs → StreamingChunkSequence-CbAJKJQx.mjs} +2 -2
  21. package/.output/server/_ssr/{index-Dik2Mc3h.mjs → index-u14_Aj60.mjs} +2 -2
  22. package/.output/server/_ssr/index.mjs +2 -2
  23. package/.output/server/_ssr/{router-DCPg8ykx.mjs → router-qgeGUp1k.mjs} +161 -18
  24. package/.output/server/{_tanstack-start-manifest_v-BaoL3JCh.mjs → _tanstack-start-manifest_v-BrfUXnre.mjs} +1 -1
  25. package/.output/server/index.mjs +59 -59
  26. package/package.json +1 -1
  27. package/src/cli/networkHints.ts +150 -0
  28. package/src/cli.ts +72 -13
  29. package/src/components/ProxyViewer.tsx +3 -0
  30. package/src/components/providers/ProviderCard.tsx +32 -0
  31. package/src/components/proxy-viewer/LogEntryHeader.tsx +38 -1
  32. package/src/lib/providerTestContract.ts +3 -0
  33. package/src/lib/sessionInfoContract.ts +3 -0
  34. package/src/mcp/toolHandlers.ts +3 -0
  35. package/src/proxy/evidenceAnalysis.ts +5 -0
  36. package/src/proxy/evidenceExporter.ts +8 -3
  37. package/src/proxy/handler.ts +8 -1
  38. package/src/proxy/logFinalizer.ts +36 -0
  39. package/src/proxy/schemas.ts +3 -0
  40. package/src/proxy/sessionInfo.ts +3 -0
  41. package/src/proxy/store.ts +3 -0
  42. package/src/proxy/streamTiming.ts +31 -0
  43. package/src/routes/api/logs.$id.replay.ts +25 -2
  44. package/src/routes/api/providers.$providerId.test.log.ts +34 -2
  45. package/.output/public/assets/ProxyViewerContainer-DItqh1EO.js +0 -115
  46. package/.output/public/assets/_sessionId-y2ATIp51.js +0 -1
  47. package/.output/public/assets/index-CQT4higx.js +0 -1
@@ -35,103 +35,103 @@ const headers = ((m) => function headersRouteRule(event) {
35
35
  }
36
36
  });
37
37
  const assets = {
38
- "/assets/index-CQT4higx.js": {
39
- "type": "text/javascript; charset=utf-8",
40
- "etag": '"74-gWIAXMUZANjNHlWTIO5NeVHxrXg"',
41
- "mtime": "2026-06-30T01:22:22.129Z",
42
- "size": 116,
43
- "path": "../public/assets/index-CQT4higx.js"
44
- },
45
- "/assets/CompareDrawer-B_p6vmMQ.js": {
46
- "type": "text/javascript; charset=utf-8",
47
- "etag": '"49fe-MtnQEnhved8xLSD42VJ6I1PWK+E"',
48
- "mtime": "2026-06-30T01:22:22.129Z",
49
- "size": 18942,
50
- "path": "../public/assets/CompareDrawer-B_p6vmMQ.js"
51
- },
52
38
  "/assets/alibaba-TTwafVwX.svg": {
53
39
  "type": "image/svg+xml",
54
40
  "etag": '"171b-6dyV5K8QjiaY35sN9qNprh9zDIs"',
55
- "mtime": "2026-06-30T01:22:22.129Z",
41
+ "mtime": "2026-06-30T09:01:48.556Z",
56
42
  "size": 5915,
57
43
  "path": "../public/assets/alibaba-TTwafVwX.svg"
58
44
  },
59
- "/assets/index-DsiKfWCp.css": {
60
- "type": "text/css; charset=utf-8",
61
- "etag": '"1a3d5-pnLJEAiyUUKlRw5OUnvwf1dCQeI"',
62
- "mtime": "2026-06-30T01:22:22.129Z",
63
- "size": 107477,
64
- "path": "../public/assets/index-DsiKfWCp.css"
45
+ "/assets/index-BhQGSdhG.js": {
46
+ "type": "text/javascript; charset=utf-8",
47
+ "etag": '"74-1e7gU3IOVaEjblxPQfgH61GRjlw"',
48
+ "mtime": "2026-06-30T09:01:48.556Z",
49
+ "size": 116,
50
+ "path": "../public/assets/index-BhQGSdhG.js"
51
+ },
52
+ "/assets/CompareDrawer-DcqxlgSQ.js": {
53
+ "type": "text/javascript; charset=utf-8",
54
+ "etag": '"49fe-+D2I+/9sd7EYksKARn3HVY2eJf0"',
55
+ "mtime": "2026-06-30T09:01:48.556Z",
56
+ "size": 18942,
57
+ "path": "../public/assets/CompareDrawer-DcqxlgSQ.js"
65
58
  },
66
59
  "/assets/minimax-BPMzvuL-.jpeg": {
67
60
  "type": "image/jpeg",
68
61
  "etag": '"1b06-IwivU89ko5UTMUM1/t7hn4sQK9A"',
69
- "mtime": "2026-06-30T01:22:22.129Z",
62
+ "mtime": "2026-06-30T09:01:48.556Z",
70
63
  "size": 6918,
71
64
  "path": "../public/assets/minimax-BPMzvuL-.jpeg"
72
65
  },
73
- "/assets/ReplayDialog-BmprJ6D3.js": {
66
+ "/assets/index-CwlHPmgL.css": {
67
+ "type": "text/css; charset=utf-8",
68
+ "etag": '"1a3f0-E2f0kOdSBi1RhjoK38+CSij/apg"',
69
+ "mtime": "2026-06-30T09:01:48.556Z",
70
+ "size": 107504,
71
+ "path": "../public/assets/index-CwlHPmgL.css"
72
+ },
73
+ "/assets/ReplayDialog-CBcnPgx1.js": {
74
74
  "type": "text/javascript; charset=utf-8",
75
- "etag": '"318c-AmdKZr32QndralPtdyOHXT6ma8g"',
76
- "mtime": "2026-06-30T01:22:22.129Z",
75
+ "etag": '"318c-Wq35vSCO67O3pnw6riReoXT9gOg"',
76
+ "mtime": "2026-06-30T09:01:48.557Z",
77
77
  "size": 12684,
78
- "path": "../public/assets/ReplayDialog-BmprJ6D3.js"
78
+ "path": "../public/assets/ReplayDialog-CBcnPgx1.js"
79
79
  },
80
- "/assets/RequestAnatomy-DqO0_SVJ.js": {
80
+ "/assets/RequestAnatomy-DSgSqCYt.js": {
81
81
  "type": "text/javascript; charset=utf-8",
82
- "etag": '"4485-Dn6zXY3u3Xh6+Mf8Wqy0LovvrJc"',
83
- "mtime": "2026-06-30T01:22:22.129Z",
82
+ "etag": '"4485-rBai6wnmYHf662t3omQFALcwHSw"',
83
+ "mtime": "2026-06-30T09:01:48.557Z",
84
84
  "size": 17541,
85
- "path": "../public/assets/RequestAnatomy-DqO0_SVJ.js"
85
+ "path": "../public/assets/RequestAnatomy-DSgSqCYt.js"
86
86
  },
87
- "/assets/ResponseView-FZbPNdHa.js": {
87
+ "/assets/ResponseView-CjqzBSoF.js": {
88
88
  "type": "text/javascript; charset=utf-8",
89
- "etag": '"30d0-Vy37cZ6aojvHWlD+tTR85FfRRSM"',
90
- "mtime": "2026-06-30T01:22:22.131Z",
89
+ "etag": '"30d0-13nPwe9jIZSSl43vTEhXlswXwn8"',
90
+ "mtime": "2026-06-30T09:01:48.557Z",
91
91
  "size": 12496,
92
- "path": "../public/assets/ResponseView-FZbPNdHa.js"
92
+ "path": "../public/assets/ResponseView-CjqzBSoF.js"
93
93
  },
94
- "/assets/StreamingChunkSequence-BZPzfJWj.js": {
94
+ "/assets/StreamingChunkSequence-DdBBKolI.js": {
95
95
  "type": "text/javascript; charset=utf-8",
96
- "etag": '"d57-pfsl61xCHzpslTaweknYjZ2A3D4"',
97
- "mtime": "2026-06-30T01:22:22.131Z",
96
+ "etag": '"d57-9H1QMwesXGWHkYNTPJ/9zDoslX0"',
97
+ "mtime": "2026-06-30T09:01:48.557Z",
98
98
  "size": 3415,
99
- "path": "../public/assets/StreamingChunkSequence-BZPzfJWj.js"
100
- },
101
- "/assets/zhipuai-BPNAnxo-.svg": {
102
- "type": "image/svg+xml",
103
- "etag": '"2bf8-hNaLCTi89nOFCsIIfWpP/jrfo0s"',
104
- "mtime": "2026-06-30T01:22:22.129Z",
105
- "size": 11256,
106
- "path": "../public/assets/zhipuai-BPNAnxo-.svg"
99
+ "path": "../public/assets/StreamingChunkSequence-DdBBKolI.js"
107
100
  },
108
- "/assets/_sessionId-y2ATIp51.js": {
101
+ "/assets/_sessionId-wctOoGh_.js": {
109
102
  "type": "text/javascript; charset=utf-8",
110
- "etag": '"d2-Pjbf+xPeCq/pZGai1EzjS0QCV6Q"',
111
- "mtime": "2026-06-30T01:22:22.129Z",
103
+ "etag": '"d2-fiIvvYyX9jF4fd1W7HpowwQ2Zj4"',
104
+ "mtime": "2026-06-30T09:01:48.556Z",
112
105
  "size": 210,
113
- "path": "../public/assets/_sessionId-y2ATIp51.js"
106
+ "path": "../public/assets/_sessionId-wctOoGh_.js"
114
107
  },
115
- "/assets/main-CzItFZlM.js": {
108
+ "/assets/main-B6OLZCp9.js": {
116
109
  "type": "text/javascript; charset=utf-8",
117
- "etag": '"51726-MNggoHWYk07f7qXrHiC/ZapXa7o"',
118
- "mtime": "2026-06-30T01:22:22.129Z",
110
+ "etag": '"51726-MaBaigWv6OLiiYg6fusFyN758Hw"',
111
+ "mtime": "2026-06-30T09:01:48.557Z",
119
112
  "size": 333606,
120
- "path": "../public/assets/main-CzItFZlM.js"
113
+ "path": "../public/assets/main-B6OLZCp9.js"
114
+ },
115
+ "/assets/zhipuai-BPNAnxo-.svg": {
116
+ "type": "image/svg+xml",
117
+ "etag": '"2bf8-hNaLCTi89nOFCsIIfWpP/jrfo0s"',
118
+ "mtime": "2026-06-30T09:01:48.556Z",
119
+ "size": 11256,
120
+ "path": "../public/assets/zhipuai-BPNAnxo-.svg"
121
121
  },
122
122
  "/assets/qwen-CONDcHqt.png": {
123
123
  "type": "image/png",
124
124
  "etag": '"572c3-cdJAPaHdOvFCGzuaQjagdgOu6XE"',
125
- "mtime": "2026-06-30T01:22:22.129Z",
125
+ "mtime": "2026-06-30T09:01:48.556Z",
126
126
  "size": 357059,
127
127
  "path": "../public/assets/qwen-CONDcHqt.png"
128
128
  },
129
- "/assets/ProxyViewerContainer-DItqh1EO.js": {
129
+ "/assets/ProxyViewerContainer-D650kQlv.js": {
130
130
  "type": "text/javascript; charset=utf-8",
131
- "etag": '"af111-hYBEti7PXxO28eHLSM5rHWrM0Vg"',
132
- "mtime": "2026-06-30T01:22:22.131Z",
133
- "size": 717073,
134
- "path": "../public/assets/ProxyViewerContainer-DItqh1EO.js"
131
+ "etag": '"af8bd-kLHfI4KjZ80Xpnq5/VoFdzyBH10"',
132
+ "mtime": "2026-06-30T09:01:48.557Z",
133
+ "size": 719037,
134
+ "path": "../public/assets/ProxyViewerContainer-D650kQlv.js"
135
135
  }
136
136
  };
137
137
  function readAsset(id) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tonyclaw/agent-inspector",
3
- "version": "2.0.27",
3
+ "version": "2.0.29",
4
4
  "type": "module",
5
5
  "description": "Agent observability and knowledge capture layer for AI coding tools.",
6
6
  "license": "MIT",
@@ -0,0 +1,150 @@
1
+ import { networkInterfaces } from "node:os";
2
+
3
+ export type NetworkInterfaceMap = ReturnType<typeof networkInterfaces>;
4
+
5
+ export type BindHost = string | undefined;
6
+
7
+ export function localUrlForPort(port: number): string {
8
+ return `http://localhost:${String(port)}`;
9
+ }
10
+
11
+ function trimHost(host: BindHost): string | undefined {
12
+ const trimmed = host?.trim();
13
+ return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed;
14
+ }
15
+
16
+ export function isWildcardHost(host: BindHost): boolean {
17
+ const trimmed = trimHost(host);
18
+ return trimmed === undefined || trimmed === "0.0.0.0" || trimmed === "::" || trimmed === "[::]";
19
+ }
20
+
21
+ export function isLoopbackHost(host: BindHost): boolean {
22
+ const trimmed = trimHost(host);
23
+ if (trimmed === undefined) return false;
24
+ return (
25
+ trimmed === "localhost" ||
26
+ trimmed === "::1" ||
27
+ trimmed === "[::1]" ||
28
+ trimmed.startsWith("127.")
29
+ );
30
+ }
31
+
32
+ function bracketIpv6(host: string): string {
33
+ if (!host.includes(":")) return host;
34
+ if (host.startsWith("[") && host.endsWith("]")) return host;
35
+ return `[${host}]`;
36
+ }
37
+
38
+ export function urlForHost(port: number, host: BindHost): string {
39
+ const trimmed = trimHost(host);
40
+ if (trimmed === undefined || isWildcardHost(trimmed)) return localUrlForPort(port);
41
+ return `http://${bracketIpv6(trimmed)}:${String(port)}`;
42
+ }
43
+
44
+ export function probeHostForBindHost(host: BindHost): string {
45
+ const trimmed = trimHost(host);
46
+ if (trimmed === undefined || isWildcardHost(trimmed)) return "127.0.0.1";
47
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) return trimmed.slice(1, -1);
48
+ return trimmed;
49
+ }
50
+
51
+ export function isValidBindHost(host: string): boolean {
52
+ const trimmed = host.trim();
53
+ if (trimmed.length === 0) return false;
54
+ if (/^https?:\/\//i.test(trimmed)) return false;
55
+ if (/[\s/\\]/.test(trimmed)) return false;
56
+ return true;
57
+ }
58
+
59
+ function isUsableAddress(address: string): boolean {
60
+ return address !== "0.0.0.0" && !address.startsWith("169.254.");
61
+ }
62
+
63
+ function is172Private(address: string): boolean {
64
+ const parts = address.split(".");
65
+ const second = Number(parts[1] ?? "");
66
+ return parts[0] === "172" && Number.isInteger(second) && second >= 16 && second <= 31;
67
+ }
68
+
69
+ function addressScore(address: string): number {
70
+ if (address.startsWith("192.168.")) return 0;
71
+ if (address.startsWith("10.")) return 1;
72
+ if (is172Private(address)) return 2;
73
+ return 3;
74
+ }
75
+
76
+ export function collectNetworkUrls(
77
+ port: number,
78
+ interfaces: NetworkInterfaceMap = networkInterfaces(),
79
+ ): string[] {
80
+ const addresses = new Set<string>();
81
+
82
+ for (const entries of Object.values(interfaces)) {
83
+ if (entries === undefined) continue;
84
+ for (const entry of entries) {
85
+ if (entry.family === "IPv4" && !entry.internal && isUsableAddress(entry.address)) {
86
+ addresses.add(entry.address);
87
+ }
88
+ }
89
+ }
90
+
91
+ return [...addresses]
92
+ .sort((left, right) => {
93
+ const score = addressScore(left) - addressScore(right);
94
+ return score !== 0 ? score : left.localeCompare(right);
95
+ })
96
+ .slice(0, 4)
97
+ .map((address) => `http://${address}:${String(port)}`);
98
+ }
99
+
100
+ export function networkUrlsForBindHost(
101
+ port: number,
102
+ host: BindHost,
103
+ interfaces: NetworkInterfaceMap = networkInterfaces(),
104
+ ): string[] {
105
+ if (isLoopbackHost(host)) return [];
106
+ if (isWildcardHost(host)) return collectNetworkUrls(port, interfaces);
107
+ return [urlForHost(port, host)];
108
+ }
109
+
110
+ export function formatAccessHintLines(
111
+ port: number,
112
+ networkUrls: readonly string[],
113
+ host: BindHost = undefined,
114
+ ): string[] {
115
+ const localUrl = localUrlForPort(port);
116
+ const hostLabel = isWildcardHost(host) ? "all interfaces (0.0.0.0)" : (trimHost(host) ?? "");
117
+ const lines = ["Access URLs:", ` Bind host: ${hostLabel}`];
118
+
119
+ if (isLoopbackHost(host) || isWildcardHost(host)) {
120
+ lines.push(` Local UI: ${localUrl}`);
121
+ lines.push(` Local proxy: ${localUrl}/proxy`);
122
+ lines.push(` Local MCP: ${localUrl}/api/mcp`);
123
+ } else {
124
+ const boundUrl = urlForHost(port, host);
125
+ lines.push(` Bound UI: ${boundUrl}`);
126
+ lines.push(` Bound proxy: ${boundUrl}/proxy`);
127
+ lines.push(` Bound MCP: ${boundUrl}/api/mcp`);
128
+ lines.push(" Localhost: may not work because the server is bound to a specific host");
129
+ }
130
+
131
+ const firstNetworkUrl = networkUrls[0];
132
+ if (isLoopbackHost(host)) {
133
+ lines.push(" Network: disabled by loopback bind host");
134
+ } else if (firstNetworkUrl === undefined) {
135
+ lines.push(" Network: no non-loopback IPv4 address detected");
136
+ } else {
137
+ lines.push(` Network UI: ${firstNetworkUrl}`);
138
+ lines.push(` Network proxy: ${firstNetworkUrl}/proxy`);
139
+ const moreUrls = networkUrls.slice(1);
140
+ if (moreUrls.length > 0) {
141
+ lines.push(` Other IPs: ${moreUrls.join(", ")}`);
142
+ }
143
+ }
144
+
145
+ lines.push(
146
+ " Container note: if the coding agent is in another container or machine, replace localhost with a reachable host/IP and verify the port is reachable.",
147
+ );
148
+
149
+ return lines;
150
+ }
package/src/cli.ts CHANGED
@@ -4,6 +4,14 @@ import { createConnection } from "node:net";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { dirname, join, resolve as resolvePath } from "node:path";
6
6
  import { existsSync } from "node:fs";
7
+ import {
8
+ formatAccessHintLines,
9
+ isValidBindHost,
10
+ localUrlForPort,
11
+ networkUrlsForBindHost,
12
+ probeHostForBindHost,
13
+ urlForHost,
14
+ } from "./cli/networkHints.js";
7
15
 
8
16
  const __filename = fileURLToPath(import.meta.url);
9
17
  const __dirname = dirname(__filename);
@@ -43,11 +51,11 @@ await runStart(process.argv.slice(2));
43
51
  // Legacy `start` behavior — start the proxy on the configured port. Extracted
44
52
  // into a function so the router above can keep the top-level flow readable.
45
53
  // -----------------------------------------------------------------------------
46
- async function isInspectorHealthy(port: number): Promise<boolean> {
54
+ async function isInspectorHealthy(port: number, host?: string): Promise<boolean> {
47
55
  const controller = new AbortController();
48
56
  const timeout = setTimeout(() => controller.abort(), LOCAL_PROBE_TIMEOUT_MS);
49
57
  try {
50
- const response = await fetch(`http://127.0.0.1:${port}/api/health`, {
58
+ const response = await fetch(`${urlForHost(port, probeHostForBindHost(host))}/api/health`, {
51
59
  cache: "no-store",
52
60
  signal: controller.signal,
53
61
  });
@@ -59,11 +67,11 @@ async function isInspectorHealthy(port: number): Promise<boolean> {
59
67
  }
60
68
  }
61
69
 
62
- async function getRunningCaptureMode(port: number): Promise<CaptureMode | null> {
70
+ async function getRunningCaptureMode(port: number, host?: string): Promise<CaptureMode | null> {
63
71
  const controller = new AbortController();
64
72
  const timeout = setTimeout(() => controller.abort(), LOCAL_PROBE_TIMEOUT_MS);
65
73
  try {
66
- const response = await fetch(`http://127.0.0.1:${port}/api/config`, {
74
+ const response = await fetch(`${urlForHost(port, probeHostForBindHost(host))}/api/config`, {
67
75
  cache: "no-store",
68
76
  signal: controller.signal,
69
77
  });
@@ -77,9 +85,9 @@ async function getRunningCaptureMode(port: number): Promise<CaptureMode | null>
77
85
  }
78
86
  }
79
87
 
80
- function isPortAcceptingConnections(port: number): Promise<boolean> {
88
+ function isPortAcceptingConnections(port: number, host?: string): Promise<boolean> {
81
89
  return new Promise((resolve) => {
82
- const socket = createConnection({ host: "127.0.0.1", port });
90
+ const socket = createConnection({ host: probeHostForBindHost(host), port });
83
91
  const finish = (value: boolean): void => {
84
92
  socket.removeAllListeners();
85
93
  socket.destroy();
@@ -99,10 +107,14 @@ function sleep(ms: number): Promise<void> {
99
107
  });
100
108
  }
101
109
 
102
- async function waitForInspectorHealthy(port: number, timeoutMs: number): Promise<boolean> {
110
+ async function waitForInspectorHealthy(
111
+ port: number,
112
+ timeoutMs: number,
113
+ host?: string,
114
+ ): Promise<boolean> {
103
115
  const start = Date.now();
104
116
  while (Date.now() - start < timeoutMs) {
105
- if (await isInspectorHealthy(port)) return true;
117
+ if (await isInspectorHealthy(port, host)) return true;
106
118
  await sleep(250);
107
119
  }
108
120
  return false;
@@ -179,13 +191,22 @@ function readCaptureMode(raw: unknown): CaptureMode | null {
179
191
  return typeof value === "string" ? parseCaptureMode(value) : null;
180
192
  }
181
193
 
194
+ function printAccessHints(port: number, host?: string): void {
195
+ const networkUrls = networkUrlsForBindHost(port, host);
196
+ for (const line of formatAccessHintLines(port, networkUrls, host)) {
197
+ console.log(line);
198
+ }
199
+ }
200
+
182
201
  async function runStart(args: string[]): Promise<void> {
183
202
  const envPort = process.env["PORT"];
184
203
  const portDefault = envPort !== undefined ? Number(envPort) : DEFAULT_PORT;
204
+ const envHost = process.env["NITRO_HOST"] ?? process.env["HOST"];
185
205
  const envMode =
186
206
  process.env["AGENT_INSPECTOR_CAPTURE_MODE"] ?? process.env["AGENT_INSPECTOR_MODE"];
187
207
 
188
208
  let port = portDefault;
209
+ let host = envHost !== undefined && envHost.trim().length > 0 ? envHost.trim() : undefined;
189
210
  let open = true;
190
211
  let openWasSpecified = false;
191
212
  let background = false;
@@ -206,8 +227,24 @@ async function runStart(args: string[]): Promise<void> {
206
227
  captureModeWasSpecified = true;
207
228
  }
208
229
 
230
+ if (host !== undefined && !isValidBindHost(host)) {
231
+ console.error(`Invalid host: ${host}. Use an IP or hostname without protocol/path.`);
232
+ process.exitCode = 1;
233
+ return;
234
+ }
235
+
209
236
  for (let i = 0; i < args.length; i++) {
210
237
  const arg = args[i] ?? "";
238
+ if (arg.startsWith("--host=")) {
239
+ const value = arg.slice(arg.indexOf("=") + 1).trim();
240
+ if (!isValidBindHost(value)) {
241
+ console.error(`Invalid host: ${value}. Use an IP or hostname without protocol/path.`);
242
+ process.exitCode = 1;
243
+ return;
244
+ }
245
+ host = value;
246
+ continue;
247
+ }
211
248
  if (arg.startsWith("--mode=") || arg.startsWith("--capture-mode=")) {
212
249
  const value = arg.slice(arg.indexOf("=") + 1);
213
250
  const parsedMode = parseCaptureMode(value);
@@ -226,6 +263,18 @@ async function runStart(args: string[]): Promise<void> {
226
263
  port = Number(args[i + 1]);
227
264
  i++;
228
265
  break;
266
+ case "--host":
267
+ case "-H": {
268
+ const value = args[i + 1]?.trim() ?? "";
269
+ if (!isValidBindHost(value)) {
270
+ console.error(`Invalid host: ${value}. Use an IP or hostname without protocol/path.`);
271
+ process.exitCode = 1;
272
+ return;
273
+ }
274
+ host = value;
275
+ i++;
276
+ break;
277
+ }
229
278
  case "--no-open":
230
279
  open = false;
231
280
  openWasSpecified = true;
@@ -348,12 +397,13 @@ async function runStart(args: string[]): Promise<void> {
348
397
 
349
398
  process.env["PORT"] = String(port);
350
399
 
351
- const url = `http://localhost:${port}`;
400
+ const url = urlForHost(port, host);
352
401
 
353
- if (!forceRestart && (await isInspectorHealthy(port))) {
402
+ if (!forceRestart && (await isInspectorHealthy(port, host))) {
354
403
  console.log(`agent-inspector is already running at ${url}`);
404
+ printAccessHints(port, host);
355
405
  if (captureModeWasSpecified) {
356
- const runningMode = await getRunningCaptureMode(port);
406
+ const runningMode = await getRunningCaptureMode(port, host);
357
407
  if (runningMode !== null && runningMode !== captureMode) {
358
408
  console.log(`Existing instance capture mode is ${runningMode}; requested ${captureMode}.`);
359
409
  console.log(`Use --force-restart to restart with ${captureMode} mode.`);
@@ -366,7 +416,7 @@ async function runStart(args: string[]): Promise<void> {
366
416
  return;
367
417
  }
368
418
 
369
- if (!forceRestart && (await isPortAcceptingConnections(port))) {
419
+ if (!forceRestart && (await isPortAcceptingConnections(port, host))) {
370
420
  console.error(`Port ${port} is already in use, but it is not a healthy agent-inspector.`);
371
421
  console.error(`Stop that process, choose --port <n>, or re-run with --force-restart.`);
372
422
  process.exitCode = 1;
@@ -380,6 +430,7 @@ async function runStart(args: string[]): Promise<void> {
380
430
  console.log(`Server running at ${url}`);
381
431
  console.log(` Capture mode: ${captureMode}`);
382
432
  console.log(` Proxy: ${url}/proxy`);
433
+ printAccessHints(port, host);
383
434
  console.log(``);
384
435
  console.log(`Route AI coding tools through the proxy:`);
385
436
  console.log(` Claude Code: ANTHROPIC_BASE_URL=${url}/proxy claude`);
@@ -388,6 +439,9 @@ async function runStart(args: string[]): Promise<void> {
388
439
  console.log(
389
440
  ` Direct HTTP: curl ${url}/proxy/v1/messages -d '{"model":"...","messages":[...]}'`,
390
441
  );
442
+ console.log(
443
+ ` Remote/container tools: use the Network proxy URL above when shown; otherwise restart with --host 0.0.0.0 or a reachable IP.`,
444
+ );
391
445
  console.log(``);
392
446
  console.log(`Routing environment variables:`);
393
447
  console.log(` ROUTES JSON map of model prefix -> upstream URL`);
@@ -429,6 +483,10 @@ async function runStart(args: string[]): Promise<void> {
429
483
  if (providersJson !== undefined) {
430
484
  serverEnv["AGENT_INSPECTOR_PROVIDERS_JSON"] = providersJson;
431
485
  }
486
+ if (host !== undefined) {
487
+ serverEnv["HOST"] = host;
488
+ serverEnv["NITRO_HOST"] = host;
489
+ }
432
490
  serverEnv["AGENT_INSPECTOR_CAPTURE_MODE"] = captureMode;
433
491
  const serverProcess = spawn(serverCommand.command, serverCommand.args, {
434
492
  stdio: background ? ["ignore", "ignore", "ignore"] : "inherit",
@@ -439,8 +497,9 @@ async function runStart(args: string[]): Promise<void> {
439
497
 
440
498
  if (background) {
441
499
  serverProcess.unref();
442
- if (await waitForInspectorHealthy(port, 5000)) {
500
+ if (await waitForInspectorHealthy(port, 5000, host)) {
443
501
  console.log(`agent-inspector background server is ready at ${url}`);
502
+ printAccessHints(port, host);
444
503
  return;
445
504
  }
446
505
  console.error(`agent-inspector background server did not become ready at ${url}.`);
@@ -783,6 +783,9 @@ export function ProxyViewer({
783
783
  <Plus className="size-4 shrink-0 text-muted-foreground/70" aria-hidden="true" />
784
784
  <McpLogo className="size-10 shrink-0" />
785
785
  </span>
786
+ <span className="max-w-[calc(100vw-7rem)] text-sm font-semibold text-muted-foreground">
787
+ Local First. Evidence First.
788
+ </span>
786
789
  <McpReadyBadge />
787
790
  </h1>
788
791
  <div className="justify-self-end">
@@ -87,6 +87,16 @@ function hasHeaderDetails(headers: Record<string, string> | undefined): boolean
87
87
  return formatHeaders(headers) !== undefined;
88
88
  }
89
89
 
90
+ function formatElapsed(ms: number): string {
91
+ if (ms < 1000) return `${ms}ms`;
92
+ return `${(ms / 1000).toFixed(1)}s`;
93
+ }
94
+
95
+ function formatTokenRate(tokensPerSecond: number): string {
96
+ if (tokensPerSecond >= 10) return `${tokensPerSecond.toFixed(0)}/s`;
97
+ return `${tokensPerSecond.toFixed(1)}/s`;
98
+ }
99
+
90
100
  function hasDiagnosticDetails(result: TestResult): boolean {
91
101
  const debug = result.debug;
92
102
  return (
@@ -255,6 +265,7 @@ function TestStatus({ result }: { result: ProviderTestState }): JSX.Element {
255
265
  }
256
266
  if (result.success) {
257
267
  const tokenParts: ReactNode[] = [];
268
+ const timingParts: ReactNode[] = [];
258
269
  if (result.inputTokens !== undefined) {
259
270
  tokenParts.push(<span key="in">{result.inputTokens} in</span>);
260
271
  }
@@ -275,11 +286,29 @@ function TestStatus({ result }: { result: ProviderTestState }): JSX.Element {
275
286
  </span>,
276
287
  );
277
288
  }
289
+ if (result.firstChunkMs !== undefined && result.firstChunkMs !== null) {
290
+ timingParts.push(<span key="first">first {formatElapsed(result.firstChunkMs)}</span>);
291
+ }
292
+ if (result.totalStreamMs !== undefined && result.totalStreamMs !== null) {
293
+ timingParts.push(<span key="total">total {formatElapsed(result.totalStreamMs)}</span>);
294
+ }
295
+ if (
296
+ result.tokensPerSecond !== undefined &&
297
+ result.tokensPerSecond !== null &&
298
+ result.tokensPerSecond > 0
299
+ ) {
300
+ timingParts.push(<span key="rate">{formatTokenRate(result.tokensPerSecond)} tok/s</span>);
301
+ }
278
302
  const displayTokens: ReactNode[] = [];
279
303
  for (let i = 0; i < tokenParts.length; i++) {
280
304
  if (i > 0) displayTokens.push(", ");
281
305
  displayTokens.push(tokenParts[i]);
282
306
  }
307
+ const displayTiming: ReactNode[] = [];
308
+ for (let i = 0; i < timingParts.length; i++) {
309
+ if (i > 0) displayTiming.push(", ");
310
+ displayTiming.push(timingParts[i]);
311
+ }
283
312
  return (
284
313
  <TooltipProvider>
285
314
  <Tooltip>
@@ -290,6 +319,9 @@ function TestStatus({ result }: { result: ProviderTestState }): JSX.Element {
290
319
  {tokenParts.length > 0 && (
291
320
  <span className="text-muted-foreground">({displayTokens})</span>
292
321
  )}
322
+ {timingParts.length > 0 && (
323
+ <span className="hidden text-muted-foreground lg:inline">({displayTiming})</span>
324
+ )}
293
325
  </div>
294
326
  </TooltipTrigger>
295
327
  <TooltipContent>Connection test passed</TooltipContent>
@@ -11,6 +11,7 @@ import {
11
11
  Copy,
12
12
  FileDiff,
13
13
  FileTerminal,
14
+ Gauge,
14
15
  Globe,
15
16
  History,
16
17
  Loader2,
@@ -45,6 +46,11 @@ function formatElapsed(ms: number): string {
45
46
  return `${(ms / 1000).toFixed(1)}s`;
46
47
  }
47
48
 
49
+ function formatTokenRate(tokensPerSecond: number): string {
50
+ if (tokensPerSecond >= 10) return `${tokensPerSecond.toFixed(0)}/s`;
51
+ return `${tokensPerSecond.toFixed(1)}/s`;
52
+ }
53
+
48
54
  /**
49
55
  * Inline trend indicator: small arrow (green up / red down) plus the absolute
50
56
  * delta in compact form. Returns `null` when there is no trend to display.
@@ -148,6 +154,11 @@ export const LogEntryHeader = memo(function ({
148
154
  log.elapsedMs > slowResponseThresholdSeconds * 1000;
149
155
 
150
156
  const hasTokens = log.inputTokens !== null || log.outputTokens !== null;
157
+ const firstChunkMs = log.firstChunkMs ?? null;
158
+ const tokensPerSecond = log.tokensPerSecond ?? null;
159
+ const firstChunkLabel = firstChunkMs === null ? null : formatElapsed(firstChunkMs);
160
+ const tokenRateLabel =
161
+ tokensPerSecond === null || tokensPerSecond <= 0 ? null : formatTokenRate(tokensPerSecond);
151
162
 
152
163
  return (
153
164
  <TooltipProvider>
@@ -238,11 +249,37 @@ export const LogEntryHeader = memo(function ({
238
249
  ? `Slow response: ${formatElapsed(log.elapsedMs)} exceeds ${formatElapsed(
239
250
  slowResponseThresholdSeconds * 1000,
240
251
  )}`
241
- : "Elapsed response time"}
252
+ : log.streaming
253
+ ? "Total streaming response time"
254
+ : "Elapsed response time"}
242
255
  </TooltipContent>
243
256
  </Tooltip>
244
257
  )}
245
258
 
259
+ {/* Streaming timing */}
260
+ {log.streaming && firstChunkLabel !== null && (
261
+ <Tooltip>
262
+ <TooltipTrigger asChild>
263
+ <span className="flex items-center gap-1 text-xs text-cyan-400 shrink-0">
264
+ <Radio className="size-3" />
265
+ <span className="font-mono tabular-nums">first {firstChunkLabel}</span>
266
+ </span>
267
+ </TooltipTrigger>
268
+ <TooltipContent>Time to first streaming chunk</TooltipContent>
269
+ </Tooltip>
270
+ )}
271
+ {log.streaming && tokenRateLabel !== null && (
272
+ <Tooltip>
273
+ <TooltipTrigger asChild>
274
+ <span className="flex items-center gap-1 text-xs text-emerald-400 shrink-0">
275
+ <Gauge className="size-3" />
276
+ <span className="font-mono tabular-nums">{tokenRateLabel}</span>
277
+ </span>
278
+ </TooltipTrigger>
279
+ <TooltipContent>Output token throughput after first chunk</TooltipContent>
280
+ </Tooltip>
281
+ )}
282
+
246
283
  {/* Token counts */}
247
284
  {hasTokens && (
248
285
  <span className="flex items-center gap-1 text-xs shrink-0">