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
@@ -1,4 +1,4 @@
1
- import { mkdtemp, rm } from 'node:fs/promises';
1
+ import { mkdtemp, rm, utimes } from 'node:fs/promises';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import type { ChatOpts, Msg, Provider, ToolCall, ToolSchema, Turn } from '../types.js';
@@ -51,6 +51,16 @@ export interface QueryOptions {
51
51
  cwd?: string;
52
52
  env?: Record<string, string | undefined>;
53
53
  maxTurns?: number;
54
+ /** Aborting this controller stops the query and tears down the `claude`
55
+ * subprocess it spawned (Agent SDK `Options.abortController`). Used so the
56
+ * watchdog's `close()` on a hung turn kills the process instead of orphaning
57
+ * it (2.2/4.1) — a stranded subprocess keeps writing to its temp cwd and, with
58
+ * KiCad local history, was a source of the disk-fill halt (I8). */
59
+ abortController?: AbortController;
60
+ /** Resume a prior SDK session by id so the subprocess reconstructs earlier
61
+ * turns itself instead of us re-sending the whole conversation each turn (1.1,
62
+ * `Options.resume`). Only set in the opt-in session-resume mode. */
63
+ resume?: string;
54
64
  }
55
65
  export interface QueryArgs {
56
66
  prompt: string;
@@ -59,6 +69,7 @@ export interface QueryArgs {
59
69
  export interface QueryMessage {
60
70
  type: string;
61
71
  subtype?: string;
72
+ session_id?: string;
62
73
  message?: { content?: Array<{ type: string; text?: string }> };
63
74
  usage?: { input_tokens?: number; output_tokens?: number };
64
75
  }
@@ -93,17 +104,35 @@ export class ClaudeCodeProvider implements Provider {
93
104
  readonly name = 'claude-code';
94
105
  private callSeq = 0;
95
106
  private cwdPromise?: Promise<string>;
107
+ /** In-flight query aborters, so close() (called by the turn watchdog on a
108
+ * hung turn) can tear down the live subprocess, not just delete its cwd. */
109
+ private readonly inFlight = new Set<AbortController>();
110
+ /** Session-resume state (1.1). `sessionId` is the last session the SDK reported;
111
+ * `sentCount` is how many `messages` we have already handed it, so a resumed
112
+ * turn sends only the delta. Unused unless `sessionResume` is on. */
113
+ private sessionId?: string;
114
+ private sentCount = 0;
96
115
 
97
116
  constructor(
98
117
  private readonly model?: string,
99
118
  private readonly injectedQuery?: QueryLike,
100
119
  private readonly importSdk: ImportLike = (specifier) => import(specifier),
120
+ /**
121
+ * Opt-in: resume one SDK session across turns and send only new messages,
122
+ * instead of flattening and re-sending the entire conversation every turn
123
+ * (1.1). Cuts the ~quadratic history re-send that dominates long-stage cost.
124
+ * OFF by default and deliberately mutually exclusive with the response cache:
125
+ * the cache replays turns the resumed session never saw, so mixing them would
126
+ * desync the session. `makeProvider` enables it only when the cache is off.
127
+ */
128
+ private readonly sessionResume = false,
101
129
  ) {}
102
130
 
103
- // `_opts.maxTokens` is intentionally ignored: the Agent SDK drives the Claude
104
- // Code subprocess and exposes no per-call max-tokens knob. loop.ts calls
105
- // chat() without opts today; noted so a future opts pass is not a surprise.
106
- async chat(messages: Msg[], tools: ToolSchema[], _opts: ChatOpts = {}): Promise<Turn> {
131
+ // `opts.maxTokens` is intentionally ignored: the Agent SDK drives the Claude
132
+ // Code subprocess and exposes no per-call max-tokens knob. `opts.onStream` is
133
+ // honored: this provider streams, so it reports cumulative streamed-text length
134
+ // as blocks arrive, which the loop turns into a liveness heartbeat (5.1).
135
+ async chat(messages: Msg[], tools: ToolSchema[], opts: ChatOpts = {}): Promise<Turn> {
107
136
  const query = await this.resolveQuery();
108
137
 
109
138
  const system = messages
@@ -111,19 +140,28 @@ export class ClaudeCodeProvider implements Provider {
111
140
  .map((m) => m.content)
112
141
  .join('\n\n');
113
142
  const systemPrompt = [system, renderToolProtocol(tools)].filter(Boolean).join('\n\n');
114
- const prompt = renderConversation(messages);
143
+ // Session-resume mode (1.1): once the SDK has given us a session id, resume it
144
+ // and send only the messages added since our last turn — the subprocess still
145
+ // holds the earlier conversation, so re-sending it would just re-bill it. The
146
+ // first turn (no session id yet) sends the full flattened history as usual.
147
+ const resume = this.sessionResume ? this.sessionId : undefined;
148
+ const prompt = resume ? renderDelta(messages, this.sentCount) : renderConversation(messages);
115
149
  const catalog = new Set(tools.map((t) => t.name));
116
150
  const cwd = await this.ensureCwd();
117
151
 
118
152
  let text: string | null = null;
119
153
  let inputTokens = 0;
120
154
  let outputTokens = 0;
155
+ // One aborter per turn: close() aborts it to kill a hung subprocess.
156
+ const aborter = new AbortController();
157
+ this.inFlight.add(aborter);
121
158
  try {
122
159
  for await (const msg of query({
123
160
  prompt,
124
161
  options: {
125
162
  systemPrompt,
126
163
  ...(this.model ? { model: this.model } : {}),
164
+ abortController: aborter,
127
165
  // Layered "the SDK executes nothing" defense (D1/D5):
128
166
  // 1. `tools: []` disables ALL built-in tools (Agent SDK 0.3.x docs:
129
167
  // "[] (empty array) - Disable all built-in tools").
@@ -134,6 +172,7 @@ export class ClaudeCodeProvider implements Provider {
134
172
  // 4. The tool_use tripwire below fails the run loudly if one is
135
173
  // emitted anyway. Any single layer failing is caught by the next.
136
174
  tools: [],
175
+ ...(resume ? { resume } : {}),
137
176
  disallowedTools: DISALLOWED_BUILTINS,
138
177
  canUseTool: async (toolName) => ({
139
178
  behavior: 'deny',
@@ -153,6 +192,9 @@ export class ClaudeCodeProvider implements Provider {
153
192
  for (const block of msg.message?.content ?? []) {
154
193
  if (block.type === 'text' && block.text) {
155
194
  text = (text ?? '') + block.text;
195
+ // Report progress so the loop's heartbeat shows this turn is alive
196
+ // and streaming, not hung, during a multi-minute large-output turn.
197
+ opts.onStream?.(text.length);
156
198
  } else if (block.type === 'tool_use') {
157
199
  // Load-bearing invariant (D1): the SDK must execute nothing, so it
158
200
  // must never emit a tool_use block. If it does, `tools: []` was not
@@ -169,6 +211,9 @@ export class ClaudeCodeProvider implements Provider {
169
211
  if (typeof msg.usage?.input_tokens === 'number') inputTokens = msg.usage.input_tokens;
170
212
  if (typeof msg.usage?.output_tokens === 'number') outputTokens = msg.usage.output_tokens;
171
213
  }
214
+ // The session id can arrive on any message (init/system/result); keep the
215
+ // latest so the next turn can resume it (1.1). No-op unless resume is on.
216
+ if (this.sessionResume && typeof msg.session_id === 'string') this.sessionId = msg.session_id;
172
217
  }
173
218
  } catch (err) {
174
219
  // Auth failures get an actionable message (non-retryable); everything else
@@ -177,15 +222,40 @@ export class ClaudeCodeProvider implements Provider {
177
222
  // distinct `name` makes otherProvider() return null for us.
178
223
  if (isAuthError(err)) throw new Error(authHint((err as Error).message));
179
224
  throw err;
225
+ } finally {
226
+ this.inFlight.delete(aborter);
180
227
  }
181
228
 
229
+ // Only advance the high-water mark on a turn that completed: a thrown turn
230
+ // (rate limit, timeout) is retried, and must re-send the same delta so no
231
+ // message is lost from the resumed session (1.1).
232
+ if (this.sessionResume) this.sentCount = messages.length;
233
+
182
234
  const parsed = parseToolCalls(text, () => `cc-${++this.callSeq}`, catalog);
183
- return { text: parsed.text, toolCalls: parsed.toolCalls, usage: { inputTokens, outputTokens } };
235
+ return {
236
+ text: parsed.text,
237
+ toolCalls: parsed.toolCalls,
238
+ usage: { inputTokens, outputTokens },
239
+ nudge: parsed.nudge,
240
+ };
184
241
  }
185
242
 
186
- /** Remove the scratch cwd. loop.ts calls this on every provider in a finally,
187
- * so the one temp dir this instance created does not leak past the run. */
243
+ /** Tear down in-flight work and remove the scratch cwd. Called by the turn
244
+ * watchdog on a hung turn (via withTimeout's onTimeout) AND once per run in a
245
+ * finally. Aborting first kills the `claude` subprocess a hung turn spawned —
246
+ * without it the process is orphaned and keeps writing to its temp cwd, which
247
+ * (with KiCad local history) was a source of the disk-fill halt (2.2/4.1, I8).
248
+ * A leftover empty dir in the OS tmpdir is harmless; the startup sweep reclaims
249
+ * any that a hard SIGKILL bypassed this cleanup for. */
188
250
  async close(): Promise<void> {
251
+ for (const aborter of this.inFlight) {
252
+ try {
253
+ aborter.abort();
254
+ } catch {
255
+ // best effort: a controller that already settled throws nothing useful
256
+ }
257
+ }
258
+ this.inFlight.clear();
189
259
  const pending = this.cwdPromise;
190
260
  this.cwdPromise = undefined;
191
261
  if (!pending) return;
@@ -199,9 +269,17 @@ export class ClaudeCodeProvider implements Provider {
199
269
  /** One isolated scratch cwd per provider instance, created once and reused
200
270
  * across turns so a long run does not leak a temp dir per turn. Even with
201
271
  * tools disabled this guarantees the SDK has no path into the repo (D5). */
202
- private ensureCwd(): Promise<string> {
272
+ private async ensureCwd(): Promise<string> {
203
273
  if (!this.cwdPromise) this.cwdPromise = mkdtemp(path.join(os.tmpdir(), 'copperhead-cc-'));
204
- return this.cwdPromise;
274
+ const cwd = await this.cwdPromise;
275
+ // Keep this reused scratch dir's mtime fresh on every turn. It is the only
276
+ // long-lived temp dir a run holds (kicad-cli dirs are per-call), so a
277
+ // multi-hour run would otherwise leave it with a stale mtime and a concurrent
278
+ // run's startup sweep (sweepStaleTempDirs, age-gated) could delete it out from
279
+ // under the live process (F4). Best-effort: a touch failure is harmless.
280
+ const now = new Date();
281
+ await utimes(cwd, now, now).catch(() => {});
282
+ return cwd;
205
283
  }
206
284
 
207
285
  private async resolveQuery(): Promise<QueryLike> {
@@ -265,6 +343,32 @@ function renderToolProtocol(tools: ToolSchema[]): string {
265
343
  return lines.join('\n');
266
344
  }
267
345
 
346
+ /**
347
+ * The prompt for a *resumed* turn (1.1): only the messages added since the last
348
+ * turn we sent, and only the ones the resumed session does not already hold. The
349
+ * subprocess already has every prior turn plus its own assistant replies, so we
350
+ * send just the new user nudges and tool results — that delta is what advances
351
+ * the conversation. Falls back to the full render (via the caller) when there is
352
+ * no session yet.
353
+ */
354
+ function renderDelta(messages: Msg[], from: number): string {
355
+ const idToName = new Map<string, string>();
356
+ for (const m of messages) {
357
+ if (m.role === 'assistant') for (const call of m.toolCalls ?? []) idToName.set(call.id, call.name);
358
+ }
359
+ const parts: string[] = [];
360
+ for (const m of messages.slice(Math.max(0, from))) {
361
+ if (m.role === 'user') {
362
+ parts.push(`[user]\n${m.content}`);
363
+ } else if (m.role === 'tool') {
364
+ const name = idToName.get(m.toolCallId) ?? m.toolCallId;
365
+ parts.push(`[result of ${name}]\n${m.content}`);
366
+ }
367
+ // assistant/system messages are already in the resumed session — skip them.
368
+ }
369
+ return parts.join('\n\n');
370
+ }
371
+
268
372
  function renderConversation(messages: Msg[]): string {
269
373
  const idToName = new Map<string, string>();
270
374
  const parts: string[] = [];
@@ -291,6 +395,33 @@ function renderConversation(messages: Msg[]): string {
291
395
  interface Parsed {
292
396
  text: string | null;
293
397
  toolCalls: ToolCall[];
398
+ nudge?: string;
399
+ }
400
+
401
+ /**
402
+ * Detect a malformed-but-intended tool call in a turn that dispatched none
403
+ * (#I10). The signature is machine-recognizable: the text contains
404
+ * `"tool":"<name>"` naming a tool in the current catalog, yet nothing parsed.
405
+ * That is the exact case where the tolerant extractor's silence misleads the
406
+ * model — the JSON was near-miss malformed (a brace short, or the outer object
407
+ * split so only an inner `{args}` with no `tool` key balanced), not the tool
408
+ * being broken. Returns a one-line steer to re-emit it, or undefined when the
409
+ * absence of a call is genuine (plain prose, no tool named).
410
+ */
411
+ function detectMalformedCall(text: string, catalog: Set<string>): string | undefined {
412
+ const re = /"tool"\s*:\s*"([^"]+)"/g;
413
+ let m: RegExpExecArray | null;
414
+ while ((m = re.exec(text)) !== null) {
415
+ const name = m[1]!;
416
+ if (catalog.has(name)) {
417
+ return (
418
+ `A tool call for "${name}" looks malformed — it named the tool but did not parse as ` +
419
+ 'valid JSON (likely unbalanced braces or a missing closing brace), so no call ran. ' +
420
+ 'Re-emit it as exactly one complete JSON object: {"tool": "...", "args": { ... }}.'
421
+ );
422
+ }
423
+ }
424
+ return undefined;
294
425
  }
295
426
 
296
427
  /**
@@ -304,28 +435,80 @@ interface Parsed {
304
435
  function parseToolCalls(text: string | null, nextId: () => string, catalog: Set<string>): Parsed {
305
436
  if (!text) return { text: null, toolCalls: [] };
306
437
  const toolCalls: ToolCall[] = [];
307
- let prose = text;
438
+ const matched: Array<[number, number]> = [];
308
439
 
309
- const fence = /```(?:json)?\s*([\s\S]*?)```/gi;
310
- let match: RegExpExecArray | null;
311
- const consumed: string[] = [];
312
- while ((match = fence.exec(text)) !== null) {
313
- const call = toToolCall(match[1], nextId, catalog);
440
+ // Extract tool calls by scanning for complete JSON objects, NOT by matching
441
+ // ``` fences. A tool call's `content`/`args` can hold a full markdown doc that
442
+ // itself contains ``` code fences; a fence regex truncates the JSON at the
443
+ // first inner fence, JSON.parse fails, and the call is silently dropped (the
444
+ // model then assumes it wrote a file it never did). The brace scan is
445
+ // string-aware, so braces and backticks inside JSON string values are ignored.
446
+ let searchFrom = 0;
447
+ while (searchFrom < text.length) {
448
+ const braceAt = text.indexOf('{', searchFrom);
449
+ if (braceAt < 0) break;
450
+ const span = scanJsonObject(text, braceAt);
451
+ if (!span) {
452
+ // Unbalanced '{' (stray brace in prose): retry from the next candidate so
453
+ // one bad brace can't hide a well-formed call later in the reply.
454
+ searchFrom = braceAt + 1;
455
+ continue;
456
+ }
457
+ const call = toToolCall(text.slice(span.start, span.end), nextId, catalog);
314
458
  if (call) {
315
459
  toolCalls.push(call);
316
- consumed.push(match[0]);
460
+ matched.push([span.start, span.end]);
317
461
  }
462
+ searchFrom = span.end;
318
463
  }
319
- for (const block of consumed) prose = prose.replace(block, '');
320
464
 
321
- // No fenced tool block: maybe the whole reply is a bare JSON object.
322
465
  if (!toolCalls.length) {
323
- const call = toToolCall(text, nextId, catalog);
324
- if (call) return { text: null, toolCalls: [call] };
466
+ // No call dispatched but did the model clearly *intend* one? A fenced
467
+ // ```json block that names a catalog tool yet produced zero calls is a
468
+ // malformed near-miss (unbalanced braces, a missing `}`, or an inner object
469
+ // with no `tool` key). Silently dropping it gives the model no signal, so it
470
+ // misreads "no result" as "this tool is broken" and can bake that false
471
+ // conclusion into a committed summary (#I10). Surface a nudge instead.
472
+ return { text: text.trim() ? text : null, toolCalls, nudge: detectMalformedCall(text, catalog) };
473
+ }
474
+
475
+ // Prose is whatever survives once the tool-call objects (and any now-empty
476
+ // ```json fences around them) are removed.
477
+ let prose = '';
478
+ let cursor = 0;
479
+ for (const [start, end] of matched) {
480
+ prose += text.slice(cursor, start);
481
+ cursor = end;
325
482
  }
483
+ prose += text.slice(cursor);
484
+ prose = prose.replace(/```(?:json)?\s*```/gi, '').replace(/```(?:json)?\s*$/gi, '').trim();
485
+ return { text: prose.length ? prose : null, toolCalls };
486
+ }
326
487
 
327
- const trimmed = prose.trim();
328
- return { text: trimmed.length ? trimmed : null, toolCalls };
488
+ /**
489
+ * Find the first complete, brace-balanced JSON object at or after `from`,
490
+ * respecting JSON string quoting/escaping so braces or backticks inside string
491
+ * values do not end the scan. Returns its `[start, end)` bounds or null.
492
+ */
493
+ function scanJsonObject(text: string, from: number): { start: number; end: number } | null {
494
+ const start = text.indexOf('{', from);
495
+ if (start < 0) return null;
496
+ let depth = 0;
497
+ let inStr = false;
498
+ let esc = false;
499
+ for (let i = start; i < text.length; i++) {
500
+ const ch = text[i];
501
+ if (inStr) {
502
+ if (esc) esc = false;
503
+ else if (ch === '\\') esc = true;
504
+ else if (ch === '"') inStr = false;
505
+ continue;
506
+ }
507
+ if (ch === '"') inStr = true;
508
+ else if (ch === '{') depth++;
509
+ else if (ch === '}' && --depth === 0) return { start, end: i + 1 };
510
+ }
511
+ return null;
329
512
  }
330
513
 
331
514
  function toToolCall(raw: string | undefined, nextId: () => string, catalog: Set<string>): ToolCall | null {
@@ -0,0 +1,162 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import type { Msg, Provider } from './types.js';
5
+
6
+ /** Thrown when a single provider turn blows past its watchdog deadline. */
7
+ export class TurnTimeoutError extends Error {
8
+ constructor(public readonly ms: number) {
9
+ super(`turn exceeded ${ms}ms without responding`);
10
+ this.name = 'TurnTimeoutError';
11
+ }
12
+ }
13
+
14
+ /**
15
+ * Race `fn()` against a deadline so a hung provider call cannot stall the run
16
+ * forever. On timeout, `onTimeout` runs (tear down the in-flight call, e.g.
17
+ * provider.close()) and the returned promise rejects with TurnTimeoutError; the
18
+ * caller decides whether to retry or fail. `ms <= 0` (or non-finite) disables the
19
+ * watchdog and just awaits `fn()`.
20
+ */
21
+ export async function withTimeout<T>(
22
+ fn: () => Promise<T>,
23
+ ms: number,
24
+ onTimeout?: () => void | Promise<void>,
25
+ ): Promise<T> {
26
+ if (!Number.isFinite(ms) || ms <= 0) return fn();
27
+ let timer: ReturnType<typeof setTimeout> | undefined;
28
+ const timeout = new Promise<never>((_, reject) => {
29
+ timer = setTimeout(() => {
30
+ void Promise.resolve(onTimeout?.()).catch(() => {});
31
+ reject(new TurnTimeoutError(ms));
32
+ }, ms);
33
+ });
34
+ try {
35
+ return await Promise.race([fn(), timeout]);
36
+ } finally {
37
+ if (timer) clearTimeout(timer);
38
+ }
39
+ }
40
+
41
+ export interface StageDiagnosis {
42
+ verdict: 'retry' | 'abort';
43
+ reason: string;
44
+ /** When retrying: concrete instructions to prepend to the next attempt. */
45
+ guidance?: string;
46
+ /** Tokens the diagnosis call itself spent, so the pipeline can fold them into
47
+ * the stage's cost total (F6). Absent when the call threw before a response. */
48
+ usage?: { inputTokens: number; outputTokens: number };
49
+ }
50
+
51
+ /** Extract the first brace-balanced JSON object from text, tolerating quoting and
52
+ * escaping, and interpret it as a StageDiagnosis. Anything unparseable is treated
53
+ * as "abort" so an ambiguous diagnosis never loops the pipeline forever. */
54
+ export function parseDiagnosis(text: string | null): StageDiagnosis {
55
+ if (!text) return { verdict: 'abort', reason: 'no diagnosis produced' };
56
+ const start = text.indexOf('{');
57
+ if (start >= 0) {
58
+ let depth = 0;
59
+ let inStr = false;
60
+ let esc = false;
61
+ for (let i = start; i < text.length; i++) {
62
+ const ch = text[i];
63
+ if (inStr) {
64
+ if (esc) esc = false;
65
+ else if (ch === '\\') esc = true;
66
+ else if (ch === '"') inStr = false;
67
+ continue;
68
+ }
69
+ if (ch === '"') inStr = true;
70
+ else if (ch === '{') depth++;
71
+ else if (ch === '}' && --depth === 0) {
72
+ try {
73
+ const o = JSON.parse(text.slice(start, i + 1)) as Partial<StageDiagnosis>;
74
+ const verdict = o.verdict === 'retry' ? 'retry' : 'abort';
75
+ return {
76
+ verdict,
77
+ reason: typeof o.reason === 'string' ? o.reason : 'no reason given',
78
+ ...(verdict === 'retry' && typeof o.guidance === 'string' && o.guidance.trim()
79
+ ? { guidance: o.guidance.trim() }
80
+ : {}),
81
+ };
82
+ } catch {
83
+ break;
84
+ }
85
+ }
86
+ }
87
+ }
88
+ return { verdict: 'abort', reason: 'diagnosis was not valid JSON' };
89
+ }
90
+
91
+ /** Compact, most-recent-last excerpt of a run's transcript for the diagnostician:
92
+ * the last assistant message and the last few tool results, truncated. */
93
+ export async function transcriptExcerpt(transcriptDir: string, maxChars = 4000): Promise<string> {
94
+ const p = path.join(transcriptDir, 'transcript.jsonl');
95
+ if (!existsSync(p)) return '(no transcript)';
96
+ let lines: string[];
97
+ try {
98
+ lines = (await readFile(p, 'utf8')).trim().split('\n');
99
+ } catch {
100
+ return '(transcript unreadable)';
101
+ }
102
+ const parts: string[] = [];
103
+ for (const line of lines.slice(-12)) {
104
+ try {
105
+ const e = JSON.parse(line) as { type: string; data?: Record<string, unknown> };
106
+ if (e.type === 'assistant' && typeof e.data?.text === 'string' && e.data.text) {
107
+ parts.push(`[assistant] ${e.data.text}`);
108
+ } else if (e.type === 'tool') {
109
+ parts.push(`[${String(e.data?.name)}] ${String(e.data?.result ?? '').split('\n')[0]}`);
110
+ }
111
+ } catch {
112
+ /* skip */
113
+ }
114
+ }
115
+ const joined = parts.join('\n');
116
+ return joined.length > maxChars ? joined.slice(joined.length - maxChars) : joined;
117
+ }
118
+
119
+ /**
120
+ * Ask the model whether a failed/incomplete stage is worth retrying, and if so
121
+ * how. Uses a fresh, tool-less provider turn (the same saved-login backend the
122
+ * pipeline runs on), so no extra credentials or config are needed. Any error or
123
+ * ambiguity resolves to "abort" — recovery must fail safe toward reporting to the
124
+ * human rather than looping.
125
+ */
126
+ export async function diagnoseStageFailure(
127
+ provider: Provider,
128
+ input: {
129
+ stageName: string;
130
+ stageGoal: string;
131
+ failure: string;
132
+ excerpt: string;
133
+ attempt: number;
134
+ maxAttempts: number;
135
+ },
136
+ ): Promise<StageDiagnosis> {
137
+ const system =
138
+ 'You are the recovery supervisor for an automated KiCad PCB-design pipeline. ' +
139
+ 'A stage just failed or ended without meeting its completion contract. Judge whether ' +
140
+ 'another automated attempt is likely to succeed, or whether a human should intervene. ' +
141
+ 'Be decisive and terse.';
142
+ const user =
143
+ `Stage: ${input.stageName}\n` +
144
+ `Stage goal: ${input.stageGoal}\n` +
145
+ `Failure: ${input.failure}\n` +
146
+ `This was attempt ${input.attempt} of ${input.maxAttempts}.\n\n` +
147
+ `Recent transcript (most recent last):\n${input.excerpt}\n\n` +
148
+ 'Reply with ONLY a JSON object, no prose:\n' +
149
+ '{"verdict":"retry"|"abort","reason":"<one sentence>","guidance":"<if retry: concrete, specific instructions to prepend to the next attempt so it avoids this failure; otherwise empty>"}\n' +
150
+ '- "retry" if the failure looks transient or fixable with clearer instructions (a dropped or locked tool call, an empty/no-op edit, a skipped step, a timeout, a formatting slip).\n' +
151
+ '- "abort" if repeating the same attempt will not help and a human should look (missing inputs, a genuine dead-end, or the same failure already seen on a prior attempt).';
152
+ const messages: Msg[] = [
153
+ { role: 'system', content: system },
154
+ { role: 'user', content: user },
155
+ ];
156
+ try {
157
+ const turn = await provider.chat(messages, []);
158
+ return { ...parseDiagnosis(turn.text), usage: turn.usage };
159
+ } catch (e) {
160
+ return { verdict: 'abort', reason: `diagnosis call failed: ${(e as Error).message}` };
161
+ }
162
+ }
@@ -12,6 +12,14 @@ export interface ProgressRenderer {
12
12
  toolResult(name: string, firstLine: string): void;
13
13
  /** Busy text while a provider call is in flight; null when idle. */
14
14
  status(text: string | null): void;
15
+ /**
16
+ * Liveness signal emitted periodically while a provider turn is in flight
17
+ * (5.1): distinguishes a slow turn from a hung one. `elapsedMs` is time since
18
+ * this turn's provider call began; `streamedChars` is cumulative streamed
19
+ * output (0 when the provider doesn't stream — the elapsed time still tells
20
+ * the operator the turn is alive).
21
+ */
22
+ heartbeat(info: { elapsedMs: number; streamedChars: number }): void;
15
23
  /** Final outcome line; replaces the status line in interactive mode. */
16
24
  finish(line: string): void;
17
25
  }
@@ -41,6 +49,11 @@ export function plainRenderer(log: (line: string) => void): ProgressRenderer {
41
49
  turnStart: (turn, maxTurns, tokensIn, tokensOut) => log(turnMarker(turn, maxTurns, tokensIn, tokensOut)),
42
50
  toolResult: (name, firstLine) => log(` [${name}] ${firstLine}`),
43
51
  status: () => {},
52
+ heartbeat: ({ elapsedMs, streamedChars }) =>
53
+ log(
54
+ ` … still working — ${fmtDuration(elapsedMs)} elapsed` +
55
+ (streamedChars ? `, ~${fmtTokens(streamedChars)} chars streamed` : ' (no output yet)'),
56
+ ),
44
57
  finish: (line) => log(line),
45
58
  };
46
59
  }
@@ -68,6 +81,7 @@ export class InteractiveRenderer implements ProgressRenderer {
68
81
  private maxTurns = 0;
69
82
  private tokensIn = 0;
70
83
  private tokensOut = 0;
84
+ private streamedChars = 0;
71
85
  private busy: string | null = null;
72
86
  private frame = 0;
73
87
  private timer: ReturnType<typeof setInterval> | null = null;
@@ -97,7 +111,11 @@ export class InteractiveRenderer implements ProgressRenderer {
97
111
  `${fmtTokens(this.tokensIn)} in / ${fmtTokens(this.tokensOut)} out`,
98
112
  fmtDuration(Date.now() - this.startMs),
99
113
  ];
100
- if (this.busy) parts.push(this.busy);
114
+ if (this.busy) {
115
+ // Fold streamed-output volume into the busy segment so a large turn's
116
+ // status line visibly grows — a hung one stays frozen (5.1).
117
+ parts.push(this.streamedChars ? `${this.busy} ~${fmtTokens(this.streamedChars)} ch` : this.busy);
118
+ }
101
119
  const spinner = this.busy ? FRAMES[this.frame % FRAMES.length] : '·';
102
120
  const line = `${spinner} ${parts.join(' · ')}`;
103
121
  const width = this.out.columns ?? 80;
@@ -138,6 +156,7 @@ export class InteractiveRenderer implements ProgressRenderer {
138
156
  this.maxTurns = maxTurns;
139
157
  this.tokensIn = tokensIn;
140
158
  this.tokensOut = tokensOut;
159
+ this.streamedChars = 0; // per-turn: reset so last turn's volume doesn't linger
141
160
  this.ensureTimer();
142
161
  this.redraw();
143
162
  }
@@ -148,10 +167,18 @@ export class InteractiveRenderer implements ProgressRenderer {
148
167
 
149
168
  status(text: string | null): void {
150
169
  this.busy = text;
170
+ if (!text) this.streamedChars = 0; // turn's provider call ended
151
171
  if (text && !this.idle) this.ensureTimer();
152
172
  this.redraw();
153
173
  }
154
174
 
175
+ heartbeat({ streamedChars }: { elapsedMs: number; streamedChars: number }): void {
176
+ // The spinner timer already advances elapsed time in place; the heartbeat's
177
+ // job here is to fold in the latest streamed-output volume and redraw.
178
+ this.streamedChars = streamedChars;
179
+ this.redraw();
180
+ }
181
+
155
182
  finish(line: string): void {
156
183
  if (this.statusShown) this.out.write(CLEAR_LINE);
157
184
  this.out.write(line + '\n');
@@ -0,0 +1,80 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync } from 'node:fs';
3
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import type { ChatOpts, Msg, Provider, ToolSchema, Turn } from './types.js';
6
+
7
+ /**
8
+ * Wraps a provider so each turn's `(messages, tools) -> Turn` is written to disk
9
+ * and replayed on an identical later call. This makes the pipeline cheap and
10
+ * fast to recover: a stage that is retried after a transient failure (a timed-out
11
+ * turn, a crash, an auto-retry) replays the responses it already paid for, from
12
+ * turn 1 up to the point where the inputs first diverge, instead of re-calling
13
+ * the model. When a retry deliberately changes the prompt (e.g. diagnosis
14
+ * guidance is appended), the input hash changes and the model is called fresh —
15
+ * so caching never pins a run to a stale, failing response.
16
+ *
17
+ * Best-effort by construction: a cache miss or any I/O error falls through to the
18
+ * live provider, and a hit reports zero token usage (the real spend was zero).
19
+ * The key is a content hash of the full message history and the advertised tool
20
+ * names, so any change to the conversation or available tools is a fresh call.
21
+ */
22
+ export class CachingProvider implements Provider {
23
+ readonly name: string;
24
+ private hits = 0;
25
+
26
+ /** Turns served from the on-disk cache so far (5.2: per-stage cache-hit%). */
27
+ get cacheHits(): number {
28
+ return this.hits;
29
+ }
30
+
31
+ constructor(
32
+ private readonly inner: Provider,
33
+ private readonly dir: string,
34
+ private readonly log?: (s: string) => void,
35
+ /** The concrete model id this run resolved to (e.g. `claude-code:opus`), used
36
+ * in the cache key so switching model on the same repo does not replay the
37
+ * other model's cached turns (F6). Falls back to the provider family name. */
38
+ private readonly modelId?: string,
39
+ ) {
40
+ this.name = inner.name;
41
+ }
42
+
43
+ private keyFor(messages: Msg[], tools: ToolSchema[]): string {
44
+ return createHash('sha256')
45
+ .update(JSON.stringify({ model: this.modelId ?? this.name, messages, tools: tools.map((t) => t.name) }))
46
+ .digest('hex');
47
+ }
48
+
49
+ async chat(messages: Msg[], tools: ToolSchema[], opts?: ChatOpts): Promise<Turn> {
50
+ const file = path.join(this.dir, `${this.keyFor(messages, tools)}.json`);
51
+ if (existsSync(file)) {
52
+ try {
53
+ const cached = JSON.parse(await readFile(file, 'utf8')) as Turn;
54
+ this.hits++;
55
+ this.log?.(`llm-cache: replayed a cached response (hit #${this.hits}, no tokens spent)`);
56
+ // Report zero usage: replaying a cached turn costs nothing.
57
+ return { ...cached, usage: { inputTokens: 0, outputTokens: 0 } };
58
+ } catch {
59
+ // corrupt/partial cache file — fall through and regenerate
60
+ }
61
+ }
62
+ const turn = await this.inner.chat(messages, tools, opts);
63
+ try {
64
+ await mkdir(this.dir, { recursive: true });
65
+ // Keep the cache out of git entirely (and out of failed-run stashes): a
66
+ // `*` .gitignore in the cache dir hides every entry, so the cache persists
67
+ // across runs without ever dirtying the tree.
68
+ const ignore = path.join(this.dir, '.gitignore');
69
+ if (!existsSync(ignore)) await writeFile(ignore, '*\n', 'utf8');
70
+ await writeFile(file, JSON.stringify(turn), 'utf8');
71
+ } catch {
72
+ // best-effort: caching must never break a run
73
+ }
74
+ return turn;
75
+ }
76
+
77
+ async close(): Promise<void> {
78
+ await this.inner.close?.();
79
+ }
80
+ }