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,181 @@
1
+ /**
2
+ * REPL-session renderer: the single owner of the bottom dock during agent
3
+ * turns. Durable output (tool lines, turn markers, the outcome) flows into
4
+ * the content region through `emit`, which the REPL also records as
5
+ * scrollable history; the live observability line (spinner, turn, tokens,
6
+ * elapsed, busy text) is painted inside the dock, pinned to the bottom of
7
+ * the screen no matter how much output scrolls above it.
8
+ */
9
+
10
+ import { rule, statusBar } from './box.js';
11
+ import { copper, dim, styleOutcome, toolLine, warn } from './theme.js';
12
+ import { fmtDuration, fmtTokens, turnMarker, type ProgressRenderer } from './render.js';
13
+ import type { TerminalDock } from '../util/dock.js';
14
+
15
+ const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
16
+
17
+ /** Claude Code-style working words, board-shop edition. One per turn. */
18
+ const WORKING = [
19
+ 'Routing',
20
+ 'Etching',
21
+ 'Reflowing',
22
+ 'Soldering',
23
+ 'Drilling',
24
+ 'Plating',
25
+ 'Probing',
26
+ 'Fluxing',
27
+ 'Tinning',
28
+ 'Laminating',
29
+ 'Silkscreening',
30
+ 'Panelizing',
31
+ ];
32
+
33
+ /** Fixed word-slot width: longest word plus the static dots. */
34
+ const WORD_SLOT = Math.max(...WORKING.map((w) => w.length)) + 3;
35
+
36
+ export class DockRenderer implements ProgressRenderer {
37
+ private turn = 0;
38
+ private maxTurns = 0;
39
+ private tokensIn = 0;
40
+ private tokensOut = 0;
41
+ private streamedChars = 0;
42
+ private busy: string | null = null;
43
+ private frame = 0;
44
+ private runSeed = 0;
45
+ private startMs = Date.now();
46
+ private timer: ReturnType<typeof setInterval> | null = null;
47
+
48
+ constructor(
49
+ private readonly dock: TerminalDock,
50
+ /** Durable line sink: content region + session history. */
51
+ private readonly emit: (line: string) => void,
52
+ /** Dock chrome around the status row (meta right, bottom hints). */
53
+ private readonly chrome: () => { meta: string | null; hints: string | null },
54
+ ) {}
55
+
56
+ log(line: string): void {
57
+ this.emit(line);
58
+ }
59
+
60
+ toolResult(name: string, firstLine: string): void {
61
+ this.emit(toolLine(name, firstLine));
62
+ }
63
+
64
+ private turnStartMs = Date.now();
65
+
66
+ turnStart(turn: number, maxTurns: number, tokensIn: number, tokensOut: number): void {
67
+ if (turn === 1) {
68
+ this.startMs = Date.now();
69
+ this.runSeed++;
70
+ }
71
+ this.turnStartMs = Date.now();
72
+ this.turn = turn;
73
+ this.maxTurns = maxTurns;
74
+ this.tokensIn = tokensIn;
75
+ this.tokensOut = tokensOut;
76
+ this.emit(dim(turnMarker(turn, maxTurns, tokensIn, tokensOut)));
77
+ this.arm();
78
+ this.paint();
79
+ }
80
+
81
+ status(text: string | null): void {
82
+ this.busy = text;
83
+ if (text === null) this.streamedChars = 0;
84
+ this.paint();
85
+ }
86
+
87
+ heartbeat(info: { elapsedMs: number; streamedChars: number }): void {
88
+ this.streamedChars = info.streamedChars;
89
+ this.paint();
90
+ }
91
+
92
+ finish(line: string): void {
93
+ this.disarm();
94
+ this.busy = null;
95
+ this.emit(styleOutcome(line));
96
+ // The next prompt's renderDock() takes the dock back over.
97
+ }
98
+
99
+ /** Currently displayed working word; morphs letter by letter on change. */
100
+ private shownWord = '';
101
+ private targetWord = '';
102
+ /** -1 = settled; otherwise progress through erase-then-type transition. */
103
+ private morph = -1;
104
+
105
+ private arm(): void {
106
+ if (this.timer) return;
107
+ this.timer = setInterval(() => {
108
+ this.frame++;
109
+ // Sweep three positions per tick: a full word crossfade in under a second.
110
+ if (this.morph >= 0) this.morph += 3;
111
+ this.paint();
112
+ }, 120);
113
+ this.timer.unref?.();
114
+ }
115
+
116
+ private disarm(): void {
117
+ if (this.timer) clearInterval(this.timer);
118
+ this.timer = null;
119
+ }
120
+
121
+ /** Pinned observability row painted inside the dock (same layout as the prompt). */
122
+ private paint(): void {
123
+ const w = Math.max(10, this.dock.cols() - 1);
124
+ const spinner = copper(FRAMES[this.frame % FRAMES.length]!);
125
+ // Claude Code-style working word, board-shop themed: rotates every ~6s
126
+ // while a turn runs, with a shimmering highlight sweeping the letters.
127
+ // A word change morphs letter by letter: the old word is erased into
128
+ // `_` slots left to right, then the new word types over them.
129
+ const wordIdx =
130
+ (this.runSeed + this.turn + Math.floor((Date.now() - this.turnStartMs) / 6000)) %
131
+ WORKING.length;
132
+ const target = WORKING[wordIdx]!;
133
+ if (this.shownWord === '') this.shownWord = target;
134
+ if (target !== this.targetWord) {
135
+ this.targetWord = target;
136
+ if (target !== this.shownWord) this.morph = 0;
137
+ }
138
+ let text = `${this.shownWord}...`;
139
+ if (this.morph >= 0) {
140
+ // Single left-to-right sweep over the whole dotted string (dots
141
+ // included): each position flips old char -> `_` -> new char, so the
142
+ // words cross-fade character by character.
143
+ const oldS = `${this.shownWord}...`;
144
+ const newS = `${this.targetWord}...`;
145
+ const width = Math.max(oldS.length, newS.length);
146
+ const k = this.morph;
147
+ if (k >= width) {
148
+ this.shownWord = this.targetWord;
149
+ this.morph = -1;
150
+ text = `${this.shownWord}...`;
151
+ } else {
152
+ let out = '';
153
+ for (let i = 0; i < width; i++) {
154
+ out += i < k ? (newS[i] ?? ' ') : i === k ? '_' : (oldS[i] ?? ' ');
155
+ }
156
+ text = out.trimEnd();
157
+ }
158
+ }
159
+ // Fixed-width slot (longest word + dots) so the stats after it never
160
+ // shift; dots and morph slots share the word's copper.
161
+ const word = copper(text.padEnd(WORD_SLOT));
162
+ const parts = [
163
+ dim(`turn ${this.turn}/${this.maxTurns}`),
164
+ dim(`${fmtTokens(this.tokensIn)} in / ${fmtTokens(this.tokensOut)} out`),
165
+ dim(fmtDuration(Date.now() - this.startMs)),
166
+ ];
167
+ if (this.busy) {
168
+ parts.push(
169
+ warn(this.streamedChars ? `${this.busy} ~${fmtTokens(this.streamedChars)} ch` : this.busy),
170
+ );
171
+ }
172
+ const { meta, hints } = this.chrome();
173
+ this.dock.set([
174
+ ...(meta ? [statusBar('', `${meta} `, w)] : []),
175
+ rule(w),
176
+ statusBar(`${spinner} ${word}`, `${parts.join(dim(' · '))} `, w),
177
+ rule(w),
178
+ ...(hints ? [statusBar(` ${hints}`, '', w)] : []),
179
+ ]);
180
+ }
181
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Terminal block-art mark derived from the website logo
3
+ * (docs/public/favicon.svg and docs.copperhead.sh): a via with a square
4
+ * drilled hole and long copper tracks routed out of both sides, copper
5
+ * #b87333 on dark. (`scripts/gen-logo.mjs` renders the exact favicon
6
+ * geometry at any size for reference.)
7
+ */
8
+
9
+ import { copper } from './theme.js';
10
+
11
+ /** 3-row quadrant-block via with long tracks; rows are equal width. */
12
+ export function fiducialMark(): string[] {
13
+ return [
14
+ ' ▄▟▙▄ ',
15
+ ' ███ ███',
16
+ ' ▀▜▛▀ ',
17
+ ];
18
+ }
19
+
20
+ /** The mark painted in brand copper. */
21
+ export function fiducialLines(): string[] {
22
+ return fiducialMark().map((line) => copper(line));
23
+ }
package/src/agent/loop.ts CHANGED
@@ -3,21 +3,25 @@ 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';
14
+ import { styleHeaderLines } from './theme.js';
12
15
  import { ObligationsLedger } from './ledger.js';
