wraptc 1.0.2 → 1.0.4

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 (71) hide show
  1. package/bin/wraptc +4 -4
  2. package/package.json +2 -2
  3. package/src/cli/__tests__/cli.test.ts +337 -0
  4. package/src/cli/index.ts +149 -0
  5. package/src/core/__tests__/fixtures/configs/project-config.json +14 -0
  6. package/src/core/__tests__/fixtures/configs/system-config.json +14 -0
  7. package/src/core/__tests__/fixtures/configs/user-config.json +15 -0
  8. package/src/core/__tests__/integration/integration.test.ts +241 -0
  9. package/src/core/__tests__/integration/mock-coder-adapter.test.ts +243 -0
  10. package/src/core/__tests__/test-utils.ts +136 -0
  11. package/src/core/__tests__/unit/adapters/runner.test.ts +302 -0
  12. package/src/core/__tests__/unit/basic-test.test.ts +44 -0
  13. package/src/core/__tests__/unit/basic.test.ts +12 -0
  14. package/src/core/__tests__/unit/config.test.ts +244 -0
  15. package/src/core/__tests__/unit/error-patterns.test.ts +181 -0
  16. package/src/core/__tests__/unit/memory-monitor.test.ts +354 -0
  17. package/src/core/__tests__/unit/plugin/registry.test.ts +356 -0
  18. package/src/core/__tests__/unit/providers/codex.test.ts +173 -0
  19. package/src/core/__tests__/unit/providers/configurable.test.ts +429 -0
  20. package/src/core/__tests__/unit/providers/gemini.test.ts +251 -0
  21. package/src/core/__tests__/unit/providers/opencode.test.ts +258 -0
  22. package/src/core/__tests__/unit/providers/qwen-code.test.ts +195 -0
  23. package/src/core/__tests__/unit/providers/simple-codex.test.ts +18 -0
  24. package/src/core/__tests__/unit/router.test.ts +967 -0
  25. package/src/core/__tests__/unit/state.test.ts +1079 -0
  26. package/src/core/__tests__/unit/unified/capabilities.test.ts +186 -0
  27. package/src/core/__tests__/unit/wrap-terminalcoder.test.ts +32 -0
  28. package/src/core/adapters/builtin/codex.ts +35 -0
  29. package/src/core/adapters/builtin/gemini.ts +34 -0
  30. package/src/core/adapters/builtin/index.ts +31 -0
  31. package/src/core/adapters/builtin/mock-coder.ts +148 -0
  32. package/src/core/adapters/builtin/qwen.ts +34 -0
  33. package/src/core/adapters/define.ts +48 -0
  34. package/src/core/adapters/index.ts +43 -0
  35. package/src/core/adapters/loader.ts +143 -0
  36. package/src/core/adapters/provider-bridge.ts +190 -0
  37. package/src/core/adapters/runner.ts +437 -0
  38. package/src/core/adapters/types.ts +172 -0
  39. package/src/core/config.ts +290 -0
  40. package/src/core/define-provider.ts +212 -0
  41. package/src/core/error-patterns.ts +147 -0
  42. package/src/core/index.ts +130 -0
  43. package/src/core/memory-monitor.ts +171 -0
  44. package/src/core/plugin/builtin.ts +87 -0
  45. package/src/core/plugin/index.ts +34 -0
  46. package/src/core/plugin/registry.ts +350 -0
  47. package/src/core/plugin/types.ts +209 -0
  48. package/src/core/provider-factory.ts +397 -0
  49. package/src/core/provider-loader.ts +171 -0
  50. package/src/core/providers/codex.ts +56 -0
  51. package/src/core/providers/configurable.ts +637 -0
  52. package/src/core/providers/custom.ts +261 -0
  53. package/src/core/providers/gemini.ts +41 -0
  54. package/src/core/providers/index.ts +383 -0
  55. package/src/core/providers/opencode.ts +168 -0
  56. package/src/core/providers/qwen-code.ts +41 -0
  57. package/src/core/router.ts +370 -0
  58. package/src/core/state.ts +258 -0
  59. package/src/core/types.ts +206 -0
  60. package/src/core/unified/capabilities.ts +184 -0
  61. package/src/core/unified/errors.ts +141 -0
  62. package/src/core/unified/index.ts +29 -0
  63. package/src/core/unified/output.ts +189 -0
  64. package/src/core/wrap-terminalcoder.ts +245 -0
  65. package/src/mcp/__tests__/server.test.ts +295 -0
  66. package/src/mcp/server.ts +284 -0
  67. package/src/test-fixtures/mock-coder.sh +194 -0
  68. package/dist/cli/index.js +0 -16501
  69. package/dist/core/index.js +0 -7531
  70. package/dist/mcp/server.js +0 -14568
  71. package/dist/wraptc-1.0.2.tgz +0 -0
