triflux 10.2.0 → 10.3.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.
@@ -10,45 +10,92 @@
10
10
  // F4: File lease violation → revert worker changes, flag shard as failed
11
11
  // F5: Merge conflict → retry integration with conflict resolution
12
12
 
13
- import { EventEmitter } from 'node:events';
14
- import { join } from 'node:path';
15
- import { mkdirSync, readFileSync, existsSync } from 'node:fs';
16
-
17
- import { createConductor, STATES } from './conductor.mjs';
18
- import { createSwarmLocks } from './swarm-locks.mjs';
19
- import { createEventLog } from './event-log.mjs';
20
- import { probeRemoteEnv, resolveRemoteDir } from './remote-session.mjs';
21
- import { fetchRemoteShard } from './worktree-lifecycle.mjs';
22
- import { getHostConfig } from '../lib/ssh-command.mjs';
13
+ import { EventEmitter } from "node:events";
14
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { getHostConfig } from "../lib/ssh-command.mjs";
17
+ import { createConductor, STATES } from "./conductor.mjs";
18
+ import { ensureConductorRegistry } from "./conductor-registry.mjs";
19
+ import { createEventLog } from "./event-log.mjs";
20
+ import { probeRemoteEnv, resolveRemoteDir } from "./remote-session.mjs";
21
+ import { createSwarmLocks } from "./swarm-locks.mjs";
22
+ import { fetchRemoteShard } from "./worktree-lifecycle.mjs";
23
+
24
+ let importedCreateRegistry = null;
25
+ let meshRegistryImportError = null;
26
+ try {
27
+ ({ createRegistry: importedCreateRegistry } = await import(
28
+ "../../mesh/mesh-registry.mjs"
29
+ ));
30
+ } catch (err) {
31
+ meshRegistryImportError = err;
32
+ }
23
33
 
24
34
  // ── Swarm states ──────────────────────────────────────────────
25
35
 
26
36
  export const SWARM_STATES = Object.freeze({
27
- PLANNING: 'planning',
28
- LAUNCHING: 'launching',
29
- RUNNING: 'running',
30
- INTEGRATING: 'integrating',
31
- VALIDATING: 'validating',
32
- COMPLETED: 'completed',
33
- FAILED: 'failed',
37
+ PLANNING: "planning",
38
+ LAUNCHING: "launching",
39
+ RUNNING: "running",
40
+ INTEGRATING: "integrating",
41
+ VALIDATING: "validating",
42
+ COMPLETED: "completed",
43
+ FAILED: "failed",
34
44
  });
35
45
 
36
46
  // ── Failure mode classification ───────────────────────────────
37
47
 
38
48
  const FAILURE_MODES = Object.freeze({
39
- F1_CRASH: 'F1_crash',
40
- F2_RATE_LIMIT: 'F2_rate_limit',
41
- F3_STALL: 'F3_stall',
42
- F4_LEASE_VIOLATION: 'F4_lease_violation',
43
- F5_MERGE_CONFLICT: 'F5_merge_conflict',
49
+ F1_CRASH: "F1_crash",
50
+ F2_RATE_LIMIT: "F2_rate_limit",
51
+ F3_STALL: "F3_stall",
52
+ F4_LEASE_VIOLATION: "F4_lease_violation",
53
+ F5_MERGE_CONFLICT: "F5_merge_conflict",
44
54
  });
45
55
 
46
56
  const FALLBACK_AGENTS = Object.freeze({
47
- codex: 'gemini',
48
- gemini: 'codex',
49
- claude: 'codex',
57
+ codex: "gemini",
58
+ gemini: "codex",
59
+ claude: "codex",
50
60
  });
51
61
 
