@triflux/remote 10.1.0 → 10.26.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.
Files changed (162) hide show
  1. package/hub/pipe.mjs +239 -121
  2. package/hub/public/dashboard.html +61 -0
  3. package/hub/server.mjs +2179 -709
  4. package/hub/store-adapter.mjs +59 -26
  5. package/hub/store.mjs +356 -153
  6. package/hub/team/agent-map.json +26 -7
  7. package/hub/team/ansi.mjs +245 -146
  8. package/hub/team/backend.mjs +78 -17
  9. package/hub/team/build-worker-prompt.mjs +73 -0
  10. package/hub/team/check-mcp-hub.mjs +49 -0
  11. package/hub/team/claude-daemon-control.mjs +1130 -0
  12. package/hub/team/claude-native-bridge.mjs +1011 -0
  13. package/hub/team/claude-session-projection.mjs +74 -0
  14. package/hub/team/cli/commands/attach.mjs +57 -37
  15. package/hub/team/cli/commands/control.mjs +29 -8
  16. package/hub/team/cli/commands/debug.mjs +95 -74
  17. package/hub/team/cli/commands/focus.mjs +80 -53
  18. package/hub/team/cli/commands/interrupt.mjs +18 -6
  19. package/hub/team/cli/commands/kill.mjs +16 -5
  20. package/hub/team/cli/commands/list.mjs +31 -24
  21. package/hub/team/cli/commands/send.mjs +19 -6
  22. package/hub/team/cli/commands/start/index.mjs +151 -33
  23. package/hub/team/cli/commands/start/parse-args.mjs +97 -13
  24. package/hub/team/cli/commands/start/start-headless.mjs +155 -37
  25. package/hub/team/cli/commands/start/start-in-process.mjs +50 -40
  26. package/hub/team/cli/commands/start/start-mux.mjs +122 -73
  27. package/hub/team/cli/commands/start/start-wt.mjs +86 -69
  28. package/hub/team/cli/commands/status.mjs +43 -14
  29. package/hub/team/cli/commands/stop.mjs +11 -4
  30. package/hub/team/cli/commands/task.mjs +8 -3
  31. package/hub/team/cli/commands/tasks.mjs +13 -13
  32. package/hub/team/cli/help.mjs +1 -1
  33. package/hub/team/cli/index.mjs +2 -2
  34. package/hub/team/cli/manifest.mjs +38 -8
  35. package/hub/team/cli/render.mjs +52 -30
  36. package/hub/team/cli/services/attach-fallback.mjs +74 -54
  37. package/hub/team/cli/services/hub-client.mjs +44 -16
  38. package/hub/team/cli/services/member-selector.mjs +37 -30
  39. package/hub/team/cli/services/native-control.mjs +144 -116
  40. package/hub/team/cli/services/runtime-mode.mjs +61 -4
  41. package/hub/team/cli/services/state-store.mjs +66 -9
  42. package/hub/team/cli/services/task-model.mjs +40 -30
  43. package/hub/team/codex-review.mjs +521 -0
  44. package/hub/team/conductor-mesh-bridge.mjs +25 -24
  45. package/hub/team/conductor-registry.mjs +121 -0
  46. package/hub/team/conductor.mjs +1379 -671
  47. package/hub/team/consensus-meta.mjs +176 -0
  48. package/hub/team/dashboard-anchor.mjs +4 -5
  49. package/hub/team/dashboard-layout.mjs +3 -1
  50. package/hub/team/dashboard-open.mjs +20 -118
  51. package/hub/team/dashboard.mjs +76 -28
  52. package/hub/team/event-log.mjs +18 -10
  53. package/hub/team/execution-mode.mjs +256 -0
  54. package/hub/team/extract-completion-payload.mjs +110 -0
  55. package/hub/team/git-preflight.mjs +286 -0
  56. package/hub/team/handoff.mjs +49 -18
  57. package/hub/team/headless-bridge-session.mjs +17 -0
  58. package/hub/team/headless.mjs +2213 -1156
  59. package/hub/team/health-probe.mjs +232 -69
  60. package/hub/team/interactive-native-launcher.mjs +106 -0
  61. package/hub/team/interactive-native-worker.mjs +80 -0
  62. package/hub/team/interactive-tui-transport.mjs +465 -0
  63. package/hub/team/launcher-template.mjs +49 -17
  64. package/hub/team/lead-control.mjs +91 -4
  65. package/hub/team/mcp-selector.mjs +145 -0
  66. package/hub/team/native-supervisor.mjs +90 -26
  67. package/hub/team/native.mjs +120 -42
  68. package/hub/team/nativeProxy.mjs +184 -113
  69. package/hub/team/notify.mjs +147 -78
  70. package/hub/team/orchestrator.mjs +173 -161
  71. package/hub/team/pane.mjs +43 -20
  72. package/hub/team/process-cleanup.mjs +69 -25
  73. package/hub/team/psmux.mjs +1022 -281
  74. package/hub/team/recovery-store.mjs +149 -0
  75. package/hub/team/remote-probe.mjs +68 -52
  76. package/hub/team/remote-session.mjs +131 -59
  77. package/hub/team/remote-watcher.mjs +61 -33
  78. package/hub/team/retry-state-machine.mjs +336 -0
  79. package/hub/team/routing.mjs +51 -25
  80. package/hub/team/runtime-strategy.mjs +200 -0
  81. package/hub/team/schemas/worker-completion.json +39 -0
  82. package/hub/team/sentinel-capture.mjs +127 -0
  83. package/hub/team/session-sync.mjs +153 -6
  84. package/hub/team/session.mjs +574 -611
  85. package/hub/team/shared.mjs +13 -13
  86. package/hub/team/staleState.mjs +72 -30
  87. package/hub/team/swarm-cli.mjs +390 -0
  88. package/hub/team/swarm-hypervisor.mjs +1544 -193
  89. package/hub/team/swarm-intent.mjs +216 -0
  90. package/hub/team/swarm-locks.mjs +140 -25
  91. package/hub/team/swarm-planner.mjs +121 -21
  92. package/hub/team/swarm-preflight.mjs +319 -0
  93. package/hub/team/swarm-reconciler.mjs +128 -23
  94. package/hub/team/synapse-cli.mjs +458 -0
  95. package/hub/team/synapse-http.mjs +60 -0
  96. package/hub/team/synapse-registry.mjs +329 -0
  97. package/hub/team/terminal-opener.mjs +216 -0
  98. package/hub/team/tui-core.mjs +292 -0
  99. package/hub/team/tui-lite.mjs +237 -148
  100. package/hub/team/tui-remote-adapter.mjs +18 -34
  101. package/hub/team/tui-synapse.mjs +223 -0
  102. package/hub/team/tui-viewer.mjs +108 -48
  103. package/hub/team/tui-widgets.mjs +262 -0
  104. package/hub/team/tui.mjs +931 -439
  105. package/hub/team/worker-completion-validator.mjs +84 -0
  106. package/hub/team/worker-sandbox.mjs +97 -0
  107. package/hub/team/worker-signal.mjs +263 -0
  108. package/hub/team/worker-signal.types.d.ts +52 -0
  109. package/hub/team/worktree-lifecycle.mjs +517 -63
  110. package/hub/team/wt-manager.mjs +297 -26
  111. package/hub/tools.mjs +477 -252
  112. package/hub/tray.mjs +411 -368
  113. package/hub/workers/antigravity-route-worker.mjs +169 -0
  114. package/hub/workers/claude-worker.mjs +162 -118
  115. package/hub/workers/codex-app-server-worker.mjs +1015 -0
  116. package/hub/workers/codex-mcp.mjs +724 -507
  117. package/hub/workers/delegator-mcp.mjs +570 -499
  118. package/hub/workers/factory.mjs +103 -21
  119. package/hub/workers/gemini-worker.mjs +126 -284
  120. package/hub/workers/interface.mjs +24 -2
  121. package/hub/workers/lib/jsonrpc-stdio.mjs +463 -0
  122. package/hub/workers/worker-utils.mjs +23 -17
  123. package/package.json +4 -3
  124. package/scripts/lib/agent-json.mjs +27 -0
  125. package/scripts/lib/async.mjs +174 -0
  126. package/scripts/lib/claudemd-scanner.mjs +223 -0
  127. package/scripts/lib/cli-agy.mjs +62 -0
  128. package/scripts/lib/cli-claude.mjs +78 -0
  129. package/scripts/lib/cli-codex.mjs +199 -0
  130. package/scripts/lib/cli-gemini.mjs +37 -0
  131. package/scripts/lib/codex-recovery.sh +50 -0
  132. package/scripts/lib/context.mjs +67 -0
  133. package/scripts/lib/cross-review-utils.mjs +59 -0
  134. package/scripts/lib/doctor-env-checks.mjs +121 -0
  135. package/scripts/lib/dynamic-route-cli.mjs +107 -0
  136. package/scripts/lib/env-probe.mjs +269 -0
  137. package/scripts/lib/env.mjs +14 -0
  138. package/scripts/lib/gemini-profiles.mjs +119 -0
  139. package/scripts/lib/handoff.mjs +187 -0
  140. package/scripts/lib/hook-utils.mjs +16 -0
  141. package/scripts/lib/hub.mjs +48 -0
  142. package/scripts/lib/keyword-rules.mjs +195 -0
  143. package/scripts/lib/logger.mjs +105 -0
  144. package/scripts/lib/mcp-filter.mjs +883 -0
  145. package/scripts/lib/mcp-gateway-health-check.mjs +159 -0
  146. package/scripts/lib/mcp-gateway-servers.mjs +70 -0
  147. package/scripts/lib/mcp-gateway-wrapper-check.mjs +35 -0
  148. package/scripts/lib/mcp-guard-engine.mjs +2120 -0
  149. package/scripts/lib/mcp-health.mjs +773 -0
  150. package/scripts/lib/mcp-manifest.mjs +89 -0
  151. package/scripts/lib/mcp-server-catalog.mjs +355 -0
  152. package/scripts/lib/pid.mjs +63 -0
  153. package/scripts/lib/process-utils.mjs +26 -0
  154. package/scripts/lib/psmux-info.mjs +224 -0
  155. package/scripts/lib/quota.mjs +47 -0
  156. package/scripts/lib/remote-spawn-transfer.mjs +226 -0
  157. package/scripts/lib/skill-state.mjs +220 -0
  158. package/scripts/lib/skill-template.mjs +375 -0
  159. package/scripts/lib/team.mjs +75 -0
  160. package/scripts/lib/timeout.mjs +66 -0
  161. package/scripts/lib/tmp.mjs +26 -0
  162. package/scripts/lib/toml.mjs +113 -0
