triflux 10.0.0 → 10.0.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.
Files changed (426) hide show
  1. package/CLAUDE.md +171 -0
  2. package/README.md +32 -15
  3. package/bin/triflux.mjs +62 -5
  4. package/hooks/agent-route-guard.mjs +109 -0
  5. package/hooks/cross-review-tracker.mjs +122 -0
  6. package/hooks/error-context.mjs +148 -0
  7. package/hooks/hook-adaptive-collector.mjs +86 -0
  8. package/hooks/hook-manager.mjs +365 -0
  9. package/hooks/hook-orchestrator.mjs +312 -0
  10. package/hooks/hook-registry.json +246 -0
  11. package/hooks/hooks.json +89 -0
  12. package/hooks/keyword-rules.json +574 -0
  13. package/hooks/lib/resolve-root.mjs +59 -0
  14. package/hooks/mcp-config-watcher.mjs +80 -0
  15. package/hooks/pipeline-stop.mjs +76 -0
  16. package/hooks/safety-guard.mjs +169 -0
  17. package/hooks/subagent-verifier.mjs +80 -0
  18. package/hub/account-broker.mjs +251 -0
  19. package/hub/adaptive-diagnostic.mjs +323 -0
  20. package/hub/adaptive-inject.mjs +186 -0
  21. package/hub/adaptive-memory.mjs +163 -0
  22. package/hub/adaptive.mjs +143 -0
  23. package/hub/assign-callbacks.mjs +133 -0
  24. package/hub/bridge.mjs +799 -0
  25. package/hub/cli-adapter-base.mjs +280 -0
  26. package/hub/codex-adapter.mjs +199 -0
  27. package/hub/codex-compat.mjs +11 -0
  28. package/hub/codex-preflight.mjs +166 -0
  29. package/hub/delegator/contracts.mjs +37 -0
  30. package/hub/delegator/index.mjs +14 -0
  31. package/hub/delegator/schema/delegator-tools.schema.json +250 -0
  32. package/hub/delegator/service.mjs +307 -0
  33. package/hub/delegator/tool-definitions.mjs +35 -0
  34. package/hub/fullcycle.mjs +96 -0
  35. package/hub/gemini-adapter.mjs +180 -0
  36. package/hub/hitl.mjs +143 -0
  37. package/hub/intent.mjs +193 -0
  38. package/hub/lib/cache-guard.mjs +114 -0
  39. package/hub/lib/known-errors.json +72 -0
  40. package/hub/lib/memory-store.mjs +748 -0
  41. package/hub/lib/process-utils.mjs +361 -0
  42. package/hub/lib/ssh-command.mjs +211 -0
  43. package/hub/lib/ssh-retry.mjs +59 -0
  44. package/hub/lib/uuidv7.mjs +44 -0
  45. package/hub/memory-doctor.mjs +480 -0
  46. package/hub/middleware/request-logger.mjs +161 -0
  47. package/hub/paths.mjs +30 -0
  48. package/hub/pipe.mjs +664 -0
  49. package/hub/pipeline/gates/confidence.mjs +56 -0
  50. package/hub/pipeline/gates/consensus.mjs +94 -0
  51. package/hub/pipeline/gates/index.mjs +5 -0
  52. package/hub/pipeline/gates/selfcheck.mjs +82 -0
  53. package/hub/pipeline/index.mjs +318 -0
  54. package/hub/pipeline/state.mjs +191 -0
  55. package/hub/pipeline/transitions.mjs +124 -0
  56. package/hub/platform.mjs +225 -0
  57. package/hub/public/dashboard.html +355 -0
  58. package/hub/public/tray-icon.ico +0 -0
  59. package/hub/public/tray-icon.png +0 -0
  60. package/hub/quality/deslop.mjs +253 -0
  61. package/hub/reflexion.mjs +372 -0
  62. package/hub/research.mjs +146 -0
  63. package/hub/router.mjs +791 -0
  64. package/hub/routing/complexity.mjs +166 -0
  65. package/hub/routing/index.mjs +117 -0
  66. package/hub/routing/q-learning.mjs +336 -0
  67. package/hub/schema.sql +148 -0
  68. package/hub/server.mjs +1264 -0
  69. package/hub/session-fingerprint.mjs +352 -0
  70. package/hub/state.mjs +258 -0
  71. package/hub/store-adapter.mjs +118 -0
  72. package/hub/store.mjs +857 -0
  73. package/hub/team/agent-map.json +11 -0
  74. package/hub/team/ansi.mjs +379 -0
  75. package/hub/team/backend.mjs +90 -0
  76. package/hub/team/cli/commands/attach.mjs +37 -0
  77. package/hub/team/cli/commands/control.mjs +43 -0
  78. package/hub/team/cli/commands/debug.mjs +74 -0
  79. package/hub/team/cli/commands/focus.mjs +53 -0
  80. package/hub/team/cli/commands/interrupt.mjs +36 -0
  81. package/hub/team/cli/commands/kill.mjs +37 -0
  82. package/hub/team/cli/commands/list.mjs +24 -0
  83. package/hub/team/cli/commands/send.mjs +37 -0
  84. package/hub/team/cli/commands/start/index.mjs +106 -0
  85. package/hub/team/cli/commands/start/parse-args.mjs +130 -0
  86. package/hub/team/cli/commands/start/start-headless.mjs +109 -0
  87. package/hub/team/cli/commands/start/start-in-process.mjs +40 -0
  88. package/hub/team/cli/commands/start/start-mux.mjs +73 -0
  89. package/hub/team/cli/commands/start/start-wt.mjs +69 -0
  90. package/hub/team/cli/commands/status.mjs +87 -0
  91. package/hub/team/cli/commands/stop.mjs +31 -0
  92. package/hub/team/cli/commands/task.mjs +30 -0
  93. package/hub/team/cli/commands/tasks.mjs +13 -0
  94. package/hub/team/cli/help.mjs +42 -0
  95. package/hub/team/cli/index.mjs +41 -0
  96. package/hub/team/cli/manifest.mjs +29 -0
  97. package/hub/team/cli/render.mjs +30 -0
  98. package/hub/team/cli/services/attach-fallback.mjs +54 -0
  99. package/hub/team/cli/services/hub-client.mjs +227 -0
  100. package/hub/team/cli/services/member-selector.mjs +30 -0
  101. package/hub/team/cli/services/native-control.mjs +117 -0
  102. package/hub/team/cli/services/runtime-mode.mjs +62 -0
  103. package/hub/team/cli/services/state-store.mjs +48 -0
  104. package/hub/team/cli/services/task-model.mjs +30 -0
  105. package/hub/team/conductor-mesh-bridge.mjs +121 -0
  106. package/hub/team/conductor.mjs +671 -0
  107. package/hub/team/dashboard-anchor.mjs +14 -0
  108. package/hub/team/dashboard-layout.mjs +33 -0
  109. package/hub/team/dashboard-open.mjs +153 -0
  110. package/hub/team/dashboard.mjs +274 -0
  111. package/hub/team/event-log.mjs +76 -0
  112. package/hub/team/handoff.mjs +303 -0
  113. package/hub/team/headless.mjs +1156 -0
  114. package/hub/team/health-probe.mjs +272 -0
  115. package/hub/team/launcher-template.mjs +95 -0
  116. package/hub/team/lead-control.mjs +104 -0
  117. package/hub/team/native-supervisor.mjs +392 -0
  118. package/hub/team/native.mjs +649 -0
  119. package/hub/team/nativeProxy.mjs +688 -0
  120. package/hub/team/notify.mjs +293 -0
  121. package/hub/team/orchestrator.mjs +161 -0
  122. package/hub/team/pane.mjs +153 -0
  123. package/hub/team/process-cleanup.mjs +342 -0
  124. package/hub/team/psmux.mjs +1354 -0
  125. package/hub/team/remote-probe.mjs +276 -0
  126. package/hub/team/remote-session.mjs +299 -0
  127. package/hub/team/remote-watcher.mjs +478 -0
  128. package/hub/team/routing.mjs +223 -0
  129. package/hub/team/session-sync.mjs +169 -0
  130. package/hub/team/session.mjs +611 -0
  131. package/hub/team/shared.mjs +13 -0
  132. package/hub/team/staleState.mjs +361 -0
  133. package/hub/team/swarm-hypervisor.mjs +589 -0
  134. package/hub/team/swarm-locks.mjs +204 -0
  135. package/hub/team/swarm-planner.mjs +260 -0
  136. package/hub/team/swarm-reconciler.mjs +137 -0
  137. package/hub/team/tui-lite.mjs +380 -0
  138. package/hub/team/tui-remote-adapter.mjs +393 -0
  139. package/hub/team/tui-viewer.mjs +463 -0
  140. package/hub/team/tui.mjs +1449 -0
  141. package/hub/team/worktree-lifecycle.mjs +193 -0
  142. package/hub/team/wt-manager.mjs +407 -0
  143. package/hub/team/wt-templates.json +43 -0
  144. package/hub/team-bridge.mjs +27 -0
  145. package/hub/token-mode.mjs +224 -0
  146. package/hub/tools.mjs +636 -0
  147. package/hub/tray.mjs +376 -0
  148. package/hub/workers/claude-worker.mjs +475 -0
  149. package/hub/workers/codex-mcp.mjs +507 -0
  150. package/hub/workers/delegator-mcp.mjs +1076 -0
  151. package/hub/workers/factory.mjs +21 -0
  152. package/hub/workers/gemini-worker.mjs +374 -0
  153. package/hub/workers/interface.mjs +52 -0
  154. package/hub/workers/worker-utils.mjs +104 -0
  155. package/hud/colors.mjs +88 -0
  156. package/hud/constants.mjs +88 -0
  157. package/hud/context-monitor.mjs +403 -0
  158. package/hud/hud-qos-status.mjs +210 -0
  159. package/hud/providers/claude.mjs +314 -0
  160. package/hud/providers/codex.mjs +151 -0
  161. package/hud/providers/gemini.mjs +320 -0
  162. package/hud/renderers.mjs +442 -0
  163. package/hud/terminal.mjs +140 -0
  164. package/hud/utils.mjs +313 -0
  165. package/mesh/index.mjs +63 -0
  166. package/mesh/mesh-budget.mjs +128 -0
  167. package/mesh/mesh-heartbeat.mjs +100 -0
  168. package/mesh/mesh-protocol.mjs +96 -0
  169. package/mesh/mesh-queue.mjs +165 -0
  170. package/mesh/mesh-registry.mjs +78 -0
  171. package/mesh/mesh-router.mjs +76 -0
  172. package/package.json +8 -1
  173. package/references/hosts.json +33 -0
  174. package/scripts/__tests__/gen-skill-docs.test.mjs +87 -0
  175. package/scripts/__tests__/keyword-detector.test.mjs +234 -0
  176. package/scripts/__tests__/mcp-guard-engine.test.mjs +118 -0
  177. package/scripts/__tests__/remote-spawn-transfer.test.mjs +117 -0
  178. package/scripts/__tests__/remote-spawn.test.mjs +92 -0
  179. package/scripts/__tests__/skill-template.test.mjs +193 -0
  180. package/scripts/__tests__/smoke.test.mjs +34 -0
  181. package/scripts/cache-buildup.mjs +30 -0
  182. package/scripts/cache-doctor.mjs +149 -0
  183. package/scripts/cache-warmup.mjs +557 -0
  184. package/scripts/claudemd-sync.mjs +148 -0
  185. package/scripts/cli-route.sh +3 -0
  186. package/scripts/completions/tfx.bash +47 -0
  187. package/scripts/completions/tfx.fish +44 -0
  188. package/scripts/completions/tfx.zsh +83 -0
  189. package/scripts/cross-review-gate.mjs +126 -0
  190. package/scripts/cross-review-tracker.mjs +238 -0
  191. package/scripts/gen-skill-docs.mjs +111 -0
  192. package/scripts/headless-guard-fast.sh +21 -0
  193. package/scripts/headless-guard.mjs +360 -0
  194. package/scripts/hub-ensure.mjs +120 -0
  195. package/scripts/keyword-detector.mjs +272 -0
  196. package/scripts/keyword-rules-expander.mjs +521 -0
  197. package/scripts/lib/claudemd-scanner.mjs +218 -0
  198. package/scripts/lib/context.mjs +67 -0
  199. package/scripts/lib/cross-review-utils.mjs +51 -0
  200. package/scripts/lib/env-probe.mjs +241 -0
  201. package/scripts/lib/gemini-profiles.mjs +85 -0
  202. package/scripts/lib/handoff.mjs +171 -0
  203. package/scripts/lib/hook-utils.mjs +14 -0
  204. package/scripts/lib/keyword-rules.mjs +166 -0
  205. package/scripts/lib/logger.mjs +105 -0
  206. package/scripts/lib/mcp-filter.mjs +739 -0
  207. package/scripts/lib/mcp-guard-engine.mjs +954 -0
  208. package/scripts/lib/mcp-manifest.mjs +79 -0
  209. package/scripts/lib/mcp-server-catalog.mjs +118 -0
  210. package/scripts/lib/psmux-info.mjs +119 -0
  211. package/scripts/lib/remote-spawn-transfer.mjs +196 -0
  212. package/scripts/lib/skill-template.mjs +326 -0
  213. package/scripts/mcp-check.mjs +237 -0
  214. package/scripts/mcp-cleanup.ps1 +17 -0
  215. package/scripts/mcp-gateway-config.mjs +207 -0
  216. package/scripts/mcp-gateway-ensure.mjs +85 -0
  217. package/scripts/mcp-gateway-integration-test.mjs +228 -0
  218. package/scripts/mcp-gateway-start.mjs +226 -0
  219. package/scripts/mcp-gateway-start.ps1 +141 -0
  220. package/scripts/mcp-gateway-verify.mjs +77 -0
  221. package/scripts/mcp-safety-guard.mjs +44 -0
  222. package/scripts/notion-read.mjs +556 -0
  223. package/scripts/pack.mjs +295 -0
  224. package/scripts/preflight-cache.mjs +69 -0
  225. package/scripts/preinstall.mjs +96 -0
  226. package/scripts/remote-spawn.mjs +1376 -0
  227. package/scripts/run.cjs +79 -0
  228. package/scripts/session-spawn-helper.mjs +185 -0
  229. package/scripts/setup.mjs +1178 -0
  230. package/scripts/test-lock.mjs +71 -0
  231. package/scripts/test-tfx-route-no-claude-native.mjs +57 -0
  232. package/scripts/tfx-batch-stats.mjs +96 -0
  233. package/scripts/tfx-gate-activate.mjs +89 -0
  234. package/scripts/tfx-route-post.mjs +505 -0
  235. package/scripts/tfx-route-worker.mjs +223 -0
  236. package/scripts/tfx-route.sh +2014 -0
  237. package/scripts/tmp-cleanup.mjs +103 -0
  238. package/scripts/token-snapshot.mjs +575 -0
  239. package/skills/tfx-auto/SKILL.md.tmpl +2 -3
  240. package/skills/tfx-autoresearch/SKILL.md +6 -5
  241. package/skills/tfx-codex/SKILL.md.tmpl +2 -3
  242. package/skills/tfx-codex-swarm-workspace/iteration-1/benchmark.json +33 -0
  243. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/eval_metadata.json +42 -0
  244. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/with_skill/grading.json +11 -0
  245. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/with_skill/outputs/analysis.md +87 -0
  246. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/with_skill/outputs/classification.md +35 -0
  247. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/with_skill/outputs/commands.sh +275 -0
  248. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/with_skill/outputs/routing.md +56 -0
  249. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/with_skill/timing.json +5 -0
  250. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/without_skill/grading.json +11 -0
  251. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/without_skill/outputs/analysis.md +92 -0
  252. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/without_skill/outputs/classification.md +71 -0
  253. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/without_skill/outputs/commands.sh +264 -0
  254. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/without_skill/outputs/routing.md +113 -0
  255. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/without_skill/timing.json +5 -0
  256. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/eval_metadata.json +32 -0
  257. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/with_skill/grading.json +9 -0
  258. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/with_skill/outputs/analysis.md +96 -0
  259. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/with_skill/outputs/classification.md +38 -0
  260. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/with_skill/outputs/commands.sh +151 -0
  261. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/with_skill/outputs/routing.md +51 -0
  262. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/with_skill/timing.json +5 -0
  263. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/without_skill/grading.json +9 -0
  264. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/without_skill/outputs/analysis.md +127 -0
  265. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/without_skill/outputs/classification.md +57 -0
  266. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/without_skill/outputs/commands.sh +129 -0
  267. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/without_skill/outputs/routing.md +84 -0
  268. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/without_skill/timing.json +5 -0
  269. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/eval_metadata.json +27 -0
  270. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/with_skill/grading.json +8 -0
  271. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/with_skill/outputs/analysis.md +98 -0
  272. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/with_skill/outputs/classification.md +65 -0
  273. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/with_skill/outputs/commands.sh +123 -0
  274. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/with_skill/outputs/routing.md +66 -0
  275. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/with_skill/timing.json +5 -0
  276. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/without_skill/grading.json +8 -0
  277. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/without_skill/outputs/analysis.md +88 -0
  278. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/without_skill/outputs/classification.md +40 -0
  279. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/without_skill/outputs/commands.sh +130 -0
  280. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/without_skill/outputs/routing.md +61 -0
  281. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/without_skill/timing.json +5 -0
  282. package/skills/tfx-deep-interview/SKILL.md +1 -2
  283. package/skills/tfx-plan/SKILL.md.tmpl +2 -3
  284. package/skills/tfx-psmux-rules/SKILL.md +11 -2
  285. package/skills/tfx-qa/SKILL.md.tmpl +2 -3
  286. package/skills/tfx-remote-spawn/SKILL.md +8 -11
  287. package/skills/tfx-research/SKILL.md.tmpl +2 -3
  288. package/skills/tfx-review/SKILL.md.tmpl +2 -3
  289. package/skills/tfx-workspace/async-tests/run-tests.sh +203 -0
  290. package/skills/tfx-workspace/evals/evals.json +79 -0
  291. package/skills/tfx-workspace/iteration-1/benchmark.json +162 -0
  292. package/skills/tfx-workspace/iteration-1/codex-gemini-remap/eval_metadata.json +11 -0
  293. package/skills/tfx-workspace/iteration-1/codex-gemini-remap/old_skill/grading.json +9 -0
  294. package/skills/tfx-workspace/iteration-1/codex-gemini-remap/old_skill/outputs/analysis.md +154 -0
  295. package/skills/tfx-workspace/iteration-1/codex-gemini-remap/old_skill/timing.json +5 -0
  296. package/skills/tfx-workspace/iteration-1/codex-gemini-remap/with_skill/grading.json +9 -0
  297. package/skills/tfx-workspace/iteration-1/codex-gemini-remap/with_skill/outputs/analysis.md +126 -0
  298. package/skills/tfx-workspace/iteration-1/codex-gemini-remap/with_skill/timing.json +5 -0
  299. package/skills/tfx-workspace/iteration-1/doctor-diagnosis/eval_metadata.json +11 -0
  300. package/skills/tfx-workspace/iteration-1/doctor-diagnosis/old_skill/grading.json +9 -0
  301. package/skills/tfx-workspace/iteration-1/doctor-diagnosis/old_skill/outputs/analysis.md +119 -0
  302. package/skills/tfx-workspace/iteration-1/doctor-diagnosis/old_skill/timing.json +5 -0
  303. package/skills/tfx-workspace/iteration-1/doctor-diagnosis/with_skill/grading.json +9 -0
  304. package/skills/tfx-workspace/iteration-1/doctor-diagnosis/with_skill/outputs/analysis.md +115 -0
  305. package/skills/tfx-workspace/iteration-1/doctor-diagnosis/with_skill/timing.json +5 -0
  306. package/skills/tfx-workspace/iteration-1/hub-start-sequence/eval_metadata.json +10 -0
  307. package/skills/tfx-workspace/iteration-1/hub-start-sequence/old_skill/grading.json +8 -0
  308. package/skills/tfx-workspace/iteration-1/hub-start-sequence/old_skill/outputs/analysis.md +86 -0
  309. package/skills/tfx-workspace/iteration-1/hub-start-sequence/old_skill/timing.json +5 -0
  310. package/skills/tfx-workspace/iteration-1/hub-start-sequence/with_skill/grading.json +8 -0
  311. package/skills/tfx-workspace/iteration-1/hub-start-sequence/with_skill/outputs/analysis.md +81 -0
  312. package/skills/tfx-workspace/iteration-1/hub-start-sequence/with_skill/timing.json +5 -0
  313. package/skills/tfx-workspace/iteration-1/multi-team-creation/eval_metadata.json +12 -0
  314. package/skills/tfx-workspace/iteration-1/multi-team-creation/old_skill/grading.json +10 -0
  315. package/skills/tfx-workspace/iteration-1/multi-team-creation/old_skill/outputs/analysis.md +316 -0
  316. package/skills/tfx-workspace/iteration-1/multi-team-creation/old_skill/timing.json +5 -0
  317. package/skills/tfx-workspace/iteration-1/multi-team-creation/with_skill/grading.json +10 -0
  318. package/skills/tfx-workspace/iteration-1/multi-team-creation/with_skill/outputs/analysis.md +352 -0
  319. package/skills/tfx-workspace/iteration-1/multi-team-creation/with_skill/timing.json +5 -0
  320. package/skills/tfx-workspace/iteration-1/review.html +1325 -0
  321. package/skills/tfx-workspace/iteration-1/routing-implement-shortcut/eval_metadata.json +12 -0
  322. package/skills/tfx-workspace/iteration-1/routing-implement-shortcut/old_skill/grading.json +10 -0
  323. package/skills/tfx-workspace/iteration-1/routing-implement-shortcut/old_skill/outputs/analysis.md +97 -0
  324. package/skills/tfx-workspace/iteration-1/routing-implement-shortcut/old_skill/timing.json +5 -0
  325. package/skills/tfx-workspace/iteration-1/routing-implement-shortcut/with_skill/grading.json +10 -0
  326. package/skills/tfx-workspace/iteration-1/routing-implement-shortcut/with_skill/outputs/analysis.md +94 -0
  327. package/skills/tfx-workspace/iteration-1/routing-implement-shortcut/with_skill/timing.json +5 -0
  328. package/skills/tfx-workspace/iteration-1/routing-multi-task-triage/eval_metadata.json +12 -0
  329. package/skills/tfx-workspace/iteration-1/routing-multi-task-triage/old_skill/grading.json +10 -0
  330. package/skills/tfx-workspace/iteration-1/routing-multi-task-triage/old_skill/outputs/analysis.md +209 -0
  331. package/skills/tfx-workspace/iteration-1/routing-multi-task-triage/old_skill/timing.json +5 -0
  332. package/skills/tfx-workspace/iteration-1/routing-multi-task-triage/with_skill/grading.json +10 -0
  333. package/skills/tfx-workspace/iteration-1/routing-multi-task-triage/with_skill/outputs/analysis.md +193 -0
  334. package/skills/tfx-workspace/iteration-1/routing-multi-task-triage/with_skill/timing.json +5 -0
  335. package/skills/tfx-workspace/iteration-2/benchmark.json +62 -0
  336. package/skills/tfx-workspace/iteration-2/multi-team-creation-refactored/eval_metadata.json +13 -0
  337. package/skills/tfx-workspace/iteration-2/multi-team-creation-refactored/old_skill/grading.json +11 -0
  338. package/skills/tfx-workspace/iteration-2/multi-team-creation-refactored/old_skill/outputs/analysis.md +382 -0
  339. package/skills/tfx-workspace/iteration-2/multi-team-creation-refactored/old_skill/timing.json +5 -0
  340. package/skills/tfx-workspace/iteration-2/multi-team-creation-refactored/with_skill/grading.json +11 -0
  341. package/skills/tfx-workspace/iteration-2/multi-team-creation-refactored/with_skill/outputs/analysis.md +333 -0
  342. package/skills/tfx-workspace/iteration-2/multi-team-creation-refactored/with_skill/timing.json +5 -0
  343. package/skills/tfx-workspace/iteration-2/review.html +1325 -0
  344. package/skills/tfx-workspace/skill-snapshot/tfx-auto/SKILL.md +217 -0
  345. package/skills/{tfx-auto-codex/SKILL.md.tmpl → tfx-workspace/skill-snapshot/tfx-auto-codex/SKILL.md} +3 -31
  346. package/skills/tfx-workspace/skill-snapshot/tfx-codex/SKILL.md +65 -0
  347. package/skills/tfx-workspace/skill-snapshot/tfx-doctor/SKILL.md +94 -0
  348. package/skills/{tfx-gemini/SKILL.md.tmpl → tfx-workspace/skill-snapshot/tfx-gemini/SKILL.md} +6 -14
  349. package/skills/tfx-workspace/skill-snapshot/tfx-hub/SKILL.md +133 -0
  350. package/skills/tfx-workspace/skill-snapshot/tfx-multi/SKILL.md +426 -0
  351. package/skills/tfx-workspace/skill-snapshot/tfx-setup/SKILL.md +101 -0
  352. package/skills/merge-worktree/SKILL.md.tmpl +0 -144
  353. package/skills/shared/arguments-processing.md +0 -2
  354. package/skills/shared/mandatory-rules.md +0 -6
  355. package/skills/shared/telemetry-segment.md +0 -6
  356. package/skills/star-prompt/SKILL.md.tmpl +0 -122
  357. package/skills/tfx-analysis/SKILL.md.tmpl +0 -106
  358. package/skills/tfx-analysis/skill.json +0 -11
  359. package/skills/tfx-auto/skill.json +0 -26
  360. package/skills/tfx-auto-codex/skill.json +0 -8
  361. package/skills/tfx-autopilot/SKILL.md.tmpl +0 -115
  362. package/skills/tfx-autopilot/skill.json +0 -10
  363. package/skills/tfx-autoresearch/SKILL.md.tmpl +0 -135
  364. package/skills/tfx-autoresearch/skill.json +0 -14
  365. package/skills/tfx-autoroute/SKILL.md.tmpl +0 -188
  366. package/skills/tfx-autoroute/skill.json +0 -12
  367. package/skills/tfx-codex/skill.json +0 -8
  368. package/skills/tfx-codex-swarm/SKILL.md.tmpl +0 -16
  369. package/skills/tfx-codex-swarm/skill.json +0 -5
  370. package/skills/tfx-consensus/SKILL.md.tmpl +0 -145
  371. package/skills/tfx-consensus/skill.json +0 -8
  372. package/skills/tfx-debate/SKILL.md.tmpl +0 -191
  373. package/skills/tfx-debate/skill.json +0 -12
  374. package/skills/tfx-deep-analysis/SKILL.md.tmpl +0 -227
  375. package/skills/tfx-deep-analysis/skill.json +0 -10
  376. package/skills/tfx-deep-interview/SKILL.md.tmpl +0 -203
  377. package/skills/tfx-deep-interview/skill.json +0 -12
  378. package/skills/tfx-deep-plan/SKILL.md.tmpl +0 -281
  379. package/skills/tfx-deep-plan/skill.json +0 -13
  380. package/skills/tfx-deep-qa/SKILL.md.tmpl +0 -164
  381. package/skills/tfx-deep-qa/skill.json +0 -11
  382. package/skills/tfx-deep-research/SKILL.md.tmpl +0 -216
  383. package/skills/tfx-deep-research/skill.json +0 -14
  384. package/skills/tfx-deep-review/SKILL.md.tmpl +0 -178
  385. package/skills/tfx-deep-review/skill.json +0 -12
  386. package/skills/tfx-doctor/SKILL.md.tmpl +0 -172
  387. package/skills/tfx-doctor/skill.json +0 -8
  388. package/skills/tfx-find/skill.json +0 -12
  389. package/skills/tfx-forge/SKILL.md.tmpl +0 -187
  390. package/skills/tfx-forge/skill.json +0 -12
  391. package/skills/tfx-fullcycle/SKILL.md.tmpl +0 -285
  392. package/skills/tfx-fullcycle/skill.json +0 -11
  393. package/skills/tfx-gemini/skill.json +0 -8
  394. package/skills/tfx-hooks/SKILL.md.tmpl +0 -216
  395. package/skills/tfx-hooks/skill.json +0 -8
  396. package/skills/tfx-hub/SKILL.md.tmpl +0 -212
  397. package/skills/tfx-hub/skill.json +0 -8
  398. package/skills/tfx-index/skill.json +0 -11
  399. package/skills/tfx-interview/SKILL.md.tmpl +0 -284
  400. package/skills/tfx-interview/skill.json +0 -12
  401. package/skills/tfx-multi/SKILL.md.tmpl +0 -183
  402. package/skills/tfx-multi/skill.json +0 -8
  403. package/skills/tfx-panel/SKILL.md.tmpl +0 -188
  404. package/skills/tfx-panel/skill.json +0 -12
  405. package/skills/tfx-persist/SKILL.md.tmpl +0 -269
  406. package/skills/tfx-persist/skill.json +0 -12
  407. package/skills/tfx-plan/skill.json +0 -11
  408. package/skills/tfx-profile/SKILL.md.tmpl +0 -239
  409. package/skills/tfx-profile/skill.json +0 -8
  410. package/skills/tfx-prune/SKILL.md.tmpl +0 -199
  411. package/skills/tfx-prune/skill.json +0 -12
  412. package/skills/tfx-psmux-rules/SKILL.md.tmpl +0 -317
  413. package/skills/tfx-psmux-rules/skill.json +0 -8
  414. package/skills/tfx-qa/skill.json +0 -11
  415. package/skills/tfx-ralph/SKILL.md.tmpl +0 -27
  416. package/skills/tfx-ralph/skill.json +0 -8
  417. package/skills/tfx-remote-setup/SKILL.md.tmpl +0 -576
  418. package/skills/tfx-remote-setup/skill.json +0 -8
  419. package/skills/tfx-remote-spawn/SKILL.md.tmpl +0 -263
  420. package/skills/tfx-remote-spawn/skill.json +0 -9
  421. package/skills/tfx-research/skill.json +0 -13
  422. package/skills/tfx-review/skill.json +0 -11
  423. package/skills/tfx-setup/SKILL.md.tmpl +0 -380
  424. package/skills/tfx-setup/skill.json +0 -8
  425. package/skills/tfx-swarm/SKILL.md.tmpl +0 -154
  426. package/skills/tfx-swarm/skill.json +0 -5
