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.
- package/bin/triflux.mjs +5 -0
- package/hooks/safety-guard.mjs +1 -1
- package/hub/assign-callbacks.mjs +13 -16
- package/hub/hitl.mjs +6 -3
- package/hub/intent.mjs +2 -7
- package/hub/lib/process-utils.mjs +5 -4
- package/hub/pipe.mjs +4 -7
- package/hub/platform.mjs +186 -0
- package/hub/router.mjs +791 -791
- package/hub/server.mjs +1112 -1000
- package/hub/state.mjs +245 -0
- package/hub/store-adapter.mjs +614 -0
- package/hub/store.mjs +820 -807
- package/hub/team/headless.mjs +298 -66
- package/hub/team/nativeProxy.mjs +2 -1
- package/hub/team/psmux.mjs +3 -3
- package/hub/tray.mjs +8 -7
- package/hub/workers/claude-worker.mjs +89 -37
- package/hub/workers/codex-mcp.mjs +123 -29
- package/hub/workers/gemini-worker.mjs +81 -137
- package/hub/workers/interface.mjs +12 -0
- package/hub/workers/worker-utils.mjs +78 -0
- package/package.json +7 -1
- package/scripts/headless-guard.mjs +7 -1
- package/scripts/setup.mjs +56 -745
- package/scripts/tfx-route.sh +2 -2
- package/scripts/tmp-cleanup.mjs +34 -5
|
@@ -3,35 +3,25 @@
|
|
|
3
3
|
|
|
4
4
|
import { spawn } from 'node:child_process';
|
|
5
5
|
import readline from 'node:readline';
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
73
|
-
const
|
|
74
|
-
|
|
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
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
128
|
-
return
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
134
|
-
const
|
|
80
|
+
function detectGeminiCategory(error) {
|
|
81
|
+
const combined = `${error?.message || ''}\n${error?.stderr || ''}`.toLowerCase();
|
|
135
82
|
|
|
136
|
-
if (
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
const commandLine = [toBashPath(resolvedCommand), ...args]
|
|
151
|
-
.map((part) => quotePosixShellArg(part))
|
|
152
|
-
.join(' ');
|
|
93
|
+
return 'transient';
|
|
94
|
+
}
|
|
153
95
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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.
|
|
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(
|
|
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.
|
|
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
|
|
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:
|
|
364
|
-
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
|
|
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
|
}
|