@@ -2,6 +2,10 @@
2
2
  // 기존 cli-adapter-base.mjs:stallThresholdMs(30s)와 headless.mjs:STALL_DEFAULTS(120s)를
3
3
  // 4단계 probe 모델로 교체. stdout+stderr 통합 스트림으로 평가 (F3 해결).
4
4
 
5
+ import { mkdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { dirname, join } from "node:path";
8
+
5
9
  /**
6
10
  * Health probe level 정의.
7
11
  * L0: Process alive (PID 존재 + exit code 없음)
@@ -11,10 +15,10 @@
11
15
  * L3: Prompt acknowledged (첫 tool call/텍스트, 120s)
12
16
  */
13
17
  export const PROBE_LEVELS = Object.freeze({
14
- L0: 'alive',
15
- L1: 'advancing',
16
- L2: 'mcp_connected',
17
- L3: 'prompt_ack',
18
+ L0: "alive",
19
+ L1: "advancing",
20
+ L2: "mcp_connected",
21
+ L3: "prompt_ack",
18
22
  });
19
23
 
20
24
  /** 기본 설정 (기존 stallThresholdMs/stallTimeout 값 계승) */
@@ -24,7 +28,12 @@ export const PROBE_DEFAULTS = Object.freeze({
24
28
  l1ThresholdMs: 30_000,
25
29
  l2ThresholdMs: 30_000,
26
30
  l3ThresholdMs: 120_000,
27
- enableL2: false,
31
+ // #168 P3: default off → on. checkMcp 미주입 시에도 probeL2 가 skip 반환하므로
32
+ // safe. conductor wiring 이 checkMcp 를 주입하면 실제 L2 판정이 활성화된다.
33
+ // opt-out: TFX_PROBE_L2=0 (conductor 에서 false 주입).
34
+ enableL2: true,
35
+ writeStateFile: false,
36
+ stateDir: join(tmpdir(), "tfx-probe"),
28
37
  });
29
38
 
30
39
  /**
@@ -32,13 +41,13 @@ export const PROBE_DEFAULTS = Object.freeze({
32
41
  * Codex가 질문하며 stdin을 기다리는 경우 stall이 아니라 INPUT_WAIT로 분류.
33
42
  */
34
43
  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, // > 프롬프트
44
+ /\?\s*$/m, // 물음표로 끝나는 줄
45
+ /\b(y\/n|yes\/no)\b/i, // y/n 프롬프트
46
+ /\b(choose|select|pick)\b.*:/i, // choose/select 프롬프트
47
+ /\b(confirm|approve|proceed)\b/i, // confirm 프롬프트
48
+ /\b(enter|input|type)\b.*:/i, // 입력 요청
49
+ /\[.*\]:\s*$/m, // [default]: 형태
50
+ />\s*$/m, // > 프롬프트
42
51
  ];
