triflux 9.7.13 → 9.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.ko.md +2 -0
- package/README.md +2 -0
- package/bin/triflux.mjs +297 -47
- package/hooks/hook-registry.json +4 -4
- package/hub/fullcycle.mjs +96 -0
- package/hub/paths.mjs +30 -28
- package/hub/pipeline/index.mjs +318 -318
- package/hub/schema.sql +146 -146
- package/hub/team/cli/commands/kill.mjs +37 -37
- package/hub/team/cli/commands/stop.mjs +31 -31
- package/hub/team/cli/commands/task.mjs +30 -30
- package/hub/team/cli/services/hub-client.mjs +208 -208
- package/hub/team/cli/services/native-control.mjs +118 -118
- package/hub/team/cli/services/runtime-mode.mjs +62 -62
- package/hub/team/cli/services/state-store.mjs +48 -48
- package/hub/team/dashboard.mjs +274 -274
- package/hub/team/native.mjs +649 -649
- package/hub/team/psmux.mjs +68 -13
- package/hub/tools.mjs +554 -554
- package/hub/workers/claude-worker.mjs +423 -423
- package/hub/workers/codex-mcp.mjs +410 -410
- package/hub/workers/gemini-worker.mjs +429 -429
- package/hub/workers/interface.mjs +40 -40
- package/package.json +1 -1
- package/scripts/__tests__/remote-spawn-transfer.test.mjs +1 -1
- package/scripts/cache-warmup.mjs +1 -0
- package/scripts/claude-logged.ps1 +54 -0
- package/scripts/demo-tui.mjs +59 -0
- package/scripts/headless-guard.mjs +4 -7
- package/scripts/hub-ensure.mjs +120 -120
- package/scripts/lib/psmux-info.mjs +119 -0
- package/scripts/lib/remote-spawn-transfer.mjs +1 -1
- package/scripts/setup.mjs +150 -6
- package/scripts/tfx-route-post.mjs +90 -13
- package/scripts/token-snapshot.mjs +575 -575
- package/skills/.omc/state/agent-replay-8f0e10a9-9693-4410-96f5-a6b07e8ed995.jsonl +1 -0
- package/skills/.omc/state/idle-notif-cooldown.json +3 -0
- package/skills/.omc/state/last-tool-error.json +7 -0
- package/skills/.omc/state/subagent-tracking.json +7 -0
- package/skills/tfx-codex-swarm/SKILL.md +40 -5
- package/skills/tfx-codex-swarm/mcp-daemon/register-autostart.ps1 +32 -0
- package/skills/tfx-doctor/SKILL.md +3 -0
- package/skills/tfx-fullcycle/SKILL.md +79 -4
- package/skills/tfx-hub/SKILL.md +3 -1
- package/skills/tfx-psmux-rules/SKILL.md +53 -31
- package/skills/tfx-remote-spawn/references/hosts.json +16 -16
- package/skills/tfx-setup/SKILL.md +9 -0
- package/tui/doctor.mjs +1 -0
|
@@ -1,429 +1,429 @@
|
|
|
1
|
-
// hub/workers/gemini-worker.mjs — Gemini headless subprocess 래퍼
|
|
2
|
-
// ADR-006: --output-format stream-json 기반 단발 실행 워커.
|
|
3
|
-
|
|
4
|
-
import { spawn } from 'node:child_process';
|
|
5
|
-
import { existsSync } from 'node:fs';
|
|
6
|
-
import { delimiter, extname, join } from 'node:path';
|
|
7
|
-
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
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function extractText(event) {
|
|
33
|
-
const parts = [];
|
|
34
|
-
appendTextFragments(event, parts);
|
|
35
|
-
return parts.join('\n').trim();
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function findLastEvent(events, predicate) {
|
|
39
|
-
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
40
|
-
if (predicate(events[index])) return events[index];
|
|
41
|
-
}
|
|
42
|
-
return null;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function buildGeminiArgs(options) {
|
|
46
|
-
const args = [];
|
|
47
|
-
|
|
48
|
-
if (options.model) {
|
|
49
|
-
args.push('--model', options.model);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
if (options.approvalMode) {
|
|
53
|
-
args.push('--approval-mode', options.approvalMode);
|
|
54
|
-
} else if (options.yolo !== false) {
|
|
55
|
-
args.push('--yolo');
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const allowedMcpServers = toStringList(options.allowedMcpServerNames);
|
|
59
|
-
if (allowedMcpServers.length) {
|
|
60
|
-
args.push('--allowed-mcp-server-names', ...allowedMcpServers);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const extraArgs = toStringList(options.extraArgs);
|
|
64
|
-
if (extraArgs.length) args.push(...extraArgs);
|
|
65
|
-
|
|
66
|
-
args.push('--prompt', options.promptArgument ?? '');
|
|
67
|
-
args.push('--output-format', 'stream-json');
|
|
68
|
-
|
|
69
|
-
return args;
|
|
70
|
-
}
|
|
71
|
-
|
|
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;
|
|
107
|
-
}
|
|
108
|
-
|
|
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("'", `'\"'\"'`)}'`;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function toBashPath(value) {
|
|
128
|
-
return String(value ?? '')
|
|
129
|
-
.replace(/^([A-Za-z]):/, (_, drive) => `/${drive.toLowerCase()}`)
|
|
130
|
-
.replaceAll('\\', '/');
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function buildSpawnSpec(command, args, env = process.env) {
|
|
134
|
-
const resolvedCommand = resolveSpawnCommand(command, env);
|
|
135
|
-
|
|
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
|
-
};
|
|
146
|
-
}
|
|
147
|
-
|
|
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(' ');
|
|
153
|
-
|
|
154
|
-
return {
|
|
155
|
-
command: bashCommand,
|
|
156
|
-
args: ['-lc', commandLine],
|
|
157
|
-
resolvedCommand,
|
|
158
|
-
};
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
return {
|
|
162
|
-
command: resolvedCommand,
|
|
163
|
-
args,
|
|
164
|
-
resolvedCommand,
|
|
165
|
-
};
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* Gemini stream-json 래퍼
|
|
170
|
-
*/
|
|
171
|
-
export class GeminiWorker {
|
|
172
|
-
type = 'gemini';
|
|
173
|
-
|
|
174
|
-
constructor(options = {}) {
|
|
175
|
-
this.command = options.command || 'gemini';
|
|
176
|
-
this.commandArgs = toStringList(options.commandArgs || options.args);
|
|
177
|
-
this.cwd = options.cwd || process.cwd();
|
|
178
|
-
this.env = { ...process.env, ...(options.env || {}) };
|
|
179
|
-
this.model = options.model || null;
|
|
180
|
-
this.approvalMode = options.approvalMode || null;
|
|
181
|
-
this.yolo = options.yolo !== false;
|
|
182
|
-
this.allowedMcpServerNames = toStringList(options.allowedMcpServerNames);
|
|
183
|
-
this.extraArgs = toStringList(options.extraArgs);
|
|
184
|
-
this.timeoutMs = Number(options.timeoutMs) > 0 ? Number(options.timeoutMs) : DEFAULT_TIMEOUT_MS;
|
|
185
|
-
this.killGraceMs = Number(options.killGraceMs) > 0 ? Number(options.killGraceMs) : DEFAULT_KILL_GRACE_MS;
|
|
186
|
-
this.onEvent = typeof options.onEvent === 'function' ? options.onEvent : null;
|
|
187
|
-
|
|
188
|
-
this.state = 'idle';
|
|
189
|
-
this.child = null;
|
|
190
|
-
this.lastRun = null;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
getStatus() {
|
|
194
|
-
return {
|
|
195
|
-
type: 'gemini',
|
|
196
|
-
state: this.state,
|
|
197
|
-
pid: this.child?.pid || null,
|
|
198
|
-
last_run_at_ms: this.lastRun?.finishedAtMs || null,
|
|
199
|
-
last_exit_code: this.lastRun?.exitCode ?? null,
|
|
200
|
-
};
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
async start() {
|
|
204
|
-
if (this.state === 'stopped') {
|
|
205
|
-
this.state = 'idle';
|
|
206
|
-
}
|
|
207
|
-
return this.getStatus();
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
async stop() {
|
|
211
|
-
if (!this.child) {
|
|
212
|
-
this.state = 'stopped';
|
|
213
|
-
return this.getStatus();
|
|
214
|
-
}
|
|
215
|
-
const child = this.child;
|
|
216
|
-
this._terminateChild(child);
|
|
217
|
-
await new Promise((resolve) => {
|
|
218
|
-
child.once('close', resolve);
|
|
219
|
-
setTimeout(resolve, this.killGraceMs + 50).unref?.();
|
|
220
|
-
});
|
|
221
|
-
this.child = null;
|
|
222
|
-
this.state = 'stopped';
|
|
223
|
-
return this.getStatus();
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
async restart() {
|
|
227
|
-
await this.stop();
|
|
228
|
-
this.state = 'idle';
|
|
229
|
-
return this.getStatus();
|
|
230
|
-
}
|
|
231
|
-
|
|
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
|
-
async run(prompt, options = {}) {
|
|
246
|
-
if (this.child) {
|
|
247
|
-
throw createWorkerError('GeminiWorker is already running', { code: 'WORKER_BUSY' });
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
await this.start();
|
|
251
|
-
|
|
252
|
-
const timeoutMs = Number(options.timeoutMs) > 0 ? Number(options.timeoutMs) : this.timeoutMs;
|
|
253
|
-
const startedAtMs = Date.now();
|
|
254
|
-
const args = [
|
|
255
|
-
...this.commandArgs,
|
|
256
|
-
...buildGeminiArgs({
|
|
257
|
-
model: options.model || this.model,
|
|
258
|
-
approvalMode: options.approvalMode || this.approvalMode,
|
|
259
|
-
yolo: options.yolo ?? this.yolo,
|
|
260
|
-
allowedMcpServerNames: options.allowedMcpServerNames || this.allowedMcpServerNames,
|
|
261
|
-
extraArgs: options.extraArgs || this.extraArgs,
|
|
262
|
-
promptArgument: options.promptArgument ?? '',
|
|
263
|
-
}),
|
|
264
|
-
];
|
|
265
|
-
const env = { ...this.env, ...(options.env || {}) };
|
|
266
|
-
const spawnSpec = buildSpawnSpec(this.command, args, env);
|
|
267
|
-
|
|
268
|
-
const child = spawn(spawnSpec.command, spawnSpec.args, {
|
|
269
|
-
cwd: options.cwd || this.cwd,
|
|
270
|
-
env,
|
|
271
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
272
|
-
windowsHide: true,
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
this.child = child;
|
|
276
|
-
this.state = 'running';
|
|
277
|
-
|
|
278
|
-
const events = [];
|
|
279
|
-
const stdoutLines = [];
|
|
280
|
-
const stderrLines = [];
|
|
281
|
-
let lastErrorEvent = null;
|
|
282
|
-
let timedOut = false;
|
|
283
|
-
let exitCode = null;
|
|
284
|
-
let exitSignal = null;
|
|
285
|
-
|
|
286
|
-
const stdoutReader = readline.createInterface({
|
|
287
|
-
input: child.stdout,
|
|
288
|
-
crlfDelay: Infinity,
|
|
289
|
-
});
|
|
290
|
-
const stderrReader = readline.createInterface({
|
|
291
|
-
input: child.stderr,
|
|
292
|
-
crlfDelay: Infinity,
|
|
293
|
-
});
|
|
294
|
-
|
|
295
|
-
stdoutReader.on('line', (line) => {
|
|
296
|
-
if (!line) return;
|
|
297
|
-
const event = safeJsonParse(line);
|
|
298
|
-
if (!event) {
|
|
299
|
-
stdoutLines.push(line);
|
|
300
|
-
return;
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
events.push(event);
|
|
304
|
-
if (event.type === 'error') lastErrorEvent = event;
|
|
305
|
-
if (this.onEvent) {
|
|
306
|
-
try { this.onEvent(event); } catch {}
|
|
307
|
-
}
|
|
308
|
-
});
|
|
309
|
-
|
|
310
|
-
stderrReader.on('line', (line) => {
|
|
311
|
-
if (!line) return;
|
|
312
|
-
stderrLines.push(line);
|
|
313
|
-
});
|
|
314
|
-
|
|
315
|
-
const closePromise = new Promise((resolve, reject) => {
|
|
316
|
-
child.once('error', reject);
|
|
317
|
-
child.once('close', (code, signal) => {
|
|
318
|
-
exitCode = code;
|
|
319
|
-
exitSignal = signal;
|
|
320
|
-
resolve();
|
|
321
|
-
});
|
|
322
|
-
});
|
|
323
|
-
|
|
324
|
-
const timeout = setTimeout(() => {
|
|
325
|
-
timedOut = true;
|
|
326
|
-
this._terminateChild(child);
|
|
327
|
-
}, timeoutMs);
|
|
328
|
-
timeout.unref?.();
|
|
329
|
-
|
|
330
|
-
child.stdin.on('error', () => {});
|
|
331
|
-
child.stdin.end(String(prompt ?? ''));
|
|
332
|
-
|
|
333
|
-
try {
|
|
334
|
-
await closePromise;
|
|
335
|
-
} finally {
|
|
336
|
-
clearTimeout(timeout);
|
|
337
|
-
stdoutReader.close();
|
|
338
|
-
stderrReader.close();
|
|
339
|
-
if (this.child === child) {
|
|
340
|
-
this.child = null;
|
|
341
|
-
}
|
|
342
|
-
this.state = 'idle';
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
const resultEvent = findLastEvent(events, (event) => event?.type === 'result');
|
|
346
|
-
const response = [
|
|
347
|
-
extractText(resultEvent),
|
|
348
|
-
...events
|
|
349
|
-
.filter((event) => (
|
|
350
|
-
event?.type === 'assistant'
|
|
351
|
-
|| (event?.type === 'message' && event?.role === 'assistant')
|
|
352
|
-
))
|
|
353
|
-
.map((event) => extractText(event))
|
|
354
|
-
.filter(Boolean),
|
|
355
|
-
...stdoutLines.filter((line) => line.trim() !== '""'),
|
|
356
|
-
]
|
|
357
|
-
.filter(Boolean)
|
|
358
|
-
.join('\n')
|
|
359
|
-
.trim();
|
|
360
|
-
|
|
361
|
-
const result = {
|
|
362
|
-
type: 'gemini',
|
|
363
|
-
command: spawnSpec.resolvedCommand,
|
|
364
|
-
args: spawnSpec.args,
|
|
365
|
-
response,
|
|
366
|
-
events,
|
|
367
|
-
resultEvent,
|
|
368
|
-
usage: resultEvent?.usage || null,
|
|
369
|
-
stdout: stdoutLines.join('\n').trim(),
|
|
370
|
-
stderr: stderrLines.join('\n').trim(),
|
|
371
|
-
exitCode,
|
|
372
|
-
exitSignal,
|
|
373
|
-
timedOut,
|
|
374
|
-
startedAtMs,
|
|
375
|
-
finishedAtMs: Date.now(),
|
|
376
|
-
};
|
|
377
|
-
|
|
378
|
-
this.lastRun = result;
|
|
379
|
-
|
|
380
|
-
if (timedOut) {
|
|
381
|
-
throw createWorkerError(`Gemini worker timed out after ${timeoutMs}ms`, {
|
|
382
|
-
code: 'ETIMEDOUT',
|
|
383
|
-
result,
|
|
384
|
-
stderr: result.stderr,
|
|
385
|
-
});
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
if (exitCode !== 0) {
|
|
389
|
-
throw createWorkerError(`Gemini worker exited with code ${exitCode}`, {
|
|
390
|
-
code: 'WORKER_EXIT',
|
|
391
|
-
result,
|
|
392
|
-
stderr: result.stderr,
|
|
393
|
-
});
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
if (lastErrorEvent) {
|
|
397
|
-
throw createWorkerError('Gemini worker emitted an error event', {
|
|
398
|
-
code: 'WORKER_EVENT_ERROR',
|
|
399
|
-
result,
|
|
400
|
-
stderr: result.stderr,
|
|
401
|
-
});
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
return result;
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
isReady() {
|
|
408
|
-
return this.state !== 'stopped';
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
async execute(prompt, options = {}) {
|
|
412
|
-
try {
|
|
413
|
-
const result = await this.run(prompt, options);
|
|
414
|
-
return {
|
|
415
|
-
output: result.response,
|
|
416
|
-
exitCode: 0,
|
|
417
|
-
sessionKey: options.sessionKey || null,
|
|
418
|
-
raw: result,
|
|
419
|
-
};
|
|
420
|
-
} catch (error) {
|
|
421
|
-
return {
|
|
422
|
-
output: error.stderr || error.message || 'Gemini worker failed',
|
|
423
|
-
exitCode: error.code === 'ETIMEDOUT' ? 124 : 1,
|
|
424
|
-
sessionKey: options.sessionKey || null,
|
|
425
|
-
raw: error.result || null,
|
|
426
|
-
};
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
}
|
|
1
|
+
// hub/workers/gemini-worker.mjs — Gemini headless subprocess 래퍼
|
|
2
|
+
// ADR-006: --output-format stream-json 기반 단발 실행 워커.
|
|
3
|
+
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
6
|
+
import { delimiter, extname, join } from 'node:path';
|
|
7
|
+
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
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function extractText(event) {
|
|
33
|
+
const parts = [];
|
|
34
|
+
appendTextFragments(event, parts);
|
|
35
|
+
return parts.join('\n').trim();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function findLastEvent(events, predicate) {
|
|
39
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
40
|
+
if (predicate(events[index])) return events[index];
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function buildGeminiArgs(options) {
|
|
46
|
+
const args = [];
|
|
47
|
+
|
|
48
|
+
if (options.model) {
|
|
49
|
+
args.push('--model', options.model);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (options.approvalMode) {
|
|
53
|
+
args.push('--approval-mode', options.approvalMode);
|
|
54
|
+
} else if (options.yolo !== false) {
|
|
55
|
+
args.push('--yolo');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const allowedMcpServers = toStringList(options.allowedMcpServerNames);
|
|
59
|
+
if (allowedMcpServers.length) {
|
|
60
|
+
args.push('--allowed-mcp-server-names', ...allowedMcpServers);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const extraArgs = toStringList(options.extraArgs);
|
|
64
|
+
if (extraArgs.length) args.push(...extraArgs);
|
|
65
|
+
|
|
66
|
+
args.push('--prompt', options.promptArgument ?? '');
|
|
67
|
+
args.push('--output-format', 'stream-json');
|
|
68
|
+
|
|
69
|
+
return args;
|
|
70
|
+
}
|
|
71
|
+
|
|
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;
|
|
107
|
+
}
|
|
108
|
+
|
|
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("'", `'\"'\"'`)}'`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function toBashPath(value) {
|
|
128
|
+
return String(value ?? '')
|
|
129
|
+
.replace(/^([A-Za-z]):/, (_, drive) => `/${drive.toLowerCase()}`)
|
|
130
|
+
.replaceAll('\\', '/');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function buildSpawnSpec(command, args, env = process.env) {
|
|
134
|
+
const resolvedCommand = resolveSpawnCommand(command, env);
|
|
135
|
+
|
|
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
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
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(' ');
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
command: bashCommand,
|
|
156
|
+
args: ['-lc', commandLine],
|
|
157
|
+
resolvedCommand,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
command: resolvedCommand,
|
|
163
|
+
args,
|
|
164
|
+
resolvedCommand,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Gemini stream-json 래퍼
|
|
170
|
+
*/
|
|
171
|
+
export class GeminiWorker {
|
|
172
|
+
type = 'gemini';
|
|
173
|
+
|
|
174
|
+
constructor(options = {}) {
|
|
175
|
+
this.command = options.command || 'gemini';
|
|
176
|
+
this.commandArgs = toStringList(options.commandArgs || options.args);
|
|
177
|
+
this.cwd = options.cwd || process.cwd();
|
|
178
|
+
this.env = { ...process.env, ...(options.env || {}) };
|
|
179
|
+
this.model = options.model || null;
|
|
180
|
+
this.approvalMode = options.approvalMode || null;
|
|
181
|
+
this.yolo = options.yolo !== false;
|
|
182
|
+
this.allowedMcpServerNames = toStringList(options.allowedMcpServerNames);
|
|
183
|
+
this.extraArgs = toStringList(options.extraArgs);
|
|
184
|
+
this.timeoutMs = Number(options.timeoutMs) > 0 ? Number(options.timeoutMs) : DEFAULT_TIMEOUT_MS;
|
|
185
|
+
this.killGraceMs = Number(options.killGraceMs) > 0 ? Number(options.killGraceMs) : DEFAULT_KILL_GRACE_MS;
|
|
186
|
+
this.onEvent = typeof options.onEvent === 'function' ? options.onEvent : null;
|
|
187
|
+
|
|
188
|
+
this.state = 'idle';
|
|
189
|
+
this.child = null;
|
|
190
|
+
this.lastRun = null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
getStatus() {
|
|
194
|
+
return {
|
|
195
|
+
type: 'gemini',
|
|
196
|
+
state: this.state,
|
|
197
|
+
pid: this.child?.pid || null,
|
|
198
|
+
last_run_at_ms: this.lastRun?.finishedAtMs || null,
|
|
199
|
+
last_exit_code: this.lastRun?.exitCode ?? null,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async start() {
|
|
204
|
+
if (this.state === 'stopped') {
|
|
205
|
+
this.state = 'idle';
|
|
206
|
+
}
|
|
207
|
+
return this.getStatus();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async stop() {
|
|
211
|
+
if (!this.child) {
|
|
212
|
+
this.state = 'stopped';
|
|
213
|
+
return this.getStatus();
|
|
214
|
+
}
|
|
215
|
+
const child = this.child;
|
|
216
|
+
this._terminateChild(child);
|
|
217
|
+
await new Promise((resolve) => {
|
|
218
|
+
child.once('close', resolve);
|
|
219
|
+
setTimeout(resolve, this.killGraceMs + 50).unref?.();
|
|
220
|
+
});
|
|
221
|
+
this.child = null;
|
|
222
|
+
this.state = 'stopped';
|
|
223
|
+
return this.getStatus();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async restart() {
|
|
227
|
+
await this.stop();
|
|
228
|
+
this.state = 'idle';
|
|
229
|
+
return this.getStatus();
|
|
230
|
+
}
|
|
231
|
+
|
|
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
|
+
async run(prompt, options = {}) {
|
|
246
|
+
if (this.child) {
|
|
247
|
+
throw createWorkerError('GeminiWorker is already running', { code: 'WORKER_BUSY' });
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
await this.start();
|
|
251
|
+
|
|
252
|
+
const timeoutMs = Number(options.timeoutMs) > 0 ? Number(options.timeoutMs) : this.timeoutMs;
|
|
253
|
+
const startedAtMs = Date.now();
|
|
254
|
+
const args = [
|
|
255
|
+
...this.commandArgs,
|
|
256
|
+
...buildGeminiArgs({
|
|
257
|
+
model: options.model || this.model,
|
|
258
|
+
approvalMode: options.approvalMode || this.approvalMode,
|
|
259
|
+
yolo: options.yolo ?? this.yolo,
|
|
260
|
+
allowedMcpServerNames: options.allowedMcpServerNames || this.allowedMcpServerNames,
|
|
261
|
+
extraArgs: options.extraArgs || this.extraArgs,
|
|
262
|
+
promptArgument: options.promptArgument ?? '',
|
|
263
|
+
}),
|
|
264
|
+
];
|
|
265
|
+
const env = { ...this.env, ...(options.env || {}) };
|
|
266
|
+
const spawnSpec = buildSpawnSpec(this.command, args, env);
|
|
267
|
+
|
|
268
|
+
const child = spawn(spawnSpec.command, spawnSpec.args, {
|
|
269
|
+
cwd: options.cwd || this.cwd,
|
|
270
|
+
env,
|
|
271
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
272
|
+
windowsHide: true,
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
this.child = child;
|
|
276
|
+
this.state = 'running';
|
|
277
|
+
|
|
278
|
+
const events = [];
|
|
279
|
+
const stdoutLines = [];
|
|
280
|
+
const stderrLines = [];
|
|
281
|
+
let lastErrorEvent = null;
|
|
282
|
+
let timedOut = false;
|
|
283
|
+
let exitCode = null;
|
|
284
|
+
let exitSignal = null;
|
|
285
|
+
|
|
286
|
+
const stdoutReader = readline.createInterface({
|
|
287
|
+
input: child.stdout,
|
|
288
|
+
crlfDelay: Infinity,
|
|
289
|
+
});
|
|
290
|
+
const stderrReader = readline.createInterface({
|
|
291
|
+
input: child.stderr,
|
|
292
|
+
crlfDelay: Infinity,
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
stdoutReader.on('line', (line) => {
|
|
296
|
+
if (!line) return;
|
|
297
|
+
const event = safeJsonParse(line);
|
|
298
|
+
if (!event) {
|
|
299
|
+
stdoutLines.push(line);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
events.push(event);
|
|
304
|
+
if (event.type === 'error') lastErrorEvent = event;
|
|
305
|
+
if (this.onEvent) {
|
|
306
|
+
try { this.onEvent(event); } catch {}
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
stderrReader.on('line', (line) => {
|
|
311
|
+
if (!line) return;
|
|
312
|
+
stderrLines.push(line);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const closePromise = new Promise((resolve, reject) => {
|
|
316
|
+
child.once('error', reject);
|
|
317
|
+
child.once('close', (code, signal) => {
|
|
318
|
+
exitCode = code;
|
|
319
|
+
exitSignal = signal;
|
|
320
|
+
resolve();
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
const timeout = setTimeout(() => {
|
|
325
|
+
timedOut = true;
|
|
326
|
+
this._terminateChild(child);
|
|
327
|
+
}, timeoutMs);
|
|
328
|
+
timeout.unref?.();
|
|
329
|
+
|
|
330
|
+
child.stdin.on('error', () => {});
|
|
331
|
+
child.stdin.end(String(prompt ?? ''));
|
|
332
|
+
|
|
333
|
+
try {
|
|
334
|
+
await closePromise;
|
|
335
|
+
} finally {
|
|
336
|
+
clearTimeout(timeout);
|
|
337
|
+
stdoutReader.close();
|
|
338
|
+
stderrReader.close();
|
|
339
|
+
if (this.child === child) {
|
|
340
|
+
this.child = null;
|
|
341
|
+
}
|
|
342
|
+
this.state = 'idle';
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const resultEvent = findLastEvent(events, (event) => event?.type === 'result');
|
|
346
|
+
const response = [
|
|
347
|
+
extractText(resultEvent),
|
|
348
|
+
...events
|
|
349
|
+
.filter((event) => (
|
|
350
|
+
event?.type === 'assistant'
|
|
351
|
+
|| (event?.type === 'message' && event?.role === 'assistant')
|
|
352
|
+
))
|
|
353
|
+
.map((event) => extractText(event))
|
|
354
|
+
.filter(Boolean),
|
|
355
|
+
...stdoutLines.filter((line) => line.trim() !== '""'),
|
|
356
|
+
]
|
|
357
|
+
.filter(Boolean)
|
|
358
|
+
.join('\n')
|
|
359
|
+
.trim();
|
|
360
|
+
|
|
361
|
+
const result = {
|
|
362
|
+
type: 'gemini',
|
|
363
|
+
command: spawnSpec.resolvedCommand,
|
|
364
|
+
args: spawnSpec.args,
|
|
365
|
+
response,
|
|
366
|
+
events,
|
|
367
|
+
resultEvent,
|
|
368
|
+
usage: resultEvent?.usage || null,
|
|
369
|
+
stdout: stdoutLines.join('\n').trim(),
|
|
370
|
+
stderr: stderrLines.join('\n').trim(),
|
|
371
|
+
exitCode,
|
|
372
|
+
exitSignal,
|
|
373
|
+
timedOut,
|
|
374
|
+
startedAtMs,
|
|
375
|
+
finishedAtMs: Date.now(),
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
this.lastRun = result;
|
|
379
|
+
|
|
380
|
+
if (timedOut) {
|
|
381
|
+
throw createWorkerError(`Gemini worker timed out after ${timeoutMs}ms`, {
|
|
382
|
+
code: 'ETIMEDOUT',
|
|
383
|
+
result,
|
|
384
|
+
stderr: result.stderr,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (exitCode !== 0) {
|
|
389
|
+
throw createWorkerError(`Gemini worker exited with code ${exitCode}`, {
|
|
390
|
+
code: 'WORKER_EXIT',
|
|
391
|
+
result,
|
|
392
|
+
stderr: result.stderr,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (lastErrorEvent) {
|
|
397
|
+
throw createWorkerError('Gemini worker emitted an error event', {
|
|
398
|
+
code: 'WORKER_EVENT_ERROR',
|
|
399
|
+
result,
|
|
400
|
+
stderr: result.stderr,
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return result;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
isReady() {
|
|
408
|
+
return this.state !== 'stopped';
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async execute(prompt, options = {}) {
|
|
412
|
+
try {
|
|
413
|
+
const result = await this.run(prompt, options);
|
|
414
|
+
return {
|
|
415
|
+
output: result.response,
|
|
416
|
+
exitCode: 0,
|
|
417
|
+
sessionKey: options.sessionKey || null,
|
|
418
|
+
raw: result,
|
|
419
|
+
};
|
|
420
|
+
} catch (error) {
|
|
421
|
+
return {
|
|
422
|
+
output: error.stderr || error.message || 'Gemini worker failed',
|
|
423
|
+
exitCode: error.code === 'ETIMEDOUT' ? 124 : 1,
|
|
424
|
+
sessionKey: options.sessionKey || null,
|
|
425
|
+
raw: error.result || null,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|