@covibes/zeroshot 2.0.0 → 3.0.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.
@@ -487,6 +487,7 @@ class Orchestrator {
487
487
  * @param {Object} options - Start options
488
488
  * @param {boolean} options.isolation - Run in Docker container
489
489
  * @param {string} options.isolationImage - Docker image to use
490
+ * @param {boolean} options.worktree - Run in git worktree isolation (lightweight, no Docker)
490
491
  * @returns {Object} Cluster object
491
492
  */
492
493
  start(config, input = {}, options = {}) {
@@ -495,6 +496,7 @@ class Orchestrator {
495
496
  cwd: options.cwd || process.cwd(), // Target working directory for agents
496
497
  isolation: options.isolation || false,
497
498
  isolationImage: options.isolationImage,
499
+ worktree: options.worktree || false,
498
500
  autoPr: process.env.CREW_PR === '1',
499
501
  });
500
502
  }
@@ -512,14 +514,15 @@ class Orchestrator {
512
514
  const ledger = new Ledger(dbPath);
513
515
  const messageBus = new MessageBus(ledger);
514
516
 
515
- // Handle isolation mode (Docker container)
517
+ // Handle isolation mode (Docker container OR git worktree)
516
518
  let isolationManager = null;
517
519
  let containerId = null;
520
+ let worktreeInfo = null;
518
521
 
519
522
  if (options.isolation) {
520
523
  // Check Docker availability
521
524
  if (!IsolationManager.isDockerAvailable()) {
522
- throw new Error('Docker is not available. Install Docker to use --isolation mode.');
525
+ throw new Error('Docker is not available. Install Docker to use --docker mode.');
523
526
  }
524
527
 
525
528
  // Ensure image exists (auto-build if missing)
@@ -537,6 +540,16 @@ class Orchestrator {
537
540
  image,
538
541
  });
539
542
  this._log(`[Orchestrator] Container created: ${containerId} (workDir: ${workDir})`);
543
+ } else if (options.worktree) {
544
+ // Worktree isolation: lightweight git-based isolation (no Docker required)
545
+ const workDir = options.cwd || process.cwd();
546
+
547
+ isolationManager = new IsolationManager({});
548
+ worktreeInfo = isolationManager.createWorktreeIsolation(clusterId, workDir);
549
+
550
+ this._log(`[Orchestrator] Starting cluster in worktree isolation mode`);
551
+ this._log(`[Orchestrator] Worktree: ${worktreeInfo.path}`);
552
+ this._log(`[Orchestrator] Branch: ${worktreeInfo.branch}`);
540
553
  }
541
554
 
542
555
  // Build cluster object
@@ -571,6 +584,17 @@ class Orchestrator {
571
584
  workDir: options.cwd || process.cwd(), // Persisted for resume
572
585
  }
573
586
  : null,
587
+ // Worktree isolation state (lightweight alternative to Docker)
588
+ worktree: options.worktree
589
+ ? {
590
+ enabled: true,
591
+ path: worktreeInfo.path,
592
+ branch: worktreeInfo.branch,
593
+ repoRoot: worktreeInfo.repoRoot,
594
+ manager: isolationManager,
595
+ workDir: options.cwd || process.cwd(), // Persisted for resume
596
+ }
597
+ : null,
574
598
  };
575
599
 
576
600
  this.clusters.set(clusterId, cluster);
