copperhead 0.10.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 (99) hide show
  1. package/README.md +41 -2
  2. package/dist/agent/context.js +2 -0
  3. package/dist/agent/context.js.map +1 -0
  4. package/dist/agent/dock-renderer.js +2 -2
  5. package/dist/agent/dock-renderer.js.map +1 -1
  6. package/dist/agent/envelope.js +105 -0
  7. package/dist/agent/envelope.js.map +1 -0
  8. package/dist/agent/loop.js +34 -14
  9. package/dist/agent/loop.js.map +1 -1
  10. package/dist/agent/providers/claude-code.js +17 -1
  11. package/dist/agent/providers/claude-code.js.map +1 -1
  12. package/dist/agent/providers/codex.js +84 -39
  13. package/dist/agent/providers/codex.js.map +1 -1
  14. package/dist/agent/recovery.js +91 -14
  15. package/dist/agent/recovery.js.map +1 -1
  16. package/dist/agent/registry.js +49 -0
  17. package/dist/agent/registry.js.map +1 -0
  18. package/dist/agent/render.js +2 -2
  19. package/dist/agent/render.js.map +1 -1
  20. package/dist/agent/theme.js +10 -5
  21. package/dist/agent/theme.js.map +1 -1
  22. package/dist/agent/tools.js +99 -769
  23. package/dist/agent/tools.js.map +1 -1
  24. package/dist/capabilities/define.js +35 -0
  25. package/dist/capabilities/define.js.map +1 -0
  26. package/dist/capabilities/handlers.js +744 -0
  27. package/dist/capabilities/handlers.js.map +1 -0
  28. package/dist/capabilities/helpers.js +39 -0
  29. package/dist/capabilities/helpers.js.map +1 -0
  30. package/dist/capabilities/index.js +50 -0
  31. package/dist/capabilities/index.js.map +1 -0
  32. package/dist/capabilities/skills/generate-report.js +23 -0
  33. package/dist/capabilities/skills/generate-report.js.map +1 -0
  34. package/dist/cli.js +84 -1
  35. package/dist/cli.js.map +1 -1
  36. package/dist/commands/create.js +5 -2
  37. package/dist/commands/create.js.map +1 -1
  38. package/dist/commands/doctor.js +33 -3
  39. package/dist/commands/doctor.js.map +1 -1
  40. package/dist/commands/skill.js +109 -0
  41. package/dist/commands/skill.js.map +1 -0
  42. package/dist/commands/sync.js +3 -1
  43. package/dist/commands/sync.js.map +1 -1
  44. package/dist/config.js +18 -6
  45. package/dist/config.js.map +1 -1
  46. package/dist/kicad/cli.js +106 -18
  47. package/dist/kicad/cli.js.map +1 -1
  48. package/dist/kicad/draft/draft.js +3 -0
  49. package/dist/kicad/draft/draft.js.map +1 -1
  50. package/dist/kicad/draft/engine.js +3139 -218
  51. package/dist/kicad/draft/engine.js.map +1 -1
  52. package/dist/kicad/draft/symsource.js +24 -10
  53. package/dist/kicad/draft/symsource.js.map +1 -1
  54. package/dist/kicad/emit.js +45 -6
  55. package/dist/kicad/emit.js.map +1 -1
  56. package/dist/kicad/legibility.js +51 -4
  57. package/dist/kicad/legibility.js.map +1 -1
  58. package/dist/kicad/score.js +173 -3
  59. package/dist/kicad/score.js.map +1 -1
  60. package/dist/kicad/sexp.js +32 -6
  61. package/dist/kicad/sexp.js.map +1 -1
  62. package/dist/mcp/server.js +485 -0
  63. package/dist/mcp/server.js.map +1 -0
  64. package/dist/memory/scaffold.js +8 -1
  65. package/dist/memory/scaffold.js.map +1 -1
  66. package/package.json +5 -2
  67. package/src/agent/context.ts +35 -0
  68. package/src/agent/dock-renderer.ts +3 -2
  69. package/src/agent/envelope.ts +124 -0
  70. package/src/agent/loop.ts +45 -17
  71. package/src/agent/providers/claude-code.ts +22 -1
  72. package/src/agent/providers/codex.ts +91 -42
  73. package/src/agent/recovery.ts +89 -12
  74. package/src/agent/registry.ts +58 -0
  75. package/src/agent/render.ts +4 -3
  76. package/src/agent/theme.ts +15 -5
  77. package/src/agent/tools.ts +124 -816
  78. package/src/agent/types.ts +10 -5
  79. package/src/capabilities/define.ts +88 -0
  80. package/src/capabilities/handlers.ts +769 -0
  81. package/src/capabilities/helpers.ts +37 -0
  82. package/src/capabilities/index.ts +53 -0
  83. package/src/capabilities/skills/generate-report.ts +25 -0
  84. package/src/cli.ts +84 -1
  85. package/src/commands/create.ts +5 -2
  86. package/src/commands/doctor.ts +34 -3
  87. package/src/commands/skill.ts +127 -0
  88. package/src/commands/sync.ts +5 -3
  89. package/src/config.ts +32 -8
  90. package/src/kicad/cli.ts +129 -18
  91. package/src/kicad/draft/draft.ts +2 -0
  92. package/src/kicad/draft/engine.ts +3034 -226
  93. package/src/kicad/draft/symsource.ts +24 -10
  94. package/src/kicad/emit.ts +71 -7
  95. package/src/kicad/legibility.ts +55 -6
  96. package/src/kicad/score.ts +187 -8
  97. package/src/kicad/sexp.ts +37 -6
  98. package/src/mcp/server.ts +560 -0
  99. package/src/memory/scaffold.ts +8 -1
