@triflux/remote 10.1.0 → 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 +61 -0
  3. package/hub/server.mjs +2165 -708
  4. package/hub/store-adapter.mjs +59 -26
  5. package/hub/store.mjs +356 -153
  6. package/hub/team/agent-map.json +26 -7
  7. package/hub/team/ansi.mjs +245 -146
  8. package/hub/team/backend.mjs +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 +125 -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 +44 -16
  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 +20 -118
  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 +1020 -279
  70. package/hub/team/recovery-store.mjs +149 -0
  71. package/hub/team/remote-probe.mjs +68 -52
  72. package/hub/team/remote-session.mjs +131 -59
  73. package/hub/team/remote-watcher.mjs +61 -33
  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 +1522 -193
  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 +18 -34
  97. package/hub/team/tui-synapse.mjs +223 -0
  98. package/hub/team/tui-viewer.mjs +104 -44
  99. package/hub/team/tui-widgets.mjs +262 -0
  100. package/hub/team/tui.mjs +927 -437
  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 +517 -63
  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 +662 -335
  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,52 +3,208 @@
3
3
  // Tier1(상단 고정) / Tier2(worker rail) / Tier3(focus pane) 3단 계층.
4
4
 
5
5
  import {
6
- RESET,
7
- FG,
8
- MOCHA,
9
- color,
10
- dim,
6
+ altScreenOff,
7
+ altScreenOn,
11
8
  bold,
12
9
  box,
13
- padRight,
14
- truncate,
15
- clip,
16
- stripAnsi,
17
- wcswidth,
18
- progressBar,
19
- statusBadge,
20
- altScreenOn,
21
- altScreenOff,
10
+ clearLine,
22
11
  clearScreen,
23
- cursorHome,
12
+ clip,
13
+ color,
24
14
  cursorHide,
15
+ cursorHome,
25
16
  cursorShow,
17
+ dim,
18
+ FG,
19
+ MOCHA,
26
20
  moveTo,
27
- clearLine,
21
+ padRight,
22
+ progressBar,
23
+ RESET,
24
+ statusBadge,
25
+ stripAnsi,
26
+ truncate,
27
+ wcswidth,
28
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";
29
59
 
30
- import { execFile as _execFile } from "node:child_process";
31
-
32
- // package.json에서 동적 로드 (실패 시 fallback)
33
- let VERSION = "7.x";
34
- try {
35
- const { createRequire } = await import("node:module");
36
- const require = createRequire(import.meta.url);
37
- VERSION = require("../../package.json").version;
38
- } catch { /* fallback */ }
60
+ const VERSION = await loadVersion();
39
61
 
40
- const FALLBACK_COLUMNS = 100;
41
- const FALLBACK_ROWS = 30;
62
+ // FALLBACK_COLUMNS, FALLBACK_ROWS → tui-core.mjs에서 import
42
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
+ }
43
196
 
44
197
  // ✻ heartbeat — Claude Code 리버스 엔지니어링 기반 breathing animation
45
198
  // 프레임: ["·","✢","✳","✶","✻","✽"] + 역재생 = 12프레임 왕복
46
199
  // 타이밍: 2000ms/cycle, RGB truecolor 보간
47
200
  const SPINNER_FRAMES_RAW = ["·", "✢", "✳", "✶", "✻", "✽"];
48
- const SPINNER_FRAMES = [...SPINNER_FRAMES_RAW, ...[...SPINNER_FRAMES_RAW].reverse()];
201
+ const SPINNER_FRAMES = [
202
+ ...SPINNER_FRAMES_RAW,
203
+ ...[...SPINNER_FRAMES_RAW].reverse(),
204
+ ];
49
205
  const SPINNER_CYCLE_MS = 2000;
50
206
  const SPINNER_BASE_COLOR = { r: 203, g: 166, b: 247 }; // Catppuccin Mocha mauve
51
- const SPINNER_SHIMMER = { r: 171, g: 43, b: 63 }; // Claude shimmer #ab2b3f
207
+ const SPINNER_SHIMMER = { r: 171, g: 43, b: 63 }; // Claude shimmer #ab2b3f
52
208
  const spinnerStart = Date.now();
53
209
  let spinnerTick = 0;
54
210
 
@@ -68,18 +224,28 @@ function pseudoRandomFrame(step, seed) {
68
224
  return Math.abs(Math.imul(step + seed, 2654435761)) % SPINNER_FRAMES.length;
69
225
  }
70
226
 
71
- function heartbeat(status, shimmerIntensity = 0, statusChangedAt = 0, time = Date.now()) {
72
- 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;
73
236
  if (transitionElapsed < 500) {
74
237
  const step = Math.floor(transitionElapsed / 50);
75
238
  const idx = pseudoRandomFrame(step, statusChangedAt % 997);
76
- const targetColor = status === "failed" || status === "error"
77
- ? MOCHA.fail
78
- : status === "done" || status === "completed"
79
- ? MOCHA.ok
80
- : shimmerIntensity > 0
81
- ? rgbSeq(lerpRgb(SPINNER_BASE_COLOR, SPINNER_SHIMMER, shimmerIntensity))
82
- : 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;
83
249
  return `${targetColor}${SPINNER_FRAMES[idx]}${RESET}`;
84
250
  }
85
251
 
@@ -87,10 +253,13 @@ function heartbeat(status, shimmerIntensity = 0, statusChangedAt = 0, time = Dat
87
253
  if (status === "failed" || status === "error") return color("✗", MOCHA.fail);
88
254
  if (status !== "running") return dim("○");
89
255
  const elapsed = time - spinnerStart;
90
- const idx = Math.floor((elapsed / SPINNER_CYCLE_MS) * SPINNER_FRAMES.length) % SPINNER_FRAMES.length;
91
- const c = shimmerIntensity > 0
92
- ? lerpRgb(SPINNER_BASE_COLOR, SPINNER_SHIMMER, shimmerIntensity)
93
- : 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;
94
263
  return `${rgbSeq(c)}${SPINNER_FRAMES[idx]}${RESET}`;
95
264
  }
96
265
 
@@ -107,7 +276,9 @@ function activityWave(tick, count = 4) {
107
276
  let wave = "";
108
277
  for (let i = 0; i < count; i++) {
109
278
  const phase = tick * 0.3 + i * 1.5;
110
- 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
+ );
111
282
  wave += WAVE_CHARS[idx];
112
283
  }
113
284
  return `${MOCHA.executing}${wave}${RESET}`;
@@ -119,17 +290,33 @@ const DEFAULT_DETAIL_LINES = 10;
119
290
  const _TIER1_ROWS = 2;
120
291
 
121
292
  const SUMMARY_KEYS = [
122
- "status", "lead_action", "verdict", "files_changed",
123
- "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",
124
303
  ];
125
304
 
126
305
  // ── 레이아웃 브레이크포인트 ──────────────────────────────────────────────
127
306
  // 80-119: 28col rail, 120-159: 36col rail, 160+: 균등
128
307
  function _resolveRailWidth(totalCols, columnCount) {
129
308
  if (columnCount <= 1) return totalCols;
130
- if (totalCols >= 160) return Math.floor((totalCols - GRID_GAP * (columnCount - 1)) / columnCount);
131
- if (totalCols >= 120) return Math.min(36, Math.floor((totalCols - GRID_GAP * (columnCount - 1)) / columnCount));
132
- 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
+ );
133
320
  }
134
321
 
