@maestrofrontier/frontier 1.4.4 → 1.5.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/.agents/plugins/marketplace.json +21 -0
- package/.codex-plugin/plugin.json +29 -0
- package/.cursorrules +197 -194
- package/AGENTS.md +214 -214
- package/CLAUDE.md +29 -29
- package/README.md +368 -278
- package/bin/maestro.cjs +75 -75
- package/commands/compress.md +36 -36
- package/commands/frontier.md +124 -124
- package/commands/terse.md +23 -23
- package/docs/codex.md +167 -98
- package/docs/orchestration.md +168 -168
- package/frontier/cli.cjs +279 -248
- package/frontier/config.cjs +468 -441
- package/frontier/dispatch.cjs +267 -255
- package/frontier/judge.cjs +92 -92
- package/frontier/run.cjs +201 -148
- package/frontier/schema.cjs +112 -112
- package/frontier/semaphore.cjs +49 -49
- package/frontier/synthesize.cjs +79 -79
- package/hooks/frontier-autorun.cjs +127 -124
- package/hooks/hooks.json +103 -103
- package/hooks/maestro-doctrine-guard.cjs +81 -81
- package/hooks/maestro-gate-reminder.cjs +22 -7
- package/hooks/maestro-gate-telemetry.cjs +79 -77
- package/hooks/maestro-phase-scope.cjs +118 -118
- package/hooks/maestro-statusline-sync.cjs +152 -152
- package/hooks/maestro-subagent-guard.cjs +148 -148
- package/hooks/maestro-terse-mode.cjs +189 -189
- package/hooks/maestro-toolbudget-advisory.cjs +127 -127
- package/integrations/README.md +111 -94
- package/integrations/cline/skills/frontier/SKILL.md +75 -75
- package/integrations/codex/prompts/frontier.md +70 -66
- package/integrations/codex/prompts/update.md +39 -36
- package/integrations/codex/skills/maestro-frontier/SKILL.md +122 -0
- package/integrations/codex/skills/{settings → maestro-settings}/SKILL.md +55 -46
- package/integrations/codex/skills/{terse → maestro-terse}/SKILL.md +58 -49
- package/integrations/codex/skills/maestro-update/SKILL.md +31 -0
- package/integrations/cursor/commands/frontier.md +63 -63
- package/integrations/cursor/commands/update.md +34 -34
- package/integrations/gemini/commands/frontier.toml +76 -76
- package/integrations/windsurf/workflows/frontier.md +70 -70
- package/package.json +58 -55
- package/scripts/install.cjs +1014 -605
- package/settings/cli.cjs +140 -140
- package/settings/config.cjs +309 -309
- package/skills/maestro-frontier/SKILL.md +122 -0
- package/skills/maestro-settings/SKILL.md +55 -0
- package/skills/maestro-terse/SKILL.md +58 -0
- package/skills/maestro-update/SKILL.md +31 -0
- package/skills/terse/SKILL.md +74 -0
- package/integrations/codex/skills/frontier/SKILL.md +0 -91
- package/integrations/codex/skills/update/SKILL.md +0 -29
package/frontier/dispatch.cjs
CHANGED
|
@@ -1,255 +1,267 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Maestro Frontier — CLI-spawn + parallel-fanout for panel adapters.
|
|
3
|
-
// spawnOne: one adapter invocation -> PanelResponse (never rejects).
|
|
4
|
-
// fanOut: bounded-concurrent map over modelIds -> PanelResponse[].
|
|
5
|
-
|
|
6
|
-
'use strict';
|
|
7
|
-
|
|
8
|
-
const { spawn } = require('child_process');
|
|
9
|
-
const fs = require('fs');
|
|
10
|
-
const os = require('os');
|
|
11
|
-
const path = require('path');
|
|
12
|
-
|
|
13
|
-
const { stripLlmWrapper } = require('./schema.cjs');
|
|
14
|
-
const { mapLimit } = require('./semaphore.cjs');
|
|
15
|
-
|
|
16
|
-
const MAX_BUF = 2 * 1024 * 1024; // 2 MB cap per stream
|
|
17
|
-
|
|
18
|
-
/** @param {string} p @returns {string} */
|
|
19
|
-
function safeReadFile(p) {
|
|
20
|
-
try { return fs.readFileSync(p, 'utf8'); } catch { return ''; }
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
// Residual cmd.exe hazards for a prompt passed as an ARGUMENT on win32.
|
|
24
|
-
// Win32 npm shims (codex/gemini, extensionless) cannot be spawned with
|
|
25
|
-
// shell:false (Node v18.20+/20.12+/24 throw EINVAL on .cmd/.bat), and
|
|
26
|
-
// shell:true concatenates args unescaped (DEP0190). So we wrap them in an
|
|
27
|
-
// explicit `cmd.exe /d /s /c <shim> ...` with shell:false: Node then applies
|
|
28
|
-
// its standard argv quoting to every element, so spaces and &|<>() inside the
|
|
29
|
-
// quoted prompt are literal to cmd and there is no unescaped concat. What
|
|
30
|
-
// Node's C-runtime quoting does NOT reconcile with cmd.exe is the double quote
|
|
31
|
-
// and percent (cmd still expands %VAR% on the /c line). stdin adapters
|
|
32
|
-
// (claude, codex) are unaffected; only the promptVia:'arg' shim path (gemini)
|
|
33
|
-
// is, so we refuse a prompt bearing those residual chars there. Plain prose —
|
|
34
|
-
// spaces and most punctuation included — passes.
|
|
35
|
-
const CMD_UNSAFE_RE = new RegExp('["%\\r\\n]');
|
|
36
|
-
function unsafeForShellArg(s) {
|
|
37
|
-
return CMD_UNSAFE_RE.test(String(s));
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Spawn one adapter process and return a PanelResponse (never rejects).
|
|
42
|
-
* @param {string} prompt
|
|
43
|
-
* @param {object} adapter — DEFAULTS.adapters[id] shape
|
|
44
|
-
* @param {{ timeoutMs?: number, fusionDepth?: number }} [opts]
|
|
45
|
-
* @returns {Promise<import('./schema.cjs').PanelResponse>}
|
|
46
|
-
*/
|
|
47
|
-
function spawnOne(prompt, adapter, opts) {
|
|
48
|
-
const timeoutMs = (opts && opts.timeoutMs != null) ? opts.timeoutMs : 180000;
|
|
49
|
-
const fusionDepth = (opts && opts.fusionDepth != null) ? opts.fusionDepth : 1;
|
|
50
|
-
|
|
51
|
-
return new Promise((resolve) => {
|
|
52
|
-
const start = Date.now();
|
|
53
|
-
|
|
54
|
-
// Guard: missing/invalid adapter
|
|
55
|
-
if (!adapter || typeof adapter !== 'object') {
|
|
56
|
-
return resolve({
|
|
57
|
-
model: '', content: '', ok: false, durationMs: 0, tokensEst: 0,
|
|
58
|
-
error: 'spawn error: adapter is undefined',
|
|
59
|
-
});
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// ---- build invocation ----
|
|
63
|
-
// Node scripts (test stubs) run via process.execPath; win32 npm shims go
|
|
64
|
-
// through an explicit cmd.exe wrapper (see CMD_UNSAFE_RE note); everything
|
|
65
|
-
// else (claude.exe, POSIX bins) spawns directly. shell is always false.
|
|
66
|
-
const isNodeScript = /\.[cm]?js$/i.test(adapter.bin);
|
|
67
|
-
const isWinShim = !isNodeScript && process.platform === 'win32' && !path.extname(adapter.bin);
|
|
68
|
-
|
|
69
|
-
// program args: base flags (+ optional output file) (+ optional prompt arg)
|
|
70
|
-
const progArgs = [...(adapter.baseArgs || [])];
|
|
71
|
-
|
|
72
|
-
// last-message-file: unique tmp path
|
|
73
|
-
let tmpPath = null;
|
|
74
|
-
if (adapter.output === 'last-message-file') {
|
|
75
|
-
tmpPath = path.join(
|
|
76
|
-
os.tmpdir(),
|
|
77
|
-
'frontier-' + process.pid + '-' + Date.now() + '-' +
|
|
78
|
-
Math.random().toString(36).slice(2) + '.txt'
|
|
79
|
-
);
|
|
80
|
-
progArgs.push('--output-last-message', tmpPath);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// prompt delivery via arg (stdin path is written after spawn)
|
|
84
|
-
if (adapter.promptVia === 'arg') {
|
|
85
|
-
if (isWinShim && unsafeForShellArg(prompt)) {
|
|
86
|
-
return resolve({
|
|
87
|
-
model: adapter.model, content: '', ok: false,
|
|
88
|
-
durationMs: Date.now() - start, tokensEst: 0,
|
|
89
|
-
error: 'unsafe prompt for win32 arg-path adapter (contains a quote, ' +
|
|
90
|
-
'percent, or newline); use a stdin-capable model or remove those characters',
|
|
91
|
-
});
|
|
92
|
-
}
|
|
93
|
-
progArgs.push(adapter.promptFlag || '-p', prompt);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
let bin, spawnArgs;
|
|
97
|
-
if (isNodeScript) {
|
|
98
|
-
bin = process.execPath;
|
|
99
|
-
spawnArgs = [adapter.bin, ...progArgs];
|
|
100
|
-
} else if (isWinShim) {
|
|
101
|
-
bin = process.env.ComSpec || 'cmd.exe';
|
|
102
|
-
spawnArgs = ['/d', '/s', '/c', adapter.bin, ...progArgs];
|
|
103
|
-
} else {
|
|
104
|
-
bin = adapter.bin;
|
|
105
|
-
spawnArgs = progArgs;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const env = { ...process.env, FUSION_DEPTH: String(fusionDepth), ...(adapter.env || {}) };
|
|
109
|
-
|
|
110
|
-
let child;
|
|
111
|
-
try {
|
|
112
|
-
child = spawn(bin, spawnArgs, { shell: false, env, windowsHide: true });
|
|
113
|
-
} catch (spawnErr) {
|
|
114
|
-
return resolve({
|
|
115
|
-
model: adapter.model, content: '', ok: false,
|
|
116
|
-
durationMs: Date.now() - start, tokensEst: 0,
|
|
117
|
-
error: 'spawn error: ' + spawnErr.message,
|
|
118
|
-
});
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// stdin delivery
|
|
122
|
-
if (adapter.promptVia !== 'arg') {
|
|
123
|
-
child.stdin.on('error', () => {});
|
|
124
|
-
try { child.stdin.write(prompt); child.stdin.end(); } catch {}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
let stdoutBuf = '';
|
|
128
|
-
let stderrBuf = '';
|
|
129
|
-
|
|
130
|
-
child.stdout.setEncoding('utf8');
|
|
131
|
-
child.stdout.on('data', (chunk) => {
|
|
132
|
-
stdoutBuf += chunk;
|
|
133
|
-
if (stdoutBuf.length > MAX_BUF) stdoutBuf = stdoutBuf.slice(-MAX_BUF);
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
child.stderr.setEncoding('utf8');
|
|
137
|
-
child.stderr.on('data', (chunk) => {
|
|
138
|
-
stderrBuf += chunk;
|
|
139
|
-
if (stderrBuf.length > MAX_BUF) stderrBuf = stderrBuf.slice(-MAX_BUF);
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
// timeout
|
|
143
|
-
let timedOut = false;
|
|
144
|
-
let killTimer = null;
|
|
145
|
-
const timer = setTimeout(() => {
|
|
146
|
-
timedOut = true;
|
|
147
|
-
child.kill('SIGTERM');
|
|
148
|
-
killTimer = setTimeout(() => child.kill('SIGKILL'), 2000);
|
|
149
|
-
}, timeoutMs);
|
|
150
|
-
|
|
151
|
-
child.on('error', (err) => {
|
|
152
|
-
clearTimeout(timer);
|
|
153
|
-
if (killTimer) clearTimeout(killTimer);
|
|
154
|
-
resolve({
|
|
155
|
-
model: adapter.model, content: '', ok: false,
|
|
156
|
-
durationMs: Date.now() - start, tokensEst: 0,
|
|
157
|
-
error: 'spawn error: ' + err.message,
|
|
158
|
-
});
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
child.on('close', (code, signal) => {
|
|
162
|
-
clearTimeout(timer);
|
|
163
|
-
if (killTimer) clearTimeout(killTimer);
|
|
164
|
-
|
|
165
|
-
// ---- parse stdout -> content (read tmp file BEFORE cleanup) ----
|
|
166
|
-
let content = '';
|
|
167
|
-
let parseError = null;
|
|
168
|
-
|
|
169
|
-
if (adapter.parse === 'claude-json') {
|
|
170
|
-
let parsed;
|
|
171
|
-
try { parsed = JSON.parse(stdoutBuf); } catch {
|
|
172
|
-
parseError = 'claude json parse fail';
|
|
173
|
-
}
|
|
174
|
-
if (!parseError) {
|
|
175
|
-
if (parsed && parsed.is_error) {
|
|
176
|
-
parseError = String(parsed.result || 'is_error');
|
|
177
|
-
} else {
|
|
178
|
-
content = stripLlmWrapper(String((parsed && parsed.result) || '').trim());
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
} else if (adapter.parse === 'gemini-json') {
|
|
182
|
-
let parsed;
|
|
183
|
-
try { parsed = JSON.parse(stdoutBuf); } catch {
|
|
184
|
-
parseError = 'gemini json parse fail';
|
|
185
|
-
}
|
|
186
|
-
if (!parseError) {
|
|
187
|
-
content = stripLlmWrapper(String((parsed && parsed.response) || '').trim());
|
|
188
|
-
}
|
|
189
|
-
} else {
|
|
190
|
-
// 'text': prefer last-message-file content, fall back to stdout
|
|
191
|
-
const raw = (adapter.output === 'last-message-file' ? safeReadFile(tmpPath) : '') || stdoutBuf;
|
|
192
|
-
content = stripLlmWrapper(String(raw).trim());
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// tmp-file cleanup (after read)
|
|
196
|
-
if (tmpPath) {
|
|
197
|
-
try { fs.unlinkSync(tmpPath); } catch {}
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
const ok = (code === 0) && !timedOut && content.length > 0 && !parseError;
|
|
201
|
-
let error;
|
|
202
|
-
if (!ok) {
|
|
203
|
-
error = parseError ||
|
|
204
|
-
(timedOut
|
|
205
|
-
? 'timeout'
|
|
206
|
-
: ('exit ' + code + (signal ? (' ' + signal) : '') +
|
|
207
|
-
(stderrBuf ? ': ' + stderrBuf.slice(0, 500) : '')));
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
const durationMs = Date.now() - start;
|
|
211
|
-
const tokensEst = Math.ceil(content.length / 4);
|
|
212
|
-
resolve({
|
|
213
|
-
model: adapter.model, content, ok, durationMs, tokensEst,
|
|
214
|
-
...(ok ? {} : { error }),
|
|
215
|
-
});
|
|
216
|
-
});
|
|
217
|
-
});
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/**
|
|
221
|
-
* fanOut — run spawnOne over modelIds bounded by concurrency; order preserved.
|
|
222
|
-
* @param {string} prompt
|
|
223
|
-
* @param {string[]} modelIds
|
|
224
|
-
* @param {{ adapters: object, timeoutMs: number, concurrency: number }} cfg
|
|
225
|
-
* @param {{ fusionDepth?: number, concurrency?: number }} [opts]
|
|
226
|
-
* @returns {Promise<import('./schema.cjs').PanelResponse[]>}
|
|
227
|
-
*/
|
|
228
|
-
async function fanOut(prompt, modelIds, cfg, opts) {
|
|
229
|
-
const fusionDepth
|
|
230
|
-
const concurrency
|
|
231
|
-
? opts.concurrency
|
|
232
|
-
: Math.min(modelIds.length, (cfg && cfg.concurrency) || 4);
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Maestro Frontier — CLI-spawn + parallel-fanout for panel adapters.
|
|
3
|
+
// spawnOne: one adapter invocation -> PanelResponse (never rejects).
|
|
4
|
+
// fanOut: bounded-concurrent map over modelIds -> PanelResponse[].
|
|
5
|
+
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
const { spawn } = require('child_process');
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const os = require('os');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
|
|
13
|
+
const { stripLlmWrapper } = require('./schema.cjs');
|
|
14
|
+
const { mapLimit } = require('./semaphore.cjs');
|
|
15
|
+
|
|
16
|
+
const MAX_BUF = 2 * 1024 * 1024; // 2 MB cap per stream
|
|
17
|
+
|
|
18
|
+
/** @param {string} p @returns {string} */
|
|
19
|
+
function safeReadFile(p) {
|
|
20
|
+
try { return fs.readFileSync(p, 'utf8'); } catch { return ''; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Residual cmd.exe hazards for a prompt passed as an ARGUMENT on win32.
|
|
24
|
+
// Win32 npm shims (codex/gemini, extensionless) cannot be spawned with
|
|
25
|
+
// shell:false (Node v18.20+/20.12+/24 throw EINVAL on .cmd/.bat), and
|
|
26
|
+
// shell:true concatenates args unescaped (DEP0190). So we wrap them in an
|
|
27
|
+
// explicit `cmd.exe /d /s /c <shim> ...` with shell:false: Node then applies
|
|
28
|
+
// its standard argv quoting to every element, so spaces and &|<>() inside the
|
|
29
|
+
// quoted prompt are literal to cmd and there is no unescaped concat. What
|
|
30
|
+
// Node's C-runtime quoting does NOT reconcile with cmd.exe is the double quote
|
|
31
|
+
// and percent (cmd still expands %VAR% on the /c line). stdin adapters
|
|
32
|
+
// (claude, codex) are unaffected; only the promptVia:'arg' shim path (gemini)
|
|
33
|
+
// is, so we refuse a prompt bearing those residual chars there. Plain prose —
|
|
34
|
+
// spaces and most punctuation included — passes.
|
|
35
|
+
const CMD_UNSAFE_RE = new RegExp('["%\\r\\n]');
|
|
36
|
+
function unsafeForShellArg(s) {
|
|
37
|
+
return CMD_UNSAFE_RE.test(String(s));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Spawn one adapter process and return a PanelResponse (never rejects).
|
|
42
|
+
* @param {string} prompt
|
|
43
|
+
* @param {object} adapter — DEFAULTS.adapters[id] shape
|
|
44
|
+
* @param {{ timeoutMs?: number, fusionDepth?: number }} [opts]
|
|
45
|
+
* @returns {Promise<import('./schema.cjs').PanelResponse>}
|
|
46
|
+
*/
|
|
47
|
+
function spawnOne(prompt, adapter, opts) {
|
|
48
|
+
const timeoutMs = (opts && opts.timeoutMs != null) ? opts.timeoutMs : 180000;
|
|
49
|
+
const fusionDepth = (opts && opts.fusionDepth != null) ? opts.fusionDepth : 1;
|
|
50
|
+
|
|
51
|
+
return new Promise((resolve) => {
|
|
52
|
+
const start = Date.now();
|
|
53
|
+
|
|
54
|
+
// Guard: missing/invalid adapter
|
|
55
|
+
if (!adapter || typeof adapter !== 'object') {
|
|
56
|
+
return resolve({
|
|
57
|
+
model: '', content: '', ok: false, durationMs: 0, tokensEst: 0,
|
|
58
|
+
error: 'spawn error: adapter is undefined',
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---- build invocation ----
|
|
63
|
+
// Node scripts (test stubs) run via process.execPath; win32 npm shims go
|
|
64
|
+
// through an explicit cmd.exe wrapper (see CMD_UNSAFE_RE note); everything
|
|
65
|
+
// else (claude.exe, POSIX bins) spawns directly. shell is always false.
|
|
66
|
+
const isNodeScript = /\.[cm]?js$/i.test(adapter.bin);
|
|
67
|
+
const isWinShim = !isNodeScript && process.platform === 'win32' && !path.extname(adapter.bin);
|
|
68
|
+
|
|
69
|
+
// program args: base flags (+ optional output file) (+ optional prompt arg)
|
|
70
|
+
const progArgs = [...(adapter.baseArgs || [])];
|
|
71
|
+
|
|
72
|
+
// last-message-file: unique tmp path
|
|
73
|
+
let tmpPath = null;
|
|
74
|
+
if (adapter.output === 'last-message-file') {
|
|
75
|
+
tmpPath = path.join(
|
|
76
|
+
os.tmpdir(),
|
|
77
|
+
'frontier-' + process.pid + '-' + Date.now() + '-' +
|
|
78
|
+
Math.random().toString(36).slice(2) + '.txt'
|
|
79
|
+
);
|
|
80
|
+
progArgs.push('--output-last-message', tmpPath);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// prompt delivery via arg (stdin path is written after spawn)
|
|
84
|
+
if (adapter.promptVia === 'arg') {
|
|
85
|
+
if (isWinShim && unsafeForShellArg(prompt)) {
|
|
86
|
+
return resolve({
|
|
87
|
+
model: adapter.model, content: '', ok: false,
|
|
88
|
+
durationMs: Date.now() - start, tokensEst: 0,
|
|
89
|
+
error: 'unsafe prompt for win32 arg-path adapter (contains a quote, ' +
|
|
90
|
+
'percent, or newline); use a stdin-capable model or remove those characters',
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
progArgs.push(adapter.promptFlag || '-p', prompt);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let bin, spawnArgs;
|
|
97
|
+
if (isNodeScript) {
|
|
98
|
+
bin = process.execPath;
|
|
99
|
+
spawnArgs = [adapter.bin, ...progArgs];
|
|
100
|
+
} else if (isWinShim) {
|
|
101
|
+
bin = process.env.ComSpec || 'cmd.exe';
|
|
102
|
+
spawnArgs = ['/d', '/s', '/c', adapter.bin, ...progArgs];
|
|
103
|
+
} else {
|
|
104
|
+
bin = adapter.bin;
|
|
105
|
+
spawnArgs = progArgs;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const env = { ...process.env, FUSION_DEPTH: String(fusionDepth), ...(adapter.env || {}) };
|
|
109
|
+
|
|
110
|
+
let child;
|
|
111
|
+
try {
|
|
112
|
+
child = spawn(bin, spawnArgs, { shell: false, env, windowsHide: true });
|
|
113
|
+
} catch (spawnErr) {
|
|
114
|
+
return resolve({
|
|
115
|
+
model: adapter.model, content: '', ok: false,
|
|
116
|
+
durationMs: Date.now() - start, tokensEst: 0,
|
|
117
|
+
error: 'spawn error: ' + spawnErr.message,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// stdin delivery
|
|
122
|
+
if (adapter.promptVia !== 'arg') {
|
|
123
|
+
child.stdin.on('error', () => {});
|
|
124
|
+
try { child.stdin.write(prompt); child.stdin.end(); } catch {}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let stdoutBuf = '';
|
|
128
|
+
let stderrBuf = '';
|
|
129
|
+
|
|
130
|
+
child.stdout.setEncoding('utf8');
|
|
131
|
+
child.stdout.on('data', (chunk) => {
|
|
132
|
+
stdoutBuf += chunk;
|
|
133
|
+
if (stdoutBuf.length > MAX_BUF) stdoutBuf = stdoutBuf.slice(-MAX_BUF);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
child.stderr.setEncoding('utf8');
|
|
137
|
+
child.stderr.on('data', (chunk) => {
|
|
138
|
+
stderrBuf += chunk;
|
|
139
|
+
if (stderrBuf.length > MAX_BUF) stderrBuf = stderrBuf.slice(-MAX_BUF);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// timeout
|
|
143
|
+
let timedOut = false;
|
|
144
|
+
let killTimer = null;
|
|
145
|
+
const timer = setTimeout(() => {
|
|
146
|
+
timedOut = true;
|
|
147
|
+
child.kill('SIGTERM');
|
|
148
|
+
killTimer = setTimeout(() => child.kill('SIGKILL'), 2000);
|
|
149
|
+
}, timeoutMs);
|
|
150
|
+
|
|
151
|
+
child.on('error', (err) => {
|
|
152
|
+
clearTimeout(timer);
|
|
153
|
+
if (killTimer) clearTimeout(killTimer);
|
|
154
|
+
resolve({
|
|
155
|
+
model: adapter.model, content: '', ok: false,
|
|
156
|
+
durationMs: Date.now() - start, tokensEst: 0,
|
|
157
|
+
error: 'spawn error: ' + err.message,
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
child.on('close', (code, signal) => {
|
|
162
|
+
clearTimeout(timer);
|
|
163
|
+
if (killTimer) clearTimeout(killTimer);
|
|
164
|
+
|
|
165
|
+
// ---- parse stdout -> content (read tmp file BEFORE cleanup) ----
|
|
166
|
+
let content = '';
|
|
167
|
+
let parseError = null;
|
|
168
|
+
|
|
169
|
+
if (adapter.parse === 'claude-json') {
|
|
170
|
+
let parsed;
|
|
171
|
+
try { parsed = JSON.parse(stdoutBuf); } catch {
|
|
172
|
+
parseError = 'claude json parse fail';
|
|
173
|
+
}
|
|
174
|
+
if (!parseError) {
|
|
175
|
+
if (parsed && parsed.is_error) {
|
|
176
|
+
parseError = String(parsed.result || 'is_error');
|
|
177
|
+
} else {
|
|
178
|
+
content = stripLlmWrapper(String((parsed && parsed.result) || '').trim());
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
} else if (adapter.parse === 'gemini-json') {
|
|
182
|
+
let parsed;
|
|
183
|
+
try { parsed = JSON.parse(stdoutBuf); } catch {
|
|
184
|
+
parseError = 'gemini json parse fail';
|
|
185
|
+
}
|
|
186
|
+
if (!parseError) {
|
|
187
|
+
content = stripLlmWrapper(String((parsed && parsed.response) || '').trim());
|
|
188
|
+
}
|
|
189
|
+
} else {
|
|
190
|
+
// 'text': prefer last-message-file content, fall back to stdout
|
|
191
|
+
const raw = (adapter.output === 'last-message-file' ? safeReadFile(tmpPath) : '') || stdoutBuf;
|
|
192
|
+
content = stripLlmWrapper(String(raw).trim());
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// tmp-file cleanup (after read)
|
|
196
|
+
if (tmpPath) {
|
|
197
|
+
try { fs.unlinkSync(tmpPath); } catch {}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const ok = (code === 0) && !timedOut && content.length > 0 && !parseError;
|
|
201
|
+
let error;
|
|
202
|
+
if (!ok) {
|
|
203
|
+
error = parseError ||
|
|
204
|
+
(timedOut
|
|
205
|
+
? 'timeout'
|
|
206
|
+
: ('exit ' + code + (signal ? (' ' + signal) : '') +
|
|
207
|
+
(stderrBuf ? ': ' + stderrBuf.slice(0, 500) : '')));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const durationMs = Date.now() - start;
|
|
211
|
+
const tokensEst = Math.ceil(content.length / 4);
|
|
212
|
+
resolve({
|
|
213
|
+
model: adapter.model, content, ok, durationMs, tokensEst,
|
|
214
|
+
...(ok ? {} : { error }),
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* fanOut — run spawnOne over modelIds bounded by concurrency; order preserved.
|
|
222
|
+
* @param {string} prompt
|
|
223
|
+
* @param {string[]} modelIds
|
|
224
|
+
* @param {{ adapters: object, timeoutMs: number, concurrency: number }} cfg
|
|
225
|
+
* @param {{ fusionDepth?: number, concurrency?: number, onProgress?: function }} [opts]
|
|
226
|
+
* @returns {Promise<import('./schema.cjs').PanelResponse[]>}
|
|
227
|
+
*/
|
|
228
|
+
async function fanOut(prompt, modelIds, cfg, opts) {
|
|
229
|
+
const fusionDepth = (opts && opts.fusionDepth != null) ? opts.fusionDepth : 1;
|
|
230
|
+
const concurrency = (opts && opts.concurrency != null)
|
|
231
|
+
? opts.concurrency
|
|
232
|
+
: Math.min(modelIds.length, (cfg && cfg.concurrency) || 4);
|
|
233
|
+
const onProgress = (opts && typeof opts.onProgress === 'function') ? opts.onProgress : null;
|
|
234
|
+
|
|
235
|
+
const total = modelIds.length;
|
|
236
|
+
let done = 0;
|
|
237
|
+
|
|
238
|
+
const settled = await mapLimit(modelIds, concurrency, (id) => {
|
|
239
|
+
const adapter = cfg && cfg.adapters && cfg.adapters[id];
|
|
240
|
+
// undefined adapter -> spawnOne resolves a failed PanelResponse
|
|
241
|
+
return spawnOne(prompt, adapter, {
|
|
242
|
+
timeoutMs: (cfg && cfg.timeoutMs) || 180000,
|
|
243
|
+
fusionDepth,
|
|
244
|
+
}).then((result) => {
|
|
245
|
+
done++;
|
|
246
|
+
if (onProgress) {
|
|
247
|
+
try {
|
|
248
|
+
onProgress({ phase: 'panel-progress', done, total, model: result.model, ms: result.durationMs });
|
|
249
|
+
} catch (_) {}
|
|
250
|
+
}
|
|
251
|
+
return result;
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
return settled.map((s, i) => {
|
|
256
|
+
if (s.ok) return s.value;
|
|
257
|
+
// spawnOne never rejects, but be defensive
|
|
258
|
+
const adapter = cfg && cfg.adapters && cfg.adapters[modelIds[i]];
|
|
259
|
+
return {
|
|
260
|
+
model: adapter ? adapter.model : modelIds[i],
|
|
261
|
+
content: '', ok: false, durationMs: 0, tokensEst: 0,
|
|
262
|
+
error: String(s.error),
|
|
263
|
+
};
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
module.exports = { spawnOne, fanOut, unsafeForShellArg };
|