@@ -0,0 +1,124 @@
1
+ import { redactSecrets } from '../util/redact.js';
2
+
3
+ /** Envelope protocol version for the ToolResult shape (design D5). */
4
+ export const PROTOCOL_VERSION = 1;
5
+
6
+ export type ViewHint = 'diagnostic' | 'mutation' | 'query' | 'export';
7
+ export type ToolErrorKind = 'unavailable' | 'validation' | 'refusal' | 'exception';
8
+
9
+ export interface ToolResult {
10
+ ok: boolean;
11
+ summary: string;
12
+ detail?: string;
13
+ data?: unknown;
14
+ error?: { kind: ToolErrorKind; message: string };
15
+ viewHint?: ViewHint;
16
+ }
17
+
18
+ function redactEnvelopeText(text: string): string {
19
+ let out = redactSecrets(text);
20
+ for (const [name, val] of Object.entries(process.env)) {
21
+ if (!val || val.length < 8) continue;
22
+ if (!/_(KEY|SECRET|TOKEN)$/.test(name)) continue;
23
+ if (out.includes(val)) out = out.split(val).join('[REDACTED]');
24
+ }
25
+ return out;
26
+ }
27
+
28
+ function redactDeep(value: unknown): unknown {
29
+ if (typeof value === 'string') return redactEnvelopeText(value);
30
+ if (Array.isArray(value)) return value.map(redactDeep);
31
+ if (value && typeof value === 'object') {
32
+ return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([k, v]) => [k, redactDeep(v)]));
33
+ }
34
+ return value;
35
+ }
36
+
37
+ /** Redact every string field at construction so render/dock never see secrets. */
38
+ export function seal(partial: ToolResult): ToolResult {
39
+ return {
40
+ ok: partial.ok,
41
+ summary: redactEnvelopeText(partial.summary),
42
+ ...(partial.detail !== undefined ? { detail: redactEnvelopeText(partial.detail) } : {}),
43
+ ...(partial.data !== undefined ? { data: redactDeep(partial.data) } : {}),
44
+ ...(partial.error
45
+ ? { error: { kind: partial.error.kind, message: redactEnvelopeText(partial.error.message) } }
46
+ : {}),
47
+ ...(partial.viewHint ? { viewHint: partial.viewHint } : {}),
48
+ };
49
+ }
50
+
51
+ export function okResult(text: string, viewHint?: ViewHint, data?: unknown): ToolResult {
52
+ const first = text.split('\n')[0] ?? text;
53
+ return seal({
54
+ ok: true,
55
+ summary: first,
56
+ ...(text !== first ? { detail: text } : {}),
57
+ ...(data !== undefined ? { data } : {}),
58
+ ...(viewHint ? { viewHint } : {}),
59
+ });
60
+ }
61
+
62
+ export function failResult(kind: ToolErrorKind, message: string, viewHint?: ViewHint): ToolResult {
63
+ const first = message.split('\n')[0] ?? message;
64
+ return seal({
65
+ ok: false,
66
+ summary: first,
67
+ error: { kind, message },
68
+ ...(viewHint ? { viewHint } : {}),
69
+ });
70
+ }
71
+
72
+ /** Flatten an envelope to the string providers already consume as tool Msg content. */
73
+ export function flatten(result: ToolResult): string {
74
+ if (result.error) return result.error.message;
75
+ if (result.detail) {
76
+ return result.detail.startsWith(result.summary) ? result.detail : `${result.summary}\n${result.detail}`;
77
+ }
78
+ return result.summary;
79
+ }
80
+
81
+ /** Build an envelope from text plus an outcome already known by the handler. */
82
+ export function outcomeResult(text: string, ok: boolean, viewHint?: ViewHint): ToolResult {
83
+ const first = text.split('\n')[0] ?? text;
84
+ return seal({
85
+ ok,
86
+ summary: first,
87
+ ...(text !== first ? { detail: text } : {}),
88
+ ...(viewHint ? { viewHint } : {}),
89
+ });
90
+ }
91
+
92
+ /**
93
+ * Wrap a legacy handler string. Error-shaped prefixes become typed failures so
94
+ * validation/refusal are not retried; everything else is success. Flattened
95
+ * text equals the original string.
96
+ */
97
+ export function textResult(text: string, viewHint: ViewHint): ToolResult {
98
+ if (
99
+ /^error:/.test(text) ||
100
+ text.startsWith('rejected:') ||
101
+ text.startsWith('edit REVERTED') ||
102
+ text.startsWith('validation FAILED')
103
+ ) {
104
+ return failResult('validation', text, viewHint);
105
+ }
106
+ if (
107
+ text.startsWith('refused:') ||
108
+ text.startsWith('cannot finish yet') ||
109
+ text.startsWith('proposal validated but human declined')
110
+ ) {
111
+ return failResult('refusal', text, viewHint);
112
+ }
113
+ return okResult(text, viewHint);
114
+ }
115
+
116
+ export function unavailable(name: string, editsUnlocked: boolean, reason?: string): ToolResult {
117
+ const suffix = reason
118
+ ? ` (${reason})`
119
+ : editsUnlocked
120
+ ? ''
121
+ : ' (edit tools unlock after the proposal validates)';
122
+ const message = `tool "${name}" is not available${suffix}`;
123
+ return failResult('unavailable', message);
124
+ }
package/src/agent/loop.ts CHANGED
@@ -2,9 +2,10 @@ 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';
10
11
  import { isCreateProducedRepo, isEngineAuthoredSchematic } from '../kicad/fab.js';