@@ -636,7 +660,8 @@ class Orchestrator {
636
660
  // Initialize agents with optional mock injection
637
661
  // Check agent type: regular agent or subcluster
638
662
  // CRITICAL: Inject cwd into each agent config for proper working directory
639
- const agentCwd = options.cwd || process.cwd();
663
+ // In worktree mode, agents run in the worktree path (not original cwd)
664
+ const agentCwd = cluster.worktree ? cluster.worktree.path : options.cwd || process.cwd();
640
665
  for (const agentConfig of config.agents) {
641
666
  // Inject cwd if not already set (config may override)
642
667
  if (!agentConfig.cwd) {
@@ -656,11 +681,10 @@ class Orchestrator {
656
681
  // TaskRunner DI - new pattern for mocking task execution
657
682
  // Creates a mockSpawnFn wrapper that delegates to the taskRunner
658
683
  if (this.taskRunner) {
659
- agentOptions.mockSpawnFn = (args, { context }) => {
660
- return this.taskRunner.run(context, {
661
- agentId: agentConfig.id,
662
- });
663
- };
684
+ // CRITICAL: agent is a closure variable capturing the AgentWrapper instance
685
+ // We cannot access agent._selectModel() here because agent doesn't exist yet
686
+ // Solution: Pass a factory function that will be called when agent is available
687
+ agentOptions.taskRunner = this.taskRunner;
664
688
  }
665
689
 
666
690
  // Pass isolation context if enabled
@@ -672,6 +696,16 @@ class Orchestrator {
672
696
  };
673
697
  }
674
698
 
699
+ // Pass worktree context if enabled (lightweight isolation without Docker)
700
+ if (cluster.worktree) {
701
+ agentOptions.worktree = {
702
+ enabled: true,
703
+ path: cluster.worktree.path,
704
+ branch: cluster.worktree.branch,
705
+ repoRoot: cluster.worktree.repoRoot,
706
+ };
707
+ }
708
+
675
709
  // Create agent or subcluster wrapper based on type
676
710
  let agent;
677
711
  if (agentConfig.type === 'subcluster') {
@@ -836,7 +870,7 @@ class Orchestrator {
836
870
  containerId,
837
871
  }).catch((err) => {
838
872
  console.error(`Failed to execute CLUSTER_OPERATIONS:`, err.message);
839
- // Publish failure for conductor to retry
873
+ // Publish failure message
840
874
  messageBus.publish({
841
875
  cluster_id: clusterId,
842
876
  topic: 'CLUSTER_OPERATIONS_FAILED',
@@ -849,6 +883,18 @@ class Orchestrator {
849
883
  },
850
884
  },
851
885
  });
886
+
887
+ // CRITICAL: Stop cluster on operation failure - cluster cannot continue
888
+ // without required agents (e.g., planner model mismatch)
889
+ this._log(`\n${'='.repeat(80)}`);
890
+ this._log(`❌ CLUSTER_OPERATIONS FAILED - STOPPING CLUSTER`);
891
+ this._log(`${'='.repeat(80)}`);
892
+ this._log(`Error: ${err.message}`);
893
+ this._log(`${'='.repeat(80)}\n`);
894
+
895
+ this.stop(clusterId).catch((stopErr) => {
896
+ console.error(`Failed to stop cluster after operation failure:`, stopErr.message);
897
+ });
852
898
  });
853
899
  }
854
900
  });
@@ -925,6 +971,14 @@ class Orchestrator {
925
971
  this._log(`[Orchestrator] Container stopped, workspace preserved`);
926
972
  }
927
973
 
974
+ // Worktree cleanup on stop: preserve for resume capability
975
+ // Branch stays, worktree stays - can resume work later
976
+ if (cluster.worktree?.manager) {
977
+ this._log(`[Orchestrator] Worktree preserved at ${cluster.worktree.path} for resume`);
978
+ this._log(`[Orchestrator] Branch: ${cluster.worktree.branch}`);
979
+ // Don't cleanup worktree - it will be reused on resume
980
+ }
981
+
928
982
  cluster.state = 'stopped';
929
983
  cluster.pid = null; // Clear PID - cluster is no longer running
930
984
  this._log(`Cluster ${clusterId} stopped`);
@@ -957,6 +1011,14 @@ class Orchestrator {
957
1011
  this._log(`[Orchestrator] Container and workspace removed`);
958
1012
  }
959
1013
 
