@progen-labs/spectra 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.html CHANGED
@@ -5,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>Performance Profiler</title>
7
7
  <link rel="icon" href="data:," />
8
- <script type="module" crossorigin src="/assets/index-mtYxDBW7.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-BDIjT029.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="/assets/index-z14kjW8r.css">
10
10
  </head>
11
11
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@progen-labs/spectra",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Spectra — a Chrome DevTools Protocol profiler that identifies the files, functions and components causing frontend slowness, across React, Vue, Svelte, Angular and Next.js builds.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,7 +24,8 @@
24
24
  "index.html",
25
25
  "vite.config.js",
26
26
  "README.md",
27
- "LICENSE"
27
+ "LICENSE",
28
+ "CHANGELOG.md"
28
29
  ],
29
30
  "scripts": {
30
31
  "spectra": "node bin/spectra.js",
@@ -36,10 +37,12 @@
36
37
  "test": "node --test \"server/**/test-*.js\" \"action/test-*.js\"",
37
38
  "test:vscode": "npm --prefix vscode test",
38
39
  "bench": "node bench/run.mjs",
39
- "postinstall": "node server/postinstall.js"
40
+ "size": "npm run build && node scripts/size-report.mjs",
41
+ "lint": "eslint .",
42
+ "test:coverage": "node --test --experimental-test-coverage --test-coverage-include=\"server/**\" --test-coverage-include=\"shared/**\" --test-coverage-include=\"bin/**\" --test-coverage-exclude=\"**/test-*.js\" --test-coverage-exclude=\"server/probe.browser.js\" --test-coverage-lines=70 --test-coverage-branches=70 --test-coverage-functions=72 \"server/**/test-*.js\" \"action/test-*.js\""
40
43
  },
41
44
  "engines": {
42
- "node": ">=18"
45
+ "node": ">=22.12"
43
46
  },
44
47
  "keywords": [
45
48
  "performance",
@@ -63,7 +66,7 @@
63
66
  ],
64
67
  "author": "Progen Labs (Sourav Debnath)",
65
68
  "license": "MIT",
66
- "homepage": "https://github.com/progen-labs/spectra#readme",
69
+ "homepage": "https://progen-labs.github.io/website/#spectra",
67
70
  "repository": {
68
71
  "type": "git",
69
72
  "url": "git+https://github.com/progen-labs/spectra.git"
@@ -79,12 +82,32 @@
79
82
  "puppeteer-core": "^25.13.0"
80
83
  },
81
84
  "devDependencies": {
85
+ "@eslint/js": "^9.39.5",
82
86
  "@modelcontextprotocol/sdk": "^1.32.1",
87
+ "@size-limit/file": "^14.2.0",
83
88
  "@vitejs/plugin-react": "^6.1.2",
84
89
  "d3": "^7.9.0",
90
+ "eslint": "^9.39.5",
91
+ "eslint-plugin-react": "^7.37.5",
92
+ "eslint-plugin-react-hooks": "^7.1.1",
93
+ "globals": "^17.13.0",
85
94
  "highlight.js": "^11.10.0",
95
+ "publint": "^0.3.25",
86
96
  "react": "^18.3.1",
87
97
  "react-dom": "^18.3.1",
98
+ "size-limit": "^14.2.0",
88
99
  "vite": "^8.3.4"
89
- }
100
+ },
101
+ "size-limit": [
102
+ {
103
+ "name": "Dashboard JavaScript (brotli)",
104
+ "path": "dist/assets/*.js",
105
+ "limit": "115 kB"
106
+ },
107
+ {
108
+ "name": "Dashboard CSS (brotli)",
109
+ "path": "dist/assets/*.css",
110
+ "limit": "10 kB"
111
+ }
112
+ ]
90
113
  }
@@ -141,7 +141,7 @@
141
141
  }
142
142
  },
