copperhead 0.9.0 → 0.11.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 (142) hide show
  1. package/NOTICE +1 -1
  2. package/README.md +47 -8
  3. package/dist/agent/context.js +2 -0
  4. package/dist/agent/context.js.map +1 -0
  5. package/dist/agent/dock-renderer.js +2 -2
  6. package/dist/agent/dock-renderer.js.map +1 -1
  7. package/dist/agent/envelope.js +105 -0
  8. package/dist/agent/envelope.js.map +1 -0
  9. package/dist/agent/filetools.js +24 -1
  10. package/dist/agent/filetools.js.map +1 -1
  11. package/dist/agent/ledger.js +24 -0
  12. package/dist/agent/ledger.js.map +1 -1
  13. package/dist/agent/loop.js +63 -72
  14. package/dist/agent/loop.js.map +1 -1
  15. package/dist/agent/prompts.js +4 -3
  16. package/dist/agent/prompts.js.map +1 -1
  17. package/dist/agent/providers/claude-code.js +17 -1
  18. package/dist/agent/providers/claude-code.js.map +1 -1
  19. package/dist/agent/providers/codex.js +84 -39
  20. package/dist/agent/providers/codex.js.map +1 -1
  21. package/dist/agent/providers/tool-protocol.js +21 -0
  22. package/dist/agent/providers/tool-protocol.js.map +1 -1
  23. package/dist/agent/recovery.js +186 -15
  24. package/dist/agent/recovery.js.map +1 -1
  25. package/dist/agent/registry.js +49 -0
  26. package/dist/agent/registry.js.map +1 -0
  27. package/dist/agent/render.js +2 -2
  28. package/dist/agent/render.js.map +1 -1
  29. package/dist/agent/theme.js +10 -5
  30. package/dist/agent/theme.js.map +1 -1
  31. package/dist/agent/tools.js +100 -586
  32. package/dist/agent/tools.js.map +1 -1
  33. package/dist/agent/transcript.js +2 -0
  34. package/dist/agent/transcript.js.map +1 -1
  35. package/dist/capabilities/define.js +35 -0
  36. package/dist/capabilities/define.js.map +1 -0
  37. package/dist/capabilities/handlers.js +744 -0
  38. package/dist/capabilities/handlers.js.map +1 -0
  39. package/dist/capabilities/helpers.js +39 -0
  40. package/dist/capabilities/helpers.js.map +1 -0
  41. package/dist/capabilities/index.js +50 -0
  42. package/dist/capabilities/index.js.map +1 -0
  43. package/dist/capabilities/skills/generate-report.js +23 -0
  44. package/dist/capabilities/skills/generate-report.js.map +1 -0
  45. package/dist/cli.js +158 -0
  46. package/dist/cli.js.map +1 -1
  47. package/dist/commands/check.js +33 -1
  48. package/dist/commands/check.js.map +1 -1
  49. package/dist/commands/create.js +181 -26
  50. package/dist/commands/create.js.map +1 -1
  51. package/dist/commands/doctor.js +83 -6
  52. package/dist/commands/doctor.js.map +1 -1
  53. package/dist/commands/skill.js +109 -0
  54. package/dist/commands/skill.js.map +1 -0
  55. package/dist/commands/sync.js +3 -1
  56. package/dist/commands/sync.js.map +1 -1
  57. package/dist/config.js +19 -6
  58. package/dist/config.js.map +1 -1
  59. package/dist/kicad/bootstrap.js +24 -3
  60. package/dist/kicad/bootstrap.js.map +1 -1
  61. package/dist/kicad/cli.js +106 -18
  62. package/dist/kicad/cli.js.map +1 -1
  63. package/dist/kicad/dossier.js +207 -0
  64. package/dist/kicad/dossier.js.map +1 -0
  65. package/dist/kicad/draft/draft.js +135 -0
  66. package/dist/kicad/draft/draft.js.map +1 -0
  67. package/dist/kicad/draft/engine.js +5310 -0
  68. package/dist/kicad/draft/engine.js.map +1 -0
  69. package/dist/kicad/draft/ir.js +368 -0
  70. package/dist/kicad/draft/ir.js.map +1 -0
  71. package/dist/kicad/draft/symsource.js +504 -0
  72. package/dist/kicad/draft/symsource.js.map +1 -0
  73. package/dist/kicad/emit.js +220 -0
  74. package/dist/kicad/emit.js.map +1 -0
  75. package/dist/kicad/fab.js +13 -0
  76. package/dist/kicad/fab.js.map +1 -1
  77. package/dist/kicad/legibility.js +608 -0
  78. package/dist/kicad/legibility.js.map +1 -0
  79. package/dist/kicad/score.js +431 -0
  80. package/dist/kicad/score.js.map +1 -0
  81. package/dist/kicad/sexp.js +265 -6
  82. package/dist/kicad/sexp.js.map +1 -1
  83. package/dist/kicad/symlib.js +346 -16
  84. package/dist/kicad/symlib.js.map +1 -1
  85. package/dist/mcp/server.js +485 -0
  86. package/dist/mcp/server.js.map +1 -0
  87. package/dist/memory/bom-table.js +75 -39
  88. package/dist/memory/bom-table.js.map +1 -1
  89. package/dist/memory/scaffold.js +14 -1
  90. package/dist/memory/scaffold.js.map +1 -1
  91. package/dist/util/redact.js +6 -0
  92. package/dist/util/redact.js.map +1 -1
  93. package/package.json +13 -8
  94. package/src/agent/context.ts +35 -0
  95. package/src/agent/dock-renderer.ts +3 -2
  96. package/src/agent/envelope.ts +124 -0
  97. package/src/agent/filetools.ts +26 -1
  98. package/src/agent/ledger.ts +24 -0
  99. package/src/agent/loop.ts +73 -78
  100. package/src/agent/prompts.ts +4 -3
  101. package/src/agent/providers/claude-code.ts +22 -1
  102. package/src/agent/providers/codex.ts +91 -42
  103. package/src/agent/providers/tool-protocol.ts +22 -0
  104. package/src/agent/recovery.ts +183 -13
  105. package/src/agent/registry.ts +58 -0
  106. package/src/agent/render.ts +4 -3
  107. package/src/agent/theme.ts +15 -5
  108. package/src/agent/tools.ts +124 -628
  109. package/src/agent/transcript.ts +6 -0
  110. package/src/agent/types.ts +10 -5
  111. package/src/capabilities/define.ts +88 -0
  112. package/src/capabilities/handlers.ts +769 -0
  113. package/src/capabilities/helpers.ts +37 -0
  114. package/src/capabilities/index.ts +53 -0
  115. package/src/capabilities/skills/generate-report.ts +25 -0
  116. package/src/cli.ts +154 -0
  117. package/src/commands/check.ts +51 -1
  118. package/src/commands/create.ts +183 -21
  119. package/src/commands/doctor.ts +85 -6
  120. package/src/commands/skill.ts +127 -0
  121. package/src/commands/sync.ts +5 -3
  122. package/src/config.ts +56 -8
  123. package/src/kicad/bootstrap.ts +24 -3
  124. package/src/kicad/cli.ts +129 -18
  125. package/src/kicad/dossier.ts +217 -0
  126. package/src/kicad/draft/draft.ts +173 -0
  127. package/src/kicad/draft/engine.ts +5274 -0
  128. package/src/kicad/draft/ir.ts +416 -0
  129. package/src/kicad/draft/symsource.ts +549 -0
  130. package/src/kicad/emit.ts +300 -0
  131. package/src/kicad/fab.ts +15 -0
  132. package/src/kicad/legibility.ts +695 -0
  133. package/src/kicad/score.ts +502 -0
  134. package/src/kicad/sexp.ts +346 -6
  135. package/src/kicad/symlib.ts +364 -18
  136. package/src/mcp/server.ts +560 -0
  137. package/src/memory/bom-table.ts +85 -38
  138. package/src/memory/scaffold.ts +14 -1
  139. package/src/util/redact.ts +6 -0
  140. package/dist/memory/synap.js +0 -152
  141. package/dist/memory/synap.js.map +0 -1
  142. package/src/memory/synap.ts +0 -217