135
322
  function _autoColumnCount(totalCols, workerCount) {
@@ -139,115 +326,31 @@ function _autoColumnCount(totalCols, workerCount) {
139
326
  return 1;
140
327
  }
141
328
 
142
- // ── 문자열 유틸 ──────────────────────────────────────────────────────────
143
- function clamp(value, min, max) {
144
- return Math.min(max, Math.max(min, value));
145
- }
146
-
147
- function stripCodeBlocks(text) {
148
- return String(text || "")
149
- .replace(/\r/g, "")
150
- // fenced code blocks
151
- .replace(/```[\s\S]*?(?:```|$)/g, "\n")
152
- .replace(/^\s*```.*$/gm, "")
153
- // indented code blocks (4+ spaces or tab at line start)
154
- .replace(/^(?: {4}|\t).+$/gm, "")
155
- // shell prompts: PS C:\...>, >, $
156
- .replace(/^(?:PS\s+\S[^\n]*?>|>\s+|\$\s+)[^\n]*/gm, "")
157
- .trim();
158
- }
159
-
160
- function sanitizeTextBlock(text, rawMode = false) {
161
- const normalized = rawMode ? String(text || "").replace(/\r/g, "") : stripCodeBlocks(text);
162
- return normalized
163
- .split("\n")
164
- .map((line) => line.trim())
165
- .filter(Boolean)
166
- .filter((line) => line !== "--- HANDOFF ---")
167
- .join("\n")
168
- .trim();
169
- }
170
-
171
- function sanitizeOneLine(text, fallback = "") {
172
- const normalized = sanitizeTextBlock(text).replace(/\s+/g, " ").trim();
173
- return normalized || fallback;
174
- }
175
-
176
- function sanitizeFiles(files) {
177
- if (!files) return [];
178
- const raw = Array.isArray(files) ? files : String(files).split(",");
179
- return raw.map((e) => sanitizeOneLine(e)).filter(Boolean);
180
- }
181
-
182
- function sanitizeFindings(findings) {
183
- if (!findings) return [];
184
- const raw = Array.isArray(findings)
185
- ? findings
186
- : sanitizeTextBlock(findings).split("\n");
187
- return raw.map((e) => sanitizeOneLine(e)).filter(Boolean);
188
- }
189
-
190
- function normalizeTokens(tokens) {
191
- if (tokens === null || tokens === undefined) return "";
192
- if (typeof tokens === "number" && Number.isFinite(tokens)) return tokens;
193
- const raw = sanitizeOneLine(tokens);
194
- if (!raw) return "";
195
- const match = raw.match(/(\d+(?:[.,]\d+)?\s*[kKmM]?)/);
196
- return match ? match[1].replace(/\s+/g, "").toLowerCase() : raw;
197
- }
198
-
199
- function formatTokens(tokens) {
200
- if (tokens === null || tokens === undefined || tokens === "") return "n/a";
201
- if (typeof tokens === "number" && Number.isFinite(tokens)) {
202
- if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}m`;
203
- if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k`;
204
- return `${tokens}`;
205
- }
206
- return String(tokens);
207
- }
208
-
209
- // ── 색상 헬퍼 ─────────────────────────────────────────────────────────────
210
- function cliColor(cli) {
211
- if (cli === "gemini") return FG.gemini;
212
- if (cli === "claude") return FG.claude;
213
- if (cli === "codex") return FG.codex;
214
- return FG.white;
215
- }
216
-
217
- function runtimeStatus(st) {
218
- return st?.handoff?.status || st?.status || "pending";
219
- }
220
-
221
- function statusColor(status) {
222
- if (status === "ok" || status === "completed") return MOCHA.ok;
223
- if (status === "partial") return MOCHA.partial;
224
- if (status === "failed") return MOCHA.fail;
225
- if (status === "running" || status === "in_progress") return MOCHA.executing;
226
- return FG.muted;
227
- }
329
+ // 텍스트/상태/색상 유틸은 tui-core.mjs에서 import (위 참조)
228
330
 
229
331
  // ── MOCHA RGB (gradual fade 보간용) ──
230
332
  const MOCHA_RGB = {
231
- ok: { r: 166, g: 227, b: 161 },
232
- partial: { r: 250, g: 179, b: 135 },
233
- 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 },
234
336
  executing: { r: 116, g: 199, b: 236 },
235
- muted: { r: 147, g: 153, b: 178 },
236
- border: { r: 69, g: 71, b: 90 },
237
- blue: { r: 137, g: 180, b: 250 },
238
- sky: { r: 116, g: 199, b: 236 },
239
- yellow: { r: 249, g: 226, b: 175 },
240
- peach: { r: 250, g: 179, b: 135 },
241
- maroon: { r: 235, g: 160, b: 172 },
242
- surface0: { r: 49, g: 50, b: 68 },
243
- 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 },
244
346
  };
245
347
 
246
348
  function statusToRgb(status) {
247
349
  if (status === "ok" || status === "completed") return MOCHA_RGB.ok;
248
350
  if (status === "partial") return MOCHA_RGB.partial;
249
351
  if (status === "failed") return MOCHA_RGB.fail;
250
- if (status === "running" || status === "in_progress") return MOCHA_RGB.executing;
352
+ if (status === "running" || status === "in_progress")
353
+ return MOCHA_RGB.executing;
251
354
  return MOCHA_RGB.muted;
252
355
  }
253
356
 
@@ -288,7 +391,7 @@ function _flashFadeBorderColor(currentStatus, prevStatus, changedAt) {
288
391
  }
289
392
 
290
393
  function easeOutCubic(t) {
291
- return 1 - ((1 - t) ** 3);
394
+ return 1 - (1 - t) ** 3;
292
395
  }
293
396
 
294
397
  function borderHighlightPosition(width, bodyLines, time = Date.now()) {
@@ -299,13 +402,20 @@ function borderHighlightPosition(width, bodyLines, time = Date.now()) {
299
402
  }
300
403
 
301
404
  function titleFlash(status, changeElapsed) {
302
- const isCompleted = status === "completed" || status === "done" || status === "ok";
303
- 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";
304
409
  if ((!isCompleted && !isFailed) || changeElapsed > 800) return null;
305
410
  const flashRgb = isCompleted ? MOCHA_RGB.ok : MOCHA_RGB.fail;
306
- const bgRgb = changeElapsed <= 300
307
- ? flashRgb
308
- : 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
+ );
309
419
  return rgbSeq(bgRgb, 48);
310
420
  }
311
421
 
@@ -324,43 +434,26 @@ function dedupeRole(role, name, cli) {
324
434
  return r;
325
435
  }
326
436
 
327
- // ── 텍스트 래핑 ──────────────────────────────────────────────────────────
328
- function wrapLine(text, width) {
329
- const limit = Math.max(8, width);
330
- const source = String(text || "").trim();
331
- if (!source) return [""];
332
- const words = source.split(/\s+/);
333
- const lines = [];
334
- let current = "";
335
- for (const word of words) {
336
- const candidate = current ? `${current} ${word}` : word;
337
- if (wcswidth(candidate) <= limit) { current = candidate; continue; }
338
- if (current) { lines.push(current); current = ""; }
339
- if (wcswidth(word) <= limit) { current = word; continue; }
340
- let offset = 0;
341
- while (offset < word.length) {
342
- lines.push(word.slice(offset, offset + limit));
343
- offset += limit;
344
- }
345
- }
346
- if (current) lines.push(current);
347
- return lines.length > 0 ? lines : [source.slice(0, limit)];
348
- }
437
+ // wrapLine, wrapTextAll → tui-core.mjs에서 import
349
438
 
350
- 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
+ ) {
351
445
  if (maxLines <= 0) return [];
352
446
  const input = sanitizeTextBlock(text, rawMode);
353
447
  if (!input) return [];
354
- 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);
355
452
  if (wrapped.length <= maxLines) return wrapped;
356
- return [...wrapped.slice(0, maxLines - 1), truncate(wrapped[wrapped.length - 1], width)];
357
- }
358
-
359
- // 스크롤 없이 전체 줄 반환 (focus pane용)
360
- function wrapTextAll(text, width, rawMode = false) {
361
- const input = sanitizeTextBlock(text, rawMode);
362
- if (!input) return [];
363
- 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
+ ];
364
457
  }
365
458
 
366
459
  // ── virtual row buffer ────────────────────────────────────────────────────
@@ -388,46 +481,53 @@ class RowBuffer {
388
481
  this._prev = [...this._rows];
389
482
  }
390
483
 
391
- get rows() { return this._rows; }
392
- get prevLen() { return this._prev.length; }
393
- }
394
-
395
- // ── 상태 집계 ─────────────────────────────────────────────────────────────
396
- function countStatuses(names, workers) {
397
- let ok = 0, partial = 0, failed = 0, running = 0;
398
- for (const name of names) {
399
- const st = workers.get(name);
400
- const s = runtimeStatus(st);
401
- if (s === "ok" || s === "completed") ok++;
402
- else if (s === "partial") partial++;
403
- else if (s === "failed") failed++;
404
- else if (s === "running" || s === "in_progress") running++;
484
+ get rows() {
485
+ return this._rows;
486
+ }
487
+ get prevLen() {
488
+ return this._prev.length;
405
489
  }
406
- return { ok, partial, failed, running };
407
490
  }
408
491
 
492
+ // countStatuses → tui-core.mjs에서 import
493
+
409
494
  // ── Tier1: 상단 고정 1행 ─────────────────────────────────────────────────
410
495
  function phaseColor(phase, time = Date.now()) {
411
496
  const shimmer = currentShimmer(time);
412
- if (phase === "exec" || phase === "executing") return rgbSeq(lerpRgb(MOCHA_RGB.blue, MOCHA_RGB.sky, shimmer));
413
- if (phase === "verify" || phase === "verifying") return rgbSeq(lerpRgb(MOCHA_RGB.yellow, MOCHA_RGB.peach, shimmer));
414
- 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));
415
503
  return FG.accent;
416
504
  }
417
505
 
418
- 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
+ ) {
419
515
  const { ok, partial, failed, running } = countStatuses(names, workers);
420
516
  const phase = pipeline.phase || "exec";
421
517
  const row1 = truncate(
422
518
  `${color("▲", FG.triflux)} v${version} ${dim("│")} ${color(phase, phaseColor(phase, time))} ${dim("│")} ${elapsed}s ${dim("│")} ` +
423
- `${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)}` : ""}`,
424
520
  width,
425
521
  );
