@triflux/remote 10.0.1 → 10.25.1

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 (155) hide show
  1. package/hub/pipe.mjs +239 -121
  2. package/hub/public/dashboard.html +68 -7
  3. package/hub/server.mjs +2181 -701
  4. package/hub/store-adapter.mjs +101 -37
  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 +84 -17
  9. package/hub/team/build-worker-prompt.mjs +42 -0
  10. package/hub/team/check-mcp-hub.mjs +49 -0
  11. package/hub/team/claude-daemon-control.mjs +219 -0
  12. package/hub/team/claude-native-bridge.mjs +784 -0
  13. package/hub/team/claude-session-projection.mjs +65 -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 +154 -33
  23. package/hub/team/cli/commands/start/parse-args.mjs +89 -12
  24. package/hub/team/cli/commands/start/start-headless.mjs +127 -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/index.mjs +2 -2
  33. package/hub/team/cli/manifest.mjs +38 -8
  34. package/hub/team/cli/render.mjs +52 -30
  35. package/hub/team/cli/services/attach-fallback.mjs +74 -54
  36. package/hub/team/cli/services/hub-client.mjs +45 -17
  37. package/hub/team/cli/services/member-selector.mjs +37 -30
  38. package/hub/team/cli/services/native-control.mjs +144 -116
  39. package/hub/team/cli/services/runtime-mode.mjs +55 -1
  40. package/hub/team/cli/services/state-store.mjs +66 -9
  41. package/hub/team/cli/services/task-model.mjs +40 -30
  42. package/hub/team/codex-review.mjs +521 -0
  43. package/hub/team/conductor-mesh-bridge.mjs +25 -24
  44. package/hub/team/conductor-registry.mjs +121 -0
  45. package/hub/team/conductor.mjs +1379 -671
  46. package/hub/team/consensus-meta.mjs +176 -0
  47. package/hub/team/dashboard-anchor.mjs +4 -5
  48. package/hub/team/dashboard-layout.mjs +3 -1
  49. package/hub/team/dashboard-open.mjs +21 -119
  50. package/hub/team/dashboard.mjs +76 -28
  51. package/hub/team/event-log.mjs +18 -10
  52. package/hub/team/execution-mode.mjs +241 -0
  53. package/hub/team/extract-completion-payload.mjs +110 -0
  54. package/hub/team/git-preflight.mjs +286 -0
  55. package/hub/team/handoff.mjs +49 -18
  56. package/hub/team/headless-bridge-session.mjs +17 -0
  57. package/hub/team/headless.mjs +2065 -1156
  58. package/hub/team/health-probe.mjs +232 -69
  59. package/hub/team/launcher-template.mjs +23 -13
  60. package/hub/team/lead-control.mjs +91 -4
  61. package/hub/team/mcp-selector.mjs +145 -0
  62. package/hub/team/native-supervisor.mjs +90 -26
  63. package/hub/team/native.mjs +74 -35
  64. package/hub/team/nativeProxy.mjs +184 -113
  65. package/hub/team/notify.mjs +147 -78
  66. package/hub/team/orchestrator.mjs +173 -161
  67. package/hub/team/pane.mjs +43 -15
  68. package/hub/team/process-cleanup.mjs +69 -25
  69. package/hub/team/psmux.mjs +1067 -264
  70. package/hub/team/recovery-store.mjs +149 -0
  71. package/hub/team/remote-probe.mjs +69 -53
  72. package/hub/team/remote-session.mjs +131 -59
  73. package/hub/team/remote-watcher.mjs +62 -34
  74. package/hub/team/retry-state-machine.mjs +267 -0
  75. package/hub/team/routing.mjs +51 -25
  76. package/hub/team/runtime-strategy.mjs +200 -0
  77. package/hub/team/schemas/worker-completion.json +39 -0
  78. package/hub/team/sentinel-capture.mjs +127 -0
  79. package/hub/team/session-sync.mjs +153 -6
  80. package/hub/team/session.mjs +605 -611
  81. package/hub/team/shared.mjs +13 -13
  82. package/hub/team/staleState.mjs +72 -30
  83. package/hub/team/swarm-cli.mjs +390 -0
  84. package/hub/team/swarm-hypervisor.mjs +1526 -198
  85. package/hub/team/swarm-intent.mjs +216 -0
  86. package/hub/team/swarm-locks.mjs +140 -25
  87. package/hub/team/swarm-planner.mjs +121 -21
  88. package/hub/team/swarm-preflight.mjs +317 -0
  89. package/hub/team/swarm-reconciler.mjs +128 -23
  90. package/hub/team/synapse-cli.mjs +458 -0
  91. package/hub/team/synapse-http.mjs +60 -0
  92. package/hub/team/synapse-registry.mjs +329 -0
  93. package/hub/team/terminal-opener.mjs +216 -0
  94. package/hub/team/tui-core.mjs +288 -0
  95. package/hub/team/tui-lite.mjs +234 -146
  96. package/hub/team/tui-remote-adapter.mjs +21 -37
  97. package/hub/team/tui-synapse.mjs +223 -0
  98. package/hub/team/tui-viewer.mjs +104 -45
  99. package/hub/team/tui-widgets.mjs +262 -0
  100. package/hub/team/tui.mjs +936 -449
  101. package/hub/team/worker-completion-validator.mjs +84 -0
  102. package/hub/team/worker-sandbox.mjs +93 -0
  103. package/hub/team/worker-signal.mjs +263 -0
  104. package/hub/team/worker-signal.types.d.ts +52 -0
  105. package/hub/team/worktree-lifecycle.mjs +518 -64
  106. package/hub/team/wt-manager.mjs +297 -26
  107. package/hub/tools.mjs +474 -252
  108. package/hub/tray.mjs +411 -368
  109. package/hub/workers/claude-worker.mjs +162 -118
  110. package/hub/workers/codex-app-server-worker.mjs +1015 -0
  111. package/hub/workers/codex-mcp.mjs +724 -507
  112. package/hub/workers/delegator-mcp.mjs +663 -337
  113. package/hub/workers/factory.mjs +100 -21
  114. package/hub/workers/gemini-worker.mjs +179 -86
  115. package/hub/workers/interface.mjs +23 -2
  116. package/hub/workers/lib/jsonrpc-stdio.mjs +463 -0
  117. package/hub/workers/worker-utils.mjs +22 -16
  118. package/package.json +4 -3
  119. package/scripts/lib/agent-json.mjs +27 -0
  120. package/scripts/lib/async.mjs +174 -0
  121. package/scripts/lib/claudemd-scanner.mjs +223 -0
  122. package/scripts/lib/cli-agy.mjs +62 -0
  123. package/scripts/lib/cli-claude.mjs +78 -0
  124. package/scripts/lib/cli-codex.mjs +199 -0
  125. package/scripts/lib/cli-gemini.mjs +67 -0
  126. package/scripts/lib/codex-recovery.sh +50 -0
  127. package/scripts/lib/context.mjs +67 -0
  128. package/scripts/lib/cross-review-utils.mjs +59 -0
  129. package/scripts/lib/doctor-env-checks.mjs +121 -0
  130. package/scripts/lib/dynamic-route-cli.mjs +107 -0
  131. package/scripts/lib/env-probe.mjs +269 -0
  132. package/scripts/lib/env.mjs +14 -0
  133. package/scripts/lib/gemini-profiles.mjs +119 -0
  134. package/scripts/lib/handoff.mjs +187 -0
  135. package/scripts/lib/hook-utils.mjs +16 -0
  136. package/scripts/lib/hub.mjs +48 -0
  137. package/scripts/lib/keyword-rules.mjs +195 -0
  138. package/scripts/lib/logger.mjs +105 -0
  139. package/scripts/lib/mcp-filter.mjs +883 -0
  140. package/scripts/lib/mcp-gateway-servers.mjs +70 -0
  141. package/scripts/lib/mcp-guard-engine.mjs +2120 -0
  142. package/scripts/lib/mcp-health.mjs +773 -0
  143. package/scripts/lib/mcp-manifest.mjs +89 -0
  144. package/scripts/lib/mcp-server-catalog.mjs +355 -0
  145. package/scripts/lib/pid.mjs +63 -0
  146. package/scripts/lib/process-utils.mjs +26 -0
  147. package/scripts/lib/psmux-info.mjs +179 -0
  148. package/scripts/lib/quota.mjs +47 -0
  149. package/scripts/lib/remote-spawn-transfer.mjs +226 -0
  150. package/scripts/lib/skill-state.mjs +220 -0
  151. package/scripts/lib/skill-template.mjs +375 -0
  152. package/scripts/lib/team.mjs +75 -0
  153. package/scripts/lib/timeout.mjs +66 -0
  154. package/scripts/lib/tmp.mjs +26 -0
  155. package/scripts/lib/toml.mjs +113 -0
package/hub/team/tui.mjs CHANGED
@@ -3,56 +3,209 @@
3
3
  // Tier1(상단 고정) / Tier2(worker rail) / Tier3(focus pane) 3단 계층.
4
4
 
5
5
  import {
6
- RESET,
7
- FG,
8
- BG,
9
- MOCHA,
10
- color,
11
- dim,
6
+ altScreenOff,
7
+ altScreenOn,
12
8
  bold,
13
9
  box,
14
- padRight,
15
- truncate,
16
- clip,
17
- stripAnsi,
18
- wcswidth,
19
- progressBar,
20
- statusBadge,
21
- STATUS_ICON,
22
- altScreenOn,
23
- altScreenOff,
10
+ clearLine,
24
11
  clearScreen,
25
- cursorHome,
12
+ clip,
13
+ color,
26
14
  cursorHide,
15
+ cursorHome,
27
16
  cursorShow,
17
+ dim,
18
+ FG,
19
+ MOCHA,
28
20
  moveTo,
29
- clearLine,
30
- clearToEnd,
21
+ padRight,
22
+ progressBar,
23
+ RESET,
24
+ statusBadge,
25
+ stripAnsi,
26
+ truncate,
27
+ wcswidth,
31
28
  } from "./ansi.mjs";
29
+ import { resolveAttachCommand } from "./session.mjs";
30
+ import {
31
+ clamp,
32
+ cliColor,
33
+ normalizeWorkerState as coreNormalizeWorkerState,
34
+ countStatuses,
35
+ FALLBACK_COLUMNS,
36
+ FALLBACK_ROWS,
37
+ formatTokens,
38
+ loadVersion,
39
+ runtimeStatus,
40
+ sanitizeFiles,
41
+ sanitizeOneLine,
42
+ sanitizeTextBlock,
43
+ statusColor,
44
+ wrapLine,
45
+ wrapText as wrapTextAll,
46
+ } from "./tui-core.mjs";
47
+ import {
48
+ createMetricsCollector,
49
+ createSynapseEventStream,
50
+ renderMetricsTier1,
51
+ } from "./tui-synapse.mjs";
52
+ import {
53
+ createPanelResizer,
54
+ createSearchState,
55
+ createTokenTracker,
56
+ createVimMotion,
57
+ } from "./tui-widgets.mjs";
58
+ import { createWtManager } from "./wt-manager.mjs";
32
59
 
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 */ }
60
+ const VERSION = await loadVersion();
42
61
 
