@triflux/remote 10.1.0 → 10.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/hub/pipe.mjs +239 -121
  2. package/hub/public/dashboard.html +61 -0
  3. package/hub/server.mjs +2179 -709
  4. package/hub/store-adapter.mjs +59 -26
  5. package/hub/store.mjs +356 -153
  6. package/hub/team/agent-map.json +26 -7
  7. package/hub/team/ansi.mjs +245 -146
  8. package/hub/team/backend.mjs +78 -17
  9. package/hub/team/build-worker-prompt.mjs +73 -0
  10. package/hub/team/check-mcp-hub.mjs +49 -0
  11. package/hub/team/claude-daemon-control.mjs +1130 -0
  12. package/hub/team/claude-native-bridge.mjs +1011 -0
  13. package/hub/team/claude-session-projection.mjs +74 -0
  14. package/hub/team/cli/commands/attach.mjs +57 -37
  15. package/hub/team/cli/commands/control.mjs +29 -8
  16. package/hub/team/cli/commands/debug.mjs +95 -74
  17. package/hub/team/cli/commands/focus.mjs +80 -53
  18. package/hub/team/cli/commands/interrupt.mjs +18 -6
  19. package/hub/team/cli/commands/kill.mjs +16 -5
  20. package/hub/team/cli/commands/list.mjs +31 -24
  21. package/hub/team/cli/commands/send.mjs +19 -6
  22. package/hub/team/cli/commands/start/index.mjs +151 -33
  23. package/hub/team/cli/commands/start/parse-args.mjs +97 -13
  24. package/hub/team/cli/commands/start/start-headless.mjs +155 -37
  25. package/hub/team/cli/commands/start/start-in-process.mjs +50 -40
  26. package/hub/team/cli/commands/start/start-mux.mjs +122 -73
  27. package/hub/team/cli/commands/start/start-wt.mjs +86 -69
  28. package/hub/team/cli/commands/status.mjs +43 -14
  29. package/hub/team/cli/commands/stop.mjs +11 -4
  30. package/hub/team/cli/commands/task.mjs +8 -3
  31. package/hub/team/cli/commands/tasks.mjs +13 -13
  32. package/hub/team/cli/help.mjs +1 -1
  33. package/hub/team/cli/index.mjs +2 -2
  34. package/hub/team/cli/manifest.mjs +38 -8
  35. package/hub/team/cli/render.mjs +52 -30
  36. package/hub/team/cli/services/attach-fallback.mjs +74 -54
  37. package/hub/team/cli/services/hub-client.mjs +44 -16
  38. package/hub/team/cli/services/member-selector.mjs +37 -30
  39. package/hub/team/cli/services/native-control.mjs +144 -116
  40. package/hub/team/cli/services/runtime-mode.mjs +61 -4
  41. package/hub/team/cli/services/state-store.mjs +66 -9
  42. package/hub/team/cli/services/task-model.mjs +40 -30
  43. package/hub/team/codex-review.mjs +521 -0
  44. package/hub/team/conductor-mesh-bridge.mjs +25 -24
  45. package/hub/team/conductor-registry.mjs +121 -0
  46. package/hub/team/conductor.mjs +1379 -671
  47. package/hub/team/consensus-meta.mjs +176 -0
  48. package/hub/team/dashboard-anchor.mjs +4 -5
  49. package/hub/team/dashboard-layout.mjs +3 -1
  50. package/hub/team/dashboard-open.mjs +20 -118
  51. package/hub/team/dashboard.mjs +76 -28
  52. package/hub/team/event-log.mjs +18 -10
  53. package/hub/team/execution-mode.mjs +256 -0
  54. package/hub/team/extract-completion-payload.mjs +110 -0
  55. package/hub/team/git-preflight.mjs +286 -0
  56. package/hub/team/handoff.mjs +49 -18
  57. package/hub/team/headless-bridge-session.mjs +17 -0
  58. package/hub/team/headless.mjs +2213 -1156
  59. package/hub/team/health-probe.mjs +232 -69
  60. package/hub/team/interactive-native-launcher.mjs +106 -0
  61. package/hub/team/interactive-native-worker.mjs +80 -0
  62. package/hub/team/interactive-tui-transport.mjs +465 -0
  63. package/hub/team/launcher-template.mjs +49 -17
  64. package/hub/team/lead-control.mjs +91 -4
  65. package/hub/team/mcp-selector.mjs +145 -0
  66. package/hub/team/native-supervisor.mjs +90 -26
  67. package/hub/team/native.mjs +120 -42
  68. package/hub/team/nativeProxy.mjs +184 -113
  69. package/hub/team/notify.mjs +147 -78
  70. package/hub/team/orchestrator.mjs +173 -161
  71. package/hub/team/pane.mjs +43 -20
  72. package/hub/team/process-cleanup.mjs +69 -25
  73. package/hub/team/psmux.mjs +1022 -281
  74. package/hub/team/recovery-store.mjs +149 -0
  75. package/hub/team/remote-probe.mjs +68 -52
  76. package/hub/team/remote-session.mjs +131 -59
  77. package/hub/team/remote-watcher.mjs +61 -33
  78. package/hub/team/retry-state-machine.mjs +336 -0
  79. package/hub/team/routing.mjs +51 -25
  80. package/hub/team/runtime-strategy.mjs +200 -0
  81. package/hub/team/schemas/worker-completion.json +39 -0
  82. package/hub/team/sentinel-capture.mjs +127 -0
  83. package/hub/team/session-sync.mjs +153 -6
  84. package/hub/team/session.mjs +574 -611
  85. package/hub/team/shared.mjs +13 -13
  86. package/hub/team/staleState.mjs +72 -30
  87. package/hub/team/swarm-cli.mjs +390 -0
  88. package/hub/team/swarm-hypervisor.mjs +1544 -193
  89. package/hub/team/swarm-intent.mjs +216 -0
  90. package/hub/team/swarm-locks.mjs +140 -25
  91. package/hub/team/swarm-planner.mjs +121 -21
  92. package/hub/team/swarm-preflight.mjs +319 -0
  93. package/hub/team/swarm-reconciler.mjs +128 -23
  94. package/hub/team/synapse-cli.mjs +458 -0
  95. package/hub/team/synapse-http.mjs +60 -0
  96. package/hub/team/synapse-registry.mjs +329 -0
  97. package/hub/team/terminal-opener.mjs +216 -0
  98. package/hub/team/tui-core.mjs +292 -0
  99. package/hub/team/tui-lite.mjs +237 -148
  100. package/hub/team/tui-remote-adapter.mjs +18 -34
  101. package/hub/team/tui-synapse.mjs +223 -0
  102. package/hub/team/tui-viewer.mjs +108 -48
  103. package/hub/team/tui-widgets.mjs +262 -0
  104. package/hub/team/tui.mjs +931 -439
  105. package/hub/team/worker-completion-validator.mjs +84 -0
  106. package/hub/team/worker-sandbox.mjs +97 -0
  107. package/hub/team/worker-signal.mjs +263 -0
  108. package/hub/team/worker-signal.types.d.ts +52 -0
  109. package/hub/team/worktree-lifecycle.mjs +517 -63
  110. package/hub/team/wt-manager.mjs +297 -26
  111. package/hub/tools.mjs +477 -252
  112. package/hub/tray.mjs +411 -368
  113. package/hub/workers/antigravity-route-worker.mjs +169 -0
  114. package/hub/workers/claude-worker.mjs +162 -118
  115. package/hub/workers/codex-app-server-worker.mjs +1015 -0
  116. package/hub/workers/codex-mcp.mjs +724 -507
  117. package/hub/workers/delegator-mcp.mjs +570 -499
  118. package/hub/workers/factory.mjs +103 -21
  119. package/hub/workers/gemini-worker.mjs +126 -284
  120. package/hub/workers/interface.mjs +24 -2
  121. package/hub/workers/lib/jsonrpc-stdio.mjs +463 -0
  122. package/hub/workers/worker-utils.mjs +23 -17
  123. package/package.json +4 -3
  124. package/scripts/lib/agent-json.mjs +27 -0
  125. package/scripts/lib/async.mjs +174 -0
  126. package/scripts/lib/claudemd-scanner.mjs +223 -0
  127. package/scripts/lib/cli-agy.mjs +62 -0
  128. package/scripts/lib/cli-claude.mjs +78 -0
  129. package/scripts/lib/cli-codex.mjs +199 -0
  130. package/scripts/lib/cli-gemini.mjs +37 -0
  131. package/scripts/lib/codex-recovery.sh +50 -0
  132. package/scripts/lib/context.mjs +67 -0
  133. package/scripts/lib/cross-review-utils.mjs +59 -0
  134. package/scripts/lib/doctor-env-checks.mjs +121 -0
  135. package/scripts/lib/dynamic-route-cli.mjs +107 -0
  136. package/scripts/lib/env-probe.mjs +269 -0
  137. package/scripts/lib/env.mjs +14 -0
  138. package/scripts/lib/gemini-profiles.mjs +119 -0
  139. package/scripts/lib/handoff.mjs +187 -0
  140. package/scripts/lib/hook-utils.mjs +16 -0
  141. package/scripts/lib/hub.mjs +48 -0
  142. package/scripts/lib/keyword-rules.mjs +195 -0
  143. package/scripts/lib/logger.mjs +105 -0
  144. package/scripts/lib/mcp-filter.mjs +883 -0
  145. package/scripts/lib/mcp-gateway-health-check.mjs +159 -0
  146. package/scripts/lib/mcp-gateway-servers.mjs +70 -0
  147. package/scripts/lib/mcp-gateway-wrapper-check.mjs +35 -0
  148. package/scripts/lib/mcp-guard-engine.mjs +2120 -0
  149. package/scripts/lib/mcp-health.mjs +773 -0
  150. package/scripts/lib/mcp-manifest.mjs +89 -0
  151. package/scripts/lib/mcp-server-catalog.mjs +355 -0
  152. package/scripts/lib/pid.mjs +63 -0
  153. package/scripts/lib/process-utils.mjs +26 -0
  154. package/scripts/lib/psmux-info.mjs +224 -0
  155. package/scripts/lib/quota.mjs +47 -0
  156. package/scripts/lib/remote-spawn-transfer.mjs +226 -0
  157. package/scripts/lib/skill-state.mjs +220 -0
  158. package/scripts/lib/skill-template.mjs +375 -0
  159. package/scripts/lib/team.mjs +75 -0
  160. package/scripts/lib/timeout.mjs +66 -0
  161. package/scripts/lib/tmp.mjs +26 -0
  162. package/scripts/lib/toml.mjs +113 -0
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,
@@ -469,29 +573,42 @@ function buildWorkerRail(name, st, opts = {}) {
469
573
  const innerWidth = Math.max(12, width - 4);
470
574
  const cli = st.cli || "codex";
471
575
  const role = sanitizeOneLine(st.role);
576
+ const displayName = sanitizeOneLine(st.displayName, name);
472
577
  const status = runtimeStatus(st);
473
578
  const sec = Number.isFinite(st._logSec) ? st._logSec : 0;
474
- const changeElapsed = st._statusChangedAt ? Math.max(0, time - st._statusChangedAt) : Number.POSITIVE_INFINITY;
579
+ const changeElapsed = st._statusChangedAt
580
+ ? Math.max(0, time - st._statusChangedAt)
581
+ : Number.POSITIVE_INFINITY;
475
582
 
476
583
  // Tier2 행 1: 이름 + CLI + role
477
584
  const selMark = selected
478
- ? (focused ? color("▶", MOCHA.blue) : color(">", FG.triflux))
585
+ ? focused
586
+ ? color("▶", MOCHA.blue)
587
+ : color(">", FG.triflux)
479
588
  : previousSelected
480
589
  ? dim("~")
481
590
  : " ";
482
- const hb = heartbeat(status, status === "running" ? currentShimmer(time) : 0, st._statusChangedAt, time);
591
+ const hb = heartbeat(
592
+ status,
593
+ status === "running" ? currentShimmer(time) : 0,
594
+ st._statusChangedAt,
595
+ time,
596
+ );
483
597
  // host 배지 (원격 워커용)
484
- const hostBadge = st.host && st.host !== "local"
485
- ? color(`[${st.host}]`, MOCHA.mauve) + " "
486
- : "";
487
- const displayRole = dedupeRole(role, name, cli);
598
+ const hostBadge =
599
+ st.host && st.host !== "local"
600
+ ? color(`[${st.host}]`, MOCHA.mauve) + " "
601
+ : "";
602
+ const displayRole = dedupeRole(role, displayName, cli);
488
603
  const title = truncate(
489
- `${selMark} ${hb} ${hostBadge}${color(name, FG.triflux)} ${color("•", MOCHA.overlay)} ${color(cli, cliColor(cli))}${displayRole ? ` ${color(`(${displayRole})`, MOCHA.overlay)}` : ""}`,
604
+ `${selMark} ${hb} ${hostBadge}${color(displayName, FG.triflux)} ${color("•", MOCHA.overlay)} ${color(cli, cliColor(cli))}${displayRole ? ` ${color(`(${displayRole})`, MOCHA.overlay)}` : ""}`,
490
605
  innerWidth,
491
606
  );
492
607
 
493
608
  const cardWidth = Math.max(MIN_CARD_WIDTH, width);
494
- const borderHighlight = focused ? borderHighlightPosition(cardWidth, compact ? 2 : 6, time) : undefined;
609
+ const borderHighlight = focused
610
+ ? borderHighlightPosition(cardWidth, compact ? 2 : 6, time)
611
+ : undefined;
495
612
  const titleFlashBg = titleFlash(status, changeElapsed);
496
613
 
497
614
  // status-specific border: focused→mauve, selected→bright, non-selected→glow decay
@@ -502,37 +619,58 @@ function buildWorkerRail(name, st, opts = {}) {
502
619
  }
503
620
  if (selected) return statusColor(status);
504
621
  const from = statusToRgb(status);
505
- const decayBase = st._statusChangedAt ? clamp(changeElapsed / CARD_GLOW_MS, 0, 1) : 1;
622
+ const decayBase = st._statusChangedAt
623
+ ? clamp(changeElapsed / CARD_GLOW_MS, 0, 1)
624
+ : 1;
506
625
  const decayT = easeOutCubic(decayBase);
507
- return rgbSeq(lerpRgb(from, MOCHA_RGB.border, 0.5 + (0.5 * decayT)));
626
+ return rgbSeq(lerpRgb(from, MOCHA_RGB.border, 0.5 + 0.5 * decayT));
508
627
  })();