@@ -349,7 +350,7 @@ async function runWithProviders(opts: RunOptions, providers: Set<Provider>): Pro
349
350
  let plan: string | null = null;
350
351
  let nudges = 0;
351
352
  let turnTimeouts = 0;
352
- const maxTurnTimeouts = 3;
353
+ const maxTurnTimeouts = MAX_TURN_TIMEOUTS;
353
354
 
354
355
  const stats = (exitPath: ExitPath): RunStats => ({
355
356
  exitPath,
@@ -500,28 +501,54 @@ async function runWithProviders(opts: RunOptions, providers: Set<Provider>): Pro
500
501
  : null;
501
502
  heartbeat?.unref?.();
502
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.
503
508
  res = await withRetry(
504
509
  () =>
505
- withTimeout(
506
- () => provider.chat(messages, tools, { onStream: (chars) => (streamedChars = chars) }),
507
- config.turnTimeoutMs,
508
- () => 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?.() },
509
519
  ),
510
520
  { onRetry: (attempt) => log(`rate limited; retry ${attempt}`) },
511
521
  );
512
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
+ }
513
535
  if (err instanceof TurnTimeoutError) {
514
- // A hung provider turn: the watchdog aborted the in-flight call and tore
515
- // down its subprocess. Retry the same turn a bounded number of times
516
- // before giving up, so a transient hang self-heals instead of stalling
517
- // 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.
518
540
  if (turnTimeouts++ < maxTurnTimeouts) {
519
- log(`turn exceeded ${config.turnTimeoutMs}ms; aborted the hung call and retrying (${turnTimeouts}/${maxTurnTimeouts})`);
520
- 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 });
521
545
  turn--;