143
143
  "agentDocs": {
144
- "description": "Spectra's knowledge doc for AI agents, written after npm install (see `spectra agents`). false: never write it; a list: only for these agents. Left out: AGENTS.md and the agents this project or machine uses.",
144
+ "description": "Spectra's knowledge doc for AI agents, written the first time Spectra runs in the project (see `spectra agents`). false: never write it; a list: only for these agents. Left out: AGENTS.md and the agents this project or machine uses.",
145
145
  "oneOf": [
146
146
  {
147
147
  "type": "boolean"
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://unpkg.com/@progen-labs/spectra/schema/result.schema.json",
4
4
  "title": "Spectra result",
5
- "description": "What `spectra … --json` prints and `--out` writes. A small envelope that points at the full reports on disk. Additive changes keep schemaVersion; breaking changes bump it.",
5
+ "description": "What `spectra \u2026 --json` prints and `--out` writes. A small envelope that points at the full reports on disk. Additive changes keep schemaVersion; breaking changes bump it.",
6
6
  "type": "object",
7
7
  "required": [
8
8
  "schemaVersion",
@@ -19,7 +19,8 @@
19
19
  "profile",
20
20
  "evaluation",
21
21
  "compare",
22
- "reports"
22
+ "reports",
23
+ "improve"
23
24
  ]
24
25
  },
25
26
  "spectraVersion": {
@@ -81,6 +82,18 @@
81
82
  "then": {
82
83
  "$ref": "#/$defs/reports"
83
84
  }
85
+ },
86
+ {
87
+ "if": {
88
+ "properties": {
89
+ "kind": {
90
+ "const": "improve"
91
+ }
92
+ }
93
+ },
94
+ "then": {
95
+ "$ref": "#/$defs/improve"
96
+ }
84
97
  }
85
98
  ],
86
99
  "$defs": {
@@ -258,7 +271,7 @@
258
271
  "string",
259
272
  "null"
260
273
  ],
261
- "description": "exact, or located (≈, found by searching the source)."
274
+ "description": "exact, or located (\u2248, found by searching the source)."
262
275
  },
263
276
  "abs": {
264
277
  "type": [
@@ -744,6 +757,54 @@
744
757
  }
745
758
  }
746
759
  }
760
+ },
761
+ "improve": {
762
+ "type": "object",
763
+ "description": "One step of a guided `spectra improve` session (next, check, report or stop): the text shown to whoever makes the changes, and the session state.",
764
+ "required": [
765
+ "step",
766
+ "message",
767
+ "session"
768
+ ],
769
+ "properties": {
770
+ "step": {
771
+ "enum": [
772
+ "next",
773
+ "check",
774
+ "report",
775
+ "stop"
776
+ ]
777
+ },
778
+ "message": {
779
+ "type": "string",
780
+ "description": "What was printed: the problem to fix, the verdict on a change, or the report."
781
+ },
782
+ "task": {
783
+ "type": [
784
+ "object",
785
+ "null"
786
+ ],
787
+ "description": "The problem to work on now, if any: title, file, line, severity, impactMs, fix, culprits."
788
+ },
789
+ "attempt": {
790
+ "type": "object",
791
+ "description": "check only: the change just measured, with outcome (kept, no-gain, slower, mixed, new-problem, unproven, checks-failed) and reason."
792
+ },
793
+ "result": {
794
+ "type": [
795
+ "object",
796
+ "null"
797
+ ],
798
+ "description": "report and stop: the overall before \u2192 after (verdict, confidence, headlines) when something was kept."
799
+ },
800
+ "session": {
801
+ "type": [
802
+ "object",
803
+ "null"
804
+ ],
805
+ "description": "The whole session: baseline, current (the result files of the latest kept state), queue, task, attempts, skipped, kept."
806
+ }
807
+ }
747
808
  }
748
809
  }
749
810
  }
@@ -10,9 +10,10 @@
10
10
  * spectra agents --print print the knowledge doc
11
11
  * … --dir <project> --dry-run
12
12
  */
13
+ import fs from 'node:fs';
13
14
  import path from 'node:path';
14
15
  import { parseArgs } from './args.js';
15
- import { TARGETS, TARGET_IDS, describe, detectTargets, installAgentDocs, knowledge, removeAgentDocs } from './agents.js';
16
+ import { TARGETS, TARGET_IDS, describe, detectTargets, installAgentDocs, knowledge, removeAgentDocs, writeNote } from './agents.js';
16
17
 
17
18
  const { opt, flag } = parseArgs(process.argv.slice(2), { values: ['dir', 'only'] });
18
19
  const root = path.resolve(opt('dir', process.cwd()));
@@ -25,7 +26,7 @@ Teaches your AI agents about Spectra: what it does, which command answers which
25
26
  request, and how to read the results. It writes Spectra's knowledge doc where