43
52
 
44
53
  /**
@@ -49,7 +58,11 @@ const INPUT_WAIT_PATTERNS = [
49
58
  export function detectInputWait(recentOutput) {
50
59
  if (!recentOutput) return { detected: false, pattern: null };
51
60
  // 마지막 5줄만 검사 (전체 output이 아닌 최근 출력)
52
- const lines = recentOutput.split(/\r?\n/).filter(Boolean).slice(-5).join('\n');
61
+ const lines = recentOutput
62
+ .split(/\r?\n/)
63
+ .filter(Boolean)
64
+ .slice(-5)
65
+ .join("\n");
53
66
  for (const re of INPUT_WAIT_PATTERNS) {
54
67
  if (re.test(lines)) {
55
68
  return { detected: true, pattern: re.source };
@@ -74,6 +87,16 @@ export function createHealthProbe(session, opts = {}) {
74
87
  const config = { ...PROBE_DEFAULTS, ...opts };
75
88
  let timer = null;
76
89
  let started = false;
90
+ // stopped flag는 in-flight probe()가 stop() 사이의 await 점에서
91
+ // writeState/unlink 와 race 하는 것을 막는다. start() 시 false 로 reset.
92
+ let stopped = false;
93
+ // P0 (#167 review): start() 마다 증가. probe() 가 시작 시 캡처 → writeState() 가 epoch
94
+ // 비교로 stop()→start() 사이의 in-flight 가 새 run 의 state 를 덮는 race 차단.
95
+ // stopped flag 만으로는 start() 가 stopped=false 로 reset 한 직후 race 못 막음.
96
+ let runEpoch = 0;
97
+ // P1-1 (#167 review): Set 으로 모든 in-flight probe 추적. interval 이 빨라 N+1 이 N 끝나기
98
+ // 전에 시작되면 단일 var 는 N 을 덮어써 stopAndDrain() 시 N 이 누락된다.
99
+ const inFlightProbes = new Set();
77
100
 
78
101
  // L1 tracking
79
102
  let lastOutputBytes = 0;
@@ -84,22 +107,114 @@ export function createHealthProbe(session, opts = {}) {
84
107
  let spawnedAt = Date.now();
85
108
 
86
109
  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'
110
+ l0: null, // 'ok' | 'fail'
111
+ l1: null, // 'ok' | 'stall' | 'input_wait'
112
+ l2: null, // 'ok' | 'fail' | 'skip'
113
+ l3: null, // 'ok' | 'timeout'
91
114
  lastProbeAt: null,
92
115
  inputWaitPattern: null,
93
116
  };
94
117
 
118
+ function getStateFilePath() {
119
+ if (typeof config.stateFile === "string" && config.stateFile.length > 0) {
120
+ return config.stateFile;
121
+ }
122
+ const pid = session.pid;
123
+ if (pid == null || pid <= 0) return null;
124
+ return join(config.stateDir, `${pid}.json`);
125
+ }
126
+
127
+ function deriveState(result) {
128
+ if (result.l0 === "fail") return "exited";
129
+ if (result.l1 === "input_wait") return "input_wait";
130
+ if (result.l2 === "fail") return "mcp_initializing";
131
+ if (result.l1 === "stall") return "stalled";
132
+ if (result.l3 === "timeout") return "reasoning";
133
+ return "active";
134
+ }
135
+
136
+ function writeState(result, probeEpoch) {
137
+ // stop() 직후 in-flight probe()의 재생성 방지.
138
+ // P0 (#167): probeEpoch !== runEpoch 면 이전 run 의 stale probe 가 새 run 의 state 를
139
+ // 덮으려는 시도 → skip.
140
+ if (stopped) return;
141
+ if (probeEpoch !== undefined && probeEpoch !== runEpoch) return;
142
+ if (!config.writeStateFile && !config.stateFile) return;
143
+ const stateFile = getStateFilePath();
144
+ if (!stateFile) return;
145
+ const payload =
146
+ JSON.stringify(
147
+ {
148
+ pid: session.pid ?? null,
149
+ state: deriveState(result),
150
+ result,
151
+ updatedAt: new Date(result.ts).toISOString(),
152
+ },
153
+ null,
154
+ 2,
155
+ ) + "\n";
156
+ // tmp+rename 으로 atomic write — heartbeat 의 sed 가 부분 파일을 읽는 race 제거.
157
+ // tmp 는 같은 디렉토리에 둬야 EXDEV (cross-device link) 가 안 난다.
158
+ const tmpPath = `${stateFile}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
159
+ try {
160
+ mkdirSync(dirname(stateFile), { recursive: true });
161
+ writeFileSync(tmpPath, payload, "utf8");
162
+ try {
163
+ renameSync(tmpPath, stateFile);
164
+ } catch (renameErr) {
165
+ // Windows: 대상이 존재할 때 EPERM/EACCES.
166
+ // P1-2 (#167 review): backup-then-swap 으로 기존 파일 보존. 옛 unlinkSync→renameSync
167
+ // 패턴은 1차 unlink 후 2차 rename 실패 시 기존 파일과 tmp 둘 다 잃었다.
168
+ if (
169
+ renameErr?.code === "EEXIST" ||
170
+ renameErr?.code === "EPERM" ||
171
+ renameErr?.code === "EACCES"
172
+ ) {
173
+ const backupPath = `${stateFile}.old-${process.pid}-${Date.now()}`;
174
+ let backupCreated = false;
175
+ try {
176
+ renameSync(stateFile, backupPath);
177
+ backupCreated = true;
178
+ } catch {
179
+ // backup 실패 (대상 없음/잠김) — 그래도 진행 (tmp→stateFile 시도)
180
+ }
181
+ try {
182
+ renameSync(tmpPath, stateFile);
183
+ if (backupCreated) {
184
+ try {
185
+ unlinkSync(backupPath);
186
+ } catch {}
187
+ }
188
+ } catch (secondErr) {
189
+ // 2차도 실패 → backup 복구해서 기존 파일 보존
190
+ if (backupCreated) {
191
+ try {
192
+ renameSync(backupPath, stateFile);
193
+ } catch {}
194
+ }
195
+ throw secondErr;
196
+ }
197
+ } else {
198
+ throw renameErr;
199
+ }
200
+ }
201
+ } catch {
202
+ // probe state is advisory only — tmp cleanup
203
+ try {
204
+ unlinkSync(tmpPath);
205
+ } catch {}
206
+ }
207
+ }
208
+
95
209
  /**
96
210
  * L0: Process alive check.
97
211
  */