13
16
  import { gitPreflight, isDirty, snapshot, restore, commitAll, changedFiles, preserveFailedRun } from '../util/git.js';
14
- import { withRetry, isRateLimit } from '../util/retry.js';
17
+ import { withRetry, isRateLimit, sessionLimit } from '../util/retry.js';
15
18
  import { openspecArchive } from '../openspec/cli.js';
16
19
  import { existsSync } from 'node:fs';
17
20
  import { OpenAIProvider } from './providers/openai.js';
18
21
  import { AnthropicProvider } from './providers/anthropic.js';
19
22
  import { CodexProvider } from './providers/codex.js';
20
23
  import { ClaudeCodeProvider } from './providers/claude-code.js';
24
+ import { CursorProvider } from './providers/cursor.js';
21
25
  import { openSynapMemory, type RunRecord, type SynapMemory } from '../memory/synap.js';
22
26
 
23
27
  /** What the user sees at the moment they decide whether to keep going. */
@@ -63,9 +67,14 @@ export interface RunResult {
63
67
  transcriptDir: string;
64
68
  filesTouched: string[];
65
69
  commit: string | null;
70
+ /** Cost/telemetry for this run. Surfaced by the create pipeline's per-stage
71
+ * cost table (5.2) so the expensive stages are obvious across runs. */
72
+ stats: RunStats;
73
+ /** Number of turns served from the on-disk response cache (5.2). */
74
+ cacheHits: number;
66
75
  }