509
628
 
510
629
  if (compact) {
511
630
  // compact 2-line 카드
512
- const progress = Number.isFinite(st.progress) ? clamp(st.progress, 0, 1) : (status === "running" ? 0.3 : 1);
631
+ const progress = Number.isFinite(st.progress)
632
+ ? clamp(st.progress, 0, 1)
633
+ : status === "running"
634
+ ? 0.3
635
+ : 1;
513
636
  const percent = Math.round(progress * 100);
514
637
  const compactLine1 = truncate(
515
638
  `${selMark} ${hb} ${hostBadge}${color(name, FG.triflux)} ${dim("•")} ${color(cli, cliColor(cli))} ${statusBadge(status)} ${String(percent).padStart(3)}%`,
516
639
  innerWidth,
517
640
  );
518
- const verdict = sanitizeOneLine(st.handoff?.verdict || st.summary || st.snapshot, status);
641
+ const verdict = sanitizeOneLine(
642
+ st.handoff?.verdict || st.summary || st.snapshot,
643
+ status,
644
+ );
519
645
  const compactLine2 = truncate(color(verdict, MOCHA.text), innerWidth);
520
- const framed = box([compactLine1, compactLine2], cardWidth, statusBorderColor, {
521
- highlightPos: borderHighlight,
522
- titleFlashBg,
523
- });
646
+ const framed = box(
647
+ [compactLine1, compactLine2],
648
+ cardWidth,
649
+ statusBorderColor,
650
+ {
651
+ highlightPos: borderHighlight,
652
+ titleFlashBg,
653
+ },
654
+ );
524
655
  return [framed.top, ...framed.body, framed.bot];
525
656
  }