426
- 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
+ );
427
526
  const hintWidth = wcswidth(stripAnsi(keysHint));
428
- const row2 = hintWidth >= width
429
- ? truncate(keysHint, width)
430
- : padRight(`${" ".repeat(width - hintWidth)}${keysHint}`, width);
527
+ const row2 =
528
+ hintWidth >= width
529
+ ? truncate(keysHint, width)
530
+ : padRight(`${" ".repeat(width - hintWidth)}${keysHint}`, width);
431
531
  return [row1, row2];
432
532
  }
433
533
 
@@ -437,7 +537,8 @@ function detailText(st) {
437
537
  const lines = [];
438
538
  for (const key of SUMMARY_KEYS) {
439
539
  const value = st.handoff?.[key];
440
- 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(", ")}`);
441
542
  else if (value) lines.push(`${key}: ${value}`);
442
543
  }
443
544
  if (st.snapshot) lines.unshift(st.snapshot);
@@ -452,7 +553,10 @@ function detailHighlights(st) {
452
553
  .map((line) => line.replace(/^verdict\s*:\s*/i, "").trim())
453
554
  .filter(Boolean)
454
555
  .filter((line) => line !== verdict)
455
- .filter((line) => !SUMMARY_KEYS.some((key) => line.toLowerCase().startsWith(`${key}:`)))
556
+ .filter(
557
+ (line) =>
558
+ !SUMMARY_KEYS.some((key) => line.toLowerCase().startsWith(`${key}:`)),
559
+ )
456
560
  .slice(0, 2);
457
561
  }
458
562
 
@@ -460,7 +564,7 @@ function buildWorkerRail(name, st, opts = {}) {
460
564
  const {
461
565
  width,
462
566
  selected = false,
463
- focused = false, // rail 포커스 여부
567
+ focused = false, // rail 포커스 여부
464
568
  previousSelected = false,
465
569
  _rawMode = false,
466
570
  compact = false,
@@ -471,19 +575,29 @@ function buildWorkerRail(name, st, opts = {}) {
471
575
  const role = sanitizeOneLine(st.role);
472
576
  const status = runtimeStatus(st);
473
577
  const sec = Number.isFinite(st._logSec) ? st._logSec : 0;
474
- 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;
475
581
 
476
582
  // Tier2 행 1: 이름 + CLI + role
477
583
  const selMark = selected
478
- ? (focused ? color("▶", MOCHA.blue) : color(">", FG.triflux))
584
+ ? focused
585
+ ? color("▶", MOCHA.blue)
586
+ : color(">", FG.triflux)
479
587
  : previousSelected
480
588
  ? dim("~")
481
589
  : " ";
482
- 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
+ );
483
596
  // host 배지 (원격 워커용)
484
- const hostBadge = st.host && st.host !== "local"
485
- ? color(`[${st.host}]`, MOCHA.mauve) + " "
486
- : "";
597
+ const hostBadge =
598
+ st.host && st.host !== "local"
599
+ ? color(`[${st.host}]`, MOCHA.mauve) + " "
600
+ : "";
487
601
  const displayRole = dedupeRole(role, name, cli);
488
602
  const title = truncate(
489
603
  `${selMark} ${hb} ${hostBadge}${color(name, FG.triflux)} ${color("•", MOCHA.overlay)} ${color(cli, cliColor(cli))}${displayRole ? ` ${color(`(${displayRole})`, MOCHA.overlay)}` : ""}`,
@@ -491,7 +605,9 @@ function buildWorkerRail(name, st, opts = {}) {
491
605
  );
492
606
 
493
607
  const cardWidth = Math.max(MIN_CARD_WIDTH, width);
494
- const borderHighlight = focused ? borderHighlightPosition(cardWidth, compact ? 2 : 6, time) : undefined;
608
+ const borderHighlight = focused
609
+ ? borderHighlightPosition(cardWidth, compact ? 2 : 6, time)
610
+ : undefined;
495
611
  const titleFlashBg = titleFlash(status, changeElapsed);
496
612
 
497
613
  // status-specific border: focused→mauve, selected→bright, non-selected→glow decay
@@ -502,37 +618,58 @@ function buildWorkerRail(name, st, opts = {}) {
502
618
  }
503
619
  if (selected) return statusColor(status);
504
620
  const from = statusToRgb(status);
505
- 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;
506
624
  const decayT = easeOutCubic(decayBase);
507
- 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));
508
626
  })();
509
627
 
510
628
  if (compact) {
511
629
  // compact 2-line 카드
512
- 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;
513
635
  const percent = Math.round(progress * 100);
514
636
  const compactLine1 = truncate(
515
637
  `${selMark} ${hb} ${hostBadge}${color(name, FG.triflux)} ${dim("•")} ${color(cli, cliColor(cli))} ${statusBadge(status)} ${String(percent).padStart(3)}%`,
516
638
  innerWidth,
517
639
  );
518
- 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
+ );
519
644
  const compactLine2 = truncate(color(verdict, MOCHA.text), innerWidth);
520
- const framed = box([compactLine1, compactLine2], cardWidth, statusBorderColor, {
521
- highlightPos: borderHighlight,
522
- titleFlashBg,
523
- });
645
+ const framed = box(
646
+ [compactLine1, compactLine2],
647
+ cardWidth,
648
+ statusBorderColor,
649
+ {
650
+ highlightPos: borderHighlight,
651
+ titleFlashBg,
652
+ },
653
+ );
524
654
  return [framed.top, ...framed.body, framed.bot];
525
655
  }
526
656
 
527
657
  // Tier2 행 2: 상태 배지 + elapsed + tokens + conf
528
- const confidence = sanitizeOneLine(st.handoff?.confidence || st.confidence, "n/a");
658
+ const confidence = sanitizeOneLine(
659
+ st.handoff?.confidence || st.confidence,
660
+ "n/a",
661
+ );
529
662
  const statusLine = truncate(
530
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)}`,
531
664
  innerWidth,
532
665
  );
