@miphamai/cli 0.2.4 → 0.4.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 (37) hide show
  1. package/bin/mipham.ts +2 -0
  2. package/dist/mipham +0 -0
  3. package/package.json +10 -9
  4. package/skills/mipham/om-artifact.mipham-skill.md +69 -0
  5. package/skills/mipham/om-model-optimize.mipham-skill.md +58 -9
  6. package/skills/mipham/om-security.mipham-skill.md +85 -10
  7. package/skills/standard/doc-generator.SKILL.md +72 -11
  8. package/skills/standard/github-ops.SKILL.md +94 -13
  9. package/skills/standard/memory.SKILL.md +72 -11
  10. package/skills/standard/self-review.SKILL.md +50 -10
  11. package/skills/standard/superpower.SKILL.md +46 -8
  12. package/skills/standard/tdd.SKILL.md +85 -12
  13. package/skills/standard/web-search.SKILL.md +65 -13
  14. package/src/agent/sub-agent.ts +0 -1
  15. package/src/artifacts/manifest.ts +101 -0
  16. package/src/artifacts/server.ts +343 -0
  17. package/src/core/context.ts +73 -13
  18. package/src/core/engine.ts +194 -71
  19. package/src/core/instructions.ts +1 -1
  20. package/src/core/permission.ts +35 -4
  21. package/src/core/session-store.ts +5 -1
  22. package/src/index.tsx +35 -2
  23. package/src/mcp/transport.ts +42 -5
  24. package/src/providers/anthropic.ts +2 -1
  25. package/src/providers/fetch-utils.ts +101 -0
  26. package/src/providers/openai-compat.ts +53 -15
  27. package/src/providers/registry.ts +1 -0
  28. package/src/shared/constants.ts +8 -1
  29. package/src/shared/types.ts +21 -1
  30. package/src/skills/loader.ts +2 -2
  31. package/src/tools/artifact/artifact.ts +144 -0
  32. package/src/tools/index.ts +3 -0
  33. package/src/ui/app.tsx +122 -15
  34. package/src/ui/chat.tsx +29 -7
  35. package/src/ui/commands.ts +117 -19
  36. package/src/ui/input.tsx +117 -35
  37. package/src/ui/picker.tsx +2 -5
@@ -1,9 +1,14 @@
1
- import type { Message, StreamChunk, ToolDefinition, ToolResult } from '../shared/index.ts'
1
+ import type { StreamChunk, ToolDefinition, ToolResult } from '../shared/index.ts'
2
2
  import { ProviderRegistry } from '../providers/registry'
3
3
  import { ContextManager } from './context'
4
4
  import { PermissionSystem } from './permission'
5
+ import type { HookEngine } from './hooks'
6
+ import type { ArtifactServer } from '../artifacts/server'
5
7
 