@@ -0,0 +1,1449 @@
1
+ // hub/team/tui.mjs — Alternate-screen diff renderer (v11)
2
+ // virtual row buffer 기반. dirty-row만 갱신. isTTY 아닐 때 append-only fallback.
3
+ // Tier1(상단 고정) / Tier2(worker rail) / Tier3(focus pane) 3단 계층.
4
+
5
+ import {
6
+ RESET,
7
+ FG,
8
+ BG,
9
+ MOCHA,
10
+ color,
11
+ dim,
12
+ bold,
13
+ box,
14
+ padRight,
15
+ truncate,
16
+ clip,
17
+ stripAnsi,
18
+ wcswidth,
19
+ progressBar,
20
+ statusBadge,
21
+ STATUS_ICON,
22
+ altScreenOn,
23
+ altScreenOff,
24
+ clearScreen,
25
+ cursorHome,
26
+ cursorHide,
27
+ cursorShow,
28
+ moveTo,
29
+ clearLine,
30
+ clearToEnd,
31
+ } from "./ansi.mjs";
32
+
33
+ import { execFile as _execFile } from "node:child_process";
34
+
35
+ // package.json에서 동적 로드 (실패 시 fallback)
36
+ let VERSION = "7.x";
37
+ try {
38
+ const { createRequire } = await import("node:module");
39
+ const require = createRequire(import.meta.url);
40
+ VERSION = require("../../package.json").version;
41
+ } catch { /* fallback */ }
42
+
43
+ const FALLBACK_COLUMNS = 100;
44
+ const FALLBACK_ROWS = 30;
45
+ const MIN_CARD_WIDTH = 28;
46
+
47
+ // ✻ heartbeat — Claude Code 리버스 엔지니어링 기반 breathing animation
48
+ // 프레임: ["·","✢","✳","✶","✻","✽"] + 역재생 = 12프레임 왕복
49
+ // 타이밍: 2000ms/cycle, RGB truecolor 보간
50
+ const SPINNER_FRAMES_RAW = ["·", "✢", "✳", "✶", "✻", "✽"];
51
+ const SPINNER_FRAMES = [...SPINNER_FRAMES_RAW, ...[...SPINNER_FRAMES_RAW].reverse()];
52
+ const SPINNER_CYCLE_MS = 2000;
53
+ const SPINNER_BASE_COLOR = { r: 203, g: 166, b: 247 }; // Catppuccin Mocha mauve
54
+ const SPINNER_SHIMMER = { r: 171, g: 43, b: 63 }; // Claude shimmer #ab2b3f
55
+ let spinnerStart = Date.now();
56
+ let spinnerTick = 0;
57
+
58
+ function lerpRgb(a, b, t) {
59
+ return {
60
+ r: Math.round(a.r + (b.r - a.r) * t),
61
+ g: Math.round(a.g + (b.g - a.g) * t),
62
+ b: Math.round(a.b + (b.b - a.b) * t),
63
+ };
64
+ }
65
+
66
+ function rgbSeq(rgb, mode = 38) {
67
+ return `\x1b[${mode};2;${rgb.r};${rgb.g};${rgb.b}m`;
68
+ }
69
+
70
+ function pseudoRandomFrame(step, seed) {
71
+ return Math.abs(Math.imul(step + seed, 2654435761)) % SPINNER_FRAMES.length;
72
+ }
73
+
74
+ function heartbeat(status, shimmerIntensity = 0, statusChangedAt = 0, time = Date.now()) {
75
+ const transitionElapsed = statusChangedAt ? Math.max(0, time - statusChangedAt) : Number.POSITIVE_INFINITY;
76
+ if (transitionElapsed < 500) {
77
+ const step = Math.floor(transitionElapsed / 50);
78
+ const idx = pseudoRandomFrame(step, statusChangedAt % 997);
79
+ const targetColor = status === "failed" || status === "error"
80
+ ? MOCHA.fail
81
+ : status === "done" || status === "completed"
82
+ ? MOCHA.ok
83
+ : shimmerIntensity > 0
84
+ ? rgbSeq(lerpRgb(SPINNER_BASE_COLOR, SPINNER_SHIMMER, shimmerIntensity))
85
+ : MOCHA.executing;
86
+ return `${targetColor}${SPINNER_FRAMES[idx]}${RESET}`;
87
+ }
88
+
89
+ if (status === "done" || status === "completed") return color("✓", MOCHA.ok);
90
+ if (status === "failed" || status === "error") return color("✗", MOCHA.fail);
91
+ if (status !== "running") return dim("○");
92
+ const elapsed = time - spinnerStart;
93
+ const idx = Math.floor((elapsed / SPINNER_CYCLE_MS) * SPINNER_FRAMES.length) % SPINNER_FRAMES.length;
94
+ const c = shimmerIntensity > 0
95
+ ? lerpRgb(SPINNER_BASE_COLOR, SPINNER_SHIMMER, shimmerIntensity)
96
+ : SPINNER_BASE_COLOR;
97
+ return `${rgbSeq(c)}${SPINNER_FRAMES[idx]}${RESET}`;
98
+ }
99
+
100
+ function currentShimmer(time = Date.now()) {
101
+ const elapsed = time - spinnerStart;
102
+ const quantized = Math.floor(elapsed / 80) * 80;
103
+ const t = (quantized % SPINNER_CYCLE_MS) / SPINNER_CYCLE_MS;
104
+ return 0.5 * (1 + Math.sin(t * Math.PI * 2));
105
+ }
106
+
107
+ // ── activity wave — Tier1 헤더용 미니 파형 ──
108
+ const WAVE_CHARS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
109
+ function activityWave(tick, count = 4) {
110
+ let wave = "";
111
+ for (let i = 0; i < count; i++) {
112
+ const phase = tick * 0.3 + i * 1.5;
113
+ const idx = Math.floor((Math.sin(phase) * 0.5 + 0.5) * (WAVE_CHARS.length - 1));
114
+ wave += WAVE_CHARS[idx];
115
+ }
116
+ return `${MOCHA.executing}${wave}${RESET}`;
117
+ }
118
+
119
+ const GRID_GAP = 2;
120
+ const DEFAULT_DETAIL_LINES = 10;
121
+ // Tier1 상단 고정 행 수
122
+ const TIER1_ROWS = 2;
123
+
124
+ const SUMMARY_KEYS = [
125
+ "status", "lead_action", "verdict", "files_changed",
126
+ "confidence", "risk", "detail", "error_stage", "retryable", "partial_output",
127
+ ];
128
+
129
+ // ── 레이아웃 브레이크포인트 ──────────────────────────────────────────────
130
+ // 80-119: 28col rail, 120-159: 36col rail, 160+: 균등
131
+ function resolveRailWidth(totalCols, columnCount) {
132
+ if (columnCount <= 1) return totalCols;
133
+ if (totalCols >= 160) return Math.floor((totalCols - GRID_GAP * (columnCount - 1)) / columnCount);
134
+ if (totalCols >= 120) return Math.min(36, Math.floor((totalCols - GRID_GAP * (columnCount - 1)) / columnCount));
135
+ return Math.min(28, Math.floor((totalCols - GRID_GAP * (columnCount - 1)) / columnCount));
136
+ }
137
+
138
+ function autoColumnCount(totalCols, workerCount) {
139
+ if (workerCount <= 1) return 1;
140
+ if (totalCols >= 160) return Math.min(workerCount, 3);
141
+ if (totalCols >= 120) return Math.min(workerCount, 2);
142
+ return 1;
143
+ }
144
+
145
+ // ── 문자열 유틸 ──────────────────────────────────────────────────────────
146
+ function clamp(value, min, max) {
147
+ return Math.min(max, Math.max(min, value));
148
+ }
149
+
150
+ function stripCodeBlocks(text) {
151
+ return String(text || "")
152
+ .replace(/\r/g, "")
153
+ // fenced code blocks
154
+ .replace(/```[\s\S]*?(?:```|$)/g, "\n")
155
+ .replace(/^\s*```.*$/gm, "")
156
+ // indented code blocks (4+ spaces or tab at line start)
157
+ .replace(/^(?: |\t).+$/gm, "")
158
+ // shell prompts: PS C:\...>, >, $
159
+ .replace(/^(?:PS\s+\S[^\n]*?>|>\s+|\$\s+)[^\n]*/gm, "")
160
+ .trim();
161
+ }
162
+
163
+ function sanitizeTextBlock(text, rawMode = false) {
164
+ const normalized = rawMode ? String(text || "").replace(/\r/g, "") : stripCodeBlocks(text);
165
+ return normalized
166
+ .split("\n")
167
+ .map((line) => line.trim())
168
+ .filter(Boolean)
169
+ .filter((line) => line !== "--- HANDOFF ---")
170
+ .join("\n")
171
+ .trim();
172
+ }
173
+
174
+ function sanitizeOneLine(text, fallback = "") {
175
+ const normalized = sanitizeTextBlock(text).replace(/\s+/g, " ").trim();
176
+ return normalized || fallback;
177
+ }
178
+
179
+ function sanitizeFiles(files) {
180
+ if (!files) return [];
181
+ const raw = Array.isArray(files) ? files : String(files).split(",");
182
+ return raw.map((e) => sanitizeOneLine(e)).filter(Boolean);
183
+ }
184
+
185
+ function sanitizeFindings(findings) {
186
+ if (!findings) return [];
187
+ const raw = Array.isArray(findings)
188
+ ? findings
189
+ : sanitizeTextBlock(findings).split("\n");
190
+ return raw.map((e) => sanitizeOneLine(e)).filter(Boolean);
191
+ }
192
+
193
+ function normalizeTokens(tokens) {
194
+ if (tokens === null || tokens === undefined) return "";
195
+ if (typeof tokens === "number" && Number.isFinite(tokens)) return tokens;
196
+ const raw = sanitizeOneLine(tokens);
197
+ if (!raw) return "";
198
+ const match = raw.match(/(\d+(?:[.,]\d+)?\s*[kKmM]?)/);
199
+ return match ? match[1].replace(/\s+/g, "").toLowerCase() : raw;
200
+ }
201
+
202
+ function formatTokens(tokens) {
203
+ if (tokens === null || tokens === undefined || tokens === "") return "n/a";
204
+ if (typeof tokens === "number" && Number.isFinite(tokens)) {
205
+ if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}m`;
206
+ if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k`;
207
+ return `${tokens}`;
208
+ }
209
+ return String(tokens);
210
+ }
211
+
212
+ // ── 색상 헬퍼 ─────────────────────────────────────────────────────────────
213
+ function cliColor(cli) {
214
+ if (cli === "gemini") return FG.gemini;
215
+ if (cli === "claude") return FG.claude;
216
+ if (cli === "codex") return FG.codex;
217
+ return FG.white;
218
+ }
219
+
220
+ function runtimeStatus(st) {
221
+ return st?.handoff?.status || st?.status || "pending";
222
+ }
223
+
224
+ function statusColor(status) {
225
+ if (status === "ok" || status === "completed") return MOCHA.ok;
226
+ if (status === "partial") return MOCHA.partial;
227
+ if (status === "failed") return MOCHA.fail;
228
+ if (status === "running" || status === "in_progress") return MOCHA.executing;
229
+ return FG.muted;
230
+ }
231
+
232
+ // ── MOCHA RGB (gradual fade 보간용) ──
233
+ const MOCHA_RGB = {
234
+ ok: { r: 166, g: 227, b: 161 },
235
+ partial: { r: 250, g: 179, b: 135 },
236
+ fail: { r: 243, g: 139, b: 168 },
237
+ executing: { r: 116, g: 199, b: 236 },
238
+ muted: { r: 147, g: 153, b: 178 },
239
+ border: { r: 69, g: 71, b: 90 },
240
+ blue: { r: 137, g: 180, b: 250 },
241
+ sky: { r: 116, g: 199, b: 236 },
242
+ yellow: { r: 249, g: 226, b: 175 },
243
+ peach: { r: 250, g: 179, b: 135 },
244
+ maroon: { r: 235, g: 160, b: 172 },
245
+ surface0: { r: 49, g: 50, b: 68 },
246
+ thinking: { r: 203, g: 166, b: 247 },
247
+ };
248
+
249
+ function statusToRgb(status) {
250
+ if (status === "ok" || status === "completed") return MOCHA_RGB.ok;
251
+ if (status === "partial") return MOCHA_RGB.partial;
252
+ if (status === "failed") return MOCHA_RGB.fail;
253
+ if (status === "running" || status === "in_progress") return MOCHA_RGB.executing;
254
+ return MOCHA_RGB.muted;
255
+ }
256
+
257
+ const FADE_DURATION_MS = 1500;
258
+ const FLASH_PHASE_MS = 250;
259
+ const CARD_GLOW_MS = 3000;
260
+
261
+ // Effect 1: Pulse border — running 워커 보더가 heartbeat 동기 breathing
262
+ function pulseBorderColor(statusRgb, time = Date.now()) {
263
+ const intensity = 0.3 + 0.7 * currentShimmer(time);
264
+ const c = lerpRgb(MOCHA_RGB.border, statusRgb, intensity);
265
+ return rgbSeq(c);
266
+ }
267
+
268
+ // Effect 2: Gradient border — focus pane 보더 상단→하단 그라데이션
269
+ function gradientBorderFn(topRgb, bottomRgb) {
270
+ return (row, totalRows) => {
271
+ const t = totalRows <= 1 ? 0 : row / (totalRows - 1);
272
+ const c = lerpRgb(topRgb, bottomRgb, t);
273
+ return `\x1b[38;2;${c.r};${c.g};${c.b}m`;
274
+ };
275
+ }
276
+
277
+ // Effect 3: Flash-fade border — 상태 변경 시 백색 플래시 → 페이드아웃
278
+ function flashFadeBorderColor(currentStatus, prevStatus, changedAt) {
279
+ const elapsed = Date.now() - (changedAt || 0);
280
+ if (elapsed >= FADE_DURATION_MS || !prevStatus) return null;
281
+ const statusRgb = statusToRgb(currentStatus);
282
+ if (elapsed < FLASH_PHASE_MS) {
283
+ const t = elapsed / FLASH_PHASE_MS;
284
+ const bright = { r: 255, g: 255, b: 255 };
285
+ const c = lerpRgb(bright, statusRgb, t);
286
+ return `\x1b[38;2;${c.r};${c.g};${c.b}m`;
287
+ }
288
+ const t = (elapsed - FLASH_PHASE_MS) / (FADE_DURATION_MS - FLASH_PHASE_MS);
289
+ const c = lerpRgb(statusRgb, MOCHA_RGB.border, t);
290
+ return `\x1b[38;2;${c.r};${c.g};${c.b}m`;
291
+ }
292
+
293
+ function easeOutCubic(t) {
294
+ return 1 - ((1 - t) ** 3);
295
+ }
296
+
297
+ function borderHighlightPosition(width, bodyLines, time = Date.now()) {
298
+ const totalRows = bodyLines + 2;
299
+ const perimeter = 2 * (width - 2) + 2 * totalRows;
300
+ if (perimeter <= 0) return undefined;
301
+ return Math.floor(time / 120) % perimeter;
302
+ }
303
+
304
+ function titleFlash(status, changeElapsed) {
305
+ const isCompleted = status === "completed" || status === "done" || status === "ok";
306
+ const isFailed = status === "failed" || status === "error" || status === "fail";
307
+ if ((!isCompleted && !isFailed) || changeElapsed > 800) return null;
308
+ const flashRgb = isCompleted ? MOCHA_RGB.ok : MOCHA_RGB.fail;
309
+ const bgRgb = changeElapsed <= 300
310
+ ? flashRgb
311
+ : lerpRgb(flashRgb, MOCHA_RGB.surface0, clamp((changeElapsed - 300) / 500, 0, 1));
312
+ return rgbSeq(bgRgb, 48);
313
+ }
314
+
315
+ function dedupeRole(role, name, cli) {
316
+ if (!role) return "";
317
+ let r = role;
318
+ const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
319
+ r = r.replace(new RegExp(esc(cli), "gi"), "").trim();
320
+ r = r.replace(new RegExp(esc(name), "gi"), "").trim();
321
+ // CLI indicator emojis 제거
322
+ r = r.replace(/[⚪⚫🔴🟠🟡🟢🔵🟣🟤⭕🔘]/g, "").trim();
323
+ // 빈 괄호 제거 + 중첩 괄호 정리
324
+ r = r.replace(/\(\s*\)/g, "").trim();
325
+ r = r.replace(/^\(([^()]+)\)$/, "$1").trim();
326
+ r = r.replace(/^\s*[•·\-]\s*/, "").trim();
327
+ return r;
328
+ }
329
+
330
+ // ── 텍스트 래핑 ──────────────────────────────────────────────────────────
331
+ function wrapLine(text, width) {
332
+ const limit = Math.max(8, width);
333
+ const source = String(text || "").trim();
334
+ if (!source) return [""];
335
+ const words = source.split(/\s+/);
336
+ const lines = [];
337
+ let current = "";
338
+ for (const word of words) {
339
+ const candidate = current ? `${current} ${word}` : word;
340
+ if (wcswidth(candidate) <= limit) { current = candidate; continue; }
341
+ if (current) { lines.push(current); current = ""; }
342
+ if (wcswidth(word) <= limit) { current = word; continue; }
343
+ let offset = 0;
344
+ while (offset < word.length) {
345
+ lines.push(word.slice(offset, offset + limit));
346
+ offset += limit;
347
+ }
348
+ }
349
+ if (current) lines.push(current);
350
+ return lines.length > 0 ? lines : [source.slice(0, limit)];
351
+ }
352
+
353
+ function wrapText(text, width, maxLines = DEFAULT_DETAIL_LINES, rawMode = false) {
354
+ if (maxLines <= 0) return [];
355
+ const input = sanitizeTextBlock(text, rawMode);
356
+ if (!input) return [];
357
+ const wrapped = input.split("\n").flatMap((line) => wrapLine(line, width)).filter(Boolean);
358
+ if (wrapped.length <= maxLines) return wrapped;
359
+ return [...wrapped.slice(0, maxLines - 1), truncate(wrapped[wrapped.length - 1], width)];
360
+ }
361
+
362
+ // 스크롤 없이 전체 줄 반환 (focus pane용)
363
+ function wrapTextAll(text, width, rawMode = false) {
364
+ const input = sanitizeTextBlock(text, rawMode);
365
+ if (!input) return [];
366
+ return input.split("\n").flatMap((line) => wrapLine(line, width)).filter(Boolean);
367
+ }
368
+
369
+ // ── virtual row buffer ────────────────────────────────────────────────────
370
+ class RowBuffer {
371
+ constructor() {
372
+ this._rows = [];
373
+ this._prev = [];
374
+ }
375
+
376
+ set(rows) {
377
+ this._rows = rows.map(String);
378
+ }
379
+
380
+ /** 변경된 row 인덱스 목록 반환 */
381
+ diff() {
382
+ const dirty = [];
383
+ const len = Math.max(this._rows.length, this._prev.length);
384
+ for (let i = 0; i < len; i++) {
385
+ if (this._rows[i] !== this._prev[i]) dirty.push(i);
386
+ }
387
+ return dirty;
388
+ }
389
+
390
+ commit() {
391
+ this._prev = [...this._rows];
392
+ }
393
+
394
+ get rows() { return this._rows; }
395
+ get prevLen() { return this._prev.length; }
396
+ }
397
+
398
+ // ── 상태 집계 ─────────────────────────────────────────────────────────────
399
+ function countStatuses(names, workers) {
400
+ let ok = 0, partial = 0, failed = 0, running = 0;
401
+ for (const name of names) {
402
+ const st = workers.get(name);
403
+ const s = runtimeStatus(st);
404
+ if (s === "ok" || s === "completed") ok++;
405
+ else if (s === "partial") partial++;
406
+ else if (s === "failed") failed++;
407
+ else if (s === "running" || s === "in_progress") running++;
408
+ }
409
+ return { ok, partial, failed, running };
410
+ }
411
+
412
+ // ── Tier1: 상단 고정 1행 ─────────────────────────────────────────────────
413
+ function phaseColor(phase, time = Date.now()) {
414
+ const shimmer = currentShimmer(time);
415
+ if (phase === "exec" || phase === "executing") return rgbSeq(lerpRgb(MOCHA_RGB.blue, MOCHA_RGB.sky, shimmer));
416
+ if (phase === "verify" || phase === "verifying") return rgbSeq(lerpRgb(MOCHA_RGB.yellow, MOCHA_RGB.peach, shimmer));
417
+ if (phase === "fix" || phase === "fixing") return rgbSeq(lerpRgb(MOCHA_RGB.fail, MOCHA_RGB.maroon, shimmer));
418
+ return FG.accent;
419
+ }
420
+
421
+ function buildTier1(names, workers, pipeline, elapsed, width, version, time = Date.now()) {
422
+ const { ok, partial, failed, running } = countStatuses(names, workers);
423
+ const phase = pipeline.phase || "exec";
424
+ const row1 = truncate(
425
+ `${color("▲", FG.triflux)} v${version} ${dim("│")} ${color(phase, phaseColor(phase, time))} ${dim("│")} ${elapsed}s ${dim("│")} ` +
426
+ `${color(`✓${ok}`, MOCHA.ok)} ${color(`◑${partial}`, MOCHA.partial)} ${color(`✗${failed}`, MOCHA.fail)} ${dim(`▶${running}`)}${running > 0 ? ` ${activityWave(spinnerTick)}` : ""}`,
427
+ width,
428
+ );
429
+ const keysHint = color("Tab:focus • j/k/↑↓:nav • f:follow • r:raw • l:tab • n:recent • 1-9:jump", MOCHA.subtext);
430
+ const hintWidth = wcswidth(stripAnsi(keysHint));
431
+ const row2 = hintWidth >= width
432
+ ? truncate(keysHint, width)
433
+ : padRight(`${" ".repeat(width - hintWidth)}${keysHint}`, width);
434
+ return [row1, row2];
435
+ }
436
+
437
+ // ── 카드 렌더러 (Tier2 worker rail) ─────────────────────────────────────
438
+ function detailText(st) {
439
+ if (st.detail) return st.detail;
440
+ const lines = [];
441
+ for (const key of SUMMARY_KEYS) {
442
+ const value = st.handoff?.[key];
443
+ if (Array.isArray(value) && value.length > 0) lines.push(`${key}: ${value.join(", ")}`);
444
+ else if (value) lines.push(`${key}: ${value}`);
445
+ }
446
+ if (st.snapshot) lines.unshift(st.snapshot);
447
+ return lines.join("\n");
448
+ }
449
+
450
+ function detailHighlights(st) {
451
+ if (Array.isArray(st.findings) && st.findings.length > 0) return st.findings;
452
+ const verdict = sanitizeOneLine(st.handoff?.verdict);
453
+ return sanitizeTextBlock(detailText(st))
454
+ .split("\n")
455
+ .map((line) => line.replace(/^verdict\s*:\s*/i, "").trim())
456
+ .filter(Boolean)
457
+ .filter((line) => line !== verdict)
458
+ .filter((line) => !SUMMARY_KEYS.some((key) => line.toLowerCase().startsWith(`${key}:`)))
459
+ .slice(0, 2);
460
+ }
461
+
462
+ function buildWorkerRail(name, st, opts = {}) {
463
+ const {
464
+ width,
465
+ selected = false,
466
+ focused = false, // rail 포커스 여부
467
+ previousSelected = false,
468
+ rawMode = false,
469
+ compact = false,
470
+ time = Date.now(),
471
+ } = opts;
472
+ const innerWidth = Math.max(12, width - 4);
473
+ const cli = st.cli || "codex";
474
+ const role = sanitizeOneLine(st.role);
475
+ const status = runtimeStatus(st);
476
+ const sec = Number.isFinite(st._logSec) ? st._logSec : 0;
477
+ const changeElapsed = st._statusChangedAt ? Math.max(0, time - st._statusChangedAt) : Number.POSITIVE_INFINITY;
478
+
479
+ // Tier2 행 1: 이름 + CLI + role
480
+ const selMark = selected
481
+ ? (focused ? color("▶", MOCHA.blue) : color(">", FG.triflux))
482
+ : previousSelected
483
+ ? dim("~")
484
+ : " ";
485
+ const hb = heartbeat(status, status === "running" ? currentShimmer(time) : 0, st._statusChangedAt, time);
486
+ // host 배지 (원격 워커용)
487
+ const hostBadge = st.host && st.host !== "local"
488
+ ? color(`[${st.host}]`, MOCHA.mauve) + " "
489
+ : "";
490
+ const displayRole = dedupeRole(role, name, cli);
491
+ const title = truncate(
492
+ `${selMark} ${hb} ${hostBadge}${color(name, FG.triflux)} ${color("•", MOCHA.overlay)} ${color(cli, cliColor(cli))}${displayRole ? ` ${color(`(${displayRole})`, MOCHA.overlay)}` : ""}`,
493
+ innerWidth,
494
+ );
495
+
496
+ const cardWidth = Math.max(MIN_CARD_WIDTH, width);
497
+ const borderHighlight = focused ? borderHighlightPosition(cardWidth, compact ? 2 : 6, time) : undefined;
498
+ const titleFlashBg = titleFlash(status, changeElapsed);
499
+
500
+ // status-specific border: focused→mauve, selected→bright, non-selected→glow decay
501
+ const statusBorderColor = (() => {
502
+ if (focused) return MOCHA.thinking;
503
+ if (selected && (status === "running" || status === "in_progress")) {
504
+ return pulseBorderColor(statusToRgb(status), time);
505
+ }
506
+ if (selected) return statusColor(status);
507
+ const from = statusToRgb(status);
508
+ const decayBase = st._statusChangedAt ? clamp(changeElapsed / CARD_GLOW_MS, 0, 1) : 1;
509
+ const decayT = easeOutCubic(decayBase);
510
+ return rgbSeq(lerpRgb(from, MOCHA_RGB.border, 0.5 + (0.5 * decayT)));
511
+ })();
512
+
513
+ if (compact) {
514
+ // compact 2-line 카드
515
+ const progress = Number.isFinite(st.progress) ? clamp(st.progress, 0, 1) : (status === "running" ? 0.3 : 1);
516
+ const percent = Math.round(progress * 100);
517
+ const compactLine1 = truncate(
518
+ `${selMark} ${hb} ${hostBadge}${color(name, FG.triflux)} ${dim("•")} ${color(cli, cliColor(cli))} ${statusBadge(status)} ${String(percent).padStart(3)}%`,
519
+ innerWidth,
520
+ );
521
+ const verdict = sanitizeOneLine(st.handoff?.verdict || st.summary || st.snapshot, status);
522
+ const compactLine2 = truncate(color(verdict, MOCHA.text), innerWidth);
523
+ const framed = box([compactLine1, compactLine2], cardWidth, statusBorderColor, {
524
+ highlightPos: borderHighlight,
525
+ titleFlashBg,
526
+ });
527
+ return [framed.top, ...framed.body, framed.bot];
528
+ }
529
+
530
+ // Tier2 행 2: 상태 배지 + elapsed + tokens + conf
531
+ const confidence = sanitizeOneLine(st.handoff?.confidence || st.confidence, "n/a");
532
+ const statusLine = truncate(
533
+ `${statusBadge(status)} ${color("•", MOCHA.overlay)} ${color(`${sec}s`, MOCHA.subtext)} ${color("•", MOCHA.overlay)} ${color(`tok ${formatTokens(st.tokens)}`, MOCHA.subtext)} ${color("•", MOCHA.overlay)} ${color(`conf ${confidence}`, MOCHA.subtext)}`,
534
+ innerWidth,
535
+ );
536
+
537
+ // Tier2 행 3: progress bar
538
+ const progress = Number.isFinite(st.progress) ? clamp(st.progress, 0, 1) : (status === "running" ? 0.3 : 1);
539
+ const percent = Math.round(progress * 100);
540
+ const barWidth = clamp(Math.floor(innerWidth * 0.3), 8, 16);
541
+ const bar = progressBar(percent, barWidth, time);
542
+ const progressLine = truncate(
543
+ `${bar} ${color(`${String(percent).padStart(3)}%`, MOCHA.text)}`,
544
+ innerWidth,
545
+ );
546
+
547
+ // Tier2 행 4-6: verdict / findings / files
548
+ const verdict = sanitizeOneLine(st.handoff?.verdict || st.summary || st.snapshot, status);
549
+ const findings = detailHighlights(st).join(" / ") || "no notable findings yet";
550
+ const files = sanitizeFiles(st.handoff?.files_changed || st.files_changed).join(", ") || "none";
551
+
552
+ const verdictClr = statusColor(status);
553
+ const lines = [
554
+ title,
555
+ statusLine,
556
+ progressLine,
557
+ truncate(`${color("verdict", MOCHA.overlay)} ${color(verdict, verdictClr)}`, innerWidth),
558
+ truncate(`${color("findings", MOCHA.overlay)} ${color(findings, MOCHA.subtext)}`, innerWidth),
559
+ truncate(`${color("files", MOCHA.overlay)} ${color(files, MOCHA.subtext)}`, innerWidth),
560
+ ];
561
+
562
+ const framed = box(lines, cardWidth, statusBorderColor, {
563
+ highlightPos: borderHighlight,
564
+ titleFlashBg,
565
+ });
566
+ return [framed.top, ...framed.body, framed.bot];
567
+ }
568
+
569
+ // ── Tier3: focus pane (우측 detail) ─────────────────────────────────────
570
+ function buildFocusPane(name, st, opts = {}) {
571
+ const {
572
+ width,
573
+ height = 20,
574
+ scrollOffset = 0,
575
+ followTail = false,
576
+ rawMode = false,
577
+ focused = false,
578
+ time = Date.now(),
579
+ } = opts;
580
+ const innerWidth = Math.max(12, width - 4);
581
+
582
+ // verdict sticky 4행
583
+ const verdict = sanitizeOneLine(st.handoff?.verdict || st.summary || st.snapshot, "—");
584
+ const confidence = sanitizeOneLine(st.handoff?.confidence || st.confidence, "n/a");
585
+ const files = sanitizeFiles(st.handoff?.files_changed || st.files_changed);
586
+ const status = runtimeStatus(st);
587
+
588
+ // Tab bar: 활성 탭은 MOCHA.blue + bold, 비활성은 MOCHA.overlay
589
+ const activeTab = opts.activeTab || "log";
590
+ const tabLog = activeTab === "log" ? `${MOCHA.blue}${bold("[Log]")}` : color("[Log]", MOCHA.overlay);
591
+ const tabDetail = activeTab === "detail" ? `${MOCHA.blue}${bold("[Detail]")}` : color("[Detail]", MOCHA.overlay);
592
+ const tabFiles = activeTab === "files" ? `${MOCHA.blue}${bold(`[Files ${files.length}]`)}` : color(`[Files ${files.length}]`, MOCHA.overlay);
593
+ const tabBar = truncate(`${tabLog} ${tabDetail} ${tabFiles}`, innerWidth);
594
+
595
+ const stickyLines = [
596
+ truncate(`${color(name, FG.triflux)} ${color("•", MOCHA.overlay)} ${statusBadge(status)}`, innerWidth),
597
+ tabBar,
598
+ truncate(`${color("verdict", MOCHA.overlay)} ${color(verdict, statusColor(status))}`, innerWidth),
599
+ truncate(`${color("conf", MOCHA.overlay)} ${color(confidence, MOCHA.text)}`, innerWidth),
600
+ color("─", MOCHA.surface0).repeat(Math.max(4, innerWidth)),
601
+ ];
602
+
603
+ // 본문 스크롤 영역
604
+ const bodyAvail = Math.max(0, height - stickyLines.length - 3); // top+bot border + scrollInfo
605
+
606
+ let allBodyLines;
607
+ if (activeTab === "detail") {
608
+ const summaryLines = [];
609
+ for (const key of SUMMARY_KEYS) {
610
+ const value = st.handoff?.[key];
611
+ if (Array.isArray(value) && value.length > 0) summaryLines.push(`${key}: ${value.join(", ")}`);
612
+ else if (value) summaryLines.push(`${key}: ${value}`);
613
+ }
614
+ allBodyLines = summaryLines.length > 0
615
+ ? summaryLines.flatMap((l) => wrapLine(l, innerWidth))
616
+ : [dim("no structured data")];
617
+ } else if (activeTab === "files") {
618
+ const filesList = sanitizeFiles(st.handoff?.files_changed || st.files_changed);
619
+ allBodyLines = filesList.length > 0
620
+ ? filesList.map((f, i) => `${i + 1}. ${f}`)
621
+ : [dim("no files changed")];
622
+ } else {
623
+ allBodyLines = wrapTextAll(detailText(st), innerWidth, rawMode);
624
+ }
625
+
626
+ let startIdx;
627
+ if (followTail) {
628
+ startIdx = Math.max(0, allBodyLines.length - bodyAvail);
629
+ } else {
630
+ startIdx = clamp(scrollOffset, 0, Math.max(0, allBodyLines.length - bodyAvail));
631
+ }
632
+
633
+ const bodySlice = allBodyLines.slice(startIdx, startIdx + bodyAvail);
634
+ if (bodySlice.length === 0) bodySlice.push(dim("no detail available"));
635
+
636
+ // scroll indicator — MOCHA.overlay for position
637
+ const scrollInfo = allBodyLines.length > bodyAvail
638
+ ? color(`${startIdx + 1}-${Math.min(startIdx + bodyAvail, allBodyLines.length)}/${allBodyLines.length}`, MOCHA.overlay)
639
+ : color(`${allBodyLines.length} lines`, MOCHA.overlay);
640
+
641
+ const contentLines = [
642
+ ...stickyLines,
643
+ ...bodySlice.map((l) => truncate(l, innerWidth)),
644
+ truncate(scrollInfo, innerWidth),
645
+ ];
646
+
647
+ // Effect 2: focused pane gets gradient border (blue→border), unfocused gets dim
648
+ const borderColor = focused
649
+ ? gradientBorderFn(MOCHA_RGB.blue, MOCHA_RGB.border)
650
+ : MOCHA.border;
651
+ const paneWidth = Math.max(MIN_CARD_WIDTH, width);
652
+ const framed = box(contentLines, paneWidth, borderColor, {
653
+ highlightPos: focused ? borderHighlightPosition(paneWidth, contentLines.length, time) : undefined,
654
+ });
655
+ return [framed.top, ...framed.body, framed.bot];
656
+ }
657
+
658
+ // ── summary bar (≥4 workers) ──────────────────────────────────────────────
659
+ function buildSummaryBar(names, workers, selectedWorker, pipeline, width, version) {
660
+ const maxChipWidth = clamp(Math.floor((width - 6) / Math.min(names.length, 4)), 16, 26);
661
+ const chips = names.map((name, idx) => {
662
+ const st = workers.get(name);
663
+ const status = runtimeStatus(st);
664
+ const progress = Number.isFinite(st.progress) ? clamp(st.progress, 0, 1) : (status === "running" ? 0.3 : 1);
665
+ const label = `${selectedWorker === name ? ">" : " "} ${idx + 1}.${name} ${status} ${Math.round(progress * 100)}%`;
666
+ return padRight(truncate(label, maxChipWidth), maxChipWidth);
667
+ });
668
+ const chipsLine = truncate(chips.join(color(" │ ", MOCHA.overlay)), width - 4);
669
+ const keysLine = truncate(color("Tab:focus • j/k/↑↓:nav • f:follow • r:raw • l:tab • n:recent • 1-9:jump", MOCHA.subtext), width - 4);
670
+ const framed = box([chipsLine, keysLine], width);
671
+ return [framed.top, ...framed.body, framed.bot];
672
+ }
673
+
674
+ // ── help overlay ──────────────────────────────────────────────────────────
675
+ function buildHelpOverlay(width, height) {
676
+ const innerWidth = Math.min(50, width - 6);
677
+ const helpLines = [
678
+ color(" Keyboard Shortcuts", FG.triflux),
679
+ "",
680
+ ` ${color("Tab", MOCHA.blue)} rail ↔ detail 포커스 전환`,
681
+ ` ${color("j/↓", MOCHA.blue)} 다음 워커 / 스크롤 아래`,
682
+ ` ${color("k/↑", MOCHA.blue)} 이전 워커 / 스크롤 위`,
683
+ ` ${color("1-9", MOCHA.blue)} 워커 직접 선택`,
684
+ ` ${color("n", MOCHA.blue)} 최근 상태 변경 워커 선택`,
685
+ ` ${color("f", MOCHA.blue)} follow-tail 토글`,
686
+ ` ${color("r", MOCHA.blue)} raw mode 토글`,
687
+ ` ${color("l", MOCHA.blue)} 탭 전환 (Log/Detail/Files)`,
688
+ ` ${color("g", MOCHA.blue)} focus pane 상단 점프`,
689
+ ` ${color("G", MOCHA.blue)} focus pane 하단 점프`,
690
+ ` ${color("PgUp", MOCHA.blue)} 페이지 위 스크롤`,
691
+ ` ${color("PgDn", MOCHA.blue)} 페이지 아래 스크롤`,
692
+ ` ${color("Shift+↑↓", MOCHA.blue)} 워커 선택 + 포커스 이동`,
693
+ ` ${color("Shift+←→", MOCHA.blue)} rail ↔ detail 포커스`,
694
+ ` ${color("h/?", MOCHA.blue)} 이 도움말 토글`,
695
+ ` ${color("q", MOCHA.blue)} 대시보드 종료`,
696
+ "",
697
+ dim(" 아무 키나 눌러 닫기"),
698
+ ];
699
+ const framed = box(helpLines, innerWidth + 4, MOCHA.blue);
700
+ const framedRows = [framed.top, ...framed.body, framed.bot];
701
+ const topPad = Math.max(0, Math.floor((height - framedRows.length) / 2));
702
+ const leftPad = " ".repeat(Math.max(0, Math.floor((width - innerWidth - 4) / 2)));
703
+ const result = [];
704
+ for (let i = 0; i < height; i++) {
705
+ const fi = i - topPad;
706
+ if (fi >= 0 && fi < framedRows.length) {
707
+ result.push(`${leftPad}${framedRows[fi]}`);
708
+ } else {
709
+ result.push("");
710
+ }
711
+ }
712
+ return result;
713
+ }
714
+
715
+ // ── joinColumns ───────────────────────────────────────────────────────────
716
+ function joinColumns(blocks, gap = GRID_GAP) {
717
+ const maxHeight = Math.max(...blocks.map((b) => b.length));
718
+ return Array.from({ length: maxHeight }, (_, rowIdx) =>
719
+ blocks
720
+ .map((block) => block[rowIdx] || " ".repeat(wcswidth(stripAnsi(block[0] || ""))))
721
+ .join(" ".repeat(gap)),
722
+ );
723
+ }
724
+
725
+ // ── normalizeWorkerState ──────────────────────────────────────────────────
726
+ function normalizeWorkerState(existing, state) {
727
+ const nextHandoff = state.handoff === undefined
728
+ ? existing.handoff
729
+ : {
730
+ ...(existing.handoff || {}),
731
+ ...(state.handoff || {}),
732
+ verdict: state.handoff?.verdict !== undefined
733
+ ? sanitizeOneLine(state.handoff.verdict)
734
+ : existing.handoff?.verdict,
735
+ files_changed: state.handoff?.files_changed !== undefined
736
+ ? sanitizeFiles(state.handoff.files_changed)
737
+ : existing.handoff?.files_changed,
738
+ confidence: state.handoff?.confidence !== undefined
739
+ ? sanitizeOneLine(state.handoff.confidence)
740
+ : existing.handoff?.confidence,
741
+ status: state.handoff?.status !== undefined
742
+ ? sanitizeOneLine(state.handoff.status)
743
+ : existing.handoff?.status,
744
+ };
745
+
746
+ return {
747
+ ...existing,
748
+ ...state,
749
+ cli: state.cli !== undefined ? sanitizeOneLine(state.cli, existing.cli || "codex") : (existing.cli || "codex"),
750
+ role: state.role !== undefined ? sanitizeOneLine(state.role) : existing.role,
751
+ status: state.status !== undefined ? sanitizeOneLine(state.status, existing.status || "pending") : (existing.status || "pending"),
752
+ snapshot: state.snapshot !== undefined ? sanitizeTextBlock(state.snapshot) : existing.snapshot,
753
+ summary: state.summary !== undefined ? sanitizeTextBlock(state.summary) : existing.summary,
754
+ detail: state.detail !== undefined ? sanitizeTextBlock(state.detail) : existing.detail,
755
+ findings: state.findings !== undefined ? sanitizeFindings(state.findings) : existing.findings,
756
+ files_changed: state.files_changed !== undefined ? sanitizeFiles(state.files_changed) : existing.files_changed,
757
+ confidence: state.confidence !== undefined ? sanitizeOneLine(state.confidence) : existing.confidence,
758
+ tokens: state.tokens !== undefined ? normalizeTokens(state.tokens) : existing.tokens,
759
+ progress: state.progress !== undefined ? clamp(Number(state.progress) || 0, 0, 1) : existing.progress,
760
+ handoff: nextHandoff,
761
+ _prevStatus: (state.status !== undefined && sanitizeOneLine(state.status) !== existing.status)
762
+ ? existing.status : existing._prevStatus,
763
+ _statusChangedAt: (state.status !== undefined && sanitizeOneLine(state.status) !== existing.status)
764
+ ? Date.now() : (existing._statusChangedAt || 0),
765
+ };
766
+ }
767
+
768
+ // ── createLogDashboard ────────────────────────────────────────────────────
769
+ /**
770
+ * alternate-screen diff renderer (Tier1/2/3)
771
+ * @param {object} [opts]
772
+ * @param {NodeJS.WriteStream} [opts.stream=process.stdout]
773
+ * @param {NodeJS.ReadStream} [opts.input=process.stdin]
774
+ * @param {number} [opts.refreshMs=1000]
775
+ * @param {number} [opts.columns] — 터미널 폭 override (테스트/뷰어용)
776
+ * @param {string} [opts.layout] — "single"|"split-2col"|"split-3col"|"summary+detail"|"auto"
777
+ * @returns {LogDashboardHandle}
778
+ */
779
+ export function createLogDashboard(opts = {}) {
780
+ const {
781
+ stream = process.stdout,
782
+ input = process.stdin,
783
+ refreshMs = 1000,
784
+ columns,
785
+ layout: layoutHint = "auto",
786
+ forceTTY = false,
787
+ } = opts;
788
+
789
+ const isTTY = forceTTY || !!stream?.isTTY;
790
+
791
+ const workers = new Map();
792
+ let pipeline = { phase: "exec", fix_attempt: 0 };
793
+ let startedAt = Date.now();
794
+ let timer = null;
795
+ let closed = false;
796
+ let frameCount = 0;
797
+ let selectedWorker = null;
798
+ let previousSelectedWorker = null;
799
+ // focus: "rail" | "detail"
800
+ let focus = "rail";
801
+ let detailScrollOffset = 0;
802
+ let followTail = false;
803
+ let rawMode = false;
804
+ let focusTab = "log"; // "log" | "detail" | "files"
805
+ let helpOverlay = false;
806
+ let inputAttached = false;
807
+ let rawModeEnabled = false;
808
+
809
+ // virtual row buffer (altScreen 전용)
810
+ const rowBuf = new RowBuffer();
811
+
812
+ // ── TTY 출력 헬퍼 ────────────────────────────────────────────────────
813
+ function write(text) {
814
+ if (!closed) stream.write(text);
815
+ }
816
+
817
+ function writeln(text) {
818
+ if (!closed) stream.write(`${text}\n`);
819
+ }
820
+
821
+ function nowElapsedSec() {
822
+ return Math.max(0, Math.round((Date.now() - startedAt) / 1000));
823
+ }
824
+
825
+ function getViewportColumns() {
826
+ const v = Number.isFinite(columns)
827
+ ? columns
828
+ : (Number.isFinite(stream?.columns)
829
+ ? stream.columns
830
+ : (Number.isFinite(process.stdout?.columns) ? process.stdout.columns : FALLBACK_COLUMNS));
831
+ return Math.max(48, v || FALLBACK_COLUMNS);
832
+ }
833
+
834
+ function getViewportRows() {
835
+ const v = Number.isFinite(stream?.rows)
836
+ ? stream.rows
837
+ : (Number.isFinite(process.stdout?.rows) ? process.stdout.rows : FALLBACK_ROWS);
838
+ return Math.max(10, v || FALLBACK_ROWS);
839
+ }
840
+
841
+ function visibleWorkerNames() {
842
+ return [...workers.keys()].sort();
843
+ }
844
+
845
+ function ensureSelectedWorker(names) {
846
+ if (names.length === 0) { selectedWorker = null; return; }
847
+ if (!selectedWorker || !workers.has(selectedWorker)) selectedWorker = names[0];
848
+ }
849
+
850
+ function setSelectedWorker(nextWorker, { preserveTrail = true } = {}) {
851
+ if (!nextWorker || nextWorker === selectedWorker) return;
852
+ if (preserveTrail && selectedWorker && workers.has(selectedWorker)) {
853
+ previousSelectedWorker = selectedWorker;
854
+ }
855
+ selectedWorker = nextWorker;
856
+ detailScrollOffset = 0;
857
+ }
858
+
859
+ function selectRelative(offset) {
860
+ const names = visibleWorkerNames();
861
+ if (names.length === 0) return;
862
+ ensureSelectedWorker(names);
863
+ const idx = Math.max(0, names.indexOf(selectedWorker));
864
+ setSelectedWorker(names[(idx + offset + names.length) % names.length]);
865
+ render();
866
+ }
867
+
868
+ function selectMostRecentChangedWorker() {
869
+ const names = visibleWorkerNames();
870
+ if (names.length === 0) return;
871
+ ensureSelectedWorker(names);
872
+ const target = names.reduce((best, name) => {
873
+ const changedAt = workers.get(name)?._statusChangedAt || 0;
874
+ const bestChangedAt = workers.get(best)?._statusChangedAt || 0;
875
+ return changedAt > bestChangedAt ? name : best;
876
+ }, names[0]);
877
+ setSelectedWorker(target);
878
+ render();
879
+ }
880
+
881
+ function scrollDetail(delta) {
882
+ followTail = false;
883
+ detailScrollOffset = Math.max(0, detailScrollOffset + delta);
884
+ render();
885
+ }
886
+
887
+ // ── doClose (내부 함수) ─────────────────────────────────────────────
888
+ function doClose() {
889
+ if (closed) return;
890
+ if (timer) clearInterval(timer);
891
+ if (inputAttached && typeof input?.off === "function") input.off("data", handleInput);
892
+ if (rawModeEnabled && typeof input?.setRawMode === "function") input.setRawMode(false);
893
+ if (inputAttached && typeof input?.pause === "function") input.pause();
894
+ exitAltScreen();
895
+ closed = true;
896
+ }
897
+
898
+ // ── 키 입력 ──────────────────────────────────────────────────────────
899
+ function handleInput(chunk) {
900
+ const key = String(chunk);
901
+ if (key === "\u0003") return; // Ctrl-C
902
+
903
+ // Help overlay: 아무 키나 누르면 닫기
904
+ if (helpOverlay) {
905
+ helpOverlay = false;
906
+ render();
907
+ return;
908
+ }
909
+
910
+ // Enter: 선택된 워커 세션에 attach (k9s 패턴)
911
+ if (key === "\r" || key === "\n") {
912
+ if (!selectedWorker) return;
913
+ const w = workers.get(selectedWorker);
914
+ if (!w) return;
915
+ const sessionTarget = w.sessionName || w.paneName;
916
+ if (!sessionTarget) return;
917
+ attachToSession(w);
918
+ return;
919
+ }
920
+
921
+ // Tab: rail ↔ detail 포커스 전환
922
+ if (key === "\t") {
923
+ focus = focus === "rail" ? "detail" : "rail";
924
+ render();
925
+ return;
926
+ }
927
+
928
+ // Shift+Arrow: 포커스 이동 + 워커 선택
929
+ if (key === "\x1b[1;2A") { selectRelative(-1); return; } // Shift+Up → 워커 위
930
+ if (key === "\x1b[1;2B") { selectRelative(1); return; } // Shift+Down → 워커 아래
931
+ if (key === "\x1b[1;2D") { focus = "rail"; render(); return; } // Shift+Left → rail
932
+ if (key === "\x1b[1;2C") { focus = "detail"; render(); return; } // Shift+Right → detail
933
+
934
+ if (focus === "detail") {
935
+ // detail 포커스: j/k/ArrowDown/Up = 스크롤
936
+ if (key === "j" || key === "\u001b[B") { scrollDetail(1); return; }
937
+ if (key === "k" || key === "\u001b[A") { scrollDetail(-1); return; }
938
+ } else {
939
+ // rail 포커스: j/k = 워커 선택
940
+ if (key === "j" || key === "\u001b[B") { selectRelative(1); return; }
941
+ if (key === "k" || key === "\u001b[A") { selectRelative(-1); return; }
942
+ }
943
+
944
+ // g: focus pane 상단 점프
945
+ if (key === "g") { followTail = false; detailScrollOffset = 0; render(); return; }
946
+ // G: focus pane 하단 점프
947
+ if (key === "G") { followTail = true; detailScrollOffset = 0; render(); return; }
948
+ // PgUp/PgDn: 페이지 단위 스크롤
949
+ const pageSize = Math.max(1, Math.floor(getViewportRows() / 2));
950
+ if (key === "\x1b[5~") { scrollDetail(-pageSize); return; } // PgUp
951
+ if (key === "\x1b[6~") { scrollDetail(pageSize); return; } // PgDn
952
+ // f: follow-tail 토글
953
+ if (key === "f") { followTail = !followTail; if (followTail) detailScrollOffset = 0; render(); return; }
954
+ // r: raw mode 토글
955
+ if (key === "r") { rawMode = !rawMode; render(); return; }
956
+ // l: 탭 전환 (Log → Detail → Files)
957
+ if (key === "l") {
958
+ const tabs = ["log", "detail", "files"];
959
+ focusTab = tabs[(tabs.indexOf(focusTab) + 1) % tabs.length];
960
+ detailScrollOffset = 0;
961
+ render();
962
+ return;
963
+ }
964
+ // n: 가장 최근 상태 변경 워커로 이동
965
+ if (key === "n") { selectMostRecentChangedWorker(); return; }
966
+ // h/?: 도움말 오버레이 토글
967
+ if (key === "h" || key === "?") { helpOverlay = true; render(); return; }
968
+ // q: 대시보드 종료
969
+ if (key === "q") { doClose(); return; }
970
+ // 1-9: 워커 직접 선택
971
+ if (/^[1-9]$/.test(key)) {
972
+ const names = visibleWorkerNames();
973
+ const target = names[Number.parseInt(key, 10) - 1];
974
+ if (target) { setSelectedWorker(target); render(); }
975
+ return;
976
+ }
977
+ }
978
+
979
+ // ── Enter→attach (k9s 패턴) ───────────────────────────────────────────
980
+ function attachToSession(worker) {
981
+ const execFileFn = opts.deps?.execFile || _execFile;
982
+ // 1. rawMode 해제 + input 일시정지 (키 이벤트 차단)
983
+ if (rawModeEnabled && typeof input?.setRawMode === "function") input.setRawMode(false);
984
+ if (typeof input?.pause === "function") input.pause();
985
+ // 2. altScreen 퇴장
986
+ exitAltScreen();
987
+
988
+ const sessionName = worker.sessionName || worker.paneName;
989
+ if (worker.remote && worker.sshUser) {
990
+ // 원격: SSH + psmux attach in new WT tab
991
+ const host = worker.host || "unknown";
992
+ const ip = worker._sshIp || host;
993
+ const title = `${host}:${worker.role || sessionName}`;
994
+ execFileFn("wt.exe", ["-w", "0", "nt", "--title", title, "--",
995
+ "ssh", `${worker.sshUser}@${ip}`, "-t", `psmux attach -t ${sessionName}`],
996
+ { detached: true, stdio: "ignore", windowsHide: false }, () => {});
997
+ } else {
998
+ // 로컬: psmux attach in new WT tab
999
+ const title = worker.role || sessionName;
1000
+ execFileFn("wt.exe", ["-w", "0", "nt", "--title", title, "--",
1001
+ "psmux", "attach", "-t", sessionName],
1002
+ { detached: true, stdio: "ignore", windowsHide: false }, () => {});
1003
+ }
1004
+
1005
+ // 3. 200ms 후 altScreen 복귀 + rawMode 재활성화
1006
+ setTimeout(() => {
1007
+ enterAltScreen();
1008
+ if (typeof input?.setRawMode === "function") { input.setRawMode(true); rawModeEnabled = true; }
1009
+ if (typeof input?.resume === "function") input.resume();
1010
+ render();
1011
+ }, 200);
1012
+ }
1013
+
1014
+ // ── flash 메시지 (완료/실패 알림용) ────────────────────────────────────
1015
+ let flashMessage = "";
1016
+ let flashTimer = null;
1017
+ function showFlash(msg, durationMs = 5000) {
1018
+ flashMessage = msg;
1019
+ if (flashTimer) clearTimeout(flashTimer);
1020
+ flashTimer = setTimeout(() => { flashMessage = ""; render(); }, durationMs);
1021
+ render();
1022
+ }
1023
+
1024
+ function attachInput() {
1025
+ if (inputAttached) return;
1026
+ if (!isTTY || (!forceTTY && !input?.isTTY) || typeof input?.on !== "function") return;
1027
+ inputAttached = true;
1028
+ if (typeof input.setRawMode === "function") { input.setRawMode(true); rawModeEnabled = true; }
1029
+ if (typeof input.resume === "function") input.resume();
1030
+ input.on("data", handleInput);
1031
+ }
1032
+
1033
+ // ── altScreen 진입/퇴장 ───────────────────────────────────────────────
1034
+ function enterAltScreen() {
1035
+ if (!isTTY) return;
1036
+ write(altScreenOn + cursorHide + clearScreen + cursorHome);
1037
+ }
1038
+
1039
+ function exitAltScreen() {
1040
+ if (!isTTY) return;
1041
+ write(cursorShow + altScreenOff);
1042
+ }
1043
+
1044
+ // ── 프레임 빌드 ───────────────────────────────────────────────────────
1045
+ function buildRows() {
1046
+ const names = visibleWorkerNames();
1047
+ if (names.length === 0) return [];
1048
+
1049
+ ensureSelectedWorker(names);
1050
+ attachInput();
1051
+
1052
+ const totalCols = getViewportColumns();
1053
+ const totalRows = getViewportRows();
1054
+
1055
+ // Help overlay: 전체 화면 오버레이
1056
+ if (helpOverlay) {
1057
+ return buildHelpOverlay(totalCols, totalRows);
1058
+ }
1059
+
1060
+ const elapsed = nowElapsedSec();
1061
+ const renderTime = Date.now();
1062
+
1063
+ // Tier1: 상단 고정 2행
1064
+ const tier1 = buildTier1(names, workers, pipeline, elapsed, totalCols, VERSION, renderTime);
1065
+ // flash 메시지 (완료/실패 알림)
1066
+ if (flashMessage) {
1067
+ tier1.push(truncate(` ${color("▸", MOCHA.green)} ${flashMessage}`, totalCols));
1068
+ }
1069
+
1070
+ // 레이아웃 결정
1071
+ let effectiveLayout = layoutHint;
1072
+ if (effectiveLayout === "auto") {
1073
+ if (names.length >= 4) effectiveLayout = "summary+detail";
1074
+ else if (names.length === 3) effectiveLayout = "split-3col";
1075
+ else if (names.length === 2) effectiveLayout = "split-2col";
1076
+ else effectiveLayout = "single";
1077
+ }
1078
+
1079
+ // summary+detail: summaryBar + focus pane
1080
+ if (effectiveLayout === "summary+detail") {
1081
+ const summaryBar = buildSummaryBar(names, workers, selectedWorker, pipeline, totalCols, VERSION);
1082
+ const selectedState = workers.get(selectedWorker);
1083
+ const focusPaneHeight = Math.max(8, totalRows - tier1.length - summaryBar.length);
1084
+ const focusPane = buildFocusPane(selectedWorker, selectedState, {
1085
+ width: totalCols,
1086
+ height: focusPaneHeight,
1087
+ scrollOffset: detailScrollOffset,
1088
+ followTail,
1089
+ rawMode,
1090
+ focused: focus === "detail",
1091
+ activeTab: focusTab,
1092
+ time: renderTime,
1093
+ });
1094
+ return [...tier1, ...summaryBar, ...focusPane];
1095
+ }
1096
+
1097
+ // 좌우 분할: Left Rail (30%) | Right Focus (70%)
1098
+ // 목업: Tier2 Left Rail + Tier3 Focus 나란히 렌더링
1099
+ const GAP = 1; // rail과 focus 사이 구분선
1100
+ const railRatio = focus === "detail" ? 0.20 : 0.30;
1101
+ const railWidth = Math.max(MIN_CARD_WIDTH, Math.floor(totalCols * railRatio));
1102
+ const focusWidth = totalCols - railWidth - GAP;
1103
+ const bodyHeight = Math.max(6, totalRows - tier1.length - 1); // -1 for status bar
1104
+
1105
+ // 반응형 compact: 워커 카드가 가용 높이 초과 시 자동 전환
1106
+ const normalCardHeight = 8; // box top/bot + 6 content lines
1107
+ const useCompact = names.length * normalCardHeight > bodyHeight;
1108
+
1109
+ // Left Rail: 워커 카드 세로 스택
1110
+ const railLines = [];
1111
+ for (const name of names) {
1112
+ const card = buildWorkerRail(name, workers.get(name), {
1113
+ width: railWidth,
1114
+ selected: name === selectedWorker,
1115
+ previousSelected: name === previousSelectedWorker,
1116
+ focused: focus === "rail" && name === selectedWorker,
1117
+ rawMode,
1118
+ compact: useCompact,
1119
+ time: renderTime,
1120
+ });
1121
+ railLines.push(...card);
1122
+ }
1123
+ // rail 높이를 bodyHeight에 맞춤 (부족하면 빈 줄, 넘치면 자름)
1124
+ while (railLines.length < bodyHeight) railLines.push(padRight("", railWidth));
1125
+ if (railLines.length > bodyHeight) railLines.length = bodyHeight;
1126
+
1127
+ // Right Focus: 선택된 워커 상세
1128
+ let focusLines = [];
1129
+ if (selectedWorker && workers.has(selectedWorker)) {
1130
+ focusLines = buildFocusPane(selectedWorker, workers.get(selectedWorker), {
1131
+ width: focusWidth,
1132
+ height: bodyHeight,
1133
+ scrollOffset: detailScrollOffset,
1134
+ followTail,
1135
+ rawMode,
1136
+ focused: focus === "detail",
1137
+ activeTab: focusTab,
1138
+ time: renderTime,
1139
+ });
1140
+ }
1141
+ while (focusLines.length < bodyHeight) focusLines.push(padRight("", focusWidth));
1142
+ if (focusLines.length > bodyHeight) focusLines.length = bodyHeight;
1143
+
1144
+ // 좌우 합성: rail[i] + separator + focus[i]
1145
+ const separator = dim("│");
1146
+ const composedRows = [];
1147
+ for (let i = 0; i < bodyHeight; i++) {
1148
+ const left = clip(railLines[i] || "", railWidth);
1149
+ const right = focusLines[i] || "";
1150
+ composedRows.push(`${left}${separator}${right}`);
1151
+ }
1152
+
1153
+ // 하단 상태바
1154
+ const statusBar = truncate(
1155
+ color(` 세션 종료됨 — 아무 키나 누르면 닫힘`, MOCHA.subtext),
1156
+ totalCols,
1157
+ );
1158
+
1159
+ return [...tier1, ...composedRows, statusBar];
1160
+ }
1161
+
1162
+ // ── altScreen diff render ─────────────────────────────────────────────
1163
+ function renderAltScreen() {
1164
+ const newRows = buildRows();
1165
+ rowBuf.set(newRows);
1166
+ const dirty = rowBuf.diff();
1167
+ const prevLen = rowBuf.prevLen;
1168
+
1169
+ if (dirty.length === 0 && newRows.length === prevLen) return;
1170
+
1171
+ const toErase = prevLen > newRows.length
1172
+ ? Array.from({ length: prevLen - newRows.length }, (_, i) => newRows.length + i)
1173
+ : [];
1174
+
1175
+ for (const i of dirty) {
1176
+ write(moveTo(i + 1, 1) + clearLine + (newRows[i] || ""));
1177
+ }
1178
+ for (const i of toErase) {
1179
+ write(moveTo(i + 1, 1) + clearLine);
1180
+ }
1181
+
1182
+ rowBuf.commit();
1183
+ }
1184
+
1185
+ // ── append-only render (non-TTY fallback) ────────────────────────────
1186
+ function renderAppendOnly() {
1187
+ const newRows = buildRows();
1188
+ if (newRows.length === 0) return;
1189
+ writeln(newRows.join("\n"));
1190
+ }
1191
+
1192
+ // ── public render ─────────────────────────────────────────────────────
1193
+ function render() {
1194
+ if (closed) return;
1195
+ frameCount++;
1196
+ spinnerTick++;
1197
+ try {
1198
+ if (isTTY) {
1199
+ renderAltScreen();
1200
+ } else {
1201
+ renderAppendOnly();
1202
+ }
1203
+ } finally {
1204
+ previousSelectedWorker = null;
1205
+ }
1206
+ }
1207
+
1208
+ // altScreen 시작
1209
+ if (isTTY) {
1210
+ enterAltScreen();
1211
+ }
1212
+
1213
+ if (refreshMs > 0) {
1214
+ timer = setInterval(render, refreshMs);
1215
+ if (timer.unref) timer.unref();
1216
+ }
1217
+
1218
+ // ── 공개 API ─────────────────────────────────────────────────────────
1219
+ return {
1220
+ updateWorker(paneName, state) {
1221
+ const existing = workers.get(paneName) || { cli: "codex", status: "pending" };
1222
+ const merged = normalizeWorkerState(existing, state);
1223
+ const nextSig = JSON.stringify({
1224
+ cli: merged.cli, status: merged.status, role: merged.role,
1225
+ snapshot: merged.snapshot, summary: merged.summary, detail: merged.detail,
1226
+ findings: merged.findings, files_changed: merged.files_changed,
1227
+ confidence: merged.confidence, tokens: merged.tokens,
1228
+ progress: merged.progress, handoff: merged.handoff,
1229
+ });
1230
+ const sigChanged = nextSig !== existing._sig;
1231
+ const explicitElapsed = Number.isFinite(state.elapsed) ? Math.max(0, Math.round(state.elapsed)) : null;
1232
+ merged._sig = nextSig;
1233
+ merged._logSec = sigChanged
1234
+ ? (explicitElapsed ?? nowElapsedSec())
1235
+ : (Number.isFinite(existing._logSec) ? existing._logSec : (explicitElapsed ?? nowElapsedSec()));
1236
+ workers.set(paneName, merged);
1237
+ ensureSelectedWorker(visibleWorkerNames());
1238
+ // follow-tail: 새 데이터 → 자동 scroll 재계산
1239
+ if (followTail) detailScrollOffset = 0;
1240
+ },
1241
+
1242
+ updatePipeline(state) {
1243
+ pipeline = { ...pipeline, ...state };
1244
+ },
1245
+
1246
+ setStartTime(ms) {
1247
+ startedAt = ms;
1248
+ },
1249
+
1250
+ selectWorker(name) {
1251
+ if (!workers.has(name)) return;
1252
+ setSelectedWorker(name);
1253
+ },
1254
+
1255
+ toggleDetail(force) {
1256
+ // 하위 호환: toggleDetail = focus pane 표시 여부
1257
+ const next = typeof force === "boolean" ? force : focus !== "detail";
1258
+ focus = next ? "detail" : "rail";
1259
+ },
1260
+
1261
+ render,
1262
+
1263
+ getWorkers() {
1264
+ return new Map(workers);
1265
+ },
1266
+
1267
+ getFrameCount() {
1268
+ return frameCount;
1269
+ },
1270
+
1271
+ getPipelineState() {
1272
+ return { ...pipeline };
1273
+ },
1274
+
1275
+ getSelectedWorker() {
1276
+ return selectedWorker;
1277
+ },
1278
+
1279
+ isDetailExpanded() {
1280
+ return focus === "detail";
1281
+ },
1282
+
1283
+ getFocusTab() {
1284
+ return focusTab;
1285
+ },
1286
+
1287
+ setFocusTab(tab) {
1288
+ const valid = ["log", "detail", "files"];
1289
+ if (valid.includes(tab)) { focusTab = tab; detailScrollOffset = 0; }
1290
+ },
1291
+
1292
+ getLayout() {
1293
+ return layoutHint;
1294
+ },
1295
+
1296
+ toggleHelp(force) {
1297
+ helpOverlay = typeof force === "boolean" ? force : !helpOverlay;
1298
+ },
1299
+
1300
+ isHelpVisible() {
1301
+ return helpOverlay;
1302
+ },
1303
+
1304
+ showFlash,
1305
+
1306
+ attachWorker(name) {
1307
+ const w = workers.get(name);
1308
+ if (w) attachToSession(w);
1309
+ },
1310
+
1311
+ close() {
1312
+ doClose();
1313
+ },
1314
+ };
1315
+ }
1316
+
1317
+ // ── Conductor Tier: 세션 테이블 렌더러 ─────────────────────────────────
1318
+ //
1319
+ // renderConductorTier(snapshot, cols)
1320
+ // snapshot: conductor.getSnapshot() 반환 배열
1321
+ // cols: 터미널 폭 (기본 100)
1322
+ //
1323
+ // 레이아웃:
1324
+ // ┌─ CONDUCTOR ──────────────────────────────────────────┐
1325
+ // │ ID Agent Host Health Last Out Restarts Why │
1326
+ // │ abc123 codex local ■ OK 2s ago 0 │
1327
+ // └──────────────────────────────────────────────────────┘
1328
+ //
1329
+ // Health 색상: healthy=green, stalled=yellow, input_wait=cyan,
1330
+ // failed=red, dead/init/starting=dim
1331
+
1332
+ const CONDUCTOR_STATE_LABEL = {
1333
+ init: { label: 'INIT', seq: MOCHA.subtext },
1334
+ starting: { label: 'START', seq: MOCHA.executing },
1335
+ healthy: { label: 'OK', seq: MOCHA.ok },
1336
+ stalled: { label: 'STALL', seq: MOCHA.yellow },
1337
+ input_wait: { label: 'INPUT_WAIT', seq: FG.cyan },
1338
+ failed: { label: 'FAIL', seq: MOCHA.fail },
1339
+ restarting: { label: 'RESTART', seq: MOCHA.partial },
1340
+ dead: { label: 'DEAD', seq: FG.gray },
1341
+ completed: { label: 'DONE', seq: MOCHA.ok },
1342
+ };
1343
+
1344
+ function conductorHealthCell(state) {
1345
+ const entry = CONDUCTOR_STATE_LABEL[state] || { label: state.toUpperCase(), seq: FG.gray };
1346
+ return `${entry.seq}■ ${entry.label}${RESET}`;
1347
+ }
1348
+
1349
+ function conductorRelTime(ms) {
1350
+ if (!ms) return '—';
1351
+ const sec = Math.round((Date.now() - ms) / 1000);
1352
+ if (sec < 0) return '—';
1353
+ if (sec < 60) return `${sec}s ago`;
1354
+ if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
1355
+ return `${Math.floor(sec / 3600)}h ago`;
1356
+ }
1357
+
1358
+ /**
1359
+ * Conductor 세션 테이블을 문자열 배열(행 목록)로 렌더링.
1360
+ *
1361
+ * @param {object[]} snapshot — conductor.getSnapshot() 결과
1362
+ * @param {number} [cols=100] — 터미널 폭
1363
+ * @returns {string[]} 렌더링된 행 목록 (altScreen rowBuf.set()에 바로 삽입 가능)
1364
+ */
1365
+ export function renderConductorTier(snapshot, cols = 100) {
1366
+ const width = Math.max(48, cols);
1367
+ const inner = width - 4; // border: '│ ' + content + ' │'
1368
+
1369
+ // ── 열 너비 계산 ────────────────────────────────────────
1370
+ // ID(8) Agent(7) Host(6) Health(dyn) LastOut(dyn) Restarts(8) Why(rest)
1371
+ const COL_ID = 8;
1372
+ const COL_AGENT = 7;
1373
+ const COL_HOST = 6;
1374
+ const COL_RESTARTS = 4;
1375
+ const COL_HEALTH = 12; // '■ INPUT_WAIT' = 12 chars
1376
+ const COL_LASTOUT = 9; // '999m ago' = 8 + space
1377
+ // Why gets the remainder
1378
+ const fixedCols = COL_ID + COL_AGENT + COL_HOST + COL_HEALTH + COL_LASTOUT + COL_RESTARTS + 6; // 6 spaces between cols
1379
+ const COL_WHY = Math.max(4, inner - fixedCols);
1380
+
1381
+ function cell(text, width_) {
1382
+ return clip(String(text ?? ''), width_);
1383
+ }
1384
+
1385
+ function buildRow(id, agent, host, healthCell, lastOut, restarts, why) {
1386
+ const idC = cell(id, COL_ID);
1387
+ const agentC = cell(agent, COL_AGENT);
1388
+ const hostC = cell(host, COL_HOST);
1389
+ const restartsC = cell(String(restarts ?? 0), COL_RESTARTS);
1390
+ const lastOutC = clip(lastOut, COL_LASTOUT);
1391
+ const whyC = cell(why, COL_WHY);
1392
+ // healthCell already has ANSI codes; pad its visible width manually
1393
+ const healthVis = wcswidth(stripAnsi(healthCell));
1394
+ const healthPad = Math.max(0, COL_HEALTH - healthVis);
1395
+ const healthC = healthCell + ' '.repeat(healthPad);
1396
+
1397
+ return `${idC} ${agentC} ${hostC} ${healthC} ${lastOutC} ${restartsC} ${whyC}`;
1398
+ }
1399
+
1400
+ const boxWidth = inner;
1401
+
1402
+ // ── 타이틀 행 ───────────────────────────────────────────
1403
+ const titleText = ` CONDUCTOR `;
1404
+ const titleColored = bold(color(titleText, FG.accent));
1405
+ // Border top with title embedded: ┌─ CONDUCTOR ──...─┐
1406
+ const dashLen = Math.max(0, boxWidth - titleText.length);
1407
+ const dashLeft = 1;
1408
+ const dashRight = Math.max(0, dashLen - dashLeft);
1409
+ const borderSeq = MOCHA.border;
1410
+ const topBorder =
1411
+ `${borderSeq}┌${'─'.repeat(dashLeft)}${RESET}${titleColored}${borderSeq}${'─'.repeat(dashRight)}┐${RESET}`;
1412
+
1413
+ // ── ヘッダー行 ───────────────────────────────────────────
1414
+ const headerRow = buildRow('ID', 'Agent', 'Host', clip('Health', COL_HEALTH), 'Last Out', 'Rst', 'Why');
1415
+ const headerLine = `${borderSeq}│${RESET} ${dim(headerRow)} ${borderSeq}│${RESET}`;
1416
+
1417
+ // ── データ行 ────────────────────────────────────────────
1418
+ const dataLines = [];
1419
+ if (!snapshot || snapshot.length === 0) {
1420
+ const emptyMsg = color('(no sessions)', FG.muted);
1421
+ const emptyPad = clip(stripAnsi(emptyMsg) === '(no sessions)' ? emptyMsg : emptyMsg, inner);
1422
+ dataLines.push(`${borderSeq}│${RESET} ${padRight(emptyMsg, inner - 2)} ${borderSeq}│${RESET}`);
1423
+ } else {
1424
+ for (const s of snapshot) {
1425
+ const id = String(s.id ?? '').slice(0, COL_ID);
1426
+ const agent = String(s.agent ?? 'unknown').slice(0, COL_AGENT);
1427
+ const host = 'local';
1428
+ const state = s.state ?? 'init';
1429
+ const healthCell = conductorHealthCell(state);
1430
+ const lastOut = conductorRelTime(s.health?.lastProbeAt ?? null);
1431
+ const restarts = s.restarts ?? 0;
1432
+ // derive "why" from last state transition context
1433
+ const why = s.health?.inputWaitPattern
1434
+ ? String(s.health.inputWaitPattern).slice(0, COL_WHY)
1435
+ : '';
1436
+
1437
+ const rowText = buildRow(id, agent, host, healthCell, lastOut, restarts, why);
1438
+ dataLines.push(`${borderSeq}│${RESET} ${rowText} ${borderSeq}│${RESET}`);
1439
+ }
1440
+ }
1441
+
1442
+ // ── Bottom border ─────────────────────────────────────
1443
+ const botBorder = `${borderSeq}└${'─'.repeat(boxWidth)}┘${RESET}`;
1444
+
1445
+ return [topBorder, headerLine, ...dataLines, botBorder];
1446
+ }
1447
+
1448
+ // 하위 호환
1449
+ export { createLogDashboard as createTui };