copperhead 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.
Files changed (58) hide show
  1. package/dist/agent/loop.js +118 -16
  2. package/dist/agent/loop.js.map +1 -1
  3. package/dist/agent/prompts.js +2 -1
  4. package/dist/agent/prompts.js.map +1 -1
  5. package/dist/agent/providers/claude-code.js +207 -27
  6. package/dist/agent/providers/claude-code.js.map +1 -1
  7. package/dist/agent/recovery.js +148 -0
  8. package/dist/agent/recovery.js.map +1 -0
  9. package/dist/agent/render.js +17 -2
  10. package/dist/agent/render.js.map +1 -1
  11. package/dist/agent/response-cache.js +81 -0
  12. package/dist/agent/response-cache.js.map +1 -0
  13. package/dist/agent/tools.js +61 -4
  14. package/dist/agent/tools.js.map +1 -1
  15. package/dist/agent/transcript.js.map +1 -1
  16. package/dist/commands/create.js +470 -35
  17. package/dist/commands/create.js.map +1 -1
  18. package/dist/config.js +19 -0
  19. package/dist/config.js.map +1 -1
  20. package/dist/kicad/bootstrap.js +166 -0
  21. package/dist/kicad/bootstrap.js.map +1 -0
  22. package/dist/kicad/spice.js +306 -0
  23. package/dist/kicad/spice.js.map +1 -0
  24. package/dist/kicad/symlib.js +228 -0
  25. package/dist/kicad/symlib.js.map +1 -0
  26. package/dist/memory/bom-table.js +193 -22
  27. package/dist/memory/bom-table.js.map +1 -1
  28. package/dist/memory/drift.js +33 -11
  29. package/dist/memory/drift.js.map +1 -1
  30. package/dist/util/git.js +37 -1
  31. package/dist/util/git.js.map +1 -1
  32. package/dist/util/preflight.js +37 -0
  33. package/dist/util/preflight.js.map +1 -1
  34. package/dist/util/retry.js +23 -0
  35. package/dist/util/retry.js.map +1 -1
  36. package/dist/util/tmp.js +119 -0
  37. package/dist/util/tmp.js.map +1 -0
  38. package/package.json +1 -1
  39. package/src/agent/loop.ts +136 -16
  40. package/src/agent/prompts.ts +2 -1
  41. package/src/agent/providers/claude-code.ts +207 -24
  42. package/src/agent/recovery.ts +162 -0
  43. package/src/agent/render.ts +28 -1
  44. package/src/agent/response-cache.ts +80 -0
  45. package/src/agent/tools.ts +62 -4
  46. package/src/agent/transcript.ts +1 -0
  47. package/src/agent/types.ts +17 -0
  48. package/src/commands/create.ts +528 -38
  49. package/src/config.ts +34 -0
  50. package/src/kicad/bootstrap.ts +181 -0
  51. package/src/kicad/spice.ts +399 -0
  52. package/src/kicad/symlib.ts +248 -0
  53. package/src/memory/bom-table.ts +191 -20
  54. package/src/memory/drift.ts +42 -11
  55. package/src/util/git.ts +37 -1
  56. package/src/util/preflight.ts +44 -0
  57. package/src/util/retry.ts +29 -0
  58. package/src/util/tmp.ts +113 -0
package/dist/util/git.js CHANGED
@@ -1,9 +1,43 @@
1
1
  import { execa } from 'execa';