43
- const FALLBACK_COLUMNS = 100;
44
- const FALLBACK_ROWS = 30;
62
+ // FALLBACK_COLUMNS, FALLBACK_ROWS → tui-core.mjs에서 import
45
63
  const MIN_CARD_WIDTH = 28;
64
+ const ATTACH_SESSION_NAME_PATTERN = /^[a-zA-Z0-9_.-]+$/u;
65
+ const DEFAULT_ATTACH_TAB_TTL_MS = 30_000;
66
+ const DEFAULT_ATTACH_LIMITS = Object.freeze({ local: 8, remote: 4 });
67
+
68
+ function sanitizeAttachTitle(value, fallback) {
69
+ const text = String(value || "")
70
+ .replace(/[\r\n]+/gu, " ")
71
+ .trim();
72
+ return text || fallback;
73
+ }
74
+
75
+ function assertAttachSessionName(value) {
76
+ const sessionName = String(value || "").trim();
77
+ if (!ATTACH_SESSION_NAME_PATTERN.test(sessionName)) {
78
+ throw new Error(`invalid attach session name: ${sessionName || "<empty>"}`);
79
+ }
80
+ return sessionName;
81
+ }
82
+
83
+ export function createTransientTabLimiter(opts = {}) {
84
+ const now = opts.now || Date.now;
85
+ const ttlMs = Number.isFinite(opts.ttlMs)
86
+ ? Math.max(1, Math.trunc(opts.ttlMs))
87
+ : DEFAULT_ATTACH_TAB_TTL_MS;
88
+ const limits = {
89
+ local: Number.isFinite(opts.limits?.local)
90
+ ? Math.max(1, Math.trunc(opts.limits.local))
91
+ : DEFAULT_ATTACH_LIMITS.local,
92
+ remote: Number.isFinite(opts.limits?.remote)
93
+ ? Math.max(1, Math.trunc(opts.limits.remote))
94
+ : DEFAULT_ATTACH_LIMITS.remote,
95
+ };
96
+ const buckets = {
97
+ local: [],
98
+ remote: [],
99
+ };
100
+ let nextId = 0;
101
+
102
+ function normalizeKind(kind) {
103
+ return kind === "remote" ? "remote" : "local";
104
+ }
105
+
106
+ function prune(kind) {
107
+ const normalized = normalizeKind(kind);
108
+ const nowAt = now();
109
+ buckets[normalized] = buckets[normalized].filter(
110
+ (entry) => entry.expiresAt > nowAt,
111
+ );
112
+ return buckets[normalized];
113
+ }
114
+
115
+ function acquire(kind) {
116
+ const normalized = normalizeKind(kind);
117
+ const active = prune(normalized);
118
+ const limit = limits[normalized];
119
+ if (active.length >= limit) {
120
+ const retryAfterMs =
121
+ active.length > 0 ? Math.max(0, active[0].expiresAt - now()) : ttlMs;
122
+ return Object.freeze({
123
+ ok: false,
124
+ kind: normalized,
125
+ limit,
126
+ active: active.length,
127
+ retryAfterMs,
128
+ });
129
+ }
130
+
131
+ const entry = Object.freeze({
132
+ id: ++nextId,
133
+ expiresAt: now() + ttlMs,
134
+ });
135
+ buckets[normalized] = [...active, entry];
136
+
137
+ return Object.freeze({
138
+ ok: true,
139
+ kind: normalized,
140
+ limit,
141
+ release() {
142
+ buckets[normalized] = buckets[normalized].filter(
143
+ (candidate) => candidate.id !== entry.id,
144
+ );
145
+ },
146
+ });
147
+ }
148
+
149
+ function snapshot() {
150
+ return Object.freeze({
151
+ local: prune("local").length,
152
+ remote: prune("remote").length,
153
+ ttlMs,
154
+ limits: Object.freeze({ ...limits }),
155
+ });
156
+ }
157
+
158
+ return Object.freeze({
159
+ acquire,
160
+ snapshot,
161
+ });
162
+ }
163
+
164
+ export function buildDashboardAttachRequest(worker, opts = {}) {
165
+ const resolveAttach = opts.resolveAttachCommand || resolveAttachCommand;
166
+ const sessionName = assertAttachSessionName(
167
+ worker?.sessionName || worker?.paneName,
168
+ );
169
+
170
+ if (worker?.remote && worker?.sshUser) {
171
+ const host = String(worker.host || "unknown");
172
+ const ip = String(worker._sshIp || host);
173
+ const title = sanitizeAttachTitle(
174
+ `${host}:${worker.role || sessionName}`,
175
+ `${host}:${sessionName}`,
176
+ );
177
+ const command = `ssh ${worker.sshUser}@${ip} -t "psmux attach-session -t ${sessionName}"`;
178
+ return Object.freeze({
179
+ kind: "remote",
180
+ sessionName,
181
+ title,
182
+ command,
183
+ });
184
+ }
185
+
186
+ const attachSpec = resolveAttach(sessionName);
187
+ const title = sanitizeAttachTitle(worker?.role || sessionName, sessionName);
188
+ const commandParts = [attachSpec.command, ...attachSpec.args];
189
+ return Object.freeze({
190
+ kind: "local",
191
+ sessionName,
192
+ title,
193
+ command: commandParts.join(" "),
194
+ });
195
+ }
46
196
 
47
197
  // ✻ heartbeat — Claude Code 리버스 엔지니어링 기반 breathing animation
48
198
  // 프레임: ["·","✢","✳","✶","✻","✽"] + 역재생 = 12프레임 왕복
49
199
  // 타이밍: 2000ms/cycle, RGB truecolor 보간
50
200
  const SPINNER_FRAMES_RAW = ["·", "✢", "✳", "✶", "✻", "✽"];
51
- const SPINNER_FRAMES = [...SPINNER_FRAMES_RAW, ...[...SPINNER_FRAMES_RAW].reverse()];
201
+ const SPINNER_FRAMES = [
202
+ ...SPINNER_FRAMES_RAW,
203
+ ...[...SPINNER_FRAMES_RAW].reverse(),
204
+ ];
52
205
  const SPINNER_CYCLE_MS = 2000;
53
206
  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();
207
+ const SPINNER_SHIMMER = { r: 171, g: 43, b: 63 }; // Claude shimmer #ab2b3f
208
+ const spinnerStart = Date.now();
56
209
  let spinnerTick = 0;
57
210
 