26
27
  each agent reads its project instructions, so you can ask in plain words
27
28
  ("why is checkout slow?", "did my change help?") and the agent runs Spectra.
28
- It runs by itself after npm install; this does it by hand.
29
+ It happens by itself the first time Spectra runs in a project; this does it by hand.
29
30
 
30
31
  Agents: ${TARGET_IDS.join(', ')}
31
32
  Off for a project: "agentDocs": false in spectra.config.json (or SPECTRA_AGENT_DOCS=0).`);
@@ -46,6 +47,8 @@ if (flag('list')) {
46
47
 
47
48
  if (flag('remove')) {
48
49
  const results = removeAgentDocs({ root, dryRun });
50
+ // and don't bring them back on the next run
51
+ if (!dryRun) writeNote(root, { removed: true });
49
52
  console.log(results.length ? `${dryRun ? 'Would remove' : 'Removed'} Spectra's knowledge doc:\n${describe(results)}` : 'Nothing to remove.');
50
53
  process.exit(0);
51
54
  }
@@ -61,6 +64,7 @@ else if (opt('only')) {
61
64
  }
62
65
  }
63
66
  const results = installAgentDocs({ root, targets, dryRun });
67
+ if (!dryRun) writeNote(root, { version: JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version });
64
68
  console.log(`${dryRun ? 'Would write' : 'Wrote'} Spectra's knowledge doc for your AI agents:\n${describe(results)}`);
65
69
  console.log('\nAsk your agent in plain words, e.g. "why is the checkout page slow?" or "did my last change make it faster?".');
66
70
  console.log('Remove it with: npx spectra agents --remove');