533
666
 
534
667
  // Tier2 행 3: progress bar
535
- 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;
536
673
  const percent = Math.round(progress * 100);
537
674
  const barWidth = clamp(Math.floor(innerWidth * 0.3), 8, 16);
538
675
  const bar = progressBar(percent, barWidth, time);
@@ -542,18 +679,33 @@ function buildWorkerRail(name, st, opts = {}) {
542
679
  );
543
680
 
544
681
  // Tier2 행 4-6: verdict / findings / files
545
- const verdict = sanitizeOneLine(st.handoff?.verdict || st.summary || st.snapshot, status);
546
- const findings = detailHighlights(st).join(" / ") || "no notable findings yet";
547
- 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";
548
691
 
549
692
  const verdictClr = statusColor(status);
550
693
  const lines = [
551
694
  title,
552
695
  statusLine,
553
696
  progressLine,
554
- truncate(`${color("verdict", MOCHA.overlay)} ${color(verdict, verdictClr)}`, innerWidth),
555
- truncate(`${color("findings", MOCHA.overlay)} ${color(findings, MOCHA.subtext)}`, innerWidth),
556
- 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
+ ),
557
709
  ];
558
710
 
559
711
  const framed = box(lines, cardWidth, statusBorderColor, {
@@ -577,23 +729,47 @@ function buildFocusPane(name, st, opts = {}) {
577
729
  const innerWidth = Math.max(12, width - 4);
578
730
 
579
731
  // verdict sticky 4행
580
- const verdict = sanitizeOneLine(st.handoff?.verdict || st.summary || st.snapshot, "—");
581
- 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
+ );
582
740
  const files = sanitizeFiles(st.handoff?.files_changed || st.files_changed);
583
741
  const status = runtimeStatus(st);
584
742
 
585
743
  // Tab bar: 활성 탭은 MOCHA.blue + bold, 비활성은 MOCHA.overlay
586
744
  const activeTab = opts.activeTab || "log";
587
- const tabLog = activeTab === "log" ? `${MOCHA.blue}${bold("[Log]")}` : color("[Log]", MOCHA.overlay);
588
- const tabDetail = activeTab === "detail" ? `${MOCHA.blue}${bold("[Detail]")}` : color("[Detail]", MOCHA.overlay);
589
- 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);
590
757
  const tabBar = truncate(`${tabLog} ${tabDetail} ${tabFiles}`, innerWidth);
591
758
 
592
759
  const stickyLines = [
593
- 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
+ ),
594
764
  tabBar,
595
- truncate(`${color("verdict", MOCHA.overlay)} ${color(verdict, statusColor(status))}`, innerWidth),
596
- 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
+ ),
597
773
  color("─", MOCHA.surface0).repeat(Math.max(4, innerWidth)),
598
774
  ];
599
775
 
