triflux 9.8.2 → 9.8.3

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.
@@ -3,35 +3,25 @@
3
3
 
4
4
  import { spawn } from 'node:child_process';
5
5
  import readline from 'node:readline';
6
- import { toStringList, safeJsonParse, createWorkerError, DEFAULT_TIMEOUT_MS, DEFAULT_KILL_GRACE_MS } from './worker-utils.mjs';
7
-
8
- function appendTextFragments(value, parts) {
9
- if (value == null) return;
10
- if (typeof value === 'string') {
11
- const trimmed = value.trim();
12
- if (trimmed) parts.push(trimmed);
13
- return;
14
- }
15
- if (Array.isArray(value)) {
16
- for (const item of value) appendTextFragments(item, parts);
17
- return;
18
- }
19
- if (typeof value === 'object') {
20
- if (typeof value.text === 'string') appendTextFragments(value.text, parts);
21
- if (typeof value.result === 'string') appendTextFragments(value.result, parts);
22
- if (typeof value.content === 'string' || Array.isArray(value.content) || value.content) {
23
- appendTextFragments(value.content, parts);
24
- }
25
- if (typeof value.message === 'string' || Array.isArray(value.message) || value.message) {
26
- appendTextFragments(value.message, parts);
27
- }
28
- }
6
+
7
+ import { extractText, terminateChild, withRetry } from './worker-utils.mjs';
8
+
9
+ const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
10
+ const DEFAULT_KILL_GRACE_MS = 1000;
11
+
12
+ function toStringList(value) {
13
+ if (!Array.isArray(value)) return [];
14
+ return value
15
+ .map((item) => String(item ?? '').trim())
16
+ .filter(Boolean);
29
17
  }
30
18
 
31
- function extractText(event) {
32
- const parts = [];
33
- appendTextFragments(event, parts);
34
- return parts.join('\n').trim();
19
+ function safeJsonParse(line) {
20
+ try {
21
+ return JSON.parse(line);
22
+ } catch {
23
+ return null;
24
+ }
35
25
  }
36
26
 
37
27
  function findSessionId(event) {
@@ -42,6 +32,62 @@ function findSessionId(event) {
42
32
  || null;
43
33
  }
44
34
 
35
+ function createWorkerError(message, details = {}) {
36
+ const error = new Error(message);
37
+ Object.assign(error, details);
38
+ return error;
39
+ }
40
+
41
+ function normalizeRetryOptions(retryOptions) {
42
+ if (!retryOptions || typeof retryOptions !== 'object') {
43
+ return Object.freeze({});
44
+ }
45
+ return Object.freeze({ ...retryOptions });
46
+ }
47
+
48
+ function isClaudeRetryable(error) {
49
+ return error?.code === 'WORKER_EXIT'
50
+ || error?.code === 'ETIMEDOUT'
51
+ || error?.code === 'WORKER_STDIN_CLOSED';
52
+ }
53
+
54
+ function detectClaudeCategory(error) {
55
+ const combined = `${error?.message || ''}\n${error?.stderr || ''}`.toLowerCase();
56
+
57
+ if (/(unauthorized|forbidden|auth|login|token|credential|apikey|api key)/.test(combined)) {
58
+ return 'auth';
59
+ }
60
+ if (/unknown option|invalid option|config|permission-mode|mcp-config/.test(combined)) {
61
+ return 'config';
62
+ }
63
+ if (/stdin|prompt|input/.test(combined) && error?.code !== 'WORKER_STDIN_CLOSED') {
64
+ return 'input';
65
+ }
66
+
67
+ return 'transient';
68
+ }
69
+
70
+ function buildClaudeErrorInfo(error, attempts) {
71
+ const category = detectClaudeCategory(error);
72
+ let recovery = 'Restart the Claude worker session and retry the turn.';
73
+
74
+ if (category === 'auth') {
75
+ recovery = 'Refresh the Claude authentication state and retry.';
76
+ } else if (category === 'config') {
77
+ recovery = 'Check the Claude CLI flags, MCP configuration, and permission settings.';
78
+ } else if (category === 'input') {
79
+ recovery = 'Check the Claude request payload before retrying.';
80
+ }
81
+
82
+ return Object.freeze({
83
+ code: error?.code || 'CLAUDE_EXECUTION_ERROR',
84
+ retryable: isClaudeRetryable(error),
85
+ attempts,
86
+ category,
87
+ recovery,
88
+ });
89
+ }
90
+
45
91
  function buildClaudeArgs(worker, options) {
46
92
  const args = [...worker.commandArgs];
47
93
 
@@ -88,6 +134,7 @@ export class ClaudeWorker {
88
134
  this.extraArgs = toStringList(options.extraArgs);
89
135
  this.timeoutMs = Number(options.timeoutMs) > 0 ? Number(options.timeoutMs) : DEFAULT_TIMEOUT_MS;
90
136
  this.killGraceMs = Number(options.killGraceMs) > 0 ? Number(options.killGraceMs) : DEFAULT_KILL_GRACE_MS;
137
+ this.retryOptions = normalizeRetryOptions(options.retryOptions);
91
138
  this.onEvent = typeof options.onEvent === 'function' ? options.onEvent : null;
92
139
  this.controlRequestHandler = typeof options.controlRequestHandler === 'function'
93
140
  ? options.controlRequestHandler
@@ -126,15 +173,7 @@ export class ClaudeWorker {
126
173
  }
127
174
 
128
175
  _terminateChild(child) {
129
- if (!child || child.exitCode !== null || child.killed) return;
130
- try { child.stdin.end(); } catch {}
131
- try { child.kill(); } catch {}
132
- const timer = setTimeout(() => {
133
- if (child.exitCode === null) {
134
- try { child.kill('SIGKILL'); } catch {}
135
- }
136
- }, this.killGraceMs);
137
- timer.unref?.();
176
+ terminateChild(child, this.killGraceMs);
138
177
  }
139
178
 
140
179
  async _handleControlRequest(event) {
@@ -401,8 +440,20 @@ export class ClaudeWorker {
401
440
  }
402
441
 
403
442
  async execute(prompt, options = {}) {
443
+ let attempts = 0;
444
+
404
445
  try {
405
- const result = await this.run(prompt, options);
446
+ const result = await withRetry(async () => {
447
+ attempts += 1;
448
+ if (attempts > 1) {
449
+ await this.restart();
450
+ }
451
+ return this.run(prompt, options);
452
+ }, {
453
+ ...this.retryOptions,
454
+ shouldRetry: (error) => isClaudeRetryable(error),
455
+ });
456
+
406
457
  return {
407
458
  output: result.response,
408
459
  exitCode: 0,
@@ -416,6 +467,7 @@ export class ClaudeWorker {
416
467
  exitCode: error.code === 'ETIMEDOUT' ? 124 : 1,
417
468
  threadId: null,
418
469
  sessionKey: options.sessionKey || this.sessionId || null,
470
+ error: buildClaudeErrorInfo(error, attempts || 1),
419
471
  raw: null,
420
472
  };
421
473
  }
@@ -5,6 +5,8 @@ import { fileURLToPath } from 'node:url';
5
5
  import { Client } from '@modelcontextprotocol/sdk/client/index.js';
6
6
  import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
7
7
 
8
+ import { withRetry } from './worker-utils.mjs';
9
+
8
10
  const REQUIRED_TOOLS = ['codex', 'codex-reply'];
9
11
 
10
12
  export const CODEX_MCP_TRANSPORT_EXIT_CODE = 70;
@@ -104,6 +106,60 @@ function withTimeout(promise, timeoutMs, message) {
104
106
  });
105
107
  }
106
108
 
109
+ function normalizeRetryOptions(retryOptions) {
110
+ if (!retryOptions || typeof retryOptions !== 'object') {
111
+ return Object.freeze({});
112
+ }
113
+ return Object.freeze({ ...retryOptions });
114
+ }
115
+
116
+ function isCodexRetryable(error) {
117
+ return error instanceof CodexMcpTransportError
118
+ || error?.code === 'ETIMEDOUT'
119
+ || error?.cause?.code === 'ETIMEDOUT';
120
+ }
121
+
122
+ function detectWorkerCategory(error, fallbackCategory = 'transient') {
123
+ const combined = `${error?.message || ''}\n${error?.stderr || ''}`.toLowerCase();
124
+
125
+ if (error?.code === 'INVALID_INPUT') return 'input';
126
+ if (/(unauthorized|forbidden|auth|login|token|credential|apikey|api key)/i.test(combined)) {
127
+ return 'auth';
128
+ }
129
+ if (/(config|unknown option|invalid option|missing|필수 mcp 도구 누락)/i.test(combined)) {
130
+ return 'config';
131
+ }
132
+
133
+ return fallbackCategory;
134
+ }
135
+
136
+ function buildCodexErrorInfo(error, attempts) {
137
+ const retryable = isCodexRetryable(error);
138
+ const code = error instanceof CodexMcpTransportError
139
+ ? 'CODEX_TRANSPORT_ERROR'
140
+ : (error?.code || 'CODEX_EXECUTION_ERROR');
141
+ const category = detectWorkerCategory(error, retryable ? 'transient' : 'config');
142
+
143
+ let recovery = 'Review the Codex worker error output and retry after correcting the issue.';
144
+ if (code === 'INVALID_INPUT') {
145
+ recovery = 'Provide a non-empty prompt before invoking the Codex worker.';
146
+ } else if (retryable) {
147
+ recovery = 'Retry after reconnecting the Codex MCP transport.';
148
+ } else if (category === 'auth') {
149
+ recovery = 'Refresh the Codex authentication state and retry.';
150
+ } else if (category === 'config') {
151
+ recovery = 'Check the Codex MCP configuration and available tools.';
152
+ }
153
+
154
+ return Object.freeze({
155
+ code,
156
+ retryable,
157
+ attempts,
158
+ category,
159
+ recovery,
160
+ });
161
+ }
162
+
107
163
  /**
108
164
  * Codex MCP 워커
109
165
  */
@@ -130,6 +186,7 @@ export class CodexMcpWorker {
130
186
  this.bootstrapTimeoutMs = Number.isFinite(options.bootstrapTimeoutMs)
131
187
  ? options.bootstrapTimeoutMs
132
188
  : DEFAULT_CODEX_MCP_BOOTSTRAP_TIMEOUT_MS;
189
+ this.retryOptions = normalizeRetryOptions(options.retryOptions);
133
190
 
134
191
  this.client = null;
135
192
  this.transport = null;
@@ -235,12 +292,11 @@ export class CodexMcpWorker {
235
292
  exitCode: CODEX_MCP_EXECUTION_EXIT_CODE,
236
293
  threadId: null,
237
294
  sessionKey: opts.sessionKey || null,
295
+ error: buildCodexErrorInfo({ code: 'INVALID_INPUT', message: 'prompt는 비어 있을 수 없습니다.' }, 0),
238
296
  raw: null,
239
297
  };
240
298
  }
241
299
 
242
- await this.start();
243
-
244
300
  const sessionKey = typeof opts.sessionKey === 'string' && opts.sessionKey
245
301
  ? opts.sessionKey
246
302
  : null;
@@ -252,46 +308,84 @@ export class CodexMcpWorker {
252
308
  const threadId = typeof opts.threadId === 'string' && opts.threadId
253
309
  ? opts.threadId
254
310
  : (sessionKey ? this.getThreadId(sessionKey) : null);
311
+ const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : DEFAULT_CODEX_MCP_TIMEOUT_MS;
312
+ let attempts = 0;
313
+ let activeThreadId = threadId;
255
314
 
256
- const toolName = pickToolName(threadId);
257
- const toolArguments = toolName === 'codex-reply'
258
- ? { prompt, threadId }
259
- : buildCodexArguments(prompt, opts);
260
-
261
- let rawResult;
262
315
  try {
263
- rawResult = await this.client.callTool(
264
- { name: toolName, arguments: toolArguments },
265
- undefined,
266
- { timeout: Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : DEFAULT_CODEX_MCP_TIMEOUT_MS },
267
- );
316
+ const { rawResult, normalized } = await withRetry(async () => {
317
+ attempts += 1;
318
+ if (attempts === 1) {
319
+ await this.start();
320
+ } else {
321
+ await this.stop();
322
+ await this.start();
323
+ }
324
+
325
+ const toolName = pickToolName(activeThreadId);
326
+ const toolArguments = toolName === 'codex-reply'
327
+ ? { prompt, threadId: activeThreadId }
328
+ : buildCodexArguments(prompt, opts);
329
+
330
+ const nextRawResult = await this.client.callTool(
331
+ { name: toolName, arguments: toolArguments },
332
+ undefined,
333
+ { timeout: timeoutMs },
334
+ );
335
+
336
+ const textContent = collectTextContent(nextRawResult.content);
337
+ const nextNormalized = normalizeStructuredContent(nextRawResult.structuredContent, textContent);
338
+ activeThreadId = nextNormalized.threadId || activeThreadId;
339
+
340
+ return { rawResult: nextRawResult, normalized: nextNormalized };
341
+ }, {
342
+ ...this.retryOptions,
343
+ shouldRetry: (error) => isCodexRetryable(error),
344
+ });
345
+
346
+ if (sessionKey && normalized.threadId) {
347
+ this.setThreadId(sessionKey, normalized.threadId);
348
+ }
349
+
350
+ if (rawResult.isError) {
351
+ return {
352
+ output: normalized.content,
353
+ exitCode: CODEX_MCP_EXECUTION_EXIT_CODE,
354
+ threadId: normalized.threadId,
355
+ sessionKey,
356
+ error: buildCodexErrorInfo(
357
+ { code: 'CODEX_TOOL_ERROR', message: normalized.content },
358
+ attempts,
359
+ ),
360
+ raw: rawResult,
361
+ };
362
+ }
363
+
364
+ return {
365
+ output: normalized.content,
366
+ exitCode: 0,
367
+ threadId: normalized.threadId,
368
+ sessionKey,
369
+ raw: rawResult,
370
+ };
268
371
  } catch (error) {
372
+ await this.stop().catch(() => {});
269
373
  return {
270
374
  output: error instanceof Error ? error.message : String(error),
271
375
  exitCode: CODEX_MCP_EXECUTION_EXIT_CODE,
272
- threadId,
376
+ threadId: activeThreadId,
273
377
  sessionKey,
378
+ error: buildCodexErrorInfo(error, attempts || 1),
274
379
  raw: null,
275
380
  };
276
381
  }
277
-
278
- const textContent = collectTextContent(rawResult.content);
279
- const normalized = normalizeStructuredContent(rawResult.structuredContent, textContent);
280
-
281
- if (sessionKey && normalized.threadId) {
282
- this.setThreadId(sessionKey, normalized.threadId);
283
- }
284
-
285
- return {
286
- output: normalized.content,
287
- exitCode: rawResult.isError ? CODEX_MCP_EXECUTION_EXIT_CODE : 0,
288
- threadId: normalized.threadId,
289
- sessionKey,
290
- raw: rawResult,
291
- };
292
382
  }
293
383
  }
294
384
 
385
+ export function createCodexMcpWorker(options = {}) {
386
+ return new CodexMcpWorker(options);
387
+ }
388
+
295
389
  function parseCliArgs(argv) {
296
390
  const options = {
297
391
  command: process.env.CODEX_BIN || 'codex',
@@ -2,37 +2,26 @@
2
2
  // ADR-006: --output-format stream-json 기반 단발 실행 워커.
3
3
 
4
4
  import { spawn } from 'node:child_process';
5
- import { existsSync } from 'node:fs';
6
- import { delimiter, extname, join } from 'node:path';
7
5
  import readline from 'node:readline';
8
- import { toStringList, safeJsonParse, createWorkerError, DEFAULT_TIMEOUT_MS, DEFAULT_KILL_GRACE_MS } from './worker-utils.mjs';
9
-
10
- function appendTextFragments(value, parts) {
11
- if (value == null) return;
12
- if (typeof value === 'string') {
13
- const trimmed = value.trim();
14
- if (trimmed) parts.push(trimmed);
15
- return;
16
- }
17
- if (Array.isArray(value)) {
18
- for (const item of value) appendTextFragments(item, parts);
19
- return;
20
- }
21
- if (typeof value === 'object') {
22
- if (typeof value.text === 'string') appendTextFragments(value.text, parts);
23
- if (typeof value.response === 'string') appendTextFragments(value.response, parts);
24
- if (typeof value.result === 'string') appendTextFragments(value.result, parts);
25
- if (typeof value.content === 'string' || Array.isArray(value.content) || value.content) {
26
- appendTextFragments(value.content, parts);
27
- }
28
- if (value.message) appendTextFragments(value.message, parts);
29
- }
6
+
7
+ import { extractText, terminateChild, withRetry } from './worker-utils.mjs';
8
+
9
+ const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
10
+ const DEFAULT_KILL_GRACE_MS = 1000;
11
+
12
+ function toStringList(value) {
13
+ if (!Array.isArray(value)) return [];
14
+ return value
15
+ .map((item) => String(item ?? '').trim())
16
+ .filter(Boolean);
30
17
  }
31
18
 
32
- function extractText(event) {
33
- const parts = [];
34
- appendTextFragments(event, parts);
35
- return parts.join('\n').trim();
19
+ function safeJsonParse(line) {
20
+ try {
21
+ return JSON.parse(line);
22
+ } catch {
23
+ return null;
24
+ }
36
25
  }
37
26
 
38
27
  function findLastEvent(events, predicate) {
@@ -69,100 +58,61 @@ function buildGeminiArgs(options) {
69
58
  return args;
70
59
  }
71
60
 
72
- function resolveSpawnCommand(command, env = process.env) {
73
- const raw = String(command ?? '').trim();
74
- if (!raw || process.platform !== 'win32') return raw;
75
-
76
- const pathExts = (env.PATHEXT || process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD')
77
- .split(';')
78
- .map((ext) => ext.trim().toLowerCase())
79
- .filter(Boolean);
80
- const extensions = extname(raw)
81
- ? ['']
82
- : [...new Set(['.cmd', '.exe', '.bat', ...pathExts, ''])];
83
-
84
- const tryResolve = (base) => {
85
- for (const ext of extensions) {
86
- const candidate = `${base}${ext}`;
87
- if (existsSync(candidate)) return candidate;
88
- }
89
- return null;
90
- };
91
-
92
- if (raw.includes('\\') || raw.includes('/')) {
93
- return tryResolve(raw.replaceAll('/', '\\')) || raw;
94
- }
95
-
96
- const pathEntries = String(env.PATH || process.env.PATH || '')
97
- .split(delimiter)
98
- .map((entry) => entry.trim())
99
- .filter(Boolean);
100
-
101
- for (const entry of pathEntries) {
102
- const resolved = tryResolve(join(entry, raw));
103
- if (resolved) return resolved;
104
- }
105
-
106
- return raw;
61
+ function createWorkerError(message, details = {}) {
62
+ const error = new Error(message);
63
+ Object.assign(error, details);
64
+ return error;
107
65
  }
108
66
 
109
- function quoteWindowsCmdArg(value) {
110
- const raw = String(value ?? '');
111
- if (raw.length === 0) return '""';
112
-
113
- const escaped = raw
114
- .replace(/(\\*)"/g, '$1$1\\"')
115
- .replace(/(\\+)$/g, '$1$1');
116
-
117
- return /[\s"&()<>^|]/.test(raw)
118
- ? `"${escaped}"`
119
- : escaped;
120
- }
121
-
122
- function quotePosixShellArg(value) {
123
- const raw = String(value ?? '');
124
- return `'${raw.replaceAll("'", `'\"'\"'`)}'`;
67
+ function normalizeRetryOptions(retryOptions) {
68
+ if (!retryOptions || typeof retryOptions !== 'object') {
69
+ return Object.freeze({});
70
+ }
71
+ return Object.freeze({ ...retryOptions });
125
72
  }
126
73
 
127
- function toBashPath(value) {
128
- return String(value ?? '')
129
- .replace(/^([A-Za-z]):/, (_, drive) => `/${drive.toLowerCase()}`)
130
- .replaceAll('\\', '/');
74
+ function isGeminiRetryable(error) {
75
+ return error?.code === 'WORKER_EXIT'
76
+ && error?.result?.exitCode !== 0
77
+ && error?.result?.exitCode !== 2;
131
78
  }
132
79
 
133
- function buildSpawnSpec(command, args, env = process.env) {
134
- const resolvedCommand = resolveSpawnCommand(command, env);
80
+ function detectGeminiCategory(error) {
81
+ const combined = `${error?.message || ''}\n${error?.stderr || ''}`.toLowerCase();
135
82
 
136
- if (process.platform === 'win32' && /\.(cmd|bat)$/i.test(resolvedCommand)) {
137
- const commandLine = [resolvedCommand, ...args]
138
- .map((part) => quoteWindowsCmdArg(part))
139
- .join(' ');
140
-
141
- return {
142
- command: 'cmd.exe',
143
- args: ['/d', '/s', '/c', commandLine],
144
- resolvedCommand,
145
- };
83
+ if (/(unauthorized|forbidden|auth|login|token|credential|apikey|api key)/.test(combined)) {
84
+ return 'auth';
85
+ }
86
+ if (error?.result?.exitCode === 2 || /expected stream-json|unknown option|invalid option|config/.test(combined)) {
87
+ return 'config';
88
+ }
89
+ if (error?.code === 'WORKER_EVENT_ERROR') {
90
+ return 'input';
146
91
  }
147
92
 
148
- if (process.platform === 'win32' && !extname(resolvedCommand) && existsSync(resolvedCommand)) {
149
- const bashCommand = env.TFX_BASH_BIN || env.BASH || 'bash';
150
- const commandLine = [toBashPath(resolvedCommand), ...args]
151
- .map((part) => quotePosixShellArg(part))
152
- .join(' ');
93
+ return 'transient';
94
+ }
153
95
 
154
- return {
155
- command: bashCommand,
156
- args: ['-lc', commandLine],
157
- resolvedCommand,
158
- };
96
+ function buildGeminiErrorInfo(error, attempts) {
97
+ const category = detectGeminiCategory(error);
98
+ const retryable = isGeminiRetryable(error);
99
+ let recovery = 'Retry the Gemini worker after correcting the reported issue.';
100
+
101
+ if (category === 'auth') {
102
+ recovery = 'Refresh the Gemini authentication state and retry.';
103
+ } else if (category === 'config') {
104
+ recovery = 'Check the Gemini CLI flags and worker configuration.';
105
+ } else if (category === 'input') {
106
+ recovery = 'Check the Gemini request payload and streamed event format.';
159
107
  }
160
108
 
161
- return {
162
- command: resolvedCommand,
163
- args,
164
- resolvedCommand,
165
- };
109
+ return Object.freeze({
110
+ code: error?.code || 'GEMINI_EXECUTION_ERROR',
111
+ retryable,
112
+ attempts,
113
+ category,
114
+ recovery,
115
+ });
166
116
  }
167
117
 
168
118
  /**
@@ -183,6 +133,7 @@ export class GeminiWorker {
183
133
  this.extraArgs = toStringList(options.extraArgs);
184
134
  this.timeoutMs = Number(options.timeoutMs) > 0 ? Number(options.timeoutMs) : DEFAULT_TIMEOUT_MS;
185
135
  this.killGraceMs = Number(options.killGraceMs) > 0 ? Number(options.killGraceMs) : DEFAULT_KILL_GRACE_MS;
136
+ this.retryOptions = normalizeRetryOptions(options.retryOptions);
186
137
  this.onEvent = typeof options.onEvent === 'function' ? options.onEvent : null;
187
138
 
188
139
  this.state = 'idle';
@@ -213,7 +164,7 @@ export class GeminiWorker {
213
164
  return this.getStatus();
214
165
  }
215
166
  const child = this.child;
216
- this._terminateChild(child);
167
+ terminateChild(child, this.killGraceMs);
217
168
  await new Promise((resolve) => {
218
169
  child.once('close', resolve);
219
170
  setTimeout(resolve, this.killGraceMs + 50).unref?.();
@@ -229,19 +180,6 @@ export class GeminiWorker {
229
180
  return this.getStatus();
230
181
  }
231
182
 
232
- _terminateChild(child) {
233
- if (!child || child.exitCode !== null || child.killed) return;
234
- try { child.stdin.end(); } catch {}
235
- try { child.kill(); } catch {}
236
-
237
- const timer = setTimeout(() => {
238
- if (child.exitCode === null) {
239
- try { child.kill('SIGKILL'); } catch {}
240
- }
241
- }, this.killGraceMs);
242
- timer.unref?.();
243
- }
244
-
245
183
  async run(prompt, options = {}) {
246
184
  if (this.child) {
247
185
  throw createWorkerError('GeminiWorker is already running', { code: 'WORKER_BUSY' });
@@ -262,14 +200,13 @@ export class GeminiWorker {
262
200
  promptArgument: options.promptArgument ?? '',
263
201
  }),
264
202
  ];
265
- const env = { ...this.env, ...(options.env || {}) };
266
- const spawnSpec = buildSpawnSpec(this.command, args, env);
267
203
 
268
- const child = spawn(spawnSpec.command, spawnSpec.args, {
204
+ const child = spawn(this.command, args, {
269
205
  cwd: options.cwd || this.cwd,
270
- env,
206
+ env: { ...this.env, ...(options.env || {}) },
271
207
  stdio: ['pipe', 'pipe', 'pipe'],
272
208
  windowsHide: true,
209
+ shell: process.platform === 'win32',
273
210
  });
274
211
 
275
212
  this.child = child;
@@ -323,7 +260,7 @@ export class GeminiWorker {
323
260
 
324
261
  const timeout = setTimeout(() => {
325
262
  timedOut = true;
326
- this._terminateChild(child);
263
+ terminateChild(child, this.killGraceMs);
327
264
  }, timeoutMs);
328
265
  timeout.unref?.();
329
266
 
@@ -346,13 +283,10 @@ export class GeminiWorker {
346
283
  const response = [
347
284
  extractText(resultEvent),
348
285
  ...events
349
- .filter((event) => (
350
- event?.type === 'assistant'
351
- || (event?.type === 'message' && event?.role === 'assistant')
352
- ))
286
+ .filter((event) => event?.type === 'message' || event?.type === 'assistant')
353
287
  .map((event) => extractText(event))
354
288
  .filter(Boolean),
355
- ...stdoutLines.filter((line) => line.trim() !== '""'),
289
+ ...stdoutLines,
356
290
  ]
357
291
  .filter(Boolean)
358
292
  .join('\n')
@@ -360,8 +294,8 @@ export class GeminiWorker {
360
294
 
361
295
  const result = {
362
296
  type: 'gemini',
363
- command: spawnSpec.resolvedCommand,
364
- args: spawnSpec.args,
297
+ command: this.command,
298
+ args,
365
299
  response,
366
300
  events,
367
301
  resultEvent,
@@ -409,8 +343,17 @@ export class GeminiWorker {
409
343
  }
410
344
 
411
345
  async execute(prompt, options = {}) {
346
+ let attempts = 0;
347
+
412
348
  try {
413
- const result = await this.run(prompt, options);
349
+ const result = await withRetry(async () => {
350
+ attempts += 1;
351
+ return this.run(prompt, options);
352
+ }, {
353
+ ...this.retryOptions,
354
+ shouldRetry: (error) => isGeminiRetryable(error),
355
+ });
356
+
414
357
  return {
415
358
  output: result.response,
416
359
  exitCode: 0,
@@ -422,6 +365,7 @@ export class GeminiWorker {
422
365
  output: error.stderr || error.message || 'Gemini worker failed',
423
366
  exitCode: error.code === 'ETIMEDOUT' ? 124 : 1,
424
367
  sessionKey: options.sessionKey || null,
368
+ error: buildGeminiErrorInfo(error, attempts || 1),
425
369
  raw: error.result || null,
426
370
  };
427
371
  }