package/server/agents.js CHANGED
@@ -5,8 +5,9 @@
5
5
  * instructions, so a user can just ask in plain words ("why is checkout
6
6
  * slow?", "did my change help?") and the agent runs the right Spectra command.
7
7
  *
8
- * Runs after `npm install @progen-labs/spectra` (server/postinstall.js) for the
9
- * agents this machine or project uses, and on demand with `spectra agents`.
8
+ * Written the first time Spectra runs in a project it is installed in (no
9
+ * install script, so package managers and supply-chain scanners have nothing
10
+ * to flag), refreshed when Spectra updates, and on demand with `spectra agents`.
10
11
  *
11
12
  * Files Spectra owns carry a `spectra:managed` marker and are replaced on
12
13
  * update; in shared files (AGENTS.md, CLAUDE.md, GEMINI.md) only the block
@@ -250,33 +251,84 @@ export function removeAgentDocs({ root, dryRun = false } = {}) {
250
251
  return out;
251
252
  }
252
253
 
254
+ // what Spectra did in a project, so it acts once per version and never undoes a removal
255
+ const noteFile = root => path.join(root, '.spectra', 'agent-docs.json');
256
+ const readNote = root => {
257
+ try {
258
+ return JSON.parse(fs.readFileSync(noteFile(root), 'utf8'));
259
+ } catch {
260
+ return null;
261
+ }
262
+ };
263
+ export function writeNote(root, note) {
264
+ try {
265
+ fs.mkdirSync(path.dirname(noteFile(root)), { recursive: true });
266
+ fs.writeFileSync(noteFile(root), `${JSON.stringify({ ...note, at: new Date().toISOString() }, null, 2)}\n`);
267
+ } catch {}
268
+ }
269
+
253
270
  /**
254
- * Should the install step write the docs? Only for a real project install:
255
- * not in CI, not a global install, not `npx` running Spectra from its cache,
256
- * not Spectra's own repository, and not when the user opted out.
271
+ * Should this run of `spectra` write the docs? Only in a project Spectra is
272
+ * installed in (its node_modules holds this copy of Spectra, and the command
273
+ * runs inside it): not under `npx` from its cache, not a global install, not
274
+ * Spectra's own repository, not in CI, and not when the user opted out or
275
+ * removed the docs. Once per Spectra version: the first run writes them for
276
+ * the agents in use; after an update, only what is already there is refreshed.
257
277
  */
258
- export function postinstallPlan({ env = process.env, pkgRoot = PKG_ROOT } = {}) {
259
- const root = env.INIT_CWD && path.resolve(env.INIT_CWD);
278
+ export function firstRunPlan({ cwd = process.cwd(), env = process.env, pkgRoot = PKG_ROOT } = {}) {
260
279
  if (env.SPECTRA_AGENT_DOCS === '0' || env.SPECTRA_AGENT_DOCS === 'false') return { skip: 'SPECTRA_AGENT_DOCS=0' };
261
280
  if (env.CI && env.CI !== 'false' && env.CI !== '0') return { skip: 'CI' };
262
- if (env.npm_config_global === 'true') return { skip: 'a global install' };
263
- if (!root) return { skip: 'no project directory (INIT_CWD)' };
264
- const pkg = path.resolve(pkgRoot);
265
- if (pkg === root) return { skip: "Spectra's own repository" };
266
- // installed into this project's node_modules (pnpm keeps it under node_modules/.pnpm), not into npx's cache
267
281
  const real = (() => {
268
282
  try {
269
- return fs.realpathSync(pkg);
283
+ return fs.realpathSync(pkgRoot);
270
284
  } catch {
271
- return pkg;
285
+ return path.resolve(pkgRoot);
272
286
  }
273
287
  })();
274
- const modules = path.join(root, 'node_modules') + path.sep;
275
- if (!pkg.startsWith(modules) && !real.startsWith(modules)) return { skip: 'not installed into this project (npx or a parent folder)' };
288
+ const at = real.indexOf(`${path.sep}node_modules${path.sep}`);
289
+ if (at < 0) return { skip: "not installed from npm (Spectra's own repository)" };
290
+ const root = real.slice(0, at);
291
+ const here = (() => {
292
+ try {
293
+ return fs.realpathSync(cwd);
294
+ } catch {
295
+ return path.resolve(cwd);
296
+ }
297
+ })();
298
+ if (here !== root && !here.startsWith(root + path.sep)) return { skip: 'not run inside the project Spectra is installed in (npx or a global install)' };
276
299
  if (!exists(path.join(root, 'package.json'))) return { skip: 'no package.json' };
277
300
  const setting = readAgentDocsSetting(root);
278
301
  if (setting === false) return { skip: '"agentDocs": false in spectra.config.json' };
279
- return { root, targets: Array.isArray(setting) ? setting.filter(id => TARGET_IDS.includes(id)) : undefined };
302
+ const note = readNote(root);
303
+ if (note?.removed) return { skip: 'removed with `spectra agents --remove`' };
304
+ if (note?.version === VERSION) return { skip: 'already done for this version' };
305
+ return { root, mode: note ? 'refresh' : 'install', targets: Array.isArray(setting) ? setting.filter(id => TARGET_IDS.includes(id)) : undefined };
306
+ }
307
+
308
+ /** Rewrites only the Spectra content that is already in the project (after an update). */
309
+ export function refreshAgentDocs({ root, dryRun = false } = {}) {
310
+ const out = [];
311
+ for (const t of TARGETS) {
312
+ const files = [{ file: t.file, write: f => (t.block ? writeBlock(f, t.block(), dryRun) : writeOwn(f, t.content(), dryRun)) }];
313
+ if (t.also) files.push({ file: t.also.file, write: f => writeBlock(f, t.also.block(), dryRun) });
314
+ for (const { file: rel, write } of files) {
315
+ const file = path.join(root, rel);
316
+ if (!exists(file)) continue;
317
+ const text = fs.readFileSync(file, 'utf8');
318
+ if (!findBlock(text) && !text.includes(MANAGED)) continue;
319
+ out.push({ id: t.id, name: t.name, file: rel, result: write(file) });
320
+ }
321
+ }
322
+ return out;
323
+ }
324
+
325
+ /** The first-run step: does what firstRunPlan says, and remembers it. Returns the files it changed. */
326
+ export function ensureAgentDocs(opts = {}) {
327
+ const plan = firstRunPlan(opts);
328
+ if (plan.skip) return { skip: plan.skip, changed: [] };
329
+ const results = plan.mode === 'install' ? installAgentDocs({ root: plan.root, targets: plan.targets }) : refreshAgentDocs({ root: plan.root });
330
+ writeNote(plan.root, { version: VERSION });
331
+ return { root: plan.root, changed: results.filter(r => r.result === 'created' || r.result === 'updated') };
280
332
  }
281
333
 
282
334
  /** One line per file, for people. */
@@ -8,6 +8,8 @@ import { spawn } from 'node:child_process';
8
8
  import fs from 'node:fs';
9
9
  import path from 'node:path';
10
10
 
11
+ // terminal colour codes in the dev server's output
12
+ // eslint-disable-next-line no-control-regex
11
13
  const ANSI = /\x1b\[[0-9;]*m/g;
12
14
  const URL_RE =
13
15
  /(https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0)(?::\d+)?[^\s)'"<]*)/i;
@@ -1,18 +1,97 @@
1
1
  /**
2
- * The coding agent `spectra fix` hands one finding to. By default it is Claude
3
- * Code in headless mode, allowed only to read and edit files: no shell, no
4
- * web, no MCP servers. Any other agent works through --agent "<command>": it
5
- * gets the instructions on stdin (and in $SPECTRA_FIX_PROMPT_FILE) and runs in
6
- * the copy of the project.
2
+ * The coding agent `spectra improve` hands one finding to when it runs
3
+ * unattended. Spectra knows the common agent CLIs by name (AGENTS below) and
4
+ * finds one on the machine by itself; Claude Code runs allowed only to read
5
+ * and edit files: no shell, no web, no MCP servers. Any other agent works
6
+ * through --agent "<command>": it gets the instructions on stdin (and in
7
+ * $SPECTRA_FIX_PROMPT_FILE) and runs in the copy of the project.
7
8
  */
8
9
  import { spawn } from 'node:child_process';
9
10
  import fs from 'node:fs';
10
11
  import os from 'node:os';
11
12
  import path from 'node:path';
12
13
 
14
+ /**
15
+ * The agents Spectra can run by name: how each one takes a single prompt,
16
+ * edits files in the current directory without asking, and exits (the
17
+ * "print" mode every CLI agent has), and the signs that it is in use.
18
+ */
19
+ export const AGENTS = {
20
+ claude: { name: 'Claude Code', bin: 'claude', project: ['CLAUDE.md', '.claude'], home: ['.claude', '.claude.json'], also: ['.local/bin/claude', '.claude/local/claude'] },
21
+ codex: { name: 'Codex CLI', bin: 'codex', args: p => ['exec', '--full-auto', p], project: ['.codex'], home: ['.codex'] },
22
+ gemini: { name: 'Gemini CLI', bin: 'gemini', args: p => ['-p', p, '--yolo'], project: ['GEMINI.md', '.gemini'], home: ['.gemini'] },
23
+ copilot: { name: 'GitHub Copilot CLI', bin: 'copilot', args: p => ['-p', p, '--allow-all-tools'], project: ['.github/copilot-instructions.md', '.github/instructions'], home: ['.copilot'] },
24
+ cursor: { name: 'Cursor CLI', bin: 'cursor-agent', args: p => ['-p', p, '--force'], project: ['.cursor', '.cursorrules'], home: ['.cursor'] },
25
+ opencode: { name: 'OpenCode', bin: 'opencode', args: p => ['run', p], project: ['opencode.json', '.opencode'], home: ['.config/opencode'] },
26
+ amp: { name: 'Amp', bin: 'amp', args: p => ['-x', p], project: [], home: ['.config/amp'] },
27
+ };
28
+ export const AGENT_IDS = Object.keys(AGENTS);
29
+
30
+ const exists = p => {
31
+ try {
32
+ fs.statSync(p);
33
+ return true;
34
+ } catch {
35
+ return false;
36
+ }
37
+ };
38
+
39
+ /** Where an agent's executable is: on PATH, or in the places its installer uses. Null when it is not installed. */
40
+ export function findExecutable(id, { env = process.env, home = os.homedir() } = {}) {
41
+ const a = AGENTS[id];
42
+ if (id === 'claude' && env.SPECTRA_CLAUDE) return env.SPECTRA_CLAUDE;
43
+ const names = process.platform === 'win32' ? [`${a.bin}.cmd`, `${a.bin}.exe`, a.bin] : [a.bin];
44
+ for (const dir of (env.PATH || '').split(path.delimiter).filter(Boolean))
45
+ for (const n of names) if (exists(path.join(dir, n))) return path.join(dir, n);
46
+ for (const rel of a.also || []) if (home && exists(path.join(home, rel))) return path.join(home, rel);
47
+ return null;
48
+ }
49
+
50
+ /**
51
+ * The agent to run unattended, when none was named: first one the project or
52
+ * the machine shows signs of (so it is the agent the user already works with),
53
+ * then any that is installed. Null when there is none.
54
+ */
55
+ export function detectAgent({ root = process.cwd(), home = os.homedir(), env = process.env } = {}) {
56
+ const installed = AGENT_IDS.filter(id => findExecutable(id, { env, home }));
57
+ const inUse = id => (AGENTS[id].project || []).some(p => exists(path.join(root, p))) || (AGENTS[id].home || []).some(p => home && exists(path.join(home, p)));
58
+ const id = installed.find(inUse) || installed[0];
59
+ return id ? { id, ...AGENTS[id], bin: findExecutable(id, { env, home }) } : null;
60
+ }
61
+
62
+ /** The agent for --agent: a name from AGENTS, or any shell command. */
63
+ export function agentFromOption(value, opts = {}) {
64
+ return AGENTS[value] ? namedAgent(value, opts) : commandAgent(String(value), opts);
65
+ }
66
+
67
+ /** One of AGENTS, ready to run. */
68
+ export function namedAgent(id, { bin, model, budgetUsd, timeoutMs = 10 * 60_000, env, home } = {}) {
69
+ const a = AGENTS[id];
70
+ if (!a) throw new Error(`unknown agent "${id}"; the names are ${AGENT_IDS.join(', ')}, or pass a command.`);
71
+ const executable = bin || findExecutable(id, { env, home }) || a.bin;
72
+ if (id === 'claude') return claudeAgent({ bin: executable, model, budgetUsd, timeoutMs });
73
+ return {
74
+ id,
75
+ name: a.name,
76
+ async run(prompt, { cwd, signal }) {
77
+ let res;
78
+ try {
79
+ res = await run(executable, a.args(prompt), { cwd, signal, timeoutMs, env: { CI: '1' } });
80
+ } catch (err) {
81
+ throw new Error(`could not start ${a.name} (${err.message}). Install it, or pass --agent "<command>".`);
82
+ }
83
+ if (res.code !== 0) throw new Error(`${a.name} exited with code ${res.code}: ${(res.err || res.out).trim().split('\n').slice(-3).join(' ')}`);
84
+ return { summary: res.out.trim().split('\n').filter(Boolean).pop()?.slice(0, 300) || '', costUsd: null, turns: null, error: null };
85
+ },
86
+ };
87
+ }
88
+
89
+ // `input` goes to the agent's stdin; without it, stdin is closed from the start
90
+ // (the prompt is in the arguments). An agent that exits before reading its
91
+ // stdin makes the write fail with EPIPE, which must not take Spectra down.
13
92
  const run = (cmd, args, { cwd, input, env, signal, timeoutMs, shell = false }) =>
14
93
  new Promise((resolve, reject) => {
15
- const child = spawn(cmd, args, { cwd, env: { ...process.env, ...env }, stdio: ['pipe', 'pipe', 'pipe'], shell });
94
+ const child = spawn(cmd, args, { cwd, env: { ...process.env, ...env }, stdio: [input == null ? 'ignore' : 'pipe', 'pipe', 'pipe'], shell });
16
95
  let out = '';
17
96
  let err = '';
18
97
  child.stdout.on('data', d => (out += d));
@@ -29,12 +108,16 @@ const run = (cmd, args, { cwd, input, env, signal, timeoutMs, shell = false }) =
29
108
  signal?.removeEventListener('abort', abort);
30
109
  resolve({ code, out, err });
31
110
  });
32
- child.stdin.end(input ?? '');
111
+ if (input != null) {
112
+ child.stdin.on('error', () => {}); // the agent closed its end first
113
+ child.stdin.end(input);
114
+ }
33
115
  });
34
116
 
35
117
  /** Claude Code, headless. */
36
118
  export function claudeAgent({ bin = process.env.SPECTRA_CLAUDE || 'claude', model, budgetUsd = 2, timeoutMs = 10 * 60_000 } = {}) {
37
119
  return {
120
+ id: 'claude',
38
121
  name: `Claude Code${model ? ` (${model})` : ''}`,
39
122
  async run(prompt, { cwd, signal }) {
40
123
  const args = [