2
- import { cp, mkdir, mkdtemp, rm } from 'node:fs/promises';
2
+ import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
3
3
  import { existsSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { PreflightError } from './preflight.js';
7
+ /**
8
+ * Paths copperhead must keep out of `git add -A`. KiCad ≥9 writes a
9
+ * git-backed local-history directory (`.history/`, complete with its own nested
10
+ * `.git`) into the project the first time kicad-cli touches it. Left untracked,
11
+ * that nested repo has an unborn HEAD, so a plain `git add -A` in the parent
12
+ * aborts with `error: '.history/' does not have a commit checked out` (exit
13
+ * 128) — which fails the commit at the end of every KiCad-touching stage
14
+ * (schematic, layout, outputs). Ignoring it is both correct (local history is
15
+ * never a project artifact) and the fix for that abort. Kept as a list so other
16
+ * KiCad transients can join it if they surface.
17
+ */
18
+ const GIT_ADD_EXCLUDES = ['.history/'];
19
+ /**
20
+ * Ensure the repo's root .gitignore lists each entry, appending only the
21
+ * missing ones. Idempotent and best-effort: a failure here must never block a
22
+ * commit, so it swallows its own errors. Run before any `git add -A` so a
23
+ * git-backed KiCad `.history/` (or similar nested repo) is skipped instead of
24
+ * aborting the add.
25
+ */
26
+ export async function ensureIgnored(repo, entries) {
27
+ try {
28
+ const p = path.join(repo, '.gitignore');
29
+ const text = existsSync(p) ? await readFile(p, 'utf8') : '';
30
+ const present = new Set(text.split('\n').map((l) => l.trim()));
31
+ const missing = entries.filter((e) => !present.has(e));
32
+ if (!missing.length)
33
+ return;
34
+ const prefix = text.length && !text.endsWith('\n') ? '\n' : '';
35
+ await writeFile(p, text + prefix + missing.join('\n') + '\n', 'utf8');
36
+ }
37
+ catch {
38
+ // best-effort: .gitignore maintenance must never be the thing that fails a run
39
+ }
40
+ }
7
41
  async function git(repo, args) {
8
42
  const { stdout } = await execa('git', args, { cwd: repo });
9
43
  return stdout.trim();
@@ -130,6 +164,7 @@ export async function preserveFailedRun(repo, runId) {
130
164
  try {
131
165
  if (!(await isDirty(repo)))
132
166
  return null;
167
+ await ensureIgnored(repo, GIT_ADD_EXCLUDES);
133
168
  // Never leave the audit trail staged: a staged-but-not-in-HEAD path is
134
169
  // deleted by restore()'s `reset --hard`, which silently defeats its
135
170
  // `clean -e .copperhead/runs` protection (that flag only spares untracked
@@ -162,6 +197,7 @@ export async function uncommittedCount(repo) {
162
197
  return status ? status.split('\n').length : 0;
163
198
  }
164
199
  export async function commitAll(repo, message) {
200
+ await ensureIgnored(repo, GIT_ADD_EXCLUDES);
165
201
  await git(repo, ['add', '-A']);
166
202
  await git(repo, ['commit', '-m', message]);
167
203
  return git(repo, ['rev-parse', 'HEAD']);
@@ -1 +1 @@
1
- {"version":3,"file":"git.js","sourceRoot":"","sources":["../../src/util/git.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"git.js","sourceRoot":"","sources":["../../src/util/git.ts"],"names":[],"mappings":"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"}
@@ -1,3 +1,4 @@
1
+ import { statfs } from 'node:fs/promises';
1
2
  /**
2
3
  * A run-blocking environment failure. Distinct from a mid-run error: nothing
3
4
  * has been written yet, so the message alone is the whole user experience.
@@ -21,4 +22,40 @@ export function formatPreflightFailure(reason, why, remedy) {
21
22
  const steps = remedy.map((step, i) => ` ${i + 1}. ${step}`);
22
23
  return [reason, '', `why it failed: ${why}`, 'to fix:', ...steps].join('\n');
23
24
  }
25
+ /** Default minimum free space to start a run: 2 GiB. A create run emits gerbers,
26
+ * STEP, SVG renders and KiCad local history; 2 GiB is comfortably above a
27
+ * single board's output while still catching a nearly-full disk. */
28
+ export const DEFAULT_MIN_FREE_BYTES = 2 * 1024 * 1024 * 1024;
29
+ const gib = (n) => `${(n / 1024 / 1024 / 1024).toFixed(1)} GiB`;
30
+ /**
31
+ * Free bytes available to this (unprivileged) user on the filesystem holding
32
+ * `dir`, or null when the platform/Node build cannot report it — callers treat
33
+ * null as "unknown" and skip the check rather than blocking a legitimate run.
34
+ */
35
+ export async function freeDiskBytes(dir) {
36
+ try {
37
+ const fs = await statfs(dir);
38
+ return fs.bavail * fs.bsize;
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ }
44
+ /**
45
+ * Refuse to start when free disk is below `minFreeBytes` (4.1). A long run can
46
+ * fill the disk mid-stage — gerbers/STEP/SVG plus unbounded KiCad local history
47
+ * — and then fail with an opaque `ENOSPC` after doing real, expensive work. A
48
+ * preflight fails fast with an actionable message instead. An unknown reading
49
+ * (unsupported platform) skips the check.
50
+ */
51
+ export async function assertDiskSpace(dir, minFreeBytes = DEFAULT_MIN_FREE_BYTES) {
52
+ const free = await freeDiskBytes(dir);
53
+ if (free === null || free >= minFreeBytes)
54
+ return;
55
+ throw new PreflightError(`not enough free disk space to start (${gib(free)} available, ${gib(minFreeBytes)} required)`, 'a create run writes fabrication outputs and KiCad local history and can fill the disk mid-stage, failing with an opaque ENOSPC only after doing real work', [
56
+ 'free up space on the volume holding this repo',
57
+ 'or lower the threshold with COPPERHEAD_MIN_FREE_MB (e.g. COPPERHEAD_MIN_FREE_MB=500)',
58
+ 'then re-run',
59
+ ]);
60
+ }
24
61
  //# sourceMappingURL=preflight.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"preflight.js","sourceRoot":"","sources":["../../src/util/preflight.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,MAAM,OAAO,cAAe,SAAQ,KAAK;IAE5B;IACA;IACA;IAHX,YACW,MAAc,EACd,GAAW,EACX,MAAgB;QAEzB,KAAK,CAAC,sBAAsB,CAAC,MAAM,EAAE,GAAG,EAAE,MAAM,CAAC,CAAC,CAAC;QAJ1C,WAAM,GAAN,MAAM,CAAQ;QACd,QAAG,GAAH,GAAG,CAAQ;QACX,WAAM,GAAN,MAAM,CAAU;QAGzB,IAAI,CAAC,IAAI,GAAG,gBAAgB,CAAC;IAC/B,CAAC;CACF;AAED,MAAM,UAAU,sBAAsB,CAAC,MAAc,EAAE,GAAW,EAAE,MAAgB;IAClF,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,CAAC,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC;IAC7D,OAAO,CAAC,MAAM,EAAE,EAAE,EAAE,kBAAkB,GAAG,EAAE,EAAE,SAAS,EAAE,GAAG,KAAK,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAC/E,CAAC"}
1
+ {"version":3,"file":"preflight.js","sourceRoot":"","sources":["../../src/util/preflight.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAE1C;;;;;;GAMG;AACH,MAAM,OAAO,cAAe,SAAQ,KAAK;IAE5B;IACA;IACA;IAHX,YACW,MAAc,EACd,GAAW,EACX,MAAgB;QAEzB,KAAK,CAAC,sBAAsB,CAAC,MAAM,EAAE,GAAG,EAAE,MAAM,CAAC,CAAC,CAAC;QAJ1C,WAAM,GAAN,MAAM,CAAQ;QACd,QAAG,GAAH,GAAG,CAAQ;QACX,WAAM,GAAN,MAAM,CAAU;QAGzB,IAAI,CAAC,IAAI,GAAG,gBAAgB,CAAC;IAC/B,CAAC;CACF;AAED,MAAM,UAAU,sBAAsB,CAAC,MAAc,EAAE,GAAW,EAAE,MAAgB;IAClF,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,CAAC,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC;IAC7D,OAAO,CAAC,MAAM,EAAE,EAAE,EAAE,kBAAkB,GAAG,EAAE,EAAE,SAAS,EAAE,GAAG,KAAK,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAC/E,CAAC;AAED;;qEAEqE;AACrE,MAAM,CAAC,MAAM,sBAAsB,GAAG,CAAC,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;AAE7D,MAAM,GAAG,GAAG,CAAC,CAAS,EAAU,EAAE,CAAC,GAAG,CAAC,CAAC,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC;AAEhF;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,GAAW;IAC7C,IAAI,CAAC;QACH,MAAM,EAAE,GAAG,MAAM,MAAM,CAAC,GAAG,CAAC,CAAC;QAC7B,OAAO,EAAE,CAAC,MAAM,GAAG,EAAE,CAAC,KAAK,CAAC;IAC9B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CAAC,GAAW,EAAE,YAAY,GAAG,sBAAsB;IACtF,MAAM,IAAI,GAAG,MAAM,aAAa,CAAC,GAAG,CAAC,CAAC;IACtC,IAAI,IAAI,KAAK,IAAI,IAAI,IAAI,IAAI,YAAY;QAAE,OAAO;IAClD,MAAM,IAAI,cAAc,CACtB,wCAAwC,GAAG,CAAC,IAAI,CAAC,eAAe,GAAG,CAAC,YAAY,CAAC,YAAY,EAC7F,2JAA2J,EAC3J;QACE,+CAA+C;QAC/C,sFAAsF;QACtF,aAAa;KACd,CACF,CAAC;AACJ,CAAC"}
@@ -3,6 +3,29 @@ export function isRateLimit(err) {
3
3
  ?? err?.statusCode;
4
4
  return status === 429;
5
5
  }
6
+ /**
7
+ * Detect a saved-login SESSION / USAGE limit (claude-code, codex), distinct from
8
+ * an HTTP 429 rate limit and from a code bug (2.4, I13). It is not a transient
9
+ * blip to back off on: it names its own reset time and clears only then. Because
10
+ * every completed turn is already in `.copperhead/llm-cache/`, re-running after
11
+ * the reset replays them at ~0 tokens and resumes in place — so the right
12
+ * handling is a schedulable pause with the reset time surfaced, not a bare
13
+ * "provider error". Returns the parsed reset time (verbatim) or null when the
14
+ * error is not a session/usage limit.
15
+ */
16
+ export function sessionLimit(err) {
17
+ const status = err?.status
18
+ ?? err?.statusCode;
19
+ if (status === 429)
20
+ return null; // a real rate limit: handled by backoff, not a pause
21
+ const msg = err?.message ?? '';
22
+ if (!/(session|usage|weekly)\s+limit|hit your .*limit|reached your usage|limit .*reset/i.test(msg)) {
23
+ return null;
24
+ }
25
+ // "resets 1:40pm", "resets at 1:40 pm", "reset at 13:40" — capture the clock text.
26
+ const reset = msg.match(/reset[s]?(?:\s+at)?\s+([0-9]{1,2}(?::[0-9]{2})?\s*(?:am|pm)?)/i);
27
+ return { resetsAt: reset?.[1]?.trim() ?? null };
28
+ }
6
29
  /** Exponential backoff ×N for rate limits (SPEC §4.5). */
7
30
  export async function withRetry(fn, opts = {}) {
8
31
  const retries = opts.retries ?? 3;
@@ -1 +1 @@
1
- {"version":3,"file":"retry.js","sourceRoot":"","sources":["../../src/util/retry.ts"],"names":[],"mappings":"AAQA,MAAM,UAAU,WAAW,CAAC,GAAY;IACtC,MAAM,MAAM,GAAI,GAAgD,EAAE,MAAM;WAClE,GAA+B,EAAE,UAAU,CAAC;IAClD,OAAO,MAAM,KAAK,GAAG,CAAC;AACxB,CAAC;AAED,0DAA0D;AAC1D,MAAM,CAAC,KAAK,UAAU,SAAS,CAAI,EAAoB,EAAE,OAAkB,EAAE;IAC3E,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,IAAI,CAAC,CAAC;IAClC,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,IAAI,IAAI,CAAC;IACnC,MAAM,WAAW,GAAG,IAAI,CAAC,WAAW,IAAI,WAAW,CAAC;IACpD,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,IAAI,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,IAAI,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,UAAU,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC;IAC5E,IAAI,OAAgB,CAAC;IACrB,KAAK,IAAI,OAAO,GAAG,CAAC,EAAE,OAAO,IAAI,OAAO,EAAE,OAAO,EAAE,EAAE,CAAC;QACpD,IAAI,CAAC;YACH,OAAO,MAAM,EAAE,EAAE,CAAC;QACpB,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,OAAO,GAAG,GAAG,CAAC;YACd,IAAI,CAAC,WAAW,CAAC,GAAG,CAAC,IAAI,OAAO,KAAK,OAAO;gBAAE,MAAM,GAAG,CAAC;YACxD,IAAI,CAAC,OAAO,EAAE,CAAC,OAAO,GAAG,CAAC,EAAE,GAAG,CAAC,CAAC;YACjC,MAAM,KAAK,CAAC,MAAM,GAAG,CAAC,IAAI,OAAO,CAAC,CAAC;QACrC,CAAC;IACH,CAAC;IACD,MAAM,OAAO,CAAC;AAChB,CAAC"}
1
+ {"version":3,"file":"retry.js","sourceRoot":"","sources":["../../src/util/retry.ts"],"names":[],"mappings":"AAQA,MAAM,UAAU,WAAW,CAAC,GAAY;IACtC,MAAM,MAAM,GAAI,GAAgD,EAAE,MAAM;WAClE,GAA+B,EAAE,UAAU,CAAC;IAClD,OAAO,MAAM,KAAK,GAAG,CAAC;AACxB,CAAC;AAQD;;;;;;;;;GASG;AACH,MAAM,UAAU,YAAY,CAAC,GAAY;IACvC,MAAM,MAAM,GAAI,GAAgD,EAAE,MAAM;WAClE,GAA+B,EAAE,UAAU,CAAC;IAClD,IAAI,MAAM,KAAK,GAAG;QAAE,OAAO,IAAI,CAAC,CAAC,qDAAqD;IACtF,MAAM,GAAG,GAAI,GAAa,EAAE,OAAO,IAAI,EAAE,CAAC;IAC1C,IAAI,CAAC,mFAAmF,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;QACnG,OAAO,IAAI,CAAC;IACd,CAAC;IACD,mFAAmF;IACnF,MAAM,KAAK,GAAG,GAAG,CAAC,KAAK,CAAC,gEAAgE,CAAC,CAAC;IAC1F,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,IAAI,IAAI,EAAE,CAAC;AAClD,CAAC;AAED,0DAA0D;AAC1D,MAAM,CAAC,KAAK,UAAU,SAAS,CAAI,EAAoB,EAAE,OAAkB,EAAE;IAC3E,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,IAAI,CAAC,CAAC;IAClC,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,IAAI,IAAI,CAAC;IACnC,MAAM,WAAW,GAAG,IAAI,CAAC,WAAW,IAAI,WAAW,CAAC;IACpD,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,IAAI,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,IAAI,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,UAAU,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC;IAC5E,IAAI,OAAgB,CAAC;IACrB,KAAK,IAAI,OAAO,GAAG,CAAC,EAAE,OAAO,IAAI,OAAO,EAAE,OAAO,EAAE,EAAE,CAAC;QACpD,IAAI,CAAC;YACH,OAAO,MAAM,EAAE,EAAE,CAAC;QACpB,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,OAAO,GAAG,GAAG,CAAC;YACd,IAAI,CAAC,WAAW,CAAC,GAAG,CAAC,IAAI,OAAO,KAAK,OAAO;gBAAE,MAAM,GAAG,CAAC;YACxD,IAAI,CAAC,OAAO,EAAE,CAAC,OAAO,GAAG,CAAC,EAAE,GAAG,CAAC,CAAC;YACjC,MAAM,KAAK,CAAC,MAAM,GAAG,CAAC,IAAI,OAAO,CAAC,CAAC;QACrC,CAAC;IACH,CAAC;IACD,MAAM,OAAO,CAAC;AAChB,CAAC"}
@@ -0,0 +1,119 @@
1
+ import { readdir, stat, rm } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import path from 'node:path';
4
+ /** Newest local-history entries to keep when capping `.history/` (see
5
+ * pruneHistoryDir). Enough to preserve a useful recovery window, bounded enough
6
+ * that the dir cannot grow without limit across a long run. */
7
+ export const DEFAULT_HISTORY_KEEP = 200;
8
+ /**
9
+ * Cap the growth of a repo's `.history/` directory (4.1). KiCad (and editor
10
+ * local-history) rewrite a snapshot on every project touch, so across a long
11
+ * run `.history/` grows without bound and was a contributor to the disk-fill
12
+ * halt (I8). It is gitignored, so its contents are disposable: keep the newest
13
+ * `keepNewest` files by mtime and remove the rest. Recursive (local history
14
+ * mirrors the workspace tree), best-effort (every error is swallowed — pruning
15
+ * housekeeping must never fail a run), and a no-op when the dir is absent or
16
+ * already under the cap. Returns the number of files removed.
17
+ */
18
+ export async function pruneHistoryDir(repoRoot, keepNewest = DEFAULT_HISTORY_KEEP) {
19
+ const root = path.join(repoRoot, '.history');
20
+ const files = [];
21
+ const walk = async (dir) => {
22
+ let entries;
23
+ try {
24
+ entries = await readdir(dir, { withFileTypes: true });
25
+ }
26
+ catch {
27
+ return; // unreadable dir — skip it
28
+ }
29
+ for (const e of entries) {
30
+ const full = path.join(dir, e.name);
31
+ if (e.isDirectory()) {
32
+ await walk(full);
33
+ }
34
+ else if (e.isFile()) {
35
+ try {
36
+ const st = await stat(full);
37
+ files.push({ full, mtimeMs: st.mtimeMs });
38
+ }
39
+ catch {
40
+ // stat race — skip this file
41
+ }
42
+ }
43
+ }
44
+ };
45
+ await walk(root);
46
+ if (files.length <= keepNewest)
47
+ return 0;
48
+ files.sort((a, b) => b.mtimeMs - a.mtimeMs); // newest first
49
+ let removed = 0;
50
+ for (const f of files.slice(keepNewest)) {
51
+ try {
52
+ await rm(f.full, { force: true });
53
+ removed++;
54
+ }
55
+ catch {
56
+ // permission/race — leave it, keep pruning the rest
57
+ }
58
+ }
59
+ return removed;
60
+ }
61
+ /**
62
+ * Every scratch dir copperhead makes under the OS temp dir shares this prefix:
63
+ * kicad-cli ERC/DRC (`copperhead-`), the KiCad edit probe (`copperhead-validate-`),
64
+ * the failed-run backup (`copperhead-runs-`), and the provider working dirs
65
+ * (`copperhead-cc-`, `copperhead-codex-`). Each site removes its own dir in a
66
+ * `finally`, but a watchdog SIGKILL of the process tree or a hard abort skips
67
+ * that cleanup, so stale dirs accumulate across runs and can eventually fill the
68
+ * disk (I8). A prefix match lets one sweep reclaim all of them.
69
+ */
70
+ export const TEMP_PREFIX = 'copperhead-';
71
+ /** Default staleness cutoff for the startup sweep: 2h. Safe even for multi-hour
72
+ * runs (10-min turns × per-stage retries × 8 stages): a live run's only
73
+ * long-lived scratch dir is the provider's reused cwd, which is `utimes`-touched
74
+ * every turn (ClaudeCodeProvider.ensureCwd), so its mtime never goes stale while
75
+ * the process is alive; per-call kicad-cli dirs are removed within a turn. A dir
76
+ * older than this therefore belongs to a dead run, and the window is short enough
77
+ * that such a leak is reclaimed on the very next invocation. */
78
+ export const DEFAULT_STALE_MS = 2 * 60 * 60 * 1000;
79
+ /**
80
+ * Remove leaked `copperhead-*` scratch dirs left in the OS temp dir by earlier
81
+ * runs whose `finally` cleanup was skipped (watchdog kill / hard abort). Only
82
+ * dirs whose mtime is older than `maxAgeMs` are removed, so a concurrent run's
83
+ * fresh scratch dirs are never touched. Best-effort: every error is swallowed
84
+ * (a temp dir we can't stat or remove is not worth failing a run over), and the
85
+ * function returns the paths it removed so a caller can log the reclaim.
86
+ *
87
+ * `now` is injected so the behaviour is deterministically testable; callers pass
88
+ * `Date.now()`.
89
+ */
90
+ export async function sweepStaleTempDirs(now, maxAgeMs = DEFAULT_STALE_MS) {
91
+ const root = tmpdir();
92
+ const removed = [];
93
+ let entries;
94
+ try {
95
+ entries = await readdir(root);
96
+ }
97
+ catch {
98
+ return removed; // no temp dir / not readable — nothing to sweep
99
+ }
100
+ for (const name of entries) {
101
+ if (!name.startsWith(TEMP_PREFIX))
102
+ continue;
103
+ const full = path.join(root, name);
104
+ try {
105
+ const st = await stat(full);
106
+ if (!st.isDirectory())
107
+ continue;
108
+ if (now - st.mtimeMs < maxAgeMs)
109
+ continue; // too fresh: could be a live run
110
+ await rm(full, { recursive: true, force: true });
111
+ removed.push(full);
112
+ }
113
+ catch {
114
+ // stat/rm race or permission issue — skip this entry, keep sweeping.
115
+ }
116
+ }
117
+ return removed;
118
+ }
119
+ //# sourceMappingURL=tmp.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tmp.js","sourceRoot":"","sources":["../../src/util/tmp.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "copperhead",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Cursor for circuit boards: an AI agent that designs, documents, and validates real PCBs on KiCad repositories",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Animesh Chouhan <animeshchouhan@outlook.com>",
package/src/agent/loop.ts CHANGED
@@ -3,15 +3,17 @@ import { readFile, writeFile } from 'node:fs/promises';
3
3
  import { execa } from 'execa';
4
4
  import type { Msg, Provider, Turn } from './types.js';
5
5
  import { availableTools, dispatchTool, type RunContext } from './tools.js';
6
+ import { CachingProvider } from './response-cache.js';
7
+ import { withTimeout, TurnTimeoutError } from './recovery.js';
6
8
  import { buildSystemPrompt } from './prompts.js';
7
9
  import { loadConstraints, reopenDeferredAffects } from '../memory/constraints.js';
8
- import { loadConfig, type CopperheadConfig } from '../config.js';
10
+ import { loadConfig, CONFIG_DIR, type CopperheadConfig } from '../config.js';
9
11
  import { Transcript, type ExitPath, type RunStats } from './transcript.js';
10
12
  import { collectRunMeta, renderCliHeader, type RunMeta, type RunMetaInput } from './runmeta.js';
11
13
  import { plainRenderer, fmtDuration, fmtTokens, type ProgressRenderer } from './render.js';
12
14
  import { ObligationsLedger } from './ledger.js';
13
15
  import { gitPreflight, isDirty, snapshot, restore, commitAll, changedFiles, preserveFailedRun } from '../util/git.js';
14
- import { withRetry, isRateLimit } from '../util/retry.js';
16
+ import { withRetry, isRateLimit, sessionLimit } from '../util/retry.js';
15
17
  import { openspecArchive } from '../openspec/cli.js';
16
18
  import { existsSync } from 'node:fs';
17
19
  import { OpenAIProvider } from './providers/openai.js';
@@ -63,9 +65,14 @@ export interface RunResult {
63
65
  transcriptDir: string;
64
66
  filesTouched: string[];
65
67
  commit: string | null;
68
+ /** Cost/telemetry for this run. Surfaced by the create pipeline's per-stage
69
+ * cost table (5.2) so the expensive stages are obvious across runs. */
70
+ stats: RunStats;
71
+ /** Number of turns served from the on-disk response cache (5.2). */
72
+ cacheHits: number;
66
73
  }
67
74
 
68
- export async function makeProvider(model: string): Promise<Provider> {
75
+ export async function makeProvider(model: string, sessionResume = false): Promise<Provider> {
69
76
  if (model === 'codex' || model.startsWith('codex:')) {
70
77
  const codexModel = model.startsWith('codex:') ? model.slice('codex:'.length) : undefined;
71
78
  if (codexModel === '') throw new Error('codex model override cannot be empty; use "codex" or "codex:<model-id>"');
@@ -91,7 +98,7 @@ export async function makeProvider(model: string): Promise<Provider> {
91
98
  if (claudeCodeModel === '') {
92
99
  throw new Error('claude-code model override cannot be empty; use "claude-code" or "claude-code:<model-id>"');
93
100
  }
94
- return new ClaudeCodeProvider(claudeCodeModel);
101
+ return new ClaudeCodeProvider(claudeCodeModel, undefined, undefined, sessionResume);
95
102
  }
96
103
  if (model === 'claude' || model.startsWith('claude')) {
97
104
  return new AnthropicProvider(model === 'claude' ? undefined : model);
@@ -198,8 +205,23 @@ async function runWithMemory(
198
205
  finishRequest: null,
199
206
  };
200
207
 
201
- let provider = opts.provider ?? (await makeProvider(opts.model));
208
+ // Session resume for claude-code (1.1) is only correct when the response cache
209
+ // is off: the cache replays turns a resumed session never saw. So enable it
210
+ // only when the env flag is set AND config.llmCache is disabled — the same
211
+ // condition under which we skip the CachingProvider wrap below.
212
+ const sessionResume = process.env.COPPERHEAD_CC_SESSION_RESUME === '1' && !config.llmCache;
213
+ let provider = opts.provider ?? (await makeProvider(opts.model, sessionResume));
214
+ // Cache every turn's response so a retried/restarted stage replays what it
215
+ // already paid for instead of re-calling the model (repo-scoped, cross-run).
216
+ // Skip an injected provider (tests drive scripted providers directly).
217
+ if (config.llmCache && !opts.provider) {
218
+ provider = new CachingProvider(provider, path.join(repoRoot, CONFIG_DIR, 'llm-cache'), log, opts.model);
219
+ }
202
220
  providers.add(provider);
221
+ // Held separately from `provider` (which is reassigned on failover) so the
222
+ // final cache-hit count survives a mid-run provider switch (5.2).
223
+ const cachingProvider = provider instanceof CachingProvider ? provider : null;
224
+ const cacheHits = (): number => cachingProvider?.cacheHits ?? 0;
203
225
 
204
226
  // Deterministic, LLM-free metadata block: collected once, rendered onto all
205
227
  // three surfaces (run-start event, summary ## Environment, CLI header) so
@@ -277,6 +299,8 @@ async function runWithMemory(
277
299
  const perTurn: { turn: number; in: number; out: number }[] = [];
278
300
  let plan: string | null = null;
279
301
  let nudges = 0;
302
+ let turnTimeouts = 0;
303
+ const maxTurnTimeouts = 3;
280
304
 
281
305
  const stats = (exitPath: ExitPath): RunStats => ({
282
306
  exitPath,
@@ -356,6 +380,8 @@ async function runWithMemory(
356
380
  transcriptDir: transcript.dir,
357
381
  filesTouched: [],
358
382
  commit: null,
383
+ stats: runStats,
384
+ cacheHits: cacheHits(),
359
385
  };
360
386
  };
361
387
 
@@ -388,15 +414,64 @@ async function runWithMemory(
388
414
  await transcript.event('budget-extended', { extraTurns: extra, budget, ...exhaustStats });
389
415
  log(`turn budget extended by ${extra} (now ${budget})`);
390
416
  }
417
+ // Advertise EVERY tool each turn; dispatchTool enforces the edit-unlock gate
418
+ // live at call time. Hiding locked edit tools from the turn catalog meant a
419
+ // model that unlocked (validate_change) and edited in the SAME reply had its
420
+ // edit silently dropped in parsing — the call named a tool the turn had not
421
+ // advertised, so it was treated as prose, executed nothing, and returned no
422
+ // error. The model then "verified" against an unchanged file (an empty
423
+ // schematic even passes ERC) and finished believing it had succeeded.
424
+ // Structural lock (SPEC.md §1.3 invariant 1): the edit tools stay OUT of the
425
+ // advertised list until a proposal validates (`editsUnlocked`), so the model
426
+ // is gated by omission, not by prompt text. `dispatchTool` re-checks the same
427
+ // `availableTools(ctx)` live, so this is defense in depth. A premature edit is
428
+ // simply not offered; once `validate_change` unlocks, the next turn advertises
429
+ // the edit tools. (Earlier this advertised every tool to let a same-turn
430
+ // propose→validate→edit batch through, but that traded the spec's structural
431
+ // guarantee for one saved turn — not worth it.)
391
432
  const tools = availableTools(ctx).map((t) => t.schema);
392
433
  r.turnStart(turn + 1, maxTurns, tokensIn, tokensOut);
393
434
  r.status('thinking');
394
435
  let res: Turn;
436
+ // Liveness heartbeat (5.1): a large-output turn can legitimately run several
437
+ // minutes, which is otherwise indistinguishable from a hung subprocess until
438
+ // the watchdog fires. Emit a periodic elapsed/streamed signal so an operator
439
+ // can tell the two apart. Fires only after the first interval, so quick turns
440
+ // stay silent; `unref` keeps it from holding the event loop open.
441
+ const turnStartMs = Date.now();
442
+ let streamedChars = 0;
443
+ const heartbeat =
444
+ config.heartbeatMs > 0
445
+ ? setInterval(
446
+ () => r.heartbeat({ elapsedMs: Date.now() - turnStartMs, streamedChars }),
447
+ config.heartbeatMs,
448
+ )
449
+ : null;
450
+ heartbeat?.unref?.();
395
451
  try {
396
- res = await withRetry(() => provider.chat(messages, tools), {
397
- onRetry: (attempt) => log(`rate limited; retry ${attempt}`),
398
- });
452
+ res = await withRetry(
453
+ () =>
454
+ withTimeout(
455
+ () => provider.chat(messages, tools, { onStream: (chars) => (streamedChars = chars) }),
456
+ config.turnTimeoutMs,
457
+ () => provider.close?.(),
458
+ ),
459
+ { onRetry: (attempt) => log(`rate limited; retry ${attempt}`) },
460
+ );
399
461
  } catch (err) {
462
+ if (err instanceof TurnTimeoutError) {
463
+ // A hung provider turn: the watchdog aborted the in-flight call and tore
464
+ // down its subprocess. Retry the same turn a bounded number of times
465
+ // before giving up, so a transient hang self-heals instead of stalling
466
+ // the run forever.
467
+ if (turnTimeouts++ < maxTurnTimeouts) {
468
+ log(`turn exceeded ${config.turnTimeoutMs}ms; aborted the hung call and retrying (${turnTimeouts}/${maxTurnTimeouts})`);
469
+ await transcript.event('turn-timeout', { ms: config.turnTimeoutMs, attempt: turnTimeouts });
470
+ turn--;
471
+ continue;
472
+ }
473
+ return fail(`provider turns timed out ${turnTimeouts}× (>${config.turnTimeoutMs}ms each)`, 'provider-error');
474
+ }
400
475
  if (isRateLimit(err)) {
401
476
  const fallback = otherProvider(provider);
402
477
  if (fallback) {
@@ -408,11 +483,34 @@ async function runWithMemory(
408
483
  continue;
409
484
  }
410
485
  }
486
+ // A saved-login session/usage limit is not a code bug and not a 429 (2.4,
487
+ // I13): it names its own reset time and clears only then, and every turn
488
+ // so far is already in the llm-cache — so re-running after the reset
489
+ // replays them at ~0 tokens and resumes in place. Surface it as its own
490
+ // exit path with the reset time and the resume instruction, rather than a
491
+ // bare "provider error" the operator would read as a failure to debug.
492
+ const limit = sessionLimit(err);
493
+ if (limit) {
494
+ const when = limit.resetsAt ? ` (resets ${limit.resetsAt})` : '';
495
+ await transcript.event('session-limit', { resetsAt: limit.resetsAt, provider: provider.name });
496
+ return fail(
497
+ `${provider.name} session/usage limit reached${when} — this is a schedulable pause, not a bug. ` +
498
+ `Wait for the reset, then re-run the same command: completed turns replay from the cache at ~0 tokens and the run resumes where it left off.`,
499
+ 'session-limit',
500
+ );
501
+ }
411
502
  return fail(`provider error: ${(err as Error).message}`, 'provider-error');
412
503
  } finally {
504
+ if (heartbeat) clearInterval(heartbeat);
413
505
  r.status(null);
414
506
  }
415
507
  turnsUsed = turn + 1;
508
+ // A productive turn resets the timeout budget: maxTurnTimeouts is meant to
509
+ // catch a turn that is genuinely, repeatedly stuck — not to cap the total
510
+ // number of slow-but-recoverable turns across a whole stage. Without this a
511
+ // long stage that merely has a few independent slow turns accumulates
512
+ // timeouts and hard-fails even though every one of them recovered.
513
+ turnTimeouts = 0;
416
514
  tokensIn += res.usage.inputTokens;
417
515
  tokensOut += res.usage.outputTokens;
418
516
  perTurn.push({ turn: turn + 1, in: res.usage.inputTokens, out: res.usage.outputTokens });
@@ -432,7 +530,9 @@ async function runWithMemory(
432
530
  if (nudges++ >= 2) return fail('model stopped calling tools without finishing', 'stalled');
433
531
  messages.push({
434
532
  role: 'user',
435
- content: 'Continue using tools, or call finish({outcome, summary}) to end the run.',
533
+ // A near-miss malformed tool call (#I10) gets a specific steer to re-emit
534
+ // it; an ordinary tool-less turn gets the generic continue prompt.
535
+ content: res.nudge ?? 'Continue using tools, or call finish({outcome, summary}) to end the run.',
436
536
  });
437
537
  continue;
438
538
  }
@@ -502,6 +602,8 @@ async function runWithMemory(
502
602
  transcriptDir: transcript.dir,
503
603
  filesTouched: [],
504
604
  commit: null,
605
+ stats: runStats,
606
+ cacheHits: cacheHits(),
505
607
  };
506
608
  }
507
609
 
@@ -547,16 +649,32 @@ async function runWithMemory(
547
649
  transcriptDir: transcript.dir,
548
650
  filesTouched: files,
549
651
  commit: null,
652
+ stats: runStats,
653
+ cacheHits: cacheHits(),
550
654
  };
551
655
  }
552
656
 
553
- await appendChangelog(repoRoot, config, {
554
- changeId: ctx.changeId,
555
- request: opts.request,
556
- files,
557
- verification,
558
- });
559
- ctx.ledger.clear('changelog');
657
+ // Bookkeeping must never cost the verified design its commit (2.1): the
658
+ // KiCad work passed its ERC/DRC gates, so a failure appending the changelog
659
+ // (a plain CHANGELOG.md read+write) is a warning, not a rollback. It stays
660
+ // before commitAll so, on the normal path, the entry lands in the run's
661
+ // single commit and a zero-edit "done" run still has something to commit;
662
+ // if it throws, the design is committed without a changelog line rather
663
+ // than sent through fail()'s rollback. The other bookkeeping — the openspec
664
+ // archive — is already post-commit and non-fatal below.
665
+ try {
666
+ await appendChangelog(repoRoot, config, {
667
+ changeId: ctx.changeId,
668
+ request: opts.request,
669
+ files,
670
+ verification,
671
+ });
672
+ ctx.ledger.clear('changelog');
673
+ } catch (err) {
674
+ const message = (err as Error).message;
675
+ log(`warning: changelog append failed (${message}); committing the verified design without a changelog entry`);
676
+ await transcript.event('changelog-append-failed', { error: message });
677
+ }
560
678
 
561
679
  const commitMsg = `copperhead: ${opts.request}\n\n${summary}\n\nVerification: ${verification}`;
562
680
  // A git failure here (e.g. `git add -A` exiting 128 on an embedded repo)
@@ -619,6 +737,8 @@ async function runWithMemory(
619
737
  transcriptDir: transcript.dir,
620
738
  filesTouched: files,
621
739
  commit,
740
+ stats: runStats,
741
+ cacheHits: cacheHits(),
622
742
  };
623
743
  }
624
744
  }
@@ -23,7 +23,8 @@ const WORKFLOW = `Workflow for every run:
23
23
  6. Record every non-trivial decision with record_decision, and every stated/assumed/discovered constraint with record_constraint.
24
24
  7. Call finish with outcome "done" when everything is verified, or outcome "refuse" (citing the violated budget/constraint) if the request should not be done. finish will list any unmet obligations; resolve them and call it again.
25
25
 
26
- Turns are the scarce resource, not tool calls: the run has a hard turn budget, and every tool call in one reply executes in the same turn. When calls are independent — multiple record_constraint or resolve_affected calls (use resolutions: [...] to clear a backlog in one call), several read_file calls — issue them together in a single reply instead of one per turn.`;
26
+ Turns are the scarce resource, not tool calls: the run has a hard turn budget, and every tool call in one reply executes in the same turn. When calls are independent — multiple record_constraint or resolve_affected calls (use resolutions: [...] to clear a backlog in one call), several read_file calls — issue them together in a single reply instead of one per turn.
27
+ Always send a populated \`args\` object that matches the tool's JSON Schema (e.g. read_file needs {"path": "..."}). Never open a stage with an empty-args call to probe a tool — it only returns an error and burns a whole turn.`;
27
28
 
28
29
  export async function buildSystemPrompt(
29
30
  repoRoot: string,