@covibes/zeroshot 1.1.3 → 1.2.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 CHANGED
@@ -1,3 +1,24 @@
1
+ # [1.2.0](https://github.com/covibes/zeroshot/compare/v1.1.4...v1.2.0) (2025-12-28)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **status-footer:** robust terminal resize handling ([767a610](https://github.com/covibes/zeroshot/commit/767a610027b3e2bb238b54c31a3a7e93db635319))
7
+
8
+
9
+ ### Features
10
+
11
+ * **agent:** publish TOKEN_USAGE events with task completion ([c79482c](https://github.com/covibes/zeroshot/commit/c79482c82582b75a692ba71005c821decdc1d769))
12
+ * **stream-parser:** add token usage tracking to result events ([91ad850](https://github.com/covibes/zeroshot/commit/91ad8507f42fd1a398bdc06f3b91b0a13eec8941))
13
+
14
+ ## [1.1.4](https://github.com/covibes/zeroshot/compare/v1.1.3...v1.1.4) (2025-12-28)
15
+
16
+
17
+ ### Bug Fixes
18
+
19
+ * **cli:** read version from package.json instead of hardcoded value ([a6e0e57](https://github.com/covibes/zeroshot/commit/a6e0e570feeaffa64dbc46d494eeef000f32b708))
20
+ * **cli:** resolve streaming mode crash and refactor message formatters ([efb9264](https://github.com/covibes/zeroshot/commit/efb9264ce0d3ede0eb7d502d4625694c2c525230))
21
+
1
22
  ## [1.1.3](https://github.com/covibes/zeroshot/compare/v1.1.2...v1.1.3) (2025-12-28)
2
23
 
3
24
 
package/cli/index.js CHANGED
@@ -341,7 +341,7 @@ if (shouldShowBanner) {
341
341
  program
342
342
  .name('zeroshot')
343
343
  .description('Multi-agent orchestration and task management for Claude')
344
- .version('1.0.0')
344
+ .version(require('../package.json').version)
345
345
  .addHelpText(
346
346
  'after',
347
347
  `
@@ -3666,7 +3666,7 @@ function printMessage(msg, showClusterId = false, watchMode = false, isActive =
3666
3666
  }
3667
3667
 
3668
3668
  if (msg.topic === 'ISSUE_OPENED') {
3669
- formatIssueOpenedNormal(msg, prefix, timestamp);
3669
+ formatIssueOpenedNormal(msg, prefix, timestamp, shownNewTaskForCluster);
3670
3670
  return;
3671
3671
  }
3672
3672
 
@@ -81,7 +81,6 @@
81
81
  "id": "fixer",
82
82
  "role": "implementation",
83
83
  "model": "{{fixer_model}}",
84
- "outputFormat": "stream-json",
85
84
  "prompt": {
86
85
  "system": "## 🚫 YOU CANNOT ASK QUESTIONS\n\nYou are running non-interactively. There is NO USER to answer.\n- NEVER use AskUserQuestion tool\n- NEVER say \"Should I...\" or \"Would you like...\"\n- When unsure: Make the SAFER choice and proceed.\n\nYou are a bug fixer. Apply the fix from the investigator.\n\n## Your Job\nFix the root cause identified in INVESTIGATION_COMPLETE.\n\n## Fix Guidelines\n- Fix the ROOT CAUSE, not just the symptom\n- Make minimal changes (don't refactor unrelated code)\n- Add comments explaining WHY if fix is non-obvious\n- Consider if same bug exists elsewhere\n\n## After Fixing\n- Run the failing tests to verify fix works\n- Run related tests for regressions\n- Add test case that would catch this bug if it recurs\n\n## šŸš€ LARGE TASKS - USE SUB-AGENTS\n\nIf task affects >10 files OR >50 errors, DO NOT fix manually. Use the Task tool to spawn parallel sub-agents:\n\n1. **Analyze scope first** - Count files/errors, group by directory or error type\n2. **Spawn sub-agents** - One per group, run in parallel\n3. **Choose model wisely:**\n - **haiku**: Mechanical fixes (unused vars, missing imports, simple type annotations)\n - **sonnet**: Complex fixes (refactoring, logic changes, architectural decisions)\n4. **Aggregate results** - Wait for all sub-agents, verify combined fix\n\nExample Task tool usage:\n```\nTask(prompt=\"Fix all @typescript-eslint/no-unused-vars errors in client/src/components/features/agents/. Prefix intentionally unused params with underscore, remove genuinely unused variables.\", model=\"haiku\")\n```\n\nDO NOT waste iterations doing manual work that sub-agents can parallelize.\n\n## šŸ”“ FORBIDDEN - DO NOT FUCKING DO THESE\n\nThese are SHORTCUTS that HIDE problems instead of FIXING them:\n\n- āŒ NEVER disable or suppress errors/warnings (config changes, disable comments, ignore directives)\n- āŒ NEVER change test expectations to match broken behavior\n- āŒ NEVER use unsafe type casts or `any` to silence type errors\n- āŒ NEVER add TODO/FIXME instead of actually fixing\n- āŒ NEVER work around the problem - FIX THE ACTUAL CODE\n\nIF THE PROBLEM STILL EXISTS BUT IS HIDDEN, YOU HAVE NOT FIXED IT.\n\n## On Rejection - READ THE FUCKING FEEDBACK\n\nWhen tester rejects:\n1. STOP. READ what they wrote. UNDERSTAND the issue.\n2. If same problem persists → your fix is WRONG, try DIFFERENT approach\n3. If new problems appeared → your fix BROKE something, REVERT and rethink\n4. Do NOT blindly retry the same approach\n5. If you are STUCK, say so. Do not waste iterations doing nothing.\n\nRepeating failed approaches = wasted time and money. LEARN from rejection."
87
86
  },
@@ -137,7 +137,6 @@
137
137
  "id": "worker",
138
138
  "role": "implementation",
139
139
  "model": "{{worker_model}}",
140
- "outputFormat": "stream-json",
141
140
  "prompt": {
142
141
  "initial": "## 🚫 YOU CANNOT ASK QUESTIONS\n\nYou are running non-interactively. There is NO USER to answer.\n- NEVER use AskUserQuestion tool\n- NEVER say \"Should I...\" or \"Would you like...\"\n- When unsure: Make the SAFER choice and proceed.\n\nYou are an implementation agent for a {{complexity}} {{task_type}} task.\n\n## First Pass - Do It Right\nImplement a COMPLETE solution from PLAN_READY:\n- Follow the plan steps carefully\n- Handle common edge cases (empty, null, error states)\n- Include error handling for likely failures\n- Write clean code with proper types\n- Write tests for ALL new functionality (reference PLAN_READY test requirements)\n- Tests MUST have meaningful assertions (not just existence checks)\n- Tests MUST be isolated and deterministic (no shared state, no network)\n- Verify edge cases from plan are covered\n- Run tests to verify your implementation passes\n\nAim for first-try approval. Don't leave obvious gaps for validators to find.\n\n## EXECUTING DELEGATED TASKS\n\nāš ļø SUB-AGENT LIMITS (CRITICAL - prevents context explosion):\n- Maximum 3 parallel sub-agents at once\n- If phase has more tasks, batch them into groups of 3\n- Prioritize by dependency order, then complexity\n\nIf PLAN_READY contains a 'delegation' field in its data, you MUST use parallel sub-agents:\n\n1. Parse delegation.phases and delegation.tasks from the plan data\n2. For each phase in order:\n a. Find all tasks for this phase (matching taskIds)\n b. Split into batches of MAX 3 tasks each\n c. For each batch:\n - Spawn sub-agents using Task tool (run_in_background: true)\n - Use the model specified in each task (haiku/sonnet/opus)\n - Wait for batch to complete using TaskOutput with block: true\n - SUMMARIZE each result (see OUTPUT HANDLING below)\n - Only proceed to next batch after current batch completes\n3. After ALL phases complete, verify changes work together\n4. Do NOT commit until all sub-agents finish\n\nExample Task tool call for each delegated task:\n```\nTask tool with:\n subagent_type: 'general-purpose'\n model: [task.model from delegation]\n prompt: '[task.description]. Files: [task.scope]. Do NOT commit.'\n run_in_background: true\n```\n\n## SUB-AGENT OUTPUT HANDLING (CRITICAL - prevents context bloat)\n\nWhen TaskOutput returns a sub-agent result, SUMMARIZE immediately:\n- Extract ONLY: success/failure, files modified, key outcomes\n- Discard: full file contents, verbose logs, intermediate steps\n- Keep as: \"Task [id] completed: [2-3 sentence summary]\"\n\nExample: \"Task fix-auth completed: Fixed JWT validation in auth.ts, added null check. Tests pass.\"\n\nDO NOT accumulate full sub-agent output - this causes context explosion.\n\nIf NO delegation field, implement directly as normal.\n\n{{#if complexity == 'CRITICAL'}}\n## CRITICAL TASK - EXTRA CARE\n- Double-check every change\n- No shortcuts or assumptions\n- Consider security implications\n- Add comprehensive error handling\n{{/if}}",
143
142
  "subsequent": "## 🚫 YOU CANNOT ASK QUESTIONS\n\nYou are running non-interactively. There is NO USER to answer.\n- NEVER use AskUserQuestion tool\n- NEVER say \"Should I...\" or \"Would you like...\"\n- When unsure: Make the SAFER choice and proceed.\n\nYou are an implementation agent for a {{complexity}} {{task_type}} task.\n\n## VALIDATORS REJECTED YOUR WORK\n\nThis is NOT a minor revision request. Senior engineers reviewed your code and found it UNACCEPTABLE. Read ALL VALIDATION_RESULT messages carefully.\n\n## FIX LIKE A SENIOR ARCHITECT WOULD\n\n### 1. DIAGNOSE BEFORE FIXING\n- Read EVERY rejection reason completely\n- Understand the ROOT CAUSE, not just the symptom\n- If multiple validators rejected, their issues may be related\n- Ask: 'Why did I make this mistake? Is my approach fundamentally flawed?'\n\n### 2. FIX PROPERLY - NO BAND-AIDS\n- A band-aid fix will be caught and rejected again\n- If your approach was wrong, REDESIGN it from scratch\n- Consider: 'Would a senior engineer be proud of this fix?'\n- Think about edge cases, error handling, maintainability\n- Don't just make the error go away - solve the actual problem\n\n### 3. VERIFY COMPREHENSIVELY\n- Test that your fix actually works\n- Verify you didn't break anything else\n- Run relevant tests if they exist\n- If you're unsure, investigate before committing\n\n### 4. ARCHITECTURAL THINKING\n- Consider blast radius of your changes\n- Think about how your fix affects other parts of the system\n- Is there a better abstraction or pattern?\n- Future maintainers will inherit your decisions\n\n## MINDSET\n- Validators are not being pedantic - they found REAL problems\n- Every rejection is expensive - get it right this time\n- Shortcuts and hacks will be caught immediately\n- Pride in craftsmanship: deliver code you'd want to maintain\n\n{{#if complexity == 'CRITICAL'}}\n## CRITICAL TASK - ZERO TOLERANCE FOR SHORTCUTS\n- This is HIGH RISK code (auth, payments, security, production)\n- Triple-check every change\n- Consider all failure modes\n- Security implications must be addressed\n- Comprehensive error handling is MANDATORY\n- If unsure, err on the side of caution\n{{/if}}"
@@ -19,7 +19,6 @@
19
19
  "id": "worker",
20
20
  "role": "implementation",
21
21
  "model": "{{worker_model}}",
22
- "outputFormat": "stream-json",
23
22
  "prompt": {
24
23
  "system": "## 🚫 YOU CANNOT ASK QUESTIONS\n\nYou are running non-interactively. There is NO USER to answer.\n- NEVER use AskUserQuestion tool\n- NEVER say \"Should I...\" or \"Would you like...\"\n- When unsure: Make the SAFER choice and proceed.\n\nYou are an agent handling a {{task_type}} task.\n\n## TASK TYPE: {{task_type}}\n\n{{#if task_type == 'INQUIRY'}}\nThis is an INQUIRY - exploration and understanding only.\n- Answer questions about the codebase\n- Explore files and explain how things work\n- DO NOT make any changes\n- Provide clear, accurate information\n{{/if}}\n\n{{#if task_type == 'TASK'}}\nThis is a TRIVIAL TASK - quick execution.\n- Straightforward, well-defined action\n- Quick to complete (< 15 minutes)\n- Low risk of breaking existing functionality\n- Execute efficiently, verify it works, done\n{{/if}}\n\n{{#if task_type == 'DEBUG'}}\nThis is a TRIVIAL DEBUG - simple fix.\n- Obvious issue with clear solution\n- Fix the root cause, not symptoms\n- Verify the fix works\n{{/if}}"
25
24
  },
@@ -25,7 +25,6 @@
25
25
  "id": "worker",
26
26
  "role": "implementation",
27
27
  "model": "{{worker_model}}",
28
- "outputFormat": "stream-json",
29
28
  "prompt": {
30
29
  "system": "## 🚫 YOU CANNOT ASK QUESTIONS\n\nYou are running non-interactively. There is NO USER to answer.\n- NEVER use AskUserQuestion tool\n- NEVER say \"Should I...\" or \"Would you like...\"\n- When unsure: Make the SAFER choice and proceed.\n\nYou are an implementation agent for a SIMPLE {{task_type}} task.\n\n## FIRST ITERATION\n\n{{#if task_type == 'TASK'}}\nImplement the requested feature/change:\n- Well-defined scope (one feature, one fix)\n- Standard patterns apply\n- Complete the implementation fully\n{{/if}}\n\n{{#if task_type == 'DEBUG'}}\nInvestigate and fix the issue:\n- Reproduce the problem\n- Find the root cause (not just symptoms)\n- Apply the fix\n- Verify it works\n{{/if}}\n\n{{#if task_type == 'INQUIRY'}}\nResearch and provide detailed answers:\n- Explore relevant code and documentation\n- Explain how things work\n- Provide accurate, complete information\n{{/if}}\n\n## SUBSEQUENT ITERATIONS (after rejection)\n\nYou are being called back because validators REJECTED your implementation. This is NOT a minor issue.\n\n### FIX LIKE A SENIOR ENGINEER\n\n1. **STOP AND UNDERSTAND FIRST**\n - Read ALL VALIDATION_RESULT messages completely\n - Understand WHY each issue exists, not just WHAT it is\n - Trace the root cause - don't patch symptoms\n\n2. **FIX PROPERLY - NO SHORTCUTS**\n - Fix the ACTUAL problem, not the error message\n - If your approach was wrong, redesign it - don't add band-aids\n - Consider architectural implications of your fix\n - A senior dev would be embarrassed to submit a half-fix\n\n3. **VERIFY YOUR FIX**\n - Test your changes actually work\n - Check you didn't break anything else\n - If unsure, investigate before committing\n\n### MINDSET\n- Validators are senior engineers reviewing your code\n- They found REAL problems - take them seriously\n- Shortcuts will be caught and rejected again"
31
30
  },
package/lib/settings.js CHANGED
@@ -15,7 +15,7 @@ const DEFAULT_SETTINGS = {
15
15
  defaultModel: 'sonnet',
16
16
  defaultConfig: 'conductor-bootstrap',
17
17
  defaultIsolation: false,
18
- strictSchema: false, // false = live streaming (default), true = guaranteed schema compliance (no streaming)
18
+ strictSchema: true, // true = reliable json output (default), false = live streaming (may crash - see bold-meadow-11)
19
19
  logLevel: 'normal',
20
20
  };
21
21
 
@@ -45,8 +45,9 @@ function parseStreamLine(line) {
45
45
  return parseUserMessage(event.message);
46
46
  }
47
47
 
48
- // result - final task result
48
+ // result - final task result (includes token usage and cost)
49
49
  if (event.type === 'result') {
50
+ const usage = event.usage || {};
50
51
  return {
51
52
  type: 'result',
52
53
  success: event.subtype === 'success',
@@ -54,6 +55,13 @@ function parseStreamLine(line) {
54
55
  error: event.is_error ? event.result : null,
55
56
  cost: event.total_cost_usd,
56
57
  duration: event.duration_ms,
58
+ // Token usage from Claude API
59
+ inputTokens: usage.input_tokens || 0,
60
+ outputTokens: usage.output_tokens || 0,
61
+ cacheReadInputTokens: usage.cache_read_input_tokens || 0,
62
+ cacheCreationInputTokens: usage.cache_creation_input_tokens || 0,
63
+ // Per-model breakdown (for multi-model tasks)
64
+ modelUsage: event.modelUsage || null,
57
65
  };
58
66
  }
59
67
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@covibes/zeroshot",
3
- "version": "1.1.3",
3
+ "version": "1.2.0",
4
4
  "description": "Multi-agent orchestration engine for Claude - cluster coordinator and CLI",
5
5
  "main": "src/orchestrator.js",
6
6
  "bin": {
@@ -278,8 +278,28 @@ async function executeTask(agent, triggeringMessage) {
278
278
  iteration: agent.iteration,
279
279
  success: true,
280
280
  taskId: agent.currentTaskId,
281
+ tokenUsage: result.tokenUsage || null,
281
282
  });
282
283
 
284
+ // Publish TOKEN_USAGE event for aggregation and tracking
285
+ if (result.tokenUsage) {
286
+ agent.messageBus.publish({
287
+ cluster_id: agent.cluster.id,
288
+ topic: 'TOKEN_USAGE',
289
+ sender: agent.id,
290
+ content: {
291
+ text: `${agent.id} used ${result.tokenUsage.inputTokens} input + ${result.tokenUsage.outputTokens} output tokens`,
292
+ data: {
293
+ agentId: agent.id,
294
+ role: agent.role,
295
+ model: agent._selectModel(),
296
+ iteration: agent.iteration,
297
+ ...result.tokenUsage,
298
+ },
299
+ },
300
+ });
301
+ }
302
+
283
303
  // Execute onComplete hook
284
304
  await executeHook({
285
305
  hook: agent.config.hooks?.onComplete,
@@ -54,6 +54,44 @@ function sanitizeErrorMessage(error) {
54
54
  // Track if we've already ensured the AskUserQuestion hook is installed
55
55
  let askUserQuestionHookInstalled = false;
56
56
 
57
+ /**
58
+ * Extract token usage from NDJSON output.
59
+ * Looks for the 'result' event line which contains usage data.
60
+ *
61
+ * @param {string} output - Full NDJSON output from Claude CLI
62
+ * @returns {Object|null} Token usage data or null if not found
63
+ */
64
+ function extractTokenUsage(output) {
65
+ if (!output) return null;
66
+
67
+ const lines = output.split('\n');
68
+
69
+ // Find the result line containing usage data
70
+ for (const line of lines) {
71
+ if (!line.trim()) continue;
72
+
73
+ try {
74
+ const event = JSON.parse(line.trim());
75
+ if (event.type === 'result') {
76
+ const usage = event.usage || {};
77
+ return {
78
+ inputTokens: usage.input_tokens || 0,
79
+ outputTokens: usage.output_tokens || 0,
80
+ cacheReadInputTokens: usage.cache_read_input_tokens || 0,
81
+ cacheCreationInputTokens: usage.cache_creation_input_tokens || 0,
82
+ totalCostUsd: event.total_cost_usd || null,
83
+ durationMs: event.duration_ms || null,
84
+ modelUsage: event.modelUsage || null,
85
+ };
86
+ }
87
+ } catch {
88
+ // Not valid JSON, continue
89
+ }
90
+ }
91
+
92
+ return null;
93
+ }
94
+
57
95
  /**
58
96
  * Ensure the AskUserQuestion blocking hook is installed in user's Claude config.
59
97
  * This adds defense-in-depth by blocking the tool at the Claude CLI level.
@@ -569,6 +607,7 @@ function followClaudeTaskLogs(agent, taskId) {
569
607
  success,
570
608
  output,
571
609
  error: sanitizeErrorMessage(errorContext),
610
+ tokenUsage: extractTokenUsage(output),
572
611
  });
573
612
  }, 500);
574
613
  }
@@ -590,6 +629,7 @@ function followClaudeTaskLogs(agent, taskId) {
590
629
  success: false,
591
630
  output,
592
631
  error: reason,
632
+ tokenUsage: extractTokenUsage(output),
593
633
  });
594
634
  },
595
635
  };
@@ -907,6 +947,7 @@ function followClaudeTaskLogsIsolated(agent, taskId) {
907
947
  output: fullOutput,
908
948
  taskId,
909
949
  result: parsedResult,
950
+ tokenUsage: extractTokenUsage(fullOutput),
910
951
  });
911
952
  }
912
953
  } catch (pollErr) {
@@ -366,7 +366,11 @@ async function getProcessMetricsLinuxAggregated(pid, samplePeriodMs) {
366
366
  const clockTicks = 100; // Usually 100 on Linux
367
367
  const cpuSeconds = totalCpuTicksDelta / clockTicks;
368
368
  const sampleSeconds = samplePeriodMs / 1000;
369
- const cpuPercent = (cpuSeconds / sampleSeconds) * 100;
369
+ const rawCpuPercent = (cpuSeconds / sampleSeconds) * 100;
370
+
371
+ // Normalize to per-core average (0-100% range)
372
+ const cpuCores = require('os').cpus().length;
373
+ const cpuPercent = Math.min(100, rawCpuPercent / cpuCores);
370
374
 
371
375
  // Get network state for all processes
372
376
  let network = { established: 0, hasActivity: false, sendQueueBytes: 0, recvQueueBytes: 0, bytesSent: 0, bytesReceived: 0 };
@@ -444,10 +448,14 @@ function getProcessMetricsDarwinAggregated(pid) {
444
448
  if (netState.hasActivity) network.hasActivity = true;
445
449
  }
446
450
 
451
+ // Normalize to per-core average (0-100% range)
452
+ const cpuCores = require('os').cpus().length;
453
+ const normalizedCpu = Math.min(100, totalCpuPercent / cpuCores);
454
+
447
455
  return {
448
456
  pid,
449
457
  exists: true,
450
- cpuPercent: parseFloat(totalCpuPercent.toFixed(1)),
458
+ cpuPercent: parseFloat(normalizedCpu.toFixed(1)),
451
459
  memoryMB: parseFloat((totalMemoryKB / 1024).toFixed(1)),
452
460
  state: rootState,
453
461
  threads: totalThreads,
@@ -8,6 +8,13 @@
8
8
  *
9
9
  * Uses ANSI escape sequences to maintain a fixed footer while
10
10
  * allowing normal terminal output to scroll above it.
11
+ *
12
+ * ROBUST RESIZE HANDLING:
13
+ * - Debounced resize events (100ms) prevent rapid-fire redraws
14
+ * - Render lock prevents concurrent renders from corrupting state
15
+ * - Full footer clear before scroll region reset prevents artifacts
16
+ * - Dimension checkpointing skips unnecessary redraws
17
+ * - Graceful degradation for terminals < 8 rows
11
18
  */
12
19
 
13
20
  const { getProcessMetrics } = require('./process-metrics');
@@ -37,6 +44,20 @@ const COLORS = {
37
44
  bgBlack: `${CSI}40m`,
38
45
  };
39
46
 
47
+ /**
48
+ * Debounce function - prevents rapid-fire calls during resize
49
+ * @param {Function} fn - Function to debounce
50
+ * @param {number} ms - Debounce delay in milliseconds
51
+ * @returns {Function} Debounced function
52
+ */
53
+ function debounce(fn, ms) {
54
+ let timeout;
55
+ return (...args) => {
56
+ clearTimeout(timeout);
57
+ timeout = setTimeout(() => fn(...args), ms);
58
+ };
59
+ }
60
+
40
61
  /**
41
62
  * @typedef {Object} AgentState
42
63
  * @property {string} id - Agent ID
@@ -65,6 +86,17 @@ class StatusFooter {
65
86
  this.clusterId = null;
66
87
  this.clusterState = 'initializing';
67
88
  this.startTime = Date.now();
89
+
90
+ // Robust resize handling state
91
+ this.isRendering = false; // Render lock - prevents concurrent renders
92
+ this.pendingResize = false; // Queue resize if render in progress
93
+ this.lastKnownRows = 0; // Track terminal dimensions for change detection
94
+ this.lastKnownCols = 0;
95
+ this.minRows = 8; // Minimum rows for footer display (graceful degradation)
96
+ this.hidden = false; // True when terminal too small for footer
97
+
98
+ // Debounced resize handler (100ms) - prevents rapid-fire redraws
99
+ this._debouncedResize = debounce(() => this._handleResize(), 100);
68
100
  }
69
101
 
70
102
  /**
@@ -95,22 +127,80 @@ class StatusFooter {
95
127
  process.stdout.write(`${CSI}${row};${col}H`);
96
128
  }
97
129
 
130
+ /**
131
+ * Clear a specific line completely
132
+ * @param {number} row - 1-based row number
133
+ * @private
134
+ */
135
+ _clearLine(row) {
136
+ process.stdout.write(`${CSI}${row};1H${CLEAR_LINE}`);
137
+ }
138
+
139
+ /**
140
+ * Clear all footer lines (uses last known height for safety)
141
+ * @private
142
+ */
143
+ _clearFooterArea() {
144
+ const { rows } = this.getTerminalSize();
145
+ // Use max of current and last footer height to ensure full cleanup
146
+ const heightToClear = Math.max(this.footerHeight, this.lastFooterHeight, 3);
147
+ const startRow = Math.max(1, rows - heightToClear + 1);
148
+
149
+ for (let row = startRow; row <= rows; row++) {
150
+ this._clearLine(row);
151
+ }
152
+ }
153
+
98
154
  /**
99
155
  * Set up scroll region to reserve space for footer
156
+ * ROBUST: Clears footer area first, resets to full screen, then sets new region
100
157
  */
101
158
  setupScrollRegion() {
102
159
  if (!this.isTTY()) return;
103
160
 
104
- const { rows } = this.getTerminalSize();
161
+ const { rows, cols } = this.getTerminalSize();
162
+
163
+ // Graceful degradation: hide footer if terminal too small
164
+ if (rows < this.minRows) {
165
+ if (!this.hidden) {
166
+ this.hidden = true;
167
+ // Reset to full screen scroll
168
+ process.stdout.write(`${CSI}1;${rows}r`);
169
+ this.scrollRegionSet = false;
170
+ }
171
+ return;
172
+ }
173
+
174
+ // Restore footer if terminal grew large enough
175
+ if (this.hidden) {
176
+ this.hidden = false;
177
+ }
178
+
105
179
  const scrollEnd = rows - this.footerHeight;
106
180
 
107
- // Set scroll region (lines 1 to scrollEnd)
181
+ // CRITICAL: Save cursor before any manipulation
182
+ process.stdout.write(SAVE_CURSOR);
183
+ process.stdout.write(HIDE_CURSOR);
184
+
185
+ // Step 1: Reset scroll region to full screen first (prevents artifacts)
186
+ process.stdout.write(`${CSI}1;${rows}r`);
187
+
188
+ // Step 2: Clear footer area completely (prevents ghosting)
189
+ this._clearFooterArea();
190
+
191
+ // Step 3: Set new scroll region (lines 1 to scrollEnd)
108
192
  process.stdout.write(`${CSI}1;${scrollEnd}r`);
109
193
 
110
- // Move cursor to top of scroll region
111
- process.stdout.write(`${CSI}1;1H`);
194
+ // Step 4: Move cursor to bottom of scroll region (safe position)
195
+ process.stdout.write(`${CSI}${scrollEnd};1H`);
196
+
197
+ // Restore cursor and show it
198
+ process.stdout.write(RESTORE_CURSOR);
199
+ process.stdout.write(SHOW_CURSOR);
112
200
 
113
201
  this.scrollRegionSet = true;
202
+ this.lastKnownRows = rows;
203
+ this.lastKnownCols = cols;
114
204
  }
115
205
 
116
206
  /**
@@ -124,6 +214,35 @@ class StatusFooter {
124
214
  this.scrollRegionSet = false;
125
215
  }
126
216
 
217
+ /**
218
+ * Handle terminal resize event
219
+ * Called via debounced wrapper to prevent rapid-fire redraws
220
+ * @private
221
+ */
222
+ _handleResize() {
223
+ if (!this.isTTY()) return;
224
+
225
+ const { rows, cols } = this.getTerminalSize();
226
+
227
+ // Skip if dimensions haven't actually changed (debounce may still fire)
228
+ if (rows === this.lastKnownRows && cols === this.lastKnownCols) {
229
+ return;
230
+ }
231
+
232
+ // If render in progress, queue resize for after
233
+ if (this.isRendering) {
234
+ this.pendingResize = true;
235
+ return;
236
+ }
237
+
238
+ // Update dimensions and reconfigure
239
+ this.lastKnownRows = rows;
240
+ this.lastKnownCols = cols;
241
+
242
+ this.setupScrollRegion();
243
+ this.render();
244
+ }
245
+
127
246
  /**
128
247
  * Register cluster for monitoring
129
248
  * @param {string} clusterId
@@ -223,72 +342,101 @@ class StatusFooter {
223
342
 
224
343
  /**
225
344
  * Render the footer
345
+ * ROBUST: Uses render lock to prevent concurrent renders from corrupting state
226
346
  */
227
347
  async render() {
228
348
  if (!this.enabled || !this.isTTY()) return;
229
349
 
230
- const { rows, cols } = this.getTerminalSize();
350
+ // Graceful degradation: don't render if hidden
351
+ if (this.hidden) return;
231
352
 
232
- // Collect metrics for all agents with PIDs
233
- for (const [agentId, agent] of this.agents) {
234
- if (agent.pid) {
235
- try {
236
- const metrics = await getProcessMetrics(agent.pid, { samplePeriodMs: 500 });
237
- this.metricsCache.set(agentId, metrics);
238
- } catch {
239
- // Process may have exited
240
- this.metricsCache.delete(agentId);
241
- }
242
- }
353
+ // Render lock: prevent concurrent renders
354
+ if (this.isRendering) {
355
+ return;
243
356
  }
357
+ this.isRendering = true;
244
358
 
245
- // Get executing agents for display
246
- const executingAgents = Array.from(this.agents.entries())
247
- .filter(([, agent]) => agent.state === 'executing')
248
- .slice(0, this.maxAgentRows);
359
+ try {
360
+ const { rows, cols } = this.getTerminalSize();
249
361
 
250
- // Calculate dynamic footer height: header + agent rows + separator + summary
251
- // Minimum 3 lines (header + "no agents" message + summary)
252
- const agentRowCount = Math.max(1, executingAgents.length);
253
- const newHeight = 2 + agentRowCount + 1; // header + agents + summary (separator merged with summary)
362
+ // Double-check terminal size (may have changed since last check)
363
+ if (rows < this.minRows) {
364
+ this.hidden = true;
365
+ this.resetScrollRegion();
366
+ return;
367
+ }
254
368
 
255
- // Update scroll region if height changed
256
- if (newHeight !== this.footerHeight) {
257
- this.footerHeight = newHeight;
258
- this.setupScrollRegion();
259
- }
369
+ // Collect metrics for all agents with PIDs
370
+ for (const [agentId, agent] of this.agents) {
371
+ if (agent.pid) {
372
+ try {
373
+ const metrics = await getProcessMetrics(agent.pid, { samplePeriodMs: 500 });
374
+ this.metricsCache.set(agentId, metrics);
375
+ } catch {
376
+ // Process may have exited
377
+ this.metricsCache.delete(agentId);
378
+ }
379
+ }
380
+ }
260
381
 
261
- // Build footer lines
262
- const headerLine = this.buildHeaderLine(cols);
263
- const agentRows = this.buildAgentRows(executingAgents, cols);
264
- const summaryLine = this.buildSummaryLine(cols);
382
+ // Get executing agents for display
383
+ const executingAgents = Array.from(this.agents.entries())
384
+ .filter(([, agent]) => agent.state === 'executing')
385
+ .slice(0, this.maxAgentRows);
386
+
387
+ // Calculate dynamic footer height: header + agent rows + summary
388
+ // Minimum 3 lines (header + "no agents" message + summary)
389
+ const agentRowCount = Math.max(1, executingAgents.length);
390
+ const newHeight = 2 + agentRowCount + 1; // header + agents + summary
391
+
392
+ // Update scroll region if height changed
393
+ if (newHeight !== this.footerHeight) {
394
+ this.lastFooterHeight = this.footerHeight;
395
+ this.footerHeight = newHeight;
396
+ this.setupScrollRegion();
397
+ }
265
398
 
266
- // Save cursor, render footer, restore cursor
267
- process.stdout.write(SAVE_CURSOR);
268
- process.stdout.write(HIDE_CURSOR);
399
+ // Build footer lines
400
+ const headerLine = this.buildHeaderLine(cols);
401
+ const agentRows = this.buildAgentRows(executingAgents, cols);
402
+ const summaryLine = this.buildSummaryLine(cols);
269
403
 
270
- // Render from top of footer area
271
- let currentRow = rows - this.footerHeight + 1;
404
+ // Save cursor, render footer, restore cursor
405
+ process.stdout.write(SAVE_CURSOR);
406
+ process.stdout.write(HIDE_CURSOR);
272
407
 
273
- // Header line
274
- this.moveTo(currentRow++, 1);
275
- process.stdout.write(CLEAR_LINE);
276
- process.stdout.write(`${COLORS.bgBlack}${headerLine}${COLORS.reset}`);
408
+ // Render from top of footer area
409
+ let currentRow = rows - this.footerHeight + 1;
277
410
 
278
- // Agent rows
279
- for (const agentRow of agentRows) {
411
+ // Header line
280
412
  this.moveTo(currentRow++, 1);
281
413
  process.stdout.write(CLEAR_LINE);
282
- process.stdout.write(`${COLORS.bgBlack}${agentRow}${COLORS.reset}`);
283
- }
414
+ process.stdout.write(`${COLORS.bgBlack}${headerLine}${COLORS.reset}`);
415
+
416
+ // Agent rows
417
+ for (const agentRow of agentRows) {
418
+ this.moveTo(currentRow++, 1);
419
+ process.stdout.write(CLEAR_LINE);
420
+ process.stdout.write(`${COLORS.bgBlack}${agentRow}${COLORS.reset}`);
421
+ }
284
422
 
285
- // Summary line (with bottom border)
286
- this.moveTo(currentRow, 1);
287
- process.stdout.write(CLEAR_LINE);
288
- process.stdout.write(`${COLORS.bgBlack}${summaryLine}${COLORS.reset}`);
423
+ // Summary line (with bottom border)
424
+ this.moveTo(currentRow, 1);
425
+ process.stdout.write(CLEAR_LINE);
426
+ process.stdout.write(`${COLORS.bgBlack}${summaryLine}${COLORS.reset}`);
289
427
 
290
- process.stdout.write(RESTORE_CURSOR);
291
- process.stdout.write(SHOW_CURSOR);
428
+ process.stdout.write(RESTORE_CURSOR);
429
+ process.stdout.write(SHOW_CURSOR);
430
+ } finally {
431
+ this.isRendering = false;
432
+
433
+ // Process pending resize if one was queued during render
434
+ if (this.pendingResize) {
435
+ this.pendingResize = false;
436
+ // Use setImmediate to avoid deep recursion
437
+ setImmediate(() => this._handleResize());
438
+ }
439
+ }
292
440
  }
293
441
 
294
442
  /**
@@ -402,7 +550,7 @@ class StatusFooter {
402
550
  parts.push(` ${COLORS.gray}│${COLORS.reset} ${COLORS.dim}${duration}${COLORS.reset}`);
403
551
 
404
552
  // Agent counts
405
- const executing = Array.from(this.agents.values()).filter(a => a.state === 'executing').length;
553
+ const executing = Array.from(this.agents.values()).filter((a) => a.state === 'executing').length;
406
554
  const total = this.agents.size;
407
555
  parts.push(` ${COLORS.gray}│${COLORS.reset} ${COLORS.green}${executing}/${total}${COLORS.reset} active`);
408
556
 
@@ -456,13 +604,21 @@ class StatusFooter {
456
604
  return;
457
605
  }
458
606
 
607
+ // Initialize dimension tracking
608
+ const { rows, cols } = this.getTerminalSize();
609
+ this.lastKnownRows = rows;
610
+ this.lastKnownCols = cols;
611
+
612
+ // Check for graceful degradation at startup
613
+ if (rows < this.minRows) {
614
+ this.hidden = true;
615
+ return; // Don't set up scroll region for tiny terminals
616
+ }
617
+
459
618
  this.setupScrollRegion();
460
619
 
461
- // Handle terminal resize
462
- process.stdout.on('resize', () => {
463
- this.setupScrollRegion();
464
- this.render();
465
- });
620
+ // Handle terminal resize with debounced handler
621
+ process.stdout.on('resize', this._debouncedResize);
466
622
 
467
623
  // Start refresh interval
468
624
  this.intervalId = setInterval(() => {
@@ -482,19 +638,19 @@ class StatusFooter {
482
638
  this.intervalId = null;
483
639
  }
484
640
 
485
- if (this.isTTY()) {
641
+ // Remove resize listener
642
+ process.stdout.removeListener('resize', this._debouncedResize);
643
+
644
+ if (this.isTTY() && !this.hidden) {
486
645
  // Reset scroll region
487
646
  this.resetScrollRegion();
488
647
 
489
- // Clear all footer lines (dynamic height)
490
- const { rows } = this.getTerminalSize();
491
- const startRow = rows - this.footerHeight + 1;
492
- for (let row = startRow; row <= rows; row++) {
493
- this.moveTo(row, 1);
494
- process.stdout.write(CLEAR_LINE);
495
- }
648
+ // Clear all footer lines
649
+ this._clearFooterArea();
496
650
 
497
651
  // Move cursor to safe position
652
+ const { rows } = this.getTerminalSize();
653
+ const startRow = rows - this.footerHeight + 1;
498
654
  this.moveTo(startRow, 1);
499
655
  process.stdout.write(SHOW_CURSOR);
500
656
  }
@@ -507,14 +663,7 @@ class StatusFooter {
507
663
  if (!this.isTTY()) return;
508
664
 
509
665
  this.resetScrollRegion();
510
-
511
- // Clear all footer lines (dynamic height)
512
- const { rows } = this.getTerminalSize();
513
- const startRow = rows - this.footerHeight + 1;
514
- for (let row = startRow; row <= rows; row++) {
515
- this.moveTo(row, 1);
516
- process.stdout.write(CLEAR_LINE);
517
- }
666
+ this._clearFooterArea();
518
667
  }
519
668
 
520
669
  /**
@@ -523,6 +672,14 @@ class StatusFooter {
523
672
  show() {
524
673
  if (!this.isTTY()) return;
525
674
 
675
+ // Reset hidden state and check terminal size
676
+ const { rows } = this.getTerminalSize();
677
+ if (rows < this.minRows) {
678
+ this.hidden = true;
679
+ return;
680
+ }
681
+
682
+ this.hidden = false;
526
683
  this.setupScrollRegion();
527
684
  this.render();
528
685
  }
@@ -62,7 +62,12 @@ const formatBytes = (bytes) => {
62
62
  */
63
63
  const formatCPU = (percent) => {
64
64
  if (typeof percent !== 'number' || percent < 0) return '0.0%';
65
- if (percent > 100) percent = 100;
65
+
66
+ // Assert normalized range (should never exceed 100% after per-core normalization)
67
+ if (percent > 100) {
68
+ console.warn(`[formatCPU] CPU percent ${percent}% exceeds 100% - normalization bug?`);
69
+ percent = 100;
70
+ }
66
71
 
67
72
  return `${percent.toFixed(1)}%`;
68
73
  };