6
8
  export class QueryEngine {
9
+ private hookEngine?: HookEngine
10
+ private artifactServer?: ArtifactServer
11
+
7
12
  constructor(
8
13
  private registry: ProviderRegistry,
9
14
  private context: ContextManager,
@@ -11,11 +16,60 @@ export class QueryEngine {
11
16
  private permission: PermissionSystem = new PermissionSystem('bypass'),
12
17
  ) {}
13
18
 
19
+ /** Register a hook engine for pre/post tool-use lifecycle events. */
20
+ setHookEngine(hooks: HookEngine): void {
21
+ this.hookEngine = hooks
22
+ }
23
+
24
+ /** Register the artifact server for tool context. */
25
+ setArtifactServer(server: ArtifactServer): void {
26
+ this.artifactServer = server
27
+ }
28
+
29
+ /** Wire LLM-based conversation summarization into the context manager. */
30
+ setupContextSummarizer(): void {
31
+ this.context.setSummarizer(async (messages, heading) => {
32
+ const text = messages
33
+ .map((m) => {
34
+ const role = m.role
35
+ const content = typeof m.content === 'string' ? m.content : JSON.stringify(m.content)
36
+ return `[${role}]: ${content.slice(0, 500)}`
37
+ })
38
+ .join('\n')
39
+
40
+ // Build a minimal system prompt for summarization
41
+ const summaryPrompt = `You are a conversation summarizer. Create a concise summary (1-3 paragraphs) of this conversation excerpt. Focus on: key topics discussed, decisions made, code changes mentioned, and open questions. Heading: ${heading}`
42
+
43
+ // Collect full summary text from streaming response
44
+ let summary = ''
45
+ try {
46
+ for await (const chunk of this.registry.chat({
47
+ model: this.registry.getActiveModel(),
48
+ messages: [
49
+ { role: 'system', content: summaryPrompt },
50
+ { role: 'user', content: text },
51
+ ],
52
+ maxTokens: 300,
53
+ })) {
54
+ if (chunk.type === 'text' && chunk.content) {
55
+ summary += chunk.content
56
+ }
57
+ if (chunk.type === 'stop') break
58
+ if (chunk.type === 'error') break
59
+ }
60
+ } catch {
61
+ // Return a minimal summary on failure
62
+ }
63
+
64
+ return summary.slice(0, 2000) || 'Prior conversation context omitted.'
65
+ })
66
+ }
67
+
14
68
  getPermission(): PermissionSystem {
15
69
  return this.permission
16
70
  }
17
71
 
18
- async *process(userInput: string): AsyncGenerator<StreamChunk> {
72
+ async *process(userInput: string, signal?: AbortSignal): AsyncGenerator<StreamChunk> {
19
73
  // Add user message to context
20
74
  this.context.addMessage({ role: 'user', content: userInput })
21
75
 
@@ -32,37 +86,51 @@ export class QueryEngine {
32
86
  const toolUses: Array<{ id: string; name: string; input: Record<string, unknown> }> = []
33
87
 
34
88
  // Stream model response
35
- for await (const chunk of this.registry.chat({
36
- model: this.registry.getActiveModel(),
37
- messages,
38
- systemPrompt,
39
- tools: toolDefs.length > 0 ? toolDefs : undefined,
40
- })) {
41
- yield chunk
42
-
43
- if (chunk.type === 'error') {
44
- this.context.addMessage({ role: 'assistant', content: `Error: ${chunk.error}` })
45
- return
46
- }
89
+ try {
90
+ for await (const chunk of this.registry.chat({
91
+ model: this.registry.getActiveModel(),
92
+ messages,
93
+ systemPrompt,
94
+ tools: toolDefs.length > 0 ? toolDefs : undefined,
95
+ signal,
96
+ })) {
97
+ yield chunk
47
98
 
48
- if (chunk.type === 'text' && chunk.content) {
49
- assistantContent += chunk.content
50
- }
99
+ if (chunk.type === 'error') {
100
+ this.context.addMessage({ role: 'assistant', content: `Error: ${chunk.error}` })
101
+ return
102
+ }
51
103
 
52
- if (chunk.type === 'tool_use' && chunk.toolUse) {
53
- toolUses.push({
54
- id: chunk.toolUse.id,
55
- name: chunk.toolUse.name,
56
- input: chunk.toolUse.input,
57
- })
58
- }
104
+ if (chunk.type === 'text' && chunk.content) {
105
+ assistantContent += chunk.content
106
+ }
59
107
 
60
- if (chunk.type === 'stop') {
61
- // Add assistant response to context
108
+ if (chunk.type === 'tool_use' && chunk.toolUse) {
109
+ toolUses.push({
110
+ id: chunk.toolUse.id,
111
+ name: chunk.toolUse.name,
112
+ input: chunk.toolUse.input,
113
+ })
114
+ }
115
+
116
+ if (chunk.type === 'stop') {
117
+ // Add assistant response to context
118
+ if (assistantContent) {
119
+ this.context.addMessage({ role: 'assistant', content: assistantContent })
120
+ }
121
+ }
122
+ }
123
+ } catch (err) {
124
+ if (isAbortError(err)) {
125
+ // User interrupted — keep partial content, stop gracefully
62
126
  if (assistantContent) {
63
127
  this.context.addMessage({ role: 'assistant', content: assistantContent })
64
128
  }
129
+ yield { type: 'stop' }
130
+ return
65
131
  }
132
+ yield { type: 'error', error: String(err) }
133
+ return
66
134
  }
67
135
 
68
136
  // Execute any tools that were requested
@@ -71,7 +139,7 @@ export class QueryEngine {
71
139
  yield {
72
140
  type: 'tool_result',
73
141
  tool_use_id: toolUse.id,
74
- content: result.content,
142
+ content: result.success ? result.content : result.error || result.content,
75
143
  }
76
144
 
77
145
  // Add tool use + result to context
@@ -87,60 +155,80 @@ export class QueryEngine {
87
155
 
88
156
  // If tools were executed, recursively continue the conversation
89
157
  if (toolUses.length > 0) {
90
- yield* this.continueWithTools()
158
+ yield* this.continueWithTools(signal)
91
159
  }
92
160
  }
93
161
 
94
- private async *continueWithTools(): AsyncGenerator<StreamChunk> {
95
- const systemPrompt = this.context.getSystemPrompt()
96
- const messages = this.context.getMessages()
162
+ private async *continueWithTools(signal?: AbortSignal): AsyncGenerator<StreamChunk> {
163
+ const MAX_TURNS = 10
97
164
  const toolDefs = this.getToolDefinitions()
98
165
 
99
- let assistantContent = ''
100
- const toolUses: Array<{ id: string; name: string; input: Record<string, unknown> }> = []
166
+ for (let turn = 0; turn < MAX_TURNS; turn++) {
167
+ const systemPrompt = this.context.getSystemPrompt()
168
+ const messages = this.context.getMessages()
101
169
 
102
- for await (const chunk of this.registry.chat({
103
- model: this.registry.getActiveModel(),
104
- messages,
105
- systemPrompt,
106
- tools: toolDefs.length > 0 ? toolDefs : undefined,
107
- })) {
108
- yield chunk
109
-
110
- if (chunk.type === 'error') return
111
- if (chunk.type === 'text' && chunk.content) assistantContent += chunk.content
112
-
113
- if (chunk.type === 'tool_use' && chunk.toolUse) {
114
- toolUses.push({
115
- id: chunk.toolUse.id,
116
- name: chunk.toolUse.name,
117
- input: chunk.toolUse.input,
118
- })
119
- }
120
- }
170
+ let assistantContent = ''
171
+ const toolUses: Array<{ id: string; name: string; input: Record<string, unknown> }> = []
121
172
 
122
- if (assistantContent) {
123
- this.context.addMessage({ role: 'assistant', content: assistantContent })
124
- }
173
+ try {
174
+ for await (const chunk of this.registry.chat({
175
+ model: this.registry.getActiveModel(),
176
+ messages,
177
+ systemPrompt,
178
+ tools: toolDefs.length > 0 ? toolDefs : undefined,
179
+ signal,
180
+ })) {
181
+ yield chunk
125
182
 
126
- // Execute tools (max 1 level of recursion to prevent infinite loops)
127
- for (const toolUse of toolUses) {
128
- const result = await this.executeTool(toolUse.name, toolUse.input)
129
- yield {
130
- type: 'tool_result',
131
- tool_use_id: toolUse.id,
132
- content: result.content,
183
+ if (chunk.type === 'error') return
184
+ if (chunk.type === 'text' && chunk.content) assistantContent += chunk.content
185
+
186
+ if (chunk.type === 'tool_use' && chunk.toolUse) {
187
+ toolUses.push({
188
+ id: chunk.toolUse.id,
189
+ name: chunk.toolUse.name,
190
+ input: chunk.toolUse.input,
191
+ })
192
+ }
193
+ }
194
+ } catch (err) {
195
+ if (isAbortError(err)) {
196
+ if (assistantContent) {
197
+ this.context.addMessage({ role: 'assistant', content: assistantContent })
198
+ }
199
+ yield { type: 'stop' }
200
+ return
201
+ }
202
+ return
133
203
  }
134
204
 
135
- this.context.addMessage({
136
- role: 'assistant',
137
- content: [{ type: 'tool_use', id: toolUse.id, name: toolUse.name, input: toolUse.input }],
138
- })
139
- this.context.addMessage({
140
- role: 'user',
141
- content: [{ type: 'tool_result', tool_use_id: toolUse.id, content: result.content }],
142
- })
205
+ if (assistantContent) {
206
+ this.context.addMessage({ role: 'assistant', content: assistantContent })
207
+ }
208
+
209
+ // No more tool calls — conversation complete
210
+ if (toolUses.length === 0) return
211
+
212
+ // Execute tools and feed results back to the model for the next turn
213
+ for (const toolUse of toolUses) {
214
+ const result = await this.executeTool(toolUse.name, toolUse.input)
215
+ yield {
216
+ type: 'tool_result',
217
+ tool_use_id: toolUse.id,
218
+ content: result.content,
219
+ }
220
+
221
+ this.context.addMessage({
222
+ role: 'assistant',
223
+ content: [{ type: 'tool_use', id: toolUse.id, name: toolUse.name, input: toolUse.input }],
224
+ })
225
+ this.context.addMessage({
226
+ role: 'user',
227
+ content: [{ type: 'tool_result', tool_use_id: toolUse.id, content: result.content }],
228
+ })
229
+ }
143
230
  }
231
+ // Max turns reached — safety limit, stop gracefully
144
232
  }
145
233
 
146
234
  private async executeTool(name: string, params: Record<string, unknown>): Promise<ToolResult> {
@@ -158,13 +246,37 @@ export class QueryEngine {
158
246
  }
159
247
  }
160
248
 
249
+ // Run PreToolUse hooks
250
+ let effectiveParams = params
251
+ if (this.hookEngine) {
252
+ const preResult = await this.hookEngine.executePreToolUse(name, params, 'session-1')
253
+ if (!preResult.allowed) {
254
+ return {
255
+ success: false,
256
+ content: '',
257
+ error: preResult.reason || `Tool "${name}" blocked by hook`,
258
+ }
259
+ }
260
+ if (preResult.modifiedInput) {
261
+ effectiveParams = { ...params, ...preResult.modifiedInput }
262
+ }
263
+ }
264
+
161
265
  try {
162
- return await tool.execute(params, {
266
+ const result = await tool.execute(effectiveParams, {
163
267
  cwd: process.cwd(),
164
268
  sessionId: 'session-1',
165
269
  provider: this.registry.getActive().config.id,
166
270
  model: this.registry.getActiveModel(),
271
+ artifactServer: this.artifactServer,
167
272
  })
273
+
274
+ // Run PostToolUse hooks
275
+ if (this.hookEngine) {
276
+ await this.hookEngine.executePostToolUse(name, effectiveParams, result, 'session-1')
277
+ }
278
+
279
+ return result
168
280
  } catch (err) {
169
281
  return { success: false, content: '', error: String(err) }
170
282
  }
@@ -191,3 +303,14 @@ export class QueryEngine {
191
303
  this.registry.switchProvider(providerId, modelId)
192
304
  }
193
305
  }
306
+
307
+ function isAbortError(err: unknown): boolean {
308
+ if (err instanceof Error && err.name === 'AbortError') return true
309
+ if (
310
+ typeof DOMException !== 'undefined' &&
311
+ err instanceof DOMException &&
312
+ err.name === 'AbortError'
313
+ )
314
+ return true
315
+ return false
316
+ }
@@ -1,4 +1,4 @@
1
- import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'
1
+ import { readFileSync, existsSync, readdirSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import { parse as parseYaml } from 'yaml'
4
4
  import type { InstructionFile } from '../shared/index.ts'
@@ -1,7 +1,13 @@
1
- import type { ToolDefinition, ToolPermission, PermissionLevel } from '../shared/index.ts'
1
+ import type {
2
+ ToolDefinition,
3
+ ToolPermission,
4
+ PermissionLevel,
5
+ PermissionRule,
6
+ } from '../shared/index.ts'
2
7
 
3
8
  export class PermissionSystem {
4
9
  private rules = new Map<string, PermissionLevel>()
10
+ private patternRules: Array<{ pattern: RegExp; level: PermissionLevel }> = []
5
11
 
6
12
  constructor(private defaultLevel: ToolPermission = 'auto') {}
7
13
 
@@ -13,18 +19,43 @@ export class PermissionSystem {
13
19
  return this.defaultLevel
14
20
  }
15
21
 
16
- setRule(toolName: string, level: PermissionLevel): void {
17
- this.rules.set(toolName, level)
22
+ setRule(toolNameOrRule: string | PermissionRule, level?: PermissionLevel): void {
23
+ if (typeof toolNameOrRule === 'string') {
24
+ if (level) this.rules.set(toolNameOrRule, level)
25
+ } else {
26
+ const rule = toolNameOrRule
27
+ if (rule.pattern) {
28
+ this.patternRules.push({ pattern: new RegExp(rule.pattern), level: rule.level })
29
+ } else {
30
+ this.rules.set(rule.toolName, rule.level)
31
+ }
32
+ }
18
33
  }
19
34
 
20
35
  removeRule(toolName: string): void {
21
36
  this.rules.delete(toolName)
22
37
  }
23
38
 
39
+ /**
40
+ * Resolve permission level for a tool call.
41
+ *
42
+ * Fallback chain: exact rule → pattern rule → tool permission → system default
43
+ */
24
44
  check(tool: ToolDefinition, _input: Record<string, unknown>): PermissionLevel {
45
+ // 1. Exact-name rule wins
25
46
  const ruleLevel = this.rules.get(tool.name)
26
47
  if (ruleLevel) return ruleLevel
27
- return tool.permission
48
+
49
+ // 2. Pattern-based rules (e.g. "file_*" → bypass)
50
+ for (const { pattern, level } of this.patternRules) {
51
+ if (pattern.test(tool.name)) return level
52
+ }
53
+
54
+ // 3. Tool's own permission level
55
+ if (tool.permission) return tool.permission
56
+
57
+ // 4. System default
58
+ return this.defaultLevel
28
59
  }
29
60
 
30
61
  needsApproval(tool: ToolDefinition, input: Record<string, unknown>): boolean {
@@ -4,6 +4,7 @@ import {
4
4
  readFileSync,
5
5
  writeFileSync,
6
6
  unlinkSync,
7
+ renameSync,
7
8
  existsSync,
8
9
  } from 'node:fs'
9
10
  import { join } from 'node:path'
@@ -60,7 +61,10 @@ export class SessionStore {
60
61
  messages,
61
62
  }
62
63
 
63
- writeFileSync(path, JSON.stringify(session) + '\n', 'utf-8')
64
+ // Atomic write: write to temp file, then rename (same-fs rename is atomic)
65
+ const tmp = path + '.tmp'
66
+ writeFileSync(tmp, JSON.stringify(session) + '\n', 'utf-8')
67
+ renameSync(tmp, path)
64
68
  }
65
69
 
66
70
  /**
package/src/index.tsx CHANGED
@@ -1,3 +1,4 @@
1
+ import { join } from 'node:path'
1
2
  import { render } from 'ink'
2
3
  import { App } from './ui/app'
3
4
  import { loadConfig } from './config/loader'
@@ -8,6 +9,10 @@ import { QueryEngine } from './core/engine'
8
9
  import { SessionStore } from './core/session-store'
9
10
  import { SkillsLoader } from './skills/loader'
10
11
  import { createToolRegistry } from './tools'
12
+ import { McpClient } from './mcp/client'
13
+ import { HookEngine } from './core/hooks'
14
+ import { ArtifactServer } from './artifacts/server'
15
+ import { ARTIFACTS_DIR, ARTIFACT_PORT, MIPHAM_DIR } from './shared/constants'
11
16
 
12
17
  interface RunOptions {
13
18
  model?: string
@@ -24,6 +29,17 @@ export async function runApp(options: RunOptions): Promise<void> {
24
29
  // Load configuration
25
30
  const config = loadConfig()
26
31
 
32
+ // Connect MCP servers (fire-and-forget — do not block startup)
33
+ const mcpServers = config.skills?.mcpServers ?? []
34
+ if (mcpServers.length > 0) {
35
+ const mcp = McpClient.getInstance()
36
+ for (const server of mcpServers) {
37
+ mcp.connect(server).catch((err) => {
38
+ process.stderr.write(`[mcp] Failed to connect "${server.name}": ${String(err)}\n`)
39
+ })
40
+ }
41
+ }
42
+
27
43
  // Bootstrap providers
28
44
  const defaultProvider = options.provider || config.defaultProvider
29
45
  const defaultModel = options.model || config.defaultModel
@@ -60,14 +76,31 @@ export async function runApp(options: RunOptions): Promise<void> {
60
76
  // Create tool registry with all 16 tools
61
77
  const tools = createToolRegistry()
62
78
 
79
+ // Initialize hook engine — register skill-defined hooks
80
+ const hookEngine = new HookEngine()
81
+ for (const skill of skillsLoader.list()) {
82
+ if (skill.hooks) {
83
+ for (const hook of skill.hooks) {
84
+ hookEngine.register(hook)
85
+ }
86
+ }
87
+ }
88
+
89
+ // Start artifact server (lazy — first artifact creation triggers listening)
90
+ const artifactsDir = join(process.cwd(), MIPHAM_DIR, ARTIFACTS_DIR)
91
+ const artifactServer = new ArtifactServer(artifactsDir, ARTIFACT_PORT)
92
+
63
93
  // Create query engine
64
94
  const engine = new QueryEngine(registry, context, tools)
95
+ engine.setHookEngine(hookEngine)
96
+ engine.setArtifactServer(artifactServer)
97
+ engine.setupContextSummarizer()
65
98
 
66
99
  // Auto-save session on exit
67
- let autoSaveName: string | undefined
68
100
  const saveAndExit = () => {
101
+ artifactServer.stop()
69
102
  if (context.getMessageCount() > 0) {
70
- autoSaveName = SessionStore.autoSave(context.getMessages(), {
103
+ SessionStore.autoSave(context.getMessages(), {
71
104
  provider: defaultProvider,
72
105
  model: defaultModel,
73
106
  })
@@ -1,11 +1,51 @@
1
1
  import { spawn, type ChildProcess } from 'node:child_process'
2
2
  import type { JsonRpcRequest, JsonRpcResponse, JsonRpcNotification } from './types'
3
3
 
4
- type ResponseHandler = (response: JsonRpcResponse) => void
5
4
  type NotificationHandler = (notification: JsonRpcNotification) => void
6
5
 
7
6
  const REQUEST_TIMEOUT_MS = 30_000
8
7
 
8
+ // ── Environment variable security: block sensitive vars from MCP subprocess ──
9
+ //
10
+ // By default, MCP servers receive a sanitised copy of the parent environment.
11
+ // Sensitive values (API keys, tokens, secrets, cloud credentials) are stripped
12
+ // to prevent data exfiltration by third-party MCP plugins.
13
+ //
14
+ // The `env` parameter on `start()` allows explicit overrides — use it to
15
+ // intentionally pass specific values to a trusted MCP server.
16
+ const SENSITIVE_ENV_PATTERNS = [
17
+ /_API_KEY$/i, // ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.
18
+ /_SECRET$/i, // STRIPE_SECRET, etc.
19
+ /_TOKEN$/i, // GITHUB_TOKEN, NPM_TOKEN, etc.
20
+ /_PASSWORD$/i, // DB_PASSWORD, etc.
21
+ /_CREDENTIALS?$/i, // GOOGLE_APPLICATION_CREDENTIALS, etc.
22
+ /^AWS_/, // AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, etc.
23
+ /^GCLOUD_/, // GCP credentials
24
+ /^AZURE_/, // Azure credentials
25
+ /^DOCKER_/, // DOCKER_TOKEN, DOCKER_PASSWORD
26
+ ]
27
+
28
+ /**
29
+ * Build a sanitised environment for MCP subprocesses.
30
+ * Strips sensitive vars; allows explicit overrides via `extra`.
31
+ */
32
+ function buildProcEnv(extra?: Record<string, string>): Record<string, string> {
33
+ const sanitized: Record<string, string> = {}
34
+
35
+ for (const [key, value] of Object.entries(process.env as Record<string, string>)) {
36
+ if (value === undefined) continue
37
+ if (SENSITIVE_ENV_PATTERNS.some((p) => p.test(key))) continue
38
+ sanitized[key] = value
39
+ }
40
+
41
+ // Explicit overrides take precedence (for intentionally shared secrets)
42
+ if (extra) {
43
+ Object.assign(sanitized, extra)
44
+ }
45
+
46
+ return sanitized
47
+ }
48
+
9
49
  /**
10
50
  * MCP stdio transport — spawns a subprocess and communicates via
11
51
  * newline-delimited JSON-RPC 2.0 messages on stdin/stdout.
@@ -30,10 +70,7 @@ export class StdioTransport {
30
70
  async start(command: string, args: string[], env?: Record<string, string>): Promise<void> {
31
71
  this.closed = false
32
72
 
33
- const procEnv: Record<string, string> = {
34
- ...(process.env as Record<string, string>),
35
- ...env,
36
- }
73
+ const procEnv = buildProcEnv(env)
37
74
 
38
75
  return new Promise((resolve, reject) => {
39
76
  const child = spawn(command, args, {
@@ -6,6 +6,7 @@ import type {
6
6
  ContentBlock,
7
7
  } from '../shared/index.ts'
8
8
  import type { ProviderInstance, ChatRequest } from './registry'
9
+ import { fetchWithRetry } from './fetch-utils'
9
10
 
10
11
  interface AnthropicContentBlock {
11
12
  type: string
@@ -72,7 +73,7 @@ export class AnthropicProvider implements ProviderInstance {
72
73
  }))
73
74
  }
74
75
 
75
- const response = await fetch(`${this.baseUrl}/messages`, {
76
+ const response = await fetchWithRetry(`${this.baseUrl}/messages`, {
76
77
  method: 'POST',
77
78
  headers: {
78
79
  'Content-Type': 'application/json',
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Shared fetch utilities — retry, timeout, backoff.
3
+ *
4
+ * Both AnthropicProvider and OpenAICompatProvider use these for
5
+ * resilient API communication.
6
+ */
7
+
8
+ export interface FetchWithRetryOptions {
9
+ /** Request timeout in ms (applied via AbortController). */
10
+ timeout?: number
11
+ /** Maximum retry attempts (default 0 = no retry). */
12
+ maxRetries?: number
13
+ /** Base delay for exponential backoff in ms (default 1000). */
14
+ baseDelay?: number
15
+ }
16
+
17
+ const RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 504])
18
+
19
+ function isRetryableError(err: unknown): boolean {
20
+ if (err instanceof DOMException && err.name === 'AbortError') return false
21
+ return true
22
+ }
23
+
24
+ /**
25
+ * Fetch with optional timeout and retry with exponential backoff.
26
+ *
27
+ * Retries on: network errors, 5xx, 429.
28
+ * Does NOT retry on: timeout (AbortError), 4xx (except 429).
29
+ */
30
+ export async function fetchWithRetry(
31
+ url: string,
32
+ init: RequestInit,
33
+ options: FetchWithRetryOptions = {},
34
+ ): Promise<Response> {
35
+ const { timeout = 60_000, maxRetries = 2, baseDelay = 1000 } = options
36
+
37
+ let lastErr: unknown
38
+
39
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
40
+ const controller = new AbortController()
41
+ const timer = setTimeout(() => controller.abort(), timeout)
42
+ const signal = init.signal ? anySignal([init.signal, controller.signal]) : controller.signal
43
+
44
+ try {
45
+ const response = await fetch(url, { ...init, signal })
46
+
47
+ // 429 / 5xx → retry
48
+ if (RETRYABLE_STATUSES.has(response.status) && attempt < maxRetries) {
49
+ const retryAfter = response.headers.get('Retry-After')
50
+ const delay = retryAfter
51
+ ? parseInt(retryAfter, 10) * 1000
52
+ : baseDelay * Math.pow(2, attempt)
53
+ await sleep(delay)
54
+ continue
55
+ }
56
+
57
+ return response
58
+ } catch (err) {
59
+ lastErr = err
60
+ if (!isRetryableError(err) || attempt >= maxRetries) throw err
61
+ await sleep(baseDelay * Math.pow(2, attempt))
62
+ } finally {
63
+ clearTimeout(timer)
64
+ // Clean up combined signal if we created one
65
+ if (init.signal) {
66
+ try {
67
+ controller.abort()
68
+ } catch {
69
+ /* best effort */
70
+ }
71
+ }
72
+ }
73
+ }
74
+
75
+ throw lastErr
76
+ }
77
+
78
+ /** Simple promise-based sleep. */
79
+ function sleep(ms: number): Promise<void> {
80
+ return new Promise((resolve) => setTimeout(resolve, ms))
81
+ }
82
+
83
+ /**
84
+ * Combine multiple AbortSignals into one — any signal aborting
85
+ * triggers the combined signal.
86
+ */
87
+ function anySignal(signals: AbortSignal[]): AbortSignal {
88
+ const controller = new AbortController()
89
+ const onAbort = () => {
90
+ controller.abort()
91
+ for (const s of signals) s.removeEventListener('abort', onAbort)
92
+ }
93
+ for (const s of signals) {
94
+ if (s.aborted) {
95
+ controller.abort()
96
+ return controller.signal
97
+ }
98
+ s.addEventListener('abort', onAbort)
99
+ }
100
+ return controller.signal
101
+ }