@tonyclaw/agent-inspector 3.0.7 → 3.0.9

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 (73) hide show
  1. package/.output/cli.js +245 -171
  2. package/.output/nitro.json +1 -1
  3. package/.output/public/assets/{CompareDrawer-C0Kbsm-Q.js → CompareDrawer-CJKL4LMm.js} +1 -1
  4. package/.output/public/assets/ProxyViewerContainer-DBUy29O-.js +106 -0
  5. package/.output/public/assets/ReplayDialog-BYanl-a_.js +1 -0
  6. package/.output/public/assets/{RequestAnatomy-fa0JXy5p.js → RequestAnatomy-DvPz1jqE.js} +1 -1
  7. package/.output/public/assets/ResponseView-sZnxp9GN.js +3 -0
  8. package/.output/public/assets/StreamingChunkSequence-G-tiYiBd.js +1 -0
  9. package/.output/public/assets/{_sessionId-D5fYH2C0.js → _sessionId-_hDcsmXq.js} +1 -1
  10. package/.output/public/assets/agent-inspector-58K1_MsC.ico +0 -0
  11. package/.output/public/assets/index-DAPAWsAd.js +1 -0
  12. package/.output/public/assets/index-DspnLqUW.css +1 -0
  13. package/.output/public/assets/{index-BEsqvywM.js → index-SuOSQb2_.js} +1 -1
  14. package/.output/public/assets/{json-viewer-B0QRbMSf.js → json-viewer-yQdhgjaw.js} +1 -1
  15. package/.output/public/assets/{main-CuKZD7n0.js → main-CgjcDxjR.js} +2 -2
  16. package/.output/server/_libs/radix-ui__react-dialog.mjs +2 -2
  17. package/.output/server/{_sessionId-DdBTK3qm.mjs → _sessionId-BGISf2En.mjs} +3 -3
  18. package/.output/server/_ssr/{CompareDrawer-m82cVZ73.mjs → CompareDrawer-s-UF9gW3.mjs} +2 -2
  19. package/.output/server/_ssr/{ProxyViewerContainer-7geuf78f.mjs → ProxyViewerContainer-C0Sbz9ED.mjs} +1033 -530
  20. package/.output/server/_ssr/{ReplayDialog-B8RlWLLH.mjs → ReplayDialog-DvS8gvJr.mjs} +58 -23
  21. package/.output/server/_ssr/{RequestAnatomy-BE7pmwpv.mjs → RequestAnatomy-BwhWglJL.mjs} +2 -2
  22. package/.output/server/_ssr/{ResponseView-D-sDsdQr.mjs → ResponseView-wBVreqQf.mjs} +2 -2
  23. package/.output/server/_ssr/{StreamingChunkSequence-DU_hThJr.mjs → StreamingChunkSequence-Dr6eg-MJ.mjs} +30 -20
  24. package/.output/server/_ssr/{index-D0sV8UbE.mjs → index-B98Wia1X.mjs} +2 -2
  25. package/.output/server/_ssr/index.mjs +2 -2
  26. package/.output/server/_ssr/{json-viewer-CaDh1zrw.mjs → json-viewer-D7L8y0FH.mjs} +2 -2
  27. package/.output/server/_ssr/{router-Zh2iZ_u0.mjs → router-g9mo0nWT.mjs} +459 -196
  28. package/.output/server/_tanstack-start-manifest_v-CbGc-o50.mjs +4 -0
  29. package/.output/server/index.mjs +76 -69
  30. package/package.json +1 -1
  31. package/src/assets/IDE/opencode.svg +1 -1
  32. package/src/assets/IDE/xiaomimimo.svg +1 -1
  33. package/src/cli/detect-tools.ts +2 -27
  34. package/src/cli/templates/codex-skill-onboard.ts +1 -1
  35. package/src/cli/templates/skill-onboard.ts +2 -3
  36. package/src/cli.ts +9 -86
  37. package/src/components/ProxyViewer.tsx +249 -37
  38. package/src/components/ProxyViewerContainer.tsx +167 -22
  39. package/src/components/clients/ClientLogo.tsx +38 -3
  40. package/src/components/ecosystem/AgentLabDialog.tsx +204 -3
  41. package/src/components/groups/GroupsDialog.tsx +28 -40
  42. package/src/components/providers/ImportWizardDialog.tsx +51 -101
  43. package/src/components/providers/ProvidersPanel.tsx +82 -68
  44. package/src/components/providers/SettingsDialog.tsx +11 -12
  45. package/src/components/proxy-viewer/AgentTraceSummary.tsx +15 -23
  46. package/src/components/proxy-viewer/ReplayDialog.tsx +65 -22
  47. package/src/components/proxy-viewer/StreamingChunkSequence.tsx +33 -20
  48. package/src/components/proxy-viewer/TurnGroup.tsx +0 -16
  49. package/src/components/proxy-viewer/log-formats/anthropic.ts +5 -5
  50. package/src/components/proxy-viewer/log-formats/openai.ts +7 -10
  51. package/src/components/proxy-viewer/requestTools.ts +5 -2
  52. package/src/lib/apiClient.ts +43 -10
  53. package/src/lib/ecosystemContract.ts +32 -0
  54. package/src/lib/export-logs.ts +37 -3
  55. package/src/proxy/ecosystemRegistry.ts +244 -0
  56. package/src/proxy/ecosystemTasks.ts +153 -0
  57. package/src/proxy/platformCommands.ts +212 -0
  58. package/src/proxy/socketTracker.ts +84 -143
  59. package/src/routes/api/ecosystem.packages.$packageId.help.ts +16 -0
  60. package/src/routes/api/ecosystem.packages.$packageId.install.ts +16 -0
  61. package/src/routes/api/ecosystem.packages.$packageId.runner-presets.ts +19 -0
  62. package/src/routes/api/ecosystem.packages.$packageId.upgrade.ts +16 -0
  63. package/src/routes/api/ecosystem.packages.ts +1 -248
  64. package/src/routes/api/ecosystem.tasks.$taskId.ts +16 -0
  65. package/src/routes/api/ecosystem.tasks.ts +12 -0
  66. package/src/routes/api/providers.$providerId.test.log.ts +28 -24
  67. package/.output/public/assets/ProxyViewerContainer-p-rpYyiF.js +0 -106
  68. package/.output/public/assets/ReplayDialog-CM3dhP8v.js +0 -1
  69. package/.output/public/assets/ResponseView-Bq6sC7Ay.js +0 -3
  70. package/.output/public/assets/StreamingChunkSequence-CGOun3Df.js +0 -1
  71. package/.output/public/assets/index-C29Olfzt.js +0 -1
  72. package/.output/public/assets/index-DgANE0r3.css +0 -1
  73. package/.output/server/_tanstack-start-manifest_v-LCE-2AaF.mjs +0 -4