62
+ function createNoopRegistry() {
63
+ return Object.freeze({
64
+ register() {},
65
+ unregister() {},
66
+ discover() {
67
+ return [];
68
+ },
69
+ getAgent() {
70
+ return null;
71
+ },
72
+ listAll() {
73
+ return [];
74
+ },
75
+ clear() {},
76
+ });
77
+ }
78
+
79
+ function createSharedRegistry(factory) {
80
+ if (typeof factory !== "function") {
81
+ return {
82
+ registry: createNoopRegistry(),
83
+ fallbackReason: meshRegistryImportError
84
+ ? `mesh_import_failed:${meshRegistryImportError.message}`
85
+ : "mesh_registry_unavailable",
86
+ };
87
+ }
88
+
89
+ try {
90
+ return { registry: factory(), fallbackReason: null };
91
+ } catch (err) {
92
+ return {
93
+ registry: createNoopRegistry(),
94
+ fallbackReason: `mesh_registry_init_failed:${err.message}`,
95
+ };
96
+ }
97
+ }
98
+
52
99
  /**
53
100
  * Create a swarm hypervisor.
54
101
  * @param {object} opts
@@ -72,13 +119,18 @@ export function createSwarmHypervisor(opts) {
72
119
  _deps = {},
73
120
  } = opts;
74
121
 
75
- if (!workdir) throw new Error('workdir is required');
76
- if (!logsDir) throw new Error('logsDir is required');
122
+ if (!workdir) throw new Error("workdir is required");
123
+ if (!logsDir) throw new Error("logsDir is required");
77
124
 
78
125
  mkdirSync(logsDir, { recursive: true });
126
+ ensureConductorRegistry();
79
127
 
128
+ const createConductorImpl = _deps.createConductor || createConductor;
129
+ const createRegistryImpl = _deps.createRegistry || importedCreateRegistry;
80
130
  const emitter = new EventEmitter();
81
- const eventLog = createEventLog(join(logsDir, 'swarm-events.jsonl'));
131
+ const eventLog = createEventLog(join(logsDir, "swarm-events.jsonl"));
132
+ const { registry: sharedRegistry, fallbackReason: meshRegistryFallback } =
133
+ createSharedRegistry(createRegistryImpl);
82
134
 
83
135
  let state = SWARM_STATES.PLANNING;
84
136
  let plan = null;
@@ -90,16 +142,20 @@ export function createSwarmHypervisor(opts) {
90
142
  /** @type {Map<string, { conductor, shardConfig }>} redundant workers for critical shards */
91
143
  const redundantWorkers = new Map();
92
144
 
93
- const results = new Map(); // shardName → validated result
94
- const failures = new Map(); // shardName → failure info
145
+ const results = new Map(); // shardName → validated result
146
+ const failures = new Map(); // shardName → failure info
147
+
148
+ if (meshRegistryFallback) {
149
+ eventLog.append("mesh_registry_fallback", { reason: meshRegistryFallback });
150
+ }
95
151
 
96
152
  // ── State machine ───────────────────────────────────────────
97
153
 