@@ -0,0 +1,637 @@
1
+ /**
2
+ * ConfigurableProvider - Provider implementation driven by ProviderDefinition
3
+ *
4
+ * This is the core implementation that enables config-only providers.
5
+ * Users can add new CLI tools by creating a .ts file with defineProvider().
6
+ *
7
+ * Phase 2 features:
8
+ * - Environment variables with ${VAR} interpolation
9
+ * - Timeout configuration with AbortController
10
+ * - System prompt support (flag or combined method)
11
+ * - Request parameters (maxTokens, temperature, language)
12
+ * - Subcommand support
13
+ * - Non-zero exit code handling (allowedExitCodes)
14
+ * - Retry logic with exponential backoff
15
+ * - Default working directory
16
+ */
17
+
18
+ import { DEFAULT_ERROR_PATTERNS, type ProviderDefinition } from "../define-provider";
19
+ import type {
20
+ CodingEvent,
21
+ CodingRequest,
22
+ ProviderErrorContext,
23
+ ProviderErrorKind,
24
+ ProviderInfo,
25
+ TokenUsage,
26
+ } from "../types";
27
+ import type { Provider, ProviderInvokeOptions } from "./index";
28
+
29
+ // Default timeout: 60 seconds
30
+ const DEFAULT_TIMEOUT_MS = 60_000;
31
+
32
+ export class ConfigurableProvider implements Provider {
33
+ public readonly id: string;
34
+ public readonly displayName: string;
35
+ public readonly supportsStreaming: boolean;
36
+ public readonly prefersJson: boolean;
37
+ public readonly capabilities?: string[];
38
+
39
+ private readonly definition: ProviderDefinition;
40
+ private readonly maxOutputSize: number = 100 * 1024 * 1024; // 100MB
41
+
42
+ constructor(definition: ProviderDefinition) {
43
+ this.definition = definition;
44
+ this.id = definition.id;
45
+ this.displayName = definition.displayName || definition.id;
46
+ this.supportsStreaming = definition.streaming.mode !== "none";
47
+ this.prefersJson = definition.output.format !== "text";
48
+ this.capabilities = definition.capabilities;
49
+ }
50
+
51
+ /**
52
+ * Interpolate environment variables in a value.
53
+ * Supports ${VAR} syntax, falling back to process.env.
54
+ */
55
+ private interpolateEnvValue(value: string): string {
56
+ return value.replace(/\$\{([^}]+)\}/g, (_, varName) => {
57
+ return process.env[varName] || "";
58
+ });
59
+ }
60
+
61
+ /**
62
+ * Build environment variables object.
63
+ * Merges process.env + provider defaults + invocation overrides.
64
+ * Interpolates ${VAR} syntax in provider-defined values.
65
+ */
66
+ private buildEnv(opts?: ProviderInvokeOptions): Record<string, string> {
67
+ const env: Record<string, string> = { ...process.env } as Record<string, string>;
68
+
69
+ // Add provider-defined environment variables with interpolation
70
+ if (this.definition.env) {
71
+ for (const [key, value] of Object.entries(this.definition.env)) {
72
+ env[key] = this.interpolateEnvValue(value);
73
+ }
74
+ }
75
+
76
+ // Override with invocation-specific environment variables
77
+ if (opts?.env) {
78
+ for (const [key, value] of Object.entries(opts.env)) {
79
+ env[key] = value;
80
+ }
81
+ }
82
+
83
+ return env;
84
+ }
85
+
86
+ /**
87
+ * Get the effective prompt, handling system prompt combination if needed.
88
+ */
89
+ private getEffectivePrompt(req: CodingRequest): string {
90
+ // If system prompt should be combined with the main prompt
91
+ if (req.systemPrompt && this.definition.args.systemPromptMethod === "combined") {
92
+ return `${req.systemPrompt}\n\n${req.prompt}`;
93
+ }
94
+ return req.prompt;
95
+ }
96
+
97
+ /**
98
+ * Build command line arguments based on config
99
+ */
100
+ protected buildArgs(req: CodingRequest): string[] {
101
+ // Use custom buildArgs if provided
102
+ if (this.definition.buildArgs) {
103
+ return this.definition.buildArgs(req);
104
+ }
105
+
106
+ const args: string[] = [];
107
+ const config = this.definition;
108
+
109
+ // Add subcommand first if configured (e.g., "exec", "run", "chat")
110
+ if (config.subcommand) {
111
+ args.push(config.subcommand);
112
+ }
113
+
114
+ // Add base arguments
115
+ args.push(...config.args.base);
116
+
117
+ // Add JSON flag if configured
118
+ if (config.args.jsonFlag && config.output.format === "json") {
119
+ // Handle flags like "-o json" (split on space)
120
+ const parts = config.args.jsonFlag.split(" ");
121
+ args.push(...parts);
122
+ }
123
+
124
+ // Add model flag if configured and model provided
125
+ if (config.args.modelFlag && req.model) {
126
+ args.push(config.args.modelFlag, req.model);
127
+ }
128
+
129
+ // Add system prompt flag if configured and system prompt provided
130
+ if (
131
+ config.args.systemPromptFlag &&
132
+ req.systemPrompt &&
133
+ config.args.systemPromptMethod !== "combined"
134
+ ) {
135
+ args.push(config.args.systemPromptFlag, req.systemPrompt);
136
+ }
137
+
138
+ // Add request parameter flags
139
+ if (config.args.maxTokensFlag && req.maxTokens !== undefined) {
140
+ args.push(config.args.maxTokensFlag, String(req.maxTokens));
141
+ }
142
+ if (config.args.temperatureFlag && req.temperature !== undefined) {
143
+ args.push(config.args.temperatureFlag, String(req.temperature));
144
+ }
145
+ if (config.args.languageFlag && req.language) {
146
+ args.push(config.args.languageFlag, req.language);
147
+ }
148
+
149
+ // Add file flags if configured
150
+ if (config.args.fileFlag && req.files?.length) {
151
+ for (const file of req.files) {
152
+ const path = typeof file === "string" ? file : file.path;
153
+ args.push(config.args.fileFlag, path);
154
+ }
155
+ }
156
+
157
+ // Get effective prompt (may include combined system prompt)
158
+ const effectivePrompt = this.getEffectivePrompt(req);
159
+
160
+ // Add prompt based on input method
161
+ if (config.input.method === "positional") {
162
+ if (config.input.position === "first") {
163
+ // Insert after subcommand if present
164
+ const insertIndex = config.subcommand ? 1 : 0;
165
+ args.splice(insertIndex, 0, effectivePrompt);
166
+ } else {
167
+ args.push(effectivePrompt);
168
+ }
169
+ } else if (config.input.method === "flag" && config.input.flag) {
170
+ args.push(config.input.flag, effectivePrompt);
171
+ }
172
+ // stdin method: prompt is piped, not added to args
173
+
174
+ return args;
175
+ }
176
+
177
+ /**
178
+ * Get stdin input based on config
179
+ */
180
+ protected getStdinInput(req: CodingRequest): string | undefined {
181
+ // Use custom getStdinInput if provided
182
+ if (this.definition.getStdinInput) {
183
+ return this.definition.getStdinInput(req);
184
+ }
185
+
186
+ // Only pipe to stdin if method is stdin
187
+ if (this.definition.input.method === "stdin") {
188
+ // Use effective prompt which may include combined system prompt
189
+ return this.getEffectivePrompt(req);
190
+ }
191
+
192
+ return undefined;
193
+ }
194
+
195
+ /**
196
+ * Check if an exit code is considered successful
197
+ */
198
+ private isSuccessExitCode(exitCode: number | null): boolean {
199
+ if (exitCode === null) return false;
200
+ const allowedCodes = this.definition.allowedExitCodes || [0];
201
+ return allowedCodes.includes(exitCode);
202
+ }
203
+
204
+ /**
205
+ * Get the effective timeout in milliseconds
206
+ */
207
+ private getTimeoutMs(opts?: ProviderInvokeOptions): number {
208
+ return opts?.timeoutMs ?? this.definition.timeoutMs ?? DEFAULT_TIMEOUT_MS;
209
+ }
210
+
211
+ /**
212
+ * Get the effective working directory
213
+ */
214
+ private getCwd(opts?: ProviderInvokeOptions): string | undefined {
215
+ return opts?.cwd ?? this.definition.defaultCwd;
216
+ }
217
+
218
+ /**
219
+ * Parse output based on config
220
+ */
221
+ protected parseOutput(stdout: string): { text: string; usage?: TokenUsage } {
222
+ // Use custom parseOutput if provided
223
+ if (this.definition.parseOutput) {
224
+ return this.definition.parseOutput(stdout);
225
+ }
226
+
227
+ const config = this.definition.output;
228
+
229
+ if (config.format === "text") {
230
+ return { text: stdout.trim() };
231
+ }
232
+
233
+ try {
234
+ const parsed = JSON.parse(stdout);
235
+ const text = config.textField
236
+ ? this.getNestedField(parsed, config.textField)
237
+ : parsed.text || parsed.response || parsed.output || stdout;
238
+ const usage = config.usageField
239
+ ? this.getNestedField(parsed, config.usageField)
240
+ : parsed.usage;
241
+
242
+ return { text: String(text), usage };
243
+ } catch {
244
+ // Fall back to raw output if JSON parsing fails
245
+ return { text: stdout.trim() };
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Get a nested field from an object using dot notation
251
+ */
252
+ private getNestedField(obj: unknown, path: string): unknown {
253
+ const parts = path.split(".");
254
+ let current: unknown = obj;
255
+
256
+ for (const part of parts) {
257
+ if (current === null || current === undefined) {
258
+ return undefined;
259
+ }
260
+ current = (current as Record<string, unknown>)[part];
261
+ }
262
+
263
+ return current;
264
+ }
265
+
266
+ /**
267
+ * Classify an error based on patterns
268
+ */
269
+ classifyError(error: ProviderErrorContext): ProviderErrorKind {
270
+ // Use custom classifyError if provided
271
+ if (this.definition.classifyError) {
272
+ return this.definition.classifyError(error);
273
+ }
274
+
275
+ const combined = ((error.stderr || "") + (error.stdout || "")).toLowerCase();
276
+
277
+ // Check provider-specific patterns first
278
+ const providerPatterns = this.definition.errors || {};
279
+ for (const [kind, patterns] of Object.entries(providerPatterns)) {
280
+ if (patterns.some((p) => combined.includes(p.toLowerCase()))) {
281
+ return kind as ProviderErrorKind;
282
+ }
283
+ }
284
+
285
+ // Then check default patterns
286
+ for (const [kind, patterns] of Object.entries(DEFAULT_ERROR_PATTERNS)) {
287
+ if (patterns.some((p) => combined.includes(p.toLowerCase()))) {
288
+ return kind as ProviderErrorKind;
289
+ }
290
+ }
291
+
292
+ return "UNKNOWN";
293
+ }
294
+
295
+ /**
296
+ * Execute a single request (internal implementation without retry)
297
+ */
298
+ private async executeOnce(
299
+ req: CodingRequest,
300
+ opts: ProviderInvokeOptions,
301
+ ): Promise<{ text: string; usage?: TokenUsage }> {
302
+ const args = this.buildArgs(req);
303
+ const stdin = this.getStdinInput(req);
304
+ const env = this.buildEnv(opts);
305
+ const cwd = this.getCwd(opts);
306
+ const timeoutMs = this.getTimeoutMs(opts);
307
+
308
+ // Create AbortController for timeout
309
+ const timeoutController = new AbortController();
310
+ const timeoutId = setTimeout(() => timeoutController.abort(), timeoutMs);
311
+
312
+ // Combine with user-provided signal if any
313
+ const combinedSignal = opts?.signal
314
+ ? AbortSignal.any([opts.signal, timeoutController.signal])
315
+ : timeoutController.signal;
316
+
317
+ try {
318
+ const proc = Bun.spawn([this.definition.binary, ...args], {
319
+ cwd,
320
+ env,
321
+ stdin: stdin ? "pipe" : "ignore",
322
+ stdout: "pipe",
323
+ stderr: "pipe",
324
+ });
325
+
326
+ // Write to stdin if needed
327
+ if (stdin && proc.stdin) {
328
+ proc.stdin.write(stdin);
329
+ proc.stdin.end();
330
+ }
331
+
332
+ // Set up abort handler
333
+ combinedSignal.addEventListener("abort", () => {
334
+ proc.kill();
335
+ });
336
+
337
+ // Read output with size limit
338
+ const stdoutChunks: string[] = [];
339
+ const stderrChunks: string[] = [];
340
+ let totalSize = 0;
341
+
342
+ if (proc.stdout) {
343
+ const reader = proc.stdout.getReader();
344
+ const decoder = new TextDecoder();
345
+ try {
346
+ while (true) {
347
+ const { done, value } = await reader.read();
348
+ if (done) break;
349
+ const text = decoder.decode(value, { stream: true });
350
+ stdoutChunks.push(text);
351
+ totalSize += text.length;
352
+ if (totalSize > this.maxOutputSize) {
353
+ proc.kill();
354
+ throw new Error("Output exceeded maximum size limit");
355
+ }
356
+ }
357
+ } finally {
358
+ reader.releaseLock();
359
+ }
360
+ }
361
+
362
+ if (proc.stderr) {
363
+ const reader = proc.stderr.getReader();
364
+ const decoder = new TextDecoder();
365
+ try {
366
+ while (true) {
367
+ const { done, value } = await reader.read();
368
+ if (done) break;
369
+ stderrChunks.push(decoder.decode(value, { stream: true }));
370
+ }
371
+ } finally {
372
+ reader.releaseLock();
373
+ }
374
+ }
375
+
376
+ const exitCode = await proc.exited;
377
+ const stdout = stdoutChunks.join("");
378
+ const stderr = stderrChunks.join("");
379
+
380
+ // Check if timed out
381
+ if (timeoutController.signal.aborted) {
382
+ const error = new Error(`${this.displayName} timed out after ${timeoutMs}ms`);
383
+ (error as Error & { code: string }).code = "TIMEOUT";
384
+ throw error;
385
+ }
386
+
387
+ // Check if user aborted
388
+ if (opts?.signal?.aborted) {
389
+ throw new Error("Aborted");
390
+ }
391
+
392
+ // Check exit code against allowed codes
393
+ if (!this.isSuccessExitCode(exitCode)) {
394
+ const error = new Error(`${this.displayName} failed with code ${exitCode}: ${stderr}`);
395
+ (error as Error & { exitCode: number; stderr: string }).exitCode = exitCode ?? -1;
396
+ (error as Error & { exitCode: number; stderr: string }).stderr = stderr;
397
+ throw error;
398
+ }
399
+
400
+ return this.parseOutput(stdout);
401
+ } finally {
402
+ clearTimeout(timeoutId);
403
+ }
404
+ }
405
+
406
+ /**
407
+ * Execute a single request with retry logic
408
+ */
409
+ async runOnce(
410
+ req: CodingRequest,
411
+ opts: ProviderInvokeOptions,
412
+ ): Promise<{ text: string; usage?: TokenUsage }> {
413
+ const retryConfig = this.definition.retry;
414
+ const maxAttempts = retryConfig?.maxAttempts ?? 1;
415
+ const delayMs = retryConfig?.delayMs ?? 1000;
416
+ const backoffMultiplier = retryConfig?.backoffMultiplier ?? 2;
417
+ const retryOn = retryConfig?.retryOn ?? ["TRANSIENT"];
418
+
419
+ let lastError: Error | null = null;
420
+
421
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
422
+ try {
423
+ return await this.executeOnce(req, opts);
424
+ } catch (err) {
425
+ lastError = err as Error;
426
+
427
+ // Check if user aborted - don't retry
428
+ if (opts?.signal?.aborted) {
429
+ throw err;
430
+ }
431
+
432
+ // Classify the error
433
+ const errorKind = this.classifyError({
434
+ stderr: (err as Error & { stderr?: string }).stderr || (err as Error).message,
435
+ exitCode: (err as Error & { exitCode?: number }).exitCode,
436
+ });
437
+
438
+ // Check if this error kind should trigger a retry
439
+ if (!retryOn.includes(errorKind)) {
440
+ throw err; // Not retryable
441
+ }
442
+
443
+ // Don't retry on last attempt
444
+ if (attempt >= maxAttempts) {
445
+ throw err;
446
+ }
447
+
448
+ // Wait before retrying with exponential backoff
449
+ const waitTime = delayMs * backoffMultiplier ** (attempt - 1);
450
+ await new Promise((resolve) => setTimeout(resolve, waitTime));
451
+ }
452
+ }
453
+
454
+ // Should never reach here, but TypeScript needs this
455
+ throw lastError || new Error("Unknown error");
456
+ }
457
+
458
+ /**
459
+ * Execute a streaming request
460
+ */
461
+ async *runStream(req: CodingRequest, opts: ProviderInvokeOptions): AsyncGenerator<CodingEvent> {
462
+ const requestId = crypto.randomUUID();
463
+ yield { type: "start", provider: this.id, requestId };
464
+
465
+ const args = this.buildArgs(req);
466
+ const stdin = this.getStdinInput(req);
467
+ const env = this.buildEnv(opts);
468
+ const cwd = this.getCwd(opts);
469
+ const timeoutMs = this.getTimeoutMs(opts);
470
+
471
+ // Create AbortController for timeout
472
+ const timeoutController = new AbortController();
473
+ const timeoutId = setTimeout(() => timeoutController.abort(), timeoutMs);
474
+
475
+ // Combine with user-provided signal if any
476
+ const combinedSignal = opts?.signal
477
+ ? AbortSignal.any([opts.signal, timeoutController.signal])
478
+ : timeoutController.signal;
479
+
480
+ try {
481
+ const proc = Bun.spawn([this.definition.binary, ...args], {
482
+ cwd,
483
+ env,
484
+ stdin: stdin ? "pipe" : "ignore",
485
+ stdout: "pipe",
486
+ stderr: "pipe",
487
+ });
488
+
489
+ // Write to stdin if needed
490
+ if (stdin && proc.stdin) {
491
+ proc.stdin.write(stdin);
492
+ proc.stdin.end();
493
+ }
494
+
495
+ // Set up abort handler
496
+ combinedSignal.addEventListener("abort", () => {
497
+ proc.kill();
498
+ });
499
+
500
+ const fullTextChunks: string[] = [];
501
+ let lineBuffer = "";
502
+
503
+ // Stream stdout
504
+ if (proc.stdout) {
505
+ const reader = proc.stdout.getReader();
506
+ const decoder = new TextDecoder();
507
+
508
+ try {
509
+ while (true) {
510
+ const { done, value } = await reader.read();
511
+ if (done) break;
512
+
513
+ const text = decoder.decode(value, { stream: true });
514
+ fullTextChunks.push(text);
515
+
516
+ const streamMode = this.definition.streaming.mode;
517
+
518
+ if (streamMode === "jsonl") {
519
+ // Buffer partial lines
520
+ lineBuffer += text;
521
+ const lines = lineBuffer.split("\n");
522
+ lineBuffer = lines.pop() ?? "";
523
+
524
+ for (const line of lines.filter(Boolean)) {
525
+ try {
526
+ const parsed = JSON.parse(line);
527
+ yield { type: "chunk", data: parsed };
528
+ } catch {
529
+ yield { type: "text_delta", text: line };
530
+ }
531
+ }
532
+ } else if (streamMode === "line") {
533
+ for (const line of text.split("\n")) {
534
+ if (line.trim()) {
535
+ yield { type: "text_delta", text: `${line}\n` };
536
+ }
537
+ }
538
+ } else {
539
+ yield { type: "text_delta", text };
540
+ }
541
+ }
542
+ } finally {
543
+ reader.releaseLock();
544
+ }
545
+ }
546
+
547
+ // Emit remaining buffer
548
+ if (lineBuffer.trim()) {
549
+ try {
550
+ const parsed = JSON.parse(lineBuffer);
551
+ yield { type: "chunk", data: parsed };
552
+ } catch {
553
+ yield { type: "text_delta", text: lineBuffer };
554
+ }
555
+ }
556
+
557
+ // Collect stderr
558
+ const stderrChunks: string[] = [];
559
+ if (proc.stderr) {
560
+ const reader = proc.stderr.getReader();
561
+ const decoder = new TextDecoder();
562
+ try {
563
+ while (true) {
564
+ const { done, value } = await reader.read();
565
+ if (done) break;
566
+ stderrChunks.push(decoder.decode(value, { stream: true }));
567
+ }
568
+ } finally {
569
+ reader.releaseLock();
570
+ }
571
+ }
572
+
573
+ const exitCode = await proc.exited;
574
+ const fullText = fullTextChunks.join("");
575
+ const stderr = stderrChunks.join("");
576
+
577
+ // Check if timed out
578
+ if (timeoutController.signal.aborted) {
579
+ yield {
580
+ type: "error",
581
+ provider: this.id,
582
+ code: "TIMEOUT",
583
+ message: `${this.displayName} timed out after ${timeoutMs}ms`,
584
+ };
585
+ return;
586
+ }
587
+
588
+ // Check if user aborted
589
+ if (opts?.signal?.aborted) {
590
+ yield {
591
+ type: "error",
592
+ provider: this.id,
593
+ code: "UNKNOWN",
594
+ message: "Aborted by user",
595
+ };
596
+ return;
597
+ }
598
+
599
+ // Check exit code against allowed codes
600
+ if (this.isSuccessExitCode(exitCode)) {
601
+ const result = this.parseOutput(fullText);
602
+ yield {
603
+ type: "complete",
604
+ provider: this.id,
605
+ text: result.text,
606
+ usage: result.usage,
607
+ };
608
+ } else {
609
+ yield {
610
+ type: "error",
611
+ provider: this.id,
612
+ code: this.classifyError({ stderr, exitCode: exitCode ?? undefined }),
613
+ message: stderr || `Process exited with code ${exitCode}`,
614
+ };
615
+ }
616
+ } finally {
617
+ clearTimeout(timeoutId);
618
+ }
619
+ }
620
+
621
+ getInfo(): ProviderInfo {
622
+ return {
623
+ id: this.id,
624
+ displayName: this.displayName,
625
+ supportsStreaming: this.supportsStreaming,
626
+ prefersJson: this.prefersJson,
627
+ capabilities: this.capabilities,
628
+ };
629
+ }
630
+ }
631
+
632
+ /**
633
+ * Create a ConfigurableProvider from a ProviderDefinition
634
+ */
635
+ export function createConfigurableProvider(definition: ProviderDefinition): ConfigurableProvider {
636
+ return new ConfigurableProvider(definition);
637
+ }