1014
+ // Force remove worktree (full cleanup, no resume)
1015
+ // Note: Branch is preserved for potential PR creation / inspection
1016
+ if (cluster.worktree?.manager) {
1017
+ this._log(`[Orchestrator] Force removing worktree for ${clusterId}...`);
1018
+ cluster.worktree.manager.cleanupWorktreeIsolation(clusterId, { preserveBranch: true });
1019
+ this._log(`[Orchestrator] Worktree removed, branch ${cluster.worktree.branch} preserved`);
1020
+ }
1021
+
960
1022
  // Close message bus and ledger
961
1023
  cluster.messageBus.close();
962
1024
 
@@ -1113,6 +1175,20 @@ class Orchestrator {
1113
1175
  }
1114
1176
  }
1115
1177
 
1178
+ // Verify worktree still exists for worktree isolation mode
1179
+ if (cluster.worktree?.enabled) {
1180
+ const worktreePath = cluster.worktree.path;
1181
+ if (!fs.existsSync(worktreePath)) {
1182
+ throw new Error(
1183
+ `Cannot resume cluster ${clusterId}: worktree at ${worktreePath} no longer exists. ` +
1184
+ `Was the worktree manually removed? Use 'zeroshot run' to start fresh.`
1185
+ );
1186
+ }
1187
+
1188
+ this._log(`[Orchestrator] Worktree at ${worktreePath} exists, reusing`);
1189
+ this._log(`[Orchestrator] Branch: ${cluster.worktree.branch}`);
1190
+ }
1191
+
1116
1192
  // Restart all agents
1117
1193
  cluster.state = 'running';