package/src/agent/loop.ts CHANGED
@@ -2,11 +2,13 @@ import path from 'node:path';
2
2
  import { readFile, writeFile } from 'node:fs/promises';
3
3
  import { execa } from 'execa';
4
4
  import type { Msg, Provider, Turn } from './types.js';
5
- import { availableTools, dispatchTool, type RunContext } from './tools.js';
5
+ import { availableTools, dispatchToolResult, type RunContext } from './tools.js';
6
+ import { flatten } from './envelope.js';
6
7
  import { CachingProvider } from './response-cache.js';
7
- import { withTimeout, TurnTimeoutError } from './recovery.js';
8
+ import { withWatchdog, TurnTimeoutError, MAX_TURN_TIMEOUTS } from './recovery.js';
8
9
  import { buildSystemPrompt } from './prompts.js';
9
10
  import { loadConstraints, reopenDeferredAffects } from '../memory/constraints.js';
11
+ import { isCreateProducedRepo, isEngineAuthoredSchematic } from '../kicad/fab.js';
10
12
  import {
11
13
  loadConfig,
12
14
  CONFIG_DIR,
@@ -30,7 +32,6 @@ import { AnthropicProvider } from './providers/anthropic.js';
30
32
  import { CodexProvider } from './providers/codex.js';
31
33
  import { ClaudeCodeProvider } from './providers/claude-code.js';
32
34
  import { CursorProvider } from './providers/cursor.js';
33
- import { openSynapMemory, type RunRecord, type SynapMemory } from '../memory/synap.js';
34
35
 
35
36
  /** What the user sees at the moment they decide whether to keep going. */
36
37
  export interface BudgetExhaustedStats {
@@ -202,16 +203,10 @@ async function appendChangelog(
202
203
  await writeFile(p, lines.join('\n'), 'utf8');
203
204
  }
204
205
 
205
- /**
206
- * Owns the Synap session for one run. The bridge is a subprocess, so the
207
- * shutdown in `finally` is what lets the CLI exit; without it the process
208
- * hangs after a successful run.
209
- */
210
206
  export async function runAgentLoop(opts: RunOptions): Promise<RunResult> {
211
- const memory = await openSynapMemory({ repoRoot: opts.repoRoot, log: opts.log });
212
207
  const providers = new Set<Provider>();
213
208
  try {
214
- return await runWithMemory(opts, memory, providers);
209
+ return await runWithProviders(opts, providers);
215
210
  } finally {
216
211
  for (const provider of providers) {
217
212
  try {
@@ -220,15 +215,10 @@ export async function runAgentLoop(opts: RunOptions): Promise<RunResult> {
220
215
  opts.log?.(`warning: ${provider.name} provider cleanup failed (${(err as Error).message})`);
221
216
  }
222
217
  }
223
- await memory?.close();
224
218
  }
225
219
  }
226
220
 
227
- async function runWithMemory(
228
- opts: RunOptions,
229
- memory: SynapMemory | null,
230
- providers: Set<Provider>,
231
- ): Promise<RunResult> {
221
+ async function runWithProviders(opts: RunOptions, providers: Set<Provider>): Promise<RunResult> {
232
222
  const r = opts.renderer ?? plainRenderer(opts.log ?? ((l: string) => console.log(l)));
233
223
  const log = (l: string): void => r.log(l);
234
224
  const repoRoot = opts.repoRoot;
@@ -240,11 +230,28 @@ async function runWithMemory(
240
230
 
241
231
  const transcript = new Transcript(repoRoot);
242
232
  await transcript.init();
233
+ // Legibility gates finish only where copperhead authored the sheet; a
234
+ // hand-drawn repo gets findings as information, never as a wedge (C6).
235
+ // Both conditions matter: the create-origin marker scopes the gate to repos
236
+ // this tool produced, and the generator stamp scopes it to sheets copperhead
237
+ // still owns. A human taking the sheet over in KiCad re-saves it under
238
+ // KiCad's generator, and from then on the gate must not defend a drawing
239
+ // the engine can no longer regenerate. A create repo whose schematic is not
240
+ // yet scaffolded keeps the gate: the sheet stage 4 will produce is
241
+ // copperhead-authored by construction.
242
+ let gateLegibility = isCreateProducedRepo(config);
243
+ if (gateLegibility && config.schematic) {
244
+ try {
245
+ gateLegibility = isEngineAuthoredSchematic(await readFile(path.join(repoRoot, config.schematic), 'utf8'));
246
+ } catch {
247
+ // schematic configured but absent (pre-scaffold): keep the gate
248
+ }
249
+ }
243
250
  const ctx: RunContext = {
244
251
  repoRoot,
245
252
  config,
246
253
  transcript,
247
- ledger: new ObligationsLedger(),
254
+ ledger: new ObligationsLedger(gateLegibility),
248
255
  runId: path.basename(transcript.dir),
249
256
  interactive: opts.interactive ?? false,
250
257
  confirm: opts.confirm ?? (async () => true),
@@ -254,6 +261,8 @@ async function runWithMemory(
254
261
  filesTouched: new Set(),
255
262
  decisions: [],
256
263
  lastErc: null,
264
+ lastLegibility: null,
265
+ lastScore: null,
257
266
  lastDrc: null,
258
267
  repairCycles: 0,
259
268
  finishRequest: null,
@@ -328,37 +337,12 @@ async function runWithMemory(
328
337
  ...reopened.map((r) => `- ${r.key} affects ${r.item}`),
329
338
  ].join('\n');
330
339
  }
331
- // Cross-run memory is appended after the repo's own docs and constraints so
332
- // that the in-repo sources of truth are what the model reads first.
333
- const recalled = memory ? await memory.recall(opts.request) : null;
334
- if (recalled) {
335
- await transcript.event('synap-recall', { chars: recalled.length });
336
- log('recalled prior context from Synap memory');
337
- }
338
- const system = recalled ? `${basePrompt}\n\n${recalled}` : basePrompt;
339
340
  const messages: Msg[] = [
340
- { role: 'system', content: system },
341
+ { role: 'system', content: basePrompt },
341
342
  { role: 'user', content: opts.stagePrompt ? `${opts.stagePrompt}\n\nRequest: ${opts.request}` : opts.request },
342
343
  ];
343
344
  await transcript.event('run-start', meta);
344
345
 
345
- /**
346
- * A memory write that fails is reported rather than swallowed, but it does
347
- * not change the run's outcome: discarding a verified commit because a
348
- * third-party write failed would be the worse trade.
349
- */
350
- const remember = async (run: RunRecord): Promise<void> => {
351
- if (!memory) return;
352
- try {
353
- await memory.record(run);
354
- await transcript.event('synap-record', { outcome: run.outcome });
355
- } catch (err) {
356
- const message = (err as Error).message;
357
- log(`warning: Synap memory write failed (${message}); this run was not recorded`);
358
- await transcript.event('synap-record-failed', { error: message });
359
- }
360
- };
361
-
362
346
  let tokensIn = 0;
363
347
  let tokensOut = 0;
364
348
  let turnsUsed = 0;
@@ -366,7 +350,7 @@ async function runWithMemory(
366
350
  let plan: string | null = null;
367
351
  let nudges = 0;
368
352
  let turnTimeouts = 0;
369
- const maxTurnTimeouts = 3;
353
+ const maxTurnTimeouts = MAX_TURN_TIMEOUTS;
370
354
 
371
355
  const stats = (exitPath: ExitPath): RunStats => ({
372
356
  exitPath,
@@ -416,6 +400,8 @@ async function runWithMemory(
416
400
  filesTouched: [...ctx.filesTouched],
417
401
  ercResult: ctx.lastErc ? (ctx.lastErc.ok ? 'clean' : `${ctx.lastErc.violations.length} violations`) : null,
418
402
  drcResult: ctx.lastDrc ? (ctx.lastDrc.ok ? 'clean' : `${ctx.lastDrc.violations.length} violations`) : null,
403
+ legibilityResult: ctx.lastLegibility ? `${ctx.lastLegibility.error} error, ${ctx.lastLegibility.advisory} advisory` : null,
404
+ scoreResult: ctx.lastScore !== null ? `${ctx.lastScore}/100` : null,
419
405
  decisions: ctx.decisions,
420
406
  tokensIn,
421
407
  tokensOut,
@@ -515,28 +501,54 @@ async function runWithMemory(
515
501
  : null;
516
502
  heartbeat?.unref?.();
517
503
  try {
504
+ // Inactivity watchdog plus hard cap: every onStream call is progress and
505
+ // restarts the idle deadline, so a turn that legitimately runs past
506
+ // turnTimeoutMs survives while it keeps producing output. A provider that
507
+ // never streams gets turnTimeoutMs as a whole-turn deadline, as before.
518
508
  res = await withRetry(
519
509
  () =>
520
- withTimeout(
521
- () => provider.chat(messages, tools, { onStream: (chars) => (streamedChars = chars) }),
522
- config.turnTimeoutMs,
523
- () => provider.close?.(),
510
+ withWatchdog(
511
+ (activity) =>
512
+ provider.chat(messages, tools, {
513
+ onStream: (chars) => {
514
+ streamedChars = chars;
515
+ activity();
516
+ },
517
+ }),
518
+ { idleMs: config.turnTimeoutMs, maxMs: config.turnMaxMs, onTimeout: () => provider.close?.() },
524
519
  ),
525
520
  { onRetry: (attempt) => log(`rate limited; retry ${attempt}`) },
526
521
  );
527
522
  } catch (err) {
523
+ if (err instanceof TurnTimeoutError && err.kind === 'max') {
524
+ // Still producing output at the hard cap: the turn is too large, not
525
+ // hung. Resending the identical request would stream just as long and
526
+ // hit the cap again, so fail now with a reason that says so; the create
527
+ // pipeline hands it to the diagnosis, which can split the work.
528
+ await transcript.event('turn-timeout', { kind: 'max', ms: err.ms, streamedChars });
529
+ return fail(
530
+ `a single provider turn was still producing output after ${fmtDuration(err.ms)} (turnMaxMs) and was stopped — ` +
531
+ 'the turn is too large, not hung; split the work into smaller steps, or raise turnMaxMs',
532
+ 'provider-error',
533
+ );
534
+ }
528
535
  if (err instanceof TurnTimeoutError) {
529
- // A hung provider turn: the watchdog aborted the in-flight call and tore
530
- // down its subprocess. Retry the same turn a bounded number of times
531
- // before giving up, so a transient hang self-heals instead of stalling
532
- // the run forever.
536
+ // A hung provider turn: no response or progress for turnTimeoutMs. The
537
+ // watchdog aborted the in-flight call and tore down its subprocess.
538
+ // Retry the same turn a bounded number of times before giving up, so a
539
+ // transient hang self-heals instead of stalling the run forever.
533
540
  if (turnTimeouts++ < maxTurnTimeouts) {
534
- log(`turn exceeded ${config.turnTimeoutMs}ms; aborted the hung call and retrying (${turnTimeouts}/${maxTurnTimeouts})`);
535
- await transcript.event('turn-timeout', { ms: config.turnTimeoutMs, attempt: turnTimeouts });
541
+ log(
542
+ `turn went ${fmtDuration(err.ms)} without a response or progress (turnTimeoutMs); aborted the hung call and retrying (${turnTimeouts}/${maxTurnTimeouts})`,
543
+ );
544
+ await transcript.event('turn-timeout', { kind: 'idle', ms: err.ms, attempt: turnTimeouts });
536
545
  turn--;
537
546
  continue;
538
547
  }
539
- return fail(`provider turns timed out ${turnTimeouts}× (>${config.turnTimeoutMs}ms each)`, 'provider-error');
548
+ return fail(
549
+ `provider turns timed out ${turnTimeouts}× (no response or progress for ${fmtDuration(err.ms)} each)`,
550
+ 'provider-error',
551
+ );
540
552
  }
541
553
  if (isRateLimit(err)) {
542
554
  const fallback = otherProvider(provider);
@@ -605,9 +617,10 @@ async function runWithMemory(
605
617
  nudges = 0;
606
618
 
607
619
  for (const call of res.toolCalls) {
608
- const result = await dispatchTool(ctx, call.name, call.args);
609
- await transcript.event('tool', { name: call.name, args: call.args, result });
610
- r.toolResult(call.name, result.split('\n')[0] ?? '');
620
+ const envelope = await dispatchToolResult(ctx, call.name, call.args, { provider });
621
+ const result = flatten(envelope);
622
+ await transcript.event('tool', { name: call.name, args: call.args, result, envelope });
623
+ r.toolResult(call.name, envelope.summary, envelope.ok, envelope.viewHint);
611
624
  messages.push({ role: 'tool', toolCallId: call.id, content: result });
612
625
  }
613
626
 
@@ -648,17 +661,6 @@ async function runWithMemory(
648
661
  env: meta,
649
662
  stats: runStats,
650
663
  });
651
- // Refusals are the most valuable thing to remember: they encode a budget
652
- // or constraint that this user's designs keep running into.
653
- await remember({
654
- request: opts.request,
655
- outcome: 'refused',
656
- summary,
657
- changeId: ctx.changeId,
658
- filesTouched: [],
659
- decisions: ctx.decisions,
660
- verification: 'n/a (refused before verification)',
661
- });
662
664
  log(`refused: ${summary}`);
663
665
  r.finish(outcomeLine(runStats));
664
666
  return {
@@ -777,6 +779,8 @@ async function runWithMemory(
777
779
  filesTouched: files,
778
780
  ercResult: ctx.lastErc ? (ctx.lastErc.ok ? 'clean' : 'FAILING') : 'not run',
779
781
  drcResult: ctx.lastDrc ? (ctx.lastDrc.ok ? 'clean' : 'FAILING') : 'not run',
782
+ legibilityResult: ctx.lastLegibility ? `${ctx.lastLegibility.error} error, ${ctx.lastLegibility.advisory} advisory` : null,
783
+ scoreResult: ctx.lastScore !== null ? `${ctx.lastScore}/100` : null,
780
784
  decisions: ctx.decisions,
781
785
  tokensIn,
782
786
  tokensOut,
@@ -785,15 +789,6 @@ async function runWithMemory(
785
789
  env: meta,
786
790
  stats: runStats,
787
791
  });
788
- await remember({
789
- request: opts.request,
790
- outcome: 'success',
791
- summary,
792
- changeId: ctx.changeId,
793
- filesTouched: files,
794
- decisions: ctx.decisions,
795
- verification,
796
- });
797
792
  log(`committed ${commit.slice(0, 10)} (${files.length} file(s))`);
798
793
  r.finish(outcomeLine(runStats, `committed ${commit.slice(0, 10)}`));
799
794
  return {
@@ -19,9 +19,10 @@ const WORKFLOW = `Workflow for every run:
19
19
  2. Call propose_change with a change id (kebab-case), why, what changes, and tasks. Then call validate_change. Edit tools (edit_file, write_file) unlock only after validation passes.
20
20
  3. Make the edits. Use the exact same net names and refdes everywhere. For .kicad_sch/.kicad_pcb use edit_file with unique anchors from the actual file text (read the file first). For renaming a net or refdes across a file, one edit_file call with replace_all: true beats many small edits.
21
21
  4. Run run_erc after schematic edits (and run_drc after board edits). If violations: read them, fix, re-run.
22
- 5. Run check_drift; update any doc that references a changed value/part/pin in the same run.
23
- 6. Record every non-trivial decision with record_decision, and every stated/assumed/discovered constraint with record_constraint.
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.
22
+ 5. After schematic edits also run check_legibility and reconcile every error-severity finding (advisories inform, they do not block). An electrically correct sheet that reads badly is not done: finish refuses while error findings are outstanding, the same way it refuses on a failing ERC.
23
+ 6. Run check_drift; update any doc that references a changed value/part/pin in the same run.
24
+ 7. Record every non-trivial decision with record_decision, and every stated/assumed/discovered constraint with record_constraint.
25
+ 8. 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
26
 
26
27
  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
28
  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.`;
@@ -62,6 +62,11 @@ export interface QueryOptions {
62
62
  * turns itself instead of us re-sending the whole conversation each turn (1.1,
63
63
  * `Options.resume`). Only set in the opt-in session-resume mode. */
64
64
  resume?: string;
65
+ /** Emit `stream_event` messages as the model generates, on top of the
66
+ * complete `assistant` message (Agent SDK `Options.includePartialMessages`).
67
+ * Used only as a progress signal for the turn watchdog and heartbeat; the
68
+ * reply is still read from the complete message. */
69
+ includePartialMessages?: boolean;
65
70
  }
66
71
  export interface QueryArgs {
67
72
  prompt: string;
@@ -73,6 +78,8 @@ export interface QueryMessage {
73
78
  session_id?: string;
74
79
  message?: { content?: Array<{ type: string; text?: string }> };
75
80
  usage?: { input_tokens?: number; output_tokens?: number };
81
+ /** On a `stream_event` message: the raw Messages-API stream event. */
82
+ event?: { type: string; delta?: { type: string; text?: string } };
76
83
  }
77
84
  export type QueryLike = (args: QueryArgs) => AsyncIterable<QueryMessage>;
78
85
 
@@ -153,6 +160,8 @@ export class ClaudeCodeProvider implements Provider {
153
160
  let text: string | null = null;
154
161
  let inputTokens = 0;
155
162
  let outputTokens = 0;
163
+ // Visible text streamed so far, for the heartbeat's count (see stream_event).
164
+ let streamedChars = 0;
156
165
  // One aborter per turn: close() aborts it to kill a hung subprocess.
157
166
  const aborter = new AbortController();
158
167
  this.inFlight.add(aborter);
@@ -163,6 +172,10 @@ export class ClaudeCodeProvider implements Provider {
163
172
  systemPrompt,
164
173
  ...(this.model ? { model: this.model } : {}),
165
174
  abortController: aborter,
175
+ // Partial-message events are the turn's progress signal: without them
176
+ // the SDK yields nothing until the reply is complete, so a turn that is
177
+ // still generating looks exactly like a hung one to the watchdog.
178
+ includePartialMessages: true,
166
179
  // Layered "the SDK executes nothing" defense (D1/D5):
167
180
  // 1. `tools: []` disables ALL built-in tools (Agent SDK 0.3.x docs:
168
181
  // "[] (empty array) - Disable all built-in tools").
@@ -189,7 +202,15 @@ export class ClaudeCodeProvider implements Provider {
189
202
  maxTurns: 1,
190
203
  },
191
204
  })) {
192
- if (msg.type === 'assistant') {
205
+ if (msg.type === 'stream_event') {
206
+ // Every partial event is progress (thinking and block boundaries too),
207
+ // and reporting it restarts the loop's inactivity watchdog; only text
208
+ // deltas grow the count. The reply itself is still read from the
209
+ // complete `assistant` message below, never reassembled from deltas.
210
+ const delta = msg.event?.delta;
211
+ if (delta?.type === 'text_delta' && delta.text) streamedChars += delta.text.length;
212
+ opts.onStream?.(streamedChars);
213
+ } else if (msg.type === 'assistant') {
193
214
  for (const block of msg.message?.content ?? []) {
194
215
  if (block.type === 'text' && block.text) {
195
216
  text = (text ?? '') + block.text;
@@ -15,7 +15,7 @@ type CodexThreadOptions = Pick<
15
15
  | 'networkAccessEnabled'
16
16
  | 'webSearchMode'
17
17
  >;
18
- type CodexTurnOptions = Pick<TurnOptions, 'outputSchema'>;
18
+ type CodexTurnOptions = Pick<TurnOptions, 'outputSchema' | 'signal'>;
19
19
 
20
20
  interface CodexTurnLike {
21
21
  finalResponse: string;
@@ -58,6 +58,13 @@ export class CodexProvider implements Provider {
58
58
  private readonly client: CodexClientLike;
59
59
  private thread: CodexThreadLike | null = null;
60
60
  private messageCursor = 0;
61
+ /** In-flight turn aborters, so close() (called by the turn watchdog on a hung
62
+ * turn) kills the `codex exec` subprocess instead of orphaning it. */
63
+ private readonly inFlight = new Set<AbortController>();
64
+ /** Bumped by close(). A turn begun under an earlier generation was abandoned;
65
+ * if it settles late it must not touch the thread or cursor that replaced it,
66
+ * or it would mark messages seen that the fresh thread never received. */
67
+ private generation = 0;
61
68
 
62
69
  constructor(options: CodexProviderOptions) {
63
70
  this.model = options.model;
@@ -67,53 +74,88 @@ export class CodexProvider implements Provider {
67
74
  }
68
75
 
69
76
  async chat(messages: Msg[], tools: ToolSchema[], _opts: ChatOpts = {}): Promise<Turn> {
70
- const workingDirectory = await this.ensureWorkingDirectory();
71
- if (!this.thread) {
72
- this.thread = this.client.startThread({
73
- ...(this.model ? { model: this.model } : {}),
74
- workingDirectory,
75
- skipGitRepoCheck: true,
76
- sandboxMode: 'read-only',
77
- approvalPolicy: 'never',
78
- networkAccessEnabled: false,
79
- webSearchMode: 'disabled',
80
- });
81
- }
82
-
83
- const cursor = this.messageCursor;
84
- const schema = turnSchema(tools);
85
- const toolCatalog = new Map(tools.map((tool) => [tool.name, tool]));
86
- const attempts: CodexTurnLike[] = [];
87
- let result = await this.runThread(renderTurnPrompt(messages, cursor, tools), schema);
88
- attempts.push(result);
89
-
90
- let parsed: ReturnType<typeof parseStructuredTurn>;
77
+ const generation = this.generation;
78
+ const aborter = new AbortController();
79
+ this.inFlight.add(aborter);
91
80
  try {
92
- parsed = parseStructuredTurn(result.finalResponse, toolCatalog);
93
- } catch (err) {
94
- const validationError = (err as Error).message;
95
- result = await this.runThread(renderCorrectionPrompt(tools, validationError), schema);
81
+ const workingDirectory = await this.ensureWorkingDirectory();
82
+ this.assertCurrent(generation);
83
+ if (!this.thread) {
84
+ this.thread = this.client.startThread({
85
+ ...(this.model ? { model: this.model } : {}),
86
+ workingDirectory,
87
+ skipGitRepoCheck: true,
88
+ sandboxMode: 'read-only',
89
+ approvalPolicy: 'never',
90
+ networkAccessEnabled: false,
91
+ webSearchMode: 'disabled',
92
+ });
93
+ }
94
+ const thread = this.thread;
95
+
96
+ const cursor = this.messageCursor;
97
+ const schema = turnSchema(tools);
98
+ const toolCatalog = new Map(tools.map((tool) => [tool.name, tool]));
99
+ const attempts: CodexTurnLike[] = [];
100
+ let result = await this.runThread(thread, renderTurnPrompt(messages, cursor, tools), schema, aborter.signal);
101
+ this.assertCurrent(generation);
96
102
  attempts.push(result);
97
- parsed = parseStructuredTurn(result.finalResponse, toolCatalog);
98
- }
99
103
 
100
- // The input remains unseen until Copperhead accepts a structured turn.
101
- this.messageCursor = messages.length;
102
- return {
103
- text: parsed.text.trim() || null,
104
- toolCalls: parsed.toolCalls,
105
- usage: {
106
- inputTokens: attempts.reduce((sum, attempt) => sum + (attempt.usage?.input_tokens ?? 0), 0),
107
- outputTokens: attempts.reduce((sum, attempt) => sum + (attempt.usage?.output_tokens ?? 0), 0),
108
- },
109
- };
104
+ let parsed: ReturnType<typeof parseStructuredTurn>;
105
+ try {
106
+ parsed = parseStructuredTurn(result.finalResponse, toolCatalog);
107
+ } catch (err) {
108
+ const validationError = (err as Error).message;
109
+ result = await this.runThread(thread, renderCorrectionPrompt(tools, validationError), schema, aborter.signal);
110
+ this.assertCurrent(generation);
111
+ attempts.push(result);
112
+ parsed = parseStructuredTurn(result.finalResponse, toolCatalog);
113
+ }
114
+
115
+ // The input remains unseen until Copperhead accepts a structured turn.
116
+ this.messageCursor = messages.length;
117
+ return {
118
+ text: parsed.text.trim() || null,
119
+ toolCalls: parsed.toolCalls,
120
+ usage: {
121
+ inputTokens: attempts.reduce((sum, attempt) => sum + (attempt.usage?.input_tokens ?? 0), 0),
122
+ outputTokens: attempts.reduce((sum, attempt) => sum + (attempt.usage?.output_tokens ?? 0), 0),
123
+ },
124
+ };
125
+ } finally {
126
+ this.inFlight.delete(aborter);
127
+ }
110
128
  }
111
129
 
112
130
  async close(): Promise<void> {
131
+ this.generation++;
132
+ for (const aborter of this.inFlight) {
133
+ try {
134
+ aborter.abort();
135
+ } catch {
136
+ // best effort: a turn that already settled has nothing to tear down
137
+ }
138
+ }
139
+ this.inFlight.clear();
140
+ // A fresh thread has seen nothing, so the next turn must send the full
141
+ // history (system prompt and request included), not the delta meant for the
142
+ // thread being discarded; otherwise a retried turn runs without context.
113
143
  this.thread = null;
114
- if (this.ownsWorkingDirectory && this.workingDirectory) {
115
- await rm(this.workingDirectory, { recursive: true, force: true });
144
+ this.messageCursor = 0;
145
+ // Forget the directory before deleting it: the watchdog does not await
146
+ // close(), so a retried turn can start while rm is still running, and it must
147
+ // create a fresh directory rather than reuse the one being deleted.
148
+ const dir = this.workingDirectory;
149
+ if (this.ownsWorkingDirectory && dir) {
116
150
  this.workingDirectory = null;
151
+ await rm(dir, { recursive: true, force: true });
152
+ }
153
+ }
154
+
155
+ /** Throw if close() ran since the turn began (see `generation`). */
156
+ private assertCurrent(generation: number): void {
157
+ if (generation !== this.generation) {
158
+ throw new Error('codex: turn abandoned because the provider was closed while it ran');
117
159
  }
118
160
  }
119
161
 
@@ -126,9 +168,16 @@ export class CodexProvider implements Provider {
126
168
  return this.workingDirectory;
127
169
  }
128
170
 
129
- private async runThread(prompt: string, outputSchema: Record<string, unknown>): Promise<CodexTurnLike> {
171
+ private async runThread(
172
+ thread: CodexThreadLike,
173
+ prompt: string,
174
+ outputSchema: Record<string, unknown>,
175
+ signal: AbortSignal,
176
+ ): Promise<CodexTurnLike> {
130
177
  try {
131
- return await this.thread!.run(prompt, { outputSchema });
178
+ // The turn's own thread, not `this.thread`, which close() may have
179
+ // replaced; the signal lets close() kill the `codex exec` subprocess.
180
+ return await thread.run(prompt, { outputSchema, signal });
132
181
  } catch (err) {
133
182
  const original = err as Error & { status?: number; statusCode?: number };
134
183
  const setupHint = isCliSetupError(original)
@@ -85,9 +85,14 @@ export interface ParsedToolTurn {
85
85
  * split so only an inner `{args}` with no `tool` key balanced), not the tool
86
86
  * being broken. Returns a one-line steer to re-emit it, or undefined when the
87
87
  * absence of a call is genuine (plain prose, no tool named).
88
+ *
89
+ * Also covers the mirror case (I18): well-formed JSON naming a tool that is NOT in
90
+ * the catalog — withheld by the edit lock, or invented. That call is correctly not
91
+ * dispatched, but it must not be silent either.
88
92
  */
89
93
  function detectMalformedCall(text: string, catalog: Set<string>): string | undefined {
90
94
  const re = /"tool"\s*:\s*"([^"]+)"/g;
95
+ const offCatalog: string[] = [];
91
96
  let m: RegExpExecArray | null;
92
97
  while ((m = re.exec(text)) !== null) {
93
98
  const name = m[1]!;
@@ -98,6 +103,23 @@ function detectMalformedCall(text: string, catalog: Set<string>): string | undef
98
103
  'Re-emit it as exactly one complete JSON object: {"tool": "...", "args": { ... }}.'
99
104
  );
100
105
  }
106
+ if (!offCatalog.includes(name)) offCatalog.push(name);
107
+ }
108
+ // A well-formed call naming a tool the turn did not advertise — a locked edit or
109
+ // drafting tool before propose_change/validate_change, or an invented name.
110
+ // `toToolCall` is right to refuse it (the lock is structural, D2), but dropping it
111
+ // to prose tells the model NOTHING, and it fills that silence: fabricating the
112
+ // result it never got (I15), or concluding the engine is absent from the build and
113
+ // refusing the stage outright (I18). Withholding the tool is the invariant; hiding
114
+ // the reason never was, so name it and print the real catalog.
115
+ if (offCatalog.length) {
116
+ const named = offCatalog.map((n) => `"${n}"`).join(', ');
117
+ return (
118
+ `No call ran: ${named} ${offCatalog.length > 1 ? 'are' : 'is'} not in this turn's tool ` +
119
+ 'catalog. Edit and drafting tools are withheld until a proposal validates — call ' +
120
+ 'propose_change, then validate_change, and they appear. Do not conclude a tool is ' +
121
+ `missing from the build. Available this turn: ${[...catalog].join(', ')}.`
122
+ );
101
123
  }
102
124
  return undefined;
103
125
  }