copperhead 0.6.0 → 0.8.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 (118) hide show
  1. package/README.md +36 -4
  2. package/dist/agent/animate.js +76 -0
  3. package/dist/agent/animate.js.map +1 -0
  4. package/dist/agent/box.js +89 -0
  5. package/dist/agent/box.js.map +1 -0
  6. package/dist/agent/dock-renderer.js +173 -0
  7. package/dist/agent/dock-renderer.js.map +1 -0
  8. package/dist/agent/logo.js +21 -0
  9. package/dist/agent/logo.js.map +1 -0
  10. package/dist/agent/loop.js +130 -18
  11. package/dist/agent/loop.js.map +1 -1
  12. package/dist/agent/prompts.js +2 -1
  13. package/dist/agent/prompts.js.map +1 -1
  14. package/dist/agent/providers/claude-code.js +85 -116
  15. package/dist/agent/providers/claude-code.js.map +1 -1
  16. package/dist/agent/providers/cursor.js +317 -0
  17. package/dist/agent/providers/cursor.js.map +1 -0
  18. package/dist/agent/providers/tool-protocol.js +205 -0
  19. package/dist/agent/providers/tool-protocol.js.map +1 -0
  20. package/dist/agent/recovery.js +148 -0
  21. package/dist/agent/recovery.js.map +1 -0
  22. package/dist/agent/render.js +44 -12
  23. package/dist/agent/render.js.map +1 -1
  24. package/dist/agent/response-cache.js +81 -0
  25. package/dist/agent/response-cache.js.map +1 -0
  26. package/dist/agent/runmeta.js +4 -5
  27. package/dist/agent/runmeta.js.map +1 -1
  28. package/dist/agent/theme.js +84 -0
  29. package/dist/agent/theme.js.map +1 -0
  30. package/dist/agent/tools.js +61 -4
  31. package/dist/agent/tools.js.map +1 -1
  32. package/dist/agent/transcript.js.map +1 -1
  33. package/dist/cli.js +134 -13
  34. package/dist/cli.js.map +1 -1
  35. package/dist/commands/create.js +482 -38
  36. package/dist/commands/create.js.map +1 -1
  37. package/dist/commands/demo.js +146 -0
  38. package/dist/commands/demo.js.map +1 -0
  39. package/dist/commands/doctor.js +240 -0
  40. package/dist/commands/doctor.js.map +1 -0
  41. package/dist/commands/repl-inspect.js +342 -0
  42. package/dist/commands/repl-inspect.js.map +1 -0
  43. package/dist/commands/repl.js +618 -0
  44. package/dist/commands/repl.js.map +1 -0
  45. package/dist/config.js +24 -2
  46. package/dist/config.js.map +1 -1
  47. package/dist/kicad/bootstrap.js +166 -0
  48. package/dist/kicad/bootstrap.js.map +1 -0
  49. package/dist/kicad/cli.js +126 -6
  50. package/dist/kicad/cli.js.map +1 -1
  51. package/dist/kicad/spice.js +306 -0
  52. package/dist/kicad/spice.js.map +1 -0
  53. package/dist/kicad/symlib.js +228 -0
  54. package/dist/kicad/symlib.js.map +1 -0
  55. package/dist/memory/bom-table.js +193 -22
  56. package/dist/memory/bom-table.js.map +1 -1
  57. package/dist/memory/drift.js +33 -11
  58. package/dist/memory/drift.js.map +1 -1
  59. package/dist/util/cli-args.js +35 -0
  60. package/dist/util/cli-args.js.map +1 -0
  61. package/dist/util/dock.js +155 -0
  62. package/dist/util/dock.js.map +1 -0
  63. package/dist/util/git.js +165 -4
  64. package/dist/util/git.js.map +1 -1
  65. package/dist/util/live-prompt.js +542 -0
  66. package/dist/util/live-prompt.js.map +1 -0
  67. package/dist/util/paths.js +9 -0
  68. package/dist/util/paths.js.map +1 -1
  69. package/dist/util/preflight.js +37 -0
  70. package/dist/util/preflight.js.map +1 -1
  71. package/dist/util/retry.js +23 -0
  72. package/dist/util/retry.js.map +1 -1
  73. package/dist/util/select.js +172 -0
  74. package/dist/util/select.js.map +1 -0
  75. package/dist/util/tmp.js +119 -0
  76. package/dist/util/tmp.js.map +1 -0
  77. package/package.json +3 -2
  78. package/src/agent/animate.ts +90 -0
  79. package/src/agent/box.ts +99 -0
  80. package/src/agent/dock-renderer.ts +181 -0
  81. package/src/agent/logo.ts +23 -0
  82. package/src/agent/loop.ts +148 -18
  83. package/src/agent/prompts.ts +2 -1
  84. package/src/agent/providers/claude-code.ts +91 -122
  85. package/src/agent/providers/cursor.ts +364 -0
  86. package/src/agent/providers/tool-protocol.ts +212 -0
  87. package/src/agent/recovery.ts +162 -0
  88. package/src/agent/render.ts +56 -12
  89. package/src/agent/response-cache.ts +80 -0
  90. package/src/agent/runmeta.ts +6 -7
  91. package/src/agent/theme.ts +91 -0
  92. package/src/agent/tools.ts +62 -4
  93. package/src/agent/transcript.ts +1 -0
  94. package/src/agent/types.ts +17 -0
  95. package/src/cli.ts +139 -15
  96. package/src/commands/create.ts +581 -40
  97. package/src/commands/demo.ts +184 -0
  98. package/src/commands/doctor.ts +289 -0
  99. package/src/commands/repl-inspect.ts +353 -0
  100. package/src/commands/repl.ts +685 -0
  101. package/src/config.ts +40 -3
  102. package/src/kicad/bootstrap.ts +181 -0
  103. package/src/kicad/cli.ts +132 -7
  104. package/src/kicad/spice.ts +399 -0
  105. package/src/kicad/symlib.ts +248 -0
  106. package/src/layout/claude-ui-layout.md +72 -0
  107. package/src/layout/repl-ui-layout.md +139 -0
  108. package/src/memory/bom-table.ts +191 -20
  109. package/src/memory/drift.ts +42 -11
  110. package/src/util/cli-args.ts +42 -0
  111. package/src/util/dock.ts +161 -0
  112. package/src/util/git.ts +176 -4
  113. package/src/util/live-prompt.ts +595 -0
  114. package/src/util/paths.ts +10 -0
  115. package/src/util/preflight.ts +44 -0
  116. package/src/util/retry.ts +29 -0
  117. package/src/util/select.ts +192 -0
  118. package/src/util/tmp.ts +113 -0
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Minimal arrow-key select menu for TTY prompts (no extra deps).
3
+ * Renders an in-place "dropdown" navigable with ↑/↓ + Enter.
4
+ */
5
+
6
+ import { copper, dim } from '../agent/theme.js';
7
+
8
+ export interface SelectItem {
9
+ value: string;
10
+ label: string;
11
+ description?: string;
12
+ }
13
+
14
+ export interface SelectOptions {
15
+ title?: string;
16
+ items: SelectItem[];
17
+ input?: NodeJS.ReadStream;
18
+ output?: NodeJS.WriteStream;
19
+ /** Injected key stream for tests. */
20
+ keys?: AsyncIterable<string>;
21
+ }
22
+
23
+ const HIDE = '\x1b[?25l';
24
+ const SHOW = '\x1b[?25h';
25
+ const CLEAR_LINE = '\r\x1b[2K';
26
+
27
+ function menuLines(title: string, items: SelectItem[], index: number): string[] {
28
+ const width = Math.max(...items.map((i) => i.label.length), 8);
29
+ return [
30
+ copper(` ${title}`),
31
+ dim(' ↑/↓ move · Enter select · Esc cancel'),
32
+ '',
33
+ ...items.map((item, i) => {
34
+ const cursor = i === index ? copper('❯') : ' ';
35
+ const label = i === index ? copper(item.label.padEnd(width)) : dim(item.label.padEnd(width));
36
+ const desc = item.description ? dim(` ${item.description}`) : '';
37
+ return ` ${cursor} ${label}${desc}`;
38
+ }),
39
+ '',
40
+ ];
41
+ }
42
+
43
+ async function* stdinKeys(input: NodeJS.ReadStream): AsyncGenerator<string> {
44
+ // Listener-based on purpose: `for await` over a Readable destroys the
45
+ // stream when the consumer breaks out, which would kill stdin for
46
+ // whatever runs after the menu (e.g. the REPL's KeyReader).
47
+ const wasRaw = input.isRaw;
48
+ if (typeof input.setRawMode === 'function') input.setRawMode(true);
49
+ input.resume();
50
+ input.setEncoding('utf8');
51
+
52
+ const queue: string[] = [];
53
+ let ended = false;
54
+ let notify: (() => void) | null = null;
55
+ const onData = (c: string | Buffer): void => {
56
+ queue.push(String(c));
57
+ notify?.();
58
+ };
59
+ const onEnd = (): void => {
60
+ ended = true;
61
+ notify?.();
62
+ };
63
+ input.on('data', onData);
64
+ input.on('end', onEnd);
65
+
66
+ try {
67
+ for (;;) {
68
+ while (queue.length) {
69
+ const s = queue.shift()!;
70
+ let i = 0;
71
+ while (i < s.length) {
72
+ if (s[i] === '\x1b' && s[i + 1] === '[') {
73
+ yield s.slice(i, i + 3);
74
+ i += 3;
75
+ continue;
76
+ }
77
+ // Lone Esc
78
+ if (s[i] === '\x1b') {
79
+ yield '\x1b';
80
+ i += 1;
81
+ continue;
82
+ }
83
+ yield s[i]!;
84
+ i += 1;
85
+ }
86
+ }
87
+ if (ended) return;
88
+ await new Promise<void>((resolve) => {
89
+ notify = resolve;
90
+ });
91
+ notify = null;
92
+ }
93
+ } finally {
94
+ input.off('data', onData);
95
+ input.off('end', onEnd);
96
+ input.pause();
97
+ if (typeof input.setRawMode === 'function') input.setRawMode(wasRaw ?? false);
98
+ }
99
+ }
100
+
101
+ /** Model choices offered when no model is configured anywhere. */
102
+ export const MODEL_CHOICES: SelectItem[] = [
103
+ { value: 'claude-code', label: 'claude-code', description: 'Claude Code CLI (saved login)' },
104
+ { value: 'codex', label: 'codex', description: 'Codex CLI (saved login)' },
105
+ { value: 'cursor', label: 'cursor', description: 'Cursor CLI (saved login)' },
106
+ { value: 'claude', label: 'claude', description: 'Anthropic API (needs ANTHROPIC_API_KEY)' },
107
+ { value: 'gpt-5', label: 'gpt-5', description: 'OpenAI API (needs OPENAI_API_KEY)' },
108
+ ];
109
+
110
+ /**
111
+ * Interactive model picker for a TTY session with no configured model.
112
+ * Resolves to the chosen model id, or null if cancelled.
113
+ */
114
+ export async function pickModel(opts: Omit<SelectOptions, 'title' | 'items'> = {}): Promise<string | null> {
115
+ return selectMenu({ title: 'Select a model for this session', items: MODEL_CHOICES, ...opts });
116
+ }
117
+
118
+ /**
119
+ * Show a selectable list. Resolves to the chosen value, or null if cancelled.
120
+ */
121
+ export async function selectMenu(opts: SelectOptions): Promise<string | null> {
122
+ const items = opts.items;
123
+ if (!items.length) return null;
124
+
125
+ const input = opts.input ?? process.stdin;
126
+ const output = opts.output ?? process.stdout;
127
+ const title = opts.title ?? 'Select';
128
+ let index = 0;
129
+ let lineCount = 0;
130
+
131
+ const paint = (): void => {
132
+ const lines = menuLines(title, items, index);
133
+ if (lineCount > 0) {
134
+ // Move to the first line of the previous paint and rewrite in place.
135
+ output.write(`\x1b[${lineCount}A`);
136
+ }
137
+ output.write(HIDE);
138
+ for (const line of lines) {
139
+ output.write(CLEAR_LINE + line + '\n');
140
+ }
141
+ lineCount = lines.length;
142
+ };
143
+
144
+ const erase = (): void => {
145
+ if (lineCount <= 0) {
146
+ output.write(SHOW);
147
+ return;
148
+ }
149
+ output.write(`\x1b[${lineCount}A`);
150
+ for (let i = 0; i < lineCount; i++) output.write(CLEAR_LINE + (i < lineCount - 1 ? '\n' : ''));
151
+ if (lineCount > 1) output.write(`\x1b[${lineCount - 1}A`);
152
+ output.write('\r' + SHOW);
153
+ lineCount = 0;
154
+ };
155
+
156
+ paint();
157
+
158
+ const keys = opts.keys ?? stdinKeys(input);
159
+ try {
160
+ for await (const key of keys) {
161
+ if (key === '\x03' || key === '\x1b') {
162
+ erase();
163
+ return null;
164
+ }
165
+ if (key === '\r' || key === '\n') {
166
+ const chosen = items[index]!.value;
167
+ erase();
168
+ return chosen;
169
+ }
170
+ if (key === '\x1b[A' || key === 'k') {
171
+ index = (index - 1 + items.length) % items.length;
172
+ paint();
173
+ continue;
174
+ }
175
+ if (key === '\x1b[B' || key === 'j') {
176
+ index = (index + 1) % items.length;
177
+ paint();
178
+ continue;
179
+ }
180
+ if (key >= '1' && key <= '9') {
181
+ const n = Number(key) - 1;
182
+ if (n >= 0 && n < items.length) {
183
+ index = n;
184
+ paint();
185
+ }
186
+ }
187
+ }
188
+ } finally {
189
+ erase();
190
+ }
191
+ return null;
192
+ }
@@ -0,0 +1,113 @@
1
+ import { readdir, stat, rm } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ /** Newest local-history entries to keep when capping `.history/` (see
6
+ * pruneHistoryDir). Enough to preserve a useful recovery window, bounded enough
7
+ * that the dir cannot grow without limit across a long run. */
8
+ export const DEFAULT_HISTORY_KEEP = 200;
9
+
10
+ /**
11
+ * Cap the growth of a repo's `.history/` directory (4.1). KiCad (and editor
12
+ * local-history) rewrite a snapshot on every project touch, so across a long
13
+ * run `.history/` grows without bound and was a contributor to the disk-fill
14
+ * halt (I8). It is gitignored, so its contents are disposable: keep the newest
15
+ * `keepNewest` files by mtime and remove the rest. Recursive (local history
16
+ * mirrors the workspace tree), best-effort (every error is swallowed — pruning
17
+ * housekeeping must never fail a run), and a no-op when the dir is absent or
18
+ * already under the cap. Returns the number of files removed.
19
+ */
20
+ export async function pruneHistoryDir(repoRoot: string, keepNewest = DEFAULT_HISTORY_KEEP): Promise<number> {
21
+ const root = path.join(repoRoot, '.history');
22
+ const files: Array<{ full: string; mtimeMs: number }> = [];
23
+ const walk = async (dir: string): Promise<void> => {
24
+ let entries: Array<{ name: string; isDirectory(): boolean; isFile(): boolean }>;
25
+ try {
26
+ entries = await readdir(dir, { withFileTypes: true });
27
+ } catch {
28
+ return; // unreadable dir — skip it
29
+ }
30
+ for (const e of entries) {
31
+ const full = path.join(dir, e.name);
32
+ if (e.isDirectory()) {
33
+ await walk(full);
34
+ } else if (e.isFile()) {
35
+ try {
36
+ const st = await stat(full);
37
+ files.push({ full, mtimeMs: st.mtimeMs });
38
+ } catch {
39
+ // stat race — skip this file
40
+ }
41
+ }
42
+ }
43
+ };
44
+ await walk(root);
45
+ if (files.length <= keepNewest) return 0;
46
+ files.sort((a, b) => b.mtimeMs - a.mtimeMs); // newest first
47
+ let removed = 0;
48
+ for (const f of files.slice(keepNewest)) {
49
+ try {
50
+ await rm(f.full, { force: true });
51
+ removed++;
52
+ } catch {
53
+ // permission/race — leave it, keep pruning the rest
54
+ }
55
+ }
56
+ return removed;
57
+ }
58
+
59
+ /**
60
+ * Every scratch dir copperhead makes under the OS temp dir shares this prefix:
61
+ * kicad-cli ERC/DRC (`copperhead-`), the KiCad edit probe (`copperhead-validate-`),
62
+ * the failed-run backup (`copperhead-runs-`), and the provider working dirs
63
+ * (`copperhead-cc-`, `copperhead-codex-`). Each site removes its own dir in a
64
+ * `finally`, but a watchdog SIGKILL of the process tree or a hard abort skips
65
+ * that cleanup, so stale dirs accumulate across runs and can eventually fill the
66
+ * disk (I8). A prefix match lets one sweep reclaim all of them.
67
+ */
68
+ export const TEMP_PREFIX = 'copperhead-';
69
+
70
+ /** Default staleness cutoff for the startup sweep: 2h. Safe even for multi-hour
71
+ * runs (10-min turns × per-stage retries × 8 stages): a live run's only
72
+ * long-lived scratch dir is the provider's reused cwd, which is `utimes`-touched
73
+ * every turn (ClaudeCodeProvider.ensureCwd), so its mtime never goes stale while
74
+ * the process is alive; per-call kicad-cli dirs are removed within a turn. A dir
75
+ * older than this therefore belongs to a dead run, and the window is short enough
76
+ * that such a leak is reclaimed on the very next invocation. */
77
+ export const DEFAULT_STALE_MS = 2 * 60 * 60 * 1000;
78
+
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: number, maxAgeMs = DEFAULT_STALE_MS): Promise<string[]> {
91
+ const root = tmpdir();
92
+ const removed: string[] = [];
93
+ let entries: string[];
94
+ try {
95
+ entries = await readdir(root);
96
+ } catch {
97
+ return removed; // no temp dir / not readable — nothing to sweep
98
+ }
99
+ for (const name of entries) {
100
+ if (!name.startsWith(TEMP_PREFIX)) continue;
101
+ const full = path.join(root, name);
102
+ try {
103
+ const st = await stat(full);
104
+ if (!st.isDirectory()) continue;
105
+ if (now - st.mtimeMs < maxAgeMs) continue; // too fresh: could be a live run
106
+ await rm(full, { recursive: true, force: true });
107
+ removed.push(full);
108
+ } catch {
109
+ // stat/rm race or permission issue — skip this entry, keep sweeping.
110
+ }
111
+ }
112
+ return removed;
113
+ }