522
546
  continue;
523
547
  }
524
- 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
+ );
525
552
  }
526
553
  if (isRateLimit(err)) {
527
554
  const fallback = otherProvider(provider);
@@ -590,9 +617,10 @@ async function runWithProviders(opts: RunOptions, providers: Set<Provider>): Pro
590
617
  nudges = 0;
591
618
 
592
619
  for (const call of res.toolCalls) {
593
- const result = await dispatchTool(ctx, call.name, call.args);
594
- await transcript.event('tool', { name: call.name, args: call.args, result });
595
- 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);
596
624
  messages.push({ role: 'tool', toolCallId: call.id, content: result });
597
625
  }
598
626
 
@@ -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)
@@ -4,10 +4,23 @@ import path from 'node:path';
4
4
  import type { Msg, Provider } from './types.js';
5
5
  import { resolveLibrarySymbol, searchInstalledSymbols, symbolSearchDirs, listInstalledLibraries } from '../kicad/symlib.js';
6
6
 
7
- /** Thrown when a single provider turn blows past its watchdog deadline. */
7
+ /**
8
+ * How many times a hung provider turn is retried before the run gives up. One
9
+ * policy, two turn loops: the main agent loop and the nested skill sub-run.
10
+ */
11
+ export const MAX_TURN_TIMEOUTS = 3;
12
+
13
+ /**
14
+ * Thrown when a provider turn trips its watchdog. `idle`: no response or
15
+ * progress for `ms`, so the call looks hung. `max`: still running at the hard
16
+ * cap however much progress it reported, so the turn is too large, not hung.
17
+ */
8
18
  export class TurnTimeoutError extends Error {
9
- constructor(public readonly ms: number) {
10
- super(`turn exceeded ${ms}ms without responding`);
19
+ constructor(
20
+ public readonly ms: number,
21
+ public readonly kind: 'idle' | 'max' = 'idle',
22
+ ) {
23
+ super(kind === 'max' ? `turn still running after ${ms}ms (hard cap)` : `turn exceeded ${ms}ms without responding`);
11
24
  this.name = 'TurnTimeoutError';
12
25
  }
13
26
  }
@@ -24,18 +37,81 @@ export async function withTimeout<T>(
24
37
  ms: number,
25
38
  onTimeout?: () => void | Promise<void>,
26
39
  ): Promise<T> {
27
- if (!Number.isFinite(ms) || ms <= 0) return fn();
28
- let timer: ReturnType<typeof setTimeout> | undefined;
40
+ return withWatchdog(() => fn(), { idleMs: ms, ...(onTimeout ? { onTimeout } : {}) });
41
+ }
42
+
43
+ /**
44
+ * The turn watchdog: `withTimeout` with the deadline measured from the last sign
45
+ * of progress instead of from the start. `fn` receives an `activity` callback and
46
+ * each call restarts the `idleMs` deadline, so a long turn that keeps producing
47
+ * output is never mistaken for a hung one, while a call that reports nothing gets
48
+ * `idleMs` as a plain whole-call deadline (unchanged for providers that cannot
49
+ * stream). `maxMs` is a hard cap activity does not extend, so a turn that streams
50
+ * forever still ends. The cap bounds a call that is producing output, so it only
51
+ * trips once `fn` has reported progress (a silent call is judged by `idleMs`
52
+ * alone, exactly as before the cap existed), and it is never shorter than
53
+ * `idleMs` (a lower cap would cut off a turn the idle deadline alone allows).
54
+ * `<= 0` (or non-finite) disables either limit; with both disabled this just
55
+ * awaits `fn`.
56
+ */
57
+ export async function withWatchdog<T>(
58
+ fn: (activity: () => void) => Promise<T>,
59
+ opts: { idleMs: number; maxMs?: number; onTimeout?: () => void | Promise<void> },
60
+ ): Promise<T> {
61
+ const idleOn = Number.isFinite(opts.idleMs) && opts.idleMs > 0;
62
+ const maxMs = opts.maxMs ?? 0;
63
+ const maxOn = Number.isFinite(maxMs) && maxMs > 0;
64
+ if (!idleOn && !maxOn) return fn(() => {});
65
+ const capMs = idleOn ? Math.max(maxMs, opts.idleMs) : maxMs;
66
+ let idleTimer: ReturnType<typeof setTimeout> | undefined;
67
+ let maxTimer: ReturnType<typeof setTimeout> | undefined;
68
+ // Set once the race is decided either way, so a late activity() from an
69
+ // abandoned call cannot re-arm a timer after the watchdog is done.
70
+ let settled = false;
71
+ // Whether fn has reported progress yet, and whether the cap came due while it
72
+ // had not (see the cap timer below).
73
+ let progressed = false;
74
+ let capPassed = false;
75
+ let trip!: (err: TurnTimeoutError) => void;
29
76
  const timeout = new Promise<never>((_, reject) => {
30
- timer = setTimeout(() => {
31
- void Promise.resolve(onTimeout?.()).catch(() => {});
32
- reject(new TurnTimeoutError(ms));
33
- }, ms);
77
+ trip = (err) => {
78
+ if (settled) return;
79
+ settled = true;
80
+ clearTimeout(idleTimer);
81
+ clearTimeout(maxTimer);
82
+ // Reject before tearing down: onTimeout (provider.close()) may reject the
83
+ // in-flight call synchronously, and the race must settle on the timeout,
84
+ // not on that teardown error, or the caller never sees TurnTimeoutError.
85
+ reject(err);
86
+ void Promise.resolve(opts.onTimeout?.()).catch(() => {});
87
+ };
34
88
  });
89
+ const armIdle = (): void => {
90
+ clearTimeout(idleTimer);
91
+ idleTimer = setTimeout(() => trip(new TurnTimeoutError(opts.idleMs, 'idle')), opts.idleMs);
92
+ };
93
+ const activity = (): void => {
94
+ if (settled) return;
95
+ progressed = true;
96
+ if (capPassed) return trip(new TurnTimeoutError(capMs, 'max'));
97
+ if (idleOn) armIdle();
98
+ };
99
+ if (idleOn) armIdle();
100
+ if (maxOn) {
101
+ maxTimer = setTimeout(() => {
102
+ // The cap bounds a turn that is producing output. A call that has reported
103
+ // nothing yet stays under the idle deadline alone; if it starts reporting
104
+ // progress later, the cap applies at that moment.
105
+ if (progressed) trip(new TurnTimeoutError(capMs, 'max'));
106
+ else capPassed = true;
107
+ }, capMs);
108
+ }
35
109
  try {
36
- return await Promise.race([fn(), timeout]);
110
+ return await Promise.race([fn(activity), timeout]);
37
111
  } finally {
38
- if (timer) clearTimeout(timer);
112
+ settled = true;
113
+ clearTimeout(idleTimer);
114
+ clearTimeout(maxTimer);
39
115
  }
40
116
  }
41
117
 
@@ -239,7 +315,8 @@ export async function diagnoseStageFailure(
239
315
  : '') +
240
316
  'Reply with ONLY a JSON object, no prose:\n' +
241
317
  '{"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' +
242
- '- "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' +
318
+ '- "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 hung provider call that timed out, a formatting slip).\n' +
319
+ '- a turn stopped at the hard time cap while still producing output (turnMaxMs, "too large, not hung") is NOT transient: repeating it hits the cap again. Retry only with guidance that splits that work into several smaller edits (e.g. one part or one sheet section per edit).\n' +
243
320
  '- "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).\n' +
244
321
  '- an agent\'s claim that a symbol or library is absent is NOT evidence: agents dead-ended by wrong library nicknames routinely conclude whole libraries are missing. If the machine-verified facts contradict the failure\'s premise (a cited-absent lib_id RESOLVES, or the part is installed under another library), the verdict is "retry", with guidance quoting the correct lib_ids.';
245
322
  const messages: Msg[] = [
@@ -0,0 +1,58 @@
1
+ import { catalog } from '../capabilities/index.js';
2
+ import type { CatalogEntry, CatalogSkill } from '../capabilities/define.js';
3
+ import type { RunContext } from './context.js';
4
+ import { PROTOCOL_VERSION } from './envelope.js';
5
+
6
+ export { PROTOCOL_VERSION };
7
+
8
+ export class ToolRegistry {
9
+ private readonly byName = new Map<string, CatalogEntry>();
10
+
11
+ constructor(entries: CatalogEntry[]) {
12
+ for (const source of entries) {
13
+ const e: CatalogEntry = { ...source };
14
+ if (this.byName.has(e.name)) throw new Error(`duplicate catalog name "${e.name}"`);
15
+ this.byName.set(e.name, e);
16
+ }
17
+ for (const e of this.byName.values()) {
18
+ if (e.kind === 'skill' && !e.gateProvided) {
19
+ const conj = (ctx: RunContext) => this.conjunction(e.tools, ctx);
20
+ e.gate = conj;
21
+ }
22
+ }
23
+ }
24
+
25
+ get protocolVersion(): number {
26
+ return PROTOCOL_VERSION;
27
+ }
28
+
29
+ get(name: string): CatalogEntry | undefined {
30
+ return this.byName.get(name);
31
+ }
32
+
33
+ all(): CatalogEntry[] {
34
+ return [...this.byName.values()];
35
+ }
36
+
37
+ skills(): CatalogSkill[] {
38
+ return this.all().filter((e): e is CatalogSkill => e.kind === 'skill');
39
+ }
40
+
41
+ /** True when every named tool exists and its gate is open. */
42
+ conjunction(toolNames: string[], ctx: RunContext): boolean {
43
+ return toolNames.every((n) => {
44
+ const t = this.byName.get(n);
45
+ return t?.kind === 'tool' && t.gate(ctx);
46
+ });
47
+ }
48
+
49
+ /** Entries whose gate is open. Skills also require every declared tool's gate (D3). */
50
+ list(ctx: RunContext): CatalogEntry[] {
51
+ return this.all().filter((e) => {
52
+ if (e.kind === 'skill' && (e.tools.includes('finish') || !this.conjunction(e.tools, ctx))) return false;
53
+ return e.gate(ctx);
54
+ });
55
+ }
56
+ }
57
+
58
+ export const registry = new ToolRegistry(catalog);
@@ -9,12 +9,13 @@
9
9
  */
10
10
 
11
11
  import { copper, dim, setColorEnabled, styleOutcome, toolLine, warn } from './theme.js';
12
+ import type { ViewHint } from './envelope.js';
12
13
 
13
14
  export interface ProgressRenderer {
14
15
  log(line: string): void;
15
16
  /** Called at the start of each turn with cumulative token totals so far. */
16
17
  turnStart(turn: number, maxTurns: number, tokensIn: number, tokensOut: number): void;
17
- toolResult(name: string, firstLine: string): void;
18
+ toolResult(name: string, firstLine: string, ok?: boolean, viewHint?: ViewHint): void;
18
19
  /** Busy text while a provider call is in flight; null when idle. */
19
20
  status(text: string | null): void;
20
21
  /**
@@ -175,8 +176,8 @@ export class InteractiveRenderer implements ProgressRenderer {
175
176
  this.redraw();
176
177
  }
177
178
 
178
- toolResult(name: string, firstLine: string): void {
179
- this.log(toolLine(name, firstLine));
179
+ toolResult(name: string, firstLine: string, ok?: boolean, viewHint?: ViewHint): void {
180
+ this.log(toolLine(name, firstLine, ok, viewHint));
180
181
  }
181
182
 
182
183
  status(text: string | null): void {