98
- function setState(next, reason = '') {
154
+ function setState(next, reason = "") {
99
155
  const prev = state;
100
156
  state = next;
101
- eventLog.append('swarm_state', { from: prev, to: next, reason });
102
- emitter.emit('stateChange', { from: prev, to: next, reason });
157
+ eventLog.append("swarm_state", { from: prev, to: next, reason });
158
+ emitter.emit("stateChange", { from: prev, to: next, reason });
103
159
  }
104
160
 
105
161
  // ── Worker lifecycle ────────────────────────────────────────
@@ -130,7 +186,10 @@ export function createSwarmHypervisor(opts) {
130
186
  }
131
187
 
132
188
  function launchShard(shard, isRedundant = false) {
133
- const shardLogsDir = join(logsDir, isRedundant ? `${shard.name}-redundant` : shard.name);
189
+ const shardLogsDir = join(
190
+ logsDir,
191
+ isRedundant ? `${shard.name}-redundant` : shard.name,
192
+ );
134
193
  mkdirSync(shardLogsDir, { recursive: true });
135
194
 
136
195
  // Remote shard: probe environment before conductor creation
@@ -138,24 +197,44 @@ export function createSwarmHypervisor(opts) {
138
197
  try {
139
198
  shard._remoteEnv = probeRemoteEnv(shard.host);
140
199
  if (!shard._remoteEnv.claudePath) {
141
- eventLog.append('remote_probe_no_claude', { shard: shard.name, host: shard.host });
142
- failures.set(shard.name, { mode: FAILURE_MODES.F1_CRASH, reason: `claude not found on ${shard.host}` });
200
+ eventLog.append("remote_probe_no_claude", {
201
+ shard: shard.name,
202
+ host: shard.host,
203
+ });
204
+ failures.set(shard.name, {
205
+ mode: FAILURE_MODES.F1_CRASH,
206
+ reason: `claude not found on ${shard.host}`,
207
+ });
143
208
  return null;
144
209
  }
145
- eventLog.append('remote_probe_ok', { shard: shard.name, host: shard.host, env: shard._remoteEnv });
210
+ eventLog.append("remote_probe_ok", {
211
+ shard: shard.name,
212
+ host: shard.host,
213
+ env: shard._remoteEnv,
214
+ });
146
215
  } catch (err) {
147
- eventLog.append('remote_probe_failed', { shard: shard.name, host: shard.host, error: err.message });
148
- failures.set(shard.name, { mode: FAILURE_MODES.F1_CRASH, reason: `remote probe failed: ${err.message}` });
216
+ eventLog.append("remote_probe_failed", {
217
+ shard: shard.name,
218
+ host: shard.host,
219
+ error: err.message,
220
+ });
221
+ failures.set(shard.name, {
222
+ mode: FAILURE_MODES.F1_CRASH,
223
+ reason: `remote probe failed: ${err.message}`,
224
+ });
149
225
  return null;
150
226
  }
151
227
  }
152
228
 
153
- const conductor = createConductor({
229
+ const conductor = createConductorImpl({
154
230
  logsDir: shardLogsDir,
155
231
  maxRestarts,
156
232
  graceMs,
157
233
  probeOpts,
158
- onCompleted: (sessionId) => handleShardCompleted(shard.name, sessionId, isRedundant),
234
+ meshRegistry: sharedRegistry,
235
+ enableMesh: true,
236
+ onCompleted: (sessionId) =>
237
+ handleShardCompleted(shard.name, sessionId, isRedundant),
159
238
  });
160
239
 
161
240
  const sessionConfig = buildSessionConfig(shard);
@@ -164,7 +243,7 @@ export function createSwarmHypervisor(opts) {
164
243
  if (!isRedundant) {
165
244
  const leaseResult = lockManager.acquire(shard.name, shard.files);
166
245
  if (!leaseResult.ok) {
167
- eventLog.append('lease_denied', {
246
+ eventLog.append("lease_denied", {
168
247
  shard: shard.name,
169
248
  conflicts: leaseResult.conflicts,
170
249
  });
@@ -178,7 +257,7 @@ export function createSwarmHypervisor(opts) {
178
257
 
179
258
  conductor.spawnSession(sessionConfig);
180
259
 
181
- eventLog.append('shard_launched', {
260
+ eventLog.append("shard_launched", {
182
261
  shard: shard.name,
183
262
  agent: shard.agent,
184
263
  sessionId: sessionConfig.id,
@@ -188,7 +267,12 @@ export function createSwarmHypervisor(opts) {
188
267
  host: shard.host || null,
189
268
  });
190
269
 
191
- const entry = { conductor, shardConfig: shard, sessionConfig, startedAt: Date.now() };
270
+ const entry = {
271
+ conductor,
272
+ shardConfig: shard,
273
+ sessionConfig,
274
+ startedAt: Date.now(),
275
+ };
192
276
 
193
277
  if (isRedundant) {
194
278
  redundantWorkers.set(shard.name, entry);
@@ -197,7 +281,7 @@ export function createSwarmHypervisor(opts) {
197
281
  }
198
282
 
199
283
  // Listen for dead events (F1/F2/F3)
200
- conductor.on('dead', ({ sessionId, reason }) => {
284
+ conductor.on("dead", ({ sessionId, reason }) => {
201
285
  handleShardFailed(shard.name, sessionId, reason, isRedundant);
202
286
  });
203
287
 
@@ -207,31 +291,35 @@ export function createSwarmHypervisor(opts) {
207
291
  // ── Completion handling ─────────────────────────────────────
208
292
 
209
293
  function handleShardCompleted(shardName, sessionId, isRedundant) {
210
- eventLog.append('shard_completed', { shard: shardName, sessionId, isRedundant });
294
+ eventLog.append("shard_completed", {
295
+ shard: shardName,
296
+ sessionId,
297
+ isRedundant,
298
+ });
211
299
 
212
300
  if (isRedundant) {
213
301
  // Redundant worker completed first — kill primary if still running
214
302
  const primary = workers.get(shardName);
215
303
  if (primary && !isTerminal(primary)) {
216
- eventLog.append('redundant_wins', { shard: shardName });
217
- void primary.conductor.shutdown('redundant_completed_first');
304
+ eventLog.append("redundant_wins", { shard: shardName });
305
+ void primary.conductor.shutdown("redundant_completed_first");
218
306
  }
219
307
  } else {
220
308
  // Primary completed — kill redundant if exists
221
309
  const redundant = redundantWorkers.get(shardName);
222
310
  if (redundant) {
223
- void redundant.conductor.shutdown('primary_completed_first');
311
+ void redundant.conductor.shutdown("primary_completed_first");
224
312
  }
225
313
  }
226
314
 
227
- emitter.emit('shardCompleted', { shardName, sessionId, isRedundant });
315
+ emitter.emit("shardCompleted", { shardName, sessionId, isRedundant });
228
316
  checkAllShardsCompleted();
229
317
  }
230
318
 
231
319
  function handleShardFailed(shardName, sessionId, reason, isRedundant) {
232
320
  const failureMode = classifyFailure(reason);
233
321
 
234
- eventLog.append('shard_failed', {
322
+ eventLog.append("shard_failed", {
235
323
  shard: shardName,
236
324
  sessionId,
237
325
  reason,
@@ -247,7 +335,7 @@ export function createSwarmHypervisor(opts) {
247
335
  if (shard) {
248
336
  const fallbackAgent = FALLBACK_AGENTS[shard.agent];
249
337
  if (fallbackAgent) {
250
- eventLog.append('fallback_agent', {
338
+ eventLog.append("fallback_agent", {
251
339
  shard: shardName,
252
340
  from: shard.agent,
253
341
  to: fallbackAgent,
@@ -263,7 +351,7 @@ export function createSwarmHypervisor(opts) {
263
351
  failures.set(shardName, { mode: failureMode, reason, sessionId });
264
352
  lockManager.release(shardName);
265
353
 
266
- emitter.emit('shardFailed', { shardName, failureMode, reason });
354
+ emitter.emit("shardFailed", { shardName, failureMode, reason });
267
355
  checkAllShardsCompleted();
268
356
  }
269
357
 
@@ -279,7 +367,9 @@ export function createSwarmHypervisor(opts) {
279
367
 
280
368
  function isTerminal(entry) {
281
369
  const snap = entry.conductor.getSnapshot();
282
- return snap.every((s) => s.state === STATES.COMPLETED || s.state === STATES.DEAD);
370
+ return snap.every(
371
+ (s) => s.state === STATES.COMPLETED || s.state === STATES.DEAD,
372
+ );
283
373
  }
284
374
 
285
375
  // ── Integration ─────────────────────────────────────────────
@@ -306,7 +396,7 @@ export function createSwarmHypervisor(opts) {
306
396
  function validateResult(shardName, changedFiles) {
307
397
  const violations = lockManager.validateChanges(shardName, changedFiles);
308
398
 
309
- eventLog.append('validate_result', {
399
+ eventLog.append("validate_result", {
310
400
  shard: shardName,
311
401
  changedFiles,
312
402
  violations,
@@ -324,14 +414,14 @@ export function createSwarmHypervisor(opts) {
324
414
  * Uses git operations for conflict detection.
325
415
  */
326
416
  async function integrateResults() {
327
- setState(SWARM_STATES.INTEGRATING, 'all_shards_done');
417
+ setState(SWARM_STATES.INTEGRATING, "all_shards_done");
328
418
 
329
419
  const integrated = [];
330
420
  const integrationFailures = [];
331
421
 
332
422
  for (const shardName of plan.mergeOrder) {
333
423
  if (failures.has(shardName)) {
334
- eventLog.append('skip_failed_shard', { shard: shardName });
424
+ eventLog.append("skip_failed_shard", { shard: shardName });
335
425
  continue;
336
426
  }
337
427
 
@@ -343,7 +433,10 @@ export function createSwarmHypervisor(opts) {
343
433
  if (shard?.host && shard._remoteEnv) {
344
434
  const hostConfig = getHostConfig(shard.host, config.rootDir);
345
435
  const sshUser = hostConfig?.ssh_user || shard.host;
346
- const remoteRepoPath = resolveRemoteDir(config.rootDir || process.cwd(), shard._remoteEnv);
436
+ const remoteRepoPath = resolveRemoteDir(
437
+ config.rootDir || process.cwd(),
438
+ shard._remoteEnv,
439
+ );
347
440
  const fetchResult = await fetchRemoteShard({
348
441
  host: shard.host,
349
442
  sshUser,
@@ -353,11 +446,17 @@ export function createSwarmHypervisor(opts) {
353
446
  });
354
447
 
355
448
  if (!fetchResult.ok) {
356
- eventLog.append('remote_fetch_failed', { shard: shardName, error: fetchResult.error });
449
+ eventLog.append("remote_fetch_failed", {
450
+ shard: shardName,
451
+ error: fetchResult.error,
452
+ });
357
453
  integrationFailures.push(shardName);
358
454
  continue;
359
455
  }
360
- eventLog.append('remote_fetch_ok', { shard: shardName, headCommit: fetchResult.headCommit });
456
+ eventLog.append("remote_fetch_ok", {
457
+ shard: shardName,
458
+ headCommit: fetchResult.headCommit,
459
+ });
361
460
  }
362
461
 
363
462
  // Read shard output log for changed files
@@ -370,7 +469,7 @@ export function createSwarmHypervisor(opts) {
370
469
  mode: FAILURE_MODES.F4_LEASE_VIOLATION,
371
470
  violations: validation.violations,
372
471
  });
373
- eventLog.append('lease_violation_revert', {
472
+ eventLog.append("lease_violation_revert", {
374
473
  shard: shardName,
375
474
  violations: validation.violations,
376
475
  });
@@ -386,19 +485,24 @@ export function createSwarmHypervisor(opts) {
386
485
  integrated.push(shardName);
387
486
  }
388
487
 
389
- eventLog.append('integration_complete', {
488
+ eventLog.append("integration_complete", {
390
489
  integrated,
391
490
  failed: integrationFailures,
392
- skipped: [...failures.keys()].filter((n) => !integrationFailures.includes(n)),
491
+ skipped: [...failures.keys()].filter(
492
+ (n) => !integrationFailures.includes(n),
493
+ ),
393
494
  });
394
495
 
395
496
  if (integrationFailures.length > 0 && integrated.length === 0) {
396
- setState(SWARM_STATES.FAILED, 'all_shards_failed_integration');
497
+ setState(SWARM_STATES.FAILED, "all_shards_failed_integration");
397
498
  } else {
398
- setState(SWARM_STATES.COMPLETED, `${integrated.length}/${plan.shards.length} integrated`);
499
+ setState(
500
+ SWARM_STATES.COMPLETED,
501
+ `${integrated.length}/${plan.shards.length} integrated`,
502
+ );
399
503
  }
400
504
 
401
- emitter.emit('integrationComplete', {
505
+ emitter.emit("integrationComplete", {
402
506
  integrated,
403
507
  failed: integrationFailures,
404
508
  results: [...results.values()],
@@ -419,11 +523,16 @@ export function createSwarmHypervisor(opts) {
419
523
  const snap = worker.conductor.getSnapshot();
420
524
  for (const session of snap) {
421
525
  if (session.outPath && existsSync(session.outPath)) {
422
- const output = readFileSync(session.outPath, 'utf8');
423
- return extractFilePathsFromOutput(output, plan.leaseMap.get(shardName) || []);
526
+ const output = readFileSync(session.outPath, "utf8");
527
+ return extractFilePathsFromOutput(
528
+ output,
529
+ plan.leaseMap.get(shardName) || [],
530
+ );
424
531
  }
425
532
  }
426
- } catch { /* best-effort */ }
533
+ } catch {
534
+ /* best-effort */
535
+ }
427
536
 
428
537
  // Fallback: trust the lease map (shard was allowed these files)
429
538
  return plan.leaseMap.get(shardName) || [];
@@ -446,8 +555,8 @@ export function createSwarmHypervisor(opts) {
446
555
  // Match common patterns
447
556
  const patterns = [
448
557
  /(?:wrote|created|modified|updated|edited)\s+['"]?([^\s'"]+\.\w+)/i,
449
- /^[+-]{3}\s+[ab]\/(.+)/, // diff headers
450
- /^diff --git a\/(.+)\s+b\//, // git diff headers
558
+ /^[+-]{3}\s+[ab]\/(.+)/, // diff headers
559
+ /^diff --git a\/(.+)\s+b\//, // git diff headers
451
560
  ];
452
561
 
453
562
  for (const re of patterns) {
@@ -458,9 +567,9 @@ export function createSwarmHypervisor(opts) {
458
567
 
459
568
  // Intersect with allowed files if we found anything
460
569
  if (found.size > 0) {
461
- return [...found].filter((f) => allowedFiles.some(
462
- (a) => f.endsWith(a) || a.endsWith(f) || f === a,
463
- ));
570
+ return [...found].filter((f) =>
571
+ allowedFiles.some((a) => f.endsWith(a) || a.endsWith(f) || f === a),
572
+ );
464
573
  }
465
574
 
466
575
  return allowedFiles;
@@ -515,9 +624,9 @@ export function createSwarmHypervisor(opts) {
515
624
 
516
625
  // Warn about file conflicts but don't block
517
626
  if (plan.conflicts.length > 0) {
518
- eventLog.append('file_conflicts_warning', { conflicts: plan.conflicts });
519
- emitter.emit('warning', {
520
- type: 'file_conflicts',
627
+ eventLog.append("file_conflicts_warning", { conflicts: plan.conflicts });
628
+ emitter.emit("warning", {
629
+ type: "file_conflicts",
521
630
  conflicts: plan.conflicts,
522
631
  });
523
632
  }
@@ -525,7 +634,7 @@ export function createSwarmHypervisor(opts) {
525
634
  // Initialize lock manager
526
635
  lockManager = createSwarmLocks({
527
636
  repoRoot: workdir,
528
- persistPath: join(workdir, '.triflux', 'swarm-locks.json'),
637
+ persistPath: join(workdir, ".triflux", "swarm-locks.json"),
529
638
  });
530
639
 
531
640
  setState(SWARM_STATES.LAUNCHING, `${plan.shards.length} shards`);
@@ -561,11 +670,14 @@ export function createSwarmHypervisor(opts) {
561
670
  launchReady();
562
671
 
563
672
  // Re-check pending on each shard completion (dependency chains)
564
- emitter.on('shardCompleted', () => {
673
+ emitter.on("shardCompleted", () => {
565
674
  if (pending.size > 0) launchReady();
566
675
  });
567
676
 
568
- setState(SWARM_STATES.RUNNING, `${launched.size} launched, ${pending.size} pending deps`);
677
+ setState(
678
+ SWARM_STATES.RUNNING,
679
+ `${launched.size} launched, ${pending.size} pending deps`,
680
+ );
569
681
 
570
682
  return getStatus();
571
683
  }
@@ -574,8 +686,8 @@ export function createSwarmHypervisor(opts) {
574
686
  * Graceful shutdown — kill all workers and release locks.
575
687
  * @param {string} [reason]
576
688
  */
577
- async function shutdown(reason = 'shutdown') {
578
- eventLog.append('swarm_shutdown', { reason, state });
689
+ async function shutdown(reason = "shutdown") {
690
+ eventLog.append("swarm_shutdown", { reason, state });
579
691
 
580
692
  const shutdowns = [];
581
693
  for (const [, w] of workers) {
@@ -587,6 +699,7 @@ export function createSwarmHypervisor(opts) {
587
699
 
588
700
  await Promise.allSettled(shutdowns);
589
701
 
702
+ sharedRegistry.clear();
590
703
  lockManager?.releaseAll();
591
704
  await eventLog.flush();
592
705
  await eventLog.close();
@@ -595,18 +708,27 @@ export function createSwarmHypervisor(opts) {
595
708
  setState(SWARM_STATES.FAILED, reason);
596
709
  }
597
710
 
598
- emitter.emit('shutdown', { reason });
711
+ emitter.emit("shutdown", { reason });
599
712
  }
600
713
 
601
714
  return Object.freeze({
602
715
  launch,
603
716
  shutdown,
604
717
  getStatus,
718
+ getMeshRegistry() {
719
+ return sharedRegistry;
720
+ },
605
721
  validateResult,
606
722
  on: emitter.on.bind(emitter),
607
723
  off: emitter.off.bind(emitter),
608
- get state() { return state; },
609
- get plan() { return plan; },
610
- get eventLogPath() { return eventLog.filePath; },
724
+ get state() {
725
+ return state;
726
+ },
727
+ get plan() {
728
+ return plan;
729
+ },
730
+ get eventLogPath() {
731
+ return eventLog.filePath;
732
+ },
611
733
  });
612
734
  }
@@ -128,7 +128,7 @@ function buildHeader(width, names, workers, pipeline, startedAt) {
128
128
  else if (status === "running" || status === "in_progress") counts.running++;
129
129
  }
130
130
  const elapsed = Math.max(0, Math.round((Date.now() - startedAt) / 1000));
131
- const line1 = color(` triflux ${VERSION} `, FG.black, BG.header)
131
+ const line1 = color(` triflux ${VERSION} `, FG.white, BG.header)
132
132
  + ` ${bold(`phase ${pipeline.phase || "exec"}`)}`
133
133
  + ` ${dim(`+${elapsed}s`)} ${names.length} workers`;
134
134
  const line2 = `${color(`ok ${counts.ok}`, MOCHA.ok)} ${color(`partial ${counts.partial}`, MOCHA.partial)} ${color(`failed ${counts.failed}`, MOCHA.fail)} ${color(`running ${counts.running}`, MOCHA.executing)}`;
@@ -210,6 +210,7 @@ export function createLiteDashboard(opts = {}) {
210
210
  let focusTab = "log";
211
211
  let helpVisible = false;
212
212
  let prevFrame = [];
213
+ let prevWidth = 0;
213
214
  let inputAttached = false;
214
215
  let rawModeEnabled = false;
215
216
 
@@ -264,7 +265,14 @@ export function createLiteDashboard(opts = {}) {
264
265
  return;
265
266
  }
266
267
  if (key === "\r" || key === "\n") {
267
- triggerOpenSelected();
268
+ if (typeof onOpenSelectedWorker !== "function") {
269
+ // 콜백 없으면 탭 순환 (기본 동작)
270
+ const tabs = ["log", "detail", "files"];
271
+ focusTab = tabs[(tabs.indexOf(focusTab) + 1) % tabs.length];
272
+ } else {
273
+ triggerOpenSelected();
274
+ }
275
+ render();
268
276
  return;
269
277
  }
270
278
  if (key === "\x1b[13;2u" || key === "\x1b[27;13;2~" || key === "\x1b\r" || key === "\x1b\n") {
@@ -339,6 +347,13 @@ export function createLiteDashboard(opts = {}) {
339
347
  if (isTTY) {
340
348
  const width = viewportColumns();
341
349
  const padded = rowsOut.map((line) => padRight(String(line ?? ""), width));
350
+ // Full redraw on first frame or terminal resize to avoid artifacts
351
+ if (prevFrame.length === 0 || width !== prevWidth) {
352
+ prevWidth = width;
353
+ write(cursorHome + padded.map((l) => l + clearToEnd).join("\n") + eraseBelow);
354
+ prevFrame = padded;
355
+ return;
356
+ }
342
357
  // Diff-based rendering: only rewrite lines that actually changed
343
358
  let buf = "";
344
359
  for (let i = 0; i < padded.length; i++) {
@@ -361,6 +376,7 @@ export function createLiteDashboard(opts = {}) {
361
376
  if (rawModeEnabled && typeof input?.setRawMode === "function") input.setRawMode(false);
362
377
  if (inputAttached && typeof input?.pause === "function") input.pause();
363
378
  if (isTTY) write(cursorShow + altScreenOff);
379
+ prevFrame = [];
364
380
  closed = true;
365
381
  }
366
382
 
@@ -2,6 +2,7 @@
2
2
  // GAP 분석 P2 #7: 심볼 통신 + 약어로 30-50% 토큰 절감
3
3
 
4
4
  /**
5
+ * @experimental 런타임 미연결 — 향후 통합 예정
5
6
  * @experimental
6
7
  * @type {Array<{ from: string[], to: string, type: 'symbol'|'abbrev' }>}
7
8
  */