@covibes/zeroshot 2.1.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.
- package/CHANGELOG.md +75 -0
- package/README.md +19 -8
- package/cli/index.js +146 -111
- package/cli/lib/first-run.js +11 -11
- package/cli/lib/update-checker.js +2 -1
- package/cluster-templates/base-templates/debug-workflow.json +75 -6
- package/docker/zeroshot-cluster/Dockerfile +8 -1
- package/docker/zeroshot-cluster/pre-baked-deps.json +28 -0
- package/lib/settings.js +46 -4
- package/package.json +1 -1
- package/src/agent/agent-config.js +38 -3
- package/src/agent/agent-task-executor.js +229 -85
- package/src/agent-wrapper.js +49 -13
- package/src/config-validator.js +198 -0
- package/src/copy-worker.js +43 -0
- package/src/isolation-manager.js +328 -108
- package/src/orchestrator.js +91 -10
- package/src/preflight.js +28 -2
- package/src/process-metrics.js +16 -4
- package/src/status-footer.js +151 -42
package/src/orchestrator.js
CHANGED
|
@@ -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 --
|
|
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
|
-
|
|
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
|
-
|
|
660
|
-
|
|
661
|
-
|
|
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
|
|
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:
|
|
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 = {
|
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 --
|
|
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 --
|
|
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 = {}) {
|
package/src/process-metrics.js
CHANGED
|
@@ -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
|
});
|
package/src/status-footer.js
CHANGED
|
@@ -69,17 +69,30 @@ function debounce(fn, ms) {
|
|
|
69
69
|
class StatusFooter {
|
|
70
70
|
/**
|
|
71
71
|
* @param {Object} options
|
|
72
|
-
* @param {number} [options.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
484
|
+
return `${COLORS.gray}○${COLORS.reset}`; // Waiting for trigger
|
|
388
485
|
case 'evaluating':
|
|
389
|
-
return
|
|
486
|
+
return blinkIndicator; // Evaluating triggers (blinking)
|
|
390
487
|
case 'building_context':
|
|
391
|
-
return
|
|
488
|
+
return blinkIndicator; // Building context (blinking)
|
|
392
489
|
case 'executing':
|
|
393
|
-
return
|
|
490
|
+
return blinkIndicator; // Running task (blinking)
|
|
394
491
|
case 'stopped':
|
|
395
|
-
return
|
|
492
|
+
return `${COLORS.gray}■${COLORS.reset}`; // Stopped
|
|
396
493
|
case 'error':
|
|
397
|
-
return
|
|
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
|
-
|
|
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
|
-
//
|
|
467
|
-
|
|
468
|
-
|
|
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
|
|
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 &&
|
|
694
|
+
if (metrics && data.exists) {
|
|
603
695
|
const cpuColor = metrics.cpuPercent > 50 ? COLORS.yellow : COLORS.green;
|
|
604
|
-
const cpuVal = `${metrics.cpuPercent}%`.padStart(
|
|
605
|
-
const ramVal = `${metrics.memoryMB}MB`.padStart(
|
|
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 =
|
|
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
|
|
693
|
-
if (
|
|
694
|
-
totalCpu +=
|
|
695
|
-
totalMem +=
|
|
696
|
-
totalBytesSent +=
|
|
697
|
-
totalBytesReceived +=
|
|
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(
|
|
705
|
-
aggregateStr += ` ${COLORS.dim}RAM:${COLORS.reset}${totalMem.toFixed(
|
|
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
|
|
754
|
-
//
|
|
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
|
|