@triflux/remote 10.0.0-alpha.1 → 10.0.0-alpha.2
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/hub/index.mjs +21 -0
- package/hub/pipe.mjs +98 -13
- package/hub/server.mjs +1245 -1124
- package/hub/store-adapter.mjs +14 -747
- package/hub/store.mjs +4 -44
- package/hub/team/backend.mjs +1 -1
- package/hub/team/cli/services/hub-client.mjs +38 -19
- package/hub/team/cli/services/native-control.mjs +1 -1
- package/hub/team/conductor.mjs +671 -0
- package/hub/team/event-log.mjs +76 -0
- package/hub/team/headless.mjs +8 -6
- package/hub/team/health-probe.mjs +272 -0
- package/hub/team/launcher-template.mjs +95 -0
- package/hub/team/lead-control.mjs +104 -0
- package/hub/team/nativeProxy.mjs +9 -2
- package/hub/team/notify.mjs +293 -0
- package/hub/team/pane.mjs +1 -1
- package/hub/team/process-cleanup.mjs +342 -0
- package/hub/team/psmux.mjs +1 -1
- package/hub/team/remote-probe.mjs +276 -0
- package/hub/team/remote-watcher.mjs +478 -0
- package/hub/team/session-sync.mjs +169 -0
- package/hub/team/staleState.mjs +1 -1
- package/hub/team/swarm-hypervisor.mjs +554 -0
- package/hub/team/swarm-locks.mjs +204 -0
- package/hub/team/swarm-planner.mjs +256 -0
- package/hub/team/swarm-reconciler.mjs +137 -0
- package/hub/team/tui-remote-adapter.mjs +393 -0
- package/hub/team/tui.mjs +206 -2
- package/hub/team/worktree-lifecycle.mjs +172 -0
- package/hub/tools.mjs +94 -12
- package/hub/tray.mjs +1 -1
- package/hub/workers/codex-mcp.mjs +8 -2
- package/hub/workers/gemini-worker.mjs +2 -1
- package/package.json +1 -1
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// hub/team/event-log.mjs — JSONL 블랙박스 리코더
|
|
2
|
+
// Conductor 세션 lifecycle 이벤트를 JSONL 파일에 기록한다.
|
|
3
|
+
// 기존 hub/server.mjs의 batch-events.jsonl(MCP 이벤트)과 독립. 공존.
|
|
4
|
+
|
|
5
|
+
import { createWriteStream, mkdirSync } from 'node:fs';
|
|
6
|
+
import { dirname } from 'node:path';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* JSONL event log 팩토리.
|
|
10
|
+
* @param {string} filePath — 로그 파일 경로 (.jsonl)
|
|
11
|
+
* @param {object} [opts]
|
|
12
|
+
* @param {string} [opts.sessionId] — 모든 이벤트에 자동 삽입할 세션 ID
|
|
13
|
+
* @returns {{ append, flush, close, filePath }}
|
|
14
|
+
*/
|
|
15
|
+
export function createEventLog(filePath, opts = {}) {
|
|
16
|
+
const { sessionId } = opts;
|
|
17
|
+
|
|
18
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
19
|
+
const stream = createWriteStream(filePath, { flags: 'a' });
|
|
20
|
+
|
|
21
|
+
let closed = false;
|
|
22
|
+
let pending = 0;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 이벤트를 JSONL 한 줄로 기록.
|
|
26
|
+
* @param {string} event — 이벤트 타입 (spawn, health, kill, stateChange, ...)
|
|
27
|
+
* @param {object} [data] — 이벤트 페이로드
|
|
28
|
+
*/
|
|
29
|
+
function append(event, data = {}) {
|
|
30
|
+
if (closed) return;
|
|
31
|
+
const entry = {
|
|
32
|
+
ts: new Date().toISOString(),
|
|
33
|
+
...(sessionId ? { session: sessionId } : {}),
|
|
34
|
+
event,
|
|
35
|
+
...data,
|
|
36
|
+
};
|
|
37
|
+
pending += 1;
|
|
38
|
+
stream.write(JSON.stringify(entry) + '\n', () => { pending -= 1; });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 버퍼된 이벤트를 디스크에 flush.
|
|
43
|
+
* @returns {Promise<void>}
|
|
44
|
+
*/
|
|
45
|
+
function flush() {
|
|
46
|
+
if (closed) return Promise.resolve();
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
stream.once('error', reject);
|
|
49
|
+
stream.write('', () => {
|
|
50
|
+
stream.removeListener('error', reject);
|
|
51
|
+
resolve();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 스트림 종료. flush 후 close.
|
|
58
|
+
* @returns {Promise<void>}
|
|
59
|
+
*/
|
|
60
|
+
function close() {
|
|
61
|
+
if (closed) return Promise.resolve();
|
|
62
|
+
closed = true;
|
|
63
|
+
return new Promise((resolve) => {
|
|
64
|
+
stream.end(() => resolve());
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return Object.freeze({
|
|
69
|
+
append,
|
|
70
|
+
flush,
|
|
71
|
+
close,
|
|
72
|
+
get filePath() { return filePath; },
|
|
73
|
+
get pending() { return pending; },
|
|
74
|
+
get closed() { return closed; },
|
|
75
|
+
});
|
|
76
|
+
}
|
package/hub/team/headless.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { tmpdir } from "node:os";
|
|
|
9
9
|
import { execSync, spawn } from "node:child_process";
|
|
10
10
|
import { createRequire } from "node:module";
|
|
11
11
|
import { randomUUID } from "node:crypto";
|
|
12
|
+
import { escapePwshSingleQuoted } from "@triflux/core/hub/cli-adapter-base.mjs";
|
|
12
13
|
import {
|
|
13
14
|
createPsmuxSession,
|
|
14
15
|
killPsmuxSession,
|
|
@@ -26,11 +27,6 @@ import { createLogDashboard } from "./tui.mjs";
|
|
|
26
27
|
|
|
27
28
|
const RESULT_DIR = join(tmpdir(), "tfx-headless");
|
|
28
29
|
|
|
29
|
-
// remote-spawn.mjs의 escapePwshSingleQuoted와 동일 — 순환 의존 방지를 위해 인라인
|
|
30
|
-
function escapePwshSingleQuoted(value) {
|
|
31
|
-
return String(value).replace(/'/g, "''");
|
|
32
|
-
}
|
|
33
|
-
|
|
34
30
|
/** CLI별 브랜드 — 이모지 + 공식 색상 (HUD와 통일) */
|
|
35
31
|
const CLI_BRAND = {
|
|
36
32
|
codex: { emoji: "\u{26AA}", label: "Codex", ansi: "\x1b[97m" }, // ⚪ bright white (codexWhite)
|
|
@@ -121,7 +117,13 @@ function readResult(resultFile, paneId) {
|
|
|
121
117
|
if (existsSync(resultFile)) {
|
|
122
118
|
return readFileSync(resultFile, "utf8").trim();
|
|
123
119
|
}
|
|
124
|
-
// fallback:
|
|
120
|
+
// fallback 1: stderr 파일 (codex 실패 시 원인 추적)
|
|
121
|
+
const errFile = `${resultFile}.err`;
|
|
122
|
+
if (existsSync(errFile)) {
|
|
123
|
+
const stderr = readFileSync(errFile, "utf8").trim();
|
|
124
|
+
if (stderr) return `[stderr] ${stderr}`;
|
|
125
|
+
}
|
|
126
|
+
// fallback 2: capture-pane (paneId = "tfx:0.1" 형태)
|
|
125
127
|
return capturePsmuxPane(paneId, 30);
|
|
126
128
|
}
|
|
127
129
|
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// hub/team/health-probe.mjs — 4단계 health model + INPUT_WAIT 감지
|
|
2
|
+
// 기존 cli-adapter-base.mjs:stallThresholdMs(30s)와 headless.mjs:STALL_DEFAULTS(120s)를
|
|
3
|
+
// 4단계 probe 모델로 교체. stdout+stderr 통합 스트림으로 평가 (F3 해결).
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Health probe level 정의.
|
|
7
|
+
* L0: Process alive (PID 존재 + exit code 없음)
|
|
8
|
+
* L1: Output advancing (stdout+stderr 통합, 30s)
|
|
9
|
+
* L1.5: INPUT_WAIT 감지 (질문 패턴이면 stall이 아니라 input-wait)
|
|
10
|
+
* L2: MCP connected (opt-in, heartbeat, 30s)
|
|
11
|
+
* L3: Prompt acknowledged (첫 tool call/텍스트, 120s)
|
|
12
|
+
*/
|
|
13
|
+
export const PROBE_LEVELS = Object.freeze({
|
|
14
|
+
L0: 'alive',
|
|
15
|
+
L1: 'advancing',
|
|
16
|
+
L2: 'mcp_connected',
|
|
17
|
+
L3: 'prompt_ack',
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/** 기본 설정 (기존 stallThresholdMs/stallTimeout 값 계승) */
|
|
21
|
+
export const PROBE_DEFAULTS = Object.freeze({
|
|
22
|
+
intervalMs: 5_000,
|
|
23
|
+
probeTimeoutMs: 5_000,
|
|
24
|
+
l1ThresholdMs: 30_000,
|
|
25
|
+
l2ThresholdMs: 30_000,
|
|
26
|
+
l3ThresholdMs: 120_000,
|
|
27
|
+
enableL2: false,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* stdin 입력 대기 패턴 (Codex 질문 블로킹 감지)
|
|
32
|
+
* Codex가 질문하며 stdin을 기다리는 경우 stall이 아니라 INPUT_WAIT로 분류.
|
|
33
|
+
*/
|
|
34
|
+
const INPUT_WAIT_PATTERNS = [
|
|
35
|
+
/\?\s*$/m, // 물음표로 끝나는 줄
|
|
36
|
+
/\b(y\/n|yes\/no)\b/i, // y/n 프롬프트
|
|
37
|
+
/\b(choose|select|pick)\b.*:/i, // choose/select 프롬프트
|
|
38
|
+
/\b(confirm|approve|proceed)\b/i, // confirm 프롬프트
|
|
39
|
+
/\b(enter|input|type)\b.*:/i, // 입력 요청
|
|
40
|
+
/\[.*\]:\s*$/m, // [default]: 형태
|
|
41
|
+
/>\s*$/m, // > 프롬프트
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 최근 output에서 INPUT_WAIT 패턴 감지.
|
|
46
|
+
* @param {string} recentOutput — 최근 stdout+stderr 통합 텍스트
|
|
47
|
+
* @returns {{ detected: boolean, pattern: string|null }}
|
|
48
|
+
*/
|
|
49
|
+
export function detectInputWait(recentOutput) {
|
|
50
|
+
if (!recentOutput) return { detected: false, pattern: null };
|
|
51
|
+
// 마지막 5줄만 검사 (전체 output이 아닌 최근 출력)
|
|
52
|
+
const lines = recentOutput.split(/\r?\n/).filter(Boolean).slice(-5).join('\n');
|
|
53
|
+
for (const re of INPUT_WAIT_PATTERNS) {
|
|
54
|
+
if (re.test(lines)) {
|
|
55
|
+
return { detected: true, pattern: re.source };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return { detected: false, pattern: null };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Health probe 팩토리.
|
|
63
|
+
* @param {object} session — probe 대상 세션 상태 객체
|
|
64
|
+
* @param {number|null} session.pid — 프로세스 PID
|
|
65
|
+
* @param {function} session.getRecentOutput — () => string (최근 stdout+stderr)
|
|
66
|
+
* @param {function} session.getOutputBytes — () => number (총 output 바이트)
|
|
67
|
+
* @param {boolean} [session.alive] — 프로세스 alive 여부 (외부 업데이트)
|
|
68
|
+
* @param {object} [opts] — PROBE_DEFAULTS 오버라이드
|
|
69
|
+
* @param {function} [opts.onProbe] — (level, result) => void 콜백
|
|
70
|
+
* @param {function} [opts.checkMcp] — () => Promise<boolean> MCP heartbeat 체커 (L2용)
|
|
71
|
+
* @returns {{ start, stop, probe, getStatus }}
|
|
72
|
+
*/
|
|
73
|
+
export function createHealthProbe(session, opts = {}) {
|
|
74
|
+
const config = { ...PROBE_DEFAULTS, ...opts };
|
|
75
|
+
let timer = null;
|
|
76
|
+
let started = false;
|
|
77
|
+
|
|
78
|
+
// L1 tracking
|
|
79
|
+
let lastOutputBytes = 0;
|
|
80
|
+
let lastOutputChangeAt = Date.now();
|
|
81
|
+
|
|
82
|
+
// L3 tracking
|
|
83
|
+
let promptAcked = false;
|
|
84
|
+
let spawnedAt = Date.now();
|
|
85
|
+
|
|
86
|
+
const status = {
|
|
87
|
+
l0: null, // 'ok' | 'fail'
|
|
88
|
+
l1: null, // 'ok' | 'stall' | 'input_wait'
|
|
89
|
+
l2: null, // 'ok' | 'fail' | 'skip'
|
|
90
|
+
l3: null, // 'ok' | 'timeout'
|
|
91
|
+
lastProbeAt: null,
|
|
92
|
+
inputWaitPattern: null,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* L0: Process alive check.
|
|
97
|
+
*/
|
|
98
|
+
function probeL0() {
|
|
99
|
+
const alive = session.alive !== undefined
|
|
100
|
+
? session.alive
|
|
101
|
+
: (session.pid != null && session.pid > 0);
|
|
102
|
+
status.l0 = alive ? 'ok' : 'fail';
|
|
103
|
+
return status.l0;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* L1 + L1.5: Output advancing + INPUT_WAIT 감지.
|
|
108
|
+
*/
|
|
109
|
+
function probeL1() {
|
|
110
|
+
const currentBytes = typeof session.getOutputBytes === 'function'
|
|
111
|
+
? session.getOutputBytes()
|
|
112
|
+
: 0;
|
|
113
|
+
|
|
114
|
+
const now = Date.now();
|
|
115
|
+
|
|
116
|
+
if (currentBytes !== lastOutputBytes) {
|
|
117
|
+
lastOutputBytes = currentBytes;
|
|
118
|
+
lastOutputChangeAt = now;
|
|
119
|
+
status.l1 = 'ok';
|
|
120
|
+
status.inputWaitPattern = null;
|
|
121
|
+
return 'ok';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const silenceMs = now - lastOutputChangeAt;
|
|
125
|
+
|
|
126
|
+
if (silenceMs >= config.l1ThresholdMs) {
|
|
127
|
+
// L1.5: INPUT_WAIT 감지 — stall 전에 질문 패턴 체크
|
|
128
|
+
const recentOutput = typeof session.getRecentOutput === 'function'
|
|
129
|
+
? session.getRecentOutput()
|
|
130
|
+
: '';
|
|
131
|
+
const inputWait = detectInputWait(recentOutput);
|
|
132
|
+
|
|
133
|
+
if (inputWait.detected) {
|
|
134
|
+
status.l1 = 'input_wait';
|
|
135
|
+
status.inputWaitPattern = inputWait.pattern;
|
|
136
|
+
return 'input_wait';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
status.l1 = 'stall';
|
|
140
|
+
status.inputWaitPattern = null;
|
|
141
|
+
return 'stall';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 아직 threshold 미달
|
|
145
|
+
status.l1 = 'ok';
|
|
146
|
+
return 'ok';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* L2: MCP connected (opt-in).
|
|
151
|
+
*/
|
|
152
|
+
async function probeL2() {
|
|
153
|
+
if (!config.enableL2) {
|
|
154
|
+
status.l2 = 'skip';
|
|
155
|
+
return 'skip';
|
|
156
|
+
}
|
|
157
|
+
if (typeof config.checkMcp !== 'function') {
|
|
158
|
+
status.l2 = 'skip';
|
|
159
|
+
return 'skip';
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
const connected = await Promise.race([
|
|
163
|
+
config.checkMcp(),
|
|
164
|
+
new Promise((_, reject) =>
|
|
165
|
+
setTimeout(() => reject(new Error('probe_timeout')), config.probeTimeoutMs)
|
|
166
|
+
),
|
|
167
|
+
]);
|
|
168
|
+
status.l2 = connected ? 'ok' : 'fail';
|
|
169
|
+
} catch {
|
|
170
|
+
status.l2 = 'fail';
|
|
171
|
+
}
|
|
172
|
+
return status.l2;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* L3: Prompt acknowledged.
|
|
177
|
+
* 첫 번째 meaningful output이 나오면 ack.
|
|
178
|
+
*/
|
|
179
|
+
function probeL3() {
|
|
180
|
+
if (promptAcked) {
|
|
181
|
+
status.l3 = 'ok';
|
|
182
|
+
return 'ok';
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const currentBytes = typeof session.getOutputBytes === 'function'
|
|
186
|
+
? session.getOutputBytes()
|
|
187
|
+
: 0;
|
|
188
|
+
|
|
189
|
+
if (currentBytes > 0) {
|
|
190
|
+
promptAcked = true;
|
|
191
|
+
status.l3 = 'ok';
|
|
192
|
+
return 'ok';
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const elapsed = Date.now() - spawnedAt;
|
|
196
|
+
if (elapsed >= config.l3ThresholdMs) {
|
|
197
|
+
status.l3 = 'timeout';
|
|
198
|
+
return 'timeout';
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
status.l3 = null; // 아직 판정 전
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* 전체 probe 실행 (L0→L1→L2→L3).
|
|
207
|
+
* @returns {Promise<object>} probe 결과
|
|
208
|
+
*/
|
|
209
|
+
async function probe() {
|
|
210
|
+
const result = {
|
|
211
|
+
l0: probeL0(),
|
|
212
|
+
l1: probeL1(),
|
|
213
|
+
l2: await probeL2(),
|
|
214
|
+
l3: probeL3(),
|
|
215
|
+
inputWaitPattern: status.inputWaitPattern,
|
|
216
|
+
ts: Date.now(),
|
|
217
|
+
};
|
|
218
|
+
status.lastProbeAt = result.ts;
|
|
219
|
+
|
|
220
|
+
if (typeof config.onProbe === 'function') {
|
|
221
|
+
config.onProbe(result);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return result;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function start() {
|
|
228
|
+
if (started) return;
|
|
229
|
+
started = true;
|
|
230
|
+
spawnedAt = Date.now();
|
|
231
|
+
lastOutputChangeAt = Date.now();
|
|
232
|
+
lastOutputBytes = 0;
|
|
233
|
+
promptAcked = false;
|
|
234
|
+
|
|
235
|
+
timer = setInterval(() => { void probe(); }, config.intervalMs);
|
|
236
|
+
timer.unref?.();
|
|
237
|
+
|
|
238
|
+
// 즉시 첫 probe 실행
|
|
239
|
+
void probe();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function stop() {
|
|
243
|
+
if (!started) return;
|
|
244
|
+
started = false;
|
|
245
|
+
if (timer) {
|
|
246
|
+
clearInterval(timer);
|
|
247
|
+
timer = null;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** L1 tracking 리셋 (restart 후 호출) */
|
|
252
|
+
function resetTracking() {
|
|
253
|
+
lastOutputBytes = 0;
|
|
254
|
+
lastOutputChangeAt = Date.now();
|
|
255
|
+
promptAcked = false;
|
|
256
|
+
spawnedAt = Date.now();
|
|
257
|
+
status.l0 = null;
|
|
258
|
+
status.l1 = null;
|
|
259
|
+
status.l2 = null;
|
|
260
|
+
status.l3 = null;
|
|
261
|
+
status.inputWaitPattern = null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return Object.freeze({
|
|
265
|
+
start,
|
|
266
|
+
stop,
|
|
267
|
+
probe,
|
|
268
|
+
resetTracking,
|
|
269
|
+
getStatus: () => ({ ...status }),
|
|
270
|
+
get started() { return started; },
|
|
271
|
+
});
|
|
272
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// hub/team/launcher-template.mjs — 결정론적 런처 생성
|
|
2
|
+
// 기존 codex-adapter/gemini-adapter의 buildExecArgs를 소비하여
|
|
3
|
+
// 동일 입력 → 동일 args 배열을 보장한다.
|
|
4
|
+
// F1 해결: codex adapter가 --dangerously-bypass-approvals-and-sandbox 자동 추가
|
|
5
|
+
// F4 해결: codex exec "prompt" 인라인 (파이프/리다이렉트 아님)
|
|
6
|
+
// F5 해결: 동일 입력 → 동일 args 배열 (런타임 분기 없음)
|
|
7
|
+
|
|
8
|
+
import { buildExecArgs as buildCodexArgs } from '@triflux/core/hub/codex-adapter.mjs';
|
|
9
|
+
import { buildExecArgs as buildGeminiArgs } from '@triflux/core/hub/gemini-adapter.mjs';
|
|
10
|
+
|
|
11
|
+
/** CLI별 adapter 레지스트리 */
|
|
12
|
+
const ADAPTERS = Object.freeze({
|
|
13
|
+
codex: {
|
|
14
|
+
bin: 'codex',
|
|
15
|
+
buildArgs: buildCodexArgs,
|
|
16
|
+
env: (profile) => (profile ? { CODEX_PROFILE: profile } : {}),
|
|
17
|
+
},
|
|
18
|
+
gemini: {
|
|
19
|
+
bin: 'gemini',
|
|
20
|
+
buildArgs: buildGeminiArgs,
|
|
21
|
+
env: () => ({}),
|
|
22
|
+
},
|
|
23
|
+
claude: {
|
|
24
|
+
bin: 'claude',
|
|
25
|
+
buildArgs: (opts = {}) => {
|
|
26
|
+
const parts = ['claude'];
|
|
27
|
+
if (opts.model) parts.push('--model', opts.model);
|
|
28
|
+
parts.push('-p', JSON.stringify(opts.prompt || ''));
|
|
29
|
+
return parts.join(' ');
|
|
30
|
+
},
|
|
31
|
+
env: () => ({}),
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* CLI adapter 조회.
|
|
37
|
+
* @param {'codex'|'gemini'|'claude'} agent
|
|
38
|
+
* @returns {object} adapter — { bin, buildArgs, env }
|
|
39
|
+
* @throws {Error} 알 수 없는 agent
|
|
40
|
+
*/
|
|
41
|
+
export function getAdapter(agent) {
|
|
42
|
+
const adapter = ADAPTERS[agent];
|
|
43
|
+
if (!adapter) {
|
|
44
|
+
throw new Error(`Unknown agent: "${agent}". Supported: ${Object.keys(ADAPTERS).join(', ')}`);
|
|
45
|
+
}
|
|
46
|
+
return adapter;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 결정론적 런처 생성.
|
|
51
|
+
* 동일 입력이면 항상 동일한 { bin, command, env } 반환.
|
|
52
|
+
*
|
|
53
|
+
* @param {object} opts
|
|
54
|
+
* @param {'codex'|'gemini'|'claude'} opts.agent — CLI 타입
|
|
55
|
+
* @param {string} [opts.profile] — CLI 프로파일
|
|
56
|
+
* @param {string} opts.prompt — 실행할 프롬프트
|
|
57
|
+
* @param {string} [opts.workdir] — 작업 디렉토리
|
|
58
|
+
* @param {string} [opts.model] — 모델 오버라이드
|
|
59
|
+
* @param {string} [opts.resultFile] — 결과 저장 경로
|
|
60
|
+
* @returns {{ bin: string, command: string, env: object, agent: string }}
|
|
61
|
+
*/
|
|
62
|
+
export function buildLauncher(opts) {
|
|
63
|
+
const { agent, profile, prompt, workdir, model, resultFile, mcpServers } = opts;
|
|
64
|
+
|
|
65
|
+
if (!agent) throw new Error('agent is required');
|
|
66
|
+
if (!prompt && prompt !== '') throw new Error('prompt is required');
|
|
67
|
+
|
|
68
|
+
const adapter = getAdapter(agent);
|
|
69
|
+
|
|
70
|
+
const command = adapter.buildArgs({
|
|
71
|
+
prompt,
|
|
72
|
+
profile,
|
|
73
|
+
model,
|
|
74
|
+
resultFile,
|
|
75
|
+
workdir,
|
|
76
|
+
mcpServers,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const env = adapter.env(profile);
|
|
80
|
+
|
|
81
|
+
return Object.freeze({
|
|
82
|
+
bin: adapter.bin,
|
|
83
|
+
command,
|
|
84
|
+
env,
|
|
85
|
+
agent,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 지원되는 agent 목록.
|
|
91
|
+
* @returns {string[]}
|
|
92
|
+
*/
|
|
93
|
+
export function listAgents() {
|
|
94
|
+
return Object.keys(ADAPTERS);
|
|
95
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
const DEFAULT_TIMEOUT_MS = 2500;
|
|
2
|
+
const CONTROL_COMMAND_ALIASES = Object.freeze({
|
|
3
|
+
stop: "abort",
|
|
4
|
+
interrupt: "abort",
|
|
5
|
+
});
|
|
6
|
+
|
|
7
|
+
export const LEAD_CONTROL_COMMANDS = Object.freeze(["pause", "resume", "abort", "reassign"]);
|
|
8
|
+
|
|
9
|
+
function resolveFetch(fetchImpl) {
|
|
10
|
+
if (typeof fetchImpl === "function") return fetchImpl;
|
|
11
|
+
if (typeof globalThis.fetch === "function") return globalThis.fetch.bind(globalThis);
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function normalizeHubBaseUrl(hubUrl) {
|
|
16
|
+
return String(hubUrl || "").replace(/\/+$/, "").replace(/\/mcp$/, "");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function normalizeCommand(command) {
|
|
20
|
+
const raw = String(command || "").trim().toLowerCase();
|
|
21
|
+
if (!raw) return "";
|
|
22
|
+
return CONTROL_COMMAND_ALIASES[raw] || raw;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function safeAbortSignal(timeoutMs) {
|
|
26
|
+
if (typeof AbortSignal?.timeout !== "function") return undefined;
|
|
27
|
+
return AbortSignal.timeout(timeoutMs);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function safeJson(res) {
|
|
31
|
+
return res.json().catch(() => ({}));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function publishLeadControl({
|
|
35
|
+
hubUrl,
|
|
36
|
+
fromAgent = "lead",
|
|
37
|
+
toAgent,
|
|
38
|
+
command,
|
|
39
|
+
reason = "",
|
|
40
|
+
payload = {},
|
|
41
|
+
traceId,
|
|
42
|
+
correlationId,
|
|
43
|
+
ttlMs = 3600000,
|
|
44
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
45
|
+
fetchImpl,
|
|
46
|
+
} = {}) {
|
|
47
|
+
const requestFetch = resolveFetch(fetchImpl);
|
|
48
|
+
if (!requestFetch) {
|
|
49
|
+
return { ok: false, error: "FETCH_UNAVAILABLE" };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const hubBase = normalizeHubBaseUrl(hubUrl);
|
|
53
|
+
if (!hubBase) {
|
|
54
|
+
return { ok: false, error: "HUB_URL_REQUIRED" };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const targetAgent = String(toAgent || "").trim();
|
|
58
|
+
if (!targetAgent) {
|
|
59
|
+
return { ok: false, error: "TARGET_AGENT_REQUIRED" };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const normalizedCommand = normalizeCommand(command);
|
|
63
|
+
if (!LEAD_CONTROL_COMMANDS.includes(normalizedCommand)) {
|
|
64
|
+
return {
|
|
65
|
+
ok: false,
|
|
66
|
+
error: "INVALID_COMMAND",
|
|
67
|
+
allowed: LEAD_CONTROL_COMMANDS,
|
|
68
|
+
command: normalizedCommand,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const res = await requestFetch(`${hubBase}/bridge/control`, {
|
|
74
|
+
method: "POST",
|
|
75
|
+
headers: { "Content-Type": "application/json" },
|
|
76
|
+
body: JSON.stringify({
|
|
77
|
+
from_agent: String(fromAgent || "lead"),
|
|
78
|
+
to_agent: targetAgent,
|
|
79
|
+
command: normalizedCommand,
|
|
80
|
+
reason: String(reason || ""),
|
|
81
|
+
payload: payload && typeof payload === "object" ? payload : {},
|
|
82
|
+
ttl_ms: ttlMs,
|
|
83
|
+
trace_id: traceId,
|
|
84
|
+
correlation_id: correlationId,
|
|
85
|
+
}),
|
|
86
|
+
signal: safeAbortSignal(timeoutMs),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const body = await safeJson(res);
|
|
90
|
+
return {
|
|
91
|
+
ok: res.ok && body?.ok !== false,
|
|
92
|
+
status: res.status,
|
|
93
|
+
body,
|
|
94
|
+
command: normalizedCommand,
|
|
95
|
+
};
|
|
96
|
+
} catch (error) {
|
|
97
|
+
return {
|
|
98
|
+
ok: false,
|
|
99
|
+
error: "CONTROL_PUBLISH_FAILED",
|
|
100
|
+
message: error?.message || "control publish failed",
|
|
101
|
+
command: normalizedCommand,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
}
|
package/hub/team/nativeProxy.mjs
CHANGED
|
@@ -19,8 +19,8 @@ import {
|
|
|
19
19
|
import { basename, dirname, join } from 'node:path';
|
|
20
20
|
import { homedir } from 'node:os';
|
|
21
21
|
import { randomUUID } from 'node:crypto';
|
|
22
|
-
import { isPidAlive } from '
|
|
23
|
-
import { IS_WINDOWS } from '
|
|
22
|
+
import { isPidAlive } from '@triflux/core/hub/lib/process-utils.mjs';
|
|
23
|
+
import { IS_WINDOWS } from '@triflux/core/hub/platform.mjs';
|
|
24
24
|
|
|
25
25
|
const TEAM_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
26
26
|
const CLAUDE_HOME = join(homedir(), '.claude');
|
|
@@ -679,3 +679,10 @@ export async function teamSendMessage(args = {}) {
|
|
|
679
679
|
return err('SEND_MESSAGE_FAILED', e.message);
|
|
680
680
|
}
|
|
681
681
|
}
|
|
682
|
+
|
|
683
|
+
export const nativeProxy = Object.freeze({
|
|
684
|
+
teamInfo,
|
|
685
|
+
teamTaskList,
|
|
686
|
+
teamTaskUpdate,
|
|
687
|
+
teamSendMessage,
|
|
688
|
+
});
|