@covibes/zeroshot 5.3.0 → 5.4.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 (82) hide show
  1. package/README.md +94 -14
  2. package/cli/commands/providers.js +8 -9
  3. package/cli/index.js +3032 -2409
  4. package/cli/message-formatters-normal.js +28 -6
  5. package/cluster-templates/base-templates/debug-workflow.json +1 -1
  6. package/cluster-templates/base-templates/full-workflow.json +72 -188
  7. package/cluster-templates/base-templates/worker-validator.json +59 -3
  8. package/cluster-templates/conductor-bootstrap.json +4 -4
  9. package/lib/docker-config.js +8 -0
  10. package/lib/git-remote-utils.js +165 -0
  11. package/lib/id-detector.js +10 -7
  12. package/lib/provider-defaults.js +62 -0
  13. package/lib/provider-names.js +2 -1
  14. package/lib/settings/claude-auth.js +78 -0
  15. package/lib/settings.js +161 -63
  16. package/package.json +7 -2
  17. package/scripts/setup-merge-queue.sh +170 -0
  18. package/src/agent/agent-config.js +135 -82
  19. package/src/agent/agent-context-builder.js +297 -188
  20. package/src/agent/agent-hook-executor.js +310 -113
  21. package/src/agent/agent-lifecycle.js +385 -325
  22. package/src/agent/agent-stuck-detector.js +7 -7
  23. package/src/agent/agent-task-executor.js +824 -565
  24. package/src/agent/output-extraction.js +41 -24
  25. package/src/agent/output-reformatter.js +1 -1
  26. package/src/agent/schema-utils.js +108 -73
  27. package/src/agent-wrapper.js +10 -1
  28. package/src/agents/git-pusher-template.js +285 -0
  29. package/src/claude-task-runner.js +85 -34
  30. package/src/config-validator.js +922 -657
  31. package/src/input-helpers.js +65 -0
  32. package/src/isolation-manager.js +289 -199
  33. package/src/issue-providers/README.md +305 -0
  34. package/src/issue-providers/azure-devops-provider.js +273 -0
  35. package/src/issue-providers/base-provider.js +232 -0
  36. package/src/issue-providers/github-provider.js +179 -0
  37. package/src/issue-providers/gitlab-provider.js +241 -0
  38. package/src/issue-providers/index.js +196 -0
  39. package/src/issue-providers/jira-provider.js +239 -0
  40. package/src/ledger.js +22 -5
  41. package/src/lib/safe-exec.js +88 -0
  42. package/src/orchestrator.js +1107 -811
  43. package/src/preflight.js +313 -159
  44. package/src/process-metrics.js +98 -56
  45. package/src/providers/anthropic/cli-builder.js +53 -25
  46. package/src/providers/anthropic/index.js +72 -2
  47. package/src/providers/anthropic/output-parser.js +32 -14
  48. package/src/providers/base-provider.js +70 -0
  49. package/src/providers/capabilities.js +9 -0
  50. package/src/providers/google/output-parser.js +47 -38
  51. package/src/providers/index.js +19 -3
  52. package/src/providers/openai/cli-builder.js +14 -3
  53. package/src/providers/openai/index.js +1 -0
  54. package/src/providers/openai/output-parser.js +44 -30
  55. package/src/providers/opencode/cli-builder.js +42 -0
  56. package/src/providers/opencode/index.js +103 -0
  57. package/src/providers/opencode/models.js +55 -0
  58. package/src/providers/opencode/output-parser.js +122 -0
  59. package/src/schemas/sub-cluster.js +68 -39
  60. package/src/status-footer.js +94 -48
  61. package/src/sub-cluster-wrapper.js +76 -35
  62. package/src/template-resolver.js +12 -9
  63. package/src/tui/data-poller.js +123 -99
  64. package/src/tui/formatters.js +4 -3
  65. package/src/tui/keybindings.js +259 -318
  66. package/src/tui/renderer.js +11 -21
  67. package/task-lib/attachable-watcher.js +118 -81
  68. package/task-lib/claude-recovery.js +61 -24
  69. package/task-lib/commands/episodes.js +105 -0
  70. package/task-lib/commands/list.js +2 -2
  71. package/task-lib/commands/logs.js +231 -189
  72. package/task-lib/commands/schedules.js +111 -61
  73. package/task-lib/config.js +0 -2
  74. package/task-lib/runner.js +84 -27
  75. package/task-lib/scheduler.js +1 -1
  76. package/task-lib/store.js +467 -168
  77. package/task-lib/tui/formatters.js +94 -45
  78. package/task-lib/tui/renderer.js +13 -3
  79. package/task-lib/tui.js +73 -32
  80. package/task-lib/watcher.js +128 -90
  81. package/src/agents/git-pusher-agent.json +0 -20
  82. package/src/github.js +0 -139