526
657
 
527
658
  // Tier2 행 2: 상태 배지 + elapsed + tokens + conf
528
- const confidence = sanitizeOneLine(st.handoff?.confidence || st.confidence, "n/a");
659
+ const confidence = sanitizeOneLine(
660
+ st.handoff?.confidence || st.confidence,
661
+ "n/a",
662
+ );
529
663
  const statusLine = truncate(
530
664
  `${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
665
  innerWidth,
532
666
  );
533
667
 
534
668
  // Tier2 행 3: progress bar
535
- const progress = Number.isFinite(st.progress) ? clamp(st.progress, 0, 1) : (status === "running" ? 0.3 : 1);
669
+ const progress = Number.isFinite(st.progress)
670
+ ? clamp(st.progress, 0, 1)
671
+ : status === "running"
672
+ ? 0.3
673
+ : 1;
536
674
  const percent = Math.round(progress * 100);
537
675
  const barWidth = clamp(Math.floor(innerWidth * 0.3), 8, 16);
538
676
  const bar = progressBar(percent, barWidth, time);
@@ -542,18 +680,33 @@ function buildWorkerRail(name, st, opts = {}) {
542
680
  );
543
681
 
544
682
  // 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";
683
+ const verdict = sanitizeOneLine(
684
+ st.handoff?.verdict || st.summary || st.snapshot,
685
+ status,
686
+ );
687
+ const findings =
688
+ detailHighlights(st).join(" / ") || "no notable findings yet";
689
+ const files =
690
+ sanitizeFiles(st.handoff?.files_changed || st.files_changed).join(", ") ||
691
+ "none";
548
692
 
549
693
  const verdictClr = statusColor(status);
550
694
  const lines = [
551
695
  title,
552
696
  statusLine,
553
697
  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),
698
+ truncate(
699
+ `${color("verdict", MOCHA.overlay)} ${color(verdict, verdictClr)}`,
700
+ innerWidth,
701
+ ),
702
+ truncate(
703
+ `${color("findings", MOCHA.overlay)} ${color(findings, MOCHA.subtext)}`,
704
+ innerWidth,
705
+ ),
706
+ truncate(
707
+ `${color("files", MOCHA.overlay)} ${color(files, MOCHA.subtext)}`,
708
+ innerWidth,
709
+ ),
557
710
  ];
558
711
 
559
712
  const framed = box(lines, cardWidth, statusBorderColor, {
@@ -577,23 +730,47 @@ function buildFocusPane(name, st, opts = {}) {
577
730
  const innerWidth = Math.max(12, width - 4);
578
731
 
579
732
  // 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");
733
+ const verdict = sanitizeOneLine(
734
+ st.handoff?.verdict || st.summary || st.snapshot,
735
+ "—",
736
+ );
737
+ const confidence = sanitizeOneLine(
738
+ st.handoff?.confidence || st.confidence,
739
+ "n/a",
740
+ );
582
741
  const files = sanitizeFiles(st.handoff?.files_changed || st.files_changed);
583
742
  const status = runtimeStatus(st);
584
743
 
585
744
  // Tab bar: 활성 탭은 MOCHA.blue + bold, 비활성은 MOCHA.overlay
586
745
  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);
746
+ const tabLog =
747
+ activeTab === "log"
748
+ ? `${MOCHA.blue}${bold("[Log]")}`
749
+ : color("[Log]", MOCHA.overlay);
750
+ const tabDetail =
751
+ activeTab === "detail"
752
+ ? `${MOCHA.blue}${bold("[Detail]")}`
753
+ : color("[Detail]", MOCHA.overlay);
754
+ const tabFiles =
755
+ activeTab === "files"
756
+ ? `${MOCHA.blue}${bold(`[Files ${files.length}]`)}`
757
+ : color(`[Files ${files.length}]`, MOCHA.overlay);
590
758
  const tabBar = truncate(`${tabLog} ${tabDetail} ${tabFiles}`, innerWidth);
591
759
 
592
760
  const stickyLines = [
593
- truncate(`${color(name, FG.triflux)} ${color("•", MOCHA.overlay)} ${statusBadge(status)}`, innerWidth),
761
+ truncate(
762
+ `${color(name, FG.triflux)} ${color("•", MOCHA.overlay)} ${statusBadge(status)}`,
763
+ innerWidth,
764
+ ),
594
765
  tabBar,
595
- truncate(`${color("verdict", MOCHA.overlay)} ${color(verdict, statusColor(status))}`, innerWidth),
596
- truncate(`${color("conf", MOCHA.overlay)} ${color(confidence, MOCHA.text)}`, innerWidth),
766
+ truncate(
767
+ `${color("verdict", MOCHA.overlay)} ${color(verdict, statusColor(status))}`,
768
+ innerWidth,
769
+ ),
770
+ truncate(
771
+ `${color("conf", MOCHA.overlay)} ${color(confidence, MOCHA.text)}`,
772
+ innerWidth,
773
+ ),
597
774
  color("─", MOCHA.surface0).repeat(Math.max(4, innerWidth)),
598
775
  ];
599
776
 
@@ -605,17 +782,22 @@ function buildFocusPane(name, st, opts = {}) {
605
782
  const summaryLines = [];
606
783
  for (const key of SUMMARY_KEYS) {
607
784
  const value = st.handoff?.[key];
608
- if (Array.isArray(value) && value.length > 0) summaryLines.push(`${key}: ${value.join(", ")}`);
785
+ if (Array.isArray(value) && value.length > 0)
786
+ summaryLines.push(`${key}: ${value.join(", ")}`);
609
787
  else if (value) summaryLines.push(`${key}: ${value}`);
610
788
  }
611
- allBodyLines = summaryLines.length > 0
612
- ? summaryLines.flatMap((l) => wrapLine(l, innerWidth))
613
- : [dim("no structured data")];
789
+ allBodyLines =
790
+ summaryLines.length > 0
791
+ ? summaryLines.flatMap((l) => wrapLine(l, innerWidth))
792
+ : [dim("no structured data")];
614
793
  } 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")];
794
+ const filesList = sanitizeFiles(
795
+ st.handoff?.files_changed || st.files_changed,
796
+ );
797
+ allBodyLines =
798
+ filesList.length > 0
799
+ ? filesList.map((f, i) => `${i + 1}. ${f}`)
800
+ : [dim("no files changed")];
619
801
  } else {
620
802
  allBodyLines = wrapTextAll(detailText(st), innerWidth, rawMode);
621
803
  }
@@ -624,16 +806,24 @@ function buildFocusPane(name, st, opts = {}) {
624
806
  if (followTail) {
625
807
  startIdx = Math.max(0, allBodyLines.length - bodyAvail);
626
808
  } else {
627
- startIdx = clamp(scrollOffset, 0, Math.max(0, allBodyLines.length - bodyAvail));
809
+ startIdx = clamp(
810
+ scrollOffset,
811
+ 0,
812
+ Math.max(0, allBodyLines.length - bodyAvail),
813
+ );
628
814
  }
629
815
 
630
816
  const bodySlice = allBodyLines.slice(startIdx, startIdx + bodyAvail);
631
817
  if (bodySlice.length === 0) bodySlice.push(dim("no detail available"));
632
818
 
633
819
  // 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);
820
+ const scrollInfo =
821
+ allBodyLines.length > bodyAvail
822
+ ? color(
823
+ `${startIdx + 1}-${Math.min(startIdx + bodyAvail, allBodyLines.length)}/${allBodyLines.length}`,
824
+ MOCHA.overlay,
825
+ )
826
+ : color(`${allBodyLines.length} lines`, MOCHA.overlay);
637
827
 
638
828
  const contentLines = [
639
829
  ...stickyLines,
@@ -647,23 +837,49 @@ function buildFocusPane(name, st, opts = {}) {
647
837
  : MOCHA.border;
648
838
  const paneWidth = Math.max(MIN_CARD_WIDTH, width);
649
839
  const framed = box(contentLines, paneWidth, borderColor, {
650
- highlightPos: focused ? borderHighlightPosition(paneWidth, contentLines.length, time) : undefined,
840
+ highlightPos: focused
841
+ ? borderHighlightPosition(paneWidth, contentLines.length, time)
842
+ : undefined,
651
843
  });
652
844
  return [framed.top, ...framed.body, framed.bot];
653
845
  }
654
846
 
655
847
  // ── 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);
848
+ function buildSummaryBar(
849
+ names,
850
+ workers,
851
+ selectedWorker,
852
+ pipeline,
853
+ width,
854
+ version,
855
+ ) {
856
+ const maxChipWidth = clamp(
857
+ Math.floor((width - 6) / Math.min(names.length, 4)),
858
+ 16,
859
+ 26,
860
+ );
658
861
  const chips = names.map((name, idx) => {
659
862
  const st = workers.get(name);
660
863
  const status = runtimeStatus(st);
661
- const progress = Number.isFinite(st.progress) ? clamp(st.progress, 0, 1) : (status === "running" ? 0.3 : 1);
864
+ const progress = Number.isFinite(st.progress)
865
+ ? clamp(st.progress, 0, 1)
866
+ : status === "running"
867
+ ? 0.3
868
+ : 1;
662
869
  const label = `${selectedWorker === name ? ">" : " "} ${idx + 1}.${name} ${status} ${Math.round(progress * 100)}%`;
663
870
  return padRight(truncate(label, maxChipWidth), maxChipWidth);
664
871
  });
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);
872
+ const chipsLine = truncate(
873
+ chips.join(color(" │ ", MOCHA.overlay)),
874
+ width - 4,
875
+ );
876
+ const keysLine = truncate(
877
+ color(
878
+ "Tab:focus • j/k:nav • gg/G:jump • /:search • n/N:next • H/L:resize • f:follow • l:tab",
879
+ MOCHA.subtext,
880
+ ),
881
+ width - 4,
882
+ );
667
883
  const framed = box([chipsLine, keysLine], width);
668
884
  return [framed.top, ...framed.body, framed.bot];
669
885
  }
@@ -696,7 +912,9 @@ function buildHelpOverlay(width, height) {
696
912
  const framed = box(helpLines, innerWidth + 4, MOCHA.blue);
697
913
  const framedRows = [framed.top, ...framed.body, framed.bot];
698
914
  const topPad = Math.max(0, Math.floor((height - framedRows.length) / 2));
699
- const leftPad = " ".repeat(Math.max(0, Math.floor((width - innerWidth - 4) / 2)));
915
+ const leftPad = " ".repeat(
916
+ Math.max(0, Math.floor((width - innerWidth - 4) / 2)),
917
+ );
700
918
  const result = [];
701
919
  for (let i = 0; i < height; i++) {
702
920
  const fi = i - topPad;
@@ -714,52 +932,23 @@ function _joinColumns(blocks, gap = GRID_GAP) {
714
932
  const maxHeight = Math.max(...blocks.map((b) => b.length));
715
933
  return Array.from({ length: maxHeight }, (_, rowIdx) =>
716
934
  blocks
717
- .map((block) => block[rowIdx] || " ".repeat(wcswidth(stripAnsi(block[0] || ""))))
935
+ .map(
936
+ (block) =>
937
+ block[rowIdx] || " ".repeat(wcswidth(stripAnsi(block[0] || ""))),
938
+ )
718
939
  .join(" ".repeat(gap)),
719
940
  );
720
941
  }
721
942
 
722
943
  // ── normalizeWorkerState ──────────────────────────────────────────────────
723
944
  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
- };
945
+ return coreNormalizeWorkerState(
946
+ existing || { cli: "codex", status: "pending" },
947
+ state,
948
+ {
949
+ trackChanges: true,
950
+ },
951
+ );
763
952
  }
764
953
 
765
954
  // ── createLogDashboard ────────────────────────────────────────────────────
@@ -774,6 +963,7 @@ function normalizeWorkerState(existing, state) {
774
963
  * @returns {LogDashboardHandle}
775
964
  */
776
965
  export function createLogDashboard(opts = {}) {
966
+ const deps = opts.deps || {};
777
967
  const {
778
968
  stream = process.stdout,
779
969
  input = process.stdin,
@@ -784,10 +974,26 @@ export function createLogDashboard(opts = {}) {
784
974
  } = opts;
785
975
 
786
976
  const isTTY = forceTTY || !!stream?.isTTY;
977
+ const now = deps.now || Date.now;
978
+ const setTimeoutFn = deps.setTimeout || setTimeout;
979
+ const clearTimeoutFn = deps.clearTimeout || clearTimeout;
980
+ const openTab =
981
+ deps.openTab ||
982
+ ((request) => {
983
+ const wtm = createWtManager();
984
+ return wtm.createTab({ title: request.title, command: request.command });
985
+ });
986
+ const attachLimiter =
987
+ deps.attachLimiter ||
988
+ createTransientTabLimiter({
989
+ now,
990
+ ttlMs: deps.attachTabTtlMs,
991
+ limits: deps.attachLimits,
992
+ });
787
993
 
788
994
  const workers = new Map();
789
995
  let pipeline = { phase: "exec", fix_attempt: 0 };
790
- let startedAt = Date.now();
996
+ let startedAt = now();
791
997
  let timer = null;
792
998
  let closed = false;
793
999
  let frameCount = 0;
@@ -803,6 +1009,27 @@ export function createLogDashboard(opts = {}) {
803
1009
  let inputAttached = false;
804
1010
  let rawModeEnabled = false;
805
1011
 
1012
+ // UX 위젯 (ISSUE-14)
1013
+ const tokenTracker = createTokenTracker();
1014
+ const searchState = createSearchState();
1015
+ const vimMotion = createVimMotion();
1016
+ const panelResizer = createPanelResizer({
1017
+ initialRatio: focus === "detail" ? 0.2 : 0.3,
1018
+ });
1019
+
1020
+ // Synapse 실시간 관제 (Phase 3)
1021
+ const synapseMetrics = deps.synapseMetrics || createMetricsCollector();
1022
+ const synapseStream =
1023
+ deps.synapseStream ||
1024
+ createSynapseEventStream({
1025
+ onEvent(event) {
1026
+ synapseMetrics.ingest(event);
1027
+ },
1028
+ fetchImpl: deps.synapseFetch,
1029
+ });
1030
+ // Synapse 자동 시작 (옵트인: deps.enableSynapse)
1031
+ if (deps.enableSynapse) synapseStream.start();
1032
+
806
1033
  // virtual row buffer (altScreen 전용)
807
1034
  const rowBuf = new RowBuffer();
808
1035
 
@@ -816,22 +1043,26 @@ export function createLogDashboard(opts = {}) {
816
1043
  }
817
1044
 
818
1045
  function nowElapsedSec() {
819
- return Math.max(0, Math.round((Date.now() - startedAt) / 1000));
1046
+ return Math.max(0, Math.round((now() - startedAt) / 1000));
820
1047
  }
821
1048
 
822
1049
  function getViewportColumns() {
823
1050
  const v = Number.isFinite(columns)
824
1051
  ? columns
825
- : (Number.isFinite(stream?.columns)
826
- ? stream.columns
827
- : (Number.isFinite(process.stdout?.columns) ? process.stdout.columns : FALLBACK_COLUMNS));
1052
+ : Number.isFinite(stream?.columns)
1053
+ ? stream.columns
1054
+ : Number.isFinite(process.stdout?.columns)
1055
+ ? process.stdout.columns
1056
+ : FALLBACK_COLUMNS;
828
1057
  return Math.max(48, v || FALLBACK_COLUMNS);
829
1058
  }
830
1059
 
831
1060
  function getViewportRows() {
832
1061
  const v = Number.isFinite(stream?.rows)
833
1062
  ? stream.rows
834
- : (Number.isFinite(process.stdout?.rows) ? process.stdout.rows : FALLBACK_ROWS);
1063
+ : Number.isFinite(process.stdout?.rows)
1064
+ ? process.stdout.rows
1065
+ : FALLBACK_ROWS;
835
1066
  return Math.max(10, v || FALLBACK_ROWS);
836
1067
  }
837
1068
 
@@ -840,8 +1071,12 @@ export function createLogDashboard(opts = {}) {
840
1071
  }
841
1072
 
842
1073
  function ensureSelectedWorker(names) {
843
- if (names.length === 0) { selectedWorker = null; return; }
844
- if (!selectedWorker || !workers.has(selectedWorker)) selectedWorker = names[0];
1074
+ if (names.length === 0) {
1075
+ selectedWorker = null;
1076
+ return;
1077
+ }
1078
+ if (!selectedWorker || !workers.has(selectedWorker))
1079
+ selectedWorker = names[0];
845
1080
  }
846
1081
 
847
1082
  function setSelectedWorker(nextWorker, { preserveTrail = true } = {}) {
@@ -885,8 +1120,11 @@ export function createLogDashboard(opts = {}) {
885
1120
  function doClose() {
886
1121
  if (closed) return;
887
1122
  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);
1123
+ synapseStream.stop();
1124
+ if (inputAttached && typeof input?.off === "function")
1125
+ input.off("data", handleInput);
1126
+ if (rawModeEnabled && typeof input?.setRawMode === "function")
1127
+ input.setRawMode(false);
890
1128
  if (inputAttached && typeof input?.pause === "function") input.pause();
891
1129
  exitAltScreen();
892
1130
  closed = true;
@@ -895,7 +1133,18 @@ export function createLogDashboard(opts = {}) {
895
1133
  // ── 키 입력 ──────────────────────────────────────────────────────────
896
1134
  function handleInput(chunk) {
897
1135
  const key = String(chunk);
898
- if (key === "\u0003") return; // Ctrl-C
1136
+ if (key === "\u0003") {
1137
+ doClose();
1138
+ return;
1139
+ }
1140
+
1141
+ // 검색 모드 활성 중: 키를 검색 상태에 위임
1142
+ if (searchState.active) {
1143
+ if (searchState.handleKey(key)) {
1144
+ render();
1145
+ return;
1146
+ }
1147
+ }
899
1148
 
900
1149
  // Help overlay: 아무 키나 누르면 닫기
901
1150
  if (helpOverlay) {
@@ -910,7 +1159,10 @@ export function createLogDashboard(opts = {}) {
910
1159
  const w = workers.get(selectedWorker);
911
1160
  if (!w) return;
912
1161
  const sessionTarget = w.sessionName || w.paneName;
913
- if (!sessionTarget) return;
1162
+ if (!sessionTarget) {
1163
+ showFlash("세션 정보 없음 — 아직 준비 중");
1164
+ return;
1165
+ }
914
1166
  attachToSession(w);
915
1167
  return;
916
1168
  }
@@ -923,33 +1175,97 @@ export function createLogDashboard(opts = {}) {
923
1175
  }
924
1176
 
925
1177
  // 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
1178
+ if (key === "\x1b[1;2A") {
1179
+ selectRelative(-1);
1180
+ return;
1181
+ } // Shift+Up → 워커 위
1182
+ if (key === "\x1b[1;2B") {
1183
+ selectRelative(1);
1184
+ return;
1185
+ } // Shift+Down → 워커 아래
1186
+ if (key === "\x1b[1;2D") {
1187
+ focus = "rail";
1188
+ render();
1189
+ return;
1190
+ } // Shift+Left → rail
1191
+ if (key === "\x1b[1;2C") {
1192
+ focus = "detail";
1193
+ render();
1194
+ return;
1195
+ } // Shift+Right → detail
930
1196
 
931
1197
  if (focus === "detail") {
932
1198
  // 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; }
1199
+ if (key === "j" || key === "\u001b[B") {
1200
+ scrollDetail(1);
1201
+ return;
1202
+ }
1203
+ if (key === "k" || key === "\u001b[A") {
1204
+ scrollDetail(-1);
1205
+ return;
1206
+ }
935
1207
  } else {
936
1208
  // rail 포커스: j/k = 워커 선택
937
- if (key === "j" || key === "\u001b[B") { selectRelative(1); return; }
938
- if (key === "k" || key === "\u001b[A") { selectRelative(-1); return; }
1209
+ if (key === "j" || key === "\u001b[B") {
1210
+ selectRelative(1);
1211
+ return;
1212
+ }
1213
+ if (key === "k" || key === "\u001b[A") {
1214
+ selectRelative(-1);
1215
+ return;
1216
+ }
939
1217
  }
940
1218
 
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; }
1219
+ // vim 모션: gg(첫 워커/상단), G(마지막 워커/하단)
1220
+ const motion = vimMotion.handleKey(key);
1221
+ if (motion === "gg") {
1222
+ if (focus === "detail") {
1223
+ followTail = false;
1224
+ detailScrollOffset = 0;
1225
+ } else {
1226
+ const names = visibleWorkerNames();
1227
+ if (names.length > 0) setSelectedWorker(names[0]);
1228
+ }
1229
+ render();
1230
+ return;
1231
+ }
1232
+ if (motion === "G") {
1233
+ if (focus === "detail") {
1234
+ followTail = true;
1235
+ detailScrollOffset = 0;
1236
+ } else {
1237
+ const names = visibleWorkerNames();
1238
+ if (names.length > 0) setSelectedWorker(names[names.length - 1]);
1239
+ }
1240
+ render();
1241
+ return;
1242
+ }
1243
+ // 단일 g는 vimMotion 대기 → return하지 않음 (이전 동작 유지)
1244
+ if (key === "g") return;
1245
+
945
1246
  // PgUp/PgDn: 페이지 단위 스크롤
946
1247
  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
1248
+ if (key === "\x1b[5~") {
1249
+ scrollDetail(-pageSize);
1250
+ return;
1251
+ } // PgUp
1252
+ if (key === "\x1b[6~") {
1253
+ scrollDetail(pageSize);
1254
+ return;
1255
+ } // PgDn
949
1256
  // f: follow-tail 토글
950
- if (key === "f") { followTail = !followTail; if (followTail) detailScrollOffset = 0; render(); return; }
1257
+ if (key === "f") {
1258
+ followTail = !followTail;
1259
+ if (followTail) detailScrollOffset = 0;
1260
+ render();
1261
+ return;
1262
+ }
951
1263
  // r: raw mode 토글
952
- if (key === "r") { rawMode = !rawMode; render(); return; }
1264
+ if (key === "r") {
1265
+ rawMode = !rawMode;
1266
+ render();
1267
+ return;
1268
+ }
953
1269
  // l: 탭 전환 (Log → Detail → Files)
954
1270
  if (key === "l") {
955
1271
  const tabs = ["log", "detail", "files"];
@@ -958,54 +1274,123 @@ export function createLogDashboard(opts = {}) {
958
1274
  render();
959
1275
  return;
960
1276
  }
961
- // n: 가장 최근 상태 변경 워커로 이동
962
- if (key === "n") { selectMostRecentChangedWorker(); return; }
1277
+ // /: 검색 모드 활성화 (vim 패턴)
1278
+ if (key === "/") {
1279
+ searchState.activate();
1280
+ render();
1281
+ return;
1282
+ }
1283
+ // n: 검색 결과 다음 / 최근 변경 워커
1284
+ if (key === "n") {
1285
+ if (searchState.query) {
1286
+ const names = visibleWorkerNames();
1287
+ const idx = names.indexOf(selectedWorker);
1288
+ const match = searchState.findMatch(names, idx, 1);
1289
+ if (match >= 0) {
1290
+ setSelectedWorker(names[match]);
1291
+ render();
1292
+ } else {
1293
+ showFlash(`검색 결과 없음: ${searchState.query}`);
1294
+ }
1295
+ } else {
1296
+ selectMostRecentChangedWorker();
1297
+ }
1298
+ return;
1299
+ }
1300
+ // N: 검색 결과 이전
1301
+ if (key === "N") {
1302
+ if (searchState.query) {
1303
+ const names = visibleWorkerNames();
1304
+ const idx = names.indexOf(selectedWorker);
1305
+ const match = searchState.findMatch(names, idx, -1);
1306
+ if (match >= 0) {
1307
+ setSelectedWorker(names[match]);
1308
+ render();
1309
+ }
1310
+ }
1311
+ return;
1312
+ }
1313
+ // H: rail 축소 (패널 리사이즈)
1314
+ if (key === "H") {
1315
+ panelResizer.shrinkRail();
1316
+ render();
1317
+ return;
1318
+ }
1319
+ // L: rail 확대 (패널 리사이즈)
1320
+ if (key === "L") {
1321
+ panelResizer.expandRail();
1322
+ render();
1323
+ return;
1324
+ }
963
1325
  // h/?: 도움말 오버레이 토글
964
- if (key === "h" || key === "?") { helpOverlay = true; render(); return; }
1326
+ if (key === "h" || key === "?") {
1327
+ helpOverlay = true;
1328
+ render();
1329
+ return;
1330
+ }
965
1331
  // q: 대시보드 종료
966
- if (key === "q") { doClose(); return; }
1332
+ if (key === "q") {
1333
+ doClose();
1334
+ return;
1335
+ }
967
1336
  // 1-9: 워커 직접 선택
968
1337
  if (/^[1-9]$/.test(key)) {
969
1338
  const names = visibleWorkerNames();
970
1339
  const target = names[Number.parseInt(key, 10) - 1];
971
- if (target) { setSelectedWorker(target); render(); }
1340
+ if (target) {
1341
+ setSelectedWorker(target);
1342
+ render();
1343
+ }
972
1344
  return;
973
1345
  }
974
1346
  }
975
1347
 
976
1348
  // ── 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();
1349
+ async function attachToSession(worker) {
1350
+ let request;
1351
+ try {
1352
+ request = buildDashboardAttachRequest(worker);
1353
+ } catch (error) {
1354
+ showFlash(`[attach] ${error.message}`);
1355
+ return false;
1356
+ }
984
1357
 
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 }, () => {});
1358
+ const lease = attachLimiter.acquire(request.kind);
1359
+ if (!lease.ok) {
1360
+ const seconds = Math.max(1, Math.ceil(lease.retryAfterMs / 1000));
1361
+ const label = lease.kind === "remote" ? "원격" : "로컬";
1362
+ showFlash(
1363
+ `[attach] ${label} 탭 제한(${lease.limit}/30초) — ${seconds}초 후 다시 시도`,
1364
+ );
1365
+ return false;
1366
+ }
1367
+
1368
+ try {
1369
+ await openTab(request);
1370
+ } catch (error) {
1371
+ lease.release?.();
1372
+ const reason = error?.reasonCode || error?.message || "unknown";
1373
+ showFlash(`[attach] 탭 열기 실패: ${reason}`);
1374
+ return false;
1375
+ }
1376
+
1377
+ if (rawModeEnabled && typeof input?.setRawMode === "function") {
1378
+ input.setRawMode(false);
1379
+ rawModeEnabled = false;
1000
1380
  }
1381
+ if (typeof input?.pause === "function") input.pause();
1382
+ exitAltScreen();
1001
1383
 
1002
- // 3. 200ms 후 altScreen 복귀 + rawMode 재활성화
1003
- setTimeout(() => {
1384
+ setTimeoutFn(() => {
1004
1385
  enterAltScreen();
1005
- if (typeof input?.setRawMode === "function") { input.setRawMode(true); rawModeEnabled = true; }
1386
+ if (typeof input?.setRawMode === "function") {
1387
+ input.setRawMode(true);
1388
+ rawModeEnabled = true;
1389
+ }
1006
1390
  if (typeof input?.resume === "function") input.resume();
1007
1391
  render();
1008
1392
  }, 200);
1393
+ return true;
1009
1394
  }
1010
1395
 
1011
1396
  // ── flash 메시지 (완료/실패 알림용) ────────────────────────────────────
@@ -1013,16 +1398,27 @@ export function createLogDashboard(opts = {}) {
1013
1398
  let flashTimer = null;
1014
1399
  function showFlash(msg, durationMs = 5000) {
1015
1400
  flashMessage = msg;
1016
- if (flashTimer) clearTimeout(flashTimer);
1017
- flashTimer = setTimeout(() => { flashMessage = ""; render(); }, durationMs);
1401
+ if (flashTimer) clearTimeoutFn(flashTimer);
1402
+ flashTimer = setTimeoutFn(() => {
1403
+ flashMessage = "";
1404
+ render();
1405
+ }, durationMs);
1018
1406
  render();
1019
1407
  }
1020
1408
 
1021
1409
  function attachInput() {
1022
1410
  if (inputAttached) return;
1023
- if (!isTTY || (!forceTTY && !input?.isTTY) || typeof input?.on !== "function") return;
1411
+ if (
1412
+ !isTTY ||
1413
+ (!forceTTY && !input?.isTTY) ||
1414
+ typeof input?.on !== "function"
1415
+ )
1416
+ return;
1024
1417
  inputAttached = true;
1025
- if (typeof input.setRawMode === "function") { input.setRawMode(true); rawModeEnabled = true; }
1418
+ if (typeof input.setRawMode === "function") {
1419
+ input.setRawMode(true);
1420
+ rawModeEnabled = true;
1421
+ }
1026
1422
  if (typeof input.resume === "function") input.resume();
1027
1423
  input.on("data", handleInput);
1028
1424
  }
@@ -1058,10 +1454,32 @@ export function createLogDashboard(opts = {}) {
1058
1454
  const renderTime = Date.now();
1059
1455
 
1060
1456
  // Tier1: 상단 고정 2행
1061
- const tier1 = buildTier1(names, workers, pipeline, elapsed, totalCols, VERSION, renderTime);
1457
+ const tier1 = buildTier1(
1458
+ names,
1459
+ workers,
1460
+ pipeline,
1461
+ elapsed,
1462
+ totalCols,
1463
+ VERSION,
1464
+ renderTime,
1465
+ );
1062
1466
  // flash 메시지 (완료/실패 알림)
1063
1467
  if (flashMessage) {
1064
- tier1.push(truncate(` ${color("▸", MOCHA.green)} ${flashMessage}`, totalCols));
1468
+ tier1.push(
1469
+ truncate(` ${color("▸", MOCHA.green)} ${flashMessage}`, totalCols),
1470
+ );
1471
+ }
1472
+ // Synapse 실시간 메트릭 (활성 시)
1473
+ if (synapseStream.isRunning) {
1474
+ const metricsSnap = synapseMetrics.snapshot();
1475
+ tier1.push(
1476
+ truncate(renderMetricsTier1(metricsSnap, totalCols), totalCols),
1477
+ );
1478
+ }
1479
+ // 검색 프롬프트 (/ 모드)
1480
+ const searchPrompt = searchState.renderPrompt(totalCols);
1481
+ if (searchPrompt) {
1482
+ tier1.push(truncate(searchPrompt, totalCols));
1065
1483
  }
1066
1484
 
1067
1485
  // 레이아웃 결정
@@ -1075,9 +1493,19 @@ export function createLogDashboard(opts = {}) {
1075
1493
 
1076
1494
  // summary+detail: summaryBar + focus pane
1077
1495
  if (effectiveLayout === "summary+detail") {
1078
- const summaryBar = buildSummaryBar(names, workers, selectedWorker, pipeline, totalCols, VERSION);
1496
+ const summaryBar = buildSummaryBar(
1497
+ names,
1498
+ workers,
1499
+ selectedWorker,
1500
+ pipeline,
1501
+ totalCols,
1502
+ VERSION,
1503
+ );
1079
1504
  const selectedState = workers.get(selectedWorker);
1080
- const focusPaneHeight = Math.max(8, totalRows - tier1.length - summaryBar.length);
1505
+ const focusPaneHeight = Math.max(
1506
+ 8,
1507
+ totalRows - tier1.length - summaryBar.length,
1508
+ );
1081
1509
  const focusPane = buildFocusPane(selectedWorker, selectedState, {
1082
1510
  width: totalCols,
1083
1511
  height: focusPaneHeight,
@@ -1091,11 +1519,12 @@ export function createLogDashboard(opts = {}) {
1091
1519
  return [...tier1, ...summaryBar, ...focusPane];
1092
1520
  }
1093
1521
 
1094
- // 좌우 분할: Left Rail (30%) | Right Focus (70%)
1095
- // 목업: Tier2 Left Rail + Tier3 Focus 나란히 렌더링
1522
+ // 좌우 분할: Left Rail | Right Focus (H/L로 비율 조정 가능)
1096
1523
  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));
1524
+ const railWidth = Math.max(
1525
+ MIN_CARD_WIDTH,
1526
+ Math.floor(totalCols * panelResizer.ratio),
1527
+ );
1099
1528
  const focusWidth = totalCols - railWidth - GAP;
1100
1529
  const bodyHeight = Math.max(6, totalRows - tier1.length - 1); // -1 for status bar
1101
1530
 
@@ -1118,7 +1547,8 @@ export function createLogDashboard(opts = {}) {
1118
1547
  railLines.push(...card);
1119
1548
  }
1120
1549
  // rail 높이를 bodyHeight에 맞춤 (부족하면 빈 줄, 넘치면 자름)
1121
- while (railLines.length < bodyHeight) railLines.push(padRight("", railWidth));
1550
+ while (railLines.length < bodyHeight)
1551
+ railLines.push(padRight("", railWidth));
1122
1552
  if (railLines.length > bodyHeight) railLines.length = bodyHeight;
1123
1553
 
1124
1554
  // Right Focus: 선택된 워커 상세
@@ -1135,7 +1565,8 @@ export function createLogDashboard(opts = {}) {
1135
1565
  time: renderTime,
1136
1566
  });
1137
1567
  }
1138
- while (focusLines.length < bodyHeight) focusLines.push(padRight("", focusWidth));
1568
+ while (focusLines.length < bodyHeight)
1569
+ focusLines.push(padRight("", focusWidth));
1139
1570
  if (focusLines.length > bodyHeight) focusLines.length = bodyHeight;
1140
1571
 
1141
1572
  // 좌우 합성: rail[i] + separator + focus[i]
@@ -1148,15 +1579,19 @@ export function createLogDashboard(opts = {}) {
1148
1579
  }
1149
1580
 
1150
1581
  // 하단 상태바
1151
- const statusBar = truncate(
1152
- color(` 세션 종료됨 — 아무 키나 누르면 닫힘`, MOCHA.subtext),
1153
- totalCols,
1154
- );
1582
+ const allDone = names.every((n) => {
1583
+ const s = runtimeStatus(workers.get(n));
1584
+ return s === "ok" || s === "completed" || s === "failed";
1585
+ });
1586
+ const statusText = allDone
1587
+ ? " Enter: attach • q: 종료"
1588
+ : " Enter: attach • Tab: 포커스 • j/k: 이동 • h: 도움말";
1589
+ const statusBar = truncate(color(statusText, MOCHA.subtext), totalCols);
1155
1590
 
1156
1591
  return [...tier1, ...composedRows, statusBar];
1157
1592
  }
1158
1593
 
1159
- // ── altScreen diff render ─────────────────────────────────────────────
1594
+ // ── altScreen diff render (batched single write → 깜빡임 방지) ───────
1160
1595
  function renderAltScreen() {
1161
1596
  const newRows = buildRows();
1162
1597
  rowBuf.set(newRows);
@@ -1165,16 +1600,16 @@ export function createLogDashboard(opts = {}) {
1165
1600
 
1166
1601
  if (dirty.length === 0 && newRows.length === prevLen) return;
1167
1602
 
1168
- const toErase = prevLen > newRows.length
1169
- ? Array.from({ length: prevLen - newRows.length }, (_, i) => newRows.length + i)
1170
- : [];
1171
-
1603
+ let buf = "";
1172
1604
  for (const i of dirty) {
1173
- write(moveTo(i + 1, 1) + clearLine + (newRows[i] || ""));
1605
+ buf += moveTo(i + 1, 1) + clearLine + (newRows[i] || "");
1174
1606
  }
1175
- for (const i of toErase) {
1176
- write(moveTo(i + 1, 1) + clearLine);
1607
+ if (prevLen > newRows.length) {
1608
+ for (let i = newRows.length; i < prevLen; i++) {
1609
+ buf += moveTo(i + 1, 1) + clearLine;
1610
+ }
1177
1611
  }
1612
+ if (buf) write(buf);
1178
1613
 
1179
1614
  rowBuf.commit();
1180
1615
  }
@@ -1215,22 +1650,40 @@ export function createLogDashboard(opts = {}) {
1215
1650
  // ── 공개 API ─────────────────────────────────────────────────────────
1216
1651
  return {
1217
1652
  updateWorker(paneName, state) {
1218
- const existing = workers.get(paneName) || { cli: "codex", status: "pending" };
1653
+ const existing = workers.get(paneName) || {
1654
+ cli: "codex",
1655
+ status: "pending",
1656
+ };
1219
1657
  const merged = normalizeWorkerState(existing, state);
1220
1658
  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,
1659
+ cli: merged.cli,
1660
+ status: merged.status,
1661
+ role: merged.role,
1662
+ displayName: merged.displayName,
1663
+ snapshot: merged.snapshot,
1664
+ summary: merged.summary,
1665
+ detail: merged.detail,
1666
+ findings: merged.findings,
1667
+ files_changed: merged.files_changed,
1668
+ confidence: merged.confidence,
1669
+ tokens: merged.tokens,
1670
+ progress: merged.progress,
1671
+ handoff: merged.handoff,
1226
1672
  });
1227
1673
  const sigChanged = nextSig !== existing._sig;
1228
- const explicitElapsed = Number.isFinite(state.elapsed) ? Math.max(0, Math.round(state.elapsed)) : null;
1674
+ const explicitElapsed = Number.isFinite(state.elapsed)
1675
+ ? Math.max(0, Math.round(state.elapsed))
1676
+ : null;
1229
1677
  merged._sig = nextSig;
1230
1678
  merged._logSec = sigChanged
1231
1679
  ? (explicitElapsed ?? nowElapsedSec())
1232
- : (Number.isFinite(existing._logSec) ? existing._logSec : (explicitElapsed ?? nowElapsedSec()));
1680
+ : Number.isFinite(existing._logSec)
1681
+ ? existing._logSec
1682
+ : (explicitElapsed ?? nowElapsedSec());
1233
1683
  workers.set(paneName, merged);
1684
+ // 토큰 히스토리 추적 (스파크라인용)
1685
+ if (merged.tokens !== undefined)
1686
+ tokenTracker.record(paneName, merged.tokens);
1234
1687
  ensureSelectedWorker(visibleWorkerNames());
1235
1688
  // follow-tail: 새 데이터 → 자동 scroll 재계산
1236
1689
  if (followTail) detailScrollOffset = 0;
@@ -1283,7 +1736,10 @@ export function createLogDashboard(opts = {}) {
1283
1736
 
1284
1737
  setFocusTab(tab) {
1285
1738
  const valid = ["log", "detail", "files"];
1286
- if (valid.includes(tab)) { focusTab = tab; detailScrollOffset = 0; }
1739
+ if (valid.includes(tab)) {
1740
+ focusTab = tab;
1741
+ detailScrollOffset = 0;
1742
+ }
1287
1743
  },
1288
1744
 
1289
1745
  getLayout() {
@@ -1302,7 +1758,19 @@ export function createLogDashboard(opts = {}) {
1302
1758
 
1303
1759
  attachWorker(name) {
1304
1760
  const w = workers.get(name);
1305
- if (w) attachToSession(w);
1761
+ if (!w) return false;
1762
+ return attachToSession(w);
1763
+ },
1764
+
1765
+ // Synapse 관제 API
1766
+ startSynapse() {
1767
+ synapseStream.start();
1768
+ },
1769
+ stopSynapse() {
1770
+ synapseStream.stop();
1771
+ },
1772
+ getSynapseMetrics() {
1773
+ return synapseMetrics.snapshot();
1306
1774
  },
1307
1775
 
1308
1776
  close() {
@@ -1327,27 +1795,30 @@ export function createLogDashboard(opts = {}) {
1327
1795
  // failed=red, dead/init/starting=dim
1328
1796
 
1329
1797
  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 },
1798
+ init: { label: "INIT", seq: MOCHA.subtext },
1799
+ starting: { label: "START", seq: MOCHA.executing },
1800
+ healthy: { label: "OK", seq: MOCHA.ok },
1801
+ stalled: { label: "STALL", seq: MOCHA.yellow },
1802
+ input_wait: { label: "INPUT_WAIT", seq: FG.cyan },
1803
+ failed: { label: "FAIL", seq: MOCHA.fail },
1804
+ restarting: { label: "RESTART", seq: MOCHA.partial },
1805
+ dead: { label: "DEAD", seq: FG.gray },
1806
+ completed: { label: "DONE", seq: MOCHA.ok },
1339
1807
  };
1340
1808
 
1341
1809
  function conductorHealthCell(state) {
1342
- const entry = CONDUCTOR_STATE_LABEL[state] || { label: state.toUpperCase(), seq: FG.gray };
1810
+ const entry = CONDUCTOR_STATE_LABEL[state] || {
1811
+ label: state.toUpperCase(),
1812
+ seq: FG.gray,
1813
+ };
1343
1814
  return `${entry.seq}■ ${entry.label}${RESET}`;
1344
1815
  }
1345
1816
 
1346
1817
  function conductorRelTime(ms) {
1347
- if (!ms) return '—';
1818
+ if (!ms) return "—";
1348
1819
  const sec = Math.round((Date.now() - ms) / 1000);
1349
- if (sec < 0) return '—';
1350
- if (sec < 60) return `${sec}s ago`;
1820
+ if (sec < 0) return "—";
1821
+ if (sec < 60) return `${sec}s ago`;
1351
1822
  if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
1352
1823
  return `${Math.floor(sec / 3600)}h ago`;
1353
1824
  }
@@ -1365,31 +1836,32 @@ export function renderConductorTier(snapshot, cols = 100) {
1365
1836
 
1366
1837
  // ── 열 너비 계산 ────────────────────────────────────────
1367
1838
  // 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;
1839
+ const COL_ID = 8;
1840
+ const COL_AGENT = 7;
1841
+ const COL_HOST = 6;
1371
1842
  const COL_RESTARTS = 4;
1372
- const COL_HEALTH = 12; // '■ INPUT_WAIT' = 12 chars
1373
- const COL_LASTOUT = 9; // '999m ago' = 8 + space
1843
+ const COL_HEALTH = 12; // '■ INPUT_WAIT' = 12 chars
1844
+ const COL_LASTOUT = 9; // '999m ago' = 8 + space
1374
1845
  // Why gets the remainder
1375
- const fixedCols = COL_ID + COL_AGENT + COL_HOST + COL_HEALTH + COL_LASTOUT + COL_RESTARTS + 6; // 6 spaces between cols
1846
+ const fixedCols =
1847
+ COL_ID + COL_AGENT + COL_HOST + COL_HEALTH + COL_LASTOUT + COL_RESTARTS + 6; // 6 spaces between cols
1376
1848
  const COL_WHY = Math.max(4, inner - fixedCols);
1377
1849
 
1378
1850
  function cell(text, width_) {
1379
- return clip(String(text ?? ''), width_);
1851
+ return clip(String(text ?? ""), width_);
1380
1852
  }
1381
1853
 
1382
1854
  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);
1855
+ const idC = cell(id, COL_ID);
1856
+ const agentC = cell(agent, COL_AGENT);
1857
+ const hostC = cell(host, COL_HOST);
1386
1858
  const restartsC = cell(String(restarts ?? 0), COL_RESTARTS);
1387
- const lastOutC = clip(lastOut, COL_LASTOUT);
1388
- const whyC = cell(why, COL_WHY);
1859
+ const lastOutC = clip(lastOut, COL_LASTOUT);
1860
+ const whyC = cell(why, COL_WHY);
1389
1861
  // healthCell already has ANSI codes; pad its visible width manually
1390
1862
  const healthVis = wcswidth(stripAnsi(healthCell));
1391
1863
  const healthPad = Math.max(0, COL_HEALTH - healthVis);
1392
- const healthC = healthCell + ' '.repeat(healthPad);
1864
+ const healthC = healthCell + " ".repeat(healthPad);
1393
1865
 
1394
1866
  return `${idC} ${agentC} ${hostC} ${healthC} ${lastOutC} ${restartsC} ${whyC}`;
1395
1867
  }
@@ -1404,40 +1876,60 @@ export function renderConductorTier(snapshot, cols = 100) {
1404
1876
  const dashLeft = 1;
1405
1877
  const dashRight = Math.max(0, dashLen - dashLeft);
1406
1878
  const borderSeq = MOCHA.border;
1407
- const topBorder =
1408
- `${borderSeq}┌${'─'.repeat(dashLeft)}${RESET}${titleColored}${borderSeq}${'─'.repeat(dashRight)}┐${RESET}`;
1879
+ const topBorder = `${borderSeq}┌${"─".repeat(dashLeft)}${RESET}${titleColored}${borderSeq}${"─".repeat(dashRight)}┐${RESET}`;
1409
1880
 
1410
1881
  // ── ヘッダー行 ───────────────────────────────────────────
1411
- const headerRow = buildRow('ID', 'Agent', 'Host', clip('Health', COL_HEALTH), 'Last Out', 'Rst', 'Why');
1882
+ const headerRow = buildRow(
1883
+ "ID",
1884
+ "Agent",
1885
+ "Host",
1886
+ clip("Health", COL_HEALTH),
1887
+ "Last Out",
1888
+ "Rst",
1889
+ "Why",
1890
+ );
1412
1891
  const headerLine = `${borderSeq}│${RESET} ${dim(headerRow)} ${borderSeq}│${RESET}`;
1413
1892
 
1414
1893
  // ── データ行 ────────────────────────────────────────────
1415
1894
  const dataLines = [];
1416
1895
  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}`);