67
76
 
68
- export async function makeProvider(model: string): Promise<Provider> {
77
+ export async function makeProvider(model: string, sessionResume = false): Promise<Provider> {
69
78
  if (model === 'codex' || model.startsWith('codex:')) {
70
79
  const codexModel = model.startsWith('codex:') ? model.slice('codex:'.length) : undefined;
71
80
  if (codexModel === '') throw new Error('codex model override cannot be empty; use "codex" or "codex:<model-id>"');
@@ -91,7 +100,15 @@ export async function makeProvider(model: string): Promise<Provider> {
91
100
  if (claudeCodeModel === '') {
92
101
  throw new Error('claude-code model override cannot be empty; use "claude-code" or "claude-code:<model-id>"');
93
102
  }
94
- return new ClaudeCodeProvider(claudeCodeModel);
103
+ return new ClaudeCodeProvider(claudeCodeModel, undefined, undefined, sessionResume);
104
+ }
105
+ // Saved-login Cursor Agent CLI (`agent login`). Matched as its own namespace.
106
+ if (model === 'cursor' || model.startsWith('cursor:')) {
107
+ const cursorModel = model.startsWith('cursor:') ? model.slice('cursor:'.length) : undefined;
108
+ if (cursorModel === '') {
109
+ throw new Error('cursor model override cannot be empty; use "cursor" or "cursor:<model-id>"');
110
+ }
111
+ return new CursorProvider(cursorModel, undefined, sessionResume);
95
112
  }
96
113
  if (model === 'claude' || model.startsWith('claude')) {
97
114
  return new AnthropicProvider(model === 'claude' ? undefined : model);
@@ -101,7 +118,7 @@ export async function makeProvider(model: string): Promise<Provider> {
101
118
 
102
119
  function otherProvider(current: Provider): Provider | null {
103
120
  // Only the two keyed providers fail over to each other. A rate-limited
104
- // 'claude-code' run returns null here (no silent fallback to a paid API).
121
+ // 'claude-code' or 'cursor' run returns null here (no silent fallback to a paid API).
105
122
  if (current.name === 'openai' && process.env.ANTHROPIC_API_KEY) return new AnthropicProvider();
106
123
  if (current.name === 'anthropic' && process.env.OPENAI_API_KEY) return new OpenAIProvider();
107
124
  return null;
@@ -198,8 +215,23 @@ async function runWithMemory(
198
215
  finishRequest: null,
199
216
  };
200
217
 
201
- let provider = opts.provider ?? (await makeProvider(opts.model));
218
+ // Session resume for claude-code / cursor is only correct when the response
219
+ // cache is off: the cache replays turns a resumed session never saw. So enable
220
+ // it only when the env flag is set AND config.llmCache is disabled — the same
221
+ // condition under which we skip the CachingProvider wrap below.
222
+ const sessionResume = process.env.COPPERHEAD_CC_SESSION_RESUME === '1' && !config.llmCache;
223
+ let provider = opts.provider ?? (await makeProvider(opts.model, sessionResume));
224
+ // Cache every turn's response so a retried/restarted stage replays what it
225
+ // already paid for instead of re-calling the model (repo-scoped, cross-run).
226
+ // Skip an injected provider (tests drive scripted providers directly).
227
+ if (config.llmCache && !opts.provider) {
228
+ provider = new CachingProvider(provider, path.join(repoRoot, CONFIG_DIR, 'llm-cache'), log, opts.model);
229
+ }
202
230
  providers.add(provider);
231
+ // Held separately from `provider` (which is reassigned on failover) so the
232
+ // final cache-hit count survives a mid-run provider switch (5.2).
233
+ const cachingProvider = provider instanceof CachingProvider ? provider : null;
234
+ const cacheHits = (): number => cachingProvider?.cacheHits ?? 0;
203
235
 
204
236
  // Deterministic, LLM-free metadata block: collected once, rendered onto all
205
237
  // three surfaces (run-start event, summary ## Environment, CLI header) so
@@ -216,7 +248,7 @@ async function runWithMemory(
216
248
  interactive: opts.interactive ?? false,
217
249
  input: opts.meta,
218
250
  });
219
- for (const line of renderCliHeader(meta)) log(line);
251
+ for (const line of styleHeaderLines(renderCliHeader(meta))) log(line);
220
252
  // Revisit obligations deferred while their artifact didn't exist re-open now
221
253
  // if it does (must run before loadConstraints so the prompt sees the updated
222
254
  // registry). They land in this run's fresh ledger, so finish gates on them.
@@ -277,6 +309,8 @@ async function runWithMemory(
277
309
  const perTurn: { turn: number; in: number; out: number }[] = [];
278
310
  let plan: string | null = null;
279
311
  let nudges = 0;
312
+ let turnTimeouts = 0;
313
+ const maxTurnTimeouts = 3;
280
314
 
281
315
  const stats = (exitPath: ExitPath): RunStats => ({
282
316
  exitPath,
@@ -356,6 +390,8 @@ async function runWithMemory(
356
390
  transcriptDir: transcript.dir,
357
391
  filesTouched: [],
358
392
  commit: null,
393
+ stats: runStats,
394
+ cacheHits: cacheHits(),
359
395
  };
360
396
  };
361
397
 
@@ -388,15 +424,64 @@ async function runWithMemory(
388
424
  await transcript.event('budget-extended', { extraTurns: extra, budget, ...exhaustStats });
389
425
  log(`turn budget extended by ${extra} (now ${budget})`);
390
426
  }
427
+ // Advertise EVERY tool each turn; dispatchTool enforces the edit-unlock gate
428
+ // live at call time. Hiding locked edit tools from the turn catalog meant a
429
+ // model that unlocked (validate_change) and edited in the SAME reply had its
430
+ // edit silently dropped in parsing — the call named a tool the turn had not
431
+ // advertised, so it was treated as prose, executed nothing, and returned no
432
+ // error. The model then "verified" against an unchanged file (an empty
433
+ // schematic even passes ERC) and finished believing it had succeeded.
434
+ // Structural lock (SPEC.md §1.3 invariant 1): the edit tools stay OUT of the
435
+ // advertised list until a proposal validates (`editsUnlocked`), so the model
436
+ // is gated by omission, not by prompt text. `dispatchTool` re-checks the same
437
+ // `availableTools(ctx)` live, so this is defense in depth. A premature edit is
438
+ // simply not offered; once `validate_change` unlocks, the next turn advertises
439
+ // the edit tools. (Earlier this advertised every tool to let a same-turn
440
+ // propose→validate→edit batch through, but that traded the spec's structural
441
+ // guarantee for one saved turn — not worth it.)
391
442
  const tools = availableTools(ctx).map((t) => t.schema);
392
443
  r.turnStart(turn + 1, maxTurns, tokensIn, tokensOut);
393
444
  r.status('thinking');
394
445
  let res: Turn;
446
+ // Liveness heartbeat (5.1): a large-output turn can legitimately run several
447
+ // minutes, which is otherwise indistinguishable from a hung subprocess until
448
+ // the watchdog fires. Emit a periodic elapsed/streamed signal so an operator
449
+ // can tell the two apart. Fires only after the first interval, so quick turns
450
+ // stay silent; `unref` keeps it from holding the event loop open.
451
+ const turnStartMs = Date.now();
452
+ let streamedChars = 0;
453
+ const heartbeat =
454
+ config.heartbeatMs > 0
455
+ ? setInterval(
456
+ () => r.heartbeat({ elapsedMs: Date.now() - turnStartMs, streamedChars }),
457
+ config.heartbeatMs,
458
+ )
459
+ : null;
460
+ heartbeat?.unref?.();
395
461
  try {
396
- res = await withRetry(() => provider.chat(messages, tools), {
397
- onRetry: (attempt) => log(`rate limited; retry ${attempt}`),
398
- });
462
+ res = await withRetry(
463
+ () =>
464
+ withTimeout(
465
+ () => provider.chat(messages, tools, { onStream: (chars) => (streamedChars = chars) }),
466
+ config.turnTimeoutMs,
467
+ () => provider.close?.(),
468
+ ),
469
+ { onRetry: (attempt) => log(`rate limited; retry ${attempt}`) },
470
+ );
399
471
  } catch (err) {
472
+ if (err instanceof TurnTimeoutError) {
473
+ // A hung provider turn: the watchdog aborted the in-flight call and tore
474
+ // down its subprocess. Retry the same turn a bounded number of times
475
+ // before giving up, so a transient hang self-heals instead of stalling
476
+ // the run forever.
477
+ if (turnTimeouts++ < maxTurnTimeouts) {
478
+ log(`turn exceeded ${config.turnTimeoutMs}ms; aborted the hung call and retrying (${turnTimeouts}/${maxTurnTimeouts})`);
479
+ await transcript.event('turn-timeout', { ms: config.turnTimeoutMs, attempt: turnTimeouts });
480
+ turn--;
481
+ continue;
482
+ }
483
+ return fail(`provider turns timed out ${turnTimeouts}× (>${config.turnTimeoutMs}ms each)`, 'provider-error');
484
+ }
400
485
  if (isRateLimit(err)) {
401
486
  const fallback = otherProvider(provider);
402
487
  if (fallback) {
@@ -408,11 +493,34 @@ async function runWithMemory(
408
493
  continue;
409
494
  }
410
495
  }
496
+ // A saved-login session/usage limit is not a code bug and not a 429 (2.4,
497
+ // I13): it names its own reset time and clears only then, and every turn
498
+ // so far is already in the llm-cache — so re-running after the reset
499
+ // replays them at ~0 tokens and resumes in place. Surface it as its own
500
+ // exit path with the reset time and the resume instruction, rather than a
501
+ // bare "provider error" the operator would read as a failure to debug.
502
+ const limit = sessionLimit(err);
503
+ if (limit) {
504
+ const when = limit.resetsAt ? ` (resets ${limit.resetsAt})` : '';
505
+ await transcript.event('session-limit', { resetsAt: limit.resetsAt, provider: provider.name });
506
+ return fail(
507
+ `${provider.name} session/usage limit reached${when} — this is a schedulable pause, not a bug. ` +
508
+ `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.`,
509
+ 'session-limit',
510
+ );
511
+ }
411
512
  return fail(`provider error: ${(err as Error).message}`, 'provider-error');
412
513
  } finally {
514
+ if (heartbeat) clearInterval(heartbeat);
413
515
  r.status(null);
414
516
  }
415
517
  turnsUsed = turn + 1;
518
+ // A productive turn resets the timeout budget: maxTurnTimeouts is meant to
519
+ // catch a turn that is genuinely, repeatedly stuck — not to cap the total
520
+ // number of slow-but-recoverable turns across a whole stage. Without this a
521
+ // long stage that merely has a few independent slow turns accumulates
522
+ // timeouts and hard-fails even though every one of them recovered.
523
+ turnTimeouts = 0;
416
524
  tokensIn += res.usage.inputTokens;
417
525
  tokensOut += res.usage.outputTokens;
418
526
  perTurn.push({ turn: turn + 1, in: res.usage.inputTokens, out: res.usage.outputTokens });
@@ -432,7 +540,9 @@ async function runWithMemory(
432
540
  if (nudges++ >= 2) return fail('model stopped calling tools without finishing', 'stalled');
433
541
  messages.push({
434
542
  role: 'user',
435
- content: 'Continue using tools, or call finish({outcome, summary}) to end the run.',
543
+ // A near-miss malformed tool call (#I10) gets a specific steer to re-emit
544
+ // it; an ordinary tool-less turn gets the generic continue prompt.
545
+ content: res.nudge ?? 'Continue using tools, or call finish({outcome, summary}) to end the run.',
436
546
  });
437
547
  continue;
438
548
  }
@@ -502,6 +612,8 @@ async function runWithMemory(
502
612
  transcriptDir: transcript.dir,
503
613
  filesTouched: [],
504
614
  commit: null,
615
+ stats: runStats,
616
+ cacheHits: cacheHits(),
505
617
  };
506
618
  }
507
619
 
@@ -547,16 +659,32 @@ async function runWithMemory(
547
659
  transcriptDir: transcript.dir,
548
660
  filesTouched: files,
549
661
  commit: null,
662
+ stats: runStats,
663
+ cacheHits: cacheHits(),
550
664
  };
551
665
  }
552
666
 
553
- await appendChangelog(repoRoot, config, {
554
- changeId: ctx.changeId,
555
- request: opts.request,
556
- files,
557
- verification,
558
- });
559
- ctx.ledger.clear('changelog');
667
+ // Bookkeeping must never cost the verified design its commit (2.1): the
668
+ // KiCad work passed its ERC/DRC gates, so a failure appending the changelog
669
+ // (a plain CHANGELOG.md read+write) is a warning, not a rollback. It stays
670
+ // before commitAll so, on the normal path, the entry lands in the run's
671
+ // single commit and a zero-edit "done" run still has something to commit;
672
+ // if it throws, the design is committed without a changelog line rather
673
+ // than sent through fail()'s rollback. The other bookkeeping — the openspec
674
+ // archive — is already post-commit and non-fatal below.
675
+ try {
676
+ await appendChangelog(repoRoot, config, {
677
+ changeId: ctx.changeId,
678
+ request: opts.request,
679
+ files,
680
+ verification,
681
+ });
682
+ ctx.ledger.clear('changelog');
683
+ } catch (err) {
684
+ const message = (err as Error).message;
685
+ log(`warning: changelog append failed (${message}); committing the verified design without a changelog entry`);
686
+ await transcript.event('changelog-append-failed', { error: message });
687
+ }
560
688
 
561
689
  const commitMsg = `copperhead: ${opts.request}\n\n${summary}\n\nVerification: ${verification}`;
562
690
  // A git failure here (e.g. `git add -A` exiting 128 on an embedded repo)
@@ -619,6 +747,8 @@ async function runWithMemory(
619
747
  transcriptDir: transcript.dir,
620
748
  filesTouched: files,
621
749
  commit,
750
+ stats: runStats,
751
+ cacheHits: cacheHits(),
622
752
  };
623
753
  }
624
754
  }
@@ -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,