58
211
  function lerpRgb(a, b, t) {
@@ -71,18 +224,28 @@ function pseudoRandomFrame(step, seed) {
71
224
  return Math.abs(Math.imul(step + seed, 2654435761)) % SPINNER_FRAMES.length;
72
225
  }
73
226
 
74
- function heartbeat(status, shimmerIntensity = 0, statusChangedAt = 0, time = Date.now()) {
75
- const transitionElapsed = statusChangedAt ? Math.max(0, time - statusChangedAt) : Number.POSITIVE_INFINITY;
227
+ function heartbeat(
228
+ status,
229
+ shimmerIntensity = 0,
230
+ statusChangedAt = 0,
231
+ time = Date.now(),
232
+ ) {
233
+ const transitionElapsed = statusChangedAt
234
+ ? Math.max(0, time - statusChangedAt)
235
+ : Number.POSITIVE_INFINITY;
76
236
  if (transitionElapsed < 500) {
77
237
  const step = Math.floor(transitionElapsed / 50);
78
238
  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;
239
+ const targetColor =
240
+ status === "failed" || status === "error"
241
+ ? MOCHA.fail
242
+ : status === "done" || status === "completed"
243
+ ? MOCHA.ok
244
+ : shimmerIntensity > 0
245
+ ? rgbSeq(
246
+ lerpRgb(SPINNER_BASE_COLOR, SPINNER_SHIMMER, shimmerIntensity),
247
+ )
248
+ : MOCHA.executing;
86
249
  return `${targetColor}${SPINNER_FRAMES[idx]}${RESET}`;
87
250
  }
88
251
 
@@ -90,10 +253,13 @@ function heartbeat(status, shimmerIntensity = 0, statusChangedAt = 0, time = Dat
90
253
  if (status === "failed" || status === "error") return color("✗", MOCHA.fail);
91
254
  if (status !== "running") return dim("○");
92
255
  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;
256
+ const idx =
257
+ Math.floor((elapsed / SPINNER_CYCLE_MS) * SPINNER_FRAMES.length) %
258
+ SPINNER_FRAMES.length;
259
+ const c =
260
+ shimmerIntensity > 0
261
+ ? lerpRgb(SPINNER_BASE_COLOR, SPINNER_SHIMMER, shimmerIntensity)
262
+ : SPINNER_BASE_COLOR;
97
263
  return `${rgbSeq(c)}${SPINNER_FRAMES[idx]}${RESET}`;
98
264
  }
99
265
 
@@ -110,7 +276,9 @@ function activityWave(tick, count = 4) {
110
276
  let wave = "";
111
277
  for (let i = 0; i < count; i++) {
112
278
  const phase = tick * 0.3 + i * 1.5;
113
- const idx = Math.floor((Math.sin(phase) * 0.5 + 0.5) * (WAVE_CHARS.length - 1));
279
+ const idx = Math.floor(
280
+ (Math.sin(phase) * 0.5 + 0.5) * (WAVE_CHARS.length - 1),
281
+ );
114
282
  wave += WAVE_CHARS[idx];
115
283
  }
116
284
  return `${MOCHA.executing}${wave}${RESET}`;
@@ -119,138 +287,70 @@ function activityWave(tick, count = 4) {
119
287
  const GRID_GAP = 2;
120
288
  const DEFAULT_DETAIL_LINES = 10;
121
289
  // Tier1 상단 고정 행 수
122
- const TIER1_ROWS = 2;
290
+ const _TIER1_ROWS = 2;
123
291
 
124
292
  const SUMMARY_KEYS = [
125
- "status", "lead_action", "verdict", "files_changed",
126
- "confidence", "risk", "detail", "error_stage", "retryable", "partial_output",
293
+ "status",
294
+ "lead_action",
295
+ "verdict",
296
+ "files_changed",
297
+ "confidence",
298
+ "risk",
299
+ "detail",
300
+ "error_stage",
301
+ "retryable",
302
+ "partial_output",
127
303
  ];
128
304
 
129
305
  // ── 레이아웃 브레이크포인트 ──────────────────────────────────────────────
130
306
  // 80-119: 28col rail, 120-159: 36col rail, 160+: 균등
131
- function resolveRailWidth(totalCols, columnCount) {
307
+ function _resolveRailWidth(totalCols, columnCount) {
132
308
  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));
309
+ if (totalCols >= 160)
310
+ return Math.floor((totalCols - GRID_GAP * (columnCount - 1)) / columnCount);
311
+ if (totalCols >= 120)
312
+ return Math.min(
313
+ 36,
314
+ Math.floor((totalCols - GRID_GAP * (columnCount - 1)) / columnCount),
315
+ );
316
+ return Math.min(
317
+ 28,
318
+ Math.floor((totalCols - GRID_GAP * (columnCount - 1)) / columnCount),
319
+ );
136
320
  }
137
321
 
138
- function autoColumnCount(totalCols, workerCount) {
322
+ function _autoColumnCount(totalCols, workerCount) {
139
323
  if (workerCount <= 1) return 1;
140
324
  if (totalCols >= 160) return Math.min(workerCount, 3);
141
325
  if (totalCols >= 120) return Math.min(workerCount, 2);
142
326
  return 1;
143
327
  }
144
328
 
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
- }
329
+ // 텍스트/상태/색상 유틸은 tui-core.mjs에서 import (위 참조)
231
330
 
232
331
  // ── MOCHA RGB (gradual fade 보간용) ──
233
332
  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 },
333
+ ok: { r: 166, g: 227, b: 161 },
334
+ partial: { r: 250, g: 179, b: 135 },
335
+ fail: { r: 243, g: 139, b: 168 },
237
336
  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 },
337
+ muted: { r: 147, g: 153, b: 178 },
338
+ border: { r: 69, g: 71, b: 90 },
339
+ blue: { r: 137, g: 180, b: 250 },
340
+ sky: { r: 116, g: 199, b: 236 },
341
+ yellow: { r: 249, g: 226, b: 175 },
342
+ peach: { r: 250, g: 179, b: 135 },
343
+ maroon: { r: 235, g: 160, b: 172 },
344
+ surface0: { r: 49, g: 50, b: 68 },
345
+ thinking: { r: 203, g: 166, b: 247 },
247
346
  };
248
347
 
249
348
  function statusToRgb(status) {
250
349
  if (status === "ok" || status === "completed") return MOCHA_RGB.ok;
251
350
  if (status === "partial") return MOCHA_RGB.partial;
252
351
  if (status === "failed") return MOCHA_RGB.fail;
253
- if (status === "running" || status === "in_progress") return MOCHA_RGB.executing;
352
+ if (status === "running" || status === "in_progress")
353
+ return MOCHA_RGB.executing;
254
354
  return MOCHA_RGB.muted;
255
355
  }
256
356
 
@@ -275,7 +375,7 @@ function gradientBorderFn(topRgb, bottomRgb) {
275
375
  }
276
376
 
277
377
  // Effect 3: Flash-fade border — 상태 변경 시 백색 플래시 → 페이드아웃
278
- function flashFadeBorderColor(currentStatus, prevStatus, changedAt) {
378
+ function _flashFadeBorderColor(currentStatus, prevStatus, changedAt) {
279
379
  const elapsed = Date.now() - (changedAt || 0);
280
380
  if (elapsed >= FADE_DURATION_MS || !prevStatus) return null;
281
381
  const statusRgb = statusToRgb(currentStatus);
@@ -291,7 +391,7 @@ function flashFadeBorderColor(currentStatus, prevStatus, changedAt) {
291
391
  }
292
392
 
293
393
  function easeOutCubic(t) {
294
- return 1 - ((1 - t) ** 3);
394
+ return 1 - (1 - t) ** 3;
295
395
  }
296
396
 
297
397
  function borderHighlightPosition(width, bodyLines, time = Date.now()) {
@@ -302,13 +402,20 @@ function borderHighlightPosition(width, bodyLines, time = Date.now()) {
302
402
  }
303
403
 
304
404
  function titleFlash(status, changeElapsed) {
305
- const isCompleted = status === "completed" || status === "done" || status === "ok";
306
- const isFailed = status === "failed" || status === "error" || status === "fail";
405
+ const isCompleted =
406
+ status === "completed" || status === "done" || status === "ok";
407
+ const isFailed =
408
+ status === "failed" || status === "error" || status === "fail";
307
409
  if ((!isCompleted && !isFailed) || changeElapsed > 800) return null;
308
410
  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));
411
+ const bgRgb =
412
+ changeElapsed <= 300
413
+ ? flashRgb
414
+ : lerpRgb(
415
+ flashRgb,
416
+ MOCHA_RGB.surface0,
417
+ clamp((changeElapsed - 300) / 500, 0, 1),
418
+ );
312
419
  return rgbSeq(bgRgb, 48);
313
420
  }
314
421
 
@@ -319,51 +426,34 @@ function dedupeRole(role, name, cli) {
319
426
  r = r.replace(new RegExp(esc(cli), "gi"), "").trim();
320
427
  r = r.replace(new RegExp(esc(name), "gi"), "").trim();
321
428
  // CLI indicator emojis 제거
322
- r = r.replace(/[⚪⚫🔴🟠🟡🟢🔵🟣🟤⭕🔘]/g, "").trim();
429
+ r = r.replace(/[⚪⚫🔴🟠🟡🟢🔵🟣🟤⭕🔘]/gu, "").trim();
323
430
  // 빈 괄호 제거 + 중첩 괄호 정리
324
431
  r = r.replace(/\(\s*\)/g, "").trim();
325
432
  r = r.replace(/^\(([^()]+)\)$/, "$1").trim();
326
- r = r.replace(/^\s*[•·\-]\s*/, "").trim();
433
+ r = r.replace(/^\s*[•·-]\s*/, "").trim();
327
434
  return r;
328
435
  }
329
436
 
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
- }
437
+ // wrapLine, wrapTextAll → tui-core.mjs에서 import
352
438
 
353
- function wrapText(text, width, maxLines = DEFAULT_DETAIL_LINES, rawMode = false) {
439
+ function _wrapText(
440
+ text,
441
+ width,
442
+ maxLines = DEFAULT_DETAIL_LINES,
443
+ rawMode = false,
444
+ ) {
354
445
  if (maxLines <= 0) return [];
355
446
  const input = sanitizeTextBlock(text, rawMode);
356
447
  if (!input) return [];
357
- const wrapped = input.split("\n").flatMap((line) => wrapLine(line, width)).filter(Boolean);
448
+ const wrapped = input
449
+ .split("\n")
450
+ .flatMap((line) => wrapLine(line, width))
451
+ .filter(Boolean);
358
452
  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);
453
+ return [
454
+ ...wrapped.slice(0, maxLines - 1),
455
+ truncate(wrapped[wrapped.length - 1], width),
456
+ ];
367
457
  }
368
458
 
369
459
  // ── virtual row buffer ────────────────────────────────────────────────────
@@ -391,46 +481,53 @@ class RowBuffer {
391
481
  this._prev = [...this._rows];
392
482
  }
393
483
 
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++;
484
+ get rows() {
485
+ return this._rows;
486
+ }
487
+ get prevLen() {
488
+ return this._prev.length;
408
489
  }
409
- return { ok, partial, failed, running };
410
490
  }
411
491
 
492
+ // countStatuses → tui-core.mjs에서 import
493
+
412
494
  // ── Tier1: 상단 고정 1행 ─────────────────────────────────────────────────
413
495
  function phaseColor(phase, time = Date.now()) {
414
496
  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));
497
+ if (phase === "exec" || phase === "executing")
498
+ return rgbSeq(lerpRgb(MOCHA_RGB.blue, MOCHA_RGB.sky, shimmer));
499
+ if (phase === "verify" || phase === "verifying")
500
+ return rgbSeq(lerpRgb(MOCHA_RGB.yellow, MOCHA_RGB.peach, shimmer));
501
+ if (phase === "fix" || phase === "fixing")
502
+ return rgbSeq(lerpRgb(MOCHA_RGB.fail, MOCHA_RGB.maroon, shimmer));
418
503
  return FG.accent;
419
504
  }
420
505
 