@@ -0,0 +1,212 @@
1
+ import { statSync } from "node:fs";
2
+ import { execFileSync } from "node:child_process";
3
+ import { delimiter, isAbsolute, join } from "node:path";
4
+
5
+ export type PlatformName = typeof process.platform;
6
+ type PathEnv = Record<string, string | undefined>;
7
+
8
+ export type CommandSpec = {
9
+ command: string;
10
+ args: string[];
11
+ };
12
+
13
+ export function platformCommand(
14
+ command: string,
15
+ args: readonly string[],
16
+ platform: PlatformName = process.platform,
17
+ ): CommandSpec {
18
+ if (platform !== "win32") return { command, args: [...args] };
19
+ return { command: "cmd.exe", args: ["/d", "/s", "/c", command, ...args] };
20
+ }
21
+
22
+ export function npmCommand(platform: PlatformName = process.platform): string {
23
+ return platform === "win32" ? "npm.cmd" : "npm";
24
+ }
25
+
26
+ export function npmSpawnCommand(
27
+ args: readonly string[],
28
+ platform: PlatformName = process.platform,
29
+ ): CommandSpec {
30
+ return platformCommand(npmCommand(platform), args, platform);
31
+ }
32
+
33
+ function pathEntries(envPath: string | undefined): string[] {
34
+ if (envPath === undefined || envPath.length === 0) return [];
35
+ return envPath.split(delimiter).filter((entry) => entry.length > 0);
36
+ }
37
+
38
+ function windowsExecutableExtensions(envPathExt: string | undefined): string[] {
39
+ const defaults = [".COM", ".EXE", ".BAT", ".CMD"];
40
+ if (envPathExt === undefined || envPathExt.trim().length === 0) return defaults;
41
+ const parsed = envPathExt
42
+ .split(";")
43
+ .map((entry) => entry.trim())
44
+ .filter((entry) => entry.length > 0);
45
+ return parsed.length > 0 ? parsed : defaults;
46
+ }
47
+
48
+ function isExecutableCandidate(path: string, platform: PlatformName): boolean {
49
+ try {
50
+ const stat = statSync(path);
51
+ if (!stat.isFile()) return false;
52
+ if (platform === "win32" || process.platform === "win32") return true;
53
+ return (stat.mode & 0o111) !== 0;
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+
59
+ function executableCandidates(bin: string, platform: PlatformName, env: PathEnv): string[] {
60
+ if (isAbsolute(bin)) return [bin];
61
+ const entries = pathEntries(env["PATH"] ?? env["Path"] ?? env["path"]);
62
+ if (platform !== "win32") return entries.map((entry) => join(entry, bin));
63
+
64
+ const hasExtension = /\.[^\\/]+$/.test(bin);
65
+ const extensions = hasExtension ? [""] : windowsExecutableExtensions(env["PATHEXT"]);
66
+ const candidates: string[] = [];
67
+ for (const entry of entries) {
68
+ for (const extension of extensions) {
69
+ candidates.push(join(entry, `${bin}${extension.toLowerCase()}`));
70
+ candidates.push(join(entry, `${bin}${extension.toUpperCase()}`));
71
+ }
72
+ }
73
+ return candidates;
74
+ }
75
+
76
+ export function resolveExecutableOnPath(
77
+ bin: string,
78
+ platform: PlatformName = process.platform,
79
+ env: PathEnv = process.env,
80
+ ): string | null {
81
+ if (bin.trim().length === 0) return null;
82
+ for (const candidate of executableCandidates(bin, platform, env)) {
83
+ if (isExecutableCandidate(candidate, platform)) return candidate;
84
+ }
85
+ return null;
86
+ }
87
+
88
+ export function openUrlCommand(
89
+ targetUrl: string,
90
+ platform: PlatformName = process.platform,
91
+ ): CommandSpec | null {
92
+ switch (platform) {
93
+ case "darwin":
94
+ return { command: "open", args: [targetUrl] };
95
+ case "linux":
96
+ case "freebsd":
97
+ case "openbsd":
98
+ case "netbsd":
99
+ return { command: "xdg-open", args: [targetUrl] };
100
+ case "win32":
101
+ return { command: "cmd.exe", args: ["/d", "/s", "/c", "start", "", targetUrl] };
102
+ case "aix":
103
+ case "android":
104
+ case "cygwin":
105
+ case "haiku":
106
+ case "sunos":
107
+ return null;
108
+ }
109
+
110
+ return null;
111
+ }
112
+
113
+ function parseWindowsNetstatPids(output: string, targetPort: number): number[] {
114
+ const pids = new Set<number>();
115
+ const targetToken = `:${targetPort}`;
116
+ for (const line of output.split(/\r?\n/)) {
117
+ const parts = line.trim().split(/\s+/);
118
+ if (parts.length < 5) continue;
119
+ const localAddress = parts[1] ?? "";
120
+ const pidStr = parts[4] ?? "";
121
+ if (!localAddress.endsWith(targetToken)) continue;
122
+ const pid = Number.parseInt(pidStr, 10);
123
+ if (Number.isInteger(pid) && pid > 0) pids.add(pid);
124
+ }
125
+ return [...pids];
126
+ }
127
+
128
+ function parseUnixLsofPids(output: string): number[] {
129
+ const pids = new Set<number>();
130
+ for (const line of output.split(/\r?\n/)) {
131
+ const pid = Number.parseInt(line.trim(), 10);
132
+ if (Number.isInteger(pid) && pid > 0) pids.add(pid);
133
+ }
134
+ return [...pids];
135
+ }
136
+
137
+ function parseUnixSsPids(output: string): number[] {
138
+ const pids = new Set<number>();
139
+ for (const match of output.matchAll(/pid=(\d+)/g)) {
140
+ const pid = Number.parseInt(match[1] ?? "", 10);
141
+ if (Number.isInteger(pid) && pid > 0) pids.add(pid);
142
+ }
143
+ return [...pids];
144
+ }
145
+
146
+ export function findPidsByPort(
147
+ targetPort: number,
148
+ platform: PlatformName = process.platform,
149
+ ): number[] {
150
+ if (!Number.isInteger(targetPort) || targetPort <= 0 || targetPort > 65_535) return [];
151
+ try {
152
+ if (platform === "win32") {
153
+ const output = execFileSync("netstat.exe", ["-ano", "-p", "TCP"], {
154
+ encoding: "utf8",
155
+ timeout: 5000,
156
+ windowsHide: true,
157
+ });
158
+ return parseWindowsNetstatPids(output, targetPort);
159
+ }
160
+
161
+ try {
162
+ const output = execFileSync("lsof", [`-ti:${String(targetPort)}`], {
163
+ encoding: "utf8",
164
+ timeout: 5000,
165
+ stdio: ["ignore", "pipe", "ignore"],
166
+ });
167
+ const pids = parseUnixLsofPids(output);
168
+ if (pids.length > 0) return pids;
169
+ } catch {
170
+ // Fall back to ss below.
171
+ }
172
+
173
+ const output = execFileSync("ss", ["-tanp"], {
174
+ encoding: "utf8",
175
+ timeout: 5000,
176
+ stdio: ["ignore", "pipe", "ignore"],
177
+ });
178
+ return parseUnixSsPids(
179
+ output
180
+ .split(/\r?\n/)
181
+ .filter((line) => line.includes(`:${String(targetPort)}`))
182
+ .join("\n"),
183
+ );
184
+ } catch {
185
+ return [];
186
+ }
187
+ }
188
+
189
+ export function killPid(pid: number, platform: PlatformName = process.platform): boolean {
190
+ if (!Number.isInteger(pid) || pid <= 0) return false;
191
+ try {
192
+ if (platform === "win32") {
193
+ execFileSync("taskkill.exe", ["/PID", String(pid), "/F"], {
194
+ encoding: "utf8",
195
+ timeout: 5000,
196
+ windowsHide: true,
197
+ stdio: ["ignore", "pipe", "ignore"],
198
+ });
199
+ return true;
200
+ }
201
+ process.kill(pid, "SIGKILL");
202
+ return true;
203
+ } catch {
204
+ return false;
205
+ }
206
+ }
207
+
208
+ export const platformCommandParsersForTests = {
209
+ parseUnixLsofPids,
210
+ parseUnixSsPids,
211
+ parseWindowsNetstatPids,
212
+ };
@@ -1,9 +1,9 @@
1
- import { exec, execFile } from "node:child_process";
1
+ import { execFile } from "node:child_process";
2
+ import { readFile } from "node:fs/promises";
2
3
  import { promisify } from "node:util";
3
4
  import { z } from "zod";
4
5
  import { logger } from "./logger";
5
6
 
6
- const execAsync = promisify(exec);
7
7
  const execFileAsync = promisify(execFile);
8
8
 
9
9
  type ClientInfo = {
@@ -102,7 +102,7 @@ function setCache(port: number, info: ClientInfo): void {
102
102
  * 2. Bun-style: `request.socket` accessor.
103
103
  * 3. Direct `socket` / `connection` properties on the request (legacy Node
104
104
  * IncomingMessage-style wrapping).
105
- * Returns `null` when nothing matches the caller will then leave `pid`
105
+ * Returns `null` when nothing matches 鈥?the caller will then leave `pid`
106
106
  * (and downstream `clientPid`) as `null`.
107
107
  */
108
108
  export function extractRemotePort(request: Request): number | null {
@@ -154,90 +154,6 @@ function pick(value: unknown, key: string): unknown {
154
154
  return value[key];
155
155
  }
156
156
 
157
- /**
158
- * Combined lookup: PID from port, then process info from PID.
159
- * On Windows, uses a single PowerShell call to get PID + command line.
160
- */
161
- async function lookupClientInfo(port: number): Promise<ClientInfo> {
162
- const platform = process.platform;
163
-
164
- try {
165
- if (platform === "win32") {
166
- // Single PowerShell call: get PID and command line in one shot
167
- const psScript = [
168
- `$conn = Get-NetTCPConnection -LocalPort ${port} -State Established -ErrorAction SilentlyContinue | Select-Object -First 1`,
169
- `if ($conn) {`,
170
- ` $pid = $conn.OwningProcess`,
171
- ` $proc = Get-CimInstance Win32_Process -Filter "ProcessId=$pid" -ErrorAction SilentlyContinue`,
172
- ` $cmd = if ($proc) { $proc.CommandLine } else { "" }`,
173
- ` Write-Output "$pid|$cmd"`,
174
- `}`,
175
- ].join("; ");
176
- const { stdout } = await execFileAsync(
177
- "powershell.exe",
178
- ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", psScript],
179
- {
180
- windowsHide: true,
181
- timeout: 3000,
182
- maxBuffer: 64 * 1024,
183
- },
184
- );
185
- const trimmed = stdout.trim();
186
- if (trimmed === "") {
187
- return { port, pid: null, cwd: null, projectFolder: null };
188
- }
189
- const [pidStr, ...cmdParts] = trimmed.split("|");
190
- const pid = parseInt(pidStr ?? "", 10);
191
- if (isNaN(pid) || pid <= 0) {
192
- return { port, pid: null, cwd: null, projectFolder: null };
193
- }
194
- const commandLine = cmdParts.join("|").trim();
195
- const { cwd, projectFolder } = parseCommandLine(commandLine, "\\");
196
- return { port, pid, cwd, projectFolder };
197
- }
198
-
199
- // Unix: two-step lookup (PID from port, then process info from PID)
200
- let pid: number | null = null;
201
-
202
- if (platform === "darwin") {
203
- const { stdout } = await execAsync(`lsof -i :${port} -sTCP:ESTABLISHED -t 2>/dev/null`, {
204
- shell: "/bin/sh",
205
- });
206
- const parsed = parseInt(stdout.trim(), 10);
207
- if (!isNaN(parsed) && parsed > 0) pid = parsed;
208
- } else {
209
- try {
210
- const { stdout } = await execAsync(
211
- `ss -tlnp 'sport = :${port}' 2>/dev/null | grep ESTAB | awk '{print $6}' | grep -o 'pid=[0-9]*' | cut -d= -f2`,
212
- { shell: "/bin/sh" },
213
- );
214
- const parsed = parseInt(stdout.trim(), 10);
215
- if (!isNaN(parsed) && parsed > 0) pid = parsed;
216
- } catch {
217
- // Fall back to netstat
218
- }
219
- if (pid === null) {
220
- const { stdout } = await execAsync(
221
- `netstat -tan 2>/dev/null | grep ':${port}' | grep ESTABLISHED | awk '{print $7}' | cut -d/ -f1`,
222
- { shell: "/bin/sh" },
223
- );
224
- const parsed = parseInt(stdout.trim(), 10);
225
- if (!isNaN(parsed) && parsed > 0) pid = parsed;
226
- }
227
- }
228
-
229
- if (pid === null) {
230
- return { port, pid: null, cwd: null, projectFolder: null };
231
- }
232
-
233
- const { cwd, projectFolder } = await lookupProcessInfo(pid);
234
- return { port, pid, cwd, projectFolder };
235
- } catch (err) {
236
- logger.debug(`[socketTracker] Failed to lookup info for port ${port}:`, String(err));
237
- return { port, pid: null, cwd: null, projectFolder: null };
238
- }
239
- }
240
-
241
157
  function parseCommandLine(
242
158
  commandLine: string,
243
159
  sep: string,
@@ -265,30 +181,25 @@ async function lookupProcessInfo(
265
181
 
266
182
  try {
267
183
  if (platform === "win32") {
184
+ const psScript = [
185
+ `$proc = Get-CimInstance Win32_Process -Filter "ProcessId=${pid}" -ErrorAction SilentlyContinue`,
186
+ `if ($proc) { Write-Output $proc.CommandLine }`,
187
+ ].join("; ");
268
188
  const { stdout } = await execFileAsync(
269
- "wmic.exe",
270
- ["process", "where", `processid=${pid}`, "get", "commandline", "/value"],
189
+ "powershell.exe",
190
+ ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", psScript],
271
191
  {
272
192
  windowsHide: true,
273
193
  timeout: 3000,
274
194
  maxBuffer: 64 * 1024,
275
195
  },
276
196
  );
277
- const lines = stdout.trim().split("\n").filter(Boolean);
278
- for (const line of lines) {
279
- if (line.includes("=")) {
280
- const commandLine = (line.split("=")[1] ?? "").trim();
281
- if (commandLine) {
282
- return parseCommandLine(commandLine, "\\");
283
- }
284
- }
285
- }
197
+ const commandLine = stdout.trim();
198
+ if (commandLine.length > 0) return parseCommandLine(commandLine, "\\");
286
199
  } else {
287
200
  try {
288
- const { stdout } = await execAsync(`cat /proc/${pid}/cmdline 2>/dev/null | tr '\\0' ' '`, {
289
- shell: "/bin/sh",
290
- });
291
- const commandLine = stdout.trim();
201
+ const raw = await readFile(`/proc/${String(pid)}/cmdline`, "utf8");
202
+ const commandLine = raw.replace(/\0/g, " ").trim();
292
203
  if (commandLine) {
293
204
  return parseCommandLine(commandLine, "/");
294
205
  }
@@ -297,8 +208,9 @@ async function lookupProcessInfo(
297
208
  }
298
209
 
299
210
  if (platform === "darwin") {
300
- const { stdout } = await execAsync(`ps -p ${pid} -o comm= 2>/dev/null`, {
301
- shell: "/bin/sh",
211
+ const { stdout } = await execFileAsync("ps", ["-p", String(pid), "-o", "comm="], {
212
+ timeout: 3000,
213
+ maxBuffer: 64 * 1024,
302
214
  });
303
215
  const exePath = stdout.trim();
304
216
  if (exePath) {
@@ -313,7 +225,7 @@ async function lookupProcessInfo(
313
225
  }
314
226
 
315
227
  /**
316
- * Scan the OS connection table for the freshest client→proxy connection and
228
+ * Scan the OS connection table for the freshest client鈫抪roxy connection and
317
229
  * return the matching PID plus its cwd/projectFolder.
318
230
  */
319
231
  async function lookupRecentClient(): Promise<ClientInfo> {
@@ -346,16 +258,9 @@ async function lookupRecentClient(): Promise<ClientInfo> {
346
258
  // Unix: netstat with -p prints `pid/program` next to each entry. Pick
347
259
  // the row matching our proxy port on the remote side (i.e. the client's
348
260
  // ephemeral port + the proxy's listen port). When multiple clients are
349
- // connected we take the highest ephemeral port Linux/macOS assign
261
+ // connected we take the highest ephemeral port 鈥?Linux/macOS assign
350
262
  // ports in ascending order, so the largest one is the freshest.
351
- const { stdout } = await execAsync(
352
- `netstat -tanp 2>/dev/null | grep ':${port}' | grep ESTAB || ss -tanp 2>/dev/null | grep ':${port}' | grep ESTAB`,
353
- { shell: "/bin/sh" },
354
- );
355
- const lines = stdout
356
- .trim()
357
- .split("\n")
358
- .filter((line) => line.includes("ESTAB"));
263
+ const lines = await readUnixEstablishedConnectionLines(port);
359
264
  const clientPid = pickNewestClientPidFromNetstat(lines, port);
360
265
  if (clientPid === null) {
361
266
  return { port: null, pid: null, cwd: null, projectFolder: null };
@@ -378,14 +283,14 @@ function pickNewestClientPidFromNetstat(lines: string[], proxyPort: number): num
378
283
  for (const line of lines) {
379
284
  order++;
380
285
  const cols = line.trim().split(/\s+/);
381
- if (cols.length < 7) continue;
286
+ if (cols.length < 5) continue;
382
287
  const localAddr = cols[3] ?? "";
383
288
  const remoteAddr = cols[4] ?? "";
384
289
  const pidField = cols[6] ?? "";
385
290
  // Client-side rows: `RemoteAddress` (5th col) is the proxy's listen port;
386
291
  // `LocalAddress` (4th col) is the client's ephemeral port.
387
292
  if (!remoteAddr.endsWith(proxyToken)) continue;
388
- const pidMatch = pidField.match(/^(\d+)/);
293
+ const pidMatch = pidField.match(/^(\d+)/) ?? line.match(/pid=(\d+)/);
389
294
  if (pidMatch === null) continue;
390
295
  const pid = parseInt(pidMatch[1] ?? "", 10);
391
296
  if (!Number.isInteger(pid) || pid <= 0) continue;
@@ -398,6 +303,30 @@ function pickNewestClientPidFromNetstat(lines: string[], proxyPort: number): num
398
303
  return candidates[0]?.pid ?? null;
399
304
  }
400
305
 
306
+ async function readUnixEstablishedConnectionLines(port: number): Promise<string[]> {
307
+ const commands = [
308
+ { command: "netstat", args: ["-tanp"] },
309
+ { command: "ss", args: ["-tanp"] },
310
+ ];
311
+ for (const spec of commands) {
312
+ try {
313
+ const { stdout } = await execFileAsync(spec.command, spec.args, {
314
+ timeout: 2000,
315
+ maxBuffer: 2 * 1024 * 1024,
316
+ });
317
+ const portToken = `:${port}`;
318
+ const lines = stdout
319
+ .trim()
320
+ .split("\n")
321
+ .filter((line) => line.includes(portToken) && line.includes("ESTAB"));
322
+ if (lines.length > 0) return lines;
323
+ } catch {
324
+ // Try the next socket table command.
325
+ }
326
+ }
327
+ return [];
328
+ }
329
+
401
330
  function parseAddressPort(address: string): number | null {
402
331
  const match = address.match(/:(\d+)$/);
403
332
  if (match === null) return null;
@@ -448,6 +377,13 @@ export function _pickNewestClientFromWindowsNetstatForTests(
448
377
  return pickNewestClientFromWindowsNetstat(lines, proxyPort);
449
378
  }
450
379
 
380
+ export function _pickNewestClientPidFromNetstatForTests(
381
+ lines: string[],
382
+ proxyPort: number,
383
+ ): number | null {
384
+ return pickNewestClientPidFromNetstat(lines, proxyPort);
385
+ }
386
+
451
387
  /** Custom request headers the client (or a wrapper script) may set so we
452
388
  * can identify the originating process without scanning the OS connection
453
389
  * table. Recognized names:
@@ -479,7 +415,7 @@ function readIntHeader(headers: Headers, name: string): number | null {
479
415
  }
480
416
 
481
417
  /** Read client-supplied headers. Returns `null` when no PID header is
482
- * present caller falls back to OS scanning. */
418
+ * present 鈥?caller falls back to OS scanning. */
483
419
  function readClientHeaders(request: Request): ClientInfo | null {
484
420
  const pid = readIntHeader(request.headers, CLIENT_HEADERS.pid);
485
421
  if (pid === null || pid === 0) return null;
@@ -502,7 +438,7 @@ function readClientHeaders(request: Request): ClientInfo | null {
502
438
  *
503
439
  * Strategy: enumerate the OS-level TCP connections to our proxy's listen
504
440
  * port and pick the most recently established client-side connection. This
505
- * is essentially "who is currently talking to us" for a session with many
441
+ * is essentially "who is currently talking to us" 鈥?for a session with many
506
442
  * rapid-fire requests the same client holds a long-lived connection, so
507
443
  * consecutive lookups within the cache TTL converge on the same PID.
508
444
  *
@@ -513,8 +449,8 @@ function readClientHeaders(request: Request): ClientInfo | null {
513
449
  export async function getClientInfo(request: Request): Promise<ClientInfo> {
514
450
  // Preferred path: explicit client identity in headers. AI coding tools
515
451
  // (or the wrapper scripts that launch them) attach
516
- // `X-Agent-Inspector-Client-Pid` (and optionally …-Cwd /
517
- // …-Project-Folder / …-Port) so we never have to guess.
452
+ // `X-Agent-Inspector-Client-Pid` (and optionally 鈥?Cwd /
453
+ // 鈥?Project-Folder / 鈥?Port) so we never have to guess.
518
454
  const fromHeaders = readClientHeaders(request);
519
455
  if (fromHeaders !== null) return fromHeaders;
520
456
 
@@ -550,7 +486,7 @@ export async function getClientInfo(request: Request): Promise<ClientInfo> {
550
486
  }
551
487
 
552
488
  // Fallback: scan the OS connection table for the most recently opened
553
- // client→proxy connection. Best-effort in environments with many
489
+ // client鈫抪roxy connection. Best-effort 鈥?in environments with many
554
490
  // long-lived keep-alive connections (browsers, background processes) the
555
491
  // picked connection may not belong to the current request.
556
492
  const cached = getRecentClientFromCache();
@@ -582,7 +518,7 @@ export async function getClientInfo(request: Request): Promise<ClientInfo> {
582
518
  * `req.socket.remotePort` (the client's ephemeral port) and needs to know
583
519
  * which process opened the connection.
584
520
  *
585
- * Results are cached by `clientPort` for `PORT_LOOKUP_TTL_MS` a single
521
+ * Results are cached by `clientPort` for `PORT_LOOKUP_TTL_MS` 鈥?a single
586
522
  * keep-alive connection typically reuses the same client port for many
587
523
  * requests, so we don't want to spawn a subprocess per request.
588
524
  */
@@ -626,27 +562,24 @@ async function lookupPidByLocalPort(clientPort: number): Promise<number | null>
626
562
  const platform = process.platform;
627
563
  try {
628
564
  if (platform === "win32") {
629
- // We deliberately use `netstat -ano | findstr` instead of
630
- // `Get-NetTCPConnection -LocalPort`. The PowerShell cmdlet
631
- // enumerates the entire socket table before filtering, which on a
632
- // busy machine takes 10+ seconds — longer than our per-request
633
- // timeout. `netstat -ano` streams its output and `findstr` filters
634
- // line by line, completing in ~0.5s.
565
+ // `Get-NetTCPConnection -LocalPort` can enumerate the entire socket table
566
+ // before filtering on busy machines. `netstat.exe` streams quickly and
567
+ // avoids PowerShell process startup for this hot lookup path.
635
568
  //
636
- // `netstat -ano` lists every TCP connection twice once for each
637
- // endpoint. The format is:
569
+ // `netstat -ano` lists every TCP connection twice: once for each endpoint.
638
570
  //
639
571
  // Proto LocalAddress RemoteAddress State PID
640
- // TCP [::1]:25949 [::1]:55193 ESTABLISHED 10936 server-side row (proxy owns the listener)
641
- // TCP [::1]:55193 [::1]:25949 ESTABLISHED 29356 client-side row (curl owns the ephemeral port)
572
+ // TCP [::1]:25949 [::1]:55193 ESTABLISHED 10936 鈫?server-side row (proxy owns the listener)
573
+ // TCP [::1]:55193 [::1]:25949 ESTABLISHED 29356 鈫?client-side row (curl owns the ephemeral port)
642
574
  //
643
575
  // For `clientPort` (the client's ephemeral port) we want the row
644
- // whose LOCAL column ends with `:<clientPort>` that's the row
576
+ // whose LOCAL column ends with `:<clientPort>` 鈥?that's the row
645
577
  // attributed to the client process.
646
- const { stdout } = await execAsync(
647
- `netstat -ano | findstr :${clientPort} | findstr ESTABLISHED`,
648
- { shell: "cmd.exe", timeout: 2000, windowsHide: true },
649
- );
578
+ const { stdout } = await execFileAsync("netstat.exe", ["-ano", "-p", "TCP"], {
579
+ windowsHide: true,
580
+ timeout: 2000,
581
+ maxBuffer: 2 * 1024 * 1024,
582
+ });
650
583
  const clientToken = `:${clientPort}`;
651
584
  for (const rawLine of stdout.split("\n")) {
652
585
  const line = rawLine.trim();
@@ -654,6 +587,8 @@ async function lookupPidByLocalPort(clientPort: number): Promise<number | null>
654
587
  const cols = line.split(/\s+/);
655
588
  if (cols.length < 5) continue;
656
589
  const localAddr = cols[1] ?? "";
590
+ const state = cols[3] ?? "";
591
+ if (state !== "ESTABLISHED") continue;
657
592
  if (!localAddr.endsWith(clientToken)) continue;
658
593
  const pidStr = cols[cols.length - 1] ?? "";
659
594
  const parsed = parseInt(pidStr, 10);
@@ -665,9 +600,13 @@ async function lookupPidByLocalPort(clientPort: number): Promise<number | null>
665
600
  // CAP_NET_ADMIN to see PIDs of processes owned by other users; when
666
601
  // permission is denied the command exits non-zero and we return null.
667
602
  try {
668
- const { stdout } = await execAsync(
669
- `ss -tanp state established 'sport = :${clientPort}' 2>/dev/null | tail -n +2 | head -n 1`,
670
- { shell: "/bin/sh" },
603
+ const { stdout } = await execFileAsync(
604
+ "ss",
605
+ ["-tanp", "state", "established", "sport", `= :${clientPort}`],
606
+ {
607
+ timeout: 2000,
608
+ maxBuffer: 2 * 1024 * 1024,
609
+ },
671
610
  );
672
611
  const match = stdout.match(/pid=(\d+)/);
673
612
  if (match !== null) {
@@ -678,10 +617,12 @@ async function lookupPidByLocalPort(clientPort: number): Promise<number | null>
678
617
  // fall through
679
618
  }
680
619
  try {
681
- const { stdout } = await execAsync(
682
- `lsof -ti tcp:${clientPort} -sTCP:ESTABLISHED 2>/dev/null`,
620
+ const { stdout } = await execFileAsync(
621
+ "lsof",
622
+ ["-ti", `tcp:${clientPort}`, "-sTCP:ESTABLISHED"],
683
623
  {
684
- shell: "/bin/sh",
624
+ timeout: 2000,
625
+ maxBuffer: 64 * 1024,
685
626
  },
686
627
  );
687
628
  const trimmed = stdout.trim().split("\n")[0] ?? "";
@@ -0,0 +1,16 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { startEcosystemTask } from "../../proxy/ecosystemTasks";
3
+
4
+ export const Route = createFileRoute("/api/ecosystem/packages/$packageId/help")({
5
+ server: {
6
+ handlers: {
7
+ POST: ({ params }: { params: { packageId: string } }) => {
8
+ const task = startEcosystemTask(params.packageId, "help");
9
+ if (task === null) {
10
+ return Response.json({ error: "Unknown ecosystem package" }, { status: 404 });
11
+ }
12
+ return Response.json({ task });
13
+ },
14
+ },
15
+ },
16
+ });
@@ -0,0 +1,16 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { startEcosystemTask } from "../../proxy/ecosystemTasks";
3
+
4
+ export const Route = createFileRoute("/api/ecosystem/packages/$packageId/install")({
5
+ server: {
6
+ handlers: {
7
+ POST: ({ params }: { params: { packageId: string } }) => {
8
+ const task = startEcosystemTask(params.packageId, "install");
9
+ if (task === null) {
10
+ return Response.json({ error: "Unknown ecosystem package" }, { status: 404 });
11
+ }
12
+ return Response.json({ task });
13
+ },
14
+ },
15
+ },
16
+ });
@@ -0,0 +1,19 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { startEcosystemTask } from "../../proxy/ecosystemTasks";
3
+
4
+ export const Route = createFileRoute("/api/ecosystem/packages/$packageId/runner-presets")({
5
+ server: {
6
+ handlers: {
7
+ POST: ({ params }: { params: { packageId: string } }) => {
8
+ const task = startEcosystemTask(params.packageId, "runner-presets");
9
+ if (task === null) {
10
+ return Response.json(
11
+ { error: "Runner presets are only available for Eval Harness" },
12
+ { status: 404 },
13
+ );
14
+ }
15
+ return Response.json({ task });
16
+ },
17
+ },
18
+ },
19
+ });
@@ -0,0 +1,16 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { startEcosystemTask } from "../../proxy/ecosystemTasks";
3
+
4
+ export const Route = createFileRoute("/api/ecosystem/packages/$packageId/upgrade")({
5
+ server: {
6
+ handlers: {
7
+ POST: ({ params }: { params: { packageId: string } }) => {
8
+ const task = startEcosystemTask(params.packageId, "upgrade");
9
+ if (task === null) {
10
+ return Response.json({ error: "Unknown ecosystem package" }, { status: 404 });
11
+ }
12
+ return Response.json({ task });
13
+ },
14
+ },
15
+ },
16
+ });