@@ -605,17 +781,22 @@ function buildFocusPane(name, st, opts = {}) {
605
781
  const summaryLines = [];
606
782
  for (const key of SUMMARY_KEYS) {
607
783
  const value = st.handoff?.[key];
608
- 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(", ")}`);
609
786
  else if (value) summaryLines.push(`${key}: ${value}`);
610
787
  }
611
- allBodyLines = summaryLines.length > 0
612
- ? summaryLines.flatMap((l) => wrapLine(l, innerWidth))
613
- : [dim("no structured data")];
788
+ allBodyLines =
789
+ summaryLines.length > 0
790
+ ? summaryLines.flatMap((l) => wrapLine(l, innerWidth))
791
+ : [dim("no structured data")];
614
792
  } else if (activeTab === "files") {
615
- const filesList = sanitizeFiles(st.handoff?.files_changed || st.files_changed);
616
- allBodyLines = filesList.length > 0
617
- ? filesList.map((f, i) => `${i + 1}. ${f}`)
618
- : [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")];
619
800
  } else {
620
801
  allBodyLines = wrapTextAll(detailText(st), innerWidth, rawMode);
621
802
  }
@@ -624,16 +805,24 @@ function buildFocusPane(name, st, opts = {}) {
624
805
  if (followTail) {
625
806
  startIdx = Math.max(0, allBodyLines.length - bodyAvail);
626
807
  } else {
627
- 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
+ );
628
813
  }
629
814
 
630
815
  const bodySlice = allBodyLines.slice(startIdx, startIdx + bodyAvail);
631
816
  if (bodySlice.length === 0) bodySlice.push(dim("no detail available"));
632
817
 
633
818
  // scroll indicator — MOCHA.overlay for position
634
- const scrollInfo = allBodyLines.length > bodyAvail
635
- ? color(`${startIdx + 1}-${Math.min(startIdx + bodyAvail, allBodyLines.length)}/${allBodyLines.length}`, MOCHA.overlay)
636
- : 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);
637
826
 
638
827
  const contentLines = [
639
828
  ...stickyLines,
@@ -647,23 +836,49 @@ function buildFocusPane(name, st, opts = {}) {
647
836
  : MOCHA.border;
648
837
  const paneWidth = Math.max(MIN_CARD_WIDTH, width);
649
838
  const framed = box(contentLines, paneWidth, borderColor, {
650
- highlightPos: focused ? borderHighlightPosition(paneWidth, contentLines.length, time) : undefined,
839
+ highlightPos: focused
840
+ ? borderHighlightPosition(paneWidth, contentLines.length, time)
841
+ : undefined,
651
842
  });
652
843
  return [framed.top, ...framed.body, framed.bot];
653
844
  }
654
845
 
655
846
  // ── summary bar (≥4 workers) ──────────────────────────────────────────────
656
- function buildSummaryBar(names, workers, selectedWorker, pipeline, width, version) {
657
- 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
+ );
658
860
  const chips = names.map((name, idx) => {
659
861
  const st = workers.get(name);
660
862
  const status = runtimeStatus(st);
661
- 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;
662
868
  const label = `${selectedWorker === name ? ">" : " "} ${idx + 1}.${name} ${status} ${Math.round(progress * 100)}%`;
663
869
  return padRight(truncate(label, maxChipWidth), maxChipWidth);
664
870
  });
665
- const chipsLine = truncate(chips.join(color(" │ ", MOCHA.overlay)), width - 4);
666
- 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
+ );
667
882
  const framed = box([chipsLine, keysLine], width);
668
883
  return [framed.top, ...framed.body, framed.bot];
669
884
  }
@@ -696,7 +911,9 @@ function buildHelpOverlay(width, height) {
696
911
  const framed = box(helpLines, innerWidth + 4, MOCHA.blue);
697
912
  const framedRows = [framed.top, ...framed.body, framed.bot];
698
913
  const topPad = Math.max(0, Math.floor((height - framedRows.length) / 2));
699
- 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
+ );
700
917
  const result = [];
701
918
  for (let i = 0; i < height; i++) {
702
919
  const fi = i - topPad;
@@ -714,52 +931,23 @@ function _joinColumns(blocks, gap = GRID_GAP) {
714
931
  const maxHeight = Math.max(...blocks.map((b) => b.length));
715
932
  return Array.from({ length: maxHeight }, (_, rowIdx) =>
716
933
  blocks
717
- .map((block) => block[rowIdx] || " ".repeat(wcswidth(stripAnsi(block[0] || ""))))
934
+ .map(
935
+ (block) =>
936
+ block[rowIdx] || " ".repeat(wcswidth(stripAnsi(block[0] || ""))),
937
+ )
718
938
  .join(" ".repeat(gap)),
719
939
  );
720
940
  }
721
941
 
722
942
  // ── normalizeWorkerState ──────────────────────────────────────────────────
723
943
  function normalizeWorkerState(existing, state) {
724
- const nextHandoff = state.handoff === undefined
725
- ? existing.handoff
726
- : {
727
- ...(existing.handoff || {}),
728
- ...(state.handoff || {}),
729
- verdict: state.handoff?.verdict !== undefined
730
- ? sanitizeOneLine(state.handoff.verdict)
731
- : existing.handoff?.verdict,
732
- files_changed: state.handoff?.files_changed !== undefined
733
- ? sanitizeFiles(state.handoff.files_changed)
734
- : existing.handoff?.files_changed,
735
- confidence: state.handoff?.confidence !== undefined
736
- ? sanitizeOneLine(state.handoff.confidence)
737
- : existing.handoff?.confidence,
738
- status: state.handoff?.status !== undefined
739
- ? sanitizeOneLine(state.handoff.status)
740
- : existing.handoff?.status,
741
- };
742
-
743
- return {
744
- ...existing,
745
- ...state,
746
- cli: state.cli !== undefined ? sanitizeOneLine(state.cli, existing.cli || "codex") : (existing.cli || "codex"),
747
- role: state.role !== undefined ? sanitizeOneLine(state.role) : existing.role,
748
- status: state.status !== undefined ? sanitizeOneLine(state.status, existing.status || "pending") : (existing.status || "pending"),
749
- snapshot: state.snapshot !== undefined ? sanitizeTextBlock(state.snapshot) : existing.snapshot,
750
- summary: state.summary !== undefined ? sanitizeTextBlock(state.summary) : existing.summary,
751
- detail: state.detail !== undefined ? sanitizeTextBlock(state.detail) : existing.detail,
752
- findings: state.findings !== undefined ? sanitizeFindings(state.findings) : existing.findings,
753
- files_changed: state.files_changed !== undefined ? sanitizeFiles(state.files_changed) : existing.files_changed,
754
- confidence: state.confidence !== undefined ? sanitizeOneLine(state.confidence) : existing.confidence,
755
- tokens: state.tokens !== undefined ? normalizeTokens(state.tokens) : existing.tokens,
756
- progress: state.progress !== undefined ? clamp(Number(state.progress) || 0, 0, 1) : existing.progress,
757
- handoff: nextHandoff,
758
- _prevStatus: (state.status !== undefined && sanitizeOneLine(state.status) !== existing.status)
759
- ? existing.status : existing._prevStatus,
760
- _statusChangedAt: (state.status !== undefined && sanitizeOneLine(state.status) !== existing.status)
761
- ? Date.now() : (existing._statusChangedAt || 0),
762
- };
944
+ return coreNormalizeWorkerState(
945
+ existing || { cli: "codex", status: "pending" },
946
+ state,
947
+ {
948
+ trackChanges: true,
949
+ },
950
+ );
763
951
  }
764
952
 
765
953
  // ── createLogDashboard ────────────────────────────────────────────────────
@@ -774,6 +962,7 @@ function normalizeWorkerState(existing, state) {
774
962
  * @returns {LogDashboardHandle}
775
963
  */
776
964
  export function createLogDashboard(opts = {}) {
965
+ const deps = opts.deps || {};
777
966
  const {
778
967
  stream = process.stdout,
779
968
  input = process.stdin,
@@ -784,10 +973,26 @@ export function createLogDashboard(opts = {}) {
784
973
  } = opts;
785
974
 
786
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
+ });
787
992
 
788
993
  const workers = new Map();
789
994
  let pipeline = { phase: "exec", fix_attempt: 0 };
790
- let startedAt = Date.now();
995
+ let startedAt = now();
791
996
  let timer = null;
792
997
  let closed = false;
793
998
  let frameCount = 0;
@@ -803,6 +1008,27 @@ export function createLogDashboard(opts = {}) {
803
1008
  let inputAttached = false;
804
1009
  let rawModeEnabled = false;
805
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
+
806
1032
  // virtual row buffer (altScreen 전용)
807
1033
  const rowBuf = new RowBuffer();
808
1034
 
@@ -816,22 +1042,26 @@ export function createLogDashboard(opts = {}) {
816
1042
  }
817
1043
 
818
1044
  function nowElapsedSec() {
819
- return Math.max(0, Math.round((Date.now() - startedAt) / 1000));
1045
+ return Math.max(0, Math.round((now() - startedAt) / 1000));
820
1046
  }
821
1047
 
822
1048
  function getViewportColumns() {
823
1049
  const v = Number.isFinite(columns)
824
1050
  ? columns
825
- : (Number.isFinite(stream?.columns)
826
- ? stream.columns
827
- : (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;
828
1056
  return Math.max(48, v || FALLBACK_COLUMNS);
829
1057
  }
830
1058
 
831
1059
  function getViewportRows() {
832
1060
  const v = Number.isFinite(stream?.rows)
833
1061
  ? stream.rows
834
- : (Number.isFinite(process.stdout?.rows) ? process.stdout.rows : FALLBACK_ROWS);
1062
+ : Number.isFinite(process.stdout?.rows)
1063
+ ? process.stdout.rows
1064
+ : FALLBACK_ROWS;
835
1065
  return Math.max(10, v || FALLBACK_ROWS);
836
1066
  }
837
1067
 
@@ -840,8 +1070,12 @@ export function createLogDashboard(opts = {}) {
840
1070
  }
841
1071
 
842
1072
  function ensureSelectedWorker(names) {
843
- if (names.length === 0) { selectedWorker = null; return; }
844
- 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];
845
1079
  }
846
1080
 
847
1081
  function setSelectedWorker(nextWorker, { preserveTrail = true } = {}) {
@@ -885,8 +1119,11 @@ export function createLogDashboard(opts = {}) {
885
1119
  function doClose() {
886
1120
  if (closed) return;
887
1121
  if (timer) clearInterval(timer);
888
- if (inputAttached && typeof input?.off === "function") input.off("data", handleInput);
889
- 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);
890
1127
  if (inputAttached && typeof input?.pause === "function") input.pause();
891
1128
  exitAltScreen();
892
1129
  closed = true;
@@ -895,7 +1132,18 @@ export function createLogDashboard(opts = {}) {
895
1132
  // ── 키 입력 ──────────────────────────────────────────────────────────
896
1133
  function handleInput(chunk) {
897
1134
  const key = String(chunk);
898
- 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
+ }
899
1147
 
900
1148
  // Help overlay: 아무 키나 누르면 닫기
901
1149
  if (helpOverlay) {
@@ -910,7 +1158,10 @@ export function createLogDashboard(opts = {}) {
910
1158
  const w = workers.get(selectedWorker);
911
1159
  if (!w) return;
912
1160
  const sessionTarget = w.sessionName || w.paneName;
913
- if (!sessionTarget) return;
1161
+ if (!sessionTarget) {
1162
+ showFlash("세션 정보 없음 — 아직 준비 중");
1163
+ return;
1164
+ }
914
1165
  attachToSession(w);
915
1166
  return;
916
1167
  }
@@ -923,33 +1174,97 @@ export function createLogDashboard(opts = {}) {
923
1174
  }
924
1175
 
925
1176
  // Shift+Arrow: 포커스 이동 + 워커 선택
926
- if (key === "\x1b[1;2A") { selectRelative(-1); return; } // Shift+Up → 워커 위
927
- if (key === "\x1b[1;2B") { selectRelative(1); return; } // Shift+Down → 워커 아래
928
- if (key === "\x1b[1;2D") { focus = "rail"; render(); return; } // Shift+Left → rail
929
- 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
930
1195
 
931
1196
  if (focus === "detail") {
932
1197
  // detail 포커스: j/k/ArrowDown/Up = 스크롤
933
- if (key === "j" || key === "\u001b[B") { scrollDetail(1); return; }
934
- 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
+ }
935
1206
  } else {
936
1207
  // rail 포커스: j/k = 워커 선택
937
- if (key === "j" || key === "\u001b[B") { selectRelative(1); return; }
938
- 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
+ }
939
1216
  }
940
1217
 
941
- // g: focus pane 상단 점프
942
- if (key === "g") { followTail = false; detailScrollOffset = 0; render(); return; }
943
- // G: focus pane 하단 점프
944
- 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
+
945
1245
  // PgUp/PgDn: 페이지 단위 스크롤
946
1246
  const pageSize = Math.max(1, Math.floor(getViewportRows() / 2));
947
- if (key === "\x1b[5~") { scrollDetail(-pageSize); return; } // PgUp
948
- 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
949
1255
  // f: follow-tail 토글
950
- 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
+ }
951
1262
  // r: raw mode 토글
952
- if (key === "r") { rawMode = !rawMode; render(); return; }
1263
+ if (key === "r") {
1264
+ rawMode = !rawMode;
1265
+ render();
1266
+ return;
1267
+ }
953
1268
  // l: 탭 전환 (Log → Detail → Files)
954
1269
  if (key === "l") {
955
1270
  const tabs = ["log", "detail", "files"];
@@ -958,54 +1273,123 @@ export function createLogDashboard(opts = {}) {
958
1273
  render();
959
1274
  return;
960
1275
  }
961
- // n: 가장 최근 상태 변경 워커로 이동
962
- 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
+ }
963
1324
  // h/?: 도움말 오버레이 토글
964
- if (key === "h" || key === "?") { helpOverlay = true; render(); return; }
1325
+ if (key === "h" || key === "?") {
1326
+ helpOverlay = true;
1327
+ render();
1328
+ return;
1329
+ }
965
1330
  // q: 대시보드 종료
966
- if (key === "q") { doClose(); return; }
1331
+ if (key === "q") {
1332
+ doClose();
1333
+ return;
1334
+ }
967
1335
  // 1-9: 워커 직접 선택
968
1336
  if (/^[1-9]$/.test(key)) {
969
1337
  const names = visibleWorkerNames();
970
1338
  const target = names[Number.parseInt(key, 10) - 1];
971
- if (target) { setSelectedWorker(target); render(); }
1339
+ if (target) {
1340
+ setSelectedWorker(target);
1341
+ render();
1342
+ }
972
1343
  return;
973
1344
  }
974
1345
  }
975
1346
 
976
1347
  // ── Enter→attach (k9s 패턴) ───────────────────────────────────────────
977
- function attachToSession(worker) {
978
- const execFileFn = opts.deps?.execFile || _execFile;
979
- // 1. rawMode 해제 + input 일시정지 (키 이벤트 차단)
980
- if (rawModeEnabled && typeof input?.setRawMode === "function") input.setRawMode(false);
981
- if (typeof input?.pause === "function") input.pause();
982
- // 2. altScreen 퇴장
983
- 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
+ }
984
1356
 
985
- const sessionName = worker.sessionName || worker.paneName;
986
- if (worker.remote && worker.sshUser) {
987
- // 원격: SSH + psmux attach in new WT tab
988
- const host = worker.host || "unknown";
989
- const ip = worker._sshIp || host;
990
- const title = `${host}:${worker.role || sessionName}`;
991
- execFileFn("wt.exe", ["-w", "0", "nt", "--title", title, "--",
992
- "ssh", `${worker.sshUser}@${ip}`, "-t", `psmux attach -t ${sessionName}`],
993
- { detached: true, stdio: "ignore", windowsHide: false }, () => {});
994
- } else {
995
- // 로컬: psmux attach in new WT tab
996
- const title = worker.role || sessionName;
997
- execFileFn("wt.exe", ["-w", "0", "nt", "--title", title, "--",
998
- "psmux", "attach", "-t", sessionName],
999
- { 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;
1000
1379
  }
1380
+ if (typeof input?.pause === "function") input.pause();
1381
+ exitAltScreen();
1001
1382
 
1002
- // 3. 200ms 후 altScreen 복귀 + rawMode 재활성화
1003
- setTimeout(() => {
1383
+ setTimeoutFn(() => {
1004
1384
  enterAltScreen();
1005
- if (typeof input?.setRawMode === "function") { input.setRawMode(true); rawModeEnabled = true; }
1385
+ if (typeof input?.setRawMode === "function") {
1386
+ input.setRawMode(true);
1387
+ rawModeEnabled = true;
1388
+ }
1006
1389
  if (typeof input?.resume === "function") input.resume();
1007
1390
  render();
1008
1391
  }, 200);
1392
+ return true;
1009
1393
  }
1010
1394
 
1011
1395
  // ── flash 메시지 (완료/실패 알림용) ────────────────────────────────────
@@ -1013,16 +1397,27 @@ export function createLogDashboard(opts = {}) {
1013
1397
  let flashTimer = null;
1014
1398
  function showFlash(msg, durationMs = 5000) {
1015
1399
  flashMessage = msg;
1016
- if (flashTimer) clearTimeout(flashTimer);
1017
- flashTimer = setTimeout(() => { flashMessage = ""; render(); }, durationMs);
1400
+ if (flashTimer) clearTimeoutFn(flashTimer);
1401
+ flashTimer = setTimeoutFn(() => {
1402
+ flashMessage = "";
1403
+ render();
1404
+ }, durationMs);
1018
1405
  render();
1019
1406
  }
1020
1407
 
1021
1408
  function attachInput() {
1022
1409
  if (inputAttached) return;
1023
- 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;
1024
1416
  inputAttached = true;
1025
- if (typeof input.setRawMode === "function") { input.setRawMode(true); rawModeEnabled = true; }
1417
+ if (typeof input.setRawMode === "function") {
1418
+ input.setRawMode(true);
1419
+ rawModeEnabled = true;
1420
+ }
1026
1421
  if (typeof input.resume === "function") input.resume();
1027
1422
  input.on("data", handleInput);
1028
1423
  }
@@ -1058,10 +1453,32 @@ export function createLogDashboard(opts = {}) {
1058
1453
  const renderTime = Date.now();
1059
1454
 
1060
1455
  // Tier1: 상단 고정 2행
1061
- 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
+ );
1062
1465
  // flash 메시지 (완료/실패 알림)
1063
1466
  if (flashMessage) {
1064
- 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));
1065
1482
  }
1066
1483
 
1067
1484
  // 레이아웃 결정
@@ -1075,9 +1492,19 @@ export function createLogDashboard(opts = {}) {
1075
1492
 
1076
1493
  // summary+detail: summaryBar + focus pane
1077
1494
  if (effectiveLayout === "summary+detail") {
1078
- 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
+ );
1079
1503
  const selectedState = workers.get(selectedWorker);
1080
- const focusPaneHeight = Math.max(8, totalRows - tier1.length - summaryBar.length);
1504
+ const focusPaneHeight = Math.max(
1505
+ 8,
1506
+ totalRows - tier1.length - summaryBar.length,
1507
+ );
1081
1508
  const focusPane = buildFocusPane(selectedWorker, selectedState, {
1082
1509
  width: totalCols,
1083
1510
  height: focusPaneHeight,
@@ -1091,11 +1518,12 @@ export function createLogDashboard(opts = {}) {
1091
1518
  return [...tier1, ...summaryBar, ...focusPane];
1092
1519
  }
1093
1520
 
1094
- // 좌우 분할: Left Rail (30%) | Right Focus (70%)
1095
- // 목업: Tier2 Left Rail + Tier3 Focus 나란히 렌더링
1521
+ // 좌우 분할: Left Rail | Right Focus (H/L로 비율 조정 가능)
1096
1522
  const GAP = 1; // rail과 focus 사이 구분선
1097
- const railRatio = focus === "detail" ? 0.20 : 0.30;
1098
- 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
+ );
1099
1527
  const focusWidth = totalCols - railWidth - GAP;
1100
1528
  const bodyHeight = Math.max(6, totalRows - tier1.length - 1); // -1 for status bar
1101
1529
 
@@ -1118,7 +1546,8 @@ export function createLogDashboard(opts = {}) {
1118
1546
  railLines.push(...card);
1119
1547
  }
1120
1548
  // rail 높이를 bodyHeight에 맞춤 (부족하면 빈 줄, 넘치면 자름)
1121
- while (railLines.length < bodyHeight) railLines.push(padRight("", railWidth));
1549
+ while (railLines.length < bodyHeight)
1550
+ railLines.push(padRight("", railWidth));
1122
1551
  if (railLines.length > bodyHeight) railLines.length = bodyHeight;
1123
1552
 
1124
1553
  // Right Focus: 선택된 워커 상세
@@ -1135,7 +1564,8 @@ export function createLogDashboard(opts = {}) {
1135
1564
  time: renderTime,
1136
1565
  });
1137
1566
  }
1138
- while (focusLines.length < bodyHeight) focusLines.push(padRight("", focusWidth));
1567
+ while (focusLines.length < bodyHeight)
1568
+ focusLines.push(padRight("", focusWidth));
1139
1569
  if (focusLines.length > bodyHeight) focusLines.length = bodyHeight;
1140
1570
 
1141
1571
  // 좌우 합성: rail[i] + separator + focus[i]
@@ -1148,15 +1578,19 @@ export function createLogDashboard(opts = {}) {
1148
1578
  }
1149
1579
 
1150
1580
  // 하단 상태바
1151
- const statusBar = truncate(
1152
- color(` 세션 종료됨 — 아무 키나 누르면 닫힘`, MOCHA.subtext),
1153
- totalCols,
1154
- );
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);
1155
1589
 
1156
1590
  return [...tier1, ...composedRows, statusBar];
1157
1591
  }
1158
1592
 
1159
- // ── altScreen diff render ─────────────────────────────────────────────
1593
+ // ── altScreen diff render (batched single write → 깜빡임 방지) ───────
1160
1594
  function renderAltScreen() {
1161
1595
  const newRows = buildRows();
1162
1596
  rowBuf.set(newRows);
@@ -1165,16 +1599,16 @@ export function createLogDashboard(opts = {}) {
1165
1599
 
1166
1600
  if (dirty.length === 0 && newRows.length === prevLen) return;
1167
1601
 
1168
- const toErase = prevLen > newRows.length
1169
- ? Array.from({ length: prevLen - newRows.length }, (_, i) => newRows.length + i)
1170
- : [];
1171
-
1602
+ let buf = "";
1172
1603
  for (const i of dirty) {
1173
- write(moveTo(i + 1, 1) + clearLine + (newRows[i] || ""));
1604
+ buf += moveTo(i + 1, 1) + clearLine + (newRows[i] || "");
1174
1605
  }
1175
- for (const i of toErase) {
1176
- 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
+ }
1177
1610
  }
1611
+ if (buf) write(buf);
1178
1612
 
1179
1613
  rowBuf.commit();
1180
1614
  }
@@ -1215,22 +1649,39 @@ export function createLogDashboard(opts = {}) {
1215
1649
  // ── 공개 API ─────────────────────────────────────────────────────────
1216
1650
  return {
1217
1651
  updateWorker(paneName, state) {
1218
- const existing = workers.get(paneName) || { cli: "codex", status: "pending" };
1652
+ const existing = workers.get(paneName) || {
1653
+ cli: "codex",
1654
+ status: "pending",
1655
+ };
1219
1656
  const merged = normalizeWorkerState(existing, state);
1220
1657
  const nextSig = JSON.stringify({
1221
- cli: merged.cli, status: merged.status, role: merged.role,
1222
- snapshot: merged.snapshot, summary: merged.summary, detail: merged.detail,
1223
- findings: merged.findings, files_changed: merged.files_changed,
1224
- confidence: merged.confidence, tokens: merged.tokens,
1225
- 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,
1226
1670
  });
1227
1671
  const sigChanged = nextSig !== existing._sig;
1228
- 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;
1229
1675
  merged._sig = nextSig;
1230
1676
  merged._logSec = sigChanged
1231
1677
  ? (explicitElapsed ?? nowElapsedSec())
1232
- : (Number.isFinite(existing._logSec) ? existing._logSec : (explicitElapsed ?? nowElapsedSec()));
1678
+ : Number.isFinite(existing._logSec)
1679
+ ? existing._logSec
1680
+ : (explicitElapsed ?? nowElapsedSec());
1233
1681
  workers.set(paneName, merged);
1682
+ // 토큰 히스토리 추적 (스파크라인용)
1683
+ if (merged.tokens !== undefined)
1684
+ tokenTracker.record(paneName, merged.tokens);
1234
1685
  ensureSelectedWorker(visibleWorkerNames());
1235
1686
  // follow-tail: 새 데이터 → 자동 scroll 재계산
1236
1687
  if (followTail) detailScrollOffset = 0;
@@ -1283,7 +1734,10 @@ export function createLogDashboard(opts = {}) {
1283
1734
 
1284
1735
  setFocusTab(tab) {
1285
1736
  const valid = ["log", "detail", "files"];
1286
- if (valid.includes(tab)) { focusTab = tab; detailScrollOffset = 0; }
1737
+ if (valid.includes(tab)) {
1738
+ focusTab = tab;
1739
+ detailScrollOffset = 0;
1740
+ }
1287
1741
  },
1288
1742
 
1289
1743
  getLayout() {
@@ -1302,7 +1756,19 @@ export function createLogDashboard(opts = {}) {
1302
1756
 
1303
1757
  attachWorker(name) {
1304
1758
  const w = workers.get(name);
1305
- 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();
1306
1772
  },
1307
1773
 
1308
1774
  close() {
@@ -1327,27 +1793,30 @@ export function createLogDashboard(opts = {}) {
1327
1793
  // failed=red, dead/init/starting=dim
1328
1794
 
1329
1795
  const CONDUCTOR_STATE_LABEL = {
1330
- init: { label: 'INIT', seq: MOCHA.subtext },
1331
- starting: { label: 'START', seq: MOCHA.executing },
1332
- healthy: { label: 'OK', seq: MOCHA.ok },
1333
- stalled: { label: 'STALL', seq: MOCHA.yellow },
1334
- input_wait: { label: 'INPUT_WAIT', seq: FG.cyan },
1335
- failed: { label: 'FAIL', seq: MOCHA.fail },
1336
- restarting: { label: 'RESTART', seq: MOCHA.partial },
1337
- dead: { label: 'DEAD', seq: FG.gray },
1338
- 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 },
1339
1805
  };
1340
1806
 
1341
1807
  function conductorHealthCell(state) {
1342
- 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
+ };
1343
1812
  return `${entry.seq}■ ${entry.label}${RESET}`;
1344
1813
  }
1345
1814
 
1346
1815
  function conductorRelTime(ms) {
1347
- if (!ms) return '—';
1816
+ if (!ms) return "—";
1348
1817
  const sec = Math.round((Date.now() - ms) / 1000);
1349
- if (sec < 0) return '—';
1350
- if (sec < 60) return `${sec}s ago`;
1818
+ if (sec < 0) return "—";
1819
+ if (sec < 60) return `${sec}s ago`;
1351
1820
  if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
1352
1821
  return `${Math.floor(sec / 3600)}h ago`;
1353
1822
  }
@@ -1365,31 +1834,32 @@ export function renderConductorTier(snapshot, cols = 100) {
1365
1834
 
1366
1835
  // ── 열 너비 계산 ────────────────────────────────────────
1367
1836
  // ID(8) Agent(7) Host(6) Health(dyn) LastOut(dyn) Restarts(8) Why(rest)
1368
- const COL_ID = 8;
1369
- const COL_AGENT = 7;
1370
- const COL_HOST = 6;
1837
+ const COL_ID = 8;
1838
+ const COL_AGENT = 7;
1839
+ const COL_HOST = 6;
1371
1840
  const COL_RESTARTS = 4;
1372
- const COL_HEALTH = 12; // '■ INPUT_WAIT' = 12 chars
1373
- 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
1374
1843
  // Why gets the remainder
1375
- 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
1376
1846
  const COL_WHY = Math.max(4, inner - fixedCols);
1377
1847
 
1378
1848
  function cell(text, width_) {
1379
- return clip(String(text ?? ''), width_);
1849
+ return clip(String(text ?? ""), width_);
1380
1850
  }
1381
1851
 
1382
1852
  function buildRow(id, agent, host, healthCell, lastOut, restarts, why) {
1383
- const idC = cell(id, COL_ID);
1384
- const agentC = cell(agent, COL_AGENT);
1385
- 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);
1386
1856
  const restartsC = cell(String(restarts ?? 0), COL_RESTARTS);
1387
- const lastOutC = clip(lastOut, COL_LASTOUT);
1388
- const whyC = cell(why, COL_WHY);
1857
+ const lastOutC = clip(lastOut, COL_LASTOUT);
1858
+ const whyC = cell(why, COL_WHY);
1389
1859
  // healthCell already has ANSI codes; pad its visible width manually
1390
1860
  const healthVis = wcswidth(stripAnsi(healthCell));
1391
1861
  const healthPad = Math.max(0, COL_HEALTH - healthVis);
1392
- const healthC = healthCell + ' '.repeat(healthPad);
1862
+ const healthC = healthCell + " ".repeat(healthPad);
1393
1863
 
1394
1864
  return `${idC} ${agentC} ${hostC} ${healthC} ${lastOutC} ${restartsC} ${whyC}`;
1395
1865
  }
@@ -1404,40 +1874,60 @@ export function renderConductorTier(snapshot, cols = 100) {
1404
1874
  const dashLeft = 1;
1405
1875
  const dashRight = Math.max(0, dashLen - dashLeft);
1406
1876
  const borderSeq = MOCHA.border;
1407
- const topBorder =
1408
- `${borderSeq}┌${'─'.repeat(dashLeft)}${RESET}${titleColored}${borderSeq}${'─'.repeat(dashRight)}┐${RESET}`;
1877
+ const topBorder = `${borderSeq}┌${"─".repeat(dashLeft)}${RESET}${titleColored}${borderSeq}${"─".repeat(dashRight)}┐${RESET}`;
1409
1878
 
1410
1879
  // ── ヘッダー行 ───────────────────────────────────────────
1411
- 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
+ );
1412
1889
  const headerLine = `${borderSeq}│${RESET} ${dim(headerRow)} ${borderSeq}│${RESET}`;
1413
1890
 
1414
1891
  // ── データ行 ────────────────────────────────────────────
1415
1892
  const dataLines = [];
1416
1893
  if (!snapshot || snapshot.length === 0) {
1417
- const emptyMsg = color('(no sessions)', FG.muted);
1418
- const _emptyPad = clip(stripAnsi(emptyMsg) === '(no sessions)' ? emptyMsg : emptyMsg, inner);
1419
- 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
+ );
1420
1902
  } else {
1421
1903
  for (const s of snapshot) {
1422
- const id = String(s.id ?? '').slice(0, COL_ID);
1423
- const agent = String(s.agent ?? 'unknown').slice(0, COL_AGENT);
1424
- const host = 'local';
1425
- 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";
1426
1908
  const healthCell = conductorHealthCell(state);
1427
- const lastOut = conductorRelTime(s.health?.lastProbeAt ?? null);
1909
+ const lastOut = conductorRelTime(s.health?.lastProbeAt ?? null);
1428
1910
  const restarts = s.restarts ?? 0;
1429
1911
  // derive "why" from last state transition context
1430
1912
  const why = s.health?.inputWaitPattern
1431
1913
  ? String(s.health.inputWaitPattern).slice(0, COL_WHY)
1432
- : '';
1433
-
1434
- 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
+ );
1435
1925
  dataLines.push(`${borderSeq}│${RESET} ${rowText} ${borderSeq}│${RESET}`);
1436
1926
  }
1437
1927
  }
1438
1928
 
1439
1929
  // ── Bottom border ─────────────────────────────────────
1440
- const botBorder = `${borderSeq}└${'─'.repeat(boxWidth)}┘${RESET}`;
1930
+ const botBorder = `${borderSeq}└${"─".repeat(boxWidth)}┘${RESET}`;
1441
1931
 
1442
1932
  return [topBorder, headerLine, ...dataLines, botBorder];
1443
1933
  }