1896
+ const emptyMsg = color("(no sessions)", FG.muted);
1897
+ const _emptyPad = clip(
1898
+ stripAnsi(emptyMsg) === "(no sessions)" ? emptyMsg : emptyMsg,
1899
+ inner,
1900
+ );
1901
+ dataLines.push(
1902
+ `${borderSeq}│${RESET} ${padRight(emptyMsg, inner - 2)} ${borderSeq}│${RESET}`,
1903
+ );
1420
1904
  } else {
1421
1905
  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';
1906
+ const id = String(s.id ?? "").slice(0, COL_ID);
1907
+ const agent = String(s.agent ?? "unknown").slice(0, COL_AGENT);
1908
+ const host = "local";
1909
+ const state = s.state ?? "init";
1426
1910
  const healthCell = conductorHealthCell(state);
1427
- const lastOut = conductorRelTime(s.health?.lastProbeAt ?? null);
1911
+ const lastOut = conductorRelTime(s.health?.lastProbeAt ?? null);
1428
1912
  const restarts = s.restarts ?? 0;
1429
1913
  // derive "why" from last state transition context
1430
1914
  const why = s.health?.inputWaitPattern
1431
1915
  ? String(s.health.inputWaitPattern).slice(0, COL_WHY)
1432
- : '';
1433
-
1434
- const rowText = buildRow(id, agent, host, healthCell, lastOut, restarts, why);
1916
+ : "";
1917
+
1918
+ const rowText = buildRow(
1919
+ id,
1920
+ agent,
1921
+ host,
1922
+ healthCell,
1923
+ lastOut,
1924
+ restarts,
1925
+ why,
1926
+ );
1435
1927
  dataLines.push(`${borderSeq}│${RESET} ${rowText} ${borderSeq}│${RESET}`);
1436
1928
  }
1437
1929
  }
1438
1930
 
1439
1931
  // ── Bottom border ─────────────────────────────────────
1440
- const botBorder = `${borderSeq}└${'─'.repeat(boxWidth)}┘${RESET}`;
1932
+ const botBorder = `${borderSeq}└${"─".repeat(boxWidth)}┘${RESET}`;
1441
1933
 
1442
1934
  return [topBorder, headerLine, ...dataLines, botBorder];
1443
1935
  }