@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
@@ -7,6 +7,7 @@ import {
7
7
  import { safeGetOwnProperty } from "../../../lib/objectUtils";
8
8
  import type { AnatomySegment } from "../anatomy/types";
9
9
  import { countCharacters, estimateTokens } from "../anatomy/tokenEstimate";
10
+ import { parseRequestTools } from "../requestTools";
10
11
  import type { LogFormatAdapter } from "./types";
11
12
  import { emptyRequestAnalysis, EMPTY_RESPONSE_ANALYSIS } from "./types";
12
13
 
@@ -146,28 +147,24 @@ export const openAILogFormatAdapter: LogFormatAdapter = {
146
147
  analyzeRequest(rawBody) {
147
148
  if (rawBody === null) return emptyRequestAnalysis(rawBody);
148
149
  try {
149
- const result = OpenAIRequestSchema.safeParse(JSON.parse(rawBody));
150
+ const parsedBody: unknown = JSON.parse(rawBody);
151
+ const toolCount = parseRequestTools(parsedBody)?.tools.length ?? null;
152
+ const result = OpenAIRequestSchema.safeParse(parsedBody);
150
153
  if (!result.success) {
151
- const responsesResult = OpenAIResponsesRequestSchema.safeParse(JSON.parse(rawBody));
154
+ const responsesResult = OpenAIResponsesRequestSchema.safeParse(parsedBody);
152
155
  if (!responsesResult.success) return emptyRequestAnalysis(rawBody);
153
156
  return {
154
157
  parsed: responsesResult.data,
155
158
  comparisonValue: responsesResult.data,
156
159
  messageCount: countResponsesInput(responsesResult.data.input),
157
- toolCount:
158
- responsesResult.data.tools !== undefined && responsesResult.data.tools.length > 0
159
- ? responsesResult.data.tools.length
160
- : null,
160
+ toolCount,
161
161
  };
162
162
  }
163
163
  return {
164
164
  parsed: result.data,
165
165
  comparisonValue: result.data,
166
166
  messageCount: result.data.messages.length,
167
- toolCount:
168
- result.data.tools !== undefined && result.data.tools.length > 0
169
- ? result.data.tools.length
170
- : null,
167
+ toolCount,
171
168
  };
172
169
  } catch {
173
170
  return emptyRequestAnalysis(rawBody);
@@ -145,8 +145,11 @@ function parseAnthropicTool(tool: unknown): RequestToolDefinition | null {
145
145
  function parseOpenAITool(tool: unknown): RequestToolDefinition | null {
146
146
  const fn = safeGetOwnProperty(tool, "function");
147
147
  const toolType = safeGetOwnProperty(tool, "type");
148
- if (fn === undefined && toolType !== "function") return null;
149
- const name = safeGetOwnProperty(fn, "name") ?? safeGetOwnProperty(tool, "name");
148
+ if (fn === undefined && typeof toolType !== "string") return null;
149
+ const name =
150
+ safeGetOwnProperty(fn, "name") ??
151
+ safeGetOwnProperty(tool, "name") ??
152
+ (typeof toolType === "string" ? toolType : undefined);
150
153
  if (typeof name !== "string" || name.length === 0) return null;
151
154
  const description =
152
155
  safeGetOwnProperty(fn, "description") ?? safeGetOwnProperty(tool, "description");
@@ -61,28 +61,61 @@ export class ApiTimeoutError extends Error {
61
61
  }
62
62
 
63
63
  /**
64
- * Fetch a JSON resource with a browser-side timeout. This prevents UI loading
65
- * states from waiting forever when a tab keeps a stale connection across a
66
- * local server force-restart.
64
+ * Fetch any resource with a browser-side timeout while preserving a caller
65
+ * supplied abort signal. The timeout is reported as `ApiTimeoutError`; caller
66
+ * cancellations keep the native abort shape so UI code can distinguish them.
67
67
  */
68
- export async function fetchJsonWithTimeout<T>(
68
+ export async function fetchWithTimeout(
69
69
  input: RequestInfo | URL,
70
- schema: z.ZodType<T>,
71
70
  timeoutMs: number,
72
71
  init?: RequestInit,
73
- errorFallback?: (response: Response) => string,
74
- ): Promise<T> {
72
+ ): Promise<Response> {
75
73
  const controller = new AbortController();
76
- const timeout = setTimeout(() => controller.abort(), timeoutMs);
74
+ let timedOut = false;
75
+ const timeout = setTimeout(() => {
76
+ timedOut = true;
77
+ controller.abort();
78
+ }, timeoutMs);
79
+
80
+ const callerSignal = init?.signal ?? undefined;
81
+ const abortFromCaller = (): void => controller.abort();
82
+ if (callerSignal !== undefined) {
83
+ if (callerSignal.aborted) {
84
+ controller.abort();
85
+ } else {
86
+ callerSignal.addEventListener("abort", abortFromCaller, { once: true });
87
+ }
88
+ }
77
89
 
78
90
  try {
79
- return await fetchJson(input, schema, { ...init, signal: controller.signal }, errorFallback);
91
+ return await fetch(input, { ...init, signal: controller.signal });
80
92
  } catch (error) {
81
- if (error instanceof DOMException && error.name === "AbortError") {
93
+ if (timedOut && error instanceof DOMException && error.name === "AbortError") {
82
94
  throw new ApiTimeoutError(timeoutMs);
83
95
  }
84
96
  throw error;
85
97
  } finally {
86
98
  clearTimeout(timeout);
99
+ callerSignal?.removeEventListener("abort", abortFromCaller);
87
100
  }
88
101
  }
102
+
103
+ /**
104
+ * Fetch a JSON resource with a browser-side timeout. This prevents UI loading
105
+ * states from waiting forever when a tab keeps a stale connection across a
106
+ * local server force-restart.
107
+ */
108
+ export async function fetchJsonWithTimeout<T>(
109
+ input: RequestInfo | URL,
110
+ schema: z.ZodType<T>,
111
+ timeoutMs: number,
112
+ init?: RequestInit,
113
+ errorFallback?: (response: Response) => string,
114
+ ): Promise<T> {
115
+ const response = await fetchWithTimeout(input, timeoutMs, init);
116
+ if (!response.ok) {
117
+ const fallback = errorFallback?.(response) ?? `Request failed with status ${response.status}`;
118
+ throw new Error(await readApiError(response, fallback));
119
+ }
120
+ return parseJsonResponse(response, schema);
121
+ }
@@ -41,7 +41,39 @@ export const EcosystemPackagesResponseSchema = z.object({
41
41
  runnerPresets: z.array(EcosystemRunnerPresetSchema),
42
42
  });
43
43
 
44
+ export const EcosystemTaskActionSchema = z.enum(["install", "upgrade", "help", "runner-presets"]);
45
+
46
+ export const EcosystemTaskStatusSchema = z.enum(["queued", "running", "succeeded", "failed"]);
47
+
48
+ export const EcosystemTaskSchema = z.object({
49
+ id: z.string(),
50
+ packageId: z.string(),
51
+ npmName: z.string(),
52
+ action: EcosystemTaskActionSchema,
53
+ status: EcosystemTaskStatusSchema,
54
+ command: z.string(),
55
+ output: z.string(),
56
+ createdAt: z.string(),
57
+ startedAt: z.string().nullable(),
58
+ finishedAt: z.string().nullable(),
59
+ exitCode: z.number().nullable(),
60
+ error: z.string().nullable(),
61
+ });
62
+
63
+ export const EcosystemTaskResponseSchema = z.object({
64
+ task: EcosystemTaskSchema,
65
+ });
66
+
67
+ export const EcosystemTasksResponseSchema = z.object({
68
+ tasks: z.array(EcosystemTaskSchema),
69
+ });
70
+
44
71
  export type EcosystemPackageState = z.infer<typeof EcosystemPackageStateSchema>;
45
72
  export type EcosystemPackage = z.infer<typeof EcosystemPackageSchema>;
46
73
  export type EcosystemRunnerPreset = z.infer<typeof EcosystemRunnerPresetSchema>;
47
74
  export type EcosystemPackagesResponse = z.infer<typeof EcosystemPackagesResponseSchema>;
75
+ export type EcosystemTaskAction = z.infer<typeof EcosystemTaskActionSchema>;
76
+ export type EcosystemTaskStatus = z.infer<typeof EcosystemTaskStatusSchema>;
77
+ export type EcosystemTask = z.infer<typeof EcosystemTaskSchema>;
78
+ export type EcosystemTaskResponse = z.infer<typeof EcosystemTaskResponseSchema>;
79
+ export type EcosystemTasksResponse = z.infer<typeof EcosystemTasksResponseSchema>;
@@ -1,5 +1,6 @@
1
1
  import JSZip from "jszip";
2
2
  import type { CapturedLog } from "../contracts";
3
+ import { fetchWithTimeout } from "./apiClient";
3
4
  import packageJson from "../../package.json";
4
5
 
5
6
  export type ExportMode = "redacted" | "raw";
@@ -8,6 +9,7 @@ export type ExportLogsResult =
8
9
  | {
9
10
  ok: false;
10
11
  message: string;
12
+ canceled?: boolean;
11
13
  };
12
14
 
13
15
  const REDACTED = "[REDACTED]";
@@ -23,6 +25,7 @@ const SECRET_TEXT_PATTERNS: readonly RegExp[] = [
23
25
  const MAX_EXPORT_LOGS = 200;
24
26
  const MAX_EXPORT_TEXT_CHARS = 25_000_000;
25
27
  const MAX_STREAMING_CHUNK_EXPORTS = 50;
28
+ const STREAMING_CHUNK_EXPORT_TIMEOUT_MS = 10_000;
26
29
 
27
30
  export type ExportManifest = {
28
31
  version: string;
@@ -65,9 +68,27 @@ function exportLimitMessage(logs: readonly CapturedLog[]): string | null {
65
68
  return null;
66
69
  }
67
70
 
68
- async function fetchStreamingChunks(logId: number): Promise<string | null> {
71
+ type ExportLogsOptions = {
72
+ signal?: AbortSignal;
73
+ };
74
+
75
+ function exportCanceled(signal: AbortSignal | undefined): ExportLogsResult | null {
76
+ if (signal?.aborted === true) {
77
+ return { ok: false, message: "Export canceled.", canceled: true };
78
+ }
79
+ return null;
80
+ }
81
+
82
+ async function fetchStreamingChunks(
83
+ logId: number,
84
+ signal: AbortSignal | undefined,
85
+ ): Promise<string | null> {
69
86
  try {
70
- const response = await fetch(`/api/logs/${logId}/chunks`);
87
+ const response = await fetchWithTimeout(
88
+ `/api/logs/${logId}/chunks`,
89
+ STREAMING_CHUNK_EXPORT_TIMEOUT_MS,
90
+ { signal },
91
+ );
71
92
  if (!response.ok) return null;
72
93
  const data: unknown = await response.json();
73
94
  return JSON.stringify(data, null, 2);
@@ -145,7 +166,11 @@ export function buildExportManifest(
145
166
  export async function exportLogsAsZip(
146
167
  logs: CapturedLog[],
147
168
  mode: ExportMode = "redacted",
169
+ options: ExportLogsOptions = {},
148
170
  ): Promise<ExportLogsResult> {
171
+ const initialCancel = exportCanceled(options.signal);
172
+ if (initialCancel !== null) return initialCancel;
173
+
149
174
  const limitMessage = exportLimitMessage(logs);
150
175
  if (limitMessage !== null) return { ok: false, message: limitMessage };
151
176
 
@@ -167,6 +192,9 @@ export async function exportLogsAsZip(
167
192
 
168
193
  // Add regular request/response files
169
194
  for (const log of logs) {
195
+ const canceled = exportCanceled(options.signal);
196
+ if (canceled !== null) return canceled;
197
+
170
198
  if (log.rawRequestBody !== null) {
171
199
  zip.file(`#${log.id}.Request.json`, exportText(log.rawRequestBody, mode));
172
200
  }
@@ -177,7 +205,10 @@ export async function exportLogsAsZip(
177
205
 
178
206
  // Fetch SSE chunks sequentially so exports do not spike browser memory.
179
207
  for (const log of streamingLogs) {
180
- const chunks = await fetchStreamingChunks(log.id);
208
+ const canceled = exportCanceled(options.signal);
209
+ if (canceled !== null) return canceled;
210
+
211
+ const chunks = await fetchStreamingChunks(log.id, options.signal);
181
212
  if (chunks !== null) {
182
213
  zip.file(`#${log.id}.SSE.Response.json`, exportText(chunks, mode));
183
214
  }
@@ -185,6 +216,9 @@ export async function exportLogsAsZip(
185
216
 
186
217
  // Generate and download ZIP
187
218
  const blob = await zip.generateAsync({ type: "blob" });
219
+ const canceled = exportCanceled(options.signal);
220
+ if (canceled !== null) return canceled;
221
+
188
222
  const url = URL.createObjectURL(blob);
189
223
 
190
224
  const anchor = document.createElement("a");
@@ -0,0 +1,244 @@
1
+ import { execFile } from "node:child_process";
2
+ import { readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import packageJson from "../../package.json";
5
+ import type {
6
+ EcosystemPackage,
7
+ EcosystemPackageState,
8
+ EcosystemRunnerPreset,
9
+ } from "../lib/ecosystemContract";
10
+ import { npmSpawnCommand } from "./platformCommands";
11
+
12
+ export type PackageDefinition = {
13
+ id: string;
14
+ name: string;
15
+ npmName: string;
16
+ capability: string;
17
+ description: string;
18
+ workflow: string;
19
+ primary: boolean;
20
+ };
21
+
22
+ type NpmResult = {
23
+ ok: boolean;
24
+ stdout: string;
25
+ };
26
+
27
+ export const ECOSYSTEM_PACKAGES: PackageDefinition[] = [
28
+ {
29
+ id: "inspector",
30
+ name: "Agent Inspector",
31
+ npmName: "@tonyclaw/agent-inspector",
32
+ capability: "Observe",
33
+ description: "Captures proxy traffic, sessions, turns, tools, providers, and raw bodies.",
34
+ workflow: "Keep every agent run visible and reloadable.",
35
+ primary: true,
36
+ },
37
+ {
38
+ id: "mcp",
39
+ name: "Inspector MCP",
40
+ npmName: "@tonyclaw/agent-inspector-mcp",
41
+ capability: "Connect",
42
+ description: "Exposes sessions, logs, providers, runs, and evidence to MCP clients.",
43
+ workflow: "Let Codex, OpenCode, MiMo Code, and other tools query Inspector as memory.",
44
+ primary: true,
45
+ },
46
+ {
47
+ id: "eval-harness",
48
+ name: "Eval Harness",
49
+ npmName: "@tonyclaw/eval-harness",
50
+ capability: "Evaluate",
51
+ description:
52
+ "Runs MCP-aware agent evaluations, runner presets, regressions, and model bakeoffs.",
53
+ workflow: "Promote Inspector evidence into repeatable evaluation batches.",
54
+ primary: false,
55
+ },
56
+ ];
57
+
58
+ export const RUNNER_PRESETS: EcosystemRunnerPreset[] = [
59
+ {
60
+ id: "opencode",
61
+ name: "OpenCode",
62
+ validatedVersion: "1.17.11",
63
+ binary: "opencode",
64
+ modelFlag: "--model",
65
+ variantFlag: "--variant",
66
+ tools: ["opencode", "node"],
67
+ logDir: "opencode",
68
+ skillsDir: ".opencode/skills",
69
+ agentsDir: ".opencode/agents",
70
+ },
71
+ {
72
+ id: "mimo",
73
+ name: "MiMo Code",
74
+ validatedVersion: "0.1.4",
75
+ binary: "mimo",
76
+ modelFlag: "--model",
77
+ variantFlag: "--agent",
78
+ tools: ["mimo", "node"],
79
+ logDir: "mimocode",
80
+ skillsDir: ".mimocode/skills",
81
+ agentsDir: ".mimocode/agents",
82
+ },
83
+ {
84
+ id: "codex",
85
+ name: "Codex CLI",
86
+ validatedVersion: "0.142.5",
87
+ binary: "codex",
88
+ modelFlag: "--model",
89
+ variantFlag: null,
90
+ tools: ["codex"],
91
+ logDir: "codex",
92
+ skillsDir: ".codex/skills",
93
+ agentsDir: ".codex/agents",
94
+ },
95
+ {
96
+ id: "claude",
97
+ name: "Claude Code",
98
+ validatedVersion: "2.1.195",
99
+ binary: "claude",
100
+ modelFlag: "--model",
101
+ variantFlag: null,
102
+ tools: ["claude"],
103
+ logDir: "claude",
104
+ skillsDir: ".claude/skills",
105
+ agentsDir: ".claude/agents",
106
+ },
107
+ ];
108
+
109
+ const NPM_TIMEOUT_MS = 4000;
110
+
111
+ function runNpm(args: readonly string[]): Promise<NpmResult> {
112
+ return new Promise((resolve) => {
113
+ const command = npmSpawnCommand(args);
114
+ try {
115
+ execFile(
116
+ command.command,
117
+ command.args,
118
+ { timeout: NPM_TIMEOUT_MS, windowsHide: true },
119
+ (error, stdout) => {
120
+ resolve({
121
+ ok: error === null,
122
+ stdout: String(stdout).trim(),
123
+ });
124
+ },
125
+ );
126
+ } catch {
127
+ resolve({ ok: false, stdout: "" });
128
+ }
129
+ });
130
+ }
131
+
132
+ async function readGlobalNpmRoot(): Promise<string | null> {
133
+ const result = await runNpm(["root", "-g"]);
134
+ if (!result.ok || result.stdout.length === 0) return null;
135
+ return result.stdout;
136
+ }
137
+
138
+ function readGlobalNpmRootFallback(): string | null {
139
+ switch (process.platform) {
140
+ case "win32": {
141
+ const appData = process.env["APPDATA"];
142
+ if (appData === undefined || appData.length === 0) return null;
143
+ return join(appData, "npm", "node_modules");
144
+ }
145
+ case "darwin":
146
+ case "linux":
147
+ case "aix":
148
+ case "freebsd":
149
+ case "openbsd":
150
+ case "sunos": {
151
+ const prefix = process.env["PREFIX"];
152
+ if (prefix !== undefined && prefix.length > 0) return join(prefix, "lib", "node_modules");
153
+ return "/usr/local/lib/node_modules";
154
+ }
155
+ case "android":
156
+ case "cygwin":
157
+ case "haiku":
158
+ case "netbsd":
159
+ return null;
160
+ }
161
+
162
+ return null;
163
+ }
164
+
165
+ function packageJsonPath(globalRoot: string, npmName: string): string {
166
+ const parts = npmName.split("/");
167
+ return join(globalRoot, ...parts, "package.json");
168
+ }
169
+
170
+ function readJsonStringField(raw: unknown, field: string): string | null {
171
+ if (typeof raw !== "object" || raw === null) return null;
172
+ const descriptor = Object.getOwnPropertyDescriptor(raw, field);
173
+ if (descriptor === undefined) return null;
174
+ return typeof descriptor.value === "string" ? descriptor.value : null;
175
+ }
176
+
177
+ async function readInstalledVersion(
178
+ globalRoot: string | null,
179
+ npmName: string,
180
+ ): Promise<string | null> {
181
+ if (npmName === packageJson.name) return packageJson.version;
182
+ if (globalRoot === null) return null;
183
+
184
+ try {
185
+ const raw = await readFile(packageJsonPath(globalRoot, npmName), "utf8");
186
+ return readJsonStringField(JSON.parse(raw), "version");
187
+ } catch {
188
+ return null;
189
+ }
190
+ }
191
+
192
+ async function readLatestVersion(npmName: string): Promise<string | null> {
193
+ const result = await runNpm(["view", npmName, "version", "--json"]);
194
+ if (!result.ok || result.stdout.length === 0) return null;
195
+
196
+ try {
197
+ const parsed: unknown = JSON.parse(result.stdout);
198
+ return typeof parsed === "string" && parsed.length > 0 ? parsed : null;
199
+ } catch {
200
+ return result.stdout.length > 0 ? result.stdout : null;
201
+ }
202
+ }
203
+
204
+ function packageState(
205
+ installedVersion: string | null,
206
+ latestVersion: string | null,
207
+ ): EcosystemPackageState {
208
+ if (installedVersion !== null && latestVersion !== null && installedVersion !== latestVersion) {
209
+ return "update-available";
210
+ }
211
+ if (installedVersion !== null) return "installed";
212
+ if (latestVersion !== null) return "available";
213
+ return "planned";
214
+ }
215
+
216
+ async function describePackage(
217
+ definition: PackageDefinition,
218
+ globalRoot: string | null,
219
+ ): Promise<EcosystemPackage> {
220
+ const [installedVersion, latestVersion] = await Promise.all([
221
+ readInstalledVersion(globalRoot, definition.npmName),
222
+ readLatestVersion(definition.npmName),
223
+ ]);
224
+ const state = packageState(installedVersion, latestVersion);
225
+
226
+ return {
227
+ ...definition,
228
+ installCommand: `npm install -g ${definition.npmName}`,
229
+ installedVersion,
230
+ latestVersion,
231
+ state,
232
+ };
233
+ }
234
+
235
+ export async function listEcosystemPackages(): Promise<EcosystemPackage[]> {
236
+ const globalRoot = (await readGlobalNpmRoot()) ?? readGlobalNpmRootFallback();
237
+ return Promise.all(
238
+ ECOSYSTEM_PACKAGES.map((definition) => describePackage(definition, globalRoot)),
239
+ );
240
+ }
241
+
242
+ export function findEcosystemPackage(packageId: string): PackageDefinition | null {
243
+ return ECOSYSTEM_PACKAGES.find((definition) => definition.id === packageId) ?? null;
244
+ }
@@ -0,0 +1,153 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { spawn } from "node:child_process";
3
+ import { randomUUID } from "node:crypto";
4
+ import type {
5
+ EcosystemTask,
6
+ EcosystemTaskAction,
7
+ EcosystemTaskStatus,
8
+ } from "../lib/ecosystemContract";
9
+ import { findEcosystemPackage } from "./ecosystemRegistry";
10
+ import { npmCommand, npmSpawnCommand } from "./platformCommands";
11
+
12
+ const MAX_TASKS = 50;
13
+ const OUTPUT_LIMIT = 40_000;
14
+ const INSTALL_TIMEOUT_MS = 120_000;
15
+ const HELP_TIMEOUT_MS = 30_000;
16
+
17
+ const tasks = new Map<string, EcosystemTask>();
18
+
19
+ function nowIso(): string {
20
+ return new Date().toISOString();
21
+ }
22
+
23
+ function trimOutput(output: string): string {
24
+ if (output.length <= OUTPUT_LIMIT) return output;
25
+ return output.slice(output.length - OUTPUT_LIMIT);
26
+ }
27
+
28
+ function appendTaskOutput(task: EcosystemTask, chunk: string): void {
29
+ task.output = trimOutput(`${task.output}${chunk}`);
30
+ }
31
+
32
+ function rememberTask(task: EcosystemTask): void {
33
+ tasks.set(task.id, task);
34
+ while (tasks.size > MAX_TASKS) {
35
+ const oldest = tasks.keys().next().value;
36
+ if (typeof oldest !== "string") return;
37
+ tasks.delete(oldest);
38
+ }
39
+ }
40
+
41
+ function packageActionArgs(action: EcosystemTaskAction, npmName: string): string[] {
42
+ switch (action) {
43
+ case "install":
44
+ case "upgrade":
45
+ return ["install", "-g", npmName];
46
+ case "help":
47
+ return ["exec", "--yes", npmName, "--", "--help"];
48
+ case "runner-presets":
49
+ return ["exec", "--yes", "@tonyclaw/eval-harness", "--", "runner-presets"];
50
+ }
51
+ }
52
+
53
+ function packageActionTimeout(action: EcosystemTaskAction): number {
54
+ switch (action) {
55
+ case "install":
56
+ case "upgrade":
57
+ return INSTALL_TIMEOUT_MS;
58
+ case "help":
59
+ case "runner-presets":
60
+ return HELP_TIMEOUT_MS;
61
+ }
62
+ }
63
+
64
+ function taskCommand(args: readonly string[]): string {
65
+ return `${npmCommand()} ${args.join(" ")}`;
66
+ }
67
+
68
+ function completeTask(
69
+ task: EcosystemTask,
70
+ status: Exclude<EcosystemTaskStatus, "queued" | "running">,
71
+ exitCode: number | null,
72
+ error: string | null,
73
+ ): void {
74
+ task.status = status;
75
+ task.exitCode = exitCode;
76
+ task.error = error;
77
+ task.finishedAt = nowIso();
78
+ }
79
+
80
+ export function getEcosystemTask(taskId: string): EcosystemTask | null {
81
+ return tasks.get(taskId) ?? null;
82
+ }
83
+
84
+ export function listEcosystemTasks(): EcosystemTask[] {
85
+ return [...tasks.values()].sort((left, right) => right.createdAt.localeCompare(left.createdAt));
86
+ }
87
+
88
+ export function startEcosystemTask(
89
+ packageId: string,
90
+ action: EcosystemTaskAction,
91
+ ): EcosystemTask | null {
92
+ const definition =
93
+ action === "runner-presets"
94
+ ? findEcosystemPackage("eval-harness")
95
+ : findEcosystemPackage(packageId);
96
+ if (definition === null) return null;
97
+ if (action === "runner-presets" && packageId !== "eval-harness") return null;
98
+
99
+ const args = packageActionArgs(action, definition.npmName);
100
+ const task: EcosystemTask = {
101
+ id: randomUUID(),
102
+ packageId: definition.id,
103
+ npmName: definition.npmName,
104
+ action,
105
+ status: "queued",
106
+ command: taskCommand(args),
107
+ output: "",
108
+ createdAt: nowIso(),
109
+ startedAt: null,
110
+ finishedAt: null,
111
+ exitCode: null,
112
+ error: null,
113
+ };
114
+ rememberTask(task);
115
+
116
+ const spawnCommand = npmSpawnCommand(args);
117
+ const child = spawn(spawnCommand.command, spawnCommand.args, {
118
+ windowsHide: true,
119
+ shell: false,
120
+ stdio: ["ignore", "pipe", "pipe"],
121
+ });
122
+ const timeout = setTimeout(() => {
123
+ child.kill();
124
+ appendTaskOutput(task, `\nTimed out after ${String(packageActionTimeout(action) / 1000)}s.\n`);
125
+ }, packageActionTimeout(action));
126
+
127
+ task.status = "running";
128
+ task.startedAt = nowIso();
129
+ appendTaskOutput(task, `$ ${task.command}\n`);
130
+
131
+ child.stdout.on("data", (chunk: Buffer) => {
132
+ appendTaskOutput(task, chunk.toString("utf8"));
133
+ });
134
+ child.stderr.on("data", (chunk: Buffer) => {
135
+ appendTaskOutput(task, chunk.toString("utf8"));
136
+ });
137
+ child.on("error", (error) => {
138
+ clearTimeout(timeout);
139
+ appendTaskOutput(task, `\n${error.message}\n`);
140
+ completeTask(task, "failed", null, error.message);
141
+ });
142
+ child.on("close", (code) => {
143
+ clearTimeout(timeout);
144
+ if (task.status === "failed") return;
145
+ if (code === 0) {
146
+ completeTask(task, "succeeded", code, null);
147
+ return;
148
+ }
149
+ completeTask(task, "failed", code, `Command exited with code ${String(code ?? "unknown")}`);
150
+ });
151
+
152
+ return task;
153
+ }