98
212
  function probeL0() {
99
- const alive = session.alive !== undefined
100
- ? session.alive
101
- : (session.pid != null && session.pid > 0);
102
- status.l0 = alive ? 'ok' : 'fail';
213
+ const alive =
214
+ session.alive !== undefined
215
+ ? session.alive
216
+ : session.pid != null && session.pid > 0;
217
+ status.l0 = alive ? "ok" : "fail";
103
218
  return status.l0;
104
219
  }
105
220
 
@@ -107,43 +222,45 @@ export function createHealthProbe(session, opts = {}) {
107
222
  * L1 + L1.5: Output advancing + INPUT_WAIT 감지.
108
223
  */
109
224
  function probeL1() {
110
- const currentBytes = typeof session.getOutputBytes === 'function'
111
- ? session.getOutputBytes()
112
- : 0;
225
+ const currentBytes =
226
+ typeof session.getOutputBytes === "function"
227
+ ? session.getOutputBytes()
228
+ : 0;
113
229
 
114
230
  const now = Date.now();
115
231
 
116
232
  if (currentBytes !== lastOutputBytes) {
117
233
  lastOutputBytes = currentBytes;
118
234
  lastOutputChangeAt = now;
119
- status.l1 = 'ok';
235
+ status.l1 = "ok";
120
236
  status.inputWaitPattern = null;
121
- return 'ok';
237
+ return "ok";
122
238
  }
123
239
 
124
240
  const silenceMs = now - lastOutputChangeAt;
125
241
 
126
242
  if (silenceMs >= config.l1ThresholdMs) {
127
243
  // L1.5: INPUT_WAIT 감지 — stall 전에 질문 패턴 체크
128
- const recentOutput = typeof session.getRecentOutput === 'function'
129
- ? session.getRecentOutput()
130
- : '';
244
+ const recentOutput =
245
+ typeof session.getRecentOutput === "function"
246
+ ? session.getRecentOutput()
247
+ : "";
131
248
  const inputWait = detectInputWait(recentOutput);
132
249
 
133
250
  if (inputWait.detected) {
134
- status.l1 = 'input_wait';
251
+ status.l1 = "input_wait";
135
252
  status.inputWaitPattern = inputWait.pattern;
136
- return 'input_wait';
253
+ return "input_wait";
137
254
  }
138
255
 
139
- status.l1 = 'stall';
256
+ status.l1 = "stall";
140
257
  status.inputWaitPattern = null;
141
- return 'stall';
258
+ return "stall";
142
259
  }
143
260
 
144
261
  // 아직 threshold 미달
145
- status.l1 = 'ok';
146
- return 'ok';
262
+ status.l1 = "ok";
263
+ return "ok";
147
264
  }
148
265
 
149
266
  /**
@@ -151,23 +268,26 @@ export function createHealthProbe(session, opts = {}) {
151
268
  */
152
269
  async function probeL2() {
153
270
  if (!config.enableL2) {
154
- status.l2 = 'skip';
155
- return 'skip';
271
+ status.l2 = "skip";
272
+ return "skip";
156
273
  }
157
- if (typeof config.checkMcp !== 'function') {
158
- status.l2 = 'skip';
159
- return 'skip';
274
+ if (typeof config.checkMcp !== "function") {
275
+ status.l2 = "skip";
276
+ return "skip";
160
277
  }
161
278
  try {
162
279
  const connected = await Promise.race([
163
280
  config.checkMcp(),
164
281
  new Promise((_, reject) =>
165
- setTimeout(() => reject(new Error('probe_timeout')), config.probeTimeoutMs)
282
+ setTimeout(
283
+ () => reject(new Error("probe_timeout")),
284
+ config.probeTimeoutMs,
285
+ ),
166
286
  ),
167
287
  ]);
168
- status.l2 = connected ? 'ok' : 'fail';
288
+ status.l2 = connected ? "ok" : "fail";
169
289
  } catch {
170
- status.l2 = 'fail';
290
+ status.l2 = "fail";
171
291
  }
172
292
  return status.l2;
173
293
  }
@@ -178,24 +298,25 @@ export function createHealthProbe(session, opts = {}) {
178
298
  */
179
299
  function probeL3() {
180
300
  if (promptAcked) {
181
- status.l3 = 'ok';
182
- return 'ok';
301
+ status.l3 = "ok";
302
+ return "ok";
183
303
  }
184
304
 
185
- const currentBytes = typeof session.getOutputBytes === 'function'
186
- ? session.getOutputBytes()
187
- : 0;
305
+ const currentBytes =
306
+ typeof session.getOutputBytes === "function"
307
+ ? session.getOutputBytes()
308
+ : 0;
188
309
 
189
310
  if (currentBytes > 0) {
190
311
  promptAcked = true;
191
- status.l3 = 'ok';
192
- return 'ok';
312
+ status.l3 = "ok";
313
+ return "ok";
193
314
  }
194
315
 
195
316
  const elapsed = Date.now() - spawnedAt;
196
317
  if (elapsed >= config.l3ThresholdMs) {
197
- status.l3 = 'timeout';
198
- return 'timeout';
318
+ status.l3 = "timeout";
319
+ return "timeout";
199
320
  }
200
321
 
201
322
  status.l3 = null; // 아직 판정 전
@@ -204,35 +325,52 @@ export function createHealthProbe(session, opts = {}) {
204
325
 
205
326
  /**
206
327
  * 전체 probe 실행 (L0→L1→L2→L3).
207
- * @returns {Promise<object>} probe 결과
328
+ * @returns {Promise<object|null>} probe 결과. stop() 이후 호출이면 null.
208
329
  */
209
330
  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
- }
331
+ if (stopped) return null;
332
+ // P0 (#167): probe 시작 시 epoch 캡처. start() 가 새 epoch 로 바꾸면 이 probe 의
333
+ // writeState 는 stale 로 판정 → skip.
334
+ const probeEpoch = runEpoch;
335
+ const promise = (async () => {
336
+ const result = {
337
+ l0: probeL0(),
338
+ l1: probeL1(),
339
+ l2: await probeL2(),
340
+ l3: probeL3(),
341
+ inputWaitPattern: status.inputWaitPattern,
342
+ ts: Date.now(),
343
+ };
344
+ status.lastProbeAt = result.ts;
345
+ writeState(result, probeEpoch);
346
+
347
+ if (typeof config.onProbe === "function") {
348
+ config.onProbe(result);
349
+ }
223
350
 
224
- return result;
351
+ return result;
352
+ })();
353
+ // P1-1 (#167): Set 으로 모든 in-flight 추적. 단일 var 패턴은 N+1 이 N 끝나기 전에
354
+ // 시작되면 N 을 덮어써 stopAndDrain() 시 N 이 누락된다.
355
+ inFlightProbes.add(promise);
356
+ promise.finally(() => inFlightProbes.delete(promise));
357
+ return promise;
225
358
  }
226
359
 
227
360
  function start() {
228
361
  if (started) return;
229
362
  started = true;
363
+ stopped = false;
364
+ // P0 (#167): epoch 증가 — 이전 run 의 in-flight probe 가 새 run 의 state 를 덮지 못하게.
365
+ runEpoch += 1;
230
366
  spawnedAt = Date.now();
231
367
  lastOutputChangeAt = Date.now();
232
368
  lastOutputBytes = 0;
233
369
  promptAcked = false;
234
370
 
235
- timer = setInterval(() => { void probe(); }, config.intervalMs);
371
+ timer = setInterval(() => {
372
+ void probe();
373
+ }, config.intervalMs);
236
374
  timer.unref?.();
237
375
 
238
376
  // 즉시 첫 probe 실행
@@ -242,10 +380,32 @@ export function createHealthProbe(session, opts = {}) {
242
380
  function stop() {
243
381
  if (!started) return;
244
382
  started = false;
383
+ // stopped 를 먼저 set 해야 in-flight probe()의 writeState() 가 skip 된다.
384
+ // 이후 unlink — in-flight 가 끝나도 writeState 가 no-op 이므로 재생성 없음.
385
+ stopped = true;
245
386
  if (timer) {
246
387
  clearInterval(timer);
247
388
  timer = null;
248
389
  }
390
+ if (config.writeStateFile || config.stateFile) {
391
+ try {
392
+ const stateFile = getStateFilePath();
393
+ if (stateFile) unlinkSync(stateFile);
394
+ } catch {}
395
+ }
396
+ }
397
+
398
+ /**
399
+ * stop() 후 in-flight probe() 가 완료될 때까지 대기.
400
+ * 결정적 종료가 필요한 테스트/teardown 용. conductor 의 sync stop() 호출자는
401
+ * 그대로 stop() 만 호출하면 stopped flag 가 race 를 막는다.
402
+ */
403
+ async function stopAndDrain() {
404
+ stop();
405
+ // P1-1 (#167): 모든 in-flight probe 대기. allSettled 로 unhandled rejection 방지.
406
+ if (inFlightProbes.size > 0) {
407
+ await Promise.allSettled(Array.from(inFlightProbes));
408
+ }
249
409
  }
250
410
 
251
411
  /** L1 tracking 리셋 (restart 후 호출) */
@@ -264,9 +424,12 @@ export function createHealthProbe(session, opts = {}) {
264
424
  return Object.freeze({
265
425
  start,
266
426
  stop,
427
+ stopAndDrain,
267
428
  probe,
268
429
  resetTracking,
269
430
  getStatus: () => ({ ...status }),
270
- get started() { return started; },
431
+ get started() {
432
+ return started;
433
+ },
271
434
  });
272
435
  }
@@ -0,0 +1,106 @@
1
+ import crypto from "node:crypto";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ import {
6
+ buildNativeWorkerRosterEntry,
7
+ deriveClaudeDaemonPaths,
8
+ mergeRosterWorkers,
9
+ removeRosterWorkers,
10
+ } from "./claude-native-bridge.mjs";
11
+ import { startInteractiveNativeWorker } from "./interactive-native-worker.mjs";
12
+
13
+ function buildWorkerSocketPaths({ configDir, sessionName, pid, short }) {
14
+ const hash = crypto
15
+ .createHash("sha256")
16
+ .update(`${configDir || ""}:${sessionName}:${pid}:${crypto.randomUUID()}`)
17
+ .digest("hex")
18
+ .slice(0, 10);
19
+ const socketRoot = path.join(os.tmpdir(), `tfx-in-${hash}`);
20
+ return {
21
+ rvSock: path.join(socketRoot, `${short}.rv.sock`),
22
+ ptySock: path.join(socketRoot, `${short}.pty.sock`),
23
+ };
24
+ }
25
+
26
+ export async function launchAdoptedInteractiveWorker({
27
+ cwd = process.cwd(),
28
+ sessionName,
29
+ launchCmd = "codex",
30
+ cli = "codex",
31
+ role = "interactive",
32
+ configDir,
33
+ pid = process.pid,
34
+ _deps = {},
35
+ } = {}) {
36
+ const startWorker =
37
+ _deps.startInteractiveNativeWorker || startInteractiveNativeWorker;
38
+ const buildRosterEntry =
39
+ _deps.buildNativeWorkerRosterEntry || buildNativeWorkerRosterEntry;
40
+ const mergeWorkers = _deps.mergeRosterWorkers || mergeRosterWorkers;
41
+ const derivePaths = _deps.deriveClaudeDaemonPaths || deriveClaudeDaemonPaths;
42
+
43
+ const resolvedCwd = path.resolve(cwd);
44
+ const effectiveSessionName =
45
+ sessionName || `tfx-${cli}-${role}-${process.pid}`;
46
+ const paths = derivePaths({ configDir });
47
+ const short = crypto.randomUUID().replaceAll("-", "").slice(0, 8);
48
+ const { rvSock, ptySock } = buildWorkerSocketPaths({
49
+ configDir,
50
+ sessionName: effectiveSessionName,
51
+ pid,
52
+ short,
53
+ });
54
+ const displayName = `Triflux ${cli} ${role}`;
55
+
56
+ let worker;
57
+ let closed = false;
58
+ let closePromise = null;
59
+
60
+ try {
61
+ worker = await startWorker({
62
+ short,
63
+ rvSock,
64
+ ptySock,
65
+ sessionName: effectiveSessionName,
66
+ cwd: resolvedCwd,
67
+ launchCmd,
68
+ pid,
69
+ });
70
+ const workerShort = worker.short || short;
71
+ const workerPid = worker.pid || pid;
72
+ const entry = buildRosterEntry({
73
+ short: workerShort,
74
+ pid: workerPid,
75
+ cwd: resolvedCwd,
76
+ rendezvousSock: worker.rvSock,
77
+ ptySock: worker.ptySock,
78
+ name: displayName,
79
+ prompt: displayName,
80
+ });
81
+ await mergeWorkers(paths.rosterPath, { [workerShort]: entry });
82
+
83
+ return {
84
+ short: workerShort,
85
+ displayName,
86
+ rosterPath: paths.rosterPath,
87
+ close() {
88
+ if (!closePromise) {
89
+ closePromise = (async () => {
90
+ if (closed) return;
91
+ closed = true;
92
+ try {
93
+ await worker.close();
94
+ } finally {
95
+ await removeRosterWorkers(paths.rosterPath, [workerShort]);
96
+ }
97
+ })();
98
+ }
99
+ return closePromise;
100
+ },
101
+ };
102
+ } catch (error) {
103
+ if (worker) await worker.close().catch(() => {});
104
+ throw error;
105
+ }
106
+ }
@@ -0,0 +1,80 @@
1
+ import { startNativeWorkerFacade } from "./claude-native-bridge.mjs";
2
+ import { createInteractiveTuiTransport } from "./interactive-tui-transport.mjs";
3
+
4
+ // Output currently flows through capture-pane snapshots, so attach clients may
5
+ // receive whole-screen refreshes rather than byte-accurate terminal deltas.
6
+ export async function startInteractiveNativeWorker({
7
+ short,
8
+ rvSock,
9
+ ptySock,
10
+ sessionName,
11
+ cwd,
12
+ launchCmd,
13
+ pid = process.pid,
14
+ env = {},
15
+ createTransport = createInteractiveTuiTransport,
16
+ } = {}) {
17
+ let facade = null;
18
+ let closePromise = null;
19
+
20
+ const transport = createTransport({
21
+ sessionName,
22
+ cwd,
23
+ launchCmd,
24
+ env,
25
+ onData(chunk) {
26
+ if (facade) facade.writeOutput(chunk);
27
+ },
28
+ });
29
+
30
+ async function close({ exitCode = 0, waitForPtyEnd = false } = {}) {
31
+ if (!closePromise) {
32
+ closePromise = (async () => {
33
+ await transport.stop().catch(() => {});
34
+ if (waitForPtyEnd) {
35
+ await new Promise((resolve) => setTimeout(resolve, 10));
36
+ }
37
+ if (facade) await facade.close({ exitCode });
38
+ })();
39
+ }
40
+ return closePromise;
41
+ }
42
+
43
+ try {
44
+ facade = await startNativeWorkerFacade({
45
+ short,
46
+ rvSock,
47
+ ptySock,
48
+ pid,
49
+ workerType: "interactive",
50
+ onInput(payload) {
51
+ void Promise.resolve()
52
+ .then(() => transport.writeInput(payload))
53
+ .catch(() => {
54
+ void close({ exitCode: 1 }).catch(() => {});
55
+ });
56
+ },
57
+ onKill() {
58
+ void close({ waitForPtyEnd: true }).catch(() => {});
59
+ },
60
+ });
61
+ } catch (error) {
62
+ await transport.stop().catch(() => {});
63
+ throw error;
64
+ }
65
+
66
+ try {
67
+ await transport.start();
68
+ } catch (error) {
69
+ await close({ exitCode: 1 }).catch(() => {});
70
+ throw error;
71
+ }
72
+
73
+ return {
74
+ short,
75
+ rvSock,
76
+ ptySock,
77
+ pid,
78
+ close,
79
+ };
80
+ }