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,261 @@
1
+ import { classifyErrorDefault } from "../error-patterns";
2
+ import type {
3
+ CodingEvent,
4
+ CodingRequest,
5
+ ProviderConfig,
6
+ ProviderErrorContext,
7
+ ProviderErrorKind,
8
+ } from "../types";
9
+ import { BaseProvider, type ProviderInvokeOptions } from "./index";
10
+
11
+ export class CustomProvider extends BaseProvider {
12
+ // Configurable output size limit (default 100MB)
13
+ private readonly maxOutputSize: number = 100 * 1024 * 1024;
14
+
15
+ constructor(id: string, config: ProviderConfig) {
16
+ super(id, id, config);
17
+ }
18
+
19
+ async runOnce(
20
+ req: CodingRequest,
21
+ opts: ProviderInvokeOptions,
22
+ ): Promise<{
23
+ text: string;
24
+ usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number };
25
+ }> {
26
+ const args = this.buildArgs(req, opts);
27
+ const needsStdin = !this.config.argsTemplate || !args.some((arg) => arg.includes(req.prompt));
28
+
29
+ // Use Bun.spawn() for better performance
30
+ const proc = Bun.spawn([this.config.binary, ...args], {
31
+ cwd: opts?.cwd,
32
+ stdin: needsStdin && req.prompt ? "pipe" : "ignore",
33
+ stdout: "pipe",
34
+ stderr: "pipe",
35
+ });
36
+
37
+ // Send prompt to stdin if not using template
38
+ if (needsStdin && req.prompt && proc.stdin) {
39
+ proc.stdin.write(req.prompt);
40
+ proc.stdin.end();
41
+ }
42
+
43
+ // Set up abort handler
44
+ if (opts?.signal) {
45
+ opts.signal.addEventListener("abort", () => {
46
+ proc.kill();
47
+ });
48
+ }
49
+
50
+ // Use array buffering instead of string concatenation
51
+ const stdoutChunks: string[] = [];
52
+ const stderrChunks: string[] = [];
53
+ let totalSize = 0;
54
+
55
+ // Read stdout with size limit
56
+ if (proc.stdout) {
57
+ const reader = proc.stdout.getReader();
58
+ const decoder = new TextDecoder();
59
+ try {
60
+ while (true) {
61
+ const { done, value } = await reader.read();
62
+ if (done) break;
63
+ const text = decoder.decode(value, { stream: true });
64
+ stdoutChunks.push(text);
65
+ totalSize += text.length;
66
+ if (totalSize > this.maxOutputSize) {
67
+ proc.kill();
68
+ throw new Error(`Output exceeded maximum size limit of ${this.maxOutputSize} bytes`);
69
+ }
70
+ }
71
+ } finally {
72
+ reader.releaseLock();
73
+ }
74
+ }
75
+
76
+ // Read stderr
77
+ if (proc.stderr) {
78
+ const reader = proc.stderr.getReader();
79
+ const decoder = new TextDecoder();
80
+ try {
81
+ while (true) {
82
+ const { done, value } = await reader.read();
83
+ if (done) break;
84
+ stderrChunks.push(decoder.decode(value, { stream: true }));
85
+ }
86
+ } finally {
87
+ reader.releaseLock();
88
+ }
89
+ }
90
+
91
+ // Wait for process to exit
92
+ const exitCode = await proc.exited;
93
+
94
+ // Check if aborted
95
+ if (opts?.signal?.aborted) {
96
+ throw new Error("Aborted");
97
+ }
98
+
99
+ const stdout = stdoutChunks.join("");
100
+ const stderr = stderrChunks.join("");
101
+
102
+ if (exitCode === 0) {
103
+ if (this.config.jsonMode !== "none") {
104
+ return this.parseJsonOutput(stdout);
105
+ }
106
+ return { text: stdout };
107
+ }
108
+
109
+ throw new Error(`${this.displayName} CLI failed with code ${exitCode}: ${stderr}`);
110
+ }
111
+
112
+ async *runStream(req: CodingRequest, opts: ProviderInvokeOptions): AsyncGenerator<CodingEvent> {
113
+ const requestId = crypto.randomUUID();
114
+ yield { type: "start", provider: this.id, requestId };
115
+
116
+ const args = this.buildArgs(req, opts);
117
+ const needsStdin =
118
+ !this.config.argsTemplate ||
119
+ !this.config.argsTemplate.some((arg) => arg.includes("{{prompt}}"));
120
+
121
+ // Use Bun.spawn() for streaming
122
+ const proc = Bun.spawn([this.config.binary, ...args], {
123
+ cwd: opts?.cwd,
124
+ stdin: needsStdin && req.prompt ? "pipe" : "ignore",
125
+ stdout: "pipe",
126
+ stderr: "pipe",
127
+ });
128
+
129
+ // Send prompt to stdin if not using template
130
+ if (needsStdin && req.prompt && proc.stdin) {
131
+ proc.stdin.write(req.prompt);
132
+ proc.stdin.end();
133
+ }
134
+
135
+ // Use array buffering for fullText instead of string concatenation
136
+ const fullTextChunks: string[] = [];
137
+ // Line buffer for handling partial lines in JSONL mode
138
+ let lineBuffer = "";
139
+
140
+ // Stream stdout directly without accumulating
141
+ if (proc.stdout) {
142
+ const reader = proc.stdout.getReader();
143
+ const decoder = new TextDecoder();
144
+ try {
145
+ while (true) {
146
+ const { done, value } = await reader.read();
147
+ if (done) break;
148
+ const text = decoder.decode(value, { stream: true });
149
+ fullTextChunks.push(text);
150
+
151
+ if (this.config.streamingMode === "jsonl") {
152
+ // Buffer partial lines and emit complete lines
153
+ lineBuffer += text;
154
+ const lines = lineBuffer.split("\n");
155
+ // Keep the last (potentially incomplete) line in the buffer
156
+ lineBuffer = lines.pop() ?? "";
157
+
158
+ for (const line of lines.filter(Boolean)) {
159
+ try {
160
+ const parsed = JSON.parse(line);
161
+ yield { type: "chunk", data: parsed };
162
+ } catch {
163
+ yield { type: "text_delta", text: line };
164
+ }
165
+ }
166
+ } else if (this.config.streamingMode === "line") {
167
+ // Emit each line as a text_delta
168
+ const lines = text.split("\n");
169
+ for (const line of lines) {
170
+ if (line.trim()) {
171
+ yield { type: "text_delta", text: `${line}\n` };
172
+ }
173
+ }
174
+ } else {
175
+ yield { type: "text_delta", text };
176
+ }
177
+ }
178
+ } finally {
179
+ reader.releaseLock();
180
+ }
181
+ }
182
+
183
+ // Emit any remaining buffered line
184
+ if (lineBuffer.trim()) {
185
+ try {
186
+ const parsed = JSON.parse(lineBuffer);
187
+ yield { type: "chunk", data: parsed };
188
+ } catch {
189
+ yield { type: "text_delta", text: lineBuffer };
190
+ }
191
+ }
192
+
193
+ // Collect stderr with array buffering (with 1MB limit)
194
+ const stderrChunks: string[] = [];
195
+ let stderrSize = 0;
196
+ const maxStderrSize = 1024 * 1024; // 1MB limit
197
+
198
+ if (proc.stderr) {
199
+ const reader = proc.stderr.getReader();
200
+ const decoder = new TextDecoder();
201
+ try {
202
+ while (true) {
203
+ const { done, value } = await reader.read();
204
+ if (done) break;
205
+ const text = decoder.decode(value, { stream: true });
206
+ if (stderrSize + text.length <= maxStderrSize) {
207
+ stderrChunks.push(text);
208
+ stderrSize += text.length;
209
+ }
210
+ }
211
+ } finally {
212
+ reader.releaseLock();
213
+ }
214
+ }
215
+
216
+ // Wait for process to exit
217
+ const exitCode = await proc.exited;
218
+ const fullText = fullTextChunks.join("");
219
+ const stderr = stderrChunks.join("");
220
+
221
+ if (exitCode === 0) {
222
+ const result =
223
+ this.config.jsonMode !== "none" ? this.parseJsonOutput(fullText) : { text: fullText };
224
+ yield { type: "complete", provider: this.id, text: result.text, usage: result.usage };
225
+ } else {
226
+ yield {
227
+ type: "error",
228
+ provider: this.id,
229
+ code: this.classifyError({ stderr, exitCode: exitCode || undefined }),
230
+ message: stderr || `Process exited with code ${exitCode}`,
231
+ };
232
+ }
233
+ }
234
+
235
+ classifyError(error: ProviderErrorContext): ProviderErrorKind {
236
+ // Use the shared error classifier from error-patterns.ts
237
+ return classifyErrorDefault(error.stderr, error.stdout, error.exitCode, error.httpStatus);
238
+ }
239
+
240
+ protected buildArgs(req: CodingRequest, opts: ProviderInvokeOptions): string[] {
241
+ // If argsTemplate is provided, use it with variable substitution
242
+ if (this.config.argsTemplate && this.config.argsTemplate.length > 0) {
243
+ return this.config.argsTemplate.map((arg) => {
244
+ return arg
245
+ .replace("{{prompt}}", req.prompt)
246
+ .replace("{{mode}}", req.mode || "generate")
247
+ .replace("{{language}}", req.language || "")
248
+ .replace("{{temperature}}", req.temperature?.toString() || "0.7");
249
+ });
250
+ }
251
+
252
+ // Otherwise use standard args with optional JSON flag
253
+ const args = [...this.config.args];
254
+
255
+ if (this.config.jsonMode === "flag" && this.config.jsonFlag) {
256
+ args.push(this.config.jsonFlag);
257
+ }
258
+
259
+ return args;
260
+ }
261
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * GeminiProvider - Simplified provider using ProcessProvider defaults
3
+ *
4
+ * Gemini CLI uses:
5
+ * - Positional prompt: gemini [options] <prompt>
6
+ * - JSON output flag: -o json
7
+ */
8
+ import type { CodingRequest, ProviderConfig, ProviderInvokeOptions } from "../types";
9
+ import { ProcessProvider } from "./index";
10
+
11
+ export class GeminiProvider extends ProcessProvider {
12
+ constructor(config: ProviderConfig) {
13
+ super("gemini", "Gemini CLI", {
14
+ ...config,
15
+ binary: config.binary || "gemini",
16
+ });
17
+ }
18
+
19
+ /**
20
+ * Gemini uses positional prompt (not stdin), so override getStdinInput
21
+ */
22
+ protected getStdinInput(): string | undefined {
23
+ return undefined; // Gemini uses positional prompt
24
+ }
25
+
26
+ /**
27
+ * Build args: gemini -o json [...args] <prompt>
28
+ */
29
+ protected buildArgs(req: CodingRequest, _opts: ProviderInvokeOptions): string[] {
30
+ const args: string[] = [];
31
+
32
+ if (this.config.jsonMode === "flag") {
33
+ args.push("-o", "json");
34
+ }
35
+
36
+ args.push(...this.config.args);
37
+ args.push(req.prompt);
38
+
39
+ return args;
40
+ }
41
+ }
@@ -0,0 +1,383 @@
1
+ import { join } from "node:path";
2
+ import { DEFAULT_ERROR_PATTERNS, classifyErrorDefault } from "../error-patterns";
3
+ import type {
4
+ CodingEvent,
5
+ CodingRequest,
6
+ CodingResponse,
7
+ ProviderConfig,
8
+ ProviderErrorContext,
9
+ ProviderErrorKind,
10
+ ProviderInfo,
11
+ ProviderInvokeOptions,
12
+ TokenUsage,
13
+ } from "../types";
14
+
15
+ // Re-export ProviderInvokeOptions for convenience
16
+ export type { ProviderInvokeOptions };
17
+
18
+ export interface Provider {
19
+ readonly id: string;
20
+ readonly displayName: string;
21
+ readonly supportsStreaming: boolean;
22
+ readonly prefersJson: boolean;
23
+ readonly capabilities?: string[];
24
+
25
+ runOnce(
26
+ req: CodingRequest,
27
+ opts: ProviderInvokeOptions,
28
+ ): Promise<{
29
+ text: string;
30
+ usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number };
31
+ }>;
32
+
33
+ runStream(req: CodingRequest, opts: ProviderInvokeOptions): AsyncGenerator<CodingEvent>;
34
+
35
+ classifyError(error: ProviderErrorContext): ProviderErrorKind;
36
+
37
+ getInfo(): ProviderInfo;
38
+ }
39
+
40
+ export abstract class BaseProvider implements Provider {
41
+ public readonly id: string;
42
+ public readonly displayName: string;
43
+ public readonly supportsStreaming: boolean;
44
+ public readonly prefersJson: boolean;
45
+ public readonly capabilities?: string[];
46
+ protected config: ProviderConfig;
47
+
48
+ constructor(id: string, displayName: string, config: ProviderConfig) {
49
+ this.id = id;
50
+ this.displayName = displayName;
51
+ this.config = config;
52
+ this.supportsStreaming = config.streamingMode !== "none";
53
+ this.prefersJson = config.jsonMode !== "none";
54
+ this.capabilities = config.capabilities;
55
+ }
56
+
57
+ // These are implemented in ProcessProvider with defaults
58
+ // Subclasses can override for custom behavior
59
+ abstract runOnce(
60
+ req: CodingRequest,
61
+ opts: ProviderInvokeOptions,
62
+ ): Promise<{ text: string; usage?: TokenUsage }>;
63
+
64
+ abstract runStream(req: CodingRequest, opts: ProviderInvokeOptions): AsyncGenerator<CodingEvent>;
65
+
66
+ abstract classifyError(error: ProviderErrorContext): ProviderErrorKind;
67
+
68
+ getInfo(): ProviderInfo {
69
+ return {
70
+ id: this.id,
71
+ displayName: this.displayName,
72
+ supportsStreaming: this.supportsStreaming,
73
+ prefersJson: this.prefersJson,
74
+ capabilities: this.capabilities,
75
+ };
76
+ }
77
+
78
+ protected async ensureConfigDir(): Promise<string> {
79
+ const configDir = join(process.env.HOME || "~", ".config", "wrap-terminalcoder");
80
+ // Use Bun's built-in mkdir via shell
81
+ await Bun.$`mkdir -p ${configDir}`.quiet();
82
+ return configDir;
83
+ }
84
+
85
+ protected buildArgs(req: CodingRequest, opts: ProviderInvokeOptions): string[] {
86
+ const args = [...this.config.args];
87
+
88
+ if (this.config.argsTemplate) {
89
+ return this.config.argsTemplate.map((arg) => {
90
+ return arg.replace("{{prompt}}", req.prompt);
91
+ });
92
+ }
93
+
94
+ if (this.config.jsonMode === "flag" && this.config.jsonFlag) {
95
+ args.push(this.config.jsonFlag);
96
+ }
97
+
98
+ return args;
99
+ }
100
+
101
+ protected parseJsonOutput(stdout: string): {
102
+ text: string;
103
+ usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number };
104
+ } {
105
+ try {
106
+ const parsed = JSON.parse(stdout);
107
+ return {
108
+ text: parsed.text || parsed.response || parsed.output || stdout,
109
+ usage: parsed.usage,
110
+ };
111
+ } catch {
112
+ return { text: stdout };
113
+ }
114
+ }
115
+ }
116
+
117
+ export abstract class ProcessProvider extends BaseProvider {
118
+ protected readonly binaryPath: string;
119
+ // Configurable output size limit (default 100MB)
120
+ protected readonly maxOutputSize: number = 100 * 1024 * 1024;
121
+
122
+ constructor(id: string, displayName: string, config: ProviderConfig) {
123
+ super(id, displayName, config);
124
+ this.binaryPath = config.binary;
125
+ }
126
+
127
+ /**
128
+ * Check if the binary exists before attempting to spawn a process.
129
+ * This prevents hanging when the binary is not found.
130
+ */
131
+ protected async checkBinaryExists(): Promise<void> {
132
+ try {
133
+ await Bun.$`which ${this.binaryPath}`.quiet();
134
+ } catch {
135
+ throw new Error(`Binary not found: ${this.binaryPath}`);
136
+ }
137
+ }
138
+
139
+ protected async executeProcess(
140
+ args: string[],
141
+ input?: string,
142
+ opts?: ProviderInvokeOptions,
143
+ ): Promise<{ stdout: string; stderr: string; exitCode: number | null }> {
144
+ // Check if binary exists first to avoid hanging on non-existent binaries
145
+ await this.checkBinaryExists();
146
+
147
+ // Use Bun.spawn() for better performance
148
+ const proc = Bun.spawn([this.binaryPath, ...args], {
149
+ cwd: opts?.cwd,
150
+ stdin: input ? "pipe" : "ignore",
151
+ stdout: "pipe",
152
+ stderr: "pipe",
153
+ });
154
+
155
+ // Write input to stdin if provided
156
+ if (input && proc.stdin) {
157
+ proc.stdin.write(input);
158
+ proc.stdin.end();
159
+ }
160
+
161
+ // Set up abort handler
162
+ if (opts?.signal) {
163
+ opts.signal.addEventListener("abort", () => {
164
+ proc.kill();
165
+ });
166
+ }
167
+
168
+ // Read stdout and stderr using Bun's streaming APIs
169
+ const stdoutChunks: string[] = [];
170
+ const stderrChunks: string[] = [];
171
+ let totalSize = 0;
172
+
173
+ // Read stdout
174
+ if (proc.stdout) {
175
+ const reader = proc.stdout.getReader();
176
+ const decoder = new TextDecoder();
177
+ try {
178
+ while (true) {
179
+ const { done, value } = await reader.read();
180
+ if (done) break;
181
+ const text = decoder.decode(value, { stream: true });
182
+ stdoutChunks.push(text);
183
+ totalSize += text.length;
184
+ if (totalSize > this.maxOutputSize) {
185
+ proc.kill();
186
+ throw new Error(`Output exceeded maximum size limit of ${this.maxOutputSize} bytes`);
187
+ }
188
+ }
189
+ } finally {
190
+ reader.releaseLock();
191
+ }
192
+ }
193
+
194
+ // Read stderr
195
+ if (proc.stderr) {
196
+ const reader = proc.stderr.getReader();
197
+ const decoder = new TextDecoder();
198
+ try {
199
+ while (true) {
200
+ const { done, value } = await reader.read();
201
+ if (done) break;
202
+ stderrChunks.push(decoder.decode(value, { stream: true }));
203
+ }
204
+ } finally {
205
+ reader.releaseLock();
206
+ }
207
+ }
208
+
209
+ // Wait for process to exit
210
+ const exitCode = await proc.exited;
211
+
212
+ // Check if aborted
213
+ if (opts?.signal?.aborted) {
214
+ throw new Error("Aborted");
215
+ }
216
+
217
+ return {
218
+ stdout: stdoutChunks.join(""),
219
+ stderr: stderrChunks.join(""),
220
+ exitCode,
221
+ };
222
+ }
223
+
224
+ protected async *streamProcess(
225
+ args: string[],
226
+ input?: string,
227
+ opts?: ProviderInvokeOptions,
228
+ ): AsyncGenerator<{ type: "stdout" | "stderr"; data: string }> {
229
+ // Check if binary exists first to avoid hanging on non-existent binaries
230
+ await this.checkBinaryExists();
231
+
232
+ // Use Bun.spawn() for streaming
233
+ const proc = Bun.spawn([this.binaryPath, ...args], {
234
+ cwd: opts?.cwd,
235
+ stdin: input ? "pipe" : "ignore",
236
+ stdout: "pipe",
237
+ stderr: "pipe",
238
+ });
239
+
240
+ // Write input to stdin if provided
241
+ if (input && proc.stdin) {
242
+ proc.stdin.write(input);
243
+ proc.stdin.end();
244
+ }
245
+
246
+ // Stream stdout chunks directly without accumulation
247
+ if (proc.stdout) {
248
+ const reader = proc.stdout.getReader();
249
+ const decoder = new TextDecoder();
250
+ try {
251
+ while (true) {
252
+ const { done, value } = await reader.read();
253
+ if (done) break;
254
+ yield { type: "stdout", data: decoder.decode(value, { stream: true }) };
255
+ }
256
+ } finally {
257
+ reader.releaseLock();
258
+ }
259
+ }
260
+
261
+ // Collect stderr for error reporting (with 1MB limit)
262
+ const stderrChunks: string[] = [];
263
+ let stderrSize = 0;
264
+ const maxStderrSize = 1024 * 1024; // 1MB limit
265
+
266
+ if (proc.stderr) {
267
+ const reader = proc.stderr.getReader();
268
+ const decoder = new TextDecoder();
269
+ try {
270
+ while (true) {
271
+ const { done, value } = await reader.read();
272
+ if (done) break;
273
+ const text = decoder.decode(value, { stream: true });
274
+ if (stderrSize + text.length <= maxStderrSize) {
275
+ stderrChunks.push(text);
276
+ stderrSize += text.length;
277
+ }
278
+ }
279
+ } finally {
280
+ reader.releaseLock();
281
+ }
282
+ }
283
+
284
+ if (stderrChunks.length > 0) {
285
+ yield { type: "stderr", data: stderrChunks.join("") };
286
+ }
287
+
288
+ // Wait for process to complete
289
+ await proc.exited;
290
+ }
291
+
292
+ /**
293
+ * Default runOnce implementation.
294
+ * Subclasses can override for custom behavior.
295
+ */
296
+ async runOnce(
297
+ req: CodingRequest,
298
+ opts: ProviderInvokeOptions,
299
+ ): Promise<{ text: string; usage?: TokenUsage }> {
300
+ const args = this.buildArgs(req, opts);
301
+ const input = this.getStdinInput(req);
302
+ const result = await this.executeProcess(args, input, opts);
303
+
304
+ if (result.exitCode !== 0) {
305
+ throw new Error(`${this.displayName} failed with code ${result.exitCode}: ${result.stderr}`);
306
+ }
307
+
308
+ return this.parseOutput(result.stdout);
309
+ }
310
+
311
+ /**
312
+ * Default runStream implementation.
313
+ * Subclasses can override for custom behavior.
314
+ */
315
+ async *runStream(req: CodingRequest, opts: ProviderInvokeOptions): AsyncGenerator<CodingEvent> {
316
+ const requestId = crypto.randomUUID();
317
+ yield { type: "start", provider: this.id, requestId };
318
+
319
+ const args = this.buildArgs(req, opts);
320
+ const input = this.getStdinInput(req);
321
+ const fullTextChunks: string[] = [];
322
+
323
+ for await (const chunk of this.streamProcess(args, input, opts)) {
324
+ if (chunk.type === "stdout") {
325
+ const text = chunk.data;
326
+ fullTextChunks.push(text);
327
+
328
+ if (this.config.streamingMode === "jsonl") {
329
+ // Try to parse as JSONL
330
+ for (const line of text.split("\n").filter(Boolean)) {
331
+ try {
332
+ const parsed = JSON.parse(line);
333
+ yield { type: "chunk", data: parsed };
334
+ } catch {
335
+ yield { type: "text_delta", text: line };
336
+ }
337
+ }
338
+ } else {
339
+ yield { type: "text_delta", text };
340
+ }
341
+ } else if (chunk.type === "stderr") {
342
+ yield {
343
+ type: "error",
344
+ provider: this.id,
345
+ code: this.classifyError({ stderr: chunk.data }),
346
+ message: chunk.data,
347
+ };
348
+ }
349
+ }
350
+
351
+ const fullText = fullTextChunks.join("");
352
+ const result = this.parseOutput(fullText);
353
+ yield { type: "complete", provider: this.id, text: result.text, usage: result.usage };
354
+ }
355
+
356
+ /**
357
+ * Default error classification using shared classifyErrorDefault.
358
+ * Subclasses can override for provider-specific patterns.
359
+ */
360
+ classifyError(error: ProviderErrorContext): ProviderErrorKind {
361
+ return classifyErrorDefault(error.stderr, error.stdout, error.exitCode, error.httpStatus);
362
+ }
363
+
364
+ /**
365
+ * Get stdin input for the process.
366
+ * Override in subclass if needed.
367
+ */
368
+ protected getStdinInput(req: CodingRequest): string | undefined {
369
+ // Default: pipe prompt to stdin
370
+ return req.prompt;
371
+ }
372
+
373
+ /**
374
+ * Parse output from the process.
375
+ * Override in subclass if needed.
376
+ */
377
+ protected parseOutput(stdout: string): { text: string; usage?: TokenUsage } {
378
+ if (this.config.jsonMode !== "none") {
379
+ return this.parseJsonOutput(stdout);
380
+ }
381
+ return { text: stdout.trim() };
382
+ }
383
+ }