@@ -58,7 +58,7 @@ const AGENT_STATE = Object.freeze({
58
58
  // Agent states that indicate "active" work (from agent-lifecycle.js)
59
59
  // These should show in the footer with metrics
60
60
  const ACTIVE_STATES = new Set([
61
- AGENT_STATE.EXECUTING_TASK, // Actually running claude CLI
61
+ AGENT_STATE.EXECUTING_TASK, // Actually running claude CLI
62
62
  AGENT_STATE.EVALUATING_LOGIC, // Evaluating trigger conditions
63
63
  AGENT_STATE.BUILDING_CONTEXT, // Building context for task
64
64
  ]);
@@ -81,7 +81,8 @@ function debounce(fn, ms) {
81
81
  * @typedef {Object} AgentState
82
82
  * @property {string} id - Agent ID
83
83
  * @property {string} state - Agent state (idle, executing, etc.)
84
- * @property {number|null} pid - Process ID if running
84
+ * @property {number|null} processPid - Process ID if running (preferred)
85
+ * @property {number|null} pid - Legacy alias for processPid
85
86
  * @property {number} iteration - Current iteration
86
87
  */
87
88
 
@@ -175,7 +176,7 @@ class StatusFooter {
175
176
  if (this.printQueue.length === 0) return;
176
177
 
177
178
  // Write all queued output
178
- const output = this.printQueue.map(text => text + '\n').join('');
179
+ const output = this.printQueue.map((text) => text + '\n').join('');
179
180
  this.printQueue = [];
180
181
  process.stdout.write(output);
181
182
  }
@@ -396,8 +397,11 @@ class StatusFooter {
396
397
  * @param {AgentState} agentState
397
398
  */
398
399
  updateAgent(agentState) {
400
+ const processPid = agentState.processPid ?? agentState.pid ?? null;
399
401
  this.agents.set(agentState.id, {
400
402
  ...agentState,
403
+ processPid,
404
+ pid: processPid,
401
405
  lastUpdate: Date.now(),
402
406
  });
403
407
  }
@@ -418,11 +422,12 @@ class StatusFooter {
418
422
  */
419
423
  async _sampleMetrics() {
420
424
  for (const [agentId, agent] of this.agents) {
421
- if (!agent.pid) continue;
425
+ const processPid = agent.processPid ?? agent.pid;
426
+ if (!processPid) continue;
422
427
 
423
428
  try {
424
429
  // Get actual metrics with 200ms sample window (for CPU calculation)
425
- const raw = await getProcessMetrics(agent.pid, { samplePeriodMs: 200 });
430
+ const raw = await getProcessMetrics(processPid, { samplePeriodMs: 200 });
426
431
  const existing = this.interpolatedMetrics.get(agentId);
427
432
 
428
433
  this.interpolatedMetrics.set(agentId, {
@@ -458,14 +463,8 @@ class StatusFooter {
458
463
  if (!data.target || !data.current) continue;
459
464
 
460
465
  // Lerp CPU and RAM toward target (they fluctuate)
461
- data.current.cpuPercent = this._lerp(
462
- data.current.cpuPercent,
463
- data.target.cpuPercent
464
- );
465
- data.current.memoryMB = this._lerp(
466
- data.current.memoryMB,
467
- data.target.memoryMB
468
- );
466
+ data.current.cpuPercent = this._lerp(data.current.cpuPercent, data.target.cpuPercent);
467
+ data.current.memoryMB = this._lerp(data.current.memoryMB, data.target.memoryMB);
469
468
  // Network is cumulative counter - no interpolation, just use latest
470
469
  }
471
470
  }
@@ -767,60 +766,107 @@ class StatusFooter {
767
766
  // Border with corner
768
767
  parts.push(`${COLORS.gray}└─${COLORS.reset}`);
769
768
 
770
- // Cluster state
769
+ this.appendClusterStateSummary(parts);
770
+ this.appendDurationSummary(parts);
771
+ this.appendAgentCountSummary(parts);
772
+ this.appendTokenCostSummary(parts);
773
+ this.appendAggregateMetricsSummary(parts);
774
+
775
+ // Pad and close with bottom corner
776
+ return this.padSummaryLine(parts, width);
777
+ }
778
+
779
+ appendClusterStateSummary(parts) {
771
780
  const stateColor = this.clusterState === 'running' ? COLORS.green : COLORS.yellow;
772
781
  parts.push(` ${stateColor}${this.clusterState}${COLORS.reset}`);
782
+ }
773
783
 
774
- // Duration
784
+ appendDurationSummary(parts) {
775
785
  const duration = this.formatDuration(Date.now() - this.startTime);
776
786
  parts.push(` ${COLORS.gray}│${COLORS.reset} ${COLORS.dim}${duration}${COLORS.reset}`);
787
+ }
777
788
 
778
- // Agent counts
779
- const executing = Array.from(this.agents.values()).filter((a) => ACTIVE_STATES.has(a.state)).length;
780
- const total = this.agents.size;
781
- parts.push(` ${COLORS.gray}│${COLORS.reset} ${COLORS.green}${executing}/${total}${COLORS.reset} active`);
789
+ appendAgentCountSummary(parts) {
790
+ const { executing, total } = this.getActiveAgentCounts();
791
+ parts.push(
792
+ ` ${COLORS.gray}│${COLORS.reset} ${COLORS.green}${executing}/${total}${COLORS.reset} active`
793
+ );
794
+ }
782
795
 
783
- // Token cost (from message bus)
784
- if (this.messageBus && this.clusterId) {
785
- try {
786
- const tokensByRole = this.messageBus.getTokensByRole(this.clusterId);
787
- const totalCost = tokensByRole?._total?.totalCostUsd || 0;
788
- if (totalCost > 0) {
789
- // Format: $0.05 or $1.23 or $12.34
790
- const costStr = totalCost < 0.01 ? '<$0.01' : `$${totalCost.toFixed(2)}`;
791
- parts.push(` ${COLORS.gray}│${COLORS.reset} ${COLORS.yellow}${costStr}${COLORS.reset}`);
792
- }
793
- } catch {
794
- // Ignore errors - token tracking is optional
796
+ getActiveAgentCounts() {
797
+ const executing = Array.from(this.agents.values()).filter((a) =>
798
+ ACTIVE_STATES.has(a.state)
799
+ ).length;
800
+ return { executing, total: this.agents.size };
801
+ }
802
+
803
+ appendTokenCostSummary(parts) {
804
+ const costStr = this.getTokenCostSummary();
805
+ if (!costStr) {
806
+ return;
807
+ }
808
+ parts.push(` ${COLORS.gray}│${COLORS.reset} ${COLORS.yellow}${costStr}${COLORS.reset}`);
809
+ }
810
+
811
+ getTokenCostSummary() {
812
+ if (!this.messageBus || !this.clusterId) {
813
+ return null;
814
+ }
815
+
816
+ try {
817
+ const tokensByRole = this.messageBus.getTokensByRole(this.clusterId);
818
+ const totalCost = tokensByRole?._total?.totalCostUsd || 0;
819
+ if (totalCost <= 0) {
820
+ return null;
795
821
  }
822
+
823
+ // Format: $0.05 or $1.23 or $12.34
824
+ return totalCost < 0.01 ? '<$0.01' : `$${totalCost.toFixed(2)}`;
825
+ } catch {
826
+ // Ignore errors - token tracking is optional
827
+ return null;
796
828
  }
829
+ }
797
830
 
798
- // Aggregate metrics (using interpolated values for smooth display)
831
+ appendAggregateMetricsSummary(parts) {
832
+ const { totalCpu, totalMem, totalBytesSent, totalBytesReceived } = this.getAggregateMetrics();
833
+
834
+ if (totalCpu <= 0 && totalMem <= 0) {
835
+ return;
836
+ }
837
+
838
+ parts.push(` ${COLORS.gray}│${COLORS.reset}`);
839
+ let aggregateStr = ` ${COLORS.cyan}Σ${COLORS.reset} `;
840
+ aggregateStr += `${COLORS.dim}CPU:${COLORS.reset}${totalCpu.toFixed(1)}%`;
841
+ aggregateStr += ` ${COLORS.dim}RAM:${COLORS.reset}${totalMem.toFixed(1)}MB`;
842
+ if (totalBytesSent > 0 || totalBytesReceived > 0) {
843
+ aggregateStr += ` ${COLORS.dim}NET:${COLORS.reset}${COLORS.cyan}↑${this.formatBytes(totalBytesSent)} ↓${this.formatBytes(totalBytesReceived)}${COLORS.reset}`;
844
+ }
845
+ parts.push(aggregateStr);
846
+ }
847
+
848
+ getAggregateMetrics() {
799
849
  let totalCpu = 0;
800
850
  let totalMem = 0;
801
851
  let totalBytesSent = 0;
802
852
  let totalBytesReceived = 0;
853
+
854
+ // Aggregate metrics (using interpolated values for smooth display)
803
855
  for (const data of this.interpolatedMetrics.values()) {
804
- if (data.exists && data.current) {
805
- totalCpu += data.current.cpuPercent;
806
- totalMem += data.current.memoryMB;
807
- totalBytesSent += data.network?.bytesSent || 0;
808
- totalBytesReceived += data.network?.bytesReceived || 0;
856
+ if (!data.exists || !data.current) {
857
+ continue;
809
858
  }
810
- }
811
859
 
812
- if (totalCpu > 0 || totalMem > 0) {
813
- parts.push(` ${COLORS.gray}│${COLORS.reset}`);
814
- let aggregateStr = ` ${COLORS.cyan}Σ${COLORS.reset} `;
815
- aggregateStr += `${COLORS.dim}CPU:${COLORS.reset}${totalCpu.toFixed(1)}%`;
816
- aggregateStr += ` ${COLORS.dim}RAM:${COLORS.reset}${totalMem.toFixed(1)}MB`;
817
- if (totalBytesSent > 0 || totalBytesReceived > 0) {
818
- aggregateStr += ` ${COLORS.dim}NET:${COLORS.reset}${COLORS.cyan}↑${this.formatBytes(totalBytesSent)} ↓${this.formatBytes(totalBytesReceived)}${COLORS.reset}`;
819
- }
820
- parts.push(aggregateStr);
860
+ totalCpu += data.current.cpuPercent;
861
+ totalMem += data.current.memoryMB;
862
+ totalBytesSent += data.network?.bytesSent || 0;
863
+ totalBytesReceived += data.network?.bytesReceived || 0;
821
864
  }
822
865
 
823
- // Pad and close with bottom corner
866
+ return { totalCpu, totalMem, totalBytesSent, totalBytesReceived };
867
+ }
868
+
869
+ padSummaryLine(parts, width) {
824
870
  const content = parts.join('');
825
871
  const contentLen = this.stripAnsi(content).length;
826
872
  const padding = Math.max(0, width - contentLen - 1);
@@ -286,47 +286,88 @@ class SubClusterWrapper {
286
286
  _buildChildContext(triggeringMessage) {
287
287
  const parentTopics = this.config.contextStrategy?.parentTopics || [];
288
288
 
289
- let context = `# Child Cluster Context\n\n`;
290
- context += `Parent Cluster: ${this.parentCluster.id}\n`;
291
- context += `SubCluster ID: ${this.id}\n`;
292
- context += `Iteration: ${this.iteration}\n\n`;
293
-
294
- // Add messages from specified parent topics
295
- if (parentTopics.length > 0) {
296
- context += `## Parent Cluster Messages\n\n`;
297
-
298
- for (const topic of parentTopics) {
299
- const messages = this.messageBus.query({
300
- cluster_id: this.parentCluster.id,
301
- topic,
302
- limit: 10,
303
- });
289
+ const lines = [
290
+ '# Child Cluster Context',
291
+ '',
292
+ `Parent Cluster: ${this.parentCluster.id}`,
293
+ `SubCluster ID: ${this.id}`,
294
+ `Iteration: ${this.iteration}`,
295
+ '',
296
+ ];
297
+
298
+ this._appendParentTopicContext(lines, parentTopics);
299
+ this._appendTriggeringMessageContext(lines, triggeringMessage);
300
+
301
+ return lines.join('\n');
302
+ }
304
303
 
305
- if (messages.length > 0) {
306
- context += `### Topic: ${topic}\n\n`;
307
- for (const msg of messages) {
308
- context += `[${new Date(msg.timestamp).toISOString()}] ${msg.sender}:\n`;
309
- if (msg.content?.text) {
310
- context += `${msg.content.text}\n`;
311
- }
312
- if (msg.content?.data) {
313
- context += `Data: ${JSON.stringify(msg.content.data, null, 2)}\n`;
314
- }
315
- context += '\n';
316
- }
317
- }
304
+ _appendParentTopicContext(lines, parentTopics) {
305
+ if (parentTopics.length === 0) {
306
+ return;
307
+ }
308
+
309
+ lines.push('## Parent Cluster Messages', '');
310
+
311
+ for (const topic of parentTopics) {
312
+ const topicLines = this._buildTopicContextLines(topic);
313
+ if (topicLines.length === 0) {
314
+ continue;
318
315
  }
316
+
317
+ lines.push(...topicLines);
318
+ }
319
+ }
320
+
321
+ _buildTopicContextLines(topic) {
322
+ const messages = this.messageBus.query({
323
+ cluster_id: this.parentCluster.id,
324
+ topic,
325
+ limit: 10,
326
+ });
327
+
328
+ if (messages.length === 0) {
329
+ return [];
319
330
  }
320
331
 
321
- // Add triggering message
322
- context += `\n## Triggering Message\n\n`;
323
- context += `Topic: ${triggeringMessage.topic}\n`;
324
- context += `Sender: ${triggeringMessage.sender}\n`;
325
- if (triggeringMessage.content?.text) {
326
- context += `\n${triggeringMessage.content.text}\n`;
332
+ const lines = [`### Topic: ${topic}`, ''];
333
+
334
+ for (const message of messages) {
335
+ lines.push(...this._buildMessageContextLines(message));
336
+ lines.push('');
327
337
  }
328
338
 
329
- return context;
339
+ return lines;
340
+ }
341
+
342
+ _buildMessageContextLines(message) {
343
+ const lines = [`[${new Date(message.timestamp).toISOString()}] ${message.sender}:`];
344
+ const text = message.content?.text;
345
+ const data = message.content?.data;
346
+
347
+ if (text) {
348
+ lines.push(text);
349
+ }
350
+
351
+ if (data) {
352
+ lines.push(`Data: ${JSON.stringify(data, null, 2)}`);
353
+ }
354
+
355
+ return lines;
356
+ }
357
+
358
+ _appendTriggeringMessageContext(lines, triggeringMessage) {
359
+ lines.push(
360
+ '',
361
+ '## Triggering Message',
362
+ '',
363
+ `Topic: ${triggeringMessage.topic}`,
364
+ `Sender: ${triggeringMessage.sender}`
365
+ );
366
+
367
+ const text = triggeringMessage.content?.text;
368
+ if (text) {
369
+ lines.push('', text);
370
+ }
330
371
  }
331
372
 
332
373
  /**
@@ -272,25 +272,28 @@ class TemplateResolver {
272
272
  * @returns {any}
273
273
  */
274
274
  _parseValue(str) {
275
- str = str.trim();
275
+ const trimmed = str.trim();
276
276
 
277
277
  // String literals (single or double quotes)
278
- if ((str.startsWith('"') && str.endsWith('"')) || (str.startsWith("'") && str.endsWith("'"))) {
279
- return str.slice(1, -1);
278
+ if (
279
+ (trimmed.startsWith('"') && trimmed.endsWith('"')) ||
280
+ (trimmed.startsWith("'") && trimmed.endsWith("'"))
281
+ ) {
282
+ return trimmed.slice(1, -1);
280
283
  }
281
284
 
282
285
  // Boolean literals
283
- if (str === 'true') return true;
284
- if (str === 'false') return false;
285
- if (str === 'undefined' || str === 'null') return undefined;
286
+ if (trimmed === 'true') return true;
287
+ if (trimmed === 'false') return false;
288
+ if (trimmed === 'undefined' || trimmed === 'null') return undefined;
286
289
 
287
290
  // Numbers
288
- if (/^-?\d+(\.\d+)?$/.test(str)) {
289
- return parseFloat(str);
291
+ if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
292
+ return parseFloat(trimmed);
290
293
  }
291
294
 
292
295
  // Return as-is for other cases
293
- return str;
296
+ return trimmed;
294
297
  }
295
298
 
296
299
  /**
@@ -10,6 +10,7 @@
10
10
 
11
11
  const pidusage = require('pidusage');
12
12
  const Ledger = require('../ledger');
13
+ const fs = require('fs');
13
14
  const path = require('path');
14
15
  const os = require('os');
15
16
 
@@ -138,47 +139,11 @@ class DataPoller {
138
139
  try {
139
140
  const clusters = this.orchestrator.listClusters();
140
141
  const stats = {};
142
+ const pids = this._collectClusterPids(clusters);
141
143
 
142
- // Collect all PIDs from all agents
143
- const pids = [];
144
- for (const cluster of clusters) {
145
- try {
146
- const status = this.orchestrator.getStatus(cluster.id);
147
- for (const agent of status.agents || []) {
148
- if (agent.pid) {
149
- pids.push(agent.pid);
150
- }
151
- }
152
- } catch {
153
- // Skip clusters that error
154
- continue;
155
- }
156
- }
157
-
158
- // Get stats for all PIDs
159
144
  if (pids.length > 0) {
160
- try {
161
- const pidStats = await pidusage(pids);
162
-
163
- // Convert to map format: pid -> { cpu, memory }
164
- for (const pid of pids) {
165
- if (pidStats[pid]) {
166
- stats[pid] = {
167
- cpu: pidStats[pid].cpu || 0,
168
- memory: pidStats[pid].memory || 0,
169
- };
170
- } else {
171
- // Process died - set to zero
172
- stats[pid] = { cpu: 0, memory: 0 };
173
- }
174
- }
175
- } catch {
176
- // pidusage throws if any process is dead
177
- // Set all to zero and continue
178
- for (const pid of pids) {
179
- stats[pid] = { cpu: 0, memory: 0 };
180
- }
181
- }
145
+ const pidStats = await this._safePidUsage(pids);
146
+ this._populatePidStats(stats, pids, pidStats);
182
147
  }
183
148
 
184
149
  this.onUpdate({
@@ -198,54 +163,36 @@ class DataPoller {
198
163
  */
199
164
  _watchForNewClusters() {
200
165
  this.watchForNewClustersStopFn = this.orchestrator.watchForNewClusters((cluster) => {
201
- try {
202
- // Lazy load ledger only when we need to stream messages
203
- // This avoids loading all ledgers on startup
204
- if (!this.ledgers.has(cluster.id)) {
205
- const storageDir = this.orchestrator.storageDir || path.join(os.homedir(), '.zeroshot');
206
- const dbPath = path.join(storageDir, `${cluster.id}.db`);
207
-
208
- // Only load if database file exists
209
- const fs = require('fs');
210
- if (!fs.existsSync(dbPath)) {
211
- return; // Skip non-existent ledgers
212
- }
213
-
214
- const ledger = new Ledger(dbPath);
215
- this.ledgers.set(cluster.id, ledger);
216
- }
217
-
218
- // Start streaming messages
219
- this._streamLedgerMessages(cluster.id);
166
+ // Lazy load ledger only when we need to stream messages
167
+ // This avoids loading all ledgers on startup
168
+ const ledger = this._loadLedgerForCluster(cluster, {
169
+ label: 'cluster',
170
+ requireExisting: true,
171
+ });
220
172
 
221
- // Emit update about new cluster
222
- this.onUpdate({
223
- type: 'new_cluster',
224
- cluster,
225
- });
226
- } catch (error) {
227
- console.error(
228
- `[DataPoller] Failed to load ledger for cluster ${cluster.id}:`,
229
- error.message
230
- );
173
+ if (!ledger) {
174
+ return;
231
175
  }
176
+
177
+ // Start streaming messages
178
+ this._streamLedgerMessages(cluster.id);
179
+
180
+ // Emit update about new cluster
181
+ this.onUpdate({
182
+ type: 'new_cluster',
183
+ cluster,
184
+ });
232
185
  }, 2000);
233
186
 
234
187
  // Also load ledgers for all existing clusters
235
188
  const existingClusters = this.orchestrator.listClusters();
236
189
  for (const cluster of existingClusters) {
237
- try {
238
- const storageDir = this.orchestrator.storageDir || path.join(os.homedir(), '.zeroshot');
239
- const dbPath = path.join(storageDir, `${cluster.id}.db`);
240
- const ledger = new Ledger(dbPath);
241
- this.ledgers.set(cluster.id, ledger);
242
- this._streamLedgerMessages(cluster.id);
243
- } catch (error) {
244
- console.error(
245
- `[DataPoller] Failed to load ledger for existing cluster ${cluster.id}:`,
246
- error.message
247
- );
190
+ const ledger = this._loadLedgerForCluster(cluster, { label: 'existing cluster' });
191
+ if (!ledger) {
192
+ continue;
248
193
  }
194
+
195
+ this._streamLedgerMessages(cluster.id);
249
196
  }
250
197
  }
251
198
 
@@ -295,31 +242,108 @@ class DataPoller {
295
242
  const clusters = this.orchestrator.listClusters();
296
243
 
297
244
  for (const cluster of clusters) {
298
- try {
299
- const status = this.orchestrator.getStatus(cluster.id);
300
-
301
- for (const agent of status.agents || []) {
302
- if (agent.pid) {
303
- try {
304
- const pidStat = await pidusage(agent.pid);
305
- stats[agent.pid] = {
306
- cpu: pidStat.cpu || 0,
307
- memory: pidStat.memory || 0,
308
- };
309
- } catch {
310
- // Process died - set to zero
311
- stats[agent.pid] = { cpu: 0, memory: 0 };
312
- }
313
- }
314
- }
315
- } catch {
316
- // Skip clusters that error
317
- continue;
245
+ const pids = this._getClusterAgentPids(cluster);
246
+ for (const pid of pids) {
247
+ stats[pid] = await this._getSinglePidStat(pid);
318
248
  }
319
249
  }
320
250
 
321
251
  return stats;
322
252
  }
253
+
254
+ _collectClusterPids(clusters) {
255
+ const pids = [];
256
+
257
+ for (const cluster of clusters) {
258
+ pids.push(...this._getClusterAgentPids(cluster));
259
+ }
260
+
261
+ return pids;
262
+ }
263
+
264
+ _getClusterAgentPids(cluster) {
265
+ try {
266
+ const status = this.orchestrator.getStatus(cluster.id);
267
+ const pids = [];
268
+
269
+ for (const agent of status.agents || []) {
270
+ if (agent.pid) {
271
+ pids.push(agent.pid);
272
+ }
273
+ }
274
+
275
+ return pids;
276
+ } catch {
277
+ // Skip clusters that error
278
+ return [];
279
+ }
280
+ }
281
+
282
+ async _getSinglePidStat(pid) {
283
+ try {
284
+ const pidStat = await pidusage(pid);
285
+ return {
286
+ cpu: pidStat.cpu || 0,
287
+ memory: pidStat.memory || 0,
288
+ };
289
+ } catch {
290
+ // Process died - set to zero
291
+ return { cpu: 0, memory: 0 };
292
+ }
293
+ }
294
+
295
+ async _safePidUsage(pids) {
296
+ try {
297
+ return await pidusage(pids);
298
+ } catch {
299
+ return null;
300
+ }
301
+ }
302
+
303
+ _populatePidStats(stats, pids, pidStats) {
304
+ for (const pid of pids) {
305
+ const entry = pidStats?.[pid];
306
+ stats[pid] = {
307
+ cpu: entry?.cpu || 0,
308
+ memory: entry?.memory || 0,
309
+ };
310
+ }
311
+ }
312
+
313
+ _loadLedgerForCluster(cluster, options) {
314
+ const { label, requireExisting = false } = options;
315
+
316
+ try {
317
+ return this._ensureLedger(cluster.id, { requireExisting });
318
+ } catch (error) {
319
+ console.error(
320
+ `[DataPoller] Failed to load ledger for ${label} ${cluster.id}:`,
321
+ error.message
322
+ );
323
+ return null;
324
+ }
325
+ }
326
+
327
+ _ensureLedger(clusterId, { requireExisting = false } = {}) {
328
+ const existing = this.ledgers.get(clusterId);
329
+ if (existing) {
330
+ return existing;
331
+ }
332
+
333
+ const dbPath = this._getLedgerPath(clusterId);
334
+ if (requireExisting && !fs.existsSync(dbPath)) {
335
+ return null;
336
+ }
337
+
338
+ const ledger = new Ledger(dbPath);
339
+ this.ledgers.set(clusterId, ledger);
340
+ return ledger;
341
+ }
342
+
343
+ _getLedgerPath(clusterId) {
344
+ const storageDir = this.orchestrator.storageDir || path.join(os.homedir(), '.zeroshot');
345
+ return path.join(storageDir, `${clusterId}.db`);
346
+ }
323
347
  }
324
348
 
325
349
  module.exports = DataPoller;
@@ -64,12 +64,13 @@ const formatCPU = (percent) => {
64
64
  if (typeof percent !== 'number' || percent < 0) return '0.0%';
65
65
 
66
66
  // Assert normalized range (should never exceed 100% after per-core normalization)
67
- if (percent > 100) {
67
+ let normalizedPercent = percent;
68
+ if (normalizedPercent > 100) {
68
69
  console.warn(`[formatCPU] CPU percent ${percent}% exceeds 100% - normalization bug?`);
69
- percent = 100;
70
+ normalizedPercent = 100;
70
71
  }
71
72
 
72
- return `${percent.toFixed(1)}%`;
73
+ return `${normalizedPercent.toFixed(1)}%`;
73
74
  };
74
75
 
75
76
  /**