1118
1194
  for (const agent of cluster.agents) {
@@ -1561,10 +1637,15 @@ Continue from where you left off. Review your previous output to understand what
1561
1637
 
1562
1638
  // Build agent options
1563
1639
  const agentOptions = {
1564
- testMode: false,
1640
+ testMode: !!this.taskRunner, // Enable testMode if taskRunner provided
1565
1641
  quiet: this.quiet,
1566
1642
  };
1567
1643
 
1644
+ // TaskRunner DI - propagate to dynamically spawned agents
1645
+ if (this.taskRunner) {
1646
+ agentOptions.taskRunner = this.taskRunner;
1647
+ }
1648
+
1568
1649
  // Pass isolation context if cluster is running in isolation mode
1569
1650
  if (cluster.isolation?.enabled && context.isolationManager) {
1570
1651
  agentOptions.isolation = {
@@ -1658,7 +1739,7 @@ Continue from where you left off. Review your previous output to understand what
1658
1739
  * @private
1659
1740
  */
1660
1741
  _opPublish(cluster, op, sender) {
1661
- const { topic, content } = op;
1742
+ const { topic, content, metadata } = op;
1662
1743
  if (!topic) {
1663
1744
  throw new Error('publish operation missing topic');
1664
1745
  }
@@ -1669,6 +1750,7 @@ Continue from where you left off. Review your previous output to understand what
1669
1750
  sender: op.sender || sender,
1670
1751
  receiver: op.receiver || 'broadcast',
1671
1752
  content: content || {},
1753
+ metadata: metadata || null,
1672
1754
  });
1673
1755
 
1674
1756
  this._log(` ✓ Published to topic: ${topic}`);
package/src/preflight.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * Validates:
5
5
  * - Claude CLI installed and authenticated
6
6
  * - gh CLI installed and authenticated (if using issue numbers)
7
- * - Docker available (if using --isolation)
7
+ * - Docker available (if using --docker)
8
8
  *
9
9
  * Provides CLEAR, ACTIONABLE error messages with recovery instructions.
10
10
  */
@@ -232,7 +232,8 @@ function checkDocker() {
232
232
  * Run all preflight checks
233
233
  * @param {Object} options - Preflight options
234
234
  * @param {boolean} options.requireGh - Whether gh CLI is required (true if using issue number)
235
- * @param {boolean} options.requireDocker - Whether Docker is required (true if using --isolation)
235
+ * @param {boolean} options.requireDocker - Whether Docker is required (true if using --docker)
236
+ * @param {boolean} options.requireGit - Whether git repo is required (true if using --worktree)
236
237
  * @param {boolean} options.quiet - Suppress success messages
237
238
  * @returns {ValidationResult}
238
239
  */
@@ -330,6 +331,30 @@ function runPreflight(options = {}) {
330
331
  }
331
332
  }
332
333
 
334
+ // 5. Check git repo (if required for worktree isolation)
335
+ if (options.requireGit) {
336
+ let isGitRepo = false;
337
+ try {
338
+ execSync('git rev-parse --git-dir', { stdio: 'pipe' });
339
+ isGitRepo = true;
340
+ } catch {
341
+ // Not a git repo
342
+ }
343
+ if (!isGitRepo) {
344
+ errors.push(
345
+ formatError(
346
+ 'Not in a git repository',
347
+ 'Worktree isolation requires a git repository',
348
+ [
349
+ 'Run from within a git repository',
350
+ 'Or use --docker instead of --worktree for non-git directories',
351
+ 'Initialize a repo with: git init',
352
+ ]
353
+ )
354
+ );
355
+ }
356
+ }
357
+
333
358
  return {
334
359
  valid: errors.length === 0,
335
360
  errors,
@@ -342,6 +367,7 @@ function runPreflight(options = {}) {
342
367
  * @param {Object} options - Preflight options
343
368
  * @param {boolean} options.requireGh - Whether gh CLI is required
344
369
  * @param {boolean} options.requireDocker - Whether Docker is required
370
+ * @param {boolean} options.requireGit - Whether git repo is required
345
371
  * @param {boolean} options.quiet - Suppress success messages
346
372
  */
347
373
  function requirePreflight(options = {}) {
@@ -18,6 +18,18 @@ const fs = require('fs');
18
18
 
19
19
  const PLATFORM = process.platform;
20
20
 
21
+ /**
22
+ * Escape a string for safe use in shell commands
23
+ * Prevents shell injection when passing dynamic values to execSync with shell: true
24
+ * @param {string} str - String to escape
25
+ * @returns {string} Shell-escaped string
26
+ */
27
+ function escapeShell(str) {
28
+ // Replace single quotes with escaped version and wrap in single quotes
29
+ // This is the safest approach for shell escaping
30
+ return `'${str.replace(/'/g, "'\\''")}'`;
31
+ }
32
+
21
33
  /**
22
34
  * @typedef {Object} ProcessMetrics
23
35
  * @property {number} pid - Process ID
@@ -46,7 +58,7 @@ function getChildPids(pid) {
46
58
  try {
47
59
  if (PLATFORM === 'darwin') {
48
60
  // macOS: Use pgrep with -P flag
49
- const output = execSync(`pgrep -P ${pid} 2>/dev/null`, {
61
+ const output = execSync(`pgrep -P ${escapeShell(String(pid))} 2>/dev/null`, {
50
62
  encoding: 'utf8',
51
63
  timeout: 2000,
52
64
  });
@@ -136,7 +148,7 @@ function getProcessMetricsLinux(pid) {
136
148
  function getProcessMetricsDarwin(pid) {
137
149
  try {
138
150
  // ps -p PID -o %cpu=,rss=,state=
139
- const output = execSync(`ps -p ${pid} -o %cpu=,rss=,state= 2>/dev/null`, {
151
+ const output = execSync(`ps -p ${escapeShell(String(pid))} -o %cpu=,rss=,state= 2>/dev/null`, {
140
152
  encoding: 'utf8',
141
153
  timeout: 2000,
142
154
  });
@@ -182,7 +194,7 @@ function getNetworkStateLinux(pid) {
182
194
  try {
183
195
  // Use ss -tip to get extended TCP info with bytes_sent/bytes_received
184
196
  // -t = TCP only, -i = show internal TCP info, -p = show process
185
- const output = execSync(`ss -tip 2>/dev/null | grep -A1 "pid=${pid}," || true`, {
197
+ const output = execSync(`ss -tip 2>/dev/null | grep -A1 "pid=${escapeShell(String(pid))}," || true`, {
186
198
  encoding: 'utf8',
187
199
  timeout: 3000,
188
200
  });
@@ -255,7 +267,7 @@ function getNetworkStateDarwin(pid) {
255
267
 
256
268
  try {
257
269
  // lsof -i -n -P for network connections
258
- const output = execSync(`lsof -i -n -P -a -p ${pid} 2>/dev/null || true`, {
270
+ const output = execSync(`lsof -i -n -P -a -p ${escapeShell(String(pid))} 2>/dev/null || true`, {
259
271
  encoding: 'utf8',
260
272
  timeout: 3000,
261
273
  });
@@ -69,17 +69,30 @@ function debounce(fn, ms) {
69
69
  class StatusFooter {
70
70
  /**
71
71
  * @param {Object} options
72
- * @param {number} [options.refreshInterval=1000] - Refresh interval in ms
72
+ * @param {number} [options.samplingInterval=500] - Background metrics sampling interval in ms
73
+ * @param {number} [options.refreshInterval=100] - Display refresh interval in ms (10 fps)
74
+ * @param {number} [options.interpolationSpeed=0.15] - Lerp factor (0-1) per refresh tick
73
75
  * @param {boolean} [options.enabled=true] - Whether footer is enabled
74
76
  * @param {number} [options.maxAgentRows=5] - Max agent rows to display
75
77
  */
76
78
  constructor(options = {}) {
77
- this.refreshInterval = options.refreshInterval || 1000;
79
+ // INTERPOLATION ARCHITECTURE:
80
+ // - Sample actual metrics every 500ms (background, non-blocking)
81
+ // - Display updates every 100ms (10 fps - appears continuous)
82
+ // - Values smoothly drift toward actual measurements via lerp
83
+ // - Result: Real-time seeming monitoring even with less frequent polling
84
+ this.samplingInterval = options.samplingInterval || 500; // Actual metrics poll (ms)
85
+ this.refreshInterval = options.refreshInterval || 100; // Display update rate (ms) - was 1000
86
+ this.interpolationSpeed = options.interpolationSpeed || 0.15; // Lerp factor per tick
78
87
  this.enabled = options.enabled !== false;
79
88
  this.maxAgentRows = options.maxAgentRows || 5;
80
89
  this.intervalId = null;
90
+ this.samplingIntervalId = null; // Background metrics sampler
81
91
  this.agents = new Map(); // agentId -> AgentState
82
- this.metricsCache = new Map(); // agentId -> ProcessMetrics
92
+ // Interpolated metrics: { current: {...}, target: {...}, lastSampleTime, exists }
93
+ // - current: Displayed value (interpolated toward target)
94
+ // - target: Actual measured value from last sample
95
+ this.interpolatedMetrics = new Map(); // agentId -> InterpolatedMetrics
83
96
  this.footerHeight = 3; // Minimum: header + 1 agent row + summary
84
97
  this.lastFooterHeight = 3;
85
98
  this.scrollRegionSet = false;
@@ -101,6 +114,9 @@ class StatusFooter {
101
114
  // All output must go through print() to coordinate with render cycles
102
115
  this.printQueue = [];
103
116
 
117
+ // Blink state for executing agents - toggles on each render for visual pulse
118
+ this.blinkState = false;
119
+
104
120
  // Debounced resize handler (100ms) - prevents rapid-fire redraws
105
121
  this._debouncedResize = debounce(() => this._handleResize(), 100);
106
122
  }
@@ -373,30 +389,111 @@ class StatusFooter {
373
389
  */
374
390
  removeAgent(agentId) {
375
391
  this.agents.delete(agentId);
376
- this.metricsCache.delete(agentId);
392
+ this.interpolatedMetrics.delete(agentId);
393
+ }
394
+
395
+ /**
396
+ * Background metrics sampling - polls actual CPU/RAM every samplingInterval ms
397
+ * Updates target values that display will interpolate toward
398
+ * @private
399
+ */
400
+ async _sampleMetrics() {
401
+ for (const [agentId, agent] of this.agents) {
402
+ if (!agent.pid) continue;
403
+
404
+ try {
405
+ // Get actual metrics with 200ms sample window (for CPU calculation)
406
+ const raw = await getProcessMetrics(agent.pid, { samplePeriodMs: 200 });
407
+ const existing = this.interpolatedMetrics.get(agentId);
408
+
409
+ this.interpolatedMetrics.set(agentId, {
410
+ // Keep interpolating from current position (smooth transition)
411
+ current: existing?.current || {
412
+ cpuPercent: raw.cpuPercent,
413
+ memoryMB: raw.memoryMB,
414
+ },
415
+ // New target to approach
416
+ target: {
417
+ cpuPercent: raw.cpuPercent,
418
+ memoryMB: raw.memoryMB,
419
+ },
420
+ // Network is cumulative - just use latest value (no interpolation)
421
+ network: raw.network,
422
+ lastSampleTime: Date.now(),
423
+ exists: raw.exists,
424
+ });
425
+ } catch {
426
+ // Process may have exited
427
+ this.interpolatedMetrics.delete(agentId);
428
+ }
429
+ }
430
+ }
431
+
432
+ /**
433
+ * Interpolate current values toward target values
434
+ * Called each render tick (100ms) for smooth visual updates
435
+ * @private
436
+ */
437
+ _interpolateMetrics() {
438
+ for (const [, data] of this.interpolatedMetrics) {
439
+ if (!data.target || !data.current) continue;
440
+
441
+ // Lerp CPU and RAM toward target (they fluctuate)
442
+ data.current.cpuPercent = this._lerp(
443
+ data.current.cpuPercent,
444
+ data.target.cpuPercent
445
+ );
446
+ data.current.memoryMB = this._lerp(
447
+ data.current.memoryMB,
448
+ data.target.memoryMB
449
+ );
450
+ // Network is cumulative counter - no interpolation, just use latest
451
+ }
452
+ }
453
+
454
+ /**
455
+ * Linear interpolation helper
456
+ * Moves current 15% of the way toward target each tick
457
+ * At 100ms ticks: reaches ~87% of target after 1 second
458
+ * @param {number} current - Current displayed value
459
+ * @param {number} target - Target measured value
460
+ * @returns {number} New interpolated value
461
+ * @private
462
+ */
463
+ _lerp(current, target) {
464
+ if (current === null || current === undefined) return target;
465
+ if (target === null || target === undefined) return current;
466
+ const diff = target - current;
467
+ return current + diff * this.interpolationSpeed;
377
468
  }
378
469
 
379
470
  /**
380
471
  * Get status icon for agent state
472
+ * Uses blinking dot for active states (executing, evaluating, building_context)
381
473
  * @param {string} state
382
474
  * @returns {string}
383
475
  */
384
476
  getAgentIcon(state) {
477
+ // Blinking indicator for active states - alternates between bright and dim
478
+ const blinkOn = `${COLORS.green}●${COLORS.reset}`;
479
+ const blinkOff = `${COLORS.dim}○${COLORS.reset}`;
480
+ const blinkIndicator = this.blinkState ? blinkOn : blinkOff;
481
+
385
482
  switch (state) {
386
483
  case 'idle':
387
- return '⏳'; // Waiting for trigger
484
+ return `${COLORS.gray}○${COLORS.reset}`; // Waiting for trigger
388
485
  case 'evaluating':
389
- return '🔍'; // Evaluating triggers
486
+ return blinkIndicator; // Evaluating triggers (blinking)
390
487
  case 'building_context':
391
- return '📝'; // Building context
488
+ return blinkIndicator; // Building context (blinking)
392
489
  case 'executing':
393
- return '🔄'; // Running task
490
+ return blinkIndicator; // Running task (blinking)
394
491
  case 'stopped':
395
- return '⏹️'; // Stopped
492
+ return `${COLORS.gray}■${COLORS.reset}`; // Stopped
396
493
  case 'error':
397
- return '❌'; // Error
494
+ return `${COLORS.red}●${COLORS.reset}`; // Error
398
495
  default:
399
- return '⚪';
496
+ return `${COLORS.gray}○${COLORS.reset}`;
400
497
  }
401
498
  }
402
499
 
@@ -441,7 +538,7 @@ class StatusFooter {
441
538
  * Render the footer
442
539
  * ROBUST: Uses render lock to prevent concurrent renders from corrupting state
443
540
  */
444
- async render() {
541
+ render() {
445
542
  if (!this.enabled || !this.isTTY()) return;
446
543
 
447
544
  // Graceful degradation: don't render if hidden
@@ -453,6 +550,9 @@ class StatusFooter {
453
550
  }
454
551
  this.isRendering = true;
455
552
 
553
+ // Toggle blink state for visual pulse effect on executing agents
554
+ this.blinkState = !this.blinkState;
555
+
456
556
  try {
457
557
  const { rows, cols } = this.getTerminalSize();
458
558
 
@@ -463,18 +563,9 @@ class StatusFooter {
463
563
  return;
464
564
  }
465
565
 
466
- // Collect metrics for all agents with PIDs
467
- for (const [agentId, agent] of this.agents) {
468
- if (agent.pid) {
469
- try {
470
- const metrics = await getProcessMetrics(agent.pid, { samplePeriodMs: 500 });
471
- this.metricsCache.set(agentId, metrics);
472
- } catch {
473
- // Process may have exited
474
- this.metricsCache.delete(agentId);
475
- }
476
- }
477
- }
566
+ // Interpolate metrics toward targets (called every 100ms for smooth visuals)
567
+ // Background sampler updates targets every 500ms
568
+ this._interpolateMetrics();
478
569
 
479
570
  // Get executing agents for display
480
571
  const executingAgents = Array.from(this.agents.entries())
@@ -592,24 +683,25 @@ class StatusFooter {
592
683
  const rows = [];
593
684
  for (const [agentId, agent] of executingAgents) {
594
685
  const icon = this.getAgentIcon(agent.state);
595
- const metrics = this.metricsCache.get(agentId);
686
+ const data = this.interpolatedMetrics.get(agentId);
687
+ const metrics = data?.current;
596
688
 
597
689
  // Build columns with fixed widths for alignment
598
690
  const iconCol = icon;
599
691
  const nameCol = agentId.padEnd(14).slice(0, 14); // Max 14 chars for name
600
692
 
601
693
  let metricsStr = '';
602
- if (metrics && metrics.exists) {
694
+ if (metrics && data.exists) {
603
695
  const cpuColor = metrics.cpuPercent > 50 ? COLORS.yellow : COLORS.green;
604
- const cpuVal = `${metrics.cpuPercent}%`.padStart(4);
605
- const ramVal = `${metrics.memoryMB}MB`.padStart(6);
696
+ const cpuVal = `${metrics.cpuPercent.toFixed(1)}%`.padStart(6);
697
+ const ramVal = `${metrics.memoryMB.toFixed(1)}MB`.padStart(8);
606
698
 
607
699
  metricsStr += `${COLORS.dim}CPU:${COLORS.reset}${cpuColor}${cpuVal}${COLORS.reset}`;
608
700
  metricsStr += ` ${COLORS.dim}RAM:${COLORS.reset}${COLORS.gray}${ramVal}${COLORS.reset}`;
609
701
 
610
- // Network bytes
611
- const net = metrics.network;
612
- if (net.bytesSent > 0 || net.bytesReceived > 0) {
702
+ // Network bytes (cumulative - no interpolation)
703
+ const net = data.network;
704
+ if (net && (net.bytesSent > 0 || net.bytesReceived > 0)) {
613
705
  const sent = this.formatBytes(net.bytesSent).padStart(7);
614
706
  const recv = this.formatBytes(net.bytesReceived).padStart(7);
615
707
  metricsStr += ` ${COLORS.dim}NET:${COLORS.reset}${COLORS.cyan}↑${sent} ↓${recv}${COLORS.reset}`;
@@ -684,25 +776,25 @@ class StatusFooter {
684
776
  }
685
777
  }
686
778
 
687
- // Aggregate metrics
779
+ // Aggregate metrics (using interpolated values for smooth display)
688
780
  let totalCpu = 0;
689
781
  let totalMem = 0;
690
782
  let totalBytesSent = 0;
691
783
  let totalBytesReceived = 0;
692
- for (const metrics of this.metricsCache.values()) {
693
- if (metrics.exists) {
694
- totalCpu += metrics.cpuPercent;
695
- totalMem += metrics.memoryMB;
696
- totalBytesSent += metrics.network.bytesSent || 0;
697
- totalBytesReceived += metrics.network.bytesReceived || 0;
784
+ for (const data of this.interpolatedMetrics.values()) {
785
+ if (data.exists && data.current) {
786
+ totalCpu += data.current.cpuPercent;
787
+ totalMem += data.current.memoryMB;
788
+ totalBytesSent += data.network?.bytesSent || 0;
789
+ totalBytesReceived += data.network?.bytesReceived || 0;
698
790
  }
699
791
  }
700
792
 
701
793
  if (totalCpu > 0 || totalMem > 0) {
702
794
  parts.push(` ${COLORS.gray}│${COLORS.reset}`);
703
795
  let aggregateStr = ` ${COLORS.cyan}Σ${COLORS.reset} `;
704
- aggregateStr += `${COLORS.dim}CPU:${COLORS.reset}${totalCpu.toFixed(0)}%`;
705
- aggregateStr += ` ${COLORS.dim}RAM:${COLORS.reset}${totalMem.toFixed(0)}MB`;
796
+ aggregateStr += `${COLORS.dim}CPU:${COLORS.reset}${totalCpu.toFixed(1)}%`;
797
+ aggregateStr += ` ${COLORS.dim}RAM:${COLORS.reset}${totalMem.toFixed(1)}MB`;
706
798
  if (totalBytesSent > 0 || totalBytesReceived > 0) {
707
799
  aggregateStr += ` ${COLORS.dim}NET:${COLORS.reset}${COLORS.cyan}↑${this.formatBytes(totalBytesSent)} ↓${this.formatBytes(totalBytesReceived)}${COLORS.reset}`;
708
800
  }
@@ -750,8 +842,19 @@ class StatusFooter {
750
842
  // Handle terminal resize with debounced handler
751
843
  process.stdout.on('resize', this._debouncedResize);
752
844
 
753
- // Start refresh interval
754
- // Guard: Skip if previous render still running (prevents overlapping renders)
845
+ // Start background metrics sampling (every 500ms)
846
+ // Non-blocking - updates target values that display will interpolate toward
847
+ this.samplingIntervalId = setInterval(() => {
848
+ this._sampleMetrics().catch(() => {
849
+ // Ignore sampling errors - display will show stale data
850
+ });
851
+ }, this.samplingInterval);
852
+
853
+ // Initial metrics sample (async, don't block startup)
854
+ this._sampleMetrics().catch(() => {});
855
+
856
+ // Start display refresh interval (every 100ms - 10 fps)
857
+ // Interpolates current values toward targets for smooth visual updates
755
858
  this.intervalId = setInterval(() => {
756
859
  if (this.isRendering) return;
757
860
  this.render();
@@ -770,6 +873,12 @@ class StatusFooter {
770
873
  this.intervalId = null;
771
874
  }
772
875
 
876
+ // Stop background metrics sampling
877
+ if (this.samplingIntervalId) {
878
+ clearInterval(this.samplingIntervalId);
879
+ this.samplingIntervalId = null;
880
+ }
881
+
773
882
  // Remove resize listener
774
883
  process.stdout.removeListener('resize', this._debouncedResize);
775
884