421
- function buildTier1(names, workers, pipeline, elapsed, width, version, time = Date.now()) {
506
+ function buildTier1(
507
+ names,
508
+ workers,
509
+ pipeline,
510
+ elapsed,
511
+ width,
512
+ version,
513
+ time = Date.now(),
514
+ ) {
422
515
  const { ok, partial, failed, running } = countStatuses(names, workers);
423
516
  const phase = pipeline.phase || "exec";
424
517
  const row1 = truncate(
425
518
  `${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)}` : ""}`,
519
+ `${color(`✓${ok}`, MOCHA.ok)} ${color(`◑${partial}`, MOCHA.partial)} ${color(`✗${failed}`, MOCHA.fail)} ${dim(`▶${running}`)}${running > 0 ? ` ${activityWave(spinnerTick)}` : ""}`,
427
520
  width,
428
521
  );
429
- const keysHint = color("Tab:focus • j/k/↑↓:nav • f:follow • r:raw • l:tab • n:recent • 1-9:jump", MOCHA.subtext);
522
+ const keysHint = color(
523
+ "Tab:focus • j/k:nav • gg/G:jump • /:search • n/N:next • H/L:resize • f:follow • l:tab",
524
+ MOCHA.subtext,
525
+ );
430
526
  const hintWidth = wcswidth(stripAnsi(keysHint));
431
- const row2 = hintWidth >= width
432
- ? truncate(keysHint, width)
433
- : padRight(`${" ".repeat(width - hintWidth)}${keysHint}`, width);
527
+ const row2 =
528
+ hintWidth >= width
529
+ ? truncate(keysHint, width)
530
+ : padRight(`${" ".repeat(width - hintWidth)}${keysHint}`, width);
434
531
  return [row1, row2];
435
532
  }
436
533
 
@@ -440,7 +537,8 @@ function detailText(st) {
440
537
  const lines = [];
441
538
  for (const key of SUMMARY_KEYS) {
442
539
  const value = st.handoff?.[key];
443
- if (Array.isArray(value) && value.length > 0) lines.push(`${key}: ${value.join(", ")}`);
540
+ if (Array.isArray(value) && value.length > 0)
541
+ lines.push(`${key}: ${value.join(", ")}`);
444
542
  else if (value) lines.push(`${key}: ${value}`);
445
543
  }
446
544
  if (st.snapshot) lines.unshift(st.snapshot);
@@ -455,7 +553,10 @@ function detailHighlights(st) {
455
553
  .map((line) => line.replace(/^verdict\s*:\s*/i, "").trim())
456
554
  .filter(Boolean)
457
555
  .filter((line) => line !== verdict)
458
- .filter((line) => !SUMMARY_KEYS.some((key) => line.toLowerCase().startsWith(`${key}:`)))
556
+ .filter(
557
+ (line) =>
558
+ !SUMMARY_KEYS.some((key) => line.toLowerCase().startsWith(`${key}:`)),
559
+ )
459
560
  .slice(0, 2);
460
561
  }
461
562
 
@@ -463,9 +564,9 @@ function buildWorkerRail(name, st, opts = {}) {
463
564
  const {
464
565
  width,
465
566
  selected = false,
466
- focused = false, // rail 포커스 여부
567
+ focused = false, // rail 포커스 여부
467
568
  previousSelected = false,
468
- rawMode = false,
569
+ _rawMode = false,
469
570
  compact = false,
470
571
  time = Date.now(),
471
572
  } = opts;
@@ -474,19 +575,29 @@ function buildWorkerRail(name, st, opts = {}) {
474
575
  const role = sanitizeOneLine(st.role);
475
576
  const status = runtimeStatus(st);
476
577
  const sec = Number.isFinite(st._logSec) ? st._logSec : 0;
477
- const changeElapsed = st._statusChangedAt ? Math.max(0, time - st._statusChangedAt) : Number.POSITIVE_INFINITY;
578
+ const changeElapsed = st._statusChangedAt
579
+ ? Math.max(0, time - st._statusChangedAt)
580
+ : Number.POSITIVE_INFINITY;
478
581
 
479
582
  // Tier2 행 1: 이름 + CLI + role
480
583
  const selMark = selected
481
- ? (focused ? color("▶", MOCHA.blue) : color(">", FG.triflux))
584
+ ? focused
585
+ ? color("▶", MOCHA.blue)
586
+ : color(">", FG.triflux)
482
587
  : previousSelected
483
588
  ? dim("~")
484
589
  : " ";
485
- const hb = heartbeat(status, status === "running" ? currentShimmer(time) : 0, st._statusChangedAt, time);
590
+ const hb = heartbeat(
591
+ status,
592
+ status === "running" ? currentShimmer(time) : 0,
593
+ st._statusChangedAt,
594
+ time,
595
+ );
486
596
  // host 배지 (원격 워커용)
487
- const hostBadge = st.host && st.host !== "local"
488
- ? color(`[${st.host}]`, MOCHA.mauve) + " "
489
- : "";
597
+ const hostBadge =
598
+ st.host && st.host !== "local"
599
+ ? color(`[${st.host}]`, MOCHA.mauve) + " "
600
+ : "";
490
601
  const displayRole = dedupeRole(role, name, cli);
491
602
  const title = truncate(
492
603
  `${selMark} ${hb} ${hostBadge}${color(name, FG.triflux)} ${color("•", MOCHA.overlay)} ${color(cli, cliColor(cli))}${displayRole ? ` ${color(`(${displayRole})`, MOCHA.overlay)}` : ""}`,
@@ -494,7 +605,9 @@ function buildWorkerRail(name, st, opts = {}) {
494
605
  );
495
606
 
496
607
  const cardWidth = Math.max(MIN_CARD_WIDTH, width);
497
- const borderHighlight = focused ? borderHighlightPosition(cardWidth, compact ? 2 : 6, time) : undefined;
608
+ const borderHighlight = focused
609
+ ? borderHighlightPosition(cardWidth, compact ? 2 : 6, time)
610
+ : undefined;
498
611
  const titleFlashBg = titleFlash(status, changeElapsed);
499
612
 
500
613
  // status-specific border: focused→mauve, selected→bright, non-selected→glow decay
@@ -505,37 +618,58 @@ function buildWorkerRail(name, st, opts = {}) {
505
618
  }
506
619
  if (selected) return statusColor(status);
507
620
  const from = statusToRgb(status);
508
- const decayBase = st._statusChangedAt ? clamp(changeElapsed / CARD_GLOW_MS, 0, 1) : 1;
621
+ const decayBase = st._statusChangedAt
622
+ ? clamp(changeElapsed / CARD_GLOW_MS, 0, 1)
623
+ : 1;
509
624
  const decayT = easeOutCubic(decayBase);
510
- return rgbSeq(lerpRgb(from, MOCHA_RGB.border, 0.5 + (0.5 * decayT)));
625
+ return rgbSeq(lerpRgb(from, MOCHA_RGB.border, 0.5 + 0.5 * decayT));
511
626
  })();
512
627
 
513
628
  if (compact) {
514
629
  // compact 2-line 카드
515
- const progress = Number.isFinite(st.progress) ? clamp(st.progress, 0, 1) : (status === "running" ? 0.3 : 1);
630
+ const progress = Number.isFinite(st.progress)
631
+ ? clamp(st.progress, 0, 1)
632
+ : status === "running"
633
+ ? 0.3
634
+ : 1;
516
635
  const percent = Math.round(progress * 100);
517
636
  const compactLine1 = truncate(
518
637
  `${selMark} ${hb} ${hostBadge}${color(name, FG.triflux)} ${dim("•")} ${color(cli, cliColor(cli))} ${statusBadge(status)} ${String(percent).padStart(3)}%`,
519
638
  innerWidth,
520
639
  );
521
- const verdict = sanitizeOneLine(st.handoff?.verdict || st.summary || st.snapshot, status);
640
+ const verdict = sanitizeOneLine(
641
+ st.handoff?.verdict || st.summary || st.snapshot,
642
+ status,
643
+ );
522
644
  const compactLine2 = truncate(color(verdict, MOCHA.text), innerWidth);
523
- const framed = box([compactLine1, compactLine2], cardWidth, statusBorderColor, {
524
- highlightPos: borderHighlight,
525
- titleFlashBg,
526
- });
645
+ const framed = box(
646
+ [compactLine1, compactLine2],
647
+ cardWidth,
648
+ statusBorderColor,
649
+ {
650
+ highlightPos: borderHighlight,
651
+ titleFlashBg,
652
+ },
653
+ );
527
654
  return [framed.top, ...framed.body, framed.bot];
528
655
  }
529
656
 
530
657
  // Tier2 행 2: 상태 배지 + elapsed + tokens + conf
531
- const confidence = sanitizeOneLine(st.handoff?.confidence || st.confidence, "n/a");
658
+ const confidence = sanitizeOneLine(
659
+ st.handoff?.confidence || st.confidence,
660
+ "n/a",
661
+ );
532
662
  const statusLine = truncate(
533
663
  `${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
664
  innerWidth,
535
665
  );
536
666
 
537
667
  // Tier2 행 3: progress bar
538
- const progress = Number.isFinite(st.progress) ? clamp(st.progress, 0, 1) : (status === "running" ? 0.3 : 1);
668
+ const progress = Number.isFinite(st.progress)
669
+ ? clamp(st.progress, 0, 1)
670
+ : status === "running"
671
+ ? 0.3
672
+ : 1;
539
673
  const percent = Math.round(progress * 100);
540
674
  const barWidth = clamp(Math.floor(innerWidth * 0.3), 8, 16);
541
675
  const bar = progressBar(percent, barWidth, time);
@@ -545,18 +679,33 @@ function buildWorkerRail(name, st, opts = {}) {
545
679
  );
546
680
 
547
681
  // 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";
682
+ const verdict = sanitizeOneLine(
683
+ st.handoff?.verdict || st.summary || st.snapshot,
684
+ status,
685
+ );
686
+ const findings =
687
+ detailHighlights(st).join(" / ") || "no notable findings yet";
688
+ const files =
689
+ sanitizeFiles(st.handoff?.files_changed || st.files_changed).join(", ") ||
690
+ "none";
551
691
 
552
692
  const verdictClr = statusColor(status);
553
693
  const lines = [
554
694
  title,
555
695
  statusLine,
556
696
  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),
697
+ truncate(
698
+ `${color("verdict", MOCHA.overlay)} ${color(verdict, verdictClr)}`,
699
+ innerWidth,
700
+ ),
701
+ truncate(
702
+ `${color("findings", MOCHA.overlay)} ${color(findings, MOCHA.subtext)}`,
703
+ innerWidth,
704
+ ),
705
+ truncate(
706
+ `${color("files", MOCHA.overlay)} ${color(files, MOCHA.subtext)}`,
707
+ innerWidth,
708
+ ),
560
709
  ];
561
710
 
562
711
  const framed = box(lines, cardWidth, statusBorderColor, {
@@ -580,23 +729,47 @@ function buildFocusPane(name, st, opts = {}) {
580
729
  const innerWidth = Math.max(12, width - 4);
581
730
 
582
731
  // 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");
732
+ const verdict = sanitizeOneLine(
733
+ st.handoff?.verdict || st.summary || st.snapshot,
734
+ "—",
735
+ );
736
+ const confidence = sanitizeOneLine(
737
+ st.handoff?.confidence || st.confidence,
738
+ "n/a",
739
+ );
585
740
  const files = sanitizeFiles(st.handoff?.files_changed || st.files_changed);
586
741
  const status = runtimeStatus(st);
587
742
 
588
743
  // Tab bar: 활성 탭은 MOCHA.blue + bold, 비활성은 MOCHA.overlay
589
744
  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);
745
+ const tabLog =
746
+ activeTab === "log"
747
+ ? `${MOCHA.blue}${bold("[Log]")}`
748
+ : color("[Log]", MOCHA.overlay);
749
+ const tabDetail =
750
+ activeTab === "detail"
751
+ ? `${MOCHA.blue}${bold("[Detail]")}`
752
+ : color("[Detail]", MOCHA.overlay);
753
+ const tabFiles =
754
+ activeTab === "files"
755
+ ? `${MOCHA.blue}${bold(`[Files ${files.length}]`)}`
756
+ : color(`[Files ${files.length}]`, MOCHA.overlay);
593
757
  const tabBar = truncate(`${tabLog} ${tabDetail} ${tabFiles}`, innerWidth);
594
758
 
595
759
  const stickyLines = [
596
- truncate(`${color(name, FG.triflux)} ${color("•", MOCHA.overlay)} ${statusBadge(status)}`, innerWidth),
760
+ truncate(
761
+ `${color(name, FG.triflux)} ${color("•", MOCHA.overlay)} ${statusBadge(status)}`,
762
+ innerWidth,
763
+ ),
597
764
  tabBar,
598
- truncate(`${color("verdict", MOCHA.overlay)} ${color(verdict, statusColor(status))}`, innerWidth),
599
- truncate(`${color("conf", MOCHA.overlay)} ${color(confidence, MOCHA.text)}`, innerWidth),
765
+ truncate(
766
+ `${color("verdict", MOCHA.overlay)} ${color(verdict, statusColor(status))}`,
767
+ innerWidth,
768
+ ),
769
+ truncate(
770
+ `${color("conf", MOCHA.overlay)} ${color(confidence, MOCHA.text)}`,
771
+ innerWidth,
772
+ ),
600
773
  color("─", MOCHA.surface0).repeat(Math.max(4, innerWidth)),
601
774
  ];
602
775
 
@@ -608,17 +781,22 @@ function buildFocusPane(name, st, opts = {}) {
608
781
  const summaryLines = [];
609
782
  for (const key of SUMMARY_KEYS) {
610
783
  const value = st.handoff?.[key];
611
- if (Array.isArray(value) && value.length > 0) summaryLines.push(`${key}: ${value.join(", ")}`);
784
+ if (Array.isArray(value) && value.length > 0)
785
+ summaryLines.push(`${key}: ${value.join(", ")}`);
612
786
  else if (value) summaryLines.push(`${key}: ${value}`);
613
787
  }
614
- allBodyLines = summaryLines.length > 0
615
- ? summaryLines.flatMap((l) => wrapLine(l, innerWidth))
616
- : [dim("no structured data")];
788
+ allBodyLines =
789
+ summaryLines.length > 0
790
+ ? summaryLines.flatMap((l) => wrapLine(l, innerWidth))
791
+ : [dim("no structured data")];
617
792
  } 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")];
793
+ const filesList = sanitizeFiles(
794
+ st.handoff?.files_changed || st.files_changed,
795
+ );
796
+ allBodyLines =
797
+ filesList.length > 0
798
+ ? filesList.map((f, i) => `${i + 1}. ${f}`)
799
+ : [dim("no files changed")];
622
800
  } else {
623
801
  allBodyLines = wrapTextAll(detailText(st), innerWidth, rawMode);
624
802
  }
@@ -627,16 +805,24 @@ function buildFocusPane(name, st, opts = {}) {
627
805
  if (followTail) {
628
806
  startIdx = Math.max(0, allBodyLines.length - bodyAvail);
629
807
  } else {
630
- startIdx = clamp(scrollOffset, 0, Math.max(0, allBodyLines.length - bodyAvail));
808
+ startIdx = clamp(
809
+ scrollOffset,
810
+ 0,
811
+ Math.max(0, allBodyLines.length - bodyAvail),
812
+ );
631
813
  }
632
814
 
633
815
  const bodySlice = allBodyLines.slice(startIdx, startIdx + bodyAvail);
634
816
  if (bodySlice.length === 0) bodySlice.push(dim("no detail available"));
635
817
 
636
818
  // 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);
819
+ const scrollInfo =
820
+ allBodyLines.length > bodyAvail
821
+ ? color(
822
+ `${startIdx + 1}-${Math.min(startIdx + bodyAvail, allBodyLines.length)}/${allBodyLines.length}`,
823
+ MOCHA.overlay,
824
+ )
825
+ : color(`${allBodyLines.length} lines`, MOCHA.overlay);
640
826
 
641
827
  const contentLines = [
642
828
  ...stickyLines,
@@ -650,23 +836,49 @@ function buildFocusPane(name, st, opts = {}) {
650
836
  : MOCHA.border;
651
837
  const paneWidth = Math.max(MIN_CARD_WIDTH, width);
652
838
  const framed = box(contentLines, paneWidth, borderColor, {
653
- highlightPos: focused ? borderHighlightPosition(paneWidth, contentLines.length, time) : undefined,
839
+ highlightPos: focused
840
+ ? borderHighlightPosition(paneWidth, contentLines.length, time)
841
+ : undefined,
654
842
  });
655
843
  return [framed.top, ...framed.body, framed.bot];
656
844
  }
657
845
 
658
846
  // ── 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);
847
+ function buildSummaryBar(
848
+ names,
849
+ workers,
850
+ selectedWorker,
851
+ pipeline,
852
+ width,
853
+ version,
854
+ ) {
855
+ const maxChipWidth = clamp(
856
+ Math.floor((width - 6) / Math.min(names.length, 4)),
857
+ 16,
858
+ 26,
859
+ );
661
860
  const chips = names.map((name, idx) => {
662
861
  const st = workers.get(name);
663
862
  const status = runtimeStatus(st);
664
- const progress = Number.isFinite(st.progress) ? clamp(st.progress, 0, 1) : (status === "running" ? 0.3 : 1);
863
+ const progress = Number.isFinite(st.progress)
864
+ ? clamp(st.progress, 0, 1)
865
+ : status === "running"
866
+ ? 0.3
867
+ : 1;
665
868
  const label = `${selectedWorker === name ? ">" : " "} ${idx + 1}.${name} ${status} ${Math.round(progress * 100)}%`;
666
869
  return padRight(truncate(label, maxChipWidth), maxChipWidth);
667
870
  });
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);
871
+ const chipsLine = truncate(
872
+ chips.join(color(" │ ", MOCHA.overlay)),
873
+ width - 4,
874
+ );
875
+ const keysLine = truncate(
876
+ color(
877
+ "Tab:focus • j/k:nav • gg/G:jump • /:search • n/N:next • H/L:resize • f:follow • l:tab",
878
+ MOCHA.subtext,
879
+ ),
880
+ width - 4,
881
+ );
670
882
  const framed = box([chipsLine, keysLine], width);
671
883
  return [framed.top, ...framed.body, framed.bot];
672
884
  }
@@ -699,7 +911,9 @@ function buildHelpOverlay(width, height) {
699
911
  const framed = box(helpLines, innerWidth + 4, MOCHA.blue);
700
912
  const framedRows = [framed.top, ...framed.body, framed.bot];
701
913
  const topPad = Math.max(0, Math.floor((height - framedRows.length) / 2));
702
- const leftPad = " ".repeat(Math.max(0, Math.floor((width - innerWidth - 4) / 2)));
914
+ const leftPad = " ".repeat(
915
+ Math.max(0, Math.floor((width - innerWidth - 4) / 2)),
916
+ );
703
917
  const result = [];
704
918
  for (let i = 0; i < height; i++) {
705
919
  const fi = i - topPad;
@@ -713,56 +927,27 @@ function buildHelpOverlay(width, height) {
713
927
  }
714
928
 
715
929
  // ── joinColumns ───────────────────────────────────────────────────────────
716
- function joinColumns(blocks, gap = GRID_GAP) {
930
+ function _joinColumns(blocks, gap = GRID_GAP) {
717
931
  const maxHeight = Math.max(...blocks.map((b) => b.length));
718
932
  return Array.from({ length: maxHeight }, (_, rowIdx) =>
719
933
  blocks
720
- .map((block) => block[rowIdx] || " ".repeat(wcswidth(stripAnsi(block[0] || ""))))
934
+ .map(
935
+ (block) =>
936
+ block[rowIdx] || " ".repeat(wcswidth(stripAnsi(block[0] || ""))),
937
+ )
721
938
  .join(" ".repeat(gap)),
722
939
  );
723
940
  }
724
941
 
725
942
  // ── normalizeWorkerState ──────────────────────────────────────────────────
726
943
  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
- };
944
+ return coreNormalizeWorkerState(
945
+ existing || { cli: "codex", status: "pending" },
946
+ state,
947
+ {
948
+ trackChanges: true,
949
+ },
950
+ );
766
951
  }
767
952
 
768
953
  // ── createLogDashboard ────────────────────────────────────────────────────
@@ -777,6 +962,7 @@ function normalizeWorkerState(existing, state) {
777
962
  * @returns {LogDashboardHandle}
778
963
  */
779
964
  export function createLogDashboard(opts = {}) {
965
+ const deps = opts.deps || {};
780
966
  const {
781
967
  stream = process.stdout,
782
968
  input = process.stdin,
@@ -787,10 +973,26 @@ export function createLogDashboard(opts = {}) {
787
973
  } = opts;
788
974
 
789
975
  const isTTY = forceTTY || !!stream?.isTTY;
976
+ const now = deps.now || Date.now;
977
+ const setTimeoutFn = deps.setTimeout || setTimeout;
978
+ const clearTimeoutFn = deps.clearTimeout || clearTimeout;
979
+ const openTab =
980
+ deps.openTab ||
981
+ ((request) => {
982
+ const wtm = createWtManager();
983
+ return wtm.createTab({ title: request.title, command: request.command });
984
+ });
985
+ const attachLimiter =
986
+ deps.attachLimiter ||
987
+ createTransientTabLimiter({
988
+ now,
989
+ ttlMs: deps.attachTabTtlMs,
990
+ limits: deps.attachLimits,
991
+ });
790
992
 
791
993
  const workers = new Map();
792
994
  let pipeline = { phase: "exec", fix_attempt: 0 };
793
- let startedAt = Date.now();
995
+ let startedAt = now();
794
996
  let timer = null;
795
997
  let closed = false;
796
998
  let frameCount = 0;
@@ -806,6 +1008,27 @@ export function createLogDashboard(opts = {}) {
806
1008
  let inputAttached = false;
807
1009
  let rawModeEnabled = false;
808
1010
 
1011
+ // UX 위젯 (ISSUE-14)
1012
+ const tokenTracker = createTokenTracker();
1013
+ const searchState = createSearchState();
1014
+ const vimMotion = createVimMotion();
1015
+ const panelResizer = createPanelResizer({
1016
+ initialRatio: focus === "detail" ? 0.2 : 0.3,
1017
+ });
1018
+
1019
+ // Synapse 실시간 관제 (Phase 3)
1020
+ const synapseMetrics = deps.synapseMetrics || createMetricsCollector();
1021
+ const synapseStream =
1022
+ deps.synapseStream ||
1023
+ createSynapseEventStream({
1024
+ onEvent(event) {
1025
+ synapseMetrics.ingest(event);
1026
+ },
1027
+ fetchImpl: deps.synapseFetch,
1028
+ });
1029
+ // Synapse 자동 시작 (옵트인: deps.enableSynapse)
1030
+ if (deps.enableSynapse) synapseStream.start();
1031
+
809
1032
  // virtual row buffer (altScreen 전용)
810
1033
  const rowBuf = new RowBuffer();
811
1034
 
@@ -819,22 +1042,26 @@ export function createLogDashboard(opts = {}) {
819
1042
  }
820
1043
 
821
1044
  function nowElapsedSec() {
822
- return Math.max(0, Math.round((Date.now() - startedAt) / 1000));
1045
+ return Math.max(0, Math.round((now() - startedAt) / 1000));
823
1046
  }
824
1047
 
825
1048
  function getViewportColumns() {
826
1049
  const v = Number.isFinite(columns)
827
1050
  ? columns
828
- : (Number.isFinite(stream?.columns)
829
- ? stream.columns
830
- : (Number.isFinite(process.stdout?.columns) ? process.stdout.columns : FALLBACK_COLUMNS));
1051
+ : Number.isFinite(stream?.columns)
1052
+ ? stream.columns
1053
+ : Number.isFinite(process.stdout?.columns)
1054
+ ? process.stdout.columns
1055
+ : FALLBACK_COLUMNS;
831
1056
  return Math.max(48, v || FALLBACK_COLUMNS);
832
1057
  }
833
1058
 
834
1059
  function getViewportRows() {
835
1060
  const v = Number.isFinite(stream?.rows)
836
1061
  ? stream.rows
837
- : (Number.isFinite(process.stdout?.rows) ? process.stdout.rows : FALLBACK_ROWS);
1062
+ : Number.isFinite(process.stdout?.rows)
1063
+ ? process.stdout.rows
1064
+ : FALLBACK_ROWS;
838
1065
  return Math.max(10, v || FALLBACK_ROWS);
839
1066
  }
840
1067
 
@@ -843,8 +1070,12 @@ export function createLogDashboard(opts = {}) {
843
1070
  }
844
1071
 
845
1072
  function ensureSelectedWorker(names) {
846
- if (names.length === 0) { selectedWorker = null; return; }
847
- if (!selectedWorker || !workers.has(selectedWorker)) selectedWorker = names[0];
1073
+ if (names.length === 0) {
1074
+ selectedWorker = null;
1075
+ return;
1076
+ }
1077
+ if (!selectedWorker || !workers.has(selectedWorker))
1078
+ selectedWorker = names[0];
848
1079
  }
849
1080
 
850
1081
  function setSelectedWorker(nextWorker, { preserveTrail = true } = {}) {
@@ -888,8 +1119,11 @@ export function createLogDashboard(opts = {}) {
888
1119
  function doClose() {
889
1120
  if (closed) return;
890
1121
  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);
1122
+ synapseStream.stop();
1123
+ if (inputAttached && typeof input?.off === "function")
1124
+ input.off("data", handleInput);
1125
+ if (rawModeEnabled && typeof input?.setRawMode === "function")
1126
+ input.setRawMode(false);
893
1127
  if (inputAttached && typeof input?.pause === "function") input.pause();
894
1128
  exitAltScreen();
895
1129
  closed = true;
@@ -898,7 +1132,18 @@ export function createLogDashboard(opts = {}) {
898
1132
  // ── 키 입력 ──────────────────────────────────────────────────────────
899
1133
  function handleInput(chunk) {
900
1134
  const key = String(chunk);
901
- if (key === "\u0003") return; // Ctrl-C
1135
+ if (key === "\u0003") {
1136
+ doClose();
1137
+ return;
1138
+ }
1139
+
1140
+ // 검색 모드 활성 중: 키를 검색 상태에 위임
1141
+ if (searchState.active) {
1142
+ if (searchState.handleKey(key)) {
1143
+ render();
1144
+ return;
1145
+ }
1146
+ }
902
1147
 
903
1148
  // Help overlay: 아무 키나 누르면 닫기
904
1149
  if (helpOverlay) {
@@ -913,7 +1158,10 @@ export function createLogDashboard(opts = {}) {
913
1158
  const w = workers.get(selectedWorker);
914
1159
  if (!w) return;
915
1160
  const sessionTarget = w.sessionName || w.paneName;
916
- if (!sessionTarget) return;
1161
+ if (!sessionTarget) {
1162
+ showFlash("세션 정보 없음 — 아직 준비 중");
1163
+ return;
1164
+ }
917
1165
  attachToSession(w);
918
1166
  return;
919
1167
  }
@@ -926,33 +1174,97 @@ export function createLogDashboard(opts = {}) {
926
1174
  }
927
1175
 
928
1176
  // 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
1177
+ if (key === "\x1b[1;2A") {
1178
+ selectRelative(-1);
1179
+ return;
1180
+ } // Shift+Up → 워커 위
1181
+ if (key === "\x1b[1;2B") {
1182
+ selectRelative(1);
1183
+ return;
1184
+ } // Shift+Down → 워커 아래
1185
+ if (key === "\x1b[1;2D") {
1186
+ focus = "rail";
1187
+ render();
1188
+ return;
1189
+ } // Shift+Left → rail
1190
+ if (key === "\x1b[1;2C") {
1191
+ focus = "detail";
1192
+ render();
1193
+ return;
1194
+ } // Shift+Right → detail
933
1195
 
934
1196
  if (focus === "detail") {
935
1197
  // 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; }
1198
+ if (key === "j" || key === "\u001b[B") {
1199
+ scrollDetail(1);
1200
+ return;
1201
+ }
1202
+ if (key === "k" || key === "\u001b[A") {
1203
+ scrollDetail(-1);
1204
+ return;
1205
+ }
938
1206
  } else {
939
1207
  // rail 포커스: j/k = 워커 선택
940
- if (key === "j" || key === "\u001b[B") { selectRelative(1); return; }
941
- if (key === "k" || key === "\u001b[A") { selectRelative(-1); return; }
1208
+ if (key === "j" || key === "\u001b[B") {
1209
+ selectRelative(1);
1210
+ return;
1211
+ }
1212
+ if (key === "k" || key === "\u001b[A") {
1213
+ selectRelative(-1);
1214
+ return;
1215
+ }
942
1216
  }
943
1217
 
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; }
1218
+ // vim 모션: gg(첫 워커/상단), G(마지막 워커/하단)
1219
+ const motion = vimMotion.handleKey(key);
1220
+ if (motion === "gg") {
1221
+ if (focus === "detail") {
1222
+ followTail = false;
1223
+ detailScrollOffset = 0;
1224
+ } else {
1225
+ const names = visibleWorkerNames();
1226
+ if (names.length > 0) setSelectedWorker(names[0]);
1227
+ }
1228
+ render();
1229
+ return;
1230
+ }
1231
+ if (motion === "G") {
1232
+ if (focus === "detail") {
1233
+ followTail = true;
1234
+ detailScrollOffset = 0;
1235
+ } else {
1236
+ const names = visibleWorkerNames();
1237
+ if (names.length > 0) setSelectedWorker(names[names.length - 1]);
1238
+ }
1239
+ render();
1240
+ return;
1241
+ }
1242
+ // 단일 g는 vimMotion 대기 → return하지 않음 (이전 동작 유지)
1243
+ if (key === "g") return;
1244
+
948
1245
  // PgUp/PgDn: 페이지 단위 스크롤
949
1246
  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
1247
+ if (key === "\x1b[5~") {
1248
+ scrollDetail(-pageSize);
1249
+ return;
1250
+ } // PgUp
1251
+ if (key === "\x1b[6~") {
1252
+ scrollDetail(pageSize);
1253
+ return;
1254
+ } // PgDn
952
1255
  // f: follow-tail 토글
953
- if (key === "f") { followTail = !followTail; if (followTail) detailScrollOffset = 0; render(); return; }
1256
+ if (key === "f") {
1257
+ followTail = !followTail;
1258
+ if (followTail) detailScrollOffset = 0;
1259
+ render();
1260
+ return;
1261
+ }
954
1262
  // r: raw mode 토글
955
- if (key === "r") { rawMode = !rawMode; render(); return; }
1263
+ if (key === "r") {
1264
+ rawMode = !rawMode;
1265
+ render();
1266
+ return;
1267
+ }
956
1268
  // l: 탭 전환 (Log → Detail → Files)
957
1269
  if (key === "l") {
958
1270
  const tabs = ["log", "detail", "files"];
@@ -961,54 +1273,123 @@ export function createLogDashboard(opts = {}) {
961
1273
  render();
962
1274
  return;
963
1275
  }
964
- // n: 가장 최근 상태 변경 워커로 이동
965
- if (key === "n") { selectMostRecentChangedWorker(); return; }
1276
+ // /: 검색 모드 활성화 (vim 패턴)
1277
+ if (key === "/") {
1278
+ searchState.activate();
1279
+ render();
1280
+ return;
1281
+ }
1282
+ // n: 검색 결과 다음 / 최근 변경 워커
1283
+ if (key === "n") {
1284
+ if (searchState.query) {
1285
+ const names = visibleWorkerNames();
1286
+ const idx = names.indexOf(selectedWorker);
1287
+ const match = searchState.findMatch(names, idx, 1);
1288
+ if (match >= 0) {
1289
+ setSelectedWorker(names[match]);
1290
+ render();
1291
+ } else {
1292
+ showFlash(`검색 결과 없음: ${searchState.query}`);
1293
+ }
1294
+ } else {
1295
+ selectMostRecentChangedWorker();
1296
+ }
1297
+ return;
1298
+ }
1299
+ // N: 검색 결과 이전
1300
+ if (key === "N") {
1301
+ if (searchState.query) {
1302
+ const names = visibleWorkerNames();
1303
+ const idx = names.indexOf(selectedWorker);
1304
+ const match = searchState.findMatch(names, idx, -1);
1305
+ if (match >= 0) {
1306
+ setSelectedWorker(names[match]);
1307
+ render();
1308
+ }
1309
+ }
1310
+ return;
1311
+ }
1312
+ // H: rail 축소 (패널 리사이즈)
1313
+ if (key === "H") {
1314
+ panelResizer.shrinkRail();
1315
+ render();
1316
+ return;
1317
+ }
1318
+ // L: rail 확대 (패널 리사이즈)
1319
+ if (key === "L") {
1320
+ panelResizer.expandRail();
1321
+ render();
1322
+ return;
1323
+ }
966
1324
  // h/?: 도움말 오버레이 토글
967
- if (key === "h" || key === "?") { helpOverlay = true; render(); return; }
1325
+ if (key === "h" || key === "?") {
1326
+ helpOverlay = true;
1327
+ render();
1328
+ return;
1329
+ }
968
1330
  // q: 대시보드 종료
969
- if (key === "q") { doClose(); return; }
1331
+ if (key === "q") {
1332
+ doClose();
1333
+ return;
1334
+ }
970
1335
  // 1-9: 워커 직접 선택
971
1336
  if (/^[1-9]$/.test(key)) {
972
1337
  const names = visibleWorkerNames();
973
1338
  const target = names[Number.parseInt(key, 10) - 1];
974
- if (target) { setSelectedWorker(target); render(); }
1339
+ if (target) {
1340
+ setSelectedWorker(target);
1341
+ render();
1342
+ }
975
1343
  return;
976
1344
  }
977
1345
  }
978
1346
 
979
1347
  // ── 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();
1348
+ async function attachToSession(worker) {
1349
+ let request;
1350
+ try {
1351
+ request = buildDashboardAttachRequest(worker);
1352
+ } catch (error) {
1353
+ showFlash(`[attach] ${error.message}`);
1354
+ return false;
1355
+ }
987
1356
 
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 }, () => {});
1357
+ const lease = attachLimiter.acquire(request.kind);
1358
+ if (!lease.ok) {
1359
+ const seconds = Math.max(1, Math.ceil(lease.retryAfterMs / 1000));
1360
+ const label = lease.kind === "remote" ? "원격" : "로컬";
1361
+ showFlash(
1362
+ `[attach] ${label} 탭 제한(${lease.limit}/30초) — ${seconds}초 후 다시 시도`,
1363
+ );
1364
+ return false;
1365
+ }
1366
+
1367
+ try {
1368
+ await openTab(request);
1369
+ } catch (error) {
1370
+ lease.release?.();
1371
+ const reason = error?.reasonCode || error?.message || "unknown";
1372
+ showFlash(`[attach] 탭 열기 실패: ${reason}`);
1373
+ return false;
1374
+ }
1375
+
1376
+ if (rawModeEnabled && typeof input?.setRawMode === "function") {
1377
+ input.setRawMode(false);
1378
+ rawModeEnabled = false;
1003
1379
  }
1380
+ if (typeof input?.pause === "function") input.pause();
1381
+ exitAltScreen();
1004
1382
 
1005
- // 3. 200ms 후 altScreen 복귀 + rawMode 재활성화
1006
- setTimeout(() => {
1383
+ setTimeoutFn(() => {
1007
1384
  enterAltScreen();
1008
- if (typeof input?.setRawMode === "function") { input.setRawMode(true); rawModeEnabled = true; }
1385
+ if (typeof input?.setRawMode === "function") {
1386
+ input.setRawMode(true);
1387
+ rawModeEnabled = true;
1388
+ }
1009
1389
  if (typeof input?.resume === "function") input.resume();
1010
1390
  render();
1011
1391
  }, 200);
1392
+ return true;
1012
1393
  }
1013
1394
 
1014
1395
  // ── flash 메시지 (완료/실패 알림용) ────────────────────────────────────
@@ -1016,16 +1397,27 @@ export function createLogDashboard(opts = {}) {
1016
1397
  let flashTimer = null;
1017
1398
  function showFlash(msg, durationMs = 5000) {
1018
1399
  flashMessage = msg;
1019
- if (flashTimer) clearTimeout(flashTimer);
1020
- flashTimer = setTimeout(() => { flashMessage = ""; render(); }, durationMs);
1400
+ if (flashTimer) clearTimeoutFn(flashTimer);
1401
+ flashTimer = setTimeoutFn(() => {
1402
+ flashMessage = "";
1403
+ render();
1404
+ }, durationMs);
1021
1405
  render();
1022
1406
  }
1023
1407
 
1024
1408
  function attachInput() {
1025
1409
  if (inputAttached) return;
1026
- if (!isTTY || (!forceTTY && !input?.isTTY) || typeof input?.on !== "function") return;
1410
+ if (
1411
+ !isTTY ||
1412
+ (!forceTTY && !input?.isTTY) ||
1413
+ typeof input?.on !== "function"
1414
+ )
1415
+ return;
1027
1416
  inputAttached = true;
1028
- if (typeof input.setRawMode === "function") { input.setRawMode(true); rawModeEnabled = true; }
1417
+ if (typeof input.setRawMode === "function") {
1418
+ input.setRawMode(true);
1419
+ rawModeEnabled = true;
1420
+ }
1029
1421
  if (typeof input.resume === "function") input.resume();
1030
1422
  input.on("data", handleInput);
1031
1423
  }
@@ -1061,10 +1453,32 @@ export function createLogDashboard(opts = {}) {
1061
1453
  const renderTime = Date.now();
1062
1454
 
1063
1455
  // Tier1: 상단 고정 2행
1064
- const tier1 = buildTier1(names, workers, pipeline, elapsed, totalCols, VERSION, renderTime);
1456
+ const tier1 = buildTier1(
1457
+ names,
1458
+ workers,
1459
+ pipeline,
1460
+ elapsed,
1461
+ totalCols,
1462
+ VERSION,
1463
+ renderTime,
1464
+ );
1065
1465
  // flash 메시지 (완료/실패 알림)
1066
1466
  if (flashMessage) {
1067
- tier1.push(truncate(` ${color("▸", MOCHA.green)} ${flashMessage}`, totalCols));
1467
+ tier1.push(
1468
+ truncate(` ${color("▸", MOCHA.green)} ${flashMessage}`, totalCols),
1469
+ );
1470
+ }
1471
+ // Synapse 실시간 메트릭 (활성 시)
1472
+ if (synapseStream.isRunning) {
1473
+ const metricsSnap = synapseMetrics.snapshot();
1474
+ tier1.push(
1475
+ truncate(renderMetricsTier1(metricsSnap, totalCols), totalCols),
1476
+ );
1477
+ }
1478
+ // 검색 프롬프트 (/ 모드)
1479
+ const searchPrompt = searchState.renderPrompt(totalCols);
1480
+ if (searchPrompt) {
1481
+ tier1.push(truncate(searchPrompt, totalCols));
1068
1482
  }
1069
1483
 
1070
1484
  // 레이아웃 결정
@@ -1078,9 +1492,19 @@ export function createLogDashboard(opts = {}) {
1078
1492
 
1079
1493
  // summary+detail: summaryBar + focus pane
1080
1494
  if (effectiveLayout === "summary+detail") {
1081
- const summaryBar = buildSummaryBar(names, workers, selectedWorker, pipeline, totalCols, VERSION);
1495
+ const summaryBar = buildSummaryBar(
1496
+ names,
1497
+ workers,
1498
+ selectedWorker,
1499
+ pipeline,
1500
+ totalCols,
1501
+ VERSION,
1502
+ );
1082
1503
  const selectedState = workers.get(selectedWorker);
1083
- const focusPaneHeight = Math.max(8, totalRows - tier1.length - summaryBar.length);
1504
+ const focusPaneHeight = Math.max(
1505
+ 8,
1506
+ totalRows - tier1.length - summaryBar.length,
1507
+ );
1084
1508
  const focusPane = buildFocusPane(selectedWorker, selectedState, {
1085
1509
  width: totalCols,
1086
1510
  height: focusPaneHeight,
@@ -1094,11 +1518,12 @@ export function createLogDashboard(opts = {}) {
1094
1518
  return [...tier1, ...summaryBar, ...focusPane];
1095
1519
  }
1096
1520
 
1097
- // 좌우 분할: Left Rail (30%) | Right Focus (70%)
1098
- // 목업: Tier2 Left Rail + Tier3 Focus 나란히 렌더링
1521
+ // 좌우 분할: Left Rail | Right Focus (H/L로 비율 조정 가능)
1099
1522
  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));
1523
+ const railWidth = Math.max(
1524
+ MIN_CARD_WIDTH,
1525
+ Math.floor(totalCols * panelResizer.ratio),
1526
+ );
1102
1527
  const focusWidth = totalCols - railWidth - GAP;
1103
1528
  const bodyHeight = Math.max(6, totalRows - tier1.length - 1); // -1 for status bar
1104
1529
 
@@ -1121,7 +1546,8 @@ export function createLogDashboard(opts = {}) {
1121
1546
  railLines.push(...card);
1122
1547
  }
1123
1548
  // rail 높이를 bodyHeight에 맞춤 (부족하면 빈 줄, 넘치면 자름)
1124
- while (railLines.length < bodyHeight) railLines.push(padRight("", railWidth));
1549
+ while (railLines.length < bodyHeight)
1550
+ railLines.push(padRight("", railWidth));
1125
1551
  if (railLines.length > bodyHeight) railLines.length = bodyHeight;
1126
1552
 
1127
1553
  // Right Focus: 선택된 워커 상세
@@ -1138,7 +1564,8 @@ export function createLogDashboard(opts = {}) {
1138
1564
  time: renderTime,
1139
1565
  });
1140
1566
  }
1141
- while (focusLines.length < bodyHeight) focusLines.push(padRight("", focusWidth));
1567
+ while (focusLines.length < bodyHeight)
1568
+ focusLines.push(padRight("", focusWidth));
1142
1569
  if (focusLines.length > bodyHeight) focusLines.length = bodyHeight;
1143
1570
 
1144
1571
  // 좌우 합성: rail[i] + separator + focus[i]
@@ -1151,15 +1578,19 @@ export function createLogDashboard(opts = {}) {
1151
1578
  }
1152
1579
 
1153
1580
  // 하단 상태바
1154
- const statusBar = truncate(
1155
- color(` 세션 종료됨 — 아무 키나 누르면 닫힘`, MOCHA.subtext),
1156
- totalCols,
1157
- );
1581
+ const allDone = names.every((n) => {
1582
+ const s = runtimeStatus(workers.get(n));
1583
+ return s === "ok" || s === "completed" || s === "failed";
1584
+ });
1585
+ const statusText = allDone
1586
+ ? " Enter: attach • q: 종료"
1587
+ : " Enter: attach • Tab: 포커스 • j/k: 이동 • h: 도움말";
1588
+ const statusBar = truncate(color(statusText, MOCHA.subtext), totalCols);
1158
1589
 
1159
1590
  return [...tier1, ...composedRows, statusBar];
1160
1591
  }
1161
1592
 
1162
- // ── altScreen diff render ─────────────────────────────────────────────
1593
+ // ── altScreen diff render (batched single write → 깜빡임 방지) ───────
1163
1594
  function renderAltScreen() {
1164
1595
  const newRows = buildRows();
1165
1596
  rowBuf.set(newRows);
@@ -1168,16 +1599,16 @@ export function createLogDashboard(opts = {}) {
1168
1599
 
1169
1600
  if (dirty.length === 0 && newRows.length === prevLen) return;
1170
1601
 
1171
- const toErase = prevLen > newRows.length
1172
- ? Array.from({ length: prevLen - newRows.length }, (_, i) => newRows.length + i)
1173
- : [];
1174
-
1602
+ let buf = "";
1175
1603
  for (const i of dirty) {
1176
- write(moveTo(i + 1, 1) + clearLine + (newRows[i] || ""));
1604
+ buf += moveTo(i + 1, 1) + clearLine + (newRows[i] || "");
1177
1605
  }
1178
- for (const i of toErase) {
1179
- write(moveTo(i + 1, 1) + clearLine);
1606
+ if (prevLen > newRows.length) {
1607
+ for (let i = newRows.length; i < prevLen; i++) {
1608
+ buf += moveTo(i + 1, 1) + clearLine;
1609
+ }
1180
1610
  }
1611
+ if (buf) write(buf);
1181
1612
 
1182
1613
  rowBuf.commit();
1183
1614
  }
@@ -1218,22 +1649,39 @@ export function createLogDashboard(opts = {}) {
1218
1649
  // ── 공개 API ─────────────────────────────────────────────────────────
1219
1650
  return {
1220
1651
  updateWorker(paneName, state) {
1221
- const existing = workers.get(paneName) || { cli: "codex", status: "pending" };
1652
+ const existing = workers.get(paneName) || {
1653
+ cli: "codex",
1654
+ status: "pending",
1655
+ };
1222
1656
  const merged = normalizeWorkerState(existing, state);
1223
1657
  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,
1658
+ cli: merged.cli,
1659
+ status: merged.status,
1660
+ role: merged.role,
1661
+ snapshot: merged.snapshot,
1662
+ summary: merged.summary,
1663
+ detail: merged.detail,
1664
+ findings: merged.findings,
1665
+ files_changed: merged.files_changed,
1666
+ confidence: merged.confidence,
1667
+ tokens: merged.tokens,
1668
+ progress: merged.progress,
1669
+ handoff: merged.handoff,
1229
1670
  });
1230
1671
  const sigChanged = nextSig !== existing._sig;
1231
- const explicitElapsed = Number.isFinite(state.elapsed) ? Math.max(0, Math.round(state.elapsed)) : null;
1672
+ const explicitElapsed = Number.isFinite(state.elapsed)
1673
+ ? Math.max(0, Math.round(state.elapsed))
1674
+ : null;
1232
1675
  merged._sig = nextSig;
1233
1676
  merged._logSec = sigChanged
1234
1677
  ? (explicitElapsed ?? nowElapsedSec())
1235
- : (Number.isFinite(existing._logSec) ? existing._logSec : (explicitElapsed ?? nowElapsedSec()));
1678
+ : Number.isFinite(existing._logSec)
1679
+ ? existing._logSec
1680
+ : (explicitElapsed ?? nowElapsedSec());
1236
1681
  workers.set(paneName, merged);
1682
+ // 토큰 히스토리 추적 (스파크라인용)
1683
+ if (merged.tokens !== undefined)
1684
+ tokenTracker.record(paneName, merged.tokens);
1237
1685
  ensureSelectedWorker(visibleWorkerNames());
1238
1686
  // follow-tail: 새 데이터 → 자동 scroll 재계산
1239
1687
  if (followTail) detailScrollOffset = 0;
@@ -1286,7 +1734,10 @@ export function createLogDashboard(opts = {}) {
1286
1734
 
1287
1735
  setFocusTab(tab) {
1288
1736
  const valid = ["log", "detail", "files"];
1289
- if (valid.includes(tab)) { focusTab = tab; detailScrollOffset = 0; }
1737
+ if (valid.includes(tab)) {
1738
+ focusTab = tab;
1739
+ detailScrollOffset = 0;
1740
+ }
1290
1741
  },
1291
1742
 
1292
1743
  getLayout() {
@@ -1305,7 +1756,19 @@ export function createLogDashboard(opts = {}) {
1305
1756
 
1306
1757
  attachWorker(name) {
1307
1758
  const w = workers.get(name);
1308
- if (w) attachToSession(w);
1759
+ if (!w) return false;
1760
+ return attachToSession(w);
1761
+ },
1762
+
1763
+ // Synapse 관제 API
1764
+ startSynapse() {
1765
+ synapseStream.start();
1766
+ },
1767
+ stopSynapse() {
1768
+ synapseStream.stop();
1769
+ },
1770
+ getSynapseMetrics() {
1771
+ return synapseMetrics.snapshot();
1309
1772
  },
1310
1773
 
1311
1774
  close() {
@@ -1330,27 +1793,30 @@ export function createLogDashboard(opts = {}) {
1330
1793
  // failed=red, dead/init/starting=dim
1331
1794
 
1332
1795
  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 },
1796
+ init: { label: "INIT", seq: MOCHA.subtext },
1797
+ starting: { label: "START", seq: MOCHA.executing },
1798
+ healthy: { label: "OK", seq: MOCHA.ok },
1799
+ stalled: { label: "STALL", seq: MOCHA.yellow },
1800
+ input_wait: { label: "INPUT_WAIT", seq: FG.cyan },
1801
+ failed: { label: "FAIL", seq: MOCHA.fail },
1802
+ restarting: { label: "RESTART", seq: MOCHA.partial },
1803
+ dead: { label: "DEAD", seq: FG.gray },
1804
+ completed: { label: "DONE", seq: MOCHA.ok },
1342
1805
  };
1343
1806
 
1344
1807
  function conductorHealthCell(state) {
1345
- const entry = CONDUCTOR_STATE_LABEL[state] || { label: state.toUpperCase(), seq: FG.gray };
1808
+ const entry = CONDUCTOR_STATE_LABEL[state] || {
1809
+ label: state.toUpperCase(),
1810
+ seq: FG.gray,
1811
+ };
1346
1812
  return `${entry.seq}■ ${entry.label}${RESET}`;
1347
1813
  }
1348
1814
 
1349
1815
  function conductorRelTime(ms) {
1350
- if (!ms) return '—';
1816
+ if (!ms) return "—";
1351
1817
  const sec = Math.round((Date.now() - ms) / 1000);
1352
- if (sec < 0) return '—';
1353
- if (sec < 60) return `${sec}s ago`;
1818
+ if (sec < 0) return "—";
1819
+ if (sec < 60) return `${sec}s ago`;
1354
1820
  if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
1355
1821
  return `${Math.floor(sec / 3600)}h ago`;
1356
1822
  }
@@ -1368,31 +1834,32 @@ export function renderConductorTier(snapshot, cols = 100) {
1368
1834
 
1369
1835
  // ── 열 너비 계산 ────────────────────────────────────────
1370
1836
  // 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;
1837
+ const COL_ID = 8;
1838
+ const COL_AGENT = 7;
1839
+ const COL_HOST = 6;
1374
1840
  const COL_RESTARTS = 4;
1375
- const COL_HEALTH = 12; // '■ INPUT_WAIT' = 12 chars
1376
- const COL_LASTOUT = 9; // '999m ago' = 8 + space
1841
+ const COL_HEALTH = 12; // '■ INPUT_WAIT' = 12 chars
1842
+ const COL_LASTOUT = 9; // '999m ago' = 8 + space
1377
1843
  // Why gets the remainder
1378
- const fixedCols = COL_ID + COL_AGENT + COL_HOST + COL_HEALTH + COL_LASTOUT + COL_RESTARTS + 6; // 6 spaces between cols
1844
+ const fixedCols =
1845
+ COL_ID + COL_AGENT + COL_HOST + COL_HEALTH + COL_LASTOUT + COL_RESTARTS + 6; // 6 spaces between cols
1379
1846
  const COL_WHY = Math.max(4, inner - fixedCols);
1380
1847
 
1381
1848
  function cell(text, width_) {
1382
- return clip(String(text ?? ''), width_);
1849
+ return clip(String(text ?? ""), width_);
1383
1850
  }
1384
1851
 
1385
1852
  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);
1853
+ const idC = cell(id, COL_ID);
1854
+ const agentC = cell(agent, COL_AGENT);
1855
+ const hostC = cell(host, COL_HOST);
1389
1856
  const restartsC = cell(String(restarts ?? 0), COL_RESTARTS);
1390
- const lastOutC = clip(lastOut, COL_LASTOUT);
1391
- const whyC = cell(why, COL_WHY);
1857
+ const lastOutC = clip(lastOut, COL_LASTOUT);
1858
+ const whyC = cell(why, COL_WHY);
1392
1859
  // healthCell already has ANSI codes; pad its visible width manually
1393
1860
  const healthVis = wcswidth(stripAnsi(healthCell));
1394
1861
  const healthPad = Math.max(0, COL_HEALTH - healthVis);
1395
- const healthC = healthCell + ' '.repeat(healthPad);
1862
+ const healthC = healthCell + " ".repeat(healthPad);
1396
1863
 
1397
1864
  return `${idC} ${agentC} ${hostC} ${healthC} ${lastOutC} ${restartsC} ${whyC}`;
1398
1865
  }
@@ -1407,40 +1874,60 @@ export function renderConductorTier(snapshot, cols = 100) {
1407
1874
  const dashLeft = 1;
1408
1875
  const dashRight = Math.max(0, dashLen - dashLeft);
1409
1876
  const borderSeq = MOCHA.border;
1410
- const topBorder =
1411
- `${borderSeq}┌${'─'.repeat(dashLeft)}${RESET}${titleColored}${borderSeq}${'─'.repeat(dashRight)}┐${RESET}`;
1877
+ const topBorder = `${borderSeq}┌${"─".repeat(dashLeft)}${RESET}${titleColored}${borderSeq}${"─".repeat(dashRight)}┐${RESET}`;
1412
1878
 
1413
1879
  // ── ヘッダー行 ───────────────────────────────────────────
1414
- const headerRow = buildRow('ID', 'Agent', 'Host', clip('Health', COL_HEALTH), 'Last Out', 'Rst', 'Why');
1880
+ const headerRow = buildRow(
1881
+ "ID",
1882
+ "Agent",
1883
+ "Host",
1884
+ clip("Health", COL_HEALTH),
1885
+ "Last Out",
1886
+ "Rst",
1887
+ "Why",
1888
+ );
1415
1889
  const headerLine = `${borderSeq}│${RESET} ${dim(headerRow)} ${borderSeq}│${RESET}`;
1416
1890
 
1417
1891
  // ── データ行 ────────────────────────────────────────────
1418
1892
  const dataLines = [];
1419
1893
  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}`);
1894
+ const emptyMsg = color("(no sessions)", FG.muted);
1895
+ const _emptyPad = clip(
1896
+ stripAnsi(emptyMsg) === "(no sessions)" ? emptyMsg : emptyMsg,
1897
+ inner,
1898
+ );
1899
+ dataLines.push(
1900
+ `${borderSeq}│${RESET} ${padRight(emptyMsg, inner - 2)} ${borderSeq}│${RESET}`,
1901
+ );
1423
1902
  } else {
1424
1903
  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';
1904
+ const id = String(s.id ?? "").slice(0, COL_ID);
1905
+ const agent = String(s.agent ?? "unknown").slice(0, COL_AGENT);
1906
+ const host = "local";
1907
+ const state = s.state ?? "init";
1429
1908
  const healthCell = conductorHealthCell(state);
1430
- const lastOut = conductorRelTime(s.health?.lastProbeAt ?? null);
1909
+ const lastOut = conductorRelTime(s.health?.lastProbeAt ?? null);
1431
1910
  const restarts = s.restarts ?? 0;
1432
1911
  // derive "why" from last state transition context
1433
1912
  const why = s.health?.inputWaitPattern
1434
1913
  ? String(s.health.inputWaitPattern).slice(0, COL_WHY)
1435
- : '';
1436
-
1437
- const rowText = buildRow(id, agent, host, healthCell, lastOut, restarts, why);
1914
+ : "";
1915
+
1916
+ const rowText = buildRow(
1917
+ id,
1918
+ agent,
1919
+ host,
1920
+ healthCell,
1921
+ lastOut,
1922
+ restarts,
1923
+ why,
1924
+ );
1438
1925
  dataLines.push(`${borderSeq}│${RESET} ${rowText} ${borderSeq}│${RESET}`);
1439
1926
  }
1440
1927
  }
1441
1928
 
1442
1929
  // ── Bottom border ─────────────────────────────────────
1443
- const botBorder = `${borderSeq}└${'─'.repeat(boxWidth)}┘${RESET}`;
1930
+ const botBorder = `${borderSeq}└${"─".repeat(boxWidth)}┘${RESET}`;
1444
1931
 
1445
1932
  return [topBorder, headerLine, ...dataLines, botBorder];
1446
1933
  }